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

@@ -4,7 +4,11 @@ import hashlib
import hmac
import os
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:
@@ -29,11 +33,13 @@ def verify_password(password: str, encoded: str) -> bool:
@dataclass
class Session:
csrf: str
created_at: float = field(default_factory=time.time)
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.ttl_seconds = ttl_seconds
self.sessions: dict[str, Session] = {}
@property
@@ -48,9 +54,16 @@ class SessionManager:
encoded = make_password_hash(plain)
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:
if not self.password_hash or not verify_password(password, self.password_hash):
return None
self._expire()
session_id = secrets.token_urlsafe(32)
session = Session(csrf=secrets.token_urlsafe(32))
self.sessions[session_id] = session
@@ -59,6 +72,7 @@ class SessionManager:
def get(self, session_id: str | None) -> Session | None:
if not session_id:
return None
self._expire()
return self.sessions.get(session_id)
def logout(self, session_id: str | None) -> None: