From 5625d21e39d7d6629872257bd57f430e771180a7 Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:19:01 +0700 Subject: [PATCH 1/3] fix: show every notification instead of silently replacing the previous one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Session-complete notifications did not pop up after the sample notification. **Cause:** every notification reused one tag (web) / id (extension). When a notification with the same tag is still in the notification list, Chrome swaps the new content into it *silently*, without showing a pop-up. The sample sent on "Turn on" stayed in the list, so "Focus session complete" replaced it invisibly. **Fix:** each notification gets a fresh id, and the previous one is closed (web) / cleared (extension) first, so only one is ever listed and every session or break end pops up. ## Testing - Lint, typecheck, unit tests and build pass. - Stubbed Notification API: sample opens without a tag, is closed, then "Focus session complete" opens fresh. - Reported scenario (Turn on → sample shown → session ends → nothing shown) to be re-checked on a real machine. --- src/components/focus/notifications.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/components/focus/notifications.ts b/src/components/focus/notifications.ts index d143a2d..a564937 100644 --- a/src/components/focus/notifications.ts +++ b/src/components/focus/notifications.ts @@ -5,8 +5,11 @@ */ const ICON_PATH = "icons/icon-128.png"; -// Reusing one id/tag replaces a stale notification instead of stacking them. -const NOTIFICATION_ID = "boost-focus-timer"; +/* + * Each notification gets a fresh id and the previous one is closed first. Reusing a tag/id + * would make the browser swap the text into the old notification silently, without a pop-up. + */ +const NOTIFICATION_ID_PREFIX = "boost-focus-timer"; const PREFERENCE_KEY = "bft:notifications"; const LEGACY_DISMISSED_KEY = "bft:notificationOfferDismissed"; export const NOTIFICATIONS_ENABLED_EVENT = "bft:notifications-enabled"; @@ -16,6 +19,7 @@ interface ExtensionApi { runtime: { id?: string; getURL(path: string): string }; notifications: { create(id: string, options: { type: "basic"; iconUrl: string; title: string; message: string; silent?: boolean }): Promise; + clear(id: string): Promise; onClicked: { addListener(listener: (id: string) => void): void }; }; tabs: { @@ -52,6 +56,8 @@ export function notificationStatus(): NotificationStatus { } let clickHandlerInstalled = false; +let previousExtensionId: string | null = null; +let previousWebNotification: Notification | null = null; /** Clicking an extension notification brings the timer tab and its window to the front. */ function installExtensionClickHandler(api: ExtensionApi): void { @@ -69,12 +75,17 @@ function show(title: string, body: string): void { try { if (api) { installExtensionClickHandler(api); + if (previousExtensionId) void api.notifications.clear(previousExtensionId); + const id = `${NOTIFICATION_ID_PREFIX}-${Date.now()}`; + previousExtensionId = id; const options = { type: "basic" as const, iconUrl: api.runtime.getURL(ICON_PATH), title, message: body, silent: true }; - api.notifications.create(NOTIFICATION_ID, options).catch((error: unknown) => console.error("Could not show notification:", error)); + api.notifications.create(id, options).catch((error: unknown) => console.error("Could not show notification:", error)); return; } + previousWebNotification?.close(); // Silent because the end bell already plays; the notification is the visual cue. - const notification = new Notification(title, { body, icon: `/${ICON_PATH}`, tag: NOTIFICATION_ID, silent: true }); + const notification = new Notification(title, { body, icon: `/${ICON_PATH}`, silent: true }); + previousWebNotification = notification; notification.onclick = () => { window.focus(); notification.close(); From 986cd77198a3f442024b952e8ecbaa90c9281c56 Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:20:46 +0700 Subject: [PATCH 2/3] fix: ask Chrome for notification permission directly on first open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the in-app 'Turn on' banner and the app-level on/off setting. The app calls Notification.requestPermission() on first open, so Chrome's own Allow/Block prompt is the only step; the extension is granted by its manifest and needs no ask. Menu → Settings → Notifications shows On / Blocked (with how to change it), or 'Turn on notifications' to ask again when the prompt was dismissed. --- README.md | 2 +- src/components/BoostApp.tsx | 7 ++- src/components/focus/notifications.ts | 67 ++++++-------------- src/components/shell/NotificationPrompt.tsx | 68 --------------------- src/components/shell/StartMenu.tsx | 38 +++++------- 5 files changed, 41 insertions(+), 141 deletions(-) delete mode 100644 src/components/shell/NotificationPrompt.tsx diff --git a/README.md b/README.md index 8a5c8bb..e23e030 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 focus session or break runs out. The app asks the first time you open it and sends a sample when you turn them on; switch them on or off any time from **Menu → Settings → Notifications**. If the sample never appears, allow notifications for your browser in your system settings and turn off Do Not Disturb / Focus. +- **Notifications** when a focus session or break runs out. The app asks for permission with Chrome's own prompt the first time you open it; **Menu → Settings → Notifications** shows the current state and asks again if you dismissed the prompt. If nothing appears, allow notifications for your browser in your system settings and turn off Do Not Disturb / Focus. - **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 cfb4088..db7957a 100644 --- a/src/components/BoostApp.tsx +++ b/src/components/BoostApp.tsx @@ -3,12 +3,12 @@ import dynamic from "next/dynamic"; import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { FocusTimer } from "./focus/FocusTimer"; +import { requestNotificationPermission } from "./focus/notifications"; import { HotkeysProvider, useHotkey, useHotkeyMap } from "./hotkeys/HotkeysProvider"; 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"; @@ -53,6 +53,10 @@ function AppShell() { const isMobile = useMediaQuery(MOBILE_QUERY); const isShortViewport = useMediaQuery(SHORT_VIEWPORT_QUERY); + useEffect(() => { + void requestNotificationPermission(); + }, []); + useEffect(() => { databaseService.getTodayFocusMinutes().then(setTodayMinutes, (error: unknown) => { console.error("Error loading today minutes:", error); @@ -210,7 +214,6 @@ function AppShell() { - ); } diff --git a/src/components/focus/notifications.ts b/src/components/focus/notifications.ts index a564937..1db2ec9 100644 --- a/src/components/focus/notifications.ts +++ b/src/components/focus/notifications.ts @@ -1,7 +1,6 @@ /* - * Notifications have an app-level on/off choice on top of the browser permission: - * inside the extension the permission is granted by the manifest, so without our own - * choice the user would never be asked. + * The browser permission is the only switch: the app asks Chrome directly on first open. + * Inside the extension the manifest grants it, so there is nothing to ask. */ const ICON_PATH = "icons/icon-128.png"; @@ -10,9 +9,6 @@ const ICON_PATH = "icons/icon-128.png"; * would make the browser swap the text into the old notification silently, without a pop-up. */ const NOTIFICATION_ID_PREFIX = "boost-focus-timer"; -const PREFERENCE_KEY = "bft:notifications"; -const LEGACY_DISMISSED_KEY = "bft:notificationOfferDismissed"; -export const NOTIFICATIONS_ENABLED_EVENT = "bft:notifications-enabled"; /** The slice of the extension `chrome` API used here; the web build never has it. */ interface ExtensionApi { @@ -36,23 +32,25 @@ function extensionApi(): ExtensionApi | null { const hasWebNotifications = (): boolean => typeof window !== "undefined" && "Notification" in window; -export type NotificationStatus = "unsupported" | "undecided" | "on" | "off" | "blocked"; +export type NotificationStatus = "unsupported" | NotificationPermission; -function readPreference(): "on" | "off" | null { - const stored = localStorage.getItem(PREFERENCE_KEY); - if (stored === "on" || stored === "off") return stored; - return localStorage.getItem(LEGACY_DISMISSED_KEY) ? "off" : null; +export function notificationStatus(): NotificationStatus { + if (extensionApi()) return "granted"; + return hasWebNotifications() ? Notification.permission : "unsupported"; } -const writePreference = (value: "on" | "off") => localStorage.setItem(PREFERENCE_KEY, value); - -export function notificationStatus(): NotificationStatus { - const preference = readPreference(); - if (extensionApi()) return preference ?? "undecided"; - if (!hasWebNotifications()) return "unsupported"; - if (Notification.permission === "denied") return "blocked"; - if (Notification.permission === "default") return preference === "off" ? "off" : "undecided"; - return preference ?? "undecided"; +/** + * Shows Chrome's own Allow/Block prompt. Called on first open without a click, Chrome may + * show its quieter prompt (a bell in the address bar) instead; the menu entry asks again from a click. + */ +export async function requestNotificationPermission(): Promise { + if (notificationStatus() !== "default") return notificationStatus(); + try { + return await Notification.requestPermission(); + } catch (error) { + console.error("Notification permission request failed:", error); + return notificationStatus(); + } } let clickHandlerInstalled = false; @@ -95,32 +93,7 @@ function show(title: string, body: string): void { } } -/** Shows a notification when the user turned them on. */ +/** Shows a notification when the browser allows it. */ export function notify(title: string, body: string): void { - if (notificationStatus() === "on") show(title, body); -} - -/** - * Turns notifications on and sends a sample so the user sees the result straight away. - * Must run from a click: browsers ignore permission requests made outside a user gesture. - */ -export async function enableNotifications(): Promise { - if (!extensionApi() && hasWebNotifications() && Notification.permission === "default") { - const permission = await Notification.requestPermission().catch((error: unknown) => { - console.error("Notification permission request failed:", error); - return "default" as const; - }); - // Closing the browser dialog without a choice leaves the user undecided. - if (permission !== "granted") return notificationStatus(); - } - const status = notificationStatus(); - if (status === "blocked" || status === "unsupported") return status; - writePreference("on"); - show("Notifications are on", "You'll get one when a focus session or break ends."); - window.dispatchEvent(new Event(NOTIFICATIONS_ENABLED_EVENT)); - return "on"; -} - -export function disableNotifications(): void { - writePreference("off"); + if (notificationStatus() === "granted") show(title, body); } diff --git a/src/components/shell/NotificationPrompt.tsx b/src/components/shell/NotificationPrompt.tsx deleted file mode 100644 index f5e185a..0000000 --- a/src/components/shell/NotificationPrompt.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { disableNotifications, enableNotifications, notificationStatus, NOTIFICATIONS_ENABLED_EVENT } from "../focus/notifications"; -import { Bell, Check, X } from "../icons"; - -type Stage = "hidden" | "ask" | "sent"; - -const PANEL = - "fixed top-14 left-1/2 -translate-x-1/2 z-50 w-[calc(100%-1.5rem)] max-w-md flex items-start gap-3 px-4 py-3 bg-gray-900 border border-gray-700 rounded-xl shadow-2xl motion-safe:animate-[bft-sheet-in_200ms_ease-out]"; -const CLOSE_BUTTON = "shrink-0 p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"; - -/** - * Asks on first open (browsers only show their permission dialog after a click) and, - * once notifications are turned on, confirms that a sample was sent and what to check if it never arrived. - */ -export function NotificationPrompt() { - const [stage, setStage] = useState(() => (notificationStatus() === "undecided" ? "ask" : "hidden")); - - useEffect(() => { - const showConfirmation = () => setStage("sent"); - window.addEventListener(NOTIFICATIONS_ENABLED_EVENT, showConfirmation); - return () => window.removeEventListener(NOTIFICATIONS_ENABLED_EVENT, showConfirmation); - }, []); - - if (stage === "hidden") return null; - - if (stage === "sent") { - return ( -
- -

- Notifications are on, and a sample was just sent. Didn't see it? Allow notifications for your browser in your - system settings and turn off Do Not Disturb or Focus mode. -

- -
- ); - } - - const allow = async () => { - const status = await enableNotifications(); - if (status !== "on" && status !== "undecided") setStage("hidden"); - }; - - const notNow = () => { - disableNotifications(); - setStage("hidden"); - }; - - return ( -
- -

Get a notification when a focus session or break ends?

- - -
- ); -} diff --git a/src/components/shell/StartMenu.tsx b/src/components/shell/StartMenu.tsx index b22b475..e946b01 100644 --- a/src/components/shell/StartMenu.tsx +++ b/src/components/shell/StartMenu.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState, type ComponentType } from "react"; -import { disableNotifications, enableNotifications, notificationStatus, type NotificationStatus } from "../focus/notifications"; +import { notificationStatus, requestNotificationPermission, type NotificationStatus } from "../focus/notifications"; import { Bell, ChevronDown, CircleUser, Menu, Settings, Tag } from "../icons"; import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import type { Tab } from "../types"; @@ -16,39 +16,31 @@ const SETTINGS: readonly { label: string; Icon: ComponentType<{ className?: stri { label: "Tags", Icon: Tag, target: "tags" }, ]; -const NOTIFICATION_LABELS: Readonly, string>> = { - on: "Notifications: On", - off: "Notifications: Off", - undecided: "Notifications: Off", - blocked: "Notifications: Blocked", +const NOTIFICATION_LABELS: Readonly> = { + granted: "Notifications: On", + default: "Turn on notifications", + denied: "Notifications: Blocked", }; -const NOTIFICATION_HINTS: Readonly, string>> = { - on: "Click to turn notifications off", - off: "Click to turn notifications on", - undecided: "Click to turn notifications on", - blocked: "Click to see how to allow notifications", +const NOTIFICATION_HELP: Readonly> = { + granted: + "Notifications are on: you'll get one when a focus session or break ends. To turn them off, block notifications for this site in your browser's site settings (the icon left of the address bar).", + denied: + "Notifications are blocked for this site. Allow them in your browser's site settings (the icon left of the address bar), then reload.", }; -const BLOCKED_HELP = - "Notifications are blocked for this site. Allow them in your browser's site settings (the icon left of the address bar), then reload."; - -/** A switch row: shows the current state and flips it; a blocked browser gets instructions instead. */ +/** Shows the browser's notification permission; asks again when the first-open prompt was dismissed. */ function NotificationsItem() { const [status, setStatus] = useState(notificationStatus); if (status === "unsupported") return null; - const toggle = async () => { - if (status === "blocked") return alert(BLOCKED_HELP); - if (status === "on") { - disableNotifications(); - return setStatus("off"); - } - setStatus(await enableNotifications()); + const act = async () => { + if (status === "default") setStatus(await requestNotificationPermission()); + else alert(NOTIFICATION_HELP[status]); }; return ( - From 78493865cb5d382137692e0316aa747340aa0517 Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:31:33 +0700 Subject: [PATCH 3/3] feat: in-app dialogs and notification permission for Firefox and Safari Replace every window.alert/confirm with an in-app dialog (OK, or Cancel/confirm with a red Delete for destructive actions; Esc cancels, Enter confirms, queued one at a time). Firefox and Safari ignore a permission request that is not inside a click or key press and answer 'default' at once, so the ask on load never showed a prompt there. When that happens the app now asks again on the user's first click or key press. A dismissed Chrome prompt (which takes the user time to answer) is not asked again. --- src/components/BoostApp.tsx | 15 ++- src/components/boost/BoostFeed.tsx | 6 +- src/components/dialogs/DialogProvider.tsx | 133 +++++++++++++++++++ src/components/focus/FocusTimer.tsx | 14 +- src/components/focus/GoalForm.tsx | 4 +- src/components/focus/notifications.ts | 29 ++++ src/components/pages/GoalsPage.tsx | 10 +- src/components/pages/HabitsPage.tsx | 6 +- src/components/pages/HistoryPage.tsx | 10 +- src/components/pages/history/TagSelector.tsx | 4 +- src/components/settings/TagsPage.tsx | 12 +- src/components/shell/StartMenu.tsx | 4 +- 12 files changed, 214 insertions(+), 33 deletions(-) create mode 100644 src/components/dialogs/DialogProvider.tsx diff --git a/src/components/BoostApp.tsx b/src/components/BoostApp.tsx index db7957a..7312b9a 100644 --- a/src/components/BoostApp.tsx +++ b/src/components/BoostApp.tsx @@ -2,8 +2,9 @@ import dynamic from "next/dynamic"; import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { DialogProvider } from "./dialogs/DialogProvider"; import { FocusTimer } from "./focus/FocusTimer"; -import { requestNotificationPermission } from "./focus/notifications"; +import { askForNotificationsOnFirstOpen } from "./focus/notifications"; import { HotkeysProvider, useHotkey, useHotkeyMap } from "./hotkeys/HotkeysProvider"; import { databaseService } from "./lib/database-service"; import { getLocalUserId } from "./lib/local-db"; @@ -28,9 +29,11 @@ function formatTodayMinutes(minutes: number): string { export function BoostApp() { return ( - - - + + + + + ); } @@ -53,9 +56,7 @@ function AppShell() { const isMobile = useMediaQuery(MOBILE_QUERY); const isShortViewport = useMediaQuery(SHORT_VIEWPORT_QUERY); - useEffect(() => { - void requestNotificationPermission(); - }, []); + useEffect(() => askForNotificationsOnFirstOpen(), []); useEffect(() => { databaseService.getTodayFocusMinutes().then(setTodayMinutes, (error: unknown) => { diff --git a/src/components/boost/BoostFeed.tsx b/src/components/boost/BoostFeed.tsx index f81d1e8..b949427 100644 --- a/src/components/boost/BoostFeed.tsx +++ b/src/components/boost/BoostFeed.tsx @@ -6,6 +6,7 @@ import { useHotkey } from "../hotkeys/HotkeysProvider"; import { getDisplayName } from "../lib/profile"; import { getLocalUserId, localDb, type Row } from "../lib/local-db"; import { PostContent } from "./PostContent"; +import { useDialog } from "../dialogs/DialogProvider"; type BoostPost = Row & { user_id: string; @@ -86,6 +87,7 @@ function UploadedPhoto({ src }: { src: string }) { } export function BoostFeed() { + const dialog = useDialog(); const [posts, setPosts] = useState([]); const [likes, setLikes] = useState(new Map()); const [loading, setLoading] = useState(true); @@ -132,11 +134,11 @@ export function BoostFeed() { const isImage = file.type.startsWith("image/"); if (!isVideo && !isImage) return; if (isVideo && file.size > MAX_VIDEO_BYTES) { - alert("Video must be smaller than 100MB"); + void dialog.alert("Video must be smaller than 100MB"); return; } if (isImage && file.size > MAX_IMAGE_BYTES) { - alert("Image must be smaller than 10MB"); + void dialog.alert("Image must be smaller than 10MB"); return; } setMediaFile(file); diff --git a/src/components/dialogs/DialogProvider.tsx b/src/components/dialogs/DialogProvider.tsx new file mode 100644 index 0000000..a20f77a --- /dev/null +++ b/src/components/dialogs/DialogProvider.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; + +interface DialogOptions { + title?: string; + confirmLabel?: string; + /** Styles the confirm button red for irreversible actions such as deletes. */ + destructive?: boolean; +} + +interface DialogApi { + /** In-app replacement for `window.alert`; resolves when dismissed. */ + alert(message: string, options?: DialogOptions): Promise; + /** In-app replacement for `window.confirm`; resolves true on confirm, false on cancel. */ + confirm(message: string, options?: DialogOptions): Promise; +} + +interface OpenDialog extends DialogOptions { + id: number; + kind: "alert" | "confirm"; + message: string; + resolve(confirmed: boolean): void; +} + +const DialogContext = createContext(null); + +function Dialog({ dialog, onClose }: { dialog: OpenDialog; onClose(confirmed: boolean): void }) { + const confirmRef = useRef(null); + const isConfirm = dialog.kind === "confirm"; + + useEffect(() => { + confirmRef.current?.focus(); + // Capture phase so Esc/Enter reach the dialog before page shortcuts and inputs behind it. + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" && event.key !== "Enter") return; + event.preventDefault(); + event.stopPropagation(); + onClose(event.key === "Enter"); + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [onClose]); + + const confirmColor = dialog.destructive ? "bg-red-600 hover:bg-red-700" : "bg-blue-600 hover:bg-blue-700"; + + return ( +
{ + if (event.target === event.currentTarget) onClose(false); + }} + role="presentation" + > +
+ {dialog.title ? ( +

+ {dialog.title} +

+ ) : null} +

+ {dialog.message} +

+
+ {isConfirm ? ( + + ) : null} + +
+
+
+ ); +} + +export function DialogProvider({ children }: { children: ReactNode }) { + // A queue, so a second dialog requested while one is open waits its turn instead of replacing it. + const [queue, setQueue] = useState([]); + const nextId = useRef(0); + + const open = useCallback((kind: OpenDialog["kind"], message: string, options?: DialogOptions) => { + const id = nextId.current++; + return new Promise((resolve) => setQueue((current) => [...current, { ...options, id, kind, message, resolve }])); + }, []); + + const api = useMemo( + () => ({ + alert: async (message, options) => { + await open("alert", message, options); + }, + confirm: (message, options) => open("confirm", message, options), + }), + [open], + ); + + const current = queue[0]; + const close = useCallback( + (confirmed: boolean) => { + current?.resolve(confirmed); + setQueue((pending) => pending.filter((dialog) => dialog.id !== current?.id)); + }, + [current], + ); + + return ( + + {children} + {current ? : null} + + ); +} + +export function useDialog(): DialogApi { + const context = useContext(DialogContext); + if (!context) throw new Error("useDialog must be used inside "); + return context; +} diff --git a/src/components/focus/FocusTimer.tsx b/src/components/focus/FocusTimer.tsx index 3dc5bae..6dbc5b3 100644 --- a/src/components/focus/FocusTimer.tsx +++ b/src/components/focus/FocusTimer.tsx @@ -11,6 +11,7 @@ import { GoalForm, hotkeyHint } from "./GoalForm"; import { PixelArt } from "./PixelArt"; import { notify } from "./notifications"; import { playSound } from "./sounds"; +import { useDialog } from "../dialogs/DialogProvider"; const DEFAULT_TITLE = "Boost Focus Timer"; const HEARTBEAT_MS = 5000; @@ -127,6 +128,7 @@ function TimerButton({ } export const FocusTimer = forwardRef(function FocusTimer(props, ref) { + const dialog = useDialog(); const { isActive, onNavigateToTab, onTodayMinutesChange } = props; const [focus, setFocus] = useState(IDLE); @@ -329,9 +331,9 @@ export const FocusTimer = forwardRef(function }); const startFocus = async () => { - if (!goal.trim() && !session) return alert("Please enter a focus goal"); + if (!goal.trim() && !session) return void dialog.alert("Please enter a focus goal"); const minutes = parseInt(inputMinutes); - if (!minutes || minutes <= 0) return alert("Please enter a valid time in minutes"); + if (!minutes || minutes <= 0) return void dialog.alert("Please enter a valid time in minutes"); localStorage.setItem("lastFocusDuration", String(minutes)); try { const created = await databaseService.createFocusSession({ goal, planned_duration_minutes: minutes, session_type: "focus" }); @@ -348,7 +350,7 @@ export const FocusTimer = forwardRef(function sendHeartbeat(created); } catch (error) { console.error("Failed to start session:", error); - alert("Failed to start focus session. Please try again."); + void dialog.alert("Failed to start focus session. Please try again."); } }; @@ -382,7 +384,7 @@ export const FocusTimer = forwardRef(function const extendFocus = async () => { const minutes = parseInt(extendMinutes); - if (isNaN(minutes) || minutes <= 0) return alert("Please enter a valid number of minutes"); + if (isNaN(minutes) || minutes <= 0) return void dialog.alert("Please enter a valid number of minutes"); try { if (session) setSession(await databaseService.updateFocusSession(session.id, { completed: false, end_time: null })); setFocus({ minutes, seconds: 0, isRunning: true, isCompleted: false }); @@ -390,13 +392,13 @@ export const FocusTimer = forwardRef(function setFocusTotalMs(minutes * MINUTE_MS); } catch (error) { console.error("Failed to extend session:", error); - alert("Failed to extend session. Please try again."); + void dialog.alert("Failed to extend session. Please try again."); } }; const startBreak = async () => { const minutes = parseInt(breakMinutes); - if (!minutes || minutes <= 0) return alert("Please enter a valid break time in minutes"); + if (!minutes || minutes <= 0) return void dialog.alert("Please enter a valid break time in minutes"); localStorage.setItem("lastBreakDuration", String(minutes)); setSession(null); setBreakTimer(IDLE); diff --git a/src/components/focus/GoalForm.tsx b/src/components/focus/GoalForm.tsx index cf0f8de..5bb6dc0 100644 --- a/src/components/focus/GoalForm.tsx +++ b/src/components/focus/GoalForm.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from import { Check, Clock, Pencil, Plus, Tag as TagIcon, Target, X } from "../icons"; import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import { databaseService, type Tag } from "../lib/database-service"; +import { useDialog } from "../dialogs/DialogProvider"; export const hotkeyHint = (label: string, combo: string | null): string | undefined => combo ? `${label} (${combo})` : undefined; @@ -107,6 +108,7 @@ function TagSelector({ onEditTags(): void; isActive: boolean; }) { + const dialog = useDialog(); const [allTags, setAllTags] = useState([]); const [isOpen, setIsOpen] = useState(false); const [isCreating, setIsCreating] = useState(false); @@ -139,7 +141,7 @@ function TagSelector({ setIsCreating(false); } catch (error) { console.error("Failed to create tag:", error); - alert("Failed to create tag. It might already exist."); + void dialog.alert("Failed to create tag. It might already exist."); } }; diff --git a/src/components/focus/notifications.ts b/src/components/focus/notifications.ts index 1db2ec9..75e3f87 100644 --- a/src/components/focus/notifications.ts +++ b/src/components/focus/notifications.ts @@ -53,6 +53,35 @@ export async function requestNotificationPermission(): Promise void { + if (notificationStatus() !== "default") return () => {}; + let isDisposed = false; + const askFromGesture = () => { + stopListening(); + void requestNotificationPermission(); + }; + const stopListening = () => GESTURE_EVENTS.forEach((type) => window.removeEventListener(type, askFromGesture, true)); + const startedAt = Date.now(); + void requestNotificationPermission().then((result) => { + const browserRefusedToPrompt = result === "default" && Date.now() - startedAt < NO_PROMPT_SHOWN_MS; + if (!isDisposed && browserRefusedToPrompt) GESTURE_EVENTS.forEach((type) => window.addEventListener(type, askFromGesture, true)); + }); + return () => { + isDisposed = true; + stopListening(); + }; +} + let clickHandlerInstalled = false; let previousExtensionId: string | null = null; let previousWebNotification: Notification | null = null; diff --git a/src/components/pages/GoalsPage.tsx b/src/components/pages/GoalsPage.tsx index 15ad21e..67dd087 100644 --- a/src/components/pages/GoalsPage.tsx +++ b/src/components/pages/GoalsPage.tsx @@ -17,6 +17,7 @@ import { type GoalData, type UserDataRow, } from "./goals/goal-rows"; +import { useDialog } from "../dialogs/DialogProvider"; const DELETE_GOAL_PROMPT = "Are you sure you want to permanently delete this goal?\n\nConsider archiving it instead to keep your history."; @@ -36,6 +37,7 @@ function renumber(list: readonly Goal[]): { list: Goal[]; changed: Goal[] } { } export function GoalsPage({ userId }: { userId: string }) { + const dialog = useDialog(); const [goals, setGoals] = useState([]); const [comments, setComments] = useState({}); const [loading, setLoading] = useState(true); @@ -143,8 +145,8 @@ export function GoalsPage({ userId }: { userId: string }) { setTimeout(() => inputRef.current?.focus(), 0); }; - const deleteGoal = (id: string) => { - if (!confirm(DELETE_GOAL_PROMPT)) return; + const deleteGoal = async (id: string) => { + if (!(await dialog.confirm(DELETE_GOAL_PROMPT, { confirmLabel: "Delete", destructive: true }))) return; setGoals((prev) => prev.filter((g) => g.id !== id)); deleteUserDataRow(id); }; @@ -164,8 +166,8 @@ export function GoalsPage({ userId }: { userId: string }) { return !error; }; - const deleteComment = (id: string) => { - if (confirm("Delete this comment?")) deleteUserDataRow(id); + const deleteComment = async (id: string) => { + if (await dialog.confirm("Delete this comment?", { confirmLabel: "Delete", destructive: true })) deleteUserDataRow(id); }; const persistOrder = async (ordered: readonly Goal[]) => { diff --git a/src/components/pages/HabitsPage.tsx b/src/components/pages/HabitsPage.tsx index 875f286..1179454 100644 --- a/src/components/pages/HabitsPage.tsx +++ b/src/components/pages/HabitsPage.tsx @@ -28,6 +28,7 @@ import { type HabitRow, type ViewMode, } from "./habits/habit-model"; +import { useDialog } from "../dialogs/DialogProvider"; type NoteMap = Record; @@ -186,6 +187,7 @@ function CreateHabitForm({ onCreate, onClose }: { onCreate(edit: HabitEdit): Pro } export function HabitsPage({ userId }: { userId: string }) { + const dialog = useDialog(); const { habits, setHabits, completions, setCompletions, notes, setNotes, loading, loadCompletions, persistOrder } = useHabitData(userId); const [showAddForm, setShowAddForm] = useState(false); useHotkey("list.new", () => setShowAddForm(true)); @@ -232,7 +234,7 @@ export function HabitsPage({ userId }: { userId: string }) { const deleteHabit = async (habit: Habit) => { const confirmText = "Are you sure you want to permanently delete this habit? This will delete all tracking history."; - if (!habit.is_suggested && !window.confirm(confirmText)) return; + if (!habit.is_suggested && !(await dialog.confirm(confirmText, { confirmLabel: "Delete", destructive: true }))) return; const backup = { habits, completions, notes }; const relatedLogIds = completions.filter((c) => c.habit_id === habit.id).map((c) => c.id); const relatedNoteIds = Object.values(notes).filter((n) => n.habit_id === habit.id).map((n) => n.id); @@ -309,7 +311,7 @@ export function HabitsPage({ userId }: { userId: string }) { if (!noteTarget) return; const key = noteKey(noteTarget.habitId, noteTarget.dateString); const existing = notes[key]; - if (!existing || !window.confirm("Delete this note?")) return; + if (!existing || !(await dialog.confirm("Delete this note?", { confirmLabel: "Delete", destructive: true }))) return; const { error } = await table().delete().eq("id", existing.id); if (error) { console.error("Failed to delete note:", error); diff --git a/src/components/pages/HistoryPage.tsx b/src/components/pages/HistoryPage.tsx index 430b89f..e372f2a 100644 --- a/src/components/pages/HistoryPage.tsx +++ b/src/components/pages/HistoryPage.tsx @@ -57,6 +57,7 @@ import { } from "./history/stats"; import { TagSelector } from "./history/TagSelector"; import { TagsAnalytics } from "./history/TagsAnalytics"; +import { useDialog } from "../dialogs/DialogProvider"; type PauseDetail = { start: string; end: string | null }; @@ -197,6 +198,7 @@ function MetricCard({ icon, label, scope, value, sub }: { icon: ReactNode; label } export function HistoryPage({ userId }: { userId: string }) { + const dialog = useDialog(); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); const [dayOffset, setDayOffset] = useState(0); @@ -357,13 +359,13 @@ export function HistoryPage({ userId }: { userId: string }) { }) .catch((e: unknown) => { console.error("Failed to update session:", e); - alert("Failed to update session times"); + void dialog.alert("Failed to update session times"); }) .finally(() => setSaving(false)); } - function doDeleteSession(sessionId: string) { - if (!confirm("Are you sure you want to delete this session? This action cannot be undone.")) return; + async function doDeleteSession(sessionId: string) { + if (!(await dialog.confirm("Are you sure you want to delete this session? This action cannot be undone.", { confirmLabel: "Delete", destructive: true }))) return; setDeleting(sessionId); deleteSessionById(sessionId) .then(() => { @@ -375,7 +377,7 @@ export function HistoryPage({ userId }: { userId: string }) { }) .catch((e: unknown) => { console.error("Failed to delete session:", e); - alert("Failed to delete session"); + void dialog.alert("Failed to delete session"); }) .finally(() => setDeleting(null)); } diff --git a/src/components/pages/history/TagSelector.tsx b/src/components/pages/history/TagSelector.tsx index 4f06f71..13b355a 100644 --- a/src/components/pages/history/TagSelector.tsx +++ b/src/components/pages/history/TagSelector.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState, type MouseEvent } from "react"; import { Check, Plus, Tag as TagIcon, X } from "../../icons"; import { addTagToSession, createTagInDb, fetchTags, removeTagFromSession, TAG_COLORS, type Tag } from "./data"; +import { useDialog } from "../../dialogs/DialogProvider"; interface TagSelectorProps { userId: string; @@ -13,6 +14,7 @@ interface TagSelectorProps { } export function TagSelector({ userId, sessionId, selectedTags, onTagsChange, compact = false }: TagSelectorProps) { + const dialog = useDialog(); const [allTags, setAllTags] = useState([]); const [showDropdown, setShowDropdown] = useState(false); const [newTagName, setNewTagName] = useState(""); @@ -66,7 +68,7 @@ export function TagSelector({ userId, sessionId, selectedTags, onTagsChange, com }) .catch((e: unknown) => { console.error("Failed to create tag:", e); - alert("Failed to create tag. It might already exist."); + void dialog.alert("Failed to create tag. It might already exist."); }); } diff --git a/src/components/settings/TagsPage.tsx b/src/components/settings/TagsPage.tsx index 5d1d128..354152a 100644 --- a/src/components/settings/TagsPage.tsx +++ b/src/components/settings/TagsPage.tsx @@ -6,6 +6,7 @@ import { useHotkey } from "../hotkeys/HotkeysProvider"; import { localDb } from "../lib/local-db"; import { createTagInDb, fetchTags, type Tag } from "../pages/history/data"; import type { SubPageProps } from "../types"; +import { useDialog } from "../dialogs/DialogProvider"; const TAG_COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16", "#6366f1", "#f97316", "#14b8a6", "#a855f7"]; @@ -64,6 +65,7 @@ function ColorGrid({ value, onChange }: { value: string; onChange(color: string) } export function TagsPage({ userId, onBack }: SubPageProps) { + const dialog = useDialog(); const [tags, setTags] = useState([]); const [loading, setLoading] = useState(true); const [editingId, setEditingId] = useState(null); @@ -103,7 +105,7 @@ export function TagsPage({ userId, onBack }: SubPageProps) { resetNew(); } catch (error) { console.error("Error adding tag:", error); - alert("Failed to create tag. It might already exist."); + void dialog.alert("Failed to create tag. It might already exist."); } } @@ -122,7 +124,7 @@ export function TagsPage({ userId, onBack }: SubPageProps) { setEditingId(null); } catch (error) { console.error("Error updating tag:", error); - alert("Failed to update tag. The name might already exist."); + void dialog.alert("Failed to update tag. The name might already exist."); } } @@ -138,18 +140,18 @@ export function TagsPage({ userId, onBack }: SubPageProps) { await loadTags(); } catch (error) { console.error("Error archiving/unarchiving tag:", error); - alert("Failed to archive/unarchive tag."); + void dialog.alert("Failed to archive/unarchive tag."); } } async function removeTag(tag: Tag) { - if (!confirm(tag.archived ? DELETE_ARCHIVED_CONFIRM : DELETE_ACTIVE_CONFIRM)) return; + if (!(await dialog.confirm(tag.archived ? DELETE_ARCHIVED_CONFIRM : DELETE_ACTIVE_CONFIRM, { confirmLabel: "Delete", destructive: true }))) return; try { await deleteTag(tag.id); setTags(tags.filter((t) => t.id !== tag.id)); } catch (error) { console.error("Error deleting tag:", error); - alert("Failed to delete tag."); + void dialog.alert("Failed to delete tag."); } } diff --git a/src/components/shell/StartMenu.tsx b/src/components/shell/StartMenu.tsx index e946b01..907122b 100644 --- a/src/components/shell/StartMenu.tsx +++ b/src/components/shell/StartMenu.tsx @@ -6,6 +6,7 @@ import { Bell, ChevronDown, CircleUser, Menu, Settings, Tag } from "../icons"; import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import type { Tab } from "../types"; import { MOBILE_NAV_OFFSET, MOBILE_QUERY, useMediaQuery } from "./use-media-query"; +import { useDialog } from "../dialogs/DialogProvider"; const SUB_ITEM = "w-full px-4 py-2 text-left text-xs text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors flex items-center gap-2"; @@ -31,12 +32,13 @@ const NOTIFICATION_HELP: Readonly> = { /** Shows the browser's notification permission; asks again when the first-open prompt was dismissed. */ function NotificationsItem() { + const dialog = useDialog(); const [status, setStatus] = useState(notificationStatus); if (status === "unsupported") return null; const act = async () => { if (status === "default") setStatus(await requestNotificationPermission()); - else alert(NOTIFICATION_HELP[status]); + else await dialog.alert(NOTIFICATION_HELP[status], { title: "Notifications" }); }; return (