feat(web): server-side bank sync button (trigger + poll + refresh)
This commit is contained in:
parent
245ef6ea03
commit
14d04d348c
5 changed files with 298 additions and 2 deletions
69
src/api/hooks/sync.test.ts
Normal file
69
src/api/hooks/sync.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
129
src/api/hooks/sync.ts
Normal file
129
src/api/hooks/sync.ts
Normal file
|
|
@ -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<T>` 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<typeof SyncJobSchema>;
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<SyncJob> {
|
||||||
|
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<SyncResult | null>(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<typeof SyncResponseSchema>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
93
src/ds/SyncButton.tsx
Normal file
93
src/ds/SyncButton.tsx
Normal file
|
|
@ -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<ReturnType<typeof useSync>["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<string | null>(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 (
|
||||||
|
<span style={{ position: "relative", display: "inline-flex" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Банк синк хийх"
|
||||||
|
disabled={isSyncing}
|
||||||
|
onClick={() => void sync()}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: "50%",
|
||||||
|
color: "var(--seed-color-fg-neutral)",
|
||||||
|
cursor: isSyncing ? "default" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
animation: isSyncing ? "mercury-sync-spin 0.8s linear infinite" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="repeat" size={20} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{message && (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "calc(100% + 6px)",
|
||||||
|
right: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--seed-color-fg-neutral)",
|
||||||
|
background: "var(--seed-color-bg-layer-default, #fff)",
|
||||||
|
border: "1px solid var(--seed-color-border-default, rgba(0,0,0,0.1))",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "6px 10px",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.12)",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Spin animates transform only (not layout), scoped to this component's class. */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes mercury-sync-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ export type { AmountToggleProps } from "./AmountToggle";
|
||||||
export { HideAmountsToggle, HIDE_AMOUNTS_EVENT } from "./HideAmountsToggle";
|
export { HideAmountsToggle, HIDE_AMOUNTS_EVENT } from "./HideAmountsToggle";
|
||||||
export type { HideAmountsToggleProps } from "./HideAmountsToggle";
|
export type { HideAmountsToggleProps } from "./HideAmountsToggle";
|
||||||
|
|
||||||
|
export { SyncButton } from "./SyncButton";
|
||||||
|
|
||||||
export { TabBar } from "./TabBar";
|
export { TabBar } from "./TabBar";
|
||||||
export type { TabBarProps, TabKey } from "./TabBar";
|
export type { TabBarProps, TabKey } from "./TabBar";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react";
|
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 { tugrikShortRaw } from "../../ds/money";
|
||||||
import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads";
|
import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads";
|
||||||
import { buildHome, type HomeData, type LedgerRow as LedgerRowData } from "./buildHome";
|
import { buildHome, type HomeData, type LedgerRow as LedgerRowData } from "./buildHome";
|
||||||
|
|
@ -128,7 +128,10 @@ export function DashboardView() {
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||||
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: 18, color: "var(--seed-color-fg-neutral)" }}>{s.wordmark}</span>
|
<span style={{ fontWeight: 600, fontSize: 18, color: "var(--seed-color-fg-neutral)" }}>{s.wordmark}</span>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<SyncButton />
|
||||||
<HideAmountsToggle label={s.hideAmounts} />
|
<HideAmountsToggle label={s.hideAmounts} />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-[18px] md:grid-cols-2 md:items-start md:gap-5">
|
<div className="grid grid-cols-1 gap-[18px] md:grid-cols-2 md:items-start md:gap-5">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue