diff --git a/README.md b/README.md index 31254dd..becbdb8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Runs as a web app or as a browser extension, and keeps every bit of data in your ## What you get - **Focus timer** with pause, resume, breaks, extend-on-complete and goal history (press ↑/↓ in the goal field). +- **Notifications** when a session or break ends while the timer tab is in the background (allow them when the browser asks on your first session). - **Pixel-art progress animations** — a coffee cup that fills, a battery that charges, a rocket that lifts off — plus an auto-shuffle option. - **To-dos** with time estimates, notes, drag-and-drop ordering, a backlog and archive; start a focus session straight from a to-do. - **Habits** with 7- and 30-day grids and notes per day. diff --git a/scripts/build-extension.mjs b/scripts/build-extension.mjs index 640c62e..9209a01 100644 --- a/scripts/build-extension.mjs +++ b/scripts/build-extension.mjs @@ -72,6 +72,8 @@ 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 }, background: { service_worker: "background.js" }, // Unassigned by default; users pick a key at chrome://extensions/shortcuts. diff --git a/src/components/focus/FocusTimer.tsx b/src/components/focus/FocusTimer.tsx index 3ab81d1..24804ca 100644 --- a/src/components/focus/FocusTimer.tsx +++ b/src/components/focus/FocusTimer.tsx @@ -9,6 +9,7 @@ import { AnimationPicker } from "./AnimationPicker"; import { ANIMATIONS, DEFAULT_ANIMATION, currentFrame, isAnimationType, metaOf, type AnimationType, type FrameState } from "./animations"; import { GoalForm, hotkeyHint } from "./GoalForm"; import { PixelArt } from "./PixelArt"; +import { notify, requestNotificationPermission } from "./notifications"; import { playSound } from "./sounds"; const DEFAULT_TITLE = "Boost Focus Timer"; @@ -292,6 +293,7 @@ export const FocusTimer = forwardRef(function onTick: (minutes, seconds) => setFocus((current) => ({ ...current, minutes, seconds })), onComplete: () => { playSound("end"); + notify("Focus session complete", `${goal.trim() || "Nice work"} · time for a break.`); setFocusStart(null); setFocus({ ...IDLE, isCompleted: true }); void completeSession(); @@ -322,12 +324,14 @@ export const FocusTimer = forwardRef(function onTick: (minutes, seconds) => setBreakTimer((current) => ({ ...current, minutes, seconds })), onComplete: () => { playSound("end"); + notify("Break is over", "Ready for the next focus session?"); void completeBreakSession(); finishBreak(); }, }); const startFocus = async () => { + requestNotificationPermission(); if (!goal.trim() && !session) return alert("Please enter a focus goal"); const minutes = parseInt(inputMinutes); if (!minutes || minutes <= 0) return alert("Please enter a valid time in minutes"); @@ -394,6 +398,7 @@ export const FocusTimer = forwardRef(function }; const startBreak = async () => { + requestNotificationPermission(); const minutes = parseInt(breakMinutes); if (!minutes || minutes <= 0) return alert("Please enter a valid break time in minutes"); localStorage.setItem("lastBreakDuration", String(minutes)); diff --git a/src/components/focus/notifications.ts b/src/components/focus/notifications.ts new file mode 100644 index 0000000..8f01825 --- /dev/null +++ b/src/components/focus/notifications.ts @@ -0,0 +1,28 @@ +const ICON = "/icons/icon-192.png"; +// Reusing one tag replaces a stale notification instead of stacking them. +const TAG = "boost-focus-timer"; + +const isSupported = (): boolean => typeof window !== "undefined" && "Notification" in window; + +/** Asks once, from a click or key press; browsers ignore requests made outside a user gesture. */ +export function requestNotificationPermission(): void { + if (!isSupported() || Notification.permission !== "default") return; + Notification.requestPermission().catch((error: unknown) => { + console.error("Notification permission request failed:", error); + }); +} + +/** Shows a system notification only while the timer tab is out of view; the chime covers the visible case. */ +export function notify(title: string, body: string): void { + if (!isSupported() || Notification.permission !== "granted" || document.visibilityState === "visible") return; + try { + // Silent because the end bell already plays; the notification is the visual cue. + const notification = new Notification(title, { body, icon: ICON, tag: TAG, silent: true }); + notification.onclick = () => { + window.focus(); + notification.close(); + }; + } catch (error) { + console.error("Could not show notification:", error); + } +}