# 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 **Database Compatibility Layer:** The notification system leverages TimeSafari's existing database abstraction layer that provides SQLite compatibility across all platforms: - **Web Platform:** Uses `@absurd-sql` to provide SQLite-compatible API over IndexedDB - **Mobile/Desktop:** Uses native SQLite via Capacitor SQLite plugin - **Unified Interface:** Both backends implement the same SQLite API, ensuring consistent notification data storage This compatibility layer allows the notification system to use identical SQLite schemas and queries across all platforms, with the underlying storage mechanism abstracted away by the platform services. **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' }; ``` **Note:** The `notifEnabled` boolean field must be added to `PlatformServiceMixin._mapColumnsToValues` for proper SQLite integer-to-boolean conversion: ```typescript // In _mapColumnsToValues method, add: column === "notifEnabled" || ``` --- ## 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. ```typescript 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 type ScheduleRequest = { slotId: SlotId; whenMs: number; title: string; body: string; extra?: Record; // { payloadRef, etag, windowLenMs, ... } }; export interface SchedulerCapabilities { exactAlarms: boolean; maxPending?: number; networkWake?: 'none' | 'opportunistic'; // v1 mobile = 'none' or 'opportunistic' } export interface Scheduler { capabilities(): Promise; scheduleExact(req: ScheduleRequest): Promise; scheduleWindow(req: ScheduleRequest & { windowLenMs: number }): Promise; cancelBySlot(slotId: SlotId): Promise; rescheduleAll(next: ScheduleRequest[]): 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, SchedulerCapabilities } from '../types'; import { Notification, app } from 'electron'; import { logger, safeStringify } from '@/utils/logger'; export class SchedulerElectron implements Scheduler { async capabilities(): Promise { // Electron timers + OS delivery while app runs; no exact-alarm guarantees. return { exactAlarms: false, maxPending: 128, networkWake: 'opportunistic' }; } async scheduleExact(req: ScheduleRequest): Promise { const delay = Math.max(0, req.whenMs - Date.now()); setTimeout(() => { try { const n = new Notification({ title: req.title, body: req.body, // 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', req.slotId); // Log with safe stringify for complex objects logger.debug('[SchedulerElectron] Notification details:', safeStringify({ slotId: req.slotId, timestamp: new Date().toISOString(), platform: 'electron' })); } catch (e) { logger.error('[SchedulerElectron] show failed', e); throw e; } }, delay); } async scheduleWindow(req: ScheduleRequest & { windowLenMs: number }): Promise { // v1 emulates "window" by scheduling at window start; OS may delay delivery. return this.scheduleExact(req); } 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, SchedulerCapabilities } from '../types'; import { logger, safeStringify } from '@/utils/logger'; export class SchedulerCapacitor implements Scheduler { async capabilities(): Promise { // Conservative default; exact permission detection will be native in v2. return { exactAlarms: false, maxPending: 64, networkWake: 'none' }; } async scheduleExact(req: ScheduleRequest): Promise { try { await LocalNotifications.schedule({ notifications: [{ id: this.generateNotificationId(req.slotId, req.whenMs), title: req.title, body: req.body, schedule: { at: new Date(req.whenMs) }, extra: { slotId: req.slotId, ...req.extra } }] }); logger.debug('[SchedulerCapacitor] Scheduled notification for slot', req.slotId, 'at', new Date(req.whenMs).toISOString()); // Log with safe stringify for complex objects logger.debug('[SchedulerCapacitor] Notification details:', safeStringify({ slotId: req.slotId, whenMs: req.whenMs, scheduledAt: new Date(req.whenMs).toISOString(), platform: 'capacitor' })); } catch (error) { logger.error('[SchedulerCapacitor] Failed to schedule notification for slot', req.slotId, error); throw error; } } async scheduleWindow(req: ScheduleRequest & { windowLenMs: number }): 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(req.slotId, req.whenMs), title: req.title, body: req.body, schedule: { at: new Date(req.whenMs), repeats: false }, extra: { slotId: req.slotId, windowLenMs: req.windowLenMs, ...req.extra } // Carry window length for telemetry }] }); logger.debug('[SchedulerCapacitor] Scheduled windowed notification for slot', req.slotId, 'at window start'); } catch (error) { logger.error('[SchedulerCapacitor] Failed to schedule windowed notification for slot', req.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 ?? 12000); 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 const ac = new AbortController(); const to = setTimeout(() => ac.abort(), 8000); // 8s timeout for ACK try { await fetch(spec.url, { method, headers: spec.headers, body, signal: ac.signal }); logger.debug('[CallbacksHttp] Acknowledged delivery for slot', slotId); } finally { clearTimeout(to); } } 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 const ac = new AbortController(); const to = setTimeout(() => ac.abort(), 8000); // 8s timeout for error reporting try { await fetch(spec.url, { method, headers: spec.headers, body, signal: ac.signal }); logger.debug('[CallbacksHttp] Reported error for slot', slotId); } finally { clearTimeout(to); } } catch (reportError) { logger.error('[CallbacksHttp] Failed to report error for slot', slotId, reportError); // Don't throw - this is not critical } } } ``` ### Future v2 Adapters (Not Implemented in v1) ```typescript // src/services/notifications/adapters/BackgroundPrefetch.ts // v2: Native background network scheduling export interface BackgroundPrefetch { schedulePrefetch(slotId: SlotId, atMs: number): Promise; // T–lead cancelPrefetch(slotId: SlotId): Promise; } // v2 Implementation Notes: // - Android: Use WorkManager/AlarmManager to wake for network work at T–lead // - iOS: Use BGTaskScheduler or silent push to wake for network work at T–lead // - After network fetch, (re)arm local notifications with fresh content // - This enables true "scheduled network events" when app is terminated // - Not implemented in v1; v2 will bind to native OS primitives ``` ### BackgroundRunnerPrefetch Adapter (v1 Cache Warmer) ```typescript // src/services/notifications/adapters/BackgroundRunnerPrefetch.ts // v1 cache-warmer using Capacitor Background Runner (opportunistic) import { logger } from '@/utils/logger'; import { CallbacksHttp } from './CallbacksHttp'; import type { DataStore, SlotId } from '../types'; export class BackgroundRunnerPrefetch { constructor( private store: DataStore, private fetcher: CallbacksHttp, private getUpcomingSlots: () => SlotId[], private getNextSlotTime: (slotId: SlotId) => number, private leadMinutes: number, private ttlSeconds: number, private allowRearm: boolean // feature flag: runnerRearm ) {} // Entrypoint invoked by Background Runner async handleTick(): Promise { const now = Date.now(); const leadMs = this.leadMinutes * 60_000; for (const slot of this.getUpcomingSlots()) { const fireAt = this.getNextSlotTime(slot); if (fireAt - now > leadMs) continue; // only act inside lead const existing = await this.store.getLatestContent(slot); const etag = existing?.etag; try { const res = await this.fetcher.fetchContent(slot, etag); if (!res) continue; // 304 // Save fresh payload await this.store.saveContent(slot, res.payload, res.etag); // Optional: cancel & re-arm if within TTL and allowed if (this.allowRearm) { const ageAtFire = fireAt - Date.now(); // newly fetched now if (ageAtFire <= this.ttlSeconds * 1000) { // Signal the orchestrator to re-arm on next foreground OR // (if environment allows) directly call orchestrator.scheduleSlot(slot, res.payload) // Keep this behind the flag to minimize risk on iOS. } } } catch (e) { logger.error('[BackgroundRunnerPrefetch] tick failed for', slot, e); } } } } ``` > **Note:** In v1, Runner **only guarantees opportunistic JS**. Keep re-arming behind `runnerRearm` (default **false**) to avoid iOS background surprises. The safe baseline is **prefetch-only**; scheduling parity stays with the foreground orchestrator. --- ## 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(); // App-level pipeline debounce (30s per app session) private lastPipelineRunAt = 0; private readonly PIPELINE_DEBOUNCE_MS = 30000; 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 { // App-level debounce to prevent burst fetches on resume const now = Date.now(); if (now - this.lastPipelineRunAt < this.PIPELINE_DEBOUNCE_MS) { logger.debug('[NotificationOrchestrator] Pipeline debounced - too soon since last run'); return; } this.lastPipelineRunAt = now; // 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 pre-warm fetch but schedule with best available payload 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); // Schedule with fresh content even outside lead window await this.scheduleSlot(slotId, content.payload); logger.debug('[NotificationOrchestrator] Scheduled', slotId, 'with fresh content outside lead window'); 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); // Check TTL at fire time - don't schedule if content will be stale const storedContent = await this.store.getLatestContent(slotId); if (storedContent) { const ttlMs = (this.opts.ttlSeconds ?? 86400) * 1000; const projectedAgeAtFire = whenMs - storedContent.fetchedAt; if (projectedAgeAtFire > ttlMs) { await this.store.recordDelivery(slotId, whenMs, 'error', { code: 'FETCH_TOO_OLD', message: `Content will be ${Math.round(projectedAgeAtFire / 1000)}s old at fire time (TTL: ${this.opts.ttlSeconds ?? 86400}s)` }); return; } } // 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; } // Route through Scheduler adapter to honor platform timing semantics const capabilities = await this.sched.capabilities(); if (capabilities.exactAlarms) { await this.sched.scheduleExact({ slotId, whenMs, title: rendered.title, body: rendered.body, extra: { payloadRef: `${slotId}:${whenMs}`, etag: storedContent?.etag } }); } else { // Use windowed scheduling for Android when exact alarms unavailable const androidWindowLenMs = 600000; // ±10 min await this.sched.scheduleWindow({ slotId, whenMs, title: rendered.title, body: rendered.body, windowLenMs: androidWindowLenMs, extra: { payloadRef: `${slotId}:${whenMs}`, etag: storedContent?.etag } }); } 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) Runtime Timing & Network Guards ### Global Timing Constants (All Platforms) | Constant | Default | Purpose | | ---------------------- | ------------------------ | ------------------------------------------------------------------ | | `prefetchLeadMinutes` | **20 min** | Earliest window we will attempt online-first before each slot | | `fetchTimeoutMs` | **12,000 ms** | Hard timeout for content fetch (AbortController) | | `ttlSeconds` | **86,400 s (24 h)** | Staleness cutoff; if offline and payload older than TTL → **skip** | | `cooldownDeliverNowMs` | **60,000 ms** | Per-slot guard for `deliverStoredNow()` to prevent double-fires | | `androidWindowLenMs` | **600,000 ms (±10 min)** | Window length when exact alarms are unavailable (Android) | ### Network Access Rules (By Platform) **iOS (Capacitor app, v1)** - **When code may run:** Only while the app is **foregrounded** or **recently backgrounded** (no JS wake when app is killed). - **Network in v1:** We will fetch **only** during app activity (launch/resume/ settings interaction). No delivery-time network for locals. - **Timeout:** `fetchTimeoutMs = 12s`, single attempt inside lead. - **Scheduling:** Use non-repeating one-shots; maintain a rolling window under the pending cap (~64). - **v2 note:** Background **silent push** or BGTaskScheduler-based prefetch will be addressed in the plugin (not in v1). **Android (Capacitor app, v1)** - **When code may run:** Same as iOS; no JS wake when app is killed. - **Network in v1:** Fetch during app activity only. - **Exact alarms:** If `SCHEDULE_EXACT_ALARM` is unavailable, we will schedule at **window start** with `androidWindowLenMs = 10m`; OS may delay within the window. Deep-link users to grant exact-alarm access. - **Timeout:** `fetchTimeoutMs = 12s`, single attempt inside lead. **Web (PWA)** - **When code may run:** **Service Worker** on **push** events (browser may be closed) or a controlled page/tab. - **Network in SW:** Allowed during the **push event**; keep total work short and call `event.waitUntil(...)`. - **Timeout:** `fetchTimeoutMs = 12s` in SW; one attempt. - **Scheduling:** **No offline scheduling** on web; rely on push payload content; local scheduling APIs are not reliable/standardized. **Electron (desktop app)** - **When code may run:** Only while the app is **running** (tray or window). - **Network:** Allowed in **main** or **renderer** depending on where the orchestrator runs. If in main, require Node 18+ (global `fetch`) or polyfill (`undici/register`). - **Timeout:** `fetchTimeoutMs = 12s`, single attempt inside lead. - **Scheduling:** Timer-based while running; **Start on Login** recommended. No true background scheduling in v1. ### Data Transfer and Request Profile (All Platforms) - **Headers:** Always send `If-None-Match` with last known ETag for the slot; accept `304` as "no change". - **Payload size:** We will keep JSON responses ≤ **16 KB** (hard ceiling) and titles/bodies within platform limits (Title ≤ 50 chars, Body ≤ 200 chars pre-escape/truncation). - **Methods:** `fetchContent` may use `GET` or `POST`; `ackDelivery`/ `reportError`/`heartbeat` will use **POST**. - **Retries:** **None** inside the lead window; outside the lead, next opportunity is the next app resume/open (v1). - **Offline detection:** We will attempt fetch even if the OS says "offline" (to avoid false negatives), but the 12s timeout will bound the attempt and we will fall back per TTL policy. ### Enforcement Implementation Notes 1. **Timeout wrapper (no `AbortSignal.timeout`)** Use `AbortController` + `setTimeout` (12s) around **all** outbound fetches (already present for content; apply to ACK/error if needed). 2. **ETag propagation** - Read latest `etag` via `DataStore.getLatestContent(slotId)`; set `If-None-Match` on `fetchContent`. - On `304`, **do not** reschedule content; leave the last payload intact and only ensure the slot is armed. 3. **Lead window single attempt** - Maintain `lastAttemptAt: Map`; if inside `[slotTime - lead, slotTime]` and an attempt exists, **skip** repeat attempts. - Add a **one-liner guard** at the start of any manual "fetch now/test" entry point to respect the same policy. 4. **Cooldown for `deliverStoredNow`** - Maintain `lastDeliveredNowAt: Map`; early-return if `< 60s` since last call for the slot. 5. **Permission & bulk reschedule guard** - Before `rescheduleAll`, check `LocalNotifications.areEnabled()`. If disabled, record `SCHEDULE_DENIED` and **do not** queue notifications. 6. **Android window behavior** - In `scheduleWindow(...)`, store `{ windowLenMs: 600000 }` in `extra` for telemetry; schedule at window **start** and rely on OS batching. 7. **Electron fetch prerequisite** - If orchestrator runs in main, ensure Node 18+ or add at app start: ```ts // main only, if Node < 18 import 'undici/register'; ``` - Set Windows AppUserModelID once: ```ts import { app } from 'electron'; if (process.platform === 'win32') app.setAppUserModelId('com.timesafari.app'); ``` 8. **Truncation order** - Truncate template **before** escaping; then pass `title/body` to schedulers (Capacitor/Electron). --- ## 10) 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); // Background Runner Integration (optional, v1 cache warmer) setTimeout(async () => { try { const conf = await store.getConfig?.('prefetchRunner'); if (conf === 'background-runner') { const { BackgroundRunner } = await import('@capacitor/background-runner'); // Register a periodic handler (OS will coalesce; ≥ ~15m typical) await BackgroundRunner.register({ // name/id of the task, platform-specific options if required }); // Provide a global/static tick handler called by the runner BackgroundRunner.addListener('tick', async () => { await bgPrefetch.handleTick(); }); } } catch (e) { logger.warn('[BackgroundRunner] unavailable, continuing without runner', e); } }, 3000); ``` ### 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 } // Start on Login (recommended for v1) 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(); } ``` --- ## 11) 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. // Mobile locals bypass the SW. // 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); }); } ``` --- ## 12) 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 }; } ``` --- ## 13) 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; }); }); ``` --- ## 14) 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 } }; } } ``` --- ## 15) Feature Flags & Config ### Feature Flags Implementation ```typescript // src/services/notifications/FeatureFlags.ts export interface NotificationFeatureFlags { scheduler: 'capacitor' | 'electron'; mode: 'auto' | 'online-first' | 'offline-first'; prefetchLeadMinutes: number; ttlSeconds: number; iosCategoryIdentifier: string; androidChannelId: string; prefetchRunner: 'none' | 'background-runner'; runnerRearm: boolean; } export class NotificationFeatureFlags { private static defaults: NotificationFeatureFlags = { scheduler: 'capacitor', mode: 'auto', prefetchLeadMinutes: 20, ttlSeconds: 86400, iosCategoryIdentifier: 'TS_DAILY', androidChannelId: 'timesafari.daily', prefetchRunner: 'none', runnerRearm: false }; static async getFlags(store: DataStore): Promise { const flags = { ...this.defaults }; for (const [key, defaultValue] of Object.entries(this.defaults)) { const value = await store.getConfig?.(key); if (value !== null && value !== undefined) { (flags as any)[key] = value; } } return flags; } static async setFlag(store: DataStore, key: keyof NotificationFeatureFlags, value: any): Promise { await store.setConfig?.(key, value); } } ``` ### Configuration Storage Feature flags are stored in the `notif_config` table as key-value pairs, separate from user settings. This allows for runtime configuration changes without affecting user preferences. --- ## 16) 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 ``` --- ## 17) 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) --- ## 19) 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 --- ## 20) 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 §13 table and Implementation §15 defaults - **Test cases**: Plan §14 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 §7 policy and Implementation §9 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` | ✅ | | Runner described as **opportunistic prefetch**, not scheduler | §7 | §9 | ✅ | | Feature flag `prefetchRunner` (default `'none'`) | §13 | §15 | ✅ | | Capabilities `networkWake: 'opportunistic' | 'none'` | §7 | Scheduler.capabilities | ✅ | | Runner tick handler bounded to ≤12s | §7 | BackgroundRunnerPrefetch | ✅ | | Optional `runnerRearm` flag & behavior | §7 | Orchestrator + Runner | ✅ | --- *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 executive overview, see `notification-system-executive-summary.md`. For strategic overview, see `notification-system-plan.md`.*