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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user