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 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.
Expand Down
5 changes: 3 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
// Browser extensions (e.g. ColorZilla) add attributes to <html>/<body> before React hydrates.
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning>{children}</body>
</html>
);
}
9 changes: 6 additions & 3 deletions src/components/focus/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@ export async function requestNotificationPermission(): Promise<NotificationPermi
}
}

/** Shows a system notification only while the timer tab is out of view; the chime covers the visible case. */
export type NotificationStatus = "unsupported" | NotificationPermission;

export const notificationStatus = (): NotificationStatus => (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();
Expand Down
32 changes: 31 additions & 1 deletion src/components/shell/StartMenu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +16,34 @@ const SETTINGS: readonly { label: string; Icon: ComponentType<{ className?: stri
{ label: "Tags", Icon: Tag, target: "tags" },
];

const NOTIFICATION_LABELS: Readonly<Record<Exclude<NotificationStatus, "unsupported">, 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>(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 (
<button onClick={() => void act()} className={SUB_ITEM}>
<Bell className="w-4 h-4" />
<span>{NOTIFICATION_LABELS[status]}</span>
</button>
);
}

export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) {
const [isOpen, setIsOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(true);
Expand Down Expand Up @@ -59,6 +88,7 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) {
<span>{label}</span>
</button>
))}
<NotificationsItem />
</div>
) : null}
</div>
Expand Down
Loading