feat: countdown anniversaries and birthdays like ticktick
ci / docker (push) Successful in 6m18s

This commit is contained in:
2026-09-06 15:36:42 +08:00
parent 3e20943d1e
commit afc78dba6c
17 changed files with 561 additions and 13 deletions
+2
View File
@@ -8,6 +8,8 @@
- 用户名密码登录,Cookie Session - 用户名密码登录,Cookie Session
- 文件夹、清单、任务基础 CRUD - 文件夹、清单、任务基础 CRUD
- 收集箱系统清单 - 收集箱系统清单
- 习惯打卡与倒数纪念日
- 倒数日支持倒数日、纪念日、生日,以及每周/月/年重复
- Vue 3 + PWA 应用外壳 - Vue 3 + PWA 应用外壳
- 手账生活感浅色 UI - 手账生活感浅色 UI
+18
View File
@@ -126,6 +126,24 @@ class RecurrenceException(Base):
deleted: Mapped[bool] = mapped_column(Boolean, default=False) deleted: Mapped[bool] = mapped_column(Boolean, default=False)
class Countdown(Base):
__tablename__ = "countdowns"
__table_args__ = (
Index("ix_countdowns_user_archive_date", "user_id", "archived_at", "event_date"),
)
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(200))
event_date: Mapped[date] = mapped_column(Date)
kind: Mapped[str] = mapped_column(String(16), default="countdown")
repeat_rule: Mapped[str] = mapped_column(String(16), default="none")
icon: Mapped[str] = mapped_column(String(32), default="📅")
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class Habit(Base): class Habit(Base):
__tablename__ = "habits" __tablename__ = "habits"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
+208 -1
View File
@@ -1,9 +1,11 @@
import calendar
import csv import csv
import io import io
import re import re
from datetime import UTC, date, datetime, time, timedelta from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -17,6 +19,7 @@ from .db import get_db
from .models import ( from .models import (
Attachment, Attachment,
AuditLog, AuditLog,
Countdown,
Folder, Folder,
Habit, Habit,
HabitLog, HabitLog,
@@ -251,6 +254,175 @@ async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", patte
def _clamped_date(year: int, month: int, day: int) -> date:
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
def countdown_occurrence(event_date: date, repeat_rule: str, today: date) -> date:
"""Return the first date-only occurrence on or after today."""
if repeat_rule == "none" or event_date >= today:
return event_date
if repeat_rule == "weekly":
days = (event_date.weekday() - today.weekday()) % 7
return today + timedelta(days=days)
if repeat_rule == "monthly":
candidate = _clamped_date(today.year, today.month, event_date.day)
if candidate < today:
year = today.year + (today.month == 12)
month = 1 if today.month == 12 else today.month + 1
candidate = _clamped_date(year, month, event_date.day)
return candidate
candidate = _clamped_date(today.year, event_date.month, event_date.day)
if candidate < today:
candidate = _clamped_date(today.year + 1, event_date.month, event_date.day)
return candidate
def countdown_status(display_date: date, today: date) -> tuple[int, str]:
days = (display_date - today).days
if days > 0:
return days, f"还有 {days}"
if days == 0:
return 0, "就是今天"
return days, f"已经 {-days}"
class CountdownInput(BaseModel):
title: str = Field(min_length=1, max_length=200)
event_date: date
kind: str = Field("countdown", pattern="^(countdown|anniversary|birthday)$")
repeat_rule: str = Field("none", pattern="^(none|weekly|monthly|yearly)$")
icon: str = Field("📅", min_length=1, max_length=32)
pinned: bool = False
class CountdownUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=200)
event_date: date | None = None
kind: str | None = Field(None, pattern="^(countdown|anniversary|birthday)$")
repeat_rule: str | None = Field(None, pattern="^(none|weekly|monthly|yearly)$")
icon: str | None = Field(None, min_length=1, max_length=32)
@model_validator(mode="after")
def reject_explicit_nulls(self):
for field in self.model_fields_set:
if getattr(self, field) is None:
raise ValueError(f"{field} cannot be null")
return self
def countdown_dict(row: Countdown, today: date | None = None):
today = today or datetime.now(ZoneInfo("Asia/Shanghai")).date()
display_date = countdown_occurrence(row.event_date, row.repeat_rule, today)
days, day_text = countdown_status(display_date, today)
return {
"id": row.id,
"title": row.title,
"event_date": row.event_date,
"display_date": display_date,
"kind": row.kind,
"repeat_rule": row.repeat_rule,
"icon": row.icon,
"pinned": row.pinned,
"archived_at": row.archived_at,
"days": days,
"day_text": day_text,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
async def owned_countdown(db, user_id, countdown_id):
row = await db.scalar(select(Countdown).where(Countdown.id == countdown_id, Countdown.user_id == user_id))
if not row:
raise HTTPException(404, "倒数日不存在")
return row
@router.post("/countdowns", status_code=201)
async def create_countdown(payload: CountdownInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
if payload.pinned:
await db.execute(update(Countdown).where(Countdown.user_id == user.id).values(pinned=False))
row = Countdown(user_id=user.id, **payload.model_dump())
db.add(row)
await db.flush()
audit(db, user.id, "create", "countdown", row.id)
await db.commit()
await db.refresh(row)
return countdown_dict(row)
@router.get("/countdowns")
async def list_countdowns(archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
condition = Countdown.archived_at.is_not(None) if archived else Countdown.archived_at.is_(None)
rows = (await db.scalars(
select(Countdown)
.where(Countdown.user_id == user.id, condition)
.order_by(Countdown.pinned.desc(), Countdown.event_date, Countdown.created_at)
)).all()
result = [countdown_dict(row) for row in rows]
return sorted(result, key=lambda item: (not item["pinned"], item["display_date"], item["created_at"]))
@router.patch("/countdowns/{countdown_id}")
async def edit_countdown(countdown_id: UUID, payload: CountdownUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_countdown(db, user.id, countdown_id)
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(row, key, value)
row.updated_at = utcnow()
audit(db, user.id, "update", "countdown", row.id)
await db.commit()
await db.refresh(row)
return countdown_dict(row)
@router.post("/countdowns/{countdown_id}/pin")
async def pin_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_countdown(db, user.id, countdown_id)
if row.archived_at is not None:
raise HTTPException(409, "已归档倒数日不能置顶")
await db.execute(update(Countdown).where(Countdown.user_id == user.id, Countdown.id != row.id).values(pinned=False))
row.pinned = True
row.updated_at = utcnow()
await db.commit()
await db.refresh(row)
return countdown_dict(row)
@router.delete("/countdowns/{countdown_id}", status_code=204)
async def archive_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_countdown(db, user.id, countdown_id)
if row.archived_at is None:
row.archived_at = utcnow()
row.pinned = False
audit(db, user.id, "archive", "countdown", row.id)
await db.commit()
return Response(status_code=204)
@router.post("/countdowns/{countdown_id}/restore")
async def restore_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_countdown(db, user.id, countdown_id)
if row.archived_at is None:
raise HTTPException(409, "倒数日未归档")
row.archived_at = None
row.updated_at = utcnow()
audit(db, user.id, "restore", "countdown", row.id)
await db.commit()
await db.refresh(row)
return countdown_dict(row)
@router.delete("/countdowns/{countdown_id}/purge", status_code=204)
async def purge_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_countdown(db, user.id, countdown_id)
if row.archived_at is None:
raise HTTPException(409, "请先归档再永久删除")
await db.delete(row)
await db.commit()
return Response(status_code=204)
class HabitCreate(BaseModel): class HabitCreate(BaseModel):
name: str = Field(min_length=1, max_length=200) name: str = Field(min_length=1, max_length=200)
kind: str = Field("boolean", pattern="^(boolean|numeric)$") kind: str = Field("boolean", pattern="^(boolean|numeric)$")
@@ -492,7 +664,7 @@ async def import_ticktick(file: UploadFile = File(...), user: User = Depends(cur
async def export_json(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def export_json(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
def serialize(row, fields): def serialize(row, fields):
return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields} return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields}
folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all()) folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all()); countdowns = list((await db.scalars(select(Countdown).where(Countdown.user_id == user.id))).all())
return { return {
"version": 1, "version": 1,
"exported_at": utcnow(), "exported_at": utcnow(),
@@ -500,6 +672,7 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists], "lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "external_id", "deleted_at"]) for x in tasks], "tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "external_id", "deleted_at"]) for x in tasks],
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at"]) for x in habits], "habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at"]) for x in habits],
"countdowns": [serialize(x, ["id", "title", "event_date", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
} }
@@ -508,6 +681,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
if payload.get("version") != 1: if payload.get("version") != 1:
raise HTTPException(422, "不支持的备份版本") raise HTTPException(422, "不支持的备份版本")
if mode == "replace": if mode == "replace":
await db.execute(delete(Countdown).where(Countdown.user_id == user.id))
await db.execute(delete(Task).where(Task.user_id == user.id)) await db.execute(delete(Task).where(Task.user_id == user.id))
await db.execute(delete(Habit).where(Habit.user_id == user.id)) await db.execute(delete(Habit).where(Habit.user_id == user.id))
await db.execute(delete(TaskList).where(TaskList.user_id == user.id)) await db.execute(delete(TaskList).where(TaskList.user_id == user.id))
@@ -579,6 +753,39 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None, archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None,
) )
db.add(row) db.add(row)
existing_countdown_ids = set((await db.scalars(select(Countdown.id).where(Countdown.user_id == user.id))).all())
has_pinned_countdown = bool(await db.scalar(select(Countdown.id).where(Countdown.user_id == user.id, Countdown.pinned.is_(True))))
for raw in payload.get("countdowns", []):
try:
source_id = UUID(raw["id"])
except (KeyError, TypeError, ValueError) as exc:
raise HTTPException(422, "无效的倒数日备份 ID") from exc
if mode == "merge" and source_id in existing_countdown_ids:
continue
try:
item = CountdownInput(
title=raw["title"],
event_date=date.fromisoformat(raw["event_date"]),
kind=raw.get("kind", "countdown"),
repeat_rule=raw.get("repeat_rule", "none"),
icon=raw.get("icon", "📅"),
pinned=bool(raw.get("pinned", False)) and not has_pinned_countdown,
)
except (KeyError, TypeError, ValueError) as exc:
raise HTTPException(422, "无效的倒数日备份数据") from exc
row = Countdown(
id=source_id,
user_id=user.id,
**item.model_dump(),
archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None,
created_at=datetime.fromisoformat(raw["created_at"]) if raw.get("created_at") else utcnow(),
updated_at=datetime.fromisoformat(raw["updated_at"]) if raw.get("updated_at") else utcnow(),
)
if row.archived_at is not None:
row.pinned = False
has_pinned_countdown = has_pinned_countdown or row.pinned
db.add(row)
existing_countdown_ids.add(source_id)
audit(db, user.id, "restore", "backup", count=restored, mode=mode) audit(db, user.id, "restore", "backup", count=restored, mode=mode)
await db.commit() await db.commit()
return {"restored": restored, "mode": mode} return {"restored": restored, "mode": mode}
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-ciX24dmm.js"></script> <!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-2NrQsU0j.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ZmXgd88u.css"> <link rel="stylesheet" crossorigin href="/assets/index-DQ5ZvkC6.css">
</head><body><div id="app"></div></body></html> </head><body><div id="app"></div></body></html>
+10
View File
@@ -36,6 +36,16 @@ Base path: `/api/v1`. 除初始化、登录和健康检查外均需 `dodo_sessio
批量请求字段:`task_ids``completed``list_id``due_at``soft_delete`。所有任务和目标清单在写入前完成归属校验;任一不存在则整批不修改。 批量请求字段:`task_ids``completed``list_id``due_at``soft_delete`。所有任务和目标清单在写入前完成归属校验;任一不存在则整批不修改。
## Countdowns
- `GET /countdowns?archived=false` — 查询倒数日;置顶项优先,其余按下一次发生日期排序
- `POST /countdowns` — 创建倒数日、纪念日或生日;支持 `none/weekly/monthly/yearly` 重复
- `PATCH /countdowns/{countdown_id}` — 编辑名称、日期、类型、重复与图标
- `POST /countdowns/{countdown_id}/pin` — 单一置顶,自动取消其他置顶项
- `DELETE /countdowns/{countdown_id}` — 归档
- `POST /countdowns/{countdown_id}/restore` — 恢复归档项
- `DELETE /countdowns/{countdown_id}/purge` — 永久删除已归档项
## Recycle bin ## Recycle bin
- `GET /trash?limit=&cursor=` — 已删除顶层任务的游标分页 - `GET /trash?limit=&cursor=` — 已删除顶层任务的游标分页
+14
View File
@@ -66,12 +66,26 @@
顶层任务软删除、恢复或永久删除时同步处理直接子任务。列表与回收站使用 `(created_at, id)` 作为稳定游标排序键。 顶层任务软删除、恢复或永久删除时同步处理直接子任务。列表与回收站使用 `(created_at, id)` 作为稳定游标排序键。
## countdowns
- `id`
- `user_id` → users,级联删除
- `title`
- `event_date`,仅日期
- `kind``countdown` / `anniversary` / `birthday`
- `repeat_rule``none` / `weekly` / `monthly` / `yearly`
- `icon`
- `pinned`,每个用户仅保留一个置顶项
- `archived_at`,非空表示归档
- `created_at` / `updated_at`
## 迁移 ## 迁移
- `0001_initial.py`:已部署的初始模式,不修改 - `0001_initial.py`:已部署的初始模式,不修改
- `0002_task_management.py`:新增文件夹/清单软删除列、历史标签表及游标/回收站索引 - `0002_task_management.py`:新增文件夹/清单软删除列、历史标签表及游标/回收站索引
- `0008_remove_calendar_subscriptions.py`:移除日历订阅表 - `0008_remove_calendar_subscriptions.py`:移除日历订阅表
- `0009_remove_tags.py`:移除历史标签表及任务标签关联表 - `0009_remove_tags.py`:移除历史标签表及任务标签关联表
- `0010_countdowns.py`:新增倒数日、纪念日与生日表
## 后续阶段预留 ## 后续阶段预留
+7
View File
@@ -66,6 +66,13 @@ postgresql+asyncpg://postgres:***@10.10.100.99:5433/dodo
- 支持暂停区间,暂停期不破坏连续记录 - 支持暂停区间,暂停期不破坏连续记录
- 归档后保留历史统计 - 归档后保留历史统计
### 倒数纪念日
- 支持倒数日、纪念日、生日
- 支持不重复、每周、每月、每年重复
- 未来显示“还有 N 天”,当天显示“就是今天”,过去显示“已经 N 天”
- 支持单一置顶、归档恢复、编辑和删除
## UI 方向 ## UI 方向
- 手账生活感 - 手账生活感
+7 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { import {
ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder, ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
Settings, Trash2, X, Repeat2, Ellipsis, Settings, Trash2, X, Repeat2, Ellipsis,
} from 'lucide-vue-next' } from 'lucide-vue-next'
@@ -9,11 +9,12 @@ import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTi
import { defaultView, isTaskView, quickTaskFields, shouldToggleRowSwipe } from './lib/mvp-utils' import { defaultView, isTaskView, quickTaskFields, shouldToggleRowSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
import MvpPanel from './MvpPanel.vue' import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
type FolderItem = { id: string; name: string } type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean } type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; subtasks?: Task[] } type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; subtasks?: Task[] }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'settings' type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
const initialized = ref<boolean | null>(null) const initialized = ref<boolean | null>(null)
const authReady = ref(false) const authReady = ref(false)
@@ -79,6 +80,7 @@ const activeName = computed(() => {
if (activeView.value === 'today') return '今天' if (activeView.value === 'today') return '今天'
if (activeView.value === 'upcoming') return '最近 7 天' if (activeView.value === 'upcoming') return '最近 7 天'
if (activeView.value === 'habits') return '习惯' if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'countdowns') return '倒数日'
if (activeView.value === 'settings') return '设置与数据' if (activeView.value === 'settings') return '设置与数据'
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱' return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
}) })
@@ -406,6 +408,7 @@ onMounted(bootstrap)
<nav class="primary-nav"> <nav class="primary-nav">
<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==='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==='today' }" @click="switchView('today')"><ListTodo />今天</button>
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button> <button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button>
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button> <button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button> <button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
@@ -434,6 +437,7 @@ onMounted(bootstrap)
<template v-if="['habits','settings'].includes(activeView)"> <template v-if="['habits','settings'].includes(activeView)">
<MvpPanel :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" /> <MvpPanel :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
</template> </template>
<CountdownPanel v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else> <template v-else>
<template v-if="activeView==='today'"> <template v-if="activeView==='today'">
<h3 class="section-heading"><ListTodo/>今日任务</h3> <h3 class="section-heading"><ListTodo/>今日任务</h3>
@@ -480,7 +484,7 @@ onMounted(bootstrap)
</aside> </aside>
<div v-if="mobileMore" class="more-mask" @click.self="mobileMore=false"><section id="mobile-more-menu" class="more-sheet" role="dialog" aria-modal="true" aria-label="更多导航"><div class="more-sheet-head"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><button @click="switchView('settings')"><Settings/>设置与数据</button></section></div> <div v-if="mobileMore" class="more-mask" @click.self="mobileMore=false"><section id="mobile-more-menu" class="more-sheet" role="dialog" aria-modal="true" aria-label="更多导航"><div class="more-sheet-head"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><button @click="switchView('settings')"><Settings/>设置与数据</button></section></div>
<nav class="bottom three-items" aria-label="主要导航"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:mobileMore||['tasks','upcoming','trash','settings'].includes(activeView)}" aria-haspopup="dialog" aria-controls="mobile-more-menu" :aria-expanded="mobileMore" @click="mobileMore=!mobileMore"><Ellipsis/><span>更多</span></button></nav> <nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:mobileMore||['tasks','upcoming','trash','settings'].includes(activeView)}" aria-haspopup="dialog" aria-controls="mobile-more-menu" :aria-expanded="mobileMore" @click="mobileMore=!mobileMore"><Ellipsis/><span>更多</span></button></nav>
<button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button> <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> <Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div> <div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Archive, ArchiveRestore, CalendarHeart, Pencil, Pin, Plus, Trash2, X } from 'lucide-vue-next'
import { csrfHeader } from './lib/csrf'
import { countdownDayText, countdownKindLabel, dateKey } from './lib/mvp-utils'
type Countdown = {
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
repeat_rule: 'none'|'weekly'|'monthly'|'yearly'; icon: string; pinned: boolean; archived_at: string|null; days: number
}
const emit = defineEmits<{ notice: [message: string] }>()
const items = ref<Countdown[]>([])
const archived = ref<Countdown[]>([])
const showArchived = ref(false)
const open = ref(false)
const editingId = ref<string|null>(null)
const busy = ref(false)
const error = ref('')
const form = ref({ title: '', event_date: dateKey(new Date()), kind: 'countdown' as Countdown['kind'], repeat_rule: 'none' as Countdown['repeat_rule'], icon: '📅' })
async function request(path: string, options: RequestInit = {}) {
const headers: Record<string,string> = { ...(options.headers as Record<string,string> || {}) }
if (options.body) headers['Content-Type'] = 'application/json'
Object.assign(headers, csrfHeader(options.method))
const response = await fetch('/api/v1' + path, { credentials: 'include', ...options, headers })
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.detail || '请求失败') }
return response.status === 204 ? null : response.json()
}
async function safe(work: () => Promise<void>) {
busy.value = true; error.value = ''
try { await work() } catch (reason) { error.value = reason instanceof Error ? reason.message : '请求失败' } finally { busy.value = false }
}
async function load() {
await safe(async () => {
const [activeRows, archivedRows] = await Promise.all([request('/countdowns'), request('/countdowns?archived=true')])
items.value = activeRows; archived.value = archivedRows
})
}
function add() {
editingId.value = null
form.value = { title: '', event_date: dateKey(new Date()), kind: 'countdown', repeat_rule: 'none', icon: '📅' }
open.value = true
}
function edit(item: Countdown) {
editingId.value = item.id
form.value = { title: item.title, event_date: item.event_date, kind: item.kind, repeat_rule: item.repeat_rule, icon: item.icon }
open.value = true
}
async function save() {
if (!form.value.title.trim()) return
await safe(async () => {
const path = editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
await request(path, { method: editingId.value ? 'PATCH' : 'POST', body: JSON.stringify({ ...form.value, title: form.value.title.trim() }) })
open.value = false; await load(); emit('notice', editingId.value ? '倒数日已更新' : '倒数日已添加')
})
}
async function pin(item: Countdown) { await safe(async () => { await request(`/countdowns/${item.id}/pin`, { method: 'POST' }); await load(); emit('notice', '已置顶') }) }
async function archiveItem(item: Countdown) { await safe(async () => { await request(`/countdowns/${item.id}`, { method: 'DELETE' }); await load(); emit('notice', '已归档') }) }
async function restore(item: Countdown) { await safe(async () => { await request(`/countdowns/${item.id}/restore`, { method: 'POST' }); await load(); emit('notice', '已恢复') }) }
async function purge(item: Countdown) {
if (!confirm(`永久删除“${item.title}”?这个操作不能撤销。`)) return
await safe(async () => { await request(`/countdowns/${item.id}/purge`, { method: 'DELETE' }); await load(); emit('notice', '已永久删除') })
}
function formatDate(value: string) {
const [year, month, day] = value.split('-')
return `${year}${Number(month)}${Number(day)}`
}
function repeatLabel(value: Countdown['repeat_rule']) { return ({ none: '不重复', weekly: '每周', monthly: '每月', yearly: '每年' })[value] }
onMounted(load)
</script>
<template>
<section class="countdown-view" :class="{ loading: busy }">
<header class="countdown-hero">
<div><small>记住值得期待与纪念的日子</small><h2>倒数日</h2></div>
<button class="countdown-add" @click="add"><Plus/>添加日子</button>
</header>
<p v-if="error" class="inline-error">{{error}}</p>
<div class="countdown-grid">
<article v-for="item in items" :key="item.id" class="countdown-card" :class="{ pinned:item.pinned }">
<div class="countdown-card-head"><span class="countdown-icon">{{item.icon}}</span><span class="countdown-type">{{countdownKindLabel(item.kind)}}</span><Pin v-if="item.pinned" class="pinned-icon"/></div>
<h3>{{item.title}}</h3>
<div class="countdown-number"><strong>{{Math.abs(item.days)}}</strong><span v-if="item.days!==0"></span></div>
<b class="countdown-copy">{{countdownDayText(item.days)}}</b>
<p>{{formatDate(item.display_date)}}<span v-if="item.repeat_rule!=='none'"> · {{repeatLabel(item.repeat_rule)}}</span></p>
<div class="countdown-actions"><button v-if="!item.pinned" @click="pin(item)"><Pin/>置顶</button><button @click="edit(item)"><Pencil/>编辑</button><button @click="archiveItem(item)"><Archive/>归档</button></div>
</article>
<button v-if="!items.length&&!busy" class="countdown-empty" @click="add"><CalendarHeart/><b>添加第一个重要日子</b><span>生日纪念日或一场期待已久的旅行</span></button>
</div>
<button v-if="archived.length" class="archived-toggle" @click="showArchived=!showArchived"><ArchiveRestore/>已归档{{archived.length}}</button>
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><span>{{item.icon}}</span><b>{{item.title}}</b><small>{{formatDate(item.event_date)}}</small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
<div v-if="open" class="countdown-modal-mask" @click.self="open=false">
<form class="countdown-modal" role="dialog" aria-modal="true" @submit.prevent="save">
<header><div><small>{{editingId?'调整重要日子':'记下重要日子'}}</small><h3>{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="open=false"><X/></button></header>
<label>图标与名称<div class="countdown-title-fields"><input v-model="form.icon" maxlength="8" aria-label="图标"><input v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></div></label>
<label>日期<input v-model="form.event_date" type="date" required></label>
<label>类型<select v-model="form.kind"><option value="countdown">倒数日</option><option value="anniversary">纪念日</option><option value="birthday">生日</option></select></label>
<label>重复<select v-model="form.repeat_rule"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
<footer><button type="button" class="secondary" @click="open=false">取消</button><button class="primary-small">保存</button></footer>
</form>
</div>
</section>
</template>
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, shouldToggleRowSwipe } from './mvp-utils' import { countdownDayText, countdownKindLabel, dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, shouldToggleRowSwipe } from './mvp-utils'
describe('MVP view utilities', () => { describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => { it('formats a local date as YYYY-MM-DD', () => {
@@ -63,6 +63,15 @@ describe('MVP view utilities', () => {
expect(previousHabitSwipeValue('numeric', 0)).toBe(0) expect(previousHabitSwipeValue('numeric', 0)).toBe(0)
}) })
it('renders countdown labels from date-only day values', () => {
expect(countdownDayText(8)).toBe('还有 8 天')
expect(countdownDayText(0)).toBe('就是今天')
expect(countdownDayText(-12)).toBe('已经 12 天')
expect(countdownKindLabel('countdown')).toBe('倒数日')
expect(countdownKindLabel('anniversary')).toBe('纪念日')
expect(countdownKindLabel('birthday')).toBe('生日')
})
it('toggles a row for a deliberate mostly-horizontal swipe', () => { it('toggles a row for a deliberate mostly-horizontal swipe', () => {
expect(shouldToggleRowSwipe(78, 8)).toBe(true) expect(shouldToggleRowSwipe(78, 8)).toBe(true)
expect(shouldToggleRowSwipe(88, 28)).toBe(true) expect(shouldToggleRowSwipe(88, 28)).toBe(true)
+10
View File
@@ -76,6 +76,16 @@ export function numericHabitInputValue(input: number | string | undefined) {
return Number.isFinite(value) && value >= 0 ? value : null return Number.isFinite(value) && value >= 0 ? value : null
} }
export function countdownDayText(days: number) {
if (days > 0) return `还有 ${days}`
if (days === 0) return '就是今天'
return `已经 ${Math.abs(days)}`
}
export function countdownKindLabel(kind: string) {
return ({ countdown: '倒数日', anniversary: '纪念日', birthday: '生日' } as Record<string, string>)[kind] ?? '倒数日'
}
export function shouldToggleRowSwipe(deltaX: number, deltaY: number) { export function shouldToggleRowSwipe(deltaX: number, deltaY: number) {
return deltaX >= 64 && deltaX > Math.abs(deltaY) * 1.5 return deltaX >= 64 && deltaX > Math.abs(deltaY) * 1.5
} }
File diff suppressed because one or more lines are too long
+44
View File
@@ -0,0 +1,44 @@
"""add countdowns
Revision ID: 0010
Revises: 0009
"""
import sqlalchemy as sa
from alembic import op
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"countdowns",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("event_date", sa.Date(), nullable=False),
sa.Column("kind", sa.String(16), nullable=False),
sa.Column("repeat_rule", sa.String(16), nullable=False),
sa.Column("icon", sa.String(32), nullable=False),
sa.Column("pinned", sa.Boolean(), nullable=False),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_countdowns_user_id", "countdowns", ["user_id"])
op.create_index(
"ix_countdowns_user_archive_date",
"countdowns",
["user_id", "archived_at", "event_date"],
)
def downgrade() -> None:
op.drop_index("ix_countdowns_user_archive_date", table_name="countdowns")
op.drop_index("ix_countdowns_user_id", table_name="countdowns")
op.drop_table("countdowns")
+119
View File
@@ -0,0 +1,119 @@
from datetime import date
from backend.mvp import countdown_occurrence, countdown_status
def boot(client):
response = client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
assert response.status_code == 201
def create_countdown(client, **overrides):
payload = {
"title": "旅行",
"event_date": "2026-09-10",
"kind": "countdown",
"repeat_rule": "none",
"icon": "🧳",
}
payload.update(overrides)
return client.post("/api/v1/countdowns", json=payload)
def test_countdown_date_math_uses_date_only_recurrence_semantics():
assert countdown_status(date(2026, 9, 10), date(2026, 9, 6)) == (4, "还有 4 天")
assert countdown_status(date(2026, 9, 6), date(2026, 9, 6)) == (0, "就是今天")
assert countdown_status(date(2026, 9, 1), date(2026, 9, 6)) == (-5, "已经 5 天")
assert countdown_occurrence(date(2026, 8, 31), "monthly", date(2026, 9, 6)) == date(2026, 9, 30)
assert countdown_occurrence(date(2024, 2, 29), "yearly", date(2026, 2, 1)) == date(2026, 2, 28)
assert countdown_occurrence(date(2026, 9, 1), "weekly", date(2026, 9, 8)) == date(2026, 9, 8)
def test_countdown_crud_single_pin_archive_restore_and_purge(client):
boot(client)
first = create_countdown(client).json()
second = create_countdown(
client,
title="生日",
event_date="1990-02-28",
kind="birthday",
repeat_rule="yearly",
icon="🎂",
).json()
assert client.post(f"/api/v1/countdowns/{first['id']}/pin").status_code == 200
assert client.post(f"/api/v1/countdowns/{second['id']}/pin").status_code == 200
active = client.get("/api/v1/countdowns").json()
assert [item["title"] for item in active] == ["生日", "旅行"]
assert [item["pinned"] for item in active] == [True, False]
updated = client.patch(
f"/api/v1/countdowns/{first['id']}",
json={"title": "海边旅行", "event_date": "2026-09-12", "kind": "anniversary", "repeat_rule": "none", "icon": "🌊"},
)
assert updated.status_code == 200
assert updated.json()["title"] == "海边旅行"
assert client.delete(f"/api/v1/countdowns/{first['id']}").status_code == 204
assert [item["id"] for item in client.get("/api/v1/countdowns", params={"archived": True}).json()] == [first["id"]]
assert client.delete(f"/api/v1/countdowns/{second['id']}/purge").status_code == 409
assert client.post(f"/api/v1/countdowns/{first['id']}/restore").status_code == 200
assert client.delete(f"/api/v1/countdowns/{first['id']}").status_code == 204
assert client.delete(f"/api/v1/countdowns/{first['id']}/purge").status_code == 204
def test_countdown_validation_and_user_isolation(client):
boot(client)
item = create_countdown(client).json()
assert create_countdown(client, kind="festival").status_code == 422
assert create_countdown(client, repeat_rule="daily").status_code == 422
client.post("/api/v1/auth/logout")
from backend.auth import hash_password
from backend.db import get_db
from backend.models import TaskList, User
async def add_other_user():
db_gen = get_db()
db = await anext(db_gen)
try:
other = User(username="other", password_hash=hash_password("correct horse battery staple"))
db.add(other)
await db.flush()
db.add(TaskList(user_id=other.id, name="收集箱", is_inbox=True))
await db.commit()
finally:
await db_gen.aclose()
client.portal.call(add_other_user)
assert client.post("/api/v1/auth/login", json={"username": "other", "password": "correct horse battery staple"}).status_code == 200
assert client.get("/api/v1/countdowns").json() == []
assert client.patch(f"/api/v1/countdowns/{item['id']}", json={"title": "偷改"}).status_code == 404
def test_countdowns_backup_replace_and_merge_round_trip(client):
boot(client)
active = create_countdown(client, title="周年", kind="anniversary", repeat_rule="yearly", pinned=True).json()
archived = create_countdown(client, title="旧日", event_date="2020-01-02").json()
client.delete(f"/api/v1/countdowns/{archived['id']}")
exported = client.get("/api/v1/export").json()
assert {item["title"] for item in exported["countdowns"]} == {"周年", "旧日"}
assert next(item for item in exported["countdowns"] if item["title"] == "周年")["pinned"] is True
assert next(item for item in exported["countdowns"] if item["title"] == "旧日")["archived_at"]
create_countdown(client, title="干扰数据")
restored = client.post("/api/v1/restore", params={"mode": "replace"}, json=exported)
assert restored.status_code == 200
assert {item["title"] for item in client.get("/api/v1/countdowns").json()} == {"周年"}
assert {item["title"] for item in client.get("/api/v1/countdowns", params={"archived": True}).json()} == {"旧日"}
merged = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported)
assert merged.status_code == 200
all_active = client.get("/api/v1/countdowns").json()
assert [item["title"] for item in all_active].count("周年") == 1
assert sum(item["pinned"] for item in all_active) == 1
assert active["title"] == "周年"