Files
crowd-funder-for-time-pwa/doc/notification-system-implementation-plan.md
2025-09-04 13:09:40 +00:00

645 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TimeSafari Notification System — Feature-First Execution Pack
**Author**: Matthew Raymer
**Date**: 2025-01-27T15:00Z (UTC)
**Status**: 🚀 **ACTIVE** - Surgical PR implementation for Capacitor platforms
## Purpose
We **will deliver** 1..M daily local notifications with content fetched beforehand so they **will display offline**. We **will support** online-first (API→DB→Schedule) with offline-first fallback. New v1 ships in-app; New v2 extracts to a native Capacitor plugin with the same API.
## What we'll ship (v1 in-app)
- **Multi-daily one-shot local notifications** (rolling window; today + tomorrow within iOS pending limits ~64)
- **Online-first** content fetch (**ETag**, 1015s timeout) with **offline-first** fallback and **TTL** handling ("(cached)" or skip)
- **SQLite persistence** (contents, deliveries, config) with **14-day retention**
- **Templating**: `{title, body}` with `{{var}}` substitution **before** scheduling
- **Event queue** in SQLite: `delivery`, `error`, `heartbeat`; **drain on foreground**
- **Resilience hooks**: re-arm on **app resume**, and when **timezone/offset** changes
---
## New v2 (Plugin) — What It Adds
- **Native schedulers** (WorkManager+AlarmManager / BGTask+UNUserNotificationCenter) incl. exact/inexact handling
- **Native HTTP** + **native SQLite** for reliability/perf
- **Server-assist (silent push)** to wake at T-lead and prefetch
- **Same TS API**, adapters swapped
---
## Feature Flags
- `scheduler: 'capacitor'|'native'`
- `mode: 'online-first'|'offline-first'|'auto'`
- `prefetchLeadMinutes`, `ttlSeconds`
---
## 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)
/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)
- Local notifications **fire at configured slots with the app killed** (iOS/Android)
- **Online-first** → 304/ETag respected; **offline-first** fallback; **TTL** policy respected
- **14-day retention** job prunes contents & deliveries
- **TZ/DST** change triggers **reschedule()**; rolling window adheres to iOS pending cap
- **Feature flags** present: `scheduler: 'capacitor'|'native'`, `mode: 'online-first'|'offline-first'|'auto'`
---
## 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
- **Android UX**: Settings deep-link to grant exact-alarm special access on API 31+
- **DST/TZ Guardrail**: On app resume, if offset/TZ changed → `reschedule()` and re-arm today's slots