Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
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

Expand Down
51 changes: 43 additions & 8 deletions extension/background.js
Original file line number Diff line number Diff line change
@@ -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));
});
14 changes: 11 additions & 3 deletions scripts/build-extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<html")) throw new Error("index.html has no <html> tag");
writeFileSync(join(EXTENSION_DIR, "popup.html"), html.replace(/<html(\s|>)/, '<html data-view="popup"$1'));
}

function writeManifest() {
const icons = Object.fromEntries([16, 32, 48, 128].map((size) => [String(size), `icons/icon-${size}.png`]));
const manifest = {
Expand All @@ -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" } },
Expand All @@ -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");
Expand Down
12 changes: 12 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 10 additions & 4 deletions src/components/BoostApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,6 +56,7 @@ function AppShell() {
const timerRef = useRef<FocusTimerHandle>(null);
const isMobile = useMediaQuery(MOBILE_QUERY);
const isShortViewport = useMediaQuery(SHORT_VIEWPORT_QUERY);
const [isPopup] = useState(isPopupView);

useEffect(() => askForNotificationsOnFirstOpen(), []);

Expand Down Expand Up @@ -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 (
<>
Expand All @@ -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. */}
<div className={tab === "timer" ? "" : "hidden"}>
Expand Down
54 changes: 53 additions & 1 deletion src/components/focus/FocusTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -202,9 +203,48 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(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<boolean> => {
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);
Expand All @@ -220,6 +260,18 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(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);
Expand Down Expand Up @@ -542,7 +594,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(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"
/>
</div>

Expand Down
71 changes: 13 additions & 58 deletions src/components/focus/notifications.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
clear(id: string): Promise<boolean>;
onClicked: { addListener(listener: (id: string) => void): void };
};
tabs: {
getCurrent(): Promise<{ id?: number; windowId?: number } | undefined>;
update(tabId: number, properties: { active: boolean }): Promise<unknown>;
};
windows: { update(windowId: number, properties: { focused: boolean }): Promise<unknown> };
}

function extensionApi(): ExtensionApi | null {
const candidate = (globalThis as { chrome?: Partial<ExtensionApi> }).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";
}

Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
1 change: 1 addition & 0 deletions src/components/hotkeys/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+/" },
];

Expand Down
1 change: 1 addition & 0 deletions src/components/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const CircleUser = createIcon("CircleUser", [["circle",{cx:"12",cy:"12",r
export const Circle = createIcon("Circle", [["circle",{cx:"12",cy:"12",r:"10"}]]);
export const Clock = createIcon("Clock", [["circle",{cx:"12",cy:"12",r:"10"}],["polyline",{points:"12 6 12 12 16 14"}]]);
export const Coffee = createIcon("Coffee", [["path",{d:"M17 8h1a4 4 0 1 1 0 8h-1"}],["path",{d:"M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"}],["line",{x1:"6",x2:"6",y1:"2",y2:"4"}],["line",{x1:"10",x2:"10",y1:"2",y2:"4"}],["line",{x1:"14",x2:"14",y1:"2",y2:"4"}]]);
export const Expand = createIcon("Expand", [["path",{d:"m21 21-6-6m6 6v-4.8m0 4.8h-4.8"}],["path",{d:"M3 16.2V21m0 0h4.8M3 21l6-6"}],["path",{d:"M21 7.8V3m0 0h-4.8M21 3l-6 6"}],["path",{d:"M3 7.8V3m0 0h4.8M3 3l6 6"}]]);
export const Globe = createIcon("Globe", [["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"}],["path",{d:"M2 12h20"}]]);
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"}]]);
Expand Down
Loading
Loading