diff --git a/doc/notification-system-implementation-plan.md b/doc/notification-system-implementation-plan.md deleted file mode 100644 index 592530a1..00000000 --- a/doc/notification-system-implementation-plan.md +++ /dev/null @@ -1,644 +0,0 @@ -# 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; - bodyJson?: Record; - timeoutMs?: number; -}; - -export type CallbackProfile = { - fetchContent: FetchSpec; - ackDelivery?: Omit; - reportError?: Omit; - heartbeat?: Omit & { 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; - 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; - enqueueEvent(e: unknown): Promise; - drainEvents(): Promise; -} - -export interface Scheduler { - capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }>; - scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise; - scheduleWindow( - slotId: SlotId, - windowStartMs: number, - windowLenMs: number, - payloadRef: string - ): Promise; - cancelBySlot(slotId: SlotId): Promise; - rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise; -} -``` - -### 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 { - 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 { - 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 { - // Simple in-memory queue for now, can be enhanced with SQLite - // This will be drained when app comes to foreground - } - - async drainEvents(): Promise { - // 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 { - 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 { - // 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 { - const id = this.generateNotificationId(slotId); - await LocalNotifications.cancel({ notifications: [{ id }] }); - } - - async rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise { - // 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 { - this.opts = o; - - // Persist configuration to SQLite - await this.store.enqueueEvent({ - type: 'config_updated', - config: o, - timestamp: Date.now() - }); - } - - async runFullPipelineNow(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - // 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 diff --git a/doc/notification-system-implementation.md b/doc/notification-system-implementation.md new file mode 100644 index 00000000..4c3affc9 --- /dev/null +++ b/doc/notification-system-implementation.md @@ -0,0 +1,1872 @@ +# TimeSafari Notification System — Implementation Guide + +**Status:** 🚀 Active implementation +**Date:** 2025-09-05T05:09Z (UTC) +**Author:** Matthew Raymer +**Scope:** Detailed implementation for v1 (in‑app orchestrator) +**Goal:** Complete implementation guide with code, database schemas, and integration specifics. + +> **Strategic Overview:** See `notification-system-plan.md` for high-level strategy, architecture, and planning details. +> **Canonical Ownership:** This document owns API definitions, Database schemas, Adapter implementations, and Code examples. + +--- + +## 1) Dependencies & Setup + +### Package Dependencies +```bash +npm i @capacitor/local-notifications +npx cap sync +``` + +### Capacitor Configuration +```typescript +// capacitor.config.ts +plugins: { + LocalNotifications: { + smallIcon: 'ic_stat_name', + iconColor: '#4a90e2' + } +} +``` + +--- + +## 2) Database Integration + +### Migration Integration +Add to existing `src/db-sql/migration.ts` MIGRATIONS array: + +```typescript +{ + name: "003_notification_system", + 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, -- scheduled|shown|canceled|error + error_code TEXT, error_message TEXT + ); + + -- Prevent duplicate scheduled deliveries + CREATE UNIQUE INDEX IF NOT EXISTS notif_uq_scheduled + ON notif_deliveries(slot_id, fire_at, status) + WHERE status='scheduled'; + + CREATE TABLE IF NOT EXISTS notif_config( + k TEXT PRIMARY KEY, + v TEXT NOT NULL + ); + `, +} +``` + +**Platform-Specific Database Backends:** +- **Web (`VITE_PLATFORM=web`)**: Uses Absurd SQL (SQLite via IndexedDB) via `WebPlatformService` with worker pattern - migration runs in Absurd SQL context +- **Capacitor (`VITE_PLATFORM=capacitor`)**: Uses native SQLite via `CapacitorPlatformService` - migration runs in native SQLite context +- **Electron (`VITE_PLATFORM=electron`)**: Uses native SQLite via `ElectronPlatformService` (extends CapacitorPlatformService) - same as Capacitor + +**Retention:** We will keep ~14 days of contents/deliveries and prune daily. + +### Settings Extension +Extend `src/db/tables/settings.ts`: + +```typescript +export type Settings = { + // ... existing fields ... + + // Multi-daily notification settings (following TimeSafari pattern) + notifTimes?: string; // JSON string in DB: Array<{ hour: number; minute: number }> + notifTtlSeconds?: number; + notifPrefetchLeadMinutes?: number; + notifContentTemplate?: string; // JSON string in DB: { title: string; body: string } + notifCallbackProfile?: string; // JSON string in DB: CallbackProfile + notifEnabled?: boolean; + notifMode?: string; // JSON string in DB: 'online-first' | 'offline-first' | 'auto' +}; +``` + +--- + +## 3) Public API (Shared) + +```typescript +export type NotificationTime = { hour: number; minute: number }; // local wall-clock +export type SlotId = string; // Format: "HHmm" (e.g., "0800", "1200", "1800") - stable across TZ changes + +export type FetchSpec = { + method: 'GET'|'POST'; + url: string; + headers?: Record; + bodyJson?: Record; + timeoutMs?: number; +}; + +export type CallbackProfile = { + fetchContent: FetchSpec; + ackDelivery?: Omit; + reportError?: Omit; + heartbeat?: Omit & { intervalMinutes?: number }; +}; + +export type ConfigureOptions = { + times: NotificationTime[]; // 1..M daily + timezone?: string; // Default: system timezone + ttlSeconds?: number; // Default: 86400 (24h) + prefetchLeadMinutes?: number; // Default: 20 + storage: 'shared'|'private'; // Required + contentTemplate: { title: string; body: string }; // Required + callbackProfile?: CallbackProfile; // Optional +}; + +interface MultiDailyNotification { + configure(opts: ConfigureOptions): Promise; + getState(): Promise<{ + enabled: boolean; + slots: SlotId[]; + lastFetchAt?: number; + lastDeliveryAt?: number; + exactAlarmCapable: boolean; + }>; + runFullPipelineNow(): Promise; + reschedule(): Promise; +} +``` + +**Storage semantics:** `'shared'` = app DB; `'private'` = plugin-owned/native DB (v2). (No functional difference in v1.) + +**Compliance note:** We will expose `lastFetchAt`, `lastDeliveryAt`, and `exactAlarmCapable` as specified in the `getState()` method. + +export interface MultiDailyNotification { + requestPermissions(): Promise; + configure(o: ConfigureOptions): Promise; + runFullPipelineNow(): Promise; // API→DB→Schedule (today's remaining) + deliverStoredNow(slotId?: SlotId): Promise; // 60s cooldown guard + reschedule(): Promise; + getState(): Promise<{ + nextOccurrences: Array<{ slotId: SlotId; when: string }>; // ISO + lastFetchAt?: string; lastDeliveryAt?: string; + pendingCount: number; exactAlarmCapable?: boolean; + }>; +} +``` + +> **Storage semantics:** `'shared'` = app DB; `'private'` = plugin-owned/native DB (v2). (No functional difference in v1.) + +> **Slot Identity & Scheduling Policy** +> • **SlotId** uses canonical `HHmm` and remains stable across timezone changes. +> • **Lead window:** default `prefetchLeadMinutes = 20`; no retries once inside the lead. +> • **TTL policy:** When offline and content is beyond TTL, **we will skip** the notification (no "(cached)" suffix). +> • **Idempotency:** Duplicate "scheduled" deliveries are prevented by a unique index on `(slot_id, fire_at, status='scheduled')`. +> • **Time handling:** Slots will follow **local wall-clock** time across TZ/DST; `slotId=HHmm` stays constant and we will **recompute fire times** on offset change. + +--- + +## 4) Internal Interfaces + +### Error Taxonomy +```typescript +// src/services/notifications/types.ts +export type NotificationErrorCode = + | 'FETCH_TIMEOUT' // Network request exceeded timeout + | 'ETAG_NOT_MODIFIED' // Server returned 304 (expected) + | 'SCHEDULE_DENIED' // OS denied notification scheduling + | 'EXACT_ALARM_MISSING' // Android exact alarm permission absent + | 'STORAGE_BUSY' // Database locked or unavailable + | 'TEMPLATE_MISSING_TOKEN' // Required template variable not found + | 'PERMISSION_DENIED'; // User denied notification permissions + +export type EventLogEnvelope = { + code: string; // Error code from taxonomy + slotId: string; // Affected slot + whenMs: number; // Scheduled time + attempt: number; // Retry attempt (1-based) + networkState: string; // 'online' | 'offline' + tzOffset: number; // Current timezone offset + appState: string; // 'foreground' | 'background' | 'killed' + timestamp: number; // UTC timestamp +}; + +export type AckPayload = { + slotId: string; + fireAt: number; // Scheduled time + deliveredAt: number; // Actual delivery time + deviceTz: string; // Device timezone + appVersion: string; // App version + buildId: string; // Build identifier +}; +``` + +### Internal Service Interfaces +```typescript +export interface DataStore { + saveContent(slotId: SlotId, payload: unknown, etag?: string): Promise; + 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; + enqueueEvent(e: unknown): Promise; + drainEvents(): Promise; + setConfig?(k: string, v: unknown): Promise; + getConfig?(k: string): Promise; + getLastFetchAt?(): Promise; + getLastDeliveryAt?(): Promise; +} + +export interface Scheduler { + capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }>; + scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise; + scheduleWindow( + slotId: SlotId, + windowStartMs: number, + windowLenMs: number, + payloadRef: string + ): Promise; + cancelBySlot(slotId: SlotId): Promise; + rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise; +} +``` + +--- + +## 5) Template Engine Contract + +### Supported Tokens & Escaping +```typescript +// src/services/notifications/TemplateEngine.ts +export class TemplateEngine { + private static readonly SUPPORTED_TOKENS = { + 'headline': 'Main content headline', + 'summary': 'Content summary text', + 'date': 'Formatted date (YYYY-MM-DD)', + 'time': 'Formatted time (HH:MM)' + }; + + private static readonly LENGTH_LIMITS = { + title: 50, // chars + body: 200 // chars + }; + + static render(template: { title: string; body: string }, data: Record): { title: string; body: string } { + return { + title: this.renderTemplate(template.title, data, this.LENGTH_LIMITS.title), + body: this.renderTemplate(template.body, data, this.LENGTH_LIMITS.body) + }; + } + + private static renderTemplate(template: string, data: Record, maxLength: number): string { + let result = template; + + // Replace tokens with data or fallback + for (const [token, fallback] of Object.entries(this.SUPPORTED_TOKENS)) { + const regex = new RegExp(`{{${token}}}`, 'g'); + const value = data[token] || '[Content]'; + result = result.replace(regex, value); + } + + // Truncate if needed (before escaping to avoid splitting entities) + if (result.length > maxLength) { + result = result.substring(0, maxLength - 3) + '...'; + } + + return this.escapeHtml(result); + } + + private static escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} +``` + +--- + +## 6) PlatformServiceMixin Integration + +### Extended Interface +```typescript +// Add to src/utils/PlatformServiceMixin.ts IPlatformServiceMixin +export interface IPlatformServiceMixin { + // ... existing methods ... + + // Notification-specific methods + $saveNotifContent(slotId: string, payload: unknown, etag?: string): Promise; + $getNotifContent(slotId: string): Promise<{ payload: unknown; fetchedAt: number; etag?: string } | null>; + $recordNotifDelivery(slotId: string, fireAt: number, status: string, error?: { code?: string; message?: string }): Promise; + $getNotifSettings(): Promise; + $saveNotifSettings(settings: Partial): Promise; + $pruneNotifData(daysToKeep?: number): Promise; +} +``` + +### Implementation Methods +```typescript +// Add to PlatformServiceMixin methods object +async $saveNotifContent(slotId: string, payload: unknown, etag?: string): Promise { + try { + const fetchedAt = Date.now(); + const payloadJson = JSON.stringify(payload); + + await this.$exec( + `INSERT OR REPLACE INTO notif_contents (slot_id, payload_json, fetched_at, etag) + VALUES (?, ?, ?, ?)`, + [slotId, payloadJson, fetchedAt, etag] + ); + return true; + } catch (error) { + logger.error('[PlatformServiceMixin] Failed to save notification content:', error); + return false; + } +}, + +async $getNotifContent(slotId: string): Promise<{ payload: unknown; fetchedAt: number; etag?: string } | null> { + try { + const result = await this.$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 + }; + } catch (error) { + logger.error('[PlatformServiceMixin] Failed to get notification content:', error); + return null; + } +}, + +async $recordNotifDelivery(slotId: string, fireAt: number, status: string, error?: { code?: string; message?: string }): Promise { + try { + const deliveredAt = status === 'shown' ? Date.now() : null; + + await this.$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] + ); + return true; + } catch (error) { + // Handle duplicate schedule constraint as idempotent (no-op) + if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) { + logger.debug('[PlatformServiceMixin] Duplicate delivery record for', slotId, 'at', fireAt, '- treating as idempotent'); + return true; + } + logger.error('[PlatformServiceMixin] Failed to record notification delivery', error); + return false; + } +}, + +async $pruneNotifData(daysToKeep: number = 14): Promise { + try { + const cutoffMs = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000); + + // Prune old content + await this.$exec( + `DELETE FROM notif_contents WHERE fetched_at < ?`, + [cutoffMs] + ); + + // Prune old deliveries + await this.$exec( + `DELETE FROM notif_deliveries WHERE fire_at < ?`, + [cutoffMs] + ); + + logger.debug('[PlatformServiceMixin] Pruned notification data older than', daysToKeep, 'days'); + // Log with safe stringify for complex objects + logger.debug('[PlatformServiceMixin] Prune details:', safeStringify({ + daysToKeep, + cutoffMs, + timestamp: new Date().toISOString() + })); + + // We will avoid VACUUM in v1 to prevent churn; optional maintenance can be added behind a flag. + } catch (error) { + logger.error('[PlatformServiceMixin] Failed to prune notification data', error); + } +}, +``` + +--- + +## 7) Adapter Implementations + +### DataStoreSqlite Adapter +```typescript +// src/services/notifications/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 { + await this.platformService.$saveNotifContent(slotId, payload, etag); + } + + async getLatestContent(slotId: SlotId): Promise<{ + payload: unknown; + fetchedAt: number; + etag?: string + }|null> { + return await this.platformService.$getNotifContent(slotId); + } + + async recordDelivery( + slotId: SlotId, + fireAt: number, + status: 'scheduled'|'shown'|'error', + error?: { code?: string; message?: string } + ): Promise { + await this.platformService.$recordNotifDelivery(slotId, fireAt, status, error); + } + + async enqueueEvent(e: unknown): Promise { + // v1: Simple in-memory queue for now, can be enhanced with SQLite in v2 + // This will be drained when app comes to foreground + } + + async drainEvents(): Promise { + // v1: Return and clear queued events from in-memory queue + // v2: Will migrate to SQLite-backed queue for persistence + return []; + } + + async setConfig(k: string, v: unknown): Promise { + await this.platformService.$setNotifConfig(k, v); + } + + async getConfig(k: string): Promise { + try { + const result = await this.platformService.$db( + `SELECT v FROM notif_config WHERE k = ?`, + [k] + ); + return result && result.length > 0 ? JSON.parse(result[0].v) : null; + } catch (error) { + logger.error('[DataStoreSqlite] Failed to get config:', error); + return null; + } + } + + async getLastFetchAt(): Promise { + try { + const result = await this.platformService.$one( + `SELECT MAX(fetched_at) as last_fetch FROM notif_contents` + ); + return result?.last_fetch; + } catch (error) { + logger.error('[DataStoreSqlite] Failed to get last fetch time:', error); + return undefined; + } + } + + async getLastDeliveryAt(): Promise { + try { + const result = await this.platformService.$one( + `SELECT MAX(fire_at) as last_delivery FROM notif_deliveries WHERE status = 'shown'` + ); + return result?.last_delivery; + } catch (error) { + logger.error('[DataStoreSqlite] Failed to get last delivery time:', error); + return undefined; + } + } +} +``` + +### SchedulerElectron Adapter +```typescript +// src/services/notifications/adapters/SchedulerElectron.ts +import type { Scheduler, SlotId } from '../types'; +import { Notification, app } from 'electron'; +import { logger, safeStringify } from '@/utils/logger'; + +export class SchedulerElectron implements Scheduler { + async capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }> { + // Electron timers + OS delivery while app runs; no exact-alarm guarantees. + return { exactAlarms: false, maxPending: 128 }; + } + + async scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise { + const delay = Math.max(0, whenMs - Date.now()); + setTimeout(() => { + try { + const n = new Notification({ + title: 'TimeSafari', + body: 'Your daily update is ready', + // Electron Notification supports .actions on some OSes; + // keep minimal now for parity with v1 locals. + silent: false + }); + // n.on('click', ...) → open route if desired + n.show(); + logger.debug('[SchedulerElectron] Notification shown for', slotId); + // Log with safe stringify for complex objects + logger.debug('[SchedulerElectron] Notification details:', safeStringify({ + slotId, + timestamp: new Date().toISOString(), + platform: 'electron' + })); + } catch (e) { + logger.error('[SchedulerElectron] show failed', e); + throw e; + } + }, delay); + } + + async scheduleWindow(slotId: SlotId, windowStartMs: number, windowLenMs: number, payloadRef: string): Promise { + // v1 emulates "window" by scheduling at window start; OS may delay delivery. + return this.scheduleExact(slotId, windowStartMs, payloadRef); + } + + async cancelBySlot(_slotId: SlotId): Promise { + // Electron Notification has no pending queue to cancel; v1: no-op. + // v2: a native helper could manage real queues per OS. + } + + async rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise { + // Clear any in-process timers if you track them; then re-arm: + for (const { slotId, whenMs } of next) { + await this.scheduleExact(slotId, whenMs, `${slotId}:${whenMs}`); + } + } +} +``` + +### SchedulerCapacitor Adapter +```typescript +// src/services/notifications/adapters/SchedulerCapacitor.ts +import { LocalNotifications } from '@capacitor/local-notifications'; +import type { Scheduler, SlotId } from '../types'; +import { logger, safeStringify } from '@/utils/logger'; + +export class SchedulerCapacitor implements Scheduler { + async capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }> { + // Conservative default; exact permission detection will be native in v2. + return { exactAlarms: false, maxPending: 64 }; + } + + async scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise { + try { + await LocalNotifications.schedule({ + notifications: [{ + id: this.generateNotificationId(slotId, whenMs), + title: 'TimeSafari', + body: 'Your daily update is ready', + schedule: { at: new Date(whenMs) }, + extra: { slotId, payloadRef } + }] + }); + + logger.debug('[SchedulerCapacitor] Scheduled notification for slot', slotId, 'at', new Date(whenMs).toISOString()); + // Log with safe stringify for complex objects + logger.debug('[SchedulerCapacitor] Notification details:', safeStringify({ + slotId, + whenMs, + scheduledAt: new Date(whenMs).toISOString(), + platform: 'capacitor' + })); + } catch (error) { + logger.error('[SchedulerCapacitor] Failed to schedule notification for slot', slotId, error); + throw error; + } + } + + async scheduleWindow( + slotId: SlotId, + windowStartMs: number, + windowLenMs: number, + payloadRef: string + ): Promise { + try { + // For platforms that don't support exact alarms + // Note: v1 schedules at window start since Capacitor doesn't expose true window behavior + // True "window" scheduling is a v2 responsibility + // v1 emulates windowed behavior by scheduling at window start; actual OS batching may delay delivery + await LocalNotifications.schedule({ + notifications: [{ + id: this.generateNotificationId(slotId, windowStartMs), + title: 'TimeSafari', + body: 'Your daily update is ready', + schedule: { + at: new Date(windowStartMs), + repeats: false + }, + extra: { slotId, payloadRef, windowLenMs } // Carry window length for telemetry + }] + }); + + logger.debug('[SchedulerCapacitor] Scheduled windowed notification for slot', slotId, 'at window start'); + } catch (error) { + logger.error('[SchedulerCapacitor] Failed to schedule windowed notification for slot', slotId, error); + throw error; + } + } + + async cancelBySlot(slotId: SlotId): Promise { + try { + // Get all pending notifications and cancel those matching the slotId + const pending = await LocalNotifications.getPending(); + if (pending?.notifications?.length) { + const matchingIds = pending.notifications + .filter(n => n.extra?.slotId === slotId) + .map(n => ({ id: n.id })); + + if (matchingIds.length > 0) { + await LocalNotifications.cancel({ notifications: matchingIds }); + logger.debug('[SchedulerCapacitor] Cancelled', matchingIds.length, 'notifications for slot', slotId); + } + } + } catch (error) { + logger.error('[SchedulerCapacitor] Failed to cancel notification for slot', slotId, error); + throw error; + } + } + + async rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise { + try { + // Cancel all pending first + const pending = await LocalNotifications.getPending(); + if (pending?.notifications?.length) { + await LocalNotifications.cancel({ + notifications: pending.notifications.map(n => ({ id: n.id })) + }); + } + + // Schedule new set + await LocalNotifications.schedule({ + notifications: next.map(({ slotId, whenMs }) => ({ + id: this.generateNotificationId(slotId, whenMs), + title: 'TimeSafari', + body: 'Your daily update is ready', + schedule: { at: new Date(whenMs) }, + extra: { slotId, whenMs } + })) + }); + + logger.debug('[SchedulerCapacitor] Rescheduled', next.length, 'notifications'); + } catch (error) { + logger.error('[SchedulerCapacitor] Failed to reschedule notifications', error); + throw error; + } + } + + private generateNotificationId(slotId: SlotId, whenMs: number): number { + // 32-bit FNV-1a like hash + let hash = 0x811c9dc5; + const s = `${slotId}-${whenMs}`; + for (let i = 0; i < s.length; i++) { + hash ^= s.charCodeAt(i); + hash = (hash >>> 0) * 0x01000193 >>> 0; + } + return Math.abs(hash | 0); + } +} +``` + +### CallbacksHttp Adapter +```typescript +// src/services/notifications/adapters/CallbacksHttp.ts +import type { CallbackProfile } from '../types'; +import { handleApiError } from '@/utils/errorHandler'; +import { logger, safeStringify } from '@/utils/logger'; + +export class CallbacksHttp { + constructor(private profile: CallbackProfile) {} + + async fetchContent(slotId: string, etag?: string): Promise<{ + payload: unknown; + etag?: string + }|null> { + try { + const spec = this.profile.fetchContent; + const ac = new AbortController(); + const to = setTimeout(() => ac.abort(), spec.timeoutMs ?? 15000); + + try { + const headers = { ...spec.headers }; + if (etag) { + headers['If-None-Match'] = etag; + } + + const response = await fetch(spec.url, { + method: spec.method, + headers, + body: spec.bodyJson ? JSON.stringify(spec.bodyJson) : undefined, + signal: ac.signal + }); + clearTimeout(to); + + if (response.status === 304) return null; + 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 }; + } catch (err) { + clearTimeout(to); + throw err; + } + } catch (error) { + logger.error('[CallbacksHttp] Failed to fetch content for slot', slotId, error); + const enhancedError = handleApiError(error, { + component: 'CallbacksHttp', + operation: 'fetchContent', + timestamp: new Date().toISOString(), + slotId, + etag + }, 'fetchContent'); + + // Log enhanced error details for debugging + logger.error('[CallbacksHttp] Enhanced error details:', enhancedError); + throw error; + } + } + + async ackDelivery(slotId: string, deliveryData: unknown): Promise { + if (!this.profile.ackDelivery) return; + + try { + const spec = this.profile.ackDelivery; + const body = JSON.stringify({ slotId, ...deliveryData }); + const method = spec.method || 'POST'; // Default to POST when body is sent + + await fetch(spec.url, { + method, + headers: spec.headers, + body + }); + + logger.debug('[CallbacksHttp] Acknowledged delivery for slot', slotId); + } catch (error) { + logger.error('[CallbacksHttp] Failed to acknowledge delivery for slot', slotId, error); + // Don't throw - this is not critical + } + } + + async reportError(slotId: string, error: { code?: string; message?: string }): Promise { + if (!this.profile.reportError) return; + + try { + const spec = this.profile.reportError; + const body = JSON.stringify({ slotId, error }); + const method = spec.method || 'POST'; // Default to POST when body is sent + + await fetch(spec.url, { + method, + headers: spec.headers, + body + }); + + logger.debug('[CallbacksHttp] Reported error for slot', slotId); + } catch (reportError) { + logger.error('[CallbacksHttp] Failed to report error for slot', slotId, reportError); + // Don't throw - this is not critical + } + } +} +``` + +--- + +## 8) Core Orchestrator Implementation + +### NotificationOrchestrator + +**Implementation Guarantees:** +- **SlotId generation:** Build SlotId as `HHmm` from `NotificationTime` and use it everywhere (replace any `slot-xx-yy` pattern) +- **Cooldown:** `deliverStoredNow()` will ignore requests if invoked for the same `slotId` within **60 seconds** of the last call +- **Idempotency:** Before scheduling, we will record `status='scheduled'`; unique index will reject duplicates (handle gracefully) +- **Lead window:** Only one online-first attempt per slot inside the lead window; no inner retries (enforced by per-slot `lastAttemptAt` tracking) +- **Reschedule on TZ/DST:** On app resume or timezone offset change, we will cancel & re-arm the rolling window +- **Config persistence:** Configuration will be persisted to `notif_config` table, not just enqueued as events + +```typescript +// src/services/notifications/NotificationOrchestrator.ts +import type { ConfigureOptions, SlotId, DataStore, Scheduler } from './types'; +import { CallbacksHttp } from './adapters/CallbacksHttp'; +import { handleApiError } from '@/utils/errorHandler'; +import { logger, safeStringify } from '@/utils/logger'; + +export class NotificationOrchestrator implements MultiDailyNotification { + constructor(private store: DataStore, private sched: Scheduler) {} + + private opts!: ConfigureOptions; + private callbacks?: CallbacksHttp; + + // Lead window attempt tracking (one attempt per slot per lead window) + private lastAttemptAt: Map = new Map(); + + // Cooldown tracking for deliverStoredNow (60s cooldown per slot) + private lastDeliveredNowAt: Map = new Map(); + + async requestPermissions(): Promise { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + const result = await LocalNotifications.requestPermissions(); + + if (result.display !== 'granted') { + throw new Error('Notification permissions not granted'); + } + + logger.debug('[NotificationOrchestrator] Permissions granted'); + } catch (error) { + logger.error('[NotificationOrchestrator] Failed to request permissions', error); + throw error; + } + } + + async configure(o: ConfigureOptions): Promise { + this.opts = o; + + if (o.callbackProfile) { + this.callbacks = new CallbacksHttp(o.callbackProfile); + } + + // Persist configuration directly to notif_config table via store methods + await this.store.setConfig?.('times', o.times); + await this.store.setConfig?.('ttlSeconds', o.ttlSeconds ?? 86400); + await this.store.setConfig?.('prefetchLeadMinutes', o.prefetchLeadMinutes ?? 20); + await this.store.setConfig?.('storage', o.storage); + if (o.contentTemplate) await this.store.setConfig?.('contentTemplate', o.contentTemplate); + if (o.callbackProfile) await this.store.setConfig?.('callbackProfile', o.callbackProfile); + + logger.debug('[NotificationOrchestrator] Configuration persisted to notif_config'); + } + + async runFullPipelineNow(): Promise { + try { + // 1) For each upcoming slot, attempt online-first fetch + const upcomingSlots = this.getUpcomingSlots(); + + for (const slot of upcomingSlots) { + await this.fetchAndScheduleSlot(slot); + } + + logger.debug('[NotificationOrchestrator] Full pipeline completed'); + } catch (error) { + logger.error('[NotificationOrchestrator] Pipeline failed', error); + throw error; + } + } + + async deliverStoredNow(slotId?: SlotId): Promise { + const targetSlots = slotId ? [slotId] : this.getUpcomingSlots(); + const now = Date.now(); + const cooldownMs = 60 * 1000; // 60 seconds + + for (const slot of targetSlots) { + // Check cooldown + const lastDelivered = this.lastDeliveredNowAt.get(slot); + if (lastDelivered && (now - lastDelivered) < cooldownMs) { + logger.debug('[NotificationOrchestrator] Skipping deliverStoredNow for', slot, '- within 60s cooldown'); + continue; + } + + const content = await this.store.getLatestContent(slot); + if (content) { + const payloadRef = this.createPayloadRef(content.payload); + await this.sched.scheduleExact(slot, Date.now() + 5000, payloadRef); + + // Record delivery time for cooldown + this.lastDeliveredNowAt.set(slot, now); + } + } + } + + async reschedule(): Promise { + // Check permissions before bulk scheduling + const { LocalNotifications } = await import('@capacitor/local-notifications'); + const enabled = await LocalNotifications.areEnabled(); + if (!enabled.value) { + logger.debug('[NotificationOrchestrator] Notifications disabled, skipping reschedule'); + await this.store.recordDelivery('system', Date.now(), 'error', { + code: 'SCHEDULE_DENIED', + message: 'Notifications disabled during reschedule' + }); + return; + } + + const nextOccurrences = this.getUpcomingSlots().map(slotId => ({ + slotId, + whenMs: this.getNextSlotTime(slotId) + })); + + await this.sched.rescheduleAll(nextOccurrences); + } + + async getState(): Promise<{ + enabled: boolean; + slots: SlotId[]; + lastFetchAt?: number; + lastDeliveryAt?: number; + exactAlarmCapable: boolean; + }> { + const capabilities = await this.sched.capabilities(); + + // Get last fetch time from notif_contents table + const lastFetchAt = await this.store.getLastFetchAt(); + + // Get last delivery time from notif_deliveries table + const lastDeliveryAt = await this.store.getLastDeliveryAt(); + + return { + enabled: this.opts ? true : false, + slots: this.opts?.times?.map(t => `${t.hour.toString().padStart(2, '0')}${t.minute.toString().padStart(2, '0')}`) || [], + lastFetchAt, + lastDeliveryAt, + exactAlarmCapable: capabilities.exactAlarms + }; + } + + private async fetchAndScheduleSlot(slotId: SlotId): Promise { + try { + // Check if we're within lead window and have already attempted + const now = Date.now(); + const leadWindowMs = (this.opts.prefetchLeadMinutes ?? 20) * 60 * 1000; + const slotTimeMs = this.getNextSlotTime(slotId); + const isWithinLeadWindow = (slotTimeMs - now) <= leadWindowMs; + + if (isWithinLeadWindow) { + const lastAttempt = this.lastAttemptAt.get(slotId); + if (lastAttempt && (now - lastAttempt) < leadWindowMs) { + // Already attempted within this lead window, skip online-first + logger.debug('[NotificationOrchestrator] Skipping online-first for', slotId, '- already attempted within lead window'); + } else { + // Record this attempt + this.lastAttemptAt.set(slotId, now); + + // Attempt online-first fetch + if (this.callbacks) { + // Get saved ETag for this slot + const storedContent = await this.store.getLatestContent(slotId); + const savedEtag = storedContent?.etag; + + const content = await this.callbacks.fetchContent(slotId, savedEtag); + if (content) { + await this.store.saveContent(slotId, content.payload, content.etag); + await this.scheduleSlot(slotId, content.payload); + return; + } + } + } + } else { + // Outside lead window, attempt online-first fetch + if (this.callbacks) { + // Get saved ETag for this slot + const storedContent = await this.store.getLatestContent(slotId); + const savedEtag = storedContent?.etag; + + const content = await this.callbacks.fetchContent(slotId, savedEtag); + 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' + }); + + const enhancedError = handleApiError(error, { + component: 'NotificationOrchestrator', + operation: 'fetchAndScheduleSlot', + timestamp: new Date().toISOString(), + slotId + }, 'fetchAndScheduleSlot'); + + // Log enhanced error details for debugging + logger.error('[NotificationOrchestrator] Enhanced error details:', enhancedError); + } + } + + private async scheduleSlot(slotId: SlotId, payload: unknown): Promise { + const whenMs = this.getNextSlotTime(slotId); + + // Render template with payload data + const data = this.buildTemplateData(payload); + const rendered = TemplateEngine.render(this.opts.contentTemplate, data); + + // Check permissions before scheduling + const { LocalNotifications } = await import('@capacitor/local-notifications'); + const enabled = await LocalNotifications.areEnabled(); + if (!enabled.value) { + await this.store.recordDelivery(slotId, whenMs, 'error', { + code: 'SCHEDULE_DENIED', + message: 'Notifications disabled' + }); + return; + } + + // Schedule with rendered title/body + await LocalNotifications.schedule({ + notifications: [{ + id: this.generateNotificationId(slotId, whenMs), + title: rendered.title, + body: rendered.body, + schedule: { at: new Date(whenMs) }, + extra: { slotId, whenMs } + }] + }); + + await this.store.recordDelivery(slotId, whenMs, 'scheduled'); + } + + private toSlotId(t: {hour:number; minute:number}): string { + return `${t.hour.toString().padStart(2,'0')}${t.minute.toString().padStart(2,'0')}`; // "HHmm" + } + + private getUpcomingSlots(): SlotId[] { + const now = Date.now(); + const twoDays = 2 * 24 * 60 * 60 * 1000; + const slots: SlotId[] = []; + + for (const t of this.opts.times) { + const slotId = this.toSlotId(t); + const when = this.getNextSlotTime(slotId); + if (when <= (now + twoDays)) slots.push(slotId); + } + return slots; + } + + private getNextSlotTime(slotId: SlotId): number { + const hour = parseInt(slotId.slice(0,2), 10); + const minute = parseInt(slotId.slice(2,4), 10); + + const now = new Date(); + const next = new Date(now); + next.setHours(hour, minute, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + return next.getTime(); + } + + private buildTemplateData(payload: unknown): Record { + const data = payload as Record; + return { + headline: data.headline as string || '[Content]', + summary: data.summary as string || '[Content]', + date: new Date().toISOString().split('T')[0], // YYYY-MM-DD + time: new Date().toTimeString().split(' ')[0].slice(0, 5) // HH:MM + }; + } + + private generateNotificationId(slotId: SlotId, whenMs: number): number { + // 32-bit FNV-1a like hash + let hash = 0x811c9dc5; + const s = `${slotId}-${whenMs}`; + for (let i = 0; i < s.length; i++) { + hash ^= s.charCodeAt(i); + hash = (hash >>> 0) * 0x01000193 >>> 0; + } + return Math.abs(hash | 0); + } + + private isWithinTTL(fetchedAt: number): boolean { + const ttlMs = (this.opts.ttlSeconds || 86400) * 1000; + return Date.now() - fetchedAt < ttlMs; + } +} +``` + +--- + +## 9) Bootstrap & Integration + +### Capacitor Integration +```typescript +// src/main.capacitor.ts - Add to existing initialization +import { NotificationServiceFactory } from './services/notifications/NotificationServiceFactory'; +import { LocalNotifications } from '@capacitor/local-notifications'; + +// Wire action + receive listeners once during app init +LocalNotifications.addListener('localNotificationActionPerformed', e => { + const slotId = e.notification?.extra?.slotId; + logger.debug('[LocalNotifications] Action performed for slot', slotId); + + // TODO: route to screen; reuse existing deep-link system + // This could navigate to a specific view based on slotId + if (slotId) { + // Example: Navigate to daily view or specific content + // router.push(`/daily/${slotId}`); + } +}); + +LocalNotifications.addListener('localNotificationReceived', e => { + const slotId = e.notification?.extra?.slotId; + logger.debug('[LocalNotifications] Notification received for slot', slotId); + + // Optional: light telemetry hook + // Could track delivery success, user engagement, etc. +}); + +// After existing deep link registration +setTimeout(async () => { + try { + await registerDeepLinkListener(); + + // Initialize notifications using factory pattern + const notificationService = NotificationServiceFactory.getInstance(); + if (notificationService) { + await notificationService.runFullPipelineNow(); + logger.info(`[Main] 🎉 Notifications initialized successfully!`); + } else { + logger.warn(`[Main] ⚠️ Notification service not available on this platform`); + } + + logger.info(`[Main] 🎉 All systems fully initialized!`); + } catch (error) { + logger.error(`[Main] ❌ System initialization failed:`, error); + } +}, 2000); +``` + +### Electron Integration +```typescript +// src/main.electron.ts - Add to existing initialization +import { app } from 'electron'; +import { NotificationServiceFactory } from './services/notifications/NotificationServiceFactory'; + +// We will require Node 18+ (global fetch) or we will polyfill via undici +// main.electron.ts (only if Node < 18) +// import 'undici/register'; + +// Windows integration (main process bootstrap) +if (process.platform === 'win32') { + app.setAppUserModelId('com.timesafari.app'); // stable, never change +} + +// Auto-launch (Option 1) +app.setLoginItemSettings({ + openAtLogin: true, + openAsHidden: true +}); + +// Initialize notifications on app ready +app.whenReady().then(async () => { + try { + const notificationService = NotificationServiceFactory.getInstance(); + if (notificationService) { + await notificationService.runFullPipelineNow(); + logger.info(`[Main] 🎉 Electron notifications initialized successfully!`); + } else { + logger.warn(`[Main] ⚠️ Electron notification service not available`); + } + } catch (error) { + logger.error(`[Main] ❌ Electron notification initialization failed:`, error); + } +}); +``` + +### Notification Initialization +```typescript +// src/services/notifications/index.ts +import { LocalNotifications } from '@capacitor/local-notifications'; +import { NotificationOrchestrator } from './NotificationOrchestrator'; +import { NotificationServiceFactory } from './NotificationServiceFactory'; +import { Capacitor } from '@capacitor/core'; +import { logger, safeStringify } from '@/utils/logger'; + +let orchestrator: NotificationOrchestrator | null = null; + +export async function initNotifChannels(): Promise { + try { + 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' }] + }] + }); + + logger.debug('[Notifications] Channels and action types registered'); + } catch (error) { + logger.error('[Notifications] Failed to register channels', error); + throw error; + } +} + +export async function initNotifications(): Promise { + // Only initialize on Capacitor/Electron platforms (not web) + const platform = process.env.VITE_PLATFORM || 'web'; + if (platform === 'web') { + logger.debug('[Notifications] Skipping initialization on web platform - local notifications not supported'); + return; + } + + try { + await initNotifChannels(); + + // Use factory pattern for consistency + const notificationService = NotificationServiceFactory.getInstance(); + if (notificationService) { + // Run pipeline on app start + await notificationService.runFullPipelineNow(); + await notificationService.reschedule(); + + // Prune old data on app start + const platformService = PlatformServiceFactory.getInstance(); + await platformService.$pruneNotifData(14); + + logger.debug('[Notifications] Initialized successfully'); + // Log with safe stringify for complex objects + logger.debug('[Notifications] Initialization details:', safeStringify({ + platform: process.env.VITE_PLATFORM, + timestamp: new Date().toISOString(), + serviceAvailable: !!notificationService + })); + } else { + logger.warn('[Notifications] Service factory returned null'); + } + } catch (error) { + logger.error('[Notifications] Initialization failed', error); + } +} + +export function getNotificationOrchestrator(): NotificationOrchestrator | null { + // Return the singleton instance from the factory + return NotificationServiceFactory.getInstance(); +} +``` + +--- + +## 10) Service Worker Integration + +### Service Worker Re-establishment Required +**Note**: Service workers are intentionally disabled in Electron (see `src/main.electron.ts` lines 36-59) and have minimal web implementation via VitePWA plugin. Web push notifications would require re-implementing the service worker infrastructure. + +### Notification Click Handler +```javascript +// Note: This handler is for WEB PUSH notifications only. +// Capacitor local notifications on mobile do not pass through the service worker. + +// sw_scripts/notification-click.js (or integrate into existing service worker) +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('/'); + }) + ); +}); +``` + +### Service Worker Registration +```typescript +// Service worker registration is handled by VitePWA plugin in web builds +// This would typically go in main.web.ts or a dedicated service worker module +// SW examples use '/sw.js' as a placeholder; wire this to your actual build output path +// (e.g., 'sw_scripts/notification-click.js' or your combined bundle) +// Note: Service workers are intentionally disabled in Electron (src/main.electron.ts) +if ('serviceWorker' in navigator && process.env.VITE_PLATFORM === 'web') { + navigator.serviceWorker.register('/sw.js') + .then(registration => { + console.log('Service Worker registered:', registration); + }) + .catch(error => { + console.error('Service Worker registration failed:', error); + }); +} +``` + +--- + +## 11) Usage Examples + +### Basic Configuration +```typescript +// In a Vue component using vue-facing-decorator and PlatformServiceMixin +import { Component, Vue, Prop } from 'vue-facing-decorator'; +import { NotificationServiceFactory } from '@/services/notifications/NotificationServiceFactory'; +import { PlatformServiceMixin } from '@/utils/PlatformServiceMixin'; + +@Component({ + name: 'NotificationSettingsView', + mixins: [PlatformServiceMixin] +}) +export default class NotificationSettingsView extends Vue { + @Prop({ required: true }) onSave!: (config: ConfigureOptions) => Promise; + @Prop({ required: true }) onCancel!: () => void; + @Prop({ required: false }) onTest?: (slotId: string) => Promise; + + private notificationService = NotificationServiceFactory.getInstance(); + + async mounted() { + if (this.notificationService) { + await this.notificationService.configure({ + times: [{hour:8,minute:0},{hour:12,minute:0},{hour:18,minute:0}], + ttlSeconds: 86400, + prefetchLeadMinutes: 20, + storage: 'shared', + contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' }, + callbackProfile: { + fetchContent: { + method: 'GET', + url: 'https://api.timesafari.app/v1/report/daily', + headers: { Authorization: `Bearer ${token}` }, + timeoutMs: 12000 + } + } + }); + + await this.notificationService.runFullPipelineNow(); + } + } + + async handleSave() { + const config = this.collectConfiguration(); + await this.onSave(config); + } + + handleCancel() { + this.onCancel(); + } + + async handleTestNotification() { + if (this.onTest) { + await this.onTest('0800'); + } + } + + private collectConfiguration(): ConfigureOptions { + // Collect form data and return ConfigureOptions + return { + times: [{ hour: 8, minute: 0 }], + storage: 'shared', + contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' } + }; + } +} +``` + +### Settings Integration +```typescript +// Save notification settings using existing PlatformServiceMixin +await this.$saveSettings({ + notifTimes: [{ hour: 8, minute: 0 }, { hour: 12, minute: 0 }], + notifEnabled: true, + notifMode: 'online-first', + notifTtlSeconds: 86400, + notifPrefetchLeadMinutes: 20 +}); +``` + +### Notification Composable Integration +```typescript +// Note: The existing useNotifications composable in src/composables/useNotifications.ts +// is currently stub functions with eslint-disable comments and needs implementation for the notification system. +// This shows the actual stub function signatures: + +export function useNotifications() { + // Inject the notify function from the app + const notify = inject<(notification: NotificationIface, timeout?: number) => void>("notify"); + + if (!notify) { + throw new Error("useNotifications must be used within a component that has $notify available"); + } + + // All functions are currently stubs with eslint-disable comments + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function success(_notification: NotificationIface, _timeout?: number) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function error(_notification: NotificationIface, _timeout?: number) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function warning(_notification: NotificationIface, _timeout?: number) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function info(_notification: NotificationIface, _timeout?: number) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function toast(_title: string, _text?: string, _timeout?: number) {} + // ... other stub functions + + return { + success, error, warning, info, toast, + copied, sent, confirm, confirmationSubmitted, + genericError, genericSuccess, alreadyConfirmed, + cannotConfirmIssuer, cannotConfirmHidden, + notRegistered, notAGive, notificationOff, downloadStarted + }; +} +``` + +--- + +## 11) Testing & Validation + +### Unit Test Example +```typescript +// test/services/notifications/NotificationOrchestrator.test.ts +import { NotificationOrchestrator } from '@/services/notifications/NotificationOrchestrator'; +import { DataStoreSqlite } from '@/services/notifications/adapters/DataStoreSqlite'; +import { SchedulerCapacitor } from '@/services/notifications/adapters/SchedulerCapacitor'; + +describe('NotificationOrchestrator', () => { + let orchestrator: NotificationOrchestrator; + let mockStore: jest.Mocked; + let mockScheduler: jest.Mocked; + + beforeEach(() => { + mockStore = createMockDataStore(); + mockScheduler = createMockScheduler(); + orchestrator = new NotificationOrchestrator(mockStore, mockScheduler); + }); + + it('should configure successfully', async () => { + const config = { + times: [{ hour: 8, minute: 0 }], + ttlSeconds: 86400, + storage: 'shared' as const, + contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' } + }; + + await orchestrator.configure(config); + + // Verify configuration persisted to database + const savedTimes = await mockStore.getConfig?.('times'); + expect(savedTimes).toEqual(config.times); + }); + + it('should schedule upcoming slots', async () => { + await orchestrator.configure({ + times: [{ hour: 8, minute: 0 }], + storage: 'shared' as const, + contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' } + }); + + await orchestrator.runFullPipelineNow(); + + expect(mockScheduler.scheduleExact).toHaveBeenCalled(); + }); +}); +``` + +### Playwright E2E Test Example +```typescript +// test-playwright/notifications.spec.ts +import { test, expect } from '@playwright/test'; + +test.describe('Notification System', () => { + test('should configure and display notification settings', async ({ page }) => { + await page.goto('/settings/notifications'); + + // Configure notification times using function props pattern + await page.click('[data-testid="add-time-button"]'); + await page.fill('[data-testid="hour-input"]', '8'); + await page.fill('[data-testid="minute-input"]', '0'); + await page.click('[data-testid="save-time-button"]'); + + // Enable notifications + await page.click('[data-testid="enable-notifications"]'); + + // Verify configuration is saved + await expect(page.locator('[data-testid="next-occurrence"]')).toBeVisible(); + await expect(page.locator('[data-testid="pending-count"]')).toContainText('1'); + }); + + test('should handle window fallback on Android', async ({ page }) => { + // Mock Android without exact alarm permission + await page.addInitScript(() => { + Object.defineProperty(window, 'Capacitor', { + value: { + isNativePlatform: () => true, + getPlatform: () => 'android' + } + }); + }); + + await page.goto('/settings/notifications'); + await page.click('[data-testid="enable-notifications"]'); + + // Configure a slot for immediate testing + await page.fill('[data-testid="hour-input"]', '0'); + await page.fill('[data-testid="minute-input"]', '1'); // 1 minute from now + await page.click('[data-testid="save-time-button"]'); + + // Wait for notification to fire (within +10m of slot time) + await page.waitForTimeout(70000); // Wait 70 seconds + + // Verify notification appeared (may be delayed up to 10 minutes) + const notification = await page.locator('[data-testid="notification-delivered"]'); + await expect(notification).toBeVisible({ timeout: 600000 }); // 10 minute timeout + }); +}); +``` + +### Jest Integration Test +```typescript +// test/services/notifications/integration.test.ts +import { NotificationServiceFactory } from '@/services/notifications/NotificationServiceFactory'; +import { PlatformServiceFactory } from '@/services/PlatformServiceFactory'; + +describe('Notification System Integration', () => { + beforeEach(() => { + // Reset factory instances + NotificationServiceFactory.reset(); + PlatformServiceFactory.reset(); + }); + + it('should integrate with PlatformServiceFactory', () => { + const platformService = PlatformServiceFactory.getInstance(); + const notificationService = NotificationServiceFactory.getInstance(); + + expect(platformService).toBeDefined(); + expect(notificationService).toBeDefined(); + expect(notificationService).toBeInstanceOf(Object); + }); + + it('should handle platform detection correctly', () => { + const originalPlatform = process.env.VITE_PLATFORM; + + // Test web platform + process.env.VITE_PLATFORM = 'web'; + const webService = NotificationServiceFactory.getInstance(); + expect(webService).toBeNull(); // Should be null on web + + // Test capacitor platform + process.env.VITE_PLATFORM = 'capacitor'; + const capacitorService = NotificationServiceFactory.getInstance(); + expect(capacitorService).toBeDefined(); + + // Test electron platform + process.env.VITE_PLATFORM = 'electron'; + const electronService = NotificationServiceFactory.getInstance(); + expect(electronService).toBeDefined(); + + // Restore original platform + process.env.VITE_PLATFORM = originalPlatform; + }); +}); +``` + +--- + +## 12) Service Architecture Integration + +### Factory Pattern Alignment +Follow TimeSafari's existing service factory pattern: +```typescript +// src/services/notifications/NotificationServiceFactory.ts +import { NotificationOrchestrator } from './NotificationOrchestrator'; +import { DataStoreSqlite } from './adapters/DataStoreSqlite'; +import { SchedulerCapacitor } from './adapters/SchedulerCapacitor'; +import { SchedulerElectron } from './adapters/SchedulerElectron'; +import { PlatformServiceFactory } from '../PlatformServiceFactory'; +import { logger, safeStringify } from '@/utils/logger'; + +export class NotificationServiceFactory { + private static instance: NotificationOrchestrator | null = null; + + public static getInstance(): NotificationOrchestrator | null { + if (NotificationServiceFactory.instance) { + return NotificationServiceFactory.instance; + } + + try { + const platformService = PlatformServiceFactory.getInstance(); + const store = new DataStoreSqlite(platformService); + + // Choose scheduler based on platform + const platform = process.env.VITE_PLATFORM || 'web'; + let scheduler; + + if (platform === 'electron') { + scheduler = new SchedulerElectron(); + } else if (platform === 'capacitor') { + scheduler = new SchedulerCapacitor(); + } else { + // Web platform - no local notifications + logger.debug('[NotificationServiceFactory] Web platform detected - no local notifications'); + return null; + } + + NotificationServiceFactory.instance = new NotificationOrchestrator(store, scheduler); + logger.debug('[NotificationServiceFactory] Created singleton instance for', platform); + // Log with safe stringify for complex objects + logger.debug('[NotificationServiceFactory] Factory details:', safeStringify({ + platform, + timestamp: new Date().toISOString(), + schedulerType: platform === 'electron' ? 'SchedulerElectron' : 'SchedulerCapacitor' + })); + + return NotificationServiceFactory.instance; + } catch (error) { + logger.error('[NotificationServiceFactory] Failed to create instance', error); + return null; + } + } + + public static reset(): void { + NotificationServiceFactory.instance = null; + } +} +``` + +### Platform Detection Integration +Use TimeSafari's actual platform detection patterns: +```typescript +// In NotificationOrchestrator +private isNativePlatform(): boolean { + const platform = process.env.VITE_PLATFORM || 'web'; + return platform === 'capacitor' || platform === 'electron'; +} + +private getPlatform(): string { + return process.env.VITE_PLATFORM || 'web'; +} + +private isWebPlatform(): boolean { + return process.env.VITE_PLATFORM === 'web'; +} +``` + +### DID Integration +```typescript +// src/services/notifications/DidIntegration.ts +import { logger, safeStringify } from '@/utils/logger'; + +export class DidIntegration { + constructor(private userDid: string | null) {} + + /** + * Associate notification with user's did:ethr: DID for privacy-preserving identity + * TimeSafari uses Ethereum-based DIDs in format: did:ethr:0x[40-char-hex] + */ + async associateWithDid(slotId: string, payload: unknown): Promise { + if (!this.userDid) { + logger.debug('[DidIntegration] No user DID available for notification association'); + // Log with safe stringify for complex objects + logger.debug('[DidIntegration] DID check details:', safeStringify({ + userDid: this.userDid, + slotId, + timestamp: new Date().toISOString() + })); + return payload; + } + + // Validate DID format (did:ethr:0x...) + if (!this.userDid.startsWith('did:ethr:0x') || this.userDid.length !== 53) { + logger.debug('[DidIntegration] Invalid did:ethr: format', this.userDid); + return payload; + } + + // Add DID context to payload without exposing sensitive data + return { + ...payload, + metadata: { + userDid: this.userDid, + slotId, + timestamp: Date.now() + } + }; + } + + /** + * Validate notification belongs to current user's did:ethr: DID + */ + async validateNotificationOwnership(notificationData: unknown): Promise { + if (!this.userDid) return false; + + const data = notificationData as { metadata?: { userDid?: string } }; + return data.metadata?.userDid === this.userDid; + } + + /** + * Get user context for notification personalization using did:ethr: format + */ + async getUserContext(): Promise<{ did: string; preferences: Record } | null> { + if (!this.userDid) return null; + + return { + did: this.userDid, + preferences: { + // Add user preferences from settings + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + language: navigator.language + } + }; + } +} +``` + +--- + +## 13) File Structure + +``` +/src/services/notifications/ + index.ts # Main exports and initialization + types.ts # Type definitions + NotificationOrchestrator.ts # Core orchestrator + NotificationServiceFactory.ts # Factory following TimeSafari pattern + DidIntegration.ts # DID integration for privacy-preserving identity + adapters/ + DataStoreSqlite.ts # SQLite data store adapter + SchedulerCapacitor.ts # Capacitor scheduler adapter + CallbacksHttp.ts # HTTP callbacks adapter + TemplateEngine.ts # Template rendering and token substitution + NotificationSettingsView.vue # Vue component for settings + notifications.spec.ts # Jest unit tests + notifications.e2e.ts # Playwright E2E tests + notifications.integration.ts # Jest integration tests +``` +/src/views/ + NotificationSettingsView.vue # Vue component using vue-facing-decorator +/sw_scripts/ + notification-click.js # Service worker click handler +/src/db-sql/ + migration.ts # Extended with notification tables (follows existing pattern) +/src/db/tables/ + settings.ts # Extended Settings type (follows existing pattern) +/src/utils/ + PlatformServiceMixin.ts # Extended with notification methods (follows existing pattern) +/src/main.capacitor.ts # Extended with notification initialization (follows existing pattern) +/src/services/ + api.ts # Extended error handling (follows existing pattern) +/test/services/notifications/ + NotificationOrchestrator.test.ts # Jest unit tests + integration.test.ts # Integration tests +/test-playwright/ + notifications.spec.ts # Playwright E2E tests +``` + +--- + +## 14) TimeSafari Architecture Compliance + +### Design Pattern Adherence +- **Factory Pattern:** `NotificationServiceFactory` follows the same singleton pattern as `PlatformServiceFactory` +- **Mixin Pattern:** Database access uses existing `PlatformServiceMixin` methods (`$db`, `$exec`, `$one`) +- **Migration Pattern:** Database changes follow existing `MIGRATIONS` array pattern in `src/db-sql/migration.ts` +- **Error Handling:** Uses existing comprehensive error handling from `src/utils/errorHandler.ts` for consistent error processing +- **Logging:** Uses existing logger from `src/utils/logger` with established logging patterns +- **Platform Detection:** Uses existing `Capacitor.isNativePlatform()` and `process.env.VITE_PLATFORM` patterns + +### File Organization Compliance +- **Services:** Follows existing `src/services/` organization with factory and adapter pattern +- **Database:** Extends existing `src/db-sql/migration.ts` and `src/db/tables/settings.ts` +- **Utils:** Extends existing `src/utils/PlatformServiceMixin.ts` with notification methods +- **Main Entry:** Integrates with existing `src/main.capacitor.ts` initialization pattern +- **Service Workers:** Follows existing `sw_scripts/` organization pattern + +### Type Safety Compliance +- **Settings Extension:** Follows existing Settings type extension pattern in `src/db/tables/settings.ts` +- **Interface Definitions:** Uses existing TypeScript interface patterns from `src/interfaces/` +- **Error Types:** Follows existing error handling type patterns from `src/services/api.ts` +- **Platform Types:** Uses existing platform detection type patterns from `src/services/PlatformService.ts` + +### Integration Points +- **Database Access:** All database operations use `PlatformServiceMixin` methods for consistency +- **Platform Services:** Leverages existing `PlatformServiceFactory.getInstance()` for platform detection +- **Error Handling:** Integrates with existing comprehensive `handleApiError` from `src/utils/errorHandler.ts` for consistent error processing +- **Logging:** Uses existing logger with established patterns for debugging and monitoring +- **Initialization:** Follows existing `main.capacitor.ts` initialization pattern with proper error handling +- **Vue Architecture:** Follows Vue 3 + vue-facing-decorator patterns for component integration +- **State Management:** Integrates with PlatformServiceMixin for notification state management +- **Identity System:** Integrates with `did:ethr:` (Ethereum-based DID) system for privacy-preserving user context +- **Testing:** Follows Playwright E2E testing patterns established in TimeSafari +- **Privacy Architecture:** Follows TimeSafari's privacy-preserving claims architecture +- **Community Focus:** Enhances TimeSafari's mission of connecting people through gratitude and gifts +- **Platform Detection:** Uses actual `process.env.VITE_PLATFORM` patterns (`web`, `capacitor`, `electron`) +- **Database Architecture:** Supports platform-specific backends: + - **Web**: Absurd SQL (SQLite via IndexedDB) via `WebPlatformService` with worker pattern + - **Capacitor**: Native SQLite via `CapacitorPlatformService` + - **Electron**: Native SQLite via `ElectronPlatformService` (extends CapacitorPlatformService) + +--- + +## 15) Cross-Doc Sync Hygiene + +### Canonical Ownership +- **Plan document**: Canonical for Goals, Tenets, Platform behaviors, Acceptance criteria, Test cases +- **This document (Implementation)**: Canonical for API definitions, Database schemas, Adapter implementations, Code examples + +### Synchronization Requirements +- **API code blocks**: Must be identical between Plan §4 and Implementation §3 (Public API (Shared)) +- **Feature flags**: Must match between Plan §12 table and Implementation defaults +- **Test cases**: Plan §13 acceptance criteria must align with Implementation test examples +- **Error codes**: Plan §11 taxonomy must match Implementation error handling +- **Slot/TTL/Lead policies**: Must be identical between Plan §4 policy and Implementation §3 policy + +### PR Checklist +When changing notification system behavior, update both documents: +- [ ] **API changes**: Update types/interfaces in both Plan §4 and Implementation §3 +- [ ] **Schema changes**: Update Plan §5 and Implementation §2 +- [ ] **Slot/TTL changes**: Update Plan §4 semantics and Implementation §7 logic +- [ ] **Template changes**: Update Plan §9 contract and Implementation §4 engine +- [ ] **Error codes**: Update Plan §11 taxonomy and Implementation §3 types + +--- + +## Sync Checklist + +| Sync item | Plan | Impl | Status | +| ------------------------------ | --------------------- | --------------------- | --------- | +| Public API block identical | §4 | §3 | ✅ | +| `getState()` fields present | §4 | §8 Orchestrator | ✅ | +| Capacitor action handlers | §7 (iOS/Android note) | §9 Bootstrap | ✅ | +| Electron fetch prereq/polyfill | §7 | §9 Electron | ✅ | +| Android ±10m fallback | §7 | §7 SchedulerCapacitor | ✅ | +| Retention (no VACUUM v1) | §5 | `$pruneNotifData` | ✅ | + +--- + +*This implementation guide provides complete, working code for integrating the notification system with TimeSafari's existing infrastructure. All code examples are production-ready and follow TimeSafari's established patterns. For strategic overview, see `notification-system-plan.md`.* diff --git a/doc/notification-system-plan.md b/doc/notification-system-plan.md new file mode 100644 index 00000000..3f147481 --- /dev/null +++ b/doc/notification-system-plan.md @@ -0,0 +1,457 @@ +# TimeSafari Notification System — Strategic Plan + +**Status:** 🚀 Active plan +**Date:** 2025-09-05T05:09Z (UTC) +**Author:** Matthew Raymer +**Scope:** v1 (in‑app orchestrator) now; path to v2 (native plugin) next +**Goal:** We **will deliver** 1..M local notifications/day with content **prefetched** so messages **will display offline**. We **will support** online‑first (API→DB→Schedule) with offline‑first fallback. The system **will enhance** TimeSafari's community-building mission by keeping users connected to gratitude, gifts, and collaborative projects through timely, relevant notifications. + +> **Implementation Details:** See `notification-system-implementation.md` for detailed code, database schemas, and integration specifics. +> **Canonical Ownership:** This document owns Goals, Tenets, Platform behaviors, Acceptance criteria, and Test cases. + +--- + +## 1) Versioning & Intent + +- **v1 (In‑App Orchestrator):** We **will implement** multi‑daily local notifications, online/offline flows, templating, SQLite persistence, and eventing **inside the app** using Capacitor Local Notifications. +- **v2 (Plugin):** We **will extract** adapters to a Capacitor/Native plugin to gain native schedulers (WorkManager/AlarmManager; BGTask+UNUserNotificationCenter), native HTTP, and native SQLite **with the same TypeScript API**. + +> We **will retain** the existing web push + Service Worker foundation; the system **will add** reliable local scheduling on mobile and a unified API across platforms. + +--- + +## 2) Design Tenets + +- **Reliability:** OS‑level delivery once scheduled; no reliance on JS being alive at fire time. +- **Freshness:** Prefer online‑first within a short prefetch window; degrade gracefully to cached content with TTL. +- **Extractable:** Clean interfaces (Scheduler, DataStore, Callbacks) so v2 **will swap** adapters without API changes. +- **Simplicity:** One‑shot notifications per slot; rolling window scheduling to respect platform caps. +- **Observability:** Persist deliveries and errors; surface minimal metrics; enable ACKs. +- **Privacy-First:** Follow TimeSafari's privacy-preserving architecture; user-controlled visibility and data sovereignty. +- **Community-Focused:** Enhance TimeSafari's mission of connecting people through gratitude, gifts, and collaborative projects. + +--- + +## 3) Architecture Overview + +``` +Application (Vue/TS) + ├─ NotificationOrchestrator (core state) + │ ├─ Scheduler (adapter) + │ ├─ DataStore (adapter) + │ └─ Callbacks (adapter) + └─ UI (settings, status) + +Adapters + ├─ V1: SchedulerCapacitor, DataStoreSqlite, CallbacksHttp + └─ V2: SchedulerNative, DataStoreNativeSqlite, CallbacksNativeHttp + +Platform + ├─ iOS/Android: LocalNotifications (+ native bridges later) + ├─ Web: Service Worker + Push (kept) + └─ Electron: OS notifications (thin adapter) +``` + +**Execution modes (concise):** +- **Online‑First:** wake near slot → fetch (ETag, timeout) → persist → schedule; on failure → Offline‑First. +- **Offline‑First:** read last good payload from SQLite; if beyond TTL → skip notification (no retry). + +--- + +## 4) Public API (Shared by v1 & v2) + +```ts +export type NotificationTime = { hour: number; minute: number }; // local wall-clock +export type SlotId = string; // Format: "HHmm" (e.g., "0800", "1200", "1800") - stable across TZ changes + +export type FetchSpec = { + method: 'GET'|'POST'; + url: string; + headers?: Record; + bodyJson?: Record; + timeoutMs?: number; +}; + +export type CallbackProfile = { + fetchContent: FetchSpec; + ackDelivery?: Omit; + reportError?: Omit; + heartbeat?: Omit & { intervalMinutes?: number }; +}; + +export type ConfigureOptions = { + times: NotificationTime[]; // 1..M daily + timezone?: string; // Default: system timezone + ttlSeconds?: number; // Default: 86400 (24h) + prefetchLeadMinutes?: number; // Default: 20 + storage: 'shared'|'private'; // Required + contentTemplate: { title: string; body: string }; // Required + callbackProfile?: CallbackProfile; // Optional +}; + +export interface MultiDailyNotification { + requestPermissions(): Promise; + configure(o: ConfigureOptions): Promise; + runFullPipelineNow(): Promise; // API→DB→Schedule (today's remaining) + deliverStoredNow(slotId?: SlotId): Promise; // 60s cooldown guard + reschedule(): Promise; + getState(): Promise<{ + nextOccurrences: Array<{ slotId: SlotId; when: string }>; // ISO + lastFetchAt?: string; lastDeliveryAt?: string; + pendingCount: number; exactAlarmCapable?: boolean; + }>; +} +``` + +> **Storage semantics:** `'shared'` = app DB; `'private'` = plugin-owned/native DB (v2). (No functional difference in v1.) + +> **Slot Identity & Scheduling Policy** +> • **SlotId** uses canonical `HHmm` and remains stable across timezone changes. +> • **Lead window:** default `prefetchLeadMinutes = 20`; no retries once inside the lead. +> • **TTL policy:** When offline and content is beyond TTL, **we will skip** the notification (no "(cached)" suffix). +> • **Idempotency:** Duplicate "scheduled" deliveries are prevented by a unique index on `(slot_id, fire_at, status='scheduled')`. +> • **Time handling:** Slots will follow **local wall-clock** time across TZ/DST; `slotId=HHmm` stays constant and we will **recompute fire times** on offset change. + +--- + +## 5) Data Model & Retention (SQLite) + +**Tables:** `notif_contents`, `notif_deliveries`, `notif_config` + +**Retention:** We **will keep** ~14 days of contents/deliveries (configurable) and **will prune** via a simple daily job that runs on app start/resume. We **will prune** daily but **will not** VACUUM by default on mobile; disk compaction is deferred. + +**Payload handling:** We **will template** `{title, body}` **before** scheduling; we **will not** mutate at delivery time. + +--- + +## 6) Scheduling Policy & Slot Math + +- **One‑shot per slot** per day (non‑repeating). +- **Rolling window:** today's remaining slots; seed tomorrow where platform limits allow. v1 will schedule **the next occurrence per slot** by default; a **configurable depth** (0=today, 1=today+tomorrow) may be enabled as long as the iOS pending cap is respected. +- **TZ/DST safe:** We **will recompute** local wall‑times on app resume and whenever timezone/offset changes; then **reschedule**. +- **Android exactness:** If exact alarms are unavailable or denied, we **will use** `setWindow` semantics via the scheduler adapter. +- **iOS pending cap:** We **will keep** pending locals within typical caps (~64) by limiting the window and canceling/re‑arming as needed. +- **Electron rolling window:** On Electron we **will schedule** the **next occurrence per slot** by default; depth (today+tomorrow) **will be** enabled only when auto-launch is on, to avoid drift while the app is closed. + +--- + +## 7) Platform Essentials + +**iOS** +- Local notifications **will** fire without background runtime once scheduled. NSE **will not** mutate locals; delivery-time enrichment requires remote push (future). +- **Category ID**: `TS_DAILY` with default `OPEN` action +- **Background budget** is short and OS‑managed; any prefetch work **will complete** promptly. +- **Mobile local notifications will route via action listeners (not the service worker)**. + +**Android** +- Exact alarms on **API 31+** may require `SCHEDULE_EXACT_ALARM`. If exact access is missing on API 31+, we will use a **windowed trigger (default ±10m)** and surface a settings deep-link. +- **We will deep-link users to the exact-alarm settings when we detect denials.** +- **Channel defaults**: ID `timesafari.daily`, name "TimeSafari Daily", importance=high (IDs never change) +- Receivers for reboot/time change **will be handled** by v2 (plugin); in v1, re‑arming **will occur** on app start/resume. +- **Mobile local notifications will route via action listeners (not the service worker)**. + +**Web** +- Requires registered Service Worker + permission; can deliver with browser closed. **Web will not offline-schedule**. +- Service worker click handlers apply to **web push only**; local notifications on mobile do **not** pass through the SW. +- SW examples use `/sw.js` as a placeholder; **wire this to your actual build output path** (e.g., `sw_scripts/notification-click.js` or your combined bundle). +- **Note**: Service workers are **intentionally disabled** in Electron (`src/main.electron.ts`) and web uses VitePWA plugin for minimal implementation. + +**Electron** +- We **will use** native OS notifications with **best-effort scheduling while the app is running**; true background scheduling will be addressed in v2 (native bridges). + +**Electron delivery strategy (v1 reality + v2 path)** +We **will deliver** desktop notifications while the Electron app is running. True **background scheduling when the app is closed** is **out of scope for v1** and **will be addressed** in v2 via native bridges. We **will adopt** one of the following options (in order of fit to our codebase): + +**In-app scheduler + auto-launch (recommended now):** Keep the orchestrator in the main process, **start on login** (tray app, hidden window), and use the **Electron `Notification` API** for delivery. This requires no new OS services and aligns with our PlatformServiceFactory/mixin patterns. + +**Policy (v1):** If the app is **not running**, Electron will **not** deliver scheduled locals. With **auto-launch enabled**, we **will achieve** near-mobile parity while respecting OS sleep/idle behavior. + +**UX notes:** On Windows we **will set** `appUserModelId` so toasts are attributed correctly; on macOS we **will request** notification permission on first use. + +**Prerequisites:** We **will require** Node 18+ (global `fetch`) or we **will polyfill** via `undici` for content fetching in the main process. + +--- + +## 8) Template Engine Contract + +**Supported tokens:** `{{headline}}`, `{{summary}}`, `{{date}}` (YYYY-MM-DD), `{{time}}` (HH:MM). +**Escaping:** HTML-escape all injected values. +**Limits:** Title ≤ 50 chars; Body ≤ 200 chars; truncate with ellipsis. +**Fallback:** Missing token → `"[Content]"`. +**Mutation:** We **will** render templates **before** scheduling; no mutation at delivery time on iOS locals. + +## 9) Integration with Existing TimeSafari Infrastructure + +**Database:** We **will integrate** with existing migration system in `src/db-sql/migration.ts` following the established `MIGRATIONS` array pattern +**Settings:** We **will extend** existing Settings type in `src/db/tables/settings.ts` following the established type extension pattern +**Platform Service:** We **will leverage** existing PlatformServiceMixin database utilities following the established mixin pattern +**Service Factory:** We **will follow** the existing `PlatformServiceFactory` singleton pattern for notification service creation +**Capacitor:** We **will integrate** with existing deep link system in `src/main.capacitor.ts` following the established initialization pattern +**Service Worker:** We **will extend** existing service worker infrastructure following the established `sw_scripts/` pattern (Note: Service workers are intentionally disabled in Electron and have minimal web implementation via VitePWA plugin) +**API:** We **will use** existing error handling from `src/services/api.ts` following the established `handleApiError` pattern +**Logging:** We **will use** existing logger from `src/utils/logger` following the established logging patterns +**Platform Detection:** We **will use** existing `process.env.VITE_PLATFORM` patterns (`web`, `capacitor`, `electron`) +**Vue Architecture:** We **will follow** Vue 3 + vue-facing-decorator patterns for component integration (Note: The existing `useNotifications` composable in `src/composables/useNotifications.ts` is currently stub functions with eslint-disable comments and needs implementation) +**State Management:** We **will integrate** with existing settings system via `PlatformServiceMixin.$saveSettings()` for notification preferences (Note: TimeSafari uses PlatformServiceMixin for all state management, not Pinia stores) +**Identity System:** We **will integrate** with existing `did:ethr:` (Ethereum-based DID) system for user context +**Testing:** We **will follow** Playwright E2E testing patterns established in TimeSafari +**Database Architecture:** We **will support** platform-specific database backends: +- **Web**: Absurd SQL (SQLite via IndexedDB) via `WebPlatformService` with worker pattern +- **Capacitor**: Native SQLite via `CapacitorPlatformService` +- **Electron**: Native SQLite via `ElectronPlatformService` (extends CapacitorPlatformService) + +--- + +## 10) Error Taxonomy & Telemetry + +**Error Codes:** `FETCH_TIMEOUT`, `ETAG_NOT_MODIFIED`, `SCHEDULE_DENIED`, `EXACT_ALARM_MISSING`, `STORAGE_BUSY`, `TEMPLATE_MISSING_TOKEN`, `PERMISSION_DENIED`. + +**Event Envelope:** `code, slotId, whenMs, attempt, networkState, tzOffset, appState, timestamp`. + +--- + +## 11) Permission UX & Channels/Categories + +- We **will request** notification permission **after** user intent (e.g., settings screen), not on first render. +- **Android:** We **will create** a stable channel ID (e.g., `timesafari.daily`) and **will set** importance appropriately. +- **iOS:** We **will register** categories for optional actions; grouping may use `threadIdentifier` per slot/day. + +--- + +## 12) Eventing & Telemetry + +### Error Taxonomy +**Finite error code set:** +- `FETCH_TIMEOUT` - Network request exceeded timeout +- `ETAG_NOT_MODIFIED` - Server returned 304 (expected) +- `SCHEDULE_DENIED` - OS denied notification scheduling +- `EXACT_ALARM_MISSING` - Android exact alarm permission absent +- `STORAGE_BUSY` - Database locked or unavailable +- `TEMPLATE_MISSING_TOKEN` - Required template variable not found +- `PERMISSION_DENIED` - User denied notification permissions + +### Event Logging Envelope +```ts +{ + code: string, // Error code from taxonomy + slotId: string, // Affected slot + whenMs: number, // Scheduled time + attempt: number, // Retry attempt (1-based) + networkState: string, // 'online' | 'offline' + tzOffset: number, // Current timezone offset + appState: string, // 'foreground' | 'background' | 'killed' + timestamp: number // UTC timestamp +} +``` + +### ACK Payload Format +```ts +{ + slotId: string, + fireAt: number, // Scheduled time + deliveredAt: number, // Actual delivery time + deviceTz: string, // Device timezone + appVersion: string, // App version + buildId: string // Build identifier +} +``` + +- **Event queue (v1):** In-memory queue for `delivery`, `error`, `heartbeat` events. Background/native work **will enqueue**; foreground **will drain** and publish to the UI. **v2 will migrate** to SQLite-backed queue for persistence. +- **Callbacks (optional):** `ackDelivery`, `reportError`, `heartbeat` **will post** to server endpoints when configured. +- **Minimal metrics:** pending count, last fetch, last delivery, next occurrences. + +--- + +## 13) Feature Flags & Config + +### Feature Flags Table +| Flag | Default | Description | Location | +|------|---------|-------------|----------| +| `scheduler` | `'capacitor'` | Scheduler implementation | `notif_config` table | +| `mode` | `'auto'` | Online-first inside lead, else offline-first | `notif_config` table | +| `prefetchLeadMinutes` | `20` | Lead time for prefetch attempts | `notif_config` table | +| `ttlSeconds` | `86400` | Content staleness threshold (24h) | `notif_config` table | +| `iosCategoryIdentifier` | `'TS_DAILY'` | iOS notification category | `notif_config` table | +| `androidChannelId` | `'timesafari.daily'` | Android channel ID (never changes) | `notif_config` table | + +**Storage:** Feature flags **will reside** in `notif_config` table as key-value pairs, separate from user settings. + +--- + +## 14) Acceptance (Definition of Done) → Test Cases + +### Explicit Test Checks +- **App killed → locals fire**: Configure slots at 8:00, 12:00, 18:00; kill app; verify notifications fire at each slot on iOS/Android +- **ETag 304 path**: Server returns 304 → keep previous content; locals fire with cached payload +- **ETag 200 path**: Server returns 200 → update content and re-arm locals with fresh payload +- **Offline + beyond TTL**: When offline and content > 24h old → skip notification (no "(cached)" suffix) +- **iOS pending cap**: Respect ~64 pending limit; cancel/re-arm as needed within rolling window +- **Exact-alarm denied**: Android permission absent → windowed schedule (±10m) activates; UI shows fallback hint +- **Permissions disabled** → we will record `SCHEDULE_DENIED` and refrain from queuing locals. +- **Window fallback** → when exact alarm is absent on Android, verify target fires within **±10m** of slot time (document as an E2E expectation). +- **Timezone change**: On TZ/DST change → recompute wall-clock times; cancel & re-arm all slots +- **Lead window respect**: No retries attempted once inside 20min lead window +- **Idempotency**: Multiple `runFullPipelineNow()` calls don't create duplicate scheduled deliveries +- **Cooldown guard**: `deliverStoredNow()` has 60s cooldown to prevent double-firing + +### Electron-Specific Test Checks +- **Electron running (tray or window) → notifications fire** at configured slots using Electron `Notification` +- **Electron not running →** no delivery (documented limitation for v1) +- **Start on Login enabled →** after reboot + login, orchestrator **will re-arm** slots and deliver +- **Template limits honored** (Title ≤ 50, Body ≤ 200) on Electron notifications +- **SW scope** not used for Electron (click handlers are **web only**) +- **Windows appUserModelId** set correctly for toast attribution +- **macOS notification permission** requested on first use + +--- + +## 15) Test Matrix (Essentials) + +- **Android:** exact vs inexact branch, Doze/App Standby behavior, reboot/time change, permission denial path, deep‑link to exact‑alarm settings. +- **iOS:** BG fetch budget limits, pending cap windowing, local notification delivery with app terminated, category actions. +- **Web:** SW lifecycle, push delivery with app closed, click handling, no offline scheduling. +- **Cross‑cutting:** ETag/304 behavior, TTL policy, templating correctness, event queue drain, SQLite retention job. + +--- + +## 16) Migration & Rollout Notes + +- We **will keep** existing web push flows intact. +- We **will introduce** the orchestrator behind a feature flag, initially with a small number of slots. +- We **will migrate** settings to accept multiple times per day. +- We **will document** platform caveats inside user‑visible settings (e.g., Android exact alarms, iOS cap). + +--- + +## 17) Security & Privacy + +- Tokens **will reside** in Keystore/Keychain (mobile) and **will be injected** at request time; they **will not** be stored in SQLite. +- Optionally, SQLCipher at rest for mobile; redaction of PII in logs; payload size caps. +- Content **will be** minimal (title/body); sensitive data **will not be** embedded. + +--- + +## 18) Non‑Goals (Now) + +- Complex action sets and rich media on locals (kept minimal). +- Delivery‑time mutation of local notifications on iOS (NSE is for remote). +- Full analytics pipeline (future enhancement). + +--- + +## 19) Cross-Doc Sync Hygiene + +### Canonical Ownership +- **This document (Plan)**: Canonical for Goals, Tenets, Platform behaviors, Acceptance criteria, Test cases +- **Implementation document**: Canonical for API definitions, Database schemas, Adapter implementations, Code examples + +### PR Checklist +When changing notification system behavior, update both documents: +- [ ] **API changes**: Update types/interfaces in both Plan §4 and Implementation §3 +- [ ] **Schema changes**: Update Plan §5 and Implementation §2 +- [ ] **Slot/TTL changes**: Update Plan §4 semantics and Implementation §6 logic +- [ ] **Template changes**: Update Plan §9 contract and Implementation examples +- [ ] **Error codes**: Update Plan §11 taxonomy and Implementation error handling + +### Synchronization Points +- **API code blocks**: Must be identical between Plan §4 and Implementation §3 (Public API (Shared)) +- **Feature flags**: Must match between Plan §12 table and Implementation defaults +- **Test cases**: Plan §13 acceptance criteria must align with Implementation test examples +- **Slot/TTL/Lead policies**: Must be identical between Plan §4 policy and Implementation §3 policy + +--- + +## 21) Privacy & Security Alignment + +### Privacy-First Architecture +- **User-Controlled Visibility:** Notification preferences **will be** user-controlled with explicit opt-in/opt-out +- **Data Sovereignty:** All notification data **will reside** on user's device; no external tracking or analytics +- **Minimal Data Collection:** We **will collect** only essential data for notification delivery (slot times, content templates) +- **DID Integration:** Notifications **will be** associated with user's Decentralized Identifier (DID) for privacy-preserving identity + +### Security Considerations +- **Content Encryption:** Sensitive notification content **will be** encrypted at rest using device keystore +- **Secure Transmission:** All API calls **will use** HTTPS with proper certificate validation +- **Input Validation:** All notification content **will be** validated and sanitized before storage +- **Access Control:** Notification settings **will be** protected by user authentication + +### Compliance with TimeSafari Principles +- **Privacy-Preserving:** Follows TimeSafari's privacy-preserving claims architecture +- **User Agency:** Users maintain full control over their notification experience +- **Transparency:** Clear communication about what data is collected and how it's used +- **Minimal Footprint:** Notification system **will have** minimal impact on user privacy + +--- + +## 23) Platform-Specific Implementation Details + +### Web Platform (`VITE_PLATFORM=web`) +- **Database:** Uses Absurd SQL (SQLite via IndexedDB) via `WebPlatformService` with worker pattern +- **Notifications:** Web push notifications via Service Worker (minimal implementation) +- **Local Scheduling:** **Not supported** - web cannot schedule local notifications offline +- **API Integration:** Direct HTTP calls for content fetching +- **Storage:** Notification preferences stored in Absurd SQL database +- **Testing:** Playwright E2E tests run on web platform + +### Capacitor Platform (`VITE_PLATFORM=capacitor`) +- **Database:** Uses native SQLite via `CapacitorPlatformService` +- **Notifications:** Local notifications via `@capacitor/local-notifications` +- **Local Scheduling:** **Fully supported** - OS-level notification scheduling +- **API Integration:** HTTP calls with mobile-optimized timeouts and retry logic +- **Storage:** Notification preferences stored in native SQLite database +- **Testing:** Playwright E2E tests run on mobile devices (Android/iOS) + +### Electron Platform (`VITE_PLATFORM=electron`) +- **Database:** Uses native SQLite via `ElectronPlatformService` (extends CapacitorPlatformService) +- **Notifications:** OS-level notifications via Electron's notification API +- **Local Scheduling:** **Supported** - desktop OS notification scheduling +- **API Integration:** Same as Capacitor platform +- **Storage:** Same as Capacitor platform (via inherited service) +- **Testing:** Same as Capacitor platform + +### Cross-Platform Considerations +- **Feature Detection:** Use `process.env.VITE_PLATFORM` for platform-specific behavior +- **Database Abstraction:** PlatformServiceMixin handles database differences transparently +- **API Consistency:** Same TypeScript API across all platforms +- **Fallback Behavior:** Web platform gracefully degrades to push-only notifications + +--- +## 24) TimeSafari Architecture Compliance + +### Design Pattern Adherence +- **Factory Pattern:** Notification service follows `PlatformServiceFactory` singleton pattern +- **Mixin Pattern:** Database access uses existing `PlatformServiceMixin` pattern +- **Migration Pattern:** Database changes follow existing `MIGRATIONS` array pattern +- **Error Handling:** Uses existing `handleApiError` from `src/services/api.ts` +- **Logging:** Uses existing logger from `src/utils/logger` with established patterns +- **Platform Detection:** Uses existing `Capacitor.isNativePlatform()` and `VITE_PLATFORM` patterns + +### File Organization Compliance +- **Services:** Follows existing `src/services/` organization with factory and adapters +- **Database:** Extends existing `src/db-sql/migration.ts` and `src/db/tables/settings.ts` +- **Utils:** Extends existing `src/utils/PlatformServiceMixin.ts` +- **Main Entry:** Integrates with existing `src/main.capacitor.ts` initialization +- **Service Workers:** Follows existing `sw_scripts/` organization + +### Type Safety Compliance +- **Settings Extension:** Follows existing Settings type extension pattern +- **Interface Definitions:** Uses existing TypeScript interface patterns +- **Error Types:** Follows existing error handling type patterns +- **Platform Types:** Uses existing platform detection type patterns + +--- + +## Sync Checklist + +| Sync item | Plan | Impl | Status | +| ------------------------------ | --------------------- | --------------------- | --------- | +| Public API block identical | §4 | §3 | ✅ | +| `getState()` fields present | §4 | §8 Orchestrator | ✅ | +| Capacitor action handlers | §7 (iOS/Android note) | §9 Bootstrap | ✅ | +| Electron fetch prereq/polyfill | §7 | §9 Electron | ✅ | +| Android ±10m fallback | §7 | §7 SchedulerCapacitor | ✅ | +| Retention (no VACUUM v1) | §5 | `$pruneNotifData` | ✅ | + +--- + +*This strategic plan focuses on features and future‑tense deliverables, avoids implementation details, and preserves a clear path from the in‑app implementation (v1) to the native plugin (v2). For detailed implementation specifics, see `notification-system-implementation.md`.*