This commit is contained in:
+52
-10
@@ -45,6 +45,7 @@ from .schemas import (
|
|||||||
TaskDetailOut,
|
TaskDetailOut,
|
||||||
TaskOut,
|
TaskOut,
|
||||||
TaskPage,
|
TaskPage,
|
||||||
|
TaskReorder,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
UserOut,
|
UserOut,
|
||||||
)
|
)
|
||||||
@@ -434,16 +435,16 @@ async def restore_list(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_cursor(created_at: datetime, task_id: UUID) -> str:
|
def _encode_cursor(position: int, created_at: datetime, task_id: UUID) -> str:
|
||||||
raw = json.dumps([created_at.isoformat(), str(task_id)]).encode()
|
raw = json.dumps([position, created_at.isoformat(), str(task_id)]).encode()
|
||||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
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:
|
try:
|
||||||
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
|
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
|
||||||
timestamp, task_id = json.loads(raw)
|
position, timestamp, task_id = json.loads(raw)
|
||||||
return datetime.fromisoformat(timestamp), UUID(task_id)
|
return int(position), datetime.fromisoformat(timestamp), UUID(task_id)
|
||||||
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||||
raise HTTPException(status_code=422, detail="无效的游标") from exc
|
raise HTTPException(status_code=422, detail="无效的游标") from exc
|
||||||
|
|
||||||
@@ -468,7 +469,14 @@ async def create_task(
|
|||||||
if parent is None:
|
if parent is None:
|
||||||
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
|
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
|
||||||
data = payload.model_dump()
|
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)
|
db.add(task)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
audit(db, user.id, "create", "task", task.id)
|
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)
|
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
|
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:
|
if page is not None:
|
||||||
size = page_size or limit
|
size = page_size or limit
|
||||||
items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all())
|
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)
|
return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
|
||||||
if cursor:
|
if cursor:
|
||||||
created_at, task_id = _decode_cursor(cursor)
|
position, created_at, task_id = _decode_cursor(cursor)
|
||||||
query = query.where(
|
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())
|
rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all())
|
||||||
has_more = len(rows) > limit
|
has_more = len(rows) > limit
|
||||||
items = 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)
|
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]
|
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)
|
@app.get("/api/v1/tasks/{task_id}", response_model=TaskDetailOut)
|
||||||
async def get_task(
|
async def get_task(
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ class Habit(Base):
|
|||||||
interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
start_date: Mapped[date] = mapped_column(Date)
|
start_date: Mapped[date] = mapped_column(Date)
|
||||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+36
-5
@@ -525,6 +525,16 @@ class HabitCreate(BaseModel):
|
|||||||
return self
|
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):
|
class HabitLogInput(BaseModel):
|
||||||
day: date
|
day: date
|
||||||
value: float = Field(gt=0)
|
value: float = Field(gt=0)
|
||||||
@@ -541,7 +551,7 @@ class PauseInput(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def habit_dict(h):
|
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):
|
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)
|
@router.post("/habits", status_code=201)
|
||||||
async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
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)
|
db.add(row); await db.commit(); await db.refresh(row); return habit_dict(row)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/habits")
|
@router.get("/habits")
|
||||||
async def list_habits(archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
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)
|
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}")
|
@router.patch("/habits/{habit_id}")
|
||||||
@@ -623,7 +653,7 @@ def scheduled(h, day):
|
|||||||
@router.get("/habits/grid")
|
@router.get("/habits/grid")
|
||||||
async def habits_grid(week: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def habits_grid(week: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
start = week - timedelta(days=week.weekday()); days = [start + timedelta(days=i) for i in range(7)]
|
start = week - timedelta(days=week.weekday()); days = [start + timedelta(days=i) for i in range(7)]
|
||||||
habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id, Habit.archived_at.is_(None)).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:
|
if not habits:
|
||||||
return {"days": days, "habits": []}
|
return {"days": days, "habits": []}
|
||||||
habit_ids = [habit.id for habit in 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],
|
"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],
|
"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],
|
"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],
|
"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"),
|
interval_days=raw.get("interval_days"),
|
||||||
start_date=date.fromisoformat(raw["start_date"]),
|
start_date=date.fromisoformat(raw["start_date"]),
|
||||||
archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None,
|
archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None,
|
||||||
|
position=raw.get("position", 0),
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
existing_countdown_ids = set((await db.scalars(select(Countdown.id).where(Countdown.user_id == user.id))).all())
|
existing_countdown_ids = set((await db.scalars(select(Countdown.id).where(Countdown.user_id == user.id))).all())
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ class TaskUpdate(BaseModel):
|
|||||||
return self
|
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):
|
class TaskOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: UUID
|
id: UUID
|
||||||
|
|||||||
+61
-3
@@ -5,7 +5,7 @@ import {
|
|||||||
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
|
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
|
||||||
Settings, Trash2, X, Repeat2,
|
Settings, Trash2, X, Repeat2,
|
||||||
} from 'lucide-vue-next'
|
} 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 { defaultView, isTaskView, nextTotalAfterLocalTaskAdd, quickTaskFields, shouldToggleRowSwipe, writeCountdownCache } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
import MvpPanel from './MvpPanel.vue'
|
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 taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||||
const taskPointerStart = 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 taskSwipeOffsets = ref<Record<string, number>>({})
|
||||||
|
const taskReorder = ref<{ id: string; startY: number } | null>(null)
|
||||||
|
const taskReorderTarget = ref('')
|
||||||
const taskComposeOpen = ref(false)
|
const taskComposeOpen = ref(false)
|
||||||
const composeTitle = ref('')
|
const composeTitle = ref('')
|
||||||
const composeListId = ref('')
|
const composeListId = ref('')
|
||||||
@@ -336,6 +338,61 @@ async function toggle(task: Task) {
|
|||||||
function isInteractiveTarget(target: EventTarget | null) {
|
function isInteractiveTarget(target: EventTarget | null) {
|
||||||
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
|
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) {
|
function startTaskSwipe(task: Task, event: TouchEvent) {
|
||||||
if (activeView.value === 'trash' || loading.value || isInteractiveTarget(event.target)) return
|
if (activeView.value === 'trash' || loading.value || isInteractiveTarget(event.target)) return
|
||||||
const touch = event.touches[0]
|
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>
|
<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}">
|
<section class="task-list" :class="{loading}">
|
||||||
<template v-for="node in taskTree" :key="node.task.id">
|
<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>
|
<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>
|
<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>
|
<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-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>
|
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
|
||||||
</article>
|
</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>
|
</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>
|
<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>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
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 { dateKey, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, writeHabitGridCache } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
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 habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||||
const habitPointerStart = 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 habitSwipeOffsets = ref<Record<string, number>>({})
|
||||||
|
const habitReorder = ref<{ id: string; startY: number } | null>(null)
|
||||||
|
const habitReorderTarget = ref('')
|
||||||
const hideCompletedHabits = ref(false)
|
const hideCompletedHabits = ref(false)
|
||||||
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
|
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)
|
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) {
|
function isInteractiveTarget(target: EventTarget | null) {
|
||||||
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
|
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) {
|
function startHabitSwipe(h: Habit, event: TouchEvent) {
|
||||||
if (busy.value || isInteractiveTarget(event.target)) return
|
if (busy.value || isInteractiveTarget(event.target)) return
|
||||||
const touch = event.touches[0]
|
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 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>
|
<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>
|
<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">
|
<div class="habit-main">
|
||||||
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
|
<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">
|
<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>
|
<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">
|
<div class="habit-main">
|
||||||
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
|
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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 = {
|
type SearchTask = {
|
||||||
id: string
|
id: string
|
||||||
@@ -26,6 +26,18 @@ describe('task utilities', () => {
|
|||||||
expect(groupTaskTree(tasks)).toEqual([{ task: tasks[0], subtasks: [tasks[1]] }, { task: tasks[2], subtasks: [] }])
|
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', () => {
|
it('renders safe basic markdown and strips unsafe html', () => {
|
||||||
const html = renderMarkdown('# Plan\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
const html = renderMarkdown('# Plan\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
||||||
expect(html).toContain('<h1>Plan</h1>')
|
expect(html).toContain('<h1>Plan</h1>')
|
||||||
|
|||||||
@@ -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) ?? [] }))
|
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) {
|
export function toDateTimeLocal(value: string | null | undefined) {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
|
|||||||
@@ -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)}
|
.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}
|
.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}
|
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}
|
.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)}
|
.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}}
|
@media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ describe('mobile navigation styles', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('task and habit row decoration', () => {
|
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', () => {
|
it('supports pointer dragging for desktop task and subtask completion', () => {
|
||||||
expect(app.match(/@pointerdown="startTaskPointer/g)?.length).toBe(2)
|
expect(app.match(/@pointerdown="startTaskPointer/g)?.length).toBe(2)
|
||||||
expect(app.match(/@pointermove="moveTaskPointer/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")
|
||||||
@@ -92,6 +92,41 @@ def test_task_can_be_updated_completed_and_soft_deleted(client):
|
|||||||
assert client.get("/api/v1/tasks").json()["items"] == []
|
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):
|
def test_task_update_rejects_null_title(client):
|
||||||
client.post(
|
client.post(
|
||||||
"/api/v1/setup/initialize",
|
"/api/v1/setup/initialize",
|
||||||
|
|||||||
@@ -75,6 +75,29 @@ def test_recurrence_rejects_occurrence_after_cutoff(client):
|
|||||||
).status_code == 422
|
).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):
|
def test_habit_logs_support_date_range_filter(client):
|
||||||
boot(client)
|
boot(client)
|
||||||
habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json()
|
habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json()
|
||||||
|
|||||||
Reference in New Issue
Block a user