1 changed files with 603 additions and 0 deletions
@ -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<string,string>; |
|||
bodyJson?: Record<string,unknown>; |
|||
timeoutMs?: number; |
|||
}; |
|||
|
|||
export type CallbackProfile = { |
|||
fetchContent: FetchSpec; |
|||
ackDelivery?: Omit<FetchSpec,'bodyJson'|'timeoutMs'>; |
|||
reportError?: Omit<FetchSpec,'bodyJson'|'timeoutMs'>; |
|||
heartbeat?: Omit<FetchSpec,'bodyJson'> & { 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<void>; |
|||
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<void>; |
|||
enqueueEvent(e: unknown): Promise<void>; |
|||
drainEvents(): Promise<unknown[]>; |
|||
} |
|||
|
|||
export interface Scheduler { |
|||
capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }>; |
|||
scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise<void>; |
|||
scheduleWindow( |
|||
slotId: SlotId, |
|||
windowStartMs: number, |
|||
windowLenMs: number, |
|||
payloadRef: string |
|||
): Promise<void>; |
|||
cancelBySlot(slotId: SlotId): Promise<void>; |
|||
rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise<void>; |
|||
} |
|||
``` |
|||
|
|||
### 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<void> { |
|||
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<void> { |
|||
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<void> { |
|||
// Simple in-memory queue for now, can be enhanced with SQLite |
|||
// This will be drained when app comes to foreground |
|||
} |
|||
|
|||
async drainEvents(): Promise<unknown[]> { |
|||
// 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<void> { |
|||
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<void> { |
|||
// 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<void> { |
|||
const id = this.generateNotificationId(slotId); |
|||
await LocalNotifications.cancel({ notifications: [{ id }] }); |
|||
} |
|||
|
|||
async rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise<void> { |
|||
// 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<void> { |
|||
this.opts = o; |
|||
|
|||
// Persist configuration to SQLite |
|||
await this.store.enqueueEvent({ |
|||
type: 'config_updated', |
|||
config: o, |
|||
timestamp: Date.now() |
|||
}); |
|||
} |
|||
|
|||
async runFullPipelineNow(): Promise<void> { |
|||
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<void> { |
|||
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<void> { |
|||
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<void> { |
|||
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<void> { |
|||
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<void> { |
|||
// 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 |
Loading…
Reference in new issue