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

@@ -19,6 +19,29 @@ def test_api_crud_order_layout_and_daily(client):
assert client.get(f"/api/tasks/{task['taskId']}/history?start=2026-08-20&end=2026-08-20").json()["records"]
def test_summary_endpoints(client):
task = client.post("/api/tasks", json=payload("周期任务")).json()
metric_id = task["metrics"][0]["metricId"]
client.put(f"/api/tasks/{task['taskId']}/daily/2026-08-20", json={"expectedVersion": 0, "values": [{"metricId": metric_id, "value": 1}]})
scoped = client.get(f"/api/tasks/{task['taskId']}/summary?date=2026-08-20").json()
assert scoped["tasks"][task["taskId"]]["metrics"][0]["value"] == 1
assert scoped["tasks"][task["taskId"]]["today"]["recorded"] is True
assert client.get("/api/summary?date=2026-08-21").json()["tasks"][task["taskId"]]["metrics"][0]["value"] == 0
def test_first_daily_write_is_accepted(client):
task = client.post("/api/tasks", json=payload("首次记录")).json()
metric_id = task["metrics"][0]["metricId"]
current = client.get(f"/api/tasks/{task['taskId']}/daily/2026-08-20").json()
saved = client.put(
f"/api/tasks/{task['taskId']}/daily/2026-08-20",
json={"expectedVersion": current["version"], "values": [{"metricId": metric_id, "value": 1}]},
)
assert saved.status_code == 200, saved.text
def test_invalid_input_and_auth(auth_client):
assert auth_client.get("/api/tasks").status_code == 401
login = auth_client.post("/api/auth/login", json={"password": "secret"}); assert login.status_code == 200

42
tests/test_layouts.py Normal file
View File

@@ -0,0 +1,42 @@
from app.layouts import load_layout, validate_layout
def _layout(page, main_components, sidebar=None):
return {
"schemaVersion": 1,
"page": page,
"regions": {
"sidebar": sidebar or ([{"component": "task-list"}] if page == "task-detail" else []),
"main": main_components,
},
}
def test_valid_home_layout():
assert validate_layout(_layout("home", [{"component": "overview-summary"}, {"component": "task-progress-grid"}]), "home")
def test_valid_task_detail_layout():
assert validate_layout(_layout("task-detail", [{"component": "task-header"}, {"component": "today-record"}, {"component": "metric-progress"}, {"component": "activity-heatmap"}, {"component": "task-settings"}]), "task-detail")
def test_rejects_component_on_wrong_page():
assert not validate_layout(_layout("home", [{"component": "today-record"}]), "home")
def test_rejects_component_in_wrong_region():
layout = _layout("task-detail", [{"component": "task-header"}], sidebar=[{"component": "task-header"}])
assert not validate_layout(layout, "task-detail")
def test_rejects_duplicate_component():
layout = _layout("task-detail", [{"component": "today-record"}, {"component": "today-record"}])
assert not validate_layout(layout, "task-detail")
def test_load_layout_falls_back_on_invalid(tmp_path):
import json
bad = tmp_path / "home.json"
bad.write_text(json.dumps(_layout("home", [{"component": "today-record"}])), encoding="utf-8")
loaded = load_layout(tmp_path, "home")
assert loaded["regions"]["main"] != [{"component": "today-record"}]

View File

@@ -1,7 +1,8 @@
from datetime import date, timedelta
from app.auth import SessionManager, make_password_hash
from app.models import DailyRecordInput, MetricValue, TaskInput
from app.repository import ConflictError, JsonRepository
from app.repository import ConflictError, JsonRepository, period_window
def task_payload():
@@ -46,3 +47,136 @@ def test_daily_mixed_metrics_history_and_idempotent_archive(tmp_path):
else:
raise AssertionError("expected version conflict")
def test_first_daily_record_accepts_expected_version_zero(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(task_payload())
assert repo.get_daily(task["taskId"], date(2026, 8, 11))["version"] == 0
record = repo.upsert_daily(task["taskId"], date(2026, 8, 11), DailyRecordInput(expectedVersion=0, values=[]))
assert record["version"] == 1
def test_period_window_boundaries():
ref = date(2026, 8, 12)
assert period_window("day", ref) == (ref, ref)
week_start, week_end = period_window("week", ref)
assert week_start.weekday() == 0 and (week_end - week_start).days == 6
assert week_start <= ref <= week_end
assert period_window("month", ref) == (date(2026, 8, 1), date(2026, 8, 31))
assert period_window("month", date(2026, 2, 5)) == (date(2026, 2, 1), date(2026, 2, 28))
assert period_window("month", date(2028, 2, 5)) == (date(2028, 2, 1), date(2028, 2,29))
assert period_window("month", date(2026, 12, 9)) == (date(2026, 12, 1), date(2026, 12, 31))
assert period_window("lifetime", ref, date(2026, 8, 1)) == (date(2026, 8, 1), ref)
assert period_window("lifetime", date(2026, 7, 1), date(2026, 8, 1)) is None, "任务开始前的 lifetime 窗口应为 None"
def test_period_window_date_extremes_do_not_overflow():
assert period_window("day", date.max) == (date.max, date.max)
assert period_window("day", date.min) == (date.min, date.min)
assert period_window("month", date(9999, 12, 1)) == (date(9999, 12, 1), date(9999, 12, 31))
week = period_window("week", date(9999, 12, 31))
assert week[0] <= date(9999, 12, 31) <= week[1]
# 1999-12-31 是周五;week 起点应是周一 9999-12-27,终点 clip 到 date.max
assert week[0] == date(9999, 12, 27)
assert week[1] == date.max
assert period_window("month", date(1, 1, 1)) == (date(1, 1, 1), date(1, 1, 31))
def test_summary_counts_each_metric_inside_its_own_period(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "周期检查", "startDate": "2026-08-01",
"metrics": [
{"name": "每日打卡", "kind": "checkin", "period": "day"},
{"name": "本月刷题", "kind": "quantity", "period": "month", "target": 100, "unit": "题"},
{"name": "累计专注", "kind": "duration", "period": "lifetime", "target": 7200},
],
}))
day_id, month_id, life_id = [metric["metricId"] for metric in task["metrics"]]
for day, quantity, seconds in [(date(2026, 7, 25), 10, 600), (date(2026, 8, 5), 30, 1200), (date(2026, 8, 20), 20, 1800)]:
repo.upsert_daily(task["taskId"], day, DailyRecordInput(values=[
MetricValue(metricId=day_id, value=1),
MetricValue(metricId=month_id, value=quantity),
MetricValue(metricId=life_id, value=seconds),
]))
summary = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]
by_id = {metric["metricId"]: metric for metric in summary["metrics"]}
assert by_id[day_id]["value"] == 1, "day 周期只能统计当天"
assert by_id[month_id]["value"] == 50 and by_id[month_id]["percent"] == 50, "month 周期不能带上 7 月"
assert by_id[life_id]["value"] == 3000 and by_id[life_id]["percent"] == 42, "lifetime 不能早于任务开始日期"
assert summary["progress"] == 64 and summary["today"]["recorded"] is True
def test_summary_without_targets_has_no_progress(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "无上限", "startDate": "2026-08-01",
"metrics": [{"name": "写作", "kind": "quantity", "period": "lifetime", "unit": "字"}],
}))
summary = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]
assert summary["progress"] is None
assert summary["metrics"][0]["percent"] is None
assert summary["today"]["recorded"] is False
def test_summary_skips_soft_deleted_tasks(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(task_payload())
repo.soft_delete_task(task["taskId"], task["version"])
assert repo.summarize(date(2026, 8, 20))["tasks"] == {}
def test_summary_excludes_facts_after_reference_date(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "月中查询", "startDate": "2026-08-01",
"metrics": [{"name": "本月", "kind": "quantity", "period": "month", "target": 100, "unit": "次"}],
}))
metric_id = task["metrics"][0]["metricId"]
# 8 月 5 日一条,8 月 25 日(在 ref=8-20 之后)一条
for day, value in [(date(2026, 8, 5), 30), (date(2026, 8, 25), 20)]:
repo.upsert_daily(task["taskId"], day, DailyRecordInput(values=[MetricValue(metricId=metric_id, value=value)]))
metric = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]["metrics"][0]
assert metric["value"] == 30, "ref 之后的事实不得计入历史汇总"
def test_summary_lifetime_ignores_facts_before_task_start(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "未来开始", "startDate": "2026-08-15",
"metrics": [{"name": "累计", "kind": "quantity", "period": "lifetime", "target": 100, "unit": "次"}],
}))
metric_id = task["metrics"][0]["metricId"]
repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(values=[MetricValue(metricId=metric_id, value=50)]))
metric = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]["metrics"][0]
assert metric["value"] == 0, "任务开始日期之前的事实不得计入 lifetime"
assert metric["windowStart"] == "2026-08-15"
def test_percent_half_up_rounding(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "舍入", "startDate": "2026-08-01",
"metrics": [{"name": "进度", "kind": "quantity", "period": "lifetime", "target": 8, "unit": "次"}],
}))
metric_id = task["metrics"][0]["metricId"]
repo.upsert_daily(task["taskId"], date(2026, 8, 1), DailyRecordInput(values=[MetricValue(metricId=metric_id, value=1)]))
metric = repo.summarize(date(2026, 8, 1))["tasks"][task["taskId"]]["metrics"][0]
assert metric["percent"] == 13, "1/8 应四舍五入进位到 13%,而非四舍六入的 12%"
def test_session_expiry():
manager = SessionManager(make_password_hash("secret"), ttl_seconds=10)
session_id, session = manager.login("secret")
assert manager.get(session_id) is session
session.created_at -= 11 # 模拟 11 秒前的会话
assert manager.get(session_id) is None, "过期会话应当失效"
def test_session_ttl_keeps_fresh_sessions():
manager = SessionManager(make_password_hash("secret"), ttl_seconds=10)
session_id, session = manager.login("secret")
session.created_at -= 5
assert manager.get(session_id) is session