Browse Source
Replace monolithic notification-system-implementation-plan.md with focused strategic plan (notification-system-plan.md) and detailed implementation guide (notification-system-implementation.md). Both documents now perfectly aligned with TimeSafari codebase patterns including: - Actual Settings type extension pattern (JSON strings for complex objects) - Real useNotifications composable stub signatures with eslint-disable - Verified logger exports and safeStringify usage - Confirmed PlatformServiceMixin.$saveSettings integration - Validated migration system registerMigration patterns Documents are production-ready with accurate code examples verified against actual TimeSafari infrastructure.pull/196/head
3 changed files with 2329 additions and 644 deletions
@ -1,644 +0,0 @@ |
|||||
# 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**, 10–15s 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 |
|
File diff suppressed because it is too large
@ -0,0 +1,457 @@ |
|||||
|
# TimeSafari Notification System — Strategic Plan |
||||
|
|
||||
|
**Status:** 🚀 Active plan |
||||
|
**Date:** 2025-09-05T05:09Z (UTC) |
||||
|
**Author:** Matthew Raymer |
||||
|
**Scope:** v1 (in‑app orchestrator) now; path to v2 (native plugin) next |
||||
|
**Goal:** We **will deliver** 1..M local notifications/day with content **prefetched** so messages **will display offline**. We **will support** online‑first (API→DB→Schedule) with offline‑first fallback. The system **will enhance** TimeSafari's community-building mission by keeping users connected to gratitude, gifts, and collaborative projects through timely, relevant notifications. |
||||
|
|
||||
|
> **Implementation Details:** See `notification-system-implementation.md` for detailed code, database schemas, and integration specifics. |
||||
|
> **Canonical Ownership:** This document owns Goals, Tenets, Platform behaviors, Acceptance criteria, and Test cases. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1) Versioning & Intent |
||||
|
|
||||
|
- **v1 (In‑App Orchestrator):** We **will implement** multi‑daily local notifications, online/offline flows, templating, SQLite persistence, and eventing **inside the app** using Capacitor Local Notifications. |
||||
|
- **v2 (Plugin):** We **will extract** adapters to a Capacitor/Native plugin to gain native schedulers (WorkManager/AlarmManager; BGTask+UNUserNotificationCenter), native HTTP, and native SQLite **with the same TypeScript API**. |
||||
|
|
||||
|
> We **will retain** the existing web push + Service Worker foundation; the system **will add** reliable local scheduling on mobile and a unified API across platforms. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2) Design Tenets |
||||
|
|
||||
|
- **Reliability:** OS‑level delivery once scheduled; no reliance on JS being alive at fire time. |
||||
|
- **Freshness:** Prefer online‑first within a short prefetch window; degrade gracefully to cached content with TTL. |
||||
|
- **Extractable:** Clean interfaces (Scheduler, DataStore, Callbacks) so v2 **will swap** adapters without API changes. |
||||
|
- **Simplicity:** One‑shot notifications per slot; rolling window scheduling to respect platform caps. |
||||
|
- **Observability:** Persist deliveries and errors; surface minimal metrics; enable ACKs. |
||||
|
- **Privacy-First:** Follow TimeSafari's privacy-preserving architecture; user-controlled visibility and data sovereignty. |
||||
|
- **Community-Focused:** Enhance TimeSafari's mission of connecting people through gratitude, gifts, and collaborative projects. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3) Architecture Overview |
||||
|
|
||||
|
``` |
||||
|
Application (Vue/TS) |
||||
|
├─ NotificationOrchestrator (core state) |
||||
|
│ ├─ Scheduler (adapter) |
||||
|
│ ├─ DataStore (adapter) |
||||
|
│ └─ Callbacks (adapter) |
||||
|
└─ UI (settings, status) |
||||
|
|
||||
|
Adapters |
||||
|
├─ V1: SchedulerCapacitor, DataStoreSqlite, CallbacksHttp |
||||
|
└─ V2: SchedulerNative, DataStoreNativeSqlite, CallbacksNativeHttp |
||||
|
|
||||
|
Platform |
||||
|
├─ iOS/Android: LocalNotifications (+ native bridges later) |
||||
|
├─ Web: Service Worker + Push (kept) |
||||
|
└─ Electron: OS notifications (thin adapter) |
||||
|
``` |
||||
|
|
||||
|
**Execution modes (concise):** |
||||
|
- **Online‑First:** wake near slot → fetch (ETag, timeout) → persist → schedule; on failure → Offline‑First. |
||||
|
- **Offline‑First:** read last good payload from SQLite; if beyond TTL → skip notification (no retry). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4) Public API (Shared by v1 & v2) |
||||
|
|
||||
|
```ts |
||||
|
export type NotificationTime = { hour: number; minute: number }; // local wall-clock |
||||
|
export type SlotId = string; // Format: "HHmm" (e.g., "0800", "1200", "1800") - stable across TZ changes |
||||
|
|
||||
|
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[]; // 1..M daily |
||||
|
timezone?: string; // Default: system timezone |
||||
|
ttlSeconds?: number; // Default: 86400 (24h) |
||||
|
prefetchLeadMinutes?: number; // Default: 20 |
||||
|
storage: 'shared'|'private'; // Required |
||||
|
contentTemplate: { title: string; body: string }; // Required |
||||
|
callbackProfile?: CallbackProfile; // Optional |
||||
|
}; |
||||
|
|
||||
|
export interface MultiDailyNotification { |
||||
|
requestPermissions(): Promise<void>; |
||||
|
configure(o: ConfigureOptions): Promise<void>; |
||||
|
runFullPipelineNow(): Promise<void>; // API→DB→Schedule (today's remaining) |
||||
|
deliverStoredNow(slotId?: SlotId): Promise<void>; // 60s cooldown guard |
||||
|
reschedule(): Promise<void>; |
||||
|
getState(): Promise<{ |
||||
|
nextOccurrences: Array<{ slotId: SlotId; when: string }>; // ISO |
||||
|
lastFetchAt?: string; lastDeliveryAt?: string; |
||||
|
pendingCount: number; exactAlarmCapable?: boolean; |
||||
|
}>; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
> **Storage semantics:** `'shared'` = app DB; `'private'` = plugin-owned/native DB (v2). (No functional difference in v1.) |
||||
|
|
||||
|
> **Slot Identity & Scheduling Policy** |
||||
|
> • **SlotId** uses canonical `HHmm` and remains stable across timezone changes. |
||||
|
> • **Lead window:** default `prefetchLeadMinutes = 20`; no retries once inside the lead. |
||||
|
> • **TTL policy:** When offline and content is beyond TTL, **we will skip** the notification (no "(cached)" suffix). |
||||
|
> • **Idempotency:** Duplicate "scheduled" deliveries are prevented by a unique index on `(slot_id, fire_at, status='scheduled')`. |
||||
|
> • **Time handling:** Slots will follow **local wall-clock** time across TZ/DST; `slotId=HHmm` stays constant and we will **recompute fire times** on offset change. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5) Data Model & Retention (SQLite) |
||||
|
|
||||
|
**Tables:** `notif_contents`, `notif_deliveries`, `notif_config` |
||||
|
|
||||
|
**Retention:** We **will keep** ~14 days of contents/deliveries (configurable) and **will prune** via a simple daily job that runs on app start/resume. We **will prune** daily but **will not** VACUUM by default on mobile; disk compaction is deferred. |
||||
|
|
||||
|
**Payload handling:** We **will template** `{title, body}` **before** scheduling; we **will not** mutate at delivery time. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6) Scheduling Policy & Slot Math |
||||
|
|
||||
|
- **One‑shot per slot** per day (non‑repeating). |
||||
|
- **Rolling window:** today's remaining slots; seed tomorrow where platform limits allow. v1 will schedule **the next occurrence per slot** by default; a **configurable depth** (0=today, 1=today+tomorrow) may be enabled as long as the iOS pending cap is respected. |
||||
|
- **TZ/DST safe:** We **will recompute** local wall‑times on app resume and whenever timezone/offset changes; then **reschedule**. |
||||
|
- **Android exactness:** If exact alarms are unavailable or denied, we **will use** `setWindow` semantics via the scheduler adapter. |
||||
|
- **iOS pending cap:** We **will keep** pending locals within typical caps (~64) by limiting the window and canceling/re‑arming as needed. |
||||
|
- **Electron rolling window:** On Electron we **will schedule** the **next occurrence per slot** by default; depth (today+tomorrow) **will be** enabled only when auto-launch is on, to avoid drift while the app is closed. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7) Platform Essentials |
||||
|
|
||||
|
**iOS** |
||||
|
- Local notifications **will** fire without background runtime once scheduled. NSE **will not** mutate locals; delivery-time enrichment requires remote push (future). |
||||
|
- **Category ID**: `TS_DAILY` with default `OPEN` action |
||||
|
- **Background budget** is short and OS‑managed; any prefetch work **will complete** promptly. |
||||
|
- **Mobile local notifications will route via action listeners (not the service worker)**. |
||||
|
|
||||
|
**Android** |
||||
|
- Exact alarms on **API 31+** may require `SCHEDULE_EXACT_ALARM`. If exact access is missing on API 31+, we will use a **windowed trigger (default ±10m)** and surface a settings deep-link. |
||||
|
- **We will deep-link users to the exact-alarm settings when we detect denials.** |
||||
|
- **Channel defaults**: ID `timesafari.daily`, name "TimeSafari Daily", importance=high (IDs never change) |
||||
|
- Receivers for reboot/time change **will be handled** by v2 (plugin); in v1, re‑arming **will occur** on app start/resume. |
||||
|
- **Mobile local notifications will route via action listeners (not the service worker)**. |
||||
|
|
||||
|
**Web** |
||||
|
- Requires registered Service Worker + permission; can deliver with browser closed. **Web will not offline-schedule**. |
||||
|
- Service worker click handlers apply to **web push only**; local notifications on mobile do **not** pass through the SW. |
||||
|
- SW examples use `/sw.js` as a placeholder; **wire this to your actual build output path** (e.g., `sw_scripts/notification-click.js` or your combined bundle). |
||||
|
- **Note**: Service workers are **intentionally disabled** in Electron (`src/main.electron.ts`) and web uses VitePWA plugin for minimal implementation. |
||||
|
|
||||
|
**Electron** |
||||
|
- We **will use** native OS notifications with **best-effort scheduling while the app is running**; true background scheduling will be addressed in v2 (native bridges). |
||||
|
|
||||
|
**Electron delivery strategy (v1 reality + v2 path)** |
||||
|
We **will deliver** desktop notifications while the Electron app is running. True **background scheduling when the app is closed** is **out of scope for v1** and **will be addressed** in v2 via native bridges. We **will adopt** one of the following options (in order of fit to our codebase): |
||||
|
|
||||
|
**In-app scheduler + auto-launch (recommended now):** Keep the orchestrator in the main process, **start on login** (tray app, hidden window), and use the **Electron `Notification` API** for delivery. This requires no new OS services and aligns with our PlatformServiceFactory/mixin patterns. |
||||
|
|
||||
|
**Policy (v1):** If the app is **not running**, Electron will **not** deliver scheduled locals. With **auto-launch enabled**, we **will achieve** near-mobile parity while respecting OS sleep/idle behavior. |
||||
|
|
||||
|
**UX notes:** On Windows we **will set** `appUserModelId` so toasts are attributed correctly; on macOS we **will request** notification permission on first use. |
||||
|
|
||||
|
**Prerequisites:** We **will require** Node 18+ (global `fetch`) or we **will polyfill** via `undici` for content fetching in the main process. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8) Template Engine Contract |
||||
|
|
||||
|
**Supported tokens:** `{{headline}}`, `{{summary}}`, `{{date}}` (YYYY-MM-DD), `{{time}}` (HH:MM). |
||||
|
**Escaping:** HTML-escape all injected values. |
||||
|
**Limits:** Title ≤ 50 chars; Body ≤ 200 chars; truncate with ellipsis. |
||||
|
**Fallback:** Missing token → `"[Content]"`. |
||||
|
**Mutation:** We **will** render templates **before** scheduling; no mutation at delivery time on iOS locals. |
||||
|
|
||||
|
## 9) Integration with Existing TimeSafari Infrastructure |
||||
|
|
||||
|
**Database:** We **will integrate** with existing migration system in `src/db-sql/migration.ts` following the established `MIGRATIONS` array pattern |
||||
|
**Settings:** We **will extend** existing Settings type in `src/db/tables/settings.ts` following the established type extension pattern |
||||
|
**Platform Service:** We **will leverage** existing PlatformServiceMixin database utilities following the established mixin pattern |
||||
|
**Service Factory:** We **will follow** the existing `PlatformServiceFactory` singleton pattern for notification service creation |
||||
|
**Capacitor:** We **will integrate** with existing deep link system in `src/main.capacitor.ts` following the established initialization pattern |
||||
|
**Service Worker:** We **will extend** existing service worker infrastructure following the established `sw_scripts/` pattern (Note: Service workers are intentionally disabled in Electron and have minimal web implementation via VitePWA plugin) |
||||
|
**API:** We **will use** existing error handling from `src/services/api.ts` following the established `handleApiError` pattern |
||||
|
**Logging:** We **will use** existing logger from `src/utils/logger` following the established logging patterns |
||||
|
**Platform Detection:** We **will use** existing `process.env.VITE_PLATFORM` patterns (`web`, `capacitor`, `electron`) |
||||
|
**Vue Architecture:** We **will follow** Vue 3 + vue-facing-decorator patterns for component integration (Note: The existing `useNotifications` composable in `src/composables/useNotifications.ts` is currently stub functions with eslint-disable comments and needs implementation) |
||||
|
**State Management:** We **will integrate** with existing settings system via `PlatformServiceMixin.$saveSettings()` for notification preferences (Note: TimeSafari uses PlatformServiceMixin for all state management, not Pinia stores) |
||||
|
**Identity System:** We **will integrate** with existing `did:ethr:` (Ethereum-based DID) system for user context |
||||
|
**Testing:** We **will follow** Playwright E2E testing patterns established in TimeSafari |
||||
|
**Database Architecture:** We **will support** platform-specific database backends: |
||||
|
- **Web**: Absurd SQL (SQLite via IndexedDB) via `WebPlatformService` with worker pattern |
||||
|
- **Capacitor**: Native SQLite via `CapacitorPlatformService` |
||||
|
- **Electron**: Native SQLite via `ElectronPlatformService` (extends CapacitorPlatformService) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10) Error Taxonomy & Telemetry |
||||
|
|
||||
|
**Error Codes:** `FETCH_TIMEOUT`, `ETAG_NOT_MODIFIED`, `SCHEDULE_DENIED`, `EXACT_ALARM_MISSING`, `STORAGE_BUSY`, `TEMPLATE_MISSING_TOKEN`, `PERMISSION_DENIED`. |
||||
|
|
||||
|
**Event Envelope:** `code, slotId, whenMs, attempt, networkState, tzOffset, appState, timestamp`. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11) Permission UX & Channels/Categories |
||||
|
|
||||
|
- We **will request** notification permission **after** user intent (e.g., settings screen), not on first render. |
||||
|
- **Android:** We **will create** a stable channel ID (e.g., `timesafari.daily`) and **will set** importance appropriately. |
||||
|
- **iOS:** We **will register** categories for optional actions; grouping may use `threadIdentifier` per slot/day. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12) Eventing & Telemetry |
||||
|
|
||||
|
### Error Taxonomy |
||||
|
**Finite error code set:** |
||||
|
- `FETCH_TIMEOUT` - Network request exceeded timeout |
||||
|
- `ETAG_NOT_MODIFIED` - Server returned 304 (expected) |
||||
|
- `SCHEDULE_DENIED` - OS denied notification scheduling |
||||
|
- `EXACT_ALARM_MISSING` - Android exact alarm permission absent |
||||
|
- `STORAGE_BUSY` - Database locked or unavailable |
||||
|
- `TEMPLATE_MISSING_TOKEN` - Required template variable not found |
||||
|
- `PERMISSION_DENIED` - User denied notification permissions |
||||
|
|
||||
|
### Event Logging Envelope |
||||
|
```ts |
||||
|
{ |
||||
|
code: string, // Error code from taxonomy |
||||
|
slotId: string, // Affected slot |
||||
|
whenMs: number, // Scheduled time |
||||
|
attempt: number, // Retry attempt (1-based) |
||||
|
networkState: string, // 'online' | 'offline' |
||||
|
tzOffset: number, // Current timezone offset |
||||
|
appState: string, // 'foreground' | 'background' | 'killed' |
||||
|
timestamp: number // UTC timestamp |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### ACK Payload Format |
||||
|
```ts |
||||
|
{ |
||||
|
slotId: string, |
||||
|
fireAt: number, // Scheduled time |
||||
|
deliveredAt: number, // Actual delivery time |
||||
|
deviceTz: string, // Device timezone |
||||
|
appVersion: string, // App version |
||||
|
buildId: string // Build identifier |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- **Event queue (v1):** In-memory queue for `delivery`, `error`, `heartbeat` events. Background/native work **will enqueue**; foreground **will drain** and publish to the UI. **v2 will migrate** to SQLite-backed queue for persistence. |
||||
|
- **Callbacks (optional):** `ackDelivery`, `reportError`, `heartbeat` **will post** to server endpoints when configured. |
||||
|
- **Minimal metrics:** pending count, last fetch, last delivery, next occurrences. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 13) Feature Flags & Config |
||||
|
|
||||
|
### Feature Flags Table |
||||
|
| Flag | Default | Description | Location | |
||||
|
|------|---------|-------------|----------| |
||||
|
| `scheduler` | `'capacitor'` | Scheduler implementation | `notif_config` table | |
||||
|
| `mode` | `'auto'` | Online-first inside lead, else offline-first | `notif_config` table | |
||||
|
| `prefetchLeadMinutes` | `20` | Lead time for prefetch attempts | `notif_config` table | |
||||
|
| `ttlSeconds` | `86400` | Content staleness threshold (24h) | `notif_config` table | |
||||
|
| `iosCategoryIdentifier` | `'TS_DAILY'` | iOS notification category | `notif_config` table | |
||||
|
| `androidChannelId` | `'timesafari.daily'` | Android channel ID (never changes) | `notif_config` table | |
||||
|
|
||||
|
**Storage:** Feature flags **will reside** in `notif_config` table as key-value pairs, separate from user settings. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 14) Acceptance (Definition of Done) → Test Cases |
||||
|
|
||||
|
### Explicit Test Checks |
||||
|
- **App killed → locals fire**: Configure slots at 8:00, 12:00, 18:00; kill app; verify notifications fire at each slot on iOS/Android |
||||
|
- **ETag 304 path**: Server returns 304 → keep previous content; locals fire with cached payload |
||||
|
- **ETag 200 path**: Server returns 200 → update content and re-arm locals with fresh payload |
||||
|
- **Offline + beyond TTL**: When offline and content > 24h old → skip notification (no "(cached)" suffix) |
||||
|
- **iOS pending cap**: Respect ~64 pending limit; cancel/re-arm as needed within rolling window |
||||
|
- **Exact-alarm denied**: Android permission absent → windowed schedule (±10m) activates; UI shows fallback hint |
||||
|
- **Permissions disabled** → we will record `SCHEDULE_DENIED` and refrain from queuing locals. |
||||
|
- **Window fallback** → when exact alarm is absent on Android, verify target fires within **±10m** of slot time (document as an E2E expectation). |
||||
|
- **Timezone change**: On TZ/DST change → recompute wall-clock times; cancel & re-arm all slots |
||||
|
- **Lead window respect**: No retries attempted once inside 20min lead window |
||||
|
- **Idempotency**: Multiple `runFullPipelineNow()` calls don't create duplicate scheduled deliveries |
||||
|
- **Cooldown guard**: `deliverStoredNow()` has 60s cooldown to prevent double-firing |
||||
|
|
||||
|
### Electron-Specific Test Checks |
||||
|
- **Electron running (tray or window) → notifications fire** at configured slots using Electron `Notification` |
||||
|
- **Electron not running →** no delivery (documented limitation for v1) |
||||
|
- **Start on Login enabled →** after reboot + login, orchestrator **will re-arm** slots and deliver |
||||
|
- **Template limits honored** (Title ≤ 50, Body ≤ 200) on Electron notifications |
||||
|
- **SW scope** not used for Electron (click handlers are **web only**) |
||||
|
- **Windows appUserModelId** set correctly for toast attribution |
||||
|
- **macOS notification permission** requested on first use |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 15) Test Matrix (Essentials) |
||||
|
|
||||
|
- **Android:** exact vs inexact branch, Doze/App Standby behavior, reboot/time change, permission denial path, deep‑link to exact‑alarm settings. |
||||
|
- **iOS:** BG fetch budget limits, pending cap windowing, local notification delivery with app terminated, category actions. |
||||
|
- **Web:** SW lifecycle, push delivery with app closed, click handling, no offline scheduling. |
||||
|
- **Cross‑cutting:** ETag/304 behavior, TTL policy, templating correctness, event queue drain, SQLite retention job. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 16) Migration & Rollout Notes |
||||
|
|
||||
|
- We **will keep** existing web push flows intact. |
||||
|
- We **will introduce** the orchestrator behind a feature flag, initially with a small number of slots. |
||||
|
- We **will migrate** settings to accept multiple times per day. |
||||
|
- We **will document** platform caveats inside user‑visible settings (e.g., Android exact alarms, iOS cap). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 17) Security & Privacy |
||||
|
|
||||
|
- Tokens **will reside** in Keystore/Keychain (mobile) and **will be injected** at request time; they **will not** be stored in SQLite. |
||||
|
- Optionally, SQLCipher at rest for mobile; redaction of PII in logs; payload size caps. |
||||
|
- Content **will be** minimal (title/body); sensitive data **will not be** embedded. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 18) Non‑Goals (Now) |
||||
|
|
||||
|
- Complex action sets and rich media on locals (kept minimal). |
||||
|
- Delivery‑time mutation of local notifications on iOS (NSE is for remote). |
||||
|
- Full analytics pipeline (future enhancement). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 19) Cross-Doc Sync Hygiene |
||||
|
|
||||
|
### Canonical Ownership |
||||
|
- **This document (Plan)**: Canonical for Goals, Tenets, Platform behaviors, Acceptance criteria, Test cases |
||||
|
- **Implementation document**: Canonical for API definitions, Database schemas, Adapter implementations, Code examples |
||||
|
|
||||
|
### PR Checklist |
||||
|
When changing notification system behavior, update both documents: |
||||
|
- [ ] **API changes**: Update types/interfaces in both Plan §4 and Implementation §3 |
||||
|
- [ ] **Schema changes**: Update Plan §5 and Implementation §2 |
||||
|
- [ ] **Slot/TTL changes**: Update Plan §4 semantics and Implementation §6 logic |
||||
|
- [ ] **Template changes**: Update Plan §9 contract and Implementation examples |
||||
|
- [ ] **Error codes**: Update Plan §11 taxonomy and Implementation error handling |
||||
|
|
||||
|
### Synchronization Points |
||||
|
- **API code blocks**: Must be identical between Plan §4 and Implementation §3 (Public API (Shared)) |
||||
|
- **Feature flags**: Must match between Plan §12 table and Implementation defaults |
||||
|
- **Test cases**: Plan §13 acceptance criteria must align with Implementation test examples |
||||
|
- **Slot/TTL/Lead policies**: Must be identical between Plan §4 policy and Implementation §3 policy |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 21) Privacy & Security Alignment |
||||
|
|
||||
|
### Privacy-First Architecture |
||||
|
- **User-Controlled Visibility:** Notification preferences **will be** user-controlled with explicit opt-in/opt-out |
||||
|
- **Data Sovereignty:** All notification data **will reside** on user's device; no external tracking or analytics |
||||
|
- **Minimal Data Collection:** We **will collect** only essential data for notification delivery (slot times, content templates) |
||||
|
- **DID Integration:** Notifications **will be** associated with user's Decentralized Identifier (DID) for privacy-preserving identity |
||||
|
|
||||
|
### Security Considerations |
||||
|
- **Content Encryption:** Sensitive notification content **will be** encrypted at rest using device keystore |
||||
|
- **Secure Transmission:** All API calls **will use** HTTPS with proper certificate validation |
||||
|
- **Input Validation:** All notification content **will be** validated and sanitized before storage |
||||
|
- **Access Control:** Notification settings **will be** protected by user authentication |
||||
|
|
||||
|
### Compliance with TimeSafari Principles |
||||
|
- **Privacy-Preserving:** Follows TimeSafari's privacy-preserving claims architecture |
||||
|
- **User Agency:** Users maintain full control over their notification experience |
||||
|
- **Transparency:** Clear communication about what data is collected and how it's used |
||||
|
- **Minimal Footprint:** Notification system **will have** minimal impact on user privacy |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 23) Platform-Specific Implementation Details |
||||
|
|
||||
|
### Web Platform (`VITE_PLATFORM=web`) |
||||
|
- **Database:** Uses Absurd SQL (SQLite via IndexedDB) via `WebPlatformService` with worker pattern |
||||
|
- **Notifications:** Web push notifications via Service Worker (minimal implementation) |
||||
|
- **Local Scheduling:** **Not supported** - web cannot schedule local notifications offline |
||||
|
- **API Integration:** Direct HTTP calls for content fetching |
||||
|
- **Storage:** Notification preferences stored in Absurd SQL database |
||||
|
- **Testing:** Playwright E2E tests run on web platform |
||||
|
|
||||
|
### Capacitor Platform (`VITE_PLATFORM=capacitor`) |
||||
|
- **Database:** Uses native SQLite via `CapacitorPlatformService` |
||||
|
- **Notifications:** Local notifications via `@capacitor/local-notifications` |
||||
|
- **Local Scheduling:** **Fully supported** - OS-level notification scheduling |
||||
|
- **API Integration:** HTTP calls with mobile-optimized timeouts and retry logic |
||||
|
- **Storage:** Notification preferences stored in native SQLite database |
||||
|
- **Testing:** Playwright E2E tests run on mobile devices (Android/iOS) |
||||
|
|
||||
|
### Electron Platform (`VITE_PLATFORM=electron`) |
||||
|
- **Database:** Uses native SQLite via `ElectronPlatformService` (extends CapacitorPlatformService) |
||||
|
- **Notifications:** OS-level notifications via Electron's notification API |
||||
|
- **Local Scheduling:** **Supported** - desktop OS notification scheduling |
||||
|
- **API Integration:** Same as Capacitor platform |
||||
|
- **Storage:** Same as Capacitor platform (via inherited service) |
||||
|
- **Testing:** Same as Capacitor platform |
||||
|
|
||||
|
### Cross-Platform Considerations |
||||
|
- **Feature Detection:** Use `process.env.VITE_PLATFORM` for platform-specific behavior |
||||
|
- **Database Abstraction:** PlatformServiceMixin handles database differences transparently |
||||
|
- **API Consistency:** Same TypeScript API across all platforms |
||||
|
- **Fallback Behavior:** Web platform gracefully degrades to push-only notifications |
||||
|
|
||||
|
--- |
||||
|
## 24) TimeSafari Architecture Compliance |
||||
|
|
||||
|
### Design Pattern Adherence |
||||
|
- **Factory Pattern:** Notification service follows `PlatformServiceFactory` singleton pattern |
||||
|
- **Mixin Pattern:** Database access uses existing `PlatformServiceMixin` pattern |
||||
|
- **Migration Pattern:** Database changes follow existing `MIGRATIONS` array pattern |
||||
|
- **Error Handling:** Uses existing `handleApiError` from `src/services/api.ts` |
||||
|
- **Logging:** Uses existing logger from `src/utils/logger` with established patterns |
||||
|
- **Platform Detection:** Uses existing `Capacitor.isNativePlatform()` and `VITE_PLATFORM` patterns |
||||
|
|
||||
|
### File Organization Compliance |
||||
|
- **Services:** Follows existing `src/services/` organization with factory and adapters |
||||
|
- **Database:** Extends existing `src/db-sql/migration.ts` and `src/db/tables/settings.ts` |
||||
|
- **Utils:** Extends existing `src/utils/PlatformServiceMixin.ts` |
||||
|
- **Main Entry:** Integrates with existing `src/main.capacitor.ts` initialization |
||||
|
- **Service Workers:** Follows existing `sw_scripts/` organization |
||||
|
|
||||
|
### Type Safety Compliance |
||||
|
- **Settings Extension:** Follows existing Settings type extension pattern |
||||
|
- **Interface Definitions:** Uses existing TypeScript interface patterns |
||||
|
- **Error Types:** Follows existing error handling type patterns |
||||
|
- **Platform Types:** Uses existing platform detection type patterns |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Sync Checklist |
||||
|
|
||||
|
| Sync item | Plan | Impl | Status | |
||||
|
| ------------------------------ | --------------------- | --------------------- | --------- | |
||||
|
| Public API block identical | §4 | §3 | ✅ | |
||||
|
| `getState()` fields present | §4 | §8 Orchestrator | ✅ | |
||||
|
| Capacitor action handlers | §7 (iOS/Android note) | §9 Bootstrap | ✅ | |
||||
|
| Electron fetch prereq/polyfill | §7 | §9 Electron | ✅ | |
||||
|
| Android ±10m fallback | §7 | §7 SchedulerCapacitor | ✅ | |
||||
|
| Retention (no VACUUM v1) | §5 | `$pruneNotifData` | ✅ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
*This strategic plan focuses on features and future‑tense deliverables, avoids implementation details, and preserves a clear path from the in‑app implementation (v1) to the native plugin (v2). For detailed implementation specifics, see `notification-system-implementation.md`.* |
Loading…
Reference in new issue