diff --git a/CODEX_HANDOFF.md b/CODEX_HANDOFF.md new file mode 100644 index 0000000..9046d58 --- /dev/null +++ b/CODEX_HANDOFF.md @@ -0,0 +1,68 @@ +# StudyFlow Codex Handoff + +## 交接日期与分支 + +- 交接环境:Ubuntu 22.04 +- 交接分支:`feature/v3-meditation` +- 远端仓库:`https://github.com/MingLeeEatPy/studyflow.git` +- 目标:把 Ubuntu 中途停止的工作安全保存到 GitHub,随后在 Windows VS Code + Codex 继续。 + +## 本次开发新增内容 + +本地未提交工作包含 V3 Growth Garden + Meditation: + +- IndexedDB schema version 3 与 V3 JSON backup。 +- `GrowthRecord`、学习树、冥想花、Today 花园及稳定植物变体。 +- `MeditationSession`、`MeditationInterval`、Meditation Repository 和页面。 +- 定时/自由冥想、呼吸引导、暂停/恢复、到时后正计时、复盘、History 类型筛选。 +- V3 单元测试、Playwright 测试和 Meditation 视觉快照。 + +## 已知未完成与风险 + +这不是已发布的 V3。最近的双重审查确认以下问题必须由 Windows Codex 继续处理: + +- 点击结束后复盘填写期间仍可能继续计时,应先冻结并持久化结束时间。 +- V3 导入的 Meditation 状态组合和成长记录一致性校验仍不够严格。 +- 页面关闭后重新打开时,长时间间隔的休眠检测还需要持久化心跳支持。 +- 多标签结束会话时,另一页需要可靠退出沉浸路由并清理旧复盘状态。 +- Today/快速开始的 Meditation 入口、呼吸阶段轻提示音、冥想专用休眠文案和部分无障碍行为仍需补齐。 + +## 当前验证证据 + +上一次 Ubuntu 验证记录: + +- `npm run typecheck`:通过。 +- `npm run lint`:通过。 +- `npm test -- --run`:92 tests passed。 +- `npm run build`:通过。 +- `npm run test:e2e`:26 tests passed。 + +这些结果证明当前本地流程可运行,但不代表上面的审查问题已经解决。Windows 接手后应先重新运行所有检查,再修复风险并新增回归测试。 + +## Ubuntu 上运行 + +项目路径:`/home/minglee/Projects/studyflow` + +```bash +cd /home/minglee/Projects/studyflow +export PATH="$HOME/.local/opt/node-v24.19.0-linux-x64/bin:$PATH" +npm install +npm run dev +``` + +也可以使用: + +```bash +./studyflow.sh +``` + +浏览器访问 `http://127.0.0.1:5173`。设计样板使用 `http://127.0.0.1:5173/?design-preview=1`。 + +## Windows Codex 接手顺序 + +1. 先执行 `git status`、`git branch -vv`,确认工作区和远端分支。 +2. 阅读本文件、`PRODUCT.md`、`ROADMAP.md` 和最近提交记录。 +3. 重新运行 typecheck、lint、unit、E2E 和 build。 +4. 先修复“已知未完成与风险”,并为每个修复增加回归测试。 +5. 再进行两次独立只读审查和完整本地验收。 +6. 只在用户确认后创建 Draft PR;不要自动合并 `main` 或创建正式版本标签。 diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..ad30021 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,27 @@ +# StudyFlow 产品交接说明 + +## 当前版本 + +当前工作区位于 `feature/v3-meditation`,目标版本为 V3 开发中间态。V1 与 V2 的核心计划和执行功能已经进入可运行状态;V3 的成长花园和 Meditation 已在本地完成一轮实现,但尚未合并到 `main`,也尚未发布正式版本号。 + +## 产品定位 + +StudyFlow 是 Web-first、本地优先的个人学习计划与执行助手,核心闭环是: + +`Plan → Execute → Record` + +当前不包含账号、云同步、PWA、好友自习室、完整分析图表、白噪音课程或 AI Planner。 + +## 已完成能力 + +- 任务、分类、Today、四象限和本地 IndexedDB 持久化。 +- Stopwatch、Pomodoro、Focus Mode、暂停/恢复/休息/超时正计时。 +- 任务结果、未完成原因、总结、History 和带审计记录的修正。 +- V1/V2 数据迁移与 JSON 备份导入导出。 +- V3 数据库结构、`GrowthRecord`、五阶段学习成长树和 Today 花园。 +- Meditation 的定时/自由模式、4-7-8/均衡/箱式/无引导、暂停恢复、复盘、冥想花和 History 类型筛选。 +- 刷新恢复、BroadcastChannel 多标签同步、休眠时间确认的基础流程。 + +## 当前交接状态 + +本次 Ubuntu 任务的目标是安全保存并同步现有工作,不继续扩展 Roadmap。V3 代码保留为可供 Windows Codex 继续审查的开发中间态;不要把当前状态当作已发布的 V3 成品。 diff --git a/README.md b/README.md index bbbfda0..1a3846f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,15 @@ # StudyFlow +## 视觉设计样板 + +当前的「自然沉浸与正念成长」视觉样板通过以下地址访问: + +```text +http://127.0.0.1:5173/?design-preview=1 +``` + +样板使用本地假数据,不读取或写入 StudyFlow 的 IndexedDB。Today、Plan、Focus、学习成长树和 Meditation 已分阶段接入正式界面;样板仍使用隔离的假数据。 + StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V2 已形成第一阶段的 `Plan → Execute → Record`:先安排学习任务,再用正计时或番茄钟执行,并把实际投入和结果保存为可审计的本地记录。 ## 主要功能 @@ -19,7 +29,9 @@ StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V2 已形 - 完成/部分完成/未完成、原因、总结和备注 - History 日期/分类/任务/结果筛选,以及保留修改前后值的时间线修正 - IndexedDB 本地持久化、`TaskEvent`、`StudyInterval` 和 `SessionRevision` 历史 -- V2 完整 JSON 备份、V1 备份兼容导入和覆盖导入前安全备份 +- V3 完整 JSON 备份、V1/V2 备份兼容导入和覆盖导入前安全备份 +- 学习会话驱动的五阶段成长树与 Today 花园(不会补生成升级前的历史植物) +- Meditation 定时/自由模式、呼吸引导、冥想花和学习/冥想 History 隔离(当前仍处于 V3 交接中间态) ## 启动 @@ -73,8 +85,9 @@ npm install ## Roadmap - V1 Plan:任务、Today、四象限、分类和本地历史(已完成) -- V2 Execution:Focus Mode、计时、番茄钟和实际学习记录(当前版本) -- V3 Experience + PWA:离线安装、环境音和主题体验 +- V2 Execution:Focus Mode、计时、番茄钟和实际学习记录(已完成主要功能) +- V3 Growth Garden + Meditation:成长树、Today 花园和冥想流程(本地实现,待 Windows 继续收尾) +- V3 后续 Experience + PWA:离线安装、环境音和主题体验 - V4 Analytics + Sync:学习统计、完成率、趋势和可选同步 - V5 AI Planner:根据目标、可用时间和历史执行情况动态调整计划 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..73427eb --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,23 @@ +# StudyFlow Roadmap + +## 当前交接点 + +- V1 Plan:已完成。 +- V2 Execution:已完成主要功能,当前版本仍需在后续发布流程中确认版本号和合并目标。 +- V3 Growth Garden + Meditation:本地分支已有实现和测试,但本次只完成安全交接,不继续添加新功能。 + +## V3 尚未完成或需要下一位 Agent 继续处理 + +1. 结束 Meditation 时需要先持久化冻结计时,再打开复盘;填写复盘的时间不能继续计入本次冥想。 +2. V3 备份导入需要完整验证 Meditation 状态机、区间组合、`<60 秒` 丢弃规则和成长花记录的一致性。 +3. 重新打开浏览器时,需要基于持久化心跳识别页面关闭期间的长时间休眠间隔。 +4. 结束/切换标签页后的沉浸页面、复盘弹窗和活动会话状态需要更严格地按 session ID 清理。 +5. Today 与快速开始需要增加 Meditation 快捷入口。 +6. 呼吸阶段的轻提示音、冥想专用休眠提示文案以及 Meditation 的无障碍 live region/焦点管理仍需完善。 +7. 完成上述修复后,再进行独立审查、提交、Draft PR、用户验收和正式合并;不要直接合并到 `main`。 + +## 后续路线 + +- V3 收尾:完成上述状态与交互边界后发布。 +- V4:学习时长、完成率、预计/实际比较、周报趋势,以及按需要设计账号和同步。 +- V5:基于目标、截止日期、可用时间和历史执行能力的 AI Planner。 diff --git a/docs/design-preview/ASSET_PROMPTS.md b/docs/design-preview/ASSET_PROMPTS.md new file mode 100644 index 0000000..b78b8e5 --- /dev/null +++ b/docs/design-preview/ASSET_PROMPTS.md @@ -0,0 +1,38 @@ +# 原创背景资产生成记录 + +生成方式:Codex 内置 `imagegen`,`photorealistic-natural` 模式。植物与花朵没有使用图片生成,均为项目内可维护的 SVG。 + +## Focus 雾林 + +输出:`src/assets/nature/focus-forest.webp` + +```text +Use case: photorealistic-natural +Asset type: full-screen desktop web app Focus background +Primary request: an original quiet misty forest scene for a study focus timer, with softly blurred leaves and fine branches framing the scene and calm atmospheric depth +Scene/backdrop: early morning woodland with light fog, natural foliage, no buildings +Style/medium: photorealistic natural photography with subtle film texture, refined but not glossy +Composition/framing: wide landscape composition; generous uncluttered negative space through the center for an overlaid plant illustration, timer, and controls; foliage may frame the outer edges but must not create a strong focal object +Lighting/mood: soft diffused dawn light, meditative, quiet, restrained, low contrast +Color palette: deep ink green #10241E, moss green #294C40, muted sage #6F9280, small touches of warm gray; low saturation +Constraints: background image only; no UI; no people; no animals; no text; no logos; no watermark; suitable for a dark translucent overlay; original composition +Avoid: neon green, bright sun, fantasy glow, dramatic landscape, colorful flowers, high contrast, busy detail, obvious stock-photo polish +``` + +## Meditation 晨雾水面 + +输出:`src/assets/nature/meditation-water.webp` + +```text +Use case: photorealistic-natural +Asset type: full-screen desktop web app Meditation background +Primary request: an original tranquil waterside dawn scene for a meditation timer, with still water, delicate mist, soft reeds and barely visible distant foliage +Scene/backdrop: quiet natural pond or slow lake at first light, no structures +Style/medium: photorealistic natural photography with subtle film texture, refined and understated +Composition/framing: wide landscape composition; calm uncluttered negative space through the center for an overlaid breathing halo, flower illustration, timer, and controls; subtle natural framing only at far edges +Lighting/mood: diffused pale morning light through mist, serene, contemplative, soft and low contrast +Color palette: muted warm gray, deep ink green #10241E, sage #6F9280, soft stone and a restrained trace of warm gold #D7B77A; low saturation +Materials/textures: gentle water reflection, realistic fine mist, soft organic reeds +Constraints: background image only; no UI; no people; no animals; no flowers as focal subjects; no text; no logos; no watermark; suitable for a dark translucent overlay; original composition +Avoid: vivid sunrise, orange sky, neon teal, dramatic mountains, fantasy glow, colorful flowers, busy detail, high contrast, obvious stock-photo polish +``` diff --git a/docs/design-preview/DESIGN_SYSTEM.md b/docs/design-preview/DESIGN_SYSTEM.md new file mode 100644 index 0000000..e5c5bdd --- /dev/null +++ b/docs/design-preview/DESIGN_SYSTEM.md @@ -0,0 +1,117 @@ +# StudyFlow「自然沉浸与正念成长」视觉设计系统 + +状态:视觉方向已确认,设计 token 已接入 Today、Plan、Focus、成长树和 Meditation 正式页面。该目录中的 `?design-preview=1` 仍使用完全隔离的假数据,不读写正式 IndexedDB。V3 Meditation 代码目前处于交接中间态,详见仓库根目录的 `CODEX_HANDOFF.md`。 + +## 1. 设计原则 + +StudyFlow 是一个安静、自然、克制、有呼吸感的学习与正念空间。 + +- 信息优先:任务标题、计时和主要操作始终比装饰更清晰。 +- 自然但不拟物:自然照片只建立氛围,成长植物使用柔和分层 SVG,不模仿 Forest 的游戏视觉。 +- 成长但不奖惩:植物记录真实投入,不枯萎、不用金币、连续签到或强烈庆祝制造压力。 +- 沉浸但可退出:Focus 与 Meditation 可以降低视觉噪音,但暂停、结束和返回始终清晰可见。 + +## 2. Design tokens + +| 用途 | Token | 值 | +| --- | --- | --- | +| 浅色主背景 | `--sf-mist` | `#F3F5F1` | +| 暖色辅助背景 | `--sf-warm` | `#F5F1E9` | +| 主文字 | `--sf-ink` | `#18231E` | +| 品牌鼠尾草绿 | `--sf-sage` | `#6F9280` | +| 柔光绿 | `--sf-sage-soft` | `#9AB9A8` | +| 深色沉浸背景 | `--sf-forest` | `#10241E` | +| 深色辅助层 | `--sf-moss` | `#294C40` | +| 到时与超时提示 | `--sf-gold` | `#D7B77A` | + +圆角分为四级:控件 `12px`、任务卡 `18px`、主面板 `24px`、沉浸控制区 `32px`。不要为了“更圆”随意增加新级别。 + +## 3. 毛玻璃与背景边界 + +允许使用毛玻璃: + +- Today 的主统计与今日花园 +- Plan 的筛选工具栏 +- 全局活动计时栏 +- 弹窗 +- Focus 与 Meditation 的沉浸控制区 + +不使用或只使用近实色: + +- 任务卡和四象限内的密集内容 +- 表单输入、选择器和长文本 +- History 的记录列表 +- 需要连续阅读的说明文字 + +浅色玻璃为约 62% 白色与 `18px` blur;深色玻璃为约 48% 黑绿与 `24px` blur。无 `backdrop-filter` 时分别降级为 `#F7F8F5` 与 `#18332A` 实色。 + +自然照片只用于 Focus、Meditation 和少量冥想入口 Hero。Today 与 Plan 使用抽象雾化渐变,避免装饰干扰优先级判断。 + +## 4. 代表页面 + +### Today + +- 第一屏左侧显示计划、完成、剩余、实际专注和进行中的会话。 +- 右侧“今日花园”直接显示最近植物,正式接入时最多 8 株。 +- 任务列表保持近实色与高对比度。 +- 移动端改为单列,主操作收成圆角图标按钮。 + +### Plan + +- 四象限与列表切换、分类/状态/日期筛选保持现有能力。 +- 筛选只使用一层玻璃;象限使用低饱和红、蓝、琥珀、绿。 +- 象限任务卡保持近实色,不叠加多层透明效果。 + +### Focus + +- 本地雾林背景上覆盖深绿渐变,保证中心文字对比。 +- 植物、任务、计时和控制区沿同一视觉轴排列。 +- 运行、暂停和超时都有明确文字;暖金只用于到时/超时提醒。 +- 植物动画不遮挡计时,`prefers-reduced-motion` 下停止非必要动画。 + +### Meditation + +- 本地晨雾水面背景比 Focus 更柔和、更开放。 +- 设置、呼吸、冥想、复盘是四个连续但可跳过的状态。 +- 呼吸光圈使用缓慢缩放和文字提示;声音提示应作为可选增强,不能成为理解阶段的唯一方式。 +- 感受选择不使用“好/坏”评价,不强迫填写备注。 + +## 5. 成长系统 + +学习生成树,冥想生成花。五阶段为: + +1. `<10%`:种子 +2. `10–34%`:嫩芽 +3. `35–64%`:幼苗 +4. `65–99%`:小树或花苞 +5. `≥100%`:成熟树或盛开 + +植物阶段跟随有效时间与目标快照动态计算。提前结束或未完成不会枯萎;成熟后超时只增加少量叶片或柔光。低饱和变体应由会话 ID 稳定生成,备份与刷新后不能改变。 + +## 6. Meditation 操作流程 + +```text +Today / 侧边栏 / 快速开始 + → 选择定时或自由计时 + → 选择意图 + → 选择呼吸引导 + → 呼吸引导(可跳过) + → 冥想计时(暂停 / 恢复 / 提前结束) + → 定时结束响铃 + → 超时正计时,直到主动结束 + → 五档感受与可选备注 + → 保存或跳过复盘并保存 + → 生成冥想花,并进入 History 与今日花园 +``` + +呼吸引导阶段不计入核心冥想时长。正式实现必须复用现有通知、音量、多标签同步、刷新恢复与休眠确认能力,并保证全局只有一个学习或冥想活动会话。 + +## 7. 实施门禁 + +当前已完成设计确认样板和第 1 项正式视觉改造,后续依次实施: + +1. ~~正式视觉 token 与 Today / Plan / Focus 改造~~ +2. IndexedDB version 3、备份 version 3、成长记录与今日花园(已完成) +3. Meditation 状态边界、备份校验、异常恢复与无障碍收尾(待 Windows Codex 继续) + +每阶段单独验收,禁止在视觉改造阶段顺带修改正式数据模型。 diff --git a/docs/design-preview/screenshots/focus-desktop.png b/docs/design-preview/screenshots/focus-desktop.png new file mode 100644 index 0000000..c13d566 Binary files /dev/null and b/docs/design-preview/screenshots/focus-desktop.png differ diff --git a/docs/design-preview/screenshots/focus-mobile.png b/docs/design-preview/screenshots/focus-mobile.png new file mode 100644 index 0000000..e4b5e6e Binary files /dev/null and b/docs/design-preview/screenshots/focus-mobile.png differ diff --git a/docs/design-preview/screenshots/growth-desktop.png b/docs/design-preview/screenshots/growth-desktop.png new file mode 100644 index 0000000..e246bc2 Binary files /dev/null and b/docs/design-preview/screenshots/growth-desktop.png differ diff --git a/docs/design-preview/screenshots/growth-mobile.png b/docs/design-preview/screenshots/growth-mobile.png new file mode 100644 index 0000000..c85f26d Binary files /dev/null and b/docs/design-preview/screenshots/growth-mobile.png differ diff --git a/docs/design-preview/screenshots/meditation-desktop.png b/docs/design-preview/screenshots/meditation-desktop.png new file mode 100644 index 0000000..14c0628 Binary files /dev/null and b/docs/design-preview/screenshots/meditation-desktop.png differ diff --git a/docs/design-preview/screenshots/meditation-mobile.png b/docs/design-preview/screenshots/meditation-mobile.png new file mode 100644 index 0000000..17c37ee Binary files /dev/null and b/docs/design-preview/screenshots/meditation-mobile.png differ diff --git a/docs/design-preview/screenshots/plan-desktop.png b/docs/design-preview/screenshots/plan-desktop.png new file mode 100644 index 0000000..2413d02 Binary files /dev/null and b/docs/design-preview/screenshots/plan-desktop.png differ diff --git a/docs/design-preview/screenshots/plan-mobile.png b/docs/design-preview/screenshots/plan-mobile.png new file mode 100644 index 0000000..4fe4764 Binary files /dev/null and b/docs/design-preview/screenshots/plan-mobile.png differ diff --git a/docs/design-preview/screenshots/today-desktop.png b/docs/design-preview/screenshots/today-desktop.png new file mode 100644 index 0000000..f960415 Binary files /dev/null and b/docs/design-preview/screenshots/today-desktop.png differ diff --git a/docs/design-preview/screenshots/today-mobile.png b/docs/design-preview/screenshots/today-mobile.png new file mode 100644 index 0000000..fea2999 Binary files /dev/null and b/docs/design-preview/screenshots/today-mobile.png differ diff --git a/e2e/design-preview.spec.ts b/e2e/design-preview.spec.ts new file mode 100644 index 0000000..b63b918 --- /dev/null +++ b/e2e/design-preview.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; + +test.describe("隔离设计样板", () => { + test("可以浏览代表页面并且不会创建正式数据库", async ({ page }) => { + await page.goto("/?design-preview=1"); + + await expect(page.getByRole("heading", { name: "今天,按自己的节奏前进" })).toBeVisible(); + await expect(page.getByText("今日花园")).toBeVisible(); + expect(await page.evaluate(async () => (await indexedDB.databases()).length)).toBe(0); + + await page.getByRole("button", { name: "Plan", exact: true }).first().click(); + await expect(page.getByRole("heading", { name: "把精力放在真正重要的地方" })).toBeVisible(); + + await page.getByRole("button", { name: "Focus", exact: true }).first().click(); + await page.getByRole("button", { name: "暂停", exact: true }).click(); + await expect(page.getByText("已暂停 · 植物也在休息")).toBeVisible(); + await page.getByRole("button", { name: "超时", exact: true }).click(); + await expect(page.getByText("本轮已完成 · 超时专注")).toBeVisible(); + + await page.getByRole("button", { name: "Meditation", exact: true }).click(); + await expect(page.getByRole("heading", { name: "此刻,你需要怎样的停留?" })).toBeVisible(); + await page.getByRole("button", { name: "开始这次停留" }).click(); + await expect(page.getByText("吸气")).toBeVisible(); + await page.getByRole("button", { name: "冥想", exact: true }).click(); + await expect(page.getByText("安静地停留")).toBeVisible(); + + expect(await page.evaluate(async () => (await indexedDB.databases()).length)).toBe(0); + }); + + test("手机预览使用 390×844 设计画布", async ({ page }) => { + await page.goto("/?design-preview=1&screen=today"); + await page.getByRole("button", { name: "手机宽度" }).click(); + const viewport = page.locator(".preview-viewport"); + await expect(viewport).toHaveCSS("width", "390px"); + await expect(viewport).toHaveCSS("height", "844px"); + await expect(page.locator(".mobile-nav")).toBeVisible(); + }); +}); diff --git a/e2e/execution.spec.ts b/e2e/execution.spec.ts index dfa1eff..5370e8f 100644 --- a/e2e/execution.spec.ts +++ b/e2e/execution.spec.ts @@ -59,6 +59,32 @@ test("不足一分钟的临时学习会话自动丢弃", async ({ page }) => { await expect(page.getByText("误触计时")).toHaveCount(0); }); +test("Focus 按真实投入成长,结束后植物进入今日花园", 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 expect(page.locator(".focus-botanical .tree-stage")).toHaveClass(/stage-0/); + await page.clock.runFor(30_000); + await expect(page.locator(".focus-botanical .tree-stage")).toHaveClass(/stage-2/); + await page.clock.runFor(31_000); + await expect(page.locator(".focus-botanical .tree-stage")).toHaveClass(/stage-4/); + await page.getByRole("button", { name: "结束学习" }).click(); + await page.getByRole("button", { name: "确认结束" }).click(); + await page.getByRole("link", { name: "Today" }).click(); + + const garden = page.getByRole("list", { name: "今日成长植物" }); + await expect(garden).toBeVisible(); + await expect(garden.getByRole("listitem")).toHaveCount(1); + await expect(garden.getByRole("img", { name: "成熟树" })).toBeVisible(); +}); + test("番茄钟到时后等待确认,再进入休息阶段", async ({ page }) => { await openAtFixedTime(page); await page.getByRole("link", { name: "专注设置" }).click(); diff --git a/e2e/meditation.spec.ts b/e2e/meditation.spec.ts new file mode 100644 index 0000000..00e1bde --- /dev/null +++ b/e2e/meditation.spec.ts @@ -0,0 +1,100 @@ +import { expect, test, type Page } from "@playwright/test"; + +const FIXED_TIME = new Date("2026-08-15T03:00:00.000Z"); + +async function openAtFixedTime(page: Page) { + await page.clock.install({ time: FIXED_TIME }); + await page.goto("/"); +} + +async function openMeditation(page: Page) { + await page.getByRole("link", { name: "Meditation" }).click(); + await expect(page.getByRole("heading", { name: "给思绪留一点安静" })).toBeVisible(); +} + +async function startOneMinuteMeditation(page: Page, breathing: "guided" | "none" = "none") { + await openMeditation(page); + await page.getByLabel("自定义冥想分钟").fill("1"); + if (breathing === "none") { + await page.getByLabel("呼吸引导").selectOption("none"); + } + await page.getByRole("button", { name: "进入 Meditation" }).click(); +} + +test("开始冥想、跳过呼吸并完成复盘后同步 Today 花朵与 History 类型筛选", async ({ page }) => { + await openAtFixedTime(page); + await startOneMinuteMeditation(page, "guided"); + + await expect(page.getByText("呼吸引导", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "跳过引导" }).click(); + await expect(page.getByText("正在冥想", { exact: true })).toBeVisible(); + await page.clock.runFor(61_000); + + await page.getByRole("button", { name: "结束冥想" }).click(); + const review = page.getByRole("dialog", { name: "结束本次冥想" }); + await expect(review).toBeVisible(); + await expect(review.getByText(/^01:0[01]$/)).toBeVisible(); + await review.getByRole("radio", { name: /4.*更放松/ }).check(); + await review.getByPlaceholder("此刻注意到了什么?").fill("呼吸慢下来以后,注意力更稳定了。"); + await review.getByRole("button", { name: "保存冥想" }).click(); + + await page.getByRole("link", { name: "Today" }).click(); + const garden = page.getByRole("list", { name: "今日成长植物" }); + await expect(garden).toBeVisible(); + await expect(garden.getByRole("listitem")).toHaveCount(1); + await expect(garden.getByRole("img", { name: "盛开的花" })).toBeVisible(); + + await page.getByRole("link", { name: "History" }).click(); + const meditationRecord = page.getByRole("article").filter({ hasText: "平静" }); + await page.getByLabel("记录类型").selectOption("meditation"); + await expect(meditationRecord).toBeVisible(); + await expect(meditationRecord).toContainText("01:01"); + await expect(meditationRecord).toContainText("更放松"); + await page.getByLabel("记录类型").selectOption("study"); + await expect(meditationRecord).toHaveCount(0); +}); + +test("定时冥想到时后继续正计时并显示超时成长状态", async ({ page }) => { + await openAtFixedTime(page); + await startOneMinuteMeditation(page); + + await page.clock.runFor(61_000); + await expect(page.getByText("定时结束 · 正计时", { exact: true })).toBeVisible(); + await expect(page.locator(".meditation-controls-glass time")).toHaveText(/^00:0[01]$/); + await expect(page.locator(".meditation-flower .plant-illustration")).toHaveClass(/is-overtime/); + await page.clock.runFor(4_000); + await expect(page.locator(".meditation-controls-glass time")).toHaveText(/^00:0[45]$/); + await expect(page.getByRole("button", { name: "结束冥想" })).toBeVisible(); +}); + +test("活动冥想在刷新后恢复,并在同源标签页间同步暂停、继续与结束", async ({ page, context }) => { + await openAtFixedTime(page); + const other = await context.newPage(); + await other.clock.install({ time: FIXED_TIME }); + await other.goto("/"); + + await openMeditation(page); + await page.getByRole("radio", { name: "自由计时" }).check(); + await page.getByLabel("呼吸引导").selectOption("none"); + await page.getByRole("button", { name: "进入 Meditation" }).click(); + await expect(other.getByRole("complementary", { name: "正在进行的冥想" })).toBeVisible(); + + await page.clock.runFor(10_000); + await page.reload(); + const restored = page.getByRole("complementary", { name: "正在进行的冥想" }); + await expect(restored).toBeVisible(); + await restored.getByRole("button", { name: /Meditation/ }).click(); + await expect(page.getByText("自由冥想", { exact: true })).toBeVisible(); + + await other.getByRole("button", { name: "暂停冥想" }).click(); + await expect(page.getByText("冥想已暂停", { exact: true })).toBeVisible(); + await other.getByRole("button", { name: "继续冥想" }).click(); + await expect(page.getByText("自由冥想", { exact: true })).toBeVisible(); + + await other.getByRole("button", { name: "结束冥想" }).click(); + const review = other.getByRole("dialog", { name: "结束本次冥想" }); + await expect(review.getByText(/不足 1 分钟/)).toBeVisible(); + await review.getByRole("button", { name: "跳过复盘并保存" }).click(); + await expect(page.getByRole("complementary", { name: "正在进行的冥想" })).toHaveCount(0); + await expect(other.getByRole("complementary", { name: "正在进行的冥想" })).toHaveCount(0); +}); diff --git a/e2e/visual-system.spec.ts b/e2e/visual-system.spec.ts new file mode 100644 index 0000000..acedc5d --- /dev/null +++ b/e2e/visual-system.spec.ts @@ -0,0 +1,140 @@ +import { expect, test, type Page } from "@playwright/test"; + +const FIXED_TIME = new Date("2026-08-15T03:00:00.000Z"); + +async function createTask(page: Page, title: string, category: string, minutes: number, important: boolean, urgent: boolean) { + await page.getByRole("button", { name: "新建任务" }).click(); + await page.getByLabel("任务标题").fill(title); + await page.getByLabel("所属分类").selectOption({ label: category }); + await page.getByLabel("预计完成时长").fill(String(minutes)); + await page.getByLabel("截止日期").fill("2026-08-15"); + if (important) await page.getByRole("checkbox", { name: /^重要 / }).check(); + if (urgent) await page.getByRole("checkbox", { name: /^紧急 / }).check(); + await page.getByRole("button", { name: "保存" }).click(); +} + +test("Today、Plan、Focus 的正式视觉在桌面和手机宽度保持稳定", async ({ page }) => { + test.setTimeout(60_000); + await page.clock.install({ time: FIXED_TIME }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await page.getByRole("link", { name: "Plan" }).click(); + + await createTask(page, "高数:复习微分中值定理", "高数", 45, true, true); + await createTask(page, "CS50 Week 4 笔记整理", "CS50", 35, true, false); + await createTask(page, "线性代数习题 3.2", "线性代数", 30, false, true); + + await expect(page).toHaveScreenshot("plan-nature-desktop.png", { animations: "disabled", maxDiffPixels: 150 }); + await page.getByRole("link", { name: "Today" }).click(); + await expect(page).toHaveScreenshot("today-nature-desktop.png", { animations: "disabled", maxDiffPixels: 150 }); + + await page.getByRole("link", { name: "Plan" }).click(); + await page.getByRole("article", { name: /高数:复习微分中值定理/ }).getByRole("button", { name: "开始学习" }).click(); + await page.getByLabel("本次目标(可选)").fill("完成本章定理复习与两道例题"); + await page.getByRole("button", { name: "进入 Focus" }).click(); + await page.clock.runFor(8 * 60 * 1000); + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 250)); + await page.locator(".focus-orbit time").evaluate((element) => { element.textContent = "08:00"; }); + await expect(page).toHaveScreenshot("focus-nature-desktop.png", { animations: "disabled", maxDiffPixels: 150 }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator(".focus-orbit time").evaluate((element) => { element.textContent = "08:00"; }); + await expect(page).toHaveScreenshot("focus-nature-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); + await page.getByRole("button", { name: "返回 StudyFlow" }).click(); + await page.getByRole("link", { name: "Today" }).click(); + await page.locator(".active-session-bar time").evaluate((element) => { element.textContent = "08:00"; }); + await expect(page).toHaveScreenshot("today-nature-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); + await page.getByRole("link", { name: "Plan" }).click(); + await page.locator(".active-session-bar time").evaluate((element) => { element.textContent = "08:00"; }); + await expect(page).toHaveScreenshot("plan-nature-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); +}); + +test("正式视觉支持键盘焦点与减少动画", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "今天,按自己的节奏前进" })).toBeVisible(); + await page.keyboard.press("Tab"); + const todayLink = page.getByRole("link", { name: "Today" }); + await expect(todayLink).toBeFocused(); + const transitionSeconds = await todayLink.evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration)); + expect(transitionSeconds).toBeLessThanOrEqual(0.001); +}); + +test("Focus 暂停、超时和三按钮状态在手机宽度保持清晰", async ({ page }) => { + await page.clock.install({ time: FIXED_TIME }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await page.getByRole("button", { name: "开始学习" }).click(); + await page.getByLabel("学习名称").fill("移动端番茄视觉检查"); + await page.getByRole("radio", { name: /番茄钟/ }).check(); + await page.getByLabel("本次专注时长").fill("1"); + await page.getByLabel("本次短休息").fill("1"); + await page.getByRole("button", { name: "进入 Focus" }).click(); + + await page.getByRole("button", { name: "暂停" }).click(); + await expect(page.locator(".focus-botanical")).toHaveClass(/paused/); + await page.getByRole("button", { name: "继续" }).click(); + await page.clock.runFor(61_000); + await expect(page.getByText("超时专注 · 正计时")).toBeVisible(); + await expect(page.getByRole("progressbar", { name: "当前专注阶段进度" })).toHaveAttribute("aria-valuenow", "100"); + await expect(page.locator(".focus-botanical")).toHaveClass(/overtime/); + + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 250)); + await page.locator(".focus-orbit time").evaluate((element) => { element.textContent = "00:01"; }); + await expectNoButtonOverlap(page); + await expect(page).toHaveScreenshot("focus-overtime-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); + + await page.getByRole("button", { name: "开始休息" }).click(); + await page.getByRole("button", { name: "暂停" }).click(); + await expect(page.locator(".focus-botanical")).toHaveClass(/paused/); + await expectNoButtonOverlap(page); + await expect(page).toHaveScreenshot("focus-break-paused-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); + + await page.setViewportSize({ width: 320, height: 844 }); + await expectNoButtonOverlap(page); +}); + +test("Meditation 的入口、沉浸计时与复盘在桌面和手机宽度保持稳定", async ({ page }) => { + test.setTimeout(60_000); + await page.clock.install({ time: FIXED_TIME }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await page.getByRole("link", { name: "Meditation" }).click(); + await expect(page.getByRole("heading", { name: "给思绪留一点安静" })).toBeVisible(); + await expect(page).toHaveScreenshot("meditation-entry-desktop.png", { animations: "disabled", maxDiffPixels: 150 }); + + await page.getByLabel("自定义冥想分钟").fill("1"); + await page.getByLabel("呼吸引导").selectOption("none"); + await page.getByRole("button", { name: "进入 Meditation" }).click(); + await page.clock.runFor(31_000); + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 250)); + await expect(page.getByText("正在冥想", { exact: true })).toBeVisible(); + await expect(page).toHaveScreenshot("meditation-focus-desktop.png", { animations: "disabled", maxDiffPixels: 150 }); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page).toHaveScreenshot("meditation-focus-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); + await page.getByRole("button", { name: "结束冥想" }).click(); + await expect(page.getByRole("dialog", { name: "结束本次冥想" })).toBeVisible(); + await expect(page).toHaveScreenshot("meditation-review-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); +}); + +async function expectNoButtonOverlap(page: Page) { + const boxes = await page.locator(".focus-actions > .button").evaluateAll((buttons) => buttons.map((button) => { + const box = button.getBoundingClientRect(); + return { + left: box.left, + right: box.right, + top: box.top, + bottom: box.bottom, + contentFits: button.scrollWidth <= button.clientWidth, + }; + })); + expect(boxes.every((box) => box.contentFits)).toBe(true); + for (let first = 0; first < boxes.length; first += 1) { + for (let second = first + 1; second < boxes.length; second += 1) { + const a = boxes[first]; const b = boxes[second]; + const overlaps = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; + expect(overlaps).toBe(false); + } + } +} diff --git a/e2e/visual-system.spec.ts-snapshots/focus-break-paused-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/focus-break-paused-mobile-linux.png new file mode 100644 index 0000000..27f51a4 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/focus-break-paused-mobile-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/focus-nature-desktop-linux.png b/e2e/visual-system.spec.ts-snapshots/focus-nature-desktop-linux.png new file mode 100644 index 0000000..6608299 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/focus-nature-desktop-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/focus-nature-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/focus-nature-mobile-linux.png new file mode 100644 index 0000000..9c992aa Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/focus-nature-mobile-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/focus-overtime-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/focus-overtime-mobile-linux.png new file mode 100644 index 0000000..9bb905f Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/focus-overtime-mobile-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/meditation-entry-desktop-linux.png b/e2e/visual-system.spec.ts-snapshots/meditation-entry-desktop-linux.png new file mode 100644 index 0000000..6151c47 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/meditation-entry-desktop-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/meditation-focus-desktop-linux.png b/e2e/visual-system.spec.ts-snapshots/meditation-focus-desktop-linux.png new file mode 100644 index 0000000..0df5704 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/meditation-focus-desktop-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/meditation-focus-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/meditation-focus-mobile-linux.png new file mode 100644 index 0000000..38f73c2 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/meditation-focus-mobile-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/meditation-review-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/meditation-review-mobile-linux.png new file mode 100644 index 0000000..391e4e8 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/meditation-review-mobile-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/plan-nature-desktop-linux.png b/e2e/visual-system.spec.ts-snapshots/plan-nature-desktop-linux.png new file mode 100644 index 0000000..2c1cf9c Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/plan-nature-desktop-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/plan-nature-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/plan-nature-mobile-linux.png new file mode 100644 index 0000000..ff3fb41 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/plan-nature-mobile-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/today-nature-desktop-linux.png b/e2e/visual-system.spec.ts-snapshots/today-nature-desktop-linux.png new file mode 100644 index 0000000..be7c2d0 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/today-nature-desktop-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/today-nature-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/today-nature-mobile-linux.png new file mode 100644 index 0000000..3e1d006 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/today-nature-mobile-linux.png differ diff --git a/shared/schemas/backup.ts b/shared/schemas/backup.ts index 465f928..2d9ada5 100644 --- a/shared/schemas/backup.ts +++ b/shared/schemas/backup.ts @@ -1,8 +1,19 @@ import { z } from "zod"; -import { categorySchema, executionSettingsSchema, sessionRevisionSchema, studyIntervalSchema, studySessionSchema, taskEventSchema, taskSchema } from "./models"; +import { + categorySchema, + executionSettingsSchema, + growthRecordSchema, + meditationIntervalSchema, + meditationSessionSchema, + sessionRevisionSchema, + studyIntervalSchema, + studySessionSchema, + taskEventSchema, + taskSchema, +} from "./models"; export const BACKUP_FORMAT = "studyflow-backup" as const; -export const BACKUP_VERSION = 2 as const; +export const BACKUP_VERSION = 3 as const; const commonDataSchema = z.object({ tasks: z.array(taskSchema), categories: z.array(categorySchema).min(1, "备份必须至少包含一个分类"), taskEvents: z.array(taskEventSchema), @@ -18,6 +29,15 @@ export const backupV2Schema = z.object({ 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 const backupV3Schema = z.object({ + format: z.literal(BACKUP_FORMAT), version: z.literal(3), exportedAt: z.string().datetime({ offset: true }), + data: commonDataSchema.extend({ + studySessions: z.array(studySessionSchema), studyIntervals: z.array(studyIntervalSchema), + sessionRevisions: z.array(sessionRevisionSchema), executionSettings: executionSettingsSchema, + growthRecords: z.array(growthRecordSchema), meditationSessions: z.array(meditationSessionSchema), + meditationIntervals: z.array(meditationIntervalSchema), + }), +}); +export const backupSchema = z.discriminatedUnion("version", [backupV1Schema, backupV2Schema, backupV3Schema]); +export type StudyFlowBackup = z.infer; export type CompatibleStudyFlowBackup = z.infer; diff --git a/shared/schemas/models.ts b/shared/schemas/models.ts index 1bbc283..635dc90 100644 --- a/shared/schemas/models.ts +++ b/shared/schemas/models.ts @@ -2,6 +2,9 @@ import { z } from "zod"; export const localDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "日期必须为 YYYY-MM-DD"); export const isoDateTimeSchema = z.string().datetime({ offset: true }); +export const timeZoneSchema = z.string().min(1).refine((value) => { + try { new Intl.DateTimeFormat("en", { timeZone: value }); return true; } catch { return false; } +}, "时区必须是有效的 IANA 时区名称"); export const taskSchema = z.object({ id: z.string().min(1), @@ -151,7 +154,7 @@ export const studySessionSchema = z.object({ pomodoroRound: z.number().int().positive(), startedAt: isoDateTimeSchema, endedAt: isoDateTimeSchema.nullable(), - timezone: z.string().min(1), + timezone: timeZoneSchema, outcome: sessionOutcomeSchema.nullable(), failureReason: failureReasonSchema.nullable(), note: z.string().trim().max(2000), @@ -183,12 +186,95 @@ export const executionSettingsSchema = z.object({ updatedAt: isoDateTimeSchema, }); +export const growthSourceTypeSchema = z.enum(["study", "meditation"]); +export const plantTypeSchema = z.enum(["tree", "flower"]); +export const growthRecordSchema = z.object({ + id: z.string().min(1), + sourceType: growthSourceTypeSchema, + sourceSessionId: z.string().min(1), + plantType: plantTypeSchema, + variant: z.number().int().min(0).max(2), + targetSecondsSnapshot: z.number().int().positive(), + localDate: localDateSchema, + timezone: timeZoneSchema, + createdAt: isoDateTimeSchema, +}); + +export const meditationModeSchema = z.enum(["timed", "free"]); +export const meditationStatusSchema = z.enum(["breathing", "running", "paused", "sleep-review", "finished"]); +export const meditationIntentionSchema = z.enum(["calm", "refocus", "observe", "self-care", "rest", "other"]); +export const breathingPatternSchema = z.enum(["4-7-8", "balanced", "box", "none"]); +export const meditationSessionSchema = z.object({ + id: z.string().min(1), + mode: meditationModeSchema, + status: meditationStatusSchema, + intention: meditationIntentionSchema, + intentionNote: z.string().trim().max(200), + breathingPattern: breathingPatternSchema, + breathingRounds: z.number().int().nonnegative().max(20), + targetSeconds: z.number().int().positive().nullable(), + activeIntervalId: z.string().min(1).nullable(), + startedAt: isoDateTimeSchema, + meditationStartedAt: isoDateTimeSchema.nullable(), + endedAt: isoDateTimeSchema.nullable(), + timezone: timeZoneSchema, + feeling: z.number().int().min(1).max(5).nullable(), + note: z.string().trim().max(2000), + revision: z.number().int().nonnegative(), + createdAt: isoDateTimeSchema, + updatedAt: isoDateTimeSchema, +}); + +export const meditationIntervalSchema = z.object({ + id: z.string().min(1), + sessionId: z.string().min(1), + kind: z.enum(["breathing", "meditation"]), + 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: "冥想阶段结束时间不能早于开始时间" }); + value.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: "暂停区间必须位于冥想阶段内" }); + } + }); + 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 startMeditationInputSchema = z.object({ + mode: meditationModeSchema, + targetMinutes: z.number().int().min(1).max(180).nullable(), + intention: meditationIntentionSchema, + intentionNote: z.string().trim().max(200).default(""), + breathingPattern: breathingPatternSchema, + timezone: timeZoneSchema, +}).refine((value) => value.mode === "free" ? value.targetMinutes === null : value.targetMinutes !== null, + "定时冥想必须设置时长,自由冥想不能设置固定时长"); + +export const finishMeditationInputSchema = z.object({ + feeling: z.number().int().min(1).max(5).nullable().default(null), + note: z.string().trim().max(2000).default(""), +}); + 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), + timezone: timeZoneSchema, pomodoroSettings: pomodoroSettingsSnapshotSchema.optional(), }); @@ -217,5 +303,16 @@ export type StudySession = z.infer; export type SessionRevision = z.infer; export type ExecutionSettings = z.infer; export type PomodoroSettingsSnapshot = z.infer; +export type GrowthSourceType = z.infer; +export type PlantType = z.infer; +export type GrowthRecord = z.infer; +export type MeditationMode = z.infer; +export type MeditationStatus = z.infer; +export type MeditationIntention = z.infer; +export type BreathingPattern = z.infer; +export type MeditationSession = z.infer; +export type MeditationInterval = z.infer; +export type StartMeditationInput = z.input; +export type FinishMeditationInput = z.input; export type StartSessionInput = z.input; export type FinishSessionInput = z.input; diff --git a/src/app/App.tsx b/src/app/App.tsx index 61f7d3e..25426bf 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,38 +1,49 @@ import { useCallback, useEffect, useRef, useState, type ChangeEvent, type ReactNode } from "react"; -import { BookOpen, CalendarCheck, Database, History, LayoutGrid, Play, Settings, Tags } from "lucide-react"; +import { BookOpen, CalendarCheck, Database, Flower2, History, LayoutGrid, Leaf, 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 type { PomodoroSettingsSnapshot } from "../../shared/schemas/models"; +import type { FinishMeditationInput, GrowthRecord, MeditationInterval, MeditationSession, PomodoroSettingsSnapshot, StartMeditationInput } from "../../shared/schemas/models"; import { executionAdapter } from "../features/executionAdapter"; import { intervalActiveMs, totalFocusMs } from "../domain/execution"; +import { calculateGrowthStage, stablePlantVariant, studyGrowthTargetSeconds } from "../domain/growth"; +import { breathingGuide, meditationEffectiveMs, totalMeditationMs } from "../domain/meditation"; +import { meditationAdapter } from "../features/meditationAdapter"; import { studyFlowApi } from "../features/api"; 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 { MeditationPage } from "../pages/MeditationPage"; import { MeditationFocusPage } from "../pages/MeditationFocusPage"; 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 { SessionPomodoroSettingsModal } from "../components/SessionPomodoroSettingsModal"; import { SleepGapDialog } from "../components/SleepGapDialog"; +import { ActiveMeditationBar } from "../components/ActiveMeditationBar"; import { FinishMeditationModal } from "../components/FinishMeditationModal"; import { backupSchema } from "../../shared/schemas/backup"; -type Page="today"|"plan"|"categories"|"history"|"settings"|"focus"; type DeleteTarget={type:"task";item:Task}|{type:"category";item:Category}; +type Page="today"|"plan"|"categories"|"history"|"settings"|"focus"|"meditation"|"meditation-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)} function playNotificationSound(volume:number){if(!("AudioContext" in window))return;try{const audio=new AudioContext(),peak=Math.max(.05,Math.min(.5,volume/200));void audio.resume().then(()=>{[0,.22,.44].forEach((delay,index)=>{const start=audio.currentTime+delay,osc=audio.createOscillator(),gain=audio.createGain();osc.frequency.value=index===2?880:660;gain.gain.setValueAtTime(.0001,start);gain.gain.exponentialRampToValueAtTime(peak,start+.02);gain.gain.exponentialRampToValueAtTime(.0001,start+.18);osc.connect(gain);gain.connect(audio.destination);osc.start(start);osc.stop(start+.2)});setTimeout(()=>void audio.close(),900)}).catch(()=>void audio.close())}catch{/* 浏览器阻止自动播放时由页面视觉提醒兜底 */}} 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),[pomodoroEditOpen,setPomodoroEditOpen]=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),overtimeRef=useRef(false); + const [page,setPage]=useState("today"),[lastPage,setLastPage]=useState("today"),[tasks,setTasks]=useState([]),[categories,setCategories]=useState([]),[sessions,setSessions]=useState([]),[growthRecords,setGrowthRecords]=useState([]),[active,setActive]=useState(null),[intervals,setIntervals]=useState([]),[sessionDurations,setSessionDurations]=useState>({}),[meditationSessions,setMeditationSessions]=useState([]),[activeMeditation,setActiveMeditation]=useState(null),[meditationIntervals,setMeditationIntervals]=useState([]),[meditationDurations,setMeditationDurations]=useState>({}),[settings,setSettings]=useState(null),[now,setNow]=useState(Date.now()),[loading,setLoading]=useState(true),[error,setError]=useState(""),[notice,setNotice]=useState(""),[finishOpen,setFinishOpen]=useState(false),[meditationFinishOpen,setMeditationFinishOpen]=useState(false),[pomodoroEditOpen,setPomodoroEditOpen]=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),meditationBoundaryRevision=useRef(null),meditationTargetNotified=useRef(null),refreshRequest=useRef(0),activeRef=useRef(null),meditationRef=useRef(null),hasUnresolvedRef=useRef(false),meditationHasUnresolvedRef=useRef(false),overtimeRef=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 refresh=useCallback(async()=>{const request=++refreshRequest.current,[nextTasks,nextCategories,nextActive,nextHistory,nextMeditationActive,nextMeditationHistory,nextSettings,nextGrowth]=await Promise.all([studyFlowApi.tasks.list(),studyFlowApi.categories.list(),executionAdapter.getActive(),executionAdapter.history(),meditationAdapter.getActive(),meditationAdapter.history(),executionAdapter.getSettings(),studyFlowApi.growth.list()]);const [nextIntervals,historyIntervals,nextMeditationIntervals,meditationHistoryIntervals]=await Promise.all([nextActive?executionAdapter.listIntervals(nextActive.id):Promise.resolve([]),Promise.all(nextHistory.map(item=>executionAdapter.listIntervals(item.id))),nextMeditationActive?meditationAdapter.listIntervals(nextMeditationActive.id):Promise.resolve([]),Promise.all(nextMeditationHistory.map(item=>meditationAdapter.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);setActiveMeditation(nextMeditationActive??null);setMeditationIntervals(nextMeditationIntervals);setMeditationDurations(Object.fromEntries(nextMeditationHistory.map((item,index)=>[item.id,Math.floor(totalMeditationMs(meditationHistoryIntervals[index])/1000)])));setMeditationSessions(nextMeditationHistory);setGrowthRecords(nextGrowth);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 mutateMeditation=useCallback(async(action:()=>Promise)=>{try{const value=await action();setActiveMeditation(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)playNotificationSound(settings.soundVolume);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(()=>{const guard=(e:BeforeUnloadEvent)=>{if(active||activeMeditation){e.preventDefault();e.returnValue=""}};window.addEventListener("beforeunload",guard);return()=>window.removeEventListener("beforeunload",guard)},[active,activeMeditation]); 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,isPomodoroOvertime=Boolean(active?.mode==="pomodoro"&&active.status==="awaiting-confirmation"&&activeInterval?.kind==="focus"&&!activeInterval.endedAt),displaySeconds=isPomodoroOvertime&&activeInterval?.targetSeconds?Math.max(0,phaseElapsed-activeInterval.targetSeconds):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 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,isPomodoroOvertime=Boolean(active?.mode==="pomodoro"&&active.status==="awaiting-confirmation"&&activeInterval?.kind==="focus"&&!activeInterval.endedAt),displaySeconds=isPomodoroOvertime&&activeInterval?.targetSeconds?Math.max(0,phaseElapsed-activeInterval.targetSeconds):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,activeGrowthStage=active?calculateGrowthStage(focusSeconds,studyGrowthTargetSeconds(active)):0,activeGrowthVariant=active?stablePlantVariant(active.id):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);overtimeRef.current=isPomodoroOvertime; - 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&&(current.status==="running"||overtimeRef.current)&&!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]); + const meditationInterval=activeMeditation?meditationIntervals.find(item=>item.id===activeMeditation.activeIntervalId):undefined,meditationSeconds=Math.floor(totalMeditationMs(meditationIntervals,new Date(now).toISOString())/1000),meditationPhaseSeconds=meditationInterval?Math.floor((meditationInterval.kind==="breathing"?intervalActiveMs(meditationInterval,new Date(now).toISOString()):meditationEffectiveMs(meditationInterval,new Date(now).toISOString()))/1000):0,meditationOvertime=Boolean(activeMeditation?.mode==="timed"&&activeMeditation.status!=="breathing"&&activeMeditation.targetSeconds&&meditationSeconds>=activeMeditation.targetSeconds),meditationGrowthStage=activeMeditation?calculateGrowthStage(meditationSeconds,activeMeditation.targetSeconds??600):0,meditationGrowthVariant=activeMeditation?stablePlantVariant(activeMeditation.id):0; + const meditationUnresolved=meditationIntervals.flatMap(interval=>interval.sleepGaps.map((gap,index)=>({interval,gap,index}))).find(item=>item.gap.resolution===null); + activeRef.current=active;meditationRef.current=activeMeditation;hasUnresolvedRef.current=Boolean(unresolved);meditationHasUnresolvedRef.current=Boolean(meditationUnresolved);overtimeRef.current=isPomodoroOvertime; + 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,currentMeditation=meditationRef.current;expected=wall+1_000;heartbeatWall.current=wall;heartbeatMonotonic.current=monotonic;const visibleJump=document.visibilityState==="visible"&&(wallGap>15_000||callbackDelay>15_000);if((visibleJump||drift>15_000)&¤t&&(current.status==="running"||overtimeRef.current)&&!hasUnresolvedRef.current)void mutate(()=>executionAdapter.reportSleepGap(current,from,new Date(wall).toISOString()));else if((visibleJump||drift>15_000)&¤tMeditation?.status==="running"&&!meditationHasUnresolvedRef.current)void mutateMeditation(()=>meditationAdapter.reportSleepGap(currentMeditation,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,mutateMeditation]); useEffect(()=>{if(!active||active.status!=="running"||boundaryRevision.current===active.revision)return;if(active.mode==="pomodoro"&&activeInterval?.targetSeconds&&phaseElapsed>=activeInterval.targetSeconds){boundaryRevision.current=active.revision;const focusEnded=activeInterval.kind==="focus";void mutate(()=>executionAdapter.completeStage(active)).then(()=>{notifyStage(focusEnded?"本轮专注已结束,已开始超时正计时":"休息已结束");setNotice(focusEnded?"本轮专注已结束,正在记录超时专注":"休息已结束,可以开始下一轮")});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]); + useEffect(()=>{if(!activeMeditation)return;if(activeMeditation.status==="breathing"){const guide=breathingGuide(activeMeditation.breathingPattern);if(guide&&meditationPhaseSeconds>=guide.totalSeconds&&meditationBoundaryRevision.current!==activeMeditation.revision){meditationBoundaryRevision.current=activeMeditation.revision;void mutateMeditation(()=>meditationAdapter.beginOrSkipBreathing(activeMeditation));setNotice("呼吸引导完成,冥想计时已经开始")}return}if(meditationOvertime&&meditationTargetNotified.current!==activeMeditation.id){meditationTargetNotified.current=activeMeditation.id;notifyStage("定时冥想已结束,正在继续正计时");setNotice("定时冥想已结束,时间将继续记录,直到你主动结束")}},[activeMeditation,meditationPhaseSeconds,meditationOvertime,mutateMeditation,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 startMeditation(input:StartMeditationInput){try{const value=await meditationAdapter.start(input);heartbeatWall.current=Date.parse(value.startedAt);heartbeatMonotonic.current=performance.now();setActiveMeditation(value);setMeditationIntervals(await meditationAdapter.listIntervals(value.id));setLastPage(page);setPage("meditation-focus");await refresh()}catch(cause){setError(cause instanceof Error?cause.message:"无法开始冥想")}} + async function finishMeditation(input:FinishMeditationInput){if(!activeMeditation)return;try{const saved=await meditationAdapter.finish(activeMeditation,input);setMeditationFinishOpen(false);setActiveMeditation(null);setPage(lastPage==="meditation-focus"?"today":lastPage);await refresh();setNotice(saved?"冥想记录已保存,一朵花已经进入今日花园":"核心冥想不足 1 分钟,本次记录已丢弃")}catch(cause){setError(cause instanceof Error?cause.message:"无法结束冥想");await refresh()}} async function updateSessionPomodoro(input:PomodoroSettingsSnapshot){if(!active)return;const updated=await executionAdapter.updatePomodoroSettings(active,input);setActive(updated);setPomodoroEditOpen(false);await refresh();channelRef.current?.postMessage("changed");setNotice("本次番茄设置已更新,将从下一个阶段生效")} 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()} @@ -40,7 +51,7 @@ export default function App(){ 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)} + function navigate(next:Page){if(page==="focus"||page==="meditation-focus")setLastPage(next);setPage(next)} if(loading)return

正在打开 StudyFlow…

@@ -48,12 +59,17 @@ export default function App(){ if(page==="focus"&&active)return <> {error&&
{error}
} {notice&&
{notice}
} -setPage(lastPage)} onPause={()=>void mutate(()=>executionAdapter.pause(active))} onResume={()=>void mutate(()=>executionAdapter.resume(active))} onAdvance={action=>void mutate(()=>executionAdapter.advance(active,action))} onEditPomodoro={()=>setPomodoroEditOpen(true)} onFinish={()=>setFinishOpen(true)}/>{pomodoroEditOpen&&setPomodoroEditOpen(false)} onSave={updateSessionPomodoro}/>} {finishOpen&&setFinishOpen(false)} onFinish={finish}/>} {unresolved&&{await mutate(()=>executionAdapter.resolveSleepGap(active,{intervalId:unresolved.interval.id,gapIndex:unresolved.index,resolution,correctedSeconds}))}}/>}; - return
+setPage(lastPage)} onPause={()=>void mutate(()=>executionAdapter.pause(active))} onResume={()=>void mutate(()=>executionAdapter.resume(active))} onAdvance={action=>void mutate(()=>executionAdapter.advance(active,action))} onEditPomodoro={()=>setPomodoroEditOpen(true)} onFinish={()=>setFinishOpen(true)}/>{pomodoroEditOpen&&setPomodoroEditOpen(false)} onSave={updateSessionPomodoro}/>} {finishOpen&&setFinishOpen(false)} onFinish={finish}/>} {unresolved&&{await mutate(()=>executionAdapter.resolveSleepGap(active,{intervalId:unresolved.interval.id,gapIndex:unresolved.index,resolution,correctedSeconds}))}}/>}; + if(page==="meditation-focus"&&activeMeditation)return <> +{error&&
{error}
} +{notice&&
{notice}
} +setPage(lastPage)} onSkipBreathing={()=>void mutateMeditation(()=>meditationAdapter.beginOrSkipBreathing(activeMeditation))} onPause={()=>void mutateMeditation(()=>meditationAdapter.pause(activeMeditation))} onResume={()=>void mutateMeditation(()=>meditationAdapter.resume(activeMeditation))} onFinish={()=>setMeditationFinishOpen(true)}/> +{meditationFinishOpen&&setMeditationFinishOpen(false)} onFinish={finishMeditation}/>} {meditationUnresolved&&{await mutateMeditation(()=>meditationAdapter.resolveSleepGap(activeMeditation,{intervalId:meditationUnresolved.interval.id,gapIndex:meditationUnresolved.index,resolution,correctedSeconds}))}}/>}; + return
-
{error&&
+
{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)}> +
}{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==="meditation"&&} {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)}/>} {activeMeditation&&{setLastPage(page);setPage("meditation-focus")}} onPause={()=>void mutateMeditation(()=>meditationAdapter.pause(activeMeditation))} onResume={()=>void mutateMeditation(()=>meditationAdapter.resume(activeMeditation))} onFinish={()=>setMeditationFinishOpen(true)}/>} {startContext&&setStartContext(null)} onStart={start}/>} {finishOpen&&active&&setFinishOpen(false)} onFinish={finish}/>} {meditationFinishOpen&&activeMeditation&&setMeditationFinishOpen(false)} onFinish={finishMeditation}/>} {unresolved&&active&&{await mutate(()=>executionAdapter.resolveSleepGap(active,{intervalId:unresolved.interval.id,gapIndex:unresolved.index,resolution,correctedSeconds}))}}/>} {meditationUnresolved&&activeMeditation&&{await mutateMeditation(()=>meditationAdapter.resolveSleepGap(activeMeditation,{intervalId:meditationUnresolved.interval.id,gapIndex:meditationUnresolved.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)}>

导出完整备份

diff --git a/src/assets/nature/focus-forest.webp b/src/assets/nature/focus-forest.webp new file mode 100644 index 0000000..0e5290a Binary files /dev/null and b/src/assets/nature/focus-forest.webp differ diff --git a/src/assets/nature/meditation-water.webp b/src/assets/nature/meditation-water.webp new file mode 100644 index 0000000..361062f Binary files /dev/null and b/src/assets/nature/meditation-water.webp differ diff --git a/src/components/ActiveMeditationBar.tsx b/src/components/ActiveMeditationBar.tsx new file mode 100644 index 0000000..0c11c59 --- /dev/null +++ b/src/components/ActiveMeditationBar.tsx @@ -0,0 +1,8 @@ +import { Flower2, Maximize2, Pause, Play, Square } from "lucide-react"; +import type { MeditationSession } from "../../shared/schemas/models"; +import { formatDuration } from "../features/executionAdapter"; + +export function ActiveMeditationBar({ session, seconds, onFocus, onPause, onResume, onFinish }: { session: MeditationSession; seconds: number; onFocus: () => void; onPause: () => void; onResume: () => void; onFinish: () => void }) { + const paused = session.status === "paused"; const breathing = session.status === "breathing"; + return ; +} diff --git a/src/components/FinishMeditationModal.tsx b/src/components/FinishMeditationModal.tsx new file mode 100644 index 0000000..72d6603 --- /dev/null +++ b/src/components/FinishMeditationModal.tsx @@ -0,0 +1,11 @@ +import { useState } from "react"; +import type { FinishMeditationInput } from "../../shared/schemas/models"; +import { formatDuration } from "../features/executionAdapter"; +import { Modal } from "./Modal"; + +const feelings = [[1,"更沉重"],[2,"略疲惫"],[3,"平稳"],[4,"更放松"],[5,"清明"]] as const; +export function FinishMeditationModal({ seconds, onClose, onFinish }: { seconds: number; onClose: () => void; onFinish: (input: FinishMeditationInput) => Promise }) { + const [feeling,setFeeling] = useState(null); const [note,setNote] = useState(""); const [busy,setBusy] = useState(false); + async function save(input: FinishMeditationInput) { setBusy(true); try { await onFinish(input); } finally { setBusy(false); } } + return
核心冥想{formatDuration(seconds)}呼吸引导未计入
此刻的感受(可选){feelings.map(([value,label]) => )}