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,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)