forked from trent_larson/crowd-funder-for-time-pwa
645 lines
18 KiB
Markdown
645 lines
18 KiB
Markdown
# TimeSafari Notification System — Feature-First Execution Pack
|
||
|
||
**Author**: Matthew Raymer
|
||
**Date**: 2025-01-27T15:00Z (UTC)
|
||
**Status**: 🚀 **ACTIVE** - Surgical PR implementation for Capacitor platforms
|
||
|
||
## Purpose
|
||
|
||
We **will deliver** 1..M daily local notifications with content fetched beforehand so they **will display offline**. We **will support** online-first (API→DB→Schedule) with offline-first fallback. New v1 ships in-app; New v2 extracts to a native Capacitor plugin with the same API.
|
||
|
||
## What we'll ship (v1 in-app)
|
||
|
||
- **Multi-daily one-shot local notifications** (rolling window; today + tomorrow within iOS pending limits ~64)
|
||
- **Online-first** content fetch (**ETag**, 10–15s timeout) with **offline-first** fallback and **TTL** handling ("(cached)" or skip)
|
||
- **SQLite persistence** (contents, deliveries, config) with **14-day retention**
|
||
- **Templating**: `{title, body}` with `{{var}}` substitution **before** scheduling
|
||
- **Event queue** in SQLite: `delivery`, `error`, `heartbeat`; **drain on foreground**
|
||
- **Resilience hooks**: re-arm on **app resume**, and when **timezone/offset** changes
|
||
|
||
---
|
||
|
||
## New v2 (Plugin) — What It Adds
|
||
|
||
- **Native schedulers** (WorkManager+AlarmManager / BGTask+UNUserNotificationCenter) incl. exact/inexact handling
|
||
- **Native HTTP** + **native SQLite** for reliability/perf
|
||
- **Server-assist (silent push)** to wake at T-lead and prefetch
|
||
- **Same TS API**, adapters swapped
|
||
|
||
---
|
||
|
||
## Feature Flags
|
||
|
||
- `scheduler: 'capacitor'|'native'`
|
||
- `mode: 'online-first'|'offline-first'|'auto'`
|
||
- `prefetchLeadMinutes`, `ttlSeconds`
|
||
|
||
---
|
||
|
||
## Minimal PR layout
|
||
|
||
```
|
||
/src/services/notifs/
|
||
types.ts
|
||
NotificationOrchestrator.ts
|
||
adapters/
|
||
DataStoreSqlite.ts
|
||
SchedulerCapacitor.ts
|
||
CallbacksHttp.ts
|
||
/migrations/
|
||
00XX_notifs.sql
|
||
/sw_scripts/
|
||
notification-click.js # (or merge into sw_scripts-combined.js)
|
||
/app/bootstrap/
|
||
notifications.ts # init + feature flags
|
||
```
|
||
|
||
---
|
||
|
||
## Changes (surgical)
|
||
/src/services/notifs/
|
||
types.ts
|
||
NotificationOrchestrator.ts
|
||
adapters/
|
||
DataStoreSqlite.ts
|
||
SchedulerCapacitor.ts
|
||
CallbacksHttp.ts
|
||
/migrations/
|
||
00XX_notifs.sql
|
||
/sw_scripts/
|
||
notification-click.js # (or merge into sw_scripts-combined.js)
|
||
/app/bootstrap/
|
||
notifications.ts # init + feature flags
|
||
```
|
||
|
||
---
|
||
|
||
## Changes (surgical)
|
||
|
||
### 1) Dependencies
|
||
|
||
```bash
|
||
npm i @capacitor/local-notifications
|
||
npx cap sync
|
||
```
|
||
|
||
### 2) Capacitor setup
|
||
|
||
```typescript
|
||
// capacitor.config.ts
|
||
plugins: {
|
||
LocalNotifications: {
|
||
smallIcon: 'ic_stat_name',
|
||
iconColor: '#4a90e2'
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3) Android channel + iOS category (once at app start)
|
||
|
||
```typescript
|
||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||
|
||
export async function initNotifChannels() {
|
||
await LocalNotifications.createChannel({
|
||
id: 'timesafari.daily',
|
||
name: 'TimeSafari Daily',
|
||
description: 'Daily briefings',
|
||
importance: 4, // high
|
||
});
|
||
|
||
await LocalNotifications.registerActionTypes({
|
||
types: [{
|
||
id: 'TS_DAILY',
|
||
actions: [{ id: 'OPEN', title: 'Open' }]
|
||
}]
|
||
});
|
||
}
|
||
```
|
||
|
||
### 4) SQLite migration
|
||
|
||
```sql
|
||
-- /migrations/00XX_notifs.sql
|
||
CREATE TABLE IF NOT EXISTS notif_contents(
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
slot_id TEXT NOT NULL,
|
||
payload_json TEXT NOT NULL,
|
||
fetched_at INTEGER NOT NULL,
|
||
etag TEXT,
|
||
UNIQUE(slot_id, fetched_at)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS notif_idx_contents_slot_time
|
||
ON notif_contents(slot_id, fetched_at DESC);
|
||
|
||
CREATE TABLE IF NOT EXISTS notif_deliveries(
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
slot_id TEXT NOT NULL,
|
||
fire_at INTEGER NOT NULL,
|
||
delivered_at INTEGER,
|
||
status TEXT NOT NULL,
|
||
error_code TEXT,
|
||
error_message TEXT
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS notif_config(
|
||
k TEXT PRIMARY KEY,
|
||
v TEXT NOT NULL
|
||
);
|
||
```
|
||
|
||
*Retention job (daily):*
|
||
|
||
```sql
|
||
DELETE FROM notif_contents
|
||
WHERE fetched_at < strftime('%s','now','-14 days');
|
||
|
||
DELETE FROM notif_deliveries
|
||
WHERE fire_at < strftime('%s','now','-14 days');
|
||
```
|
||
|
||
### 5) Types (shared API)
|
||
|
||
```typescript
|
||
// src/services/notifs/types.ts
|
||
export type NotificationTime = { hour: number; minute: number };
|
||
export type SlotId = string;
|
||
|
||
export type FetchSpec = {
|
||
method: 'GET'|'POST';
|
||
url: string;
|
||
headers?: Record<string,string>;
|
||
bodyJson?: Record<string,unknown>;
|
||
timeoutMs?: number;
|
||
};
|
||
|
||
export type CallbackProfile = {
|
||
fetchContent: FetchSpec;
|
||
ackDelivery?: Omit<FetchSpec,'bodyJson'|'timeoutMs'>;
|
||
reportError?: Omit<FetchSpec,'bodyJson'|'timeoutMs'>;
|
||
heartbeat?: Omit<FetchSpec,'bodyJson'> & { intervalMinutes?: number };
|
||
};
|
||
|
||
export type ConfigureOptions = {
|
||
times: NotificationTime[];
|
||
ttlSeconds?: number;
|
||
prefetchLeadMinutes?: number;
|
||
storage: 'shared'|'plugin';
|
||
dbPath?: string;
|
||
contentTemplate?: { title: string; body: string };
|
||
callbackProfile?: CallbackProfile;
|
||
};
|
||
|
||
export interface DataStore {
|
||
saveContent(slotId: SlotId, payload: unknown, etag?: string): Promise<void>;
|
||
getLatestContent(slotId: SlotId): Promise<{
|
||
payload: unknown;
|
||
fetchedAt: number;
|
||
etag?: string
|
||
}|null>;
|
||
recordDelivery(
|
||
slotId: SlotId,
|
||
fireAt: number,
|
||
status: 'scheduled'|'shown'|'error',
|
||
error?: { code?: string; message?: string }
|
||
): Promise<void>;
|
||
enqueueEvent(e: unknown): Promise<void>;
|
||
drainEvents(): Promise<unknown[]>;
|
||
}
|
||
|
||
export interface Scheduler {
|
||
capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }>;
|
||
scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise<void>;
|
||
scheduleWindow(
|
||
slotId: SlotId,
|
||
windowStartMs: number,
|
||
windowLenMs: number,
|
||
payloadRef: string
|
||
): Promise<void>;
|
||
cancelBySlot(slotId: SlotId): Promise<void>;
|
||
rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise<void>;
|
||
}
|
||
```
|
||
|
||
### 6) Adapters (thin)
|
||
|
||
```typescript
|
||
// src/services/notifs/adapters/DataStoreSqlite.ts
|
||
import type { DataStore, SlotId } from '../types';
|
||
import { PlatformServiceMixin } from '@/utils/PlatformServiceMixin';
|
||
|
||
export class DataStoreSqlite implements DataStore {
|
||
constructor(private platformService: PlatformServiceMixin) {}
|
||
|
||
async saveContent(slotId: SlotId, payload: unknown, etag?: string): Promise<void> {
|
||
const fetchedAt = Date.now();
|
||
const payloadJson = JSON.stringify(payload);
|
||
|
||
await this.platformService.$exec(
|
||
`INSERT OR REPLACE INTO notif_contents (slot_id, payload_json, fetched_at, etag)
|
||
VALUES (?, ?, ?, ?)`,
|
||
[slotId, payloadJson, fetchedAt, etag]
|
||
);
|
||
}
|
||
|
||
async getLatestContent(slotId: SlotId): Promise<{
|
||
payload: unknown;
|
||
fetchedAt: number;
|
||
etag?: string
|
||
}|null> {
|
||
const result = await this.platformService.$db(
|
||
`SELECT payload_json, fetched_at, etag
|
||
FROM notif_contents
|
||
WHERE slot_id = ?
|
||
ORDER BY fetched_at DESC
|
||
LIMIT 1`,
|
||
[slotId]
|
||
);
|
||
|
||
if (!result || result.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const row = result[0];
|
||
return {
|
||
payload: JSON.parse(row.payload_json),
|
||
fetchedAt: row.fetched_at,
|
||
etag: row.etag
|
||
};
|
||
}
|
||
|
||
async recordDelivery(
|
||
slotId: SlotId,
|
||
fireAt: number,
|
||
status: 'scheduled'|'shown'|'error',
|
||
error?: { code?: string; message?: string }
|
||
): Promise<void> {
|
||
const deliveredAt = status === 'shown' ? Date.now() : null;
|
||
|
||
await this.platformService.$exec(
|
||
`INSERT INTO notif_deliveries (slot_id, fire_at, delivered_at, status, error_code, error_message)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
[slotId, fireAt, deliveredAt, status, error?.code, error?.message]
|
||
);
|
||
}
|
||
|
||
async enqueueEvent(e: unknown): Promise<void> {
|
||
// Simple in-memory queue for now, can be enhanced with SQLite
|
||
// This will be drained when app comes to foreground
|
||
}
|
||
|
||
async drainEvents(): Promise<unknown[]> {
|
||
// Return and clear queued events
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// src/services/notifs/adapters/SchedulerCapacitor.ts
|
||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||
import type { Scheduler, SlotId } from '../types';
|
||
|
||
export class SchedulerCapacitor implements Scheduler {
|
||
async capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }> {
|
||
// iOS: ~64 pending notifications
|
||
// Android: depends on exact alarm permissions
|
||
return {
|
||
exactAlarms: true, // Assume we have permissions
|
||
maxPending: 64
|
||
};
|
||
}
|
||
|
||
async scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise<void> {
|
||
await LocalNotifications.schedule({
|
||
notifications: [{
|
||
id: this.generateNotificationId(slotId),
|
||
title: 'TimeSafari',
|
||
body: 'Your daily update is ready',
|
||
schedule: { at: new Date(whenMs) },
|
||
extra: { slotId, payloadRef }
|
||
}]
|
||
});
|
||
}
|
||
|
||
async scheduleWindow(
|
||
slotId: SlotId,
|
||
windowStartMs: number,
|
||
windowLenMs: number,
|
||
payloadRef: string
|
||
): Promise<void> {
|
||
// For platforms that don't support exact alarms
|
||
await LocalNotifications.schedule({
|
||
notifications: [{
|
||
id: this.generateNotificationId(slotId),
|
||
title: 'TimeSafari',
|
||
body: 'Your daily update is ready',
|
||
schedule: {
|
||
at: new Date(windowStartMs),
|
||
repeats: false
|
||
},
|
||
extra: { slotId, payloadRef }
|
||
}]
|
||
});
|
||
}
|
||
|
||
async cancelBySlot(slotId: SlotId): Promise<void> {
|
||
const id = this.generateNotificationId(slotId);
|
||
await LocalNotifications.cancel({ notifications: [{ id }] });
|
||
}
|
||
|
||
async rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise<void> {
|
||
// Cancel all existing notifications
|
||
await LocalNotifications.cancel({ notifications: [] });
|
||
|
||
// Schedule new ones
|
||
const notifications = next.map(({ slotId, whenMs }) => ({
|
||
id: this.generateNotificationId(slotId),
|
||
title: 'TimeSafari',
|
||
body: 'Your daily update is ready',
|
||
schedule: { at: new Date(whenMs) },
|
||
extra: { slotId }
|
||
}));
|
||
|
||
await LocalNotifications.schedule({ notifications });
|
||
}
|
||
|
||
private generateNotificationId(slotId: SlotId): number {
|
||
// Simple hash of slotId to generate unique notification ID
|
||
let hash = 0;
|
||
for (let i = 0; i < slotId.length; i++) {
|
||
const char = slotId.charCodeAt(i);
|
||
hash = ((hash << 5) - hash) + char;
|
||
hash = hash & hash; // Convert to 32-bit integer
|
||
}
|
||
return Math.abs(hash);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 7) Orchestrator (lean core)
|
||
|
||
```typescript
|
||
// src/services/notifs/NotificationOrchestrator.ts
|
||
import type { ConfigureOptions, SlotId, DataStore, Scheduler } from './types';
|
||
import { logger } from '@/utils/logger';
|
||
|
||
export class NotificationOrchestrator {
|
||
constructor(private store: DataStore, private sched: Scheduler) {}
|
||
|
||
private opts!: ConfigureOptions;
|
||
|
||
async configure(o: ConfigureOptions): Promise<void> {
|
||
this.opts = o;
|
||
|
||
// Persist configuration to SQLite
|
||
await this.store.enqueueEvent({
|
||
type: 'config_updated',
|
||
config: o,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
async runFullPipelineNow(): Promise<void> {
|
||
try {
|
||
// 1) For each upcoming slot, attempt online-first fetch
|
||
const upcomingSlots = this.getUpcomingSlots();
|
||
|
||
for (const slot of upcomingSlots) {
|
||
await this.fetchAndScheduleSlot(slot);
|
||
}
|
||
|
||
logger.log('[NotificationOrchestrator] Full pipeline completed');
|
||
} catch (error) {
|
||
logger.error('[NotificationOrchestrator] Pipeline failed:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async deliverStoredNow(slotId?: SlotId): Promise<void> {
|
||
const targetSlots = slotId ? [slotId] : this.getUpcomingSlots();
|
||
|
||
for (const slot of targetSlots) {
|
||
const content = await this.store.getLatestContent(slot);
|
||
if (content) {
|
||
const payloadRef = this.createPayloadRef(content.payload);
|
||
await this.sched.scheduleExact(slot, Date.now() + 5000, payloadRef);
|
||
}
|
||
}
|
||
}
|
||
|
||
async reschedule(): Promise<void> {
|
||
const nextOccurrences = this.getUpcomingSlots().map(slotId => ({
|
||
slotId,
|
||
whenMs: this.getNextSlotTime(slotId)
|
||
}));
|
||
|
||
await this.sched.rescheduleAll(nextOccurrences);
|
||
}
|
||
|
||
async getState(): Promise<{
|
||
pendingCount: number;
|
||
nextOccurrences: Array<{slotId: string; when: string}>
|
||
}> {
|
||
const capabilities = await this.sched.capabilities();
|
||
const upcomingSlots = this.getUpcomingSlots();
|
||
|
||
return {
|
||
pendingCount: Math.min(upcomingSlots.length, capabilities.maxPending || 64),
|
||
nextOccurrences: upcomingSlots.map(slotId => ({
|
||
slotId,
|
||
when: new Date(this.getNextSlotTime(slotId)).toISOString()
|
||
}))
|
||
};
|
||
}
|
||
|
||
private async fetchAndScheduleSlot(slotId: SlotId): Promise<void> {
|
||
try {
|
||
// Attempt online-first fetch
|
||
if (this.opts.callbackProfile?.fetchContent) {
|
||
const content = await this.fetchContent(slotId);
|
||
if (content) {
|
||
await this.store.saveContent(slotId, content.payload, content.etag);
|
||
await this.scheduleSlot(slotId, content.payload);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Fallback to offline-first
|
||
const storedContent = await this.store.getLatestContent(slotId);
|
||
if (storedContent && this.isWithinTTL(storedContent.fetchedAt)) {
|
||
await this.scheduleSlot(slotId, storedContent.payload);
|
||
}
|
||
} catch (error) {
|
||
logger.error(`[NotificationOrchestrator] Failed to fetch/schedule ${slotId}:`, error);
|
||
await this.store.recordDelivery(slotId, Date.now(), 'error', {
|
||
code: 'fetch_failed',
|
||
message: error instanceof Error ? error.message : 'Unknown error'
|
||
});
|
||
}
|
||
}
|
||
|
||
private async fetchContent(slotId: SlotId): Promise<{
|
||
payload: unknown;
|
||
etag?: string
|
||
}|null> {
|
||
const spec = this.opts.callbackProfile!.fetchContent;
|
||
const response = await fetch(spec.url, {
|
||
method: spec.method,
|
||
headers: spec.headers,
|
||
body: spec.bodyJson ? JSON.stringify(spec.bodyJson) : undefined,
|
||
signal: AbortSignal.timeout(spec.timeoutMs || 15000)
|
||
});
|
||
|
||
if (response.status === 304) {
|
||
return null; // Not modified
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const payload = await response.json();
|
||
const etag = response.headers.get('etag') || undefined;
|
||
|
||
return { payload, etag };
|
||
}
|
||
|
||
private async scheduleSlot(slotId: SlotId, payload: unknown): Promise<void> {
|
||
const whenMs = this.getNextSlotTime(slotId);
|
||
const payloadRef = this.createPayloadRef(payload);
|
||
|
||
await this.sched.scheduleExact(slotId, whenMs, payloadRef);
|
||
await this.store.recordDelivery(slotId, whenMs, 'scheduled');
|
||
}
|
||
|
||
private getUpcomingSlots(): SlotId[] {
|
||
const now = new Date();
|
||
const slots: SlotId[] = [];
|
||
|
||
for (const time of this.opts.times) {
|
||
const slotId = `slot-${time.hour.toString().padStart(2, '0')}-${time.minute.toString().padStart(2, '0')}`;
|
||
const nextTime = this.getNextSlotTime(slotId);
|
||
|
||
// Include slots for today and tomorrow (within rolling window)
|
||
if (nextTime <= now.getTime() + (2 * 24 * 60 * 60 * 1000)) {
|
||
slots.push(slotId);
|
||
}
|
||
}
|
||
|
||
return slots;
|
||
}
|
||
|
||
private getNextSlotTime(slotId: SlotId): number {
|
||
const [_, hourStr, minuteStr] = slotId.match(/slot-(\d{2})-(\d{2})/) || [];
|
||
const hour = parseInt(hourStr, 10);
|
||
const minute = parseInt(minuteStr, 10);
|
||
|
||
const now = new Date();
|
||
const next = new Date(now);
|
||
next.setHours(hour, minute, 0, 0);
|
||
|
||
// If time has passed today, schedule for tomorrow
|
||
if (next <= now) {
|
||
next.setDate(next.getDate() + 1);
|
||
}
|
||
|
||
return next.getTime();
|
||
}
|
||
|
||
private createPayloadRef(payload: unknown): string {
|
||
return `payload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||
}
|
||
|
||
private isWithinTTL(fetchedAt: number): boolean {
|
||
const ttlMs = (this.opts.ttlSeconds || 86400) * 1000;
|
||
return Date.now() - fetchedAt < ttlMs;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 8) Bootstrap + feature flags
|
||
|
||
```typescript
|
||
// /app/bootstrap/notifications.ts
|
||
import { initNotifChannels } from './initNotifChannels';
|
||
import { NotificationOrchestrator } from '@/services/notifs/NotificationOrchestrator';
|
||
import { DataStoreSqlite } from '@/services/notifs/adapters/DataStoreSqlite';
|
||
import { SchedulerCapacitor } from '@/services/notifs/adapters/SchedulerCapacitor';
|
||
import { PlatformServiceMixin } from '@/utils/PlatformServiceMixin';
|
||
import { Capacitor } from '@capacitor/core';
|
||
|
||
let orchestrator: NotificationOrchestrator | null = null;
|
||
|
||
export async function initNotifications(): Promise<void> {
|
||
// Only initialize on Capacitor platforms
|
||
if (!Capacitor.isNativePlatform()) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await initNotifChannels();
|
||
|
||
const platformService = new PlatformServiceMixin();
|
||
const store = new DataStoreSqlite(platformService);
|
||
const scheduler = new SchedulerCapacitor();
|
||
|
||
orchestrator = new NotificationOrchestrator(store, scheduler);
|
||
|
||
// Run pipeline on app start
|
||
await orchestrator.runFullPipelineNow();
|
||
await orchestrator.reschedule();
|
||
|
||
logger.log('[Notifications] Initialized successfully');
|
||
} catch (error) {
|
||
logger.error('[Notifications] Initialization failed:', error);
|
||
}
|
||
}
|
||
|
||
export function getNotificationOrchestrator(): NotificationOrchestrator | null {
|
||
return orchestrator;
|
||
}
|
||
```
|
||
|
||
### 9) Service Worker click handler (web)
|
||
|
||
```javascript
|
||
// sw_scripts/notification-click.js (or inside combined file)
|
||
self.addEventListener('notificationclick', (event) => {
|
||
event.notification.close();
|
||
|
||
// Extract slotId from notification data
|
||
const slotId = event.notification.data?.slotId;
|
||
|
||
// Open appropriate route based on notification type
|
||
const route = slotId ? '/#/daily' : '/#/notifications';
|
||
|
||
event.waitUntil(
|
||
clients.openWindow(route).catch(() => {
|
||
// Fallback if openWindow fails
|
||
return clients.openWindow('/');
|
||
})
|
||
);
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Acceptance (v1)
|
||
|
||
- Local notifications **fire at configured slots with the app killed** (iOS/Android)
|
||
- **Online-first** → 304/ETag respected; **offline-first** fallback; **TTL** policy respected
|
||
- **14-day retention** job prunes contents & deliveries
|
||
- **TZ/DST** change triggers **reschedule()**; rolling window adheres to iOS pending cap
|
||
- **Feature flags** present: `scheduler: 'capacitor'|'native'`, `mode: 'online-first'|'offline-first'|'auto'`
|
||
|
||
---
|
||
|
||
## Platform Notes
|
||
|
||
- **Capacitor Only**: This implementation is designed for iOS/Android via Capacitor
|
||
- **Web Fallback**: Web platform will use existing service worker + push notifications
|
||
- **Electron**: Will need separate implementation using native notification APIs
|
||
- **Feature Flags**: Can be toggled per platform in bootstrap configuration
|
||
- **Android UX**: Settings deep-link to grant exact-alarm special access on API 31+
|
||
- **DST/TZ Guardrail**: On app resume, if offset/TZ changed → `reschedule()` and re-arm today's slots
|