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:
@@ -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
|
||||
|
||||
18
app/auth.py
18
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => `<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
1
data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
42
tests/test_layouts.py
Normal 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"}]
|
||||
@@ -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
|
||||
|
||||
33
需求记录.md
33
需求记录.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/<id>/summary`
|
||||
按各指标自身周期窗口聚合,`lifetime` 下界取任务开始日期,历史查询不会计入 `ref` 之后的事实。
|
||||
- 百分比采用与浏览器 `Math.round` 一致的四舍五入进位(half-up),避免 `.5` 边界不一致。
|
||||
- 布局组件按页面/区域约束(如 `task-header`/`today-record` 仅限任务详情页),非法组件不渲染。
|
||||
- 时长输入为「时:分:秒」,完整 round-trip 任意非负整数秒;一键打卡一次提交所有打卡指标。
|
||||
- 极端日期(`date.min`/`date.max` 附近的 month/week 窗口)不再溢出,返回 200。
|
||||
|
||||
## 后续路线图
|
||||
|
||||
|
||||
Reference in New Issue
Block a user