Compare commits

..

3 commits

10 changed files with 477 additions and 4 deletions

View 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
View 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 };
}

View file

@ -2,9 +2,31 @@ import "./globals.css";
export const metadata = { title: "Mercury" }; export const metadata = { title: "Mercury" };
// Runs before paint (in <head>, ahead of hydration) to set the Seed
// color-mode attribute from the persisted preference, avoiding a
// flash-of-incorrect-theme: the server always renders "system" (no
// localStorage access during SSR), so without this the client would briefly
// paint light/system before this script could run post-hydration.
const THEME_INIT_SCRIPT = `
(function () {
try {
var v = localStorage.getItem("mercury.theme");
var mode = v === "light" ? "light-only" : v === "dark" ? "dark-only" : "system";
document.documentElement.setAttribute("data-seed-color-mode", mode);
} catch (e) {}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="mn" data-seed data-seed-color-mode="light-only"> // suppressHydrationWarning: the inline script below intentionally
// rewrites data-seed-color-mode before hydration (to the persisted
// preference), so it will legitimately differ from this "system"
// server-rendered value — React should not warn about or "fix" that.
<html lang="mn" data-seed data-seed-color-mode="system" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
</head>
<body>{children}</body> <body>{children}</body>
</html> </html>
); );

93
src/ds/SyncButton.tsx Normal file
View 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>
);
}

View file

@ -0,0 +1,49 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { it, expect, beforeEach } from "vitest";
import { ThemeToggle, getStoredTheme } from "./ThemeToggle";
beforeEach(() => {
try {
localStorage.clear();
} catch {}
document.documentElement.removeAttribute("data-seed-color-mode");
});
it("defaults to system, and picks the persisted choice back up on remount", () => {
expect(getStoredTheme()).toBe("system");
render(<ThemeToggle />);
expect(screen.getByText("Систем")).toBeInTheDocument();
expect(screen.getByText("Гэрэл")).toBeInTheDocument();
expect(screen.getByText("Харанхуй")).toBeInTheDocument();
cleanup();
localStorage.setItem("mercury.theme", "dark");
render(<ThemeToggle />);
const darkInput = screen.getByText("Харанхуй").closest("label")!.querySelector("input")!;
expect(darkInput).toBeChecked();
});
it("selecting dark persists the choice and flips the live data-seed-color-mode attribute", () => {
render(<ThemeToggle />);
const darkInput = screen.getByText("Харанхуй").closest("label")!.querySelector("input")!;
fireEvent.click(darkInput);
expect(getStoredTheme()).toBe("dark");
expect(document.documentElement.getAttribute("data-seed-color-mode")).toBe("dark-only");
});
it("selecting light and back to system round-trips through localStorage + the html attribute", () => {
render(<ThemeToggle />);
const lightInput = screen.getByText("Гэрэл").closest("label")!.querySelector("input")!;
fireEvent.click(lightInput);
expect(getStoredTheme()).toBe("light");
expect(document.documentElement.getAttribute("data-seed-color-mode")).toBe("light-only");
const systemInput = screen.getByText("Систем").closest("label")!.querySelector("input")!;
fireEvent.click(systemInput);
expect(getStoredTheme()).toBe("system");
expect(document.documentElement.getAttribute("data-seed-color-mode")).toBe("system");
});

87
src/ds/ThemeToggle.tsx Normal file
View file

@ -0,0 +1,87 @@
"use client";
import * as React from "react";
import {
SegmentedControlRoot,
SegmentedControlItem,
SegmentedControlItemHiddenInput,
SegmentedControlIndicator,
} from "@seed-design/react";
/** "system" follows the OS via prefers-color-scheme; "light"/"dark" pin it. */
export type MercuryTheme = "system" | "light" | "dark";
const STORAGE_KEY = "mercury.theme";
/** Mirrors the inline script in app/layout.tsx (kept in sync manually the
* script must stay dependency-free/pre-hydration, so it can't import this). */
function toColorMode(theme: MercuryTheme): "system" | "light-only" | "dark-only" {
if (theme === "light") return "light-only";
if (theme === "dark") return "dark-only";
return "system";
}
export function getStoredTheme(): MercuryTheme {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === "light" || v === "dark" || v === "system") return v;
} catch {
// localStorage unavailable (private mode, SSR) — fall through to default.
}
return "system";
}
function persistTheme(theme: MercuryTheme): void {
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch {
// Best-effort; the toggle still works for the current page load.
}
}
function applyTheme(theme: MercuryTheme): void {
if (typeof document === "undefined") return;
document.documentElement.setAttribute("data-seed-color-mode", toColorMode(theme));
}
export interface ThemeToggleProps {
labels?: { system: string; light: string; dark: string };
}
const DEFAULT_LABELS = { system: "Систем", light: "Гэрэл", dark: "Харанхуй" };
/** 3-way Систем / Гэрэл / Харанхуй display-mode control. Updates
* localStorage and the live `data-seed-color-mode` attribute on `<html>`
* immediately, so every `--seed-color-*` token flips without a reload. */
export function ThemeToggle({ labels = DEFAULT_LABELS }: ThemeToggleProps) {
// Lazy initializer runs on the client at mount (same pattern as
// HideAmountsToggle/isHidden) so it picks up the real preference
// immediately — layout.tsx's inline script has already applied it to the
// DOM before this ever mounts, so both agree from the first paint.
const [theme, setTheme] = React.useState<MercuryTheme>(() => getStoredTheme());
function handleChange(next: string) {
const nextTheme = next as MercuryTheme;
setTheme(nextTheme);
persistTheme(nextTheme);
applyTheme(nextTheme);
}
return (
<SegmentedControlRoot value={theme} onValueChange={handleChange} aria-label="Дэлгэцийн горим">
<SegmentedControlIndicator />
<SegmentedControlItem value="system">
<SegmentedControlItemHiddenInput />
{labels.system}
</SegmentedControlItem>
<SegmentedControlItem value="light">
<SegmentedControlItemHiddenInput />
{labels.light}
</SegmentedControlItem>
<SegmentedControlItem value="dark">
<SegmentedControlItemHiddenInput />
{labels.dark}
</SegmentedControlItem>
</SegmentedControlRoot>
);
}

View file

@ -7,6 +7,9 @@ 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 { ThemeToggle, getStoredTheme } from "./ThemeToggle";
export type { MercuryTheme, ThemeToggleProps } from "./ThemeToggle";
export { TabBar } from "./TabBar"; export { TabBar } from "./TabBar";
export type { TabBarProps, TabKey } from "./TabBar"; export type { TabBarProps, TabKey } from "./TabBar";
@ -20,3 +23,4 @@ export * from "./icons";
export * from "./IconChip"; export * from "./IconChip";
export * from "./SectionHeader"; export * from "./SectionHeader";
export * from "./EmptyState"; export * from "./EmptyState";
export * from "./SyncButton";

View file

@ -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">

View file

@ -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 { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Card, HideAmountsToggle, Icon, IconChip, MercuryButton } from "@/ds"; import { Card, HideAmountsToggle, Icon, IconChip, MercuryButton, ThemeToggle } from "@/ds";
import type { IconName } from "@/ds"; import type { IconName } from "@/ds";
import { useMe, useSettings } from "@/api/hooks/reads"; import { useMe, useSettings } from "@/api/hooks/reads";
import { profileStrings } from "./strings"; import { profileStrings } from "./strings";
@ -101,6 +101,19 @@ export function ProfileView() {
</nav> </nav>
</Card> </Card>
<Card style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{profileStrings.display.themeMode}
</span>
<ThemeToggle
labels={{
system: profileStrings.display.themeSystem,
light: profileStrings.display.themeLight,
dark: profileStrings.display.themeDark,
}}
/>
</Card>
<Card> <Card>
<ConnectedBanks /> <ConnectedBanks />
</Card> </Card>

View file

@ -19,6 +19,10 @@ export const profileStrings = {
display: { display: {
title: "Дэлгэц ба нууцлал", title: "Дэлгэц ба нууцлал",
hideAmounts: "Үнийн дүн нуух", hideAmounts: "Үнийн дүн нуух",
themeMode: "Дэлгэцийн горим",
themeSystem: "Систем",
themeLight: "Гэрэл",
themeDark: "Харанхуй",
}, },
banks: { banks: {
title: "Банкууд", title: "Банкууд",