)
+ .catch(() => {});
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
if (!cur) return null;
const total = cur.minutes * 60 || 1;
@@ -414,6 +580,7 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[];
const goBack = () => {
const prev = Math.max(0, idx - 1);
const target = ordered[prev];
+ void cancelAlarmsFor([cur.id]);
// Reset target so going back never lands on 0s (fixes instant auto-skip)
if (target && target.secondsLeft <= 0)
db.steps.update(target.id, { status: 'todo', secondsLeft: Math.max(60, target.minutes * 60) }).catch(() => {});
@@ -422,14 +589,38 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[];
setIdx(prev);
};
const completeStep = () => {
- db.steps.update(cur.id, { status: 'done', secondsLeft: 0 }).catch(() => {});
+ db.steps.update(cur.id, { status: 'done', secondsLeft: 0, endsAt: undefined }).catch(() => {});
+ void cancelAlarmsFor([cur.id]);
if (idx + 1 < ordered.length) setIdx(idx + 1);
- else { db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); onClose(); }
+ else {
+ db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {});
+ void cancelAllAlarms();
+ void fgStop();
+ onClose();
+ }
};
const skipStep = () => {
- db.steps.update(cur.id, { status: 'skipped', secondsLeft: 0 }).catch(() => {});
+ db.steps.update(cur.id, { status: 'skipped', secondsLeft: 0, endsAt: undefined }).catch(() => {});
+ void cancelAlarmsFor([cur.id]);
if (idx + 1 < ordered.length) setIdx(idx + 1);
- else { db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); onClose(); }
+ else {
+ db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {});
+ void cancelAllAlarms();
+ void fgStop();
+ onClose();
+ }
+ };
+ const togglePause = () => {
+ if (running) {
+ setRunning(false);
+ db.steps.update(cur.id, { endsAt: undefined }).catch(() => {});
+ void cancelAlarmsFor(ordered.map((s) => s.id));
+ void fgStop();
+ void stopSpeak();
+ } else {
+ setRunning(true);
+ announced.current = ''; // force re-arm (intro re-speaks โ good resume feedback)
+ }
};
return (
@@ -460,18 +651,38 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[];
-
- db.steps.update(cur.id, { secondsLeft: cur.secondsLeft + 60, minutes: cur.minutes + 1 }).catch(() => {})}
+ {
+ const nl = cur.secondsLeft + 60;
+ const bumped = { ...cur, secondsLeft: nl, minutes: cur.minutes + 1 };
+ db.steps.update(cur.id, { secondsLeft: nl, minutes: cur.minutes + 1 }).catch(() => {});
+ // Re-arm wall-clock state so alarms/catch-up use the extended time.
+ // Pass the bumped snapshot: the closure `ordered` is stale here,
+ // and the tick derives from endsAt so no decrement can clobber it.
+ const snap = ordered.map((s) => (s.id === cur.id ? bumped : s));
+ armStep(bumped, idx, ordered.length, snap).catch(() => {});
+ }}
className="min-h-[44px] rounded-full bg-slate-100 px-4 py-2 text-[13px] font-medium dark:bg-white/10">+1 min
Skip โ
One step at a time. Future steps hidden to protect focus.
+ {!alarmsOn && isNativePlatform() && (
+ {
+ localStorage.removeItem('mindlist.exactAsked');
+ void ensureNotifPermission().then((g) => setAlarmsOn(g));
+ }}
+ className="mt-1 min-h-[44px] w-full rounded-xl text-[12px] font-medium text-amber-700 dark:text-amber-300"
+ >
+ ๐ Background alarms off โ tap to enable notifications for lock-screen timing.
+
+ )}
);
diff --git a/src/app.test.ts b/src/app.test.ts
index 713811e..95f8745 100644
--- a/src/app.test.ts
+++ b/src/app.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+import { buildAlarms, catchUp, type CatchStep } from './lib/catchup';
import { heuristicPlan } from './lib/fallback';
import { parsePlanExported } from './lib/openrouter';
import { buildSystemPrompt } from './lib/prompts';
@@ -46,3 +47,92 @@ describe('parsePlan', () => {
expect(p?.totalMin).toBe(20 * 60);
});
});
+
+const T0 = 1_000_000_000_000;
+const mk = (over: Partial & { id: string; order: number }): CatchStep => ({
+ status: 'todo',
+ secondsLeft: 60,
+ minutes: 1,
+ ...over,
+});
+
+describe('catchUp', () => {
+ it('no-op when nothing elapsed', () => {
+ const steps = [mk({ id: 'a', order: 0, status: 'active', endsAt: T0 + 60_000 })];
+ const r = catchUp(steps, T0 + 5_000);
+ expect(r.updates).toHaveLength(1);
+ expect(r.updates[0]).toMatchObject({ id: 'a', status: 'active' });
+ expect(r.updates[0].secondsLeft).toBeGreaterThan(50);
+ expect(r.finishedIds).toEqual([]);
+ expect(r.activeId).toBe('a');
+ });
+ it('finishes one step and activates next with live remaining', () => {
+ const steps = [
+ mk({ id: 'a', order: 0, status: 'active', endsAt: T0 + 60_000 }),
+ { ...mk({ id: 'b', order: 1, minutes: 2 }), secondsLeft: 60 },
+ ];
+ const r = catchUp(steps, T0 + 90_000);
+ expect(r.finishedIds).toEqual(['a']);
+ expect(r.activeId).toBe('b');
+ expect(r.updates.find((u) => u.id === 'b')?.secondsLeft).toBe(30);
+ });
+ it('cascades across multiple missed steps and reports allDone', () => {
+ const steps = [
+ mk({ id: 'a', order: 0, status: 'active', endsAt: T0 + 60_000 }),
+ mk({ id: 'b', order: 1, minutes: 1 }),
+ mk({ id: 'c', order: 2, minutes: 1 }),
+ ];
+ const r = catchUp(steps, T0 + 300_000);
+ expect(r.finishedIds).toEqual(['a', 'b', 'c']);
+ expect(r.allDone).toBe(true);
+ expect(r.activeId).toBeNull();
+ });
+ it('paused step (no endsAt) stops the chain', () => {
+ const steps = [mk({ id: 'a', order: 0, status: 'todo' })];
+ const r = catchUp(steps, T0 + 999_999_999);
+ expect(r.updates).toEqual([]);
+ expect(r.activeId).toBe('a');
+ });
+ it('skips done/skipped steps', () => {
+ const steps = [
+ mk({ id: 'a', order: 0, status: 'done', secondsLeft: 0 }),
+ mk({ id: 'b', order: 1, status: 'skipped', secondsLeft: 0 }),
+ mk({ id: 'c', order: 2, status: 'active', endsAt: T0 + 60_000 }),
+ ];
+ const r = catchUp(steps, T0 + 120_000);
+ expect(r.finishedIds).toEqual(['c']);
+ });
+});
+
+describe('buildAlarms', () => {
+ it('emits warn+end per step with chained times', () => {
+ const steps = [
+ { ...mk({ id: 'a', order: 0, minutes: 2 }), secondsLeft: 120 },
+ { ...mk({ id: 'b', order: 1, minutes: 1 }), secondsLeft: 60 },
+ ];
+ const alarms = buildAlarms(steps, 0, T0);
+ expect(alarms.map((a) => [a.kind, a.atMs])).toEqual([
+ ['warn', T0 + 110_000],
+ ['end', T0 + 120_000],
+ ['warn', T0 + 170_000],
+ ['end', T0 + 180_000],
+ ]);
+ });
+ it('prefers live secondsLeft over minutes for partial steps', () => {
+ const steps = [{ ...mk({ id: 'a', order: 0, minutes: 5 }), secondsLeft: 30 }];
+ const alarms = buildAlarms(steps, 0, T0);
+ expect(alarms.map((a) => [a.kind, a.atMs])).toEqual([
+ ['warn', T0 + 20_000],
+ ['end', T0 + 30_000],
+ ]);
+ });
+ it('skips warn for very short steps, skips done steps', () => {
+ const steps = [
+ mk({ id: 'a', order: 0, minutes: 1, status: 'done' }),
+ { id: 'b', order: 1, minutes: 1, status: 'todo' as const, secondsLeft: 8, endsAt: undefined },
+ ];
+ const short = [{ ...steps[1], minutes: 0.1 }];
+ const alarms = buildAlarms([steps[0], short[0]], 0, T0);
+ expect(alarms.map((a) => a.kind)).toEqual(['end']);
+ });
+});
diff --git a/src/lib/audio.ts b/src/lib/audio.ts
new file mode 100644
index 0000000..ecc0567
--- /dev/null
+++ b/src/lib/audio.ts
@@ -0,0 +1,92 @@
+import { Capacitor } from '@capacitor/core';
+import { getVoiceOn } from './store';
+
+// Unified audio layer: native TTS on Android (Web Speech is unreliable
+// inside the WebView), Web Speech fallback on desktop web.
+// All entry points are safe no-ops when muted or unavailable.
+
+const isNative = () => {
+ try {
+ return Capacitor.isNativePlatform();
+ } catch {
+ return false;
+ }
+};
+
+async function nativeSpeak(text: string): Promise {
+ try {
+ const mod = await import('@capacitor-community/text-to-speech');
+ const TTS = mod.TextToSpeech;
+ await TTS.speak({ text, lang: 'en-US', rate: 1.0, queueStrategy: mod.QueueStrategy.Flush });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function webSpeak(text: string): boolean {
+ try {
+ if (!('speechSynthesis' in window)) return false;
+ window.speechSynthesis.cancel();
+ const u = new SpeechSynthesisUtterance(text);
+ u.rate = 1.02;
+ window.speechSynthesis.speak(u);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export async function speak(text: string): Promise {
+ if (!getVoiceOn()) return;
+ if (isNative()) {
+ const ok = await nativeSpeak(text);
+ if (ok) return;
+ }
+ webSpeak(text);
+}
+
+export async function stopSpeak(): Promise {
+ try {
+ if (isNative()) {
+ const mod = await import('@capacitor-community/text-to-speech');
+ await mod.TextToSpeech.stop();
+ return;
+ }
+ } catch {
+ /* fall through to web */
+ }
+ try {
+ if ('speechSynthesis' in window) window.speechSynthesis.cancel();
+ } catch {
+ /* noop */
+ }
+}
+
+// Short countdown beeps for the last seconds of a step (foreground only;
+// background warnings go through scheduled notifications instead).
+let beepCtx: AudioContext | null = null;
+export function beep(times = 1, freq = 880): void {
+ if (!getVoiceOn()) return;
+ try {
+ const Ctx =
+ window.AudioContext ||
+ (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
+ beepCtx ??= new Ctx();
+ const ctx = beepCtx;
+ if (ctx.state === 'suspended') void ctx.resume();
+ for (let i = 0; i < times; i++) {
+ const o = ctx.createOscillator();
+ const g = ctx.createGain();
+ o.connect(g);
+ g.connect(ctx.destination);
+ o.frequency.value = freq;
+ g.gain.value = 0.08;
+ const t0 = ctx.currentTime + i * 0.35;
+ o.start(t0);
+ o.stop(t0 + 0.22);
+ }
+ } catch {
+ /* noop */
+ }
+}
diff --git a/src/lib/catchup.ts b/src/lib/catchup.ts
new file mode 100644
index 0000000..8ad9194
--- /dev/null
+++ b/src/lib/catchup.ts
@@ -0,0 +1,98 @@
+// Pure background-catch-up logic (no DB, no plugins โ fully unit-tested).
+// The live timer records `endsAt` (epoch ms) when a step starts. When the
+// app returns from background, catchUp() replays the chain so missed steps
+// are marked done instead of silently freezing the timer.
+
+export interface CatchStep {
+ id: string;
+ order: number;
+ status: 'todo' | 'active' | 'done' | 'skipped';
+ secondsLeft: number;
+ minutes: number;
+ endsAt?: number;
+}
+
+export interface StepUpdate {
+ id: string;
+ status: 'todo' | 'active' | 'done';
+ secondsLeft: number;
+}
+
+export interface CatchResult {
+ updates: StepUpdate[];
+ finishedIds: string[];
+ activeId: string | null;
+ allDone: boolean;
+}
+
+export function catchUp(steps: CatchStep[], nowMs: number): CatchResult {
+ const ordered = [...steps].sort((a, b) => a.order - b.order);
+ const updates: StepUpdate[] = [];
+ const finishedIds: string[] = [];
+ let activeId: string | null = null;
+ // Walk the chain of unfinished steps, consuming elapsed wall-clock time.
+ let cursor = -1;
+ for (const s of ordered) {
+ if (s.status === 'done' || s.status === 'skipped') continue;
+ if (cursor === -1) {
+ // First unfinished step: needs a recorded endsAt to infer anything.
+ if (s.endsAt == null) {
+ activeId = s.id;
+ break; // paused โ nothing to catch up
+ }
+ cursor = s.endsAt;
+ } else {
+ // Prefer live secondsLeft (accounts for +1 min / partial consumption);
+ // fall back to full minutes for untouched steps.
+ cursor += Math.max(1000, (s.secondsLeft || s.minutes * 60) * 1000);
+ }
+ if (nowMs >= cursor) {
+ updates.push({ id: s.id, status: 'done', secondsLeft: 0 });
+ finishedIds.push(s.id);
+ } else {
+ const left = Math.max(1, Math.ceil((cursor - nowMs) / 1000));
+ updates.push({ id: s.id, status: 'active', secondsLeft: left });
+ activeId = s.id;
+ break;
+ }
+ }
+ const remaining = ordered.filter(
+ (s) => s.status !== 'done' && s.status !== 'skipped' && !finishedIds.includes(s.id),
+ );
+ const allDone = finishedIds.length > 0 && activeId === null && remaining.length === 0;
+ return { updates, finishedIds, activeId, allDone };
+}
+
+// Alarm schedule for a step chain starting at `fromOrder`, assuming the
+// step at fromOrder starts at `startEpochMs`. Pure + testable; the caller
+// maps these to LocalNotifications (or drops them on web).
+export interface Alarm {
+ atMs: number;
+ kind: 'warn' | 'end';
+ stepId: string;
+ order: number;
+}
+
+export const WARN_BEFORE_MS = 10000;
+
+export function buildAlarms(
+ steps: Pick[],
+ fromOrder: number,
+ startEpochMs: number,
+): Alarm[] {
+ const ordered = [...steps]
+ .filter((s) => s.order >= fromOrder && s.status !== 'done' && s.status !== 'skipped')
+ .sort((a, b) => a.order - b.order);
+ const alarms: Alarm[] = [];
+ let cursor = startEpochMs;
+ for (const s of ordered) {
+ const durMs = Math.max(1000, (s.secondsLeft || s.minutes * 60) * 1000);
+ const endsAt = cursor + durMs;
+ if (durMs > WARN_BEFORE_MS + 2000) {
+ alarms.push({ atMs: endsAt - WARN_BEFORE_MS, kind: 'warn', stepId: s.id, order: s.order });
+ }
+ alarms.push({ atMs: endsAt, kind: 'end', stepId: s.id, order: s.order });
+ cursor = endsAt;
+ }
+ return alarms;
+}
diff --git a/src/lib/fgservice.ts b/src/lib/fgservice.ts
new file mode 100644
index 0000000..66df877
--- /dev/null
+++ b/src/lib/fgservice.ts
@@ -0,0 +1,45 @@
+import { Capacitor, registerPlugin } from '@capacitor/core';
+
+// Bridge to the native TimerService foreground service (#1).
+// Web: safe no-ops (Wake Lock + timestamp catch-up cover desktop).
+
+interface TimerServicePlugin {
+ start(opts: { title: string; text: string }): Promise;
+ update(opts: { title: string; text: string }): Promise;
+ stop(): Promise;
+}
+
+let cached: TimerServicePlugin | null = null;
+function native(): TimerServicePlugin | null {
+ try {
+ if (!Capacitor.isNativePlatform()) return null;
+ } catch {
+ return null;
+ }
+ cached ??= registerPlugin('TimerService');
+ return cached;
+}
+
+export async function fgStart(title: string, text: string): Promise {
+ try {
+ await native()?.start({ title, text });
+ } catch {
+ /* service unavailable โ alarms + catch-up still cover background */
+ }
+}
+
+export async function fgUpdate(text: string, title = 'Mindlist focus'): Promise {
+ try {
+ await native()?.update({ title, text });
+ } catch {
+ /* noop */
+ }
+}
+
+export async function fgStop(): Promise {
+ try {
+ await native()?.stop();
+ } catch {
+ /* noop */
+ }
+}
diff --git a/src/lib/notify.ts b/src/lib/notify.ts
new file mode 100644
index 0000000..ea145be
--- /dev/null
+++ b/src/lib/notify.ts
@@ -0,0 +1,115 @@
+import { Capacitor } from '@capacitor/core';
+import type { Alarm } from './catchup';
+
+// Local-notification alarms for step boundaries. They fire even when the
+// WebView is suspended (locked phone), which is exactly the gap in #1.
+// Web fallback: silent no-ops (in-foreground beeps cover desktop).
+
+const isNative = () => {
+ try {
+ return Capacitor.isNativePlatform();
+ } catch {
+ return false;
+ }
+};
+
+export const isNativePlatform = isNative;
+
+async function ln() {
+ const mod = await import('@capacitor/local-notifications');
+ return mod.LocalNotifications;
+}
+
+// Deterministic notification IDs so rescheduling never duplicates.
+export function alarmId(stepId: string, kind: 'warn' | 'end'): number {
+ let h = kind === 'warn' ? 7 : 13;
+ const s = `${stepId}:${kind}`;
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
+ return Math.abs(h) % 2000000000;
+}
+
+export async function ensureNotifPermission(): Promise {
+ if (!isNative()) return false;
+ try {
+ const L = await ln();
+ const cur = await L.checkPermissions();
+ if (cur.display === 'granted') return true;
+ const req = await L.requestPermissions();
+ return req.display === 'granted';
+ } catch {
+ return false;
+ }
+}
+
+// Exact alarms need the system "Alarms & reminders" grant (API 31+).
+// Returns true when exact scheduling is available. Opens the settings
+// screen once when missing (plugin behavior); caller continues with
+// inexact fallback + timestamp catch-up for that chain.
+export async function ensureExactAlarms(): Promise {
+ if (!isNative()) return false;
+ try {
+ const L = await ln();
+ const st = await L.checkExactNotificationSetting();
+ if (st.exact_alarm === 'granted') return true;
+ // Ask at most once per install โ the system screen yanks the user out
+ // of focus, so never nag on every step.
+ if (localStorage.getItem('mindlist.exactAsked') === 'yes') return false;
+ localStorage.setItem('mindlist.exactAsked', 'yes');
+ await L.changeExactNotificationSetting();
+ const again = await L.checkExactNotificationSetting();
+ return again.exact_alarm === 'granted';
+ } catch {
+ return false;
+ }
+}
+
+export async function scheduleAlarms(
+ alarms: Alarm[],
+ textFor: (a: Alarm) => { title: string; body: string },
+): Promise {
+ if (!isNative() || alarms.length === 0) return;
+ try {
+ const L = await ln();
+ await L.schedule({
+ notifications: alarms.map((a) => {
+ const { title, body } = textFor(a);
+ return {
+ id: alarmId(a.stepId, a.kind),
+ title,
+ body,
+ schedule: { at: new Date(a.atMs), allowWhileIdle: true },
+ sound: undefined, // default notification sound
+ smallIcon: 'ic_stat_timer',
+ };
+ }),
+ });
+ } catch {
+ /* notifications unavailable โ timer catch-up still covers resume */
+ }
+}
+
+export async function cancelAlarmsFor(stepIds: string[]): Promise {
+ if (!isNative() || stepIds.length === 0) return;
+ try {
+ const L = await ln();
+ const ids = stepIds.flatMap((id) => [alarmId(id, 'warn' as const), alarmId(id, 'end' as const)]);
+ await L.cancel({ notifications: ids.map((id) => ({ id })) });
+ } catch {
+ /* noop */
+ }
+}
+
+export async function cancelAllAlarms(): Promise {
+ if (!isNative()) return;
+ try {
+ const L = await ln();
+ const pending = await L.getPending();
+ if (pending.notifications.length > 0) {
+ await L.cancel({
+ notifications: pending.notifications.map((n) => ({ id: n.id })),
+ });
+ }
+ } catch {
+ /* noop */
+ }
+}
diff --git a/src/lib/store.ts b/src/lib/store.ts
index 66cabb1..945e421 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -22,6 +22,9 @@ export interface Step {
minutes: number;
secondsLeft: number;
status: 'todo' | 'active' | 'done' | 'skipped';
+ /** Wall-clock epoch ms when the running step ends. Set on start/resume,
+ * cleared on pause โ drives background catch-up (#1). Unindexed. */
+ endsAt?: number;
}
class DB extends Dexie {