feat(onboarding): step cards of the Categories and Reports pages (#432) #445

Merged
maximus merged 2 commits from issue-432-cards-categories-reports into main 2026-10-16 00:38:00 +00:00
11 changed files with 717 additions and 23 deletions
Showing only changes of commit 00be2877f3 - Show all commits

View file

@ -21,22 +21,6 @@ vi.mock("react-router-dom", async (importOriginal) => ({
useNavigate: () => ctx.navigate,
}));
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
// /categories guides no step before its card ships (#432): a stand-in page
// that guides the categories step, so the wiring of "Got it" is exercised now.
vi.mock("../../utils/onboardingPath", async (importOriginal) => {
const orig = await importOriginal<typeof import("../../utils/onboardingPath")>();
return {
...orig,
cardStep: (...args: Parameters<typeof orig.cardStep>) => {
const [checklist, pathname] = args;
if (pathname !== "/stand-in-categories") return orig.cardStep(checklist, pathname);
const unlocked = checklist.items.filter((item) => !item.locked);
const step = unlocked.find((item) => item.key === "categories")!;
return { step, n: unlocked.indexOf(step) + 1, total: unlocked.length };
},
};
});
import { hookHarness } from "../../test/hookHarness";
import OnboardingStepCard from "./OnboardingStepCard";
import StepCard, { type StepCardProps } from "./StepCard";
@ -183,10 +167,10 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
expect(render()!.props.onAcknowledge).toBeUndefined();
});
it("'Got it' on a visit step acknowledges the step the card shows (#425)", () => {
ctx.location = { pathname: "/stand-in-categories", state: null, key: "arrival-1" };
it("'Got it' on the Categories card acknowledges the categories step (#425, #432)", () => {
ctx.location = { pathname: "/categories", state: null, key: "arrival-1" };
ctx.onboarding = path(IMPORTED, {
cardPage: "/stand-in-categories",
cardPage: "/categories",
checklist: computeChecklist(IMPORTED, {
importFolderSet: true,
budgetAllowed: true,
@ -203,6 +187,26 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
expect(value.skip).not.toHaveBeenCalled();
});
it("'Got it' on the Reports card acknowledges the reports step, last of eight (#432)", () => {
const allButReports: OnboardingCounters = { ...IMPORTED, manualCategorized: 1, budgetEntries: 1 };
ctx.location = { pathname: "/reports", state: null, key: "arrival-1" };
ctx.onboarding = path(allButReports, {
cardPage: "/reports",
checklist: computeChecklist(allButReports, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["categories"],
}),
});
const value = ctx.onboarding as OnboardingValue;
const props = render()!.props;
expect(props.step.key).toBe("reports");
expect(props.position).toEqual({ n: 8, total: 8 });
props.onAcknowledge!();
expect(value.acknowledge).toHaveBeenCalledWith("reports");
});
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
const value = ctx.onboarding as OnboardingValue;
const props = render()!.props;

View file

@ -0,0 +1,66 @@
// HubReportNavCard — the report card of the hub and its getting-started props
// (#432). No jsdom: react-dom/server renders the markup, and the component
// called as a function exposes the props of the link it returns (the
// FilterPanel.test.tsx pattern), for the click handler.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import type { ReactElement } from "react";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import HubReportNavCard, { REPORTS_TRENDS_TARGET, type HubReportNavCardProps } from "./HubReportNavCard";
const BASE: HubReportNavCardProps = {
to: "/reports/trends",
icon: null,
title: "Tendances",
description: "Évolution",
};
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const render = (props: Partial<HubReportNavCardProps> = {}) =>
renderToStaticMarkup(
<MemoryRouter>
<HubReportNavCard {...BASE} {...props} />
</MemoryRouter>,
);
/** The link the card returns, read without rendering it. */
const link = (props: Partial<HubReportNavCardProps> = {}) =>
HubReportNavCard({ ...BASE, ...props }) as ReactElement<{ onClick?: () => void; to: string }>;
describe("HubReportNavCard — the getting-started target (#432)", () => {
it("carries the highlight on its link with onboardingTarget", () => {
const html = render({ onboardingTarget: true });
expect(html).toContain(`data-onboarding-target="${REPORTS_TRENDS_TARGET}" ${HIGHLIGHT}`);
expect(html.indexOf(HIGHLIGHT)).toBeLessThan(html.indexOf('href="/reports/trends"'));
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
});
it("carries no highlight without it", () => {
for (const onboardingTarget of [undefined, false]) {
const html = render({ onboardingTarget });
expect(html).not.toContain("data-onboarding-target");
expect(html).not.toContain("data-onboarding-highlight");
}
});
it("a click calls onNavigate when it is given", () => {
const onNavigate = vi.fn();
const element = link({ onNavigate });
expect(element.props.to).toBe("/reports/trends");
element.props.onClick!();
expect(onNavigate).toHaveBeenCalledTimes(1);
});
it("without onNavigate, the link has no click handler of its own", () => {
expect(link().props.onClick).toBeUndefined();
});
it("keeps its lock badge, translated", () => {
expect(render({ locked: true })).toContain('aria-label="t(nav.locked)"');
expect(render()).not.toContain("t(nav.locked)");
});
});

View file

@ -2,6 +2,10 @@ import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { Lock } from "lucide-react";
import { useTranslation } from "react-i18next";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
/** The target key of the Trends card while the Reports step card points at it (#432). */
export const REPORTS_TRENDS_TARGET = "reports-trends-card";
export interface HubReportNavCardProps {
to: string;
@ -13,6 +17,13 @@ export interface HubReportNavCardProps {
* entitled). The card stays clickable — the gated route renders the upsell.
*/
locked?: boolean;
/**
* The getting-started card of the Reports page points at this card (#432):
* the link carries the highlight attributes.
*/
onboardingTarget?: boolean;
/** Called on a click, before the navigation (the Trends card acknowledges the step, D17). */
onNavigate?: () => void;
}
export default function HubReportNavCard({
@ -21,12 +32,16 @@ export default function HubReportNavCard({
title,
description,
locked,
onboardingTarget,
onNavigate,
}: HubReportNavCardProps) {
const { t } = useTranslation();
return (
<Link
to={to}
onClick={onNavigate}
{...onboardingTargetAttributes(REPORTS_TRENDS_TARGET, onboardingTarget)}
className="relative group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
>
{locked && (

View file

@ -98,6 +98,11 @@
"budget": {
"collapsed": "Click “{{expand}}” to show your categories. Only the last-level ones, without a chevron, take an amount: click a month and type it (Enter saves; Tab saves and moves to the next month). An amount typed in the “{{annual}}” column is split evenly over the 12 months and replaces the monthly amounts.",
"flat": "Only last-level categories, without a chevron, take an amount: click a month and type it (Enter saves; Tab saves and moves to the next month). An amount typed in the “{{annual}}” column is split evenly over the 12 months and replaces the monthly amounts."
},
"categories": "A keyword categorizes, as soon as it is imported, every transaction whose description contains it; when two keywords apply, the one with the higher priority wins. “{{allKeywords}}” lists them all, with their category. In the next step, you will add one from a transaction.",
"reports": {
"all": "Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “{{trends}}”.",
"locked": "Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “{{trends}}”. Cards with a padlock require the Base edition."
}
},
"state": {

View file

@ -98,6 +98,11 @@
"budget": {
"collapsed": "Cliquez « {{expand}} » pour afficher vos catégories. Seules celles du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « {{annual}} » est réparti également sur les 12 mois et remplace les montants mensuels.",
"flat": "Seules les catégories du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « {{annual}} » est réparti également sur les 12 mois et remplace les montants mensuels."
},
"categories": "Un mot-clé classe d'office, dès l'import, chaque transaction dont la description le contient ; quand deux mots-clés s'appliquent, le plus prioritaire l'emporte. « {{allKeywords}} » les liste tous, avec leur catégorie. À l'étape suivante, vous en ajouterez un depuis une transaction.",
"reports": {
"all": "Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « {{trends}} ».",
"locked": "Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « {{trends}} ». Les cartes à cadenas demandent l'édition Base."
}
},
"state": {

View file

@ -0,0 +1,152 @@
// CategoriesPage and its getting-started card (#432). No jsdom:
// react-dom/server renders in vitest's node environment. The categories hook
// is stubbed (the page is loading, its header shows), and the path is a fake
// `useOnboarding()` built from the real checklist. What is pinned is the
// page's wiring: where the card sits, what it says, and which control carries
// the one highlight ("All keywords", never "Reset").
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
const harness = vi.hoisted(() => ({ onboarding: null as unknown }));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useCategories", () => ({
useCategories: () => ({
state: {
categories: [],
tree: [],
keywords: [],
selectedCategoryId: null,
editingCategory: null,
isCreating: false,
isLoading: true,
isSaving: false,
error: null,
},
}),
}));
import CategoriesPage, { CATEGORIES_ALL_KEYWORDS_TARGET } from "./CategoriesPage";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
import { computeChecklist } from "../utils/onboardingChecklist";
import { nextStep } from "../utils/onboardingPath";
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
/** Folder, source and import done: the next step is the categories step. */
const IMPORTED: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 0,
budgetEntries: 0,
transactions: 41,
};
/** The path latched on /categories (card mode), as the provider shares it. */
function path(acknowledged: ChecklistItemKey[], overrides: Partial<OnboardingValue> = {}): OnboardingValue {
const checklist = computeChecklist(IMPORTED, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged,
});
return {
status: "ready",
checklist,
counters: IMPORTED,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/categories",
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
}
const render = () =>
renderToStaticMarkup(
<MemoryRouter initialEntries={["/categories"]}>
<CategoriesPage />
</MemoryRouter>,
);
const CARD = 'aria-label="t(onboarding.banner.label)"';
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
beforeEach(() => {
harness.onboarding = path([]);
});
describe("CategoriesPage — the step card (#432)", () => {
it("renders the card first, before the page's heading, fifth of eight", () => {
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=8])");
expect(html).toContain("t(onboarding.checklist.items.categories)");
});
it("explains keywords, 'All keywords' named by its own label", () => {
expect(sentence(render())).toBe(
"t(onboarding.card.categories[allKeywords=t(categories.allKeywords)])",
);
});
it("offers 'Got it' and 'Skip this step'", () => {
const html = render();
expect(html).toContain("t(onboarding.card.acknowledge)");
expect(html).toContain("t(onboarding.card.skip)");
});
it("'All keywords' is the one target, never 'Reset'", () => {
const html = render();
expect(count(html, HIGHLIGHT)).toBe(1);
expect(html).toContain(`data-onboarding-target="${CATEGORIES_ALL_KEYWORDS_TARGET}" ${HIGHLIGHT}`);
const ring = html.indexOf(HIGHLIGHT);
const allKeywords = html.indexOf("t(categories.allKeywords)</button>");
const reset = html.indexOf("t(categories.reinitialize)</button>");
// The ring sits on the button whose label closes right after it, before "Reset".
expect(ring).toBeLessThan(allKeywords);
expect(html.lastIndexOf("<button", allKeywords)).toBeLessThan(ring);
expect(ring).toBeLessThan(reset);
});
it("the step acknowledged: 'Done.', the next step is the manual category, no ring", () => {
harness.onboarding = path(["categories"]);
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html).toContain("t(onboarding.card.done)");
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
expect(html).not.toContain("t(onboarding.card.acknowledge)");
expect(html).not.toContain(HIGHLIGHT);
});
it("without the card on screen, no card and no ring", () => {
for (const overrides of [{ mode: "banner" as const }, { cardPage: null }, { cardPage: "/import" }]) {
harness.onboarding = path([], overrides);
const html = render();
expect(html, JSON.stringify(overrides)).not.toContain(CARD);
expect(html, JSON.stringify(overrides)).not.toContain(HIGHLIGHT);
}
});
it("opening the page acknowledges nothing", () => {
const value = harness.onboarding as OnboardingValue;
render();
expect(value.acknowledge).not.toHaveBeenCalled();
expect(value.skip).not.toHaveBeenCalled();
});
});

View file

@ -1,11 +1,22 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
import { useCategories } from "../hooks/useCategories";
import CategoryTree from "../components/categories/CategoryTree";
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { useOnboarding } from "../contexts/OnboardingContext";
import {
categoriesCardModel,
onboardingTargetAttributes,
showsStepCard,
} from "../utils/onboardingPath";
/** The target key of "All keywords" while the Categories step card points at it (#432). */
export const CATEGORIES_ALL_KEYWORDS_TARGET = "categories-all-keywords";
export default function CategoriesPage() {
const { t } = useTranslation();
@ -26,6 +37,16 @@ export default function CategoriesPage() {
moveCategory,
} = useCategories();
// The getting-started card (#432) points at "All keywords" while it is on
// screen, never at "Reset" (it deletes the keywords and uncategorizes every
// transaction).
const location = useLocation();
const onboarding = useOnboarding();
const cardModel = categoriesCardModel(onboarding.checklist);
const target = showsStepCard(onboarding.mode, onboarding.cardPage, location.pathname)
? cardModel.target
: null;
const handleReinitialize = async () => {
setShowReinitConfirm(false);
await reinitializeCategories();
@ -38,6 +59,8 @@ export default function CategoriesPage() {
return (
<div>
<OnboardingStepCard model={cardModel} />
<div className="relative flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("categories.title")}</h1>
@ -46,6 +69,7 @@ export default function CategoriesPage() {
<div className="flex items-center gap-2">
<button
onClick={() => setShowAllKeywords((v) => !v)}
{...onboardingTargetAttributes(CATEGORIES_ALL_KEYWORDS_TARGET, target === "allKeywords")}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
showAllKeywords
? "border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]"

View file

@ -0,0 +1,178 @@
// ReportsPage and its getting-started card (#432). No jsdom:
// react-dom/server renders in vitest's node environment. The highlights and
// the entitlement are stubbed, the path is a fake `useOnboarding()` built from
// the real checklist, and the report cards are the real ones, their props
// recorded on the way. What is pinned is the page's wiring: where the card
// sits, the padlock variant, the Trends card as the one target, and its click
// acknowledging the step (D17).
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import type { HubReportNavCardProps } from "../components/reports/HubReportNavCard";
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
vi.mock("../components/reports/HubHighlightsPanel", () => ({ default: () => null }));
const harness = vi.hoisted(() => ({
onboarding: null as unknown,
entitlement: { allowed: true, ready: true },
cards: [] as HubReportNavCardProps[],
}));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useEntitlement", () => ({ useEntitlement: () => harness.entitlement }));
vi.mock("../hooks/useHighlights", () => ({
useHighlights: () => ({ data: null, isLoading: false, error: null }),
}));
vi.mock("../components/reports/HubReportNavCard", async (importOriginal) => {
const actual = await importOriginal<typeof import("../components/reports/HubReportNavCard")>();
return {
...actual,
default: (props: HubReportNavCardProps) => {
harness.cards.push(props);
return actual.default(props);
},
};
});
import ReportsPage from "./ReportsPage";
import { REPORTS_TRENDS_TARGET } from "../components/reports/HubReportNavCard";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
import { computeChecklist } from "../utils/onboardingChecklist";
import { nextStep } from "../utils/onboardingPath";
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
/** Every data step done: only the reports step is left. */
const ALL_DATA: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 1,
budgetEntries: 1,
transactions: 41,
};
/** The path latched on /reports (card mode), as the provider shares it. */
function path(
acknowledged: ChecklistItemKey[],
overrides: Partial<OnboardingValue> = {},
budgetAllowed = true,
): OnboardingValue {
const checklist = computeChecklist(ALL_DATA, {
importFolderSet: true,
budgetAllowed,
visitStepsInitialized: true,
acknowledged,
});
return {
status: "ready",
checklist,
counters: ALL_DATA,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/reports",
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
}
const render = () =>
renderToStaticMarkup(
<MemoryRouter initialEntries={["/reports"]}>
<ReportsPage />
</MemoryRouter>,
);
const CARD = 'aria-label="t(onboarding.banner.label)"';
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
const trends = () => harness.cards.find((card) => card.to.startsWith("/reports/trends"))!;
beforeEach(() => {
harness.onboarding = path(["categories"]);
harness.entitlement = { allowed: true, ready: true };
harness.cards = [];
});
describe("ReportsPage — the step card (#432)", () => {
it("renders the card first, before the page's heading, last of eight", () => {
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
expect(html).toContain("t(onboarding.card.stepOf[n=8,total=8])");
expect(html).toContain("t(onboarding.checklist.items.reports)");
expect(html).toContain("t(onboarding.card.acknowledge)");
});
it("every report open: the sentence without the padlock, Trends named by its own label", () => {
expect(sentence(render())).toBe("t(onboarding.card.reports.all[trends=t(reports.hub.trends)])");
});
it("the Free edition: the sentence that mentions the padlock, last of seven", () => {
harness.entitlement = { allowed: false, ready: true };
harness.onboarding = path(["categories"], {}, false);
const html = render();
expect(sentence(html)).toBe("t(onboarding.card.reports.locked[trends=t(reports.hub.trends)])");
expect(html).toContain("t(onboarding.card.stepOf[n=7,total=7])");
});
it("the license not read yet: no padlock in the sentence, as on the cards", () => {
harness.entitlement = { allowed: false, ready: false };
expect(sentence(render())).toBe("t(onboarding.card.reports.all[trends=t(reports.hub.trends)])");
});
it("the Trends card is the one target", () => {
const html = render();
expect(count(html, HIGHLIGHT)).toBe(1);
expect(html).toContain(`data-onboarding-target="${REPORTS_TRENDS_TARGET}" ${HIGHLIGHT}`);
const ring = html.indexOf(HIGHLIGHT);
expect(html.indexOf('href="/reports/trends"', ring)).toBeGreaterThan(ring);
expect(html.indexOf('href="/reports/compare"')).toBeGreaterThan(ring);
expect(html.indexOf('href="/reports/highlights"')).toBeLessThan(ring);
for (const card of harness.cards) {
if (card === trends()) continue;
expect(card.onboardingTarget, card.to).toBeUndefined();
expect(card.onNavigate, card.to).toBeUndefined();
}
});
it("opening the Trends card acknowledges the reports step (D17)", () => {
const value = harness.onboarding as OnboardingValue;
render();
expect(value.acknowledge).not.toHaveBeenCalled();
trends().onNavigate!();
expect(value.acknowledge).toHaveBeenCalledWith("reports");
expect(value.skip).not.toHaveBeenCalled();
});
it("the step acknowledged: 'Done.', the end message, no ring, the Trends card back to a plain link", () => {
harness.onboarding = path(["categories", "reports"]);
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html).toContain("t(onboarding.card.done)");
expect(html).toContain("t(onboarding.banner.complete)");
expect(html).not.toContain(HIGHLIGHT);
expect(trends().onNavigate).toBeUndefined();
});
it("without the card on screen, no card, no ring and no acknowledgement on the Trends card", () => {
for (const overrides of [{ mode: "banner" as const }, { cardPage: null }, { cardPage: "/budget" }]) {
harness.cards = [];
harness.onboarding = path(["categories"], overrides);
const html = render();
expect(html, JSON.stringify(overrides)).not.toContain(CARD);
expect(html, JSON.stringify(overrides)).not.toContain(HIGHLIGHT);
expect(trends().onNavigate, JSON.stringify(overrides)).toBeUndefined();
}
});
});

View file

@ -1,9 +1,13 @@
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Sparkles, TrendingUp, Scale, Search, LayoutDashboard } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
import HubReportNavCard from "../components/reports/HubReportNavCard";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { useOnboarding } from "../contexts/OnboardingContext";
import { reportsCardModel, showsStepCard } from "../utils/onboardingPath";
import { useEntitlement } from "../hooks/useEntitlement";
import { useHighlights } from "../hooks/useHighlights";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
@ -17,6 +21,17 @@ export default function ReportsPage() {
const { allowed: advancedAllowed, ready: licenseReady } = useEntitlement("reports-advanced");
const advancedLocked = licenseReady && !advancedAllowed;
// The getting-started card (#432) points at the Trends card while it is on
// screen and the step is not done; opening that card acknowledges the step
// (D17): following the ring leaves the page, which releases the card.
const location = useLocation();
const onboarding = useOnboarding();
const cardModel = reportsCardModel(onboarding.checklist, advancedLocked);
const trendsTargeted =
showsStepCard(onboarding.mode, onboarding.cardPage, location.pathname) &&
cardModel.target === "trendsCard";
const { acknowledge } = onboarding;
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const navCards = [
{
@ -31,6 +46,9 @@ export default function ReportsPage() {
icon: <TrendingUp size={24} />,
title: t("reports.hub.trends"),
description: t("reports.hub.trendsDescription"),
...(trendsTargeted
? { onboardingTarget: true, onNavigate: () => void acknowledge("reports") }
: {}),
},
{
to: `/reports/compare${preserveSearch}`,
@ -57,6 +75,8 @@ export default function ReportsPage() {
return (
<div>
<OnboardingStepCard model={cardModel} />
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("reports.hub.title")}</h1>

View file

@ -6,19 +6,23 @@ import {
bannerMode,
budgetCardModel,
cardNext,
categoriesCardModel,
cardStep,
importCardModel,
latchCardPage,
nextStep,
onboardingTargetAttributes,
readOnboardingNavState,
reportsCardModel,
showsStepCard,
stepNavState,
stepsOfPage,
transactionsCardModel,
type BannerModeInput,
type BudgetCardModel,
type CategoriesCardModel,
type ImportCardModel,
type ReportsCardModel,
type StepCardText,
type TransactionsCardModel,
} from "./onboardingPath";
@ -146,12 +150,14 @@ describe("nextStep (#396)", () => {
describe("stepsOfPage (#396)", () => {
it("maps each guided page to its steps, in checklist order", () => {
expect(stepsOfPage("/import")).toEqual(["importFolder", "source", "import"]);
expect(stepsOfPage("/categories")).toEqual(["categories"]);
expect(stepsOfPage("/transactions")).toEqual(["manualCategory"]);
expect(stepsOfPage("/budget")).toEqual(["budget"]);
expect(stepsOfPage("/reports")).toEqual(["reports"]);
});
it("gives no step to any other page, inherited object keys included", () => {
for (const page of ["/", "/settings/users", "/reports", "constructor", "__proto__", ""]) {
for (const page of ["/", "/settings/users", "/reports/trends", "constructor", "__proto__", ""]) {
expect(stepsOfPage(page)).toEqual([]);
}
});
@ -411,6 +417,12 @@ describe("CARD_PAGES (#398, #399, #400)", () => {
it("carries the Budget page", () => {
expect(CARD_PAGES.has("/budget")).toBe(true);
});
it("carries the Categories and Reports pages (#432), not a report's own page", () => {
expect(CARD_PAGES.has("/categories")).toBe(true);
expect(CARD_PAGES.has("/reports")).toBe(true);
expect(CARD_PAGES.has("/reports/trends")).toBe(false);
});
});
describe("showsStepCard (#398)", () => {
@ -477,8 +489,44 @@ describe("cardStep (#398)", () => {
expect(at(beforeInit, "/transactions")).toEqual({ key: "manualCategory", n: 5, total: 6 });
});
it("on /categories, the categories step, fifth of eight (#432)", () => {
const imported = { ...zero, sources: 1, imports: 1, transactions: 41 };
expect(at(checklistOf(imported, { importFolderSet: true, acknowledged: [] }), "/categories")).toEqual({
key: "categories",
n: 5,
total: 8,
});
// Acknowledged: the card keeps showing it (the page's last step), "Done." then says so.
expect(at(checklistOf(imported, { importFolderSet: true }), "/categories")).toEqual({
key: "categories",
n: 5,
total: 8,
});
});
it("on /reports, the reports step, last of eight, last of seven in the Free edition (#432)", () => {
const allButReports = checklistOf(
{ ...upToManual, budgetEntries: 1 },
{ importFolderSet: true, acknowledged: ["categories"] },
);
expect(at(allButReports, "/reports")).toEqual({ key: "reports", n: 8, total: 8 });
const free = checklistOf(upToManual, {
importFolderSet: true,
budgetAllowed: false,
acknowledged: ["categories"],
});
expect(at(free, "/reports")).toEqual({ key: "reports", n: 7, total: 7 });
});
it("is null on a visit step's page until the visit steps are initialized (#425, #432)", () => {
const beforeInit = checklistOf(zero, { acknowledged: [], visitStepsInitialized: false });
expect(cardStep(beforeInit, "/categories")).toBeNull();
expect(cardStep(beforeInit, "/reports")).toBeNull();
});
it("is null on a page that guides no unlocked step", () => {
expect(cardStep(checklistOf(zero), "/reports")).toBeNull();
expect(cardStep(checklistOf(zero), "/settings")).toBeNull();
expect(cardStep(checklistOf(zero), "/reports/trends")).toBeNull();
expect(cardStep(checklistOf(zero, { budgetAllowed: false }), "/budget")).toBeNull();
expect(cardStep(checklistOf(zero), "/budget")?.step.key).toBe("budget");
});
@ -505,6 +553,25 @@ describe("cardNext (#398)", () => {
}
expect(cardNext(step("budget"), "/budget", true)).toEqual({ kind: "wait" });
expect(cardNext(step("budget"), "/import", true).kind).toBe("step");
expect(cardNext(step("categories"), "/categories", true)).toEqual({ kind: "wait" });
expect(cardNext(step("reports"), "/reports", true)).toEqual({ kind: "wait" });
});
it("after the categories step, the next step is the manual category (#432)", () => {
const acknowledged = checklistOf(
{ ...zero, sources: 1, imports: 1, transactions: 41 },
{ importFolderSet: true, acknowledged: ["categories"] },
);
expect(cardNext(nextStep(acknowledged), "/categories", true)).toEqual({
kind: "step",
step: step("manualCategory"),
});
});
it("after the reports step, the last one, the end (#432)", () => {
const finished = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true });
expect(nextStep(finished)).toBeNull();
expect(cardNext(nextStep(finished), "/reports", true)).toEqual({ kind: "end" });
});
});
@ -793,6 +860,103 @@ describe("budgetCardModel (#400)", () => {
});
});
describe("categoriesCardModel (#432)", () => {
/** Imported, the categories step left: the card's sentence. */
const left = checklistOf({ ...zero, sources: 1, imports: 1, transactions: 41 }, { importFolderSet: true, acknowledged: [] });
const SENTENCE: CategoriesCardModel = {
text: { key: "onboarding.card.categories", labels: { allKeywords: "categories.allKeywords" } },
done: false,
target: "allKeywords",
};
it("the step left: what a keyword does, 'All keywords' named by its own label and pointed at", () => {
expect(categoriesCardModel(left)).toEqual(SENTENCE);
expect(categoriesCardModel(null)).toEqual(SENTENCE);
});
it("the step acknowledged: 'Done.', nothing to point at", () => {
const acknowledged = checklistOf(
{ ...zero, sources: 1, imports: 1, transactions: 41 },
{ importFolderSet: true, acknowledged: ["categories"] },
);
expect(categoriesCardModel(acknowledged)).toEqual({ text: null, done: true, target: null });
});
it("a skipped step is not a checked one", () => {
const skipped = checklistOf(
{ ...zero, sources: 1, imports: 1, transactions: 41 },
{ importFolderSet: true, acknowledged: [], skipped: ["categories"] },
);
expect(categoriesCardModel(skipped)).toEqual(SENTENCE);
});
it("reads the sentence in French and in English", () => {
expect(render(SENTENCE.text, "fr")).toBe(
"Un mot-clé classe d'office, dès l'import, chaque transaction dont la description le contient ; quand deux mots-clés s'appliquent, le plus prioritaire l'emporte. « Tous les mots-clés » les liste tous, avec leur catégorie. À l'étape suivante, vous en ajouterez un depuis une transaction.",
);
expect(render(SENTENCE.text, "en")).toBe(
"A keyword categorizes, as soon as it is imported, every transaction whose description contains it; when two keywords apply, the one with the higher priority wins. “All Keywords” lists them all, with their category. In the next step, you will add one from a transaction.",
);
});
});
describe("reportsCardModel (#432)", () => {
/** Every step done but the reports one. */
const left = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true, acknowledged: ["categories"] });
const ALL: ReportsCardModel = {
text: { key: "onboarding.card.reports.all", labels: { trends: "reports.hub.trends" } },
done: false,
target: "trendsCard",
};
const LOCKED: ReportsCardModel = {
text: { key: "onboarding.card.reports.locked", labels: { trends: "reports.hub.trends" } },
done: false,
target: "trendsCard",
};
it("every report open: the sentence without the padlock, Trends named and pointed at", () => {
expect(reportsCardModel(left, false)).toEqual(ALL);
});
it("the advanced reports locked (Free edition): the sentence that mentions the padlock", () => {
expect(reportsCardModel(left, true)).toEqual(LOCKED);
expect(reportsCardModel(null, true)).toEqual(LOCKED);
});
it("the step acknowledged: 'Done.', nothing to point at, locked or not", () => {
const finished = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true });
for (const advancedLocked of [true, false]) {
expect(reportsCardModel(finished, advancedLocked)).toEqual({ text: null, done: true, target: null });
}
});
it("a skipped step is not a checked one", () => {
const skipped = checklistOf(
{ ...upToManual, budgetEntries: 1 },
{ importFolderSet: true, acknowledged: ["categories"], skipped: ["reports"] },
);
expect(reportsCardModel(skipped, false)).toEqual(ALL);
});
it("reads the two sentences in French", () => {
expect(render(ALL.text, "fr")).toBe(
"Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « Tendances ».",
);
expect(render(LOCKED.text, "fr")).toBe(
"Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « Tendances ». Les cartes à cadenas demandent l'édition Base.",
);
});
it("reads the two sentences in English", () => {
expect(render(ALL.text, "en")).toBe(
"Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “Trends”.",
);
expect(render(LOCKED.text, "en")).toBe(
"Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “Trends”. Cards with a padlock require the Base edition.",
);
});
});
describe("onboardingTargetAttributes (#398)", () => {
it("marks the target and its highlight when on, nothing otherwise", () => {
expect(onboardingTargetAttributes("wizard-next", true)).toEqual({

View file

@ -27,19 +27,24 @@ export function nextStep(checklist: Checklist): ChecklistItem | null {
/**
* Routes whose page carries a step card. Each card issue adds its page (#398:
* Import, #399: Transactions, #400: Budget), and a page without a delivered
* card keeps the banner, so a partly merged stack never removes any guidance.
* Import, #399: Transactions, #400: Budget, #432: Categories and Reports), and
* a page without a delivered card keeps the banner, so a partly merged stack
* never removes any guidance.
*/
export const CARD_PAGES: ReadonlySet<string> = new Set<string>([
"/import",
"/categories",
"/transactions",
"/budget",
"/reports",
]);
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
"/import": ["importFolder", "source", "import"],
"/categories": ["categories"],
"/transactions": ["manualCategory"],
"/budget": ["budget"],
"/reports": ["reports"],
};
/** The steps a page guides, in checklist order (none for any other page). */
@ -439,6 +444,62 @@ export function budgetCardModel(
}
}
/** The control of the Categories page that the card points at. */
export type CategoriesCardTarget = "allKeywords";
export interface CategoriesCardModel extends StepCardModel {
/** The control to highlight, null for none. */
target: CategoriesCardTarget | null;
}
/**
* The Categories page's card (#432), a visit step done by "Got it" (#425):
* - the step checked: "Done.", nothing to point at;
* - otherwise, what a keyword does, "All keywords" named by its own label and
* pointed at, and the keyword the next step adds from a transaction. Never
* "Reset": it deletes the keywords and uncategorizes every transaction.
*/
export function categoriesCardModel(checklist: Checklist | null): CategoriesCardModel {
if (checked(checklist, "categories")) return { text: null, done: true, target: null };
return {
text: { key: "onboarding.card.categories", labels: { allKeywords: "categories.allKeywords" } },
done: false,
target: "allKeywords",
};
}
/** The control of the Reports page that the card points at. */
export type ReportsCardTarget = "trendsCard";
export interface ReportsCardModel extends StepCardModel {
/** The control to highlight, null for none. */
target: ReportsCardTarget | null;
}
/**
* The Reports page's card (#432), a visit step done by "Got it" or by opening
* the highlighted Trends card (D17):
* - the step checked: "Done.", nothing to point at;
* - otherwise, the period, the highlights panel and the report cards, Trends
* named by its own label and pointed at (it is never locked); with
* `advancedLocked` (the page's: license known and not entitled), the
* sentence also says the padlocked cards require the Base edition.
*/
export function reportsCardModel(
checklist: Checklist | null,
advancedLocked: boolean,
): ReportsCardModel {
if (checked(checklist, "reports")) return { text: null, done: true, target: null };
return {
text: {
key: advancedLocked ? "onboarding.card.reports.locked" : "onboarding.card.reports.all",
labels: { trends: "reports.hub.trends" },
},
done: false,
target: "trendsCard",
};
}
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
export interface OnboardingTargetAttributes {
"data-onboarding-target"?: string;