From e0a114fb3dc2549b807081ff8566b3a34ea4fb0f Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:37:31 +0700 Subject: [PATCH] feat: confirm reset, shortcuts menu item, extension popup window --- README.md | 2 +- extension/background.js | 49 +++++++++++++++++++++- scripts/build-extension.mjs | 6 ++- src/app/globals.css | 10 ++--- src/components/focus/FocusTimer.tsx | 21 ++++++++-- src/components/hotkeys/HotkeysProvider.tsx | 7 +++- src/components/hotkeys/ShortcutsPanel.tsx | 10 +++-- src/components/icons.ts | 1 + src/components/lib/extension.ts | 6 +-- src/components/shell/StartMenu.tsx | 17 +++++++- 10 files changed, 102 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index dd56d80..1f06296 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ 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 **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. +3. Click the toolbar icon to open the timer in its own **popup window** (a compact layout of the same app) with a taskbar icon; it remembers its size and position. 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`. 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. diff --git a/extension/background.js b/extension/background.js index d532c57..96b39ef 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,7 +1,12 @@ -// Background worker. The toolbar button opens the popup (manifest `default_popup`); this worker +// Background worker. The toolbar button opens the compact app in a popup window; this worker also // 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. +// popup window may be closed while a timer runs. const APP_URL = chrome.runtime.getURL("index.html"); +const POPUP_URL = chrome.runtime.getURL("popup.html"); +const POPUP_SIZE = { width: 420, height: 640 }; +const POPUP_EDGE_MARGIN_PX = 16; +const POPUP_TOOLBAR_OFFSET_PX = 72; +const POPUP_BOUNDS_KEY = "popupBounds"; const ICON_URL = chrome.runtime.getURL("icons/icon-128.png"); const END_ALARM = "session-end"; const PENDING_KEY = "pendingNotification"; @@ -18,6 +23,38 @@ async function openApp() { await chrome.windows.update(existing.windowId, { focused: true }); } +async function popupContext() { + const [context] = await chrome.runtime.getContexts({ contextTypes: ["TAB"], documentUrls: [POPUP_URL] }); + return context ?? null; +} + +/** Top-right of the current browser window, under its toolbar, like the dropdown it replaces. */ +function defaultPopupBounds(browserWindow) { + return { + left: Math.max(0, browserWindow.left + browserWindow.width - POPUP_SIZE.width - POPUP_EDGE_MARGIN_PX), + top: Math.max(0, browserWindow.top + POPUP_TOOLBAR_OFFSET_PX), + ...POPUP_SIZE, + }; +} + +async function openPopup() { + const existing = await popupContext(); + if (existing) { + await chrome.windows.update(existing.windowId, { focused: true }); + return; + } + const { [POPUP_BOUNDS_KEY]: saved } = await chrome.storage.local.get(POPUP_BOUNDS_KEY); + const bounds = saved ?? defaultPopupBounds(await chrome.windows.getLastFocused()); + await chrome.windows.create({ url: POPUP_URL, type: "popup", focused: true, ...bounds }); +} + +async function rememberPopupBounds(browserWindow) { + const popup = await popupContext(); + if (popup?.windowId !== browserWindow.id) return; + const { left, top, width, height } = browserWindow; + await chrome.storage.local.set({ [POPUP_BOUNDS_KEY]: { left, top, width, height } }); +} + async function scheduleEnd({ at, title, body }) { await chrome.storage.session.set({ [PENDING_KEY]: { title, body } }); await chrome.alarms.create(END_ALARM, { when: at }); @@ -28,6 +65,14 @@ async function cancelEnd() { await chrome.storage.session.remove(PENDING_KEY); } +chrome.action.onClicked.addListener(() => { + openPopup().catch((error) => console.error("Failed to open the popup window:", error)); +}); + +chrome.windows.onBoundsChanged.addListener((browserWindow) => { + rememberPopupBounds(browserWindow).catch((error) => console.error("Failed to remember the popup size:", error)); +}); + 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)); diff --git a/scripts/build-extension.mjs b/scripts/build-extension.mjs index f3b500e..ec02451 100644 --- a/scripts/build-extension.mjs +++ b/scripts/build-extension.mjs @@ -63,7 +63,7 @@ function externalizeInlineScripts() { } } -/** The toolbar popup is the same page, marked so the app switches to its compact 400x600 layout. */ +/** The popup window 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"); @@ -82,7 +82,9 @@ function writeManifest() { // notifications + alarms: the background worker announces a session's end even with the popup closed; // storage: it keeps that notification's text in chrome.storage.session until the alarm fires. permissions: ["notifications", "alarms", "storage"], - action: { default_title: "Boost Focus Timer", default_icon: icons, default_popup: "popup.html" }, + // No default_popup: the background worker opens popup.html in its own window, which gets a + // taskbar entry with the extension icon and survives losing focus. + action: { default_title: "Boost Focus Timer", default_icon: icons }, background: { service_worker: "background.js" }, // Unassigned by default; users pick a key at chrome://extensions/shortcuts. commands: { _execute_action: { description: "Open Boost Focus Timer" } }, diff --git a/src/app/globals.css b/src/app/globals.css index ac9a6f6..1be73c9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,7 +6,7 @@ * 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). */ +/* The extension's popup window: popup.html carries data-view="popup" (see scripts/build-extension.mjs). */ @custom-variant popup (&:where([data-view="popup"] *)); @theme { @@ -261,14 +261,10 @@ 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. - */ + /* The popup is a resizable window (see extension/background.js), so it fills whatever size it has. */ html[data-view="popup"], html[data-view="popup"] body { - width: 400px; - height: 600px; + height: 100vh; } } diff --git a/src/components/focus/FocusTimer.tsx b/src/components/focus/FocusTimer.tsx index a21753f..a228831 100644 --- a/src/components/focus/FocusTimer.tsx +++ b/src/components/focus/FocusTimer.tsx @@ -209,7 +209,7 @@ export const FocusTimer = forwardRef(function }; /** - * Extension only: the popup closes whenever it loses focus, so a countdown that was running when it + * Extension only: the popup window can be closed mid-session, 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 => { @@ -303,6 +303,19 @@ export const FocusTimer = forwardRef(function rerollAnimation(); }; + // An unfinished session has no end time, so its minutes never reach today's total. + const confirmReset = async () => { + if (session && !session.completed) { + const confirmed = await dialog.confirm("Time focused so far won't count toward today.", { + title: "Reset this session?", + confirmLabel: "Reset", + destructive: true, + }); + if (!confirmed) return; + } + resetTimer(); + }; + const completeSession = async () => { if (!session || session.completed) return; try { @@ -541,7 +554,7 @@ export const FocusTimer = forwardRef(function useHotkey("timer.shuffle", toggleAutoShuffle, isActive && isIdle); useHotkey("timer.nextAnimation", selectNextAnimation, isActive && isIdle); const isSessionComplete = !isIdle && !isOnBreak && focus.isCompleted; - useHotkey("timer.reset", resetTimer, isActive && !isIdle && !isDirectBreak && !isOnBreak); + useHotkey("timer.reset", () => void confirmReset(), isActive && !isIdle && !isDirectBreak && !isOnBreak); useHotkey("timer.extend", () => void extendFocus(), isActive && isSessionComplete); const toggleCombo = useHotkeyLabel("timer.toggle"); const endCombo = useHotkeyLabel("timer.end"); @@ -744,7 +757,7 @@ export const FocusTimer = forwardRef(function Start Break - + void confirmReset()} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_BASE} px-4 py-3 bg-gray-500 hover:bg-gray-600`}> Reset @@ -766,7 +779,7 @@ export const FocusTimer = forwardRef(function Done - + void confirmReset()} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_SMALL} bg-gray-500 hover:bg-gray-600`}> Reset diff --git a/src/components/hotkeys/HotkeysProvider.tsx b/src/components/hotkeys/HotkeysProvider.tsx index c18c441..36bb876 100644 --- a/src/components/hotkeys/HotkeysProvider.tsx +++ b/src/components/hotkeys/HotkeysProvider.tsx @@ -20,6 +20,7 @@ interface HotkeysContextValue { rebind(actionId: string, combo: string | null): void; resetAll(): void; setCapturing(isCapturing: boolean): void; + openPanel(): void; } const HotkeysContext = createContext(null); @@ -90,9 +91,11 @@ export function HotkeysProvider({ children }: { children: ReactNode }) { return () => window.removeEventListener("keydown", onKeyDown); }, [bindings]); + const openPanel = useCallback(() => setIsPanelOpen(true), []); + const value = useMemo( - () => ({ bindings, register, rebind, resetAll, setCapturing }), - [bindings, register, rebind, resetAll, setCapturing], + () => ({ bindings, register, rebind, resetAll, setCapturing, openPanel }), + [bindings, register, rebind, resetAll, setCapturing, openPanel], ); return ( diff --git a/src/components/hotkeys/ShortcutsPanel.tsx b/src/components/hotkeys/ShortcutsPanel.tsx index cf817fc..8ccc32a 100644 --- a/src/components/hotkeys/ShortcutsPanel.tsx +++ b/src/components/hotkeys/ShortcutsPanel.tsx @@ -19,10 +19,11 @@ function isExtensionPage(): boolean { export function ShortcutsPanel({ actions, onClose }: { actions: readonly HotkeyAction[]; onClose(): void }) { const { bindings, rebind, resetAll, setCapturing } = useHotkeys(); const [editingId, setEditingId] = useState(null); - const doneRef = useRef(null); + const dialogRef = useRef(null); + // Focus lands on the dialog itself so no control shows a ring and Enter cannot close it by accident. useEffect(() => { - doneRef.current?.focus(); + dialogRef.current?.focus(); }, []); useEffect(() => { @@ -62,17 +63,18 @@ export function ShortcutsPanel({ actions, onClose }: { actions: readonly HotkeyA role="presentation" > Keyboard Shortcuts diff --git a/src/components/icons.ts b/src/components/icons.ts index 9124848..14bbcaa 100644 --- a/src/components/icons.ts +++ b/src/components/icons.ts @@ -24,6 +24,7 @@ export const Globe = createIcon("Globe", [["circle",{cx:"12",cy:"12",r:"10"}],[" export const GripVertical = createIcon("GripVertical", [["circle",{cx:"9",cy:"12",r:"1"}],["circle",{cx:"9",cy:"5",r:"1"}],["circle",{cx:"9",cy:"19",r:"1"}],["circle",{cx:"15",cy:"12",r:"1"}],["circle",{cx:"15",cy:"5",r:"1"}],["circle",{cx:"15",cy:"19",r:"1"}]]); export const Handshake = createIcon("Handshake", [["path",{d:"m11 17 2 2a1 1 0 1 0 3-3"}],["path",{d:"m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"}],["path",{d:"m21 3 1 11h-2"}],["path",{d:"M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"}],["path",{d:"M3 4h8"}]]); export const Hash = createIcon("Hash", [["line",{x1:"4",x2:"20",y1:"9",y2:"9"}],["line",{x1:"4",x2:"20",y1:"15",y2:"15"}],["line",{x1:"10",x2:"8",y1:"3",y2:"21"}],["line",{x1:"16",x2:"14",y1:"3",y2:"21"}]]); +export const Keyboard = createIcon("Keyboard", [["rect",{width:"20",height:"16",x:"2",y:"4",rx:"2"}],["path",{d:"M6 8h.001"}],["path",{d:"M10 8h.001"}],["path",{d:"M14 8h.001"}],["path",{d:"M18 8h.001"}],["path",{d:"M8 12h.001"}],["path",{d:"M12 12h.001"}],["path",{d:"M16 12h.001"}],["path",{d:"M7 16h10"}]]); export const Layers = createIcon("Layers", [["path",{d:"m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"}],["path",{d:"m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"}],["path",{d:"m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"}]]); export const LayoutGrid = createIcon("LayoutGrid", [["rect",{width:"7",height:"7",x:"3",y:"3",rx:"1"}],["rect",{width:"7",height:"7",x:"14",y:"3",rx:"1"}],["rect",{width:"7",height:"7",x:"14",y:"14",rx:"1"}],["rect",{width:"7",height:"7",x:"3",y:"14",rx:"1"}]]); export const LineChart = createIcon("LineChart", [["path",{d:"M3 3v18h18"}],["path",{d:"m19 9-5 5-4-4-3 3"}]]); diff --git a/src/components/lib/extension.ts b/src/components/lib/extension.ts index 2819256..e73befd 100644 --- a/src/components/lib/extension.ts +++ b/src/components/lib/extension.ts @@ -1,7 +1,7 @@ /* - * 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. + * Browser-extension glue. In the extension the app runs in a popup window the user may close + * at any time, 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"; diff --git a/src/components/shell/StartMenu.tsx b/src/components/shell/StartMenu.tsx index c55558e..59ea7d9 100644 --- a/src/components/shell/StartMenu.tsx +++ b/src/components/shell/StartMenu.tsx @@ -2,9 +2,9 @@ import { useEffect, useRef, useState, type ComponentType } from "react"; import { notificationStatus, requestNotificationPermission, type NotificationStatus } from "../focus/notifications"; -import { Bell, ChevronDown, CircleUser, Expand, Menu, Settings, Tag, Volume2 } from "../icons"; +import { Bell, ChevronDown, CircleUser, Expand, Keyboard, Menu, Settings, Tag, Volume2 } from "../icons"; import { hotkeyHint } from "../focus/GoalForm"; -import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; +import { useHotkey, useHotkeyLabel, useHotkeys } from "../hotkeys/HotkeysProvider"; import type { Tab } from "../types"; import { isPopupView, openFullPage } from "../lib/extension"; import { SoundsPanel } from "./SoundsPanel"; @@ -80,6 +80,8 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { setIsOpen(false); }); const soundsCombo = useHotkeyLabel("app.sounds"); + const { openPanel: openShortcuts } = useHotkeys(); + const shortcutsCombo = useHotkeyLabel("app.shortcuts"); const [notificationsSupported] = useState(() => notificationStatus() !== "unsupported"); // Closing the menu keeps its Notifications item from showing a status the hotkey just changed. useHotkey("app.notifications", () => { @@ -160,6 +162,17 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { Sounds + { + openShortcuts(); + setIsOpen(false); + }} + title={hotkeyHint("Keyboard Shortcuts", shortcutsCombo)} + className={SUB_ITEM} + > + + Keyboard Shortcuts + ) : null}