From 5110c17fba988ef21d0ae1aeff7eef47e8579631 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Thu, 4 Sep 2025 13:09:00 +0000 Subject: [PATCH] chore: create new notification plan --- ...notification-system-implementation-plan.md | 603 ++++++++++++++++++ 1 file changed, 603 insertions(+) create mode 100644 doc/notification-system-implementation-plan.md diff --git a/doc/notification-system-implementation-plan.md b/doc/notification-system-implementation-plan.md new file mode 100644 index 00000000..c2cb51a6 --- /dev/null +++ b/doc/notification-system-implementation-plan.md @@ -0,0 +1,603 @@ +# 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