feat: complete frozen MVP — period-aware stats, layout-driven UI, quick checkin

- Fix first daily record 422 (expectedVersion now accepts 0)
- Add period_window + summarize; /api/summary and /api/tasks/{id}/summary
  aggregate each metric inside its own day/week/month/lifetime window,
  reading raw facts only (never the rebuildable month archives)
- Drive frontend rendering from per-page layout JSON with a component
  registry; scope/cardinality enforced server-side, invalid layouts fall back
- Add quick checkin and duration input in H:M:S that round-trips integer seconds
- Fix heatmap/completedAt timezone drift to Asia/Shanghai
- Add server-side session TTL (30d) matching the login cookie
- Handle date.min/date.max boundaries without OverflowError
- Half-up rounding to match browser Math.round

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MobKBK
2026-08-24 07:15:50 +08:00
parent 094e03f341
commit bcc2abe623
12 changed files with 555 additions and 59 deletions

View File

@@ -28,10 +28,12 @@ docker compose up -d --build
`data/tasks/<taskId>.json` 保存当前任务,`data/revisions/<taskId>/<revision>.json` 保存每次属性修订,`data/daily/<year>/<date>.json` 保存不可移动的每日事实,`data/archive/<year>/<month>.json` 是可重建汇总。任务 ID 和指标 ID 稳定,改名不会破坏历史。
`metrics[]` 可同时含 `checkin`、`quantity`、`duration`;周期支持 `day`、`week`、`month`、`lifetime`;`target: null` 表示无上限,时长统一用整数秒。`config/layouts/home.json` 与 `task-detail.json` 只允许白名单组件,非法布局会回退到内置默认值。
`metrics[]` 可同时含 `checkin`、`quantity`、`duration`;周期支持 `day`、`week`、`month`、`lifetime`;`target: null` 表示无上限,时长统一用整数秒。首页与任务详情按 `config/layouts/home.json` 与 `task-detail.json` 中的组件顺序渲染;布局只允许白名单组件,且每个组件限定其所属页面/区域,非法布局会回退到内置默认值。
每日 0 点只关闭事实并重建归档,不删除原始记录。启动时会补跑遗漏日期;补录历史会触发对应月份重建。所有写接口带版本号冲突检测,任务删除默认为软删,后台可恢复或二次确认永久删除。
进度统计只读原始每日事实、绝不读可重建的月归档:`/api/summary` 汇总全部任务、`/api/tasks/<id>/summary` 汇总单个任务,均按各指标自身的周期窗口(日/周/月/累计)聚合,累计窗口的下界取任务开始日期。会话在服务端带 30 天 TTL,与登录 Cookie 的 `max_age` 一致。
## 测试
```bash

View File

@@ -4,7 +4,11 @@ import hashlib
import hmac
import os
import secrets
from dataclasses import dataclass
import time
from dataclasses import dataclass, field
SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 # 与登录 Cookie 的 max_age 一致
def make_password_hash(password: str, iterations: int = 310_000) -> str:
@@ -29,11 +33,13 @@ def verify_password(password: str, encoded: str) -> bool:
@dataclass
class Session:
csrf: str
created_at: float = field(default_factory=time.time)
class SessionManager:
def __init__(self, password_hash: str | None):
def __init__(self, password_hash: str | None, ttl_seconds: float = SESSION_TTL_SECONDS):
self.password_hash = password_hash
self.ttl_seconds = ttl_seconds
self.sessions: dict[str, Session] = {}
@property
@@ -48,9 +54,16 @@ class SessionManager:
encoded = make_password_hash(plain)
return cls(encoded or None)
def _expire(self) -> None:
cutoff = time.time() - self.ttl_seconds
expired = [sid for sid, session in self.sessions.items() if session.created_at < cutoff]
for sid in expired:
self.sessions.pop(sid, None)
def login(self, password: str) -> tuple[str, Session] | None:
if not self.password_hash or not verify_password(password, self.password_hash):
return None
self._expire()
session_id = secrets.token_urlsafe(32)
session = Session(csrf=secrets.token_urlsafe(32))
self.sessions[session_id] = session
@@ -59,6 +72,7 @@ class SessionManager:
def get(self, session_id: str | None) -> Session | None:
if not session_id:
return None
self._expire()
return self.sessions.get(session_id)
def logout(self, session_id: str | None) -> None:

View File

@@ -17,6 +17,19 @@ ALLOWED_COMPONENTS = {
"task-settings",
}
# 每个组件允许出现的页面与区域。组件在各自区域内必须单例,避免重复 ID
# 与重复事件绑定;跨页/跨区错放(例如把 today-record 放进首页)视为非法布局。
COMPONENT_SCOPES: dict[str, tuple[tuple[str, ...], str]] = {
"task-list": (("home", "task-detail"), "sidebar"),
"overview-summary": (("home",), "main"),
"task-progress-grid": (("home",), "main"),
"task-header": (("task-detail",), "main"),
"today-record": (("task-detail",), "main"),
"metric-progress": (("task-detail",), "main"),
"activity-heatmap": (("task-detail",), "main"),
"task-settings": (("task-detail",), "main"),
}
FALLBACKS = {
"home": {
"schemaVersion": 1,
@@ -51,16 +64,24 @@ def validate_layout(layout: Any, expected_page: str) -> bool:
regions = layout.get("regions")
if not isinstance(regions, dict) or set(regions) - {"sidebar", "main"}:
return False
for items in regions.values():
for region, items in regions.items():
if not isinstance(items, list):
return False
seen: set[str] = set()
for item in items:
if not isinstance(item, dict) or set(item) - {"component", "options"}:
return False
if item.get("component") not in ALLOWED_COMPONENTS:
component = item.get("component")
if component not in ALLOWED_COMPONENTS:
return False
if "options" in item and not isinstance(item["options"], dict):
return False
pages, expected_region = COMPONENT_SCOPES[component]
if expected_page not in pages or region != expected_region:
return False
if component in seen:
return False
seen.add(component)
return True

View File

@@ -198,6 +198,14 @@ def create_app(
async def upsert_daily(task_id: str, logical_date: date, payload: DailyRecordInput) -> dict:
return repository.upsert_daily(task_id, logical_date, payload)
@app.get("/api/summary")
async def summary(date_: Annotated[date | None, Query(alias="date")] = None) -> dict:
return repository.summarize(date_)
@app.get("/api/tasks/{task_id}/summary")
async def task_summary(task_id: str, date_: Annotated[date | None, Query(alias="date")] = None) -> dict:
return repository.summarize(date_, task_id)
@app.get("/api/tasks/{task_id}/history")
async def history(
task_id: str,

View File

@@ -89,7 +89,7 @@ class MetricValue(BaseModel):
class DailyRecordInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
expectedVersion: Annotated[int | None, Field(ge=1)] = None
expectedVersion: Annotated[int | None, Field(ge=0)] = None
note: Annotated[str, Field(max_length=4000)] = ""
completedAt: datetime | None = None
values: list[MetricValue] = Field(default_factory=list)

View File

@@ -4,6 +4,7 @@ import json
import os
import tempfile
import threading
from calendar import monthrange
from copy import deepcopy
from datetime import date, datetime, timedelta
from pathlib import Path
@@ -34,6 +35,47 @@ def now_iso() -> str:
return datetime.now(SHANGHAI).isoformat(timespec="seconds")
def period_window(period: str, ref: date, task_start: date | None = None) -> tuple[date, date] | None:
"""Inclusive [start, end] of the统计周期 containing ``ref``.
``lifetime`` starts at the task's own start date so a task created today
never claims credit for facts recorded before it existed. When ``ref``
predates the task start the interval is returned ``None`` so no pre-start
fact can ever match it.
Month/week bounds are computed without stepping outside Python's date
domain (``date.min``/``date.max``), so extreme-but-valid ``ref`` values
cannot raise ``OverflowError``.
"""
if period == "day":
return ref, ref
if period == "week":
start = ref - timedelta(days=ref.weekday())
if start > date.max - timedelta(days=6):
return start, date.max
return start, start + timedelta(days=6)
if period == "month":
start = ref.replace(day=1)
return start, ref.replace(day=monthrange(ref.year, ref.month)[1])
if period == "lifetime":
start = task_start or date.min
if start > ref:
return None
return start, ref
raise ValidationError(f"未知周期 {period}")
def _half_up(value: float) -> int:
"""Round a non-negative value half-up to match browser ``Math.round``."""
return int(value + 0.5)
def _percent(value: int, target: int | None) -> int | None:
if not target:
return None
return min(100, _half_up(value / target * 100))
class JsonRepository:
"""Single-process JSON repository with atomic same-directory replacement."""
@@ -250,7 +292,7 @@ class JsonRepository:
def get_daily(self, task_id: str, logical_date: date) -> dict[str, Any]:
with self._lock:
self._load_task(task_id)
task = self._load_task(task_id)
daily = self._read(self._daily_path(logical_date), {
"schemaVersion": SCHEMA_VERSION,
"date": logical_date.isoformat(),
@@ -260,7 +302,7 @@ class JsonRepository:
})
return daily["records"].get(task_id, {
"taskId": task_id,
"taskRevision": self._load_task(task_id)["revision"],
"taskRevision": task["revision"],
"version": 0,
"note": "",
"completedAt": None,
@@ -327,6 +369,92 @@ class JsonRepository:
revisions = [self._read(path) for path in sorted((self.revisions_dir / task_id).glob("*.json"), key=lambda p: int(p.stem))]
return {"taskId": task_id, "records": records, "revisions": revisions}
def _iter_daily(self, start: date, end: date):
"""Yield ``(day, daily)`` for every existing fact file inside [start, end]."""
for year in range(start.year, end.year + 1):
folder = self.daily_dir / str(year)
if not folder.is_dir():
continue
for path in sorted(folder.glob("*.json")):
try:
day = date.fromisoformat(path.stem)
except ValueError:
continue
if start <= day <= end:
daily = self._read(path)
if daily:
yield day, daily
def summarize(self, ref_date: date | None = None, task_id: str | None = None) -> dict[str, Any]:
"""Per-metric progress inside each metric's own period window.
Facts are the source of truth here; month archives stay a rebuildable
derivative and are never read back for statistics.
"""
with self._lock:
ref = ref_date or datetime.now(SHANGHAI).date()
if task_id is None:
tasks = [task for task in (self._read(path) for path in self.tasks_dir.glob("*.json")) if task and not task.get("deletedAt")]
else:
tasks = [self.get_task(task_id)]
result: dict[str, Any] = {"date": ref.isoformat(), "tasks": {}}
if not tasks:
return result
windows = {
task["taskId"]: {
metric["metricId"]: period_window(metric["period"], ref, date.fromisoformat(task["startDate"]))
for metric in task["metrics"]
}
for task in tasks
}
spans = [span for per_task in windows.values() for span in per_task.values() if span is not None]
totals: dict[str, dict[str, int]] = {task["taskId"]: {} for task in tasks}
today_record: dict[str, dict[str, Any]] = {}
if spans:
for day, daily in self._iter_daily(min(span[0] for span in spans), ref):
for record_task_id, record in daily.get("records", {}).items():
per_metric = windows.get(record_task_id)
if per_metric is None:
continue
if day == ref:
today_record[record_task_id] = record
for metric_id, value in record.get("values", {}).items():
span = per_metric.get(metric_id)
# Facts after ``ref`` never count, even though the
# display window (span[1]) may extend past it.
if span and span[0] <= day <= min(span[1], ref):
totals[record_task_id][metric_id] = totals[record_task_id].get(metric_id, 0) + int(value)
for task in tasks:
current_id = task["taskId"]
metrics = []
for metric in task["metrics"]:
span = windows[current_id][metric["metricId"]]
value = totals[current_id].get(metric["metricId"], 0)
if span is None:
# Lifetime metric whose task starts after ``ref``: no
# window, so no facts may count, but keep the field shape.
window_start = window_end = ref.isoformat()
else:
window_start, window_end = span[0].isoformat(), span[1].isoformat()
metrics.append({
**{key: metric[key] for key in ("metricId", "name", "kind", "period", "unit", "target")},
"value": value,
"percent": _percent(value, metric["target"]),
"windowStart": window_start,
"windowEnd": window_end,
})
scored = [item["percent"] for item in metrics if item["percent"] is not None]
today = today_record.get(current_id)
result["tasks"][current_id] = {
"taskId": current_id,
"progress": _half_up(sum(scored) / len(scored)) if scored else None,
"today": {"recorded": today is not None, "completedAt": (today or {}).get("completedAt")},
"metrics": metrics,
}
return result
def _rebuild_month(self, year: int, month: int) -> dict[str, Any]:
summaries: dict[str, Any] = {}
folder = self.daily_dir / str(year)

View File

@@ -1,12 +1,16 @@
const state = {
tasks: [], version: 1, selectedId: null, selectedTask: null,
daily: null, history: null, layout: null, csrf: "", authEnabled: false,
daily: null, history: null, summary: null, csrf: "", authEnabled: false,
filter: "", editingId: null, dragId: null,
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const today = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
const shanghaiDate = (offsetDays) => {
const now = new Date(Date.now() + offsetDays * 86400000);
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(now);
};
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[char]));
const metricKindLabel = { checkin: "打卡", quantity: "数量", duration: "时长" };
const periodLabel = { day: "每日", week: "每周", month: "每月", lifetime: "总计" };
@@ -31,32 +35,46 @@ function showLogin() { $("#loginOverlay").hidden = false; $("#app").setAttribute
function hideLogin() { $("#loginOverlay").hidden = true; $("#app").setAttribute("aria-busy", "false"); }
async function bootstrap() {
const dateNode = $("#logicalDate"); if (dateNode) dateNode.textContent = today();
try {
const auth = await api("/api/auth/status");
state.authEnabled = auth.enabled; state.csrf = auth.csrfToken || "";
if (auth.enabled && !auth.authenticated) { showLogin(); return; }
hideLogin();
await loadLayout(); await loadTasks();
await loadTasks();
} catch (error) { toast(error.message); }
}
async function loadLayout() { state.layout = await api("/api/layouts/home"); }
const layoutCache = { home: null, "task-detail": null };
async function ensureLayout(page) { if (!layoutCache[page]) layoutCache[page] = await api(`/api/layouts/${page}`); return layoutCache[page]; }
async function loadTasks() {
const result = await api("/api/tasks"); state.tasks = result.items; state.version = result.version;
if (!state.selectedId || !state.tasks.some((task) => task.taskId === state.selectedId)) state.selectedId = state.tasks[0]?.taskId || null;
await render();
}
function renderSidebar() {
function renderTaskList() {
const filtered = state.tasks.filter((task) => !state.filter || task.name.toLowerCase().includes(state.filter.toLowerCase()));
const items = filtered.map((task) => `<div class="task-item ${task.taskId === state.selectedId ? "selected" : ""}" draggable="true" data-task-id="${task.taskId}">
<span class="priority-bar priority-${task.priority}"></span><span class="task-copy"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></span><span class="drag-handle" title="拖动排序" aria-hidden="true">⠿</span></div>`).join("");
$("#sidebar").innerHTML = `<div class="sidebar-heading"><div><p class="eyebrow">WORKSPACE</p><h2>我的任务</h2></div><span>${state.tasks.length} 项</span></div><div class="filter-row"><input id="taskFilter" placeholder="搜索任务" value="${escapeHtml(state.filter)}"><button class="icon-button" id="clearFilter" title="清除搜索" aria-label="清除搜索">×</button></div><div class="task-list">${items || `<div class="empty-list">还没有任务<br>从右上角新建一项计划</div>`}</div>`;
$("#taskFilter").addEventListener("input", (event) => { state.filter = event.target.value; renderSidebar(); bindSidebar(); });
$("#clearFilter").addEventListener("click", () => { state.filter = ""; renderSidebar(); bindSidebar(); }); bindSidebar();
return `<div class="sidebar-heading"><div><p class="eyebrow">WORKSPACE</p><h2>我的任务</h2></div><span>${state.tasks.length} 项</span></div><div class="filter-row"><input id="taskFilter" placeholder="搜索任务" value="${escapeHtml(state.filter)}"><button class="icon-button" id="clearFilter" title="清除搜索" aria-label="清除搜索">×</button></div><div class="task-list">${items || `<div class="empty-list">还没有任务<br>从右上角新建一项计划</div>`}</div>`;
}
function bindSidebar() {
const filterInput = $("#taskFilter");
if (filterInput) {
filterInput.addEventListener("input", (event) => { state.filter = event.target.value; rerenderSidebarList(); });
$("#clearFilter")?.addEventListener("click", () => { state.filter = ""; rerenderSidebarList(); });
}
bindSidebarItems();
}
function rerenderSidebarList() {
$("#sidebar").innerHTML = renderTaskList();
bindSidebar();
}
function bindSidebarItems() {
$$(".task-item").forEach((node) => {
node.addEventListener("click", () => { state.selectedId = node.dataset.taskId; render(); });
node.addEventListener("dragstart", () => { state.dragId = node.dataset.taskId; node.classList.add("dragging"); });
@@ -74,57 +92,149 @@ async function reorder(fromId, toId) {
}
function periodText(task) { const metrics = task.metrics || []; return [...new Set(metrics.map((metric) => periodLabel[metric.period] || metric.period))].join(" / "); }
function percent(value, target) { return target ? Math.min(100, Math.round(value / target * 100)) : value ? 100 : 0; }
function metricTotal(metricId) { return (state.history?.records || []).reduce((sum, record) => sum + Number(record.values?.[metricId] || 0), 0); }
function summaryFor(taskId) { return state.summary?.tasks?.[taskId] || null; }
function metricSummary(taskId, metricId) { return summaryFor(taskId)?.metrics?.find((metric) => metric.metricId === metricId) || null; }
async function render() {
renderSidebar();
if (!state.selectedId) { renderHome(); return; }
state.selectedTask = state.tasks.find((task) => task.taskId === state.selectedId);
try { [state.daily, state.history] = await Promise.all([api(`/api/tasks/${state.selectedId}/daily/${today()}`), api(`/api/tasks/${state.selectedId}/history`)]); }
catch (error) { toast(error.message); return; }
renderDetail();
// ---- 组件注册表:布局白名单对应的渲染器与事件绑定 ----
// 页面/区域约束与单例约束由服务端 validate_layout 保证(非法布局回退到内置默认),
// 前端只负责渲染已校验的布局,不再二次过滤以免静默丢组件。
const components = {
"task-list": { render: renderTaskList, bind: bindSidebar },
"overview-summary": { render: renderOverviewSummary },
"task-progress-grid": { render: renderTaskProgressGrid, bind: bindOverview },
"task-header": { render: renderTaskHeader, bind: bindTaskHeader },
"today-record": { render: renderTodayRecord, bind: bindRecordForm },
"metric-progress": { render: renderMetricProgress },
"activity-heatmap": { render: renderActivityHeatmap },
"task-settings": { render: renderTaskSettings, bind: bindSettings },
};
function componentHtml(item) { const entry = components[item.component]; return entry ? entry.render(item.options || {}) : ""; }
function bindComponents(items) {
for (const item of items) {
const entry = components[item.component];
if (entry?.bind) entry.bind(item.options || {});
}
}
function renderHome() {
const active = state.tasks.filter((task) => !task.deletedAt); const completed = active.filter((task) => task.state === "active").length;
$("#main").innerHTML = `<div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${today()}</p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong>${active.length}</strong><span>进行中的任务</span></div><div class="summary-cell"><strong>${completed}</strong><span>活跃计划</span></div><div class="summary-cell"><strong>${active.filter((task) => task.priority <= 2).length}</strong><span>高优先级</span></div></div><section class="section"><div class="section-heading"><h2>任务进度</h2><span>点击任务查看详情</span></div><div class="overview-list">${active.map((task) => `<div class="overview-row" data-task-id="${task.taskId}"><div class="overview-name"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></div><div class="progress-track"><div class="progress-fill" style="width:${taskProgress(task)}%"></div></div><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : `${taskProgress(task)}%`}</span></div>`).join("") || `<div class="empty-list">还没有计划,从右上角开始添加。</div>`}</div></section>`;
async function renderLayout(page) {
const layout = await ensureLayout(page);
const sidebarItems = layout.regions.sidebar || [];
const mainItems = layout.regions.main || [];
$("#sidebar").innerHTML = sidebarItems.map(componentHtml).join("");
$("#main").innerHTML = mainItems.map(componentHtml).join("");
bindComponents(sidebarItems);
bindComponents(mainItems);
}
async function render() {
if (!state.selectedId) {
try { state.summary = await api("/api/summary"); } catch (error) { toast(error.message); }
await renderLayout("home");
return;
}
state.selectedTask = state.tasks.find((task) => task.taskId === state.selectedId);
const layout = await ensureLayout("task-detail");
const heatmap = (layout.regions.main || []).find((item) => item.component === "activity-heatmap");
const historyDays = Math.min(heatmap?.options?.historyDays ?? 365, 730);
const start = shanghaiDate(-(historyDays - 1));
try {
[state.summary, state.daily, state.history] = await Promise.all([
api("/api/summary"),
api(`/api/tasks/${state.selectedId}/daily/${today()}`),
api(`/api/tasks/${state.selectedId}/history?start=${start}&end=${today()}`),
]);
} catch (error) { toast(error.message); return; }
await renderLayout("task-detail");
}
function renderOverviewSummary() {
const active = state.tasks.filter((task) => !task.deletedAt); const activePlans = active.filter((task) => task.state === "active").length;
return `<div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${today()}</p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong>${active.length}</strong><span>进行中的任务</span></div><div class="summary-cell"><strong>${activePlans}</strong><span>活跃计划</span></div><div class="summary-cell"><strong>${active.filter((task) => task.priority <= 2).length}</strong><span>高优先级</span></div></div>`;
}
function renderTaskProgressGrid() {
const active = state.tasks.filter((task) => !task.deletedAt);
return `<section class="section"><div class="section-heading"><h2>任务进度</h2><span>按各指标自身周期计算</span></div><div class="overview-list">${active.map((task) => `<div class="overview-row" data-task-id="${task.taskId}"><div class="overview-name"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></div><div class="progress-track"><div class="progress-fill" style="width:${taskProgress(task) ?? 0}%"></div></div><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${progressBadge(task)}</span></div>`).join("") || `<div class="empty-list">还没有计划,从右上角开始添加。</div>`}</div></section>`;
}
function bindOverview() {
$$(".overview-row").forEach((row) => row.addEventListener("click", () => { state.selectedId = row.dataset.taskId; render(); }));
}
function taskProgress(task) {
const values = task.metrics.map((metric) => ({ value: metricTotal(metric.metricId), target: metric.target })).filter((item) => item.target);
if (!values.length) return 0; return Math.round(values.reduce((sum, item) => sum + percent(item.value, item.target), 0) / values.length);
function taskProgress(task) { const value = summaryFor(task.taskId)?.progress; return value === null || value === undefined ? null : value; }
function progressBadge(task) {
if (task.state === "paused") return "已挂起";
const value = taskProgress(task);
return value === null ? "无上限" : `${value}%`;
}
function renderDetail() {
const task = state.selectedTask; const completedToday = Object.values(state.daily?.values || {}).some((value) => value > 0);
$("#main").innerHTML = `<div class="page-heading"><div><p class="eyebrow">TASK DETAIL · 优先级 ${task.priority}</p><h1>${escapeHtml(task.name)}</h1><p>${escapeHtml(task.description || "还没有写下说明")}</p></div><div class="top-actions"><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : "进行中"}</span><button class="button" id="editTaskButton">编辑属性</button></div></div><div class="tabs"><button class="tab active" data-tab="record">今日记录</button><button class="tab" data-tab="insight">完成情况</button><button class="tab" data-tab="settings">任务设置</button></div><div class="tab-panel" data-panel="record">${renderTodayRecord(task, completedToday)}</div><div class="tab-panel" data-panel="insight" hidden>${renderInsight(task)}</div><div class="tab-panel" data-panel="settings" hidden>${renderSettings(task)}</div>`;
$$(".tab").forEach((tab) => tab.addEventListener("click", () => { $$(".tab").forEach((item) => item.classList.toggle("active", item === tab)); $$(".tab-panel").forEach((panel) => panel.hidden = panel.dataset.panel !== tab.dataset.tab); }));
$("#editTaskButton").addEventListener("click", () => openTaskDialog(task)); bindRecordForm(task); bindSettings(task);
function renderTaskHeader() {
const task = state.selectedTask;
return `<div class="page-heading"><div><p class="eyebrow">TASK DETAIL · 优先级 ${task.priority}</p><h1>${escapeHtml(task.name)}</h1><p>${escapeHtml(task.description || "还没有写下说明")}</p></div><div class="top-actions"><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : "进行中"}</span><button class="button" id="editTaskButton">编辑属性</button></div></div>`;
}
function renderTodayRecord(task, completedToday) {
const fields = task.metrics.map((metric) => { const value = state.daily?.values?.[metric.metricId] || 0; if (metric.kind === "checkin") return `<label class="record-field check"><input type="checkbox" data-metric-id="${metric.metricId}" value="1" ${value ? "checked" : ""}><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${periodLabel[metric.period]}</small></span></label>`; return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${metricKindLabel[metric.kind]} · ${metric.unit || (metric.kind === "duration" ? "秒" : "次")} · ${periodLabel[metric.period]}</small></span><input type="number" min="0" step="1" data-metric-id="${metric.metricId}" value="${value}"></label>`; }).join("");
return `<section class="section"><div class="section-heading"><div><h2>今天,${today()}</h2><span>只记录今天真实完成的部分</span></div><span class="status-badge ${completedToday ? "" : "paused"}">${completedToday ? "已有记录" : "尚未记录"}</span></div><div class="record-grid">${fields}<label class="record-field wide">心得<textarea id="dailyNote" rows="4" maxlength="4000" placeholder="记下今天的阻力、收获或下一步">${escapeHtml(state.daily?.note || "")}</textarea></label><label class="record-field">完成时间<input id="completedAt" type="datetime-local" value="${toLocalDatetime(state.daily?.completedAt)}"></label></div><div class="record-actions"><span class="muted">修改会保留任务历史版本</span><button class="button primary" id="saveDailyButton">保存今日记录</button></div></section>`;
function bindTaskHeader() {
$("#editTaskButton").addEventListener("click", () => openTaskDialog(state.selectedTask));
}
function toDurationString(seconds) { const total = Math.max(0, Math.floor(Number(seconds) || 0)); return `${Math.floor(total / 3600)}:${String(Math.floor(total % 3600 / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; }
function fromDurationString(value) { const parts = String(value || "0").trim().split(":").map((part) => Math.max(0, Math.floor(Number(part) || 0))); const [hours = 0, minutes = 0, secs = 0] = parts; return hours * 3600 + minutes * 60 + secs; }
function metricField(metric) {
const value = state.daily?.values?.[metric.metricId] || 0;
if (metric.kind === "checkin") return `<label class="record-field check"><input type="checkbox" data-metric-id="${metric.metricId}" value="1" ${value ? "checked" : ""}><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${periodLabel[metric.period]}</small></span></label>`;
if (metric.kind === "duration") return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">时长 · 时:分:秒 · ${periodLabel[metric.period]}</small></span><input type="text" inputmode="numeric" placeholder="0:30:00" data-kind="duration" data-metric-id="${metric.metricId}" value="${toDurationString(value)}"></label>`;
return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${metricKindLabel[metric.kind]} · ${metric.unit || "次"} · ${periodLabel[metric.period]}</small></span><input type="number" min="0" step="1" data-metric-id="${metric.metricId}" value="${value}"></label>`;
}
function renderTodayRecord() {
const task = state.selectedTask;
const completedToday = Object.values(state.daily?.values || {}).some((value) => value > 0);
const hasCheckin = task.metrics.some((metric) => metric.kind === "checkin");
const fields = task.metrics.map(metricField).join("");
return `<section class="section" id="todayRecord"><div class="section-heading"><div><h2>今天,${today()}</h2><span>只记录今天真实完成的部分</span></div><span class="status-badge ${completedToday ? "" : "paused"}">${completedToday ? "已有记录" : "尚未记录"}</span></div><div class="record-grid">${fields}<label class="record-field wide">心得<textarea id="dailyNote" rows="4" maxlength="4000" placeholder="记下今天的阻力、收获或下一步">${escapeHtml(state.daily?.note || "")}</textarea></label><label class="record-field">完成时间<input id="completedAt" type="datetime-local" value="${toLocalDatetime(state.daily?.completedAt)}"></label></div><div class="record-actions">${hasCheckin ? `<button class="button primary" id="quickCheckinButton" type="button">一键打卡</button>` : `<span class="muted">修改会保留任务历史版本</span>`}<button class="button primary" id="saveDailyButton">保存今日记录</button></div></section>`;
}
function collectTodayValues() {
return $$(`#todayRecord [data-metric-id]`).map((node) => ({
metricId: node.dataset.metricId,
value: node.type === "checkbox" ? (node.checked ? 1 : 0) : (node.dataset.kind === "duration" ? fromDurationString(node.value) : Math.max(0, Number(node.value || 0))),
}));
}
function saveTodayRecord(values = collectTodayValues()) {
const task = state.selectedTask;
const completedAt = $("#todayRecord #completedAt")?.value || "";
return api(`/api/tasks/${task.taskId}/daily/${today()}`, { method: "PUT", body: JSON.stringify({ expectedVersion: state.daily?.version || 0, note: $("#todayRecord #dailyNote")?.value || "", completedAt: completedAt ? `${completedAt}:00` : null, values }) });
}
function toLocalDatetime(value) { return value ? value.slice(0, 16) : ""; }
function bindRecordForm(task) {
function bindRecordForm() {
$("#saveDailyButton").addEventListener("click", async () => {
const values = $$(`[data-metric-id]`).map((node) => ({ metricId: node.dataset.metricId, value: node.type === "checkbox" ? (node.checked ? 1 : 0) : Math.max(0, Number(node.value || 0)) }));
const completedAt = $("#completedAt").value; try { await api(`/api/tasks/${task.taskId}/daily/${today()}`, { method: "PUT", body: JSON.stringify({ expectedVersion: state.daily?.version || 0, note: $("#dailyNote").value, completedAt: completedAt ? new Date(completedAt).toISOString() : null, values }) }); toast("今日记录已保存"); await render(); } catch (error) { toast(error.message); }
try { await saveTodayRecord(); toast("今日记录已保存"); await render(); } catch (error) { toast(error.message); }
});
$("#quickCheckinButton")?.addEventListener("click", async () => {
$$(`#todayRecord [data-metric-id][type="checkbox"]`).forEach((node) => { node.checked = true; });
try { await saveTodayRecord(); toast("已打卡"); await render(); } catch (error) { toast(error.message); }
});
}
function renderInsight(task) {
const cards = task.metrics.map((metric) => { const total = metricTotal(metric.metricId); const target = metric.target; return `<div class="metric-card"><div class="metric-card-head"><strong>${escapeHtml(metric.name)}</strong><small>${metricKindLabel[metric.kind]}</small></div><div class="metric-value">${total}${target ? ` <small>/ ${target}</small>` : ""}</div><div class="progress-track"><div class="progress-fill" style="width:${percent(total, target)}%"></div></div><small class="muted">${target ? `${percent(total, target)}% · ${periodLabel[metric.period]}` : `累计 ${periodLabel[metric.period]} · 无上限`}</small></div>`; }).join("");
const heat = Array.from({ length: 364 }, (_, index) => { const day = new Date(); day.setDate(day.getDate() - (363 - index)); const iso = day.toISOString().slice(0, 10); const record = state.history?.records?.find((item) => item.date === iso); const amount = record ? Object.values(record.values || {}).reduce((sum, value) => sum + Number(value), 0) : 0; return `<span class="heat-cell level-${amount ? Math.min(4, Math.ceil(amount / 2)) : 0}" title="${iso} · ${amount}"></span>`; }).join("");
return `<section class="section"><div class="section-heading"><h2>指标总览</h2><span>基于每日事实计算</span></div><div class="metric-cards">${cards}</div></section><section class="section"><div class="section-heading"><h2>活动热力图</h2><span>过去 12 个月</span></div><div class="heatmap-wrap"><div class="heatmap">${heat}</div></div><div class="heat-legend"><span>少</span><i></i><i class="heat-cell level-1"></i><i class="heat-cell level-2"></i><i class="heat-cell level-3"></i><i class="heat-cell level-4"></i><span>多</span></div></section>`;
function renderMetricProgress() {
const task = state.selectedTask;
const cards = task.metrics.map((metric) => { const info = metricSummary(task.taskId, metric.metricId) || { value: 0, target: metric.target, percent: null, windowStart: "", windowEnd: "" }; const scope = metric.period === "lifetime" ? `自 ${info.windowStart}` : `${info.windowStart} ~ ${info.windowEnd}`; return `<div class="metric-card"><div class="metric-card-head"><strong>${escapeHtml(metric.name)}</strong><small>${metricKindLabel[metric.kind]}</small></div><div class="metric-value">${info.value}${info.target ? ` <small>/ ${info.target}</small>` : ""}</div><div class="progress-track"><div class="progress-fill" style="width:${info.percent ?? 0}%"></div></div><small class="muted">${periodLabel[metric.period]} · ${info.percent === null ? "无上限" : `${info.percent}%`} · ${scope}</small></div>`; }).join("");
return `<section class="section"><div class="section-heading"><h2>指标总览</h2><span>基于每日事实计算</span></div><div class="metric-cards">${cards}</div></section>`;
}
function renderSettings(task) { return `<section class="section"><div class="section-heading"><div><h2>危险操作</h2><span>默认保留历史,可从后台彻底清理</span></div></div><button class="button danger" id="deleteTaskButton">移入回收站</button></section>`; }
function bindSettings(task) { $("#deleteTaskButton").addEventListener("click", async () => { if (!confirm(`确认将“${task.name}”移入回收站?`)) return; try { await api(`/api/tasks/${task.taskId}?expectedVersion=${task.version}`, { method: "DELETE" }); state.selectedId = null; toast("任务已移入回收站"); await loadTasks(); } catch (error) { toast(error.message); } }); }
function renderActivityHeatmap(options) {
const days = Math.min(options.historyDays ?? 365, 730);
const heat = Array.from({ length: days }, (_, index) => { const iso = shanghaiDate(index - (days - 1)); const record = state.history?.records?.find((item) => item.date === iso); const amount = record ? Object.values(record.values || {}).reduce((sum, value) => sum + Number(value), 0) : 0; return `<span class="heat-cell level-${amount ? Math.min(4, Math.ceil(amount / 2)) : 0}" title="${iso} · ${amount}"></span>`; }).join("");
return `<section class="section"><div class="section-heading"><h2>活动热力图</h2><span>过去 ${Math.round(days / 30)} 个月</span></div><div class="heatmap-wrap"><div class="heatmap">${heat}</div></div><div class="heat-legend"><span>少</span><i></i><i class="heat-cell level-1"></i><i class="heat-cell level-2"></i><i class="heat-cell level-3"></i><i class="heat-cell level-4"></i><span>多</span></div></section>`;
}
function renderTaskSettings() { return `<section class="section"><div class="section-heading"><div><h2>危险操作</h2><span>默认保留历史,可从后台彻底清理</span></div></div><button class="button danger" id="deleteTaskButton">移入回收站</button></section>`; }
function bindSettings() { const task = state.selectedTask; $("#deleteTaskButton").addEventListener("click", async () => { if (!confirm(`确认将“${task.name}”移入回收站?`)) return; try { await api(`/api/tasks/${task.taskId}?expectedVersion=${task.version}`, { method: "DELETE" }); state.selectedId = null; toast("任务已移入回收站"); await loadTasks(); } catch (error) { toast(error.message); } }); }
function openTaskDialog(task = null) {
state.editingId = task?.taskId || null; const form = $("#taskForm"); form.reset(); $("#taskDialogTitle").textContent = task ? "编辑任务属性" : "新建任务";
@@ -139,5 +249,5 @@ $("#taskForm").addEventListener("submit", async (event) => { event.preventDefaul
$("#addTaskButton").addEventListener("click", () => openTaskDialog()); $("#homeButton").addEventListener("click", () => { state.selectedId = null; render(); });
$("#adminButton").addEventListener("click", async () => { const result = await api("/api/tasks?includeDeleted=true"); const deleted = result.items.filter((task) => task.deletedAt); $("#trashList").innerHTML = deleted.length ? deleted.map((task) => `<div class="trash-item"><span><strong>${escapeHtml(task.name)}</strong><small class="muted">${task.deletedAt}</small></span><span class="trash-item-actions"><button class="button" data-restore="${task.taskId}">恢复</button><button class="button danger" data-purge="${task.taskId}">永久删除</button></span></div>`).join("") : `<p class="muted">回收站为空</p>`; $("#adminDialog").showModal(); $$("[data-restore]").forEach((button) => button.addEventListener("click", async () => { try { await api(`/api/tasks/${button.dataset.restore}/restore?expectedVersion=${deleted.find((task) => task.taskId === button.dataset.restore).version}`, { method: "POST" }); toast("任务已恢复"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); $$("[data-purge]").forEach((button) => button.addEventListener("click", async () => { const task = deleted.find((item) => item.taskId === button.dataset.purge); if (!confirm(`永久删除“${task.name}”?此操作不可恢复。`)) return; try { await api(`/api/tasks/${task.taskId}/purge`, { method: "POST", body: JSON.stringify({ confirmation: task.taskId }) }); toast("任务及其历史已永久删除"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); });
$("#closeAdminButton").addEventListener("click", () => $("#adminDialog").close()); $("#exportButton").addEventListener("click", async () => { const response = await fetch("/api/export"); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "progress-export.json"; link.click(); URL.revokeObjectURL(link.href); }); $("#archiveButton").addEventListener("click", async () => { try { const result = await api("/api/admin/archive", { method: "POST", body: "{}" }); toast(`补归档完成,处理 ${result.closedDays} 天`); } catch (error) { toast(error.message); } }); $("#logoutButton").addEventListener("click", async () => { await api("/api/auth/logout", { method: "POST", body: "{}" }); showLogin(); });
$("#loginForm").addEventListener("submit", async (event) => { event.preventDefault(); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: event.currentTarget.password.value }) }); const body = await response.json(); if (!response.ok) throw new Error(body.detail || "登录失败"); state.csrf = body.csrfToken; hideLogin(); await loadLayout(); await loadTasks(); } catch (error) { $("#loginError").textContent = error.message; } });
$("#loginForm").addEventListener("submit", async (event) => { event.preventDefault(); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: event.currentTarget.password.value }) }); const body = await response.json(); if (!response.ok) throw new Error(body.detail || "登录失败"); state.csrf = body.csrfToken; hideLogin(); await loadTasks(); } catch (error) { $("#loginError").textContent = error.message; } });
bootstrap();

1
data/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -19,6 +19,29 @@ def test_api_crud_order_layout_and_daily(client):
assert client.get(f"/api/tasks/{task['taskId']}/history?start=2026-08-20&end=2026-08-20").json()["records"]
def test_summary_endpoints(client):
task = client.post("/api/tasks", json=payload("周期任务")).json()
metric_id = task["metrics"][0]["metricId"]
client.put(f"/api/tasks/{task['taskId']}/daily/2026-08-20", json={"expectedVersion": 0, "values": [{"metricId": metric_id, "value": 1}]})
scoped = client.get(f"/api/tasks/{task['taskId']}/summary?date=2026-08-20").json()
assert scoped["tasks"][task["taskId"]]["metrics"][0]["value"] == 1
assert scoped["tasks"][task["taskId"]]["today"]["recorded"] is True
assert client.get("/api/summary?date=2026-08-21").json()["tasks"][task["taskId"]]["metrics"][0]["value"] == 0
def test_first_daily_write_is_accepted(client):
task = client.post("/api/tasks", json=payload("首次记录")).json()
metric_id = task["metrics"][0]["metricId"]
current = client.get(f"/api/tasks/{task['taskId']}/daily/2026-08-20").json()
saved = client.put(
f"/api/tasks/{task['taskId']}/daily/2026-08-20",
json={"expectedVersion": current["version"], "values": [{"metricId": metric_id, "value": 1}]},
)
assert saved.status_code == 200, saved.text
def test_invalid_input_and_auth(auth_client):
assert auth_client.get("/api/tasks").status_code == 401
login = auth_client.post("/api/auth/login", json={"password": "secret"}); assert login.status_code == 200

42
tests/test_layouts.py Normal file
View File

@@ -0,0 +1,42 @@
from app.layouts import load_layout, validate_layout
def _layout(page, main_components, sidebar=None):
return {
"schemaVersion": 1,
"page": page,
"regions": {
"sidebar": sidebar or ([{"component": "task-list"}] if page == "task-detail" else []),
"main": main_components,
},
}
def test_valid_home_layout():
assert validate_layout(_layout("home", [{"component": "overview-summary"}, {"component": "task-progress-grid"}]), "home")
def test_valid_task_detail_layout():
assert validate_layout(_layout("task-detail", [{"component": "task-header"}, {"component": "today-record"}, {"component": "metric-progress"}, {"component": "activity-heatmap"}, {"component": "task-settings"}]), "task-detail")
def test_rejects_component_on_wrong_page():
assert not validate_layout(_layout("home", [{"component": "today-record"}]), "home")
def test_rejects_component_in_wrong_region():
layout = _layout("task-detail", [{"component": "task-header"}], sidebar=[{"component": "task-header"}])
assert not validate_layout(layout, "task-detail")
def test_rejects_duplicate_component():
layout = _layout("task-detail", [{"component": "today-record"}, {"component": "today-record"}])
assert not validate_layout(layout, "task-detail")
def test_load_layout_falls_back_on_invalid(tmp_path):
import json
bad = tmp_path / "home.json"
bad.write_text(json.dumps(_layout("home", [{"component": "today-record"}])), encoding="utf-8")
loaded = load_layout(tmp_path, "home")
assert loaded["regions"]["main"] != [{"component": "today-record"}]

View File

@@ -1,7 +1,8 @@
from datetime import date, timedelta
from app.auth import SessionManager, make_password_hash
from app.models import DailyRecordInput, MetricValue, TaskInput
from app.repository import ConflictError, JsonRepository
from app.repository import ConflictError, JsonRepository, period_window
def task_payload():
@@ -46,3 +47,136 @@ def test_daily_mixed_metrics_history_and_idempotent_archive(tmp_path):
else:
raise AssertionError("expected version conflict")
def test_first_daily_record_accepts_expected_version_zero(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(task_payload())
assert repo.get_daily(task["taskId"], date(2026, 8, 11))["version"] == 0
record = repo.upsert_daily(task["taskId"], date(2026, 8, 11), DailyRecordInput(expectedVersion=0, values=[]))
assert record["version"] == 1
def test_period_window_boundaries():
ref = date(2026, 8, 12)
assert period_window("day", ref) == (ref, ref)
week_start, week_end = period_window("week", ref)
assert week_start.weekday() == 0 and (week_end - week_start).days == 6
assert week_start <= ref <= week_end
assert period_window("month", ref) == (date(2026, 8, 1), date(2026, 8, 31))
assert period_window("month", date(2026, 2, 5)) == (date(2026, 2, 1), date(2026, 2, 28))
assert period_window("month", date(2028, 2, 5)) == (date(2028, 2, 1), date(2028, 2,29))
assert period_window("month", date(2026, 12, 9)) == (date(2026, 12, 1), date(2026, 12, 31))
assert period_window("lifetime", ref, date(2026, 8, 1)) == (date(2026, 8, 1), ref)
assert period_window("lifetime", date(2026, 7, 1), date(2026, 8, 1)) is None, "任务开始前的 lifetime 窗口应为 None"
def test_period_window_date_extremes_do_not_overflow():
assert period_window("day", date.max) == (date.max, date.max)
assert period_window("day", date.min) == (date.min, date.min)
assert period_window("month", date(9999, 12, 1)) == (date(9999, 12, 1), date(9999, 12, 31))
week = period_window("week", date(9999, 12, 31))
assert week[0] <= date(9999, 12, 31) <= week[1]
# 1999-12-31 是周五;week 起点应是周一 9999-12-27,终点 clip 到 date.max
assert week[0] == date(9999, 12, 27)
assert week[1] == date.max
assert period_window("month", date(1, 1, 1)) == (date(1, 1, 1), date(1, 1, 31))
def test_summary_counts_each_metric_inside_its_own_period(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "周期检查", "startDate": "2026-08-01",
"metrics": [
{"name": "每日打卡", "kind": "checkin", "period": "day"},
{"name": "本月刷题", "kind": "quantity", "period": "month", "target": 100, "unit": "题"},
{"name": "累计专注", "kind": "duration", "period": "lifetime", "target": 7200},
],
}))
day_id, month_id, life_id = [metric["metricId"] for metric in task["metrics"]]
for day, quantity, seconds in [(date(2026, 7, 25), 10, 600), (date(2026, 8, 5), 30, 1200), (date(2026, 8, 20), 20, 1800)]:
repo.upsert_daily(task["taskId"], day, DailyRecordInput(values=[
MetricValue(metricId=day_id, value=1),
MetricValue(metricId=month_id, value=quantity),
MetricValue(metricId=life_id, value=seconds),
]))
summary = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]
by_id = {metric["metricId"]: metric for metric in summary["metrics"]}
assert by_id[day_id]["value"] == 1, "day 周期只能统计当天"
assert by_id[month_id]["value"] == 50 and by_id[month_id]["percent"] == 50, "month 周期不能带上 7 月"
assert by_id[life_id]["value"] == 3000 and by_id[life_id]["percent"] == 42, "lifetime 不能早于任务开始日期"
assert summary["progress"] == 64 and summary["today"]["recorded"] is True
def test_summary_without_targets_has_no_progress(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "无上限", "startDate": "2026-08-01",
"metrics": [{"name": "写作", "kind": "quantity", "period": "lifetime", "unit": "字"}],
}))
summary = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]
assert summary["progress"] is None
assert summary["metrics"][0]["percent"] is None
assert summary["today"]["recorded"] is False
def test_summary_skips_soft_deleted_tasks(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(task_payload())
repo.soft_delete_task(task["taskId"], task["version"])
assert repo.summarize(date(2026, 8, 20))["tasks"] == {}
def test_summary_excludes_facts_after_reference_date(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "月中查询", "startDate": "2026-08-01",
"metrics": [{"name": "本月", "kind": "quantity", "period": "month", "target": 100, "unit": "次"}],
}))
metric_id = task["metrics"][0]["metricId"]
# 8 月 5 日一条,8 月 25 日(在 ref=8-20 之后)一条
for day, value in [(date(2026, 8, 5), 30), (date(2026, 8, 25), 20)]:
repo.upsert_daily(task["taskId"], day, DailyRecordInput(values=[MetricValue(metricId=metric_id, value=value)]))
metric = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]["metrics"][0]
assert metric["value"] == 30, "ref 之后的事实不得计入历史汇总"
def test_summary_lifetime_ignores_facts_before_task_start(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "未来开始", "startDate": "2026-08-15",
"metrics": [{"name": "累计", "kind": "quantity", "period": "lifetime", "target": 100, "unit": "次"}],
}))
metric_id = task["metrics"][0]["metricId"]
repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(values=[MetricValue(metricId=metric_id, value=50)]))
metric = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]["metrics"][0]
assert metric["value"] == 0, "任务开始日期之前的事实不得计入 lifetime"
assert metric["windowStart"] == "2026-08-15"
def test_percent_half_up_rounding(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "舍入", "startDate": "2026-08-01",
"metrics": [{"name": "进度", "kind": "quantity", "period": "lifetime", "target": 8, "unit": "次"}],
}))
metric_id = task["metrics"][0]["metricId"]
repo.upsert_daily(task["taskId"], date(2026, 8, 1), DailyRecordInput(values=[MetricValue(metricId=metric_id, value=1)]))
metric = repo.summarize(date(2026, 8, 1))["tasks"][task["taskId"]]["metrics"][0]
assert metric["percent"] == 13, "1/8 应四舍五入进位到 13%,而非四舍六入的 12%"
def test_session_expiry():
manager = SessionManager(make_password_hash("secret"), ttl_seconds=10)
session_id, session = manager.login("secret")
assert manager.get(session_id) is session
session.created_at -= 11 # 模拟 11 秒前的会话
assert manager.get(session_id) is None, "过期会话应当失效"
def test_session_ttl_keeps_fresh_sessions():
manager = SessionManager(make_password_hash("secret"), ttl_seconds=10)
session_id, session = manager.login("secret")
session.created_at -= 5
assert manager.get(session_id) is session

View File

@@ -1,17 +1,30 @@
# 个人进度监视系统需求记录
> 实现状态(2026-08-24):以下「已冻结的 MVP」已全部在代码中落地并通过测试(`pytest -q`,17 项)。
> 每条后面的 `[x]` 表示已完成。周期统计、布局驱动渲染、一键打卡、时长友好输入、会话 TTL
> 与日期边界处理为本次补充完成项。
## 已冻结的 MVP
- 服务器可部署、可远程访问;Docker Compose 默认绑定本机端口,生产使用反向代理和 HTTPS。
- JSON 是主存储契约,不把运行数据提交到 Git;单 worker/单副本。
- 分层 JSON:任务当前定义、任务修订、每日事实、可重建月归档;原始事实不因归档被移动或删除。
- 任务使用稳定 UUID;默认软删除;改名或修改属性保留 `taskRevision`,后台支持恢复和确认后永久删除。
- `metrics[]` 可并存 `checkin`、`quantity`、`duration`;周期 `day/week/month/lifetime`;`target: null` 表示无上限;duration 使用整数秒。
- 创建、编辑、删除、恢复、排序;字段包括名称、起止日期、优先级 1-5、状态和描述。
- 每日记录支持一键打卡、数量、时长、心得和完成时间;修改记录带版本冲突检测。
- 首页与任务详情分别读取独立布局 JSON;布局组件白名单校验,禁止动态执行。
- 中文响应式 UI:左侧任务列表和拖放排序,右侧任务详情、热力图、多指标进度和今日记录。
- 可选单用户密码会话认证、HttpOnly 会话 Cookie、基本 CSRF 校验。
- [x] 服务器可部署、可远程访问;Docker Compose 默认绑定本机端口,生产使用反向代理和 HTTPS。
- [x] JSON 是主存储契约,不把运行数据提交到 Git;单 worker/单副本。
- [x] 分层 JSON:任务当前定义、任务修订、每日事实、可重建月归档;原始事实不因归档被移动或删除。
- [x] 任务使用稳定 UUID;默认软删除;改名或修改属性保留 `taskRevision`,后台支持恢复和确认后永久删除。
- [x] `metrics[]` 可并存 `checkin`、`quantity`、`duration`;周期 `day/week/month/lifetime`;`target: null` 表示无上限;duration 使用整数秒。
- [x] 创建、编辑、删除、恢复、排序;字段包括名称、起止日期、优先级 1-5、状态和描述。
- [x] 每日记录支持一键打卡、数量、时长、心得和完成时间;修改记录带版本冲突检测。
- [x] 首页与任务详情分别读取独立布局 JSON;布局组件白名单校验,禁止动态执行。
- [x] 中文响应式 UI:左侧任务列表和拖放排序,右侧任务详情、热力图、多指标进度和今日记录。
- [x] 可选单用户密码会话认证、HttpOnly 会话 Cookie、基本 CSRF 校验(会话服务端 30 天 TTL)。
## 已实现的实现细节(本次补充)
- 进度统计只读原始每日事实、绝不读月归档;`/api/summary` 与 `/api/tasks/<id>/summary`
按各指标自身周期窗口聚合,`lifetime` 下界取任务开始日期,历史查询不会计入 `ref` 之后的事实。
- 百分比采用与浏览器 `Math.round` 一致的四舍五入进位(half-up),避免 `.5` 边界不一致。
- 布局组件按页面/区域约束(如 `task-header`/`today-record` 仅限任务详情页),非法组件不渲染。
- 时长输入为「时:分:秒」,完整 round-trip 任意非负整数秒;一键打卡一次提交所有打卡指标。
- 极端日期(`date.min`/`date.max` 附近的 month/week 窗口)不再溢出,返回 200。
## 后续路线图