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.
 
 
 
 
 
 

81 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

Database Compatibility Layer:

The notification system leverages TimeSafari's existing database abstraction layer that provides SQLite compatibility across all platforms:

  • Web Platform: Uses @absurd-sql to provide SQLite-compatible API over IndexedDB
  • Mobile/Desktop: Uses native SQLite via Capacitor SQLite plugin
  • Unified Interface: Both backends implement the same SQLite API, ensuring consistent notification data storage

This compatibility layer allows the notification system to use identical SQLite schemas and queries across all platforms, with the underlying storage mechanism abstracted away by the platform services.

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'
};

Note: The notifEnabled boolean field must be added to PlatformServiceMixin._mapColumnsToValues for proper SQLite integer-to-boolean conversion:

// In _mapColumnsToValues method, add:
column === "notifEnabled" ||

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<void>;
  configure(o: ConfigureOptions): Promise<void>;
  runFullPipelineNow(): Promise<void>;              // API→DB→Schedule (today's remaining)
  deliverStoredNow(slotId?: SlotId): Promise<void>; // 60s cooldown guard
  reschedule(): Promise<void>;
  getState(): Promise<{
    nextOccurrences: Array<{ slotId: SlotId; when: string }>; // ISO
    lastFetchAt?: string; lastDeliveryAt?: string;
    pendingCount: number; exactAlarmCapable?: boolean;
  }>;
}

Storage semantics: 'shared' = app DB; 'private' = plugin-owned/native DB (v2). (No functional difference in v1.) Slot Identity & Scheduling PolicySlotId 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

// 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 type ScheduleRequest = {
  slotId: SlotId;
  whenMs: number;
  title: string;
  body: string;
  extra?: Record<string, unknown>; // { payloadRef, etag, windowLenMs, ... }
};

export interface SchedulerCapabilities {
  exactAlarms: boolean;
  maxPending?: number;
  networkWake?: 'none' | 'opportunistic'; // v1 mobile = 'none' or 'opportunistic'
}

export interface Scheduler {
  capabilities(): Promise<SchedulerCapabilities>;
  scheduleExact(req: ScheduleRequest): Promise<void>;
  scheduleWindow(req: ScheduleRequest & { windowLenMs: number }): Promise<void>;
  cancelBySlot(slotId: SlotId): Promise<void>;
  rescheduleAll(next: ScheduleRequest[]): 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, SchedulerCapabilities } from '../types';
import { Notification, app } from 'electron';
import { logger, safeStringify } from '@/utils/logger';

export class SchedulerElectron implements Scheduler {
  async capabilities(): Promise<SchedulerCapabilities> {
    // Electron timers + OS delivery while app runs; no exact-alarm guarantees.
    return { exactAlarms: false, maxPending: 128, networkWake: 'opportunistic' };
  }

  async scheduleExact(req: ScheduleRequest): Promise<void> {
    const delay = Math.max(0, req.whenMs - Date.now());
    setTimeout(() => {
      try {
        const n = new Notification({
          title: req.title,
          body: req.body,
          // 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', req.slotId);
      // Log with safe stringify for complex objects
      logger.debug('[SchedulerElectron] Notification details:', safeStringify({
        slotId: req.slotId,
        timestamp: new Date().toISOString(),
        platform: 'electron'
      }));
      } catch (e) {
        logger.error('[SchedulerElectron] show failed', e);
        throw e;
      }
    }, delay);
  }

  async scheduleWindow(req: ScheduleRequest & { windowLenMs: number }): Promise<void> {
    // v1 emulates "window" by scheduling at window start; OS may delay delivery.
    return this.scheduleExact(req);
  }

  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, SchedulerCapabilities } from '../types';
import { logger, safeStringify } from '@/utils/logger';

export class SchedulerCapacitor implements Scheduler {
  async capabilities(): Promise<SchedulerCapabilities> {
    // Conservative default; exact permission detection will be native in v2.
    return { exactAlarms: false, maxPending: 64, networkWake: 'none' };
  }

  async scheduleExact(req: ScheduleRequest): Promise<void> {
    try {
      await LocalNotifications.schedule({
        notifications: [{
          id: this.generateNotificationId(req.slotId, req.whenMs),
          title: req.title,
          body: req.body,
          schedule: { at: new Date(req.whenMs) },
          extra: { slotId: req.slotId, ...req.extra }
        }]
      });

      logger.debug('[SchedulerCapacitor] Scheduled notification for slot', req.slotId, 'at', new Date(req.whenMs).toISOString());
      // Log with safe stringify for complex objects
      logger.debug('[SchedulerCapacitor] Notification details:', safeStringify({
        slotId: req.slotId,
        whenMs: req.whenMs,
        scheduledAt: new Date(req.whenMs).toISOString(),
        platform: 'capacitor'
      }));
    } catch (error) {
      logger.error('[SchedulerCapacitor] Failed to schedule notification for slot', req.slotId, error);
      throw error;
    }
  }

  async scheduleWindow(req: ScheduleRequest & { windowLenMs: number }): 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(req.slotId, req.whenMs),
          title: req.title,
          body: req.body,
          schedule: {
            at: new Date(req.whenMs),
            repeats: false
          },
          extra: { slotId: req.slotId, windowLenMs: req.windowLenMs, ...req.extra } // Carry window length for telemetry
        }]
      });

      logger.debug('[SchedulerCapacitor] Scheduled windowed notification for slot', req.slotId, 'at window start');
    } catch (error) {
      logger.error('[SchedulerCapacitor] Failed to schedule windowed notification for slot', req.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 ?? 12000);

      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

      const ac = new AbortController();
      const to = setTimeout(() => ac.abort(), 8000); // 8s timeout for ACK

      try {
        await fetch(spec.url, {
          method,
          headers: spec.headers,
          body,
          signal: ac.signal
        });

        logger.debug('[CallbacksHttp] Acknowledged delivery for slot', slotId);
      } finally {
        clearTimeout(to);
      }
    } 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

      const ac = new AbortController();
      const to = setTimeout(() => ac.abort(), 8000); // 8s timeout for error reporting

      try {
        await fetch(spec.url, {
          method,
          headers: spec.headers,
          body,
          signal: ac.signal
        });

        logger.debug('[CallbacksHttp] Reported error for slot', slotId);
      } finally {
        clearTimeout(to);
      }
    } catch (reportError) {
      logger.error('[CallbacksHttp] Failed to report error for slot', slotId, reportError);
      // Don't throw - this is not critical
    }
  }
}

Future v2 Adapters (Not Implemented in v1)

// src/services/notifications/adapters/BackgroundPrefetch.ts
// v2: Native background network scheduling

export interface BackgroundPrefetch {
  schedulePrefetch(slotId: SlotId, atMs: number): Promise<void>; // T–lead
  cancelPrefetch(slotId: SlotId): Promise<void>;
}

// v2 Implementation Notes:
// - Android: Use WorkManager/AlarmManager to wake for network work at T–lead
// - iOS: Use BGTaskScheduler or silent push to wake for network work at T–lead
// - After network fetch, (re)arm local notifications with fresh content
// - This enables true "scheduled network events" when app is terminated
// - Not implemented in v1; v2 will bind to native OS primitives

BackgroundRunnerPrefetch Adapter (v1 Cache Warmer)

// src/services/notifications/adapters/BackgroundRunnerPrefetch.ts
// v1 cache-warmer using Capacitor Background Runner (opportunistic)
import { logger } from '@/utils/logger';
import { CallbacksHttp } from './CallbacksHttp';
import type { DataStore, SlotId } from '../types';

export class BackgroundRunnerPrefetch {
  constructor(
    private store: DataStore,
    private fetcher: CallbacksHttp,
    private getUpcomingSlots: () => SlotId[],
    private getNextSlotTime: (slotId: SlotId) => number,
    private leadMinutes: number,
    private ttlSeconds: number,
    private allowRearm: boolean // feature flag: runnerRearm
  ) {}

  // Entrypoint invoked by Background Runner
  async handleTick(): Promise<void> {
    const now = Date.now();
    const leadMs = this.leadMinutes * 60_000;

    for (const slot of this.getUpcomingSlots()) {
      const fireAt = this.getNextSlotTime(slot);
      if (fireAt - now > leadMs) continue; // only act inside lead

      const existing = await this.store.getLatestContent(slot);
      const etag = existing?.etag;

      try {
        const res = await this.fetcher.fetchContent(slot, etag);
        if (!res) continue; // 304

        // Save fresh payload
        await this.store.saveContent(slot, res.payload, res.etag);

        // Optional: cancel & re-arm if within TTL and allowed
        if (this.allowRearm) {
          const ageAtFire = fireAt - Date.now(); // newly fetched now
          if (ageAtFire <= this.ttlSeconds * 1000) {
            // Signal the orchestrator to re-arm on next foreground OR
            // (if environment allows) directly call orchestrator.scheduleSlot(slot, res.payload)
            // Keep this behind the flag to minimize risk on iOS.
          }
        }
      } catch (e) {
        logger.error('[BackgroundRunnerPrefetch] tick failed for', slot, e);
      }
    }
  }
}

Note: In v1, Runner only guarantees opportunistic JS. Keep re-arming behind runnerRearm (default false) to avoid iOS background surprises. The safe baseline is prefetch-only; scheduling parity stays with the foreground orchestrator.


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();

  // App-level pipeline debounce (30s per app session)
  private lastPipelineRunAt = 0;
  private readonly PIPELINE_DEBOUNCE_MS = 30000;

  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 {
      // App-level debounce to prevent burst fetches on resume
      const now = Date.now();
      if (now - this.lastPipelineRunAt < this.PIPELINE_DEBOUNCE_MS) {
        logger.debug('[NotificationOrchestrator] Pipeline debounced - too soon since last run');
        return;
      }
      this.lastPipelineRunAt = now;

      // 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 pre-warm fetch but schedule with best available payload
        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);
            // Schedule with fresh content even outside lead window
            await this.scheduleSlot(slotId, content.payload);
            logger.debug('[NotificationOrchestrator] Scheduled', slotId, 'with fresh content outside lead window');
            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);

    // Check TTL at fire time - don't schedule if content will be stale
    const storedContent = await this.store.getLatestContent(slotId);
    if (storedContent) {
      const ttlMs = (this.opts.ttlSeconds ?? 86400) * 1000;
      const projectedAgeAtFire = whenMs - storedContent.fetchedAt;
      if (projectedAgeAtFire > ttlMs) {
        await this.store.recordDelivery(slotId, whenMs, 'error', {
          code: 'FETCH_TOO_OLD',
          message: `Content will be ${Math.round(projectedAgeAtFire / 1000)}s old at fire time (TTL: ${this.opts.ttlSeconds ?? 86400}s)`
        });
        return;
      }
    }

    // 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;
    }

    // Route through Scheduler adapter to honor platform timing semantics
    const capabilities = await this.sched.capabilities();
    if (capabilities.exactAlarms) {
      await this.sched.scheduleExact({
        slotId,
        whenMs,
        title: rendered.title,
        body: rendered.body,
        extra: { payloadRef: `${slotId}:${whenMs}`, etag: storedContent?.etag }
      });
    } else {
      // Use windowed scheduling for Android when exact alarms unavailable
      const androidWindowLenMs = 600000; // ±10 min
      await this.sched.scheduleWindow({
        slotId,
        whenMs,
        title: rendered.title,
        body: rendered.body,
        windowLenMs: androidWindowLenMs,
        extra: { payloadRef: `${slotId}:${whenMs}`, etag: storedContent?.etag }
      });
    }

    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) Runtime Timing & Network Guards

Global Timing Constants (All Platforms)

Constant Default Purpose
prefetchLeadMinutes 20 min Earliest window we will attempt online-first before each slot
fetchTimeoutMs 12,000 ms Hard timeout for content fetch (AbortController)
ttlSeconds 86,400 s (24 h) Staleness cutoff; if offline and payload older than TTL → skip
cooldownDeliverNowMs 60,000 ms Per-slot guard for deliverStoredNow() to prevent double-fires
androidWindowLenMs 600,000 ms (±10 min) Window length when exact alarms are unavailable (Android)

Network Access Rules (By Platform)

iOS (Capacitor app, v1)

  • When code may run: Only while the app is foregrounded or recently backgrounded (no JS wake when app is killed).
  • Network in v1: We will fetch only during app activity (launch/resume/ settings interaction). No delivery-time network for locals.
  • Timeout: fetchTimeoutMs = 12s, single attempt inside lead.
  • Scheduling: Use non-repeating one-shots; maintain a rolling window under the pending cap (~64).
  • v2 note: Background silent push or BGTaskScheduler-based prefetch will be addressed in the plugin (not in v1).

Android (Capacitor app, v1)

  • When code may run: Same as iOS; no JS wake when app is killed.
  • Network in v1: Fetch during app activity only.
  • Exact alarms: If SCHEDULE_EXACT_ALARM is unavailable, we will schedule at window start with androidWindowLenMs = 10m; OS may delay within the window. Deep-link users to grant exact-alarm access.
  • Timeout: fetchTimeoutMs = 12s, single attempt inside lead.

Web (PWA)

  • When code may run: Service Worker on push events (browser may be closed) or a controlled page/tab.
  • Network in SW: Allowed during the push event; keep total work short and call event.waitUntil(...).
  • Timeout: fetchTimeoutMs = 12s in SW; one attempt.
  • Scheduling: No offline scheduling on web; rely on push payload content; local scheduling APIs are not reliable/standardized.

Electron (desktop app)

  • When code may run: Only while the app is running (tray or window).
  • Network: Allowed in main or renderer depending on where the orchestrator runs. If in main, require Node 18+ (global fetch) or polyfill (undici/register).
  • Timeout: fetchTimeoutMs = 12s, single attempt inside lead.
  • Scheduling: Timer-based while running; Start on Login recommended. No true background scheduling in v1.

Data Transfer and Request Profile (All Platforms)

  • Headers: Always send If-None-Match with last known ETag for the slot; accept 304 as "no change".
  • Payload size: We will keep JSON responses ≤ 16 KB (hard ceiling) and titles/bodies within platform limits (Title ≤ 50 chars, Body ≤ 200 chars pre-escape/truncation).
  • Methods: fetchContent may use GET or POST; ackDelivery/ reportError/heartbeat will use POST.
  • Retries: None inside the lead window; outside the lead, next opportunity is the next app resume/open (v1).
  • Offline detection: We will attempt fetch even if the OS says "offline" (to avoid false negatives), but the 12s timeout will bound the attempt and we will fall back per TTL policy.

Enforcement Implementation Notes

  1. Timeout wrapper (no AbortSignal.timeout) Use AbortController + setTimeout (12s) around all outbound fetches (already present for content; apply to ACK/error if needed).

  2. ETag propagation

    • Read latest etag via DataStore.getLatestContent(slotId); set If-None-Match on fetchContent.
    • On 304, do not reschedule content; leave the last payload intact and only ensure the slot is armed.
  3. Lead window single attempt

    • Maintain lastAttemptAt: Map<SlotId, number>; if inside [slotTime - lead, slotTime] and an attempt exists, skip repeat attempts.
    • Add a one-liner guard at the start of any manual "fetch now/test" entry point to respect the same policy.
  4. Cooldown for deliverStoredNow

    • Maintain lastDeliveredNowAt: Map<SlotId, number>; early-return if < 60s since last call for the slot.
  5. Permission & bulk reschedule guard

    • Before rescheduleAll, check LocalNotifications.areEnabled(). If disabled, record SCHEDULE_DENIED and do not queue notifications.
  6. Android window behavior

    • In scheduleWindow(...), store { windowLenMs: 600000 } in extra for telemetry; schedule at window start and rely on OS batching.
  7. Electron fetch prerequisite

    • If orchestrator runs in main, ensure Node 18+ or add at app start:

      // main only, if Node < 18
      import 'undici/register';
      
    • Set Windows AppUserModelID once:

      import { app } from 'electron';
      if (process.platform === 'win32') app.setAppUserModelId('com.timesafari.app');
      
  8. Truncation order

    • Truncate template before escaping; then pass title/body to schedulers (Capacitor/Electron).

10) 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);

// Background Runner Integration (optional, v1 cache warmer)
setTimeout(async () => {
  try {
    const conf = await store.getConfig?.('prefetchRunner');
    if (conf === 'background-runner') {
      const { BackgroundRunner } = await import('@capacitor/background-runner');
      // Register a periodic handler (OS will coalesce; ≥ ~15m typical)
      await BackgroundRunner.register({
        // name/id of the task, platform-specific options if required
      });

      // Provide a global/static tick handler called by the runner
      BackgroundRunner.addListener('tick', async () => {
        await bgPrefetch.handleTick();
      });
    }
  } catch (e) {
    logger.warn('[BackgroundRunner] unavailable, continuing without runner', e);
  }
}, 3000);

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
}

// Start on Login (recommended for v1)
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();
}

11) 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.
// Mobile locals bypass the SW.

// 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);
    });
}

12) 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
  };
}

13) 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;
  });
});

14) 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
      }
    };
  }
}

15) Feature Flags & Config

Feature Flags Implementation

// src/services/notifications/FeatureFlags.ts
export interface NotificationFeatureFlags {
  scheduler: 'capacitor' | 'electron';
  mode: 'auto' | 'online-first' | 'offline-first';
  prefetchLeadMinutes: number;
  ttlSeconds: number;
  iosCategoryIdentifier: string;
  androidChannelId: string;
  prefetchRunner: 'none' | 'background-runner';
  runnerRearm: boolean;
}

export class NotificationFeatureFlags {
  private static defaults: NotificationFeatureFlags = {
    scheduler: 'capacitor',
    mode: 'auto',
    prefetchLeadMinutes: 20,
    ttlSeconds: 86400,
    iosCategoryIdentifier: 'TS_DAILY',
    androidChannelId: 'timesafari.daily',
    prefetchRunner: 'none',
    runnerRearm: false
  };

  static async getFlags(store: DataStore): Promise<NotificationFeatureFlags> {
    const flags = { ...this.defaults };
    
    for (const [key, defaultValue] of Object.entries(this.defaults)) {
      const value = await store.getConfig?.(key);
      if (value !== null && value !== undefined) {
        (flags as any)[key] = value;
      }
    }
    
    return flags;
  }

  static async setFlag(store: DataStore, key: keyof NotificationFeatureFlags, value: any): Promise<void> {
    await store.setConfig?.(key, value);
  }
}

Configuration Storage

Feature flags are stored in the notif_config table as key-value pairs, separate from user settings. This allows for runtime configuration changes without affecting user preferences.


16) 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


---

## 17) 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)

---

## 19) TimeSafari Architecture Compliance

### Design Pattern Adherence

- **Factory Pattern:** Notification service follows `PlatformServiceFactory` singleton pattern
- **Mixin Pattern:** Database access uses existing `PlatformServiceMixin` pattern
- **Migration Pattern:** Database changes follow existing `MIGRATIONS` array pattern
- **Error Handling:** Uses existing `handleApiError` from `src/services/api.ts`
- **Logging:** Uses existing logger from `src/utils/logger` with established patterns
- **Platform Detection:** Uses existing `Capacitor.isNativePlatform()` and `VITE_PLATFORM` patterns

### File Organization Compliance

- **Services:** Follows existing `src/services/` organization with factory and adapters
- **Database:** Extends existing `src/db-sql/migration.ts` and `src/db/tables/settings.ts`
- **Utils:** Extends existing `src/utils/PlatformServiceMixin.ts`
- **Main Entry:** Integrates with existing `src/main.capacitor.ts` initialization
- **Service Workers:** Follows existing `sw_scripts/` organization

### Type Safety Compliance

- **Settings Extension:** Follows existing Settings type extension pattern
- **Interface Definitions:** Uses existing TypeScript interface patterns
- **Error Types:** Follows existing error handling type patterns
- **Platform Types:** Uses existing platform detection type patterns

---

## 20) 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 §13 table and Implementation §15 defaults
- **Test cases**: Plan §14 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 §7 policy and Implementation §9 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`     | ✅     |
| Runner described as **opportunistic prefetch**, not scheduler | §7       | §9                       | ✅     |
| Feature flag `prefetchRunner` (default `'none'`)              | §13      | §15                      | ✅     |
| Capabilities `networkWake: 'opportunistic' | 'none'` | §7                       | Scheduler.capabilities | ✅     |
| Runner tick handler bounded to ≤12s                           | §7       | BackgroundRunnerPrefetch | ✅     |
| Optional `runnerRearm` flag & behavior                        | §7       | Orchestrator + Runner    | ✅     |

---

*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 executive overview, see `notification-system-executive-summary.md`. For strategic overview, see `notification-system-plan.md`.*