From e4a96e19c4ec0a7afc869cae191117e2f1d0e926 Mon Sep 17 00:00:00 2001
From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:59:30 +0700
Subject: [PATCH] feat: notify when a focus session or break ends
## Summary
System notifications when a timer runs out on its own:
- "Focus session complete" with the goal, when a focus countdown ends.
- "Break is over", when a break countdown ends.
They only appear while the timer tab is out of view (the end bell covers the visible case), are silent because the bell already plays, reuse one tag so they replace instead of stack, and clicking one focuses the timer. Permission is requested on the first Start Focus / Start Break, since browsers only allow the request from a user gesture; a denial is respected. The extension manifest gains the `notifications` permission so extension pages can notify without a prompt.
## Testing
- Lint, typecheck, unit tests and build pass.
- In the browser with a hidden tab: 1-minute focus and break sessions each produced the right notification; permission was requested once.
---
README.md | 1 +
scripts/build-extension.mjs | 2 ++
src/components/focus/FocusTimer.tsx | 5 +++++
src/components/focus/notifications.ts | 28 +++++++++++++++++++++++++++
4 files changed, 36 insertions(+)
create mode 100644 src/components/focus/notifications.ts
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);
+ }
+}