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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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