From 036b00022e83f1c9ed667e50b8af6fa03c897c26 Mon Sep 17 00:00:00 2001 From: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:42:42 +0400 Subject: [PATCH 1/5] feat: link the focus timer to screen time sessions Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/learning/Pomodoro.tsx | 134 ++++++- .../learning/ScreenTimeSettings.tsx | 58 +++ .../lib/__tests__/screen-time-adapter.test.ts | 346 ++++++++++++++++++ .../lib/__tests__/screen-time-link.test.ts | 202 ++++++++++ .../__tests__/screen-time-pomodoro.test.tsx | 144 ++++++++ apps/web/src/lib/screen-time/client.ts | 200 ++++++++++ apps/web/src/lib/screen-time/contract.ts | 83 +++++ apps/web/src/lib/server/screen-time.ts | 289 +++++++++++++++ apps/web/src/routeTree.gen.ts | 21 ++ .../web/src/routes/api/screen-time/session.ts | 54 +++ apps/web/src/views/studio/learn.tsx | 20 +- .../src/views/studio/personal-settings.tsx | 2 + docs/syllo-setup.md | 9 + 13 files changed, 1554 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/learning/ScreenTimeSettings.tsx create mode 100644 apps/web/src/lib/__tests__/screen-time-adapter.test.ts create mode 100644 apps/web/src/lib/__tests__/screen-time-link.test.ts create mode 100644 apps/web/src/lib/__tests__/screen-time-pomodoro.test.tsx create mode 100644 apps/web/src/lib/screen-time/client.ts create mode 100644 apps/web/src/lib/screen-time/contract.ts create mode 100644 apps/web/src/lib/server/screen-time.ts create mode 100644 apps/web/src/routes/api/screen-time/session.ts diff --git a/apps/web/src/components/learning/Pomodoro.tsx b/apps/web/src/components/learning/Pomodoro.tsx index dab57aa..64a8852 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,24 @@ 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 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: setLinkStatus, + }); useEffect(() => { try { const raw = localStorage.getItem(KEY); @@ -121,26 +176,86 @@ 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); + } + }, [loaded, linked, intent]); 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}