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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +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).
- **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 scripts/build-extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ function writeManifest() {
version,
description,
icons,
// Lets the timer page show session-complete notifications without a prompt.
permissions: ["notifications"],
action: { default_title: "Open Boost Focus Timer", default_icon: icons },
background: { service_worker: "background.js" },
// Unassigned by default; users pick a key at chrome://extensions/shortcuts.
Expand Down
5 changes: 5 additions & 0 deletions src/components/focus/FocusTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +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 { playSound } from "./sounds";

const DEFAULT_TITLE = "Boost Focus Timer";
Expand Down Expand Up @@ -292,6 +293,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
onTick: (minutes, seconds) => setFocus((current) => ({ ...current, minutes, seconds })),
onComplete: () => {
playSound("end");
notify("Focus session complete", `${goal.trim() || "Nice work"} · time for a break.`);
setFocusStart(null);
setFocus({ ...IDLE, isCompleted: true });
void completeSession();
Expand Down Expand Up @@ -322,12 +324,14 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
onTick: (minutes, seconds) => setBreakTimer((current) => ({ ...current, minutes, seconds })),
onComplete: () => {
playSound("end");
notify("Break is over", "Ready for the next focus session?");
void completeBreakSession();
finishBreak();
},
});

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 @@ -394,6 +398,7 @@ 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
28 changes: 28 additions & 0 deletions src/components/focus/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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 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) => {
console.error("Notification permission request failed:", error);
});
}

/** Shows a system notification only while the timer tab is out of view; the chime covers the visible case. */
export function notify(title: string, body: string): void {
if (!isSupported() || Notification.permission !== "granted" || document.visibilityState === "visible") 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();
notification.close();
};
} catch (error) {
console.error("Could not show notification:", error);
}
}
Loading