Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V2 已形
- 独立分类管理
- 从任务或临时学习记录开始 `Stopwatch`(正计时)或 `Pomodoro`(番茄钟)
- Focus Mode、全局迷你计时栏、暂停、恢复、跳过休息和结束
- 番茄专注到时播放提示音;未开始休息时自动进入超时正计时并继续记录实际专注
- 全局番茄默认值,以及开始学习时独立覆盖本次专注/休息时长和每组轮数
- Focus 中可调整本次会话的后续番茄阶段,不影响当前阶段或其他会话
- 可配置提示音和浏览器通知
- 可开关提示音、调节并试听音量,以及启用浏览器通知
- 休眠或明显时间跳跃确认、多标签页状态同步和刷新恢复
- 完成/部分完成/未完成、原因、总结和备注
- History 日期/分类/任务/结果筛选,以及保留修改前后值的时间线修正
Expand Down Expand Up @@ -53,7 +54,7 @@ cd /home/minglee/Projects/studyflow

1. 在 `Plan` 创建任务,或点击左下角“开始学习”建立临时记录。
2. 选择正计时或番茄钟;番茄钟可以沿用全局默认值,也可以为这次学习单独设置专注、休息和轮数。
3. 使用暂停、继续、跳过休息或结束在 Focus 中修改本次番茄设置时,当前阶段保持不变,后续阶段使用新值。离开 Focus 后可通过底部迷你栏返回。
3. 使用暂停、继续、跳过休息或结束。番茄专注到时会播放提示音;如果尚未开始休息,页面会转为超时正计时并继续累计实际专注。在 Focus 中修改本次番茄设置时,当前阶段保持不变,后续阶段使用新值。离开 Focus 后可通过底部迷你栏返回。
4. 结束时选择结果;部分完成或未完成必须说明原因。
5. 在 `History` 查看和筛选记录。如需修正,必须填写修正原因,原值不会被无痕覆盖。
6. 定期从“数据管理”导出 JSON 备份。
Expand Down
44 changes: 42 additions & 2 deletions e2e/execution.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ test("从任务开始正计时,暂停恢复后结束并同步完成状态", as
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 expect(page.getByRole("dialog", { name: "结束本次学习" }).getByText(/^01:0[01]$/)).toBeVisible();
await page.getByRole("button", { name: "确认结束" }).click();

await page.getByRole("link", { name: "History" }).click();
Expand Down Expand Up @@ -71,13 +71,35 @@ test("番茄钟到时后等待确认,再进入休息阶段", async ({ page })
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 expect(page.getByText("超时专注 · 正计时")).toBeVisible();
const overtimeTimer = page.locator(".focus-orbit time");
await expect(overtimeTimer).toHaveText(/^00:0[01]$/);
await page.clock.runFor(4_000);
await expect(overtimeTimer).toHaveText(/^00:0[45]$/);
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 page.addInitScript(() => {
const original = AudioContext.prototype.createOscillator;
AudioContext.prototype.createOscillator = function (...args) {
sessionStorage.setItem("studyflow-test-tone-count", String(Number(sessionStorage.getItem("studyflow-test-tone-count") ?? "0") + 1));
return original.apply(this, args);
};
});
await openAtFixedTime(page);
await page.getByRole("button", { name: "开始学习" }).click();
await page.getByLabel("学习名称").fill("到时声音测试");
await page.getByRole("radio", { name: /番茄钟/ }).check();
await page.getByLabel("本次专注时长").fill("1");
await page.getByRole("button", { name: "进入 Focus" }).click();
await page.clock.runFor(61_000);
await expect.poll(() => page.evaluate(() => Number(sessionStorage.getItem("studyflow-test-tone-count") ?? "0"))).toBe(3);
});

test("开始时可独立设置番茄参数,Focus 修改从下一阶段生效", async ({ page }) => {
await openAtFixedTime(page);
await page.getByRole("button", { name: "开始学习" }).click();
Expand Down Expand Up @@ -181,7 +203,25 @@ test("设置修改会广播到同源的另一个标签页", async ({ page, conte

await other.getByRole("link", { name: "专注设置" }).click();
await page.getByRole("link", { name: "专注设置" }).click();
await expect(page.getByLabel("提示音音量")).toHaveValue("80");
await page.getByLabel("专注时长").fill("50");
await page.getByLabel("提示音音量").fill("95");
await page.getByRole("button", { name: "保存设置" }).click();
await expect(other.getByLabel("专注时长")).toHaveValue("50");
await expect(other.getByLabel("提示音音量")).toHaveValue("95");
});

test("用户可以调节并试听提示音音量", async ({ page }) => {
await page.addInitScript(() => {
const original = AudioContext.prototype.createOscillator;
AudioContext.prototype.createOscillator = function (...args) {
sessionStorage.setItem("studyflow-preview-tone-count", String(Number(sessionStorage.getItem("studyflow-preview-tone-count") ?? "0") + 1));
return original.apply(this, args);
};
});
await openAtFixedTime(page);
await page.getByRole("link", { name: "专注设置" }).click();
await page.getByLabel("提示音音量").fill("100");
await page.getByRole("button", { name: "试听提示音" }).click();
await expect.poll(() => page.evaluate(() => Number(sessionStorage.getItem("studyflow-preview-tone-count") ?? "0"))).toBe(3);
});
3 changes: 2 additions & 1 deletion shared/schemas/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const sleepGapSchema = z.object({
to: isoDateTimeSchema,
resolution: z.enum(["include", "exclude", "correct"]).nullable(),
correctedSeconds: z.number().int().nonnegative().nullable(),
resumeStatus: z.enum(["running", "paused"]),
resumeStatus: z.enum(["running", "paused", "awaiting-confirmation"]),
}).superRefine((value, ctx) => {
const durationSeconds = (Date.parse(value.to) - Date.parse(value.from)) / 1000;
if (durationSeconds < 0) ctx.addIssue({ code: "custom", path: ["to"], message: "休眠结束时间不能早于开始时间" });
Expand Down Expand Up @@ -177,6 +177,7 @@ export const executionSettingsSchema = z.object({
longBreakMinutes: z.number().int().min(1).max(120),
roundsPerSet: z.number().int().min(1).max(12),
soundEnabled: z.boolean(),
soundVolume: z.number().int().min(10).max(100).default(80),
notificationsEnabled: z.boolean(),
stopwatchAutoPauseMinutes: z.number().int().min(60).max(1440),
updatedAt: isoDateTimeSchema,
Expand Down
Loading
Loading