Skip to content

Commit 51a00ed

Browse files
authored
feat: confirm reset, shortcuts menu item, extension popup window (#29)
1 parent b2bd829 commit 51a00ed

10 files changed

Lines changed: 102 additions & 27 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ This writes an unpacked extension to `dist/extension/` and a zip to `dist/boost-
5959

6060
1. Open `chrome://extensions` (or `edge://extensions`) and turn on **Developer mode**.
6161
2. Click **Load unpacked** and select `dist/extension`.
62-
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 <kbd>Alt</kbd>+<kbd>Enter</kbd>, to open the **full page** in a tab; an existing tab is focused instead of opening a second one.
62+
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 <kbd>Alt</kbd>+<kbd>Enter</kbd>, to open the **full page** in a tab; an existing tab is focused instead of opening a second one.
6363
4. Optional: assign a global shortcut for **Open Boost Focus Timer** at `chrome://extensions/shortcuts`.
6464

6565
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.

‎extension/background.js‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
// Background worker. The toolbar button opens the popup (manifest `default_popup`); this worker
1+
// Background worker. The toolbar button opens the compact app in a popup window; this worker also
22
// opens the full-page tab on request and raises the end-of-session notification, because the
3-
// popup is destroyed whenever it loses focus and cannot wait for a timer itself.
3+
// popup window may be closed while a timer runs.
44
const APP_URL = chrome.runtime.getURL("index.html");
5+
const POPUP_URL = chrome.runtime.getURL("popup.html");
6+
const POPUP_SIZE = { width: 420, height: 640 };
7+
const POPUP_EDGE_MARGIN_PX = 16;
8+
const POPUP_TOOLBAR_OFFSET_PX = 72;
9+
const POPUP_BOUNDS_KEY = "popupBounds";
510
const ICON_URL = chrome.runtime.getURL("icons/icon-128.png");
611
const END_ALARM = "session-end";
712
const PENDING_KEY = "pendingNotification";
@@ -18,6 +23,38 @@ async function openApp() {
1823
await chrome.windows.update(existing.windowId, { focused: true });
1924
}
2025

26+
async function popupContext() {
27+
const [context] = await chrome.runtime.getContexts({ contextTypes: ["TAB"], documentUrls: [POPUP_URL] });
28+
return context ?? null;
29+
}
30+
31+
/** Top-right of the current browser window, under its toolbar, like the dropdown it replaces. */
32+
function defaultPopupBounds(browserWindow) {
33+
return {
34+
left: Math.max(0, browserWindow.left + browserWindow.width - POPUP_SIZE.width - POPUP_EDGE_MARGIN_PX),
35+
top: Math.max(0, browserWindow.top + POPUP_TOOLBAR_OFFSET_PX),
36+
...POPUP_SIZE,
37+
};
38+
}
39+
40+
async function openPopup() {
41+
const existing = await popupContext();
42+
if (existing) {
43+
await chrome.windows.update(existing.windowId, { focused: true });
44+
return;
45+
}
46+
const { [POPUP_BOUNDS_KEY]: saved } = await chrome.storage.local.get(POPUP_BOUNDS_KEY);
47+
const bounds = saved ?? defaultPopupBounds(await chrome.windows.getLastFocused());
48+
await chrome.windows.create({ url: POPUP_URL, type: "popup", focused: true, ...bounds });
49+
}
50+
51+
async function rememberPopupBounds(browserWindow) {
52+
const popup = await popupContext();
53+
if (popup?.windowId !== browserWindow.id) return;
54+
const { left, top, width, height } = browserWindow;
55+
await chrome.storage.local.set({ [POPUP_BOUNDS_KEY]: { left, top, width, height } });
56+
}
57+
2158
async function scheduleEnd({ at, title, body }) {
2259
await chrome.storage.session.set({ [PENDING_KEY]: { title, body } });
2360
await chrome.alarms.create(END_ALARM, { when: at });
@@ -28,6 +65,14 @@ async function cancelEnd() {
2865
await chrome.storage.session.remove(PENDING_KEY);
2966
}
3067

68+
chrome.action.onClicked.addListener(() => {
69+
openPopup().catch((error) => console.error("Failed to open the popup window:", error));
70+
});
71+
72+
chrome.windows.onBoundsChanged.addListener((browserWindow) => {
73+
rememberPopupBounds(browserWindow).catch((error) => console.error("Failed to remember the popup size:", error));
74+
});
75+
3176
chrome.runtime.onMessage.addListener((message) => {
3277
const handlers = { "open-app": openApp, "schedule-end": scheduleEnd, "cancel-end": cancelEnd };
3378
handlers[message?.type]?.(message).catch((error) => console.error(`Failed to handle ${message.type}:`, error));

‎scripts/build-extension.mjs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function externalizeInlineScripts() {
6363
}
6464
}
6565

66-
/** The toolbar popup is the same page, marked so the app switches to its compact 400x600 layout. */
66+
/** The popup window is the same page, marked so the app switches to its compact 400x600 layout. */
6767
function writePopupPage() {
6868
const html = readFileSync(join(EXTENSION_DIR, "index.html"), "utf8");
6969
if (!html.includes("<html")) throw new Error("index.html has no <html> tag");
@@ -82,7 +82,9 @@ function writeManifest() {
8282
// notifications + alarms: the background worker announces a session's end even with the popup closed;
8383
// storage: it keeps that notification's text in chrome.storage.session until the alarm fires.
8484
permissions: ["notifications", "alarms", "storage"],
85-
action: { default_title: "Boost Focus Timer", default_icon: icons, default_popup: "popup.html" },
85+
// No default_popup: the background worker opens popup.html in its own window, which gets a
86+
// taskbar entry with the extension icon and survives losing focus.
87+
action: { default_title: "Boost Focus Timer", default_icon: icons },
8688
background: { service_worker: "background.js" },
8789
// Unassigned by default; users pick a key at chrome://extensions/shortcuts.
8890
commands: { _execute_action: { description: "Open Boost Focus Timer" } },

‎src/app/globals.css‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Classes v4 dropped (bg-opacity-*, flex-shrink-*, ...) must be rewritten in markup.
77
*/
88
@custom-variant wide (@media (min-width: 820px));
9-
/* The extension's toolbar popup: popup.html carries data-view="popup" (see scripts/build-extension.mjs). */
9+
/* The extension's popup window: popup.html carries data-view="popup" (see scripts/build-extension.mjs). */
1010
@custom-variant popup (&:where([data-view="popup"] *));
1111

1212
@theme {
@@ -261,14 +261,10 @@
261261
background-color: #111827;
262262
}
263263

264-
/*
265-
* Chrome caps extension popups at 800x600 and sizes them to the document. 400 wide matches the
266-
* phone layout the app was designed at; 600 is the tallest Chrome allows.
267-
*/
264+
/* The popup is a resizable window (see extension/background.js), so it fills whatever size it has. */
268265
html[data-view="popup"],
269266
html[data-view="popup"] body {
270-
width: 400px;
271-
height: 600px;
267+
height: 100vh;
272268
}
273269
}
274270

‎src/components/focus/FocusTimer.tsx‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
209209
};
210210

211211
/**
212-
* Extension only: the popup closes whenever it loses focus, so a countdown that was running when it
212+
* Extension only: the popup window can be closed mid-session, so a countdown that was running when it
213213
* closed keeps running (or has finished) instead of coming back paused.
214214
*/
215215
const continueRunningTimer = async (running: RunningTimer, latest: FocusSession): Promise<boolean> => {
@@ -303,6 +303,19 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
303303
rerollAnimation();
304304
};
305305

306+
// An unfinished session has no end time, so its minutes never reach today's total.
307+
const confirmReset = async () => {
308+
if (session && !session.completed) {
309+
const confirmed = await dialog.confirm("Time focused so far won't count toward today.", {
310+
title: "Reset this session?",
311+
confirmLabel: "Reset",
312+
destructive: true,
313+
});
314+
if (!confirmed) return;
315+
}
316+
resetTimer();
317+
};
318+
306319
const completeSession = async () => {
307320
if (!session || session.completed) return;
308321
try {
@@ -541,7 +554,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
541554
useHotkey("timer.shuffle", toggleAutoShuffle, isActive && isIdle);
542555
useHotkey("timer.nextAnimation", selectNextAnimation, isActive && isIdle);
543556
const isSessionComplete = !isIdle && !isOnBreak && focus.isCompleted;
544-
useHotkey("timer.reset", resetTimer, isActive && !isIdle && !isDirectBreak && !isOnBreak);
557+
useHotkey("timer.reset", () => void confirmReset(), isActive && !isIdle && !isDirectBreak && !isOnBreak);
545558
useHotkey("timer.extend", () => void extendFocus(), isActive && isSessionComplete);
546559
const toggleCombo = useHotkeyLabel("timer.toggle");
547560
const endCombo = useHotkeyLabel("timer.end");
@@ -744,7 +757,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
744757
<Play className="w-5 h-5" />
745758
Start Break
746759
</TimerButton>
747-
<TimerButton onClick={resetTimer} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_BASE} px-4 py-3 bg-gray-500 hover:bg-gray-600`}>
760+
<TimerButton onClick={() => void confirmReset()} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_BASE} px-4 py-3 bg-gray-500 hover:bg-gray-600`}>
748761
<RotateCcw className="w-5 h-5" />
749762
Reset
750763
</TimerButton>
@@ -766,7 +779,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
766779
<Target className={ICON_SMALL} />
767780
<span>Done</span>
768781
</TimerButton>
769-
<TimerButton onClick={resetTimer} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_SMALL} bg-gray-500 hover:bg-gray-600`}>
782+
<TimerButton onClick={() => void confirmReset()} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_SMALL} bg-gray-500 hover:bg-gray-600`}>
770783
<RotateCcw className={ICON_SMALL} />
771784
Reset
772785
</TimerButton>

‎src/components/hotkeys/HotkeysProvider.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface HotkeysContextValue {
2020
rebind(actionId: string, combo: string | null): void;
2121
resetAll(): void;
2222
setCapturing(isCapturing: boolean): void;
23+
openPanel(): void;
2324
}
2425

2526
const HotkeysContext = createContext<HotkeysContextValue | null>(null);
@@ -90,9 +91,11 @@ export function HotkeysProvider({ children }: { children: ReactNode }) {
9091
return () => window.removeEventListener("keydown", onKeyDown);
9192
}, [bindings]);
9293

94+
const openPanel = useCallback(() => setIsPanelOpen(true), []);
95+
9396
const value = useMemo(
94-
() => ({ bindings, register, rebind, resetAll, setCapturing }),
95-
[bindings, register, rebind, resetAll, setCapturing],
97+
() => ({ bindings, register, rebind, resetAll, setCapturing, openPanel }),
98+
[bindings, register, rebind, resetAll, setCapturing, openPanel],
9699
);
97100

98101
return (

‎src/components/hotkeys/ShortcutsPanel.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ function isExtensionPage(): boolean {
1919
export function ShortcutsPanel({ actions, onClose }: { actions: readonly HotkeyAction[]; onClose(): void }) {
2020
const { bindings, rebind, resetAll, setCapturing } = useHotkeys();
2121
const [editingId, setEditingId] = useState<string | null>(null);
22-
const doneRef = useRef<HTMLButtonElement>(null);
22+
const dialogRef = useRef<HTMLDivElement>(null);
2323

24+
// Focus lands on the dialog itself so no control shows a ring and Enter cannot close it by accident.
2425
useEffect(() => {
25-
doneRef.current?.focus();
26+
dialogRef.current?.focus();
2627
}, []);
2728

2829
useEffect(() => {
@@ -62,17 +63,18 @@ export function ShortcutsPanel({ actions, onClose }: { actions: readonly HotkeyA
6263
role="presentation"
6364
>
6465
<div
66+
ref={dialogRef}
67+
tabIndex={-1}
6568
role="dialog"
6669
aria-modal="true"
6770
aria-labelledby="shortcuts-title"
68-
className="w-full max-w-md max-h-[85vh] flex flex-col bg-gray-900 border border-gray-700 rounded-2xl shadow-2xl overflow-hidden motion-safe:animate-[bft-sheet-in_200ms_ease-out]"
71+
className="outline-none w-full max-w-md max-h-[85vh] flex flex-col bg-gray-900 border border-gray-700 rounded-2xl shadow-2xl overflow-hidden motion-safe:animate-[bft-sheet-in_200ms_ease-out]"
6972
>
7073
<div className="relative flex items-center justify-center px-5 pt-5 pb-2">
7174
<h2 id="shortcuts-title" className="text-base font-semibold text-white">
7275
Keyboard Shortcuts
7376
</h2>
7477
<button
75-
ref={doneRef}
7678
onClick={onClose}
7779
className="absolute right-4 rounded-md px-1.5 py-0.5 text-sm font-semibold text-blue-400 hover:text-blue-300 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60"
7880
>

‎src/components/icons.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const Globe = createIcon("Globe", [["circle",{cx:"12",cy:"12",r:"10"}],["
2424
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"}]]);
2525
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"}]]);
2626
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"}]]);
27+
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"}]]);
2728
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"}]]);
2829
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"}]]);
2930
export const LineChart = createIcon("LineChart", [["path",{d:"M3 3v18h18"}],["path",{d:"m19 9-5 5-4-4-3 3"}]]);

‎src/components/lib/extension.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
2-
* Browser-extension glue. In the extension the app runs in a toolbar popup that is destroyed
3-
* whenever it loses focus, so a running timer is recorded here and the background worker
4-
* raises the end-of-session notification. Every function is a no-op in the web app.
2+
* Browser-extension glue. In the extension the app runs in a popup window the user may close
3+
* at any time, so a running timer is recorded here and the background worker raises the
4+
* end-of-session notification. Every function is a no-op in the web app.
55
*/
66

77
const RUNNING_TIMER_KEY = "bft:runningTimer";

‎src/components/shell/StartMenu.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
import { useEffect, useRef, useState, type ComponentType } from "react";
44
import { notificationStatus, requestNotificationPermission, type NotificationStatus } from "../focus/notifications";
5-
import { Bell, ChevronDown, CircleUser, Expand, Menu, Settings, Tag, Volume2 } from "../icons";
5+
import { Bell, ChevronDown, CircleUser, Expand, Keyboard, Menu, Settings, Tag, Volume2 } from "../icons";
66
import { hotkeyHint } from "../focus/GoalForm";
7-
import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider";
7+
import { useHotkey, useHotkeyLabel, useHotkeys } from "../hotkeys/HotkeysProvider";
88
import type { Tab } from "../types";
99
import { isPopupView, openFullPage } from "../lib/extension";
1010
import { SoundsPanel } from "./SoundsPanel";
@@ -80,6 +80,8 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) {
8080
setIsOpen(false);
8181
});
8282
const soundsCombo = useHotkeyLabel("app.sounds");
83+
const { openPanel: openShortcuts } = useHotkeys();
84+
const shortcutsCombo = useHotkeyLabel("app.shortcuts");
8385
const [notificationsSupported] = useState(() => notificationStatus() !== "unsupported");
8486
// Closing the menu keeps its Notifications item from showing a status the hotkey just changed.
8587
useHotkey("app.notifications", () => {
@@ -160,6 +162,17 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) {
160162
<Volume2 className="w-4 h-4" />
161163
<span>Sounds</span>
162164
</button>
165+
<button
166+
onClick={() => {
167+
openShortcuts();
168+
setIsOpen(false);
169+
}}
170+
title={hotkeyHint("Keyboard Shortcuts", shortcutsCombo)}
171+
className={SUB_ITEM}
172+
>
173+
<Keyboard className="w-4 h-4" />
174+
<span>Keyboard Shortcuts</span>
175+
</button>
163176
<NotificationsItem />
164177
</div>
165178
) : null}

0 commit comments

Comments
 (0)