Wave 1 (#1, #2): foreground timer service, background catch-up, alarms, native TTS, warning tone

This commit is contained in:
Lukas Holzner committed 2026-10-02 14:18:15 +02:00
1 parent fe9e75a4e7
commit bbfcbe74af
16 files changed
+920 -17

No files matched your search

+3 -1
View File
@@ -9,7 +9,9 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-text-to-speech')
implementation project(':capacitor-app')
implementation project(':capacitor-local-notifications')
}
+13
View File
@@ -33,9 +33,22 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
<!-- Ongoing focus timer (#1) -->
<service
android:name=".TimerService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Ongoing focus timer keeping step countdown alive while the app is in background." />
</service>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>
@@ -1,5 +1,13 @@
package app.mindlist.foss;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(TimerPlugin.class);
super.onCreate(savedInstanceState);
}
}
@@ -0,0 +1,62 @@
package app.mindlist.foss;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/** JS bridge for TimerService (#1). All methods are safe no-ops on web. */
@CapacitorPlugin(name = "TimerService")
public class TimerPlugin extends Plugin {
@PluginMethod
public void start(PluginCall call) {
String title = call.getString("title", "Mindlist focus");
String text = call.getString("text", "");
Context ctx = getContext();
Intent i = new Intent(ctx, TimerService.class);
i.putExtra(TimerService.EXTRA_TITLE, title);
i.putExtra(TimerService.EXTRA_TEXT, text);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(i);
} else {
ctx.startService(i);
}
call.resolve(new JSObject());
}
@PluginMethod
public void update(PluginCall call) {
String title = call.getString("title", "Mindlist focus");
String text = call.getString("text", "");
Context ctx = getContext();
Intent i = new Intent(ctx, TimerService.class);
i.setAction(TimerService.ACTION_UPDATE);
i.putExtra(TimerService.EXTRA_TITLE, title);
i.putExtra(TimerService.EXTRA_TEXT, text);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(i);
} else {
ctx.startService(i);
}
call.resolve(new JSObject());
}
@PluginMethod
public void stop(PluginCall call) {
Context ctx = getContext();
Intent i = new Intent(ctx, TimerService.class);
i.setAction(TimerService.ACTION_STOP);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(i);
} else {
ctx.startService(i);
}
call.resolve(new JSObject());
}
}
@@ -0,0 +1,103 @@
package app.mindlist.foss;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import androidx.core.app.NotificationCompat;
/**
* Ongoing foreground service that keeps the focus timer alive while the
* app is minimized or the phone is locked (#1). Shows a persistent
* notification with the current step + remaining time.
*/
public class TimerService extends Service {
public static final String CHANNEL_ID = "mindlist_timer";
public static final int NOTIF_ID = 1001;
public static final String EXTRA_TITLE = "title";
public static final String EXTRA_TEXT = "text";
public static final String ACTION_UPDATE = "app.mindlist.foss.UPDATE_TIMER";
public static final String ACTION_STOP = "app.mindlist.foss.STOP_TIMER";
private String title = "Mindlist focus";
private String text = "";
@Override
public void onCreate() {
super.onCreate();
createChannel();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null) {
String action = intent.getAction();
if (ACTION_STOP.equals(action)) {
stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
return START_NOT_STICKY;
}
if (intent.hasExtra(EXTRA_TITLE)) title = intent.getStringExtra(EXTRA_TITLE);
if (intent.hasExtra(EXTRA_TEXT)) text = intent.getStringExtra(EXTRA_TEXT);
}
Notification notif = buildNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif);
} else {
startForeground(NOTIF_ID, notif);
}
// NOT_STICKY: scheduled alarms (not the service) are the source of
// truth — a kill-restarted zombie with stale text is worse than none.
// Known gap: reboot drops pending alarms (no BOOT receiver yet).
return START_NOT_STICKY;
}
private void createChannel() {
NotificationManager nm = getSystemService(NotificationManager.class);
if (nm == null) return;
NotificationChannel ch = new NotificationChannel(
CHANNEL_ID, "Focus timer", NotificationManager.IMPORTANCE_LOW);
ch.setDescription("Ongoing focus timer while steps run");
ch.setSound(null, null);
nm.createNotificationChannel(ch);
}
private Notification buildNotification() {
Intent open = new Intent(this, MainActivity.class);
open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pi = PendingIntent.getActivity(
this, 0, open, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Intent stop = new Intent(this, TimerService.class);
stop.setAction(ACTION_STOP);
PendingIntent stopPi = PendingIntent.getService(
this, 1, stop, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
int icon = getResources().getIdentifier("ic_stat_timer", "drawable", getPackageName());
if (icon == 0) icon = android.R.drawable.ic_dialog_info;
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(text)
.setStyle(new NotificationCompat.BigTextStyle().bigText(text))
.setSmallIcon(icon)
.setContentIntent(pi)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPi)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
}
@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="512" android:viewportHeight="512">
<!-- monochrome silhouette of the v3 logo for status-bar icons -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 256 124 A 132 132 0 1 0 360 180"
android:strokeColor="#FFFFFF"
android:strokeWidth="36"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:fillColor="#FFFFFF"
android:pathData="M 210 270 l 34 34 l 64 -76"
android:strokeColor="#FFFFFF"
android:strokeWidth="42"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
+9
View File
@@ -1,3 +1,12 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-text-to-speech'
project(':capacitor-community-text-to-speech').projectDir = new File('../node_modules/@capacitor-community/text-to-speech/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
+30
View File
@@ -8,9 +8,12 @@
"name": "app",
"version": "0.0.0",
"dependencies": {
"@capacitor-community/text-to-speech": "^8.0.2",
"@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.1",
"@capacitor/cli": "^8.5.2",
"@capacitor/core": "^8.5.2",
"@capacitor/local-notifications": "^8.3.1",
"@tailwindcss/vite": "^4.3.3",
"dexie": "^4.4.6",
"dexie-react-hooks": "^4.4.0",
@@ -1499,6 +1502,15 @@
"node": ">=6.9.0"
}
},
"node_modules/@capacitor-community/text-to-speech": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@capacitor-community/text-to-speech/-/text-to-speech-8.0.2.tgz",
"integrity": "sha512-SbpBMzcmzdTiN8jQDme/yUYHEJISZA+IiaRn5zT14k3UKpjaA7eYBnSm9h76rJHRV/y6DVZVhT1J0SaK5JTKtA==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/android": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz",
@@ -1508,6 +1520,15 @@
"@capacitor/core": "^8.5.0"
}
},
"node_modules/@capacitor/app": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.1.tgz",
"integrity": "sha512-xM2ZTX5jK60tFtmjsmJv+Cdj1Qsb6NcavkqmdEnCPQBeya9oKhamZJxYOnQNj1ZvcJM7sWfG6BEtSLx42pisbA==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/cli": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz",
@@ -1585,6 +1606,15 @@
"tslib": "^2.1.0"
}
},
"node_modules/@capacitor/local-notifications": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.3.1.tgz",
"integrity": "sha512-F7zu/+p/mIR958YP4axOkeI5hBKRaXDyYjbNPrAil2wWfd7OxScPSTvP9t85NpQ0eULVRu0/sntoxOg6ck477A==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@ionic/cli-framework-output": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz",
+3
View File
@@ -11,9 +11,12 @@
"preview": "vite preview"
},
"dependencies": {
"@capacitor-community/text-to-speech": "^8.0.2",
"@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.1",
"@capacitor/cli": "^8.5.2",
"@capacitor/core": "^8.5.2",
"@capacitor/local-notifications": "^8.3.1",
"@tailwindcss/vite": "^4.3.3",
"dexie": "^4.4.6",
"dexie-react-hooks": "^4.4.0",
+226 -15
View File
@@ -21,8 +21,20 @@ import {
VolumeX,
Wand2,
} from 'lucide-react';
import { App as CapApp } from '@capacitor/app';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { beep, heuristicPlan, speak } from './lib/fallback';
import { beep, speak, stopSpeak } from './lib/audio';
import { buildAlarms, catchUp, WARN_BEFORE_MS, type Alarm } from './lib/catchup';
import { heuristicPlan } from './lib/fallback';
import { fgStart, fgStop, fgUpdate } from './lib/fgservice';
import {
cancelAlarmsFor,
cancelAllAlarms,
ensureExactAlarms,
ensureNotifPermission,
isNativePlatform,
scheduleAlarms,
} from './lib/notify';
import {
callOpenRouter,
getKey,
@@ -352,6 +364,55 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[];
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const cur = ordered[Math.min(idx, Math.max(0, ordered.length - 1))];
const announced = useRef('');
const lastBeeped = useRef(-1);
const [alarmsOn, setAlarmsOn] = useState(true);
// (Re)arm background machinery for the chain starting at `index`:
// notification alarms + foreground service. Reads live snapshot.
const scheduleFrom = useCallback(
async (index: number, snap: Step[]) => {
const step = snap[index];
if (!step) return;
const now = Date.now();
const firstEnds = now + Math.max(1, step.secondsLeft) * 1000;
const alarms: Alarm[] = [];
if (step.secondsLeft * 1000 > WARN_BEFORE_MS + 2000) {
alarms.push({ atMs: firstEnds - WARN_BEFORE_MS, kind: 'warn', stepId: step.id, order: step.order });
}
alarms.push({ atMs: firstEnds, kind: 'end', stepId: step.id, order: step.order });
const nextOrder = snap[index + 1]?.order ?? Number.MAX_SAFE_INTEGER;
alarms.push(...buildAlarms(snap, nextOrder, firstEnds));
const granted = await ensureNotifPermission();
setAlarmsOn(granted);
if (!granted) return;
await ensureExactAlarms(); // opens "Alarms & reminders" once if needed; chain still schedules (inexact fallback)
await scheduleAlarms(alarms, (a) => {
const s = snap.find((x) => x.id === a.stepId);
const label = s ? `${s.emoji} ${s.text}` : 'Next step';
const next = snap.find(
(x) => x.order === a.order + 1 && x.status !== 'done' && x.status !== 'skipped',
);
if (a.kind === 'warn') return { title: 'Mindlist · 10 seconds left', body: label };
return next
? { title: 'Mindlist · step done', body: `Next up: ${next.emoji} ${next.text}` }
: { title: 'Mindlist · all done 🎉', body: `${task.title} — well done!` };
});
},
[task.title],
);
const armStep = useCallback(
async (step: Step, index: number, total: number, snap?: Step[]) => {
const endsAt = Date.now() + Math.max(1, step.secondsLeft) * 1000;
await db.steps
.update(step.id, { status: step.status === 'todo' ? 'active' : step.status, endsAt })
.catch(() => {});
const live = snap ?? ordered;
await scheduleFrom(index, live);
await fgStart('Mindlist focus', `Step ${index + 1}/${total} · ${step.text}`);
},
[ordered, scheduleFrom],
);
// Sync to first unfinished when task changes (fixes stale idx + resume-at-0)
useEffect(() => {
@@ -379,32 +440,137 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[];
if (!cur || !running) return;
if (announced.current !== cur.id) {
announced.current = cur.id;
speak(`${cur.text}. ${Math.max(1, Math.round(cur.secondsLeft / 60))} minutes.`, voice && getVoiceOn());
if (cur.status === 'todo') db.steps.update(cur.id, { status: 'active' }).catch(() => {});
lastBeeped.current = -1;
void speak(`${cur.text}. ${Math.max(1, Math.round(cur.secondsLeft / 60))} minutes.`);
armStep(cur, idx, ordered.length).catch(() => {});
}
if (cur.secondsLeft <= 0) {
beep();
db.steps.update(cur.id, { status: 'done' }).catch(() => {});
beep(2);
void stopSpeak();
db.steps.update(cur.id, { status: 'done', secondsLeft: 0, endsAt: undefined }).catch(() => {});
void cancelAlarmsFor([cur.id]);
const next = ordered[idx + 1];
if (next) {
// Reset next if revisiting a finished step so it doesn't instantly skip
if (next.status === 'done' && next.secondsLeft <= 0)
db.steps.update(next.id, { status: 'todo', secondsLeft: next.minutes * 60 }).catch(() => {});
speak(`Done. Next: ${next.text}`, voice && getVoiceOn());
void speak(`Done. Next: ${next.text}`);
setIdx(idx + 1);
} else {
speak('All steps finished. Well done.', voice && getVoiceOn());
void speak('All steps finished. Well done.');
db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {});
void cancelAllAlarms();
void fgStop();
setRunning(false);
onClose();
}
return;
}
// Foreground warning beeps for the final countdown (#2).
if (cur.secondsLeft <= 10 && cur.secondsLeft >= 1 && lastBeeped.current !== cur.secondsLeft) {
lastBeeped.current = cur.secondsLeft;
beep(1, cur.secondsLeft <= 3 ? 1320 : 990);
}
// Single source of truth is endsAt (set by armStep): the tick derives
// remaining time from the wall clock, so +1min/catch-up can never be
// clobbered by a stale decrement racing an async DB write.
const t = setTimeout(() => {
db.steps.update(cur.id, { secondsLeft: Math.max(0, cur.secondsLeft - 1) }).catch(() => {});
void (async () => {
try {
const fresh = await db.steps.get(cur.id);
const left =
fresh?.endsAt != null
? Math.max(0, Math.round((fresh.endsAt - Date.now()) / 1000))
: Math.max(0, cur.secondsLeft - 1);
await db.steps.update(cur.id, { secondsLeft: left });
} catch {
/* noop */
}
})();
}, 1000);
return () => clearTimeout(t);
}, [cur, running, idx, ordered, task.id, voice, onClose]);
}, [cur, running, idx, ordered, task.id, voice, onClose, armStep]);
// Background catch-up (#1): replay elapsed wall-clock time on return.
const catchUpNow = useCallback(async () => {
const live = await db.steps.where('taskId').equals(task.id).sortBy('order');
const res = catchUp(live, Date.now());
if (res.updates.length === 0) return;
for (const u of res.updates) {
await db.steps.update(u.id, { status: u.status, secondsLeft: u.secondsLeft, endsAt: undefined }).catch(() => {});
}
// Finished steps must not alarm late (inexact/Doze-delayed alarms).
if (res.finishedIds.length > 0) await cancelAlarmsFor(res.finishedIds);
const missed = res.finishedIds.length;
if (res.allDone) {
await db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {});
await cancelAllAlarms();
await fgStop();
void speak('All steps finished while you were away. Well done.');
setRunning(false);
onClose();
return;
}
if (res.activeId) {
const upd = res.updates.find((u) => u.id === res.activeId);
const left = upd?.secondsLeft ?? 60;
await db.steps.update(res.activeId, { endsAt: Date.now() + left * 1000 }).catch(() => {});
const fresh = await db.steps.where('taskId').equals(task.id).sortBy('order');
const ai = fresh.findIndex((s) => s.id === res.activeId);
announced.current = res.activeId; // suppress intro re-speak; summary below instead
setIdx(Math.max(0, ai));
const now = fresh[Math.max(0, ai)];
if (now) {
void speak(
missed > 0
? `Welcome back. ${missed} ${missed === 1 ? 'step' : 'steps'} finished while you were away. Now: ${now.text}.`
: `Welcome back. ${now.text}.`,
);
await scheduleFrom(Math.max(0, ai), fresh);
await fgUpdate(`Step ${Math.max(0, ai) + 1}/${fresh.length} · ${now.text}`);
}
}
}, [task.id, onClose, scheduleFrom]);
useEffect(() => {
if (!running) return;
const onVisible = () => {
if (document.visibilityState === 'visible') void catchUpNow();
};
document.addEventListener('visibilitychange', onVisible);
let sub: { remove: () => void } | null = null;
CapApp.addListener('appStateChange', ({ isActive }) => {
if (isActive) void catchUpNow();
})
.then((s) => {
sub = s;
})
.catch(() => {});
return () => {
document.removeEventListener('visibilitychange', onVisible);
try {
sub?.remove();
} catch {
/* noop */
}
};
}, [running, catchUpNow]);
// Leaving focus freezes the timer: clear wall-clock state + alarms + service.
useEffect(() => {
return () => {
void cancelAllAlarms();
void fgStop();
void stopSpeak();
db.steps
.where('taskId')
.equals(task.id)
.filter((s) => s.status === 'active')
.modify({ endsAt: undefined } as Partial<Step>)
.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[];
</div>
<div className="mt-3 flex items-center justify-center gap-3">
<button onClick={goBack} aria-label="Go to previous step" className="grid size-12 place-items-center rounded-full bg-slate-100 dark:bg-white/10"><ChevronLeft size={19} /></button>
<button onClick={() => setRunning(!running)} aria-label={running ? 'Pause timer' : 'Resume timer'} aria-pressed={running}
<button onClick={togglePause} aria-label={running ? 'Pause timer' : 'Resume timer'} aria-pressed={running}
className="grid size-14 place-items-center rounded-full bg-teal-700 text-white">
{running ? <Pause size={22} /> : <Play size={22} />}</button>
<button onClick={completeStep} aria-label="Complete this step" className="grid size-12 place-items-center rounded-full bg-teal-700 text-white"><Check size={19} /></button>
</div>
<div className="mt-3 flex justify-center gap-2">
<button onClick={() => db.steps.update(cur.id, { secondsLeft: cur.secondsLeft + 60, minutes: cur.minutes + 1 }).catch(() => {})}
<button onClick={() => {
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</button>
<button onClick={skipStep} aria-label="Skip this step"
className="min-h-[44px] rounded-full bg-slate-100 px-4 py-2 text-[13px] font-medium dark:bg-white/10">Skip →</button>
</div>
<p className="mt-2 text-[11px] text-slate-400">One step at a time. Future steps hidden to protect focus.</p>
{!alarmsOn && isNativePlatform() && (
<button
onClick={() => {
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.
</button>
)}
</div>
</div>
);
+90
View File
@@ -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<CatchStep> & { 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']);
});
});
+92
View File
@@ -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<boolean> {
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<void> {
if (!getVoiceOn()) return;
if (isNative()) {
const ok = await nativeSpeak(text);
if (ok) return;
}
webSpeak(text);
}
export async function stopSpeak(): Promise<void> {
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 */
}
}
+98
View File
@@ -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<CatchStep, 'id' | 'order' | 'minutes' | 'secondsLeft' | 'status'>[],
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;
}
+45
View File
@@ -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<void>;
update(opts: { title: string; text: string }): Promise<void>;
stop(): Promise<void>;
}
let cached: TimerServicePlugin | null = null;
function native(): TimerServicePlugin | null {
try {
if (!Capacitor.isNativePlatform()) return null;
} catch {
return null;
}
cached ??= registerPlugin<TimerServicePlugin>('TimerService');
return cached;
}
export async function fgStart(title: string, text: string): Promise<void> {
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<void> {
try {
await native()?.update({ title, text });
} catch {
/* noop */
}
}
export async function fgStop(): Promise<void> {
try {
await native()?.stop();
} catch {
/* noop */
}
}
+115
View File
@@ -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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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 */
}
}
+3
View File
@@ -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 {