From b83a4c46344959a0dd66511f335be5fbd515a6f0 Mon Sep 17 00:00:00 2001 From: MingLeeEatPy <297402474+MingLeeEatPy@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:46:36 +0800 Subject: [PATCH 1/2] feat: add V2 execution workflow --- README.md | 37 ++- e2e/backup.spec.ts | 9 +- e2e/execution.spec.ts | 163 ++++++++++++ package-lock.json | 4 +- package.json | 2 +- playwright.config.ts | 4 +- shared/schemas/backup.ts | 28 +- shared/schemas/models.ts | 145 +++++++++++ src/app/App.tsx | 158 +++++++----- src/components/ActiveSessionBar.tsx | 13 + src/components/FinishSessionModal.tsx | 11 + src/components/SessionCorrectionModal.tsx | 15 ++ src/components/SleepGapDialog.tsx | 10 + src/components/StartSessionModal.tsx | 44 ++++ src/components/TaskCard.tsx | 8 +- src/db/backupRepository.ts | 61 ++++- src/db/database.ts | 35 ++- src/db/index.ts | 4 +- src/db/sessionRepository.ts | 268 +++++++++++++++++++ src/db/settingsRepository.ts | 23 ++ src/domain/execution.ts | 110 ++++++++ src/domain/index.ts | 2 +- src/domain/models.ts | 11 +- src/features/api.ts | 6 +- src/features/executionAdapter.ts | 18 ++ src/features/executionTypes.ts | 7 + src/pages/ExecutionSettingsPage.tsx | 10 + src/pages/FocusPage.tsx | 37 +++ src/pages/HistoryPage.tsx | 56 ++++ src/pages/PlanPage.tsx | 50 +++- src/pages/TodayPage.tsx | 17 +- src/styles/global.css | 164 +++++++++++- studyflow.sh | 8 +- tests/TEST_CONTRACT.md | 29 +++ tests/backup.test.ts | 63 ++++- tests/execution-domain.test.ts | 137 ++++++++++ tests/session-repository.test.ts | 300 ++++++++++++++++++++++ 37 files changed, 1936 insertions(+), 131 deletions(-) create mode 100644 e2e/execution.spec.ts create mode 100644 src/components/ActiveSessionBar.tsx create mode 100644 src/components/FinishSessionModal.tsx create mode 100644 src/components/SessionCorrectionModal.tsx create mode 100644 src/components/SleepGapDialog.tsx create mode 100644 src/components/StartSessionModal.tsx create mode 100644 src/db/sessionRepository.ts create mode 100644 src/db/settingsRepository.ts create mode 100644 src/domain/execution.ts create mode 100644 src/features/executionAdapter.ts create mode 100644 src/features/executionTypes.ts create mode 100644 src/pages/ExecutionSettingsPage.tsx create mode 100644 src/pages/FocusPage.tsx create mode 100644 src/pages/HistoryPage.tsx create mode 100644 tests/execution-domain.test.ts create mode 100644 tests/session-repository.test.ts diff --git a/README.md b/README.md index 5bf36bf..ffc6a37 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ # StudyFlow -StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V1 聚焦任务规划、Today、艾森豪威尔四象限、分类管理和浏览器本地持久化,为后续执行记录、统计分析、PWA 和 AI Planner 保留扩展边界。 +StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V2 已形成第一阶段的 `Plan → Execute → Record`:先安排学习任务,再用正计时或番茄钟执行,并把实际投入和结果保存为可审计的本地记录。 -## V1 功能 +## 主要功能 - 新建、编辑、完成、重新打开和归档任务 - 四象限看板与任务列表切换 - 分类、状态和截止日期筛选 -- Today 今日/逾期任务与计划、完成、剩余时长 +- Today 今日/逾期任务、计划时长、实际专注、完成数量和当前会话 - 独立分类管理 -- IndexedDB 本地持久化和 TaskEvent 历史 -- 完整 JSON 导出和校验后的覆盖导入 +- 从任务或临时学习记录开始 `Stopwatch`(正计时)或 `Pomodoro`(番茄钟) +- Focus Mode、全局迷你计时栏、暂停、恢复、跳过休息和结束 +- 可配置专注/短休息/长休息/每组轮数、提示音和浏览器通知 +- 休眠或明显时间跳跃确认、多标签页状态同步和刷新恢复 +- 完成/部分完成/未完成、原因、总结和备注 +- History 日期/分类/任务/结果筛选,以及保留修改前后值的时间线修正 +- IndexedDB 本地持久化、`TaskEvent`、`StudyInterval` 和 `SessionRevision` 历史 +- V2 完整 JSON 备份、V1 备份兼容导入和覆盖导入前安全备份 ## 启动 @@ -26,18 +32,31 @@ cd /home/minglee/Projects/studyflow 其他命令: ```bash +./studyflow.sh typecheck ./studyflow.sh test ./studyflow.sh lint +./studyflow.sh e2e ./studyflow.sh build ``` ## 数据与备份 -V1 数据保存在当前浏览器的 IndexedDB 中。刷新页面或关闭浏览器不会丢失,但不同浏览器、不同网址和不同设备之间不会自动共享。 +数据保存在当前浏览器的 IndexedDB 中。刷新页面或关闭浏览器后会恢复任务与活动会话;StudyFlow 也会尝试申请浏览器持久存储。不同浏览器、不同网址和不同设备之间不会自动共享,浏览器数据清理仍可能删除本地数据。 建议定期点击应用左下角“数据管理”并导出 JSON。覆盖导入前,StudyFlow 会先触发下载一份当前数据的安全备份;无效或不兼容的文件不会修改现有数据库。 -任务在 UI 中删除后会归档而不是物理清除,TaskEvent 会保留创建、编辑、完成、重开和归档记录,为未来完成率与计划分析提供历史基础。 +任务在 UI 中删除后会归档而不是物理清除。任务事件、学习会话、专注/休息/暂停区间以及有原因的修正历史都会保留,为后续完成率、预计与实际时间、周报和趋势分析提供数据基础。 + +## V2 使用流程 + +1. 在 `Plan` 创建任务,或点击左下角“开始学习”建立临时记录。 +2. 选择正计时或番茄钟,填写本次目标后进入 `Focus`。 +3. 使用暂停、继续、跳过休息或结束;离开 Focus 后可通过底部迷你栏返回。 +4. 结束时选择结果;部分完成或未完成必须说明原因。 +5. 在 `History` 查看和筛选记录。如需修正,必须填写修正原因,原值不会被无痕覆盖。 +6. 定期从“数据管理”导出 JSON 备份。 + +计时以持久化 UTC 时间戳为准。休息、暂停和被排除的休眠时间不计入实际专注;跨午夜时按会话记录的本地时区分配到对应日期。 ## 开发 @@ -50,8 +69,8 @@ npm install ## Roadmap -- V1 Plan:任务、Today、四象限、分类和本地历史(当前版本) -- V2 Execution:Focus Mode、计时、番茄钟和实际学习记录 +- V1 Plan:任务、Today、四象限、分类和本地历史(已完成) +- V2 Execution:Focus Mode、计时、番茄钟和实际学习记录(当前版本) - V3 Experience + PWA:离线安装、环境音和主题体验 - V4 Analytics + Sync:学习统计、完成率、趋势和可选同步 - V5 AI Planner:根据目标、可用时间和历史执行情况动态调整计划 diff --git a/e2e/backup.spec.ts b/e2e/backup.spec.ts index 0ed3c29..d056110 100644 --- a/e2e/backup.spec.ts +++ b/e2e/backup.spec.ts @@ -1,7 +1,11 @@ import { expect, test } from '@playwright/test'; -test('导出后可覆盖导入,并在确认前自动下载当前安全备份', async ({ page }) => { +test('导出后可覆盖导入、自动安全备份并同步其他标签页', async ({ page, context }) => { await page.goto('/'); + const other = await context.newPage(); + await other.goto('/'); + await other.getByRole('link', { name: 'Categories' }).click(); + await expect(other.getByRole('heading', { name: '导入分类' })).toHaveCount(0); await page.getByRole('button', { name: '数据管理' }).click(); const exportDownload = page.waitForEvent('download'); @@ -13,7 +17,7 @@ test('导出后可覆盖导入,并在确认前自动下载当前安全备份', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ format: 'studyflow-backup', version: 1, exportedAt: new Date().toISOString(), - data: { categories: [{ id: 'other', name: '其他', sortOrder: 0, archivedAt: null, + data: { categories: [{ id: 'imported', name: '导入分类', sortOrder: 0, archivedAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }], tasks: [], taskEvents: [] }, })), }); @@ -23,6 +27,7 @@ test('导出后可覆盖导入,并在确认前自动下载当前安全备份', await page.getByRole('button', { name: '确认覆盖导入' }).click(); await safetyDownload; await expect(page.getByRole('status')).toContainText('导入成功'); + await expect(other.getByRole('heading', { name: '导入分类' })).toBeVisible(); }); test('无效文件不触发确认,也不改变当前数据', async ({ page }) => { diff --git a/e2e/execution.spec.ts b/e2e/execution.spec.ts new file mode 100644 index 0000000..f43d5f1 --- /dev/null +++ b/e2e/execution.spec.ts @@ -0,0 +1,163 @@ +import { expect, test, type Page } from "@playwright/test"; + +const FIXED_TIME = new Date("2026-08-14T08:00:00.000Z"); + +async function openAtFixedTime(page: Page) { + await page.clock.install({ time: FIXED_TIME }); + await page.goto("/"); +} + +test("从任务开始正计时,暂停恢复后结束并同步完成状态", async ({ page }) => { + await openAtFixedTime(page); + await page.getByRole("link", { name: "Plan" }).click(); + await page.getByRole("button", { name: "新建任务" }).click(); + await page.getByLabel("任务标题").fill("V2 执行测试"); + await page.getByLabel("预计完成时长").fill("30"); + await page.getByLabel("截止日期").fill("2026-08-14"); + await page.getByRole("button", { name: "保存" }).click(); + + const task = page.getByRole("article", { name: "V2 执行测试" }); + await task.getByRole("button", { name: "开始学习" }).click(); + await page.getByLabel("本次目标(可选)").fill("完成测试章节"); + await page.getByRole("button", { name: "进入 Focus" }).click(); + await expect(page.getByRole("heading", { name: "V2 执行测试" })).toBeVisible(); + + await page.clock.runFor(30_000); + await page.getByRole("button", { name: "暂停" }).click(); + await page.clock.runFor(30_000); + await page.getByRole("button", { name: "继续" }).click(); + await page.clock.runFor(31_000); + await page.getByRole("button", { name: "结束学习" }).click(); + await expect(page.getByRole("dialog", { name: "结束本次学习" })).toBeVisible(); + await expect(page.getByRole("dialog", { name: "结束本次学习" }).getByText("01:01")).toBeVisible(); + await page.getByRole("button", { name: "确认结束" }).click(); + + await page.getByRole("link", { name: "History" }).click(); + const history = page.getByRole("article").filter({ hasText: "V2 执行测试" }); + await expect(history).toContainText("完成"); + await history.getByRole("button", { name: "修正" }).click(); + await page.getByLabel("执行结果").selectOption("partial"); + await page.getByLabel("主要原因").selectOption("interrupted"); + await page.getByLabel("修正原因(必填)").fill("复盘后确认只完成一部分"); + await page.getByRole("button", { name: "保存修正" }).click(); + await expect(history).toContainText("部分完成"); + await page.getByRole("link", { name: "Plan" }).click(); + await page.getByLabel("按状态筛选").selectOption("completed"); + await expect(page.getByRole("article", { name: "V2 执行测试" })).toHaveAttribute("data-completed", "true"); +}); + +test("不足一分钟的临时学习会话自动丢弃", async ({ page }) => { + await openAtFixedTime(page); + await page.getByRole("button", { name: "开始学习" }).click(); + await page.getByLabel("学习名称").fill("误触计时"); + await page.getByRole("button", { name: "进入 Focus" }).click(); + await page.clock.runFor(10_000); + await page.getByRole("button", { name: "结束学习" }).click(); + await expect(page.getByText(/不足 1 分钟/)).toBeVisible(); + await page.getByRole("button", { name: "确认结束" }).click(); + await page.getByRole("link", { name: "History" }).click(); + await expect(page.getByText("误触计时")).toHaveCount(0); +}); + +test("番茄钟到时后等待确认,再进入休息阶段", async ({ page }) => { + await openAtFixedTime(page); + await page.getByRole("link", { name: "专注设置" }).click(); + await page.getByLabel("专注时长").fill("1"); + await page.getByLabel("短休息").fill("1"); + await page.getByRole("button", { name: "保存设置" }).click(); + + await page.getByRole("button", { name: "开始学习" }).click(); + await page.getByLabel("学习名称").fill("番茄阶段测试"); + await page.getByRole("radio", { name: /番茄钟/ }).check(); + await page.getByRole("button", { name: "进入 Focus" }).click(); + await page.clock.runFor(61_000); + await expect(page.getByText("本阶段完成")).toBeVisible(); + await page.getByRole("button", { name: "开始休息" }).click(); + await expect(page.getByText("休息", { exact: true })).toBeVisible(); + await page.clock.runFor(61_000); + await expect(page.getByRole("button", { name: "开始下一轮" })).toBeVisible(); +}); + +test("正计时达到任务预计时长后只提示并继续计时", async ({ page }) => { + await openAtFixedTime(page); + await page.getByRole("link", { name: "Plan" }).click(); + await page.getByRole("button", { name: "新建任务" }).click(); + await page.getByLabel("任务标题").fill("预计时长提醒"); + await page.getByLabel("预计完成时长").fill("1"); + await page.getByLabel("截止日期").fill("2026-08-14"); + await page.getByRole("button", { name: "保存" }).click(); + await page.getByRole("article", { name: "预计时长提醒" }).getByRole("button", { name: "开始学习" }).click(); + await page.getByRole("button", { name: "进入 Focus" }).click(); + await page.clock.runFor(61_000); + await expect(page.getByText("已达到任务预计时长,计时会继续进行")).toBeVisible(); + await expect(page.getByRole("button", { name: "暂停" })).toBeVisible(); +}); + +test("番茄休息可以暂停并跳过进入下一轮", async ({ page }) => { + await openAtFixedTime(page); + await page.getByRole("link", { name: "专注设置" }).click(); + await page.getByLabel("专注时长").fill("1"); + await page.getByRole("button", { name: "保存设置" }).click(); + await page.getByRole("button", { name: "开始学习" }).click(); + await page.getByLabel("学习名称").fill("休息控制测试"); + await page.getByRole("radio", { name: /番茄钟/ }).check(); + await page.getByRole("button", { name: "进入 Focus" }).click(); + await page.clock.runFor(61_000); + await page.getByRole("button", { name: "开始休息" }).click(); + await page.getByRole("button", { name: "暂停" }).click(); + await expect(page.getByText("休息已暂停")).toBeVisible(); + await page.getByRole("button", { name: "继续" }).click(); + await page.getByRole("button", { name: "跳过休息" }).click(); + await expect(page.getByText("POMODORO · ROUND 2")).toBeVisible(); + await expect(page.getByText("正在专注")).toBeVisible(); +}); + +test("可见页面发生明显 wall-clock 跳跃时要求处理休眠间隔", async ({ page }) => { + await openAtFixedTime(page); + await page.getByRole("button", { name: "开始学习" }).click(); + await page.getByLabel("学习名称").fill("休眠检测测试"); + await page.getByRole("button", { name: "进入 Focus" }).click(); + + await expect.poll(() => page.evaluate(() => document.visibilityState)).toBe("visible"); + await page.clock.runFor(1_000); + const before = await page.evaluate(() => Date.now()); + // 只移动 wall clock,不触发定时器;随后模拟系统恢复后窗口重新获得焦点。 + await page.clock.setSystemTime(new Date(before + 60_000)); + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); + const jumped = await page.evaluate(() => Date.now()) - before; + expect(jumped).toBeGreaterThanOrEqual(60_000); + expect(jumped).toBeLessThan(61_000); + await expect.poll(() => page.evaluate(() => document.visibilityState)).toBe("visible"); + await expect(page.getByRole("dialog", { name: "检测到计时中断" })).toBeVisible(); + await expect(page.getByText(/排除这段时间/)).toBeVisible(); +}); + +test("开始和结束会广播到同源的另一个标签页", async ({ page, context }) => { + await openAtFixedTime(page); + const other = await context.newPage(); + await other.clock.install({ time: FIXED_TIME }); + await other.goto("/"); + + await page.getByRole("button", { name: "开始学习" }).click(); + await page.getByLabel("学习名称").fill("跨标签同步测试"); + await page.getByRole("button", { name: "进入 Focus" }).click(); + await expect(other.getByRole("complementary", { name: "正在进行的学习" })).toContainText("跨标签同步测试"); + + await page.clock.runFor(61_000); + await page.getByRole("button", { name: "结束学习" }).click(); + await page.getByRole("button", { name: "确认结束" }).click(); + await expect(other.getByRole("complementary", { name: "正在进行的学习" })).toHaveCount(0); +}); + +test("设置修改会广播到同源的另一个标签页", async ({ page, context }) => { + await openAtFixedTime(page); + const other = await context.newPage(); + await other.clock.install({ time: FIXED_TIME }); + await other.goto("/"); + + await other.getByRole("link", { name: "专注设置" }).click(); + await page.getByRole("link", { name: "专注设置" }).click(); + await page.getByLabel("专注时长").fill("50"); + await page.getByRole("button", { name: "保存设置" }).click(); + await expect(other.getByLabel("专注时长")).toHaveValue("50"); +}); diff --git a/package-lock.json b/package-lock.json index e3cddfc..ae6782d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "studyflow", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "studyflow", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "dexie": "^4.2.0", "dexie-react-hooks": "^4.2.0", diff --git a/package.json b/package.json index 3a08137..5346f45 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "studyflow", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/playwright.config.ts b/playwright.config.ts index 4504ed1..07da757 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,8 +8,8 @@ export default defineConfig({ trace: 'on-first-retry', }, webServer: { - command: 'npm run preview', + command: 'npm run build && npm run preview', port: 4173, - reuseExistingServer: true, + reuseExistingServer: false, }, }) diff --git a/shared/schemas/backup.ts b/shared/schemas/backup.ts index b92e570..465f928 100644 --- a/shared/schemas/backup.ts +++ b/shared/schemas/backup.ts @@ -1,19 +1,23 @@ import { z } from "zod"; -import { categorySchema, taskEventSchema, taskSchema } from "./models"; +import { categorySchema, executionSettingsSchema, sessionRevisionSchema, studyIntervalSchema, studySessionSchema, taskEventSchema, taskSchema } from "./models"; export const BACKUP_FORMAT = "studyflow-backup" as const; -export const BACKUP_VERSION = 1 as const; +export const BACKUP_VERSION = 2 as const; -export const backupSchema = z.object({ +const commonDataSchema = z.object({ + tasks: z.array(taskSchema), categories: z.array(categorySchema).min(1, "备份必须至少包含一个分类"), taskEvents: z.array(taskEventSchema), +}); +export const backupV1Schema = z.object({ format: z.literal(BACKUP_FORMAT), - version: z.literal(BACKUP_VERSION), + version: z.literal(1), exportedAt: z.string().datetime({ offset: true }), - data: z.object({ - tasks: z.array(taskSchema), - categories: z.array(categorySchema).min(1, "备份必须至少包含一个分类"), - taskEvents: z.array(taskEventSchema), - }), + data: commonDataSchema, }); - -export type StudyFlowBackup = z.infer; - +export const backupV2Schema = z.object({ + format: z.literal(BACKUP_FORMAT), version: z.literal(2), exportedAt: z.string().datetime({ offset: true }), + data: commonDataSchema.extend({ studySessions: z.array(studySessionSchema), studyIntervals: z.array(studyIntervalSchema), + sessionRevisions: z.array(sessionRevisionSchema), executionSettings: executionSettingsSchema }), +}); +export const backupSchema = z.discriminatedUnion("version", [backupV1Schema, backupV2Schema]); +export type StudyFlowBackup = z.infer; +export type CompatibleStudyFlowBackup = z.infer; diff --git a/shared/schemas/models.ts b/shared/schemas/models.ts index 2c80b59..807e576 100644 --- a/shared/schemas/models.ts +++ b/shared/schemas/models.ts @@ -64,6 +64,140 @@ export const updateTaskInputSchema = createTaskInputSchema.partial().refine( export const createCategoryInputSchema = categorySchema.pick({ name: true }); export const updateCategoryInputSchema = createCategoryInputSchema; +export const timerModeSchema = z.enum(["stopwatch", "pomodoro"]); +export const sessionStatusSchema = z.enum(["running", "paused", "awaiting-confirmation", "sleep-review", "finished"]); +export const sessionOutcomeSchema = z.enum(["completed", "partial", "unfinished"]); +export const intervalKindSchema = z.enum(["focus", "break"]); +export const failureReasonSchema = z.enum([ + "underestimated", "insufficient-time", "interrupted", "low-energy", "plan-changed", "other", +]); + +export const pausePeriodSchema = z.object({ + startedAt: isoDateTimeSchema, + endedAt: isoDateTimeSchema.nullable(), +}).superRefine((value, ctx) => { + if (value.endedAt && Date.parse(value.endedAt) < Date.parse(value.startedAt)) { + ctx.addIssue({ code: "custom", path: ["endedAt"], message: "暂停结束时间不能早于开始时间" }); + } +}); + +export const sleepGapSchema = z.object({ + detectedAt: isoDateTimeSchema, + from: isoDateTimeSchema, + to: isoDateTimeSchema, + resolution: z.enum(["include", "exclude", "correct"]).nullable(), + correctedSeconds: z.number().int().nonnegative().nullable(), + resumeStatus: z.enum(["running", "paused"]), +}).superRefine((value, ctx) => { + const durationSeconds = (Date.parse(value.to) - Date.parse(value.from)) / 1000; + if (durationSeconds < 0) ctx.addIssue({ code: "custom", path: ["to"], message: "休眠结束时间不能早于开始时间" }); + if (value.resolution === "correct" && (value.correctedSeconds ?? 0) > durationSeconds) { + ctx.addIssue({ code: "custom", path: ["correctedSeconds"], message: "修正时长不能超过休眠区间" }); + } +}); + +export const studyIntervalSchema = z.object({ + id: z.string().min(1), + sessionId: z.string().min(1), + kind: intervalKindSchema, + pomodoroRound: z.number().int().positive().nullable(), + targetSeconds: z.number().int().positive().nullable(), + startedAt: isoDateTimeSchema, + endedAt: isoDateTimeSchema.nullable(), + pauses: z.array(pausePeriodSchema), + sleepGaps: z.array(sleepGapSchema), + createdAt: isoDateTimeSchema, + updatedAt: isoDateTimeSchema, +}).superRefine((value, ctx) => { + const start = Date.parse(value.startedAt); + const end = value.endedAt ? Date.parse(value.endedAt) : null; + if (end !== null && end < start) ctx.addIssue({ code: "custom", path: ["endedAt"], message: "阶段结束时间不能早于开始时间" }); + const pauses = [...value.pauses].sort((a, b) => Date.parse(a.startedAt) - Date.parse(b.startedAt)); + pauses.forEach((pause, index) => { + const pauseStart = Date.parse(pause.startedAt); + const pauseEnd = pause.endedAt ? Date.parse(pause.endedAt) : null; + if (pauseStart < start || (end !== null && (pauseEnd ?? Number.POSITIVE_INFINITY) > end)) { + ctx.addIssue({ code: "custom", path: ["pauses", index], message: "暂停区间必须位于所属阶段内" }); + } + const previous = pauses[index - 1]; + if (previous && (!previous.endedAt || Date.parse(previous.endedAt) > pauseStart)) { + ctx.addIssue({ code: "custom", path: ["pauses", index], message: "暂停区间不能重叠" }); + } + }); + value.sleepGaps.forEach((gap, index) => { + if (Date.parse(gap.from) < start || (end !== null && Date.parse(gap.to) > end)) { + ctx.addIssue({ code: "custom", path: ["sleepGaps", index], message: "休眠区间必须位于所属阶段内" }); + } + }); +}); + +export const pomodoroSettingsSnapshotSchema = z.object({ + focusMinutes: z.number().int().min(1).max(180), shortBreakMinutes: z.number().int().min(1).max(60), + longBreakMinutes: z.number().int().min(1).max(120), roundsPerSet: z.number().int().min(1).max(12), +}); + +export const studySessionSchema = z.object({ + id: z.string().min(1), + taskId: z.string().min(1).nullable(), + categoryId: z.string().min(1), + taskTitleSnapshot: z.string().trim().min(1).max(200), + categoryNameSnapshot: z.string().trim().min(1).max(80), + estimatedMinutesSnapshot: z.number().int().min(1).max(1440).nullable(), + goal: z.string().trim().max(500), + mode: timerModeSchema, + pomodoroSettingsSnapshot: pomodoroSettingsSnapshotSchema.nullable(), + status: sessionStatusSchema, + activeIntervalId: z.string().min(1).nullable(), + pomodoroRound: z.number().int().positive(), + startedAt: isoDateTimeSchema, + endedAt: isoDateTimeSchema.nullable(), + timezone: z.string().min(1), + outcome: sessionOutcomeSchema.nullable(), + failureReason: failureReasonSchema.nullable(), + note: z.string().trim().max(2000), + summary: z.string().trim().max(5000), + revision: z.number().int().nonnegative(), + createdAt: isoDateTimeSchema, + updatedAt: isoDateTimeSchema, +}); + +export const sessionRevisionSchema = z.object({ + id: z.string().min(1), + sessionId: z.string().min(1), + reason: z.string().trim().min(1).max(500), + before: z.record(z.string(), z.unknown()), + after: z.record(z.string(), z.unknown()), + createdAt: isoDateTimeSchema, +}); + +export const executionSettingsSchema = z.object({ + id: z.literal("default"), + focusMinutes: z.number().int().min(1).max(180), + shortBreakMinutes: z.number().int().min(1).max(60), + longBreakMinutes: z.number().int().min(1).max(120), + roundsPerSet: z.number().int().min(1).max(12), + soundEnabled: z.boolean(), + notificationsEnabled: z.boolean(), + stopwatchAutoPauseMinutes: z.number().int().min(60).max(1440), + updatedAt: isoDateTimeSchema, +}); + +export const startSessionInputSchema = z.object({ + taskId: z.string().min(1).nullable().optional(), + categoryId: z.string().min(1), + title: z.string().trim().min(1).max(200).optional(), + goal: z.string().trim().max(500).default(""), + timezone: z.string().min(1), +}); + +export const finishSessionInputSchema = z.object({ + outcome: sessionOutcomeSchema, + failureReason: failureReasonSchema.nullable().default(null), + note: z.string().trim().max(2000).default(""), + summary: z.string().trim().max(5000).default(""), + completeTask: z.boolean().default(false), +}).refine((v) => v.outcome === "completed" || v.failureReason !== null, "部分完成或未完成必须选择原因"); + export type Task = z.infer; export type Category = z.infer; export type TaskEvent = z.infer; @@ -72,3 +206,14 @@ export type TaskSnapshot = z.infer; export type CreateTaskInput = z.infer; export type UpdateTaskInput = z.infer; export type CreateCategoryInput = z.infer; +export type TimerMode = z.infer; +export type SessionStatus = z.infer; +export type SessionOutcome = z.infer; +export type FailureReason = z.infer; +export type StudyInterval = z.infer; +export type StudySession = z.infer; +export type SessionRevision = z.infer; +export type ExecutionSettings = z.infer; +export type PomodoroSettingsSnapshot = z.infer; +export type StartSessionInput = z.input; +export type FinishSessionInput = z.input; diff --git a/src/app/App.tsx b/src/app/App.tsx index 90b47e7..86325a6 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,70 +1,98 @@ -import { useCallback, useEffect, useRef, useState, type ChangeEvent } from "react"; -import { BookOpen, CalendarCheck, Database, LayoutGrid, Tags } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type ChangeEvent, type ReactNode } from "react"; +import { BookOpen, CalendarCheck, Database, History, LayoutGrid, Play, Settings, Tags } from "lucide-react"; import type { Category, CreateTaskInput, Task } from "../domain/models"; +import type { ExecutionSettings, FinishSessionInput, StartContext, StartSessionInput, StudyInterval, StudySession, TimerMode } from "../features/executionTypes"; +import { executionAdapter } from "../features/executionAdapter"; +import { intervalActiveMs, totalFocusMs } from "../domain/execution"; import { studyFlowApi } from "../features/api"; -import { TodayPage } from "../pages/TodayPage"; -import { PlanPage } from "../pages/PlanPage"; -import { CategoriesPage } from "../pages/CategoriesPage"; -import { TaskForm } from "../components/TaskForm"; -import { ConfirmDialog } from "../components/ConfirmDialog"; -import { Modal } from "../components/Modal"; +import { TodayPage } from "../pages/TodayPage"; import { PlanPage } from "../pages/PlanPage"; import { CategoriesPage } from "../pages/CategoriesPage"; import { FocusPage } from "../pages/FocusPage"; import { HistoryPage } from "../pages/HistoryPage"; import { ExecutionSettingsPage } from "../pages/ExecutionSettingsPage"; +import { TaskForm } from "../components/TaskForm"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { Modal } from "../components/Modal"; import { StartSessionModal } from "../components/StartSessionModal"; import { ActiveSessionBar } from "../components/ActiveSessionBar"; import { FinishSessionModal } from "../components/FinishSessionModal"; import { SessionCorrectionModal } from "../components/SessionCorrectionModal"; import { SleepGapDialog } from "../components/SleepGapDialog"; import { backupSchema } from "../../shared/schemas/backup"; -type Page = "today" | "plan" | "categories"; -type DeleteTarget = { type: "task"; item: Task } | { type: "category"; item: Category }; - -function downloadJson(data: unknown, prefix = "studyflow-backup") { - const date = new Date(); - const local = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; - const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" })); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = `${prefix}-${local}.json`; - anchor.click(); - URL.revokeObjectURL(url); -} - -export default function App() { - const [page, setPage] = useState("today"); - const [tasks, setTasks] = useState([]); - const [categories, setCategories] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [notice, setNotice] = useState(""); - const [editingTask, setEditingTask] = useState(undefined); - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleting, setDeleting] = useState(false); - const [backupOpen, setBackupOpen] = useState(false); - const [pendingImport, setPendingImport] = useState(null); - const fileRef = useRef(null); - - const refresh = useCallback(async () => { - const [nextTasks, nextCategories] = await Promise.all([studyFlowApi.tasks.list(), studyFlowApi.categories.list()]); - setTasks(nextTasks); setCategories(nextCategories); - }, []); - useEffect(() => { refresh().catch((reason) => setError(reason instanceof Error ? reason.message : "读取本地数据失败")).finally(() => setLoading(false)); }, [refresh]); - async function saveTask(input: CreateTaskInput) { if (editingTask) await studyFlowApi.tasks.update(editingTask.id, input); else await studyFlowApi.tasks.create(input); await refresh(); } - async function toggle(task: Task) { try { await studyFlowApi.tasks.toggleComplete(task.id); await refresh(); } catch (reason) { setError(reason instanceof Error ? reason.message : "更新失败"); } } - async function confirmDelete() { if (!deleteTarget) return; setDeleting(true); try { if (deleteTarget.type === "task") await studyFlowApi.tasks.archive(deleteTarget.item.id); else await studyFlowApi.categories.archive(deleteTarget.item.id); setDeleteTarget(null); await refresh(); } catch (reason) { setError(reason instanceof Error ? reason.message : "删除失败"); setDeleteTarget(null); } finally { setDeleting(false); } } - async function exportData(prefix?: string) { try { downloadJson(await studyFlowApi.backup.exportData(), prefix); setNotice("备份已导出"); } catch (reason) { setError(reason instanceof Error ? reason.message : "导出失败"); } } - async function selectImport(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; try { const parsed: unknown = JSON.parse(await file.text()); setPendingImport(backupSchema.parse(parsed)); setBackupOpen(false); setError(""); } catch { setError("无效的备份文件:格式或版本不受支持"); } } - async function confirmImport() { if (!pendingImport) return; try { downloadJson(await studyFlowApi.backup.exportData(), "studyflow-safety-backup"); await studyFlowApi.backup.replaceAll(pendingImport); setPendingImport(null); setBackupOpen(false); await refresh(); setNotice("导入成功,当前数据已被备份内容覆盖"); } catch (reason) { setError(reason instanceof Error ? reason.message : "导入失败,当前数据未改变"); } } - - if (loading) return

正在打开 StudyFlow…

; - return
- -
{error &&
{error}
}{notice &&
{notice}
} - {page === "today" && setDeleteTarget({ type: "task", item })} onNew={() => setEditingTask(null)} />} - {page === "plan" && setDeleteTarget({ type: "task", item })} onNew={() => setEditingTask(null)} />} - {page === "categories" && { await studyFlowApi.categories.create({ name }); await refresh(); }} onUpdate={async (id, name) => { await studyFlowApi.categories.update(id, { name }); await refresh(); }} onDelete={(item) => setDeleteTarget({ type: "category", item })} />} -
- {editingTask !== undefined && setEditingTask(undefined)} />} - {deleteTarget && void confirmDelete()} onClose={() => setDeleteTarget(null)} />} - {backupOpen && setBackupOpen(false)}>

导出完整备份

将任务、分类和历史事件保存为 JSON 文件。

覆盖导入

导入会替换当前数据。确认导入时,会先自动下载当前数据的安全备份。

void selectImport(e)} />
} - {pendingImport !== null && setPendingImport(null)}>

备份中的内容将替换当前全部数据。StudyFlow 会先自动下载当前数据的安全备份。

} -
; +type Page="today"|"plan"|"categories"|"history"|"settings"|"focus"; type DeleteTarget={type:"task";item:Task}|{type:"category";item:Category}; +function downloadJson(data:unknown,prefix="studyflow-backup"){const d=new Date(),local=`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`,url=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:"application/json"})),a=document.createElement("a");a.href=url;a.download=`${prefix}-${local}.json`;a.click();URL.revokeObjectURL(url)} +export default function App(){ + const [page,setPage]=useState("today"),[lastPage,setLastPage]=useState("today"),[tasks,setTasks]=useState([]),[categories,setCategories]=useState([]),[sessions,setSessions]=useState([]),[active,setActive]=useState(null),[intervals,setIntervals]=useState([]),[sessionDurations,setSessionDurations]=useState>({}),[settings,setSettings]=useState(null),[now,setNow]=useState(Date.now()),[loading,setLoading]=useState(true),[error,setError]=useState(""),[notice,setNotice]=useState(""),[finishOpen,setFinishOpen]=useState(false),[startContext,setStartContext]=useState(null),[correcting,setCorrecting]=useState(null),[editingTask,setEditingTask]=useState(undefined),[deleteTarget,setDeleteTarget]=useState(null),[backupOpen,setBackupOpen]=useState(false),[pendingImport,setPendingImport]=useState(null); const fileRef=useRef(null),channelRef=useRef(null),boundaryRevision=useRef(null),estimateNotified=useRef(null),refreshRequest=useRef(0),activeRef=useRef(null),hasUnresolvedRef=useRef(false); + const heartbeatWall=useRef(Date.now()),heartbeatMonotonic=useRef(performance.now()); + const refresh=useCallback(async()=>{const request=++refreshRequest.current,[nextTasks,nextCategories,nextActive,nextHistory,nextSettings]=await Promise.all([studyFlowApi.tasks.list(),studyFlowApi.categories.list(),executionAdapter.getActive(),executionAdapter.history(),executionAdapter.getSettings()]);const nextIntervals=nextActive?await executionAdapter.listIntervals(nextActive.id):[],historyIntervals=await Promise.all(nextHistory.map(item=>executionAdapter.listIntervals(item.id)));if(request!==refreshRequest.current)return;setTasks(nextTasks);setCategories(nextCategories);setActive(nextActive);setIntervals(nextIntervals);setSessionDurations(Object.fromEntries(nextHistory.map((item,index)=>[item.id,Math.floor(totalFocusMs(historyIntervals[index])/1000)])));setSessions(nextHistory);setSettings(nextSettings)},[]); + const mutate=useCallback(async(action:()=>Promise)=>{try{const value=await action();setActive(value??null);await refresh();channelRef.current?.postMessage("changed")}catch(e){setError(e instanceof Error?e.message:"操作失败");await refresh()}},[refresh]); + const notifyStage=useCallback((message:string)=>{if(settings?.soundEnabled&&"AudioContext" in window)try{const audio=new AudioContext(),osc=audio.createOscillator(),gain=audio.createGain();osc.connect(gain);gain.connect(audio.destination);gain.gain.value=.05;osc.onended=()=>void audio.close();osc.start();osc.stop(audio.currentTime+.18)}catch{/* 浏览器阻止自动播放时仅保留页面提醒 */}if(settings?.notificationsEnabled&&"Notification" in window&&Notification.permission==="granted")try{new Notification("StudyFlow",{body:message})}catch{/* 通知失败不影响计时 */}},[settings]); + useEffect(()=>{void refresh().catch(e=>setError(e instanceof Error?e.message:"读取本地数据失败")).finally(()=>setLoading(false))},[refresh]); + useEffect(()=>{void navigator.storage?.persist?.().catch(()=>false)},[]); + useEffect(()=>{const timer=window.setInterval(()=>setNow(Date.now()),1000);return()=>clearInterval(timer)},[]); + useEffect(()=>{const channel=new BroadcastChannel("studyflow-execution");channelRef.current=channel;channel.onmessage=()=>void refresh();return()=>{channel.close();channelRef.current=null}},[refresh]); + useEffect(()=>{const guard=(e:BeforeUnloadEvent)=>{if(active){e.preventDefault();e.returnValue=""}};window.addEventListener("beforeunload",guard);return()=>window.removeEventListener("beforeunload",guard)},[active]); + useEffect(()=>{if(backupOpen)fileRef.current?.setAttribute("aria-label","导入备份文件")},[backupOpen]); + const activeInterval=active?intervals.find(item=>item.id===active.activeIntervalId):undefined,focusSeconds=Math.floor(totalFocusMs(intervals,new Date(now).toISOString())/1000),phaseElapsed=activeInterval?Math.floor(intervalActiveMs(activeInterval,new Date(now).toISOString())/1000):0,displaySeconds=active?.mode==="pomodoro"&&activeInterval?.targetSeconds?Math.max(0,activeInterval.targetSeconds-phaseElapsed):focusSeconds,estimateReached=Boolean(active?.mode==="stopwatch"&&active.estimatedMinutesSnapshot&&focusSeconds>=active.estimatedMinutesSnapshot*60),continuousRunningSeconds=active?.status==="running"?Math.max(0,Math.floor((now-Date.parse(active.updatedAt))/1000)):0; + const unresolved=intervals.flatMap(interval=>interval.sleepGaps.map((gap,index)=>({interval,gap,index}))).find(item=>item.gap.resolution===null); + activeRef.current=active;hasUnresolvedRef.current=Boolean(unresolved); + useEffect(()=>{let expected=Date.now()+1_000;const check=()=>{const wall=Date.now(),monotonic=performance.now(),wallGap=wall-heartbeatWall.current,callbackDelay=wall-expected,drift=wallGap-(monotonic-heartbeatMonotonic.current),from=new Date(heartbeatWall.current).toISOString(),current=activeRef.current;expected=wall+1_000;heartbeatWall.current=wall;heartbeatMonotonic.current=monotonic;const visibleJump=document.visibilityState==="visible"&&(wallGap>15_000||callbackDelay>15_000);if(current?.status==="running"&&!hasUnresolvedRef.current&&(visibleJump||drift>15_000))void mutate(()=>executionAdapter.reportSleepGap(current,from,new Date(wall).toISOString()))};const onVisibilityChange=()=>{if(document.visibilityState==="visible")check()};const timer=window.setInterval(check,1_000);window.addEventListener("focus",check);document.addEventListener("visibilitychange",onVisibilityChange);return()=>{clearInterval(timer);window.removeEventListener("focus",check);document.removeEventListener("visibilitychange",onVisibilityChange)}},[mutate]); + useEffect(()=>{if(!active||active.status!=="running"||boundaryRevision.current===active.revision)return;if(active.mode==="pomodoro"&&activeInterval?.targetSeconds&&phaseElapsed>=activeInterval.targetSeconds){boundaryRevision.current=active.revision;void mutate(()=>executionAdapter.completeStage(active)).then(()=>notifyStage("本阶段已结束"));return}if(active.mode==="stopwatch"&&settings&&continuousRunningSeconds>=settings.stopwatchAutoPauseMinutes*60){boundaryRevision.current=active.revision;void mutate(()=>executionAdapter.autoPause(active)).then(()=>notifyStage("正计时已自动暂停"))}},[active,activeInterval,phaseElapsed,continuousRunningSeconds,settings,mutate,notifyStage]); + useEffect(()=>{if(active&&estimateReached&&estimateNotified.current!==active.id){estimateNotified.current=active.id;notifyStage("已达到任务预计时长");setNotice("已达到任务预计时长,计时仍在继续")}},[active,estimateReached,notifyStage]); + async function start(mode:TimerMode,input:StartSessionInput){const value=await executionAdapter.start(mode,input);heartbeatWall.current=Date.parse(value.startedAt);heartbeatMonotonic.current=performance.now();setActive(value);setIntervals(await executionAdapter.listIntervals(value.id));setStartContext(null);setLastPage(page);setPage("focus");channelRef.current?.postMessage("changed")} + async function finish(input:FinishSessionInput){if(!active)return;const saved=await executionAdapter.finish(active,input);setFinishOpen(false);setActive(null);setPage(lastPage==="focus"?"today":lastPage);await refresh();channelRef.current?.postMessage("changed");setNotice(saved?"学习记录已保存":"有效专注不足 1 分钟,本次记录已丢弃")} + async function saveTask(input:CreateTaskInput){if(editingTask)await studyFlowApi.tasks.update(editingTask.id,input);else await studyFlowApi.tasks.create(input);await refresh()} + async function toggle(task:Task){await studyFlowApi.tasks.toggleComplete(task.id);await refresh()} + async function confirmDelete(){if(!deleteTarget)return;try{if(deleteTarget.type==="task")await studyFlowApi.tasks.archive(deleteTarget.item.id);else await studyFlowApi.categories.archive(deleteTarget.item.id);setDeleteTarget(null);await refresh()}catch(e){setError(e instanceof Error?e.message:"删除失败")}} + async function exportData(prefix?:string){downloadJson(await studyFlowApi.backup.exportData(),prefix);await refresh();channelRef.current?.postMessage("changed");setNotice("备份已导出")} + async function selectImport(event:ChangeEvent){const file=event.target.files?.[0];event.target.value="";if(!file)return;try{setPendingImport(backupSchema.parse(JSON.parse(await file.text())));setBackupOpen(false)}catch{setError("无效的备份文件:格式或版本不受支持")}} + async function confirmImport(){if(!pendingImport)return;try{downloadJson(await studyFlowApi.backup.exportData(),"studyflow-safety-backup");await studyFlowApi.backup.replaceAll(pendingImport);setPendingImport(null);await refresh();channelRef.current?.postMessage("changed");setNotice("导入成功")}catch(cause){setError(cause instanceof Error?cause.message:"导入失败,当前数据未改变")}} + function navigate(next:Page){if(page==="focus")setLastPage(next);setPage(next)} + if(loading)return
+ +

正在打开 StudyFlow…

+
; + if(page==="focus"&&active)return <> +{error&&
{error}
} +setPage(lastPage)} onPause={()=>void mutate(()=>executionAdapter.pause(active))} onResume={()=>void mutate(()=>executionAdapter.resume(active))} onAdvance={action=>void mutate(()=>executionAdapter.advance(active,action))} onFinish={()=>setFinishOpen(true)}/>{finishOpen&&setFinishOpen(false)} onFinish={finish}/>} {unresolved&&{await mutate(()=>executionAdapter.resolveSleepGap(active,{intervalId:unresolved.interval.id,gapIndex:unresolved.index,resolution,correctedSeconds}))}}/>}; + return
+ +
{error&&
+{error} + +
}{notice&&
+{notice} + +
}{page==="today"&&void toggle(task)} onEdit={setEditingTask} onDelete={item=>setDeleteTarget({type:"task",item})} onNew={()=>setEditingTask(null)} onStart={task=>setStartContext({task})}/>} {page==="plan"&&void toggle(task)} onEdit={setEditingTask} onDelete={item=>setDeleteTarget({type:"task",item})} onNew={()=>setEditingTask(null)} onStart={task=>setStartContext({task})}/>} {page==="categories"&&{await studyFlowApi.categories.create({name});await refresh()}} onUpdate={async(id,name)=>{await studyFlowApi.categories.update(id,{name});await refresh()}} onDelete={item=>setDeleteTarget({type:"category",item})}/>} {page==="history"&&void refresh()} onCorrect={setCorrecting}/>} {page==="settings"&&{const next=await executionAdapter.saveSettings(value);setSettings(next);channelRef.current?.postMessage("changed");if(next.notificationsEnabled&&"Notification" in window&&Notification.permission==="default")try{await Notification.requestPermission()}catch{/* 权限请求失败不影响已保存设置 */}}}/>}
{active&&{setLastPage(page);setPage("focus")}} onPause={()=>void mutate(()=>executionAdapter.pause(active))} onResume={()=>void mutate(()=>executionAdapter.resume(active))} onFinish={()=>setFinishOpen(true)}/>} {startContext&&setStartContext(null)} onStart={start}/>} {finishOpen&&active&&setFinishOpen(false)} onFinish={finish}/>} {unresolved&&active&&{await mutate(()=>executionAdapter.resolveSleepGap(active,{intervalId:unresolved.interval.id,gapIndex:unresolved.index,resolution,correctedSeconds}))}}/>} {correcting&&setCorrecting(null)} onSave={async input=>{await executionAdapter.correct(correcting,input);setCorrecting(null);await refresh();channelRef.current?.postMessage("changed");setNotice("修正已保存,原始值已进入审计记录")}}/>} {editingTask!==undefined&&setEditingTask(undefined)}/>} {deleteTarget&&void confirmDelete()} onClose={()=>setDeleteTarget(null)}/>} {backupOpen&&setBackupOpen(false)}> +
+
+

导出完整备份

+

保存计划和执行记录。

+ +
+
+

覆盖导入

+void selectImport(e)}/> + +
+
+
} {pendingImport!==null&&setPendingImport(null)}> +

当前数据将先自动备份,再被文件内容覆盖。

+
+ + +
+
}
; } +function Nav({active,icon,label,onClick}:{active:boolean;icon:ReactNode;label:string;onClick:()=>void}){return {e.preventDefault();onClick()}}>{icon}{label}} diff --git a/src/components/ActiveSessionBar.tsx b/src/components/ActiveSessionBar.tsx new file mode 100644 index 0000000..80172fc --- /dev/null +++ b/src/components/ActiveSessionBar.tsx @@ -0,0 +1,13 @@ +import { Maximize2, Pause, Play, Square } from "lucide-react"; +import type { StudySession } from "../features/executionTypes"; +import { formatDuration } from "../features/executionAdapter"; + +export function ActiveSessionBar({ session, seconds, onFocus, onPause, onResume, onFinish }: { session: StudySession; seconds: number; onFocus: () => void; onPause: () => void; onResume: () => void; onFinish: () => void }) { + const paused = session.status === "paused"; + const canToggle = session.status === "running" || paused; + return ; +} diff --git a/src/components/FinishSessionModal.tsx b/src/components/FinishSessionModal.tsx new file mode 100644 index 0000000..087fb7c --- /dev/null +++ b/src/components/FinishSessionModal.tsx @@ -0,0 +1,11 @@ +import { useState, type FormEvent } from "react"; +import type { FailureReason, FinishSessionInput, SessionOutcome, StudySession } from "../features/executionTypes"; +import { formatDuration } from "../features/executionAdapter"; +import { Modal } from "./Modal"; + +const reasons = [["underestimated", "低估任务难度"], ["insufficient-time", "可用时间不足"], ["interrupted", "被其他事情打断"], ["low-energy", "状态不佳"], ["plan-changed", "学习计划改变"], ["other", "其他"]] as const; +export function FinishSessionModal({ session, focusSeconds, onClose, onFinish }: { session: StudySession; focusSeconds: number; onClose: () => void; onFinish: (input: FinishSessionInput) => Promise }) { + const [result, setResult] = useState("completed"); const [reason, setReason] = useState(""); const [summary, setSummary] = useState(""); const [note, setNote] = useState(""); const [completeTask, setCompleteTask] = useState(Boolean(session.taskId)); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + async function submit(event: FormEvent) { event.preventDefault(); if (result !== "completed" && !reason) return setError("请选择未完全完成的原因"); setBusy(true); setError(""); try { await onFinish({ outcome: result, failureReason: reason || null, summary: summary.trim(), note: note.trim(), completeTask: result === "completed" && completeTask }); } catch (cause) { setError(cause instanceof Error ? cause.message : "保存失败"); setBusy(false); } } + return
void submit(event)}>
有效专注{formatDuration(focusSeconds)}{session.taskTitleSnapshot}
本次结果{([['completed','完成'],['partial','部分完成'],['unfinished','未完成']] as const).map(([value,label]) => )}
{result !== "completed" && }