diff --git a/README.md b/README.md index ce867d6..a1ce6db 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 (the app offers to turn them on the first time you open it). +- **Notifications** when a focus session or break runs out. The app offers to turn them on the first time you open it; you can also turn them on, test them or see why they are blocked from **Menu → Settings → Notifications**. - **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/app/layout.tsx b/src/app/layout.tsx index 4a0710f..64a57b3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -21,8 +21,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + // Browser extensions (e.g. ColorZilla) add attributes to / before React hydrates. + + {children} ); } diff --git a/src/components/focus/notifications.ts b/src/components/focus/notifications.ts index 1cdd806..41ca231 100644 --- a/src/components/focus/notifications.ts +++ b/src/components/focus/notifications.ts @@ -25,11 +25,14 @@ export async function requestNotificationPermission(): Promise (isSupported() ? Notification.permission : "unsupported"); + +/** Shows a system notification when the user has allowed them; silent because the end bell already plays. */ export function notify(title: string, body: string): void { - if (!isSupported() || Notification.permission !== "granted" || document.visibilityState === "visible") return; + if (!isSupported() || Notification.permission !== "granted") 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(); diff --git a/src/components/shell/StartMenu.tsx b/src/components/shell/StartMenu.tsx index e89c73e..20ecbaf 100644 --- a/src/components/shell/StartMenu.tsx +++ b/src/components/shell/StartMenu.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect, useRef, useState, type ComponentType } from "react"; -import { ChevronDown, CircleUser, Menu, Settings, Tag } from "../icons"; +import { notificationStatus, notify, 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"; import { MOBILE_NAV_OFFSET, MOBILE_QUERY, useMediaQuery } from "./use-media-query"; @@ -15,6 +16,34 @@ const SETTINGS: readonly { label: string; Icon: ComponentType<{ className?: stri { label: "Tags", Icon: Tag, target: "tags" }, ]; +const NOTIFICATION_LABELS: Readonly, string>> = { + default: "Turn on notifications", + granted: "Notifications on · test", + denied: "Notifications blocked", +}; + +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."; + +/** One menu row that shows the current permission and does the useful next step for it. */ +function NotificationsItem() { + const [status, setStatus] = useState(notificationStatus); + if (status === "unsupported") return null; + + const act = async () => { + if (status === "default") setStatus((await requestNotificationPermission()) ?? notificationStatus()); + else if (status === "granted") notify("Notifications are on", "You'll get one when a focus session or break ends."); + else alert(BLOCKED_HELP); + }; + + return ( + + ); +} + export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { const [isOpen, setIsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(true); @@ -59,6 +88,7 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { {label} ))} + ) : null}