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 (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.
Expand Down
2 changes: 2 additions & 0 deletions src/components/BoostApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -209,6 +210,7 @@ function AppShell() {
<BottomNav activeTab={tab} onTabChange={setTab} />
</div>
<StartMenu onNavigate={navigateFromCurrent} />
<NotificationPrompt />
</>
);
}
6 changes: 1 addition & 5 deletions src/components/focus/FocusTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -229,7 +229,6 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
setBreakStart(null);
setFocusTotalMs(0);
setBreakTotalMs(0);
setGoal("");
setInputMinutes(storedFocusMinutes());
setBreakMinutes(storedBreakMinutes());
setExtendMinutes("10");
Expand Down Expand Up @@ -311,7 +310,6 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
setBreakStart(null);
setBreakTotalMs(0);
setBreakPauseId(null);
setGoal("");
setInputMinutes(storedFocusMinutes());
setBreakMinutes(storedBreakMinutes());
};
Expand All @@ -331,7 +329,6 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(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");
Expand Down Expand Up @@ -398,7 +395,6 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(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));
Expand Down
23 changes: 18 additions & 5 deletions src/components/focus/notifications.ts
Original file line number Diff line number Diff line change
@@ -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<NotificationPermission | null> {
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. */
Expand Down
1 change: 1 addition & 0 deletions src/components/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]]);
Expand Down
48 changes: 48 additions & 0 deletions src/components/shell/NotificationPrompt.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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]"
>
<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>
<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
</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"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
Loading