You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

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) via WebPlatformService with worker pattern - migration runs in Absurd SQL context
  • Capacitor (VITE_PLATFORM=capacitor): Uses native SQLite via CapacitorPlatformService - migration runs in native SQLite context
  • Electron (VITE_PLATFORM=electron): Uses native SQLite via ElectronPlatformService (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, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;');
  }
}

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 from NotificationTime and use it everywhere (replace any slot-xx-yy pattern)
  • Cooldown: deliverStoredNow() will ignore requests if invoked for the same slotId 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`.*