feat: recurring tasks, habits, attachments, import/export, audit and security

This commit is contained in:
2026-09-05 13:35:17 +08:00
parent 57145c198b
commit 067d27a477
22 changed files with 1231 additions and 33 deletions
+23 -9
View File
@@ -2,16 +2,17 @@
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import {
ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
GripVertical, Inbox, ListChecks, ListTodo, Menu, MoreHorizontal, Pencil, Plus, Search,
Settings, Tag as TagIcon, Trash2, X,
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
Settings, Trash2, X, CalendarRange, Repeat2,
} from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
import MvpPanel from './MvpPanel.vue'
type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type Tag = { id: string; name: string; color: string }
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; tags?: Tag[]; subtasks?: Task[] }
type View = 'tasks' | 'today' | 'upcoming' | 'trash'
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; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'calendar' | 'habits' | 'settings'
const initialized = ref<boolean | null>(null)
const authenticated = ref(false)
@@ -41,6 +42,9 @@ const activeName = computed(() => {
if (activeView.value === 'trash') return '回收站'
if (activeView.value === 'today') return '今天'
if (activeView.value === 'upcoming') return '最近 7 天'
if (activeView.value === 'calendar') return '月历'
if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'settings') return '设置与数据'
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
})
const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : tasks.value)
@@ -50,6 +54,7 @@ const visibleTasks = computed(() => {
let result = sourceTasks.value
if (query.value.trim()) return result
if (activeView.value === 'tasks') result = result.filter((task) => task.list_id === activeList.value)
if (['calendar','habits','settings'].includes(activeView.value)) return []
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
if (!showCompleted.value && activeView.value !== 'trash') result = result.filter((task) => !task.completed)
@@ -135,7 +140,7 @@ async function loadTrash() {
async function switchView(view: View, listId?: string) {
activeView.value = view
if (listId) activeList.value = listId
selectedTask.value = null; selectedIds.value = new Set(); mobileSidebar.value = false
selectedTask.value = null; selectedIds.value = new Set(); mobileSidebar.value = false; mobileDetail.value = false
if (view === 'trash') await loadTrash()
}
async function addTask() {
@@ -159,7 +164,7 @@ async function saveTask() {
if (!selectedTask.value?.title.trim()) return
try {
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, tag_ids: (task.tags ?? []).map((tag) => tag.id) } as Partial<Task>)
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, recurrence_rule: task.recurrence_rule || null, recurrence_end_at: task.recurrence_end_at || null, tag_ids: (task.tags ?? []).map((tag) => tag.id) } as Partial<Task>)
toast('已保存')
} catch (reason) { fail(reason) }
}
@@ -252,6 +257,8 @@ onMounted(bootstrap)
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button>
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarRange />月历</button>
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
</nav>
<div class="section-title"><span>我的清单</span><span><button class="mini-icon" aria-label="新建文件夹" @click="createFolder"><Folder /></button><button class="mini-icon" aria-label="新建清单" @click="createList(null)"><Plus /></button></span></div>
@@ -262,7 +269,7 @@ onMounted(bootstrap)
</div>
<button v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span><span class="row-actions"><button aria-label="重命名清单" @click.stop="renameEntity('lists',list)"><Pencil/></button><button aria-label="删除清单" @click.stop="deleteEntity('lists',list)"><Trash2/></button></span></button>
</div>
<button class="settings"><Settings />设置</button>
<button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
</aside>
<main>
@@ -271,6 +278,10 @@ onMounted(bootstrap)
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd> K</kbd></label>
</header>
<template v-if="['calendar','habits','settings'].includes(activeView)">
<MvpPanel :key="activeView" :view="activeView as 'calendar'|'habits'|'settings'" :tasks="tasks" @changed="loadAll" @notice="toast" />
</template>
<template v-else>
<form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form>
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span>{{visibleTasks.length}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
<div v-if="selectedCount" class="batch-bar"><b>已选 {{selectedCount}} 项</b><button @click="batch('complete')"><Check/>完成</button><button @click="batch('move')"><Folder/>移动</button><button class="danger" @click="batch('delete')"><Trash2/>删除</button><button class="icon" aria-label="取消选择" @click="selectedIds=new Set()"><X/></button></div>
@@ -289,6 +300,7 @@ onMounted(bootstrap)
</template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section>
</template>
</main>
<aside class="detail" :class="{open:mobileDetail}">
@@ -298,6 +310,8 @@ onMounted(bootstrap)
<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>优先级<select v-model.number="selectedTask.priority" @change="saveTask"><option :value="0"></option><option :value="1"></option><option :value="2"></option><option :value="3"></option></select></label>
<label>重复<select v-model="selectedTask.recurrence_rule" @change="saveTask"><option value="">不重复</option><option value="FREQ=DAILY">每天</option><option value="FREQ=WEEKLY">每周</option><option value="FREQ=MONTHLY">每月</option></select></label>
<label v-if="selectedTask.recurrence_rule">重复截止<input v-model="selectedTask.recurrence_end_at" type="date" @change="saveTask"></label>
<div class="field"><div class="field-label"><span>标签</span><button class="link" @click="createTag"><Plus/>新建</button></div><div class="tag-picker"><button v-for="tag in tags" :key="tag.id" :class="{chosen:taskHasTag(tag)}" @click="toggleTag(tag);saveTask()"><i :style="{background:tag.color}"/>{{tag.name}}</button><span v-if="!tags.length" class="hint">还没有标签</span></div></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 tasks.filter(t=>t.parent_id===selectedTask?.id)" :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="!tasks.some(t=>t.parent_id===selectedTask?.id)" class="hint">把这件事拆成更小的步骤</span></div>
@@ -306,8 +320,8 @@ onMounted(bootstrap)
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期优先级标签子任务和 Markdown 备注会出现在这里</p></div>
</aside>
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='upcoming'}" @click="switchView('upcoming')"><CalendarDays/><span>计划</span></button><button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2/><span>回收站</span></button></nav>
<button v-if="activeView!=='trash'" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='calendar'}" @click="switchView('calendar')"><CalendarRange/><span>月历</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
<button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
</div>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import type { CalendarOptions, EventDropArg } from '@fullcalendar/core'
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
import { dateKey, habitWeek, mergePage, moveDueDate } from './lib/mvp-utils'
type View = 'calendar'|'habits'|'settings'
type Task = { id:string; title:string; due_at:string|null; version:number }
type Habit = { id:string; name:string; type?:string; target?:number; unit?:string; logs?: Array<{date:string;value:number|boolean}>; stats?: Record<string,number> }
type Session = { id:string; created_at?:string; last_seen_at?:string; current?:boolean; user_agent?:string }
const props = defineProps<{ view:View; tasks:Task[] }>()
const emit = defineEmits<{ changed:[]; notice:[message:string] }>()
const habits = ref<Habit[]>([]), sessions = ref<Session[]>([]), audit = ref<any[]>([])
const busy = ref(false), error = ref(''), habitName = ref(''), habitType = ref('boolean'), habitTarget = ref(1)
const importFile = ref<File|null>(null), importPreview = ref<any>(null), restoreFile = ref<File|null>(null)
const week = computed(() => habitWeek())
async function request(path:string, options:RequestInit={}) {
const headers:Record<string,string> = { ...(options.headers as Record<string,string> || {}) }
if (options.body && !(options.body instanceof FormData)) headers['Content-Type']='application/json'
const response = await fetch('/api/v1'+path,{ credentials:'include',...options,headers })
if (!response.ok) throw new Error((await response.json().catch(()=>({}))).detail || `请求失败 (${response.status})`)
const type=response.headers.get('content-type')||''
return response.status===204?null:type.includes('json')?response.json():response.blob()
}
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try{await work()}catch(e){error.value=e instanceof Error?e.message:'请求失败'}finally{busy.value=false} }
async function loadHabits(){ await safe(async()=>{const page=mergePage<Habit>(await request('/habits')); habits.value=page.items; await Promise.all(habits.value.map(async h=>{try{const [logs,stats]=await Promise.all([request(`/habits/${h.id}/logs?from=${dateKey(week.value[0])}&to=${dateKey(week.value[6])}`),request(`/habits/${h.id}/stats`)]);h.logs=mergePage<any>(logs).items;h.stats=stats}catch{/* optional enrichment */}}))}) }
async function addHabit(){if(!habitName.value.trim())return;await safe(async()=>{await request('/habits',{method:'POST',body:JSON.stringify({name:habitName.value.trim(),type:habitType.value,target:habitTarget.value})});habitName.value='';await loadHabits();emit('notice','习惯已创建')})}
function logFor(h:Habit,day:string){return h.logs?.find(l=>l.date===day)}
async function checkIn(h:Habit,day:string,value?:number){await safe(async()=>{await request(`/habits/${h.id}/logs`,{method:'POST',body:JSON.stringify({date:day,value:h.type==='numeric'?(value??h.target??1):!Boolean(logFor(h,day)?.value)})});await loadHabits();emit('notice','打卡已记录')})}
async function deleteHabit(h:Habit){if(!confirm(`删除习惯“${h.name}”?`))return;await safe(async()=>{await request(`/habits/${h.id}`,{method:'DELETE'});await loadHabits()})}
async function moveTask(arg:EventDropArg){const task=props.tasks.find(t=>t.id===arg.event.id);if(!task)return;const previous=task.due_at;try{await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:moveDueDate(previous,arg.event.startStr.slice(0,10)),version:task.version})});emit('changed');const undo=confirm('日期已更新。要撤销吗?');if(undo){const fresh=props.tasks.find(t=>t.id===task.id) || task;await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:previous,version:fresh.version})});emit('changed')}}catch(e){arg.revert();error.value=e instanceof Error?e.message:'移动失败'}}
const calendarOptions=computed<CalendarOptions>(()=>({plugins:[dayGridPlugin,interactionPlugin],initialView:'dayGridMonth',locale:'zh-cn',firstDay:1,height:'auto',editable:true,dayMaxEvents:4,headerToolbar:{left:'prev,next today',center:'title',right:''},events:props.tasks.filter(t=>t.due_at).map(t=>({id:t.id,title:t.title,start:t.due_at!})),eventDrop:moveTask}))
async function loadSettings(){await safe(async()=>{const [s,a]=await Promise.all([request('/sessions').catch(()=>[]),request('/audit-logs?limit=20').catch(()=>[])]);sessions.value=mergePage<Session>(s).items;audit.value=mergePage<any>(a).items})}
async function revoke(id:string){await safe(async()=>{await request(`/sessions/${id}`,{method:'DELETE'});await loadSettings();emit('notice','会话已撤销')})}
function downloadBlob(blob:Blob,name:string){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
async function exportData(){await safe(async()=>downloadBlob(await request('/export'),'dodo-export.json'))}
async function previewImport(){if(!importFile.value)return;await safe(async()=>{const form=new FormData();form.append('file',importFile.value!);importPreview.value=await request('/import/ticktick/preview',{method:'POST',body:form})})}
async function confirmImport(){await safe(async()=>{const form=new FormData();form.append('file',importFile.value!);const result=await request('/import/ticktick',{method:'POST',body:form});importPreview.value=null;emit('changed');emit('notice',`导入完成:新增 ${result?.imported??0},跳过 ${result?.skipped??0}`)})}
async function restore(){if(!restoreFile.value)return;if(!confirm('恢复为合并模式,将导入 JSON 中的清单与任务。继续吗?'))return;await safe(async()=>{const text=await restoreFile.value!.text();await request('/restore?mode=merge',{method:'POST',body:text});emit('changed');emit('notice','数据已恢复')})}
onMounted(()=>props.view==='habits'?loadHabits():props.view==='settings'?loadSettings():undefined)
</script>
<template>
<section class="mvp-view" :class="{loading:busy}">
<p v-if="error" class="inline-error">{{error}}</p>
<template v-if="view==='calendar'">
<header class="view-intro"><div><small>拖动任务即可改期</small><h2>月历</h2></div><span>{{tasks.filter(t=>t.due_at).length}} 个已排期任务</span></header>
<div class="calendar-card"><FullCalendar :options="calendarOptions" /></div>
</template>
<template v-else-if="view==='habits'">
<header class="view-intro"><div><small>今天做一点明天更轻松</small><h2>习惯</h2></div><button class="soft-button" @click="loadHabits"><RefreshCw/>刷新</button></header>
<form class="habit-create" @submit.prevent="addHabit"><input v-model="habitName" placeholder="新习惯名称"><select v-model="habitType"><option value="boolean">完成 / 未完成</option><option value="numeric">数值</option></select><input v-if="habitType==='numeric'" v-model.number="habitTarget" type="number" min="0" step="any" aria-label="目标值"><button><Plus/>添加</button></form>
<div class="habit-list"><article v-for="h in habits" :key="h.id" class="habit-card"><div class="habit-title"><div><h3>{{h.name}}</h3><small v-if="h.stats">连续 {{h.stats.current_streak??0}} 天 · 完成率 {{Math.round((h.stats.completion_rate??0)*100)}}%</small></div><button class="icon ghost" aria-label="删除习惯" @click="deleteHabit(h)"><Trash2/></button></div><div class="week-grid"><div v-for="d in week" :key="dateKey(d)"><small>{{['一','二','三','四','五','六','日'][(d.getDay()+6)%7]}}<br>{{d.getDate()}}</small><button v-if="h.type!=='numeric'" class="habit-check" :class="{done:logFor(h,dateKey(d))?.value}" @click="checkIn(h,dateKey(d))">{{logFor(h,dateKey(d))?.value?'✓':'·'}}</button><input v-else type="number" :value="logFor(h,dateKey(d))?.value??''" :placeholder="String(h.target??1)" @change="checkIn(h,dateKey(d),Number(($event.target as HTMLInputElement).value))"></div></div></article><div v-if="!habits.length&&!busy" class="empty-panel">还没有习惯从一件容易坚持的小事开始</div></div>
</template>
<template v-else>
<header class="view-intro"><div><small>备份迁移与安全</small><h2>设置与数据</h2></div></header>
<div class="settings-grid">
<article class="tool-card"><FileJson/><h3>数据导出与恢复</h3><p>下载完整 JSON 备份或从备份恢复</p><button class="soft-button" @click="exportData"><Download/>导出 JSON</button><label class="file-button"><ArchiveRestore/>选择备份<input type="file" accept="application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article>
<article class="tool-card"><Upload/><h3>导入</h3><p>先预览变化确认后才写入</p><label class="file-button">选择文件<input type="file" accept=".json,.csv" @change="importFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button :disabled="!importFile" class="soft-button" @click="previewImport">生成预览</button><pre v-if="importPreview">{{JSON.stringify(importPreview,null,2)}}</pre><button v-if="importPreview" class="primary-small" @click="confirmImport">确认导入</button></article>
<article class="tool-card wide"><LogOut/><h3>登录会话</h3><div v-for="s in sessions" :key="s.id" class="session-row"><span><b>{{s.current?'当前设备':'其他设备'}}</b><small>{{s.user_agent||'未知设备'}} · {{s.last_seen_at||s.created_at}}</small></span><button v-if="!s.current" class="danger-text" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length">没有可显示的会话</p></article>
<article v-if="audit.length" class="tool-card wide"><Activity/><h3>最近活动</h3><div v-for="(row,i) in audit" :key="row.id||i" class="audit-row"><span>{{row.action||row.event||'变更'}}</span><small>{{row.created_at||row.timestamp}}</small></div></article>
</div>
</template>
</section>
</template>
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { dateKey, moveDueDate } from './mvp-utils'
describe('MVP view utilities', () => {
it('normalizes to UTC so stored due_at stays stable in every local timezone', () => {
const moved = moveDueDate('2026-09-05T14:30:00+08:00', '2026-09-09')
expect(moved).toBe('2026-09-09T14:30:00.000Z')
const newly = moveDueDate(null, '2026-09-09')
expect(newly).toBe('2026-09-09T09:00:00.000Z')
})
})
+41
View File
@@ -0,0 +1,41 @@
export function dateKey(date: Date) {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}`
}
export function calendarRange(date: Date) {
const first = new Date(date.getFullYear(), date.getMonth(), 1)
const from = new Date(first)
const mondayOffset = (first.getDay() + 6) % 7
from.setDate(first.getDate() - mondayOffset)
const to = new Date(from)
to.setDate(from.getDate() + 41)
return { from: dateKey(from), to: dateKey(to) }
}
export function moveDueDate(current: string | null, day: string) {
const source = current ? new Date(current) : null
const hours = source && !Number.isNaN(source.valueOf()) ? source.getHours() : 9
const minutes = source && !Number.isNaN(source.valueOf()) ? source.getMinutes() : 0
const date = new Date(`${day}T00:00:00`)
date.setHours(hours, minutes, 0, 0)
const offsetMs = date.getTimezoneOffset() * 60_000
return new Date(date.getTime() - offsetMs).toISOString()
}
export function habitWeek(now = new Date()) {
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7))
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(monday)
date.setDate(monday.getDate() + index)
return date
})
}
export function mergePage<T>(page: T[] | { items?: T[]; next_cursor?: string | null }) {
if (Array.isArray(page)) return { items: page, nextCursor: null }
return { items: page.items ?? [], nextCursor: page.next_cursor ?? null }
}
+2
View File
@@ -9,4 +9,6 @@ main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient
.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:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar,.detail{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow)}.sidebar{left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{right:0;width:min(430px,94vw);transform:translateX(105%)}.detail.open{transform:none}.scrim{position:fixed;z-index:20;inset:0;background:rgba(45,38,31,.26)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin-bottom:17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.select-box{opacity:.45}.row-actions{opacity:1}.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-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}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.batch-bar{overflow:auto}.batch-bar b{white-space:nowrap}.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}.calendar-card,.habit-card,.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)}.fc{--fc-button-bg-color:var(--accent);--fc-button-border-color:var(--accent);--fc-button-hover-bg-color:#cf461d;--fc-today-bg-color:#fff3eb;font-size:13px}.fc .fc-toolbar-title{font-size:18px}.fc .fc-event{border-color:var(--accent);background:var(--accent);cursor:grab}.soft-button,.primary-small,.danger-button,.file-button{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-create{display:grid;grid-template-columns:1fr 150px 90px auto;gap:8px}.habit-create input,.habit-create select{min-width:0;border:1px solid var(--line);border-radius:8px;background:#fff;padding:9px}.habit-create button{border:0;border-radius:8px;background:var(--accent);color:#fff;padding:8px 13px;display:flex;align-items:center;gap:5px}.habit-list{display:grid;gap:10px}.habit-title{display:flex;justify-content:space-between;align-items:start}.habit-title h3,.tool-card h3{margin:0 0 4px}.habit-title small{color:var(--muted)}.week-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:7px;margin-top:13px}.week-grid>div{display:grid;place-items:center;gap:5px;text-align:center}.week-grid small{color:var(--muted);font-size:10px}.habit-check{width:34px;height:34px;border-radius:50%;border:1px solid var(--line);background:#faf7f0}.habit-check.done{background:var(--accent);border-color:var(--accent);color:#fff}.week-grid input{width:100%;min-width:0;border:1px solid var(--line);border-radius:7px;padding:7px;text-align:center}.empty-panel{text-align:center;color:var(--muted)}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding:9px 0}.session-row span{display:grid}.session-row small,.audit-row small{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700}
@media(max-width:800px){.habit-create{grid-template-columns:1fr 1fr}.habit-create button{justify-content:center}.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.fc .fc-toolbar{align-items:flex-start}.fc .fc-toolbar-title{font-size:16px}.calendar-card{padding:8px}.week-grid{gap:3px}.habit-card{padding:12px}.habit-check{width:30px;height:30px}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}