"use client"; import { useCallback, useRef, useState } from "react"; import { z } from "zod"; import { apiGet, apiSend, ApiError } from "../client"; import { queryClient } from "../queryClient"; import { keys } from "../keys"; // --- wire schemas (kept local to this hook, per scope) --- const JobRefSchema = z.object({ bank: z.string(), jobId: z.number() }); const LockedRefSchema = z.object({ bank: z.string(), retryAfterSeconds: z.number() }); // Note: fields below are `.optional()` rather than `.default(...)` — apiGet's // `z.ZodType` param type unifies output/input into one T, and zod's // ZodDefault input type (optional) leaks into T when it's inferred that way. // Optional + a `?? fallback` at each call site keeps the inferred type honest. const SyncResponseSchema = z.object({ jobs: z.array(JobRefSchema).optional(), locked: z.array(LockedRefSchema).optional(), }); const SyncJobSchema = z.object({ id: z.number(), bank: z.string(), status: z.enum(["pending", "done", "error"]), newCount: z.number().optional(), error: z.string().optional(), }); type SyncJob = z.infer; export interface SyncError { bank: string; message: string; } export interface SyncResult { /** True when POST /sync came back 400 "no banks connected" — nothing was enqueued. */ noBanksConnected: boolean; /** Sum of new_count across every completed job. */ newCount: number; /** Jobs that finished with status "error" (or timed out still pending). */ errors: SyncError[]; /** Banks the server declined to enqueue this round (already syncing). */ locked: { bank: string; retryAfterSeconds: number }[]; } const POLL_INTERVAL_MS = 1500; const MAX_POLLS = 40; // ~60s ceiling function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Polls one sync job until it leaves "pending", capped at MAX_POLLS attempts. * A job still pending at the cap is surfaced to the caller as an error (rather * than hanging the UI indefinitely) so a stuck bank sync can't spin forever. */ export async function pollJobUntilDone(jobId: number): Promise { let last: SyncJob | null = null; for (let i = 0; i < MAX_POLLS; i++) { last = await apiGet(`/sync/jobs/${jobId}`, SyncJobSchema); if (last.status !== "pending") return last; if (i < MAX_POLLS - 1) await sleep(POLL_INTERVAL_MS); } return last ?? { id: jobId, bank: "", status: "error" as const, newCount: 0, error: "timeout" }; } /** Invalidates every read query the dashboard/pages depend on so a completed * sync's fresh data (new transactions, updated balances/budget/etc.) shows up * without a manual reload. */ function invalidateAllReads() { queryClient.invalidateQueries({ queryKey: keys.networth }); queryClient.invalidateQueries({ queryKey: ["transactions"] }); queryClient.invalidateQueries({ queryKey: ["analyze"] }); // covers both analyzeMonth + analyzeToday queryClient.invalidateQueries({ queryKey: keys.budget }); queryClient.invalidateQueries({ queryKey: keys.connections }); queryClient.invalidateQueries({ queryKey: keys.manualAssets }); queryClient.invalidateQueries({ queryKey: keys.subscriptions }); queryClient.invalidateQueries({ queryKey: keys.categories }); queryClient.invalidateQueries({ queryKey: keys.me }); } /** Triggers a server-side bank sync (POST /sync), polls every returned job to * completion, then refreshes all read queries. See internal/transport/api/sync_handlers.go * for the backend contract this mirrors. */ export function useSync() { const [isSyncing, setIsSyncing] = useState(false); const [result, setResult] = useState(null); const runningRef = useRef(false); const sync = useCallback(async () => { if (runningRef.current) return; runningRef.current = true; setIsSyncing(true); setResult(null); try { let res: z.infer; try { res = (await apiSend("POST", "/sync", undefined, SyncResponseSchema)) as z.infer< typeof SyncResponseSchema >; } catch (err) { if (err instanceof ApiError && err.status === 400) { setResult({ noBanksConnected: true, newCount: 0, errors: [], locked: [] }); return; } throw err; } const jobs = res.jobs ?? []; const jobResults = await Promise.all(jobs.map((j) => pollJobUntilDone(j.jobId))); let newCount = 0; const errors: SyncError[] = []; for (const j of jobResults) { if (j.status === "error") errors.push({ bank: j.bank, message: j.error || "sync_failed" }); else newCount += j.newCount ?? 0; } invalidateAllReads(); setResult({ noBanksConnected: false, newCount, errors, locked: res.locked ?? [] }); } finally { runningRef.current = false; setIsSyncing(false); } }, []); return { sync, isSyncing, result }; }