Compare commits
2 Commits
5f50eb54ed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff10e7f065 | ||
|
|
8340d1dbbd |
@@ -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
|
|||||||
```
|
```
|
||||||
|
|
||||||
路线图:备份恢复向导、多副本协调、跨进程压力测试和更丰富布局编辑器。
|
路线图:备份恢复向导、多副本协调、跨进程压力测试和更丰富布局编辑器。
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
115
app/main.py
115
app/main.py
@@ -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,
|
||||||
|
|||||||
@@ -121,3 +121,60 @@ 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
|
||||||
|
yMax: Annotated[int | None, Field(ge=1)] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChartUpdate(ChartInput):
|
||||||
|
expectedVersion: Annotated[int, Field(ge=1)]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,65 @@ 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 niceCeil(value) {
|
||||||
|
if (!(value > 0)) return 1;
|
||||||
|
const magnitude = Math.pow(10, Math.floor(Math.log10(value)));
|
||||||
|
const steps = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];
|
||||||
|
for (const step of steps) {
|
||||||
|
const candidate = step * magnitude;
|
||||||
|
if (value <= candidate) return Number(candidate.toFixed(6));
|
||||||
|
}
|
||||||
|
return magnitude * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 16, bottom: 30 };
|
||||||
|
const dataMax = Math.max(...points.map((point) => Number(point.value) || 0), 1);
|
||||||
|
const scaleMax = chart.yMax ? Math.max(Number(chart.yMax), dataMax) : niceCeil(dataMax * 1.25);
|
||||||
|
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 / scaleMax);
|
||||||
|
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(scaleMax)}" x2="${width - pad.right}" y2="${y(scaleMax)}"/><text class="chart-max" x="4" y="${y(scaleMax) + 4}">${scaleMax}</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; form.yMax.value = chart?.yMax || ""; $("#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 +181,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 +206,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 +236,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, " ");
|
||||||
|
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 +566,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), yMax: form.yMax.value ? Number(form.yMax.value) : null };
|
||||||
|
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 = [
|
||||||
|
|||||||
@@ -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,36 @@
|
|||||||
</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>
|
||||||
|
<label>纵轴上限<input name="yMax" type="number" min="1" placeholder="自动"></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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
.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) {
|
@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; }
|
||||||
|
|||||||
@@ -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" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
config/layouts/review.json
Normal file
13
config/layouts/review.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"page": "review",
|
||||||
|
"regions": {
|
||||||
|
"sidebar": [
|
||||||
|
{ "component": "task-list" }
|
||||||
|
],
|
||||||
|
"main": [
|
||||||
|
{ "component": "review-charts" },
|
||||||
|
{ "component": "review-notes" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
78
tests/test_charts.py
Normal file
78
tests/test_charts.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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_yMax_roundtrip(tmp_path):
|
||||||
|
repo = JsonRepository(tmp_path)
|
||||||
|
bounded = repo.create_chart(ChartInput(title="带上限", yMax=10))
|
||||||
|
assert repo.get_chart(bounded["chartId"])["yMax"] == 10
|
||||||
|
auto = repo.create_chart(ChartInput(title="自动"))
|
||||||
|
assert repo.get_chart(auto["chartId"])["yMax"] is None
|
||||||
|
|
||||||
|
|
||||||
|
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
167
tests/test_schedules.py
Normal 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
|
||||||
18
需求记录.md
18
需求记录.md
@@ -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、多副本的分布式锁和更强一致性压力测试。
|
||||||
- 自定义布局编辑器、更多图表和提醒通知。
|
- 自定义布局编辑器、更多图表和提醒通知。
|
||||||
- 细化周/月目标的跨周期统计与时区偏好设置。
|
- 细化周/月目标的跨周期统计与时区偏好设置。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user