From 2987a787d34c948afc18329ee99b5940166122aa Mon Sep 17 00:00:00 2001 From: bboysoul Date: Sat, 5 Sep 2026 19:51:00 +0800 Subject: [PATCH] perf: reduce dodo data loading requests --- backend/models.py | 13 +++- backend/mvp.py | 83 ++++++++++++++++++----- frontend/src/App.vue | 39 +++++++---- frontend/src/MvpPanel.vue | 43 +++++++++--- frontend/src/lib/mvp-utils.test.ts | 7 +- frontend/src/lib/mvp-utils.ts | 4 ++ migrations/versions/0005_query_indexes.py | 41 +++++++++++ tests/test_mvp_backend.py | 62 ++++++++++++++++- 8 files changed, 248 insertions(+), 44 deletions(-) create mode 100644 migrations/versions/0005_query_indexes.py diff --git a/backend/models.py b/backend/models.py index 2c7f90b..aa1f98f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -8,6 +8,7 @@ from sqlalchemy import ( DateTime, Float, ForeignKey, + Index, Integer, String, Text, @@ -94,7 +95,12 @@ class TaskTag(Base): class Task(Base): __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) 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) @@ -154,7 +160,10 @@ class Habit(Base): class HabitLog(Base): __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) habit_id: Mapped[UUID] = mapped_column(ForeignKey("habits.id", ondelete="CASCADE"), index=True) day: Mapped[date] = mapped_column(Date) diff --git a/backend/mvp.py b/backend/mvp.py index e48f308..79b7c9e 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -149,20 +149,42 @@ async def calendar(start: date, end: date, user: User = Depends(current_user), d raise HTTPException(422, "日期范围无效或超过一年") start_dt = datetime.combine(start, time.min, 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() - output = [] - for template, task in rows: - exception_rows = (await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id == template.id))).all() - exceptions: dict[datetime, RecurrenceException] = {} - for exc in exception_rows: - key = exc.occurrence_at.replace(tzinfo=UTC) if exc.occurrence_at.tzinfo is None else exc.occurrence_at - exceptions[key] = exc + recurrence_rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all() + template_ids = [template.id for template, _ in recurrence_rows] + exception_rows = [] if not template_ids else list((await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id.in_(template_ids)))).all()) + exceptions_by_template = {} + for exception in exception_rows: + key = exception.occurrence_at.replace(tzinfo=UTC) if exception.occurrence_at.tzinfo is None else exception.occurrence_at + exceptions_by_template.setdefault(exception.template_id, {})[key] = exception + 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): exception = exceptions.get(at) if exception and exception.deleted: 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)}) - 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): @@ -310,9 +332,14 @@ async def edit_habit_log(habit_id: UUID, day: date, payload: HabitLogEdit, user: @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) - 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) @@ -331,12 +358,36 @@ def scheduled(h, day): @router.get("/habits/grid") 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)] - 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 = [] - for h 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()} - pauses = list((await db.scalars(select(HabitPause).where(HabitPause.habit_id == h.id))).all()) - 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]}) + for habit in habits: + logs = logs_by_habit.get(habit.id, {}) + pauses = pauses_by_habit.get(habit.id, []) + 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} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d95bad3..077f1c3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,6 +6,7 @@ import { Settings, Trash2, X, CalendarRange, Repeat2, } from 'lucide-vue-next' import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils' +import { isTaskView } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import MvpPanel from './MvpPanel.vue' @@ -41,6 +42,7 @@ const pageSize = 50 const totalTasks = ref(0) const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize))) const expandedFolders = ref(new Set()) +const navigationLoaded = ref(false) const activeName = computed(() => { if (activeView.value === 'trash') return '回收站' @@ -66,12 +68,12 @@ const taskTree = computed(() => filteredTaskTree.value) let searchTimer: number | undefined watch(query, () => { if (searchTimer) window.clearTimeout(searchTimer) - if (activeView.value === 'trash') return + if (!isTaskView(activeView.value)) return page.value = 1 searchTimer = window.setTimeout(() => loadAll(), 250) }) 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 = {}) { @@ -148,19 +150,30 @@ async function loadTasksPage() { tasks.value = data.items ?? [] 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() { loading.value = true; error.value = '' try { - const [folderData, listData, tagData] = await Promise.all([ - 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 || '' + if (!navigationLoaded.value) await loadNavigation() 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 } } +async function refreshAll() { + navigationLoaded.value = false + await loadAll() +} async function loadTrashPage() { const data = await api(`/trash?page=${page.value}&page_size=${pageSize}`) trash.value = data.items ?? [] @@ -176,6 +189,8 @@ async function switchView(view: View, listId?: string) { page.value = 1 selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false if (view === 'trash') await loadTrash() + else if (view === 'calendar') return + else if (!isTaskView(view)) tasks.value = [] else await loadAll() } 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) { 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() { const name = window.prompt('标签名称')?.trim(); if (!name) return @@ -256,12 +271,12 @@ function focusQuick() { nextTick(() => document.querySelector( function previousPage() { if (page.value <= 1 || loading.value) return page.value -= 1 - activeView.value === 'trash' ? loadTrash() : loadAll() + activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined } function nextPage() { if (page.value >= totalPages.value || loading.value) return page.value += 1 - activeView.value === 'trash' ? loadTrash() : loadAll() + activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined } onMounted(bootstrap) @@ -308,7 +323,7 @@ onMounted(bootstrap)