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

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
# 推荐生产环境使用 PBKDF2 哈希:python -c "from app.auth import make_password_hash; print(make_password_hash('change-me'))"
PPM_PASSWORD_HASH=
# 本地快速启动可用明文环境变量,生产环境请改用上面的哈希
PPM_PASSWORD=change-me
PPM_PORT=8000
PPM_COOKIE_SECURE=false

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
data/*
!data/.gitkeep
__pycache__/
.pytest_cache/
.env
*.pyc

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PPM_DATA_DIR=/app/data PPM_LAYOUT_DIR=/app/config/layouts
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY config ./config
COPY data ./data
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# 进度台:个人进度监视系统
一个面向单用户、单 worker 的中文个人进度面板。任务、每日事实、修订快照和可重建月归档均以 JSON 保存,适合 Docker 部署在服务器上,并通过反向代理公开 HTTPS 访问。
## 快速开始
本机需要 Python 3.11+。在当前目录执行:
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
uvicorn app.main:app --reload
```
浏览器打开 <http://127.0.0.1:8000>。不设置密码时为本机免登录模式;生产环境请在 `.env` 中设置 `PPM_PASSWORD_HASH`,或者临时设置 `PPM_PASSWORD`。
## Docker
```bash
cp .env.example .env
docker compose up -d --build
```
默认只绑定 `127.0.0.1:8000`,建议由 Nginx/Caddy 终止 HTTPS 后反代到该端口。只有单副本、单 worker 受支持;JSON 仓储使用进程锁和同目录原子替换,数据卷请纳入主机备份。
## 数据与契约
`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` 只允许白名单组件,非法布局会回退到内置默认值。
每日 0 点只关闭事实并重建归档,不删除原始记录。启动时会补跑遗漏日期;补录历史会触发对应月份重建。所有写接口带版本号冲突检测,任务删除默认为软删,后台可恢复或二次确认永久删除。
## 测试
```bash
pytest -q
```
路线图:备份恢复向导、多副本协调、跨进程压力测试和更丰富布局编辑器。

2
app/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Personal progress monitor application."""

67
app/auth.py Normal file
View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import hashlib
import hmac
import os
import secrets
from dataclasses import dataclass
def make_password_hash(password: str, iterations: int = 310_000) -> str:
salt = secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), iterations).hex()
return f"pbkdf2_sha256${iterations}${salt}${digest}"
def verify_password(password: str, encoded: str) -> bool:
try:
algorithm, iterations_text, salt, expected = encoded.split("$", 3)
if algorithm != "pbkdf2_sha256":
return False
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode(), bytes.fromhex(salt), int(iterations_text)
).hex()
return hmac.compare_digest(digest, expected)
except (ValueError, TypeError):
return False
@dataclass
class Session:
csrf: str
class SessionManager:
def __init__(self, password_hash: str | None):
self.password_hash = password_hash
self.sessions: dict[str, Session] = {}
@property
def enabled(self) -> bool:
return bool(self.password_hash)
@classmethod
def from_environment(cls) -> "SessionManager":
encoded = os.getenv("PPM_PASSWORD_HASH", "").strip()
plain = os.getenv("PPM_PASSWORD", "")
if not encoded and plain:
encoded = make_password_hash(plain)
return cls(encoded or 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
session_id = secrets.token_urlsafe(32)
session = Session(csrf=secrets.token_urlsafe(32))
self.sessions[session_id] = session
return session_id, session
def get(self, session_id: str | None) -> Session | None:
if not session_id:
return None
return self.sessions.get(session_id)
def logout(self, session_id: str | None) -> None:
if session_id:
self.sessions.pop(session_id, None)

78
app/layouts.py Normal file
View File

@@ -0,0 +1,78 @@
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",
}
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 items in regions.values():
if not isinstance(items, list):
return False
for item in items:
if not isinstance(item, dict) or set(item) - {"component", "options"}:
return False
if item.get("component") not in ALLOWED_COMPONENTS:
return False
if "options" in item and not isinstance(item["options"], dict):
return False
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])

226
app/main.py Normal file
View File

@@ -0,0 +1,226 @@
from __future__ import annotations
import asyncio
import os
import secrets
from contextlib import asynccontextmanager
from datetime import date, datetime, time, timedelta
from pathlib import Path
from typing import Annotated
from fastapi import FastAPI, Query, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from .auth import SessionManager
from .layouts import load_layout
from .models import DailyRecordInput, LoginInput, PurgeInput, ReorderInput, TaskInput, TaskUpdate
from .repository import ConflictError, JsonRepository, NotFoundError, SHANGHAI, ValidationError
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
def _cookie_secure() -> bool:
return os.getenv("PPM_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
def _set_session_cookie(response: JSONResponse, session_id: str) -> None:
response.set_cookie(
"ppm_session",
session_id,
httponly=True,
secure=_cookie_secure(),
samesite="strict",
max_age=60 * 60 * 24 * 30,
)
def _set_csrf_cookie(response: JSONResponse, token: str) -> None:
response.set_cookie(
"ppm_csrf",
token,
httponly=False,
secure=_cookie_secure(),
samesite="strict",
max_age=60 * 60 * 24 * 30,
)
async def _archive_worker(repository: JsonRepository) -> None:
while True:
now = datetime.now(SHANGHAI)
tomorrow = now.date() + timedelta(days=1)
next_run = datetime.combine(tomorrow, time(hour=0, minute=0, second=2), SHANGHAI)
await asyncio.sleep(max((next_run - now).total_seconds(), 1))
repository.close_missed_days()
def create_app(
data_dir: Path | None = None,
layout_dir: Path | None = None,
sessions: SessionManager | None = None,
) -> FastAPI:
repository = JsonRepository(data_dir or Path(os.getenv("PPM_DATA_DIR", PROJECT_ROOT / "data")))
layouts = layout_dir or Path(os.getenv("PPM_LAYOUT_DIR", PROJECT_ROOT / "config" / "layouts"))
session_manager = sessions or SessionManager.from_environment()
@asynccontextmanager
async def lifespan(_: FastAPI):
repository.close_missed_days()
worker = asyncio.create_task(_archive_worker(repository))
yield
worker.cancel()
try:
await worker
except asyncio.CancelledError:
pass
app = FastAPI(title="个人进度监视系统", version="0.1.0", lifespan=lifespan)
app.state.repository = repository
app.state.sessions = session_manager
app.mount("/static", StaticFiles(directory=PROJECT_ROOT / "app" / "static"), name="static")
@app.exception_handler(NotFoundError)
async def handle_not_found(_: Request, exc: NotFoundError) -> JSONResponse:
return JSONResponse({"detail": str(exc)}, status_code=404)
@app.exception_handler(ConflictError)
async def handle_conflict(_: Request, exc: ConflictError) -> JSONResponse:
return JSONResponse({"detail": str(exc)}, status_code=409)
@app.exception_handler(ValidationError)
async def handle_validation(_: Request, exc: ValidationError) -> JSONResponse:
return JSONResponse({"detail": str(exc)}, status_code=422)
@app.middleware("http")
async def security_middleware(request: Request, call_next):
path = request.url.path
public = path in {"/api/health", "/api/auth/status", "/api/auth/login"}
if path.startswith("/api/") and not public:
if session_manager.enabled and not session_manager.get(request.cookies.get("ppm_session")):
return JSONResponse({"detail": "需要登录"}, status_code=401)
if path.startswith("/api/") and request.method not in SAFE_METHODS and path != "/api/auth/login":
submitted = request.headers.get("X-CSRF-Token", "")
if session_manager.enabled:
session = session_manager.get(request.cookies.get("ppm_session"))
expected = session.csrf if session else ""
else:
expected = ""
if session_manager.enabled and (not expected or not secrets.compare_digest(submitted, expected)):
return JSONResponse({"detail": "CSRF 校验失败"}, status_code=403)
return await call_next(request)
@app.get("/", include_in_schema=False)
async def index() -> FileResponse:
return FileResponse(PROJECT_ROOT / "app" / "static" / "index.html")
@app.get("/api/health")
async def health() -> dict:
return {"status": "ok", "singleWorker": True}
@app.get("/api/auth/status")
async def auth_status(request: Request) -> JSONResponse:
if session_manager.enabled:
session = session_manager.get(request.cookies.get("ppm_session"))
body = {"enabled": True, "authenticated": bool(session), "csrfToken": session.csrf if session else None}
return JSONResponse(body)
token = request.cookies.get("ppm_csrf") or secrets.token_urlsafe(32)
response = JSONResponse({"enabled": False, "authenticated": True, "csrfToken": token})
_set_csrf_cookie(response, token)
return response
@app.post("/api/auth/login")
async def login(payload: LoginInput) -> JSONResponse:
result = session_manager.login(payload.password)
if result is None:
return JSONResponse({"detail": "密码错误"}, status_code=401)
session_id, session = result
response = JSONResponse({"authenticated": True, "csrfToken": session.csrf})
_set_session_cookie(response, session_id)
_set_csrf_cookie(response, session.csrf)
return response
@app.post("/api/auth/logout")
async def logout(request: Request) -> JSONResponse:
session_manager.logout(request.cookies.get("ppm_session"))
response = JSONResponse({"authenticated": False})
response.delete_cookie("ppm_session")
response.delete_cookie("ppm_csrf")
return response
@app.get("/api/layouts/{page}")
async def get_layout(page: str) -> dict:
if page not in {"home", "task-detail"}:
raise NotFoundError("布局不存在")
return load_layout(layouts, page)
@app.get("/api/tasks")
async def list_tasks(includeDeleted: bool = False) -> dict:
return repository.list_tasks(includeDeleted)
@app.post("/api/tasks", status_code=201)
async def create_task(payload: TaskInput) -> dict:
return repository.create_task(payload)
@app.get("/api/tasks/{task_id}")
async def get_task(task_id: str, includeDeleted: bool = False) -> dict:
return repository.get_task(task_id, includeDeleted)
@app.put("/api/tasks/{task_id}")
async def update_task(task_id: str, payload: TaskUpdate) -> dict:
task_input = TaskInput.model_validate(payload.model_dump(exclude={"expectedVersion"}))
return repository.update_task(task_id, task_input, payload.expectedVersion)
@app.delete("/api/tasks/{task_id}")
async def delete_task(task_id: str, expectedVersion: int = Query(ge=1)) -> dict:
return repository.soft_delete_task(task_id, expectedVersion)
@app.post("/api/tasks/{task_id}/restore")
async def restore_task(task_id: str, expectedVersion: int = Query(ge=1)) -> dict:
return repository.restore_task(task_id, expectedVersion)
@app.post("/api/tasks/{task_id}/purge")
async def purge_task(task_id: str, payload: PurgeInput) -> dict:
repository.purge_task(task_id, payload.confirmation)
return {"purged": True, "taskId": task_id}
@app.put("/api/tasks/reorder/all")
async def reorder_tasks(payload: ReorderInput) -> dict:
return repository.reorder(payload.taskIds, payload.expectedVersion)
@app.get("/api/tasks/{task_id}/daily/{logical_date}")
async def get_daily(task_id: str, logical_date: date) -> dict:
return repository.get_daily(task_id, logical_date)
@app.put("/api/tasks/{task_id}/daily/{logical_date}")
async def upsert_daily(task_id: str, logical_date: date, payload: DailyRecordInput) -> dict:
return repository.upsert_daily(task_id, logical_date, payload)
@app.get("/api/tasks/{task_id}/history")
async def history(
task_id: str,
start: Annotated[date | None, Query()] = None,
end: Annotated[date | None, Query()] = None,
) -> dict:
logical_today = datetime.now(SHANGHAI).date()
history_end = end or logical_today
history_start = start or history_end - timedelta(days=364)
return repository.history(task_id, history_start, history_end)
@app.get("/api/export")
async def export_data() -> JSONResponse:
return JSONResponse(
repository.export_all(),
headers={"Content-Disposition": "attachment; filename=progress-export.json"},
)
@app.post("/api/admin/archive")
async def run_archive() -> dict:
return {"closedDays": repository.close_missed_days()}
return app
app = create_app()

115
app/models.py Normal file
View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from datetime import date, datetime
from enum import Enum
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class MetricKind(str, Enum):
checkin = "checkin"
quantity = "quantity"
duration = "duration"
class MetricPeriod(str, Enum):
day = "day"
week = "week"
month = "month"
lifetime = "lifetime"
class MetricInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
metricId: str | None = None
name: Annotated[str, Field(min_length=1, max_length=60)]
kind: MetricKind
period: MetricPeriod = MetricPeriod.day
target: Annotated[int | None, Field(ge=1)] = None
unit: Annotated[str, Field(max_length=20)] = ""
@model_validator(mode="after")
def normalize_metric(self) -> "MetricInput":
if self.kind == MetricKind.checkin:
self.target = 1 if self.target is None else self.target
self.unit = self.unit or "次"
elif self.kind == MetricKind.duration:
self.unit = "秒"
return self
class TaskInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
name: Annotated[str, Field(min_length=1, max_length=120)]
description: Annotated[str, Field(max_length=2000)] = ""
startDate: date
endDate: date | None = None
priority: Annotated[int, Field(ge=1, le=5)] = 3
state: Literal["active", "paused"] = "active"
metrics: Annotated[list[MetricInput], Field(min_length=1, max_length=20)]
@model_validator(mode="after")
def validate_dates_and_metrics(self) -> "TaskInput":
if self.endDate and self.endDate < self.startDate:
raise ValueError("结束日期不能早于开始日期")
ids = [metric.metricId for metric in self.metrics if metric.metricId]
if len(ids) != len(set(ids)):
raise ValueError("metricId 不能重复")
return self
class TaskUpdate(TaskInput):
expectedVersion: Annotated[int, Field(ge=1)]
class ReorderInput(BaseModel):
model_config = ConfigDict(extra="forbid")
taskIds: list[str]
expectedVersion: Annotated[int, Field(ge=1)]
@field_validator("taskIds")
@classmethod
def unique_task_ids(cls, value: list[str]) -> list[str]:
if len(value) != len(set(value)):
raise ValueError("taskIds 不能重复")
return value
class MetricValue(BaseModel):
model_config = ConfigDict(extra="forbid")
metricId: str
value: Annotated[int, Field(ge=0)]
class DailyRecordInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
expectedVersion: Annotated[int | None, Field(ge=1)] = None
note: Annotated[str, Field(max_length=4000)] = ""
completedAt: datetime | None = None
values: list[MetricValue] = Field(default_factory=list)
@field_validator("values")
@classmethod
def unique_metric_values(cls, value: list[MetricValue]) -> list[MetricValue]:
ids = [item.metricId for item in value]
if len(ids) != len(set(ids)):
raise ValueError("同一指标只能提交一次")
return value
class LoginInput(BaseModel):
model_config = ConfigDict(extra="forbid")
password: Annotated[str, Field(min_length=1, max_length=500)]
class PurgeInput(BaseModel):
model_config = ConfigDict(extra="forbid")
confirmation: str

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,
}

143
app/static/app.js Normal file
View File

@@ -0,0 +1,143 @@
const state = {
tasks: [], version: 1, selectedId: null, selectedTask: null,
daily: null, history: null, layout: null, csrf: "", authEnabled: false,
filter: "", editingId: null, dragId: null,
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const today = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[char]));
const metricKindLabel = { checkin: "打卡", quantity: "数量", duration: "时长" };
const periodLabel = { day: "每日", week: "每周", month: "每月", lifetime: "总计" };
async function api(path, options = {}) {
const opts = { ...options, headers: { ...(options.body ? { "Content-Type": "application/json" } : {}), ...(options.headers || {}) } };
if (!opts.method || !["GET", "HEAD"].includes(opts.method)) opts.headers["X-CSRF-Token"] = state.csrf;
const response = await fetch(path, opts);
let body = null;
try { body = await response.json(); } catch { body = {}; }
if (response.status === 401 && state.authEnabled) { showLogin(); throw new Error(body.detail || "需要登录"); }
if (!response.ok) throw new Error(body.detail || `请求失败 (${response.status})`);
return body;
}
function toast(message) {
const node = $("#toast"); node.textContent = message; node.classList.add("show");
window.clearTimeout(toast.timer); toast.timer = window.setTimeout(() => node.classList.remove("show"), 2600);
}
function showLogin() { $("#loginOverlay").hidden = false; $("#app").setAttribute("aria-busy", "true"); }
function hideLogin() { $("#loginOverlay").hidden = true; $("#app").setAttribute("aria-busy", "false"); }
async function bootstrap() {
try {
const auth = await api("/api/auth/status");
state.authEnabled = auth.enabled; state.csrf = auth.csrfToken || "";
if (auth.enabled && !auth.authenticated) { showLogin(); return; }
hideLogin();
await loadLayout(); await loadTasks();
} catch (error) { toast(error.message); }
}
async function loadLayout() { state.layout = await api("/api/layouts/home"); }
async function loadTasks() {
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;
await render();
}
function renderSidebar() {
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}">
<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>`;
$("#taskFilter").addEventListener("input", (event) => { state.filter = event.target.value; renderSidebar(); bindSidebar(); });
$("#clearFilter").addEventListener("click", () => { state.filter = ""; renderSidebar(); bindSidebar(); }); bindSidebar();
}
function bindSidebar() {
$$(".task-item").forEach((node) => {
node.addEventListener("click", () => { state.selectedId = node.dataset.taskId; render(); });
node.addEventListener("dragstart", () => { state.dragId = node.dataset.taskId; node.classList.add("dragging"); });
node.addEventListener("dragend", () => { node.classList.remove("dragging"); state.dragId = null; });
node.addEventListener("dragover", (event) => event.preventDefault());
node.addEventListener("drop", async (event) => { event.preventDefault(); if (!state.dragId || state.dragId === node.dataset.taskId) return; await reorder(state.dragId, node.dataset.taskId); });
});
}
async function reorder(fromId, toId) {
const ids = state.tasks.map((task) => task.taskId); const from = ids.indexOf(fromId); const to = ids.indexOf(toId);
ids.splice(from, 1); ids.splice(to, 0, fromId);
try { await api("/api/tasks/reorder/all", { method: "PUT", body: JSON.stringify({ taskIds: ids, expectedVersion: state.version }) }); toast("排序已保存"); await loadTasks(); }
catch (error) { toast(error.message); }
}
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 metricTotal(metricId) { return (state.history?.records || []).reduce((sum, record) => sum + Number(record.values?.[metricId] || 0), 0); }
async function render() {
renderSidebar();
if (!state.selectedId) { renderHome(); return; }
state.selectedTask = state.tasks.find((task) => task.taskId === state.selectedId);
try { [state.daily, state.history] = await Promise.all([api(`/api/tasks/${state.selectedId}/daily/${today()}`), api(`/api/tasks/${state.selectedId}/history`)]); }
catch (error) { toast(error.message); return; }
renderDetail();
}
function renderHome() {
const active = state.tasks.filter((task) => !task.deletedAt); const completed = active.filter((task) => task.state === "active").length;
$("#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>`;
$$(".overview-row").forEach((row) => row.addEventListener("click", () => { state.selectedId = row.dataset.taskId; render(); }));
}
function taskProgress(task) {
const values = task.metrics.map((metric) => ({ value: metricTotal(metric.metricId), target: metric.target })).filter((item) => item.target);
if (!values.length) return 0; return Math.round(values.reduce((sum, item) => sum + percent(item.value, item.target), 0) / values.length);
}
function renderDetail() {
const task = state.selectedTask; const completedToday = Object.values(state.daily?.values || {}).some((value) => value > 0);
$("#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>`;
$$(".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) {
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("");
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 toLocalDatetime(value) { return value ? value.slice(0, 16) : ""; }
function bindRecordForm(task) {
$("#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)) }));
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); }
});
}
function renderInsight(task) {
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 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("");
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>`;
}
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 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); } }); }
function openTaskDialog(task = null) {
state.editingId = task?.taskId || null; const form = $("#taskForm"); form.reset(); $("#taskDialogTitle").textContent = task ? "编辑任务属性" : "新建任务";
form.startDate.value = task?.startDate || today(); form.endDate.value = task?.endDate || ""; form.name.value = task?.name || ""; form.description.value = task?.description || ""; form.priority.value = task?.priority || 3; form.state.value = task?.state || "active";
renderMetricEditor(task?.metrics || [{ name: "完成", kind: "checkin", period: "day", target: 1, unit: "次" }]); $("#taskDialog").showModal();
}
function renderMetricEditor(metrics) { $("#metricEditor").innerHTML = metrics.map((metric, index) => `<div class="metric-row" data-index="${index}" data-metric-id="${escapeHtml(metric.metricId || "")}"><label>名称<input data-field="name" value="${escapeHtml(metric.name)}" maxlength="60" required></label><label>类型<select data-field="kind"><option value="checkin" ${metric.kind === "checkin" ? "selected" : ""}>打卡</option><option value="quantity" ${metric.kind === "quantity" ? "selected" : ""}>数量</option><option value="duration" ${metric.kind === "duration" ? "selected" : ""}>时长(秒)</option></select></label><label>周期<select data-field="period"><option value="day" ${metric.period === "day" ? "selected" : ""}>每日</option><option value="week" ${metric.period === "week" ? "selected" : ""}>每周</option><option value="month" ${metric.period === "month" ? "selected" : ""}>每月</option><option value="lifetime" ${metric.period === "lifetime" ? "selected" : ""}>总计</option></select></label><label>目标<input data-field="target" type="number" min="1" placeholder="无上限" value="${metric.target ?? ""}"></label><label>单位<input data-field="unit" maxlength="20" value="${escapeHtml(metric.unit || "")}" placeholder="次"></label><button type="button" class="icon-button remove-metric" data-remove="${index}" title="删除指标" aria-label="删除指标">×</button></div>`).join(""); $$("[data-remove]").forEach((button) => button.addEventListener("click", () => { const rows = $$(".metric-row"); if (rows.length <= 1) return toast("至少保留一个指标"); button.closest(".metric-row").remove(); })); }
$("#addMetricButton").addEventListener("click", () => { const metrics = $$(".metric-row").map((row) => Object.fromEntries($$("[data-field]", row).map((input) => [input.dataset.field, input.value]))); metrics.push({ name: "新指标", kind: "quantity", period: "day", target: "", unit: "次" }); renderMetricEditor(metrics); });
$("#taskForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const metrics = $$(".metric-row").map((row) => { const result = Object.fromEntries($$("[data-field]", row).map((input) => [input.dataset.field, input.value])); result.metricId = row.dataset.metricId || null; result.target = result.target ? Number(result.target) : null; return result; }); const payload = { name: form.name.value, description: form.description.value, startDate: form.startDate.value, endDate: form.endDate.value || null, priority: Number(form.priority.value), state: form.state.value, metrics }; try { const endpoint = state.editingId ? `/api/tasks/${state.editingId}` : "/api/tasks"; const method = state.editingId ? "PUT" : "POST"; if (state.editingId) payload.expectedVersion = state.selectedTask.version; const task = await api(endpoint, { method, body: JSON.stringify(payload) }); $("#taskDialog").close(); state.selectedId = task.taskId; toast(state.editingId ? "任务属性已更新" : "任务已创建"); await loadTasks(); } catch (error) { toast(error.message); } });
$("#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); } })); });
$("#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; } });
bootstrap();

69
app/static/index.html Normal file
View File

@@ -0,0 +1,69 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>进度台</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div id="app" class="app-shell" aria-busy="true">
<header class="topbar">
<button class="brand-button" id="homeButton" type="button" title="返回总览">
<span class="brand-mark" aria-hidden="true">P</span>
<span><strong>进度台</strong><small id="logicalDate"></small></span>
</button>
<div class="top-actions">
<button class="button primary" id="addTaskButton" type="button">+ 新建任务</button>
<button class="icon-button" id="adminButton" type="button" title="数据管理" aria-label="数据管理">⚙</button>
</div>
</header>
<div class="workspace">
<aside id="sidebar" class="sidebar"></aside>
<main id="main" class="main-content"></main>
</div>
</div>
<dialog id="taskDialog" class="dialog task-dialog">
<form id="taskForm" method="dialog">
<div class="dialog-heading">
<div><p class="eyebrow">任务定义</p><h2 id="taskDialogTitle">新建任务</h2></div>
<button class="icon-button" value="cancel" formmethod="dialog" title="关闭" aria-label="关闭">×</button>
</div>
<div class="form-grid">
<label class="wide">任务名称<input name="name" required maxlength="120"></label>
<label>开始日期<input name="startDate" type="date" required></label>
<label>结束日期<input name="endDate" type="date"></label>
<label>优先级<select name="priority"><option value="1">1 · 紧急</option><option value="2">2 · 高</option><option value="3" selected>3 · 正常</option><option value="4">4 · 低</option><option value="5">5 · 挂起候选</option></select></label>
<label>状态<select name="state"><option value="active">进行中</option><option value="paused">已挂起</option></select></label>
<label class="wide">说明<textarea name="description" maxlength="2000" rows="3"></textarea></label>
</div>
<div class="metric-heading"><div><p class="eyebrow">衡量标准</p><h3>多个指标可同时记录</h3></div><button class="button subtle" id="addMetricButton" type="button">+ 添加指标</button></div>
<div id="metricEditor" class="metric-editor"></div>
<div class="dialog-actions"><button class="button" value="cancel" formmethod="dialog">取消</button><button class="button primary" id="saveTaskButton" type="submit">保存任务</button></div>
</form>
</dialog>
<dialog id="adminDialog" class="dialog admin-dialog">
<div class="dialog-heading"><div><p class="eyebrow">后台</p><h2>数据管理</h2></div><button class="icon-button" id="closeAdminButton" title="关闭" aria-label="关闭">×</button></div>
<div class="admin-actions"><button class="button" id="exportButton">导出 JSON</button><button class="button" id="archiveButton">执行补归档</button><button class="button" id="logoutButton">退出登录</button></div>
<h3>回收站</h3>
<div id="trashList" class="trash-list"></div>
</dialog>
<div id="loginOverlay" class="login-overlay" hidden>
<form id="loginForm" class="login-panel">
<span class="brand-mark large" aria-hidden="true">P</span>
<p class="eyebrow">PERSONAL PROGRESS</p>
<h1>登录进度台</h1>
<label>访问密码<input name="password" type="password" autocomplete="current-password" required autofocus></label>
<p id="loginError" class="form-error" role="alert"></p>
<button class="button primary wide-button" type="submit">登录</button>
</form>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="/static/app.js" defer></script>
</body>
</html>

127
app/static/styles.css Normal file
View File

@@ -0,0 +1,127 @@
:root {
--ink: #1d2520;
--muted: #667169;
--line: #dce3dc;
--paper: #f6f8f5;
--surface: #ffffff;
--green: #176b4b;
--green-soft: #e1f0e7;
--yellow: #d9a526;
--red: #b4473d;
--blue: #386d9d;
font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--ink);
background: var(--paper);
letter-spacing: 0;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: var(--paper); }
button, input, textarea, select { font: inherit; letter-spacing: 0; }
button { cursor: pointer; }
.app-shell { min-height: 100vh; }
.topbar { height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.96); position: sticky; top: 0; z-index: 10; }
.brand-button { display: flex; align-items: center; gap: 10px; border: 0; background: transparent; color: var(--ink); text-align: left; padding: 4px; }
.brand-button strong { display: block; font-size: 17px; }
.brand-button small { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; background: var(--ink); color: white; border-radius: 6px; font-family: Georgia, serif; font-weight: 700; }
.brand-mark.large { width: 48px; height: 48px; font-size: 22px; margin-bottom: 26px; }
.top-actions, .admin-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.button, .icon-button { min-height: 38px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 8px 13px; }
.button:hover, .icon-button:hover { border-color: #aab5ad; background: #f8faf8; }
.button.primary { background: var(--green); border-color: var(--green); color: white; }
.button.subtle { background: var(--green-soft); border-color: transparent; color: var(--green); }
.button.danger { color: var(--red); border-color: #e8c8c4; }
.icon-button { width: 38px; padding: 0; font-size: 20px; display: grid; place-items: center; }
.workspace { display: grid; grid-template-columns: minmax(300px, 360px) 1fr; min-height: calc(100vh - 68px); }
.sidebar { background: var(--surface); border-right: 1px solid var(--line); padding: 18px 14px; overflow: auto; }
.sidebar-heading { display: flex; justify-content: space-between; align-items: flex-end; padding: 0 7px 12px; }
.sidebar-heading h2 { font-size: 15px; margin: 0; }
.sidebar-heading span { color: var(--muted); font-size: 12px; }
.filter-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin: 0 6px 14px; }
input, textarea, select { width: 100%; color: var(--ink); background: white; border: 1px solid #cfd8d0; border-radius: 5px; padding: 9px 10px; outline: none; }
input:focus, textarea:focus, select:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(23,107,75,.12); }
label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #455149; }
.task-list { display: grid; gap: 5px; }
.task-item { min-height: 72px; display: grid; grid-template-columns: 5px 1fr auto; gap: 10px; align-items: center; padding: 10px 9px 10px 5px; border: 1px solid transparent; border-radius: 6px; background: transparent; cursor: pointer; }
.task-item:hover { background: #f4f7f4; }
.task-item.selected { background: var(--green-soft); border-color: #bad6c5; }
.priority-bar { width: 4px; align-self: stretch; border-radius: 3px; background: var(--blue); }
.priority-1 { background: var(--red); }.priority-2 { background: var(--yellow); }.priority-3 { background: var(--green); }.priority-4 { background: var(--blue); }.priority-5 { background: #9ba39d; }
.task-copy { min-width: 0; }
.task-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
.task-copy small { display: block; margin-top: 6px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drag-handle { color: #9aa49c; font-size: 18px; cursor: grab; padding: 10px 2px; }
.task-item.dragging { opacity: .45; }
.empty-list { color: var(--muted); padding: 32px 12px; text-align: center; font-size: 13px; }
.main-content { padding: 28px clamp(20px, 4vw, 52px) 60px; min-width: 0; }
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; margin-bottom: 26px; }
.page-heading h1 { margin: 3px 0 7px; font-size: 28px; font-weight: 680; }
.page-heading p { margin: 0; color: var(--muted); }
.eyebrow { color: var(--green); font-size: 10px; line-height: 1.2; font-weight: 800; margin: 0; text-transform: uppercase; }
.summary-strip { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); margin: 18px 0 30px; }
.summary-cell { padding: 19px 20px; border-right: 1px solid var(--line); }
.summary-cell:first-child { padding-left: 0; }.summary-cell:last-child { border: 0; }
.summary-cell strong { display: block; font-size: 25px; }.summary-cell span { color: var(--muted); font-size: 12px; }
.section { padding: 24px 0; border-bottom: 1px solid var(--line); }
.section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; margin-bottom: 16px; }
.section-heading h2 { margin: 0; font-size: 17px; }.section-heading span { font-size: 12px; color: var(--muted); }
.overview-list { display: grid; gap: 12px; }
.overview-row { display: grid; grid-template-columns: minmax(160px, 1.4fr) 2fr auto; gap: 16px; align-items: center; padding: 13px 0; cursor: pointer; }
.overview-row:hover strong { color: var(--green); }
.overview-name strong, .overview-name small { display: block; }.overview-name small { color: var(--muted); margin-top: 4px; }
.progress-track { height: 7px; background: #e5eae5; overflow: hidden; border-radius: 4px; }
.progress-fill { height: 100%; background: var(--green); border-radius: inherit; }
.muted { color: var(--muted); }.nowrap { white-space: nowrap; }
.status-badge { display: inline-flex; align-items: center; min-height: 25px; padding: 3px 8px; border-radius: 4px; background: var(--green-soft); color: var(--green); font-size: 11px; font-weight: 700; }
.status-badge.paused { background: #ecefed; color: #6d756f; }
.tabs { display: flex; gap: 3px; border-bottom: 1px solid var(--line); margin-top: 22px; }
.tab { border: 0; border-bottom: 2px solid transparent; padding: 10px 14px; background: transparent; color: var(--muted); }
.tab.active { color: var(--green); border-bottom-color: var(--green); font-weight: 700; }
.tab-panel[hidden] { display: none; }
.record-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; }
.record-field { padding: 15px; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; }
.record-field.check { display: flex; align-items: center; gap: 11px; }
.record-field.check input { width: 19px; height: 19px; }
.record-field.wide { grid-column: 1 / -1; }
.record-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; }
.metric-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.metric-card { border: 1px solid var(--line); background: var(--surface); border-radius: 6px; padding: 17px; }
.metric-card-head { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 13px; }
.metric-card strong { font-size: 14px; }.metric-card small { color: var(--muted); }
.metric-value { font-size: 22px; margin: 10px 0 4px; font-weight: 700; }
.heatmap-wrap { overflow-x: auto; padding-bottom: 8px; }
.heatmap { display: grid; grid-template-rows: repeat(7, 11px); grid-auto-flow: column; grid-auto-columns: 11px; gap: 3px; width: max-content; min-height: 95px; }
.heat-cell { width: 11px; height: 11px; background: #e3e8e3; border-radius: 2px; }
.heat-cell.level-1 { background: #b9dbc6; }.heat-cell.level-2 { background: #70b18a; }.heat-cell.level-3 { background: #2d855f; }.heat-cell.level-4 { background: #12583d; }
.heat-legend { display: flex; gap: 4px; align-items: center; justify-content: flex-end; font-size: 11px; color: var(--muted); margin-top: 8px; }
.heat-legend i { width: 11px; height: 11px; border-radius: 2px; background: #e3e8e3; }
.detail-empty { min-height: 60vh; display: grid; place-items: center; text-align: center; color: var(--muted); }
.dialog { width: min(760px, calc(100vw - 28px)); max-height: calc(100vh - 40px); border: 1px solid var(--line); border-radius: 8px; padding: 24px; color: var(--ink); box-shadow: 0 20px 60px rgba(23,33,26,.22); }
.dialog::backdrop { background: rgba(24,31,26,.42); }
.dialog-heading, .metric-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 20px; }
.dialog-heading h2, .metric-heading h3 { margin: 3px 0 0; font-size: 20px; }.metric-heading h3 { font-size: 15px; }
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }.form-grid .wide { grid-column: 1 / -1; }
.metric-heading { margin: 25px 0 10px; align-items: center; }
.metric-editor { display: grid; gap: 8px; }
.metric-row { display: grid; grid-template-columns: 1.5fr 1fr 1fr .8fr .8fr 38px; gap: 7px; align-items: end; padding: 10px; border: 1px solid var(--line); border-radius: 6px; }
.metric-row label { font-size: 10px; }.remove-metric { margin-bottom: 0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
.admin-dialog { width: min(660px, calc(100vw - 28px)); }.admin-actions { margin-bottom: 30px; }
.trash-list { display: grid; gap: 8px; }.trash-item { display: flex; justify-content: space-between; align-items: center; gap: 12px; border-top: 1px solid var(--line); padding: 12px 0; }.trash-item-actions { display: flex; gap: 6px; }
.login-overlay { position: fixed; inset: 0; z-index: 100; background: #eef2ee; display: grid; place-items: center; padding: 20px; }
.login-overlay[hidden] { display: none; }.login-panel { width: min(380px, 100%); background: white; border: 1px solid var(--line); padding: 38px; border-radius: 8px; }.login-panel h1 { margin: 6px 0 28px; font-size: 25px; }.wide-button { width: 100%; margin-top: 16px; }.form-error { color: var(--red); min-height: 18px; font-size: 12px; }
.toast { position: fixed; right: 22px; bottom: 22px; z-index: 200; max-width: min(360px, calc(100vw - 44px)); background: var(--ink); color: white; border-radius: 6px; padding: 11px 15px; transform: translateY(100px); opacity: 0; transition: .2s ease; }.toast.show { transform: translateY(0); opacity: 1; }
@media (max-width: 820px) {
.topbar { padding: 0 12px; }.button.primary { padding-inline: 10px; }
.workspace { display: block; }.sidebar { border-right: 0; border-bottom: 1px solid var(--line); max-height: 41vh; }.main-content { padding: 22px 16px 50px; }
.summary-strip { grid-template-columns: repeat(3, 1fr); }.summary-cell { padding: 15px 10px; }.summary-cell strong { font-size: 20px; }
.overview-row { grid-template-columns: 1fr auto; }.overview-row .progress-track { grid-column: 1 / -1; grid-row: 2; }
.metric-row { grid-template-columns: repeat(2, 1fr); }.remove-metric { grid-column: 2; justify-self: end; }
}
@media (max-width: 520px) {
.brand-button small { display: none; }.top-actions .primary { font-size: 0; width: 42px; }.top-actions .primary::first-letter { font-size: 18px; }
.page-heading h1 { font-size: 23px; }.summary-strip { grid-template-columns: 1fr; }.summary-cell, .summary-cell:first-child { padding: 12px 0; border-right: 0; border-bottom: 1px solid var(--line); }
.record-grid, .form-grid { grid-template-columns: 1fr; }.form-grid .wide { grid-column: auto; }.record-field.wide { grid-column: auto; }
.dialog { padding: 18px; }.metric-row { grid-template-columns: 1fr; }.remove-metric { grid-column: auto; justify-self: stretch; width: 100%; }
.trash-item { align-items: flex-start; flex-direction: column; }
}

24
compose.yaml Normal file
View File

@@ -0,0 +1,24 @@
services:
progress-monitor:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:${PPM_PORT:-8000}:8000"
environment:
PPM_DATA_DIR: /app/data
PPM_LAYOUT_DIR: /app/config/layouts
PPM_PASSWORD_HASH: ${PPM_PASSWORD_HASH:-}
PPM_PASSWORD: ${PPM_PASSWORD:-}
PPM_COOKIE_SECURE: ${PPM_COOKIE_SECURE:-false}
volumes:
- ppm-data:/app/data
- ./config/layouts:/app/config/layouts:ro
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"]
interval: 30s
timeout: 5s
retries: 3
volumes:
ppm-data:

13
config/layouts/home.json Normal file
View File

@@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"page": "home",
"regions": {
"sidebar": [
{ "component": "task-list" }
],
"main": [
{ "component": "overview-summary" },
{ "component": "task-progress-grid", "options": { "historyDays": 365 } }
]
}
}

View File

@@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"page": "task-detail",
"regions": {
"sidebar": [
{ "component": "task-list" }
],
"main": [
{ "component": "task-header" },
{ "component": "today-record" },
{ "component": "metric-progress" },
{ "component": "activity-heatmap", "options": { "historyDays": 365 } },
{ "component": "task-settings" }
]
}
}

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi==0.136.3
uvicorn[standard]==0.48.0
pydantic==2.13.2
pytest==8.4.2
httpx==0.28.1

23
tests/conftest.py Normal file
View File

@@ -0,0 +1,23 @@
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.auth import SessionManager, make_password_hash
from app.main import create_app
@pytest.fixture()
def client(tmp_path: Path):
app = create_app(tmp_path / "data", Path(__file__).parents[1] / "config" / "layouts", SessionManager(None))
with TestClient(app) as test_client:
yield test_client
@pytest.fixture()
def auth_client(tmp_path: Path):
manager = SessionManager(make_password_hash("secret"))
app = create_app(tmp_path / "data", Path(__file__).parents[1] / "config" / "layouts", manager)
with TestClient(app) as test_client:
yield test_client

31
tests/test_api.py Normal file
View File

@@ -0,0 +1,31 @@
from datetime import date
def payload(name="API 任务"):
return {"name": name, "startDate": "2026-08-01", "priority": 3, "metrics": [{"name": "完成", "kind": "checkin", "period": "day"}]}
def test_api_crud_order_layout_and_daily(client):
response = client.get("/api/health"); assert response.status_code == 200
assert client.get("/api/layouts/home").json()["page"] == "home"
first = client.post("/api/tasks", json=payload("第一项")); assert first.status_code == 201
second = client.post("/api/tasks", json=payload("第二项")).json()
task = first.json(); metric_id = task["metrics"][0]["metricId"]
listed = client.get("/api/tasks").json(); assert len(listed["items"]) == 2
reordered = client.put("/api/tasks/reorder/all", json={"taskIds": [second["taskId"], task["taskId"]], "expectedVersion": listed["version"]})
assert reordered.status_code == 200
saved = client.put(f"/api/tasks/{task['taskId']}/daily/2026-08-20", json={"values": [{"metricId": metric_id, "value": 1}], "note": "完成"})
assert saved.status_code == 200
assert client.get(f"/api/tasks/{task['taskId']}/history?start=2026-08-20&end=2026-08-20").json()["records"]
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
assert auth_client.get("/api/tasks").status_code == 200
csrf = login.json()["csrfToken"]
bad = auth_client.post("/api/tasks", json={"name": "", "startDate": "2026-08-01", "metrics": []}, headers={"X-CSRF-Token": csrf})
assert bad.status_code == 422
forbidden = auth_client.post("/api/tasks", json=payload())
assert forbidden.status_code == 403

48
tests/test_repository.py Normal file
View File

@@ -0,0 +1,48 @@
from datetime import date, timedelta
from app.models import DailyRecordInput, MetricValue, TaskInput
from app.repository import ConflictError, JsonRepository
def task_payload():
return TaskInput.model_validate({
"name": "学习计划", "startDate": "2026-08-01", "priority": 2,
"metrics": [
{"name": "打卡", "kind": "checkin", "period": "day"},
{"name": "刷题", "kind": "quantity", "period": "lifetime", "target": 100, "unit": "题"},
{"name": "专注", "kind": "duration", "period": "day", "target": 3600},
],
})
def test_crud_revision_soft_delete_and_purge(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(task_payload())
assert len(task["metrics"]) == 3
updated = repo.update_task(task["taskId"], task_payload().model_copy(update={"name": "改名后的计划"}), task["version"])
assert updated["taskId"] == task["taskId"] and updated["revision"] == 2
assert repo.history(task["taskId"], date(2026, 8, 1), date(2026, 8, 1))["revisions"]
deleted = repo.soft_delete_task(task["taskId"], updated["version"])
assert repo.list_tasks()["items"] == []
repo.restore_task(task["taskId"], deleted["version"])
repo.soft_delete_task(task["taskId"], deleted["version"] + 1)
repo.purge_task(task["taskId"], task["taskId"])
assert repo.list_tasks(include_deleted=True)["items"] == []
def test_daily_mixed_metrics_history_and_idempotent_archive(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(task_payload())
values = [MetricValue(metricId=item["metricId"], value=1 if item["kind"] == "checkin" else 120) for item in task["metrics"]]
record = repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(note="不错", values=values))
assert record["version"] == 1
repo.close_day(date(2026, 8, 10)); repo.close_day(date(2026, 8, 10))
history = repo.history(task["taskId"], date(2026, 8, 10), date(2026, 8, 10))
assert history["records"][0]["note"] == "不错"
try:
repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(expectedVersion=0, values=[]))
except ConflictError:
pass
else:
raise AssertionError("expected version conflict")

22
需求记录.md Normal file
View File

@@ -0,0 +1,22 @@
# 个人进度监视系统需求记录
## 已冻结的 MVP
- 服务器可部署、可远程访问;Docker Compose 默认绑定本机端口,生产使用反向代理和 HTTPS。
- JSON 是主存储契约,不把运行数据提交到 Git;单 worker/单副本。
- 分层 JSON:任务当前定义、任务修订、每日事实、可重建月归档;原始事实不因归档被移动或删除。
- 任务使用稳定 UUID;默认软删除;改名或修改属性保留 `taskRevision`,后台支持恢复和确认后永久删除。
- `metrics[]` 可并存 `checkin`、`quantity`、`duration`;周期 `day/week/month/lifetime`;`target: null` 表示无上限;duration 使用整数秒。
- 创建、编辑、删除、恢复、排序;字段包括名称、起止日期、优先级 1-5、状态和描述。
- 每日记录支持一键打卡、数量、时长、心得和完成时间;修改记录带版本冲突检测。
- 首页与任务详情分别读取独立布局 JSON;布局组件白名单校验,禁止动态执行。
- 中文响应式 UI:左侧任务列表和拖放排序,右侧任务详情、热力图、多指标进度和今日记录。
- 可选单用户密码会话认证、HttpOnly 会话 Cookie、基本 CSRF 校验。
## 后续路线图
- 备份/恢复向导与定期快照。
- 多 worker、多副本的分布式锁和更强一致性压力测试。
- 自定义布局编辑器、更多图表和提醒通知。
- 细化周/月目标的跨周期统计与时区偏好设置。