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
39 changes: 30 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
# 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、全局迷你计时栏、暂停、恢复、跳过休息和结束
- 全局番茄默认值,以及开始学习时独立覆盖本次专注/休息时长和每组轮数
- Focus 中可调整本次会话的后续番茄阶段,不影响当前阶段或其他会话
- 可配置提示音和浏览器通知
- 休眠或明显时间跳跃确认、多标签页状态同步和刷新恢复
- 完成/部分完成/未完成、原因、总结和备注
- History 日期/分类/任务/结果筛选,以及保留修改前后值的时间线修正
- IndexedDB 本地持久化、`TaskEvent`、`StudyInterval` 和 `SessionRevision` 历史
- V2 完整 JSON 备份、V1 备份兼容导入和覆盖导入前安全备份

## 启动

Expand All @@ -26,18 +34,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. 选择正计时或番茄钟;番茄钟可以沿用全局默认值,也可以为这次学习单独设置专注、休息和轮数。
3. 使用暂停、继续、跳过休息或结束;在 Focus 中修改本次番茄设置时,当前阶段保持不变,后续阶段使用新值。离开 Focus 后可通过底部迷你栏返回。
4. 结束时选择结果;部分完成或未完成必须说明原因。
5. 在 `History` 查看和筛选记录。如需修正,必须填写修正原因,原值不会被无痕覆盖。
6. 定期从“数据管理”导出 JSON 备份。

计时以持久化 UTC 时间戳为准。休息、暂停和被排除的休眠时间不计入实际专注;跨午夜时按会话记录的本地时区分配到对应日期。

## 开发

Expand All @@ -50,8 +71,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:根据目标、可用时间和历史执行情况动态调整计划
Expand Down
9 changes: 7 additions & 2 deletions e2e/backup.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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: [] },
})),
});
Expand All @@ -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 }) => {
Expand Down
187 changes: 187 additions & 0 deletions e2e/execution.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
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("开始时可独立设置番茄参数,Focus 修改从下一阶段生效", async ({ page }) => {
await openAtFixedTime(page);
await page.getByRole("button", { name: "开始学习" }).click();
await page.getByLabel("学习名称").fill("独立番茄设置测试");
await page.getByRole("radio", { name: /番茄钟/ }).check();
await expect(page.getByLabel("本次专注时长")).toHaveValue("25");
await page.getByLabel("本次专注时长").fill("1");
await page.getByLabel("本次短休息").fill("1");
await page.getByRole("button", { name: "进入 Focus" }).click();
await expect(page.getByText("目标 1 分钟")).toBeVisible();

await page.getByRole("button", { name: "调整本次番茄设置" }).click();
await page.getByLabel("后续专注时长").fill("3");
await page.getByLabel("后续短休息").fill("2");
await page.getByRole("button", { name: "保存本次设置" }).click();
await expect(page.getByText("目标 1 分钟")).toBeVisible();

await page.clock.runFor(61_000);
await page.getByRole("button", { name: "开始休息" }).click();
await expect(page.getByText("目标 2 分钟")).toBeVisible();
await page.getByRole("button", { name: "跳过休息" }).click();
await expect(page.getByText("目标 3 分钟")).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");
});
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "studyflow",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
4 changes: 2 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
})
28 changes: 16 additions & 12 deletions shared/schemas/backup.ts
Original file line number Diff line number Diff line change
@@ -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<typeof backupSchema>;

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<typeof backupV2Schema>;
export type CompatibleStudyFlowBackup = z.infer<typeof backupSchema>;
Loading
Loading