From 0163218487a6a3d8b3d83c77d557568768a5b0eb Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:14:32 +0700 Subject: [PATCH 1/3] feat: open the extension in a 400x600 popup with an open-full-page action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The extension's toolbar button now opens the app in a popup, with a way to open the full page. ### Popup size: 400 × 600 Chrome caps extension popups at 800 × 600 and the app switches to its phone layout below 820 px, so any popup uses the phone layout. 400 wide matches the ~390 px phone width the layout was designed at (the bottom nav gets 376 px); 600 is the tallest Chrome allows. At that size the stock phone layout was 774 px tall with **Start Focus hidden behind the bottom nav**, so the popup gets a compact variant (`popup:` Tailwind variant, `data-view="popup"` on popup.html): - timer artwork 160 px instead of 256, top padding 3rem instead of 4rem, desktop nav clearance → the idle and running timer both fit in exactly 600 px with no scrollbar; - the floating menu button (which covered Pause while running) moves into the top bar and opens downward. ### Open full page ⤢ button in the popup's top bar and a new **Alt+Enter** shortcut ("Open full page (extension popup)", rebindable) ask the background worker to focus the existing app tab or open one, then close the popup. ### The timer survives the popup closing A popup is destroyed whenever it loses focus. Previously a reopened app restored a running session as **paused**, and its end notification never fired. - While a focus session or break runs, the app records `{ kind, sessionId, endAt, totalMs, goal }` (extension only) and the background worker sets a `chrome.alarms` alarm for `endAt`. - Reopening continues the countdown; if `endAt` has passed it marks the session complete at its real end time and shows the completion state. - The background worker raises the "Focus session complete" / "Break is over" notification when the alarm fires (fresh id each time) and opens the app on click. Pages in the extension no longer notify themselves, so there are no duplicates. - Pause, Done, End and Reset clear the record and cancel the alarm. Manifest gains the `alarms` permission and `default_popup: popup.html`. - The web app is unchanged. ## Testing - Lint, typecheck, unit tests and build pass. - Built extension served under the MV3 policy (`script-src 'self'`) at 400 × 600 with a stubbed `chrome.runtime`: - idle: artwork 160 px, Start Focus 414–462 px and Switch to Break above the nav (514 px); no scrollbar; - running: page exactly 600 px, Pause/Done/Reset clear of the menu (now top-right); - Start → `schedule-end` at +120 s; Pause → `cancel-end`; Resume → `schedule-end` at +119 s; - reload while running (= reopening the popup) continues at 00:57 instead of pausing; - reload after `endAt` → "Session complete!", session saved as completed at `endAt`, record cleared; - ⤢ and Alt+Enter → `open-app` and the popup closes. - Not run: a real Chrome with the unpacked extension (this machine's Chrome ignores command-line extension loading), so the background alarm and notification were reviewed, not executed. --- README.md | 4 +- extension/background.js | 51 ++++++++++++++--- scripts/build-extension.mjs | 14 ++++- src/app/globals.css | 12 ++++ src/components/BoostApp.tsx | 14 +++-- src/components/focus/FocusTimer.tsx | 54 +++++++++++++++++- src/components/focus/notifications.ts | 71 +++++------------------- src/components/hotkeys/bindings.ts | 1 + src/components/icons.ts | 1 + src/components/lib/extension.ts | 79 +++++++++++++++++++++++++++ src/components/shell/StartMenu.tsx | 23 ++++++-- src/components/shell/TopBar.tsx | 21 ++++++- 12 files changed, 262 insertions(+), 83 deletions(-) create mode 100644 src/components/lib/extension.ts diff --git a/README.md b/README.md index bdcbfbe..e742811 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,10 @@ This writes an unpacked extension to `dist/extension/` and a zip to `dist/boost- 1. Open `chrome://extensions` (or `edge://extensions`) and turn on **Developer mode**. 2. Click **Load unpacked** and select `dist/extension`. -3. Click the toolbar icon to open the timer in a tab. Clicking again focuses the existing tab instead of opening a second timer. +3. Click the toolbar icon to open the timer in a **400 × 600 popup** (a compact layout of the same app). Use the ⤢ button in its top bar, or Alt+Enter, to open the **full page** in a tab; an existing tab is focused instead of opening a second one. 4. Optional: assign a global shortcut for **Open Boost Focus Timer** at `chrome://extensions/shortcuts`. -Extension data lives in the extension's own storage, separate from the web app's. Firefox is not supported yet. +A session keeps running when the popup closes: reopening it continues the countdown, and the extension shows the "Focus session complete" / "Break is over" notification even while the popup is closed (the end chime only plays if the app is open). Popup and full page share the same data, which lives in the extension's own storage, separate from the web app's. Firefox is not supported yet. ## Keyboard shortcuts diff --git a/extension/background.js b/extension/background.js index 992d06b..d532c57 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,18 +1,53 @@ -// Toolbar button (and its optional keyboard shortcut): focus the open timer tab, or open one. -// A single tab matters because two running timers would race on the same local data. +// Background worker. The toolbar button opens the popup (manifest `default_popup`); this worker +// opens the full-page tab on request and raises the end-of-session notification, because the +// popup is destroyed whenever it loses focus and cannot wait for a timer itself. const APP_URL = chrome.runtime.getURL("index.html"); +const ICON_URL = chrome.runtime.getURL("icons/icon-128.png"); +const END_ALARM = "session-end"; +const PENDING_KEY = "pendingNotification"; -async function findAppTab() { +/** One app tab only: two running timers would race on the same local data. */ +async function openApp() { const contexts = await chrome.runtime.getContexts({ contextTypes: ["TAB"], documentUrls: [APP_URL] }); - return contexts.find((context) => context.tabId !== -1) ?? null; -} - -chrome.action.onClicked.addListener(async () => { - const existing = await findAppTab(); + const existing = contexts.find((context) => context.tabId !== -1); if (!existing) { await chrome.tabs.create({ url: APP_URL }); return; } await chrome.tabs.update(existing.tabId, { active: true }); await chrome.windows.update(existing.windowId, { focused: true }); +} + +async function scheduleEnd({ at, title, body }) { + await chrome.storage.session.set({ [PENDING_KEY]: { title, body } }); + await chrome.alarms.create(END_ALARM, { when: at }); +} + +async function cancelEnd() { + await chrome.alarms.clear(END_ALARM); + await chrome.storage.session.remove(PENDING_KEY); +} + +chrome.runtime.onMessage.addListener((message) => { + const handlers = { "open-app": openApp, "schedule-end": scheduleEnd, "cancel-end": cancelEnd }; + handlers[message?.type]?.(message).catch((error) => console.error(`Failed to handle ${message.type}:`, error)); +}); + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== END_ALARM) return; + const { [PENDING_KEY]: pending } = await chrome.storage.session.get(PENDING_KEY); + if (!pending) return; + await chrome.storage.session.remove(PENDING_KEY); + // A fresh id per notification: reusing one swaps the text in silently without a pop-up. + await chrome.notifications.create(`session-end-${Date.now()}`, { + type: "basic", + iconUrl: ICON_URL, + title: pending.title, + message: pending.body, + }); +}); + +chrome.notifications.onClicked.addListener((id) => { + chrome.notifications.clear(id); + openApp().catch((error) => console.error("Failed to open the app:", error)); }); diff --git a/scripts/build-extension.mjs b/scripts/build-extension.mjs index 9209a01..340d343 100644 --- a/scripts/build-extension.mjs +++ b/scripts/build-extension.mjs @@ -63,6 +63,13 @@ function externalizeInlineScripts() { } } +/** The toolbar popup is the same page, marked so the app switches to its compact 400x600 layout. */ +function writePopupPage() { + const html = readFileSync(join(EXTENSION_DIR, "index.html"), "utf8"); + if (!html.includes(" tag"); + writeFileSync(join(EXTENSION_DIR, "popup.html"), html.replace(/)/, ' [String(size), `icons/icon-${size}.png`])); const manifest = { @@ -72,9 +79,9 @@ function writeManifest() { version, description, icons, - // Lets the timer page show session-complete notifications without a prompt. - permissions: ["notifications"], - action: { default_title: "Open Boost Focus Timer", default_icon: icons }, + // notifications + alarms: the background worker announces a session's end even with the popup closed. + permissions: ["notifications", "alarms"], + action: { default_title: "Boost Focus Timer", default_icon: icons, default_popup: "popup.html" }, background: { service_worker: "background.js" }, // Unassigned by default; users pick a key at chrome://extensions/shortcuts. commands: { _execute_action: { description: "Open Boost Focus Timer" } }, @@ -98,6 +105,7 @@ function zipExtension() { exportStaticSite(); renameReservedPaths(); externalizeInlineScripts(); +writePopupPage(); writeManifest(); const zipPath = zipExtension(); if (!existsSync(join(EXTENSION_DIR, "index.html"))) throw new Error("Static export produced no index.html"); diff --git a/src/app/globals.css b/src/app/globals.css index 104c74c..ac9a6f6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,6 +6,8 @@ * Classes v4 dropped (bg-opacity-*, flex-shrink-*, ...) must be rewritten in markup. */ @custom-variant wide (@media (min-width: 820px)); +/* The extension's toolbar popup: popup.html carries data-view="popup" (see scripts/build-extension.mjs). */ +@custom-variant popup (&:where([data-view="popup"] *)); @theme { --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", @@ -258,6 +260,16 @@ body { background-color: #111827; } + + /* + * Chrome caps extension popups at 800x600 and sizes them to the document. 400 wide matches the + * phone layout the app was designed at; 600 is the tallest Chrome allows. + */ + html[data-view="popup"], + html[data-view="popup"] body { + width: 400px; + height: 600px; + } } @utility force-scrollbar { diff --git a/src/components/BoostApp.tsx b/src/components/BoostApp.tsx index 7312b9a..601daa5 100644 --- a/src/components/BoostApp.tsx +++ b/src/components/BoostApp.tsx @@ -7,6 +7,7 @@ import { FocusTimer } from "./focus/FocusTimer"; import { askForNotificationsOnFirstOpen } from "./focus/notifications"; import { HotkeysProvider, useHotkey, useHotkeyMap } from "./hotkeys/HotkeysProvider"; import { databaseService } from "./lib/database-service"; +import { isPopupView } from "./lib/extension"; import { getLocalUserId } from "./lib/local-db"; import { getDisplayName, saveDisplayName } from "./lib/profile"; import { BottomNav } from "./shell/BottomNav"; @@ -55,6 +56,7 @@ function AppShell() { const timerRef = useRef(null); const isMobile = useMediaQuery(MOBILE_QUERY); const isShortViewport = useMediaQuery(SHORT_VIEWPORT_QUERY); + const [isPopup] = useState(isPopupView); useEffect(() => askForNotificationsOnFirstOpen(), []); @@ -103,9 +105,13 @@ function AppShell() { setPendingTodo(null); }; - const navHeight = isMobile ? "8rem" : MOBILE_NAV_OFFSET; - const paddingBottom = - tab === "timer" && !isShortViewport && !isMobile ? "0.25rem" : `calc(1rem + ${navHeight} + env(safe-area-inset-bottom, 0px))`; + // The popup keeps the bottom nav but has no phone home-indicator area, so it needs the desktop clearance. + const navHeight = isMobile && !isPopup ? "8rem" : MOBILE_NAV_OFFSET; + const paddingBottom = isPopup + ? navHeight + : tab === "timer" && !isShortViewport && !isMobile + ? "0.25rem" + : `calc(1rem + ${navHeight} + env(safe-area-inset-bottom, 0px))`; return ( <> @@ -117,7 +123,7 @@ function AppShell() { className={`bg-black wide:border wide:border-gray-800 wide:rounded-3xl wide:shadow-2xl p-4 sm:p-8 w-full max-w-2xl mx-auto ${ tab === "timer" ? "mt-auto" : "wide:mt-auto" }`} - style={{ paddingTop: isMobile ? "4rem" : undefined }} + style={{ paddingTop: isPopup ? "3rem" : isMobile ? "4rem" : undefined }} > {/* Kept mounted while hidden so a running session survives tab switches. */}
diff --git a/src/components/focus/FocusTimer.tsx b/src/components/focus/FocusTimer.tsx index 8f46455..bbae7ba 100644 --- a/src/components/focus/FocusTimer.tsx +++ b/src/components/focus/FocusTimer.tsx @@ -3,6 +3,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type ReactNode } from "react"; import { CheckCircle2, Clock, Pause, Play, RotateCcw, Target, X } from "../icons"; import { databaseService, type FocusSession, type Tag } from "../lib/database-service"; +import { clearRunningTimer, readRunningTimer, trackRunningTimer, type RunningTimer } from "../lib/extension"; import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import type { FocusTimerHandle, FocusTimerProps } from "../types"; import { AnimationPicker } from "./AnimationPicker"; @@ -202,9 +203,48 @@ export const FocusTimer = forwardRef(function databaseService.updateUserSettings({ auto_shuffle_animations: next }).catch(logError("Failed to save user settings:")); }; + /** + * Extension only: the popup closes whenever it loses focus, so a countdown that was running when it + * closed keeps running (or has finished) instead of coming back paused. + */ + const continueRunningTimer = async (running: RunningTimer, latest: FocusSession): Promise => { + if (latest.id !== running.sessionId || latest.completed) return false; + const remaining = running.endAt - Date.now(); + const startedAt = running.endAt - running.totalMs; + if (running.kind === "focus") { + setGoal(latest.goal); + setInputMinutes(String(latest.planned_duration_minutes)); + setHasStarted(true); + setFocusTotalMs(running.totalMs); + if (remaining > 0) { + setSession(latest); + setFocusStart(startedAt); + setFocus({ ...IDLE, ...splitMs(remaining), isRunning: true }); + } else { + setSession(await databaseService.updateFocusSession(latest.id, { end_time: new Date(running.endAt).toISOString(), completed: true })); + setFocus({ ...IDLE, isCompleted: true }); + } + return true; + } + if (remaining > 0) { + setBreakSession(latest); + setIsOnBreak(true); + setHasStartedBreak(true); + setBreakTotalMs(running.totalMs); + setBreakStart(startedAt); + setBreakTimer({ ...IDLE, ...splitMs(remaining), isRunning: true }); + } else { + await databaseService.updateFocusSession(latest.id, { end_time: new Date(running.endAt).toISOString(), completed: true }); + setBreakTimer({ ...IDLE, isCompleted: true }); + } + return true; + }; + useEffect(() => { const restore = async () => { const [latest] = await databaseService.getUserFocusSessions(1); + const running = readRunningTimer(); + if (running && latest && (await continueRunningTimer(running, latest))) return; if (!latest || latest.completed || Date.now() - new Date(latest.created_at).getTime() >= RESTORE_WINDOW_MS) return; const restored = await restoreSession(latest); setSession(latest); @@ -220,6 +260,18 @@ export const FocusTimer = forwardRef(function .finally(() => setIsInitialized(true)); }, []); + // Keep the extension's running-timer record (and its background alarm) in step with the countdowns. + useEffect(() => { + if (!isInitialized) return; + if (focus.isRunning && focusStart !== null && session) { + trackRunningTimer({ kind: "focus", sessionId: session.id, endAt: focusStart + focusTotalMs, totalMs: focusTotalMs, goal }); + } else if (breakTimer.isRunning && breakStart !== null && breakSession) { + trackRunningTimer({ kind: "break", sessionId: breakSession.id, endAt: breakStart + breakTotalMs, totalMs: breakTotalMs, goal: "" }); + } else { + clearRunningTimer(); + } + }, [isInitialized, focus.isRunning, focusStart, focusTotalMs, session, goal, breakTimer.isRunning, breakStart, breakTotalMs, breakSession]); + const resetAll = () => { setFocus(IDLE); setBreakTimer(IDLE); @@ -542,7 +594,7 @@ export const FocusTimer = forwardRef(function type={selectedAnimation} frame={currentFrame(selectedAnimation, frameState)} label={`${metaOf(selectedAnimation).title} timer`} - className="w-64 h-64 sm:w-96 sm:h-96 mx-auto" + className="w-64 h-64 sm:w-96 sm:h-96 popup:w-40 popup:h-40 mx-auto" />
diff --git a/src/components/focus/notifications.ts b/src/components/focus/notifications.ts index 75e3f87..f505ce2 100644 --- a/src/components/focus/notifications.ts +++ b/src/components/focus/notifications.ts @@ -1,41 +1,18 @@ /* - * The browser permission is the only switch: the app asks Chrome directly on first open. - * Inside the extension the manifest grants it, so there is nothing to ask. + * The browser permission is the only switch: the app asks the browser directly on first open. + * Inside the extension the manifest grants it and the background worker shows the + * end-of-session notifications (see lib/extension.ts), so pages there never notify themselves. */ +import { isExtensionPage } from "../lib/extension"; -const ICON_PATH = "icons/icon-128.png"; -/* - * Each notification gets a fresh id and the previous one is closed first. Reusing a tag/id - * would make the browser swap the text into the old notification silently, without a pop-up. - */ -const NOTIFICATION_ID_PREFIX = "boost-focus-timer"; - -/** The slice of the extension `chrome` API used here; the web build never has it. */ -interface ExtensionApi { - runtime: { id?: string; getURL(path: string): string }; - notifications: { - create(id: string, options: { type: "basic"; iconUrl: string; title: string; message: string; silent?: boolean }): Promise; - clear(id: string): Promise; - onClicked: { addListener(listener: (id: string) => void): void }; - }; - tabs: { - getCurrent(): Promise<{ id?: number; windowId?: number } | undefined>; - update(tabId: number, properties: { active: boolean }): Promise; - }; - windows: { update(windowId: number, properties: { focused: boolean }): Promise }; -} - -function extensionApi(): ExtensionApi | null { - const candidate = (globalThis as { chrome?: Partial }).chrome; - return candidate?.runtime?.id && candidate.notifications ? (candidate as ExtensionApi) : null; -} +const ICON_PATH = "/icons/icon-128.png"; const hasWebNotifications = (): boolean => typeof window !== "undefined" && "Notification" in window; export type NotificationStatus = "unsupported" | NotificationPermission; export function notificationStatus(): NotificationStatus { - if (extensionApi()) return "granted"; + if (isExtensionPage()) return "granted"; return hasWebNotifications() ? Notification.permission : "unsupported"; } @@ -82,37 +59,15 @@ export function askForNotificationsOnFirstOpen(): () => void { }; } -let clickHandlerInstalled = false; -let previousExtensionId: string | null = null; -let previousWebNotification: Notification | null = null; - -/** Clicking an extension notification brings the timer tab and its window to the front. */ -function installExtensionClickHandler(api: ExtensionApi): void { - if (clickHandlerInstalled) return; - clickHandlerInstalled = true; - api.notifications.onClicked.addListener(async () => { - const tab = await api.tabs.getCurrent(); - if (tab?.id !== undefined) await api.tabs.update(tab.id, { active: true }); - if (tab?.windowId !== undefined) await api.windows.update(tab.windowId, { focused: true }); - }); -} +// The previous notification is closed first: a reused tag would swap the text in silently, without a pop-up. +let previousNotification: Notification | null = null; function show(title: string, body: string): void { - const api = extensionApi(); try { - if (api) { - installExtensionClickHandler(api); - if (previousExtensionId) void api.notifications.clear(previousExtensionId); - const id = `${NOTIFICATION_ID_PREFIX}-${Date.now()}`; - previousExtensionId = id; - const options = { type: "basic" as const, iconUrl: api.runtime.getURL(ICON_PATH), title, message: body, silent: true }; - api.notifications.create(id, options).catch((error: unknown) => console.error("Could not show notification:", error)); - return; - } - previousWebNotification?.close(); + previousNotification?.close(); // Silent because the end bell already plays; the notification is the visual cue. - const notification = new Notification(title, { body, icon: `/${ICON_PATH}`, silent: true }); - previousWebNotification = notification; + const notification = new Notification(title, { body, icon: ICON_PATH, silent: true }); + previousNotification = notification; notification.onclick = () => { window.focus(); notification.close(); @@ -122,7 +77,7 @@ function show(title: string, body: string): void { } } -/** Shows a notification when the browser allows it. */ +/** Shows a notification in the web app when the browser allows it; the extension's background worker handles its own. */ export function notify(title: string, body: string): void { - if (notificationStatus() === "granted") show(title, body); + if (!isExtensionPage() && notificationStatus() === "granted") show(title, body); } diff --git a/src/components/hotkeys/bindings.ts b/src/components/hotkeys/bindings.ts index ca64b7e..8eb5f7e 100644 --- a/src/components/hotkeys/bindings.ts +++ b/src/components/hotkeys/bindings.ts @@ -32,6 +32,7 @@ export const HOTKEY_ACTIONS: readonly HotkeyAction[] = [ { id: "boost.attach", group: "Lists", label: "Attach media to post", defaultCombo: "Alt+U" }, { id: "app.displayName", group: "App", label: "Edit display name", defaultCombo: "Alt+Shift+N" }, + { id: "app.fullPage", group: "App", label: "Open full page (extension popup)", defaultCombo: "Alt+Enter" }, { id: "app.shortcuts", group: "App", label: "Show or hide this list", defaultCombo: "Ctrl+/" }, ]; diff --git a/src/components/icons.ts b/src/components/icons.ts index 9971118..a5ad096 100644 --- a/src/components/icons.ts +++ b/src/components/icons.ts @@ -30,6 +30,7 @@ export const ListTodo = createIcon("ListTodo", [["rect",{x:"3",y:"5",width:"6",h export const List = createIcon("List", [["line",{x1:"8",x2:"21",y1:"6",y2:"6"}],["line",{x1:"8",x2:"21",y1:"12",y2:"12"}],["line",{x1:"8",x2:"21",y1:"18",y2:"18"}],["line",{x1:"3",x2:"3.01",y1:"6",y2:"6"}],["line",{x1:"3",x2:"3.01",y1:"12",y2:"12"}],["line",{x1:"3",x2:"3.01",y1:"18",y2:"18"}]]); export const Loader2 = createIcon("Loader2", [["path",{d:"M21 12a9 9 0 1 1-6.219-8.56"}]]); export const Lock = createIcon("Lock", [["rect",{width:"18",height:"11",x:"3",y:"11",rx:"2",ry:"2"}],["path",{d:"M7 11V7a5 5 0 0 1 10 0v4"}]]); +export const Maximize2 = createIcon("Maximize2", [["polyline",{points:"15 3 21 3 21 9"}],["polyline",{points:"9 21 3 21 3 15"}],["line",{x1:"21",x2:"14",y1:"3",y2:"10"}],["line",{x1:"3",x2:"10",y1:"21",y2:"14"}]]); export const Menu = createIcon("Menu", [["line",{x1:"4",x2:"20",y1:"12",y2:"12"}],["line",{x1:"4",x2:"20",y1:"6",y2:"6"}],["line",{x1:"4",x2:"20",y1:"18",y2:"18"}]]); export const MessageCircle = createIcon("MessageCircle", [["path",{d:"M7.9 20A9 9 0 1 0 4 16.1L2 22Z"}]]); export const MessageSquare = createIcon("MessageSquare", [["path",{d:"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"}]]); diff --git a/src/components/lib/extension.ts b/src/components/lib/extension.ts new file mode 100644 index 0000000..c43aa41 --- /dev/null +++ b/src/components/lib/extension.ts @@ -0,0 +1,79 @@ +/* + * Browser-extension glue. In the extension the app runs in a toolbar popup that is destroyed + * whenever it loses focus, so a running timer is recorded here and the background worker + * raises the end-of-session notification. Every function is a no-op in the web app. + */ + +const RUNNING_TIMER_KEY = "bft:runningTimer"; + +interface ExtensionRuntime { + runtime: { id?: string; sendMessage(message: BackgroundMessage): Promise }; +} + +export type BackgroundMessage = + | { type: "open-app" } + | { type: "schedule-end"; at: number; title: string; body: string } + | { type: "cancel-end" }; + +function runtime(): ExtensionRuntime["runtime"] | null { + const candidate = (globalThis as { chrome?: Partial }).chrome; + return candidate?.runtime?.id ? candidate.runtime : null; +} + +export const isExtensionPage = (): boolean => runtime() !== null; + +/** The build marks popup.html with `data-view="popup"`; the full-page tab has no marker. */ +export const isPopupView = (): boolean => + typeof document !== "undefined" && document.documentElement.dataset.view === "popup"; + +function send(message: BackgroundMessage): void { + runtime() + ?.sendMessage(message) + .catch((error: unknown) => console.error(`Background message "${message.type}" failed:`, error)); +} + +/** Opens (or focuses) the full-page app tab and closes the popup. */ +export function openFullPage(): void { + send({ type: "open-app" }); + if (isPopupView()) window.close(); +} + +export interface RunningTimer { + readonly kind: "focus" | "break"; + readonly sessionId: string; + /** Epoch ms when the countdown reaches zero. */ + readonly endAt: number; + readonly totalMs: number; + readonly goal: string; +} + +/** + * Records the running countdown so a reopened popup continues it, and has the background + * worker notify at `endAt` even when no app page is open. + */ +export function trackRunningTimer(timer: RunningTimer): void { + if (!isExtensionPage()) return; + localStorage.setItem(RUNNING_TIMER_KEY, JSON.stringify(timer)); + const notification = + timer.kind === "focus" + ? { title: "Focus session complete", body: `${timer.goal.trim() || "Nice work"} · time for a break.` } + : { title: "Break is over", body: "Ready for the next focus session?" }; + send({ type: "schedule-end", at: timer.endAt, ...notification }); +} + +export function clearRunningTimer(): void { + if (!isExtensionPage() || localStorage.getItem(RUNNING_TIMER_KEY) === null) return; + localStorage.removeItem(RUNNING_TIMER_KEY); + send({ type: "cancel-end" }); +} + +export function readRunningTimer(): RunningTimer | null { + if (!isExtensionPage()) return null; + try { + const stored = localStorage.getItem(RUNNING_TIMER_KEY); + return stored ? (JSON.parse(stored) as RunningTimer) : null; + } catch (error) { + console.error("Unreadable running timer record:", error); + return null; + } +} diff --git a/src/components/shell/StartMenu.tsx b/src/components/shell/StartMenu.tsx index c808c82..56ff006 100644 --- a/src/components/shell/StartMenu.tsx +++ b/src/components/shell/StartMenu.tsx @@ -5,6 +5,7 @@ import { notificationStatus, requestNotificationPermission, type NotificationSta import { Bell, ChevronDown, CircleUser, Menu, Settings, Tag, Volume2 } from "../icons"; import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import type { Tab } from "../types"; +import { isPopupView } from "../lib/extension"; import { SoundsPanel } from "./SoundsPanel"; import { MOBILE_NAV_OFFSET, MOBILE_QUERY, useMediaQuery } from "./use-media-query"; import { useDialog } from "../dialogs/DialogProvider"; @@ -56,6 +57,9 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { const [isSoundsOpen, setIsSoundsOpen] = useState(false); const menuRef = useRef(null); const isMobile = useMediaQuery(MOBILE_QUERY); + // In the 400x600 popup a floating bottom-left button would cover the timer controls, + // so the menu moves into the top bar and opens downward. + const [isPopup] = useState(isPopupView); useHotkey("nav.menu", () => setIsOpen((open) => !open)); const menuCombo = useHotkeyLabel("nav.menu"); @@ -74,11 +78,18 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { }; const offset = isMobile ? ` + ${MOBILE_NAV_OFFSET}` : ""; + const position = isPopup + ? { top: "max(env(safe-area-inset-top), 1rem)", right: "1rem" } + : { bottom: `calc(1rem${offset} + env(safe-area-inset-bottom, 0px))`, left: "1rem" }; return ( -
+
{isOpen ? ( -
+
{isSoundsOpen ? setIsSoundsOpen(false)} /> : null}
diff --git a/src/components/shell/TopBar.tsx b/src/components/shell/TopBar.tsx index 5866b36..dacf7cd 100644 --- a/src/components/shell/TopBar.tsx +++ b/src/components/shell/TopBar.tsx @@ -1,8 +1,9 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { useHotkeyLabel } from "../hotkeys/HotkeysProvider"; -import { BarChart3, ChevronDown, Pencil, User } from "../icons"; +import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; +import { BarChart3, ChevronDown, Maximize2, Pencil, User } from "../icons"; +import { isPopupView, openFullPage } from "../lib/extension"; const SAFE_TOP = "max(env(safe-area-inset-top), 1rem)"; @@ -17,6 +18,9 @@ export function TopBar({ displayName, displayTime, onEditDisplayName, onOpenHist const [isDropdownOpen, setIsDropdownOpen] = useState(false); const dropdownRef = useRef(null); const historyCombo = useHotkeyLabel("nav.history"); + const fullPageCombo = useHotkeyLabel("app.fullPage"); + const [isPopup] = useState(isPopupView); + useHotkey("app.fullPage", openFullPage, isPopup); useEffect(() => { if (!isDropdownOpen) return; @@ -57,7 +61,18 @@ export function TopBar({ displayName, displayTime, onEditDisplayName, onOpenHist
)}
-
+ {/* In the popup the menu button takes the top-right corner (see StartMenu). */} +
+ {isPopup ? ( + + ) : null} + ) : null} ) : null} diff --git a/src/components/shell/TopBar.tsx b/src/components/shell/TopBar.tsx index 4b3249e..f0f4b26 100644 --- a/src/components/shell/TopBar.tsx +++ b/src/components/shell/TopBar.tsx @@ -1,9 +1,9 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; -import { BarChart3, ChevronDown, Maximize2, Pencil, User } from "../icons"; -import { isPopupView, openFullPage } from "../lib/extension"; +import { useHotkeyLabel } from "../hotkeys/HotkeysProvider"; +import { BarChart3, ChevronDown, Pencil, User } from "../icons"; +import { isPopupView } from "../lib/extension"; const SAFE_TOP = "max(env(safe-area-inset-top), 1rem)"; @@ -18,9 +18,7 @@ export function TopBar({ displayName, displayTime, onEditDisplayName, onOpenHist const [isDropdownOpen, setIsDropdownOpen] = useState(false); const dropdownRef = useRef(null); const historyCombo = useHotkeyLabel("nav.history"); - const fullPageCombo = useHotkeyLabel("app.fullPage"); const [isPopup] = useState(isPopupView); - useHotkey("app.fullPage", () => void openFullPage(), isPopup); useEffect(() => { if (!isDropdownOpen) return; @@ -63,16 +61,6 @@ export function TopBar({ displayName, displayTime, onEditDisplayName, onOpenHist
{/* In the popup the menu button takes the top-right corner (see StartMenu). */}
- {isPopup ? ( - - ) : null}