jtlevine's picture
Surface per-zone thresholds on Vercel frontend
c7d99b0
import { useQuery } from '@tanstack/react-query'
import type { DisbursementsResponse } from '../types'
// ── Fixture-backed data layer ─────────────────────────────────────────────
// The portfolio copy of this dashboard runs entirely off in-memory fixtures
// so the dashboard renders a fully-populated experience without a Neon
// database or deployed Vercel functions. Hook names, types, and return
// shapes are preserved verbatim so the pages are unaware of the swap.
// Fixtures are imported lazily inside each queryFn so TypeScript still
// type-checks the api.ts interface exports even if mockData.ts moves.
const BASE_URL = import.meta.env.VITE_API_URL ?? ''
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`)
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`)
return res.json() as Promise<T>
}
function mock<T>(value: T, delayMs = 180): Promise<T> {
// Short delay so React Query still flashes a loading state on first paint.
return new Promise((resolve) => setTimeout(() => resolve(value), delayMs))
}
// ── Types (matched to actual API response field names) ──────────────────
// Field names are kept verbatim-aligned with the Neon serverless handlers
// in `api/*.ts` so the porting chat can swap `mock(...)` β†’ `fetch(...)`
// without touching page components. Any field in these interfaces is
// guaranteed to be returned by the corresponding handler; fields the
// backend doesn't return are omitted even if the mock could compute them.
export interface Zone {
zone_id: string
name: string
city: string
country: string
latitude: number
longitude: number
elevation_m: number
settlement_type: string
worker_population_est: number
outdoor_exposure_pct: number
heat_vulnerability: string
risk_level: string
corrected_temp_c: number
current_wbgt_c: number
current_heat_index_c: number
max_temp_c: number
max_wbgt_c: number
consecutive_hot_days: number
trigger_probability_7d: number
prediction_confidence: number
model_tier: string
// Zone-specific trigger thresholds from the zone_thresholds table (populated
// by the pipeline when THRESHOLD_MODE=zone_specific). Null for zones without
// calibrated thresholds β€” consumers fall back to city-wide defaults (35.1/38.8).
alert_threshold_c?: number | null
payout_threshold_c?: number | null
threshold_uhi_model?: string | null
threshold_mode?: string | null
}
export interface ZonesResponse {
zones: Zone[]
total: number
cities: string[]
}
export interface DailyHeat {
date: string
temp_c: number
grid_temp_c: number
wbgt_c: number
}
export interface IndexData {
zone_id: string
zone_name: string
city: string
temp_current: number
wbgt_current: number
risk_level: string
daily_history: DailyHeat[]
}
export interface IndicesResponse {
indices: IndexData[]
total: number
}
export interface Trigger {
zone_id: string
zone_name: string
city: string
trigger_level: string
triggered_at: string
heat_risk_score: number
max_temp_c: number
max_wbgt_c: number
consecutive_days: number
settlement_type: string
payout_per_worker_usd: number
}
export interface TriggersResponse {
triggers: Trigger[]
total: number
}
export interface BasisRisk {
zone_id: string
zone_name: string
city: string
settlement_type: string
false_positive_rate: number
false_negative_rate: number
correlation: number
mae: number
accuracy_by_tier: Record<string, number>
recommendations: unknown
}
export interface BasisRiskResponse {
basis_risk: BasisRisk[]
total: number
}
export interface Notification {
id: string
zone_id: string
zone_name: string
city: string
trigger_level: string
channel: string
language: string
recipient_count: number
message_preview: string
status: string
delivered_at: string
}
export interface NotificationsResponse {
notifications: Notification[]
by_language: Record<string, number>
}
export interface PipelineStep {
step: string
status: string
duration_s: number
}
export interface PipelineRun {
run_id: string
started_at: string
ended_at: string
status: string
duration_s: number
zones_processed: number
triggers_found: number
notifications_sent: number
total_cost_usd: number
steps: PipelineStep[]
}
export interface PipelineRunsResponse {
runs: PipelineRun[]
total: number
}
export interface PipelineStats {
total_runs: number
successful_runs: number
success_rate: number
zones_monitored: number
cities: number
active_triggers: number
total_enrolled: number
total_cost_usd: number
avg_cost_per_run_usd: number
last_run: string | null
data_sources: string[]
}
export interface EnrolledResponse {
by_zone: { zone_id: string; enrolled: number; worker_population: number }[]
total_enrolled: number
}
export interface CalibrateParams {
temp_threshold: number
consecutive_days: number
wbgt_threshold: number
payout_usd: number
budget_usd: number
worker_contribution_usd: number
}
export interface CalibrateZoneResult {
zone_id: string
zone_name: string
city: string
settlement_type: string
heat_vulnerability: string
enrolled_workers: number
days_above_temp: number
days_above_wbgt: number
consecutive_days_temp: number
consecutive_days_wbgt: number
trigger_events: number
events_per_year: number
annual_payout_per_worker: number
annual_payout_total: number
basis_risk_score: number
triggered: boolean
actuarial_cost_per_worker: number
cost_breakdown: Record<string, any>
allocated_budget: number
workers_covered: number
coverage_pct: number
priority_rank: number
}
export interface CalibrateSummary {
total_zones: number
zones_triggered: number
total_trigger_days: number
avg_events_per_year: number
total_annual_cost: number
avg_cost_per_worker: number
total_enrolled: number
avg_basis_risk: number
}
export interface CalibrateAllocation {
budget_usd: number
worker_contribution_usd: number
workers_covered: number
overall_coverage_pct: number
zones_fully_funded: number
zones_partially_funded: number
zones_unfunded: number
stretch_analysis: Record<string, any>
}
export interface CalibrateResponse {
zones: CalibrateZoneResult[]
summary: CalibrateSummary
allocation: CalibrateAllocation
thresholds: CalibrateParams
}
// ── Query hooks ──────────────────────────────────────────────────────────
import {
ZONES_RESPONSE,
INDICES_RESPONSE,
TRIGGERS_RESPONSE,
BASIS_RISK_RESPONSE,
NOTIFICATIONS_RESPONSE,
DISBURSEMENTS_RESPONSE,
PIPELINE_RUNS_RESPONSE,
PIPELINE_STATS_RESPONSE,
ENROLLED_RESPONSE,
buildCoverage,
} from './mockData'
const STALE_5MIN = 5 * 60 * 1000
export function useZones() {
return useQuery<ZonesResponse>({
queryKey: ['zones'],
queryFn: () => fetchJson<ZonesResponse>('/api/zones'),
staleTime: STALE_5MIN,
})
}
export function useIndices() {
return useQuery<IndicesResponse>({
queryKey: ['indices'],
queryFn: () => fetchJson<IndicesResponse>('/api/indices'),
staleTime: STALE_5MIN,
})
}
export function useTriggers() {
return useQuery<TriggersResponse>({
queryKey: ['triggers'],
queryFn: () => fetchJson<TriggersResponse>('/api/triggers'),
staleTime: STALE_5MIN,
})
}
export function useBasisRisk() {
return useQuery<BasisRiskResponse>({
queryKey: ['basis-risk'],
queryFn: () => fetchJson<BasisRiskResponse>('/api/basis-risk'),
staleTime: STALE_5MIN,
})
}
export function useNotifications() {
return useQuery<NotificationsResponse>({
queryKey: ['notifications'],
queryFn: () => fetchJson<NotificationsResponse>('/api/notifications'),
staleTime: STALE_5MIN,
})
}
export function useDisbursements() {
return useQuery<DisbursementsResponse>({
queryKey: ['disbursements'],
queryFn: () => fetchJson<DisbursementsResponse>('/api/disbursements'),
staleTime: STALE_5MIN,
})
}
export function usePipelineRuns() {
return useQuery<PipelineRunsResponse>({
queryKey: ['pipeline-runs'],
queryFn: () => fetchJson<PipelineRunsResponse>('/api/pipeline/runs'),
staleTime: STALE_5MIN,
})
}
export function usePipelineStats() {
return useQuery<PipelineStats>({
queryKey: ['pipeline-stats'],
queryFn: () => fetchJson<PipelineStats>('/api/pipeline/stats'),
staleTime: STALE_5MIN,
})
}
export function useEnrolled() {
return useQuery<EnrolledResponse>({
queryKey: ['enrolled'],
queryFn: () => fetchJson<EnrolledResponse>('/api/enrolled-workers'),
staleTime: STALE_5MIN,
})
}
// ── Coverage recommendation ──
export interface CoverageZone {
zone_id: string
zone_name: string
city: string
settlement_type: string
heat_vulnerability: string
urgency: string
current_temp_c: number
current_wbgt_c: number
trigger_probability_7d: number
triggers_this_week: boolean
risk_level: string
outdoor_exposure_pct: number
enrolled_workers: number
// Per-worker this week
weekly_cost_per_worker: number
alert_payout: number
insurance_payout: number
total_payout_this_week: number
worker_contribution: number
philanthropy_share: number
insurer_premium: number
neural_model: boolean
learned_frequency: number | null
payout_factor: number | null
// Annual estimates
annual_cost_per_worker: number
annual_worker_share: number
annual_philanthropy_share: number
annual_insurer_share: number
annual_trigger_days: number
}
export interface CoverageRecommendation {
weekly_cost_per_worker: number
weekly_cost_total: number
annual_cost_total: number
annual_cost_per_worker: number
total_workers: number
zones_triggering: number
zones_total: number
model_type: string
scenario: string
payout_threshold_wbgt: number
}
export interface CoverageCostSummary {
total_to_workers_pct: number
total_admin_pct: number
total_basis_risk_pct: number
worker_contribution_pct: number
philanthropy_pct: number
insurer_pct: number
}
export interface CoverageResponse {
recommendation: CoverageRecommendation
zones: CoverageZone[]
cost_summary: CoverageCostSummary
}
export function useCoverageRecommendation(payoutUsd: number = 10, filters: string = 'all|all') {
const [gender, settlement] = filters.split('|')
return useQuery<CoverageResponse>({
queryKey: ['coverage-recommendation', payoutUsd, gender, settlement],
queryFn: () =>
fetchJson<CoverageResponse>(
`/api/coverage-recommendation?payout_usd=${payoutUsd}&gender=${gender || 'all'}&settlement=${settlement || 'all'}`
),
staleTime: STALE_5MIN,
})
}
// ── Pilot worker personas (DPI-style) ───────────────────────────────────
export interface Worker {
worker_id: string
name: string
name_swahili: string | null
nida_id: string | null
phone: string
zone_id: string
zone_name: string
settlement_type: string
occupation: string
age: number | null
years_outdoor: number | null
household_size: number | null
mobile_money: string | null
tasaf_enrolled: boolean
enrolled_at: string
}
export interface WorkersResponse {
workers: Worker[]
total: number
}
export function useWorkers() {
return useQuery<WorkersResponse>({
queryKey: ['workers'],
queryFn: () => fetchJson<WorkersResponse>('/api/workers'),
staleTime: STALE_5MIN,
})
}