feat: scaffold personal progress monitor MVP

This commit is contained in:
MobKBK
2026-08-24 06:18:21 +08:00
commit 094e03f341
21 changed files with 1480 additions and 0 deletions

401
app/repository.py Normal file
View File

@@ -0,0 +1,401 @@
from __future__ import annotations
import json
import os
import tempfile
import threading
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")
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:
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": self._load_task(task_id)["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 _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,
}