From 988d131d9b868407d924f126b1d452c83094327f Mon Sep 17 00:00:00 2001 From: bboysoul Date: Tue, 8 Sep 2026 06:51:31 +0800 Subject: [PATCH] feat: add task and habit reordering --- backend/main.py | 62 +++++++++++++++++---- backend/models.py | 1 + backend/mvp.py | 41 ++++++++++++-- backend/schemas.py | 10 ++++ frontend/src/App.vue | 64 +++++++++++++++++++++- frontend/src/MvpPanel.vue | 48 +++++++++++++++- frontend/src/lib/task-utils.test.ts | 14 ++++- frontend/src/lib/task-utils.ts | 16 ++++++ frontend/src/style.css | 1 + frontend/src/style.test.ts | 13 +++++ migrations/versions/0012_habit_position.py | 23 ++++++++ tests/test_app.py | 35 ++++++++++++ tests/test_mvp_backend.py | 23 ++++++++ 13 files changed, 329 insertions(+), 22 deletions(-) create mode 100644 migrations/versions/0012_habit_position.py diff --git a/backend/main.py b/backend/main.py index 236008a..c326068 100644 --- a/backend/main.py +++ b/backend/main.py @@ -45,6 +45,7 @@ from .schemas import ( TaskDetailOut, TaskOut, TaskPage, + TaskReorder, TaskUpdate, UserOut, ) @@ -434,16 +435,16 @@ async def restore_list( -def _encode_cursor(created_at: datetime, task_id: UUID) -> str: - raw = json.dumps([created_at.isoformat(), str(task_id)]).encode() +def _encode_cursor(position: int, created_at: datetime, task_id: UUID) -> str: + raw = json.dumps([position, created_at.isoformat(), str(task_id)]).encode() return base64.urlsafe_b64encode(raw).decode().rstrip("=") -def _decode_cursor(cursor: str) -> tuple[datetime, UUID]: +def _decode_cursor(cursor: str) -> tuple[int, datetime, UUID]: try: raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)) - timestamp, task_id = json.loads(raw) - return datetime.fromisoformat(timestamp), UUID(task_id) + position, timestamp, task_id = json.loads(raw) + return int(position), datetime.fromisoformat(timestamp), UUID(task_id) except (ValueError, TypeError, json.JSONDecodeError) as exc: raise HTTPException(status_code=422, detail="无效的游标") from exc @@ -468,7 +469,14 @@ async def create_task( if parent is None: raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务") data = payload.model_dump() - task = Task(user_id=user.id, **data) + parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None) + max_position = await db.scalar(select(func.max(Task.position)).where( + Task.user_id == user.id, + Task.list_id == payload.list_id, + parent_filter, + Task.deleted_at.is_(None), + )) + task = Task(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **data) db.add(task) await db.flush() audit(db, user.id, "create", "task", task.id) @@ -516,20 +524,24 @@ async def list_tasks( or_(Task.title.ilike(pattern), Task.description.ilike(pattern), list_match) ) total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 - ordering = (Task.created_at, Task.id) + ordering = (Task.position, Task.created_at, Task.id) if page is not None: size = page_size or limit items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all()) return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size) if cursor: - created_at, task_id = _decode_cursor(cursor) + position, created_at, task_id = _decode_cursor(cursor) query = query.where( - or_(Task.created_at > created_at, (Task.created_at == created_at) & (Task.id > task_id)) + or_( + Task.position > position, + (Task.position == position) & (Task.created_at > created_at), + (Task.position == position) & (Task.created_at == created_at) & (Task.id > task_id), + ) ) rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all()) has_more = len(rows) > limit items = rows[:limit] - next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None + next_cursor = _encode_cursor(items[-1].position, items[-1].created_at, items[-1].id) if has_more else None return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit) @@ -561,6 +573,36 @@ async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut: return (await _task_details(db, [task]))[0] +@app.put("/api/v1/tasks/reorder", status_code=204) +async def reorder_tasks( + payload: TaskReorder, + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + rows = list((await db.scalars(select(Task).where( + Task.id.in_(payload.task_ids), Task.user_id == user.id, Task.deleted_at.is_(None) + ))).all()) + if len(rows) != len(payload.task_ids): + raise HTTPException(status_code=404, detail="任务不存在") + parent_scopes = {row.parent_id for row in rows} + if len(parent_scopes) != 1: + raise HTTPException(status_code=400, detail="只能调整同一层级任务的顺序") + parent_id = next(iter(parent_scopes)) + scope_query = select(Task).where( + Task.user_id == user.id, + Task.deleted_at.is_(None), + Task.parent_id.is_(None) if parent_id is None else Task.parent_id == parent_id, + ).order_by(Task.position, Task.created_at, Task.id) + scope_rows = list((await db.scalars(scope_query)).all()) + requested = set(payload.task_ids) + ordered_rows = iter([next(row for row in rows if row.id == task_id) for task_id in payload.task_ids]) + merged = [next(ordered_rows) if row.id in requested else row for row in scope_rows] + for position, row in enumerate(merged): + row.position = position + await db.commit() + return Response(status_code=204) + + @app.get("/api/v1/tasks/{task_id}", response_model=TaskDetailOut) async def get_task( task_id: UUID, diff --git a/backend/models.py b/backend/models.py index e238644..f23fd1a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -162,6 +162,7 @@ class Habit(Base): interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True) start_date: Mapped[date] = mapped_column(Date) archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + position: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/mvp.py b/backend/mvp.py index a511054..bc53904 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -525,6 +525,16 @@ class HabitCreate(BaseModel): return self +class HabitReorder(BaseModel): + habit_ids: list[UUID] = Field(min_length=1) + + @model_validator(mode="after") + def unique_ids(self): + if len(self.habit_ids) != len(set(self.habit_ids)): + raise ValueError("habit_ids must be unique") + return self + + class HabitLogInput(BaseModel): day: date value: float = Field(gt=0) @@ -541,7 +551,7 @@ class PauseInput(BaseModel): def habit_dict(h): - return {"id": h.id, "name": h.name, "kind": h.kind, "target": h.target, "max_value": h.max_value, "schedule_type": h.schedule_type, "weekdays": [int(x) for x in h.weekdays.split(",")] if h.weekdays else None, "month_days": [int(x) for x in h.month_days.split(",")] if h.month_days else None, "interval_days": h.interval_days, "start_date": h.start_date, "archived_at": h.archived_at} + return {"id": h.id, "name": h.name, "kind": h.kind, "target": h.target, "max_value": h.max_value, "schedule_type": h.schedule_type, "weekdays": [int(x) for x in h.weekdays.split(",")] if h.weekdays else None, "month_days": [int(x) for x in h.month_days.split(",")] if h.month_days else None, "interval_days": h.interval_days, "start_date": h.start_date, "archived_at": h.archived_at, "position": h.position} async def owned_habit(db, user_id, habit_id): @@ -552,14 +562,34 @@ async def owned_habit(db, user_id, habit_id): @router.post("/habits", status_code=201) async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): - row = Habit(user_id=user.id, **payload.model_dump(exclude={"weekdays", "month_days"}), weekdays=",".join(map(str, payload.weekdays)) if payload.weekdays else None, month_days=",".join(map(str, payload.month_days)) if payload.month_days else None) + max_position = await db.scalar(select(func.max(Habit.position)).where(Habit.user_id == user.id)) + row = Habit(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **payload.model_dump(exclude={"weekdays", "month_days"}), weekdays=",".join(map(str, payload.weekdays)) if payload.weekdays else None, month_days=",".join(map(str, payload.month_days)) if payload.month_days else None) db.add(row); await db.commit(); await db.refresh(row); return habit_dict(row) @router.get("/habits") async def list_habits(archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): condition = Habit.archived_at.is_not(None) if archived else Habit.archived_at.is_(None) - return [habit_dict(h) for h in (await db.scalars(select(Habit).where(Habit.user_id == user.id, condition).order_by(Habit.created_at))).all()] + return [habit_dict(h) for h in (await db.scalars(select(Habit).where(Habit.user_id == user.id, condition).order_by(Habit.position, Habit.created_at))).all()] + + +@router.put("/habits/reorder", status_code=204) +async def reorder_habits(payload: HabitReorder, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + rows = list((await db.scalars(select(Habit).where( + Habit.id.in_(payload.habit_ids), Habit.user_id == user.id, Habit.archived_at.is_(None) + ))).all()) + if len(rows) != len(payload.habit_ids): + raise HTTPException(404, "习惯不存在") + scope_rows = list((await db.scalars(select(Habit).where( + Habit.user_id == user.id, Habit.archived_at.is_(None) + ).order_by(Habit.position, Habit.created_at))).all()) + requested = set(payload.habit_ids) + ordered_rows = iter([next(row for row in rows if row.id == habit_id) for habit_id in payload.habit_ids]) + merged = [next(ordered_rows) if row.id in requested else row for row in scope_rows] + for position, row in enumerate(merged): + row.position = position + await db.commit() + return Response(status_code=204) @router.patch("/habits/{habit_id}") @@ -623,7 +653,7 @@ 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)).order_by(Habit.created_at))).all()) + habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id, Habit.archived_at.is_(None)).order_by(Habit.position, Habit.created_at))).all()) if not habits: return {"days": days, "habits": []} habit_ids = [habit.id for habit in habits] @@ -755,7 +785,7 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep "folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders], "lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists], "tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "external_id", "deleted_at"]) for x in tasks], - "habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at"]) for x in habits], + "habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits], "countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns], } @@ -835,6 +865,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg interval_days=raw.get("interval_days"), start_date=date.fromisoformat(raw["start_date"]), archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None, + position=raw.get("position", 0), ) db.add(row) existing_countdown_ids = set((await db.scalars(select(Countdown.id).where(Countdown.user_id == user.id))).all()) diff --git a/backend/schemas.py b/backend/schemas.py index a749f09..519bbf1 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -96,6 +96,16 @@ class TaskUpdate(BaseModel): return self +class TaskReorder(BaseModel): + task_ids: list[UUID] = Field(min_length=1) + + @model_validator(mode="after") + def unique_ids(self): + if len(self.task_ids) != len(set(self.task_ids)): + raise ValueError("task_ids must be unique") + return self + + class TaskOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0c81d1d..ef04db2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,7 +5,7 @@ import { GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search, Settings, Trash2, X, Repeat2, } from 'lucide-vue-next' -import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils' +import { filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, renderMarkdown, toDateTimeLocal } from './lib/task-utils' import { defaultView, isTaskView, nextTotalAfterLocalTaskAdd, quickTaskFields, shouldToggleRowSwipe, writeCountdownCache } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import MvpPanel from './MvpPanel.vue' @@ -50,6 +50,8 @@ const navigationLoaded = ref(false) const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null) const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null) const taskSwipeOffsets = ref>({}) +const taskReorder = ref<{ id: string; startY: number } | null>(null) +const taskReorderTarget = ref('') const taskComposeOpen = ref(false) const composeTitle = ref('') const composeListId = ref('') @@ -336,6 +338,61 @@ async function toggle(task: Task) { function isInteractiveTarget(target: EventTarget | null) { return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label')) } +function startTaskReorder(task: Task, event: PointerEvent) { + if (activeView.value === 'trash' || loading.value || query.value || totalPages.value > 1) return + taskReorder.value = { id: task.id, startY: event.clientY } + taskReorderTarget.value = task.id + try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ } +} +function taskById(id: string) { + return tasks.value.find((item) => item.id === id) + ?? tasks.value.flatMap((item) => item.subtasks ?? []).find((item) => item.id === id) +} +function moveTaskReorder(task: Task, event: PointerEvent) { + if (taskReorder.value?.id !== task.id) return + const row = document.elementFromPoint(event.clientX, event.clientY)?.closest('[data-task-id]') + if (row?.dataset.taskId) taskReorderTarget.value = row.dataset.taskId +} +async function finishTaskReorder(task: Task, event: PointerEvent) { + const drag = taskReorder.value + const targetId = taskReorderTarget.value + taskReorder.value = null + taskReorderTarget.value = '' + if (!drag || drag.id !== task.id || !targetId || targetId === task.id) return + const target = taskById(targetId) + if (!target || (target.parent_id ?? null) !== (task.parent_id ?? null)) return + const placement = event.clientY >= drag.startY ? 'after' : 'before' + const previous = tasks.value + const previousSelected = selectedTask.value + let ids: string[] + if (task.parent_id) { + const parent = tasks.value.find((item) => item.id === task.parent_id) + if (!parent) return + const reordered = moveItemWithinScope(parent.subtasks ?? [], task.id, targetId, placement) + if (reordered === parent.subtasks) return + tasks.value = tasks.value.map((item) => item.id === parent.id ? { ...item, subtasks: reordered } : item) + if (selectedTask.value?.id === parent.id) selectedTask.value = { ...selectedTask.value, subtasks: reordered } + ids = reordered.map((item) => item.id) + } else { + const next = moveItemWithinScope(previous, task.id, targetId, placement) + if (next === previous) return + tasks.value = next + ids = next.map((item) => item.id) + } + try { + await api('/tasks/reorder', { method: 'PUT', body: JSON.stringify({ task_ids: ids }) }) + toast('顺序已保存') + } catch (reason) { + tasks.value = previous + selectedTask.value = previousSelected + fail(reason) + } +} +function cancelTaskReorder() { + taskReorder.value = null + taskReorderTarget.value = '' +} + function startTaskSwipe(task: Task, event: TouchEvent) { if (activeView.value === 'trash' || loading.value || isInteractiveTarget(event.target)) return const touch = event.touches[0] @@ -554,7 +611,8 @@ onMounted(bootstrap)
{{page}} / {{totalPages}}
{{query?'没有匹配的任务':'这里还很安静'}}{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}
diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index a70234a..6c513ca 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -1,6 +1,7 @@ ') expect(html).toContain('

Plan

') diff --git a/frontend/src/lib/task-utils.ts b/frontend/src/lib/task-utils.ts index 2aa595f..66bf2c1 100644 --- a/frontend/src/lib/task-utils.ts +++ b/frontend/src/lib/task-utils.ts @@ -88,6 +88,22 @@ export function groupTaskTree(tasks: T[]) { return tasks.filter((task) => !task.parent_id).map((task) => ({ task, subtasks: children.get(task.id) ?? [] })) } +export function moveItemWithinScope(items: T[], sourceId: string, targetId: string, placement: 'before' | 'after') { + if (sourceId === targetId) return items + const source = items.find((item) => item.id === sourceId) + const target = items.find((item) => item.id === targetId) + if (!source || !target || (source.parent_id ?? null) !== (target.parent_id ?? null)) return items + const scoped = items.filter((item) => (item.parent_id ?? null) === (source.parent_id ?? null)) + const sourceIndex = scoped.findIndex((item) => item.id === sourceId) + const targetIndex = scoped.findIndex((item) => item.id === targetId) + const reordered = scoped.filter((item) => item.id !== sourceId) + const adjustedTargetIndex = reordered.findIndex((item) => item.id === targetId) + reordered.splice(adjustedTargetIndex + (placement === 'after' ? 1 : 0), 0, source) + if (sourceIndex === targetIndex || reordered.every((item, index) => item.id === scoped[index]?.id)) return items + const reorderedIterator = reordered[Symbol.iterator]() + return items.map((item) => (item.parent_id ?? null) === (source.parent_id ?? null) ? reorderedIterator.next().value! : item) +} + export function toDateTimeLocal(value: string | null | undefined) { if (!value) return '' const date = new Date(value) diff --git a/frontend/src/style.css b/frontend/src/style.css index 457a3c9..32f4368 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -5,6 +5,7 @@ .auth-shell{background:radial-gradient(circle at 20% 10%,#ffe3d5 0,transparent 28%),linear-gradient(135deg,#f8f3e8,#fffaf0)}.auth-card{width:min(390px,90vw);padding:38px;background:var(--paper);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);display:grid;gap:16px}.brand{font-size:40px;font-weight:850;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:29px}.auth-card p{margin:0 0 8px;color:var(--muted)}.auth-card label,.detail-form>label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.auth-card input,.detail-form input,.detail-form select{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none}.auth-card input:focus,.detail-form input:focus,.detail-form select:focus,.detail-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.primary{border:0;background:var(--accent);color:#fff;padding:12px;border-radius:9px;font-weight:700;box-shadow:0 5px 12px rgba(241,90,41,.2)}.auth-card small{color:var(--danger)} .shell{height:100vh;display:grid;grid-template-columns:238px minmax(430px,1fr) 350px;background:var(--paper);overflow:hidden}.shell.sidebar-collapsed{grid-template-columns:0 minmax(430px,1fr) 350px}.shell.sidebar-collapsed .sidebar{width:0;min-width:0;overflow:hidden;border-right:0;padding:0}.sidebar{border-right:1px solid var(--line);background:var(--sidebar);display:flex;flex-direction:column;min-height:0;overflow:hidden}.brand-row{height:76px;padding:0 20px;display:flex;align-items:center;justify-content:space-between}.primary-nav{display:grid;padding:4px 12px 12px;gap:3px}.primary-nav button,.settings{display:flex;align-items:center;gap:10px;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#665e52;text-align:left}.primary-nav button:hover,.list-row:hover,.folder-row>button:hover{background:rgba(255,255,255,.52)}.primary-nav button.active,.list-row.active{background:var(--accent-soft);color:#b7421e;font-weight:700}.section-title{padding:17px 17px 7px 22px;display:flex;justify-content:space-between;align-items:center;color:#958b7d;text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:750}.section-title>span:last-child{display:flex}.mini-icon,.row-actions button{min-width:44px;min-height:44px;border:0;background:transparent;padding:4px;color:#8e8477}.mini-icon svg,.row-actions svg{width:14px;height:14px}.folders{flex:1;min-height:0;padding:0 10px;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;scrollbar-width:none}.folders::-webkit-scrollbar{display:none}.folder-row,.list-row{position:relative;display:flex;align-items:center;border-radius:8px}.folder-row>button,.list-row-main{flex:1;min-width:0;border:0;background:transparent;padding:8px;display:flex;align-items:center;gap:7px;color:#6d6559;text-align:left}.folder-row>button>span,.list-row-main>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.folder-row>button svg{width:14px}.row-actions{display:flex;position:absolute;right:0;opacity:0;pointer-events:none;transition:opacity .15s}.folder-row:hover .row-actions,.folder-row:focus-within .row-actions,.list-row:hover .row-actions,.list-row:focus-within .row-actions{opacity:1;pointer-events:auto}.list-row-main{width:100%;min-height:44px;padding:8px 7px 8px 31px;color:#665f55}.list-row.active .list-row-main{color:inherit}.list-row i{flex:0 0 8px;width:8px;height:8px;background:#d89b62;border-radius:3px}.settings{margin:auto 12px 14px}.mobile-only,.bottom{display:none}.scrim{display:none} main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.topbar.today-topbar h1{margin-bottom:4px}.today-board{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:18px 0 8px}.today-stat{background:#fff;border:1px solid rgba(231,221,204,.86);border-radius:14px;padding:12px 14px;box-shadow:0 3px 14px rgba(81,61,38,.04);display:grid;gap:3px}.today-stat strong{font-size:23px;line-height:1;color:#3c372f}.today-stat span{font-size:12px;color:var(--muted)}.today-stat.muted strong{color:#8b8275}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{border-left:0;--swipe-x:0px;min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}.task-row.swipeable{touch-action:pan-y;transform:translateX(var(--swipe-x))}.task-row.swipeable:not([style*="0px"]){transition:none}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(calc(var(--swipe-x) + 2px))}.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.check svg{width:13px}.task-check{width:44px;height:44px;flex:0 0 44px;border:0;background:transparent;padding:0;display:grid;place-items:center;border-radius:9px}.task-check-mark{width:19px;height:19px;border:1.6px solid #c6baa8;background:#fff;border-radius:50%;display:grid;place-items:center;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .15s ease}.task-check-mark svg{width:13px}.task-check:hover .task-check-mark{transform:scale(1.06);border-color:#9d8f7b}.task-check:focus-visible{outline:2px solid rgba(241,90,41,.32);outline-offset:1px}.task-check[aria-pressed="true"] .task-check-mark{background:#71856b;border-color:#71856b;color:#fff}.task-check-mark.p1{border-color:#4b93d1}.task-check-mark.p2{border-color:#d79b25}.task-check-mark.p3{border-color:#dc4b30}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{min-width:44px;min-height:44px;border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.mobile-only{display:none}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px} +.drag-handle{width:34px;height:44px;flex:0 0 34px;display:grid;place-items:center;border:0;background:transparent;color:#b3a795;cursor:grab;touch-action:none;border-radius:8px}.drag-handle:active{cursor:grabbing}.drag-handle:disabled{opacity:.3;cursor:not-allowed}.drag-handle svg{width:17px;height:17px}.reordering{opacity:.58}.reorder-target{box-shadow:inset 0 2px 0 var(--accent)} .detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.today-summary{align-items:stretch;text-align:left;place-items:stretch}.today-summary>svg{justify-self:start}.today-summary-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%;margin-top:4px}.today-summary-grid span{display:grid;gap:2px;background:#faf7f0;border-radius:10px;padding:10px;text-align:center;color:var(--muted);font-size:11px}.today-summary-grid strong{font-size:18px;color:#4f473d}.detail-form{padding:19px;display:grid;gap:15px}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px} .toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)} @media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}} diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 1f49000..2707f9a 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -31,6 +31,19 @@ describe('mobile navigation styles', () => { }) describe('task and habit row decoration', () => { + it('supports vertical drag handles for reordering tasks and habits', () => { + expect(app.match(/class="drag-handle task-drag-handle"/g)?.length).toBe(2) + expect(app).toContain('@pointerdown.stop="startTaskReorder') + expect(app).toContain('@pointerup.stop="finishTaskReorder') + expect(app).toContain("api('/tasks/reorder'") + expect(mvpPanel.match(/class="drag-handle habit-drag-handle"/g)?.length).toBe(2) + expect(mvpPanel).toContain('@pointerdown.stop="startHabitReorder') + expect(mvpPanel).toContain('@pointerup.stop="finishHabitReorder') + expect(mvpPanel).toContain("request('/habits/reorder'") + expect(css).toContain('.drag-handle{') + expect(css).toContain('.reordering{') + }) + it('supports pointer dragging for desktop task and subtask completion', () => { expect(app.match(/@pointerdown="startTaskPointer/g)?.length).toBe(2) expect(app.match(/@pointermove="moveTaskPointer/g)?.length).toBe(2) diff --git a/migrations/versions/0012_habit_position.py b/migrations/versions/0012_habit_position.py new file mode 100644 index 0000000..c0d8d29 --- /dev/null +++ b/migrations/versions/0012_habit_position.py @@ -0,0 +1,23 @@ +"""add habit position + +Revision ID: 0012_habit_position +Revises: 0011_lunar_countdowns +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0012_habit_position" +down_revision = "0011_lunar_countdowns" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("habits") as batch_op: + batch_op.add_column(sa.Column("position", sa.Integer(), nullable=False, server_default="0")) + + +def downgrade() -> None: + with op.batch_alter_table("habits") as batch_op: + batch_op.drop_column("position") diff --git a/tests/test_app.py b/tests/test_app.py index e3cc399..b2907a7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -92,6 +92,41 @@ def test_task_can_be_updated_completed_and_soft_deleted(client): assert client.get("/api/v1/tasks").json()["items"] == [] +def test_reorder_tasks_persists_top_level_and_subtask_order(client): + client = initialized_client(client) + inbox = client.get("/api/v1/lists").json()[0] + first = client.post("/api/v1/tasks", json={"title": "第一个", "list_id": inbox["id"]}).json() + second = client.post("/api/v1/tasks", json={"title": "第二个", "list_id": inbox["id"]}).json() + child_a = client.post( + "/api/v1/tasks", json={"title": "子任务 A", "list_id": inbox["id"], "parent_id": first["id"]} + ).json() + child_b = client.post( + "/api/v1/tasks", json={"title": "子任务 B", "list_id": inbox["id"], "parent_id": first["id"]} + ).json() + + top_level = client.put("/api/v1/tasks/reorder", json={"task_ids": [second["id"], first["id"]]}) + assert top_level.status_code == 204 + children = client.put("/api/v1/tasks/reorder", json={"task_ids": [child_b["id"], child_a["id"]]}) + assert children.status_code == 204 + + client.post("/api/v1/tasks", json={"title": "第三个", "list_id": inbox["id"]}) + listed = client.get("/api/v1/tasks", params={"list_id": inbox["id"], "page": 1}).json()["items"] + assert [task["title"] for task in listed] == ["第二个", "第一个", "第三个"] + assert [task["title"] for task in listed[1]["subtasks"]] == ["子任务 B", "子任务 A"] + + +def test_reorder_tasks_rejects_mixed_parent_scopes(client): + client = initialized_client(client) + inbox = client.get("/api/v1/lists").json()[0] + parent = client.post("/api/v1/tasks", json={"title": "父任务", "list_id": inbox["id"]}).json() + child = client.post( + "/api/v1/tasks", json={"title": "子任务", "list_id": inbox["id"], "parent_id": parent["id"]} + ).json() + + response = client.put("/api/v1/tasks/reorder", json={"task_ids": [parent["id"], child["id"]]}) + assert response.status_code == 400 + + def test_task_update_rejects_null_title(client): client.post( "/api/v1/setup/initialize", diff --git a/tests/test_mvp_backend.py b/tests/test_mvp_backend.py index adeb75f..7d22891 100644 --- a/tests/test_mvp_backend.py +++ b/tests/test_mvp_backend.py @@ -75,6 +75,29 @@ def test_recurrence_rejects_occurrence_after_cutoff(client): ).status_code == 422 +def test_habit_reorder_persists_in_lists_and_grid(client): + boot(client) + first = client.post("/api/v1/habits", json={"name": "第一个", "kind": "boolean", "schedule_type": "daily"}).json() + second = client.post("/api/v1/habits", json={"name": "第二个", "kind": "boolean", "schedule_type": "daily"}).json() + + response = client.put("/api/v1/habits/reorder", json={"habit_ids": [second["id"], first["id"]]}) + assert response.status_code == 204 + client.post("/api/v1/habits", json={"name": "第三个", "kind": "boolean", "schedule_type": "daily"}) + assert [habit["name"] for habit in client.get("/api/v1/habits").json()] == ["第二个", "第一个", "第三个"] + grid = client.get("/api/v1/habits/grid", params={"week": "2026-09-07"}).json() + assert [habit["name"] for habit in grid["habits"]] == ["第二个", "第一个", "第三个"] + + +def test_habit_reorder_rejects_foreign_or_missing_ids(client): + boot(client) + habit = client.post("/api/v1/habits", json={"name": "自己的习惯", "kind": "boolean", "schedule_type": "daily"}).json() + response = client.put( + "/api/v1/habits/reorder", + json={"habit_ids": [habit["id"], "00000000-0000-0000-0000-000000000001"]}, + ) + assert response.status_code == 404 + + 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()