Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>↑</kbd>/<kbd>↓</kbd> in the goal field).
- **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**.
- **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.
- **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.
Expand Down
121 changes: 96 additions & 25 deletions src/components/focus/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,80 @@
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";
/*
* 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.
*/

const isSupported = (): boolean => typeof window !== "undefined" && "Notification" in window;
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";
const PREFERENCE_KEY = "bft:notifications";
const LEGACY_DISMISSED_KEY = "bft:notificationOfferDismissed";
export const NOTIFICATIONS_ENABLED_EVENT = "bft:notifications-enabled";

/** 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);
/** The slice of the extension `chrome` API used here; the web build never has it. */
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<string>;
onClicked: { addListener(listener: (id: string) => void): void };
};
tabs: {
getCurrent(): Promise<{ id?: number; windowId?: number } | undefined>;
update(tabId: number, properties: { active: boolean }): Promise<unknown>;
};
windows: { update(windowId: number, properties: { focused: boolean }): Promise<unknown> };
}

export function dismissNotificationOffer(): void {
localStorage.setItem(OFFER_DISMISSED_KEY, "1");
function extensionApi(): ExtensionApi | null {
const candidate = (globalThis as { chrome?: Partial<ExtensionApi> }).chrome;
return candidate?.runtime?.id && candidate.notifications ? (candidate as ExtensionApi) : null;
}

/** Must run from a click or key press; browsers ignore permission requests made outside a user gesture. */
export async function requestNotificationPermission(): Promise<NotificationPermission | null> {
if (!isSupported()) return null;
try {
return await Notification.requestPermission();
} catch (error) {
console.error("Notification permission request failed:", error);
return null;
}
const hasWebNotifications = (): boolean => typeof window !== "undefined" && "Notification" in window;

export type NotificationStatus = "unsupported" | "undecided" | "on" | "off" | "blocked";

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 type NotificationStatus = "unsupported" | NotificationPermission;
const writePreference = (value: "on" | "off") => localStorage.setItem(PREFERENCE_KEY, value);

export const notificationStatus = (): NotificationStatus => (isSupported() ? Notification.permission : "unsupported");
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 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") return;
let clickHandlerInstalled = false;

/** Clicking an extension notification brings the timer tab and its window to the front. */
function installExtensionClickHandler(api: ExtensionApi): void {
if (clickHandlerInstalled) return;
clickHandlerInstalled = true;
api.notifications.onClicked.addListener(async () => {
const tab = await api.tabs.getCurrent();
if (tab?.id !== undefined) await api.tabs.update(tab.id, { active: true });
if (tab?.windowId !== undefined) await api.windows.update(tab.windowId, { focused: true });
});
}

function show(title: string, body: string): void {
const api = extensionApi();
try {
const notification = new Notification(title, { body, icon: ICON, tag: TAG, silent: true });
if (api) {
installExtensionClickHandler(api);
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));
return;
}
// 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 });
notification.onclick = () => {
window.focus();
notification.close();
Expand All @@ -42,3 +83,33 @@ export function notify(title: string, body: string): void {
console.error("Could not show notification:", error);
}
}

/** Shows a notification when the user turned them on. */
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<NotificationStatus> {
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");
}
68 changes: 44 additions & 24 deletions src/components/shell/NotificationPrompt.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,66 @@
"use client";

import { useState } from "react";
import { dismissNotificationOffer, requestNotificationPermission, shouldOfferNotifications } from "../focus/notifications";
import { Bell, X } from "../icons";
import { useEffect, useState } from "react";
import { disableNotifications, enableNotifications, notificationStatus, NOTIFICATIONS_ENABLED_EVENT } from "../focus/notifications";
import { Bell, Check, X } from "../icons";

/** First-visit banner: the browser only shows its permission dialog after a click, so this provides the click. */
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 [isVisible, setIsVisible] = useState(shouldOfferNotifications);
const [stage, setStage] = useState<Stage>(() => (notificationStatus() === "undecided" ? "ask" : "hidden"));

useEffect(() => {
const showConfirmation = () => setStage("sent");
window.addEventListener(NOTIFICATIONS_ENABLED_EVENT, showConfirmation);
return () => window.removeEventListener(NOTIFICATIONS_ENABLED_EVENT, showConfirmation);
}, []);

if (!isVisible) return null;
if (stage === "hidden") return null;

if (stage === "sent") {
return (
<div role="status" className={PANEL}>
<Check className="w-5 h-5 mt-0.5 shrink-0 text-green-400" />
<p className="flex-1 text-sm text-gray-300">
Notifications are on, and a sample was just sent. Didn&apos;t see it? Allow notifications for your browser in your
system settings and turn off Do Not Disturb or Focus mode.
</p>
<button onClick={() => setStage("hidden")} aria-label="Close" title="Close" className={CLOSE_BUTTON}>
<X className="w-4 h-4" />
</button>
</div>
);
}

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 status = await enableNotifications();
if (status !== "on" && status !== "undecided") setStage("hidden");
};

const notNow = () => {
dismissNotificationOffer();
setIsVisible(false);
disableNotifications();
setStage("hidden");
};

return (
<div
role="dialog"
aria-label="Enable notifications"
className="fixed top-14 left-1/2 -translate-x-1/2 z-50 w-[calc(100%-1.5rem)] max-w-md flex items-center 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]"
>
<div role="dialog" aria-label="Enable notifications" className={`${PANEL} items-center`}>
<Bell className="w-5 h-5 shrink-0 text-blue-400" />
<p className="flex-1 text-sm text-gray-300">Get a notification when a focus session or break ends.</p>
<p className="flex-1 text-sm text-gray-300">Get a notification when a focus session or break ends?</p>
<button
onClick={() => void allow()}
className="shrink-0 px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Allow
Turn on
</button>
<button
onClick={notNow}
aria-label="Not now"
title="Not now"
className="shrink-0 p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
>
<button onClick={notNow} aria-label="Not now" title="Not now" className={CLOSE_BUTTON}>
<X className="w-4 h-4" />
</button>
</div>
Expand Down
31 changes: 21 additions & 10 deletions src/components/shell/StartMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useEffect, useRef, useState, type ComponentType } from "react";
import { notificationStatus, notify, requestNotificationPermission, type NotificationStatus } from "../focus/notifications";
import { disableNotifications, enableNotifications, notificationStatus, 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";
Expand All @@ -17,27 +17,38 @@ const SETTINGS: readonly { label: string; Icon: ComponentType<{ className?: stri
];

const NOTIFICATION_LABELS: Readonly<Record<Exclude<NotificationStatus, "unsupported">, string>> = {
default: "Turn on notifications",
granted: "Notifications on · test",
denied: "Notifications blocked",
on: "Notifications: On",
off: "Notifications: Off",
undecided: "Notifications: Off",
blocked: "Notifications: Blocked",
};

const NOTIFICATION_HINTS: Readonly<Record<Exclude<NotificationStatus, "unsupported">, 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 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. */
/** A switch row: shows the current state and flips it; a blocked browser gets instructions instead. */
function NotificationsItem() {
const [status, setStatus] = useState<NotificationStatus>(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);
const toggle = async () => {
if (status === "blocked") return alert(BLOCKED_HELP);
if (status === "on") {
disableNotifications();
return setStatus("off");
}
setStatus(await enableNotifications());
};

return (
<button onClick={() => void act()} className={SUB_ITEM}>
<button onClick={() => void toggle()} title={NOTIFICATION_HINTS[status]} className={SUB_ITEM}>
<Bell className="w-4 h-4" />
<span>{NOTIFICATION_LABELS[status]}</span>
</button>
Expand Down
Loading