diff --git a/README.md b/README.md index 0b97cc5..bbbfda0 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V2 已形 - 独立分类管理 - 从任务或临时学习记录开始 `Stopwatch`(正计时)或 `Pomodoro`(番茄钟) - Focus Mode、全局迷你计时栏、暂停、恢复、跳过休息和结束 +- 番茄专注到时播放提示音;未开始休息时自动进入超时正计时并继续记录实际专注 - 全局番茄默认值,以及开始学习时独立覆盖本次专注/休息时长和每组轮数 - Focus 中可调整本次会话的后续番茄阶段,不影响当前阶段或其他会话 -- 可配置提示音和浏览器通知 +- 可开关提示音、调节并试听音量,以及启用浏览器通知 - 休眠或明显时间跳跃确认、多标签页状态同步和刷新恢复 - 完成/部分完成/未完成、原因、总结和备注 - History 日期/分类/任务/结果筛选,以及保留修改前后值的时间线修正 @@ -53,7 +54,7 @@ cd /home/minglee/Projects/studyflow 1. 在 `Plan` 创建任务,或点击左下角“开始学习”建立临时记录。 2. 选择正计时或番茄钟;番茄钟可以沿用全局默认值,也可以为这次学习单独设置专注、休息和轮数。 -3. 使用暂停、继续、跳过休息或结束;在 Focus 中修改本次番茄设置时,当前阶段保持不变,后续阶段使用新值。离开 Focus 后可通过底部迷你栏返回。 +3. 使用暂停、继续、跳过休息或结束。番茄专注到时会播放提示音;如果尚未开始休息,页面会转为超时正计时并继续累计实际专注。在 Focus 中修改本次番茄设置时,当前阶段保持不变,后续阶段使用新值。离开 Focus 后可通过底部迷你栏返回。 4. 结束时选择结果;部分完成或未完成必须说明原因。 5. 在 `History` 查看和筛选记录。如需修正,必须填写修正原因,原值不会被无痕覆盖。 6. 定期从“数据管理”导出 JSON 备份。 diff --git a/e2e/execution.spec.ts b/e2e/execution.spec.ts index 61fc780..dfa1eff 100644 --- a/e2e/execution.spec.ts +++ b/e2e/execution.spec.ts @@ -29,7 +29,7 @@ test("从任务开始正计时,暂停恢复后结束并同步完成状态", as await page.clock.runFor(31_000); await page.getByRole("button", { name: "结束学习" }).click(); await expect(page.getByRole("dialog", { name: "结束本次学习" })).toBeVisible(); - await expect(page.getByRole("dialog", { name: "结束本次学习" }).getByText("01:01")).toBeVisible(); + await expect(page.getByRole("dialog", { name: "结束本次学习" }).getByText(/^01:0[01]$/)).toBeVisible(); await page.getByRole("button", { name: "确认结束" }).click(); await page.getByRole("link", { name: "History" }).click(); @@ -71,13 +71,35 @@ test("番茄钟到时后等待确认,再进入休息阶段", async ({ page }) await page.getByRole("radio", { name: /番茄钟/ }).check(); await page.getByRole("button", { name: "进入 Focus" }).click(); await page.clock.runFor(61_000); - await expect(page.getByText("本阶段完成")).toBeVisible(); + await expect(page.getByText("超时专注 · 正计时")).toBeVisible(); + const overtimeTimer = page.locator(".focus-orbit time"); + await expect(overtimeTimer).toHaveText(/^00:0[01]$/); + await page.clock.runFor(4_000); + await expect(overtimeTimer).toHaveText(/^00:0[45]$/); await page.getByRole("button", { name: "开始休息" }).click(); await expect(page.getByText("休息", { exact: true })).toBeVisible(); await page.clock.runFor(61_000); await expect(page.getByRole("button", { name: "开始下一轮" })).toBeVisible(); }); +test("番茄专注到时会触发三声提示音", async ({ page }) => { + await page.addInitScript(() => { + const original = AudioContext.prototype.createOscillator; + AudioContext.prototype.createOscillator = function (...args) { + sessionStorage.setItem("studyflow-test-tone-count", String(Number(sessionStorage.getItem("studyflow-test-tone-count") ?? "0") + 1)); + return original.apply(this, args); + }; + }); + await openAtFixedTime(page); + await page.getByRole("button", { name: "开始学习" }).click(); + await page.getByLabel("学习名称").fill("到时声音测试"); + await page.getByRole("radio", { name: /番茄钟/ }).check(); + await page.getByLabel("本次专注时长").fill("1"); + await page.getByRole("button", { name: "进入 Focus" }).click(); + await page.clock.runFor(61_000); + await expect.poll(() => page.evaluate(() => Number(sessionStorage.getItem("studyflow-test-tone-count") ?? "0"))).toBe(3); +}); + test("开始时可独立设置番茄参数,Focus 修改从下一阶段生效", async ({ page }) => { await openAtFixedTime(page); await page.getByRole("button", { name: "开始学习" }).click(); @@ -181,7 +203,25 @@ test("设置修改会广播到同源的另一个标签页", async ({ page, conte await other.getByRole("link", { name: "专注设置" }).click(); await page.getByRole("link", { name: "专注设置" }).click(); + await expect(page.getByLabel("提示音音量")).toHaveValue("80"); await page.getByLabel("专注时长").fill("50"); + await page.getByLabel("提示音音量").fill("95"); await page.getByRole("button", { name: "保存设置" }).click(); await expect(other.getByLabel("专注时长")).toHaveValue("50"); + await expect(other.getByLabel("提示音音量")).toHaveValue("95"); +}); + +test("用户可以调节并试听提示音音量", async ({ page }) => { + await page.addInitScript(() => { + const original = AudioContext.prototype.createOscillator; + AudioContext.prototype.createOscillator = function (...args) { + sessionStorage.setItem("studyflow-preview-tone-count", String(Number(sessionStorage.getItem("studyflow-preview-tone-count") ?? "0") + 1)); + return original.apply(this, args); + }; + }); + await openAtFixedTime(page); + await page.getByRole("link", { name: "专注设置" }).click(); + await page.getByLabel("提示音音量").fill("100"); + await page.getByRole("button", { name: "试听提示音" }).click(); + await expect.poll(() => page.evaluate(() => Number(sessionStorage.getItem("studyflow-preview-tone-count") ?? "0"))).toBe(3); }); diff --git a/shared/schemas/models.ts b/shared/schemas/models.ts index 0c5377c..1bbc283 100644 --- a/shared/schemas/models.ts +++ b/shared/schemas/models.ts @@ -87,7 +87,7 @@ export const sleepGapSchema = z.object({ to: isoDateTimeSchema, resolution: z.enum(["include", "exclude", "correct"]).nullable(), correctedSeconds: z.number().int().nonnegative().nullable(), - resumeStatus: z.enum(["running", "paused"]), + resumeStatus: z.enum(["running", "paused", "awaiting-confirmation"]), }).superRefine((value, ctx) => { const durationSeconds = (Date.parse(value.to) - Date.parse(value.from)) / 1000; if (durationSeconds < 0) ctx.addIssue({ code: "custom", path: ["to"], message: "休眠结束时间不能早于开始时间" }); @@ -177,6 +177,7 @@ export const executionSettingsSchema = z.object({ longBreakMinutes: z.number().int().min(1).max(120), roundsPerSet: z.number().int().min(1).max(12), soundEnabled: z.boolean(), + soundVolume: z.number().int().min(10).max(100).default(80), notificationsEnabled: z.boolean(), stopwatchAutoPauseMinutes: z.number().int().min(60).max(1440), updatedAt: isoDateTimeSchema, diff --git a/src/app/App.tsx b/src/app/App.tsx index ab64e09..61f7d3e 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -12,23 +12,24 @@ import { backupSchema } from "../../shared/schemas/backup"; type Page="today"|"plan"|"categories"|"history"|"settings"|"focus"; type DeleteTarget={type:"task";item:Task}|{type:"category";item:Category}; function downloadJson(data:unknown,prefix="studyflow-backup"){const d=new Date(),local=`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`,url=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:"application/json"})),a=document.createElement("a");a.href=url;a.download=`${prefix}-${local}.json`;a.click();URL.revokeObjectURL(url)} +function playNotificationSound(volume:number){if(!("AudioContext" in window))return;try{const audio=new AudioContext(),peak=Math.max(.05,Math.min(.5,volume/200));void audio.resume().then(()=>{[0,.22,.44].forEach((delay,index)=>{const start=audio.currentTime+delay,osc=audio.createOscillator(),gain=audio.createGain();osc.frequency.value=index===2?880:660;gain.gain.setValueAtTime(.0001,start);gain.gain.exponentialRampToValueAtTime(peak,start+.02);gain.gain.exponentialRampToValueAtTime(.0001,start+.18);osc.connect(gain);gain.connect(audio.destination);osc.start(start);osc.stop(start+.2)});setTimeout(()=>void audio.close(),900)}).catch(()=>void audio.close())}catch{/* 浏览器阻止自动播放时由页面视觉提醒兜底 */}} export default function App(){ - const [page,setPage]=useState("today"),[lastPage,setLastPage]=useState("today"),[tasks,setTasks]=useState([]),[categories,setCategories]=useState([]),[sessions,setSessions]=useState([]),[active,setActive]=useState(null),[intervals,setIntervals]=useState([]),[sessionDurations,setSessionDurations]=useState>({}),[settings,setSettings]=useState(null),[now,setNow]=useState(Date.now()),[loading,setLoading]=useState(true),[error,setError]=useState(""),[notice,setNotice]=useState(""),[finishOpen,setFinishOpen]=useState(false),[pomodoroEditOpen,setPomodoroEditOpen]=useState(false),[startContext,setStartContext]=useState(null),[correcting,setCorrecting]=useState(null),[editingTask,setEditingTask]=useState(undefined),[deleteTarget,setDeleteTarget]=useState(null),[backupOpen,setBackupOpen]=useState(false),[pendingImport,setPendingImport]=useState(null); const fileRef=useRef(null),channelRef=useRef(null),boundaryRevision=useRef(null),estimateNotified=useRef(null),refreshRequest=useRef(0),activeRef=useRef(null),hasUnresolvedRef=useRef(false); + 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 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 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&&"AudioContext" in window)try{const audio=new AudioContext(),osc=audio.createOscillator(),gain=audio.createGain();osc.connect(gain);gain.connect(audio.destination);gain.gain.value=.05;osc.onended=()=>void audio.close();osc.start();osc.stop(audio.currentTime+.18)}catch{/* 浏览器阻止自动播放时仅保留页面提醒 */}if(settings?.notificationsEnabled&&"Notification" in window&&Notification.permission==="granted")try{new Notification("StudyFlow",{body:message})}catch{/* 通知失败不影响计时 */}},[settings]); + const notifyStage=useCallback((message:string)=>{if(settings?.soundEnabled)playNotificationSound(settings.soundVolume);if(settings?.notificationsEnabled&&"Notification" in window&&Notification.permission==="granted")try{new Notification("StudyFlow",{body:message})}catch{/* 通知失败不影响计时 */}},[settings]); useEffect(()=>{void refresh().catch(e=>setError(e instanceof Error?e.message:"读取本地数据失败")).finally(()=>setLoading(false))},[refresh]); useEffect(()=>{void navigator.storage?.persist?.().catch(()=>false)},[]); useEffect(()=>{const timer=window.setInterval(()=>setNow(Date.now()),1000);return()=>clearInterval(timer)},[]); useEffect(()=>{const channel=new BroadcastChannel("studyflow-execution");channelRef.current=channel;channel.onmessage=()=>void refresh();return()=>{channel.close();channelRef.current=null}},[refresh]); useEffect(()=>{const guard=(e:BeforeUnloadEvent)=>{if(active){e.preventDefault();e.returnValue=""}};window.addEventListener("beforeunload",guard);return()=>window.removeEventListener("beforeunload",guard)},[active]); useEffect(()=>{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,displaySeconds=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; 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); - 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?.status==="running"&&!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]); - useEffect(()=>{if(!active||active.status!=="running"||boundaryRevision.current===active.revision)return;if(active.mode==="pomodoro"&&activeInterval?.targetSeconds&&phaseElapsed>=activeInterval.targetSeconds){boundaryRevision.current=active.revision;void mutate(()=>executionAdapter.completeStage(active)).then(()=>notifyStage("本阶段已结束"));return}if(active.mode==="stopwatch"&&settings&&continuousRunningSeconds>=settings.stopwatchAutoPauseMinutes*60){boundaryRevision.current=active.revision;void mutate(()=>executionAdapter.autoPause(active)).then(()=>notifyStage("正计时已自动暂停"))}},[active,activeInterval,phaseElapsed,continuousRunningSeconds,settings,mutate,notifyStage]); + 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]); + useEffect(()=>{if(!active||active.status!=="running"||boundaryRevision.current===active.revision)return;if(active.mode==="pomodoro"&&activeInterval?.targetSeconds&&phaseElapsed>=activeInterval.targetSeconds){boundaryRevision.current=active.revision;const focusEnded=activeInterval.kind==="focus";void mutate(()=>executionAdapter.completeStage(active)).then(()=>{notifyStage(focusEnded?"本轮专注已结束,已开始超时正计时":"休息已结束");setNotice(focusEnded?"本轮专注已结束,正在记录超时专注":"休息已结束,可以开始下一轮")});return}if(active.mode==="stopwatch"&&settings&&continuousRunningSeconds>=settings.stopwatchAutoPauseMinutes*60){boundaryRevision.current=active.revision;void mutate(()=>executionAdapter.autoPause(active)).then(()=>notifyStage("正计时已自动暂停"))}},[active,activeInterval,phaseElapsed,continuousRunningSeconds,settings,mutate,notifyStage]); useEffect(()=>{if(active&&estimateReached&&estimateNotified.current!==active.id){estimateNotified.current=active.id;notifyStage("已达到任务预计时长");setNotice("已达到任务预计时长,计时仍在继续")}},[active,estimateReached,notifyStage]); async function start(mode:TimerMode,input:StartSessionInput){const value=await executionAdapter.start(mode,input);heartbeatWall.current=Date.parse(value.startedAt);heartbeatMonotonic.current=performance.now();setActive(value);setIntervals(await executionAdapter.listIntervals(value.id));setStartContext(null);setLastPage(page);setPage("focus");channelRef.current?.postMessage("changed")} async function finish(input:FinishSessionInput){if(!active)return;const saved=await executionAdapter.finish(active,input);setFinishOpen(false);setActive(null);setPage(lastPage==="focus"?"today":lastPage);await refresh();channelRef.current?.postMessage("changed");setNotice(saved?"学习记录已保存":"有效专注不足 1 分钟,本次记录已丢弃")} @@ -46,7 +47,8 @@ export default function App(){ ; if(page==="focus"&&active)return <> {error&&
{error}
} -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}))}}/>}; +{notice&&
{notice}
} +setPage(lastPage)} onPause={()=>void mutate(()=>executionAdapter.pause(active))} onResume={()=>void mutate(()=>executionAdapter.resume(active))} onAdvance={action=>void mutate(()=>executionAdapter.advance(active,action))} onEditPomodoro={()=>setPomodoroEditOpen(true)} onFinish={()=>setFinishOpen(true)}/>{pomodoroEditOpen&&setPomodoroEditOpen(false)} onSave={updateSessionPomodoro}/>} {finishOpen&&setFinishOpen(false)} onFinish={finish}/>} {unresolved&&{await mutate(()=>executionAdapter.resolveSleepGap(active,{intervalId:unresolved.interval.id,gapIndex:unresolved.index,resolution,correctedSeconds}))}}/>}; return
}{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/db/database.ts b/src/db/database.ts index f19c8cf..999aaf4 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -60,7 +60,7 @@ export class StudyFlowDatabase extends Dexie { export function defaultExecutionSettings(at = nowIso()): ExecutionSettings { return { id: "default", focusMinutes: 25, shortBreakMinutes: 5, longBreakMinutes: 15, - roundsPerSet: 4, soundEnabled: true, notificationsEnabled: false, + roundsPerSet: 4, soundEnabled: true, soundVolume: 80, notificationsEnabled: false, stopwatchAutoPauseMinutes: 240, updatedAt: at, }; } diff --git a/src/db/sessionRepository.ts b/src/db/sessionRepository.ts index d6b3a05..0e13848 100644 --- a/src/db/sessionRepository.ts +++ b/src/db/sessionRepository.ts @@ -121,6 +121,10 @@ export class SessionRepository { return { ...session, status: "running", activeIntervalId: next.id, pomodoroRound: round }; } if (session.status !== "awaiting-confirmation") throw new ConflictError("当前番茄阶段尚未等待确认"); + if (!interval.endedAt) { + interval.endedAt = now; interval.updatedAt = now; + await this.database.studyIntervals.put(studyIntervalSchema.parse(interval)); + } let kind: "focus" | "break"; let round = session.pomodoroRound; if (interval.kind === "focus") { @@ -144,8 +148,12 @@ export class SessionRepository { async completeCurrentStage(id: string, expectedRevision?: number): Promise { return this.mutateActive(id, expectedRevision, async (session, interval, now) => { if (session.mode !== "pomodoro" || session.status !== "running") throw new ConflictError("当前阶段不能结束"); - interval.endedAt = now; interval.updatedAt = now; - await this.database.studyIntervals.put(studyIntervalSchema.parse(interval)); + // 专注到时后继续开放该区间,以正计时记录用户尚未开始休息的超时时间。 + // 休息到时则正常关闭,等待用户确认下一轮。 + if (interval.kind === "break") { + interval.endedAt = now; interval.updatedAt = now; + await this.database.studyIntervals.put(studyIntervalSchema.parse(interval)); + } return { ...session, status: "awaiting-confirmation", activeIntervalId: interval.id }; }); } @@ -153,7 +161,7 @@ export class SessionRepository { async reportSleepGap(id: string, from: string, to: string, expectedRevision?: number): Promise { return this.mutateActive(id, expectedRevision, async (session, interval, now) => { interval.sleepGaps.push({ detectedAt: now, from, to, resolution: null, correctedSeconds: null, - resumeStatus: session.status === "paused" ? "paused" : "running" }); + resumeStatus: session.status === "paused" ? "paused" : session.status === "awaiting-confirmation" ? "awaiting-confirmation" : "running" }); interval.updatedAt = now; await this.database.studyIntervals.put(studyIntervalSchema.parse(interval)); return { ...session, status: "sleep-review" }; }); diff --git a/src/pages/ExecutionSettingsPage.tsx b/src/pages/ExecutionSettingsPage.tsx index 92ec9ba..50e3911 100644 --- a/src/pages/ExecutionSettingsPage.tsx +++ b/src/pages/ExecutionSettingsPage.tsx @@ -1,10 +1,10 @@ import { useEffect, useState, type FormEvent } from "react"; import type { ExecutionSettings } from "../features/executionTypes"; -export function ExecutionSettingsPage({ settings, onSave }: { settings: ExecutionSettings | null; onSave: (value: Partial) => Promise }) { +export function ExecutionSettingsPage({ settings, onSave, onPreviewSound }: { settings: ExecutionSettings | null; onSave: (value: Partial) => Promise; onPreviewSound: (volume: number) => void }) { const [draft,setDraft]=useState(settings);const [saving,setSaving]=useState(false);const [saved,setSaved]=useState(false); useEffect(()=>setDraft(settings),[settings]); if(!draft)return

正在读取设置…

; async function submit(e:FormEvent){e.preventDefault();const value=draft;if(!value)return;setSaving(true);setSaved(false);try{await onSave(value);setSaved(true)}finally{setSaving(false)}} - return <>

Execution

专注设置

调整番茄节奏与计时提醒。更改只影响之后开始的会话。

void submit(e)}>

番茄钟

设置一组专注与休息的节奏。

setDraft({...draft,focusMinutes})}/>setDraft({...draft,shortBreakMinutes})}/>setDraft({...draft,longBreakMinutes})}/>setDraft({...draft,roundsPerSet})}/>

安全与提醒

避免忘记停止计时,并选择阶段完成时的提醒方式。

setDraft({...draft,stopwatchAutoPauseMinutes})}/>
{saved?"设置已保存":"所有设置仅保存在当前浏览器"}
; + return <>

Execution

专注设置

调整番茄节奏与计时提醒。更改只影响之后开始的会话。

void submit(e)}>

番茄钟

设置一组专注与休息的节奏。

setDraft({...draft,focusMinutes})}/>setDraft({...draft,shortBreakMinutes})}/>setDraft({...draft,longBreakMinutes})}/>setDraft({...draft,roundsPerSet})}/>

安全与提醒

避免忘记停止计时,并选择阶段完成时的提醒方式。

setDraft({...draft,stopwatchAutoPauseMinutes})}/>
setDraft({...draft,soundVolume:Number(e.target.value)})}/>
{saved?"设置已保存":"所有设置仅保存在当前浏览器"}
; } function NumberField({label,value,min,max,unit="分钟",onChange}:{label:string;value:number;min:number;max:number;unit?:string;onChange:(n:number)=>void}){return } diff --git a/src/pages/FocusPage.tsx b/src/pages/FocusPage.tsx index ddb57f9..63236ac 100644 --- a/src/pages/FocusPage.tsx +++ b/src/pages/FocusPage.tsx @@ -2,7 +2,7 @@ import { ArrowLeft, Coffee, Pause, Play, Settings2, Square } from "lucide-react" import type { ExecutionSettings, StudyInterval, StudySession } from "../features/executionTypes"; import { formatDuration } from "../features/executionAdapter"; -export function FocusPage({ session, activeInterval, settings, seconds, estimateReached, onLeave, onPause, onResume, onAdvance, onEditPomodoro, onFinish }: { session: StudySession; activeInterval?: StudyInterval; settings: ExecutionSettings | null; seconds: number; 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, 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 }) { 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; @@ -14,9 +14,9 @@ export function FocusPage({ session, activeInterval, settings, seconds, estimate {session.mode === "pomodoro" ? `POMODORO · ROUND ${session.pomodoroRound}` : "STOPWATCH"}
-
+
-{isBreak ? awaiting ? "休息结束" : paused ? "休息已暂停" : "休息" : awaiting ? "本阶段完成" : paused ? "已暂停" : "正在专注"} +{isBreak ? awaiting ? "休息结束" : paused ? "休息已暂停" : "休息" : overtime ? "超时专注 · 正计时" : awaiting ? "本阶段完成" : paused ? "已暂停" : "正在专注"} {target && 目标 {target} 分钟}

{session.categoryNameSnapshot}

diff --git a/src/styles/global.css b/src/styles/global.css index 5ea3806..a3c7213 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -4,8 +4,10 @@ .session-pomodoro{border:1px solid var(--border);border-radius:12px;padding:14px;margin:0}.session-pomodoro legend{padding:0 6px;font-weight:700}.session-pomodoro>p{margin:10px 0 0;color:var(--muted);font-size:12px}.pomodoro-fields{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.session-pomodoro-edit{display:grid;gap:15px} .focus-page{min-height:100vh;color:#eaf5ef;background:radial-gradient(circle at 50% 38%,#28614d,#173f32 45%,#102d24);display:grid;grid-template-rows:auto 1fr auto;padding:26px clamp(22px,5vw,72px)}.focus-page>header,.focus-page>footer{display:flex;justify-content:space-between;color:#b8d1c6;font-size:12px}.focus-back{border:0;background:transparent;color:#d8e9e1;display:flex;gap:8px}.focus-center{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}.focus-orbit{width:min(360px,70vw);aspect-ratio:1;border-radius:50%;border:3px solid #91cfb4;display:grid;place-items:center;margin-bottom:28px}.focus-orbit.paused{border-color:#d8af69}.focus-orbit>div{display:flex;flex-direction:column}.focus-orbit time{font-size:clamp(48px,8vw,76px);font-weight:780;font-variant-numeric:tabular-nums}.focus-center h1{font-size:clamp(25px,4vw,38px);margin:6px}.focus-goal,.focus-category{color:#b8d1c6}.focus-milestone{margin:10px 0 4px;padding:8px 13px;border:1px solid #d8af69;border-radius:99px;color:#f4d99f;background:#513d1f66;font-size:12px}.focus-alert{position:fixed;z-index:40;top:18px;left:50%;transform:translateX(-50%);width:min(560px,calc(100vw - 32px))}.focus-actions{display:flex;gap:10px}.focus-primary{background:#dff2e8;color:#174e39}.focus-secondary{border:1px solid #618c7a;background:transparent;color:#e7f2ed} .focus-settings{display:flex;align-items:center;gap:7px;margin-top:14px;border:0;background:transparent;color:#b8d1c6;font-size:12px}.focus-settings:hover{color:#fff}.focus-settings svg{width:15px;height:15px} +.focus-orbit.overtime{border-color:#f0bd68;box-shadow:0 0 0 10px #f0bd6814}.focus-orbit.overtime time{color:#ffe0a3} .history-toolbar{justify-content:flex-start}.history-toolbar input{border:1px solid #d5ddd7;border-radius:8px;padding:9px}.history-list{display:grid;gap:10px}.history-card{display:grid;grid-template-columns:70px minmax(0,1fr) auto auto auto;align-items:center;gap:16px;background:#fff;border:1px solid var(--border);border-radius:13px;padding:15px}.history-date,.history-main>div{display:flex;flex-direction:column}.history-date span,.history-main span{font-size:11px;color:var(--muted)}.history-main h3{margin:0}.outcome{border-radius:99px;padding:4px 9px;font-size:11px;font-weight:750}.outcome.completed{background:#e5f4ec;color:#23714f}.outcome.partial{background:#fff2dc;color:#8e611b}.outcome.unfinished{background:#fde9e9;color:#9a3e3e}.history-correct{border:0;background:transparent;color:var(--green)} .settings-panel{display:grid;gap:16px;max-width:850px}.settings-panel>section{background:#fff;border:1px solid var(--border);border-radius:14px;padding:22px;display:grid;grid-template-columns:190px 1fr;gap:30px}.settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.number-input{display:flex;border:1px solid #d5ddd7;border-radius:8px}.number-input input{border:0;min-width:0;width:100%;padding:9px}.setting-switch{border:1px solid var(--border);border-radius:9px;padding:10px;display:flex;justify-content:space-between}.setting-switch span{display:flex;flex-direction:column}.settings-panel>footer{display:flex;justify-content:flex-end;gap:16px}.sleep-gap{display:grid;gap:10px}.sleep-gap>label{border:1px solid var(--border);border-radius:10px;padding:12px;display:flex;gap:9px}.sleep-gap>label>span{display:flex;flex-direction:column}.sleep-gap small{color:var(--muted)} +.volume-setting{grid-column:1/-1;border:1px solid var(--border);border-radius:9px;padding:12px;display:grid;grid-template-columns:1fr auto;gap:10px 14px;align-items:center}.volume-setting>label{grid-column:1/-1;display:flex;justify-content:space-between}.volume-setting>label span{display:flex;flex-direction:column}.volume-setting output{font-weight:700;color:var(--accent)}.volume-setting input[type="range"]{width:100%;accent-color:var(--accent)}.volume-setting.disabled{opacity:.55} @media(max-width:850px){.active-session-bar{left:16px;right:16px}.quick-start{margin:0}.history-card{grid-template-columns:60px minmax(0,1fr) auto}.settings-panel>section{grid-template-columns:1fr}} @media(max-width:560px){.active-session-bar{gap:8px;bottom:8px}.active-session-bar time{font-size:17px}.active-session-bar>div button:nth-child(2){display:none}.session-form,.settings-grid,.mode-picker,.result-picker,.pomodoro-fields{grid-template-columns:1fr}.history-card{grid-template-columns:55px 1fr}.history-duration,.outcome,.history-correct{grid-column:2}.focus-page{padding:18px}.focus-page>header span,.focus-page>footer span:last-child{display:none}.focus-actions{flex-direction:column;width:100%}.settings-panel>section{padding:16px}} :root{font-family:Inter,"Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;color:#19231f;background:#f5f7f4;font-synthesis:none;--green:#176b4a;--green-dark:#105239;--border:#dfe6e0;--muted:#68746e;--surface:#fff;line-height:1.5} diff --git a/tests/backup.test.ts b/tests/backup.test.ts index c82caca..1e41ffe 100644 --- a/tests/backup.test.ts +++ b/tests/backup.test.ts @@ -78,6 +78,17 @@ describe('完整导出与覆盖导入', () => { }); }); + it('兼容缺少音量字段的早期 V2 备份并补齐默认音量', async () => { + const exported = await backups.exportData(); + const legacySettings: Record = { ...exported.data.executionSettings }; + delete legacySettings.soundVolume; + await backups.replaceAll({ + ...exported, + data: { ...exported.data, executionSettings: legacySettings }, + }); + expect((await backups.exportData()).data.executionSettings.soundVolume).toBe(80); + }); + it('V2 执行会话和区间可完整导出并覆盖恢复', async () => { const category = (await db.categories.orderBy('sortOrder').first())!; let now = new Date('2026-08-14T00:00:00.000Z'); diff --git a/tests/session-repository.test.ts b/tests/session-repository.test.ts index 8d41141..628ee54 100644 --- a/tests/session-repository.test.ts +++ b/tests/session-repository.test.ts @@ -103,6 +103,43 @@ describe("V2 学习会话 Repository", () => { .toEqual(["focus", "break", "focus"]); }); + it("专注到时后保持区间开放并累计超时,开始休息时才关闭", async () => { + const category = (await db.categories.orderBy("sortOrder").first())!; + const started = await sessions.startPomodoro({ + categoryId: category.id, title: "超时专注", timezone: "Asia/Shanghai", + pomodoroSettings: { focusMinutes: 1, shortBreakMinutes: 5, longBreakMinutes: 15, roundsPerSet: 4 }, + }); + advance(60_000); + const overtime = await sessions.completeCurrentStage(started.id, started.revision); + expect(overtime.status).toBe("awaiting-confirmation"); + expect((await sessions.listIntervals(started.id))[0].endedAt).toBeNull(); + + advance(30_000); + const onBreak = await sessions.advancePomodoro(started.id, "start-break", overtime.revision); + const intervals = await sessions.listIntervals(started.id); + expect(intervals[0].endedAt).toBe("2026-08-14T00:01:30.000Z"); + expect(intervals[1]).toMatchObject({ kind: "break", startedAt: "2026-08-14T00:01:30.000Z" }); + expect(onBreak.status).toBe("running"); + }); + + it("超时正计时发生休眠时,处理后恢复到等待休息状态", async () => { + const category = (await db.categories.orderBy("sortOrder").first())!; + const started = await sessions.startPomodoro({ + categoryId: category.id, title: "超时休眠", timezone: "Asia/Shanghai", + pomodoroSettings: { focusMinutes: 1, shortBreakMinutes: 5, longBreakMinutes: 15, roundsPerSet: 4 }, + }); + advance(60_000); + const overtime = await sessions.completeCurrentStage(started.id, started.revision); + advance(30_000); + const reviewing = await sessions.reportSleepGap( + started.id, "2026-08-14T00:01:00.000Z", "2026-08-14T00:01:30.000Z", overtime.revision, + ); + const resolved = await sessions.resolveSleepGap(started.id, { + intervalId: started.activeIntervalId!, gapIndex: 0, resolution: "exclude", + }, reviewing.revision); + expect(resolved.status).toBe("awaiting-confirmation"); + }); + it("每条番茄会话可覆盖默认设置,运行中修改只影响后续阶段", async () => { const category = (await db.categories.orderBy("sortOrder").first())!; const settings = new SettingsRepository(db, clock); @@ -289,10 +326,12 @@ describe("V2 学习会话 Repository", () => { const settings = new SettingsRepository(db, clock); expect(await settings.getExecutionSettings()).toMatchObject({ focusMinutes: 25, shortBreakMinutes: 5, longBreakMinutes: 15, - roundsPerSet: 4, stopwatchAutoPauseMinutes: 240, + roundsPerSet: 4, soundVolume: 80, stopwatchAutoPauseMinutes: 240, }); expect((await settings.updateExecutionSettings({ focusMinutes: 50 })).focusMinutes).toBe(50); + expect((await settings.updateExecutionSettings({ soundVolume: 100 })).soundVolume).toBe(100); await expect(settings.updateExecutionSettings({ focusMinutes: 0 })).rejects.toThrow(); + await expect(settings.updateExecutionSettings({ soundVolume: 0 })).rejects.toThrow(); }); });