Repository navigation
feat(learn): start a screen time focus session with each focus period #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
036b000
85de0e9
2d108a6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Session>(initial); | ||
| const [loaded, setLoaded] = useState(false); | ||
| const [study, setStudy] = useState<StudyFocus | null>(null); | ||
| const [linked] = useScreenTimeLink(); | ||
| const [linkStatus, setLinkStatus] = useState<ScreenTimeStatus | null>(null); | ||
| const sessionRef = useRef(session); | ||
| sessionRef.current = session; | ||
| const syncRef = useRef<ReturnType<typeof createFocusSync> | 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 ( | ||
| <Context.Provider | ||
| value={{ session, focus, rest, toggle, reset, configure }} | ||
| > | ||
| {children} | ||
| </Context.Provider> | ||
| <StudyFocusContext.Provider value={setStudy}> | ||
| <Context.Provider | ||
| value={{ | ||
| session, | ||
| focus, | ||
| rest, | ||
| toggle, | ||
| reset, | ||
| configure, | ||
| linked, | ||
| linkStatus, | ||
| }} | ||
| > | ||
| {children} | ||
| </Context.Provider> | ||
| </StudyFocusContext.Provider> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * 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 ( | ||
| <section className="sy-pomodoro"> | ||
| <div className="sy-pomodoro-head"> | ||
| <h2>Pomodoro</h2> | ||
| <span>{session.mode === "focus" ? "Focus" : "Break"}</span> | ||
| <span | ||
| title={ | ||
| blocking | ||
| ? "Screen Time is blocking distractions" | ||
| : undefined | ||
| } | ||
| > | ||
| {session.mode === "focus" ? "Focus" : "Break"} | ||
| {blocking ? " · Screen Time" : ""} | ||
| </span> | ||
| </div> | ||
| <div | ||
| className="sy-timer-clock" | ||
|
|
@@ -157,6 +272,11 @@ export function Pomodoro() { | |
| Reset | ||
| </button> | ||
| </div> | ||
| {linked && runningFocus && linkStatus?.kind !== "ready" ? ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Show stop failures after pause or reset. If a stop request fails while Screen Time still has an active session, the provider stores the failure status. However, pause or reset makes Show actionable failure statuses after stop requests, including when the timer is paused or idle.
|
||
| <p className="sy-form-hint" role="status"> | ||
| {screenTimeMessage(linkStatus)} | ||
| </p> | ||
| ) : null} | ||
| <div className="sy-timer-settings"> | ||
| <label> | ||
| Focus · min | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { useCallback, useEffect, useState } from "react"; | ||
| import SettingsSection from "@/components/layout/SettingsSection"; | ||
| import { Switch } from "@/components/ui/switch"; | ||
| import { screenTimeApi, useScreenTimeLink } from "@/lib/screen-time/client"; | ||
| import { | ||
| screenTimeMessage, | ||
| type ScreenTimeStatus, | ||
| } from "@/lib/screen-time/contract"; | ||
|
|
||
| /** | ||
| * The Settings page row that links the Pomodoro timer to the Screen Time app | ||
| * on this computer. Reachability is reported under the toggle whether or not | ||
| * the link is on. | ||
| */ | ||
| export default function ScreenTimeSettings() { | ||
| const [linked, setLinked] = useScreenTimeLink(); | ||
| const [status, setStatus] = useState<ScreenTimeStatus | null>(null); | ||
| const check = useCallback(() => { | ||
| setStatus(null); | ||
| void screenTimeApi.status().then(setStatus); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent older checks from replacing the latest status. If the user selects “Check again” while another check runs, both requests can update Add a request-sequence guard before Based on learnings: flag async refresh handlers when older work can finish last and overwrite newer state.
|
||
| }, []); | ||
| useEffect(() => { | ||
| check(); | ||
| }, [check]); | ||
| return ( | ||
| <SettingsSection | ||
| title="Screen Time" | ||
| description={ | ||
| <> | ||
| Block distracting apps and sites while a focus period runs. | ||
| Choose what stays allowed in Screen Time; Syllo and your | ||
| lesson’s links always stay open. This works only when Syllo | ||
| runs on the same computer as Screen Time, not from a hosted | ||
| Syllo. | ||
| </> | ||
| } | ||
| > | ||
| <div className="flex items-start justify-between gap-4"> | ||
| <p className="text-sm text-text"> | ||
| Start a Screen Time session with each focus period | ||
| </p> | ||
| <Switch | ||
| aria-label="Link Screen Time to focus periods" | ||
| checked={linked} | ||
| onCheckedChange={setLinked} | ||
| /> | ||
| </div> | ||
| <p className="sy-form-hint" role="status"> | ||
| {screenTimeMessage(status)} | ||
| </p> | ||
| <div className="sy-card-actions"> | ||
| <button className="sy-button quiet" onClick={check}> | ||
| Check again | ||
| </button> | ||
| </div> | ||
| </SettingsSection> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.