jtlevine's picture
Remove hardcoded fallbacks from frontend API endpoints
53c421d
// ──────────────────────────────────────────────────────────────────────────
// 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<string, (typeof ZONE_SEEDS)[number]> = 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<string, number> = NOTIFICATIONS.reduce(
(acc, n) => {
acc[n.language] = (acc[n.language] ?? 0) + 1
return acc
},
{} as Record<string, number>
)
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<string, ZonePopulation> = {
'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,
}