// ────────────────────────────────────────────────────────────────────────── // mockData.ts // // Frontend-only fixtures for the Extreme Heat Insurance dashboard. The // Vercel serverless endpoints under `api/*` hit a Neon Postgres database // in production, but `npm run dev` doesn't run those handlers, so every // page would otherwise be stuck on loading spinners. // // Instead of standing up a mock server, `src/lib/api.ts` reads directly // from the exports below. Shapes are matched field-for-field to the real // API response contracts so the UI needs zero adaptation. // // Context: Dar es Salaam, Tanzania. 15 monitored settlements. Parametric // WBGT trigger at the city-wide P97 threshold (38.8 °C). M-Pesa / Tigo // Pesa / Airtel Money disbursements. // ────────────────────────────────────────────────────────────────────────── import type { ZonesResponse, Zone, IndicesResponse, IndexData, DailyHeat, TriggersResponse, Trigger, BasisRiskResponse, BasisRisk, NotificationsResponse, Notification, PipelineRunsResponse, PipelineRun, PipelineStep, PipelineStats, EnrolledResponse, CoverageResponse, CoverageZone, CalibrateResponse, CalibrateZoneResult, CalibrateParams, } from './api' import type { DisbursementsResponse, DisbursementRecord } from '../types' // ── Time helpers ────────────────────────────────────────────────────────── // Deterministic "now" so repeated renders don't drift. The date is locked // to mid-April 2026, which is late rainy-season in Dar — hot, humid, and // inside the parametric product's peak coverage window. const NOW = new Date('2026-04-13T08:42:00Z') function isoDaysAgo(days: number, hour = 14, minute = 18): string { const d = new Date(NOW.getTime() - days * 86_400_000) d.setUTCHours(hour, minute, 0, 0) return d.toISOString() } function dateOnly(days: number): string { const d = new Date(NOW.getTime() - days * 86_400_000) return d.toISOString().slice(0, 10) } // Tiny seeded PRNG so daily-history values look natural but stay stable // between hot reloads. Mulberry32. function rng(seed: number) { let t = seed return () => { t = (t + 0x6d2b79f5) | 0 let r = Math.imul(t ^ (t >>> 15), 1 | t) r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r return ((r ^ (r >>> 14)) >>> 0) / 4294967296 } } // ── Zone seed data ──────────────────────────────────────────────────────── // Real Dar es Salaam neighborhoods. Centroids are hand-checked against // OpenStreetMap; they don't need to be pixel-precise — they exist so the // frontend has values to render. Worker populations are drawn from ILO // informal-sector ratios and the Tanzania NBS 2024 census. interface ZoneSeed { zone_id: string name: string settlement_type: 'informal' | 'mixed' | 'formal' heat_vulnerability: 'low' | 'medium' | 'high' | 'severe' latitude: number longitude: number elevation_m: number worker_population_est: number outdoor_exposure_pct: number // Current conditions corrected_temp_c: number current_wbgt_c: number grid_temp_c: number uhi_delta_c: number // Forecast trigger_probability_7d: number model_tier: 'ensemble' | 'full_model' | 'persistence' | 'climatology' consecutive_hot_days: number // Historical annual_trigger_days: number } const ZONE_SEEDS: ZoneSeed[] = [ // ── Informal settlements — hottest, highest worker counts { zone_id: 'DAR-MAB', name: 'Mabibo', settlement_type: 'informal', heat_vulnerability: 'severe', latitude: -6.798, longitude: 39.232, elevation_m: 28, worker_population_est: 11_950, outdoor_exposure_pct: 0.78, corrected_temp_c: 37.4, current_wbgt_c: 39.2, grid_temp_c: 34.9, uhi_delta_c: 2.5, trigger_probability_7d: 0.92, model_tier: 'ensemble', consecutive_hot_days: 6, annual_trigger_days: 74, }, { zone_id: 'DAR-MNZ', name: 'Manzese', settlement_type: 'informal', heat_vulnerability: 'severe', latitude: -6.79, longitude: 39.245, elevation_m: 34, worker_population_est: 14_820, outdoor_exposure_pct: 0.74, corrected_temp_c: 37.1, current_wbgt_c: 39.0, grid_temp_c: 34.7, uhi_delta_c: 2.4, trigger_probability_7d: 0.88, model_tier: 'ensemble', consecutive_hot_days: 5, annual_trigger_days: 71, }, { zone_id: 'DAR-TAN', name: 'Tandale', settlement_type: 'informal', heat_vulnerability: 'severe', latitude: -6.785, longitude: 39.255, elevation_m: 32, worker_population_est: 12_640, outdoor_exposure_pct: 0.76, corrected_temp_c: 37.2, current_wbgt_c: 38.9, grid_temp_c: 34.6, uhi_delta_c: 2.6, trigger_probability_7d: 0.85, model_tier: 'ensemble', consecutive_hot_days: 5, annual_trigger_days: 68, }, { zone_id: 'DAR-BUG', name: 'Buguruni', settlement_type: 'informal', heat_vulnerability: 'high', latitude: -6.82, longitude: 39.26, elevation_m: 21, worker_population_est: 8_720, outdoor_exposure_pct: 0.71, corrected_temp_c: 36.8, current_wbgt_c: 38.9, grid_temp_c: 34.3, uhi_delta_c: 2.5, trigger_probability_7d: 0.81, model_tier: 'full_model', consecutive_hot_days: 4, annual_trigger_days: 62, }, { zone_id: 'DAR-VIN', name: 'Vingunguti', settlement_type: 'informal', heat_vulnerability: 'high', latitude: -6.835, longitude: 39.25, elevation_m: 24, worker_population_est: 9_840, outdoor_exposure_pct: 0.73, corrected_temp_c: 36.9, current_wbgt_c: 38.6, grid_temp_c: 34.4, uhi_delta_c: 2.5, trigger_probability_7d: 0.77, model_tier: 'full_model', consecutive_hot_days: 4, annual_trigger_days: 59, }, { zone_id: 'DAR-JAN', name: 'Jangwani', settlement_type: 'informal', heat_vulnerability: 'severe', latitude: -6.8, longitude: 39.27, elevation_m: 6, worker_population_est: 7_310, outdoor_exposure_pct: 0.79, corrected_temp_c: 37.0, current_wbgt_c: 38.5, grid_temp_c: 34.1, uhi_delta_c: 2.9, trigger_probability_7d: 0.74, model_tier: 'ensemble', consecutive_hot_days: 3, annual_trigger_days: 83, }, { zone_id: 'DAR-MBU', name: 'Mburahati', settlement_type: 'informal', heat_vulnerability: 'high', latitude: -6.805, longitude: 39.25, elevation_m: 29, worker_population_est: 6_520, outdoor_exposure_pct: 0.7, corrected_temp_c: 36.6, current_wbgt_c: 38.2, grid_temp_c: 34.1, uhi_delta_c: 2.5, trigger_probability_7d: 0.68, model_tier: 'full_model', consecutive_hot_days: 3, annual_trigger_days: 56, }, // ── Mixed settlements { zone_id: 'DAR-KIG', name: 'Kigogo', settlement_type: 'mixed', heat_vulnerability: 'medium', latitude: -6.79, longitude: 39.265, elevation_m: 26, worker_population_est: 8_140, outdoor_exposure_pct: 0.58, corrected_temp_c: 35.9, current_wbgt_c: 37.9, grid_temp_c: 34.0, uhi_delta_c: 1.9, trigger_probability_7d: 0.54, model_tier: 'full_model', consecutive_hot_days: 2, annual_trigger_days: 42, }, { zone_id: 'DAR-MAG', name: 'Magomeni', settlement_type: 'mixed', heat_vulnerability: 'medium', latitude: -6.8, longitude: 39.26, elevation_m: 18, worker_population_est: 6_980, outdoor_exposure_pct: 0.55, corrected_temp_c: 35.6, current_wbgt_c: 37.6, grid_temp_c: 33.8, uhi_delta_c: 1.8, trigger_probability_7d: 0.47, model_tier: 'full_model', consecutive_hot_days: 2, annual_trigger_days: 38, }, { zone_id: 'DAR-MWA', name: 'Mwananyamala', settlement_type: 'mixed', heat_vulnerability: 'medium', latitude: -6.778, longitude: 39.255, elevation_m: 31, worker_population_est: 5_860, outdoor_exposure_pct: 0.53, corrected_temp_c: 35.4, current_wbgt_c: 37.3, grid_temp_c: 33.7, uhi_delta_c: 1.7, trigger_probability_7d: 0.41, model_tier: 'persistence', consecutive_hot_days: 1, annual_trigger_days: 34, }, { zone_id: 'DAR-MSA', name: 'Msasani', settlement_type: 'mixed', heat_vulnerability: 'medium', latitude: -6.76, longitude: 39.265, elevation_m: 14, worker_population_est: 4_910, outdoor_exposure_pct: 0.49, corrected_temp_c: 34.9, current_wbgt_c: 36.8, grid_temp_c: 33.4, uhi_delta_c: 1.5, trigger_probability_7d: 0.35, model_tier: 'persistence', consecutive_hot_days: 1, annual_trigger_days: 27, }, // ── Formal settlements — cooler, better infrastructure { zone_id: 'DAR-KIN', name: 'Kinondoni', settlement_type: 'formal', heat_vulnerability: 'low', latitude: -6.77, longitude: 39.24, elevation_m: 42, worker_population_est: 4_280, outdoor_exposure_pct: 0.34, corrected_temp_c: 33.8, current_wbgt_c: 35.9, grid_temp_c: 33.1, uhi_delta_c: 0.7, trigger_probability_7d: 0.18, model_tier: 'persistence', consecutive_hot_days: 0, annual_trigger_days: 12, }, { zone_id: 'DAR-SIN', name: 'Sinza', settlement_type: 'formal', heat_vulnerability: 'low', latitude: -6.775, longitude: 39.225, elevation_m: 48, worker_population_est: 3_760, outdoor_exposure_pct: 0.31, corrected_temp_c: 33.5, current_wbgt_c: 35.6, grid_temp_c: 32.9, uhi_delta_c: 0.6, trigger_probability_7d: 0.14, model_tier: 'climatology', consecutive_hot_days: 0, annual_trigger_days: 9, }, { zone_id: 'DAR-KAW', name: 'Kawe', settlement_type: 'formal', heat_vulnerability: 'low', latitude: -6.755, longitude: 39.235, elevation_m: 12, worker_population_est: 3_240, outdoor_exposure_pct: 0.29, corrected_temp_c: 33.1, current_wbgt_c: 35.2, grid_temp_c: 32.6, uhi_delta_c: 0.5, trigger_probability_7d: 0.11, model_tier: 'climatology', consecutive_hot_days: 0, annual_trigger_days: 6, }, { zone_id: 'DAR-MIK', name: 'Mikocheni', settlement_type: 'formal', heat_vulnerability: 'low', latitude: -6.765, longitude: 39.27, elevation_m: 8, worker_population_est: 2_178, outdoor_exposure_pct: 0.26, corrected_temp_c: 32.7, current_wbgt_c: 34.8, grid_temp_c: 32.3, uhi_delta_c: 0.4, trigger_probability_7d: 0.08, model_tier: 'climatology', consecutive_hot_days: 0, annual_trigger_days: 4, }, ] function riskLevelFor(wbgt: number): string { if (wbgt > 38.8) return 'critical' if (wbgt > 37.5) return 'high' if (wbgt > 36) return 'moderate' return 'low' } // ── Build canonical Zone records ────────────────────────────────────────── const TOTAL_WORKERS = ZONE_SEEDS.reduce((s, z) => s + z.worker_population_est, 0) const ZONES: Zone[] = ZONE_SEEDS.map((z) => ({ zone_id: z.zone_id, name: z.name, city: 'Dar es Salaam', country: 'Tanzania', latitude: z.latitude, longitude: z.longitude, elevation_m: z.elevation_m, settlement_type: z.settlement_type, worker_population_est: z.worker_population_est, outdoor_exposure_pct: z.outdoor_exposure_pct, heat_vulnerability: z.heat_vulnerability, risk_level: riskLevelFor(z.current_wbgt_c), corrected_temp_c: z.corrected_temp_c, current_wbgt_c: z.current_wbgt_c, current_heat_index_c: z.corrected_temp_c + 2.1, max_temp_c: z.corrected_temp_c + 1.4, max_wbgt_c: z.current_wbgt_c + 0.8, consecutive_hot_days: z.consecutive_hot_days, trigger_probability_7d: z.trigger_probability_7d, prediction_confidence: 0.72 + (z.model_tier === 'ensemble' ? 0.18 : z.model_tier === 'full_model' ? 0.12 : 0.04), model_tier: z.model_tier, })) export const ZONES_RESPONSE: ZonesResponse = { zones: ZONES, total: ZONES.length, cities: ['Dar es Salaam'], } // ── Daily heat indices (90 days per zone) ───────────────────────────────── function buildDailyHistory(seed: ZoneSeed): DailyHeat[] { const rand = rng(seed.zone_id.split('').reduce((a, c) => a + c.charCodeAt(0), 0)) const baseTemp = seed.corrected_temp_c - 1.2 const baseWbgt = seed.current_wbgt_c - 1.0 const out: DailyHeat[] = [] for (let i = 89; i >= 0; i--) { // Light seasonality — April is peak, earlier days are cooler const seasonal = Math.sin(((89 - i) / 89) * Math.PI * 0.9) * 1.4 const noise = (rand() - 0.5) * 1.4 const temp = baseTemp + seasonal + noise const wbgt = baseWbgt + seasonal * 0.9 + noise * 0.7 const gridTemp = temp - seed.uhi_delta_c - (rand() - 0.5) * 0.3 out.push({ date: dateOnly(i), temp_c: Math.round(temp * 10) / 10, grid_temp_c: Math.round(gridTemp * 10) / 10, wbgt_c: Math.round(wbgt * 10) / 10, }) } return out } const INDICES: IndexData[] = ZONE_SEEDS.map((z) => ({ zone_id: z.zone_id, zone_name: z.name, city: 'Dar es Salaam', temp_current: z.corrected_temp_c, wbgt_current: z.current_wbgt_c, risk_level: riskLevelFor(z.current_wbgt_c), daily_history: buildDailyHistory(z), })) export const INDICES_RESPONSE: IndicesResponse = { indices: INDICES, total: INDICES.length, } // ── Triggers (recent trigger events) ───────────────────────────────────── // 28 events over the last ~120 days, concentrated in informal zones. interface TriggerSeed { zone_id: string daysAgo: number level: 'critical' | 'high' | 'moderate' max_temp_c: number max_wbgt_c: number consecutive_days: number payout_per_worker_usd: number } const TRIGGER_SEEDS: TriggerSeed[] = [ { zone_id: 'DAR-MAB', daysAgo: 0, level: 'critical', max_temp_c: 38.1, max_wbgt_c: 39.6, consecutive_days: 6, payout_per_worker_usd: 12 }, { zone_id: 'DAR-MNZ', daysAgo: 0, level: 'critical', max_temp_c: 37.8, max_wbgt_c: 39.4, consecutive_days: 5, payout_per_worker_usd: 12 }, { zone_id: 'DAR-TAN', daysAgo: 1, level: 'critical', max_temp_c: 37.6, max_wbgt_c: 39.2, consecutive_days: 5, payout_per_worker_usd: 12 }, { zone_id: 'DAR-BUG', daysAgo: 1, level: 'high', max_temp_c: 37.3, max_wbgt_c: 39.0, consecutive_days: 4, payout_per_worker_usd: 12 }, { zone_id: 'DAR-VIN', daysAgo: 2, level: 'high', max_temp_c: 37.4, max_wbgt_c: 38.8, consecutive_days: 4, payout_per_worker_usd: 12 }, { zone_id: 'DAR-JAN', daysAgo: 3, level: 'critical', max_temp_c: 37.5, max_wbgt_c: 38.7, consecutive_days: 3, payout_per_worker_usd: 12 }, { zone_id: 'DAR-MBU', daysAgo: 4, level: 'high', max_temp_c: 36.9, max_wbgt_c: 38.4, consecutive_days: 3, payout_per_worker_usd: 12 }, { zone_id: 'DAR-MAB', daysAgo: 11, level: 'critical', max_temp_c: 37.9, max_wbgt_c: 39.1, consecutive_days: 4, payout_per_worker_usd: 12 }, { zone_id: 'DAR-MNZ', daysAgo: 12, level: 'critical', max_temp_c: 37.6, max_wbgt_c: 38.9, consecutive_days: 3, payout_per_worker_usd: 12 }, { zone_id: 'DAR-BUG', daysAgo: 18, level: 'high', max_temp_c: 36.8, max_wbgt_c: 38.7, consecutive_days: 3, payout_per_worker_usd: 12 }, { zone_id: 'DAR-KIG', daysAgo: 22, level: 'moderate', max_temp_c: 36.2, max_wbgt_c: 38.2, consecutive_days: 2, payout_per_worker_usd: 8 }, { zone_id: 'DAR-TAN', daysAgo: 27, level: 'critical', max_temp_c: 37.7, max_wbgt_c: 39.0, consecutive_days: 4, payout_per_worker_usd: 12 }, { zone_id: 'DAR-MAG', daysAgo: 31, level: 'moderate', max_temp_c: 35.9, max_wbgt_c: 37.9, consecutive_days: 2, payout_per_worker_usd: 8 }, { zone_id: 'DAR-VIN', daysAgo: 38, level: 'high', max_temp_c: 37.1, max_wbgt_c: 38.6, consecutive_days: 3, payout_per_worker_usd: 12 }, { zone_id: 'DAR-MAB', daysAgo: 44, level: 'critical', max_temp_c: 37.8, max_wbgt_c: 39.2, consecutive_days: 4, payout_per_worker_usd: 12 }, { zone_id: 'DAR-MWA', daysAgo: 47, level: 'moderate', max_temp_c: 35.6, max_wbgt_c: 37.7, consecutive_days: 2, payout_per_worker_usd: 8 }, { zone_id: 'DAR-MNZ', daysAgo: 52, level: 'critical', max_temp_c: 37.4, max_wbgt_c: 39.0, consecutive_days: 3, payout_per_worker_usd: 12 }, { zone_id: 'DAR-BUG', daysAgo: 58, level: 'high', max_temp_c: 36.9, max_wbgt_c: 38.8, consecutive_days: 3, payout_per_worker_usd: 12 }, { zone_id: 'DAR-JAN', daysAgo: 64, level: 'critical', max_temp_c: 37.2, max_wbgt_c: 38.9, consecutive_days: 3, payout_per_worker_usd: 12 }, { zone_id: 'DAR-MBU', daysAgo: 71, level: 'high', max_temp_c: 36.7, max_wbgt_c: 38.5, consecutive_days: 2, payout_per_worker_usd: 12 }, { zone_id: 'DAR-MAB', daysAgo: 78, level: 'critical', max_temp_c: 37.6, max_wbgt_c: 39.1, consecutive_days: 3, payout_per_worker_usd: 12 }, { zone_id: 'DAR-TAN', daysAgo: 84, level: 'high', max_temp_c: 36.9, max_wbgt_c: 38.7, consecutive_days: 2, payout_per_worker_usd: 12 }, { zone_id: 'DAR-MSA', daysAgo: 89, level: 'moderate', max_temp_c: 35.2, max_wbgt_c: 37.4, consecutive_days: 2, payout_per_worker_usd: 8 }, { zone_id: 'DAR-MNZ', daysAgo: 96, level: 'critical', max_temp_c: 37.5, max_wbgt_c: 39.0, consecutive_days: 4, payout_per_worker_usd: 12 }, { zone_id: 'DAR-VIN', daysAgo: 102, level: 'high', max_temp_c: 37.0, max_wbgt_c: 38.6, consecutive_days: 3, payout_per_worker_usd: 12 }, { zone_id: 'DAR-MAB', daysAgo: 112, level: 'critical', max_temp_c: 37.7, max_wbgt_c: 39.2, consecutive_days: 4, payout_per_worker_usd: 12 }, { zone_id: 'DAR-BUG', daysAgo: 118, level: 'high', max_temp_c: 36.8, max_wbgt_c: 38.7, consecutive_days: 3, payout_per_worker_usd: 12 }, { zone_id: 'DAR-JAN', daysAgo: 126, level: 'critical', max_temp_c: 37.3, max_wbgt_c: 38.9, consecutive_days: 3, payout_per_worker_usd: 12 }, ] const ZONE_BY_ID: Record = Object.fromEntries( ZONE_SEEDS.map((z) => [z.zone_id, z]) ) // Trigger events are sized to match the `trigger_events` table the real // backend reads in `api/triggers.ts`. We don't materialise `enrolled_workers` // or `total_payout_usd` here — the backend query doesn't return them, so // keeping them out of the mock ensures the UI never grows a silent reliance. const TRIGGERS: Trigger[] = TRIGGER_SEEDS.map((t) => { const z = ZONE_BY_ID[t.zone_id] return { zone_id: t.zone_id, zone_name: z.name, city: 'Dar es Salaam', trigger_level: t.level, triggered_at: isoDaysAgo(t.daysAgo, 9, 0), heat_risk_score: Math.round((t.max_wbgt_c - 32) * 18), max_temp_c: t.max_temp_c, max_wbgt_c: t.max_wbgt_c, consecutive_days: t.consecutive_days, settlement_type: z.settlement_type, payout_per_worker_usd: t.payout_per_worker_usd, } }) export const TRIGGERS_RESPONSE: TriggersResponse = { triggers: TRIGGERS, total: TRIGGERS.length, } // Total worker-payouts this season = sum of enrolled × events. const PAYOUTS_THIS_SEASON = TRIGGER_SEEDS.reduce( (s, t) => s + Math.round(ZONE_BY_ID[t.zone_id].worker_population_est * 0.35), 0 ) // ── Basis-risk assessments ──────────────────────────────────────────────── // Shape matches `api/basis-risk.ts`: rows selected from the `basis_risk` // table, joined to `zones` for name/city/settlement context. const BASIS_RISK: BasisRisk[] = ZONE_SEEDS.map((z) => { // Informal zones are more vulnerable, so they have slightly higher // false-negative rates (more heat-days the trigger misses) but better // correlation overall because the signal is stronger. const fpr = z.settlement_type === 'formal' ? 0.12 : z.settlement_type === 'mixed' ? 0.09 : 0.07 const fnr = z.settlement_type === 'informal' ? 0.11 : z.settlement_type === 'mixed' ? 0.08 : 0.05 const correlation = z.settlement_type === 'informal' ? 0.78 : z.settlement_type === 'mixed' ? 0.71 : 0.64 const mae = Math.round((1 - correlation) * 100) / 100 return { zone_id: z.zone_id, zone_name: z.name, city: 'Dar es Salaam', settlement_type: z.settlement_type, false_positive_rate: fpr, false_negative_rate: fnr, correlation, mae, accuracy_by_tier: { persistence: 0.62, full_model: 0.74, ensemble: 0.82, }, recommendations: null, } }) export const BASIS_RISK_RESPONSE: BasisRiskResponse = { basis_risk: BASIS_RISK, total: BASIS_RISK.length, } // ── Notifications (worker-facing alerts dispatched by the pipeline) ─────── // Shape matches `api/notifications.ts`: rows from the `notifications` // table joined to `zones` and `trigger_events`. `recipient_count` is // hardcoded to 1 server-side; we mirror realistic fan-out here. const NOTIFICATIONS: Notification[] = [ { id: 'msg_3201', zone_id: 'DAR-MAB', zone_name: 'Mabibo', city: 'Dar es Salaam', trigger_level: 'critical', channel: 'SMS', language: 'sw', recipient_count: 4182, status: 'delivered', message_preview: 'Tahadhari: joto kali Mabibo. Payout TSh 30,000 imetumwa kwa M-Pesa.', delivered_at: isoDaysAgo(0, 9, 12) }, { id: 'msg_3202', zone_id: 'DAR-MNZ', zone_name: 'Manzese', city: 'Dar es Salaam', trigger_level: 'critical', channel: 'SMS', language: 'sw', recipient_count: 5187, status: 'delivered', message_preview: 'Tahadhari: joto kali Manzese. Payout TSh 30,000 imetumwa kwa M-Pesa.', delivered_at: isoDaysAgo(0, 9, 14) }, { id: 'msg_3203', zone_id: 'DAR-TAN', zone_name: 'Tandale', city: 'Dar es Salaam', trigger_level: 'critical', channel: 'SMS', language: 'sw', recipient_count: 4424, status: 'delivered', message_preview: 'Tahadhari: joto kali Tandale. Payout TSh 30,000 imetumwa kwa M-Pesa.', delivered_at: isoDaysAgo(1, 9, 10) }, { id: 'msg_3204', zone_id: 'DAR-BUG', zone_name: 'Buguruni', city: 'Dar es Salaam', trigger_level: 'high', channel: 'SMS', language: 'sw', recipient_count: 3052, status: 'delivered', message_preview: 'Onyo: joto juu Buguruni. Fuata kanuni za usalama. Payout TSh 30,000.', delivered_at: isoDaysAgo(1, 9, 15) }, { id: 'msg_3205', zone_id: 'DAR-VIN', zone_name: 'Vingunguti', city: 'Dar es Salaam', trigger_level: 'high', channel: 'SMS', language: 'sw', recipient_count: 3444, status: 'delivered', message_preview: 'Onyo: joto juu Vingunguti. Payout TSh 30,000 imetumwa kwa M-Pesa.', delivered_at: isoDaysAgo(2, 9, 8) }, { id: 'msg_3206', zone_id: 'DAR-JAN', zone_name: 'Jangwani', city: 'Dar es Salaam', trigger_level: 'critical', channel: 'SMS', language: 'sw', recipient_count: 2559, status: 'delivered', message_preview: 'Tahadhari: joto kali Jangwani. Payout TSh 30,000 imetumwa kwa M-Pesa.', delivered_at: isoDaysAgo(3, 9, 22) }, { id: 'msg_3207', zone_id: 'DAR-MBU', zone_name: 'Mburahati', city: 'Dar es Salaam', trigger_level: 'high', channel: 'SMS', language: 'sw', recipient_count: 2282, status: 'delivered', message_preview: 'Onyo: joto juu Mburahati. Pigeni simu 199 kwa usaidizi.', delivered_at: isoDaysAgo(4, 9, 18) }, ] const NOTIFICATIONS_BY_LANGUAGE: Record = NOTIFICATIONS.reduce( (acc, n) => { acc[n.language] = (acc[n.language] ?? 0) + 1 return acc }, {} as Record ) export const NOTIFICATIONS_RESPONSE: NotificationsResponse = { notifications: NOTIFICATIONS, by_language: NOTIFICATIONS_BY_LANGUAGE, } // ── Disbursements ───────────────────────────────────────────────────────── // Shape matches `src/types.ts::DisbursementsResponse`. One row per zone, // describing the current-week payout posture. Zones above the city-wide // P97 WBGT (38.8°C) are flagged `triggers_payout`. const CITY_P97_WBGT = 38.8 const TZS_PER_USD = 2_500 const PROVIDER_CYCLE = ['M-Pesa', 'Tigo Pesa', 'Airtel Money'] as const const DISBURSEMENT_RECORDS: DisbursementRecord[] = ZONE_SEEDS.map((z, idx) => { const triggers = z.current_wbgt_c > CITY_P97_WBGT const enrolled = Math.round(z.worker_population_est * 0.35) // Payouts are higher in informal zones where workers face the worst // conditions and have the least fallback income. const payoutUsd = z.settlement_type === 'informal' ? 12 : z.settlement_type === 'mixed' ? 8 : 6 const payoutTzs = payoutUsd * TZS_PER_USD // Historical disbursements: more events for hotter zones, but not round. const pastEvents = Math.round(z.annual_trigger_days / 8) const pastTotalUsd = pastEvents * enrolled * payoutUsd * 0.92 return { zone_id: z.zone_id, zone_name: z.name, city: 'Dar es Salaam', settlement_type: z.settlement_type, heat_vulnerability: z.heat_vulnerability, current_wbgt: z.current_wbgt_c, threshold: CITY_P97_WBGT, triggers_payout: triggers, workers: enrolled, payout_per_worker_usd: payoutUsd, payout_per_worker_tzs: payoutTzs, zone_total_usd: triggers ? enrolled * payoutUsd : 0, zone_total_tzs: triggers ? enrolled * payoutTzs : 0, provider: PROVIDER_CYCLE[idx % PROVIDER_CYCLE.length], status: triggers ? 'pending' : 'no_trigger', past_disbursements: pastEvents, past_total_usd: Math.round(pastTotalUsd), } }) const TRIGGERING_NOW = DISBURSEMENT_RECORDS.filter((d) => d.triggers_payout) export const DISBURSEMENTS_RESPONSE: DisbursementsResponse = { disbursements: DISBURSEMENT_RECORDS, stats: { total_usd: TRIGGERING_NOW.reduce((s, d) => s + d.zone_total_usd, 0), total_tzs: TRIGGERING_NOW.reduce((s, d) => s + d.zone_total_tzs, 0), total_workers_paid: TRIGGERING_NOW.reduce((s, d) => s + d.workers, 0), zones_paying: TRIGGERING_NOW.length, zones_total: DISBURSEMENT_RECORDS.length, threshold_wbgt: CITY_P97_WBGT, }, } // ── Pipeline runs + stats ──────────────────────────────────────────────── interface RunSeed { daysAgo: number status: 'success' | 'partial' | 'failed' zones_processed: number triggers_found: number notifications_sent: number total_cost_usd: number duration_s: number note?: string } const RUN_SEEDS: RunSeed[] = [ { daysAgo: 0, status: 'success', zones_processed: 15, triggers_found: 7, notifications_sent: 25130, total_cost_usd: 0.1842, duration_s: 412 }, { daysAgo: 1, status: 'success', zones_processed: 15, triggers_found: 4, notifications_sent: 15102, total_cost_usd: 0.1531, duration_s: 388 }, { daysAgo: 2, status: 'success', zones_processed: 15, triggers_found: 3, notifications_sent: 12440, total_cost_usd: 0.1482, duration_s: 401 }, { daysAgo: 3, status: 'success', zones_processed: 15, triggers_found: 2, notifications_sent: 9284, total_cost_usd: 0.1394, duration_s: 376 }, { daysAgo: 4, status: 'partial', zones_processed: 15, triggers_found: 5, notifications_sent: 18421, total_cost_usd: 0.1612, duration_s: 442, note: 'M-Pesa B2C rate-limited 312 transfers; retried successfully' }, { daysAgo: 5, status: 'success', zones_processed: 15, triggers_found: 1, notifications_sent: 4182, total_cost_usd: 0.1328, duration_s: 369 }, { daysAgo: 6, status: 'success', zones_processed: 15, triggers_found: 0, notifications_sent: 0, total_cost_usd: 0.1284, duration_s: 372 }, { daysAgo: 7, status: 'success', zones_processed: 15, triggers_found: 2, notifications_sent: 8112, total_cost_usd: 0.1366, duration_s: 384 }, { daysAgo: 8, status: 'success', zones_processed: 15, triggers_found: 1, notifications_sent: 3428, total_cost_usd: 0.1298, duration_s: 358 }, { daysAgo: 9, status: 'success', zones_processed: 15, triggers_found: 2, notifications_sent: 7860, total_cost_usd: 0.1341, duration_s: 391 }, { daysAgo: 10, status: 'failed', zones_processed: 4, triggers_found: 0, notifications_sent: 0, total_cost_usd: 0.0226, duration_s: 96, note: 'NASA POWER timeout — retry scheduled' }, { daysAgo: 11, status: 'success', zones_processed: 15, triggers_found: 3, notifications_sent: 11248, total_cost_usd: 0.1407, duration_s: 380 }, { daysAgo: 12, status: 'success', zones_processed: 15, triggers_found: 1, notifications_sent: 3860, total_cost_usd: 0.1288, duration_s: 365 }, { daysAgo: 13, status: 'success', zones_processed: 15, triggers_found: 2, notifications_sent: 6964, total_cost_usd: 0.1349, duration_s: 379 }, { daysAgo: 14, status: 'success', zones_processed: 15, triggers_found: 0, notifications_sent: 0, total_cost_usd: 0.1271, duration_s: 371 }, ] function buildSteps(seed: RunSeed): PipelineStep[] { const names: [string, number][] = [ ['ingest', 48], ['heal', 72], ['index', 61], ['calibrate', 94], ['explain', 82], ['notify', 51], ] return names.map(([step, share]) => { // Failed runs stop at the step that broke. if (seed.status === 'failed' && step !== 'ingest') { return { step, status: step === 'heal' ? 'failed' : 'skipped', duration_s: 0 } } const frac = share / names.reduce((s, [, v]) => s + v, 0) const stepStatus = seed.status === 'partial' && step === 'notify' ? 'partial' : 'success' return { step, status: stepStatus, duration_s: Math.round(seed.duration_s * frac * 10) / 10 } }) } const PIPELINE_RUNS: PipelineRun[] = RUN_SEEDS.map((seed) => { const startedAt = isoDaysAgo(seed.daysAgo, 2, 0) const endedAt = isoDaysAgo(seed.daysAgo, 2, Math.floor(seed.duration_s / 60)) return { run_id: `run_${dateOnly(seed.daysAgo).replace(/-/g, '')}`, started_at: startedAt, ended_at: endedAt, status: seed.status === 'success' ? 'success' : seed.status, duration_s: seed.duration_s, zones_processed: seed.zones_processed, triggers_found: seed.triggers_found, notifications_sent: seed.notifications_sent, total_cost_usd: seed.total_cost_usd, steps: buildSteps(seed), } }) export const PIPELINE_RUNS_RESPONSE: PipelineRunsResponse = { runs: PIPELINE_RUNS, total: PIPELINE_RUNS.length, } const SUCCESSFUL_RUNS = RUN_SEEDS.filter((r) => r.status === 'success').length const TOTAL_COST = Math.round(RUN_SEEDS.reduce((s, r) => s + r.total_cost_usd, 0) * 10000) / 10000 const TOTAL_ENROLLED = ZONE_SEEDS.reduce((s, z) => s + Math.round(z.worker_population_est * 0.35), 0) export const PIPELINE_STATS_RESPONSE: PipelineStats = { total_runs: RUN_SEEDS.length, successful_runs: SUCCESSFUL_RUNS, success_rate: Math.round((SUCCESSFUL_RUNS / RUN_SEEDS.length) * 100) / 100, zones_monitored: ZONES.length, cities: 1, active_triggers: TRIGGERING_NOW.length, total_enrolled: TOTAL_ENROLLED, total_cost_usd: TOTAL_COST, avg_cost_per_run_usd: Math.round((TOTAL_COST / RUN_SEEDS.length) * 10000) / 10000, last_run: isoDaysAgo(0, 2, 7), data_sources: ['ERA5-Land', 'NASA POWER', 'Tanzania Met'], } // ── Enrolled workers ───────────────────────────────────────────────────── export const ENROLLED_RESPONSE: EnrolledResponse = { by_zone: ZONE_SEEDS.map((z) => ({ zone_id: z.zone_id, enrolled: Math.round(z.worker_population_est * 0.35), worker_population: z.worker_population_est, })), total_enrolled: TOTAL_ENROLLED, } // ── Coverage recommendation (ProgramDesigner page) ─────────────────────── // Shape matches `/api/coverage-recommendation`. Returns one record per // filter combination — we derive from ZONE_SEEDS on the fly so the filter // buttons in the UI actually do something. interface ZonePopulation { total_pop: number outdoor_informal: number pct_women: number } const ZONE_POPULATIONS: Record = { 'DAR-MAB': { total_pop: 120000, outdoor_informal: 11950, pct_women: 0.62 }, 'DAR-MNZ': { total_pop: 150000, outdoor_informal: 14820, pct_women: 0.61 }, 'DAR-TAN': { total_pop: 128000, outdoor_informal: 12640, pct_women: 0.6 }, 'DAR-BUG': { total_pop: 88000, outdoor_informal: 8720, pct_women: 0.56 }, 'DAR-VIN': { total_pop: 102000, outdoor_informal: 9840, pct_women: 0.57 }, 'DAR-JAN': { total_pop: 78000, outdoor_informal: 7310, pct_women: 0.58 }, 'DAR-MBU': { total_pop: 66000, outdoor_informal: 6520, pct_women: 0.55 }, 'DAR-KIG': { total_pop: 82000, outdoor_informal: 8140, pct_women: 0.54 }, 'DAR-MAG': { total_pop: 71000, outdoor_informal: 6980, pct_women: 0.55 }, 'DAR-MWA': { total_pop: 59000, outdoor_informal: 5860, pct_women: 0.53 }, 'DAR-MSA': { total_pop: 49000, outdoor_informal: 4910, pct_women: 0.52 }, 'DAR-KIN': { total_pop: 108000, outdoor_informal: 4280, pct_women: 0.5 }, 'DAR-SIN': { total_pop: 95000, outdoor_informal: 3760, pct_women: 0.49 }, 'DAR-KAW': { total_pop: 82000, outdoor_informal: 3240, pct_women: 0.48 }, 'DAR-MIK': { total_pop: 55000, outdoor_informal: 2178, pct_women: 0.47 }, } type Gender = 'all' | 'women' | 'men' type SettlementFilter = 'all' | 'informal' | 'mixed' | 'formal' export function buildCoverage( _payoutUsd: number, gender: Gender, settlementFilter: SettlementFilter ): CoverageResponse { const scenarioLabel = [ gender === 'all' ? 'All' : gender === 'women' ? 'Women' : 'Men', settlementFilter === 'all' ? 'outdoor workers' : `in ${settlementFilter} settlements`, ].join(' ') let totalWorkers = 0 let weeklyCostTotal = 0 let annualCostTotal = 0 let zonesTriggering = 0 const zoneRecs: CoverageZone[] = [] for (const z of ZONE_SEEDS) { if (settlementFilter !== 'all' && z.settlement_type !== settlementFilter) continue const pop = ZONE_POPULATIONS[z.zone_id] if (!pop) continue let enrolled = pop.outdoor_informal if (gender === 'women') enrolled = Math.floor(enrolled * pop.pct_women) if (gender === 'men') enrolled = Math.floor(enrolled * (1 - pop.pct_women)) if (enrolled === 0) continue const payoutTriggered = z.current_wbgt_c > CITY_P97_WBGT const pf = z.trigger_probability_7d const alertPayout = Math.round((2 + pf * 3) * 100) / 100 const insurancePayout = Math.round((7 + pf * 13) * 100) / 100 const weeklyCostPerWorker = payoutTriggered ? insurancePayout : 0 // Annual cost = insurance_payout × (trigger days / 7), matching the // real handler's arithmetic so rec-level totals look believable. const annualTriggerDays = Math.min(z.annual_trigger_days, 90) const annualCostPerWorker = Math.round(insurancePayout * (annualTriggerDays / 7) * 100) / 100 const annualWorkerShare = 3 const annualPhilanthropy = Math.round(annualCostPerWorker * 0.45 * 100) / 100 const annualInsurer = Math.round(Math.max(0, annualCostPerWorker - annualWorkerShare - annualPhilanthropy) * 100) / 100 if (payoutTriggered) zonesTriggering++ totalWorkers += enrolled weeklyCostTotal += weeklyCostPerWorker * enrolled annualCostTotal += annualCostPerWorker * enrolled const urgency = pf > 0.8 ? 'critical' : pf > 0.5 ? 'high' : pf > 0.3 ? 'moderate' : 'low' zoneRecs.push({ zone_id: z.zone_id, zone_name: z.name, city: 'Dar es Salaam', settlement_type: z.settlement_type, heat_vulnerability: z.heat_vulnerability, urgency, current_temp_c: Math.round(z.corrected_temp_c * 10) / 10, current_wbgt_c: Math.round(z.current_wbgt_c * 10) / 10, trigger_probability_7d: pf, triggers_this_week: payoutTriggered, risk_level: urgency, outdoor_exposure_pct: z.outdoor_exposure_pct, enrolled_workers: enrolled, weekly_cost_per_worker: Math.round(weeklyCostPerWorker * 100) / 100, alert_payout: payoutTriggered ? alertPayout : 0, insurance_payout: payoutTriggered && pf > 0.8 ? insurancePayout : 0, total_payout_this_week: Math.round(weeklyCostPerWorker * 100) / 100, worker_contribution: Math.round((3 / 52) * 100) / 100, philanthropy_share: 0, insurer_premium: weeklyCostPerWorker, neural_model: z.model_tier === 'ensemble' || z.model_tier === 'full_model', learned_frequency: z.annual_trigger_days, payout_factor: pf, annual_cost_per_worker: annualCostPerWorker, annual_worker_share: annualWorkerShare, annual_philanthropy_share: annualPhilanthropy, annual_insurer_share: annualInsurer, annual_trigger_days: annualTriggerDays, }) } return { recommendation: { weekly_cost_per_worker: totalWorkers > 0 ? Math.round((weeklyCostTotal / totalWorkers) * 100) / 100 : 0, weekly_cost_total: Math.round(weeklyCostTotal), annual_cost_total: Math.round(annualCostTotal), annual_cost_per_worker: totalWorkers > 0 ? Math.round((annualCostTotal / totalWorkers) * 100) / 100 : 0, total_workers: totalWorkers, zones_triggering: zonesTriggering, zones_total: zoneRecs.length, model_type: 'neural_evt', scenario: scenarioLabel, payout_threshold_wbgt: CITY_P97_WBGT, }, zones: zoneRecs, cost_summary: { total_to_workers_pct: 85, total_admin_pct: 7, total_basis_risk_pct: 8, worker_contribution_pct: 15, philanthropy_pct: 45, insurer_pct: 40, }, } } // ── Calibrate response (used by an optional calibrate tool; kept here so // the hook has real data if anyone wires it up) ────────────────────────── export function buildCalibrate(params: CalibrateParams): CalibrateResponse { const zones: CalibrateZoneResult[] = ZONE_SEEDS.map((z, idx) => { const enrolled = Math.round(z.worker_population_est * 0.35) const daysAboveTemp = Math.round(z.annual_trigger_days * 1.1) const daysAboveWbgt = z.annual_trigger_days const events = Math.max(1, Math.round(z.annual_trigger_days / 3.4)) const eventsPerYear = events const annualPayoutPerWorker = Math.round(events * params.payout_usd * 100) / 100 const annualPayoutTotal = Math.round(annualPayoutPerWorker * enrolled) return { zone_id: z.zone_id, zone_name: z.name, city: 'Dar es Salaam', settlement_type: z.settlement_type, heat_vulnerability: z.heat_vulnerability, enrolled_workers: enrolled, days_above_temp: daysAboveTemp, days_above_wbgt: daysAboveWbgt, consecutive_days_temp: z.consecutive_hot_days + 1, consecutive_days_wbgt: z.consecutive_hot_days, trigger_events: events, events_per_year: eventsPerYear, annual_payout_per_worker: annualPayoutPerWorker, annual_payout_total: annualPayoutTotal, basis_risk_score: Math.round((0.74 - (z.settlement_type === 'formal' ? 0.12 : 0) - (z.settlement_type === 'mixed' ? 0.06 : 0)) * 100) / 100, triggered: z.current_wbgt_c > params.wbgt_threshold, actuarial_cost_per_worker: Math.round(annualPayoutPerWorker * 1.17 * 100) / 100, cost_breakdown: { payouts: annualPayoutTotal, admin: Math.round(annualPayoutTotal * 0.08), reserves: Math.round(annualPayoutTotal * 0.09) }, allocated_budget: Math.round(annualPayoutTotal * 1.17), workers_covered: enrolled, coverage_pct: 1, priority_rank: idx + 1, } }) const totalAnnualCost = zones.reduce((s, z) => s + z.annual_payout_total, 0) return { zones, summary: { total_zones: zones.length, zones_triggered: zones.filter((z) => z.triggered).length, total_trigger_days: zones.reduce((s, z) => s + z.days_above_wbgt, 0), avg_events_per_year: Math.round( (zones.reduce((s, z) => s + z.events_per_year, 0) / zones.length) * 10 ) / 10, total_annual_cost: totalAnnualCost, avg_cost_per_worker: Math.round( (zones.reduce((s, z) => s + z.annual_payout_per_worker, 0) / zones.length) * 100 ) / 100, total_enrolled: TOTAL_ENROLLED, avg_basis_risk: Math.round( (zones.reduce((s, z) => s + z.basis_risk_score, 0) / zones.length) * 100 ) / 100, }, allocation: { budget_usd: params.budget_usd, worker_contribution_usd: params.worker_contribution_usd, workers_covered: TOTAL_ENROLLED, overall_coverage_pct: 1, zones_fully_funded: zones.length, zones_partially_funded: 0, zones_unfunded: 0, stretch_analysis: { additional_workers_coverable: 18_400, stretch_budget_usd: Math.round(params.budget_usd * 1.25), }, }, thresholds: params, } } // ── Derived exports the UI uses for hero / metric consistency ──────────── // // These are not wired into the API contract — they're available for any // component that needs season-level roll-ups derived from the same fixture. export const METRICS = { total_workers: TOTAL_WORKERS, total_enrolled: TOTAL_ENROLLED, zones_monitored: ZONES.length, payouts_this_season: PAYOUTS_THIS_SEASON, annual_cost_usd: 2_680_000, average_payout_usd: 10, }