This commit is contained in:
+11
-2
@@ -8,6 +8,7 @@ from sqlalchemy import (
|
|||||||
DateTime,
|
DateTime,
|
||||||
Float,
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
@@ -94,7 +95,12 @@ class TaskTag(Base):
|
|||||||
|
|
||||||
class Task(Base):
|
class Task(Base):
|
||||||
__tablename__ = "tasks"
|
__tablename__ = "tasks"
|
||||||
__table_args__ = (UniqueConstraint("user_id", "external_id", name="uq_tasks_external_id"),)
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "external_id", name="uq_tasks_external_id"),
|
||||||
|
Index("ix_tasks_user_active_created", "user_id", "deleted_at", "parent_id", "created_at", "id"),
|
||||||
|
Index("ix_tasks_user_due", "user_id", "deleted_at", "due_at"),
|
||||||
|
Index("ix_tasks_user_list_active", "user_id", "list_id", "deleted_at", "parent_id", "created_at", "id"),
|
||||||
|
)
|
||||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
list_id: Mapped[UUID] = mapped_column(ForeignKey("task_lists.id", ondelete="CASCADE"), index=True)
|
list_id: Mapped[UUID] = mapped_column(ForeignKey("task_lists.id", ondelete="CASCADE"), index=True)
|
||||||
@@ -154,7 +160,10 @@ class Habit(Base):
|
|||||||
|
|
||||||
class HabitLog(Base):
|
class HabitLog(Base):
|
||||||
__tablename__ = "habit_logs"
|
__tablename__ = "habit_logs"
|
||||||
__table_args__ = (UniqueConstraint("habit_id", "day", name="uq_habit_log_day"),)
|
__table_args__ = (
|
||||||
|
UniqueConstraint("habit_id", "day", name="uq_habit_log_day"),
|
||||||
|
Index("ix_habit_logs_habit_day", "habit_id", "day"),
|
||||||
|
)
|
||||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
habit_id: Mapped[UUID] = mapped_column(ForeignKey("habits.id", ondelete="CASCADE"), index=True)
|
habit_id: Mapped[UUID] = mapped_column(ForeignKey("habits.id", ondelete="CASCADE"), index=True)
|
||||||
day: Mapped[date] = mapped_column(Date)
|
day: Mapped[date] = mapped_column(Date)
|
||||||
|
|||||||
+67
-16
@@ -149,20 +149,42 @@ async def calendar(start: date, end: date, user: User = Depends(current_user), d
|
|||||||
raise HTTPException(422, "日期范围无效或超过一年")
|
raise HTTPException(422, "日期范围无效或超过一年")
|
||||||
start_dt = datetime.combine(start, time.min, tzinfo=UTC)
|
start_dt = datetime.combine(start, time.min, tzinfo=UTC)
|
||||||
end_dt = datetime.combine(end, time.max, tzinfo=UTC)
|
end_dt = datetime.combine(end, time.max, tzinfo=UTC)
|
||||||
rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all()
|
recurrence_rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all()
|
||||||
output = []
|
template_ids = [template.id for template, _ in recurrence_rows]
|
||||||
for template, task in rows:
|
exception_rows = [] if not template_ids else list((await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id.in_(template_ids)))).all())
|
||||||
exception_rows = (await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id == template.id))).all()
|
exceptions_by_template = {}
|
||||||
exceptions: dict[datetime, RecurrenceException] = {}
|
for exception in exception_rows:
|
||||||
for exc in exception_rows:
|
key = exception.occurrence_at.replace(tzinfo=UTC) if exception.occurrence_at.tzinfo is None else exception.occurrence_at
|
||||||
key = exc.occurrence_at.replace(tzinfo=UTC) if exc.occurrence_at.tzinfo is None else exc.occurrence_at
|
exceptions_by_template.setdefault(exception.template_id, {})[key] = exception
|
||||||
exceptions[key] = exc
|
recurring_task_ids = {task.id for _, task in recurrence_rows}
|
||||||
|
normal_query = select(Task).where(
|
||||||
|
Task.user_id == user.id,
|
||||||
|
Task.deleted_at.is_(None),
|
||||||
|
Task.parent_id.is_(None),
|
||||||
|
Task.due_at >= start_dt,
|
||||||
|
Task.due_at <= end_dt,
|
||||||
|
)
|
||||||
|
if recurring_task_ids:
|
||||||
|
normal_query = normal_query.where(Task.id.not_in(recurring_task_ids))
|
||||||
|
normal_tasks = list((await db.scalars(normal_query)).all())
|
||||||
|
output = [{
|
||||||
|
"id": task.id,
|
||||||
|
"recurrence_id": None,
|
||||||
|
"task_id": task.id,
|
||||||
|
"occurrence_at": task.due_at,
|
||||||
|
"title": task.title,
|
||||||
|
"due_at": task.due_at,
|
||||||
|
"completed": task.completed,
|
||||||
|
"version": task.version,
|
||||||
|
} for task in normal_tasks]
|
||||||
|
for template, task in recurrence_rows:
|
||||||
|
exceptions = exceptions_by_template.get(template.id, {})
|
||||||
for at in occurrences(template.rrule, template.starts_at, start_dt, end_dt, template.ends_at):
|
for at in occurrences(template.rrule, template.starts_at, start_dt, end_dt, template.ends_at):
|
||||||
exception = exceptions.get(at)
|
exception = exceptions.get(at)
|
||||||
if exception and exception.deleted:
|
if exception and exception.deleted:
|
||||||
continue
|
continue
|
||||||
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": at, "title": exception.title if exception and exception.title else task.title, "due_at": exception.due_at if exception and exception.due_at else at, "completed": bool(exception and exception.completed)})
|
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": at, "title": exception.title if exception and exception.title else task.title, "due_at": exception.due_at if exception and exception.due_at else at, "completed": bool(exception and exception.completed)})
|
||||||
return sorted(output, key=lambda item: item["occurrence_at"])
|
return sorted(output, key=lambda item: item["occurrence_at"].replace(tzinfo=UTC) if item["occurrence_at"].tzinfo is None else item["occurrence_at"])
|
||||||
|
|
||||||
|
|
||||||
async def upsert_exception(db, template_id, at):
|
async def upsert_exception(db, template_id, at):
|
||||||
@@ -310,9 +332,14 @@ async def edit_habit_log(habit_id: UUID, day: date, payload: HabitLogEdit, user:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/habits/{habit_id}/logs")
|
@router.get("/habits/{habit_id}/logs")
|
||||||
async def habit_logs(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def habit_logs(habit_id: UUID, from_date: date | None = Query(default=None, alias="from"), to_date: date | None = Query(default=None, alias="to"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
habit = await owned_habit(db, user.id, habit_id)
|
habit = await owned_habit(db, user.id, habit_id)
|
||||||
return [{"day": x.day, "value": x.value} for x in (await db.scalars(select(HabitLog).where(HabitLog.habit_id == habit.id).order_by(HabitLog.day.desc()))).all()]
|
condition = HabitLog.habit_id == habit.id
|
||||||
|
if from_date is not None:
|
||||||
|
condition = condition & (HabitLog.day >= from_date)
|
||||||
|
if to_date is not None:
|
||||||
|
condition = condition & (HabitLog.day <= to_date)
|
||||||
|
return [{"day": x.day, "value": x.value} for x in (await db.scalars(select(HabitLog).where(condition).order_by(HabitLog.day.desc()))).all()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/habits/{habit_id}/pauses", status_code=201)
|
@router.post("/habits/{habit_id}/pauses", status_code=201)
|
||||||
@@ -331,12 +358,36 @@ def scheduled(h, day):
|
|||||||
@router.get("/habits/grid")
|
@router.get("/habits/grid")
|
||||||
async def habits_grid(week: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def habits_grid(week: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
start = week - timedelta(days=week.weekday()); days = [start + timedelta(days=i) for i in range(7)]
|
start = week - timedelta(days=week.weekday()); days = [start + timedelta(days=i) for i in range(7)]
|
||||||
habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id, Habit.archived_at.is_(None)))).all())
|
habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id, Habit.archived_at.is_(None)).order_by(Habit.created_at))).all())
|
||||||
|
if not habits:
|
||||||
|
return {"days": days, "habits": []}
|
||||||
|
habit_ids = [habit.id for habit in habits]
|
||||||
|
week_logs = (await db.scalars(select(HabitLog).where(HabitLog.habit_id.in_(habit_ids), HabitLog.day.between(days[0], days[-1])))).all()
|
||||||
|
all_logs = (await db.scalars(select(HabitLog).where(HabitLog.habit_id.in_(habit_ids)))).all()
|
||||||
|
pause_rows = (await db.scalars(select(HabitPause).where(HabitPause.habit_id.in_(habit_ids), HabitPause.end_date >= days[0], HabitPause.start_date <= days[-1]))).all()
|
||||||
|
logs_by_habit = {}
|
||||||
|
stats_by_habit = {}
|
||||||
|
pauses_by_habit = {}
|
||||||
|
for log in week_logs:
|
||||||
|
logs_by_habit.setdefault(log.habit_id, {})[log.day] = log.value
|
||||||
|
for log in all_logs:
|
||||||
|
stats = stats_by_habit.setdefault(log.habit_id, {"total": 0, "completed_days": 0, "logged_days": 0})
|
||||||
|
stats["total"] += log.value
|
||||||
|
stats["logged_days"] += 1
|
||||||
|
targets = {habit.id: habit.target for habit in habits}
|
||||||
|
for log in all_logs:
|
||||||
|
if log.value >= targets[log.habit_id]:
|
||||||
|
stats_by_habit[log.habit_id]["completed_days"] += 1
|
||||||
|
for pause in pause_rows:
|
||||||
|
pauses_by_habit.setdefault(pause.habit_id, []).append(pause)
|
||||||
output = []
|
output = []
|
||||||
for h in habits:
|
for habit in habits:
|
||||||
logs = {x.day: x.value for x in (await db.scalars(select(HabitLog).where(HabitLog.habit_id == h.id, HabitLog.day.between(days[0], days[-1])))).all()}
|
logs = logs_by_habit.get(habit.id, {})
|
||||||
pauses = list((await db.scalars(select(HabitPause).where(HabitPause.habit_id == h.id))).all())
|
pauses = pauses_by_habit.get(habit.id, [])
|
||||||
output.append({"id": h.id, "name": h.name, "cells": [{"day": d, "scheduled": scheduled(h, d), "paused": any(p.start_date <= d <= p.end_date for p in pauses), "value": logs.get(d, 0)} for d in days]})
|
data = habit_dict(habit)
|
||||||
|
data["cells"] = [{"day": day, "scheduled": scheduled(habit, day), "paused": any(pause.start_date <= day <= pause.end_date for pause in pauses), "value": logs.get(day, 0)} for day in days]
|
||||||
|
data["stats"] = stats_by_habit.get(habit.id, {"total": 0, "completed_days": 0, "logged_days": 0})
|
||||||
|
output.append(data)
|
||||||
return {"days": days, "habits": output}
|
return {"days": days, "habits": output}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+27
-12
@@ -6,6 +6,7 @@ import {
|
|||||||
Settings, Trash2, X, CalendarRange, Repeat2,
|
Settings, Trash2, X, CalendarRange, Repeat2,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
|
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
|
||||||
|
import { isTaskView } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
import MvpPanel from './MvpPanel.vue'
|
import MvpPanel from './MvpPanel.vue'
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ const pageSize = 50
|
|||||||
const totalTasks = ref(0)
|
const totalTasks = ref(0)
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
|
||||||
const expandedFolders = ref(new Set<string>())
|
const expandedFolders = ref(new Set<string>())
|
||||||
|
const navigationLoaded = ref(false)
|
||||||
|
|
||||||
const activeName = computed(() => {
|
const activeName = computed(() => {
|
||||||
if (activeView.value === 'trash') return '回收站'
|
if (activeView.value === 'trash') return '回收站'
|
||||||
@@ -66,12 +68,12 @@ const taskTree = computed(() => filteredTaskTree.value)
|
|||||||
let searchTimer: number | undefined
|
let searchTimer: number | undefined
|
||||||
watch(query, () => {
|
watch(query, () => {
|
||||||
if (searchTimer) window.clearTimeout(searchTimer)
|
if (searchTimer) window.clearTimeout(searchTimer)
|
||||||
if (activeView.value === 'trash') return
|
if (!isTaskView(activeView.value)) return
|
||||||
page.value = 1
|
page.value = 1
|
||||||
searchTimer = window.setTimeout(() => loadAll(), 250)
|
searchTimer = window.setTimeout(() => loadAll(), 250)
|
||||||
})
|
})
|
||||||
watch(showCompleted, () => {
|
watch(showCompleted, () => {
|
||||||
if (activeView.value !== 'trash') { page.value = 1; loadAll() }
|
if (isTaskView(activeView.value)) { page.value = 1; loadAll() }
|
||||||
})
|
})
|
||||||
|
|
||||||
async function api(path: string, options: RequestInit = {}) {
|
async function api(path: string, options: RequestInit = {}) {
|
||||||
@@ -148,19 +150,30 @@ async function loadTasksPage() {
|
|||||||
tasks.value = data.items ?? []
|
tasks.value = data.items ?? []
|
||||||
totalTasks.value = data.total ?? tasks.value.length
|
totalTasks.value = data.total ?? tasks.value.length
|
||||||
}
|
}
|
||||||
|
async function loadNavigation(force = false) {
|
||||||
|
if (!force && navigationLoaded.value) return
|
||||||
|
const [folderData, listData, tagData] = await Promise.all([
|
||||||
|
api('/folders'), api('/lists'), api('/tags').catch(() => []),
|
||||||
|
])
|
||||||
|
folders.value = folderData; lists.value = listData; tags.value = tagData
|
||||||
|
navigationLoaded.value = true
|
||||||
|
if (!lists.value.some((item) => item.id === activeList.value)) {
|
||||||
|
activeList.value = lists.value.find((item) => item.is_inbox)?.id || lists.value[0]?.id || ''
|
||||||
|
}
|
||||||
|
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
|
||||||
|
}
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
loading.value = true; error.value = ''
|
loading.value = true; error.value = ''
|
||||||
try {
|
try {
|
||||||
const [folderData, listData, tagData] = await Promise.all([
|
if (!navigationLoaded.value) await loadNavigation()
|
||||||
api('/folders'), api('/lists'), api('/tags').catch(() => []),
|
|
||||||
])
|
|
||||||
folders.value = folderData; lists.value = listData; tags.value = tagData
|
|
||||||
activeList.value ||= lists.value.find((item) => item.is_inbox)?.id || lists.value[0]?.id || ''
|
|
||||||
await loadTasksPage()
|
await loadTasksPage()
|
||||||
if (page.value > totalPages.value) { page.value = totalPages.value; await loadTasksPage() }
|
if (page.value > totalPages.value) { page.value = totalPages.value; await loadTasksPage() }
|
||||||
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
|
|
||||||
} catch (reason) { fail(reason) } finally { loading.value = false }
|
} catch (reason) { fail(reason) } finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
async function refreshAll() {
|
||||||
|
navigationLoaded.value = false
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
async function loadTrashPage() {
|
async function loadTrashPage() {
|
||||||
const data = await api(`/trash?page=${page.value}&page_size=${pageSize}`)
|
const data = await api(`/trash?page=${page.value}&page_size=${pageSize}`)
|
||||||
trash.value = data.items ?? []
|
trash.value = data.items ?? []
|
||||||
@@ -176,6 +189,8 @@ async function switchView(view: View, listId?: string) {
|
|||||||
page.value = 1
|
page.value = 1
|
||||||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false
|
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false
|
||||||
if (view === 'trash') await loadTrash()
|
if (view === 'trash') await loadTrash()
|
||||||
|
else if (view === 'calendar') return
|
||||||
|
else if (!isTaskView(view)) tasks.value = []
|
||||||
else await loadAll()
|
else await loadAll()
|
||||||
}
|
}
|
||||||
async function addTask() {
|
async function addTask() {
|
||||||
@@ -238,7 +253,7 @@ async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskLi
|
|||||||
}
|
}
|
||||||
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
||||||
if (!window.confirm(`删除“${item.name}”?`)) return
|
if (!window.confirm(`删除“${item.name}”?`)) return
|
||||||
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await loadAll(); toast('已删除') } catch (reason) { fail(reason) }
|
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await refreshAll(); toast('已删除') } catch (reason) { fail(reason) }
|
||||||
}
|
}
|
||||||
async function createTag() {
|
async function createTag() {
|
||||||
const name = window.prompt('标签名称')?.trim(); if (!name) return
|
const name = window.prompt('标签名称')?.trim(); if (!name) return
|
||||||
@@ -256,12 +271,12 @@ function focusQuick() { nextTick(() => document.querySelector<HTMLInputElement>(
|
|||||||
function previousPage() {
|
function previousPage() {
|
||||||
if (page.value <= 1 || loading.value) return
|
if (page.value <= 1 || loading.value) return
|
||||||
page.value -= 1
|
page.value -= 1
|
||||||
activeView.value === 'trash' ? loadTrash() : loadAll()
|
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
|
||||||
}
|
}
|
||||||
function nextPage() {
|
function nextPage() {
|
||||||
if (page.value >= totalPages.value || loading.value) return
|
if (page.value >= totalPages.value || loading.value) return
|
||||||
page.value += 1
|
page.value += 1
|
||||||
activeView.value === 'trash' ? loadTrash() : loadAll()
|
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(bootstrap)
|
onMounted(bootstrap)
|
||||||
@@ -308,7 +323,7 @@ onMounted(bootstrap)
|
|||||||
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
|
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
|
||||||
</header>
|
</header>
|
||||||
<template v-if="['calendar','habits','settings'].includes(activeView)">
|
<template v-if="['calendar','habits','settings'].includes(activeView)">
|
||||||
<MvpPanel :key="activeView" :view="activeView as 'calendar'|'habits'|'settings'" :tasks="tasks" @changed="loadAll" @notice="toast" />
|
<MvpPanel :key="activeView" :view="activeView as 'calendar'|'habits'|'settings'" @changed="refreshAll" @notice="toast" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form>
|
<form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form>
|
||||||
|
|||||||
+32
-11
@@ -9,12 +9,12 @@ import { dateKey, habitWeek, mergePage, moveDueDate } from './lib/mvp-utils'
|
|||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
|
|
||||||
type View = 'calendar'|'habits'|'settings'
|
type View = 'calendar'|'habits'|'settings'
|
||||||
type Task = { id:string; title:string; due_at:string|null; version:number }
|
type Task = { id:string; title:string; due_at:string|null; version:number; recurrence_id?:string|null }
|
||||||
type Habit = { id:string; name:string; kind?:string; target?:number; unit?:string; logs?: Array<{day:string;value:number|boolean}>; stats?: Record<string,number> }
|
type Habit = { id:string; name:string; kind?:string; target?:number; max_value?:number|null; unit?:string; cells?: Array<{day:string; scheduled?:boolean; paused?:boolean; value:number|boolean}>; stats?: Record<string,number> }
|
||||||
type Session = { id:string; created_at?:string; last_seen_at?:string; current?:boolean; user_agent?:string }
|
type Session = { id:string; created_at?:string; last_seen_at?:string; current?:boolean; user_agent?:string }
|
||||||
const props = defineProps<{ view:View; tasks:Task[] }>()
|
const props = defineProps<{ view:View }>()
|
||||||
const emit = defineEmits<{ changed:[]; notice:[message:string] }>()
|
const emit = defineEmits<{ changed:[]; notice:[message:string] }>()
|
||||||
const habits = ref<Habit[]>([]), sessions = ref<Session[]>([]), audit = ref<any[]>([])
|
const habits = ref<Habit[]>([]), calendarTasks = ref<Task[]>([]), sessions = ref<Session[]>([]), audit = ref<any[]>([])
|
||||||
const busy = ref(false), error = ref(''), habitName = ref(''), habitType = ref('boolean'), habitTarget = ref(1)
|
const busy = ref(false), error = ref(''), habitName = ref(''), habitType = ref('boolean'), habitTarget = ref(1)
|
||||||
const importFile = ref<File|null>(null), importPreview = ref<any>(null), restoreFile = ref<File|null>(null)
|
const importFile = ref<File|null>(null), importPreview = ref<any>(null), restoreFile = ref<File|null>(null)
|
||||||
const week = computed(() => habitWeek())
|
const week = computed(() => habitWeek())
|
||||||
@@ -49,13 +49,34 @@ async function request(path:string, options:RequestInit={}) {
|
|||||||
return response.status===204?null:type.includes('json')?response.json():response.blob()
|
return response.status===204?null:type.includes('json')?response.json():response.blob()
|
||||||
}
|
}
|
||||||
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try{await work()}catch(e){error.value=e instanceof Error?e.message:'请求失败'}finally{busy.value=false} }
|
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try{await work()}catch(e){error.value=e instanceof Error?e.message:'请求失败'}finally{busy.value=false} }
|
||||||
async function loadHabits(){ await safe(async()=>{const page=mergePage<Habit>(await request('/habits')); habits.value=page.items; await Promise.all(habits.value.map(async h=>{try{const [logs,stats]=await Promise.all([request(`/habits/${h.id}/logs?from=${dateKey(week.value[0])}&to=${dateKey(week.value[6])}`),request(`/habits/${h.id}/stats`)]);h.logs=mergePage<any>(logs).items;h.stats=stats}catch{/* optional enrichment */}}))}) }
|
async function loadHabits(){ await safe(async()=>{const grid=await request(`/habits/grid?week=${dateKey(week.value[0])}`); habits.value=(grid as {habits?: Habit[]}).habits ?? [];}) }
|
||||||
async function addHabit(){if(!habitName.value.trim())return;await safe(async()=>{await request('/habits',{method:'POST',body:JSON.stringify({name:habitName.value.trim(),type:habitType.value,target:habitTarget.value})});habitName.value='';await loadHabits();emit('notice','习惯已创建')})}
|
async function addHabit(){if(!habitName.value.trim())return;await safe(async()=>{await request('/habits',{method:'POST',body:JSON.stringify({name:habitName.value.trim(),kind:habitType.value,target:habitTarget.value,schedule_type:'daily'})});habitName.value='';await loadHabits();emit('notice','习惯已创建')})}
|
||||||
function logFor(h:Habit,day:string){return h.logs?.find(l=>l.day===day)}
|
function logFor(h:Habit,day:string){return (h.cells??[]).find(c=>c.day===day)}
|
||||||
async function checkIn(h:Habit,day:string,value?:number){await safe(async()=>{await request(`/habits/${h.id}/logs`,{method:'POST',body:JSON.stringify({day,value:h.kind==='numeric'?(value??h.target??1):!Boolean(logFor(h,day)?.value)})});await loadHabits();emit('notice','打卡已记录')})}
|
async function checkIn(h:Habit,day:string,value?:number){await safe(async()=>{const current=logFor(h,day);const next=h.kind==='numeric'?(value??h.target??1):(current?.value?0:1);await request(`/habits/${h.id}/logs/${day}`,{method:'PUT',body:JSON.stringify({value:next})});await loadHabits();emit('notice','打卡已记录')})}
|
||||||
async function deleteHabit(h:Habit){if(!confirm(`删除习惯“${h.name}”?`))return;await safe(async()=>{await request(`/habits/${h.id}`,{method:'DELETE'});await loadHabits()})}
|
async function deleteHabit(h:Habit){if(!confirm(`删除习惯“${h.name}”?`))return;await safe(async()=>{await request(`/habits/${h.id}`,{method:'DELETE'});await loadHabits()})}
|
||||||
async function moveTask(arg:EventDropArg){const task=props.tasks.find(t=>t.id===arg.event.id);if(!task)return;const previous=task.due_at;try{await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:moveDueDate(previous,arg.event.startStr.slice(0,10)),version:task.version})});emit('changed');const undo=confirm('日期已更新。要撤销吗?');if(undo){const fresh=props.tasks.find(t=>t.id===task.id) || task;await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:previous,version:fresh.version})});emit('changed')}}catch(e){arg.revert();error.value=e instanceof Error?e.message:'移动失败'}}
|
const currentCalendarRange = ref<{ startStr:string; endStr:string }|null>(null)
|
||||||
const calendarOptions=computed<CalendarOptions>(()=>({plugins:[dayGridPlugin,interactionPlugin],initialView:'dayGridMonth',locale:'zh-cn',firstDay:1,height:'auto',editable:true,dayMaxEvents:4,headerToolbar:{left:'prev,next today',center:'title',right:''},events:props.tasks.filter(t=>t.due_at).map(t=>({id:t.id,title:t.title,start:t.due_at!})),eventDrop:moveTask}))
|
async function loadCalendar(range?: { startStr?: string; endStr?: string }) {
|
||||||
|
if (range?.startStr && range?.endStr) currentCalendarRange.value = { startStr: range.startStr, endStr: range.endStr }
|
||||||
|
const now = new Date()
|
||||||
|
const activeRange = currentCalendarRange.value
|
||||||
|
const start = activeRange?.startStr.slice(0, 10) ?? dateKey(new Date(now.getFullYear(), now.getMonth(), 1))
|
||||||
|
const endExclusiveKey = activeRange?.endStr.slice(0, 10) ?? dateKey(new Date(now.getFullYear(), now.getMonth() + 1, 1))
|
||||||
|
const endExclusive = new Date(`${endExclusiveKey}T00:00:00`)
|
||||||
|
endExclusive.setDate(endExclusive.getDate() - 1)
|
||||||
|
const end = dateKey(endExclusive)
|
||||||
|
await safe(async()=>{
|
||||||
|
const rows = await request(`/calendar?start=${start}&end=${end}`) as Array<{id?:string;task_id:string;recurrence_id?:string|null;title:string;due_at?:string;occurrence_at:string;version?:number}>
|
||||||
|
calendarTasks.value = rows.map(row=>({
|
||||||
|
id: row.recurrence_id ? `${row.task_id}:${row.occurrence_at}` : (row.id ?? row.task_id),
|
||||||
|
title: row.title,
|
||||||
|
due_at: row.due_at ?? row.occurrence_at,
|
||||||
|
version: row.version ?? 0,
|
||||||
|
recurrence_id: row.recurrence_id,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function moveTask(arg:EventDropArg){const task=calendarTasks.value.find(t=>t.id===arg.event.id);if(!task||task.version===0)return;const previous=task.due_at;try{await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:moveDueDate(previous,arg.event.startStr.slice(0,10)),version:task.version})});await loadCalendar();emit('notice','日期已更新')}catch(e){arg.revert();error.value=e instanceof Error?e.message:'移动失败'}}
|
||||||
|
const calendarOptions=computed<CalendarOptions>(()=>({plugins:[dayGridPlugin,interactionPlugin],initialView:'dayGridMonth',locale:'zh-cn',firstDay:1,height:'auto',editable:true,dayMaxEvents:4,headerToolbar:{left:'prev,next today',center:'title',right:''},events:calendarTasks.value.filter(t=>t.due_at).map(t=>({id:t.id,title:t.title,start:t.due_at!,editable:t.version!==0})),datesSet:loadCalendar,eventDrop:moveTask}))
|
||||||
async function loadSettings(){await safe(async()=>{const [s,a]=await Promise.all([request('/sessions').catch(()=>[]),request('/audit-logs?limit=20').catch(()=>[])]);sessions.value=mergePage<Session>(s).items;audit.value=mergePage<any>(a).items})}
|
async function loadSettings(){await safe(async()=>{const [s,a]=await Promise.all([request('/sessions').catch(()=>[]),request('/audit-logs?limit=20').catch(()=>[])]);sessions.value=mergePage<Session>(s).items;audit.value=mergePage<any>(a).items})}
|
||||||
async function revoke(id:string){await safe(async()=>{await request(`/sessions/${id}`,{method:'DELETE'});await loadSettings();emit('notice','会话已撤销')})}
|
async function revoke(id:string){await safe(async()=>{await request(`/sessions/${id}`,{method:'DELETE'});await loadSettings();emit('notice','会话已撤销')})}
|
||||||
function downloadBlob(blob:Blob,name:string){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
function downloadBlob(blob:Blob,name:string){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||||
@@ -69,7 +90,7 @@ onMounted(()=>props.view==='habits'?loadHabits():props.view==='settings'?loadSet
|
|||||||
<section class="mvp-view" :class="{loading:busy}">
|
<section class="mvp-view" :class="{loading:busy}">
|
||||||
<p v-if="error" class="inline-error">{{error}}</p>
|
<p v-if="error" class="inline-error">{{error}}</p>
|
||||||
<template v-if="view==='calendar'">
|
<template v-if="view==='calendar'">
|
||||||
<header class="view-intro"><div><small>拖动任务即可改期</small><h2>月历</h2></div><span>{{tasks.filter(t=>t.due_at).length}} 个已排期任务</span></header>
|
<header class="view-intro"><div><small>拖动任务即可改期</small><h2>月历</h2></div><span>{{calendarTasks.length}} 个已排期任务</span></header>
|
||||||
<div class="calendar-card"><FullCalendar :options="calendarOptions" /></div>
|
<div class="calendar-card"><FullCalendar :options="calendarOptions" /></div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="view==='habits'">
|
<template v-else-if="view==='habits'">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { dateKey, moveDueDate } from './mvp-utils'
|
import { dateKey, isTaskView, moveDueDate } from './mvp-utils'
|
||||||
|
|
||||||
describe('MVP view utilities', () => {
|
describe('MVP view utilities', () => {
|
||||||
it('normalizes to UTC so stored due_at stays stable in every local timezone', () => {
|
it('normalizes to UTC so stored due_at stays stable in every local timezone', () => {
|
||||||
@@ -8,4 +8,9 @@ describe('MVP view utilities', () => {
|
|||||||
const newly = moveDueDate(null, '2026-09-09')
|
const newly = moveDueDate(null, '2026-09-09')
|
||||||
expect(newly).toBe('2026-09-09T09:00:00.000Z')
|
expect(newly).toBe('2026-09-09T09:00:00.000Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('identifies only task-backed views as task data loaders', () => {
|
||||||
|
expect(['tasks', 'today', 'upcoming'].filter(isTaskView)).toEqual(['tasks', 'today', 'upcoming'])
|
||||||
|
expect(['calendar', 'habits', 'settings', 'trash'].filter(isTaskView)).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,3 +39,7 @@ export function mergePage<T>(page: T[] | { items?: T[]; next_cursor?: string | n
|
|||||||
if (Array.isArray(page)) return { items: page, nextCursor: null }
|
if (Array.isArray(page)) return { items: page, nextCursor: null }
|
||||||
return { items: page.items ?? [], nextCursor: page.next_cursor ?? null }
|
return { items: page.items ?? [], nextCursor: page.next_cursor ?? null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isTaskView(view: string) {
|
||||||
|
return view === 'tasks' || view === 'today' || view === 'upcoming'
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""add task and habit query indexes
|
||||||
|
Revision ID: 0005
|
||||||
|
Revises: 0004
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0005"
|
||||||
|
down_revision = "0004"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index(
|
||||||
|
"ix_tasks_user_active_created",
|
||||||
|
"tasks",
|
||||||
|
["user_id", "deleted_at", "parent_id", "created_at", "id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_tasks_user_due",
|
||||||
|
"tasks",
|
||||||
|
["user_id", "deleted_at", "due_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_tasks_user_list_active",
|
||||||
|
"tasks",
|
||||||
|
["user_id", "list_id", "deleted_at", "parent_id", "created_at", "id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_habit_logs_habit_day",
|
||||||
|
"habit_logs",
|
||||||
|
["habit_id", "day"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_habit_logs_habit_day", table_name="habit_logs")
|
||||||
|
op.drop_index("ix_tasks_user_list_active", table_name="tasks")
|
||||||
|
op.drop_index("ix_tasks_user_due", table_name="tasks")
|
||||||
|
op.drop_index("ix_tasks_user_active_created", table_name="tasks")
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
|
|
||||||
def boot(client):
|
def boot(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -16,6 +18,14 @@ def test_recurring_calendar_exceptions_and_scopes(client):
|
|||||||
"/api/v1/tasks",
|
"/api/v1/tasks",
|
||||||
json={"title": "站会", "list_id": inbox["id"], "due_at": "2026-09-01T09:00:00Z"},
|
json={"title": "站会", "list_id": inbox["id"], "due_at": "2026-09-01T09:00:00Z"},
|
||||||
).json()
|
).json()
|
||||||
|
normal_task = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "月内普通任务", "list_id": inbox["id"], "due_at": "2026-09-12T09:00:00Z"},
|
||||||
|
).json()
|
||||||
|
client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "月外普通任务", "list_id": inbox["id"], "due_at": "2026-10-02T09:00:00Z"},
|
||||||
|
)
|
||||||
recurrence = client.post(
|
recurrence = client.post(
|
||||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=TU,TH;COUNT=5"}
|
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=TU,TH;COUNT=5"}
|
||||||
)
|
)
|
||||||
@@ -24,6 +34,8 @@ def test_recurring_calendar_exceptions_and_scopes(client):
|
|||||||
calendar = client.get(
|
calendar = client.get(
|
||||||
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
||||||
).json()
|
).json()
|
||||||
|
assert any(row.get("id") == normal_task["id"] for row in calendar)
|
||||||
|
assert not any(row["title"] == "月外普通任务" for row in calendar)
|
||||||
occurrences = [row for row in calendar if row["recurrence_id"] == recurrence_id]
|
occurrences = [row for row in calendar if row["recurrence_id"] == recurrence_id]
|
||||||
assert len(occurrences) == 5
|
assert len(occurrences) == 5
|
||||||
assert occurrences[0]["title"] == "站会"
|
assert occurrences[0]["title"] == "站会"
|
||||||
@@ -45,9 +57,50 @@ def test_recurring_calendar_exceptions_and_scopes(client):
|
|||||||
f"/api/v1/recurrences/{recurrence_id}",
|
f"/api/v1/recurrences/{recurrence_id}",
|
||||||
params={"scope": "this", "occurrence_at": occurrences[2]["occurrence_at"]},
|
params={"scope": "this", "occurrence_at": occurrences[2]["occurrence_at"]},
|
||||||
).status_code == 204
|
).status_code == 204
|
||||||
assert len(client.get(
|
remaining = client.get(
|
||||||
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
||||||
).json()) == 4
|
).json()
|
||||||
|
assert len([row for row in remaining if row["recurrence_id"] == recurrence_id]) == 4
|
||||||
|
assert any(row.get("id") == normal_task["id"] for row in remaining)
|
||||||
|
|
||||||
|
|
||||||
|
def test_habit_logs_support_date_range_filter(client):
|
||||||
|
boot(client)
|
||||||
|
habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json()
|
||||||
|
hid = habit["id"]
|
||||||
|
for day in ("2026-08-01", "2026-08-15", "2026-09-01"):
|
||||||
|
client.post(f"/api/v1/habits/{hid}/logs", json={"day": day, "value": 1})
|
||||||
|
|
||||||
|
all_logs = client.get(f"/api/v1/habits/{hid}/logs").json()
|
||||||
|
ranged = client.get(f"/api/v1/habits/{hid}/logs", params={"from": "2026-08-10", "to": "2026-08-31"}).json()
|
||||||
|
|
||||||
|
assert len(all_logs) == 3
|
||||||
|
assert [row["day"] for row in ranged] == ["2026-08-15"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_habit_grid_uses_a_bounded_number_of_queries(client, monkeypatch):
|
||||||
|
boot(client)
|
||||||
|
for index in range(6):
|
||||||
|
client.post("/api/v1/habits", json={"name": f"习惯 {index}", "kind": "boolean", "schedule_type": "daily"})
|
||||||
|
|
||||||
|
from backend.db import get_engine
|
||||||
|
|
||||||
|
statement_count = 0
|
||||||
|
engine = get_engine().sync_engine
|
||||||
|
|
||||||
|
def count_queries(*_):
|
||||||
|
nonlocal statement_count
|
||||||
|
statement_count += 1
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", count_queries)
|
||||||
|
try:
|
||||||
|
response = client.get("/api/v1/habits/grid", params={"week": "2026-09-01"})
|
||||||
|
finally:
|
||||||
|
event.remove(engine, "before_cursor_execute", count_queries)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json()["habits"]) == 6
|
||||||
|
assert statement_count <= 5
|
||||||
|
|
||||||
|
|
||||||
def test_habits_numeric_accumulation_pause_archive_grid_and_stats(client):
|
def test_habits_numeric_accumulation_pause_archive_grid_and_stats(client):
|
||||||
@@ -67,6 +120,11 @@ def test_habits_numeric_accumulation_pause_archive_grid_and_stats(client):
|
|||||||
assert client.post(f"/api/v1/habits/{habit_id}/pauses", json={"start_date": yesterday, "end_date": today}).status_code == 201
|
assert client.post(f"/api/v1/habits/{habit_id}/pauses", json={"start_date": yesterday, "end_date": today}).status_code == 201
|
||||||
grid = client.get("/api/v1/habits/grid", params={"week": yesterday}).json()
|
grid = client.get("/api/v1/habits/grid", params={"week": yesterday}).json()
|
||||||
assert len(grid["days"]) == 7 and grid["habits"][0]["cells"]
|
assert len(grid["days"]) == 7 and grid["habits"][0]["cells"]
|
||||||
|
assert grid["habits"][0]["kind"] == "numeric"
|
||||||
|
assert grid["habits"][0]["target"] == 8
|
||||||
|
assert grid["habits"][0]["max_value"] == 10
|
||||||
|
assert grid["habits"][0]["stats"]["total"] == 8
|
||||||
|
assert grid["habits"][0]["stats"]["completed_days"] == 1
|
||||||
stats = client.get(f"/api/v1/habits/{habit_id}/stats").json()
|
stats = client.get(f"/api/v1/habits/{habit_id}/stats").json()
|
||||||
assert stats["total"] == 8 and stats["completed_days"] == 1
|
assert stats["total"] == 8 and stats["completed_days"] == 1
|
||||||
assert client.delete(f"/api/v1/habits/{habit_id}").status_code == 204
|
assert client.delete(f"/api/v1/habits/{habit_id}").status_code == 204
|
||||||
|
|||||||
Reference in New Issue
Block a user