feat: add task and habit reordering
ci / docker (push) Successful in 3m33s

This commit is contained in:
2026-09-08 06:51:31 +08:00
parent a02b6f660c
commit 988d131d9b
13 changed files with 329 additions and 22 deletions
+52 -10
View File
@@ -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,
+1
View File
@@ -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)
+36 -5
View File
@@ -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())
+10
View File
@@ -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
+61 -3
View File
@@ -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<Record<string, number>>({})
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<HTMLElement>('[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)
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
<section class="task-list" :class="{loading}">
<template v-for="node in taskTree" :key="node.task.id">
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div>
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span>
@@ -562,7 +620,7 @@ onMounted(bootstrap)
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
</article>
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><GripVertical/><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div></article>
<article v-for="subtask in node.subtasks" :key="subtask.id" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button class="drag-handle task-drag-handle" aria-label="上下拖动子任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(subtask, $event)" @pointermove.stop="moveTaskReorder(subtask, $event)" @pointerup.stop="finishTaskReorder(subtask, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div></article>
</template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
</section>
+45 -3
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { Activity, ArchiveRestore, Check, Download, FileJson, LogOut, RefreshCw, Trash2, Upload, X } from 'lucide-vue-next'
import { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogOut, RefreshCw, Trash2, Upload, X } from 'lucide-vue-next'
import { moveItemWithinScope } from './lib/task-utils'
import { dateKey, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, writeHabitGridCache } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
@@ -33,6 +34,8 @@ const todayKey = ref(dateKey(new Date()))
const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
const habitPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
const habitSwipeOffsets = ref<Record<string, number>>({})
const habitReorder = ref<{ id: string; startY: number } | null>(null)
const habitReorderTarget = ref('')
const hideCompletedHabits = ref(false)
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
const visibleTodayHabits = computed(() => hideCompletedHabits.value ? todayHabits.value.filter((item) => !isDone(item, todayKey.value)) : todayHabits.value)
@@ -78,6 +81,43 @@ function isDone(h: Habit, day: string) { return isHabitComplete(h.kind, logFor(h
function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
}
function startHabitReorder(h: Habit, event: PointerEvent) {
if (busy.value || hideCompletedHabits.value) return
habitReorder.value = { id: h.id, startY: event.clientY }
habitReorderTarget.value = h.id
try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ }
}
function moveHabitReorder(h: Habit, event: PointerEvent) {
if (habitReorder.value?.id !== h.id) return
const row = document.elementFromPoint(event.clientX, event.clientY)?.closest<HTMLElement>('[data-habit-id]')
if (row?.dataset.habitId) habitReorderTarget.value = row.dataset.habitId
}
async function finishHabitReorder(h: Habit, event: PointerEvent) {
const drag = habitReorder.value
const targetId = habitReorderTarget.value
habitReorder.value = null
habitReorderTarget.value = ''
if (!drag || drag.id !== h.id || !targetId || targetId === h.id) return
const placement = event.clientY >= drag.startY ? 'after' : 'before'
const previous = habits.value
const next = moveItemWithinScope(previous, h.id, targetId, placement)
if (next === previous) return
habits.value = next
writeHabitGridCache(dateKey(new Date()), next)
try {
await request('/habits/reorder', { method: 'PUT', body: JSON.stringify({ habit_ids: next.map((item) => item.id) }) })
emit('notice', '顺序已保存')
} catch (e) {
habits.value = previous
writeHabitGridCache(dateKey(new Date()), previous)
error.value = e instanceof Error ? e.message : '请求失败'
}
}
function cancelHabitReorder() {
habitReorder.value = null
habitReorderTarget.value = ''
}
function startHabitSwipe(h: Habit, event: TouchEvent) {
if (busy.value || isInteractiveTarget(event.target)) return
const touch = event.touches[0]
@@ -346,7 +386,8 @@ onBeforeUnmount(() => {
<!-- 今日习惯只展示今天该做的习惯复用正式习惯行样式 -->
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
<div class="habit-toolbar habit-toolbar-today"><label><input v-model="hideCompletedHabits" type="checkbox"> 隐藏已完成</label></div>
<article v-for="h in visibleTodayHabits" :key="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<article v-for="h in visibleTodayHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<button class="drag-handle habit-drag-handle" :disabled="hideCompletedHabits" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button>
<button class="task-check habit-check" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
<div class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
@@ -370,7 +411,8 @@ onBeforeUnmount(() => {
<!-- 习惯列表支持整行滑动记录 -->
<div v-if="view === 'habits'" class="habit-list">
<article v-for="h in visibleHabits" :key="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<button class="drag-handle habit-drag-handle" :disabled="hideCompletedHabits" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button>
<button class="task-check habit-check" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
<div class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { filterTasks, groupTaskTree, renderMarkdown, toDateTimeLocal } from './task-utils'
import { filterTasks, groupTaskTree, moveItemWithinScope, renderMarkdown, toDateTimeLocal } from './task-utils'
type SearchTask = {
id: string
@@ -26,6 +26,18 @@ describe('task utilities', () => {
expect(groupTaskTree(tasks)).toEqual([{ task: tasks[0], subtasks: [tasks[1]] }, { task: tasks[2], subtasks: [] }])
})
it('moves an item before or after another item without changing other scopes', () => {
const rows = [
{ id: 'a', parent_id: null },
{ id: 'a1', parent_id: 'a' },
{ id: 'b', parent_id: null },
{ id: 'c', parent_id: null },
]
expect(moveItemWithinScope(rows, 'c', 'a', 'before').map((row) => row.id)).toEqual(['c', 'a1', 'a', 'b'])
expect(moveItemWithinScope(rows, 'a', 'c', 'after').map((row) => row.id)).toEqual(['b', 'a1', 'c', 'a'])
expect(moveItemWithinScope(rows, 'a1', 'b', 'before')).toEqual(rows)
})
it('renders safe basic markdown and strips unsafe html', () => {
const html = renderMarkdown('# Plan\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
expect(html).toContain('<h1>Plan</h1>')
+16
View File
@@ -88,6 +88,22 @@ export function groupTaskTree<T extends MinimalTask>(tasks: T[]) {
return tasks.filter((task) => !task.parent_id).map((task) => ({ task, subtasks: children.get(task.id) ?? [] }))
}
export function moveItemWithinScope<T extends { id: string; parent_id?: string | null }>(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)
+1
View File
@@ -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}}
+13
View File
@@ -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)
@@ -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")
+35
View File
@@ -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",
+23
View File
@@ -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()