# 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`.*