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

+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 {