feat: add repeating tasks
ci / docker (push) Successful in 4m13s

This commit is contained in:
2026-09-08 08:16:57 +08:00
parent fde37de0fc
commit 0a3b959cb8
7 changed files with 179 additions and 10 deletions
+1
View File
@@ -7,6 +7,7 @@
- 首次初始化管理员 - 首次初始化管理员
- 用户名密码登录,Cookie Session - 用户名密码登录,Cookie Session
- 文件夹、清单、任务基础 CRUD - 文件夹、清单、任务基础 CRUD
- 任务支持截止时间,以及每天 / 每周 / 每月 / 每年重复
- 收集箱系统清单 - 收集箱系统清单
- 习惯打卡与倒数纪念日 - 习惯打卡与倒数纪念日
- 倒数日支持倒数日、纪念日、生日,以及每周/月/年重复 - 倒数日支持倒数日、纪念日、生日,以及每周/月/年重复
+25 -8
View File
@@ -3,7 +3,7 @@ import json
import time import time
from collections import defaultdict, deque from collections import defaultdict, deque
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
@@ -26,8 +26,8 @@ from .auth import (
verify_password, verify_password,
) )
from .db import create_schema, get_db from .db import create_schema, get_db
from .models import AppState, Folder, Session, Task, TaskList, User, utcnow from .models import AppState, Folder, RecurrenceTemplate, Session, Task, TaskList, User, utcnow
from .mvp import audit from .mvp import audit, occurrences
from .mvp import router as mvp_router from .mvp import router as mvp_router
from .schemas import ( from .schemas import (
BatchResult, BatchResult,
@@ -624,16 +624,33 @@ async def update_task(
user: User = Depends(current_user), user: User = Depends(current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None)))
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
expected_version = data.pop("version") expected_version = data.pop("version")
recurrence = None
if data.get("completed") is True:
recurrence = await db.scalar(select(RecurrenceTemplate).where(
RecurrenceTemplate.task_id == task_id, RecurrenceTemplate.user_id == user.id
))
if recurrence:
next_items = occurrences(
recurrence.rrule,
recurrence.starts_at,
recurrence.starts_at + timedelta(microseconds=1),
recurrence.starts_at + timedelta(days=3660),
recurrence.ends_at,
)
if next_items:
data["completed"] = False
data["due_at"] = next_items[0]
recurrence.starts_at = next_items[0]
if "list_id" in data: if "list_id" in data:
await _owned_list(db, user.id, data["list_id"]) await _owned_list(db, user.id, data["list_id"])
parent_id = await db.scalar( if task.parent_id:
select(Task.parent_id).where(Task.id == task_id, Task.user_id == user.id)
)
if parent_id:
parent_list = await db.scalar( parent_list = await db.scalar(
select(Task.list_id).where(Task.id == parent_id, Task.user_id == user.id) select(Task.list_id).where(Task.id == task.parent_id, Task.user_id == user.id)
) )
if parent_list != data["list_id"]: if parent_list != data["list_id"]:
raise HTTPException(status_code=400, detail="子任务必须与父任务属于同一清单") raise HTTPException(status_code=400, detail="子任务必须与父任务属于同一清单")
+24
View File
@@ -186,6 +186,17 @@ def ensure_real_occurrence(template: RecurrenceTemplate, occurrence_at: datetime
raise HTTPException(422, "occurrence_at 晚于该重复规则的有效截止时间") raise HTTPException(422, "occurrence_at 晚于该重复规则的有效截止时间")
@router.get("/tasks/{task_id}/recurrence")
async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
await owned_task(db, user.id, task_id)
row = await db.scalar(select(RecurrenceTemplate).where(
RecurrenceTemplate.task_id == task_id, RecurrenceTemplate.user_id == user.id
))
if row is None:
return None
return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at, "ends_at": row.ends_at}
@router.post("/recurrences", status_code=201) @router.post("/recurrences", status_code=201)
async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
task = await owned_task(db, user.id, payload.task_id) task = await owned_task(db, user.id, payload.task_id)
@@ -779,12 +790,14 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep
def serialize(row, fields): def serialize(row, fields):
return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields} return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields}
folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all()); countdowns = list((await db.scalars(select(Countdown).where(Countdown.user_id == user.id))).all()) folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all()); countdowns = list((await db.scalars(select(Countdown).where(Countdown.user_id == user.id))).all())
recurrences = list((await db.scalars(select(RecurrenceTemplate).where(RecurrenceTemplate.user_id == user.id))).all())
return { return {
"version": 1, "version": 1,
"exported_at": utcnow(), "exported_at": utcnow(),
"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],
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at"]) for x in recurrences],
"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], "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],
} }
@@ -852,6 +865,17 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
for task_id, old_parent_id in pending_tasks: for task_id, old_parent_id in pending_tasks:
if old_parent_id and old_parent_id in task_id_map: if old_parent_id and old_parent_id in task_id_map:
await db.execute(update(Task).where(Task.id == task_id, Task.user_id == user.id).values(parent_id=task_id_map[old_parent_id])) await db.execute(update(Task).where(Task.id == task_id, Task.user_id == user.id).values(parent_id=task_id_map[old_parent_id]))
for raw in payload.get("recurrences", []):
task_id = task_id_map.get(raw.get("task_id"))
if not task_id:
continue
db.add(RecurrenceTemplate(
user_id=user.id,
task_id=task_id,
rrule=raw["rrule"],
starts_at=datetime.fromisoformat(raw["starts_at"]),
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
))
for raw in payload.get("habits", []): for raw in payload.get("habits", []):
row = Habit( row = Habit(
user_id=user.id, user_id=user.id,
+64 -1
View File
@@ -15,6 +15,8 @@ import FloatingAddButton from './components/FloatingAddButton.vue'
type FolderItem = { id: string; name: string } type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean } type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; subtasks?: Task[] } type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; subtasks?: Task[] }
type RepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'
type Recurrence = { id: string; task_id: string; rrule: string }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings' type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
const initialized = ref<boolean | null>(null) const initialized = ref<boolean | null>(null)
@@ -58,6 +60,10 @@ const composeListId = ref('')
const composeDueAt = ref('') const composeDueAt = ref('')
const composePriority = ref(0) const composePriority = ref(0)
const composeDescription = ref('') const composeDescription = ref('')
const composeRepeat = ref<RepeatOption>('none')
const selectedTaskRepeat = ref<RepeatOption>('none')
const selectedTaskRecurrence = ref<Recurrence | null>(null)
let recurrenceLoadToken = 0
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null) const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null) const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 }) const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
@@ -72,6 +78,7 @@ function openTaskCompose() {
composeDueAt.value = activeView.value === 'today' ? toDateTimeLocal(quickTaskFields('today', activeList.value, inboxId).due_at ?? null) : '' composeDueAt.value = activeView.value === 'today' ? toDateTimeLocal(quickTaskFields('today', activeList.value, inboxId).due_at ?? null) : ''
composePriority.value = 0 composePriority.value = 0
composeDescription.value = '' composeDescription.value = ''
composeRepeat.value = 'none'
taskComposeOpen.value = true taskComposeOpen.value = true
nextTick(() => document.querySelector<HTMLInputElement>('.task-compose-input')?.focus()) nextTick(() => document.querySelector<HTMLInputElement>('.task-compose-input')?.focus())
} }
@@ -83,6 +90,47 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin) else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin)
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose() else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
} }
function repeatRrule(value: RepeatOption) {
return value === 'none' ? '' : `FREQ=${value.toUpperCase()}`
}
function repeatOption(rrule?: string): RepeatOption {
const value = /FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/.exec(rrule ?? '')?.[1]?.toLowerCase()
return (value as RepeatOption | undefined) ?? 'none'
}
async function saveRepeat(task: Task, value: RepeatOption) {
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
if (value === 'none') {
if (selectedTaskRecurrence.value) await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
return
}
const rrule = repeatRrule(value)
if (selectedTaskRecurrence.value) {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ rrule }) })
selectedTaskRecurrence.value = { ...selectedTaskRecurrence.value, rrule }
} else {
selectedTaskRecurrence.value = await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule }) })
}
selectedTaskRepeat.value = value
}
async function loadTaskRecurrence(task: Task) {
const token = ++recurrenceLoadToken
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
try {
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
if (token !== recurrenceLoadToken || selectedTask.value?.id !== task.id) return
selectedTaskRecurrence.value = recurrence
selectedTaskRepeat.value = repeatOption(recurrence?.rrule)
} catch (reason) { if (token === recurrenceLoadToken) fail(reason) }
}
async function updateSelectedTaskRepeat() {
if (!selectedTask.value) return
try { await saveRepeat(selectedTask.value, selectedTaskRepeat.value); toast('重复设置已保存') }
catch (reason) { selectedTaskRepeat.value = repeatOption(selectedTaskRecurrence.value?.rrule); fail(reason) }
}
async function submitTaskCompose() { async function submitTaskCompose() {
const taskTitle = composeTitle.value.trim() const taskTitle = composeTitle.value.trim()
if (!taskTitle || !composeListId.value) return if (!taskTitle || !composeListId.value) return
@@ -94,6 +142,7 @@ async function submitTaskCompose() {
priority: composePriority.value, priority: composePriority.value,
description: composeDescription.value, description: composeDescription.value,
}) }) }) })
if (composeRepeat.value !== 'none') await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule: repeatRrule(composeRepeat.value) }) })
if (isTaskView(activeView.value)) { if (isTaskView(activeView.value)) {
tasks.value.push(task) tasks.value.push(task)
totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value) totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value)
@@ -485,7 +534,18 @@ async function saveTask() {
if (!selectedTask.value?.title.trim()) return if (!selectedTask.value?.title.trim()) return
try { try {
const task = selectedTask.value const task = selectedTask.value
await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: fromDateTimeLocal(toDateTimeLocal(task.due_at)), list_id: task.list_id } as Partial<Task>) const dueAt = fromDateTimeLocal(toDateTimeLocal(task.due_at))
const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: dueAt, list_id: task.list_id } as Partial<Task>)
if (selectedTaskRecurrence.value) {
if (dueAt) {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ due_at: dueAt }) })
} else {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
}
}
if (selectedTask.value) selectedTask.value = { ...selectedTask.value, ...updated }
toast('已保存') toast('已保存')
} catch (reason) { fail(reason) } } catch (reason) { fail(reason) }
} }
@@ -509,6 +569,7 @@ async function addSubtask() {
function selectTask(task: Task) { function selectTask(task: Task) {
selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] } selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] }
markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true
void loadTaskRecurrence(task)
} }
async function createFolder() { async function createFolder() {
const name = (await askText('新建文件夹', '文件夹名称', '', '创建'))?.trim(); if (!name) return const name = (await askText('新建文件夹', '文件夹名称', '', '创建'))?.trim(); if (!name) return
@@ -642,6 +703,7 @@ onMounted(bootstrap)
<div class="detail-title"><button class="check large" :class="`p${selectedTask.priority}`" @click="toggle(selectedTask)"><Check v-if="selectedTask.completed"/></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题" @blur="saveTask"/></div> <div class="detail-title"><button class="check large" :class="`p${selectedTask.priority}`" @click="toggle(selectedTask)"><Check v-if="selectedTask.completed"/></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题" @blur="saveTask"/></div>
<label>清单<select v-model="selectedTask.list_id" @change="saveTask"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label> <label>清单<select v-model="selectedTask.list_id" @change="saveTask"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
<label>截止时间<input :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value;saveTask()"></label> <label>截止时间<input :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value;saveTask()"></label>
<label>重复<select v-model="selectedTaskRepeat" :disabled="!selectedTask.due_at" @change="updateSelectedTaskRepeat"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select><small v-if="!selectedTask.due_at" class="field-hint">设置截止时间后可重复</small></label>
<div class="field markdown"><div class="field-label"><span>备注</span><span><button :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div><div v-if="markdownPreview" class="markdown-preview" v-html="renderMarkdown(selectedTask.description)"/><textarea v-else v-model="selectedTask.description" rows="9" placeholder="支持 Markdown…" @blur="saveTask"/></div> <div class="field markdown"><div class="field-label"><span>备注</span><span><button :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div><div v-if="markdownPreview" class="markdown-preview" v-html="renderMarkdown(selectedTask.description)"/><textarea v-else v-model="selectedTask.description" rows="9" placeholder="支持 Markdown…" @blur="saveTask"/></div>
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><button v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail" @click="toggle(subtask)"><span class="check"><Check v-if="subtask.completed"/></span><span :class="{strike:subtask.completed}">{{subtask.title}}</span></button><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></div> <div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><button v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail" @click="toggle(subtask)"><span class="check"><Check v-if="subtask.completed"/></span><span :class="{strike:subtask.completed}">{{subtask.title}}</span></button><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></div>
<details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body"> <details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body">
@@ -663,6 +725,7 @@ onMounted(bootstrap)
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off"></label> <label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off"></label>
<div class="task-compose-row"><label>清单<select v-model="composeListId"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label><label>优先级<select v-model.number="composePriority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3"></option></select></label></div> <div class="task-compose-row"><label>清单<select v-model="composeListId"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label><label>优先级<select v-model.number="composePriority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3"></option></select></label></div>
<label>截止时间<input v-model="composeDueAt" type="datetime-local"></label> <label>截止时间<input v-model="composeDueAt" type="datetime-local"></label>
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select><small v-if="!composeDueAt" class="field-hint">设置截止时间后可重复</small></label>
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label> <label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
<footer><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer> <footer><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
</form> </form>
+1 -1
View File
@@ -7,7 +7,7 @@
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:translate(var(--swipe-x),var(--reorder-y,0px))}.task-row.swipeable:not([style*="0px"]){transition:none}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover:not(.reordering){transform:translate(calc(var(--swipe-x) + 2px),var(--reorder-y,0px))}.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:translate(var(--swipe-x),var(--reorder-y,0px))}.task-row.swipeable:not([style*="0px"]){transition:none}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover:not(.reordering){transform:translate(calc(var(--swipe-x) + 2px),var(--reorder-y,0px))}.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:.78;z-index:4;box-shadow:0 12px 28px rgba(78,58,34,.18)!important;transition:none!important;pointer-events:none}.reorder-target{box-shadow:inset 0 2px 0 var(--accent)} .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:.78;z-index:4;box-shadow:0 12px 28px rgba(78,58,34,.18)!important;transition:none!important;pointer-events:none}.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)} .field-hint{color:var(--muted);font-size:11px;font-weight:400}.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}}
@media(max-width:930px){.shell{height:100dvh;display:block;overflow:auto}.sidebar{position:fixed;z-index:50;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow);left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{position:fixed;z-index:40;display:block;left:0;right:0;top:auto;bottom:0;width:100%;max-height:min(88dvh,760px);overflow:auto;background:#fffdf8;border:1px solid var(--line);border-bottom:0;border-radius:22px 22px 0 0;box-shadow:0 -14px 38px rgba(56,40,24,.2);transform:translateY(105%);transition:transform .22s ease;padding-bottom:max(16px,env(safe-area-inset-bottom))}.detail.open{transform:none}.detail-head{position:sticky;z-index:2;top:0;height:62px;background:#fffdf8;border-bottom:1px solid var(--line);padding:0 18px}.detail-head .icon{display:grid;width:42px;height:42px;background:var(--accent-soft);color:var(--accent);border-radius:50%}.detail-form{padding:18px}.detail-title textarea{font-size:21px}.more-mask{position:fixed;z-index:45;inset:0;background:rgba(45,38,31,.32);display:flex;align-items:flex-end}.more-sheet{width:100%;background:#fffdf8;border-radius:22px 22px 0 0;padding:14px 16px max(22px,env(safe-area-inset-bottom));display:grid;gap:6px}.more-sheet-head{display:flex;align-items:center;justify-content:space-between;padding:2px 4px 8px}.more-sheet>button{display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}.scrim{position:fixed;z-index:35;inset:0;background:rgba(45,38,31,.32);display:block}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar{align-items:flex-start}.topbar h1{font-size:24px;margin:0 0 3px}.topbar.today-topbar{gap:10px}.today-board{gap:7px;margin:14px 0 4px}.today-stat{padding:10px 11px;border-radius:12px}.today-stat strong{font-size:20px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.list-toolbar{height:auto;min-height:36px}.section-heading{margin:12px 0 8px}.empty{min-height:190px}.today-empty-panel{min-height:104px}.row-actions{position:static;opacity:1;pointer-events:auto}.row-actions button{padding:7px;min-height:34px}.list-row{padding:6px 4px}.archived-row{padding:9px 7px;border:1px dashed #e0cfae;border-radius:8px;color:#8a6d3b;background:#fbf3e2}.archived-row .list-row-main{padding-left:14px}.archived-row .list-row-main>svg{color:#c9a45c}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-height:44px;min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}} @media(max-width:930px){.shell{height:100dvh;display:block;overflow:auto}.sidebar{position:fixed;z-index:50;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow);left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{position:fixed;z-index:40;display:block;left:0;right:0;top:auto;bottom:0;width:100%;max-height:min(88dvh,760px);overflow:auto;background:#fffdf8;border:1px solid var(--line);border-bottom:0;border-radius:22px 22px 0 0;box-shadow:0 -14px 38px rgba(56,40,24,.2);transform:translateY(105%);transition:transform .22s ease;padding-bottom:max(16px,env(safe-area-inset-bottom))}.detail.open{transform:none}.detail-head{position:sticky;z-index:2;top:0;height:62px;background:#fffdf8;border-bottom:1px solid var(--line);padding:0 18px}.detail-head .icon{display:grid;width:42px;height:42px;background:var(--accent-soft);color:var(--accent);border-radius:50%}.detail-form{padding:18px}.detail-title textarea{font-size:21px}.more-mask{position:fixed;z-index:45;inset:0;background:rgba(45,38,31,.32);display:flex;align-items:flex-end}.more-sheet{width:100%;background:#fffdf8;border-radius:22px 22px 0 0;padding:14px 16px max(22px,env(safe-area-inset-bottom));display:grid;gap:6px}.more-sheet-head{display:flex;align-items:center;justify-content:space-between;padding:2px 4px 8px}.more-sheet>button{display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}.scrim{position:fixed;z-index:35;inset:0;background:rgba(45,38,31,.32);display:block}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar{align-items:flex-start}.topbar h1{font-size:24px;margin:0 0 3px}.topbar.today-topbar{gap:10px}.today-board{gap:7px;margin:14px 0 4px}.today-stat{padding:10px 11px;border-radius:12px}.today-stat strong{font-size:20px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.list-toolbar{height:auto;min-height:36px}.section-heading{margin:12px 0 8px}.empty{min-height:190px}.today-empty-panel{min-height:104px}.row-actions{position:static;opacity:1;pointer-events:auto}.row-actions button{padding:7px;min-height:34px}.list-row{padding:6px 4px}.archived-row{padding:9px 7px;border:1px dashed #e0cfae;border-radius:8px;color:#8a6d3b;background:#fbf3e2}.archived-row .list-row-main{padding-left:14px}.archived-row .list-row-main>svg{color:#c9a45c}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-height:44px;min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
.mvp-view{display:grid;gap:16px;padding-bottom:36px}.view-intro{display:flex;align-items:end;justify-content:space-between;border-bottom:1px dashed var(--line);padding-bottom:12px}.view-intro h2{margin:2px 0 0;font-size:22px}.view-intro small,.view-intro>span{color:var(--muted);font-size:12px}.tool-card,.empty-panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 3px 14px rgba(81,61,38,.05)}.soft-button,.primary-small,.danger-button,.file-button{min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 11px;font-size:12px}.soft-button svg,.file-button svg{width:15px}.primary-small{background:var(--accent);border-color:var(--accent);color:#fff}.danger-button{color:var(--danger);border-color:#e5b7ad}.habit-list{display:grid;gap:10px}.tool-card h3{margin:0 0 4px} .mvp-view{display:grid;gap:16px;padding-bottom:36px}.view-intro{display:flex;align-items:end;justify-content:space-between;border-bottom:1px dashed var(--line);padding-bottom:12px}.view-intro h2{margin:2px 0 0;font-size:22px}.view-intro small,.view-intro>span{color:var(--muted);font-size:12px}.tool-card,.empty-panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 3px 14px rgba(81,61,38,.05)}.soft-button,.primary-small,.danger-button,.file-button{min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 11px;font-size:12px}.soft-button svg,.file-button svg{width:15px}.primary-small{background:var(--accent);border-color:var(--accent);color:#fff}.danger-button{color:var(--danger);border-color:#e5b7ad}.habit-list{display:grid;gap:10px}.tool-card h3{margin:0 0 4px}
+10
View File
@@ -172,6 +172,16 @@ describe('mobile touch targets', () => {
}) })
describe('unified floating add interaction', () => { describe('unified floating add interaction', () => {
it('lets task create and detail forms configure a repeat rule', () => {
expect(app).toContain('const composeRepeat = ref')
expect(app).toContain('const selectedTaskRepeat = ref')
expect(app).toContain('重复<select v-model="composeRepeat"')
expect(app).toContain('重复<select v-model="selectedTaskRepeat"')
expect(app).toContain("api('/recurrences'")
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
expect(app).toContain("api(`/recurrences/${selectedTaskRecurrence.value.id}`")
})
it('preloads countdowns after authentication', () => { it('preloads countdowns after authentication', () => {
expect(app).toContain('void preloadCountdowns()') expect(app).toContain('void preloadCountdowns()')
expect(app).toContain("const active = await api('/countdowns')") expect(app).toContain("const active = await api('/countdowns')")
+54
View File
@@ -29,6 +29,60 @@ def test_bootstrap_returns_navigation_and_current_user(client):
def test_get_recurrence_by_task_returns_rule_or_null(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "每天复盘", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z"},
).json()
empty = client.get(f"/api/v1/tasks/{task['id']}/recurrence")
assert empty.status_code == 200
assert empty.json() is None
created = client.post(
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=DAILY"}
).json()
found = client.get(f"/api/v1/tasks/{task['id']}/recurrence")
assert found.status_code == 200
assert found.json()["id"] == created["id"]
assert found.json()["rrule"] == "FREQ=DAILY"
def test_export_and_restore_preserve_task_recurrence(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "每周整理", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z"},
).json()
client.post("/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY"})
exported = client.get("/api/v1/export").json()
assert exported["recurrences"][0]["task_id"] == task["id"]
restored = client.post("/api/v1/restore?mode=replace", json=exported)
assert restored.status_code == 200
restored_task = client.get("/api/v1/tasks", params={"q": "每周整理"}).json()["items"][0]
recurrence = client.get(f"/api/v1/tasks/{restored_task['id']}/recurrence").json()
assert recurrence["rrule"] == "FREQ=WEEKLY"
def test_completing_repeating_task_advances_due_date_instead_of_closing_it(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "每日复盘", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z"},
).json()
client.post("/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=DAILY"})
completed = client.patch(
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
)
assert completed.status_code == 200
assert completed.json()["completed"] is False
assert completed.json()["due_at"].replace("Z", "") == "2026-09-08T09:00:00"
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
assert recurrence["starts_at"].replace("Z", "") == "2026-09-08T09:00:00"
def test_recurrence_mutations_keep_exact_timestamp_validation(client): def test_recurrence_mutations_keep_exact_timestamp_validation(client):
inbox = boot(client) inbox = boot(client)
task = client.post( task = client.post(