Browse Source

chore: create new notification plan

pull/196/head
Matthew Raymer 2 weeks ago
parent
commit
5110c17fba
  1. 603
      doc/notification-system-implementation-plan.md

603
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<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…
Cancel
Save