Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
e88233a
Make sortable headers keyboard accessible
WMP Sep 8, 2026
fc52ccd
Mark partial report table totals
WMP Sep 8, 2026
c67a4fe
Make allocation rows keyboard operable
WMP Sep 8, 2026
fc8f748
Translate scheduled transaction markers
WMP Sep 8, 2026
8d50ffc
Translate security type allocation labels
WMP Sep 9, 2026
8df0a10
Localize recurring expense metadata
WMP Sep 9, 2026
6fa5485
Honor date format in mobile reports
WMP Sep 9, 2026
398da4c
Carry report currency with uncategorized totals
WMP Sep 9, 2026
381f515
Localize budget trend month labels
WMP Sep 9, 2026
fb3f779
Share mobile table chrome classes
WMP Sep 9, 2026
3bd9a6c
Share sortable table column types
WMP Sep 9, 2026
e024ca0
Wrap realized gains tables on phones
WMP Sep 9, 2026
6b80df3
Wrap investment performance table on phones
WMP Sep 9, 2026
4a0f5d5
Sort Budget Health Score's Group column by the displayed label
claude Sep 9, 2026
5e04811
Guard the centralized mobile-table chrome constants
claude Sep 9, 2026
600c0a4
Wrap Spending by Category table on phones
claude Sep 9, 2026
0874f51
Wrap Income by Source table on phones
claude Sep 9, 2026
11f9eb9
Import the shared chrome constants in the two new report conversions
claude Sep 9, 2026
758c0f0
Wrap Dividend Yield Growth table on phones
claude Sep 9, 2026
aac5294
Wrap Security Performance table on phones
claude Sep 9, 2026
524f47f
Wrap Portfolio Value table on phones
claude Sep 9, 2026
7267246
Wrap Monthly Comparison table on phones
claude Sep 9, 2026
ab1f3ce
Wrap Geographic Allocation table on phones
claude Sep 9, 2026
c7ebfdc
Wrap Dividend Income tables on phones
claude Sep 10, 2026
f09b1b7
Wrap Monte Carlo tables on phones
claude Sep 10, 2026
281c25e
fix(ui): one keyboard-activation helper, and a formatMonth that canno…
claude Sep 10, 2026
f4ded83
fix(reports): a dividend total that does not drift, and investment fi…
claude Sep 10, 2026
50c2022
docs(ui): say why the focus ring is safe to keep in a .ts module
claude Sep 10, 2026
4078921
fix(reports): read the date-fns call's arguments instead of regexing …
claude Sep 10, 2026
a3b1a3b
Fix the report exports, keyboard rows and shared declarations
claude Sep 10, 2026
ad844ec
Read the Bill Payment History chart's month axis as a month name
claude Sep 10, 2026
c9cf097
fix(reports): a figure derived from a marked subtotal is marked too
claude Sep 10, 2026
7c83b1d
fix(budgets): finish the month-key migration, and put month axes on t…
claude Sep 10, 2026
b6e090c
fix(reports): stop shipping an English month label nobody reads
claude Sep 10, 2026
c421cb1
test(hooks): reach renderHook through the intl harness, not RTL directly
claude Sep 10, 2026
93b72e4
fix(fx): write the FX fallback guard from the rule, and say what INV-…
claude Sep 10, 2026
39ceff9
fix(reports): a dividend period that earned zero says zero, not "unkn…
claude Sep 10, 2026
9a80bee
test(reports): revive the Geographic Allocation exchange branch, whic…
claude Sep 10, 2026
5ce86da
fix(i18n): localize the scheduled transfer and split tooltips, and sc…
claude Sep 10, 2026
bcf60a4
test(ui): guard the phone-wrapped font size, and give the chrome scan…
claude Sep 10, 2026
c702d19
docs(reports): stop five stray comments describing the wrong declaration
claude Sep 10, 2026
eb49ceb
docs(reports): say "identical except the nowrap", because that is wha…
claude Sep 10, 2026
ac60dd0
Integrate the four review-fix packages
claude Sep 10, 2026
08b82b4
fix(budgets): update health history month-key tests
WMP Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions backend/src/budgets/budget-health-reports.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,12 @@ describe("BudgetHealthReportsService", () => {
);

expect(result).toHaveLength(2);
expect(result[0].month).toContain("Jan");
expect(result[1].month).toContain("Feb");
// Structure, not a label: a `Jan 2026` from the server was English on
// every locale and sorted alphabetically at the client. `YYYY-MM` also
// sorts chronologically as a string, which is what the two reports'
// month columns now order on.
expect(result[0].monthKey).toBe("2026-01");
expect(result[1].monthKey).toBe("2026-02");
// First period was under, second was over -> first should be higher
expect(result[0].score).toBeGreaterThan(result[1].score);
expect(["Excellent", "Good", "Needs Attention", "Off Track"]).toContain(
Expand Down Expand Up @@ -434,5 +438,40 @@ describe("BudgetHealthReportsService", () => {
savingsRate: 0,
});
});

/**
* The month was a server-rendered `Mmm YYYY` label and nothing in this
* suite looked at it, so shipping English months to 22 locales -- and a
* client-side sort that put Apr before Jan -- was invisible here. These
* assertions are deliberately clock-free: they check the SHAPE and the
* consecutiveness of the keys, both of which a label fails, rather than
* naming today's months.
*/
it("keys each month structurally and in calendar order", async () => {
const result = await service.getSavingsRate("user-1", "budget-1", 6);

const keys = result.map((point) => point.monthKey);
for (const key of keys) {
expect(key).toMatch(/^\d{4}-(0[1-9]|1[0-2])$/);
}

// `YYYY-MM` sorts chronologically as a plain string, which is what the
// report's month column now orders on; `Apr 2026` does not.
expect([...keys].sort()).toEqual(keys);

// Oldest first, one calendar month apart -- so the series really is the
// requested window and not six renderings of one label.
const asMonthNumber = (key: string) => {
const [year, month] = key.split("-").map(Number);
return year * 12 + month;
};
for (let i = 1; i < keys.length; i++) {
expect(asMonthNumber(keys[i]) - asMonthNumber(keys[i - 1])).toBe(1);
}

// The old label field is gone rather than kept as an alias: two names for
// one value is how a consumer goes on reading the English one.
expect(result[0]).not.toHaveProperty("month");
});
});
});
28 changes: 6 additions & 22 deletions backend/src/budgets/budget-health-reports.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,14 @@ import { BudgetPeriod, PeriodStatus } from "./entities/budget-period.entity";
import { Transaction } from "../transactions/entities/transaction.entity";
import { TransactionSplit } from "../transactions/entities/transaction-split.entity";
import { BudgetsService } from "./budgets.service";
import { getMonthEndYMD } from "../common/date-utils";
import { formatMonthKey, getMonthEndYMD } from "../common/date-utils";
import {
HealthScoreResult,
HealthScoreHistoryPoint,
SavingsRatePoint,
} from "./budget-reports.service";
import { roundMoney, roundToDecimals } from "../common/round.util";

const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];

@Injectable()
export class BudgetHealthReportsService {
private readonly logger = new Logger(BudgetHealthReportsService.name);
Expand Down Expand Up @@ -208,7 +193,7 @@ export class BudgetHealthReportsService {
const score = Math.min(100, Math.max(0, Math.round(rawScore)));

result.push({
month: this.formatPeriodMonth(period.periodStart),
monthKey: this.formatPeriodMonthKey(period.periodStart),
score,
label: this.getScoreLabel(score),
});
Expand Down Expand Up @@ -439,8 +424,7 @@ export class BudgetHealthReportsService {
const d = new Date(today.getFullYear(), today.getMonth() - i, 1);
const year = d.getFullYear();
const month = d.getMonth();
const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`;
const monthLabel = `${MONTH_NAMES[month].substring(0, 3)} ${year}`;
const monthKey = formatMonthKey(year, month + 1);

const income = incomeByMonth.get(monthKey) || 0;
const expenses = expenseByMonth.get(monthKey) || 0;
Expand All @@ -449,7 +433,7 @@ export class BudgetHealthReportsService {
income > 0 ? roundToDecimals((savings / income) * 100, 2) : 0;

result.push({
month: monthLabel,
monthKey,
income: roundMoney(income),
expenses: roundMoney(expenses),
savings: roundMoney(savings),
Expand Down Expand Up @@ -546,10 +530,10 @@ export class BudgetHealthReportsService {
return "Off Track";
}

private formatPeriodMonth(periodStart: string): string {
private formatPeriodMonthKey(periodStart: string): string {
const parts = periodStart.split("-");
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10);
return `${MONTH_NAMES[month - 1].substring(0, 3)} ${year}`;
return formatMonthKey(year, month);
}
}
20 changes: 12 additions & 8 deletions backend/src/budgets/budget-reports.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,11 +305,11 @@ describe("BudgetReportsService", () => {
const result = await service.getTrend("user-1", "budget-1", 6);

expect(result).toHaveLength(2);
expect(result[0].month).toBe("Dec 2025");
expect(result[0].monthKey).toBe("2025-12");
expect(result[0].budgeted).toBe(800);
expect(result[0].actual).toBe(750);
expect(result[0].variance).toBe(-50);
expect(result[1].month).toBe("Jan 2026");
expect(result[1].monthKey).toBe("2026-01");
expect(result[1].actual).toBe(820);
expect(result[1].variance).toBe(20);
});
Expand Down Expand Up @@ -353,7 +353,7 @@ describe("BudgetReportsService", () => {
const result = await service.getTrend("user-1", "budget-1", 6);

expect(result).toHaveLength(2);
expect(result[1].month).toBe("Feb 2026");
expect(result[1].monthKey).toBe("2026-02");
expect(result[1].actual).toBe(450);
expect(result[1].budgeted).toBe(800);
});
Expand All @@ -366,6 +366,9 @@ describe("BudgetReportsService", () => {

expect(result).toHaveLength(3);
expect(budgetsService.findOne).toHaveBeenCalledWith("user-1", "budget-1");
expect(
result.every((point) => /^\d{4}-\d{2}$/.test(point.monthKey)),
).toBe(true);
});

it("should return empty array when no categories and no periods", async () => {
Expand Down Expand Up @@ -438,6 +441,7 @@ describe("BudgetReportsService", () => {
expect(result[0].categoryId).toBe("cat-1");
expect(result[0].categoryName).toBe("Groceries");
expect(result[0].data).toHaveLength(1);
expect(result[0].data[0].monthKey).toBe("2026-01");
expect(result[0].data[0].budgeted).toBe(500);
expect(result[0].data[0].actual).toBe(420);
expect(result[0].data[0].variance).toBe(-80);
Expand Down Expand Up @@ -1246,7 +1250,7 @@ describe("BudgetReportsService", () => {
);

expect(result).toHaveLength(1);
expect(result[0].month).toBe("Jan 2026");
expect(result[0].monthKey).toBe("2026-01");
expect(result[0].score).toBeGreaterThanOrEqual(0);
expect(result[0].score).toBeLessThanOrEqual(100);
expect(result[0].label).toBeDefined();
Expand Down Expand Up @@ -1684,8 +1688,8 @@ describe("BudgetReportsService", () => {
);

expect(result).toHaveLength(2);
expect(result[0].month).toBe("Dec 2025");
expect(result[1].month).toBe("Jun 2026");
expect(result[0].monthKey).toBe("2025-12");
expect(result[1].monthKey).toBe("2026-06");
});

it("should return correct labels for different score ranges", async () => {
Expand Down Expand Up @@ -1829,8 +1833,8 @@ describe("BudgetReportsService", () => {
);

expect(result).toHaveLength(2);
expect(result[0].month).toBe("Jan 2026");
expect(result[1].month).toBe("Feb 2026");
expect(result[0].monthKey).toBe("2026-01");
expect(result[1].monthKey).toBe("2026-02");
// First period under budget, second over budget
expect(result[0].score).toBeGreaterThan(result[1].score);
});
Expand Down
17 changes: 12 additions & 5 deletions backend/src/budgets/budget-reports.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ import {
} from "./budget-date.utils";

export interface BudgetTrendPoint {
month: string;
monthKey: string;
budgeted: number;
actual: number;
variance: number;
percentUsed: number;
}

export interface CategoryTrendPoint {
month: string;
monthKey: string;
categoryId: string;
categoryName: string;
budgeted: number;
Expand All @@ -31,7 +31,7 @@ export interface CategoryTrendSeries {
categoryId: string;
categoryName: string;
data: Array<{
month: string;
monthKey: string;
budgeted: number;
actual: number;
variance: number;
Expand Down Expand Up @@ -86,15 +86,22 @@ export interface FlexGroupStatusResult {
}

export interface SavingsRatePoint {
month: string;
/**
* The month as structure (`YYYY-MM`), not as a label. A server-formatted
* `Mmm YYYY` shipped English to every locale and, being a label, sorted
* alphabetically at the client -- Apr, Aug, Dec, Feb... The client renders
* this through its own date preference and orders on the key.
*/
monthKey: string;
income: number;
expenses: number;
savings: number;
savingsRate: number;
}

export interface HealthScoreHistoryPoint {
month: string;
/** `YYYY-MM` -- see `SavingsRatePoint.monthKey`. */
monthKey: string;
score: number;
label: string;
}
Expand Down
11 changes: 8 additions & 3 deletions backend/src/budgets/budget-trend-reports.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,11 +312,11 @@ describe("BudgetTrendReportsService", () => {
const result = await service.getTrend("user-1", "budget-1", 6);

expect(result).toHaveLength(2);
expect(result[0].month).toBe("Dec 2025");
expect(result[0].monthKey).toBe("2025-12");
expect(result[0].budgeted).toBe(800);
expect(result[0].actual).toBe(750);
expect(result[0].variance).toBe(-50);
expect(result[1].month).toBe("Jan 2026");
expect(result[1].monthKey).toBe("2026-01");
expect(result[1].actual).toBe(820);
expect(result[1].variance).toBe(20);
});
Expand Down Expand Up @@ -360,7 +360,7 @@ describe("BudgetTrendReportsService", () => {
const result = await service.getTrend("user-1", "budget-1", 6);

expect(result).toHaveLength(2);
expect(result[1].month).toBe("Feb 2026");
expect(result[1].monthKey).toBe("2026-02");
expect(result[1].actual).toBe(450);
expect(result[1].budgeted).toBe(800);
});
Expand Down Expand Up @@ -404,6 +404,9 @@ describe("BudgetTrendReportsService", () => {

expect(result).toHaveLength(3);
expect(budgetsService.findOne).toHaveBeenCalledWith("user-1", "budget-1");
expect(
result.every((point) => /^\d{4}-\d{2}$/.test(point.monthKey)),
).toBe(true);
});

it("should return empty when no categories and no transfers in live mode", async () => {
Expand Down Expand Up @@ -743,6 +746,7 @@ describe("BudgetTrendReportsService", () => {
expect(result).toHaveLength(1);
expect(result[0].categoryId).toBe("cat-1");
expect(result[0].categoryName).toBe("Groceries");
expect(result[0].data[0].monthKey).toBe("2026-01");
expect(result[0].data[0].budgeted).toBe(500);
expect(result[0].data[0].actual).toBe(420);
});
Expand Down Expand Up @@ -975,6 +979,7 @@ describe("BudgetTrendReportsService", () => {

// Current month for cat-1: 350 + 50 = 400
const cat1CurrentMonth = cat1!.data[cat1!.data.length - 1];
expect(cat1CurrentMonth.monthKey).toBe(monthKey);
expect(cat1CurrentMonth.actual).toBe(400);
expect(cat1CurrentMonth.budgeted).toBe(500);
expect(cat1CurrentMonth.variance).toBe(-100);
Expand Down
Loading
Loading