From 5609b64df6d90fb759676a763da18fac241d0b76 Mon Sep 17 00:00:00 2001
From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com>
Date: Sun, 11 Oct 2026 22:23:06 +0700
Subject: [PATCH] fix: ask for notifications on first visit and keep the task
after a session
## Summary
Two fixes from daily use.
**Ask for notifications on first visit.** A small banner ("Get a notification when a focus session or break ends") with **Allow** and a dismiss button appears the first time the app opens, while the browser has not asked yet. Allow opens the browser's permission dialog (browsers only show it after a click, which the banner provides). Dismiss hides it for good. If the browser dialog is closed without a choice, the banner stays to try again. Replaces the request that used to fire on the first Start Focus.
**Keep the task after a session.** Reset and the end of a break used to clear the focus goal, so continuing the same task meant typing it again. The goal now stays filled in; edit or clear it when you switch tasks.
## Testing
- Lint, typecheck, unit tests and build pass.
- Browser with a stubbed permission state: banner shows on first visit, Allow requests permission once and hides it.
- "Write chapter 3" stayed in the goal field after Reset, and after a 1-minute focus session plus a 1-minute break.
---
README.md | 2 +-
src/components/BoostApp.tsx | 2 +
src/components/focus/FocusTimer.tsx | 6 +--
src/components/focus/notifications.ts | 23 +++++++---
src/components/icons.ts | 1 +
src/components/shell/NotificationPrompt.tsx | 48 +++++++++++++++++++++
6 files changed, 71 insertions(+), 11 deletions(-)
create mode 100644 src/components/shell/NotificationPrompt.tsx
diff --git a/README.md b/README.md
index 4334b3a..ce867d6 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +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).
+- **Notifications** when a session or break ends while the timer tab is in the background (the app offers to turn them on the first time you open it).
- **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/src/components/BoostApp.tsx b/src/components/BoostApp.tsx
index 664c07a..cfb4088 100644
--- a/src/components/BoostApp.tsx
+++ b/src/components/BoostApp.tsx
@@ -8,6 +8,7 @@ import { databaseService } from "./lib/database-service";
import { getLocalUserId } from "./lib/local-db";
import { getDisplayName, saveDisplayName } from "./lib/profile";
import { BottomNav } from "./shell/BottomNav";
+import { NotificationPrompt } from "./shell/NotificationPrompt";
import { StartMenu } from "./shell/StartMenu";
import { TopBar } from "./shell/TopBar";
import { MOBILE_NAV_OFFSET, MOBILE_QUERY, SHORT_VIEWPORT_QUERY, useMediaQuery } from "./shell/use-media-query";
@@ -209,6 +210,7 @@ function AppShell() {
+
>
);
}
diff --git a/src/components/focus/FocusTimer.tsx b/src/components/focus/FocusTimer.tsx
index 24804ca..3dc5bae 100644
--- a/src/components/focus/FocusTimer.tsx
+++ b/src/components/focus/FocusTimer.tsx
@@ -9,7 +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 { notify } from "./notifications";
import { playSound } from "./sounds";
const DEFAULT_TITLE = "Boost Focus Timer";
@@ -229,7 +229,6 @@ export const FocusTimer = forwardRef(function
setBreakStart(null);
setFocusTotalMs(0);
setBreakTotalMs(0);
- setGoal("");
setInputMinutes(storedFocusMinutes());
setBreakMinutes(storedBreakMinutes());
setExtendMinutes("10");
@@ -311,7 +310,6 @@ export const FocusTimer = forwardRef(function
setBreakStart(null);
setBreakTotalMs(0);
setBreakPauseId(null);
- setGoal("");
setInputMinutes(storedFocusMinutes());
setBreakMinutes(storedBreakMinutes());
};
@@ -331,7 +329,6 @@ export const FocusTimer = forwardRef(function
});
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");
@@ -398,7 +395,6 @@ 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
index 8f01825..1cdd806 100644
--- a/src/components/focus/notifications.ts
+++ b/src/components/focus/notifications.ts
@@ -1,15 +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 OFFER_DISMISSED_KEY = "bft:notificationOfferDismissed";
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) => {
+/** True until the user has decided: the browser hasn't asked yet and they haven't said "Not now". */
+export function shouldOfferNotifications(): boolean {
+ return isSupported() && Notification.permission === "default" && !localStorage.getItem(OFFER_DISMISSED_KEY);
+}
+
+export function dismissNotificationOffer(): void {
+ localStorage.setItem(OFFER_DISMISSED_KEY, "1");
+}
+
+/** Must run from a click or key press; browsers ignore permission requests made outside a user gesture. */
+export async function requestNotificationPermission(): Promise {
+ if (!isSupported()) return null;
+ try {
+ return await Notification.requestPermission();
+ } catch (error) {
console.error("Notification permission request failed:", error);
- });
+ return null;
+ }
}
/** Shows a system notification only while the timer tab is out of view; the chime covers the visible case. */
diff --git a/src/components/icons.ts b/src/components/icons.ts
index c260550..f97d2bd 100644
--- a/src/components/icons.ts
+++ b/src/components/icons.ts
@@ -6,6 +6,7 @@ export const ArchiveX = createIcon("ArchiveX", [["rect",{width:"20",height:"5",x
export const Archive = createIcon("Archive", [["rect",{width:"20",height:"5",x:"2",y:"3",rx:"1"}],["path",{d:"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"}],["path",{d:"M10 12h4"}]]);
export const ArrowLeft = createIcon("ArrowLeft", [["path",{d:"m12 19-7-7 7-7"}],["path",{d:"M19 12H5"}]]);
export const BarChart3 = createIcon("BarChart3", [["path",{d:"M3 3v18h18"}],["path",{d:"M18 17V9"}],["path",{d:"M13 17V5"}],["path",{d:"M8 17v-3"}]]);
+export const Bell = createIcon("Bell", [["path",{d:"M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"}],["path",{d:"M10.3 21a1.94 1.94 0 0 0 3.4 0"}]]);
export const Calendar = createIcon("Calendar", [["path",{d:"M8 2v4"}],["path",{d:"M16 2v4"}],["rect",{width:"18",height:"18",x:"3",y:"4",rx:"2"}],["path",{d:"M3 10h18"}]]);
export const CheckCircle2 = createIcon("CheckCircle2", [["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"m9 12 2 2 4-4"}]]);
export const CheckSquare = createIcon("CheckSquare", [["path",{d:"m9 11 3 3L22 4"}],["path",{d:"M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"}]]);
diff --git a/src/components/shell/NotificationPrompt.tsx b/src/components/shell/NotificationPrompt.tsx
new file mode 100644
index 0000000..df058c3
--- /dev/null
+++ b/src/components/shell/NotificationPrompt.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useState } from "react";
+import { dismissNotificationOffer, requestNotificationPermission, shouldOfferNotifications } from "../focus/notifications";
+import { Bell, X } from "../icons";
+
+/** First-visit banner: the browser only shows its permission dialog after a click, so this provides the click. */
+export function NotificationPrompt() {
+ const [isVisible, setIsVisible] = useState(shouldOfferNotifications);
+
+ if (!isVisible) return null;
+
+ const allow = async () => {
+ const permission = await requestNotificationPermission();
+ // Closing the browser dialog leaves the permission at "default"; keep offering in that case.
+ if (permission !== "default") setIsVisible(false);
+ };
+
+ const notNow = () => {
+ dismissNotificationOffer();
+ setIsVisible(false);
+ };
+
+ return (
+
+
+
Get a notification when a focus session or break ends.