diff --git a/apps/web/src/components/learning/Pomodoro.tsx b/apps/web/src/components/learning/Pomodoro.tsx index dab57aa..cdecbf8 100644 --- a/apps/web/src/components/learning/Pomodoro.tsx +++ b/apps/web/src/components/learning/Pomodoro.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, + useMemo, useRef, useState, type ReactNode, @@ -12,6 +13,18 @@ import { putPersonalEntry, usePersonalData, } from "@/lib/personal/store"; +import { + createFocusSync, + focusIntent, + screenTimeApi, + useScreenTimeLink, + type StudyFocus, +} from "@/lib/screen-time/client"; +import { + SCREEN_TIME_SOURCE, + screenTimeMessage, + type ScreenTimeStatus, +} from "@/lib/screen-time/contract"; const sessionSchema = z.object({ mode: z.enum(["focus", "break"]), @@ -27,9 +40,35 @@ const Context = createContext<{ toggle: () => void; reset: () => void; configure: (kind: "focus" | "rest", value: number) => void; + linked: boolean; + linkStatus: ScreenTimeStatus | null; } | null>(null); +const StudyFocusContext = createContext< + ((focus: StudyFocus | null) => void) | null +>(null); const KEY = "syllo:pomodoro-session"; +/** + * The lesson page reports the open lesson through this hook so a running focus + * period can name its Screen Time session and keep the lesson's sites allowed. + * The setter lives outside the timer context so ticks never re-render the page, + * and the last lesson stays the goal after the page unmounts. + */ +export function useStudyFocus(focus: StudyFocus | null) { + const setStudy = useContext(StudyFocusContext); + const key = focus ? [focus.goal, ...focus.resourceUrls].join("\n") : ""; + const focusRef = useRef(focus); + focusRef.current = focus; + useEffect(() => { + setStudy?.(focusRef.current); + }, [key, setStudy]); +} + +/** + * Owns the timer state for the whole app and mirrors it to Screen Time when + * the user linked the two. The sync only sends on real intent changes, never + * on the per-second tick. + */ export default function PomodoroProvider({ children, }: { @@ -41,8 +80,31 @@ export default function PomodoroProvider({ const rest = entry?.kind === "timer" ? entry.data.rest : 5; const [session, setSession] = useState(initial); const [loaded, setLoaded] = useState(false); + const [study, setStudy] = useState(null); + const [linked] = useScreenTimeLink(); + const [linkStatus, setLinkStatus] = useState(null); + const [tracking, setTracking] = useState(false); const sessionRef = useRef(session); sessionRef.current = session; + const syncRef = useRef | null>(null); + if (syncRef.current === null) + syncRef.current = createFocusSync({ + send: (intent) => + intent.kind === "start" + ? screenTimeApi.start({ + goal: intent.goal, + durationSeconds: intent.durationSeconds, + resourceUrls: intent.resourceUrls, + }) + : screenTimeApi.stop(), + onStatus: (status) => { + setLinkStatus(status); + setTracking( + status.kind === "ready" && + status.session?.source === SCREEN_TIME_SOURCE, + ); + }, + }); useEffect(() => { try { const raw = localStorage.getItem(KEY); @@ -121,26 +183,123 @@ export default function PomodoroProvider({ endAt: null, }); } + // Ticks only change `remaining`, so this intent identity stays put between + // real state changes and the effect below never re-sends on a tick. + const intent = useMemo( + () => + focusIntent( + { mode: session.mode, endAt: session.endAt }, + study, + ), + [session.mode, session.endAt, study], + ); + useEffect(() => { + if (!loaded) return; + const sync = syncRef.current; + if (!sync) return; + if (linked) sync.update(intent); + else { + sync.release(); + setLinkStatus(null); + setTracking(false); + } + }, [loaded, linked, intent]); + const runningFocus = session.mode === "focus" && session.endAt !== null; + // While our session is believed on, watch for the user ending it in Screen + // Time so the label drops instead of claiming a block that is not there. + useEffect(() => { + if (!linked || !runningFocus || !tracking) return; + const sync = syncRef.current; + if (!sync) return; + let cancelled = false; + const poll = async () => { + const before = sync.version(); + const result = await screenTimeApi.status(); + if (cancelled || sync.version() !== before) return; + if ( + result.kind === "ready" && + result.session?.source === SCREEN_TIME_SOURCE + ) { + setLinkStatus(result); + } else if (result.kind === "ready") { + sync.markEnded(); + setTracking(false); + setLinkStatus(result); + } else { + setLinkStatus(result); + } + }; + const timer = setInterval(() => void poll(), 20_000); + const onVisible = () => { + if (document.visibilityState === "visible") void poll(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + cancelled = true; + clearInterval(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [linked, runningFocus, tracking]); return ( - - {children} - + + + {children} + + ); } +/** + * The timer card on the lesson page. When the Screen Time link is on it shows + * whether the local app is blocking distractions, and otherwise stays a plain + * timer. + */ export function Pomodoro() { const timer = useContext(Context); if (!timer) return null; - const { session, focus, rest, toggle, reset, configure } = timer; + const { + session, + focus, + rest, + toggle, + reset, + configure, + linked, + linkStatus, + } = timer; + const runningFocus = session.mode === "focus" && session.endAt !== null; + const blocking = + linked && + runningFocus && + linkStatus?.kind === "ready" && + linkStatus.session?.source === SCREEN_TIME_SOURCE; const minutes = String(Math.floor(session.remaining / 60)).padStart(2, "0"); const seconds = String(session.remaining % 60).padStart(2, "0"); return (

Pomodoro

- {session.mode === "focus" ? "Focus" : "Break"} + + {session.mode === "focus" ? "Focus" : "Break"} + {blocking ? " · Screen Time" : ""} +
+ {linked && runningFocus && linkStatus?.kind !== "ready" ? ( +

+ {screenTimeMessage(linkStatus)} +

+ ) : null}