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