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 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.
- **Notifications** when a focus session or break runs out. The app asks for permission with Chrome's own prompt the first time you open it; **Menu → Settings → Notifications** shows the current state and asks again if you dismissed the prompt. If nothing 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
14 changes: 9 additions & 5 deletions src/components/BoostApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

import dynamic from "next/dynamic";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { DialogProvider } from "./dialogs/DialogProvider";
import { FocusTimer } from "./focus/FocusTimer";
import { askForNotificationsOnFirstOpen } from "./focus/notifications";
import { HotkeysProvider, useHotkey, useHotkeyMap } from "./hotkeys/HotkeysProvider";
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 All @@ -28,9 +29,11 @@ function formatTodayMinutes(minutes: number): string {

export function BoostApp() {
return (
<HotkeysProvider>
<AppShell />
</HotkeysProvider>
<DialogProvider>
<HotkeysProvider>
<AppShell />
</HotkeysProvider>
</DialogProvider>
);
}

Expand All @@ -53,6 +56,8 @@ function AppShell() {
const isMobile = useMediaQuery(MOBILE_QUERY);
const isShortViewport = useMediaQuery(SHORT_VIEWPORT_QUERY);

useEffect(() => askForNotificationsOnFirstOpen(), []);

useEffect(() => {
databaseService.getTodayFocusMinutes().then(setTodayMinutes, (error: unknown) => {
console.error("Error loading today minutes:", error);
Expand Down Expand Up @@ -210,7 +215,6 @@ function AppShell() {
<BottomNav activeTab={tab} onTabChange={setTab} />
</div>
<StartMenu onNavigate={navigateFromCurrent} />
<NotificationPrompt />
</>
);
}
6 changes: 4 additions & 2 deletions src/components/boost/BoostFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useHotkey } from "../hotkeys/HotkeysProvider";
import { getDisplayName } from "../lib/profile";
import { getLocalUserId, localDb, type Row } from "../lib/local-db";
import { PostContent } from "./PostContent";
import { useDialog } from "../dialogs/DialogProvider";

type BoostPost = Row & {
user_id: string;
Expand Down Expand Up @@ -86,6 +87,7 @@ function UploadedPhoto({ src }: { src: string }) {
}

export function BoostFeed() {
const dialog = useDialog();
const [posts, setPosts] = useState<BoostPost[]>([]);
const [likes, setLikes] = useState(new Map<string, LikeState>());
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -132,11 +134,11 @@ export function BoostFeed() {
const isImage = file.type.startsWith("image/");
if (!isVideo && !isImage) return;
if (isVideo && file.size > MAX_VIDEO_BYTES) {
alert("Video must be smaller than 100MB");
void dialog.alert("Video must be smaller than 100MB");
return;
}
if (isImage && file.size > MAX_IMAGE_BYTES) {
alert("Image must be smaller than 10MB");
void dialog.alert("Image must be smaller than 10MB");
return;
}
setMediaFile(file);
Expand Down
133 changes: 133 additions & 0 deletions src/components/dialogs/DialogProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"use client";

import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";

interface DialogOptions {
title?: string;
confirmLabel?: string;
/** Styles the confirm button red for irreversible actions such as deletes. */
destructive?: boolean;
}

interface DialogApi {
/** In-app replacement for `window.alert`; resolves when dismissed. */
alert(message: string, options?: DialogOptions): Promise<void>;
/** In-app replacement for `window.confirm`; resolves true on confirm, false on cancel. */
confirm(message: string, options?: DialogOptions): Promise<boolean>;
}

interface OpenDialog extends DialogOptions {
id: number;
kind: "alert" | "confirm";
message: string;
resolve(confirmed: boolean): void;
}

const DialogContext = createContext<DialogApi | null>(null);

function Dialog({ dialog, onClose }: { dialog: OpenDialog; onClose(confirmed: boolean): void }) {
const confirmRef = useRef<HTMLButtonElement>(null);
const isConfirm = dialog.kind === "confirm";

useEffect(() => {
confirmRef.current?.focus();
// Capture phase so Esc/Enter reach the dialog before page shortcuts and inputs behind it.
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" && event.key !== "Enter") return;
event.preventDefault();
event.stopPropagation();
onClose(event.key === "Enter");
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [onClose]);

const confirmColor = dialog.destructive ? "bg-red-600 hover:bg-red-700" : "bg-blue-600 hover:bg-blue-700";

return (
<div
className="fixed inset-0 z-[70] bg-black/60 flex items-center justify-center p-4 motion-safe:animate-[bft-fade-in_150ms_ease-out]"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose(false);
}}
role="presentation"
>
<div
role="alertdialog"
aria-modal="true"
aria-labelledby={dialog.title ? "app-dialog-title" : undefined}
aria-describedby="app-dialog-message"
className="bg-gray-800 rounded-2xl p-6 max-w-md w-full border border-gray-700 shadow-2xl motion-safe:animate-[bft-sheet-in_200ms_ease-out]"
>
{dialog.title ? (
<h2 id="app-dialog-title" className="text-lg font-bold text-white mb-3">
{dialog.title}
</h2>
) : null}
<p id="app-dialog-message" className="text-gray-300 mb-6 whitespace-pre-line">
{dialog.message}
</p>
<div className="flex gap-3 justify-end">
{isConfirm ? (
<button
onClick={() => onClose(false)}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
>
Cancel
</button>
) : null}
<button
ref={confirmRef}
onClick={() => onClose(true)}
className={`px-4 py-2 ${confirmColor} text-white rounded-lg font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400`}
>
{dialog.confirmLabel ?? (isConfirm ? "Confirm" : "OK")}
</button>
</div>
</div>
</div>
);
}

export function DialogProvider({ children }: { children: ReactNode }) {
// A queue, so a second dialog requested while one is open waits its turn instead of replacing it.
const [queue, setQueue] = useState<OpenDialog[]>([]);
const nextId = useRef(0);

const open = useCallback((kind: OpenDialog["kind"], message: string, options?: DialogOptions) => {
const id = nextId.current++;
return new Promise<boolean>((resolve) => setQueue((current) => [...current, { ...options, id, kind, message, resolve }]));
}, []);

const api = useMemo<DialogApi>(
() => ({
alert: async (message, options) => {
await open("alert", message, options);
},
confirm: (message, options) => open("confirm", message, options),
}),
[open],
);

const current = queue[0];
const close = useCallback(
(confirmed: boolean) => {
current?.resolve(confirmed);
setQueue((pending) => pending.filter((dialog) => dialog.id !== current?.id));
},
[current],
);

return (
<DialogContext.Provider value={api}>
{children}
{current ? <Dialog key={current.id} dialog={current} onClose={close} /> : null}
</DialogContext.Provider>
);
}

export function useDialog(): DialogApi {
const context = useContext(DialogContext);
if (!context) throw new Error("useDialog must be used inside <DialogProvider>");
return context;
}
14 changes: 8 additions & 6 deletions src/components/focus/FocusTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { GoalForm, hotkeyHint } from "./GoalForm";
import { PixelArt } from "./PixelArt";
import { notify } from "./notifications";
import { playSound } from "./sounds";
import { useDialog } from "../dialogs/DialogProvider";

const DEFAULT_TITLE = "Boost Focus Timer";
const HEARTBEAT_MS = 5000;
Expand Down Expand Up @@ -127,6 +128,7 @@ function TimerButton({
}

export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function FocusTimer(props, ref) {
const dialog = useDialog();
const { isActive, onNavigateToTab, onTodayMinutesChange } = props;

const [focus, setFocus] = useState<Countdown>(IDLE);
Expand Down Expand Up @@ -329,9 +331,9 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
});

const startFocus = async () => {
if (!goal.trim() && !session) return alert("Please enter a focus goal");
if (!goal.trim() && !session) return void dialog.alert("Please enter a focus goal");
const minutes = parseInt(inputMinutes);
if (!minutes || minutes <= 0) return alert("Please enter a valid time in minutes");
if (!minutes || minutes <= 0) return void dialog.alert("Please enter a valid time in minutes");
localStorage.setItem("lastFocusDuration", String(minutes));
try {
const created = await databaseService.createFocusSession({ goal, planned_duration_minutes: minutes, session_type: "focus" });
Expand All @@ -348,7 +350,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
sendHeartbeat(created);
} catch (error) {
console.error("Failed to start session:", error);
alert("Failed to start focus session. Please try again.");
void dialog.alert("Failed to start focus session. Please try again.");
}
};

Expand Down Expand Up @@ -382,21 +384,21 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function

const extendFocus = async () => {
const minutes = parseInt(extendMinutes);
if (isNaN(minutes) || minutes <= 0) return alert("Please enter a valid number of minutes");
if (isNaN(minutes) || minutes <= 0) return void dialog.alert("Please enter a valid number of minutes");
try {
if (session) setSession(await databaseService.updateFocusSession(session.id, { completed: false, end_time: null }));
setFocus({ minutes, seconds: 0, isRunning: true, isCompleted: false });
setFocusStart(Date.now());
setFocusTotalMs(minutes * MINUTE_MS);
} catch (error) {
console.error("Failed to extend session:", error);
alert("Failed to extend session. Please try again.");
void dialog.alert("Failed to extend session. Please try again.");
}
};

const startBreak = async () => {
const minutes = parseInt(breakMinutes);
if (!minutes || minutes <= 0) return alert("Please enter a valid break time in minutes");
if (!minutes || minutes <= 0) return void dialog.alert("Please enter a valid break time in minutes");
localStorage.setItem("lastBreakDuration", String(minutes));
setSession(null);
setBreakTimer(IDLE);
Expand Down
4 changes: 3 additions & 1 deletion src/components/focus/GoalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from
import { Check, Clock, Pencil, Plus, Tag as TagIcon, Target, X } from "../icons";
import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider";
import { databaseService, type Tag } from "../lib/database-service";
import { useDialog } from "../dialogs/DialogProvider";

export const hotkeyHint = (label: string, combo: string | null): string | undefined =>
combo ? `${label} (${combo})` : undefined;
Expand Down Expand Up @@ -107,6 +108,7 @@ function TagSelector({
onEditTags(): void;
isActive: boolean;
}) {
const dialog = useDialog();
const [allTags, setAllTags] = useState<Tag[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isCreating, setIsCreating] = useState(false);
Expand Down Expand Up @@ -139,7 +141,7 @@ function TagSelector({
setIsCreating(false);
} catch (error) {
console.error("Failed to create tag:", error);
alert("Failed to create tag. It might already exist.");
void dialog.alert("Failed to create tag. It might already exist.");
}
};

Expand Down
Loading
Loading