diff --git a/README.md b/README.md index 9892a28..d9425c8 100644 --- a/README.md +++ b/README.md @@ -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`:先安排学习任务,再用正计时或番茄钟执行,并把实际投入和结果保存为可审计的本地记录。 @@ -29,7 +29,8 @@ StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V2 已形 - 完成/部分完成/未完成、原因、总结和备注 - History 日期/分类/任务/结果筛选,以及保留修改前后值的时间线修正 - IndexedDB 本地持久化、`TaskEvent`、`StudyInterval` 和 `SessionRevision` 历史 -- V2 完整 JSON 备份、V1 备份兼容导入和覆盖导入前安全备份 +- V3 完整 JSON 备份、V1/V2 备份兼容导入和覆盖导入前安全备份 +- 学习会话驱动的五阶段成长树与 Today 花园(不会补生成升级前的历史植物) ## 启动 diff --git a/docs/design-preview/DESIGN_SYSTEM.md b/docs/design-preview/DESIGN_SYSTEM.md index c4a4515..fb2f696 100644 --- a/docs/design-preview/DESIGN_SYSTEM.md +++ b/docs/design-preview/DESIGN_SYSTEM.md @@ -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. 设计原则 @@ -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) 每阶段单独验收,禁止在视觉改造阶段顺带修改正式数据模型。 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/visual-system.spec.ts-snapshots/focus-break-paused-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/focus-break-paused-mobile-linux.png index 7041239..27f51a4 100644 Binary files a/e2e/visual-system.spec.ts-snapshots/focus-break-paused-mobile-linux.png 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 index 618b393..6608299 100644 Binary files a/e2e/visual-system.spec.ts-snapshots/focus-nature-desktop-linux.png 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 index f7f6733..9c992aa 100644 Binary files a/e2e/visual-system.spec.ts-snapshots/focus-nature-mobile-linux.png 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 index 0745ed2..9bb905f 100644 Binary files a/e2e/visual-system.spec.ts-snapshots/focus-overtime-mobile-linux.png and b/e2e/visual-system.spec.ts-snapshots/focus-overtime-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 index 67d0a74..5981f97 100644 Binary files a/e2e/visual-system.spec.ts-snapshots/today-nature-desktop-linux.png 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 index 4fef4a5..6b8de02 100644 Binary files a/e2e/visual-system.spec.ts-snapshots/today-nature-mobile-linux.png 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..72455c0 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,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(), }); @@ -217,5 +272,14 @@ 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 StartSessionInput = z.input; export type FinishSessionInput = z.input; diff --git a/src/app/App.tsx b/src/app/App.tsx index 2b57c02..675923d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2,9 +2,10 @@ import { useCallback, useEffect, useRef, useState, type ChangeEvent, type ReactN import { BookOpen, CalendarCheck, Database, 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 { GrowthRecord, PomodoroSettingsSnapshot } from "../../shared/schemas/models"; import { executionAdapter } from "../features/executionAdapter"; import { intervalActiveMs, totalFocusMs } from "../domain/execution"; +import { calculateGrowthStage, stablePlantVariant, studyGrowthTargetSeconds } from "../domain/growth"; 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 { 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"; @@ -14,9 +15,9 @@ type Page="today"|"plan"|"categories"|"history"|"settings"|"focus"; type DeleteT 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>({}),[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 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,nextSettings,nextGrowth]=await Promise.all([studyFlowApi.tasks.list(),studyFlowApi.categories.list(),executionAdapter.getActive(),executionAdapter.history(),executionAdapter.getSettings(),studyFlowApi.growth.list()]);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);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 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]); @@ -25,7 +26,7 @@ export default function App(){ useEffect(()=>{const channel=new BroadcastChannel("studyflow-execution");channelRef.current=channel;channel.onmessage=()=>void refresh();return()=>{channel.close();channelRef.current=null}},[refresh]); useEffect(()=>{const guard=(e:BeforeUnloadEvent)=>{if(active){e.preventDefault();e.returnValue=""}};window.addEventListener("beforeunload",guard);return()=>window.removeEventListener("beforeunload",guard)},[active]); useEffect(()=>{if(backupOpen)fileRef.current?.setAttribute("aria-label","导入备份文件")},[backupOpen]); - const activeInterval=active?intervals.find(item=>item.id===active.activeIntervalId):undefined,focusSeconds=Math.floor(totalFocusMs(intervals,new Date(now).toISOString())/1000),phaseElapsed=activeInterval?Math.floor(intervalActiveMs(activeInterval,new Date(now).toISOString())/1000):0,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]); @@ -48,7 +49,7 @@ 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}))}}/>}; +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
}{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)}>

导出完整备份

diff --git a/src/components/PlantIllustration.tsx b/src/components/PlantIllustration.tsx index 8135169..4b8db36 100644 --- a/src/components/PlantIllustration.tsx +++ b/src/components/PlantIllustration.tsx @@ -1,3 +1,5 @@ +import { useId } from "react"; + type PlantKind = "tree" | "flower"; export function PlantIllustration({ kind, stage, variant = 0, overtime = false }: { @@ -7,23 +9,28 @@ export function PlantIllustration({ kind, stage, variant = 0, overtime = false } overtime?: boolean; }) { const hueClass = `plant-variant-${variant % 3}`; + const uniqueId = useId().replaceAll(":", ""); if (kind === "flower") { return - - - + + + {stage === 0 && } - {stage >= 1 && 2 ? 128 : 145} ${225 - stage * 27} 140 ${205 - stage * 28}`} stroke="url(#flower-stem)"/>} + {stage >= 1 && 2 ? 128 : 145} ${225 - stage * 27} 140 ${205 - stage * 28}`} + stroke={`url(#flower-stem-${uniqueId})`} + />} {stage >= 1 && } {stage >= 2 && } {stage >= 3 && - {stage === 3 ? : <> - - {[0,60,120,180,240,300].map(angle => ) } + {stage === 3 ? : <> + + {[0,60,120,180,240,300].map(angle => ) } } } @@ -33,23 +40,23 @@ export function PlantIllustration({ kind, stage, variant = 0, overtime = false } return - - - + + + {stage === 0 && } - {stage >= 1 && = 4 ? 15 : stage >= 3 ? 11 : 7} d={`M160 274 Q${stage >= 3 ? 150 : 164} ${233 - stage * 16} 160 ${210 - stage * 31}`} />} + {stage >= 1 && = 4 ? 15 : stage >= 3 ? 11 : 7} d={`M160 274 Q${stage >= 3 ? 150 : 164} ${233 - stage * 16} 160 ${210 - stage * 31}`} />} {stage >= 2 && } {stage >= 3 && } {stage >= 4 && } - {stage >= 1 && + {stage >= 1 && = 3 ? 34 : 22} ry={stage >= 3 ? 27 : 18}/> {stage >= 2 && } {stage >= 3 && <>} {stage >= 4 && <>} } - {overtime && stage === 4 && <>} + {overtime && stage === 4 && <>} ; } diff --git a/src/db/backupRepository.ts b/src/db/backupRepository.ts index ff64763..fa41b56 100644 --- a/src/db/backupRepository.ts +++ b/src/db/backupRepository.ts @@ -4,9 +4,17 @@ import { BACKUP_FORMAT, BACKUP_VERSION, backupSchema, - backupV2Schema, + backupV3Schema, type StudyFlowBackup, } from "../../shared/schemas/backup"; +import { intervalActiveMs, totalFocusMs } from "../domain/execution"; +import { + localDateInTimezone, + meditationGrowthTargetSeconds, + stablePlantVariant, + studyGrowthTargetSeconds, +} from "../domain/growth"; +import { studyIntervalSchema, studySessionSchema } from "../../shared/schemas/models"; export class BackupRepository { constructor(private readonly database: StudyFlowDatabase = db) {} @@ -29,19 +37,20 @@ export class BackupRepository { await this.database.studySessions.update(current.id, { status: "paused", revision: current.revision + 1, updatedAt: now }); }); } - const [tasks, categories, taskEvents, studySessions, studyIntervals, sessionRevisions, executionSettings] = await Promise.all([ + const [tasks, categories, taskEvents, studySessions, studyIntervals, sessionRevisions, executionSettings, growthRecords, meditationSessions, meditationIntervals] = await Promise.all([ this.database.tasks.toArray(), this.database.categories.toArray(), this.database.taskEvents.toArray(), this.database.studySessions.toArray(), this.database.studyIntervals.toArray(), this.database.sessionRevisions.toArray(), this.database.executionSettings.get("default"), + this.database.growthRecords.toArray(), this.database.meditationSessions.toArray(), this.database.meditationIntervals.toArray(), ]); - return backupV2Schema.parse({ + return backupV3Schema.parse({ format: BACKUP_FORMAT, version: BACKUP_VERSION, exportedAt: new Date().toISOString(), data: { tasks, categories, taskEvents, studySessions, studyIntervals, sessionRevisions, - executionSettings: executionSettings ?? defaultExecutionSettings() }, + executionSettings: executionSettings ?? defaultExecutionSettings(), growthRecords, meditationSessions, meditationIntervals }, }); } @@ -51,9 +60,12 @@ export class BackupRepository { async replaceAll(input: unknown): Promise { const parsed = this.parse(input); - const backup = parsed.version === 2 ? parsed : backupV2Schema.parse({ ...parsed, version: 2, data: { + const executionData = parsed.version === 1 ? { ...parsed.data, studySessions: [], studyIntervals: [], sessionRevisions: [], executionSettings: defaultExecutionSettings(), + } : parsed.data; + const backup = parsed.version === 3 ? parsed : backupV3Schema.parse({ ...parsed, version: 3, data: { + ...executionData, growthRecords: [], meditationSessions: [], meditationIntervals: [], }}); const categoryIds = new Set(backup.data.categories.map((category) => category.id)); if (backup.data.tasks.some((task) => !categoryIds.has(task.categoryId))) { @@ -75,18 +87,62 @@ export class BackupRepository { throw new Error("备份包含无效的当前计时阶段引用"); } const activeCount = backup.data.studySessions.filter((session) => session.status !== "finished").length; - if (activeCount > 1) throw new Error("备份包含多个进行中的学习会话"); + const meditationIds = new Set(backup.data.meditationSessions.map((session) => session.id)); + if (backup.data.meditationIntervals.some((item) => !meditationIds.has(item.sessionId))) { + throw new Error("备份包含引用不存在冥想会话的阶段记录"); + } + const meditationIntervalById = new Map(backup.data.meditationIntervals.map((item) => [item.id, item])); + if (backup.data.meditationSessions.some((session) => session.activeIntervalId !== null + && meditationIntervalById.get(session.activeIntervalId)?.sessionId !== session.id)) { + throw new Error("备份包含无效的冥想计时阶段引用"); + } + const growthSources = new Set(); + for (const record of backup.data.growthRecords) { + const validSource = record.sourceType === "study" ? sessionIds.has(record.sourceSessionId) : meditationIds.has(record.sourceSessionId); + if (!validSource) throw new Error("备份包含引用不存在会话的成长记录"); + if (growthSources.has(record.sourceSessionId)) throw new Error("同一会话不能生成多条成长记录"); + growthSources.add(record.sourceSessionId); + if (record.sourceType === "study") { + const source = backup.data.studySessions.find((session) => session.id === record.sourceSessionId)!; + const sourceIntervals = backup.data.studyIntervals.filter((interval) => interval.sessionId === source.id); + if (source.status !== "finished" || !source.endedAt || sourceIntervals.some((interval) => !interval.endedAt) + || !this.studyGrowthWasEarned(backup, source, sourceIntervals)) { + throw new Error("学习成长记录必须来自至少 1 分钟的已结束会话"); + } + if (record.plantType !== "tree" || record.targetSecondsSnapshot !== studyGrowthTargetSeconds(source)) { + throw new Error("学习成长记录的植物类型或目标快照无效"); + } + this.validateGrowthIdentity(record, source.id, source.timezone, source.endedAt); + } else { + const source = backup.data.meditationSessions.find((session) => session.id === record.sourceSessionId)!; + const effective = backup.data.meditationIntervals.filter((interval) => interval.sessionId === source.id && interval.kind === "meditation") + .reduce((sum, interval) => sum + intervalActiveMs(interval), 0); + const sourceIntervals = backup.data.meditationIntervals.filter((interval) => interval.sessionId === source.id); + if (source.status !== "finished" || !source.endedAt || sourceIntervals.some((interval) => !interval.endedAt) + || effective < 60_000) { + throw new Error("冥想成长记录必须来自至少 1 分钟的已结束会话"); + } + if (record.plantType !== "flower" || record.targetSecondsSnapshot !== meditationGrowthTargetSeconds(source)) { + throw new Error("冥想成长记录的植物类型或目标快照无效"); + } + this.validateGrowthIdentity(record, source.id, source.timezone, source.endedAt); + } + } + const meditationActiveCount = backup.data.meditationSessions.filter((session) => session.status !== "finished").length; + if (activeCount + meditationActiveCount > 1) throw new Error("备份包含多个进行中的会话"); await this.database.transaction( "rw", [this.database.tasks, this.database.categories, this.database.taskEvents, this.database.studySessions, - this.database.studyIntervals, this.database.sessionRevisions, this.database.executionSettings], + this.database.studyIntervals, this.database.sessionRevisions, this.database.executionSettings, + this.database.growthRecords, this.database.meditationSessions, this.database.meditationIntervals], async () => { await Promise.all([ this.database.tasks.clear(), this.database.categories.clear(), this.database.taskEvents.clear(), this.database.studySessions.clear(), this.database.studyIntervals.clear(), this.database.sessionRevisions.clear(), this.database.executionSettings.clear(), + this.database.growthRecords.clear(), this.database.meditationSessions.clear(), this.database.meditationIntervals.clear(), ]); await this.database.categories.bulkAdd(backup.data.categories); await this.database.tasks.bulkAdd(backup.data.tasks); @@ -95,9 +151,80 @@ export class BackupRepository { await this.database.studyIntervals.bulkAdd(backup.data.studyIntervals); await this.database.sessionRevisions.bulkAdd(backup.data.sessionRevisions); await this.database.executionSettings.put(backup.data.executionSettings); + await this.database.growthRecords.bulkAdd(backup.data.growthRecords); + await this.database.meditationSessions.bulkAdd(backup.data.meditationSessions); + await this.database.meditationIntervals.bulkAdd(backup.data.meditationIntervals); }, ); } + + private validateGrowthIdentity( + record: StudyFlowBackup["data"]["growthRecords"][number], + sourceSessionId: string, + timezone: string, + endedAt: string, + ): void { + if (record.variant !== stablePlantVariant(sourceSessionId) + || record.timezone !== timezone + || record.createdAt !== endedAt + || record.localDate !== localDateInTimezone(endedAt, timezone)) { + throw new Error("成长记录的稳定变体、日期或时区快照无效"); + } + } + + private studyGrowthWasEarned( + backup: StudyFlowBackup, + source: StudyFlowBackup["data"]["studySessions"][number], + currentIntervals: StudyFlowBackup["data"]["studyIntervals"], + ): boolean { + let cursorSession = source; + let cursorIntervals = currentIntervals; + const revisions = backup.data.sessionRevisions.filter((revision) => revision.sessionId === source.id); + const maximumSteps = revisions.length; + for (let step = 0; step <= maximumSteps; step += 1) { + if (totalFocusMs(cursorIntervals) >= 60_000) return true; + const link = revisions.find((revision) => { + const after = revision.after as { session?: unknown; intervals?: unknown }; + const afterSession = studySessionSchema.safeParse(after.session); + const afterIntervals = studyIntervalSchema.array().safeParse(after.intervals); + return afterSession.success && afterIntervals.success + && this.sameSessionState(afterSession.data, cursorSession) + && this.sameTimeline(afterIntervals.data, cursorIntervals); + }); + if (!link) return false; + const before = link.before as { session?: unknown; intervals?: unknown }; + const after = link.after as { session?: unknown; intervals?: unknown }; + const beforeSession = studySessionSchema.safeParse(before.session); + const beforeIntervals = studyIntervalSchema.array().safeParse(before.intervals); + const afterSession = studySessionSchema.parse(after.session); + const afterIntervals = studyIntervalSchema.array().parse(after.intervals); + if (!beforeSession.success || !beforeIntervals.success + || beforeSession.data.id !== source.id || afterSession.id !== source.id + || beforeSession.data.status !== "finished" || afterSession.status !== "finished" + || afterSession.revision !== beforeSession.data.revision + 1 + || !beforeIntervals.data.every((interval) => interval.sessionId === source.id && interval.endedAt !== null) + || !afterIntervals.every((interval) => interval.sessionId === source.id && interval.endedAt !== null) + || !this.sameIntervalIds(beforeIntervals.data, afterIntervals)) return false; + cursorSession = beforeSession.data; + cursorIntervals = beforeIntervals.data; + revisions.splice(revisions.indexOf(link), 1); + } + return false; + } + + private sameSessionState(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); + } + + private sameTimeline(left: StudyFlowBackup["data"]["studyIntervals"], right: StudyFlowBackup["data"]["studyIntervals"]): boolean { + const ordered = (items: StudyFlowBackup["data"]["studyIntervals"]) => [...items].sort((a, b) => a.id.localeCompare(b.id)); + return JSON.stringify(ordered(left)) === JSON.stringify(ordered(right)); + } + + private sameIntervalIds(left: StudyFlowBackup["data"]["studyIntervals"], right: StudyFlowBackup["data"]["studyIntervals"]): boolean { + return left.length === right.length + && [...left].map((interval) => interval.id).sort().join("\n") === [...right].map((interval) => interval.id).sort().join("\n"); + } } export const backupRepository = new BackupRepository(); diff --git a/src/db/database.ts b/src/db/database.ts index 999aaf4..e8b1d81 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -1,5 +1,16 @@ import Dexie, { type Table } from "dexie"; -import type { Category, ExecutionSettings, SessionRevision, StudyInterval, StudySession, Task, TaskEvent } from "../domain/models"; +import type { + Category, + ExecutionSettings, + GrowthRecord, + MeditationInterval, + MeditationSession, + SessionRevision, + StudyInterval, + StudySession, + Task, + TaskEvent, +} from "../domain/models"; export const DEFAULT_CATEGORY_NAMES = ["高数", "线性代数", "C", "CS50", "其他"] as const; @@ -19,6 +30,9 @@ export class StudyFlowDatabase extends Dexie { studyIntervals!: Table; sessionRevisions!: Table; executionSettings!: Table; + growthRecords!: Table; + meditationSessions!: Table; + meditationIntervals!: Table; constructor(name = "StudyFlow") { super(name); @@ -39,6 +53,18 @@ export class StudyFlowDatabase extends Dexie { const table = tx.table("executionSettings"); await table.put(defaultExecutionSettings()); }); + this.version(3).stores({ + tasks: "id, categoryId, dueDate, completed, archivedAt, createdAt", + categories: "id, &name, sortOrder, archivedAt, createdAt", + taskEvents: "id, taskId, &sequence, type, occurredAt", + studySessions: "id, taskId, categoryId, status, mode, startedAt, endedAt, updatedAt", + studyIntervals: "id, sessionId, kind, startedAt, endedAt", + sessionRevisions: "id, sessionId, createdAt", + executionSettings: "id", + growthRecords: "id, &sourceSessionId, sourceType, plantType, localDate, createdAt", + meditationSessions: "id, status, mode, startedAt, endedAt, updatedAt", + meditationIntervals: "id, sessionId, kind, startedAt, endedAt", + }); this.on("populate", () => { const timestamp = nowIso(); diff --git a/src/db/growthRepository.ts b/src/db/growthRepository.ts new file mode 100644 index 0000000..02b55c2 --- /dev/null +++ b/src/db/growthRepository.ts @@ -0,0 +1,21 @@ +import type { GrowthRecord } from "../../shared/schemas/models"; +import { db, type StudyFlowDatabase } from "./database"; + +export class GrowthRepository { + constructor(private readonly database: StudyFlowDatabase = db) {} + + async list(): Promise { + return (await this.database.growthRecords.toArray()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } + + async listForLocalDate(localDate: string): Promise { + return (await this.database.growthRecords.where("localDate").equals(localDate).toArray()) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } + + async getForSourceSession(sourceSessionId: string): Promise { + return this.database.growthRecords.where("sourceSessionId").equals(sourceSessionId).first(); + } +} + +export const growthRepository = new GrowthRepository(); diff --git a/src/db/index.ts b/src/db/index.ts index 64ca2e1..df862a4 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -6,3 +6,4 @@ export { ConflictError, DataError, NotFoundError } from "./errors"; export { sessionRepository, SessionRepository } from "./sessionRepository"; export type { PomodoroAdvanceAction, HistoryFilter, SleepGapResolution, SessionCorrection } from "./sessionRepository"; export { settingsRepository, SettingsRepository } from "./settingsRepository"; +export { growthRepository, GrowthRepository } from "./growthRepository"; diff --git a/src/db/sessionRepository.ts b/src/db/sessionRepository.ts index 0e13848..9903b1e 100644 --- a/src/db/sessionRepository.ts +++ b/src/db/sessionRepository.ts @@ -3,6 +3,7 @@ import { type FinishSessionInput, type PomodoroSettingsSnapshot, type SessionOutcome, type StartSessionInput, type StudyInterval, type StudySession, } from "../../shared/schemas/models"; import { hasUnresolvedSleepGap, totalFocusMs } from "../domain/execution"; +import { createStudyGrowthRecord } from "../domain/growth"; import { ConflictError, NotFoundError } from "./errors"; import { db, type StudyFlowDatabase } from "./database"; import { taskRepository, TaskRepository } from "./taskRepository"; @@ -25,8 +26,11 @@ export class SessionRepository { private async start(input: StartSessionInput, mode: "stopwatch" | "pomodoro"): Promise { const value = startSessionInputSchema.parse(input); - return this.database.transaction("rw", this.database.studySessions, this.database.studyIntervals, this.database.tasks, this.database.categories, this.database.executionSettings, async () => { - if (await this.database.studySessions.where("status").anyOf("running", "paused", "awaiting-confirmation", "sleep-review").first()) { + return this.database.transaction("rw", [this.database.studySessions, this.database.studyIntervals, this.database.tasks, + this.database.categories, this.database.executionSettings, this.database.meditationSessions], async () => { + const activeStudy = await this.database.studySessions.where("status").anyOf("running", "paused", "awaiting-confirmation", "sleep-review").first(); + const activeMeditation = await this.database.meditationSessions.where("status").anyOf("breathing", "running", "paused", "sleep-review").first(); + if (activeStudy || activeMeditation) { throw new ConflictError("已有进行中的学习会话"); } const category = await this.database.categories.get(value.categoryId); @@ -189,7 +193,7 @@ export class SessionRepository { async finish(id: string, input: FinishSessionInput, expectedRevision?: number): Promise { const value = finishSessionInputSchema.parse(input); const now = this.clock().toISOString(); - return this.database.transaction("rw", this.database.studySessions, this.database.studyIntervals, this.database.tasks, this.database.taskEvents, async () => { + return this.database.transaction("rw", this.database.studySessions, this.database.studyIntervals, this.database.tasks, this.database.taskEvents, this.database.growthRecords, async () => { const session = await this.requireSession(id); this.checkRevision(session, expectedRevision); if (session.status === "finished") throw new ConflictError("会话已结束"); @@ -215,6 +219,7 @@ export class SessionRepository { revision: session.revision + 1, updatedAt: now }); if (current) await this.database.studyIntervals.put(studyIntervalSchema.parse(current)); await this.database.studySessions.put(finished); + await this.database.growthRecords.add(createStudyGrowthRecord(finished, this.createId(), now)); if (value.completeTask && value.outcome === "completed" && session.taskId) { await this.tasks.toggleComplete(session.taskId, true); } @@ -223,8 +228,11 @@ export class SessionRepository { } async discard(id: string): Promise { - await this.database.transaction("rw", this.database.studySessions, this.database.studyIntervals, async () => { + await this.database.transaction("rw", this.database.studySessions, this.database.studyIntervals, this.database.growthRecords, async () => { + const session = await this.requireSession(id); + if (session.status === "finished") throw new ConflictError("已结束的学习记录不能无痕删除"); await this.database.studyIntervals.where("sessionId").equals(id).delete(); + await this.database.growthRecords.where("sourceSessionId").equals(id).delete(); await this.database.studySessions.delete(id); }); } diff --git a/src/domain/execution.ts b/src/domain/execution.ts index 56b3ca3..5c1e5f6 100644 --- a/src/domain/execution.ts +++ b/src/domain/execution.ts @@ -1,8 +1,10 @@ import type { StudyInterval, StudySession } from "../../shared/schemas/models"; +type ActiveInterval = Pick; + const ms = (value: string) => new Date(value).getTime(); -export function intervalActiveMs(interval: StudyInterval, until = new Date().toISOString()): number { +export function intervalActiveMs(interval: ActiveInterval, until = new Date().toISOString()): number { const start = ms(interval.startedAt); const end = ms(interval.endedAt ?? until); const total = Math.max(0, end - start); diff --git a/src/domain/growth.ts b/src/domain/growth.ts new file mode 100644 index 0000000..862761c --- /dev/null +++ b/src/domain/growth.ts @@ -0,0 +1,62 @@ +import { growthRecordSchema, type GrowthRecord, type MeditationSession, type StudySession } from "../../shared/schemas/models"; + +export type GrowthStage = 0 | 1 | 2 | 3 | 4; + +export function growthStageFromRatio(ratio: number): GrowthStage { + if (ratio >= 1) return 4; + if (ratio >= 0.65) return 3; + if (ratio >= 0.35) return 2; + if (ratio >= 0.1) return 1; + return 0; +} + +export function calculateGrowthStage(effectiveSeconds: number, targetSeconds: number): GrowthStage { + if (targetSeconds <= 0) return 0; + return growthStageFromRatio(Math.max(0, effectiveSeconds) / targetSeconds); +} + +export function studyGrowthTargetSeconds(session: StudySession): number { + if (session.taskId && session.estimatedMinutesSnapshot) return session.estimatedMinutesSnapshot * 60; + if (session.mode === "pomodoro" && session.pomodoroSettingsSnapshot) { + return session.pomodoroSettingsSnapshot.focusMinutes * 60; + } + return 25 * 60; +} + +export function meditationGrowthTargetSeconds(session: MeditationSession): number { + return session.mode === "timed" && session.targetSeconds ? session.targetSeconds : 10 * 60; +} + +export function stablePlantVariant(sourceSessionId: string): 0 | 1 | 2 { + let hash = 2166136261; + for (const character of sourceSessionId) { + hash ^= character.codePointAt(0) ?? 0; + hash = Math.imul(hash, 16777619); + } + return (Math.abs(hash) % 3) as 0 | 1 | 2; +} + +export function localDateInTimezone(instant: string, timezone: string): string { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date(instant)); + const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ""; + return `${value("year")}-${value("month")}-${value("day")}`; +} + +export function createStudyGrowthRecord(session: StudySession, id: string, completedAt: string): GrowthRecord { + return growthRecordSchema.parse({ + id, + sourceType: "study", + sourceSessionId: session.id, + plantType: "tree", + variant: stablePlantVariant(session.id), + targetSecondsSnapshot: studyGrowthTargetSeconds(session), + localDate: localDateInTimezone(completedAt, session.timezone), + timezone: session.timezone, + createdAt: completedAt, + }); +} diff --git a/src/domain/index.ts b/src/domain/index.ts index 8b83ad0..49be1d8 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -1,4 +1,5 @@ export * from "./models"; export * from "./quadrant"; export * from "./today"; +export * from "./growth"; export * from "./execution"; diff --git a/src/domain/models.ts b/src/domain/models.ts index db837ea..e6ea655 100644 --- a/src/domain/models.ts +++ b/src/domain/models.ts @@ -17,4 +17,13 @@ export type { SessionStatus, TimerMode, PomodoroSettingsSnapshot, + GrowthRecord, + GrowthSourceType, + PlantType, + MeditationSession, + MeditationInterval, + MeditationMode, + MeditationStatus, + MeditationIntention, + BreathingPattern, } from "../../shared/schemas/models"; diff --git a/src/features/api.ts b/src/features/api.ts index 113e8c8..e158659 100644 --- a/src/features/api.ts +++ b/src/features/api.ts @@ -1,5 +1,5 @@ import type { Category, CreateCategoryInput, CreateTaskInput, Task, UpdateTaskInput } from "../domain/models"; -import { backupRepository, categoryRepository, sessionRepository, settingsRepository, taskRepository } from "../db"; +import { backupRepository, categoryRepository, growthRepository, sessionRepository, settingsRepository, taskRepository } from "../db"; export interface StudyFlowApi { tasks: { @@ -21,6 +21,7 @@ export interface StudyFlowApi { }; sessions: typeof sessionRepository; settings: typeof settingsRepository; + growth: typeof growthRepository; } export const studyFlowApi: StudyFlowApi = { @@ -29,4 +30,5 @@ export const studyFlowApi: StudyFlowApi = { backup: backupRepository, sessions: sessionRepository, settings: settingsRepository, + growth: growthRepository, }; diff --git a/src/pages/FocusPage.tsx b/src/pages/FocusPage.tsx index d1ccdda..57d8942 100644 --- a/src/pages/FocusPage.tsx +++ b/src/pages/FocusPage.tsx @@ -2,8 +2,9 @@ import { ArrowLeft, Coffee, Pause, Play, Settings2, Square } from "lucide-react" import type { ExecutionSettings, StudyInterval, StudySession } from "../features/executionTypes"; import { formatDuration } from "../features/executionAdapter"; import { PlantIllustration } from "../components/PlantIllustration"; +import type { GrowthStage } from "../domain/growth"; -export function FocusPage({ session, activeInterval, settings, seconds, overtime = false, estimateReached, onLeave, onPause, onResume, onAdvance, onEditPomodoro, onFinish }: { session: StudySession; activeInterval?: StudyInterval; settings: ExecutionSettings | null; seconds: number; overtime?: boolean; estimateReached?: boolean; onLeave: () => void; onPause: () => void; onResume: () => void; onAdvance: (action: "start-break" | "skip-break" | "start-focus") => void; onEditPomodoro: () => void; onFinish: () => void }) { +export function FocusPage({ session, activeInterval, settings, seconds, growthStage, growthVariant, overtime = false, estimateReached, onLeave, onPause, onResume, onAdvance, onEditPomodoro, onFinish }: { session: StudySession; activeInterval?: StudyInterval; settings: ExecutionSettings | null; seconds: number; growthStage: GrowthStage; growthVariant: number; overtime?: boolean; estimateReached?: boolean; onLeave: () => void; onPause: () => void; onResume: () => void; onAdvance: (action: "start-break" | "skip-break" | "start-focus") => void; onEditPomodoro: () => void; onFinish: () => void }) { const paused = session.status === "paused"; const awaiting = session.status === "awaiting-confirmation"; const isBreak = activeInterval?.kind === "break"; const setComplete = isBreak && session.pomodoroRound % (session.pomodoroSettingsSnapshot?.roundsPerSet ?? settings?.roundsPerSet ?? 4) === 0; @@ -20,7 +21,7 @@ export function FocusPage({ session, activeInterval, settings, seconds, overtime

今日任务

{pendingCount} 项待完成 · {completedCount} 项已完成

diff --git a/src/styles/nature.css b/src/styles/nature.css index 3cfe6df..c54518e 100644 --- a/src/styles/nature.css +++ b/src/styles/nature.css @@ -297,6 +297,57 @@ textarea:focus-visible { line-height: 1.65; } +.today-garden-card { + justify-content: flex-start; +} + +.today-garden { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 16px; +} + +.today-garden article { + min-width: 0; + display: grid; + justify-items: center; + gap: 3px; + padding: 7px 4px 5px; + border: 1px solid rgba(82, 119, 100, .09); + border-radius: 14px; + background: rgba(249, 251, 248, .62); +} + +.today-garden .plant-illustration { + width: 58px; + height: 58px; +} + +.today-garden article > span { + width: 100%; + overflow: hidden; + color: #5d6c63; + font-size: 9px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.today-garden-empty { + width: 112px; + height: 94px; + margin: 15px auto 0; + opacity: .68; +} + +.today-garden-more { + margin-top: 8px; + color: #5d6c63; + font-size: 10px; + text-align: right; +} + .today-rest-mark { position: relative; height: 67px; diff --git a/tests/backup.test.ts b/tests/backup.test.ts index 1e41ffe..be8ee82 100644 --- a/tests/backup.test.ts +++ b/tests/backup.test.ts @@ -14,13 +14,14 @@ describe('完整导出与覆盖导入', () => { }); afterEach(async () => db.delete()); - it('V2 导出包含计划数据、执行数据和设置', async () => { + it('V3 导出包含计划、执行、成长、冥想预留数据和设置', async () => { const backup = await backups.exportData(); expect(backup).toMatchObject({ format: 'studyflow-backup', - version: 2, + version: 3, data: { tasks: [], taskEvents: [], studySessions: [], studyIntervals: [], sessionRevisions: [], + growthRecords: [], meditationSessions: [], meditationIntervals: [], executionSettings: { focusMinutes: 25, stopwatchAutoPauseMinutes: 240 }, }, }); @@ -70,26 +71,34 @@ describe('完整导出与覆盖导入', () => { }); const upgraded = await backups.exportData(); expect(upgraded).toMatchObject({ - version: 2, + version: 3, data: { - studySessions: [], studyIntervals: [], sessionRevisions: [], + studySessions: [], studyIntervals: [], sessionRevisions: [], growthRecords: [], + meditationSessions: [], meditationIntervals: [], executionSettings: { focusMinutes: 25, roundsPerSet: 4 }, }, }); }); - it('兼容缺少音量字段的早期 V2 备份并补齐默认音量', async () => { + it('兼容缺少音量字段的早期 V2 备份并补齐默认音量及 V3 空数据', async () => { const exported = await backups.exportData(); const legacySettings: Record = { ...exported.data.executionSettings }; delete legacySettings.soundVolume; await backups.replaceAll({ ...exported, - data: { ...exported.data, executionSettings: legacySettings }, + version: 2, + data: { + tasks: exported.data.tasks, categories: exported.data.categories, taskEvents: exported.data.taskEvents, + studySessions: exported.data.studySessions, studyIntervals: exported.data.studyIntervals, + sessionRevisions: exported.data.sessionRevisions, executionSettings: legacySettings, + }, }); - expect((await backups.exportData()).data.executionSettings.soundVolume).toBe(80); + const upgraded = await backups.exportData(); + expect(upgraded.data.executionSettings.soundVolume).toBe(80); + expect(upgraded.data.growthRecords).toEqual([]); }); - it('V2 执行会话和区间可完整导出并覆盖恢复', async () => { + it('V3 执行会话、区间与成长记录可完整导出并覆盖恢复', async () => { const category = (await db.categories.orderBy('sortOrder').first())!; let now = new Date('2026-08-14T00:00:00.000Z'); let sequence = 0; @@ -102,12 +111,71 @@ describe('完整导出与覆盖导入', () => { const exported = await backups.exportData(); expect(exported.data.studySessions).toHaveLength(1); expect(exported.data.studyIntervals).toHaveLength(1); + expect(exported.data.growthRecords).toHaveLength(1); await db.studySessions.clear(); await db.studyIntervals.clear(); + await db.growthRecords.clear(); await backups.replaceAll(exported); expect(await db.studySessions.get(started.id)).toMatchObject({ outcome: 'completed' }); expect(await db.studyIntervals.where('sessionId').equals(started.id).count()).toBe(1); + expect(await db.growthRecords.where('sourceSessionId').equals(started.id).count()).toBe(1); + }); + + it('拒绝破坏成长领域不变量的 V3 备份,且不改变当前数据', async () => { + const category = (await db.categories.orderBy('sortOrder').first())!; + let now = new Date('2026-08-14T00:00:00.000Z'); + const sessions = new SessionRepository(db, () => new Date(now), (() => { let sequence = 0; return () => `growth-check-${++sequence}`; })()); + const started = await sessions.startStopwatch({ categoryId: category.id, title: '合法成长来源', timezone: 'Asia/Shanghai' }); + now = new Date('2026-08-14T00:01:01.000Z'); + await sessions.finish(started.id, { outcome: 'completed' }, started.revision); + const valid = await backups.exportData(); + + const candidates = [ + (() => { const value = structuredClone(valid); value.data.studySessions[0].status = 'paused'; value.data.studySessions[0].endedAt = null; return value; })(), + (() => { const value = structuredClone(valid); value.data.studyIntervals[0].endedAt = '2026-08-14T00:00:30.000Z'; return value; })(), + (() => { const value = structuredClone(valid); value.data.growthRecords[0].plantType = 'flower'; return value; })(), + (() => { const value = structuredClone(valid); value.data.growthRecords[0].variant = ((value.data.growthRecords[0].variant + 1) % 3) as 0 | 1 | 2; return value; })(), + (() => { const value = structuredClone(valid); value.data.growthRecords[0].targetSecondsSnapshot += 60; return value; })(), + (() => { const value = structuredClone(valid); value.data.growthRecords[0].timezone = 'UTC'; return value; })(), + (() => { const value = structuredClone(valid); value.data.growthRecords[0].localDate = '2026-08-15'; return value; })(), + (() => { const value = structuredClone(valid); value.data.growthRecords[0].createdAt = '2026-08-14T00:01:00.000Z'; return value; })(), + (() => { const value = structuredClone(valid); value.data.growthRecords.push({ ...value.data.growthRecords[0], id: 'duplicate-growth' }); return value; })(), + (() => { + const value = structuredClone(valid); + const originalInterval = structuredClone(value.data.studyIntervals[0]); + value.data.studyIntervals[0].endedAt = '2026-08-14T00:00:30.000Z'; + value.data.sessionRevisions.push({ + id: 'forged-revision', sessionId: value.data.studySessions[0].id, reason: '无关审计记录', + before: { session: { ...value.data.studySessions[0], id: 'unrelated-session', revision: 0 }, intervals: [originalInterval] }, + after: { session: value.data.studySessions[0], intervals: value.data.studyIntervals }, + createdAt: '2026-08-14T00:01:01.000Z', + }); + return value; + })(), + ]; + for (const candidate of candidates) { + await expect(backups.replaceAll(candidate)).rejects.toThrow(/成长|会话/); + expect((await backups.exportData()).data).toEqual(valid.data); + } + }); + + it('历史修正到一分钟以下后,审计记录仍能证明植物来源并允许恢复备份', async () => { + const category = (await db.categories.orderBy('sortOrder').first())!; + let now = new Date('2026-08-14T00:00:00.000Z'); + let sequence = 0; + const sessions = new SessionRepository(db, () => new Date(now), () => `revision-growth-${++sequence}`); + const started = await sessions.startStopwatch({ categoryId: category.id, title: '修正后的幼苗', timezone: 'Asia/Shanghai' }); + now = new Date('2026-08-14T00:01:01.000Z'); + const finished = (await sessions.finish(started.id, { outcome: 'completed' }, started.revision))!; + const shortened = (await sessions.listIntervals(finished.id)).map((interval) => ({ + ...interval, endedAt: '2026-08-14T00:00:30.000Z', updatedAt: '2026-08-14T00:00:30.000Z', + })); + await sessions.correct(finished.id, { intervals: shortened, reason: '修正误记时间' }, finished.revision); + const exported = await backups.exportData(); + await backups.replaceAll(exported); + expect(await db.growthRecords.where('sourceSessionId').equals(finished.id).count()).toBe(1); + expect(await db.sessionRevisions.where('sessionId').equals(finished.id).count()).toBe(1); }); it('导出运行中的会话前自动暂停,并把暂停状态写入备份和数据库', async () => { diff --git a/tests/growth-domain.test.ts b/tests/growth-domain.test.ts new file mode 100644 index 0000000..a1df5a6 --- /dev/null +++ b/tests/growth-domain.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { StudySession } from "../shared/schemas/models"; +import { + calculateGrowthStage, + createStudyGrowthRecord, + localDateInTimezone, + stablePlantVariant, + studyGrowthTargetSeconds, +} from "../src/domain/growth"; + +function session(overrides: Partial = {}): StudySession { + return { + id: "session-stable", taskId: null, categoryId: "category-1", taskTitleSnapshot: "自由学习", + categoryNameSnapshot: "其他", estimatedMinutesSnapshot: null, goal: "", mode: "stopwatch", + pomodoroSettingsSnapshot: null, status: "finished", activeIntervalId: null, pomodoroRound: 1, + startedAt: "2026-08-14T15:50:00.000Z", endedAt: "2026-08-14T16:10:00.000Z", + timezone: "Asia/Shanghai", outcome: "completed", failureReason: null, note: "", summary: "", + revision: 1, createdAt: "2026-08-14T15:50:00.000Z", updatedAt: "2026-08-14T16:10:00.000Z", + ...overrides, + }; +} + +describe("成长阶段与稳定植物", () => { + it.each([ + [0, 0], [0.099, 0], [0.1, 1], [0.349, 1], [0.35, 2], [0.649, 2], + [0.65, 3], [0.999, 3], [1, 4], [1.8, 4], + ] as const)("成长比例 %s 对应阶段 %s", (ratio, stage) => { + expect(calculateGrowthStage(ratio * 1000, 1000)).toBe(stage); + }); + + it("关联任务、临时番茄和自由正计时使用各自成长目标", () => { + expect(studyGrowthTargetSeconds(session({ taskId: "task-1", estimatedMinutesSnapshot: 45 }))).toBe(45 * 60); + expect(studyGrowthTargetSeconds(session({ mode: "pomodoro", pomodoroSettingsSnapshot: { + focusMinutes: 30, shortBreakMinutes: 5, longBreakMinutes: 15, roundsPerSet: 4, + } }))).toBe(30 * 60); + expect(studyGrowthTargetSeconds(session())).toBe(25 * 60); + }); + + it("同一会话始终得到相同变体,并按会话时区记录完成日期", () => { + expect(stablePlantVariant("same-session")).toBe(stablePlantVariant("same-session")); + expect(localDateInTimezone("2026-08-14T16:10:00.000Z", "Asia/Shanghai")).toBe("2026-08-15"); + expect(createStudyGrowthRecord(session(), "growth-1", "2026-08-14T16:10:00.000Z")).toMatchObject({ + sourceType: "study", sourceSessionId: "session-stable", plantType: "tree", + targetSecondsSnapshot: 25 * 60, localDate: "2026-08-15", timezone: "Asia/Shanghai", + }); + }); +}); diff --git a/tests/plant-illustration.test.tsx b/tests/plant-illustration.test.tsx new file mode 100644 index 0000000..13ba675 --- /dev/null +++ b/tests/plant-illustration.test.tsx @@ -0,0 +1,33 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { PlantIllustration } from "../src/components/PlantIllustration"; + +describe("多株植物 SVG", () => { + it("每株树和花使用唯一渐变 ID,叶片与花瓣引用自己的定义", () => { + const { container } = render(<> + + + + + + ); + const ids = [...container.querySelectorAll("[id]")].map((element) => element.id); + expect(new Set(ids).size).toBe(ids.length); + for (const leaves of container.querySelectorAll(".tree-leaves")) { + const reference = leaves.style.fill.match(/#([^)]+)/)?.[1]; + expect(reference).toBeTruthy(); + expect(container.querySelector(`[id="${reference}"]`)).not.toBeNull(); + } + for (const petal of container.querySelectorAll(".petal")) { + const reference = petal.style.fill.match(/#([^)]+)/)?.[1]; + expect(reference).toBeTruthy(); + expect(container.querySelector(`[id="${reference}"]`)).not.toBeNull(); + } + expect(container.querySelector(".bud")?.getAttribute("style")).toContain("url(#petal-"); + for (const element of container.querySelectorAll("svg *")) { + const references = [element.getAttribute("style"), element.getAttribute("stroke"), element.getAttribute("filter")] + .flatMap((value) => [...(value ?? "").matchAll(/url\(#([^)]+)\)/g)].map((match) => match[1])); + for (const reference of references) expect(container.querySelector(`[id="${reference}"]`)).not.toBeNull(); + } + }); +}); diff --git a/tests/session-repository.test.ts b/tests/session-repository.test.ts index 628ee54..8d159ab 100644 --- a/tests/session-repository.test.ts +++ b/tests/session-repository.test.ts @@ -4,6 +4,8 @@ import { StudyFlowDatabase } from "../src/db/database"; import { SessionRepository } from "../src/db/sessionRepository"; import { SettingsRepository } from "../src/db/settingsRepository"; import { TaskRepository } from "../src/db/taskRepository"; +import { calculateGrowthStage } from "../src/domain/growth"; +import { totalFocusMs } from "../src/domain/execution"; describe("V2 学习会话 Repository", () => { let db: StudyFlowDatabase; @@ -62,6 +64,44 @@ describe("V2 学习会话 Repository", () => { expect(await sessions.finish(started.id, { outcome: "completed" }, started.revision)).toBeNull(); expect(await db.studySessions.get(started.id)).toBeUndefined(); expect(await db.studyIntervals.where("sessionId").equals(started.id).count()).toBe(0); + expect(await db.growthRecords.count()).toBe(0); + }); + + it("有效学习结束后生成一条稳定成长记录", async () => { + const started = await startStopwatch(); + advance(61_000); + await sessions.finish(started.id, { outcome: "completed" }, started.revision); + expect(await db.growthRecords.where("sourceSessionId").equals(started.id).first()).toMatchObject({ + sourceType: "study", plantType: "tree", targetSecondsSnapshot: 25 * 60, + localDate: "2026-08-14", timezone: "Asia/Shanghai", + }); + }); + + it("已结束且生成植物的学习记录不能无痕 discard", async () => { + const started = await startStopwatch(); + advance(61_000); + const finished = (await sessions.finish(started.id, { outcome: "completed" }, started.revision))!; + await expect(sessions.discard(finished.id)).rejects.toThrow(/不能无痕删除/); + expect(await db.studySessions.get(finished.id)).toBeDefined(); + expect(await db.growthRecords.where("sourceSessionId").equals(finished.id).count()).toBe(1); + }); + + it("开始会话时拒绝无效 IANA 时区", async () => { + const category = (await db.categories.orderBy("sortOrder").first())!; + await expect(sessions.startStopwatch({ + categoryId: category.id, title: "坏时区", timezone: "Moon/Sea-Of-Tranquility", + })).rejects.toThrow(/时区/); + }); + + it("存在活动冥想时不允许再开始学习", async () => { + const timestamp = now.toISOString(); + await db.meditationSessions.add({ + id: "active-meditation", mode: "free", status: "running", intention: "calm", intentionNote: "", + breathingPattern: "none", breathingRounds: 0, targetSeconds: null, activeIntervalId: null, + startedAt: timestamp, meditationStartedAt: timestamp, endedAt: null, timezone: "Asia/Shanghai", + feeling: null, note: "", revision: 0, createdAt: timestamp, updatedAt: timestamp, + }); + await expect(startStopwatch()).rejects.toThrow(/已有进行中的/); }); it("达到设置的 4 小时阈值后自动暂停且不会重复暂停", async () => { @@ -260,6 +300,7 @@ describe("V2 学习会话 Repository", () => { ]); expect(results.filter((item) => item.status === "fulfilled")).toHaveLength(1); expect(results.filter((item) => item.status === "rejected")).toHaveLength(1); + expect(await db.growthRecords.where("sourceSessionId").equals(started.id).count()).toBe(1); }); it("完成关联任务时同步任务状态,但未勾选时保持任务未完成", async () => { @@ -307,6 +348,19 @@ describe("V2 学习会话 Repository", () => { }); }); + it("历史时间线修正后植物阶段按真实有效时长动态更新", async () => { + const started = await startStopwatch(); + advance(30 * 60_000); + const finished = (await sessions.finish(started.id, { outcome: "completed" }, started.revision))!; + const record = (await db.growthRecords.where("sourceSessionId").equals(finished.id).first())!; + expect(calculateGrowthStage(totalFocusMs(await sessions.listIntervals(finished.id)) / 1000, record.targetSecondsSnapshot)).toBe(4); + const shortened = (await sessions.listIntervals(finished.id)).map((interval) => ({ + ...interval, endedAt: "2026-08-14T00:10:00.000Z", updatedAt: "2026-08-14T00:10:00.000Z", + })); + await sessions.correct(finished.id, { intervals: shortened, reason: "修正误记时长" }, finished.revision); + expect(calculateGrowthStage(totalFocusMs(await sessions.listIntervals(finished.id)) / 1000, record.targetSecondsSnapshot)).toBe(2); + }); + it("历史修正拒绝重叠阶段且失败时不破坏原时间线", async () => { const started = await startStopwatch(); advance(61_000); @@ -365,3 +419,38 @@ describe("V1 到 V2 IndexedDB migration", () => { await upgraded.delete(); }); }); + +describe("V2 到 V3 IndexedDB migration", () => { + it("保留 V2 学习会话,并创建成长与冥想数据表且不补历史植物", async () => { + const name = `studyflow-v2-migration-${crypto.randomUUID()}`; + const legacy = new Dexie(name); + legacy.version(2).stores({ + tasks: "id, categoryId, dueDate, completed, archivedAt, createdAt", + categories: "id, &name, sortOrder, archivedAt, createdAt", + taskEvents: "id, taskId, &sequence, type, occurredAt", + studySessions: "id, taskId, categoryId, status, mode, startedAt, endedAt, updatedAt", + studyIntervals: "id, sessionId, kind, startedAt, endedAt", + sessionRevisions: "id, sessionId, createdAt", + executionSettings: "id", + }); + await legacy.open(); + const timestamp = "2026-08-14T00:00:00.000Z"; + await legacy.table("studySessions").add({ + id: "legacy-session", taskId: null, categoryId: "legacy-category", taskTitleSnapshot: "旧学习", + categoryNameSnapshot: "旧分类", estimatedMinutesSnapshot: null, goal: "", mode: "stopwatch", + pomodoroSettingsSnapshot: null, status: "finished", activeIntervalId: null, pomodoroRound: 1, + startedAt: timestamp, endedAt: "2026-08-14T00:10:00.000Z", timezone: "Asia/Shanghai", + outcome: "completed", failureReason: null, note: "", summary: "", revision: 1, + createdAt: timestamp, updatedAt: "2026-08-14T00:10:00.000Z", + }); + legacy.close(); + + const upgraded = new StudyFlowDatabase(name); + await upgraded.open(); + expect(await upgraded.studySessions.get("legacy-session")).toMatchObject({ taskTitleSnapshot: "旧学习" }); + expect(await upgraded.growthRecords.count()).toBe(0); + expect(await upgraded.meditationSessions.count()).toBe(0); + expect(await upgraded.meditationIntervals.count()).toBe(0); + await upgraded.delete(); + }); +});