feat: remove calendar and refine habits
ci / docker (push) Successful in 3m53s

This commit is contained in:
2026-09-06 07:56:23 +08:00
parent ceaaaa593e
commit 35e562026c
16 changed files with 277 additions and 500 deletions
+1 -1
View File
@@ -1 +1 @@
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"[email protected]","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@fullcalendar/core":"^6.1.21","@fullcalendar/daygrid":"^6.1.21","@fullcalendar/interaction":"^6.1.21","@fullcalendar/vue3":"^6.1.21","@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/node":"latest","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}}
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"[email protected]","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/node":"latest","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}}
-53
View File
@@ -8,18 +8,6 @@ importers:
.:
dependencies:
'@fullcalendar/core':
specifier: ^6.1.21
version: 6.1.21
'@fullcalendar/daygrid':
specifier: ^6.1.21
version: 6.1.21(@fullcalendar/[email protected])
'@fullcalendar/interaction':
specifier: ^6.1.21
version: 6.1.21(@fullcalendar/[email protected])
'@fullcalendar/vue3':
specifier: ^6.1.21
version: 6.1.21(@fullcalendar/[email protected])([email protected]([email protected]))
'@vitejs/plugin-vue':
specifier: latest
version: 6.0.8([email protected](@types/[email protected])([email protected]))([email protected]([email protected]))
@@ -158,25 +146,6 @@ packages:
'@floating-ui/[email protected]':
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
'@fullcalendar/[email protected]':
resolution: {integrity: sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==}
'@fullcalendar/[email protected]':
resolution: {integrity: sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==}
peerDependencies:
'@fullcalendar/core': ~6.1.21
'@fullcalendar/[email protected]':
resolution: {integrity: sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==}
peerDependencies:
'@fullcalendar/core': ~6.1.21
'@fullcalendar/[email protected]':
resolution: {integrity: sha512-OGt6WSC+/zz/ej6a0KfIBNl7BYuGchpZU49SsedYyv3WZWbghAE+D8YD6nhH1ia/I4p5Gcsv/nEXgEkT/I8aYQ==}
peerDependencies:
'@fullcalendar/core': ~6.1.21
vue: ^3.0.11
'@internationalized/[email protected]':
resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==}
@@ -879,9 +848,6 @@ packages:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==}
[email protected]:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1267,23 +1233,6 @@ snapshots:
- '@vue/composition-api'
- vue
'@fullcalendar/[email protected]':
dependencies:
preact: 10.12.1
'@fullcalendar/[email protected](@fullcalendar/[email protected])':
dependencies:
'@fullcalendar/core': 6.1.21
'@fullcalendar/[email protected](@fullcalendar/[email protected])':
dependencies:
'@fullcalendar/core': 6.1.21
'@fullcalendar/[email protected](@fullcalendar/[email protected])([email protected]([email protected]))':
dependencies:
'@fullcalendar/core': 6.1.21
vue: 3.5.42([email protected])
'@internationalized/[email protected]':
dependencies:
'@swc/helpers': 0.5.23
@@ -1889,8 +1838,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
+6 -9
View File
@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import {
ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
Settings, Trash2, X, CalendarRange, Repeat2,
Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
import { isTaskView } from './lib/mvp-utils'
@@ -14,7 +14,7 @@ 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; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'calendar' | 'habits' | 'settings'
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'settings'
const initialized = ref<boolean | null>(null)
const authenticated = ref(false)
@@ -48,7 +48,6 @@ 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 || '收集箱'
@@ -58,7 +57,7 @@ const visibleTasks = computed(() => {
const now = new Date()
const end = new Date(now); end.setDate(end.getDate() + 7)
let result = sourceTasks.value
if (['calendar','habits','settings'].includes(activeView.value)) return []
if (['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)
return query.value.trim() ? filterTasks(result, query.value) : result
@@ -202,7 +201,6 @@ async function switchView(view: View, listId?: string) {
page.value = 1
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false
if (view === 'trash') await loadTrash()
else if (view === 'calendar') return
else if (!isTaskView(view)) tasks.value = []
else await loadAll()
}
@@ -314,7 +312,6 @@ 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>
@@ -335,8 +332,8 @@ 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'" @changed="refreshAll" @notice="toast" />
<template v-if="['habits','settings'].includes(activeView)">
<MvpPanel :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @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>
@@ -376,7 +373,7 @@ 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==='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>
<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==='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>
+191 -80
View File
@@ -1,23 +1,28 @@
<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'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Activity, ArchiveRestore, Check, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
import { dateKey, isHabitComplete, mergePage, numericHabitInputValue } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
type View = 'calendar'|'habits'|'settings'
type Task = { id:string; title:string; due_at:string|null; version:number; recurrence_id?:string|null }
type Habit = { id:string; name:string; kind?:string; target?:number; max_value?:number|null; unit?:string; cells?: Array<{day:string; scheduled?:boolean; paused?:boolean; 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 }>()
const emit = defineEmits<{ changed:[]; notice:[message:string] }>()
const habits = ref<Habit[]>([]), calendarTasks = ref<Task[]>([]), 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())
type View = 'habits' | 'settings'
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; unit?: string; cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; 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 }>()
const emit = defineEmits<{ changed: []; notice: [message: string] }>()
const habits = ref<Habit[]>([])
const sessions = ref<Session[]>([])
const audit = ref<any[]>([])
const busy = ref(false)
const error = ref('')
const habitName = ref('')
const habitType = ref<'boolean' | 'numeric'>('boolean')
const habitTarget = ref(1)
const importFile = ref<File | null>(null)
const importPreview = ref<any>(null)
const restoreFile = ref<File | null>(null)
const numericValues = ref<Record<string, number>>({})
const todayKey = ref(dateKey(new Date()))
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
function formatErrorMessage(detail: unknown): string {
if (typeof detail === 'string') return detail
@@ -35,78 +40,184 @@ function formatErrorMessage(detail: unknown): string {
return '请求失败'
}
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'
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 csrf = csrfHeader(options.method)
if (csrf['x-csrf-token']) headers['x-csrf-token'] = csrf['x-csrf-token']
const response = await fetch('/api/v1'+path,{ credentials:'include',...options,headers })
const response = await fetch('/api/v1' + path, { credentials: 'include', ...options, headers })
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(formatErrorMessage((body as { detail?: unknown }).detail))
}
const type=response.headers.get('content-type')||''
return response.status===204?null:type.includes('json')?response.json():response.blob()
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 grid=await request(`/habits/grid?week=${dateKey(week.value[0])}`); habits.value=(grid as {habits?: Habit[]}).habits ?? [];}) }
async function addHabit(){if(!habitName.value.trim())return;await safe(async()=>{await request('/habits',{method:'POST',body:JSON.stringify({name:habitName.value.trim(),kind:habitType.value,target:habitTarget.value,schedule_type:'daily'})});habitName.value='';await loadHabits();emit('notice','习惯已创建')})}
function logFor(h:Habit,day:string){return (h.cells??[]).find(c=>c.day===day)}
async function checkIn(h:Habit,day:string,value?:number){await safe(async()=>{const current=logFor(h,day);const next=h.kind==='numeric'?(value??h.target??1):(current?.value?0:1);await request(`/habits/${h.id}/logs/${day}`,{method:'PUT',body:JSON.stringify({value:next})});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()})}
const currentCalendarRange = ref<{ startStr:string; endStr:string }|null>(null)
async function loadCalendar(range?: { startStr?: string; endStr?: string }) {
if (range?.startStr && range?.endStr) currentCalendarRange.value = { startStr: range.startStr, endStr: range.endStr }
const now = new Date()
const activeRange = currentCalendarRange.value
const start = activeRange?.startStr.slice(0, 10) ?? dateKey(new Date(now.getFullYear(), now.getMonth(), 1))
const endExclusiveKey = activeRange?.endStr.slice(0, 10) ?? dateKey(new Date(now.getFullYear(), now.getMonth() + 1, 1))
const endExclusive = new Date(`${endExclusiveKey}T00:00:00`)
endExclusive.setDate(endExclusive.getDate() - 1)
const end = dateKey(endExclusive)
await safe(async()=>{
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
const rows = await request(`/calendar?start=${start}&end=${end}&timezone=${encodeURIComponent(tzName)}`) as Array<{id?:string;task_id:string;recurrence_id?:string|null;title:string;due_at?:string;occurrence_at:string;version?:number}>
calendarTasks.value = rows.map(row=>({
id: row.recurrence_id ? `${row.task_id}:${row.occurrence_at}` : (row.id ?? row.task_id),
title: row.title,
due_at: row.due_at ?? row.occurrence_at,
version: row.version ?? 0,
recurrence_id: row.recurrence_id,
}))
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 }
}
function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) }
function isDone(h: Habit, day: string) { return isHabitComplete(h.kind, logFor(h, day)?.value, h.target ?? 1) }
async function toggleHabit(h: Habit, day: string) {
if (busy.value) return
const current = logFor(h, day)
const next = current?.value ? 0 : 1
await safe(async () => {
await request(`/habits/${h.id}/logs/${day}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
await loadHabits()
emit('notice', next ? '打卡成功 🎉' : '已取消打卡')
})
}
async function moveTask(arg:EventDropArg){const task=calendarTasks.value.find(t=>t.id===arg.event.id);if(!task||task.version===0)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})});await loadCalendar();emit('notice','日期已更新')}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:calendarTasks.value.filter(t=>t.due_at).map(t=>({id:t.id,title:t.title,start:t.due_at!,editable:t.version!==0})),datesSet:loadCalendar,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)
async function recordNumeric(h: Habit, day: string) {
if (busy.value) return
const next = numericHabitInputValue(numericValues.value[h.id])
if (next === null || (h.max_value != null && next > h.max_value)) return
await safe(async () => {
await request(`/habits/${h.id}/logs/${day}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
await loadHabits()
emit('notice', next > 0 ? '已记录 🎉' : '已清零')
})
}
async function addHabit() {
if (!habitName.value.trim()) return
await safe(async () => {
await request('/habits', { method: 'POST', body: JSON.stringify({ name: habitName.value.trim(), kind: habitType.value, target: habitTarget.value, schedule_type: 'daily' }) })
habitName.value = ''
await loadHabits()
emit('notice', '习惯已创建')
})
}
async function archiveHabit(h: Habit) {
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
await safe(async () => {
await request(`/habits/${h.id}`, { method: 'DELETE' })
await loadHabits()
emit('notice', '习惯已归档')
})
}
function refreshHabitDay() {
const next = dateKey(new Date())
if (next !== todayKey.value) {
todayKey.value = next
if (props.view === 'habits') void loadHabits()
}
}
async function loadHabits() {
await safe(async () => {
const data = await request(`/habits/grid?week=${dateKey(new Date())}`) as { habits?: Habit[] }
habits.value = data.habits ?? []
})
}
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); const 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') as Blob, '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 }) as { imported?: number; skipped?: number }
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(() => {
if (props.view === 'habits') {
refreshHabitDay()
void loadHabits()
dayRolloverTimer = setInterval(refreshHabitDay, 60_000)
} else {
void loadSettings()
}
})
onBeforeUnmount(() => {
if (dayRolloverTimer) clearInterval(dayRolloverTimer)
})
</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>{{calendarTasks.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.kind!=='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>
<section class="mvp-view" :class="{ loading: busy }">
<p v-if="error" class="inline-error">{{ error }}</p>
<!-- 习惯TickTick 风格一次只操作一个习惯不再逐格小按钮误触 -->
<template v-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="新习惯名称(如:喝水 8 杯)" aria-label="新习惯名称">
<select v-model="habitType" aria-label="习惯类型">
<option value="boolean">完成 / 未完成</option>
<option value="numeric">按数量记录</option>
</select>
<input v-if="habitType === 'numeric'" v-model.number="habitTarget" type="number" min="0" step="any" placeholder="目标值" aria-label="目标值">
<button class="primary-small"><Plus />添加</button>
</form>
<!-- 习惯列表展示和操作分离只点右侧明确按钮降低误触 -->
<div class="habit-list">
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey) }">
<div v-if="h.kind !== 'numeric'" class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已打卡' : '今天还没做' }}</small></span>
<button class="habit-check-button" :class="{ done: isDone(h, todayKey) }" :aria-label="isDone(h, todayKey) ? `取消${h.name}今天的打卡` : `完成${h.name}今天的打卡`" :aria-pressed="isDone(h, todayKey)" @click="toggleHabit(h, todayKey)">
<Check v-if="isDone(h, todayKey)" />
</button>
</div>
<div v-else class="habit-main numeric-habit">
<span><span class="habit-name">{{ h.name }}</span><small>今天 {{ logFor(h, todayKey)?.value || 0 }} / {{ h.target || 1 }}{{ h.unit || '' }}</small></span>
<span class="numeric-action">
<input v-model.number="numericValues[h.id]" type="number" min="0" :max="h.max_value ?? undefined" step="any" :placeholder="String(h.target || 1)" :aria-label="`${h.name}今日数值`">
<button class="soft-button" @click="recordNumeric(h, todayKey)">记录</button>
</span>
</div>
<button class="icon ghost" aria-label="归档习惯" @click="archiveHabit(h)"><Trash2 /></button>
</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>
+22 -7
View File
@@ -1,16 +1,31 @@
import { describe, expect, it } from 'vitest'
import { dateKey, isTaskView, moveDueDate } from './mvp-utils'
import { dateKey, habitWeek, isHabitComplete, isTaskView, numericHabitInputValue } 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')
it('formats a local date as YYYY-MM-DD', () => {
expect(dateKey(new Date(2026, 8, 5))).toBe('2026-09-05')
})
it('returns the monday-to-sunday week containing the requested day', () => {
expect(habitWeek(new Date(2026, 8, 5)).map(dateKey)).toEqual([
'2026-08-31', '2026-09-01', '2026-09-02', '2026-09-03', '2026-09-04', '2026-09-05', '2026-09-06',
])
})
it('identifies only task-backed views as task data loaders', () => {
expect(['tasks', 'today', 'upcoming'].filter(isTaskView)).toEqual(['tasks', 'today', 'upcoming'])
expect(['calendar', 'habits', 'settings', 'trash'].filter(isTaskView)).toEqual([])
expect(['habits', 'settings', 'trash'].filter(isTaskView)).toEqual([])
})
it('only completes numeric habits when progress reaches the target', () => {
expect(isHabitComplete('numeric', 3, 5)).toBe(false)
expect(isHabitComplete('numeric', 5, 5)).toBe(true)
expect(isHabitComplete('boolean', 1, 5)).toBe(true)
})
it('does not invent a numeric value when the input is empty', () => {
expect(numericHabitInputValue(undefined)).toBeNull()
expect(numericHabitInputValue('')).toBeNull()
expect(numericHabitInputValue(4)).toBe(4)
})
})
+11 -20
View File
@@ -5,26 +5,6 @@ export function dateKey(date: Date) {
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))
@@ -43,3 +23,14 @@ export function mergePage<T>(page: T[] | { items?: T[]; next_cursor?: string | n
export function isTaskView(view: string) {
return view === 'tasks' || view === 'today' || view === 'upcoming'
}
export function isHabitComplete(kind: string | undefined, value: number | boolean | undefined, target = 1) {
if (kind === 'numeric') return Number(value ?? 0) >= target
return Boolean(value)
}
export function numericHabitInputValue(input: number | string | undefined) {
if (input === undefined || input === '') return null
const value = Number(input)
return Number.isFinite(value) && value >= 0 ? value : null
}
+18 -2
View File
@@ -9,7 +9,23 @@ 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}.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}.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}}
.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{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}.tool-card h3{margin:0 0 4px}
.habit-row{display:flex;align-items:center;gap:8px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:5px 5px 5px 12px;box-shadow:0 3px 14px rgba(81,61,38,.05);transition:border-color .15s,box-shadow .15s}
.habit-row.done{border-color:var(--accent)}
.habit-main{flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:10px;border:0;background:transparent;padding:13px 4px;text-align:left;border-radius:10px}
.habit-name{font-size:15px;font-weight:700;color:#3c372f;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.habit-main>span:first-child{display:grid;gap:3px}.habit-main small{font-size:11px;color:var(--muted)}
.habit-row.done .habit-name{color:var(--accent)}
.habit-check-button{width:44px;height:44px;flex:0 0 44px;border-radius:50%;border:1.5px solid #cfc3b3;background:#fff;color:#fff;display:grid;place-items:center;padding:0}
.habit-check-button.done{background:var(--accent);border-color:var(--accent)}.habit-check-button svg{width:19px;height:19px;stroke-width:2.6}
.habit-row .icon.ghost{color:#b3a795;margin-left:4px}
.habit-row .icon.ghost:hover{color:var(--danger)}
.numeric-habit>span:first-child{display:grid;gap:3px}.numeric-habit small{font-size:11px;color:var(--muted)}
.numeric-action{display:flex;gap:6px;align-items:center}.numeric-action input{width:74px;border:1px solid var(--line);border-radius:8px;padding:7px;background:#fff}.numeric-action .soft-button{min-height:44px;padding:9px 12px}
.habit-row>.icon.ghost{width:44px;height:44px;flex:0 0 44px}
.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}.habit-row{padding:5px 4px 5px 8px}.habit-main{padding:10px 2px}.numeric-action input{width:62px}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
.pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin:0 0 14px}.pager button:disabled{opacity:.4;cursor:not-allowed}.pager span{color:var(--muted);font-size:13px}