diff --git a/apps/web/src/modules/finyk/pages/Overview.tsx b/apps/web/src/modules/finyk/pages/Overview.tsx index aedb84b07..3e96d0cd8 100644 --- a/apps/web/src/modules/finyk/pages/Overview.tsx +++ b/apps/web/src/modules/finyk/pages/Overview.tsx @@ -32,9 +32,14 @@ import { } from "@sergeant/finyk-domain/domain/budget"; import { filterStatTransactions } from "@sergeant/finyk-domain/domain/transactions"; import { Skeleton } from "@shared/components/ui/Skeleton"; +import { + DataState, + type DataStateQueryLike, +} from "@shared/components/ui/DataState"; import { safeReadStringLS, safeWriteLS } from "@shared/lib/storage/storage"; import { THEME_HEX } from "@shared/lib/ui/themeHex"; import { SyncStatusBadge } from "../components/SyncStatusBadge"; +import type { Transaction } from "@sergeant/finyk-domain/domain/types"; import { FirstInsightBanner } from "./overview/FirstInsightBanner"; import { HeroCard } from "./overview/HeroCard"; @@ -348,18 +353,26 @@ export function Overview({ [subscriptionFlows, debtOutFlows, debtInFlows, currentYear, currentMonth], ); - if (loadingTx && realTx.length === 0) { - return ( -
-
- - - - -
+ // DataState contract: `data === undefined` triggers the skeleton slot. + // We treat the very first month load (no realTx yet) as loading; + // subsequent background refetches keep `data` defined so the page stays + // visible while a stale-revalidate happens. Mirrors the prior + // `if (loadingTx && realTx.length === 0)` early-return guard exactly. + const overviewQuery: DataStateQueryLike = { + data: loadingTx && realTx.length === 0 ? undefined : realTx, + isLoading: loadingTx, + }; + + const overviewLoadingSkeleton = ( +
+
+ + + +
- ); - } +
+ ); const recurringOutThisMonth = monthFlows .filter( @@ -400,78 +413,82 @@ export function Overview({ }); return ( -
-
- {(clientInfo || - syncState?.status === "error" || - syncState?.status === "loading" || - monoError) && ( - - )} - - {showFirstInsight && hasAnyData && ( - - )} - - - - - - - - - - {})} - showBalance={showBalance} - /> - - {loadingTx && ( -

Оновлення…

- )} -
-
+ + {() => ( +
+
+ {(clientInfo || + syncState?.status === "error" || + syncState?.status === "loading" || + monoError) && ( + + )} + + {showFirstInsight && hasAnyData && ( + + )} + + + + + + + + + + {})} + showBalance={showBalance} + /> + + {loadingTx && ( +

Оновлення…

+ )} +
+
+ )} +
); } diff --git a/apps/web/src/modules/finyk/pages/budgets/Budgets.tsx b/apps/web/src/modules/finyk/pages/budgets/Budgets.tsx index 30f5c4878..944fec008 100644 --- a/apps/web/src/modules/finyk/pages/budgets/Budgets.tsx +++ b/apps/web/src/modules/finyk/pages/budgets/Budgets.tsx @@ -1,6 +1,10 @@ import { useMemo, useState, useCallback, useEffect, useRef } from "react"; import type { Dispatch, SetStateAction } from "react"; import { Skeleton, SkeletonBudgetBar } from "@shared/components/ui/Skeleton"; +import { + DataState, + type DataStateQueryLike, +} from "@shared/components/ui/DataState"; import { calcCategorySpent } from "../../utils"; import { computeFinykSchedule, startOfToday } from "../../lib/upcomingSchedule"; import { FinykStatsStrip } from "../../components/FinykStatsStrip"; @@ -305,22 +309,29 @@ export function Budgets({ resetForm(); }; - if (loadingTx && realTx.length === 0) { - return ( -
- {/* Shape-aware: header bar + 3 budget rows so the layout doesn't - reflow when data lands. */} - - - - -
- ); - } + // DataState contract: `data === undefined` triggers the skeleton slot. + // First-paint of the Budgets page treats "loading and no realTx yet" as + // initial-load; once data lands we keep rendering even on background + // refetches so the page never blanks out. + const budgetsQuery: DataStateQueryLike = { + data: loadingTx && realTx.length === 0 ? undefined : realTx, + isLoading: loadingTx, + }; + + const budgetsLoadingSkeleton = ( +
+ {/* Shape-aware: header bar + 3 budget rows so the layout doesn't + reflow when data lands. */} + + + + +
+ ); const { remaining: remaining2, @@ -334,89 +345,93 @@ export function Budgets({ ); return ( -
-
- {/* Сума підписок + Наступний платіж з тих самих даних, що й на + + {() => ( +
+
+ {/* Сума підписок + Наступний платіж з тих самих даних, що й на сторінці Активи — без пасив-з-дедлайном тайлу (у Плануванні це не релевантно). Зникає цілком, якщо обидва слоти пусті. */} - + - + - + - + - {showForm ? ( - - ) : ( - - )} -
-
+ {showForm ? ( + + ) : ( + + )} +
+
+ )} + ); } diff --git a/apps/web/src/modules/finyk/pages/transactions/TransactionList.test.tsx b/apps/web/src/modules/finyk/pages/transactions/TransactionList.test.tsx new file mode 100644 index 000000000..6677bcb05 --- /dev/null +++ b/apps/web/src/modules/finyk/pages/transactions/TransactionList.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; + +// `GroupedVirtuoso` needs ResizeObserver / a real layout to render its +// items. For the DataState routing test we only care which slot is +// rendered (skeleton / empty / list) — a trivial mock that surfaces +// children via `groupContent` + `itemContent` is enough. +vi.mock("react-virtuoso", () => ({ + GroupedVirtuoso: ({ + groupCounts, + groupContent, + itemContent, + }: { + groupCounts: number[]; + groupContent: (i: number) => React.ReactNode; + itemContent: (i: number) => React.ReactNode; + }) => { + const total = groupCounts.reduce((s, n) => s + n, 0); + return ( +
+ {groupCounts.map((_, gi) => ( +
{groupContent(gi)}
+ ))} + {Array.from({ length: total }).map((_, i) => ( +
{itemContent(i)}
+ ))} +
+ ); + }, +})); + +import { TransactionList } from "./TransactionList"; +import type { Transaction } from "@sergeant/finyk-domain/domain/types"; + +/** + * Common no-op handlers + maps that every render needs but we don't + * exercise in DataState routing tests. Hoisted so each test stays + * focused on the props that change branch. + */ +const NOOP = (): void => undefined; +const baseProps = { + groupedByDate: [] as { key: string; items: Transaction[] }[], + groupCounts: [] as number[], + flatItems: [] as Transaction[], + collapsedKeys: new Set(), + daySummaries: {}, + showBalance: true, + toggleDay: NOOP, + selectMode: false, + selectedIds: new Set(), + hiddenTxIdSet: new Set(), + txCategories: {}, + txSplits: {}, + accounts: undefined, + customCategories: undefined, + onToggleSelect: NOOP, + onSwipeHideTx: NOOP, + onSwipeDeleteManual: NOOP, + onEditManual: NOOP, + onHideTx: NOOP, + onCatChange: NOOP, + onSplitChange: NOOP, +}; + +const SAMPLE_TX: Transaction = { + id: "tx-1", + date: "2026-05-04", + description: "Сільпо", + amount: -250, + account: "mono-1", +} as unknown as Transaction; + +describe("TransactionList — DataState routing", () => { + // The shared web vitest setup (`src/test/setup.ts`) does not auto-run + // `cleanup()` between tests — it stays focused on MSW lifecycle. + // Each render here mounts the same scrollable shell, so without + // explicit cleanup test N leaks DOM into test N+1 and the assertions + // matching by text/testid see duplicates from the previous case. + afterEach(() => cleanup()); + + it("renders the skeleton slot when first-paint loading and activeTx is empty", () => { + render( + , + ); + + // The skeleton block is the only rendered branch — the live region + // we attach is the most stable assertion target. + const skeletons = document.querySelectorAll('[aria-busy="true"]'); + expect(skeletons.length).toBeGreaterThan(0); + + // The empty-state title and the virtualized list must NOT be + // rendered while skeleton is on. + expect(screen.queryByText("Немає транзакцій")).not.toBeInTheDocument(); + expect(screen.queryByTestId("grouped-virtuoso")).not.toBeInTheDocument(); + }); + + it("renders the empty slot when not loading and filtered list is empty (with activeTx present)", () => { + render( + , + ); + + expect(screen.getByText("Немає транзакцій")).toBeInTheDocument(); + expect(screen.queryByTestId("grouped-virtuoso")).not.toBeInTheDocument(); + }); + + it("renders the virtualized list when filtered has rows", () => { + render( + , + ); + + expect(screen.getByTestId("grouped-virtuoso")).toBeInTheDocument(); + expect(screen.queryByText("Немає транзакцій")).not.toBeInTheDocument(); + }); + + it("keeps the list visible during a background refetch (loading=true with prior activeTx)", () => { + // Stale-revalidate: a refetch is in flight but we already have a + // payload from the previous tick. The list must NOT collapse to + // the skeleton slot — that's the core of the DataState contract + // (`data` stays defined while `isLoading` is true). + render( + , + ); + + expect(screen.getByTestId("grouped-virtuoso")).toBeInTheDocument(); + expect(document.querySelectorAll('[aria-busy="true"]')).toHaveLength(0); + }); +}); diff --git a/apps/web/src/modules/finyk/pages/transactions/TransactionList.tsx b/apps/web/src/modules/finyk/pages/transactions/TransactionList.tsx index d95a84b6a..b83154fcc 100644 --- a/apps/web/src/modules/finyk/pages/transactions/TransactionList.tsx +++ b/apps/web/src/modules/finyk/pages/transactions/TransactionList.tsx @@ -6,6 +6,10 @@ import { SkeletonTransactionRow } from "@shared/components/ui/Skeleton"; import { EmptyState } from "@shared/components/ui/EmptyState"; import { FinykEmptyIllustration } from "@shared/components/ui/EmptyStateIllustrations"; import { PullToRefresh } from "@shared/components/ui/PullToRefresh"; +import { + DataState, + type DataStateQueryLike, +} from "@shared/components/ui/DataState"; import { cn } from "@shared/lib/ui/cn"; import { TransactionDayHeader } from "./TransactionDayHeader"; import type { computeDaySummary } from "./transactionsLib"; @@ -108,107 +112,127 @@ export function TransactionList({ }: TransactionListProps) { const [scrollParent, setScrollParent] = useState(null); - const content = ( -
- {header} - {/* Skeleton — shape-aware: matches a real TxRow (icon · 2-line - description · amount). Stagger fades down so the list feels - like it's "loading from the top" instead of pulsing as a slab. */} - {loading && activeTx.length === 0 && ( -
- {Array(10) - .fill(0) - .map((_, i) => ( - - ))} -
- )} + // DataState contract: + // - `data === undefined` triggers the skeleton slot. We mark the + // query as still-loading only on the very first paint, when the + // parent month list is empty (`activeTx.length === 0`); subsequent + // background refetches keep `data` defined so the existing list + // stays visible while a stale-revalidate happens. + // - `isEmpty` reads the post-filter list (`filtered`) so the empty + // slot shows when filters/exclusions hide every row, not when the + // month payload itself is empty (which the skeleton already covers + // during the first paint). + const txQuery: DataStateQueryLike = { + data: loading && activeTx.length === 0 ? undefined : filtered, + isLoading: loading, + }; - {/* Empty */} - {filtered.length === 0 && !loading && ( -
- } - title="Немає транзакцій" - description="Зміни місяць, фільтр або переключи «приховані», якщо вони є." + const skeleton = ( + // Skeleton — shape-aware: matches a real TxRow (icon · 2-line + // description · amount). Stagger fades down so the list feels + // like it's "loading from the top" instead of pulsing as a slab. +
+ {Array(10) + .fill(0) + .map((_, i) => ( + -
- )} + ))} +
+ ); - {/* Virtualized list */} - {filtered.length > 0 && ( -
- { - const group = groupedByDate[groupIndex]; - if (!group) return null; - const key = group.key; - const collapsed = collapsedKeys.has(key); - const summary = daySummaries[key] ?? { - total: 0, - count: 0, - statCount: 0, - }; - // Коли у день є тільки «не в статистиці» транзакції, сховати - // суму — інакше побачимо «0,00₴» або (як раніше) злиплі - // перекази у вигляді доходу. - const showTotal = showBalance && summary.statCount > 0; - return ( - - ); - }} - itemContent={(index) => { - const t = flatItems[index]; - if (!t) return null; - const rowTx = t as TxRowTx; - return ( -
- )} + const emptyFallback = ( +
+ } + title="Немає транзакцій" + description="Зміни місяць, фільтр або переключи «приховані», якщо вони є." + /> +
+ ); + + const content = ( +
+ {header} + data.length === 0} + > + {() => ( +
+ { + const group = groupedByDate[groupIndex]; + if (!group) return null; + const key = group.key; + const collapsed = collapsedKeys.has(key); + const summary = daySummaries[key] ?? { + total: 0, + count: 0, + statCount: 0, + }; + // Коли у день є тільки «не в статистиці» транзакції, сховати + // суму — інакше побачимо «0,00₴» або (як раніше) злиплі + // перекази у вигляді доходу. + const showTotal = showBalance && summary.statCount > 0; + return ( + + ); + }} + itemContent={(index) => { + const t = flatItems[index]; + if (!t) return null; + const rowTx = t as TxRowTx; + return ( +
+ )} +
{trailing}