diff --git a/src/api/hooks/sync.test.ts b/src/api/hooks/sync.test.ts new file mode 100644 index 0000000..7fa7793 --- /dev/null +++ b/src/api/hooks/sync.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { pollJobUntilDone } from "./sync"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }); +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("pollJobUntilDone", () => { + it("polls until the job leaves pending, then returns it", async () => { + let calls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + calls += 1; + const status = calls < 3 ? "pending" : "done"; + return jsonResponse({ id: 1, bank: "khan", status, newCount: status === "done" ? 5 : 0, error: "" }); + }), + ); + + const promise = pollJobUntilDone(1); + // Let each pending iteration's fetch + sleep settle before advancing time. + for (let i = 0; i < 3; i++) { + await vi.advanceTimersByTimeAsync(1500); + } + const job = await promise; + + expect(calls).toBe(3); + expect(job.status).toBe("done"); + expect(job.newCount).toBe(5); + }); + + it("stops after the poll cap and surfaces the last-seen state as an error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ id: 2, bank: "khan", status: "pending", newCount: 0, error: "" })), + ); + + const promise = pollJobUntilDone(2); + // 40 attempts, each gated by a 1500ms sleep except the last. + for (let i = 0; i < 40; i++) { + await vi.advanceTimersByTimeAsync(1500); + } + const job = await promise; + + expect(job.status).toBe("pending"); + }, 10000); + + it("propagates an error status immediately without further polling", async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ id: 3, bank: "khan", status: "error", newCount: 0, error: "bank_auth_rejected" }), + ); + vi.stubGlobal("fetch", fetchMock); + + const job = await pollJobUntilDone(3); + + expect(job.status).toBe("error"); + expect(job.error).toBe("bank_auth_rejected"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/api/hooks/sync.ts b/src/api/hooks/sync.ts new file mode 100644 index 0000000..f0e7376 --- /dev/null +++ b/src/api/hooks/sync.ts @@ -0,0 +1,129 @@ +"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 }; +} diff --git a/src/ds/SyncButton.tsx b/src/ds/SyncButton.tsx new file mode 100644 index 0000000..079e890 --- /dev/null +++ b/src/ds/SyncButton.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Icon } from "./icons"; +import { useSync } from "../api/hooks/sync"; + +/** Turns a completed sync result into the short Mongolian summary shown after + * a tap — mirrors the outcomes the server can actually report (see + * internal/transport/api/sync_handlers.go): no banks connected, one or more + * jobs errored, some new transactions landed, or nothing new this time. */ +function summarize(result: NonNullable["result"]>): string { + if (result.noBanksConnected) return "Банк холбоогүй байна"; + if (result.errors.length > 0) return "Синк амжилтгүй боллоо"; + if (result.newCount > 0) return `${result.newCount} шинэ гүйлгээ`; + return "Шинэ гүйлгээ алга"; +} + +/** Header sync button: triggers a server-side bank sync (POST /sync), spins + * while polling the jobs to completion, then briefly shows a one-line result. + * Mirrors ios/Mercury/DesignSystem/SyncButton.swift. */ +export function SyncButton() { + const { sync, isSyncing, result } = useSync(); + const [message, setMessage] = useState(null); + + // Show the result for a few seconds after each sync completes, then clear it. + useEffect(() => { + if (!result) return; + setMessage(summarize(result)); + const t = setTimeout(() => setMessage(null), 3000); + return () => clearTimeout(t); + }, [result]); + + return ( + + + {message && ( + + {message} + + )} + {/* Spin animates transform only (not layout), scoped to this component's class. */} + + + ); +} diff --git a/src/ds/index.ts b/src/ds/index.ts index 83262cd..7891c3a 100644 --- a/src/ds/index.ts +++ b/src/ds/index.ts @@ -7,6 +7,8 @@ export type { AmountToggleProps } from "./AmountToggle"; export { HideAmountsToggle, HIDE_AMOUNTS_EVENT } from "./HideAmountsToggle"; export type { HideAmountsToggleProps } from "./HideAmountsToggle"; +export { SyncButton } from "./SyncButton"; + export { TabBar } from "./TabBar"; export type { TabBarProps, TabKey } from "./TabBar"; diff --git a/src/features/home/DashboardView.tsx b/src/features/home/DashboardView.tsx index 28b8b96..9ecd053 100644 --- a/src/features/home/DashboardView.tsx +++ b/src/features/home/DashboardView.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import Link from "next/link"; import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react"; -import { Card, AmountToggle, HideAmountsToggle, IconChip, SectionHeader, EmptyState, categoryStyle } from "../../ds"; +import { Card, AmountToggle, HideAmountsToggle, IconChip, SectionHeader, EmptyState, SyncButton, categoryStyle } from "../../ds"; import { tugrikShortRaw } from "../../ds/money"; import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads"; import { buildHome, type HomeData, type LedgerRow as LedgerRowData } from "./buildHome"; @@ -128,7 +128,10 @@ export function DashboardView() {
{s.wordmark} - +
+ + +