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);
+ }
+}