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:
@@ -28,10 +28,12 @@ docker compose up -d --build
|
|||||||
|
|
||||||
`data/tasks/<taskId>.json` 保存当前任务,`data/revisions/<taskId>/<revision>.json` 保存每次属性修订,`data/daily/<year>/<date>.json` 保存不可移动的每日事实,`data/archive/<year>/<month>.json` 是可重建汇总。任务 ID 和指标 ID 稳定,改名不会破坏历史。
|
`data/tasks/<taskId>.json` 保存当前任务,`data/revisions/<taskId>/<revision>.json` 保存每次属性修订,`data/daily/<year>/<date>.json` 保存不可移动的每日事实,`data/archive/<year>/<month>.json` 是可重建汇总。任务 ID 和指标 ID 稳定,改名不会破坏历史。
|
||||||
|
|
||||||
`metrics[]` 可同时含 `checkin`、`quantity`、`duration`;周期支持 `day`、`week`、`month`、`lifetime`;`target: null` 表示无上限,时长统一用整数秒。`config/layouts/home.json` 与 `task-detail.json` 只允许白名单组件,非法布局会回退到内置默认值。
|
`metrics[]` 可同时含 `checkin`、`quantity`、`duration`;周期支持 `day`、`week`、`month`、`lifetime`;`target: null` 表示无上限,时长统一用整数秒。首页与任务详情按 `config/layouts/home.json` 与 `task-detail.json` 中的组件顺序渲染;布局只允许白名单组件,且每个组件限定其所属页面/区域,非法布局会回退到内置默认值。
|
||||||
|
|
||||||
每日 0 点只关闭事实并重建归档,不删除原始记录。启动时会补跑遗漏日期;补录历史会触发对应月份重建。所有写接口带版本号冲突检测,任务删除默认为软删,后台可恢复或二次确认永久删除。
|
每日 0 点只关闭事实并重建归档,不删除原始记录。启动时会补跑遗漏日期;补录历史会触发对应月份重建。所有写接口带版本号冲突检测,任务删除默认为软删,后台可恢复或二次确认永久删除。
|
||||||
|
|
||||||
|
进度统计只读原始每日事实、绝不读可重建的月归档:`/api/summary` 汇总全部任务、`/api/tasks/<id>/summary` 汇总单个任务,均按各指标自身的周期窗口(日/周/月/累计)聚合,累计窗口的下界取任务开始日期。会话在服务端带 30 天 TTL,与登录 Cookie 的 `max_age` 一致。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
18
app/auth.py
18
app/auth.py
@@ -4,7 +4,11 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from dataclasses import dataclass
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 # 与登录 Cookie 的 max_age 一致
|
||||||
|
|
||||||
|
|
||||||
def make_password_hash(password: str, iterations: int = 310_000) -> str:
|
def make_password_hash(password: str, iterations: int = 310_000) -> str:
|
||||||
@@ -29,11 +33,13 @@ def verify_password(password: str, encoded: str) -> bool:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Session:
|
class Session:
|
||||||
csrf: str
|
csrf: str
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
class SessionManager:
|
||||||
def __init__(self, password_hash: str | None):
|
def __init__(self, password_hash: str | None, ttl_seconds: float = SESSION_TTL_SECONDS):
|
||||||
self.password_hash = password_hash
|
self.password_hash = password_hash
|
||||||
|
self.ttl_seconds = ttl_seconds
|
||||||
self.sessions: dict[str, Session] = {}
|
self.sessions: dict[str, Session] = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -48,9 +54,16 @@ class SessionManager:
|
|||||||
encoded = make_password_hash(plain)
|
encoded = make_password_hash(plain)
|
||||||
return cls(encoded or None)
|
return cls(encoded or None)
|
||||||
|
|
||||||
|
def _expire(self) -> None:
|
||||||
|
cutoff = time.time() - self.ttl_seconds
|
||||||
|
expired = [sid for sid, session in self.sessions.items() if session.created_at < cutoff]
|
||||||
|
for sid in expired:
|
||||||
|
self.sessions.pop(sid, None)
|
||||||
|
|
||||||
def login(self, password: str) -> tuple[str, Session] | None:
|
def login(self, password: str) -> tuple[str, Session] | None:
|
||||||
if not self.password_hash or not verify_password(password, self.password_hash):
|
if not self.password_hash or not verify_password(password, self.password_hash):
|
||||||
return None
|
return None
|
||||||
|
self._expire()
|
||||||
session_id = secrets.token_urlsafe(32)
|
session_id = secrets.token_urlsafe(32)
|
||||||
session = Session(csrf=secrets.token_urlsafe(32))
|
session = Session(csrf=secrets.token_urlsafe(32))
|
||||||
self.sessions[session_id] = session
|
self.sessions[session_id] = session
|
||||||
@@ -59,6 +72,7 @@ class SessionManager:
|
|||||||
def get(self, session_id: str | None) -> Session | None:
|
def get(self, session_id: str | None) -> Session | None:
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return None
|
return None
|
||||||
|
self._expire()
|
||||||
return self.sessions.get(session_id)
|
return self.sessions.get(session_id)
|
||||||
|
|
||||||
def logout(self, session_id: str | None) -> None:
|
def logout(self, session_id: str | None) -> None:
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ ALLOWED_COMPONENTS = {
|
|||||||
"task-settings",
|
"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 = {
|
FALLBACKS = {
|
||||||
"home": {
|
"home": {
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
@@ -51,16 +64,24 @@ def validate_layout(layout: Any, expected_page: str) -> bool:
|
|||||||
regions = layout.get("regions")
|
regions = layout.get("regions")
|
||||||
if not isinstance(regions, dict) or set(regions) - {"sidebar", "main"}:
|
if not isinstance(regions, dict) or set(regions) - {"sidebar", "main"}:
|
||||||
return False
|
return False
|
||||||
for items in regions.values():
|
for region, items in regions.items():
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
return False
|
return False
|
||||||
|
seen: set[str] = set()
|
||||||
for item in items:
|
for item in items:
|
||||||
if not isinstance(item, dict) or set(item) - {"component", "options"}:
|
if not isinstance(item, dict) or set(item) - {"component", "options"}:
|
||||||
return False
|
return False
|
||||||
if item.get("component") not in ALLOWED_COMPONENTS:
|
component = item.get("component")
|
||||||
|
if component not in ALLOWED_COMPONENTS:
|
||||||
return False
|
return False
|
||||||
if "options" in item and not isinstance(item["options"], dict):
|
if "options" in item and not isinstance(item["options"], dict):
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,14 @@ def create_app(
|
|||||||
async def upsert_daily(task_id: str, logical_date: date, payload: DailyRecordInput) -> dict:
|
async def upsert_daily(task_id: str, logical_date: date, payload: DailyRecordInput) -> dict:
|
||||||
return repository.upsert_daily(task_id, logical_date, payload)
|
return repository.upsert_daily(task_id, logical_date, payload)
|
||||||
|
|
||||||
|
@app.get("/api/summary")
|
||||||
|
async def summary(date_: Annotated[date | None, Query(alias="date")] = None) -> dict:
|
||||||
|
return repository.summarize(date_)
|
||||||
|
|
||||||
|
@app.get("/api/tasks/{task_id}/summary")
|
||||||
|
async def task_summary(task_id: str, date_: Annotated[date | None, Query(alias="date")] = None) -> dict:
|
||||||
|
return repository.summarize(date_, task_id)
|
||||||
|
|
||||||
@app.get("/api/tasks/{task_id}/history")
|
@app.get("/api/tasks/{task_id}/history")
|
||||||
async def history(
|
async def history(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class MetricValue(BaseModel):
|
|||||||
class DailyRecordInput(BaseModel):
|
class DailyRecordInput(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||||
|
|
||||||
expectedVersion: Annotated[int | None, Field(ge=1)] = None
|
expectedVersion: Annotated[int | None, Field(ge=0)] = None
|
||||||
note: Annotated[str, Field(max_length=4000)] = ""
|
note: Annotated[str, Field(max_length=4000)] = ""
|
||||||
completedAt: datetime | None = None
|
completedAt: datetime | None = None
|
||||||
values: list[MetricValue] = Field(default_factory=list)
|
values: list[MetricValue] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
from calendar import monthrange
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -34,6 +35,47 @@ def now_iso() -> str:
|
|||||||
return datetime.now(SHANGHAI).isoformat(timespec="seconds")
|
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:
|
class JsonRepository:
|
||||||
"""Single-process JSON repository with atomic same-directory replacement."""
|
"""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]:
|
def get_daily(self, task_id: str, logical_date: date) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._load_task(task_id)
|
task = self._load_task(task_id)
|
||||||
daily = self._read(self._daily_path(logical_date), {
|
daily = self._read(self._daily_path(logical_date), {
|
||||||
"schemaVersion": SCHEMA_VERSION,
|
"schemaVersion": SCHEMA_VERSION,
|
||||||
"date": logical_date.isoformat(),
|
"date": logical_date.isoformat(),
|
||||||
@@ -260,7 +302,7 @@ class JsonRepository:
|
|||||||
})
|
})
|
||||||
return daily["records"].get(task_id, {
|
return daily["records"].get(task_id, {
|
||||||
"taskId": task_id,
|
"taskId": task_id,
|
||||||
"taskRevision": self._load_task(task_id)["revision"],
|
"taskRevision": task["revision"],
|
||||||
"version": 0,
|
"version": 0,
|
||||||
"note": "",
|
"note": "",
|
||||||
"completedAt": None,
|
"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))]
|
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}
|
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]:
|
def _rebuild_month(self, year: int, month: int) -> dict[str, Any]:
|
||||||
summaries: dict[str, Any] = {}
|
summaries: dict[str, Any] = {}
|
||||||
folder = self.daily_dir / str(year)
|
folder = self.daily_dir / str(year)
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
const state = {
|
const state = {
|
||||||
tasks: [], version: 1, selectedId: null, selectedTask: null,
|
tasks: [], version: 1, selectedId: null, selectedTask: null,
|
||||||
daily: null, history: null, layout: null, csrf: "", authEnabled: false,
|
daily: null, history: null, summary: null, csrf: "", authEnabled: false,
|
||||||
filter: "", editingId: null, dragId: null,
|
filter: "", editingId: null, dragId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (selector, root = document) => root.querySelector(selector);
|
const $ = (selector, root = document) => root.querySelector(selector);
|
||||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||||
const today = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
|
const today = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
|
||||||
|
const shanghaiDate = (offsetDays) => {
|
||||||
|
const now = new Date(Date.now() + offsetDays * 86400000);
|
||||||
|
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(now);
|
||||||
|
};
|
||||||
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[char]));
|
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[char]));
|
||||||
const metricKindLabel = { checkin: "打卡", quantity: "数量", duration: "时长" };
|
const metricKindLabel = { checkin: "打卡", quantity: "数量", duration: "时长" };
|
||||||
const periodLabel = { day: "每日", week: "每周", month: "每月", lifetime: "总计" };
|
const periodLabel = { day: "每日", week: "每周", month: "每月", lifetime: "总计" };
|
||||||
@@ -31,32 +35,46 @@ function showLogin() { $("#loginOverlay").hidden = false; $("#app").setAttribute
|
|||||||
function hideLogin() { $("#loginOverlay").hidden = true; $("#app").setAttribute("aria-busy", "false"); }
|
function hideLogin() { $("#loginOverlay").hidden = true; $("#app").setAttribute("aria-busy", "false"); }
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
const dateNode = $("#logicalDate"); if (dateNode) dateNode.textContent = today();
|
||||||
try {
|
try {
|
||||||
const auth = await api("/api/auth/status");
|
const auth = await api("/api/auth/status");
|
||||||
state.authEnabled = auth.enabled; state.csrf = auth.csrfToken || "";
|
state.authEnabled = auth.enabled; state.csrf = auth.csrfToken || "";
|
||||||
if (auth.enabled && !auth.authenticated) { showLogin(); return; }
|
if (auth.enabled && !auth.authenticated) { showLogin(); return; }
|
||||||
hideLogin();
|
hideLogin();
|
||||||
await loadLayout(); await loadTasks();
|
await loadTasks();
|
||||||
} catch (error) { toast(error.message); }
|
} catch (error) { toast(error.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLayout() { state.layout = await api("/api/layouts/home"); }
|
const layoutCache = { home: null, "task-detail": null };
|
||||||
|
async function ensureLayout(page) { if (!layoutCache[page]) layoutCache[page] = await api(`/api/layouts/${page}`); return layoutCache[page]; }
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
const result = await api("/api/tasks"); state.tasks = result.items; state.version = result.version;
|
const result = await api("/api/tasks"); state.tasks = result.items; state.version = result.version;
|
||||||
if (!state.selectedId || !state.tasks.some((task) => task.taskId === state.selectedId)) state.selectedId = state.tasks[0]?.taskId || null;
|
if (!state.selectedId || !state.tasks.some((task) => task.taskId === state.selectedId)) state.selectedId = state.tasks[0]?.taskId || null;
|
||||||
await render();
|
await render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSidebar() {
|
function renderTaskList() {
|
||||||
const filtered = state.tasks.filter((task) => !state.filter || task.name.toLowerCase().includes(state.filter.toLowerCase()));
|
const filtered = state.tasks.filter((task) => !state.filter || task.name.toLowerCase().includes(state.filter.toLowerCase()));
|
||||||
const items = filtered.map((task) => `<div class="task-item ${task.taskId === state.selectedId ? "selected" : ""}" draggable="true" data-task-id="${task.taskId}">
|
const items = filtered.map((task) => `<div class="task-item ${task.taskId === state.selectedId ? "selected" : ""}" draggable="true" data-task-id="${task.taskId}">
|
||||||
<span class="priority-bar priority-${task.priority}"></span><span class="task-copy"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></span><span class="drag-handle" title="拖动排序" aria-hidden="true">⠿</span></div>`).join("");
|
<span class="priority-bar priority-${task.priority}"></span><span class="task-copy"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></span><span class="drag-handle" title="拖动排序" aria-hidden="true">⠿</span></div>`).join("");
|
||||||
$("#sidebar").innerHTML = `<div class="sidebar-heading"><div><p class="eyebrow">WORKSPACE</p><h2>我的任务</h2></div><span>${state.tasks.length} 项</span></div><div class="filter-row"><input id="taskFilter" placeholder="搜索任务" value="${escapeHtml(state.filter)}"><button class="icon-button" id="clearFilter" title="清除搜索" aria-label="清除搜索">×</button></div><div class="task-list">${items || `<div class="empty-list">还没有任务<br>从右上角新建一项计划</div>`}</div>`;
|
return `<div class="sidebar-heading"><div><p class="eyebrow">WORKSPACE</p><h2>我的任务</h2></div><span>${state.tasks.length} 项</span></div><div class="filter-row"><input id="taskFilter" placeholder="搜索任务" value="${escapeHtml(state.filter)}"><button class="icon-button" id="clearFilter" title="清除搜索" aria-label="清除搜索">×</button></div><div class="task-list">${items || `<div class="empty-list">还没有任务<br>从右上角新建一项计划</div>`}</div>`;
|
||||||
$("#taskFilter").addEventListener("input", (event) => { state.filter = event.target.value; renderSidebar(); bindSidebar(); });
|
|
||||||
$("#clearFilter").addEventListener("click", () => { state.filter = ""; renderSidebar(); bindSidebar(); }); bindSidebar();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindSidebar() {
|
function bindSidebar() {
|
||||||
|
const filterInput = $("#taskFilter");
|
||||||
|
if (filterInput) {
|
||||||
|
filterInput.addEventListener("input", (event) => { state.filter = event.target.value; rerenderSidebarList(); });
|
||||||
|
$("#clearFilter")?.addEventListener("click", () => { state.filter = ""; rerenderSidebarList(); });
|
||||||
|
}
|
||||||
|
bindSidebarItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rerenderSidebarList() {
|
||||||
|
$("#sidebar").innerHTML = renderTaskList();
|
||||||
|
bindSidebar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSidebarItems() {
|
||||||
$$(".task-item").forEach((node) => {
|
$$(".task-item").forEach((node) => {
|
||||||
node.addEventListener("click", () => { state.selectedId = node.dataset.taskId; render(); });
|
node.addEventListener("click", () => { state.selectedId = node.dataset.taskId; render(); });
|
||||||
node.addEventListener("dragstart", () => { state.dragId = node.dataset.taskId; node.classList.add("dragging"); });
|
node.addEventListener("dragstart", () => { state.dragId = node.dataset.taskId; node.classList.add("dragging"); });
|
||||||
@@ -74,57 +92,149 @@ async function reorder(fromId, toId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function periodText(task) { const metrics = task.metrics || []; return [...new Set(metrics.map((metric) => periodLabel[metric.period] || metric.period))].join(" / "); }
|
function periodText(task) { const metrics = task.metrics || []; return [...new Set(metrics.map((metric) => periodLabel[metric.period] || metric.period))].join(" / "); }
|
||||||
function percent(value, target) { return target ? Math.min(100, Math.round(value / target * 100)) : value ? 100 : 0; }
|
function summaryFor(taskId) { return state.summary?.tasks?.[taskId] || null; }
|
||||||
function metricTotal(metricId) { return (state.history?.records || []).reduce((sum, record) => sum + Number(record.values?.[metricId] || 0), 0); }
|
function metricSummary(taskId, metricId) { return summaryFor(taskId)?.metrics?.find((metric) => metric.metricId === metricId) || null; }
|
||||||
|
|
||||||
async function render() {
|
// ---- 组件注册表:布局白名单对应的渲染器与事件绑定 ----
|
||||||
renderSidebar();
|
// 页面/区域约束与单例约束由服务端 validate_layout 保证(非法布局回退到内置默认),
|
||||||
if (!state.selectedId) { renderHome(); return; }
|
// 前端只负责渲染已校验的布局,不再二次过滤以免静默丢组件。
|
||||||
state.selectedTask = state.tasks.find((task) => task.taskId === state.selectedId);
|
const components = {
|
||||||
try { [state.daily, state.history] = await Promise.all([api(`/api/tasks/${state.selectedId}/daily/${today()}`), api(`/api/tasks/${state.selectedId}/history`)]); }
|
"task-list": { render: renderTaskList, bind: bindSidebar },
|
||||||
catch (error) { toast(error.message); return; }
|
"overview-summary": { render: renderOverviewSummary },
|
||||||
renderDetail();
|
"task-progress-grid": { render: renderTaskProgressGrid, bind: bindOverview },
|
||||||
|
"task-header": { render: renderTaskHeader, bind: bindTaskHeader },
|
||||||
|
"today-record": { render: renderTodayRecord, bind: bindRecordForm },
|
||||||
|
"metric-progress": { render: renderMetricProgress },
|
||||||
|
"activity-heatmap": { render: renderActivityHeatmap },
|
||||||
|
"task-settings": { render: renderTaskSettings, bind: bindSettings },
|
||||||
|
};
|
||||||
|
|
||||||
|
function componentHtml(item) { const entry = components[item.component]; return entry ? entry.render(item.options || {}) : ""; }
|
||||||
|
|
||||||
|
function bindComponents(items) {
|
||||||
|
for (const item of items) {
|
||||||
|
const entry = components[item.component];
|
||||||
|
if (entry?.bind) entry.bind(item.options || {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHome() {
|
async function renderLayout(page) {
|
||||||
const active = state.tasks.filter((task) => !task.deletedAt); const completed = active.filter((task) => task.state === "active").length;
|
const layout = await ensureLayout(page);
|
||||||
$("#main").innerHTML = `<div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${today()}</p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong>${active.length}</strong><span>进行中的任务</span></div><div class="summary-cell"><strong>${completed}</strong><span>活跃计划</span></div><div class="summary-cell"><strong>${active.filter((task) => task.priority <= 2).length}</strong><span>高优先级</span></div></div><section class="section"><div class="section-heading"><h2>任务进度</h2><span>点击任务查看详情</span></div><div class="overview-list">${active.map((task) => `<div class="overview-row" data-task-id="${task.taskId}"><div class="overview-name"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></div><div class="progress-track"><div class="progress-fill" style="width:${taskProgress(task)}%"></div></div><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : `${taskProgress(task)}%`}</span></div>`).join("") || `<div class="empty-list">还没有计划,从右上角开始添加。</div>`}</div></section>`;
|
const sidebarItems = layout.regions.sidebar || [];
|
||||||
|
const mainItems = layout.regions.main || [];
|
||||||
|
$("#sidebar").innerHTML = sidebarItems.map(componentHtml).join("");
|
||||||
|
$("#main").innerHTML = mainItems.map(componentHtml).join("");
|
||||||
|
bindComponents(sidebarItems);
|
||||||
|
bindComponents(mainItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
if (!state.selectedId) {
|
||||||
|
try { state.summary = await api("/api/summary"); } catch (error) { toast(error.message); }
|
||||||
|
await renderLayout("home");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.selectedTask = state.tasks.find((task) => task.taskId === state.selectedId);
|
||||||
|
const layout = await ensureLayout("task-detail");
|
||||||
|
const heatmap = (layout.regions.main || []).find((item) => item.component === "activity-heatmap");
|
||||||
|
const historyDays = Math.min(heatmap?.options?.historyDays ?? 365, 730);
|
||||||
|
const start = shanghaiDate(-(historyDays - 1));
|
||||||
|
try {
|
||||||
|
[state.summary, state.daily, state.history] = await Promise.all([
|
||||||
|
api("/api/summary"),
|
||||||
|
api(`/api/tasks/${state.selectedId}/daily/${today()}`),
|
||||||
|
api(`/api/tasks/${state.selectedId}/history?start=${start}&end=${today()}`),
|
||||||
|
]);
|
||||||
|
} catch (error) { toast(error.message); return; }
|
||||||
|
await renderLayout("task-detail");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverviewSummary() {
|
||||||
|
const active = state.tasks.filter((task) => !task.deletedAt); const activePlans = active.filter((task) => task.state === "active").length;
|
||||||
|
return `<div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${today()}</p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong>${active.length}</strong><span>进行中的任务</span></div><div class="summary-cell"><strong>${activePlans}</strong><span>活跃计划</span></div><div class="summary-cell"><strong>${active.filter((task) => task.priority <= 2).length}</strong><span>高优先级</span></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskProgressGrid() {
|
||||||
|
const active = state.tasks.filter((task) => !task.deletedAt);
|
||||||
|
return `<section class="section"><div class="section-heading"><h2>任务进度</h2><span>按各指标自身周期计算</span></div><div class="overview-list">${active.map((task) => `<div class="overview-row" data-task-id="${task.taskId}"><div class="overview-name"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></div><div class="progress-track"><div class="progress-fill" style="width:${taskProgress(task) ?? 0}%"></div></div><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${progressBadge(task)}</span></div>`).join("") || `<div class="empty-list">还没有计划,从右上角开始添加。</div>`}</div></section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindOverview() {
|
||||||
$$(".overview-row").forEach((row) => row.addEventListener("click", () => { state.selectedId = row.dataset.taskId; render(); }));
|
$$(".overview-row").forEach((row) => row.addEventListener("click", () => { state.selectedId = row.dataset.taskId; render(); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskProgress(task) {
|
function taskProgress(task) { const value = summaryFor(task.taskId)?.progress; return value === null || value === undefined ? null : value; }
|
||||||
const values = task.metrics.map((metric) => ({ value: metricTotal(metric.metricId), target: metric.target })).filter((item) => item.target);
|
function progressBadge(task) {
|
||||||
if (!values.length) return 0; return Math.round(values.reduce((sum, item) => sum + percent(item.value, item.target), 0) / values.length);
|
if (task.state === "paused") return "已挂起";
|
||||||
|
const value = taskProgress(task);
|
||||||
|
return value === null ? "无上限" : `${value}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetail() {
|
function renderTaskHeader() {
|
||||||
const task = state.selectedTask; const completedToday = Object.values(state.daily?.values || {}).some((value) => value > 0);
|
const task = state.selectedTask;
|
||||||
$("#main").innerHTML = `<div class="page-heading"><div><p class="eyebrow">TASK DETAIL · 优先级 ${task.priority}</p><h1>${escapeHtml(task.name)}</h1><p>${escapeHtml(task.description || "还没有写下说明")}</p></div><div class="top-actions"><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : "进行中"}</span><button class="button" id="editTaskButton">编辑属性</button></div></div><div class="tabs"><button class="tab active" data-tab="record">今日记录</button><button class="tab" data-tab="insight">完成情况</button><button class="tab" data-tab="settings">任务设置</button></div><div class="tab-panel" data-panel="record">${renderTodayRecord(task, completedToday)}</div><div class="tab-panel" data-panel="insight" hidden>${renderInsight(task)}</div><div class="tab-panel" data-panel="settings" hidden>${renderSettings(task)}</div>`;
|
return `<div class="page-heading"><div><p class="eyebrow">TASK DETAIL · 优先级 ${task.priority}</p><h1>${escapeHtml(task.name)}</h1><p>${escapeHtml(task.description || "还没有写下说明")}</p></div><div class="top-actions"><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : "进行中"}</span><button class="button" id="editTaskButton">编辑属性</button></div></div>`;
|
||||||
$$(".tab").forEach((tab) => tab.addEventListener("click", () => { $$(".tab").forEach((item) => item.classList.toggle("active", item === tab)); $$(".tab-panel").forEach((panel) => panel.hidden = panel.dataset.panel !== tab.dataset.tab); }));
|
|
||||||
$("#editTaskButton").addEventListener("click", () => openTaskDialog(task)); bindRecordForm(task); bindSettings(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTodayRecord(task, completedToday) {
|
function bindTaskHeader() {
|
||||||
const fields = task.metrics.map((metric) => { const value = state.daily?.values?.[metric.metricId] || 0; if (metric.kind === "checkin") return `<label class="record-field check"><input type="checkbox" data-metric-id="${metric.metricId}" value="1" ${value ? "checked" : ""}><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${periodLabel[metric.period]}</small></span></label>`; return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${metricKindLabel[metric.kind]} · ${metric.unit || (metric.kind === "duration" ? "秒" : "次")} · ${periodLabel[metric.period]}</small></span><input type="number" min="0" step="1" data-metric-id="${metric.metricId}" value="${value}"></label>`; }).join("");
|
$("#editTaskButton").addEventListener("click", () => openTaskDialog(state.selectedTask));
|
||||||
return `<section class="section"><div class="section-heading"><div><h2>今天,${today()}</h2><span>只记录今天真实完成的部分</span></div><span class="status-badge ${completedToday ? "" : "paused"}">${completedToday ? "已有记录" : "尚未记录"}</span></div><div class="record-grid">${fields}<label class="record-field wide">心得<textarea id="dailyNote" rows="4" maxlength="4000" placeholder="记下今天的阻力、收获或下一步">${escapeHtml(state.daily?.note || "")}</textarea></label><label class="record-field">完成时间<input id="completedAt" type="datetime-local" value="${toLocalDatetime(state.daily?.completedAt)}"></label></div><div class="record-actions"><span class="muted">修改会保留任务历史版本</span><button class="button primary" id="saveDailyButton">保存今日记录</button></div></section>`;
|
}
|
||||||
|
|
||||||
|
function toDurationString(seconds) { const total = Math.max(0, Math.floor(Number(seconds) || 0)); return `${Math.floor(total / 3600)}:${String(Math.floor(total % 3600 / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; }
|
||||||
|
function fromDurationString(value) { const parts = String(value || "0").trim().split(":").map((part) => Math.max(0, Math.floor(Number(part) || 0))); const [hours = 0, minutes = 0, secs = 0] = parts; return hours * 3600 + minutes * 60 + secs; }
|
||||||
|
|
||||||
|
function metricField(metric) {
|
||||||
|
const value = state.daily?.values?.[metric.metricId] || 0;
|
||||||
|
if (metric.kind === "checkin") return `<label class="record-field check"><input type="checkbox" data-metric-id="${metric.metricId}" value="1" ${value ? "checked" : ""}><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${periodLabel[metric.period]}</small></span></label>`;
|
||||||
|
if (metric.kind === "duration") return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">时长 · 时:分:秒 · ${periodLabel[metric.period]}</small></span><input type="text" inputmode="numeric" placeholder="0:30:00" data-kind="duration" data-metric-id="${metric.metricId}" value="${toDurationString(value)}"></label>`;
|
||||||
|
return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${metricKindLabel[metric.kind]} · ${metric.unit || "次"} · ${periodLabel[metric.period]}</small></span><input type="number" min="0" step="1" data-metric-id="${metric.metricId}" value="${value}"></label>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTodayRecord() {
|
||||||
|
const task = state.selectedTask;
|
||||||
|
const completedToday = Object.values(state.daily?.values || {}).some((value) => value > 0);
|
||||||
|
const hasCheckin = task.metrics.some((metric) => metric.kind === "checkin");
|
||||||
|
const fields = task.metrics.map(metricField).join("");
|
||||||
|
return `<section class="section" id="todayRecord"><div class="section-heading"><div><h2>今天,${today()}</h2><span>只记录今天真实完成的部分</span></div><span class="status-badge ${completedToday ? "" : "paused"}">${completedToday ? "已有记录" : "尚未记录"}</span></div><div class="record-grid">${fields}<label class="record-field wide">心得<textarea id="dailyNote" rows="4" maxlength="4000" placeholder="记下今天的阻力、收获或下一步">${escapeHtml(state.daily?.note || "")}</textarea></label><label class="record-field">完成时间<input id="completedAt" type="datetime-local" value="${toLocalDatetime(state.daily?.completedAt)}"></label></div><div class="record-actions">${hasCheckin ? `<button class="button primary" id="quickCheckinButton" type="button">一键打卡</button>` : `<span class="muted">修改会保留任务历史版本</span>`}<button class="button primary" id="saveDailyButton">保存今日记录</button></div></section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTodayValues() {
|
||||||
|
return $$(`#todayRecord [data-metric-id]`).map((node) => ({
|
||||||
|
metricId: node.dataset.metricId,
|
||||||
|
value: node.type === "checkbox" ? (node.checked ? 1 : 0) : (node.dataset.kind === "duration" ? fromDurationString(node.value) : Math.max(0, Number(node.value || 0))),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTodayRecord(values = collectTodayValues()) {
|
||||||
|
const task = state.selectedTask;
|
||||||
|
const completedAt = $("#todayRecord #completedAt")?.value || "";
|
||||||
|
return api(`/api/tasks/${task.taskId}/daily/${today()}`, { method: "PUT", body: JSON.stringify({ expectedVersion: state.daily?.version || 0, note: $("#todayRecord #dailyNote")?.value || "", completedAt: completedAt ? `${completedAt}:00` : null, values }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocalDatetime(value) { return value ? value.slice(0, 16) : ""; }
|
function toLocalDatetime(value) { return value ? value.slice(0, 16) : ""; }
|
||||||
function bindRecordForm(task) {
|
function bindRecordForm() {
|
||||||
$("#saveDailyButton").addEventListener("click", async () => {
|
$("#saveDailyButton").addEventListener("click", async () => {
|
||||||
const values = $$(`[data-metric-id]`).map((node) => ({ metricId: node.dataset.metricId, value: node.type === "checkbox" ? (node.checked ? 1 : 0) : Math.max(0, Number(node.value || 0)) }));
|
try { await saveTodayRecord(); toast("今日记录已保存"); await render(); } catch (error) { toast(error.message); }
|
||||||
const completedAt = $("#completedAt").value; try { await api(`/api/tasks/${task.taskId}/daily/${today()}`, { method: "PUT", body: JSON.stringify({ expectedVersion: state.daily?.version || 0, note: $("#dailyNote").value, completedAt: completedAt ? new Date(completedAt).toISOString() : null, values }) }); toast("今日记录已保存"); await render(); } catch (error) { toast(error.message); }
|
});
|
||||||
|
$("#quickCheckinButton")?.addEventListener("click", async () => {
|
||||||
|
$$(`#todayRecord [data-metric-id][type="checkbox"]`).forEach((node) => { node.checked = true; });
|
||||||
|
try { await saveTodayRecord(); toast("已打卡"); await render(); } catch (error) { toast(error.message); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderInsight(task) {
|
function renderMetricProgress() {
|
||||||
const cards = task.metrics.map((metric) => { const total = metricTotal(metric.metricId); const target = metric.target; return `<div class="metric-card"><div class="metric-card-head"><strong>${escapeHtml(metric.name)}</strong><small>${metricKindLabel[metric.kind]}</small></div><div class="metric-value">${total}${target ? ` <small>/ ${target}</small>` : ""}</div><div class="progress-track"><div class="progress-fill" style="width:${percent(total, target)}%"></div></div><small class="muted">${target ? `${percent(total, target)}% · ${periodLabel[metric.period]}` : `累计 ${periodLabel[metric.period]} · 无上限`}</small></div>`; }).join("");
|
const task = state.selectedTask;
|
||||||
const heat = Array.from({ length: 364 }, (_, index) => { const day = new Date(); day.setDate(day.getDate() - (363 - index)); const iso = day.toISOString().slice(0, 10); const record = state.history?.records?.find((item) => item.date === iso); const amount = record ? Object.values(record.values || {}).reduce((sum, value) => sum + Number(value), 0) : 0; return `<span class="heat-cell level-${amount ? Math.min(4, Math.ceil(amount / 2)) : 0}" title="${iso} · ${amount}"></span>`; }).join("");
|
const cards = task.metrics.map((metric) => { const info = metricSummary(task.taskId, metric.metricId) || { value: 0, target: metric.target, percent: null, windowStart: "", windowEnd: "" }; const scope = metric.period === "lifetime" ? `自 ${info.windowStart}` : `${info.windowStart} ~ ${info.windowEnd}`; return `<div class="metric-card"><div class="metric-card-head"><strong>${escapeHtml(metric.name)}</strong><small>${metricKindLabel[metric.kind]}</small></div><div class="metric-value">${info.value}${info.target ? ` <small>/ ${info.target}</small>` : ""}</div><div class="progress-track"><div class="progress-fill" style="width:${info.percent ?? 0}%"></div></div><small class="muted">${periodLabel[metric.period]} · ${info.percent === null ? "无上限" : `${info.percent}%`} · ${scope}</small></div>`; }).join("");
|
||||||
return `<section class="section"><div class="section-heading"><h2>指标总览</h2><span>基于每日事实计算</span></div><div class="metric-cards">${cards}</div></section><section class="section"><div class="section-heading"><h2>活动热力图</h2><span>过去 12 个月</span></div><div class="heatmap-wrap"><div class="heatmap">${heat}</div></div><div class="heat-legend"><span>少</span><i></i><i class="heat-cell level-1"></i><i class="heat-cell level-2"></i><i class="heat-cell level-3"></i><i class="heat-cell level-4"></i><span>多</span></div></section>`;
|
return `<section class="section"><div class="section-heading"><h2>指标总览</h2><span>基于每日事实计算</span></div><div class="metric-cards">${cards}</div></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSettings(task) { return `<section class="section"><div class="section-heading"><div><h2>危险操作</h2><span>默认保留历史,可从后台彻底清理</span></div></div><button class="button danger" id="deleteTaskButton">移入回收站</button></section>`; }
|
function renderActivityHeatmap(options) {
|
||||||
function bindSettings(task) { $("#deleteTaskButton").addEventListener("click", async () => { if (!confirm(`确认将“${task.name}”移入回收站?`)) return; try { await api(`/api/tasks/${task.taskId}?expectedVersion=${task.version}`, { method: "DELETE" }); state.selectedId = null; toast("任务已移入回收站"); await loadTasks(); } catch (error) { toast(error.message); } }); }
|
const days = Math.min(options.historyDays ?? 365, 730);
|
||||||
|
const heat = Array.from({ length: days }, (_, index) => { const iso = shanghaiDate(index - (days - 1)); const record = state.history?.records?.find((item) => item.date === iso); const amount = record ? Object.values(record.values || {}).reduce((sum, value) => sum + Number(value), 0) : 0; return `<span class="heat-cell level-${amount ? Math.min(4, Math.ceil(amount / 2)) : 0}" title="${iso} · ${amount}"></span>`; }).join("");
|
||||||
|
return `<section class="section"><div class="section-heading"><h2>活动热力图</h2><span>过去 ${Math.round(days / 30)} 个月</span></div><div class="heatmap-wrap"><div class="heatmap">${heat}</div></div><div class="heat-legend"><span>少</span><i></i><i class="heat-cell level-1"></i><i class="heat-cell level-2"></i><i class="heat-cell level-3"></i><i class="heat-cell level-4"></i><span>多</span></div></section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskSettings() { return `<section class="section"><div class="section-heading"><div><h2>危险操作</h2><span>默认保留历史,可从后台彻底清理</span></div></div><button class="button danger" id="deleteTaskButton">移入回收站</button></section>`; }
|
||||||
|
function bindSettings() { const task = state.selectedTask; $("#deleteTaskButton").addEventListener("click", async () => { if (!confirm(`确认将“${task.name}”移入回收站?`)) return; try { await api(`/api/tasks/${task.taskId}?expectedVersion=${task.version}`, { method: "DELETE" }); state.selectedId = null; toast("任务已移入回收站"); await loadTasks(); } catch (error) { toast(error.message); } }); }
|
||||||
|
|
||||||
function openTaskDialog(task = null) {
|
function openTaskDialog(task = null) {
|
||||||
state.editingId = task?.taskId || null; const form = $("#taskForm"); form.reset(); $("#taskDialogTitle").textContent = task ? "编辑任务属性" : "新建任务";
|
state.editingId = task?.taskId || null; const form = $("#taskForm"); form.reset(); $("#taskDialogTitle").textContent = task ? "编辑任务属性" : "新建任务";
|
||||||
@@ -139,5 +249,5 @@ $("#taskForm").addEventListener("submit", async (event) => { event.preventDefaul
|
|||||||
$("#addTaskButton").addEventListener("click", () => openTaskDialog()); $("#homeButton").addEventListener("click", () => { state.selectedId = null; render(); });
|
$("#addTaskButton").addEventListener("click", () => openTaskDialog()); $("#homeButton").addEventListener("click", () => { state.selectedId = null; render(); });
|
||||||
$("#adminButton").addEventListener("click", async () => { const result = await api("/api/tasks?includeDeleted=true"); const deleted = result.items.filter((task) => task.deletedAt); $("#trashList").innerHTML = deleted.length ? deleted.map((task) => `<div class="trash-item"><span><strong>${escapeHtml(task.name)}</strong><small class="muted">${task.deletedAt}</small></span><span class="trash-item-actions"><button class="button" data-restore="${task.taskId}">恢复</button><button class="button danger" data-purge="${task.taskId}">永久删除</button></span></div>`).join("") : `<p class="muted">回收站为空</p>`; $("#adminDialog").showModal(); $$("[data-restore]").forEach((button) => button.addEventListener("click", async () => { try { await api(`/api/tasks/${button.dataset.restore}/restore?expectedVersion=${deleted.find((task) => task.taskId === button.dataset.restore).version}`, { method: "POST" }); toast("任务已恢复"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); $$("[data-purge]").forEach((button) => button.addEventListener("click", async () => { const task = deleted.find((item) => item.taskId === button.dataset.purge); if (!confirm(`永久删除“${task.name}”?此操作不可恢复。`)) return; try { await api(`/api/tasks/${task.taskId}/purge`, { method: "POST", body: JSON.stringify({ confirmation: task.taskId }) }); toast("任务及其历史已永久删除"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); });
|
$("#adminButton").addEventListener("click", async () => { const result = await api("/api/tasks?includeDeleted=true"); const deleted = result.items.filter((task) => task.deletedAt); $("#trashList").innerHTML = deleted.length ? deleted.map((task) => `<div class="trash-item"><span><strong>${escapeHtml(task.name)}</strong><small class="muted">${task.deletedAt}</small></span><span class="trash-item-actions"><button class="button" data-restore="${task.taskId}">恢复</button><button class="button danger" data-purge="${task.taskId}">永久删除</button></span></div>`).join("") : `<p class="muted">回收站为空</p>`; $("#adminDialog").showModal(); $$("[data-restore]").forEach((button) => button.addEventListener("click", async () => { try { await api(`/api/tasks/${button.dataset.restore}/restore?expectedVersion=${deleted.find((task) => task.taskId === button.dataset.restore).version}`, { method: "POST" }); toast("任务已恢复"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); $$("[data-purge]").forEach((button) => button.addEventListener("click", async () => { const task = deleted.find((item) => item.taskId === button.dataset.purge); if (!confirm(`永久删除“${task.name}”?此操作不可恢复。`)) return; try { await api(`/api/tasks/${task.taskId}/purge`, { method: "POST", body: JSON.stringify({ confirmation: task.taskId }) }); toast("任务及其历史已永久删除"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); });
|
||||||
$("#closeAdminButton").addEventListener("click", () => $("#adminDialog").close()); $("#exportButton").addEventListener("click", async () => { const response = await fetch("/api/export"); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "progress-export.json"; link.click(); URL.revokeObjectURL(link.href); }); $("#archiveButton").addEventListener("click", async () => { try { const result = await api("/api/admin/archive", { method: "POST", body: "{}" }); toast(`补归档完成,处理 ${result.closedDays} 天`); } catch (error) { toast(error.message); } }); $("#logoutButton").addEventListener("click", async () => { await api("/api/auth/logout", { method: "POST", body: "{}" }); showLogin(); });
|
$("#closeAdminButton").addEventListener("click", () => $("#adminDialog").close()); $("#exportButton").addEventListener("click", async () => { const response = await fetch("/api/export"); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "progress-export.json"; link.click(); URL.revokeObjectURL(link.href); }); $("#archiveButton").addEventListener("click", async () => { try { const result = await api("/api/admin/archive", { method: "POST", body: "{}" }); toast(`补归档完成,处理 ${result.closedDays} 天`); } catch (error) { toast(error.message); } }); $("#logoutButton").addEventListener("click", async () => { await api("/api/auth/logout", { method: "POST", body: "{}" }); showLogin(); });
|
||||||
$("#loginForm").addEventListener("submit", async (event) => { event.preventDefault(); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: event.currentTarget.password.value }) }); const body = await response.json(); if (!response.ok) throw new Error(body.detail || "登录失败"); state.csrf = body.csrfToken; hideLogin(); await loadLayout(); await loadTasks(); } catch (error) { $("#loginError").textContent = error.message; } });
|
$("#loginForm").addEventListener("submit", async (event) => { event.preventDefault(); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: event.currentTarget.password.value }) }); const body = await response.json(); if (!response.ok) throw new Error(body.detail || "登录失败"); state.csrf = body.csrfToken; hideLogin(); await loadTasks(); } catch (error) { $("#loginError").textContent = error.message; } });
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
1
data/.gitkeep
Normal file
1
data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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"]
|
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):
|
def test_invalid_input_and_auth(auth_client):
|
||||||
assert auth_client.get("/api/tasks").status_code == 401
|
assert auth_client.get("/api/tasks").status_code == 401
|
||||||
login = auth_client.post("/api/auth/login", json={"password": "secret"}); assert login.status_code == 200
|
login = auth_client.post("/api/auth/login", json={"password": "secret"}); assert login.status_code == 200
|
||||||
|
|||||||
42
tests/test_layouts.py
Normal file
42
tests/test_layouts.py
Normal 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"}]
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from app.auth import SessionManager, make_password_hash
|
||||||
from app.models import DailyRecordInput, MetricValue, TaskInput
|
from app.models import DailyRecordInput, MetricValue, TaskInput
|
||||||
from app.repository import ConflictError, JsonRepository
|
from app.repository import ConflictError, JsonRepository, period_window
|
||||||
|
|
||||||
|
|
||||||
def task_payload():
|
def task_payload():
|
||||||
@@ -46,3 +47,136 @@ def test_daily_mixed_metrics_history_and_idempotent_archive(tmp_path):
|
|||||||
else:
|
else:
|
||||||
raise AssertionError("expected version conflict")
|
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
|
||||||
|
|||||||
33
需求记录.md
33
需求记录.md
@@ -1,17 +1,30 @@
|
|||||||
# 个人进度监视系统需求记录
|
# 个人进度监视系统需求记录
|
||||||
|
|
||||||
|
> 实现状态(2026-08-24):以下「已冻结的 MVP」已全部在代码中落地并通过测试(`pytest -q`,17 项)。
|
||||||
|
> 每条后面的 `[x]` 表示已完成。周期统计、布局驱动渲染、一键打卡、时长友好输入、会话 TTL
|
||||||
|
> 与日期边界处理为本次补充完成项。
|
||||||
|
|
||||||
## 已冻结的 MVP
|
## 已冻结的 MVP
|
||||||
|
|
||||||
- 服务器可部署、可远程访问;Docker Compose 默认绑定本机端口,生产使用反向代理和 HTTPS。
|
- [x] 服务器可部署、可远程访问;Docker Compose 默认绑定本机端口,生产使用反向代理和 HTTPS。
|
||||||
- JSON 是主存储契约,不把运行数据提交到 Git;单 worker/单副本。
|
- [x] JSON 是主存储契约,不把运行数据提交到 Git;单 worker/单副本。
|
||||||
- 分层 JSON:任务当前定义、任务修订、每日事实、可重建月归档;原始事实不因归档被移动或删除。
|
- [x] 分层 JSON:任务当前定义、任务修订、每日事实、可重建月归档;原始事实不因归档被移动或删除。
|
||||||
- 任务使用稳定 UUID;默认软删除;改名或修改属性保留 `taskRevision`,后台支持恢复和确认后永久删除。
|
- [x] 任务使用稳定 UUID;默认软删除;改名或修改属性保留 `taskRevision`,后台支持恢复和确认后永久删除。
|
||||||
- `metrics[]` 可并存 `checkin`、`quantity`、`duration`;周期 `day/week/month/lifetime`;`target: null` 表示无上限;duration 使用整数秒。
|
- [x] `metrics[]` 可并存 `checkin`、`quantity`、`duration`;周期 `day/week/month/lifetime`;`target: null` 表示无上限;duration 使用整数秒。
|
||||||
- 创建、编辑、删除、恢复、排序;字段包括名称、起止日期、优先级 1-5、状态和描述。
|
- [x] 创建、编辑、删除、恢复、排序;字段包括名称、起止日期、优先级 1-5、状态和描述。
|
||||||
- 每日记录支持一键打卡、数量、时长、心得和完成时间;修改记录带版本冲突检测。
|
- [x] 每日记录支持一键打卡、数量、时长、心得和完成时间;修改记录带版本冲突检测。
|
||||||
- 首页与任务详情分别读取独立布局 JSON;布局组件白名单校验,禁止动态执行。
|
- [x] 首页与任务详情分别读取独立布局 JSON;布局组件白名单校验,禁止动态执行。
|
||||||
- 中文响应式 UI:左侧任务列表和拖放排序,右侧任务详情、热力图、多指标进度和今日记录。
|
- [x] 中文响应式 UI:左侧任务列表和拖放排序,右侧任务详情、热力图、多指标进度和今日记录。
|
||||||
- 可选单用户密码会话认证、HttpOnly 会话 Cookie、基本 CSRF 校验。
|
- [x] 可选单用户密码会话认证、HttpOnly 会话 Cookie、基本 CSRF 校验(会话服务端 30 天 TTL)。
|
||||||
|
|
||||||
|
## 已实现的实现细节(本次补充)
|
||||||
|
|
||||||
|
- 进度统计只读原始每日事实、绝不读月归档;`/api/summary` 与 `/api/tasks/<id>/summary`
|
||||||
|
按各指标自身周期窗口聚合,`lifetime` 下界取任务开始日期,历史查询不会计入 `ref` 之后的事实。
|
||||||
|
- 百分比采用与浏览器 `Math.round` 一致的四舍五入进位(half-up),避免 `.5` 边界不一致。
|
||||||
|
- 布局组件按页面/区域约束(如 `task-header`/`today-record` 仅限任务详情页),非法组件不渲染。
|
||||||
|
- 时长输入为「时:分:秒」,完整 round-trip 任意非负整数秒;一键打卡一次提交所有打卡指标。
|
||||||
|
- 极端日期(`date.min`/`date.max` 附近的 month/week 窗口)不再溢出,返回 200。
|
||||||
|
|
||||||
## 后续路线图
|
## 后续路线图
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user