Skip to content
Draft
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 @@ -8,7 +8,7 @@
http://127.0.0.1:5173/?design-preview=1
```

样板使用本地假数据,不读取或写入 StudyFlow 的 IndexedDB。Today、Plan、Focus、Meditation 和成长阶段仅用于设计确认;正式界面会在样板确认后分阶段接入
样板使用本地假数据,不读取或写入 StudyFlow 的 IndexedDB。Today、Plan、Focus 和学习成长树已经分阶段接入正式界面;样板中的 Meditation 仍只用于下一阶段设计参照

StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V2 已形成第一阶段的 `Plan → Execute → Record`:先安排学习任务,再用正计时或番茄钟执行,并把实际投入和结果保存为可审计的本地记录。

Expand All @@ -29,7 +29,8 @@ StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V2 已形
- 完成/部分完成/未完成、原因、总结和备注
- History 日期/分类/任务/结果筛选,以及保留修改前后值的时间线修正
- IndexedDB 本地持久化、`TaskEvent`、`StudyInterval` 和 `SessionRevision` 历史
- V2 完整 JSON 备份、V1 备份兼容导入和覆盖导入前安全备份
- V3 完整 JSON 备份、V1/V2 备份兼容导入和覆盖导入前安全备份
- 学习会话驱动的五阶段成长树与 Today 花园(不会补生成升级前的历史植物)

## 启动

Expand Down
6 changes: 3 additions & 3 deletions docs/design-preview/DESIGN_SYSTEM.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# StudyFlow「自然沉浸与正念成长」视觉设计系统

状态:视觉方向已确认,第二阶段已将设计 token 与 Today、Plan、Focus 接入正式页面。该目录中的 `?design-preview=1` 截图仍使用假数据;成长记录、今日花园和 Meditation 尚未接入正式数据
状态:视觉方向已确认,第二阶段已将设计 token 与 Today、Plan、Focus 接入正式页面;第三阶段已接入学习成长记录、Focus 实时树和 Today 花园。该目录中的 `?design-preview=1` 仍使用完全隔离的假数据;Meditation 正式流程留在下一 Batch

## 1. 设计原则

Expand Down Expand Up @@ -111,7 +111,7 @@ Today / 侧边栏 / 快速开始
当前已完成设计确认样板和第 1 项正式视觉改造,后续依次实施:

1. ~~正式视觉 token 与 Today / Plan / Focus 改造~~
2. IndexedDB version 3、备份 version 3、成长记录与今日花园
3. Meditation 数据层、完整流程、History 类型隔离
2. IndexedDB version 3、备份 version 3、成长记录与今日花园(已完成)
3. Meditation 数据层、完整流程、History 类型隔离(下一 Batch)

每阶段单独验收,禁止在视觉改造阶段顺带修改正式数据模型。
26 changes: 26 additions & 0 deletions e2e/execution.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/visual-system.spec.ts-snapshots/focus-nature-desktop-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 24 additions & 4 deletions shared/schemas/backup.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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<typeof backupV2Schema>;
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<typeof backupV3Schema>;
export type CompatibleStudyFlowBackup = z.infer<typeof backupSchema>;
68 changes: 66 additions & 2 deletions shared/schemas/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -183,12 +186,64 @@ 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,
});

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(),
});

Expand Down Expand Up @@ -217,5 +272,14 @@ export type StudySession = z.infer<typeof studySessionSchema>;
export type SessionRevision = z.infer<typeof sessionRevisionSchema>;
export type ExecutionSettings = z.infer<typeof executionSettingsSchema>;
export type PomodoroSettingsSnapshot = z.infer<typeof pomodoroSettingsSnapshotSchema>;
export type GrowthSourceType = z.infer<typeof growthSourceTypeSchema>;
export type PlantType = z.infer<typeof plantTypeSchema>;
export type GrowthRecord = z.infer<typeof growthRecordSchema>;
export type MeditationMode = z.infer<typeof meditationModeSchema>;
export type MeditationStatus = z.infer<typeof meditationStatusSchema>;
export type MeditationIntention = z.infer<typeof meditationIntentionSchema>;
export type BreathingPattern = z.infer<typeof breathingPatternSchema>;
export type MeditationSession = z.infer<typeof meditationSessionSchema>;
export type MeditationInterval = z.infer<typeof meditationIntervalSchema>;
export type StartSessionInput = z.input<typeof startSessionInputSchema>;
export type FinishSessionInput = z.input<typeof finishSessionInputSchema>;
Loading
Loading