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
+2
View File
@@ -806,6 +806,8 @@ if static_dir.exists():
@app.get("/{path:path}", include_in_schema=False)
async def spa(path: str):
if path == "api" or path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not Found")
root = static_dir.resolve()
target = (root / path).resolve()
headers = {"Cache-Control": "no-cache, no-store, must-revalidate, max-age=0"}
+3 -122
View File
@@ -4,7 +4,6 @@ import re
from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse
@@ -127,7 +126,6 @@ def occurrences(rule: str, starts: datetime, start: datetime, end: datetime, cut
def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
"""Return True when `at` is the exact timestamp of an occurrence this rule generates."""
parts = parse_rrule(rule)
interval = int(parts.get("INTERVAL", 1))
if starts.tzinfo is None:
starts = starts.replace(tzinfo=UTC)
else:
@@ -143,38 +141,9 @@ def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
until = until.replace(tzinfo=UTC) if until.tzinfo is None else until.astimezone(UTC)
if at > until:
return False
if "COUNT" in parts:
count = int(parts["COUNT"])
match = 0
cursor = starts
guard = 0
while cursor <= at and guard < 40000:
if parts["FREQ"] == "DAILY":
include = (cursor.date() - starts.date()).days % interval == 0
elif parts["FREQ"] == "WEEKLY":
days = {_WEEKDAYS[x] for x in parts.get("BYDAY", list(_WEEKDAYS)[starts.weekday()]).split(",")}
include = cursor.weekday() in days and ((cursor.date() - starts.date()).days // 7) % interval == 0
elif parts["FREQ"] == "MONTHLY":
month_delta = (cursor.year - starts.year) * 12 + cursor.month - starts.month
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
include = month_delta % interval == 0 and cursor.day in month_days
else:
years = cursor.year - starts.year
months = {int(x) for x in parts.get("BYMONTH", str(starts.month)).split(",")}
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
include = years % interval == 0 and cursor.month in months and cursor.day in month_days
if include and cursor >= starts:
match += 1
if cursor == at:
return True
if match >= count:
return False
guard += 1
cursor += timedelta(days=1)
return False
# Exact-match validation via the same canonical day generator used for
# rendering, so time-of-day is preserved and nothing outside the rule is
# accepted as a valid occurrence.
# Exact-match validation via the same canonical generator used for recurrence
# mutations, so COUNT/UNTIL, time-of-day, BYDAY/BYMONTHDAY and sparse yearly
# rules all share one behavior.
candidates = occurrences(rule, starts, starts, at, None)
return at in candidates
@@ -222,94 +191,6 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at}
@router.get("/calendar")
async def calendar(start: date, end: date, timezone: str = Query(default="UTC", pattern=r"^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$"), timezone_offset: int | None = Query(default=None, ge=-840, le=840), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
if end < start or (end - start).days > 366:
raise HTTPException(422, "日期范围无效或超过一年")
try:
zone = ZoneInfo(timezone)
except ZoneInfoNotFoundError:
raise HTTPException(422, "未知时区")
if timezone_offset is not None and timezone == "UTC":
# Legacy clients pass an offset instead of an IANA name.
zone = ZoneInfo("UTC")
start_dt = datetime.combine(start, time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset)
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset) - timedelta(microseconds=1)
else:
start_dt = datetime.combine(start, time.min, tzinfo=zone).astimezone(UTC)
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=zone).astimezone(UTC) - timedelta(microseconds=1)
def as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
recurrence_rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all()
template_ids = [template.id for template, _ in recurrence_rows]
exception_rows = [] if not template_ids else list((await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id.in_(template_ids)))).all())
exceptions_by_template = {}
for exception in exception_rows:
key = as_utc(exception.occurrence_at)
exceptions_by_template.setdefault(exception.template_id, {})[key] = exception
recurring_task_ids = {task.id for _, task in recurrence_rows}
normal_query = select(Task).where(
Task.user_id == user.id,
Task.deleted_at.is_(None),
Task.parent_id.is_(None),
Task.due_at >= start_dt,
Task.due_at <= end_dt,
)
if recurring_task_ids:
normal_query = normal_query.where(Task.id.not_in(recurring_task_ids))
normal_tasks = list((await db.scalars(normal_query)).all())
output = [{
"id": task.id,
"recurrence_id": None,
"task_id": task.id,
"occurrence_at": task.due_at,
"title": task.title,
"due_at": task.due_at,
"completed": task.completed,
"version": task.version,
} for task in normal_tasks]
emitted = set()
for template, task in recurrence_rows:
exceptions = exceptions_by_template.get(template.id, {})
# Canonical series from the template's true start so every exception
# whose occurrence is real and inside this series is considered.
series_start = template.starts_at
if exceptions:
first_exception = min(as_utc(at) for at in exceptions)
series_start = min(as_utc(series_start), first_exception)
generated = occurrences(template.rrule, template.starts_at, as_utc(series_start), end_dt, template.ends_at)
for at in generated:
exception = exceptions.get(as_utc(at))
if exception and exception.deleted:
continue
due_at = as_utc(exception.due_at) if exception and exception.due_at else at
if due_at < start_dt or due_at > end_dt:
continue
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": at, "title": exception.title if exception and exception.title else task.title, "due_at": due_at, "completed": bool(exception and exception.completed)})
emitted.add(as_utc(at))
# Fall back for stored exceptions whose original slot is real but which
# the canonical generation skipped only because their original date is
# outside the requested window (e.g. moved backwards across the month).
for occurrence_at, exception in exceptions.items():
if exception.deleted or exception.due_at is None:
continue
if occurrence_at in emitted:
continue
if not is_occurrence(template.rrule, template.starts_at, occurrence_at):
continue
if template.ends_at and occurrence_at > as_utc(template.ends_at):
continue
due_at = as_utc(exception.due_at)
if start_dt <= due_at <= end_dt:
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": occurrence_at, "title": exception.title or task.title, "due_at": due_at, "completed": bool(exception.completed)})
return sorted(output, key=lambda item: item["occurrence_at"].replace(tzinfo=UTC) if item["occurrence_at"].tzinfo is None else item["occurrence_at"])
async def upsert_exception(db, template_id, at):
row = await db.scalar(select(RecurrenceException).where(RecurrenceException.template_id == template_id, RecurrenceException.occurrence_at == at))
if not row:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-Dq8LoBCn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CpBTIN38.css">
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-CmIuUToo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nhCaMkMR.css">
</head><body><div id="app"></div></body></html>
+8 -3
View File
@@ -1,5 +1,5 @@
const CACHE = 'dodo-shell-v2'
const SHELL = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
const CACHE = 'dodo-shell-v3'
const SHELL = ['/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
self.addEventListener('install', (event) => {
self.skipWaiting()
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)))
@@ -11,11 +11,16 @@ self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (url.pathname.startsWith('/api/')) return
if (event.request.method !== 'GET') return
const isNavigation = event.request.mode === 'navigate'
if (isNavigation) {
event.respondWith(fetch(event.request).catch(() => caches.match('/offline.html')))
return
}
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request).then((response) => {
const copy = response.clone()
caches.open(CACHE).then((cache) => cache.put(event.request, copy))
return response
}).catch(() => caches.match('/'))),
})),
)
})
+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}
+7 -195
View File
@@ -29,160 +29,12 @@ def test_bootstrap_returns_navigation_and_current_user(client):
assert data["inbox_id"] == inbox["id"]
def test_recurring_calendar_exceptions_and_scopes(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "站会", "list_id": inbox["id"], "due_at": "2026-09-01T09:00:00Z"},
).json()
normal_task = client.post(
"/api/v1/tasks",
json={"title": "月内普通任务", "list_id": inbox["id"], "due_at": "2026-09-12T09:00:00Z"},
).json()
client.post(
"/api/v1/tasks",
json={"title": "月外普通任务", "list_id": inbox["id"], "due_at": "2026-10-02T09:00:00Z"},
)
recurrence = client.post(
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=TU,TH;COUNT=5"}
)
assert recurrence.status_code == 201
recurrence_id = recurrence.json()["id"]
yearly = client.post(
"/api/v1/tasks",
json={"title": "年度任务", "list_id": inbox["id"], "due_at": "2026-09-06T09:00:00Z"},
).json()
assert client.post(
"/api/v1/recurrences", json={"task_id": yearly["id"], "rrule": "FREQ=YEARLY;INTERVAL=1;BYMONTH=9;BYMONTHDAY=6"}
).status_code == 201
calendar = client.get(
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
).json()
assert any(row.get("id") == normal_task["id"] for row in calendar)
assert any(row["title"] == "年度任务" for row in calendar)
assert not any(row["title"] == "月外普通任务" for row in calendar)
occurrences = [row for row in calendar if row["recurrence_id"] == recurrence_id]
assert len(occurrences) == 5
assert occurrences[0]["title"] == "站会"
at = occurrences[1]["occurrence_at"]
edited = client.patch(
f"/api/v1/recurrences/{recurrence_id}",
params={"scope": "this", "occurrence_at": at},
json={"title": "特殊站会"},
)
assert edited.status_code == 200
client.post(f"/api/v1/recurrences/{recurrence_id}/complete", json={"occurrence_at": at})
changed = client.get(
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
).json()
exception = next(row for row in changed if row.get("occurrence_at") == at)
assert exception["title"] == "特殊站会" and exception["completed"] is True
assert client.delete(
f"/api/v1/recurrences/{recurrence_id}",
params={"scope": "this", "occurrence_at": occurrences[2]["occurrence_at"]},
).status_code == 204
remaining = client.get(
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
).json()
assert len([row for row in remaining if row["recurrence_id"] == recurrence_id]) == 4
assert any(row.get("id") == normal_task["id"] for row in remaining)
def test_calendar_respects_timezone_boundaries_and_moved_occurrences(client):
inbox = boot(client)
inside = client.post(
"/api/v1/tasks",
json={"title": "上海九月第一刻", "list_id": inbox["id"], "due_at": "2026-08-31T16:30:00Z"},
).json()
client.post(
"/api/v1/tasks",
json={"title": "上海十月第一刻", "list_id": inbox["id"], "due_at": "2026-09-30T16:30:00Z"},
)
recurring = client.post(
"/api/v1/tasks",
json={"title": "跨月重复任务", "list_id": inbox["id"], "due_at": "2026-10-01T09:00:00Z"},
).json()
recurrence = client.post(
"/api/v1/recurrences", json={"task_id": recurring["id"], "rrule": "FREQ=WEEKLY;COUNT=2"}
).json()
client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-10-01T09:00:00Z"},
json={"due_at": "2026-09-20T09:00:00Z"},
)
calendar = client.get(
"/api/v1/calendar",
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "Asia/Shanghai"},
).json()
assert any(row.get("id") == inside["id"] for row in calendar)
assert not any(row["title"] == "上海十月第一刻" for row in calendar)
assert any(row.get("recurrence_id") == recurrence["id"] and row["due_at"].startswith("2026-09-20") for row in calendar)
def test_calendar_rejects_unknown_timezone(client):
def test_calendar_endpoint_is_removed(client):
boot(client)
response = client.get("/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "Mars/Olympus"})
assert response.status_code == 422
assert client.get("/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}).status_code == 404
def test_recurrence_mutations_reject_ghost_occurrences(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "每周任务", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z"},
).json()
recurrence = client.post(
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;COUNT=3"}
).json()
# 9/7 and 9/14 are real occurrences; 9/9 is NOT part of this series.
ghost_patch = client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-09-09T09:00:00Z"},
json={"due_at": "2026-09-20T09:00:00Z"},
)
assert ghost_patch.status_code == 422
calendar = client.get(
"/api/v1/calendar",
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
).json()
assert not any(row.get("recurrence_id") == recurrence["id"] and row["due_at"].startswith("2026-09-20") for row in calendar)
assert len([row for row in calendar if row.get("recurrence_id") == recurrence["id"]]) == 3
def test_calendar_does_not_duplicate_moved_exceptions_when_both_in_range(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "每周一", "list_id": inbox["id"], "due_at": "2026-09-14T09:00:00Z"},
).json()
recurrence = client.post(
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=4"}
).json()
# Series: 9/14, 9/21, 9/28, 10/5. Move the 9/21 occurrence to 9/23.
client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-09-21T09:00:00Z"},
json={"due_at": "2026-09-23T09:00:00Z"},
)
calendar = client.get(
"/api/v1/calendar",
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
).json()
matches = [row for row in calendar if row.get("recurrence_id") == recurrence["id"]]
keys = [(row["occurrence_at"], row["due_at"]) for row in matches]
assert len(keys) == len(set(keys))
assert len(matches) == 3
due_days = [row["due_at"][:10] for row in matches]
assert due_days == ["2026-09-14", "2026-09-23", "2026-09-28"]
assert not any(row["due_at"].startswith("2026-09-21") for row in matches)
def test_recurrence_rejects_wrong_time_of_day_and_alternate_offset(client):
def test_recurrence_mutations_keep_exact_timestamp_validation(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
@@ -216,56 +68,16 @@ def test_recurrence_rejects_occurrence_after_cutoff(client):
recurrence = client.post(
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=DAILY;COUNT=10"}
).json()
# Cut the series off at 9/3 (ends_at = 9/3T08:59:59.999999Z)
client.patch(
assert client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this-and-future", "occurrence_at": "2026-09-03T09:00:00Z"},
json={},
)
after_cutoff = client.patch(
).status_code == 200
assert client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-09-05T09:00:00Z"},
json={"title": "晚于截止"},
)
assert after_cutoff.status_code == 422
def test_calendar_accepts_legacy_timezone_offset(client):
inbox = boot(client)
client.post(
"/api/v1/tasks",
json={"title": "上海边界任务", "list_id": inbox["id"], "due_at": "2026-09-01T16:00:00Z"},
)
calendar = client.get(
"/api/v1/calendar",
params={"start": "2026-09-01", "end": "2026-09-30", "timezone_offset": 480},
).json()
assert any(row["title"] == "上海边界任务" for row in calendar)
def test_calendar_does_not_duplicate_moved_exception_when_original_and_moved_in_range(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "每周一", "list_id": inbox["id"], "due_at": "2026-09-14T09:00:00Z"},
).json()
recurrence = client.post(
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=3"}
).json()
# Series: 9/14, 9/21, 9/28 — all inside the requested month.
# Move the 9/21 occurrence to 9/23 (also inside). Regression: this used to render twice.
client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-09-21T09:00:00Z"},
json={"due_at": "2026-09-23T09:00:00Z"},
)
calendar = client.get(
"/api/v1/calendar",
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
).json()
matches = [row for row in calendar if row.get("recurrence_id") == recurrence["id"]]
due_days = sorted(row["due_at"][:10] for row in matches)
assert due_days == ["2026-09-14", "2026-09-23", "2026-09-28"]
).status_code == 422
def test_habit_logs_support_date_range_filter(client):