- 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>
100 lines
3.2 KiB
Python
100 lines
3.2 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ALLOWED_COMPONENTS = {
|
|
"task-list",
|
|
"overview-summary",
|
|
"task-progress-grid",
|
|
"task-header",
|
|
"today-record",
|
|
"metric-progress",
|
|
"activity-heatmap",
|
|
"task-settings",
|
|
}
|
|
|
|
# 每个组件允许出现的页面与区域。组件在各自区域内必须单例,避免重复 ID
|
|
# 与重复事件绑定;跨页/跨区错放(例如把 today-record 放进首页)视为非法布局。
|
|
COMPONENT_SCOPES: dict[str, tuple[tuple[str, ...], str]] = {
|
|
"task-list": (("home", "task-detail"), "sidebar"),
|
|
"overview-summary": (("home",), "main"),
|
|
"task-progress-grid": (("home",), "main"),
|
|
"task-header": (("task-detail",), "main"),
|
|
"today-record": (("task-detail",), "main"),
|
|
"metric-progress": (("task-detail",), "main"),
|
|
"activity-heatmap": (("task-detail",), "main"),
|
|
"task-settings": (("task-detail",), "main"),
|
|
}
|
|
|
|
FALLBACKS = {
|
|
"home": {
|
|
"schemaVersion": 1,
|
|
"page": "home",
|
|
"regions": {
|
|
"sidebar": [{"component": "task-list"}],
|
|
"main": [{"component": "overview-summary"}, {"component": "task-progress-grid"}],
|
|
},
|
|
},
|
|
"task-detail": {
|
|
"schemaVersion": 1,
|
|
"page": "task-detail",
|
|
"regions": {
|
|
"sidebar": [{"component": "task-list"}],
|
|
"main": [
|
|
{"component": "task-header"},
|
|
{"component": "today-record"},
|
|
{"component": "metric-progress"},
|
|
{"component": "activity-heatmap"},
|
|
{"component": "task-settings"},
|
|
],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def validate_layout(layout: Any, expected_page: str) -> bool:
|
|
if not isinstance(layout, dict):
|
|
return False
|
|
if layout.get("schemaVersion") != 1 or layout.get("page") != expected_page:
|
|
return False
|
|
regions = layout.get("regions")
|
|
if not isinstance(regions, dict) or set(regions) - {"sidebar", "main"}:
|
|
return False
|
|
for region, items in regions.items():
|
|
if not isinstance(items, list):
|
|
return False
|
|
seen: set[str] = set()
|
|
for item in items:
|
|
if not isinstance(item, dict) or set(item) - {"component", "options"}:
|
|
return False
|
|
component = item.get("component")
|
|
if component not in ALLOWED_COMPONENTS:
|
|
return False
|
|
if "options" in item and not isinstance(item["options"], dict):
|
|
return False
|
|
pages, expected_region = COMPONENT_SCOPES[component]
|
|
if expected_page not in pages or region != expected_region:
|
|
return False
|
|
if component in seen:
|
|
return False
|
|
seen.add(component)
|
|
return True
|
|
|
|
|
|
def load_layout(layout_dir: Path, page: str) -> dict[str, Any]:
|
|
if page not in FALLBACKS:
|
|
raise KeyError(page)
|
|
try:
|
|
with (layout_dir / f"{page}.json").open("r", encoding="utf-8") as handle:
|
|
layout = json.load(handle)
|
|
if validate_layout(layout, page):
|
|
return layout
|
|
except (OSError, json.JSONDecodeError):
|
|
pass
|
|
return deepcopy(FALLBACKS[page])
|
|
|