fix(budget): drop the grid's target once the grid leaves the page
On a profile without transactions whose import steps were skipped, the rows can land before the transaction count, both reads queueing behind the DB lock: the grid mounted, reported "Expand all", then gave way to the empty state, and the card kept "Click Expand all…" above it with no grid on the page. The page now passes no target while it shows the empty state, and a wiring test replays that sequence. Invisible in dev, where StrictMode reorders the two reads. The month test sets the clock to 23:00 on December 31, which is still December in UTC: under the CI runner's zone, a month read in UTC passed. The zone is pinned west of Greenwich, as in useImportWizard.test.ts. Review of #400 (PR #406). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ba71a6421d
commit
abc9076d23
3 changed files with 50 additions and 6 deletions
|
|
@ -5,7 +5,7 @@
|
||||||
// subtotal-position preference is read from a stubbed localStorage. What is
|
// subtotal-position preference is read from a stubbed localStorage. What is
|
||||||
// pinned is where the one highlight lands: "Expand all" or this month's cell
|
// pinned is where the one highlight lands: "Expand all" or this month's cell
|
||||||
// of the first editable row shown.
|
// of the first editable row shown.
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||||
|
|
@ -172,9 +172,23 @@ describe("BudgetTable — the getting-started card's target (#400)", () => {
|
||||||
expect(count(html, HIGHLIGHT)).toBe(1);
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the cell follows the clock's month", () => {
|
describe("in a timezone west of Greenwich", () => {
|
||||||
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
|
// Under TZ=UTC (the CI runner), 23:00 on December 31 is December both in
|
||||||
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
|
// local time and in UTC, so a month read in UTC would pass unnoticed. The
|
||||||
|
// zone is pinned as in useImportWizard.test.ts (#356).
|
||||||
|
const originalTz = process.env.TZ;
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.TZ = "America/Toronto";
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
if (originalTz === undefined) delete process.env.TZ;
|
||||||
|
else process.env.TZ = originalTz;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the cell follows the clock's month", () => {
|
||||||
|
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
|
||||||
|
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("subtotals at the bottom: still the first editable row shown", () => {
|
it("subtotals at the bottom: still the first editable row shown", () => {
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,13 @@ export default function BudgetPage() {
|
||||||
|
|
||||||
// The getting-started card follows the grid (#400). Its target is
|
// The getting-started card follows the grid (#400). Its target is
|
||||||
// highlighted only while the card is on screen: a ring needs the card that
|
// highlighted only while the card is on screen: a ring needs the card that
|
||||||
// explains it.
|
// explains it. The grid's last report does not outlive the grid: the rows
|
||||||
const cardModel = budgetCardModel({ target: gridTarget }, onboarding.checklist);
|
// can land before the transaction count (both reads queue behind the DB
|
||||||
|
// lock), so the grid may report, then give way to the empty state.
|
||||||
|
const cardModel = budgetCardModel(
|
||||||
|
{ target: isEmptyProfile ? null : gridTarget },
|
||||||
|
onboarding.checklist,
|
||||||
|
);
|
||||||
const highlight =
|
const highlight =
|
||||||
cardModel.target !== null && showsStepCard(onboarding.mode, onboarding.cardPage, pathname);
|
cardModel.target !== null && showsStepCard(onboarding.mode, onboarding.cardPage, pathname);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -183,4 +183,29 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
|
||||||
it("hands the grid the same callback at every render, so the grid does not report at every render", () => {
|
it("hands the grid the same callback at every render, so the grid does not report at every render", () => {
|
||||||
expect(render().report).toBe(render().report);
|
expect(render().report).toBe(render().report);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a profile without transactions: the grid's last report does not outlive the grid", () => {
|
||||||
|
// Steps 3 to 5 skipped: the budget step is next on a profile without any
|
||||||
|
// transaction. The rows can land before the count (both reads queue
|
||||||
|
// behind db.ts's lock): the grid mounts, reports "Expand all", then gives
|
||||||
|
// way to the empty state (review of #400).
|
||||||
|
const counters = { ...BUDGET_LEFT, imports: 0, manualCategorized: 0, transactions: 0 };
|
||||||
|
const checklist = computeChecklist(counters, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
skipped: ["source", "import", "manualCategory"],
|
||||||
|
});
|
||||||
|
harness.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
|
||||||
|
const budget = harness.budget as { state: BudgetState };
|
||||||
|
budget.state = { ...budget.state, totalInDb: null };
|
||||||
|
render().report("expandAll");
|
||||||
|
expect(render().model.text?.key).toBe("onboarding.card.budget.collapsed");
|
||||||
|
|
||||||
|
budget.state = { ...budget.state, totalInDb: 0 };
|
||||||
|
harness.cursor = 0;
|
||||||
|
const page = BudgetPage() as AnyElement;
|
||||||
|
expect(find(page, (el) => el.type === BudgetTable)).toHaveLength(0);
|
||||||
|
const [card] = find(page, (el) => el.type === OnboardingStepCard);
|
||||||
|
expect(card.props.model).toEqual({ text: null, done: false, target: null });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue