Files
personal-progress-monitor/app/repository.py
MobKBK bcc2abe623 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>
2026-08-24 07:15:50 +08:00

530 lines
22 KiB
Python

from __future__ import annotations
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
from typing import Any
from uuid import uuid4
from zoneinfo import ZoneInfo
from .models import DailyRecordInput, TaskInput
SCHEMA_VERSION = 1
SHANGHAI = ZoneInfo("Asia/Shanghai")
class NotFoundError(Exception):
pass
class ConflictError(Exception):
pass
class ValidationError(Exception):
pass
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."""
def __init__(self, root: Path):
self.root = Path(root)
self.tasks_dir = self.root / "tasks"
self.revisions_dir = self.root / "revisions"
self.daily_dir = self.root / "daily"
self.archive_dir = self.root / "archive"
self.meta_path = self.root / "meta.json"
self._lock = threading.RLock()
for folder in (self.root, self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir):
folder.mkdir(parents=True, exist_ok=True)
if not self.meta_path.exists():
yesterday = date.today() - timedelta(days=1)
self._atomic_write(self.meta_path, {
"schemaVersion": SCHEMA_VERSION,
"version": 1,
"taskOrder": [],
"lastClosedDate": yesterday.isoformat(),
})
def _read(self, path: Path, default: Any = None) -> Any:
if not path.exists():
return deepcopy(default)
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def _atomic_write(self, path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
json.dump(value, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, path)
if os.name != "nt":
dir_fd = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
def _meta(self) -> dict[str, Any]:
return self._read(self.meta_path)
def _task_path(self, task_id: str) -> Path:
return self.tasks_dir / f"{task_id}.json"
def _daily_path(self, logical_date: date) -> Path:
return self.daily_dir / str(logical_date.year) / f"{logical_date.isoformat()}.json"
def _load_task(self, task_id: str) -> dict[str, Any]:
task = self._read(self._task_path(task_id))
if task is None:
raise NotFoundError("任务不存在")
return task
def list_tasks(self, include_deleted: bool = False) -> dict[str, Any]:
with self._lock:
meta = self._meta()
tasks = []
for path in self.tasks_dir.glob("*.json"):
task = self._read(path)
if include_deleted or not task.get("deletedAt"):
tasks.append(task)
positions = {task_id: index for index, task_id in enumerate(meta["taskOrder"])}
tasks.sort(key=lambda item: (positions.get(item["taskId"], 10**9), item["createdAt"]))
return {"version": meta["version"], "items": tasks}
def get_task(self, task_id: str, include_deleted: bool = False) -> dict[str, Any]:
with self._lock:
task = self._load_task(task_id)
if task.get("deletedAt") and not include_deleted:
raise NotFoundError("任务不存在")
return task
def _metric_dicts(self, payload: TaskInput, old: dict[str, Any] | None = None) -> list[dict[str, Any]]:
old_ids = {item["metricId"] for item in (old or {}).get("metrics", [])}
result = []
seen = set()
for metric in payload.metrics:
metric_data = metric.model_dump(mode="json")
metric_id = metric_data.get("metricId")
if not metric_id:
metric_id = str(uuid4())
elif old is not None and metric_id not in old_ids:
raise ValidationError("不能指定不属于该任务的 metricId")
if metric_id in seen:
raise ValidationError("metricId 不能重复")
seen.add(metric_id)
metric_data["metricId"] = metric_id
result.append(metric_data)
return result
def _write_revision(self, task: dict[str, Any]) -> None:
path = self.revisions_dir / task["taskId"] / f"{task['revision']}.json"
self._atomic_write(path, task)
def create_task(self, payload: TaskInput) -> dict[str, Any]:
with self._lock:
task_id = str(uuid4())
timestamp = now_iso()
task = {
"schemaVersion": SCHEMA_VERSION,
"taskId": task_id,
"version": 1,
"revision": 1,
**payload.model_dump(mode="json", exclude={"metrics"}),
"metrics": self._metric_dicts(payload),
"createdAt": timestamp,
"updatedAt": timestamp,
"deletedAt": None,
}
self._atomic_write(self._task_path(task_id), task)
self._write_revision(task)
meta = self._meta()
meta["taskOrder"].append(task_id)
meta["version"] += 1
self._atomic_write(self.meta_path, meta)
return task
def update_task(self, task_id: str, payload: TaskInput, expected_version: int) -> dict[str, Any]:
with self._lock:
old = self._load_task(task_id)
if old.get("deletedAt"):
raise NotFoundError("任务已删除")
if old["version"] != expected_version:
raise ConflictError("任务已被更新,请刷新后重试")
timestamp = now_iso()
task = {
**old,
**payload.model_dump(mode="json", exclude={"metrics"}),
"metrics": self._metric_dicts(payload, old),
"version": old["version"] + 1,
"revision": old["revision"] + 1,
"updatedAt": timestamp,
}
self._atomic_write(self._task_path(task_id), task)
self._write_revision(task)
return task
def _change_deleted(self, task_id: str, expected_version: int, restore: bool) -> dict[str, Any]:
task = self._load_task(task_id)
if task["version"] != expected_version:
raise ConflictError("任务已被更新,请刷新后重试")
if restore and not task.get("deletedAt"):
return task
if not restore and task.get("deletedAt"):
return task
task["deletedAt"] = None if restore else now_iso()
task["version"] += 1
task["revision"] += 1
task["updatedAt"] = now_iso()
self._atomic_write(self._task_path(task_id), task)
self._write_revision(task)
return task
def soft_delete_task(self, task_id: str, expected_version: int) -> dict[str, Any]:
with self._lock:
return self._change_deleted(task_id, expected_version, False)
def restore_task(self, task_id: str, expected_version: int) -> dict[str, Any]:
with self._lock:
return self._change_deleted(task_id, expected_version, True)
def purge_task(self, task_id: str, confirmation: str) -> None:
with self._lock:
task = self._load_task(task_id)
if not task.get("deletedAt"):
raise ValidationError("永久删除前必须先放入回收站")
if confirmation != task_id:
raise ValidationError("确认内容必须是 taskId")
self._task_path(task_id).unlink()
revision_folder = self.revisions_dir / task_id
for path in revision_folder.glob("*.json"):
path.unlink()
if revision_folder.exists():
revision_folder.rmdir()
touched_months: set[tuple[int, int]] = set()
for path in self.daily_dir.glob("*/*.json"):
daily = self._read(path)
if task_id in daily.get("records", {}):
del daily["records"][task_id]
daily["version"] += 1
self._atomic_write(path, daily)
day = date.fromisoformat(daily["date"])
touched_months.add((day.year, day.month))
meta = self._meta()
meta["taskOrder"] = [item for item in meta["taskOrder"] if item != task_id]
meta["version"] += 1
self._atomic_write(self.meta_path, meta)
for year, month in touched_months:
self._rebuild_month(year, month)
def reorder(self, task_ids: list[str], expected_version: int) -> dict[str, Any]:
with self._lock:
meta = self._meta()
if meta["version"] != expected_version:
raise ConflictError("排序已被更新,请刷新后重试")
active_ids = {item["taskId"] for item in self.list_tasks()["items"]}
if set(task_ids) != active_ids:
raise ValidationError("排序列表必须包含全部未删除任务")
deleted_ids = [item for item in meta["taskOrder"] if item not in active_ids]
meta["taskOrder"] = task_ids + deleted_ids
meta["version"] += 1
self._atomic_write(self.meta_path, meta)
return {"version": meta["version"], "taskIds": task_ids}
def get_daily(self, task_id: str, logical_date: date) -> dict[str, Any]:
with self._lock:
task = self._load_task(task_id)
daily = self._read(self._daily_path(logical_date), {
"schemaVersion": SCHEMA_VERSION,
"date": logical_date.isoformat(),
"version": 1,
"closedAt": None,
"records": {},
})
return daily["records"].get(task_id, {
"taskId": task_id,
"taskRevision": task["revision"],
"version": 0,
"note": "",
"completedAt": None,
"values": {},
})
def upsert_daily(self, task_id: str, logical_date: date, payload: DailyRecordInput) -> dict[str, Any]:
with self._lock:
task = self._load_task(task_id)
if task.get("deletedAt"):
raise NotFoundError("任务已删除")
path = self._daily_path(logical_date)
daily = self._read(path, {
"schemaVersion": SCHEMA_VERSION,
"date": logical_date.isoformat(),
"version": 1,
"closedAt": None,
"records": {},
})
old = daily["records"].get(task_id)
current_version = old["version"] if old else 0
if payload.expectedVersion is not None and payload.expectedVersion != current_version:
raise ConflictError("当日记录已被更新,请刷新后重试")
metric_map = {metric["metricId"]: metric for metric in task["metrics"]}
values: dict[str, int] = {}
for item in payload.values:
metric = metric_map.get(item.metricId)
if metric is None:
raise ValidationError("记录包含未知指标")
if metric["kind"] == "checkin" and item.value not in (0, 1):
raise ValidationError("打卡指标只能是 0 或 1")
values[item.metricId] = item.value
record = {
"taskId": task_id,
"taskRevision": task["revision"],
"version": current_version + 1,
"note": payload.note,
"completedAt": payload.completedAt.isoformat() if payload.completedAt else None,
"values": values,
"updatedAt": now_iso(),
}
daily["records"][task_id] = record
daily["version"] += 1
self._atomic_write(path, daily)
if logical_date < datetime.now(SHANGHAI).date():
if not daily.get("closedAt"):
daily["closedAt"] = now_iso()
self._atomic_write(path, daily)
self._rebuild_month(logical_date.year, logical_date.month)
return record
def history(self, task_id: str, start: date, end: date) -> dict[str, Any]:
with self._lock:
self._load_task(task_id)
if end < start or (end - start).days > 730:
raise ValidationError("历史查询范围无效或超过 730 天")
records = []
cursor = start
while cursor <= end:
daily = self._read(self._daily_path(cursor))
if daily and task_id in daily.get("records", {}):
records.append({"date": cursor.isoformat(), **daily["records"][task_id]})
cursor += timedelta(days=1)
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)
for path in sorted(folder.glob(f"{year:04d}-{month:02d}-*.json")):
daily = self._read(path)
if not daily.get("closedAt"):
continue
for task_id, record in daily.get("records", {}).items():
task_summary = summaries.setdefault(task_id, {"days": 0, "completedDays": 0, "metricTotals": {}})
task_summary["days"] += 1
if record.get("completedAt"):
task_summary["completedDays"] += 1
for metric_id, value in record.get("values", {}).items():
task_summary["metricTotals"][metric_id] = task_summary["metricTotals"].get(metric_id, 0) + value
archive = {
"schemaVersion": SCHEMA_VERSION,
"month": f"{year:04d}-{month:02d}",
"rebuiltAt": now_iso(),
"tasks": summaries,
}
self._atomic_write(self.archive_dir / str(year) / f"{year:04d}-{month:02d}.json", archive)
return archive
def close_day(self, logical_date: date) -> None:
with self._lock:
path = self._daily_path(logical_date)
daily = self._read(path, {
"schemaVersion": SCHEMA_VERSION,
"date": logical_date.isoformat(),
"version": 1,
"closedAt": None,
"records": {},
})
if not daily.get("closedAt"):
daily["closedAt"] = now_iso()
daily["version"] += 1
self._atomic_write(path, daily)
self._rebuild_month(logical_date.year, logical_date.month)
def close_missed_days(self, today: date | None = None) -> int:
with self._lock:
today = today or datetime.now(SHANGHAI).date()
meta = self._meta()
cursor = date.fromisoformat(meta["lastClosedDate"]) + timedelta(days=1)
last = today - timedelta(days=1)
count = 0
while cursor <= last:
self.close_day(cursor)
meta["lastClosedDate"] = cursor.isoformat()
cursor += timedelta(days=1)
count += 1
if count:
meta["version"] += 1
self._atomic_write(self.meta_path, meta)
return count
def export_all(self) -> dict[str, Any]:
with self._lock:
tasks = [self._read(path) for path in self.tasks_dir.glob("*.json")]
revisions = [self._read(path) for path in self.revisions_dir.glob("*/*.json")]
daily = [self._read(path) for path in self.daily_dir.glob("*/*.json")]
archives = [self._read(path) for path in self.archive_dir.glob("*/*.json")]
return {
"schemaVersion": SCHEMA_VERSION,
"exportedAt": now_iso(),
"meta": self._meta(),
"tasks": tasks,
"revisions": revisions,
"daily": daily,
"archives": archives,
}