feat: 新增日程面板、复盘页面与 Agent 读写接口

- 日程:单次/每日/每周/每月重复,JSON 存储,月历按优先级着色圆点 + 悬停提示
- 复盘页:可自定义折线图卡片(SVG) + 每日心得总览(按日期/任务展开)
- Agent 接口:GET /api/agent/overview 读日程+进度,POST /api/agent/schedules 建日程(PPM_API_TOKEN Bearer 鉴权)
- 导出/导入/统计纳入日程与图表;47 项测试全绿
This commit is contained in:
MobKBK
2026-09-04 14:46:41 +08:00
parent 5f50eb54ed
commit 8340d1dbbd
13 changed files with 1027 additions and 19 deletions

View File

@@ -28,12 +28,20 @@ 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 稳定,改名不会破坏历史。
`data/schedules/<scheduleId>.json` 保存日程(支持单次与每日/每周/每月重复,`completions` 按日期记录完成情况),`data/schedule-revisions/<scheduleId>/<revision>.json` 保存日程修订快照。
顶栏「复盘」页提供每日完成趋势图和每日心得总览。图表保存在 `data/charts/<chartId>.json`,可按任务或指标配置天数;`/api/charts` 提供图表 CRUD,`/api/charts/<id>/data` 返回连续日期点列,`/api/review/notes` 返回日期倒序的心得记录。导出备份包含 `charts` 列表,旧备份缺省该字段时仍可导入。
`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` 一致。 进度统计只读原始每日事实、绝不读可重建的月归档:`/api/summary` 汇总全部任务、`/api/tasks/<id>/summary` 汇总单个任务,均按各指标自身的周期窗口(日/周/月/累计)聚合,累计窗口的下界取任务开始日期。会话在服务端带 30 天 TTL,与登录 Cookie 的 `max_age` 一致。
日程 API 与任务同风格:`/api/schedules` 提供 CRUD、`/api/schedules/calendar?month=YYYY-MM` 月视图、`/api/schedules/today?date=` 当日日程(含重复展开与完成状态);首页「日程安排」面板由 `schedule-board` 布局组件渲染。
机器接口:设置环境变量 `PPM_API_TOKEN` 后,可带 `Authorization: Bearer <token>` 访问 Agent 接口——`GET /api/agent/overview?date=` 读取「当日日程 + 任务进度」,`POST /api/agent/schedules` 新建日程;未配置 token 时这些接口返回 503(fail-secure)。
## 测试 ## 测试
```bash ```bash
@@ -41,4 +49,3 @@ pytest -q
``` ```
路线图:备份恢复向导、多副本协调、跨进程压力测试和更丰富布局编辑器。 路线图:备份恢复向导、多副本协调、跨进程压力测试和更丰富布局编辑器。

View File

@@ -16,12 +16,15 @@ ALLOWED_COMPONENTS = {
"metric-progress", "metric-progress",
"activity-heatmap", "activity-heatmap",
"task-settings", "task-settings",
"schedule-board",
"review-charts",
"review-notes",
} }
# 每个组件允许出现的页面与区域。组件在各自区域内必须单例,避免重复 ID # 每个组件允许出现的页面与区域。组件在各自区域内必须单例,避免重复 ID
# 与重复事件绑定;跨页/跨区错放(例如把 today-record 放进首页)视为非法布局。 # 与重复事件绑定;跨页/跨区错放(例如把 today-record 放进首页)视为非法布局。
COMPONENT_SCOPES: dict[str, tuple[tuple[str, ...], str]] = { COMPONENT_SCOPES: dict[str, tuple[tuple[str, ...], str]] = {
"task-list": (("home", "task-detail"), "sidebar"), "task-list": (("home", "task-detail", "review"), "sidebar"),
"overview-summary": (("home",), "main"), "overview-summary": (("home",), "main"),
"task-progress-grid": (("home",), "main"), "task-progress-grid": (("home",), "main"),
"task-gantt": (("home",), "main"), "task-gantt": (("home",), "main"),
@@ -30,6 +33,9 @@ COMPONENT_SCOPES: dict[str, tuple[tuple[str, ...], str]] = {
"metric-progress": (("task-detail",), "main"), "metric-progress": (("task-detail",), "main"),
"activity-heatmap": (("task-detail",), "main"), "activity-heatmap": (("task-detail",), "main"),
"task-settings": (("task-detail",), "main"), "task-settings": (("task-detail",), "main"),
"schedule-board": (("home",), "main"),
"review-charts": (("review",), "main"),
"review-notes": (("review",), "main"),
} }
FALLBACKS = { FALLBACKS = {
@@ -42,6 +48,7 @@ FALLBACKS = {
{"component": "overview-summary"}, {"component": "overview-summary"},
{"component": "task-progress-grid"}, {"component": "task-progress-grid"},
{"component": "task-gantt"}, {"component": "task-gantt"},
{"component": "schedule-board"},
], ],
}, },
}, },
@@ -59,6 +66,14 @@ FALLBACKS = {
], ],
}, },
}, },
"review": {
"schemaVersion": 1,
"page": "review",
"regions": {
"sidebar": [{"component": "task-list"}],
"main": [{"component": "review-charts"}, {"component": "review-notes"}],
},
},
} }
@@ -102,4 +117,3 @@ def load_layout(layout_dir: Path, page: str) -> dict[str, Any]:
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
pass pass
return deepcopy(FALLBACKS[page]) return deepcopy(FALLBACKS[page])

View File

@@ -8,13 +8,13 @@ from datetime import date, datetime, time, timedelta
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
from fastapi import FastAPI, Query, Request from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .auth import SessionManager from .auth import SessionManager
from .layouts import load_layout from .layouts import load_layout
from .models import DailyRecordInput, LoginInput, PurgeInput, ReorderInput, TaskInput, TaskUpdate from .models import ChartInput, ChartUpdate, DailyRecordInput, LoginInput, PurgeInput, ReorderInput, ScheduleCompleteInput, ScheduleInput, ScheduleUpdate, TaskInput, TaskUpdate
from .repository import ConflictError, JsonRepository, NotFoundError, SHANGHAI, ValidationError from .repository import ConflictError, JsonRepository, NotFoundError, SHANGHAI, ValidationError
@@ -22,6 +22,15 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
def require_agent_token(request: Request):
token = os.getenv("PPM_API_TOKEN", "").strip()
if not token:
raise HTTPException(status_code=503, detail="agent API 未启用")
auth = request.headers.get("Authorization", "")
if not secrets.compare_digest(auth, f"Bearer {token}"):
raise HTTPException(status_code=401, detail="token 无效")
def _cookie_secure() -> bool: def _cookie_secure() -> bool:
return os.getenv("PPM_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"} return os.getenv("PPM_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
@@ -97,11 +106,11 @@ def create_app(
@app.middleware("http") @app.middleware("http")
async def security_middleware(request: Request, call_next): async def security_middleware(request: Request, call_next):
path = request.url.path path = request.url.path
public = path in {"/api/health", "/api/auth/status", "/api/auth/login"} public = path in {"/api/health", "/api/auth/status", "/api/auth/login"} or path.startswith("/api/agent/")
if path.startswith("/api/") and not public: if path.startswith("/api/") and not public:
if session_manager.enabled and not session_manager.get(request.cookies.get("ppm_session")): if session_manager.enabled and not session_manager.get(request.cookies.get("ppm_session")):
return JSONResponse({"detail": "需要登录"}, status_code=401) return JSONResponse({"detail": "需要登录"}, status_code=401)
if path.startswith("/api/") and request.method not in SAFE_METHODS and path != "/api/auth/login": if path.startswith("/api/") and request.method not in SAFE_METHODS and path != "/api/auth/login" and not path.startswith("/api/agent/"):
submitted = request.headers.get("X-CSRF-Token", "") submitted = request.headers.get("X-CSRF-Token", "")
if session_manager.enabled: if session_manager.enabled:
session = session_manager.get(request.cookies.get("ppm_session")) session = session_manager.get(request.cookies.get("ppm_session"))
@@ -155,7 +164,7 @@ def create_app(
@app.get("/api/layouts/{page}") @app.get("/api/layouts/{page}")
async def get_layout(page: str) -> dict: async def get_layout(page: str) -> dict:
if page not in {"home", "task-detail"}: if page not in {"home", "task-detail", "review"}:
raise NotFoundError("布局不存在") raise NotFoundError("布局不存在")
return load_layout(layouts, page) return load_layout(layouts, page)
@@ -189,6 +198,67 @@ def create_app(
repository.purge_task(task_id, payload.confirmation) repository.purge_task(task_id, payload.confirmation)
return {"purged": True, "taskId": task_id} return {"purged": True, "taskId": task_id}
@app.get("/api/schedules")
async def list_schedules(includeDeleted: bool = False) -> dict:
return repository.list_schedules(includeDeleted)
@app.post("/api/schedules", status_code=201)
async def create_schedule(payload: ScheduleInput) -> dict:
return repository.create_schedule(payload)
@app.get("/api/schedules/calendar")
async def schedule_calendar(month: Annotated[str | None, Query()] = None) -> dict:
value = month or datetime.now(SHANGHAI).strftime("%Y-%m")
try:
year, month_number = (int(part) for part in value.split("-"))
if len(value) != 7 or value[4] != "-" or not 1 <= month_number <= 12:
raise ValueError
except ValueError:
raise ValidationError("月份必须是 YYYY-MM 格式")
return repository.calendar_month(year, month_number)
@app.get("/api/schedules/today")
async def schedules_today(date_: Annotated[date | None, Query(alias="date")] = None) -> dict:
day = date_ or datetime.now(SHANGHAI).date()
return {"date": day.isoformat(), "items": repository.schedules_for_date(day)}
@app.get("/api/schedules/{schedule_id}")
async def get_schedule(schedule_id: str, includeDeleted: bool = False) -> dict:
return repository.get_schedule(schedule_id, includeDeleted)
@app.put("/api/schedules/{schedule_id}")
async def update_schedule(schedule_id: str, payload: ScheduleUpdate) -> dict:
schedule_input = ScheduleInput.model_validate(payload.model_dump(exclude={"expectedVersion"}))
return repository.update_schedule(schedule_id, schedule_input, payload.expectedVersion)
@app.delete("/api/schedules/{schedule_id}")
async def delete_schedule(schedule_id: str, expectedVersion: int = Query(ge=1)) -> dict:
return repository.soft_delete_schedule(schedule_id, expectedVersion)
@app.post("/api/schedules/{schedule_id}/restore")
async def restore_schedule(schedule_id: str, expectedVersion: int = Query(ge=1)) -> dict:
return repository.restore_schedule(schedule_id, expectedVersion)
@app.post("/api/schedules/{schedule_id}/purge")
async def purge_schedule(schedule_id: str, payload: PurgeInput) -> dict:
repository.purge_schedule(schedule_id, payload.confirmation)
return {"purged": True, "scheduleId": schedule_id}
@app.post("/api/schedules/{schedule_id}/complete")
async def complete_schedule(schedule_id: str, payload: ScheduleCompleteInput) -> dict:
return repository.complete_schedule(schedule_id, payload.date, payload.completed, payload.expectedVersion)
@app.get("/api/agent/overview")
async def agent_overview(
date_: Annotated[date | None, Query(alias="date")] = None,
_: None = Depends(require_agent_token),
) -> dict:
return repository.agent_overview(date_ or datetime.now(SHANGHAI).date())
@app.post("/api/agent/schedules", status_code=201)
async def agent_create_schedule(payload: ScheduleInput, _: None = Depends(require_agent_token)) -> dict:
return repository.create_schedule(payload)
@app.put("/api/tasks/reorder/all") @app.put("/api/tasks/reorder/all")
async def reorder_tasks(payload: ReorderInput) -> dict: async def reorder_tasks(payload: ReorderInput) -> dict:
return repository.reorder(payload.taskIds, payload.expectedVersion) return repository.reorder(payload.taskIds, payload.expectedVersion)
@@ -209,6 +279,41 @@ def create_app(
async def task_summary(task_id: str, date_: Annotated[date | None, Query(alias="date")] = None) -> dict: async def task_summary(task_id: str, date_: Annotated[date | None, Query(alias="date")] = None) -> dict:
return repository.summarize(date_, task_id) return repository.summarize(date_, task_id)
@app.get("/api/charts")
async def list_charts() -> dict:
return repository.list_charts()
@app.post("/api/charts", status_code=201)
async def create_chart(payload: ChartInput) -> dict:
return repository.create_chart(payload)
@app.get("/api/charts/{chart_id}/data")
async def chart_data(chart_id: str, ref: Annotated[date | None, Query()] = None) -> dict:
return repository.chart_data(chart_id, ref)
@app.get("/api/charts/{chart_id}")
async def get_chart(chart_id: str) -> dict:
return repository.get_chart(chart_id)
@app.put("/api/charts/{chart_id}")
async def update_chart(chart_id: str, payload: ChartUpdate) -> dict:
chart_input = ChartInput.model_validate(payload.model_dump(exclude={"expectedVersion"}))
return repository.update_chart(chart_id, chart_input, payload.expectedVersion)
@app.delete("/api/charts/{chart_id}")
async def delete_chart(chart_id: str) -> dict:
return repository.delete_chart(chart_id)
@app.get("/api/review/notes")
async def review_notes(
start: Annotated[date | None, Query()] = None,
end: Annotated[date | None, Query()] = None,
) -> dict:
logical_today = datetime.now(SHANGHAI).date()
review_end = end or logical_today
review_start = start or review_end - timedelta(days=29)
return repository.review_notes(review_start, review_end)
@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,

View File

@@ -121,3 +121,59 @@ class PurgeInput(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
confirmation: str confirmation: str
class ScheduleRecurrence(str, Enum):
none = "none"
daily = "daily"
weekly = "weekly"
monthly = "monthly"
class ScheduleInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
title: Annotated[str, Field(min_length=1, max_length=120)]
description: Annotated[str, Field(max_length=2000)] = ""
date: date
time: Annotated[str | None, Field(max_length=5)] = None
recurrence: ScheduleRecurrence = ScheduleRecurrence.none
priority: Annotated[int, Field(ge=1, le=5)] = 3
state: Literal["active", "archived"] = "active"
@field_validator("time")
@classmethod
def validate_time(cls, value: str | None) -> str | None:
if value is None or value == "":
return None
if len(value) != 5 or value[2] != ":" or not value[:2].isdigit() or not value[3:].isdigit():
raise ValueError("时间必须是 HH:MM 格式")
hour, minute = int(value[:2]), int(value[3:])
if hour > 23 or minute > 59:
raise ValueError("时间必须是合法的时分")
return value
class ScheduleUpdate(ScheduleInput):
expectedVersion: Annotated[int, Field(ge=1)]
class ScheduleCompleteInput(BaseModel):
model_config = ConfigDict(extra="forbid")
date: date
completed: bool = True
expectedVersion: Annotated[int, Field(ge=1)]
class ChartInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
title: Annotated[str, Field(min_length=1, max_length=80)]
taskId: str | None = None
metricId: str | None = None
days: Annotated[int, Field(ge=7, le=365)] = 30
class ChartUpdate(ChartInput):
expectedVersion: Annotated[int, Field(ge=1)]

View File

@@ -12,7 +12,7 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from .models import DailyRecordInput, TaskInput from .models import ChartInput, DailyRecordInput, ScheduleInput, TaskInput
SCHEMA_VERSION = 1 SCHEMA_VERSION = 1
@@ -85,9 +85,12 @@ class JsonRepository:
self.revisions_dir = self.root / "revisions" self.revisions_dir = self.root / "revisions"
self.daily_dir = self.root / "daily" self.daily_dir = self.root / "daily"
self.archive_dir = self.root / "archive" self.archive_dir = self.root / "archive"
self.schedules_dir = self.root / "schedules"
self.schedule_revisions_dir = self.root / "schedule-revisions"
self.charts_dir = self.root / "charts"
self.meta_path = self.root / "meta.json" self.meta_path = self.root / "meta.json"
self._lock = threading.RLock() self._lock = threading.RLock()
for folder in (self.root, self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir): for folder in (self.root, self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir, self.schedules_dir, self.schedule_revisions_dir, self.charts_dir):
folder.mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
if not self.meta_path.exists(): if not self.meta_path.exists():
yesterday = date.today() - timedelta(days=1) yesterday = date.today() - timedelta(days=1)
@@ -130,6 +133,280 @@ class JsonRepository:
def _task_path(self, task_id: str) -> Path: def _task_path(self, task_id: str) -> Path:
return self.tasks_dir / f"{task_id}.json" return self.tasks_dir / f"{task_id}.json"
def _schedule_path(self, schedule_id: str) -> Path:
return self.schedules_dir / f"{schedule_id}.json"
def _chart_path(self, chart_id: str) -> Path:
return self.charts_dir / f"{chart_id}.json"
def _load_chart(self, chart_id: str) -> dict[str, Any]:
chart = self._read(self._chart_path(chart_id))
if chart is None:
raise NotFoundError("图表不存在")
return chart
def create_chart(self, payload: ChartInput) -> dict[str, Any]:
with self._lock:
chart_id = str(uuid4())
timestamp = now_iso()
chart = {
"schemaVersion": SCHEMA_VERSION,
"chartId": chart_id,
"version": 1,
**payload.model_dump(mode="json"),
"createdAt": timestamp,
"updatedAt": timestamp,
}
self._atomic_write(self._chart_path(chart_id), chart)
return chart
def list_charts(self) -> dict[str, Any]:
with self._lock:
items = [self._read(path) for path in self.charts_dir.glob("*.json")]
items = [item for item in items if item]
items.sort(key=lambda item: item.get("createdAt") or "")
return {"items": items}
def get_chart(self, chart_id: str) -> dict[str, Any]:
with self._lock:
return self._load_chart(chart_id)
def update_chart(self, chart_id: str, payload: ChartInput, expected_version: int) -> dict[str, Any]:
with self._lock:
old = self._load_chart(chart_id)
if old["version"] != expected_version:
raise ConflictError("图表已被更新,请刷新后重试")
chart = {**old, **payload.model_dump(mode="json"), "version": old["version"] + 1, "updatedAt": now_iso()}
self._atomic_write(self._chart_path(chart_id), chart)
return chart
def delete_chart(self, chart_id: str) -> dict[str, Any]:
with self._lock:
self._load_chart(chart_id)
self._chart_path(chart_id).unlink()
return {"deleted": True, "chartId": chart_id}
def chart_data(self, chart_id: str, ref_date: date | None = None) -> dict[str, Any]:
with self._lock:
chart = self._load_chart(chart_id)
ref = ref_date or datetime.now(SHANGHAI).date()
days = int(chart["days"])
start = ref - timedelta(days=days - 1)
points = []
cursor = start
while cursor <= ref:
daily = self._read(self._daily_path(cursor), {}) or {}
value = 0
records = daily.get("records", {})
if chart.get("metricId"):
for task_id, record in records.items():
if chart.get("taskId") and task_id != chart["taskId"]:
continue
value += int(record.get("values", {}).get(chart["metricId"], 0) or 0)
else:
seen = set()
for task_id, record in records.items():
if chart.get("taskId") and task_id != chart["taskId"]:
continue
if any(int(item or 0) > 0 for item in record.get("values", {}).values()):
seen.add(task_id)
value = len(seen)
points.append({"date": cursor.isoformat(), "value": value})
cursor += timedelta(days=1)
return {"chartId": chart["chartId"], "title": chart["title"], "days": days, "points": points}
def review_notes(self, start: date, end: date) -> dict[str, Any]:
with self._lock:
if end < start or (end - start).days > 730:
raise ValidationError("心得查询范围无效或超过 730 天")
task_names = {}
for path in self.tasks_dir.glob("*.json"):
task = self._read(path)
if task and not task.get("deletedAt"):
task_names[task.get("taskId")] = task.get("name", "")
days = []
cursor = end
while cursor >= start:
daily = self._read(self._daily_path(cursor), {}) or {}
records = []
for task_id, record in daily.get("records", {}).items():
if not (record.get("note") or record.get("values") or record.get("completedAt")):
continue
records.append({
"taskId": task_id,
"taskName": task_names.get(task_id, "已删除任务"),
"note": record.get("note", ""),
"completedAt": record.get("completedAt"),
"values": record.get("values", {}),
})
if records:
days.append({"date": cursor.isoformat(), "records": records})
cursor -= timedelta(days=1)
return {"start": start.isoformat(), "end": end.isoformat(), "days": days}
def _load_schedule(self, schedule_id: str) -> dict[str, Any]:
schedule = self._read(self._schedule_path(schedule_id))
if schedule is None:
raise NotFoundError("日程不存在")
return schedule
def _write_schedule_revision(self, schedule: dict[str, Any]) -> None:
path = self.schedule_revisions_dir / schedule["scheduleId"] / f"{schedule['revision']}.json"
self._atomic_write(path, schedule)
def create_schedule(self, payload: ScheduleInput) -> dict[str, Any]:
with self._lock:
schedule_id = str(uuid4())
timestamp = now_iso()
schedule = {
"schemaVersion": SCHEMA_VERSION,
"scheduleId": schedule_id,
"version": 1,
"revision": 1,
**payload.model_dump(mode="json"),
"completions": {},
"createdAt": timestamp,
"updatedAt": timestamp,
"deletedAt": None,
}
self._atomic_write(self._schedule_path(schedule_id), schedule)
self._write_schedule_revision(schedule)
return schedule
def list_schedules(self, include_deleted: bool = False) -> dict[str, Any]:
with self._lock:
schedules = [self._read(path) for path in self.schedules_dir.glob("*.json")]
schedules = [item for item in schedules if item and (include_deleted or not item.get("deletedAt"))]
schedules.sort(key=lambda item: (item.get("date") or "9999-12-31", item.get("time") or "", item.get("createdAt") or ""))
return {"items": schedules}
def get_schedule(self, schedule_id: str, include_deleted: bool = False) -> dict[str, Any]:
with self._lock:
schedule = self._load_schedule(schedule_id)
if schedule.get("deletedAt") and not include_deleted:
raise NotFoundError("日程不存在")
return schedule
def update_schedule(self, schedule_id: str, payload: ScheduleInput, expected_version: int) -> dict[str, Any]:
with self._lock:
old = self._load_schedule(schedule_id)
if old.get("deletedAt"):
raise NotFoundError("日程已删除")
if old["version"] != expected_version:
raise ConflictError("日程已被更新,请刷新后重试")
schedule = {
**old,
**payload.model_dump(mode="json"),
"version": old["version"] + 1,
"revision": old["revision"] + 1,
"updatedAt": now_iso(),
}
self._atomic_write(self._schedule_path(schedule_id), schedule)
self._write_schedule_revision(schedule)
return schedule
def _change_schedule_deleted(self, schedule_id: str, expected_version: int, restore: bool) -> dict[str, Any]:
schedule = self._load_schedule(schedule_id)
if schedule["version"] != expected_version:
raise ConflictError("日程已被更新,请刷新后重试")
if restore and not schedule.get("deletedAt"):
return schedule
if not restore and schedule.get("deletedAt"):
return schedule
schedule["deletedAt"] = None if restore else now_iso()
schedule["version"] += 1
schedule["revision"] += 1
schedule["updatedAt"] = now_iso()
self._atomic_write(self._schedule_path(schedule_id), schedule)
self._write_schedule_revision(schedule)
return schedule
def soft_delete_schedule(self, schedule_id: str, expected_version: int) -> dict[str, Any]:
with self._lock:
return self._change_schedule_deleted(schedule_id, expected_version, False)
def restore_schedule(self, schedule_id: str, expected_version: int) -> dict[str, Any]:
with self._lock:
return self._change_schedule_deleted(schedule_id, expected_version, True)
def purge_schedule(self, schedule_id: str, confirmation: str) -> None:
with self._lock:
schedule = self._load_schedule(schedule_id)
if not schedule.get("deletedAt"):
raise ValidationError("永久删除前必须先放入回收站")
if confirmation != schedule_id:
raise ValidationError("确认内容必须是 scheduleId")
self._schedule_path(schedule_id).unlink()
revision_folder = self.schedule_revisions_dir / schedule_id
for path in revision_folder.glob("*.json"):
path.unlink()
if revision_folder.exists():
revision_folder.rmdir()
def complete_schedule(self, schedule_id: str, logical_date: date, completed: bool, expected_version: int) -> dict[str, Any]:
with self._lock:
schedule = self._load_schedule(schedule_id)
if schedule.get("deletedAt"):
raise NotFoundError("日程已删除")
if schedule["version"] != expected_version:
raise ConflictError("日程已被更新,请刷新后重试")
key = logical_date.isoformat()
if completed:
schedule.setdefault("completions", {})[key] = now_iso()
else:
schedule.setdefault("completions", {}).pop(key, None)
schedule["version"] += 1
schedule["updatedAt"] = now_iso()
self._atomic_write(self._schedule_path(schedule_id), schedule)
return schedule
def schedules_for_date(self, day: date) -> list[dict[str, Any]]:
with self._lock:
result = []
for schedule in self.list_schedules(include_deleted=False)["items"]:
if schedule.get("state") != "active":
continue
start = date.fromisoformat(schedule["date"])
recurrence = schedule.get("recurrence", "none")
occurs = (
recurrence == "none" and start == day
or recurrence == "daily" and start <= day
or recurrence == "weekly" and start <= day and start.weekday() == day.weekday()
or recurrence == "monthly" and start <= day and start.day == day.day
)
if not occurs:
continue
completion_time = schedule.get("completions", {}).get(day.isoformat())
result.append({**schedule, "completed": bool(completion_time), "completionTime": completion_time})
result.sort(key=lambda item: (item.get("time") or "", item.get("createdAt") or ""))
return result
def calendar_month(self, year: int, month: int) -> dict[str, Any]:
with self._lock:
days = []
for day_number in range(1, monthrange(year, month)[1] + 1):
current = date(year, month, day_number)
schedules = [item for item in self.schedules_for_date(current) if item.get("state") == "active"]
days.append({"date": current.isoformat(), "schedules": schedules})
return {"year": year, "month": month, "days": days}
def agent_overview(self, day: date) -> dict[str, Any]:
schedules = [
{key: item.get(key) for key in ("scheduleId", "title", "description", "date", "time", "recurrence", "priority", "state", "completed", "completionTime")}
for item in self.schedules_for_date(day)
if item.get("state") == "active"
]
progress = self.summarize(day)
for task_id, info in progress.get("tasks", {}).items():
task = self._read(self._task_path(task_id))
if task:
info["name"] = task.get("name", "")
info["priority"] = task.get("priority")
info["state"] = task.get("state", "")
else:
info["name"] = ""
return {"date": day.isoformat(), "schedules": schedules, "taskProgress": progress}
def _daily_path(self, logical_date: date) -> Path: def _daily_path(self, logical_date: date) -> Path:
return self.daily_dir / str(logical_date.year) / f"{logical_date.isoformat()}.json" return self.daily_dir / str(logical_date.year) / f"{logical_date.isoformat()}.json"
@@ -521,6 +798,9 @@ class JsonRepository:
revisions = [self._read(path) for path in self.revisions_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")] daily = [self._read(path) for path in self.daily_dir.glob("*/*.json")]
archives = [self._read(path) for path in self.archive_dir.glob("*/*.json")] archives = [self._read(path) for path in self.archive_dir.glob("*/*.json")]
schedules = [self._read(path) for path in self.schedules_dir.glob("*.json")]
schedule_revisions = [self._read(path) for path in self.schedule_revisions_dir.glob("*/*.json")]
charts = [self._read(path) for path in self.charts_dir.glob("*.json")]
return { return {
"schemaVersion": SCHEMA_VERSION, "schemaVersion": SCHEMA_VERSION,
"exportedAt": now_iso(), "exportedAt": now_iso(),
@@ -529,6 +809,9 @@ class JsonRepository:
"revisions": revisions, "revisions": revisions,
"daily": daily, "daily": daily,
"archives": archives, "archives": archives,
"schedules": schedules,
"scheduleRevisions": schedule_revisions,
"charts": charts,
} }
def stats(self) -> dict[str, Any]: def stats(self) -> dict[str, Any]:
@@ -537,6 +820,9 @@ class JsonRepository:
daily_files = [self._read(path) for path in self.daily_dir.glob("*/*.json")] daily_files = [self._read(path) for path in self.daily_dir.glob("*/*.json")]
revisions = [path for path in self.revisions_dir.glob("*/*.json")] revisions = [path for path in self.revisions_dir.glob("*/*.json")]
archives = [path for path in self.archive_dir.glob("*/*.json")] archives = [path for path in self.archive_dir.glob("*/*.json")]
schedules = [path for path in self.schedules_dir.glob("*.json")]
schedule_revisions = [path for path in self.schedule_revisions_dir.glob("*/*.json")]
charts = [path for path in self.charts_dir.glob("*.json")]
def bytes_of(paths): def bytes_of(paths):
total = 0 total = 0
@@ -555,11 +841,17 @@ class JsonRepository:
"dailyFactCount": len(daily_files), "dailyFactCount": len(daily_files),
"revisionCount": len(revisions), "revisionCount": len(revisions),
"archiveCount": len(archives), "archiveCount": len(archives),
"scheduleCount": len(schedules),
"scheduleRevisionCount": len(schedule_revisions),
"chartCount": len(charts),
"storageBytes": bytes_of( "storageBytes": bytes_of(
list(self.tasks_dir.glob("*.json")) list(self.tasks_dir.glob("*.json"))
+ list(self.daily_dir.glob("*/*.json")) + list(self.daily_dir.glob("*/*.json"))
+ list(self.revisions_dir.glob("*/*.json")) + list(self.revisions_dir.glob("*/*.json"))
+ list(self.archive_dir.glob("*/*.json")) + list(self.archive_dir.glob("*/*.json"))
+ list(self.schedules_dir.glob("*.json"))
+ list(self.schedule_revisions_dir.glob("*/*.json"))
+ charts
), ),
} }
@@ -583,6 +875,9 @@ class JsonRepository:
tasks = payload.get("tasks") tasks = payload.get("tasks")
revisions = payload.get("revisions") or [] revisions = payload.get("revisions") or []
daily = payload.get("daily") or [] daily = payload.get("daily") or []
schedules = payload.get("schedules") or []
schedule_revisions = payload.get("scheduleRevisions") or []
charts = payload.get("charts") or []
if not isinstance(meta, dict) or not isinstance(tasks, list): if not isinstance(meta, dict) or not isinstance(tasks, list):
raise ValidationError("备份数据缺少 meta/tasks") raise ValidationError("备份数据缺少 meta/tasks")
@@ -618,8 +913,36 @@ class JsonRepository:
daily_dates.add(day) daily_dates.add(day)
staged.append((self._daily_path(day), fact)) staged.append((self._daily_path(day), fact))
seen_schedules: set[str] = set()
for schedule in schedules:
if not isinstance(schedule, dict):
raise ValidationError("备份日程数据无效")
schedule_id = self._validate_identifier(schedule.get("scheduleId"), "scheduleId")
if schedule_id in seen_schedules:
raise ValidationError("备份包含重复日程")
seen_schedules.add(schedule_id)
staged.append((self._schedule_path(schedule_id), schedule))
for schedule_revision in schedule_revisions:
if not isinstance(schedule_revision, dict):
continue
schedule_id = self._validate_identifier(schedule_revision.get("scheduleId"), "scheduleId")
rev = self._validate_identifier(str(schedule_revision.get("revision")), "revision")
folder = self.schedule_revisions_dir / schedule_id
staged.append((folder / f"{rev}.json", schedule_revision))
seen_charts: set[str] = set()
for chart in charts:
if not isinstance(chart, dict):
raise ValidationError("备份图表数据无效")
chart_id = self._validate_identifier(chart.get("chartId"), "chartId")
if chart_id in seen_charts:
raise ValidationError("备份包含重复图表")
seen_charts.add(chart_id)
staged.append((self._chart_path(chart_id), chart))
# 原子替换整个数据集:先清空旧数据,再整体写入。 # 原子替换整个数据集:先清空旧数据,再整体写入。
for folder in (self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir): for folder in (self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir, self.schedules_dir, self.schedule_revisions_dir, self.charts_dir):
for path in folder.glob("*"): for path in folder.glob("*"):
if path.is_dir(): if path.is_dir():
for child in path.iterdir(): for child in path.iterdir():
@@ -635,4 +958,3 @@ class JsonRepository:
for day in sorted(daily_dates): for day in sorted(daily_dates):
self._rebuild_month(day.year, day.month) self._rebuild_month(day.year, day.month)
return len(seen_tasks) return len(seen_tasks)

View File

@@ -1,7 +1,10 @@
const state = { const state = {
view: "home",
tasks: [], version: 1, selectedId: null, selectedTask: null, tasks: [], version: 1, selectedId: null, selectedTask: null,
daily: null, history: null, summary: null, csrf: "", authEnabled: false, daily: null, history: null, summary: null, csrf: "", authEnabled: false,
charts: [], notes: null, editingChartId: null,
filter: "", editingId: null, dragId: null, selectedDate: null, ganttScale: "month", filter: "", editingId: null, dragId: null, selectedDate: null, ganttScale: "month",
schedules: { calendar: null, today: null }, scheduleMonth: new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()).slice(0, 7), editingScheduleId: null,
}; };
const $ = (selector, root = document) => root.querySelector(selector); const $ = (selector, root = document) => root.querySelector(selector);
@@ -47,7 +50,7 @@ async function bootstrap() {
} catch (error) { toast(error.message); } } catch (error) { toast(error.message); }
} }
const layoutCache = { home: null, "task-detail": null }; const layoutCache = { home: null, "task-detail": null, review: null };
async function ensureLayout(page) { if (!layoutCache[page]) layoutCache[page] = await api(`/api/layouts/${page}`); return layoutCache[page]; } 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;
@@ -55,6 +58,15 @@ async function loadTasks() {
await render(); await render();
} }
async function loadSchedules() {
const day = state.selectedDate || today();
const [calendar, todayResult] = await Promise.all([
api(`/api/schedules/calendar?month=${encodeURIComponent(state.scheduleMonth)}`),
api(`/api/schedules/today?date=${encodeURIComponent(day)}`),
]);
state.schedules = { calendar, today: todayResult };
}
function renderTaskList() { 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}">
@@ -78,7 +90,7 @@ function rerenderSidebarList() {
function bindSidebarItems() { function bindSidebarItems() {
$$(".task-item").forEach((node) => { $$(".task-item").forEach((node) => {
node.addEventListener("click", () => { state.selectedId = node.dataset.taskId; render(); }); node.addEventListener("click", () => { state.view = "home"; 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"); });
node.addEventListener("dragend", () => { node.classList.remove("dragging"); state.dragId = null; }); node.addEventListener("dragend", () => { node.classList.remove("dragging"); state.dragId = null; });
node.addEventListener("dragover", (event) => event.preventDefault()); node.addEventListener("dragover", (event) => event.preventDefault());
@@ -97,6 +109,53 @@ function periodText(task) { const metrics = task.metrics || []; return [...new S
function summaryFor(taskId) { return state.summary?.tasks?.[taskId] || null; } function summaryFor(taskId) { return state.summary?.tasks?.[taskId] || null; }
function metricSummary(taskId, metricId) { return summaryFor(taskId)?.metrics?.find((metric) => metric.metricId === metricId) || null; } function metricSummary(taskId, metricId) { return summaryFor(taskId)?.metrics?.find((metric) => metric.metricId === metricId) || null; }
function renderChartSvg(chart) {
const points = chart.data?.points || [];
if (!points.length || points.every((point) => Number(point.value) === 0)) return `<div class="chart-empty">暂无数据</div>`;
const width = 600, height = 200, pad = { left: 34, right: 12, top: 14, bottom: 30 };
const max = Math.max(...points.map((point) => Number(point.value) || 0), 1);
const x = (index) => pad.left + (points.length === 1 ? 0 : index * (width - pad.left - pad.right) / (points.length - 1));
const y = (value) => pad.top + (height - pad.top - pad.bottom) * (1 - value / max);
const coords = points.map((point, index) => `${x(index).toFixed(1)},${y(Number(point.value) || 0).toFixed(1)}`).join(" ");
const areaPath = `M ${pad.left.toFixed(1)} ${y(0).toFixed(1)} L ${coords.replace(/ /g, " L ")} L ${x(points.length - 1).toFixed(1)} ${y(0).toFixed(1)} Z`;
const labels = [points[0], points[Math.floor((points.length - 1) / 2)], points[points.length - 1]].filter((point, index, list) => point && list.findIndex((item) => item.date === point.date) === index).map((point) => {
const index = points.indexOf(point); return `<text x="${x(index)}" y="${height - 7}" text-anchor="middle">${escapeHtml(point.date.slice(5))}</text>`;
}).join("");
const circles = points.map((point, index) => `<circle cx="${x(index).toFixed(1)}" cy="${y(Number(point.value) || 0).toFixed(1)}" r="2.2"/>`).join("");
return `<svg class="chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(chart.title)}"><line class="chart-guide" x1="${pad.left}" y1="${y(0)}" x2="${width - pad.right}" y2="${y(0)}"/><line class="chart-guide" x1="${pad.left}" y1="${y(max)}" x2="${width - pad.right}" y2="${y(max)}"/><text class="chart-max" x="4" y="${y(max) + 4}">${max}</text><path class="chart-area" d="${areaPath}"/><polyline points="${coords}"/><g class="chart-points">${circles}</g><g class="chart-labels">${labels}</g></svg>`;
}
function renderReviewCharts() {
const cards = state.charts.map((chart) => `<article class="review-chart-card"><div class="review-chart-head"><h3>${escapeHtml(chart.title)}</h3><span><button class="icon-button chart-edit" data-chart-id="${chart.chartId}" title="编辑图表" aria-label="编辑图表">✎</button><button class="icon-button chart-delete" data-chart-id="${chart.chartId}" title="删除图表" aria-label="删除图表">×</button></span></div>${renderChartSvg(chart)}</article>`).join("");
return `<section class="section review-charts"><div class="page-heading review-heading"><div><p class="eyebrow">REVIEW</p><h1>复盘</h1><p>观察完成趋势,回看每日心得</p></div><button class="button primary" id="addChartButton">+ 添加图表</button></div><div class="review-chart-grid">${cards || `<p class="empty-list">还没有图表,添加一张开始复盘</p>`}</div></section>`;
}
function renderReviewNotes() {
const days = state.notes?.days || [];
const content = days.map((day) => `<details class="review-day" open><summary><span class="review-date-badge">${escapeHtml(day.date.slice(5))}</span><strong>${escapeHtml(day.date)}</strong><span class="muted">${day.records.length} 条记录</span></summary><div class="review-day-records">${day.records.map((record) => `<div class="review-note-record"><strong>${escapeHtml(record.taskName)}</strong>${record.completedAt ? `<time>${escapeHtml(record.completedAt.replace("T", " "))}</time>` : ""}${record.note ? `<p>${escapeHtml(record.note)}</p>` : ""}</div>`).join("")}</div></details>`).join("");
return `<section class="section review-notes"><div class="section-heading"><div><p class="eyebrow">REVIEW</p><h2>每日心得</h2></div><span>最近 30 天</span></div>${content || `<p class="empty-list">最近没有心得记录</p>`}</section>`;
}
function fillChartMetrics(taskId, metricId = "") {
const select = $("#chartMetricSelect");
const task = state.tasks.find((item) => item.taskId === taskId);
const options = [`<option value="">完成数</option>`].concat(task ? task.metrics.map((metric) => `<option value="${escapeHtml(metric.metricId)}" ${metric.metricId === metricId ? "selected" : ""}>${escapeHtml(metric.name)}</option>`) : []);
select.innerHTML = options.join("");
}
function openChartDialog(chart = null) {
state.editingChartId = chart?.chartId || null;
const form = $("#chartForm"); form.reset(); $("#chartDialogTitle").textContent = chart ? "编辑图表" : "添加图表";
$("#chartTaskSelect").innerHTML = `<option value="">全部任务</option>${state.tasks.map((task) => `<option value="${escapeHtml(task.taskId)}">${escapeHtml(task.name)}</option>`).join("")}`;
form.title.value = chart?.title || ""; form.days.value = chart?.days || 30; $("#chartTaskSelect").value = chart?.taskId || ""; fillChartMetrics($("#chartTaskSelect").value, chart?.metricId || ""); $("#chartDialog").showModal();
}
function bindReviewCharts() {
$("#addChartButton")?.addEventListener("click", () => openChartDialog());
$(".chart-edit") && $$(".chart-edit").forEach((button) => button.addEventListener("click", () => openChartDialog(state.charts.find((chart) => chart.chartId === button.dataset.chartId))));
$$(".chart-delete").forEach((button) => button.addEventListener("click", async () => { if (!confirm("确认删除这张图表?")) return; try { await api(`/api/charts/${button.dataset.chartId}`, { method: "DELETE" }); toast("图表已删除"); await render(); } catch (error) { toast(error.message); } }));
}
// ---- 组件注册表:布局白名单对应的渲染器与事件绑定 ---- // ---- 组件注册表:布局白名单对应的渲染器与事件绑定 ----
// 页面/区域约束与单例约束由服务端 validate_layout 保证(非法布局回退到内置默认), // 页面/区域约束与单例约束由服务端 validate_layout 保证(非法布局回退到内置默认),
// 前端只负责渲染已校验的布局,不再二次过滤以免静默丢组件。 // 前端只负责渲染已校验的布局,不再二次过滤以免静默丢组件。
@@ -110,6 +169,9 @@ const components = {
"metric-progress": { render: renderMetricProgress }, "metric-progress": { render: renderMetricProgress },
"activity-heatmap": { render: renderActivityHeatmap }, "activity-heatmap": { render: renderActivityHeatmap },
"task-settings": { render: renderTaskSettings, bind: bindSettings }, "task-settings": { render: renderTaskSettings, bind: bindSettings },
"schedule-board": { render: renderScheduleBoard, bind: bindScheduleBoard },
"review-charts": { render: renderReviewCharts, bind: bindReviewCharts },
"review-notes": { render: renderReviewNotes },
}; };
function componentHtml(item) { const entry = components[item.component]; return entry ? entry.render(item.options || {}) : ""; } function componentHtml(item) { const entry = components[item.component]; return entry ? entry.render(item.options || {}) : ""; }
@@ -132,8 +194,17 @@ async function renderLayout(page) {
} }
async function render() { async function render() {
if (state.view === "review") {
try {
const [charts, notes] = await Promise.all([api("/api/charts"), api(`/api/review/notes?start=${encodeURIComponent(shanghaiDate(-29))}&end=${encodeURIComponent(today())}`)]);
state.notes = notes;
state.charts = await Promise.all((charts.items || []).map(async (chart) => ({ ...chart, data: await api(`/api/charts/${chart.chartId}/data`) })));
} catch (error) { toast(error.message); return; }
await renderLayout("review");
return;
}
if (!state.selectedId) { if (!state.selectedId) {
try { state.summary = await api("/api/summary"); } catch (error) { toast(error.message); } try { state.summary = await api("/api/summary"); await loadSchedules(); } catch (error) { toast(error.message); }
await renderLayout("home"); await renderLayout("home");
return; return;
} }
@@ -153,6 +224,70 @@ async function render() {
await renderLayout("task-detail"); await renderLayout("task-detail");
} }
function scheduleMonthOffset(offset) {
const [year, month] = state.scheduleMonth.split("-").map(Number);
const value = new Date(Date.UTC(year, month - 1 + offset, 1));
state.scheduleMonth = `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, "0")}`;
}
function scheduleForSelectedDate() {
const selected = state.selectedDate || today();
if (state.schedules.today?.date === selected) return state.schedules.today.items || [];
return state.schedules.calendar?.days?.find((item) => item.date === selected)?.schedules || [];
}
function renderScheduleBoard() {
const calendar = state.schedules.calendar;
if (!calendar) return `<section class="section"><div class="section-heading"><h2>日程</h2></div><p class="muted">正在加载日程…</p></section>`;
const selected = state.selectedDate || today();
const days = calendar.days || [];
const offset = days.length ? (new Date(`${days[0].date}T00:00:00Z`).getUTCDay() + 6) % 7 : 0;
const cells = Array.from({ length: offset }, () => `<span class="schedule-day blank"></span>`).join("") + days.map((item) => {
const schedules = item.schedules || [];
const priorities = [...new Set(schedules.map((schedule) => Number(schedule.priority)).filter((priority) => priority >= 1 && priority <= 5))].sort((a, b) => a - b);
const visiblePriorities = priorities.slice(0, 3);
const remainingPriorities = Math.max(0, priorities.length - visiblePriorities.length);
const dots = visiblePriorities.map((priority) => `<i class="dot priority-${priority}" aria-hidden="true"></i>`).join("");
const more = remainingPriorities ? `<span class="schedule-dots-more">+${remainingPriorities}</span>` : "";
const tooltip = schedules.map((schedule) => `${schedule.time ? escapeHtml(schedule.time) : "全天"} ${escapeHtml(schedule.title)} · 优先级${Number(schedule.priority) || 3}`).join("\n").replace(/\n/g, "&#10;");
const tooltipAttribute = tooltip ? ` data-tooltip="${tooltip}"` : "";
return `<button type="button" class="schedule-day ${item.date === selected ? "selected" : ""} ${item.date === today() ? "today" : ""}" data-schedule-date="${item.date}"${tooltipAttribute}><span class="schedule-date">${Number(item.date.slice(-2))}</span><span class="schedule-dots" aria-label="${priorities.length} 种优先级">${dots}${more}</span></button>`;
}).join("");
const items = scheduleForSelectedDate().map((schedule) => `<div class="schedule-item ${schedule.completed ? "completed" : ""}"><span class="priority-bar priority-${schedule.priority}"></span><div class="schedule-copy"><strong>${escapeHtml(schedule.title)}</strong><small>${schedule.time ? escapeHtml(schedule.time) : "全天"}${schedule.recurrence !== "none" ? ` · ${escapeHtml(schedule.recurrence)}` : ""}</small></div><button type="button" class="button subtle schedule-complete" data-schedule-id="${schedule.scheduleId}" data-schedule-version="${schedule.version}" data-completed="${schedule.completed ? "false" : "true"}">${schedule.completed ? "取消完成" : "完成"}</button><button type="button" class="icon-button schedule-edit" data-schedule-id="${schedule.scheduleId}" title="编辑" aria-label="编辑">✎</button></div>`).join("");
const legend = [[1, "紧急"], [2, "高"], [3, "正常"], [4, "低"], [5, "挂起候选"]].map(([priority, label]) => `<span class="schedule-legend-item"><i class="dot priority-${priority}" aria-hidden="true"></i><span>${label}</span></span>`).join("");
return `<section class="section schedule-board"><div class="section-heading"><div><p class="eyebrow">SCHEDULE</p><h2>日程安排</h2><span>${selected} · ${scheduleForSelectedDate().length} 项</span></div><div class="schedule-actions"><button type="button" class="icon-button" id="schedulePrev" title="上月" aria-label="上月">‹</button><strong>${calendar.year}-${String(calendar.month).padStart(2, "0")}</strong><button type="button" class="icon-button" id="scheduleNext" title="下月" aria-label="下月">›</button><button type="button" class="button primary" id="addScheduleButton">+ 新建日程</button></div></div><div class="schedule-weekdays"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div><div class="schedule-calendar">${cells}</div><div class="schedule-legend" aria-label="优先级图例"><span class="schedule-legend-label">优先级图例</span>${legend}</div><div class="schedule-list">${items || `<p class="muted">这一天没有日程</p>`}</div></section>`;
}
async function refreshSchedules() { try { await loadSchedules(); await renderLayout("home"); } catch (error) { toast(error.message); } }
function bindScheduleBoard() {
$("#schedulePrev")?.addEventListener("click", async () => { scheduleMonthOffset(-1); await refreshSchedules(); });
$("#scheduleNext")?.addEventListener("click", async () => { scheduleMonthOffset(1); await refreshSchedules(); });
$("#addScheduleButton")?.addEventListener("click", () => openScheduleDialog());
$$('[data-schedule-date]').forEach((node) => node.addEventListener("click", async () => { state.selectedDate = node.dataset.scheduleDate; await refreshSchedules(); }));
$$(".schedule-edit").forEach((node) => node.addEventListener("click", () => {
const schedule = scheduleForSelectedDate().find((item) => item.scheduleId === node.dataset.scheduleId); if (schedule) openScheduleDialog(schedule);
}));
$$(".schedule-complete").forEach((node) => node.addEventListener("click", async () => {
try { await api(`/api/schedules/${node.dataset.scheduleId}/complete`, { method: "POST", body: JSON.stringify({ date: state.selectedDate || today(), completed: node.dataset.completed === "true", expectedVersion: Number(node.dataset.scheduleVersion) }) }); toast("日程状态已更新"); await refreshSchedules(); } catch (error) { toast(error.message); }
}));
}
function openScheduleDialog(schedule = null) {
state.editingScheduleId = schedule?.scheduleId || null;
const form = $("#scheduleForm"); form.reset(); $("#scheduleDialogTitle").textContent = schedule ? "编辑日程" : "新建日程";
form.title.value = schedule?.title || ""; form.description.value = schedule?.description || ""; form.date.value = schedule?.date || state.selectedDate || today(); form.time.value = schedule?.time || ""; form.recurrence.value = schedule?.recurrence || "none"; form.priority.value = schedule?.priority || 3; form.state.value = schedule?.state || "active";
$("#scheduleDialog").showModal();
}
$("#scheduleForm")?.addEventListener("submit", async (event) => {
event.preventDefault(); const form = event.currentTarget;
const payload = { title: form.title.value, description: form.description.value, date: form.date.value || null, time: form.time.value || null, recurrence: form.recurrence.value, priority: Number(form.priority.value), state: form.state.value };
try { const editing = state.editingScheduleId; if (editing) { payload.expectedVersion = scheduleForSelectedDate().find((item) => item.scheduleId === editing)?.version || 1; }
const schedule = await api(editing ? `/api/schedules/${editing}` : "/api/schedules", { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) }); $("#scheduleDialog").close(); state.selectedDate = schedule.date; state.scheduleMonth = schedule.date.slice(0, 7); toast(editing ? "日程已更新" : "日程已创建"); await refreshSchedules();
} catch (error) { toast(error.message); }
});
$("#cancelScheduleButton")?.addEventListener("click", () => $("#scheduleDialog").close()); $("#closeScheduleButton")?.addEventListener("click", () => $("#scheduleDialog").close());
function renderOverviewSummary() { function renderOverviewSummary() {
const active = state.tasks.filter((task) => !task.deletedAt); const activePlans = active.filter((task) => task.state === "active").length; 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>`; 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>`;
@@ -419,9 +554,25 @@ function renderMetricEditor(metrics) {
$("#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); }); $("#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; if (result.kind === "duration") { result.unit = ""; result.durationUnit = result.durationUnit || "minute"; if (result.target != null) result.target = Math.round(result.target * (DURATION_SECONDS[result.durationUnit] || 60)); } else { delete result.durationUnit; } return result; }); const payload = { name: form.name.value, description: form.description.value, startDate: form.startDate.value || null, 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); } }); $("#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; if (result.kind === "duration") { result.unit = ""; result.durationUnit = result.durationUnit || "minute"; if (result.target != null) result.target = Math.round(result.target * (DURATION_SECONDS[result.durationUnit] || 60)); } else { delete result.durationUnit; } return result; }); const payload = { name: form.name.value, description: form.description.value, startDate: form.startDate.value || null, 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(); }); $("#addTaskButton").addEventListener("click", () => openTaskDialog());
$("#reviewButton").addEventListener("click", () => { state.view = "review"; render(); });
$("#homeButton").addEventListener("click", () => { state.view = "home"; state.selectedId = null; render(); });
$("#cancelTaskButton").addEventListener("click", () => $("#taskDialog").close()); $("#cancelTaskButton").addEventListener("click", () => $("#taskDialog").close());
$("#closeTaskButton").addEventListener("click", () => $("#taskDialog").close()); $("#closeTaskButton").addEventListener("click", () => $("#taskDialog").close());
$("#cancelChartButton").addEventListener("click", () => $("#chartDialog").close());
$("#closeChartButton").addEventListener("click", () => $("#chartDialog").close());
$("#chartTaskSelect").addEventListener("change", (event) => fillChartMetrics(event.target.value));
$("#chartForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const payload = { title: form.title.value, taskId: form.taskId.value || null, metricId: form.metricId.value || null, days: Number(form.days.value) };
try {
const editing = state.editingChartId ? state.charts.find((chart) => chart.chartId === state.editingChartId) : null;
if (editing) payload.expectedVersion = editing.version;
await api(editing ? `/api/charts/${editing.chartId}` : "/api/charts", { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) });
$("#chartDialog").close(); toast(editing ? "图表已更新" : "图表已创建"); await render();
} catch (error) { toast(error.message); }
});
function renderAdminStats(stats) { function renderAdminStats(stats) {
const cells = [ const cells = [

View File

@@ -15,6 +15,7 @@
<span><strong>进度台</strong><small id="logicalDate"></small></span> <span><strong>进度台</strong><small id="logicalDate"></small></span>
</button> </button>
<div class="top-actions"> <div class="top-actions">
<button class="button" id="reviewButton" type="button">复盘</button>
<button class="button primary" id="addTaskButton" type="button">+ 新建任务</button> <button class="button primary" id="addTaskButton" type="button">+ 新建任务</button>
<button class="icon-button" id="adminButton" type="button" title="数据管理" aria-label="数据管理">⚙</button> <button class="icon-button" id="adminButton" type="button" title="数据管理" aria-label="数据管理">⚙</button>
</div> </div>
@@ -45,6 +46,35 @@
</form> </form>
</dialog> </dialog>
<dialog id="scheduleDialog" class="dialog schedule-dialog">
<form id="scheduleForm">
<div class="dialog-heading"><div><p class="eyebrow">日程定义</p><h2 id="scheduleDialogTitle">新建日程</h2></div><button class="icon-button" id="closeScheduleButton" type="button" title="关闭" aria-label="关闭">×</button></div>
<div class="form-grid">
<label class="wide">标题<input name="title" required maxlength="120"></label>
<label>日期<input name="date" type="date"></label>
<label>时间<input name="time" type="time" maxlength="5"></label>
<label>重复<select name="recurrence"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option></select></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="archived">已归档</option></select></label>
<label class="wide">说明<textarea name="description" maxlength="2000" rows="3"></textarea></label>
</div>
<div class="dialog-actions"><button class="button" id="cancelScheduleButton" type="button">取消</button><button class="button primary" type="submit">保存日程</button></div>
</form>
</dialog>
<dialog id="chartDialog" class="dialog chart-dialog">
<form id="chartForm">
<div class="dialog-heading"><div><p class="eyebrow">REVIEW</p><h2 id="chartDialogTitle">添加图表</h2></div><button class="icon-button" id="closeChartButton" type="button" title="关闭" aria-label="关闭">×</button></div>
<div class="form-grid">
<label class="wide">标题<input name="title" required maxlength="80"></label>
<label>任务<select name="taskId" id="chartTaskSelect"><option value="">全部任务</option></select></label>
<label>指标<select name="metricId" id="chartMetricSelect"><option value="">完成数</option></select></label>
<label>天数<input name="days" type="number" min="7" max="365" value="30" required></label>
</div>
<div class="dialog-actions"><button class="button" id="cancelChartButton" type="button">取消</button><button class="button primary" type="submit">保存图表</button></div>
</form>
</dialog>
<dialog id="adminDialog" class="dialog admin-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="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="importButton">导入还原</button><input type="file" id="importFile" accept="application/json,.json" hidden><button class="button" id="archiveButton">执行补归档</button><button class="button" id="logoutButton">退出登录</button></div> <div class="admin-actions"><button class="button" id="exportButton">导出 JSON</button><button class="button" id="importButton">导入还原</button><input type="file" id="importFile" accept="application/json,.json" hidden><button class="button" id="archiveButton">执行补归档</button><button class="button" id="logoutButton">退出登录</button></div>

View File

@@ -194,12 +194,70 @@ label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #4551
.login-overlay { position: fixed; inset: 0; z-index: 100; background: #eef2ee; display: grid; place-items: center; padding: 20px; } .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; } .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; } .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) { .review-heading { margin-bottom: 20px; }
.review-chart-grid { display: grid; gap: 14px; }
.review-chart-card { border: 1px solid var(--line); background: var(--surface); border-radius: 6px; padding: 16px; }
.review-chart-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 10px; }
.review-chart-head h3 { margin: 0; font-size: 15px; }
.review-chart-head > span { display: flex; gap: 5px; }
.review-chart-head .icon-button { width: 30px; min-height: 30px; font-size: 16px; }
.chart-svg { display: block; width: 100%; height: auto; min-height: 180px; overflow: visible; }
.chart-svg polyline { fill: none; stroke: var(--green); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.chart-svg circle { fill: var(--surface); stroke: var(--green); stroke-width: 1.5; }
.chart-area { fill: rgba(23,107,75,.10); }
.chart-guide { stroke: #e6ebe7; stroke-width: 1; }
.chart-svg text { fill: var(--muted); font-size: 11px; }
.chart-empty { min-height: 180px; display: grid; place-items: center; color: var(--muted); font-size: 13px; }
.review-day { border: 1px solid var(--line); border-radius: 6px; background: var(--surface); margin-bottom: 9px; overflow: hidden; }
.review-day summary { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; list-style: none; }
.review-day summary::-webkit-details-marker { display: none; }
.review-date-badge { display: grid; place-items: center; min-width: 48px; min-height: 30px; border-radius: 4px; background: var(--green-soft); color: var(--green); font-size: 12px; font-weight: 700; }
.review-day-records { border-top: 1px solid var(--line); padding: 2px 14px 8px 72px; }
.review-note-record { padding: 10px 0; border-bottom: 1px solid #eef2ee; }
.review-note-record:last-child { border-bottom: 0; }
.review-note-record strong { display: block; font-size: 13px; }.review-note-record time { display: block; color: var(--muted); font-size: 11px; margin-top: 3px; }.review-note-record p { margin: 7px 0 0; white-space: pre-wrap; font-size: 13px; line-height: 1.6; }
.chart-dialog { width: min(560px, calc(100vw - 28px)); }
.schedule-actions { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.schedule-actions strong { min-width: 76px; text-align: center; font-size: 14px; }
.schedule-weekdays, .schedule-calendar { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 5px; }
.schedule-weekdays { color: var(--muted); font-size: 11px; text-align: center; margin-bottom: 5px; }
.schedule-date { font-size: 13px; font-weight: 600; }
.schedule-day { min-height: 52px; position: relative; border: 1px solid var(--line); border-radius: 5px; background: var(--surface); color: var(--ink); display: flex; align-items: flex-start; justify-content: flex-start; padding: 7px; transition: .15s ease; }
.schedule-day:hover, .schedule-day.selected { border-color: var(--green); background: var(--green-soft); }
.schedule-day:hover, .schedule-day:focus-visible { transform: translateY(-1px); box-shadow: 0 3px 8px rgba(23,33,26,.08); z-index: 4; }
.schedule-day.today { border-color: var(--green); box-shadow: 0 0 0 1px var(--green); }
.schedule-day.today .schedule-date { font-weight: 800; color: var(--green); }
.schedule-dots { position: absolute; right: 7px; bottom: 7px; display: flex; align-items: flex-end; justify-content: flex-end; gap: 4px; min-height: 9px; max-width: calc(100% - 14px); }
.schedule-dots .dot, .schedule-legend .dot { width: 7px; height: 7px; flex: 0 0 7px; display: block; border-radius: 50%; box-shadow: 0 0 0 1.5px #fff; }
.schedule-dots .dot.priority-1, .schedule-legend .dot.priority-1 { background: #e5484d; }
.schedule-dots .dot.priority-2, .schedule-legend .dot.priority-2 { background: #f76b15; }
.schedule-dots .dot.priority-3, .schedule-legend .dot.priority-3 { background: var(--green); }
.schedule-dots .dot.priority-4, .schedule-legend .dot.priority-4 { background: #8a93a6; }
.schedule-dots .dot.priority-5, .schedule-legend .dot.priority-5 { background: #c6ccd6; }
.schedule-dots-more { color: var(--muted); font-size: 10px; line-height: 9px; white-space: nowrap; }
.schedule-day[data-tooltip]::after { content: attr(data-tooltip); position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20; display: none; width: max-content; max-width: min(260px, calc(100vw - 28px)); padding: 8px 10px; border-radius: 5px; background: var(--ink); color: #fff; box-shadow: 0 5px 16px rgba(23,33,26,.2); font-size: 11px; font-weight: 400; line-height: 1.5; text-align: left; white-space: pre-line; pointer-events: none; }
.schedule-day[data-tooltip]:hover::after, .schedule-day[data-tooltip]:focus-visible::after { display: block; }
.schedule-legend { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 13px; margin-top: 12px; color: var(--muted); font-size: 11px; }
.schedule-legend-label { margin-right: 2px; font-weight: 600; }
.schedule-legend-item { display: inline-flex; align-items: center; gap: 5px; }
.schedule-list { display: grid; gap: 7px; margin-top: 15px; }
.schedule-item { display: grid; grid-template-columns: 4px 1fr auto auto; gap: 10px; align-items: center; border-top: 1px solid var(--line); padding: 11px 0; }
.schedule-item .priority-bar { height: 36px; }
.schedule-copy { min-width: 0; }
.schedule-copy strong, .schedule-copy small { display: block; }
.schedule-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.schedule-copy small { margin-top: 4px; color: var(--muted); font-size: 11px; }
.schedule-item.completed .schedule-copy strong { text-decoration: line-through; color: var(--muted); }
.schedule-item .icon-button { width: 32px; min-height: 32px; font-size: 16px; }
.schedule-dialog { width: min(620px, calc(100vw - 28px)); }
.schedule-day.blank { border-color: transparent; background: transparent; pointer-events: none; }
@media (max-width: 820px) {
.topbar { padding: 0 12px; }.button.primary { padding-inline: 10px; } .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; } .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; } .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; } .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; } .metric-row { grid-template-columns: repeat(2, 1fr); }.remove-metric { grid-column: 2; justify-self: end; }
.review-day-records { padding-left: 14px; }
} }
@media (max-width: 520px) { @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; } .brand-button small { display: none; }.top-actions .primary { font-size: 0; width: 42px; }.top-actions .primary::first-letter { font-size: 18px; }

View File

@@ -9,6 +9,7 @@
{ "component": "overview-summary" }, { "component": "overview-summary" },
{ "component": "task-progress-grid", "options": { "historyDays": 365 } }, { "component": "task-progress-grid", "options": { "historyDays": 365 } },
{ "component": "task-gantt" } { "component": "task-gantt" }
,{ "component": "schedule-board" }
] ]
} }
} }

View File

@@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"page": "review",
"regions": {
"sidebar": [
{ "component": "task-list" }
],
"main": [
{ "component": "review-charts" },
{ "component": "review-notes" }
]
}
}

70
tests/test_charts.py Normal file
View File

@@ -0,0 +1,70 @@
from datetime import date
from app.models import ChartInput, DailyRecordInput, MetricValue, TaskInput
from app.repository import ConflictError, JsonRepository
def _task(repo):
return repo.create_task(TaskInput.model_validate({
"name": "复盘任务",
"metrics": [
{"name": "完成", "kind": "checkin", "period": "day"},
{"name": "数量", "kind": "quantity", "period": "day"},
],
}))
def test_chart_crud_and_conflict(tmp_path):
repo = JsonRepository(tmp_path)
chart = repo.create_chart(ChartInput(title="趋势"))
assert repo.list_charts()["items"][0]["chartId"] == chart["chartId"]
updated = repo.update_chart(chart["chartId"], ChartInput(title="新趋势"), chart["version"])
assert updated["version"] == 2
try:
repo.update_chart(chart["chartId"], ChartInput(title="冲突"), chart["version"])
except ConflictError:
pass
else:
raise AssertionError("expected chart version conflict")
assert repo.delete_chart(chart["chartId"])["deleted"] is True
def test_chart_data_modes_and_continuous_points(tmp_path):
repo = JsonRepository(tmp_path)
task = _task(repo)
checkin_id, quantity_id = [metric["metricId"] for metric in task["metrics"]]
repo.upsert_daily(task["taskId"], date(2026, 8, 2), DailyRecordInput(values=[MetricValue(metricId=checkin_id, value=1), MetricValue(metricId=quantity_id, value=3)], note="完成"))
repo.upsert_daily(task["taskId"], date(2026, 8, 4), DailyRecordInput(values=[MetricValue(metricId=quantity_id, value=2)]))
done = repo.create_chart(ChartInput(title="完成数", days=7))
metric = repo.create_chart(ChartInput(title="数量", taskId=task["taskId"], metricId=quantity_id, days=7))
for result in (repo.chart_data(done["chartId"], date(2026, 8, 4)), repo.chart_data(metric["chartId"], date(2026, 8, 4))):
assert len(result["points"]) == 7
assert [point["date"] for point in result["points"]] == sorted(point["date"] for point in result["points"])
done_points = {point["date"]: point["value"] for point in repo.chart_data(done["chartId"], date(2026, 8, 4))["points"]}
metric_points = {point["date"]: point["value"] for point in repo.chart_data(metric["chartId"], date(2026, 8, 4))["points"]}
assert done_points["2026-08-03"] == 0 and done_points["2026-08-04"] == 1
assert metric_points["2026-08-02"] == 3 and metric_points["2026-08-03"] == 0
def test_review_notes_skips_empty_and_resolves_deleted_task(tmp_path):
repo = JsonRepository(tmp_path)
task = _task(repo)
repo.upsert_daily(task["taskId"], date(2026, 8, 1), DailyRecordInput(note="心得"))
repo.upsert_daily(task["taskId"], date(2026, 8, 2), DailyRecordInput())
repo.soft_delete_task(task["taskId"], task["version"])
result = repo.review_notes(date(2026, 8, 1), date(2026, 8, 2))
assert [day["date"] for day in result["days"]] == ["2026-08-01"]
assert result["days"][0]["records"][0]["taskName"] == "已删除任务"
def test_export_import_charts(tmp_path):
repo = JsonRepository(tmp_path)
repo.create_chart(ChartInput(title="备份图表"))
exported = repo.export_all()
assert len(exported["charts"]) == 1
target = JsonRepository(tmp_path / "target")
target.import_all(exported)
assert target.stats()["chartCount"] == 1
legacy = {key: value for key, value in exported.items() if key != "charts"}
target.import_all(legacy)
assert target.stats()["chartCount"] == 0

167
tests/test_schedules.py Normal file
View File

@@ -0,0 +1,167 @@
from datetime import date
def schedule_payload(title="日程", **overrides):
payload = {"title": title, "date": "2026-08-01", "time": "22:00", "recurrence": "none", "priority": 3, "state": "active"}
payload.update(overrides)
return payload
def test_schedule_crud_lifecycle(client):
created = client.post("/api/schedules", json=schedule_payload("第一个日程"))
assert created.status_code == 201, created.text
schedule = created.json()
schedule_id = schedule["scheduleId"]
assert schedule["version"] == 1 and schedule["completions"] == {}
listed = client.get("/api/schedules").json()
assert len(listed["items"]) == 1
fetched = client.get(f"/api/schedules/{schedule_id}")
assert fetched.status_code == 200 and fetched.json()["title"] == "第一个日程"
updated = client.put(
f"/api/schedules/{schedule_id}",
json=schedule_payload("改名日程", expectedVersion=1),
)
assert updated.status_code == 200 and updated.json()["version"] == 2
deleted = client.delete(f"/api/schedules/{schedule_id}?expectedVersion=2")
assert deleted.status_code == 200
assert client.get("/api/schedules").json()["items"] == []
restored = client.post(f"/api/schedules/{schedule_id}/restore?expectedVersion=3")
assert restored.status_code == 200
assert len(client.get("/api/schedules").json()["items"]) == 1
def test_schedule_update_conflict_and_purge(client):
created = client.post("/api/schedules", json=schedule_payload("冲突日程")).json()
schedule_id = created["scheduleId"]
conflict = client.put(f"/api/schedules/{schedule_id}", json=schedule_payload("冲突", expectedVersion=99))
assert conflict.status_code == 409
client.delete(f"/api/schedules/{schedule_id}?expectedVersion=1")
assert client.get(f"/api/schedules/{schedule_id}").status_code == 404
purged = client.post(f"/api/schedules/{schedule_id}/purge", json={"confirmation": schedule_id})
assert purged.status_code == 200
assert client.get(f"/api/schedules/{schedule_id}?includeDeleted=true").status_code == 404
def test_schedule_complete_toggle(client):
schedule = client.post("/api/schedules", json=schedule_payload("打卡日程")).json()
schedule_id = schedule["scheduleId"]
done = client.post(
f"/api/schedules/{schedule_id}/complete",
json={"date": "2026-08-01", "completed": True, "expectedVersion": 1},
)
assert done.status_code == 200
today = client.get("/api/schedules/today?date=2026-08-01").json()
assert today["items"][0]["completed"] is True
undone = client.post(
f"/api/schedules/{schedule_id}/complete",
json={"date": "2026-08-01", "completed": False, "expectedVersion": 2},
)
assert undone.status_code == 200
today = client.get("/api/schedules/today?date=2026-08-01").json()
assert today["items"][0]["completed"] is False
def test_schedule_recurrence_resolution(client):
client.post("/api/schedules", json=schedule_payload("单次", date="2026-08-01"))
client.post("/api/schedules", json=schedule_payload("每天", date="2026-08-01", recurrence="daily"))
client.post("/api/schedules", json=schedule_payload("每周", date="2026-08-03", recurrence="weekly")) # 2026-08-03 是周一
client.post("/api/schedules", json=schedule_payload("每月", date="2026-08-15", recurrence="monthly"))
def titles(day):
return {item["title"] for item in client.get(f"/api/schedules/today?date={day}").json()["items"]}
# 单次:只有当天
assert titles("2026-08-01") == {"单次", "每天"}
assert "单次" not in titles("2026-08-02")
# 每天:起始日之后每天都出现
assert "每天" in titles("2026-08-20")
assert "每天" not in titles("2026-07-31")
# 每周:起始日之后的每个周一出现
assert "每周" in titles("2026-08-10")
assert "每周" not in titles("2026-08-04") # 周二
# 每月:起始日之后的每月 15 号出现
assert "每月" in titles("2026-09-15")
assert "每月" not in titles("2026-09-16")
def test_schedule_archived_not_shown(client):
client.post("/api/schedules", json=schedule_payload("归档日程", state="archived"))
today = client.get("/api/schedules/today?date=2026-08-01").json()
assert today["items"] == []
def test_schedule_calendar_endpoint(client):
client.post("/api/schedules", json=schedule_payload("八月日程", date="2026-08-15"))
calendar = client.get("/api/schedules/calendar?month=2026-08").json()
assert calendar["year"] == 2026 and calendar["month"] == 8
assert len(calendar["days"]) == 31
day_15 = next(item for item in calendar["days"] if item["date"] == "2026-08-15")
assert [item["title"] for item in day_15["schedules"]] == ["八月日程"]
assert client.get("/api/schedules/calendar?month=bad").status_code == 422
def test_schedule_validation(client):
bad_time = client.post("/api/schedules", json=schedule_payload("坏时间", time="25:99"))
assert bad_time.status_code == 422
missing_date = client.post("/api/schedules", json={"title": "无日期", "recurrence": "none"})
assert missing_date.status_code == 422
bad_recurrence = client.post("/api/schedules", json=schedule_payload("坏重复", recurrence="yearly"))
assert bad_recurrence.status_code == 422
def test_agent_overview_token_auth(client, monkeypatch):
client.post("/api/schedules", json=schedule_payload("今日日程", date="2026-08-01"))
client.post("/api/tasks", json={"name": "任务", "startDate": "2026-08-01", "metrics": [{"name": "完成", "kind": "checkin", "period": "day"}]})
# 未配置 token → 503
assert client.get("/api/agent/overview").status_code == 503
monkeypatch.setenv("PPM_API_TOKEN", "tok")
# 无 / 错误 token → 401
assert client.get("/api/agent/overview").status_code == 401
assert client.get("/api/agent/overview", headers={"Authorization": "Bearer wrong"}).status_code == 401
# 正确 token → 200,含日程与任务进度
response = client.get("/api/agent/overview?date=2026-08-01", headers={"Authorization": "Bearer tok"})
assert response.status_code == 200, response.text
body = response.json()
assert body["date"] == "2026-08-01"
assert [item["title"] for item in body["schedules"]] == ["今日日程"]
assert "tasks" in body["taskProgress"]
task_info = next(iter(body["taskProgress"]["tasks"].values()))
assert task_info["name"] == "任务"
def test_agent_create_schedule_write(client, monkeypatch):
monkeypatch.setenv("PPM_API_TOKEN", "tok")
# 无 token → 401
assert client.post("/api/agent/schedules", json=schedule_payload("通过API添加")).status_code == 401
# 带 token → 201,且出现在列表里
created = client.post(
"/api/agent/schedules",
json=schedule_payload("通过API添加"),
headers={"Authorization": "Bearer tok"},
)
assert created.status_code == 201, created.text
schedule_id = created.json()["scheduleId"]
assert any(item["scheduleId"] == schedule_id for item in client.get("/api/schedules").json()["items"])
# 非法 payload → 422
assert client.post("/api/agent/schedules", json={"title": "x"}, headers={"Authorization": "Bearer tok"}).status_code == 422
def test_schedule_survives_export_import(client):
client.post("/api/schedules", json=schedule_payload("导出日程"))
exported = client.get("/api/export").json()
assert any(s["title"] == "导出日程" for s in exported.get("schedules", []))
imported = client.post("/api/admin/import", json=exported)
assert imported.status_code == 200, imported.text
listed = client.get("/api/schedules").json()["items"]
assert any(s["title"] == "导出日程" for s in listed)
assert client.get("/api/admin/stats").json()["scheduleCount"] == 1

View File

@@ -1,6 +1,6 @@
# 个人进度监视系统需求记录 # 个人进度监视系统需求记录
> 实现状态(2026-08-24):以下「已冻结的 MVP」已全部在代码中落地并通过测试(`pytest -q`,17 项)。 > 实现状态(2026-08-24):以下「已冻结的 MVP」已全部在代码中落地并通过测试(`pytest -q`,43 项)。
> 每条后面的 `[x]` 表示已完成。周期统计、布局驱动渲染、一键打卡、时长友好输入、会话 TTL > 每条后面的 `[x]` 表示已完成。周期统计、布局驱动渲染、一键打卡、时长友好输入、会话 TTL
> 与日期边界处理为本次补充完成项。 > 与日期边界处理为本次补充完成项。
@@ -26,10 +26,24 @@
- 时长输入为「时:分:秒」,完整 round-trip 任意非负整数秒;一键打卡一次提交所有打卡指标。 - 时长输入为「时:分:秒」,完整 round-trip 任意非负整数秒;一键打卡一次提交所有打卡指标。
- 极端日期(`date.min`/`date.max` 附近的 month/week 窗口)不再溢出,返回 200。 - 极端日期(`date.min`/`date.max` 附近的 month/week 窗口)不再溢出,返回 200。
## 日程管理面板(2026-09-04)
- [x] 新增「日程」实体:单次与每日/每周/每月重复,JSON 字段存储(`data/schedules/`),软删除 + 修订快照 + 版本冲突检测,与任务一致。
- [x] 日程按日期打卡(`completions`),重复日程逐次记录完成情况。
- [x] 首页「日程安排」月历面板(`schedule-board` 布局组件),月历按星期对齐,支持新建/编辑/完成/归档。
- [x] 后端 API:`/api/schedules` CRUD、`/api/schedules/calendar`、`/api/schedules/today`、`/api/schedules/{id}/complete`。
- [x] 机器接口(`PPM_API_TOKEN` + Bearer 鉴权):`GET /api/agent/overview` 读当日日程与任务进度,`POST /api/agent/schedules` 新建日程。
## 复盘页面(2026-09-04)
- [x] 新增复盘页布局与顶栏导航,支持按日期查看每日心得。
- [x] 新增可配置完成趋势图卡片(任务/指标/天数),内联 SVG 渲染并支持增删改。
- [x] 图表 JSON 存储、连续日期数据、导出导入和后台统计均已接入。
- [x] 新增 `/api/charts`、`/api/charts/{id}/data` 与 `/api/review/notes` 接口。
## 后续路线图 ## 后续路线图
- 备份/恢复向导与定期快照。 - 备份/恢复向导与定期快照。
- 多 worker、多副本的分布式锁和更强一致性压力测试。 - 多 worker、多副本的分布式锁和更强一致性压力测试。
- 自定义布局编辑器、更多图表和提醒通知。 - 自定义布局编辑器、更多图表和提醒通知。
- 细化周/月目标的跨周期统计与时区偏好设置。 - 细化周/月目标的跨周期统计与时区偏好设置。