From 9a3d301b9aaaa57d0529a9ccde515f174f7b8eaa Mon Sep 17 00:00:00 2001 From: MingLeeEatPy <297402474+MingLeeEatPy@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:17:02 +0800 Subject: [PATCH 1/2] feat: continue timing after pomodoro focus --- README.md | 3 ++- e2e/execution.spec.ts | 23 +++++++++++++++++++- shared/schemas/models.ts | 2 +- src/app/App.tsx | 15 +++++++------ src/db/sessionRepository.ts | 14 +++++++++--- src/pages/FocusPage.tsx | 6 +++--- src/styles/global.css | 1 + tests/session-repository.test.ts | 37 ++++++++++++++++++++++++++++++++ 8 files changed, 85 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0b97cc5..50c61bb 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V2 已形 - 独立分类管理 - 从任务或临时学习记录开始 `Stopwatch`(正计时)或 `Pomodoro`(番茄钟) - Focus Mode、全局迷你计时栏、暂停、恢复、跳过休息和结束 +- 番茄专注到时播放提示音;未开始休息时自动进入超时正计时并继续记录实际专注 - 全局番茄默认值,以及开始学习时独立覆盖本次专注/休息时长和每组轮数 - Focus 中可调整本次会话的后续番茄阶段,不影响当前阶段或其他会话 - 可配置提示音和浏览器通知 @@ -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..a46fbd6 100644 --- a/e2e/execution.spec.ts +++ b/e2e/execution.spec.ts @@ -71,13 +71,34 @@ 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(); + await expect(page.getByText("00:01", { exact: true })).toBeVisible(); + await page.clock.runFor(4_000); + await expect(page.getByText("00:05", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "开始休息" }).click(); await expect(page.getByText("休息", { exact: true })).toBeVisible(); await page.clock.runFor(61_000); await expect(page.getByRole("button", { name: "开始下一轮" })).toBeVisible(); }); +test("番茄专注到时会触发三声提示音", 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(); diff --git a/shared/schemas/models.ts b/shared/schemas/models.ts index 0c5377c..3703b50 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: "休眠结束时间不能早于开始时间" }); diff --git a/src/app/App.tsx b/src/app/App.tsx index ab64e09..43ecc6a 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -13,22 +13,22 @@ 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)} 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&&"AudioContext" in window)try{const audio=new AudioContext();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(.12,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{/* 浏览器阻止自动播放时仅保留页面提醒 */}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 +46,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