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.

+ + +
+ ); +}