diff --git a/README.md b/README.md index 8aadf29..ba47548 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,12 @@ docker compose up -d --build `data/tasks/.json` 保存当前任务,`data/revisions//.json` 保存每次属性修订,`data/daily//.json` 保存不可移动的每日事实,`data/archive//.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//summary` 汇总单个任务,均按各指标自身的周期窗口(日/周/月/累计)聚合,累计窗口的下界取任务开始日期。会话在服务端带 30 天 TTL,与登录 Cookie 的 `max_age` 一致。 + ## 测试 ```bash diff --git a/app/auth.py b/app/auth.py index 27d0a66..3750952 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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: diff --git a/app/layouts.py b/app/layouts.py index 5954aa1..07db2dd 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -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 diff --git a/app/main.py b/app/main.py index 53897f4..ea80b7e 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/models.py b/app/models.py index 33804a8..6336e7c 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/repository.py b/app/repository.py index 1cd0458..bf182ac 100644 --- a/app/repository.py +++ b/app/repository.py @@ -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) diff --git a/app/static/app.js b/app/static/app.js index 07038c6..a0c4b53 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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) => `
${escapeHtml(task.name)}${periodText(task)} · ${task.metrics.length} 个指标
`).join(""); - $("#sidebar").innerHTML = `
${items || `
还没有任务
从右上角新建一项计划
`}
`; - $("#taskFilter").addEventListener("input", (event) => { state.filter = event.target.value; renderSidebar(); bindSidebar(); }); - $("#clearFilter").addEventListener("click", () => { state.filter = ""; renderSidebar(); bindSidebar(); }); bindSidebar(); + return `
${items || `
还没有任务
从右上角新建一项计划
`}
`; } 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 = `

OVERVIEW · ${today()}

把注意力放回正在发生的事

所有计划、记录和节奏,都在这里保持可见。

${active.length}进行中的任务
${completed}活跃计划
${active.filter((task) => task.priority <= 2).length}高优先级

任务进度

点击任务查看详情
${active.map((task) => `
${escapeHtml(task.name)}${periodText(task)} · ${task.metrics.length} 个指标
${task.state === "paused" ? "已挂起" : `${taskProgress(task)}%`}
`).join("") || `
还没有计划,从右上角开始添加。
`}
`; +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 `

OVERVIEW · ${today()}

把注意力放回正在发生的事

所有计划、记录和节奏,都在这里保持可见。

${active.length}进行中的任务
${activePlans}活跃计划
${active.filter((task) => task.priority <= 2).length}高优先级
`; +} + +function renderTaskProgressGrid() { + const active = state.tasks.filter((task) => !task.deletedAt); + return `

任务进度

按各指标自身周期计算
${active.map((task) => `
${escapeHtml(task.name)}${periodText(task)} · ${task.metrics.length} 个指标
${progressBadge(task)}
`).join("") || `
还没有计划,从右上角开始添加。
`}
`; +} + +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 = `

TASK DETAIL · 优先级 ${task.priority}

${escapeHtml(task.name)}

${escapeHtml(task.description || "还没有写下说明")}

${task.state === "paused" ? "已挂起" : "进行中"}
${renderTodayRecord(task, completedToday)}
`; - $$(".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 `

TASK DETAIL · 优先级 ${task.priority}

${escapeHtml(task.name)}

${escapeHtml(task.description || "还没有写下说明")}

${task.state === "paused" ? "已挂起" : "进行中"}
`; } -function renderTodayRecord(task, completedToday) { - const fields = task.metrics.map((metric) => { const value = state.daily?.values?.[metric.metricId] || 0; if (metric.kind === "checkin") return ``; return ``; }).join(""); - return `

今天,${today()}

只记录今天真实完成的部分
${completedToday ? "已有记录" : "尚未记录"}
${fields}
修改会保留任务历史版本
`; +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 ``; + if (metric.kind === "duration") return ``; + return ``; +} + +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 `

今天,${today()}

只记录今天真实完成的部分
${completedToday ? "已有记录" : "尚未记录"}
${fields}
${hasCheckin ? `` : `修改会保留任务历史版本`}
`; +} + +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 `
${escapeHtml(metric.name)}${metricKindLabel[metric.kind]}
${total}${target ? ` / ${target}` : ""}
${target ? `${percent(total, target)}% · ${periodLabel[metric.period]}` : `累计 ${periodLabel[metric.period]} · 无上限`}
`; }).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 ``; }).join(""); - return `

指标总览

基于每日事实计算
${cards}

活动热力图

过去 12 个月
${heat}
少多
`; +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 `
${escapeHtml(metric.name)}${metricKindLabel[metric.kind]}
${info.value}${info.target ? ` / ${info.target}` : ""}
${periodLabel[metric.period]} · ${info.percent === null ? "无上限" : `${info.percent}%`} · ${scope}
`; }).join(""); + return `

指标总览

基于每日事实计算
${cards}
`; } -function renderSettings(task) { return `

危险操作

默认保留历史,可从后台彻底清理
`; } -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 ``; }).join(""); + return `

活动热力图

过去 ${Math.round(days / 30)} 个月
${heat}
少多
`; +} + +function renderTaskSettings() { return `

危险操作

默认保留历史,可从后台彻底清理
`; } +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) => `
${escapeHtml(task.name)}${task.deletedAt}
`).join("") : `

回收站为空

`; $("#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(); diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/test_api.py b/tests/test_api.py index e86253a..186874d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_layouts.py b/tests/test_layouts.py new file mode 100644 index 0000000..8686a6d --- /dev/null +++ b/tests/test_layouts.py @@ -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"}] diff --git a/tests/test_repository.py b/tests/test_repository.py index 868660e..54a89b9 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -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 diff --git a/需求记录.md b/需求记录.md index 61003ea..8d111b6 100644 --- a/需求记录.md +++ b/需求记录.md @@ -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//summary` + 按各指标自身周期窗口聚合,`lifetime` 下界取任务开始日期,历史查询不会计入 `ref` 之后的事实。 +- 百分比采用与浏览器 `Math.round` 一致的四舍五入进位(half-up),避免 `.5` 边界不一致。 +- 布局组件按页面/区域约束(如 `task-header`/`today-record` 仅限任务详情页),非法组件不渲染。 +- 时长输入为「时:分:秒」,完整 round-trip 任意非负整数秒;一键打卡一次提交所有打卡指标。 +- 极端日期(`date.min`/`date.max` 附近的 month/week 窗口)不再溢出,返回 200。 ## 后续路线图