66 KiB
TimeSafari Notification System — Implementation Guide
Status: 🚀 Active implementation
Date: 2025-09-05T05:09Z (UTC)
Author: Matthew Raymer
Scope: Detailed implementation for v1 (in‑app orchestrator)
Goal: Complete implementation guide with code, database schemas, and integration specifics.
Strategic Overview: See
notification-system-plan.md
for high-level strategy, architecture, and planning details.
Canonical Ownership: This document owns API definitions, Database schemas, Adapter implementations, and Code examples.
1) Dependencies & Setup
Package Dependencies
npm i @capacitor/local-notifications
npx cap sync
Capacitor Configuration
// capacitor.config.ts
plugins: {
LocalNotifications: {
smallIcon: 'ic_stat_name',
iconColor: '#4a90e2'
}
}
2) Database Integration
Migration Integration
Add to existing src/db-sql/migration.ts
MIGRATIONS array:
{
name: "003_notification_system",
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, -- scheduled|shown|canceled|error
error_code TEXT, error_message TEXT
);
-- Prevent duplicate scheduled deliveries
CREATE UNIQUE INDEX IF NOT EXISTS notif_uq_scheduled
ON notif_deliveries(slot_id, fire_at, status)
WHERE status='scheduled';
CREATE TABLE IF NOT EXISTS notif_config(
k TEXT PRIMARY KEY,
v TEXT NOT NULL
);
`,
}
Platform-Specific Database Backends:
- Web (
VITE_PLATFORM=web
): Uses Absurd SQL (SQLite via IndexedDB) viaWebPlatformService
with worker pattern - migration runs in Absurd SQL context - Capacitor (
VITE_PLATFORM=capacitor
): Uses native SQLite viaCapacitorPlatformService
- migration runs in native SQLite context - Electron (
VITE_PLATFORM=electron
): Uses native SQLite viaElectronPlatformService
(extends CapacitorPlatformService) - same as Capacitor
Retention: We will keep ~14 days of contents/deliveries and prune daily.
Settings Extension
Extend src/db/tables/settings.ts
:
export type Settings = {
// ... existing fields ...
// Multi-daily notification settings (following TimeSafari pattern)
notifTimes?: string; // JSON string in DB: Array<{ hour: number; minute: number }>
notifTtlSeconds?: number;
notifPrefetchLeadMinutes?: number;
notifContentTemplate?: string; // JSON string in DB: { title: string; body: string }
notifCallbackProfile?: string; // JSON string in DB: CallbackProfile
notifEnabled?: boolean;
notifMode?: string; // JSON string in DB: 'online-first' | 'offline-first' | 'auto'
};
3) Public API (Shared)
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
};
interface MultiDailyNotification {
configure(opts: ConfigureOptions): Promise<void>;
getState(): Promise<{
enabled: boolean;
slots: SlotId[];
lastFetchAt?: number;
lastDeliveryAt?: number;
exactAlarmCapable: boolean;
}>;
runFullPipelineNow(): Promise<void>;
reschedule(): Promise<void>;
}
Storage semantics: 'shared'
= app DB; 'private'
= plugin-owned/native DB (v2). (No functional difference in v1.)
Compliance note: We will expose lastFetchAt
, lastDeliveryAt
, and exactAlarmCapable
as specified in the getState()
method.
export interface MultiDailyNotification { requestPermissions(): Promise; configure(o: ConfigureOptions): Promise; runFullPipelineNow(): Promise; // API→DB→Schedule (today's remaining) deliverStoredNow(slotId?: SlotId): Promise; // 60s cooldown guard reschedule(): Promise; 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.
---
## 4) Internal Interfaces
### Error Taxonomy
```typescript
// src/services/notifications/types.ts
export type NotificationErrorCode =
| '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
export type EventLogEnvelope = {
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
};
export type AckPayload = {
slotId: string;
fireAt: number; // Scheduled time
deliveredAt: number; // Actual delivery time
deviceTz: string; // Device timezone
appVersion: string; // App version
buildId: string; // Build identifier
};
Internal Service Interfaces
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[]>;
setConfig?(k: string, v: unknown): Promise<void>;
getConfig?(k: string): Promise<unknown | null>;
getLastFetchAt?(): Promise<number | undefined>;
getLastDeliveryAt?(): Promise<number | undefined>;
}
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>;
}
5) Template Engine Contract
Supported Tokens & Escaping
// src/services/notifications/TemplateEngine.ts
export class TemplateEngine {
private static readonly SUPPORTED_TOKENS = {
'headline': 'Main content headline',
'summary': 'Content summary text',
'date': 'Formatted date (YYYY-MM-DD)',
'time': 'Formatted time (HH:MM)'
};
private static readonly LENGTH_LIMITS = {
title: 50, // chars
body: 200 // chars
};
static render(template: { title: string; body: string }, data: Record<string, string>): { title: string; body: string } {
return {
title: this.renderTemplate(template.title, data, this.LENGTH_LIMITS.title),
body: this.renderTemplate(template.body, data, this.LENGTH_LIMITS.body)
};
}
private static renderTemplate(template: string, data: Record<string, string>, maxLength: number): string {
let result = template;
// Replace tokens with data or fallback
for (const [token, fallback] of Object.entries(this.SUPPORTED_TOKENS)) {
const regex = new RegExp(`{{${token}}}`, 'g');
const value = data[token] || '[Content]';
result = result.replace(regex, value);
}
// Truncate if needed (before escaping to avoid splitting entities)
if (result.length > maxLength) {
result = result.substring(0, maxLength - 3) + '...';
}
return this.escapeHtml(result);
}
private static escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
}
6) PlatformServiceMixin Integration
Extended Interface
// Add to src/utils/PlatformServiceMixin.ts IPlatformServiceMixin
export interface IPlatformServiceMixin {
// ... existing methods ...
// Notification-specific methods
$saveNotifContent(slotId: string, payload: unknown, etag?: string): Promise<boolean>;
$getNotifContent(slotId: string): Promise<{ payload: unknown; fetchedAt: number; etag?: string } | null>;
$recordNotifDelivery(slotId: string, fireAt: number, status: string, error?: { code?: string; message?: string }): Promise<boolean>;
$getNotifSettings(): Promise<NotificationSettings | null>;
$saveNotifSettings(settings: Partial<NotificationSettings>): Promise<boolean>;
$pruneNotifData(daysToKeep?: number): Promise<void>;
}
Implementation Methods
// Add to PlatformServiceMixin methods object
async $saveNotifContent(slotId: string, payload: unknown, etag?: string): Promise<boolean> {
try {
const fetchedAt = Date.now();
const payloadJson = JSON.stringify(payload);
await this.$exec(
`INSERT OR REPLACE INTO notif_contents (slot_id, payload_json, fetched_at, etag)
VALUES (?, ?, ?, ?)`,
[slotId, payloadJson, fetchedAt, etag]
);
return true;
} catch (error) {
logger.error('[PlatformServiceMixin] Failed to save notification content:', error);
return false;
}
},
async $getNotifContent(slotId: string): Promise<{ payload: unknown; fetchedAt: number; etag?: string } | null> {
try {
const result = await this.$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
};
} catch (error) {
logger.error('[PlatformServiceMixin] Failed to get notification content:', error);
return null;
}
},
async $recordNotifDelivery(slotId: string, fireAt: number, status: string, error?: { code?: string; message?: string }): Promise<boolean> {
try {
const deliveredAt = status === 'shown' ? Date.now() : null;
await this.$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]
);
return true;
} catch (error) {
// Handle duplicate schedule constraint as idempotent (no-op)
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
logger.debug('[PlatformServiceMixin] Duplicate delivery record for', slotId, 'at', fireAt, '- treating as idempotent');
return true;
}
logger.error('[PlatformServiceMixin] Failed to record notification delivery', error);
return false;
}
},
async $pruneNotifData(daysToKeep: number = 14): Promise<void> {
try {
const cutoffMs = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
// Prune old content
await this.$exec(
`DELETE FROM notif_contents WHERE fetched_at < ?`,
[cutoffMs]
);
// Prune old deliveries
await this.$exec(
`DELETE FROM notif_deliveries WHERE fire_at < ?`,
[cutoffMs]
);
logger.debug('[PlatformServiceMixin] Pruned notification data older than', daysToKeep, 'days');
// Log with safe stringify for complex objects
logger.debug('[PlatformServiceMixin] Prune details:', safeStringify({
daysToKeep,
cutoffMs,
timestamp: new Date().toISOString()
}));
// We will avoid VACUUM in v1 to prevent churn; optional maintenance can be added behind a flag.
} catch (error) {
logger.error('[PlatformServiceMixin] Failed to prune notification data', error);
}
},
7) Adapter Implementations
DataStoreSqlite Adapter
// src/services/notifications/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> {
await this.platformService.$saveNotifContent(slotId, payload, etag);
}
async getLatestContent(slotId: SlotId): Promise<{
payload: unknown;
fetchedAt: number;
etag?: string
}|null> {
return await this.platformService.$getNotifContent(slotId);
}
async recordDelivery(
slotId: SlotId,
fireAt: number,
status: 'scheduled'|'shown'|'error',
error?: { code?: string; message?: string }
): Promise<void> {
await this.platformService.$recordNotifDelivery(slotId, fireAt, status, error);
}
async enqueueEvent(e: unknown): Promise<void> {
// v1: Simple in-memory queue for now, can be enhanced with SQLite in v2
// This will be drained when app comes to foreground
}
async drainEvents(): Promise<unknown[]> {
// v1: Return and clear queued events from in-memory queue
// v2: Will migrate to SQLite-backed queue for persistence
return [];
}
async setConfig(k: string, v: unknown): Promise<void> {
await this.platformService.$setNotifConfig(k, v);
}
async getConfig(k: string): Promise<unknown | null> {
try {
const result = await this.platformService.$db(
`SELECT v FROM notif_config WHERE k = ?`,
[k]
);
return result && result.length > 0 ? JSON.parse(result[0].v) : null;
} catch (error) {
logger.error('[DataStoreSqlite] Failed to get config:', error);
return null;
}
}
async getLastFetchAt(): Promise<number | undefined> {
try {
const result = await this.platformService.$one(
`SELECT MAX(fetched_at) as last_fetch FROM notif_contents`
);
return result?.last_fetch;
} catch (error) {
logger.error('[DataStoreSqlite] Failed to get last fetch time:', error);
return undefined;
}
}
async getLastDeliveryAt(): Promise<number | undefined> {
try {
const result = await this.platformService.$one(
`SELECT MAX(fire_at) as last_delivery FROM notif_deliveries WHERE status = 'shown'`
);
return result?.last_delivery;
} catch (error) {
logger.error('[DataStoreSqlite] Failed to get last delivery time:', error);
return undefined;
}
}
}
SchedulerElectron Adapter
// src/services/notifications/adapters/SchedulerElectron.ts
import type { Scheduler, SlotId } from '../types';
import { Notification, app } from 'electron';
import { logger, safeStringify } from '@/utils/logger';
export class SchedulerElectron implements Scheduler {
async capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }> {
// Electron timers + OS delivery while app runs; no exact-alarm guarantees.
return { exactAlarms: false, maxPending: 128 };
}
async scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise<void> {
const delay = Math.max(0, whenMs - Date.now());
setTimeout(() => {
try {
const n = new Notification({
title: 'TimeSafari',
body: 'Your daily update is ready',
// Electron Notification supports .actions on some OSes;
// keep minimal now for parity with v1 locals.
silent: false
});
// n.on('click', ...) → open route if desired
n.show();
logger.debug('[SchedulerElectron] Notification shown for', slotId);
// Log with safe stringify for complex objects
logger.debug('[SchedulerElectron] Notification details:', safeStringify({
slotId,
timestamp: new Date().toISOString(),
platform: 'electron'
}));
} catch (e) {
logger.error('[SchedulerElectron] show failed', e);
throw e;
}
}, delay);
}
async scheduleWindow(slotId: SlotId, windowStartMs: number, windowLenMs: number, payloadRef: string): Promise<void> {
// v1 emulates "window" by scheduling at window start; OS may delay delivery.
return this.scheduleExact(slotId, windowStartMs, payloadRef);
}
async cancelBySlot(_slotId: SlotId): Promise<void> {
// Electron Notification has no pending queue to cancel; v1: no-op.
// v2: a native helper could manage real queues per OS.
}
async rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise<void> {
// Clear any in-process timers if you track them; then re-arm:
for (const { slotId, whenMs } of next) {
await this.scheduleExact(slotId, whenMs, `${slotId}:${whenMs}`);
}
}
}
SchedulerCapacitor Adapter
// src/services/notifications/adapters/SchedulerCapacitor.ts
import { LocalNotifications } from '@capacitor/local-notifications';
import type { Scheduler, SlotId } from '../types';
import { logger, safeStringify } from '@/utils/logger';
export class SchedulerCapacitor implements Scheduler {
async capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }> {
// Conservative default; exact permission detection will be native in v2.
return { exactAlarms: false, maxPending: 64 };
}
async scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise<void> {
try {
await LocalNotifications.schedule({
notifications: [{
id: this.generateNotificationId(slotId, whenMs),
title: 'TimeSafari',
body: 'Your daily update is ready',
schedule: { at: new Date(whenMs) },
extra: { slotId, payloadRef }
}]
});
logger.debug('[SchedulerCapacitor] Scheduled notification for slot', slotId, 'at', new Date(whenMs).toISOString());
// Log with safe stringify for complex objects
logger.debug('[SchedulerCapacitor] Notification details:', safeStringify({
slotId,
whenMs,
scheduledAt: new Date(whenMs).toISOString(),
platform: 'capacitor'
}));
} catch (error) {
logger.error('[SchedulerCapacitor] Failed to schedule notification for slot', slotId, error);
throw error;
}
}
async scheduleWindow(
slotId: SlotId,
windowStartMs: number,
windowLenMs: number,
payloadRef: string
): Promise<void> {
try {
// For platforms that don't support exact alarms
// Note: v1 schedules at window start since Capacitor doesn't expose true window behavior
// True "window" scheduling is a v2 responsibility
// v1 emulates windowed behavior by scheduling at window start; actual OS batching may delay delivery
await LocalNotifications.schedule({
notifications: [{
id: this.generateNotificationId(slotId, windowStartMs),
title: 'TimeSafari',
body: 'Your daily update is ready',
schedule: {
at: new Date(windowStartMs),
repeats: false
},
extra: { slotId, payloadRef, windowLenMs } // Carry window length for telemetry
}]
});
logger.debug('[SchedulerCapacitor] Scheduled windowed notification for slot', slotId, 'at window start');
} catch (error) {
logger.error('[SchedulerCapacitor] Failed to schedule windowed notification for slot', slotId, error);
throw error;
}
}
async cancelBySlot(slotId: SlotId): Promise<void> {
try {
// Get all pending notifications and cancel those matching the slotId
const pending = await LocalNotifications.getPending();
if (pending?.notifications?.length) {
const matchingIds = pending.notifications
.filter(n => n.extra?.slotId === slotId)
.map(n => ({ id: n.id }));
if (matchingIds.length > 0) {
await LocalNotifications.cancel({ notifications: matchingIds });
logger.debug('[SchedulerCapacitor] Cancelled', matchingIds.length, 'notifications for slot', slotId);
}
}
} catch (error) {
logger.error('[SchedulerCapacitor] Failed to cancel notification for slot', slotId, error);
throw error;
}
}
async rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise<void> {
try {
// Cancel all pending first
const pending = await LocalNotifications.getPending();
if (pending?.notifications?.length) {
await LocalNotifications.cancel({
notifications: pending.notifications.map(n => ({ id: n.id }))
});
}
// Schedule new set
await LocalNotifications.schedule({
notifications: next.map(({ slotId, whenMs }) => ({
id: this.generateNotificationId(slotId, whenMs),
title: 'TimeSafari',
body: 'Your daily update is ready',
schedule: { at: new Date(whenMs) },
extra: { slotId, whenMs }
}))
});
logger.debug('[SchedulerCapacitor] Rescheduled', next.length, 'notifications');
} catch (error) {
logger.error('[SchedulerCapacitor] Failed to reschedule notifications', error);
throw error;
}
}
private generateNotificationId(slotId: SlotId, whenMs: number): number {
// 32-bit FNV-1a like hash
let hash = 0x811c9dc5;
const s = `${slotId}-${whenMs}`;
for (let i = 0; i < s.length; i++) {
hash ^= s.charCodeAt(i);
hash = (hash >>> 0) * 0x01000193 >>> 0;
}
return Math.abs(hash | 0);
}
}
CallbacksHttp Adapter
// src/services/notifications/adapters/CallbacksHttp.ts
import type { CallbackProfile } from '../types';
import { handleApiError } from '@/utils/errorHandler';
import { logger, safeStringify } from '@/utils/logger';
export class CallbacksHttp {
constructor(private profile: CallbackProfile) {}
async fetchContent(slotId: string, etag?: string): Promise<{
payload: unknown;
etag?: string
}|null> {
try {
const spec = this.profile.fetchContent;
const ac = new AbortController();
const to = setTimeout(() => ac.abort(), spec.timeoutMs ?? 15000);
try {
const headers = { ...spec.headers };
if (etag) {
headers['If-None-Match'] = etag;
}
const response = await fetch(spec.url, {
method: spec.method,
headers,
body: spec.bodyJson ? JSON.stringify(spec.bodyJson) : undefined,
signal: ac.signal
});
clearTimeout(to);
if (response.status === 304) return null;
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 };
} catch (err) {
clearTimeout(to);
throw err;
}
} catch (error) {
logger.error('[CallbacksHttp] Failed to fetch content for slot', slotId, error);
const enhancedError = handleApiError(error, {
component: 'CallbacksHttp',
operation: 'fetchContent',
timestamp: new Date().toISOString(),
slotId,
etag
}, 'fetchContent');
// Log enhanced error details for debugging
logger.error('[CallbacksHttp] Enhanced error details:', enhancedError);
throw error;
}
}
async ackDelivery(slotId: string, deliveryData: unknown): Promise<void> {
if (!this.profile.ackDelivery) return;
try {
const spec = this.profile.ackDelivery;
const body = JSON.stringify({ slotId, ...deliveryData });
const method = spec.method || 'POST'; // Default to POST when body is sent
await fetch(spec.url, {
method,
headers: spec.headers,
body
});
logger.debug('[CallbacksHttp] Acknowledged delivery for slot', slotId);
} catch (error) {
logger.error('[CallbacksHttp] Failed to acknowledge delivery for slot', slotId, error);
// Don't throw - this is not critical
}
}
async reportError(slotId: string, error: { code?: string; message?: string }): Promise<void> {
if (!this.profile.reportError) return;
try {
const spec = this.profile.reportError;
const body = JSON.stringify({ slotId, error });
const method = spec.method || 'POST'; // Default to POST when body is sent
await fetch(spec.url, {
method,
headers: spec.headers,
body
});
logger.debug('[CallbacksHttp] Reported error for slot', slotId);
} catch (reportError) {
logger.error('[CallbacksHttp] Failed to report error for slot', slotId, reportError);
// Don't throw - this is not critical
}
}
}
8) Core Orchestrator Implementation
NotificationOrchestrator
Implementation Guarantees:
- SlotId generation: Build SlotId as
HHmm
fromNotificationTime
and use it everywhere (replace anyslot-xx-yy
pattern) - Cooldown:
deliverStoredNow()
will ignore requests if invoked for the sameslotId
within 60 seconds of the last call - Idempotency: Before scheduling, we will record
status='scheduled'
; unique index will reject duplicates (handle gracefully) - Lead window: Only one online-first attempt per slot inside the lead window; no inner retries (enforced by per-slot
lastAttemptAt
tracking) - Reschedule on TZ/DST: On app resume or timezone offset change, we will cancel & re-arm the rolling window
- Config persistence: Configuration will be persisted to
notif_config
table, not just enqueued as events
// src/services/notifications/NotificationOrchestrator.ts
import type { ConfigureOptions, SlotId, DataStore, Scheduler } from './types';
import { CallbacksHttp } from './adapters/CallbacksHttp';
import { handleApiError } from '@/utils/errorHandler';
import { logger, safeStringify } from '@/utils/logger';
export class NotificationOrchestrator implements MultiDailyNotification {
constructor(private store: DataStore, private sched: Scheduler) {}
private opts!: ConfigureOptions;
private callbacks?: CallbacksHttp;
// Lead window attempt tracking (one attempt per slot per lead window)
private lastAttemptAt: Map<SlotId, number> = new Map();
// Cooldown tracking for deliverStoredNow (60s cooldown per slot)
private lastDeliveredNowAt: Map<SlotId, number> = new Map();
async requestPermissions(): Promise<void> {
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
const result = await LocalNotifications.requestPermissions();
if (result.display !== 'granted') {
throw new Error('Notification permissions not granted');
}
logger.debug('[NotificationOrchestrator] Permissions granted');
} catch (error) {
logger.error('[NotificationOrchestrator] Failed to request permissions', error);
throw error;
}
}
async configure(o: ConfigureOptions): Promise<void> {
this.opts = o;
if (o.callbackProfile) {
this.callbacks = new CallbacksHttp(o.callbackProfile);
}
// Persist configuration directly to notif_config table via store methods
await this.store.setConfig?.('times', o.times);
await this.store.setConfig?.('ttlSeconds', o.ttlSeconds ?? 86400);
await this.store.setConfig?.('prefetchLeadMinutes', o.prefetchLeadMinutes ?? 20);
await this.store.setConfig?.('storage', o.storage);
if (o.contentTemplate) await this.store.setConfig?.('contentTemplate', o.contentTemplate);
if (o.callbackProfile) await this.store.setConfig?.('callbackProfile', o.callbackProfile);
logger.debug('[NotificationOrchestrator] Configuration persisted to notif_config');
}
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.debug('[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();
const now = Date.now();
const cooldownMs = 60 * 1000; // 60 seconds
for (const slot of targetSlots) {
// Check cooldown
const lastDelivered = this.lastDeliveredNowAt.get(slot);
if (lastDelivered && (now - lastDelivered) < cooldownMs) {
logger.debug('[NotificationOrchestrator] Skipping deliverStoredNow for', slot, '- within 60s cooldown');
continue;
}
const content = await this.store.getLatestContent(slot);
if (content) {
const payloadRef = this.createPayloadRef(content.payload);
await this.sched.scheduleExact(slot, Date.now() + 5000, payloadRef);
// Record delivery time for cooldown
this.lastDeliveredNowAt.set(slot, now);
}
}
}
async reschedule(): Promise<void> {
// Check permissions before bulk scheduling
const { LocalNotifications } = await import('@capacitor/local-notifications');
const enabled = await LocalNotifications.areEnabled();
if (!enabled.value) {
logger.debug('[NotificationOrchestrator] Notifications disabled, skipping reschedule');
await this.store.recordDelivery('system', Date.now(), 'error', {
code: 'SCHEDULE_DENIED',
message: 'Notifications disabled during reschedule'
});
return;
}
const nextOccurrences = this.getUpcomingSlots().map(slotId => ({
slotId,
whenMs: this.getNextSlotTime(slotId)
}));
await this.sched.rescheduleAll(nextOccurrences);
}
async getState(): Promise<{
enabled: boolean;
slots: SlotId[];
lastFetchAt?: number;
lastDeliveryAt?: number;
exactAlarmCapable: boolean;
}> {
const capabilities = await this.sched.capabilities();
// Get last fetch time from notif_contents table
const lastFetchAt = await this.store.getLastFetchAt();
// Get last delivery time from notif_deliveries table
const lastDeliveryAt = await this.store.getLastDeliveryAt();
return {
enabled: this.opts ? true : false,
slots: this.opts?.times?.map(t => `${t.hour.toString().padStart(2, '0')}${t.minute.toString().padStart(2, '0')}`) || [],
lastFetchAt,
lastDeliveryAt,
exactAlarmCapable: capabilities.exactAlarms
};
}
private async fetchAndScheduleSlot(slotId: SlotId): Promise<void> {
try {
// Check if we're within lead window and have already attempted
const now = Date.now();
const leadWindowMs = (this.opts.prefetchLeadMinutes ?? 20) * 60 * 1000;
const slotTimeMs = this.getNextSlotTime(slotId);
const isWithinLeadWindow = (slotTimeMs - now) <= leadWindowMs;
if (isWithinLeadWindow) {
const lastAttempt = this.lastAttemptAt.get(slotId);
if (lastAttempt && (now - lastAttempt) < leadWindowMs) {
// Already attempted within this lead window, skip online-first
logger.debug('[NotificationOrchestrator] Skipping online-first for', slotId, '- already attempted within lead window');
} else {
// Record this attempt
this.lastAttemptAt.set(slotId, now);
// Attempt online-first fetch
if (this.callbacks) {
// Get saved ETag for this slot
const storedContent = await this.store.getLatestContent(slotId);
const savedEtag = storedContent?.etag;
const content = await this.callbacks.fetchContent(slotId, savedEtag);
if (content) {
await this.store.saveContent(slotId, content.payload, content.etag);
await this.scheduleSlot(slotId, content.payload);
return;
}
}
}
} else {
// Outside lead window, attempt online-first fetch
if (this.callbacks) {
// Get saved ETag for this slot
const storedContent = await this.store.getLatestContent(slotId);
const savedEtag = storedContent?.etag;
const content = await this.callbacks.fetchContent(slotId, savedEtag);
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'
});
const enhancedError = handleApiError(error, {
component: 'NotificationOrchestrator',
operation: 'fetchAndScheduleSlot',
timestamp: new Date().toISOString(),
slotId
}, 'fetchAndScheduleSlot');
// Log enhanced error details for debugging
logger.error('[NotificationOrchestrator] Enhanced error details:', enhancedError);
}
}
private async scheduleSlot(slotId: SlotId, payload: unknown): Promise<void> {
const whenMs = this.getNextSlotTime(slotId);
// Render template with payload data
const data = this.buildTemplateData(payload);
const rendered = TemplateEngine.render(this.opts.contentTemplate, data);
// Check permissions before scheduling
const { LocalNotifications } = await import('@capacitor/local-notifications');
const enabled = await LocalNotifications.areEnabled();
if (!enabled.value) {
await this.store.recordDelivery(slotId, whenMs, 'error', {
code: 'SCHEDULE_DENIED',
message: 'Notifications disabled'
});
return;
}
// Schedule with rendered title/body
await LocalNotifications.schedule({
notifications: [{
id: this.generateNotificationId(slotId, whenMs),
title: rendered.title,
body: rendered.body,
schedule: { at: new Date(whenMs) },
extra: { slotId, whenMs }
}]
});
await this.store.recordDelivery(slotId, whenMs, 'scheduled');
}
private toSlotId(t: {hour:number; minute:number}): string {
return `${t.hour.toString().padStart(2,'0')}${t.minute.toString().padStart(2,'0')}`; // "HHmm"
}
private getUpcomingSlots(): SlotId[] {
const now = Date.now();
const twoDays = 2 * 24 * 60 * 60 * 1000;
const slots: SlotId[] = [];
for (const t of this.opts.times) {
const slotId = this.toSlotId(t);
const when = this.getNextSlotTime(slotId);
if (when <= (now + twoDays)) slots.push(slotId);
}
return slots;
}
private getNextSlotTime(slotId: SlotId): number {
const hour = parseInt(slotId.slice(0,2), 10);
const minute = parseInt(slotId.slice(2,4), 10);
const now = new Date();
const next = new Date(now);
next.setHours(hour, minute, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
return next.getTime();
}
private buildTemplateData(payload: unknown): Record<string, string> {
const data = payload as Record<string, unknown>;
return {
headline: data.headline as string || '[Content]',
summary: data.summary as string || '[Content]',
date: new Date().toISOString().split('T')[0], // YYYY-MM-DD
time: new Date().toTimeString().split(' ')[0].slice(0, 5) // HH:MM
};
}
private generateNotificationId(slotId: SlotId, whenMs: number): number {
// 32-bit FNV-1a like hash
let hash = 0x811c9dc5;
const s = `${slotId}-${whenMs}`;
for (let i = 0; i < s.length; i++) {
hash ^= s.charCodeAt(i);
hash = (hash >>> 0) * 0x01000193 >>> 0;
}
return Math.abs(hash | 0);
}
private isWithinTTL(fetchedAt: number): boolean {
const ttlMs = (this.opts.ttlSeconds || 86400) * 1000;
return Date.now() - fetchedAt < ttlMs;
}
}
9) Bootstrap & Integration
Capacitor Integration
// src/main.capacitor.ts - Add to existing initialization
import { NotificationServiceFactory } from './services/notifications/NotificationServiceFactory';
import { LocalNotifications } from '@capacitor/local-notifications';
// Wire action + receive listeners once during app init
LocalNotifications.addListener('localNotificationActionPerformed', e => {
const slotId = e.notification?.extra?.slotId;
logger.debug('[LocalNotifications] Action performed for slot', slotId);
// TODO: route to screen; reuse existing deep-link system
// This could navigate to a specific view based on slotId
if (slotId) {
// Example: Navigate to daily view or specific content
// router.push(`/daily/${slotId}`);
}
});
LocalNotifications.addListener('localNotificationReceived', e => {
const slotId = e.notification?.extra?.slotId;
logger.debug('[LocalNotifications] Notification received for slot', slotId);
// Optional: light telemetry hook
// Could track delivery success, user engagement, etc.
});
// After existing deep link registration
setTimeout(async () => {
try {
await registerDeepLinkListener();
// Initialize notifications using factory pattern
const notificationService = NotificationServiceFactory.getInstance();
if (notificationService) {
await notificationService.runFullPipelineNow();
logger.info(`[Main] 🎉 Notifications initialized successfully!`);
} else {
logger.warn(`[Main] ⚠️ Notification service not available on this platform`);
}
logger.info(`[Main] 🎉 All systems fully initialized!`);
} catch (error) {
logger.error(`[Main] ❌ System initialization failed:`, error);
}
}, 2000);
Electron Integration
// src/main.electron.ts - Add to existing initialization
import { app } from 'electron';
import { NotificationServiceFactory } from './services/notifications/NotificationServiceFactory';
// We will require Node 18+ (global fetch) or we will polyfill via undici
// main.electron.ts (only if Node < 18)
// import 'undici/register';
// Windows integration (main process bootstrap)
if (process.platform === 'win32') {
app.setAppUserModelId('com.timesafari.app'); // stable, never change
}
// Auto-launch (Option 1)
app.setLoginItemSettings({
openAtLogin: true,
openAsHidden: true
});
// Initialize notifications on app ready
app.whenReady().then(async () => {
try {
const notificationService = NotificationServiceFactory.getInstance();
if (notificationService) {
await notificationService.runFullPipelineNow();
logger.info(`[Main] 🎉 Electron notifications initialized successfully!`);
} else {
logger.warn(`[Main] ⚠️ Electron notification service not available`);
}
} catch (error) {
logger.error(`[Main] ❌ Electron notification initialization failed:`, error);
}
});
Notification Initialization
// src/services/notifications/index.ts
import { LocalNotifications } from '@capacitor/local-notifications';
import { NotificationOrchestrator } from './NotificationOrchestrator';
import { NotificationServiceFactory } from './NotificationServiceFactory';
import { Capacitor } from '@capacitor/core';
import { logger, safeStringify } from '@/utils/logger';
let orchestrator: NotificationOrchestrator | null = null;
export async function initNotifChannels(): Promise<void> {
try {
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' }]
}]
});
logger.debug('[Notifications] Channels and action types registered');
} catch (error) {
logger.error('[Notifications] Failed to register channels', error);
throw error;
}
}
export async function initNotifications(): Promise<void> {
// Only initialize on Capacitor/Electron platforms (not web)
const platform = process.env.VITE_PLATFORM || 'web';
if (platform === 'web') {
logger.debug('[Notifications] Skipping initialization on web platform - local notifications not supported');
return;
}
try {
await initNotifChannels();
// Use factory pattern for consistency
const notificationService = NotificationServiceFactory.getInstance();
if (notificationService) {
// Run pipeline on app start
await notificationService.runFullPipelineNow();
await notificationService.reschedule();
// Prune old data on app start
const platformService = PlatformServiceFactory.getInstance();
await platformService.$pruneNotifData(14);
logger.debug('[Notifications] Initialized successfully');
// Log with safe stringify for complex objects
logger.debug('[Notifications] Initialization details:', safeStringify({
platform: process.env.VITE_PLATFORM,
timestamp: new Date().toISOString(),
serviceAvailable: !!notificationService
}));
} else {
logger.warn('[Notifications] Service factory returned null');
}
} catch (error) {
logger.error('[Notifications] Initialization failed', error);
}
}
export function getNotificationOrchestrator(): NotificationOrchestrator | null {
// Return the singleton instance from the factory
return NotificationServiceFactory.getInstance();
}
10) Service Worker Integration
Service Worker Re-establishment Required
Note: Service workers are intentionally disabled in Electron (see src/main.electron.ts
lines 36-59) and have minimal web implementation via VitePWA plugin. Web push notifications would require re-implementing the service worker infrastructure.
Notification Click Handler
// Note: This handler is for WEB PUSH notifications only.
// Capacitor local notifications on mobile do not pass through the service worker.
// sw_scripts/notification-click.js (or integrate into existing service worker)
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('/');
})
);
});
Service Worker Registration
// Service worker registration is handled by VitePWA plugin in web builds
// This would typically go in main.web.ts or a dedicated service worker module
// 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)
if ('serviceWorker' in navigator && process.env.VITE_PLATFORM === 'web') {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker registered:', registration);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
}
11) Usage Examples
Basic Configuration
// In a Vue component using vue-facing-decorator and PlatformServiceMixin
import { Component, Vue, Prop } from 'vue-facing-decorator';
import { NotificationServiceFactory } from '@/services/notifications/NotificationServiceFactory';
import { PlatformServiceMixin } from '@/utils/PlatformServiceMixin';
@Component({
name: 'NotificationSettingsView',
mixins: [PlatformServiceMixin]
})
export default class NotificationSettingsView extends Vue {
@Prop({ required: true }) onSave!: (config: ConfigureOptions) => Promise<void>;
@Prop({ required: true }) onCancel!: () => void;
@Prop({ required: false }) onTest?: (slotId: string) => Promise<void>;
private notificationService = NotificationServiceFactory.getInstance();
async mounted() {
if (this.notificationService) {
await this.notificationService.configure({
times: [{hour:8,minute:0},{hour:12,minute:0},{hour:18,minute:0}],
ttlSeconds: 86400,
prefetchLeadMinutes: 20,
storage: 'shared',
contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' },
callbackProfile: {
fetchContent: {
method: 'GET',
url: 'https://api.timesafari.app/v1/report/daily',
headers: { Authorization: `Bearer ${token}` },
timeoutMs: 12000
}
}
});
await this.notificationService.runFullPipelineNow();
}
}
async handleSave() {
const config = this.collectConfiguration();
await this.onSave(config);
}
handleCancel() {
this.onCancel();
}
async handleTestNotification() {
if (this.onTest) {
await this.onTest('0800');
}
}
private collectConfiguration(): ConfigureOptions {
// Collect form data and return ConfigureOptions
return {
times: [{ hour: 8, minute: 0 }],
storage: 'shared',
contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' }
};
}
}
Settings Integration
// Save notification settings using existing PlatformServiceMixin
await this.$saveSettings({
notifTimes: [{ hour: 8, minute: 0 }, { hour: 12, minute: 0 }],
notifEnabled: true,
notifMode: 'online-first',
notifTtlSeconds: 86400,
notifPrefetchLeadMinutes: 20
});
Notification Composable Integration
// Note: The existing useNotifications composable in src/composables/useNotifications.ts
// is currently stub functions with eslint-disable comments and needs implementation for the notification system.
// This shows the actual stub function signatures:
export function useNotifications() {
// Inject the notify function from the app
const notify = inject<(notification: NotificationIface, timeout?: number) => void>("notify");
if (!notify) {
throw new Error("useNotifications must be used within a component that has $notify available");
}
// All functions are currently stubs with eslint-disable comments
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function success(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function error(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function warning(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function info(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function toast(_title: string, _text?: string, _timeout?: number) {}
// ... other stub functions
return {
success, error, warning, info, toast,
copied, sent, confirm, confirmationSubmitted,
genericError, genericSuccess, alreadyConfirmed,
cannotConfirmIssuer, cannotConfirmHidden,
notRegistered, notAGive, notificationOff, downloadStarted
};
}
11) Testing & Validation
Unit Test Example
// test/services/notifications/NotificationOrchestrator.test.ts
import { NotificationOrchestrator } from '@/services/notifications/NotificationOrchestrator';
import { DataStoreSqlite } from '@/services/notifications/adapters/DataStoreSqlite';
import { SchedulerCapacitor } from '@/services/notifications/adapters/SchedulerCapacitor';
describe('NotificationOrchestrator', () => {
let orchestrator: NotificationOrchestrator;
let mockStore: jest.Mocked<DataStoreSqlite>;
let mockScheduler: jest.Mocked<SchedulerCapacitor>;
beforeEach(() => {
mockStore = createMockDataStore();
mockScheduler = createMockScheduler();
orchestrator = new NotificationOrchestrator(mockStore, mockScheduler);
});
it('should configure successfully', async () => {
const config = {
times: [{ hour: 8, minute: 0 }],
ttlSeconds: 86400,
storage: 'shared' as const,
contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' }
};
await orchestrator.configure(config);
// Verify configuration persisted to database
const savedTimes = await mockStore.getConfig?.('times');
expect(savedTimes).toEqual(config.times);
});
it('should schedule upcoming slots', async () => {
await orchestrator.configure({
times: [{ hour: 8, minute: 0 }],
storage: 'shared' as const,
contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' }
});
await orchestrator.runFullPipelineNow();
expect(mockScheduler.scheduleExact).toHaveBeenCalled();
});
});
Playwright E2E Test Example
// test-playwright/notifications.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Notification System', () => {
test('should configure and display notification settings', async ({ page }) => {
await page.goto('/settings/notifications');
// Configure notification times using function props pattern
await page.click('[data-testid="add-time-button"]');
await page.fill('[data-testid="hour-input"]', '8');
await page.fill('[data-testid="minute-input"]', '0');
await page.click('[data-testid="save-time-button"]');
// Enable notifications
await page.click('[data-testid="enable-notifications"]');
// Verify configuration is saved
await expect(page.locator('[data-testid="next-occurrence"]')).toBeVisible();
await expect(page.locator('[data-testid="pending-count"]')).toContainText('1');
});
test('should handle window fallback on Android', async ({ page }) => {
// Mock Android without exact alarm permission
await page.addInitScript(() => {
Object.defineProperty(window, 'Capacitor', {
value: {
isNativePlatform: () => true,
getPlatform: () => 'android'
}
});
});
await page.goto('/settings/notifications');
await page.click('[data-testid="enable-notifications"]');
// Configure a slot for immediate testing
await page.fill('[data-testid="hour-input"]', '0');
await page.fill('[data-testid="minute-input"]', '1'); // 1 minute from now
await page.click('[data-testid="save-time-button"]');
// Wait for notification to fire (within +10m of slot time)
await page.waitForTimeout(70000); // Wait 70 seconds
// Verify notification appeared (may be delayed up to 10 minutes)
const notification = await page.locator('[data-testid="notification-delivered"]');
await expect(notification).toBeVisible({ timeout: 600000 }); // 10 minute timeout
});
});
Jest Integration Test
// test/services/notifications/integration.test.ts
import { NotificationServiceFactory } from '@/services/notifications/NotificationServiceFactory';
import { PlatformServiceFactory } from '@/services/PlatformServiceFactory';
describe('Notification System Integration', () => {
beforeEach(() => {
// Reset factory instances
NotificationServiceFactory.reset();
PlatformServiceFactory.reset();
});
it('should integrate with PlatformServiceFactory', () => {
const platformService = PlatformServiceFactory.getInstance();
const notificationService = NotificationServiceFactory.getInstance();
expect(platformService).toBeDefined();
expect(notificationService).toBeDefined();
expect(notificationService).toBeInstanceOf(Object);
});
it('should handle platform detection correctly', () => {
const originalPlatform = process.env.VITE_PLATFORM;
// Test web platform
process.env.VITE_PLATFORM = 'web';
const webService = NotificationServiceFactory.getInstance();
expect(webService).toBeNull(); // Should be null on web
// Test capacitor platform
process.env.VITE_PLATFORM = 'capacitor';
const capacitorService = NotificationServiceFactory.getInstance();
expect(capacitorService).toBeDefined();
// Test electron platform
process.env.VITE_PLATFORM = 'electron';
const electronService = NotificationServiceFactory.getInstance();
expect(electronService).toBeDefined();
// Restore original platform
process.env.VITE_PLATFORM = originalPlatform;
});
});
12) Service Architecture Integration
Factory Pattern Alignment
Follow TimeSafari's existing service factory pattern:
// src/services/notifications/NotificationServiceFactory.ts
import { NotificationOrchestrator } from './NotificationOrchestrator';
import { DataStoreSqlite } from './adapters/DataStoreSqlite';
import { SchedulerCapacitor } from './adapters/SchedulerCapacitor';
import { SchedulerElectron } from './adapters/SchedulerElectron';
import { PlatformServiceFactory } from '../PlatformServiceFactory';
import { logger, safeStringify } from '@/utils/logger';
export class NotificationServiceFactory {
private static instance: NotificationOrchestrator | null = null;
public static getInstance(): NotificationOrchestrator | null {
if (NotificationServiceFactory.instance) {
return NotificationServiceFactory.instance;
}
try {
const platformService = PlatformServiceFactory.getInstance();
const store = new DataStoreSqlite(platformService);
// Choose scheduler based on platform
const platform = process.env.VITE_PLATFORM || 'web';
let scheduler;
if (platform === 'electron') {
scheduler = new SchedulerElectron();
} else if (platform === 'capacitor') {
scheduler = new SchedulerCapacitor();
} else {
// Web platform - no local notifications
logger.debug('[NotificationServiceFactory] Web platform detected - no local notifications');
return null;
}
NotificationServiceFactory.instance = new NotificationOrchestrator(store, scheduler);
logger.debug('[NotificationServiceFactory] Created singleton instance for', platform);
// Log with safe stringify for complex objects
logger.debug('[NotificationServiceFactory] Factory details:', safeStringify({
platform,
timestamp: new Date().toISOString(),
schedulerType: platform === 'electron' ? 'SchedulerElectron' : 'SchedulerCapacitor'
}));
return NotificationServiceFactory.instance;
} catch (error) {
logger.error('[NotificationServiceFactory] Failed to create instance', error);
return null;
}
}
public static reset(): void {
NotificationServiceFactory.instance = null;
}
}
Platform Detection Integration
Use TimeSafari's actual platform detection patterns:
// In NotificationOrchestrator
private isNativePlatform(): boolean {
const platform = process.env.VITE_PLATFORM || 'web';
return platform === 'capacitor' || platform === 'electron';
}
private getPlatform(): string {
return process.env.VITE_PLATFORM || 'web';
}
private isWebPlatform(): boolean {
return process.env.VITE_PLATFORM === 'web';
}
DID Integration
// src/services/notifications/DidIntegration.ts
import { logger, safeStringify } from '@/utils/logger';
export class DidIntegration {
constructor(private userDid: string | null) {}
/**
* Associate notification with user's did:ethr: DID for privacy-preserving identity
* TimeSafari uses Ethereum-based DIDs in format: did:ethr:0x[40-char-hex]
*/
async associateWithDid(slotId: string, payload: unknown): Promise<unknown> {
if (!this.userDid) {
logger.debug('[DidIntegration] No user DID available for notification association');
// Log with safe stringify for complex objects
logger.debug('[DidIntegration] DID check details:', safeStringify({
userDid: this.userDid,
slotId,
timestamp: new Date().toISOString()
}));
return payload;
}
// Validate DID format (did:ethr:0x...)
if (!this.userDid.startsWith('did:ethr:0x') || this.userDid.length !== 53) {
logger.debug('[DidIntegration] Invalid did:ethr: format', this.userDid);
return payload;
}
// Add DID context to payload without exposing sensitive data
return {
...payload,
metadata: {
userDid: this.userDid,
slotId,
timestamp: Date.now()
}
};
}
/**
* Validate notification belongs to current user's did:ethr: DID
*/
async validateNotificationOwnership(notificationData: unknown): Promise<boolean> {
if (!this.userDid) return false;
const data = notificationData as { metadata?: { userDid?: string } };
return data.metadata?.userDid === this.userDid;
}
/**
* Get user context for notification personalization using did:ethr: format
*/
async getUserContext(): Promise<{ did: string; preferences: Record<string, unknown> } | null> {
if (!this.userDid) return null;
return {
did: this.userDid,
preferences: {
// Add user preferences from settings
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: navigator.language
}
};
}
}
13) File Structure
/src/services/notifications/
index.ts # Main exports and initialization
types.ts # Type definitions
NotificationOrchestrator.ts # Core orchestrator
NotificationServiceFactory.ts # Factory following TimeSafari pattern
DidIntegration.ts # DID integration for privacy-preserving identity
adapters/
DataStoreSqlite.ts # SQLite data store adapter
SchedulerCapacitor.ts # Capacitor scheduler adapter
CallbacksHttp.ts # HTTP callbacks adapter
TemplateEngine.ts # Template rendering and token substitution
NotificationSettingsView.vue # Vue component for settings
notifications.spec.ts # Jest unit tests
notifications.e2e.ts # Playwright E2E tests
notifications.integration.ts # Jest integration tests
/src/views/ NotificationSettingsView.vue # Vue component using vue-facing-decorator /sw_scripts/ notification-click.js # Service worker click handler /src/db-sql/ migration.ts # Extended with notification tables (follows existing pattern) /src/db/tables/ settings.ts # Extended Settings type (follows existing pattern) /src/utils/ PlatformServiceMixin.ts # Extended with notification methods (follows existing pattern) /src/main.capacitor.ts # Extended with notification initialization (follows existing pattern) /src/services/ api.ts # Extended error handling (follows existing pattern) /test/services/notifications/ NotificationOrchestrator.test.ts # Jest unit tests integration.test.ts # Integration tests /test-playwright/ notifications.spec.ts # Playwright E2E tests
---
## 14) TimeSafari Architecture Compliance
### Design Pattern Adherence
- **Factory Pattern:** `NotificationServiceFactory` follows the same singleton pattern as `PlatformServiceFactory`
- **Mixin Pattern:** Database access uses existing `PlatformServiceMixin` methods (`$db`, `$exec`, `$one`)
- **Migration Pattern:** Database changes follow existing `MIGRATIONS` array pattern in `src/db-sql/migration.ts`
- **Error Handling:** Uses existing comprehensive error handling from `src/utils/errorHandler.ts` for consistent error processing
- **Logging:** Uses existing logger from `src/utils/logger` with established logging patterns
- **Platform Detection:** Uses existing `Capacitor.isNativePlatform()` and `process.env.VITE_PLATFORM` patterns
### File Organization Compliance
- **Services:** Follows existing `src/services/` organization with factory and adapter pattern
- **Database:** Extends existing `src/db-sql/migration.ts` and `src/db/tables/settings.ts`
- **Utils:** Extends existing `src/utils/PlatformServiceMixin.ts` with notification methods
- **Main Entry:** Integrates with existing `src/main.capacitor.ts` initialization pattern
- **Service Workers:** Follows existing `sw_scripts/` organization pattern
### Type Safety Compliance
- **Settings Extension:** Follows existing Settings type extension pattern in `src/db/tables/settings.ts`
- **Interface Definitions:** Uses existing TypeScript interface patterns from `src/interfaces/`
- **Error Types:** Follows existing error handling type patterns from `src/services/api.ts`
- **Platform Types:** Uses existing platform detection type patterns from `src/services/PlatformService.ts`
### Integration Points
- **Database Access:** All database operations use `PlatformServiceMixin` methods for consistency
- **Platform Services:** Leverages existing `PlatformServiceFactory.getInstance()` for platform detection
- **Error Handling:** Integrates with existing comprehensive `handleApiError` from `src/utils/errorHandler.ts` for consistent error processing
- **Logging:** Uses existing logger with established patterns for debugging and monitoring
- **Initialization:** Follows existing `main.capacitor.ts` initialization pattern with proper error handling
- **Vue Architecture:** Follows Vue 3 + vue-facing-decorator patterns for component integration
- **State Management:** Integrates with PlatformServiceMixin for notification state management
- **Identity System:** Integrates with `did:ethr:` (Ethereum-based DID) system for privacy-preserving user context
- **Testing:** Follows Playwright E2E testing patterns established in TimeSafari
- **Privacy Architecture:** Follows TimeSafari's privacy-preserving claims architecture
- **Community Focus:** Enhances TimeSafari's mission of connecting people through gratitude and gifts
- **Platform Detection:** Uses actual `process.env.VITE_PLATFORM` patterns (`web`, `capacitor`, `electron`)
- **Database Architecture:** Supports platform-specific backends:
- **Web**: Absurd SQL (SQLite via IndexedDB) via `WebPlatformService` with worker pattern
- **Capacitor**: Native SQLite via `CapacitorPlatformService`
- **Electron**: Native SQLite via `ElectronPlatformService` (extends CapacitorPlatformService)
---
## 15) Cross-Doc Sync Hygiene
### Canonical Ownership
- **Plan document**: Canonical for Goals, Tenets, Platform behaviors, Acceptance criteria, Test cases
- **This document (Implementation)**: Canonical for API definitions, Database schemas, Adapter implementations, Code examples
### Synchronization Requirements
- **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
- **Error codes**: Plan §11 taxonomy must match Implementation error handling
- **Slot/TTL/Lead policies**: Must be identical between Plan §4 policy and Implementation §3 policy
### 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 §7 logic
- [ ] **Template changes**: Update Plan §9 contract and Implementation §4 engine
- [ ] **Error codes**: Update Plan §11 taxonomy and Implementation §3 types
---
## 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 implementation guide provides complete, working code for integrating the notification system with TimeSafari's existing infrastructure. All code examples are production-ready and follow TimeSafari's established patterns. For strategic overview, see `notification-system-plan.md`.*