From 1827c27d75527aaaaae7f456cd8b9b04930e0d7b Mon Sep 17 00:00:00 2001 From: Alessio Speranza <33428827+alessiospera@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:13:03 +0200 Subject: [PATCH 1/3] Fix a render crash in the Comparison page's percentile gauge GaugeArc interpolated a styled-components keyframes object into a plain inline style string instead of an actual styled-components template, which styled-components deliberately rejects at runtime. It crashed the whole page as soon as benchmark consent was on and the gauge had a value to animate, surfacing in the browser console as a styled-components error #12 and a cascade of unrelated-looking downstream failures. Moved the animation into a real styled.path component so keyframes are interpolated correctly, and added a render smoke test for Comparison so this class of runtime-only bug - invisible to lint, types, and pure-function tests - fails a test run instead of only showing up in production. --- CHANGELOG.md | 8 ++ .../sections/Comparison.render.test.jsx | 88 +++++++++++++++++++ src/sections/Comparison.tsx | 15 ++-- 3 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/sections/Comparison.render.test.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 358391bd..eb674f67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ## [Unreleased] ### Fixed +- The redesigned Comparison page's percentile gauge crashed the whole page at + render time (a styled-components `keyframes` object was interpolated into + a plain inline `style` string instead of an actual styled-components + template, which styled-components deliberately throws on) - visible in + the browser console as `Uncaught Error ... errors.md#12`, reachable the + moment benchmark consent was on and the gauge had a value to animate. + Added a render smoke test for `Comparison` so this class of runtime-only + bug (invisible to lint/types/pure-function tests) fails a test run again. - Consistency and correctness pass across the CSV import wizard and the Aggiungi entrate/uscite flow, prompted by live-testing a real Trade Republic export: diff --git a/src/__tests__/sections/Comparison.render.test.jsx b/src/__tests__/sections/Comparison.render.test.jsx new file mode 100644 index 00000000..7add1f69 --- /dev/null +++ b/src/__tests__/sections/Comparison.render.test.jsx @@ -0,0 +1,88 @@ +/** + * Smoke-render test for the redesigned Comparison page. The hero gauge's + * SVG arc previously interpolated a styled-components `keyframes` object + * into a plain inline `style` object instead of a real styled-components + * tagged template - that only throws at render time (styled-components + * error #12), so no lint/type/unit check on pure functions ever caught it. + * This test actually mounts with demo-realistic data so that + * class of bug fails a test run instead of only showing up in production. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { ThemeContext } from '../../contexts/ThemeContext'; +import { LanguageContext } from '../../contexts/LanguageContext'; +import { CurrencyContext } from '../../contexts/CurrencyContext'; +import enLocale from '../../i18n/locales/en.json'; +import { generateDemoData } from '../../data/demoData'; + +vi.mock('../../hooks/useLocalizedNavigate', () => ({ + useLocalizedNavigate: () => vi.fn(), +})); + +vi.mock('../../hooks/useDemoServices', () => ({ + useDemoServices: () => ({ + rankingService: { + previewCustomBenchmark: vi.fn().mockResolvedValue({ + requestedFactors: [], factors: [], relaxed: false, available: true, + cohort: { size: 34, populationSize: 214, minimumSize: 20, averageSimilarity: 0.74 }, + }), + getCustomBenchmark: vi.fn().mockResolvedValue({ + available: true, requestedFactors: [], factors: [], relaxed: false, generatedAt: new Date().toISOString(), + cohort: { size: 34, populationSize: 214, minimumSize: 20, averageSimilarity: 0.74 }, + averages: { balances: 36859, incomes: 2506, expenses: 1358, assetAllocation: { liquid: 38, investments: 50, crypto: 12 } }, + rankings: { balance: 78, incomes: 68, outflows: 38 }, + }), + }, + userService: { + setBenchmarkConsent: vi.fn().mockResolvedValue({ benchmarkConsent: true }), + }, + statsService: { + getBehaviourBenchmark: vi.fn().mockResolvedValue({ + available: true, minimumCohortSize: 20, cohortSize: 214, + personal: { savingConsistency: 78, investmentRegularity: 65, contributionFrequency: 3.2, goalProgress: 54 }, + rankings: { savingConsistency: 72, investmentRegularity: 68, contributionFrequency: 55, goalProgress: 61 }, + }), + }, + }), +})); + +vi.mock('../../contexts/DeploymentContext', () => ({ + useDeployment: () => ({ selfHosted: false }), +})); + +import Comparison from '../../sections/Comparison'; + +const theme = { mode: 'light', textColor: '#000', buttonBackgroundColor: '#079164' }; +const currencyCtx = { + formatAmount: (v) => `€${Number(v).toFixed(0)}`, +}; +const translations = { + comparison: enLocale.comparison, + general: { comingSoon: 'Coming soon' }, +}; + +function renderComparison(userData) { + return render( + + + + + + + , + ); +} + +describe('Comparison (render smoke test)', () => { + it('renders the hero gauge and accordion without throwing, with demo data (benchmarkConsent true)', () => { + const userData = generateDemoData(); + expect(userData.benchmarkConsent).toBe(true); + expect(() => renderComparison(userData)).not.toThrow(); + }); + + it('renders the opt-in gate without throwing when benchmark consent has not been given', () => { + const userData = { ...generateDemoData(), benchmarkConsent: false }; + expect(() => renderComparison(userData)).not.toThrow(); + }); +}); diff --git a/src/sections/Comparison.tsx b/src/sections/Comparison.tsx index 50c15446..7aa01ae7 100644 --- a/src/sections/Comparison.tsx +++ b/src/sections/Comparison.tsx @@ -67,6 +67,13 @@ const drawArc = keyframes` to { stroke-dashoffset: var(--arc-offset); } `; +// A keyframes object can only be interpolated inside an actual styled-components +// tagged template (here) - interpolating it into a plain JS template string for +// a raw inline `style` prop throws at runtime (styled-components error #12). +const AnimatedArcPath = styled.path` + animation: ${drawArc} 1s ease-out forwards; +`; + const DEFAULT_FACTOR_GROUPS = ['career', 'location', 'lifeStage', 'household']; /** Title/description for the benchmark opt-in card: a self-hosted instance's @@ -590,18 +597,14 @@ const GaugeArc = ({ value, theme, size = 176 }) => { - {category.displayName} - - {isHidden ? '****' : formatCurrency(category.value)} - +
+ + + {category.displayName} + + {isHidden ? '****' : formatCurrency(category.value)} + + {category.peerAverage !== null && ( + + {t.cards.spendingCategories.avgSimilar} + {isHidden ? '****' : formatCurrency(category.peerAverage)} + + )} +
)) : (

{t.cards.spendingCategories.noExpenses}

) @@ -1135,8 +1166,9 @@ function Comparison({ theme, userData, isHidden }) { {!hasBenchmarkConsent ? ( <> - - + + + {optInCopy.description} @@ -1146,8 +1178,7 @@ function Comparison({ theme, userData, isHidden }) { ) : ( <> - - + {overallGaugeValue !== null ? ( <> {isHidden ? '**' : `${overallGaugeValue}`}% @@ -1156,11 +1187,11 @@ function Comparison({ theme, userData, isHidden }) { ) : ( )} - + {overallGaugeValue !== null - ? (t.hero?.gaugeCaption || 'Your net worth compared to people with a similar profile.') + ? (t.hero?.gaugeCaption || 'Higher than {value}% of people with a similar profile to yours.').replace('{value}', isHidden ? '**' : String(overallGaugeValue)) : (t.hero?.gaugeLockedDescription || 'We will show this as soon as your comparison group reaches the minimum privacy threshold.')} @@ -1177,6 +1208,7 @@ function Comparison({ theme, userData, isHidden }) {
{similarRanks.outflows > 0 ? `${Math.min(similarRanks.outflows, 100)}%` : '—'}
{t.hero?.outflowsLabel || 'Frugality'}
+ {t.hero?.chipsCaption || 'Percentiles vs. your comparison group - higher is always better, including for frugality.'} )} @@ -1256,7 +1288,7 @@ function Comparison({ theme, userData, isHidden }) {

{t.benchmarkOverview?.customizeDescription || 'Choose which parts of your profile matter for your comparison. Data stays aggregated and anonymous.'}

- + From 3b18da564d14ec8be2757766277e4291e76ce5eb Mon Sep 17 00:00:00 2001 From: Alessio Speranza <33428827+alessiospera@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:52:05 +0200 Subject: [PATCH 3/3] Center the gauge caption and let Insert Data's tools row use the full page width The Comparison gauge caption was missing margin: 0 auto on its max-width box, so it hugged the left edge of the card instead of centering under the circular gauge above it. Insert Data's secondary tools row (CSV/Excel, Ricorrenti, Spese condivise) was still capped at an old 1000px width on desktop after the page around it was widened to 1400px, leaving a wide empty strip on the right instead of aligning with the form card beneath it. --- CHANGELOG.md | 10 ++++++++++ src/sections/Comparison.tsx | 2 +- src/sections/InsertValues.tsx | 4 +++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be2fdac5..182d55d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and - "Spending by Category" only ever showed the user's own amounts - added the same "vs. your comparison group" line every other accordion section already has, per category. + - The gauge caption (the sentence explaining what the percentile means) was + missing `margin: 0 auto` on its `max-width`-constrained box, so it hugged + the left edge of the card instead of centering under the now-circular + gauge above it. +- Insert Data's "outflows"/"income" secondary tools row (CSV/Excel, + Ricorrenti, Spese condivise) stayed capped at an old 1000px width on + desktop while the page around it (`ContentWrapper`/`SectionCard`) was + widened to 1400px in a previous change, leaving a wide dead strip to its + right instead of right-aligning against the same edge as the form card + beneath it. - Consistency and correctness pass across the CSV import wizard and the Aggiungi entrate/uscite flow, prompted by live-testing a real Trade Republic export: diff --git a/src/sections/Comparison.tsx b/src/sections/Comparison.tsx index ded20b96..103d5b2a 100644 --- a/src/sections/Comparison.tsx +++ b/src/sections/Comparison.tsx @@ -219,7 +219,7 @@ const GaugeValue = styled.div` `; const GaugeCaption = styled.p` - margin: 0; + margin: 0 auto; max-width: 320px; color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.62)' : 'rgba(15,23,42,0.58)'}; font-size: 0.82rem; diff --git a/src/sections/InsertValues.tsx b/src/sections/InsertValues.tsx index 5eb60059..6fd9f8ac 100644 --- a/src/sections/InsertValues.tsx +++ b/src/sections/InsertValues.tsx @@ -225,7 +225,9 @@ const ToolsBar = styled.div` justify-content: flex-end; gap: 0.35rem; width: 100%; - max-width: 1000px; + /* No max-width of its own - fills ContentWrapper (1400px) so it right-aligns + against the same edge as SectionCard below it, instead of stopping short + at an old, narrower cap and leaving a stretch of dead space on the right. */ margin: 0 auto 1rem; @media (max-width: 768px) {