"use client"; import { useEffect, useReducer, useRef } from "react"; import type { SSEEvent } from "@transportationer/shared"; export type PipelineStageKey = | "download-pbf" | "extract-pois" | "generate-grid" | "build-valhalla" | "compute-scores" | "refresh-city"; export interface StageStatus { key: string; label: string; status: "pending" | "active" | "completed" | "failed"; pct: number; message: string; } const STAGE_ORDER: Array<{ key: string; label: string }> = [ { key: "Downloading PBF", label: "Download OSM data" }, { key: "Filtering OSM tags", label: "Filter & extract POIs" }, { key: "Importing to PostGIS", label: "Import to database" }, { key: "Building routing graph", label: "Build routing graph" }, { key: "Generating grid", label: "Generate analysis grid" }, { key: "Computing scores", label: "Compute accessibility scores" }, ]; export type OverallStatus = "pending" | "active" | "completed" | "failed"; interface ProgressState { stages: StageStatus[]; overall: OverallStatus; error?: string; } type Action = | { type: "progress"; stage: string; pct: number; message: string } | { type: "completed" } | { type: "failed"; error: string }; function initialState(): ProgressState { return { stages: STAGE_ORDER.map((s) => ({ key: s.key, label: s.label, status: "pending", pct: 0, message: "", })), overall: "pending", }; } function reducer(state: ProgressState, action: Action): ProgressState { switch (action.type) { case "progress": { let found = false; const stages = state.stages.map((s) => { if (s.key === action.stage) { found = true; return { ...s, status: "active" as const, pct: action.pct, message: action.message, }; } // Mark prior stages completed once a later stage is active if (!found) return { ...s, status: "completed" as const, pct: 100 }; return s; }); return { ...state, stages, overall: "active" }; } case "completed": return { ...state, overall: "completed", stages: state.stages.map((s) => ({ ...s, status: "completed", pct: 100, })), }; case "failed": return { ...state, overall: "failed", error: action.error }; default: return state; } } export function useJobProgress(jobId: string | null): ProgressState { const [state, dispatch] = useReducer(reducer, undefined, initialState); const esRef = useRef(null); useEffect(() => { if (!jobId) return; const es = new EventSource(`/api/admin/jobs/${jobId}/stream`); esRef.current = es; es.onmessage = (event) => { const payload = JSON.parse(event.data) as SSEEvent; if (payload.type === "heartbeat") return; dispatch(payload as Action); }; es.onerror = () => { dispatch({ type: "failed", error: "Lost connection to job stream" }); es.close(); }; return () => { es.close(); esRef.current = null; }; }, [jobId]); return state; }