import { useEffect, useMemo, useRef, useState } from "react"; import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets"; import type { AisTarget } from "../../entities/aisTarget/model/types"; export type AisPollingStatus = "idle" | "loading" | "ready" | "error"; export type AisPollingSnapshot = { status: AisPollingStatus; error: string | null; lastFetchAt: string | null; lastFetchMinutes: number | null; lastMessage: string | null; total: number; lastUpserted: number; lastInserted: number; lastDeleted: number; }; export type AisPollingOptions = { initialMinutes?: number; incrementalMinutes?: number; intervalMs?: number; retentionMinutes?: number; bbox?: string; enabled?: boolean; }; function upsertByMmsi(store: Map, rows: AisTarget[]) { let inserted = 0; let upserted = 0; for (const r of rows) { if (!r || typeof r.mmsi !== "number") continue; const prev = store.get(r.mmsi); if (!prev) { store.set(r.mmsi, r); inserted += 1; upserted += 1; continue; } // Prefer newer records if the upstream ever returns stale items. const prevTs = prev.messageTimestamp ?? ""; const nextTs = r.messageTimestamp ?? ""; if (nextTs && prevTs && nextTs < prevTs) continue; store.set(r.mmsi, r); upserted += 1; } return { inserted, upserted }; } function parseBbox(raw: string | undefined) { if (!raw) return null; const parts = raw .split(",") .map((s) => s.trim()) .filter(Boolean); if (parts.length !== 4) return null; const [lonMin, latMin, lonMax, latMax] = parts.map((p) => Number(p)); const ok = Number.isFinite(lonMin) && Number.isFinite(latMin) && Number.isFinite(lonMax) && Number.isFinite(latMax) && lonMin >= -180 && lonMax <= 180 && latMin >= -90 && latMax <= 90 && lonMin < lonMax && latMin < latMax; if (!ok) return null; return { lonMin, latMin, lonMax, latMax }; } function pruneStore(store: Map, retentionMinutes: number, bboxRaw: string | undefined) { const cutoffMs = Date.now() - retentionMinutes * 60_000; const bbox = parseBbox(bboxRaw); let deleted = 0; for (const [mmsi, t] of store.entries()) { const ts = Date.parse(t.messageTimestamp || ""); if (Number.isFinite(ts) && ts < cutoffMs) { store.delete(mmsi); deleted += 1; continue; } if (bbox) { const lat = t.lat; const lon = t.lon; if (typeof lat !== "number" || typeof lon !== "number") { store.delete(mmsi); deleted += 1; continue; } if (lon < bbox.lonMin || lon > bbox.lonMax || lat < bbox.latMin || lat > bbox.latMax) { store.delete(mmsi); deleted += 1; continue; } } } return deleted; } export function useAisTargetPolling(opts: AisPollingOptions = {}) { const initialMinutes = opts.initialMinutes ?? 60; const incrementalMinutes = opts.incrementalMinutes ?? 1; const intervalMs = opts.intervalMs ?? 60_000; const retentionMinutes = opts.retentionMinutes ?? initialMinutes; const enabled = opts.enabled ?? true; const bbox = opts.bbox; const storeRef = useRef>(new Map()); const inFlightRef = useRef(false); const [rev, setRev] = useState(0); const [snapshot, setSnapshot] = useState({ status: "idle", error: null, lastFetchAt: null, lastFetchMinutes: null, lastMessage: null, total: 0, lastUpserted: 0, lastInserted: 0, lastDeleted: 0, }); useEffect(() => { if (!enabled) return; let cancelled = false; const controller = new AbortController(); async function run(minutes: number) { if (inFlightRef.current) return; inFlightRef.current = true; try { setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null })); const res = await searchAisTargets({ minutes, bbox }, controller.signal); if (cancelled) return; const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data); const deleted = pruneStore(storeRef.current, retentionMinutes, bbox); const total = storeRef.current.size; const lastFetchAt = new Date().toISOString(); setSnapshot({ status: "ready", error: null, lastFetchAt, lastFetchMinutes: minutes, lastMessage: res.message, total, lastUpserted: upserted, lastInserted: inserted, lastDeleted: deleted, }); setRev((r) => r + 1); } catch (e) { if (cancelled) return; setSnapshot((s) => ({ ...s, status: "error", error: e instanceof Error ? e.message : String(e), })); } finally { inFlightRef.current = false; } } // Reset store when polling config changes (bbox, retention, etc). storeRef.current = new Map(); setSnapshot({ status: "loading", error: null, lastFetchAt: null, lastFetchMinutes: null, lastMessage: null, total: 0, lastUpserted: 0, lastInserted: 0, lastDeleted: 0, }); setRev((r) => r + 1); void run(initialMinutes); const id = window.setInterval(() => void run(incrementalMinutes), intervalMs); return () => { cancelled = true; controller.abort(); window.clearInterval(id); }; }, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, enabled]); const targets = useMemo(() => { // `rev` is a version counter so we recompute the array snapshot when the store changes. void rev; return Array.from(storeRef.current.values()); }, [rev]); return { targets, snapshot }; }