diff --git a/CHANGELOG.md b/CHANGELOG.md index 358391bd..182d55d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,42 @@ 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. +- Comparison page polish from live-testing on a deployed preview: + - The percentile gauge is now a full ring instead of a semicircle dome, and + its number/label are centered with plain flexbox instead of a hand-tuned + `top: 54%` guess - the old shape made pixel-perfect centering inherently + fiddly; a full circle's visual center trivially matches its bounding box. + - The gauge caption and the three headline chips (net worth/income/ + frugality percentiles) didn't explain what the percentage actually meant. + The caption now states it explicitly ("higher than X% of people with a + similar profile"), and a new line under the chips clarifies that higher + is always better, including for the frugality (outflow) one. + - The info icon next to "Your comparison group" rendered black in dark + mode (no theme-aware color was set, so it fell back to the browser + default instead of inheriting one) - same class of bug as any other + icon with no explicit color, now fixed here explicitly since no + centralized icon-color helper exists in this codebase. + - "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/__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/i18n/locales/de.json b/src/i18n/locales/de.json index 5764983f..b98a246e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1192,7 +1192,8 @@ "hero": { "eyebrow": "Nur aggregierte, anonyme Daten", "gaugeLabel": "Perzentil", - "gaugeCaption": "Dein Vermögen im Vergleich zu Personen mit ähnlichem Profil.", + "gaugeCaption": "Höher als bei {value}% der Personen mit einem ähnlichen Profil wie deinem.", + "chipsCaption": "Perzentile im Vergleich zu deiner Vergleichsgruppe — höher ist immer besser, auch bei den Ausgaben.", "gaugeLockedDescription": "Wir zeigen dies, sobald deine Vergleichsgruppe die Mindestgröße zum Datenschutz erreicht.", "balanceLabel": "Vermögen", "incomeLabel": "Einkommen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9de2f4cb..c2e727ed 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1260,7 +1260,8 @@ "hero": { "eyebrow": "Aggregate, anonymous data only", "gaugeLabel": "Percentile", - "gaugeCaption": "Your net worth compared to people with a similar profile.", + "gaugeCaption": "Higher than {value}% of people with a similar profile to yours.", + "chipsCaption": "Percentiles vs. your comparison group - higher is always better, including for frugality.", "gaugeLockedDescription": "We'll show this as soon as your comparison group reaches the minimum privacy threshold.", "balanceLabel": "Net worth", "incomeLabel": "Income", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index b6132580..0a0bd55c 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1194,7 +1194,8 @@ "hero": { "eyebrow": "Solo datos agregados y anónimos", "gaugeLabel": "Percentil", - "gaugeCaption": "Tu patrimonio comparado con personas de perfil similar.", + "gaugeCaption": "Más alto que el {value}% de las personas con un perfil similar al tuyo.", + "chipsCaption": "Percentiles frente a tu grupo de comparación — cuanto más alto, mejor, también en el gasto.", "gaugeLockedDescription": "Lo mostraremos en cuanto tu grupo de comparación alcance el umbral mínimo de privacidad.", "balanceLabel": "Patrimonio", "incomeLabel": "Ingresos", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 08c4d02c..6e35026f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1192,7 +1192,8 @@ "hero": { "eyebrow": "Uniquement des données agrégées et anonymes", "gaugeLabel": "Percentile", - "gaugeCaption": "Ton patrimoine comparé à des personnes au profil similaire.", + "gaugeCaption": "Supérieur à {value}% des personnes ayant un profil similaire au tien.", + "chipsCaption": "Percentiles par rapport à ton groupe de comparaison — plus c'est élevé, mieux c'est, y compris pour les dépenses.", "gaugeLockedDescription": "Nous l'afficherons dès que ton groupe de comparaison atteindra le seuil minimal de confidentialité.", "balanceLabel": "Patrimoine", "incomeLabel": "Revenus", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4880767a..0b84912b 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1260,7 +1260,8 @@ "hero": { "eyebrow": "Solo dati aggregati e anonimi", "gaugeLabel": "Percentile", - "gaugeCaption": "Il tuo patrimonio rispetto a persone con un profilo simile al tuo.", + "gaugeCaption": "Il tuo patrimonio supera quello del {value}% delle persone con un profilo simile al tuo.", + "chipsCaption": "Percentili rispetto al tuo gruppo di confronto — più alto è sempre meglio, anche per la spesa.", "gaugeLockedDescription": "Lo mostreremo appena il tuo gruppo di confronto raggiungerà la soglia minima di privacy.", "balanceLabel": "Patrimonio", "incomeLabel": "Entrate", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 9969c032..132f90a0 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1192,7 +1192,8 @@ "hero": { "eyebrow": "Apenas dados agregados e anônimos", "gaugeLabel": "Percentil", - "gaugeCaption": "Seu patrimônio comparado a pessoas com perfil semelhante.", + "gaugeCaption": "Maior que o de {value}% das pessoas com um perfil semelhante ao seu.", + "chipsCaption": "Percentis em relação ao seu grupo de comparação — quanto maior, melhor, inclusive nos gastos.", "gaugeLockedDescription": "Vamos mostrar isso assim que seu grupo de comparação atingir o limite mínimo de privacidade.", "balanceLabel": "Patrimônio", "incomeLabel": "Renda", diff --git a/src/sections/Comparison.tsx b/src/sections/Comparison.tsx index 50c15446..103d5b2a 100644 --- a/src/sections/Comparison.tsx +++ b/src/sections/Comparison.tsx @@ -62,9 +62,16 @@ const fadeInUp = keyframes` to { opacity: 1; transform: translateY(0); } `; -const drawArc = keyframes` - from { stroke-dashoffset: var(--arc-full); } - to { stroke-dashoffset: var(--arc-offset); } +const drawRing = keyframes` + from { stroke-dashoffset: var(--ring-full); } + to { stroke-dashoffset: var(--ring-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 AnimatedRingCircle = styled.circle` + animation: ${drawRing} 1s ease-out forwards; `; const DEFAULT_FACTOR_GROUPS = ['career', 'location', 'lifeStage', 'household']; @@ -171,7 +178,6 @@ const HeroSubtitle = styled.p` `; const GaugeFigure = styled.div` - position: relative; display: inline-flex; flex-direction: column; align-items: center; @@ -179,14 +185,22 @@ const GaugeFigure = styled.div` animation: ${fadeInUp} 0.5s ease-out both; `; +// A relative box exactly the size of the ring SVG, so the value overlay below +// can center itself with plain flexbox instead of a hand-tuned top/left +// percentage - that's what previously left the number looking off-center. +const GaugeRingBox = styled.div` + position: relative; + display: inline-flex; + filter: drop-shadow(0 6px 16px ${p => p.theme.buttonBackgroundColor}30); +`; + const GaugeValue = styled.div` position: absolute; - top: 54%; - left: 50%; - transform: translate(-50%, -50%); + inset: 0; display: flex; flex-direction: column; align-items: center; + justify-content: center; strong { font-size: 2.1rem; @@ -195,7 +209,7 @@ const GaugeValue = styled.div` line-height: 1; } span { - margin-top: 0.2rem; + margin-top: 0.25rem; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; @@ -205,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; @@ -220,6 +234,12 @@ const HeadlineChips = styled.div` margin-top: 1.5rem; `; +const ChipsCaption = styled.p` + margin: 0.6rem 0 0; + font-size: 0.72rem; + color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.45)' : 'rgba(15,23,42,0.42)'}; +`; + const HeadlineChip = styled.div` display: flex; align-items: center; @@ -576,36 +596,39 @@ const CountryResult = styled.div` /* ─── Small building blocks ─── */ -const GaugeArc = ({ value, theme, size = 176 }) => { +// A full ring (not a semicircle dome) - both because a circle's own visual +// center trivially matches its bounding box (so the value overlay centers +// perfectly via flexbox, unlike the old dome shape) and because it reads as +// a more familiar "percentile ring" pattern. +const GaugeRing = ({ value, theme, size = 176, children }) => { const stroke = 14; const radius = (size - stroke) / 2; - const half = Math.PI * radius; + const circumference = 2 * Math.PI * radius; const clamped = value === null ? 0 : Math.max(1, Math.min(100, value)); - const offset = half - (clamped / 100) * half; - const cx = size / 2; - const cy = size / 2 + 6; - const path = `M ${stroke / 2} ${cy} A ${radius} ${radius} 0 0 1 ${size - stroke / 2} ${cy}`; + const offset = circumference - (clamped / 100) * circumference; + const center = size / 2; const trackColor = theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.07)'; return ( - + + + {children} + ); }; @@ -871,13 +894,20 @@ function Comparison({ theme, userData, isHidden }) { resolveTagKeyFromLocalized(name, 'en', 'expense') || resolveTagKeyFromLocalized(name, language, 'expense') || String(name).toLowerCase(); + const categoryIndex = userData?.tags?.outflowsTags?.find( + tag => tag.label === tagKey || translateTag(tag.label, 'en', 'expense') === name + )?.index; + const peerAverage = similarComparisonAvailable && categoryIndex !== undefined + ? similarUsersExpensesByCategory?.[categoryIndex] ?? null + : null; return { name, tagKey, displayName: translateTag(tagKey, language, 'expense') || name, value, percentage: (value / totalSpending) * 100, - color: getCategoryColor(tagKey, language) + color: getCategoryColor(tagKey, language), + peerAverage }; }) .sort((a, b) => b.value - a.value); @@ -916,14 +946,10 @@ function Comparison({ theme, userData, isHidden }) { }); } - const categoryOpportunities = spendingByCategory.map(category => { - const categoryIndex = userData?.tags?.outflowsTags?.find( - tag => tag.label === category.tagKey || translateTag(tag.label, 'en', 'expense') === category.name - )?.index; - const peerAverage = categoryIndex !== undefined ? similarUsersExpensesByCategory?.[categoryIndex] : null; - return { ...category, peerAverage, difference: peerAverage == null ? 0 : category.value - peerAverage }; - }).filter(category => category.peerAverage > 0 && category.difference > Math.max(50, category.peerAverage * 0.1)) - .sort((a, b) => b.difference - a.difference); + const categoryOpportunities = spendingByCategory + .map(category => ({ ...category, difference: category.peerAverage == null ? 0 : category.value - category.peerAverage })) + .filter(category => category.peerAverage > 0 && category.difference > Math.max(50, category.peerAverage * 0.1)) + .sort((a, b) => b.difference - a.difference); if (categoryOpportunities.length > 0) { const opportunity = categoryOpportunities[0]; @@ -1087,12 +1113,20 @@ function Comparison({ theme, userData, isHidden }) { title: t.cards.spendingCategories.title, teaser: t.cards.spendingCategories.description, render: () => spendingByCategory.length > 0 ? spendingByCategory.slice(0, 6).map((category) => ( - - - {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}

) @@ -1132,8 +1166,9 @@ function Comparison({ theme, userData, isHidden }) { {!hasBenchmarkConsent ? ( <> - - + + + {optInCopy.description} @@ -1143,8 +1178,7 @@ function Comparison({ theme, userData, isHidden }) { ) : ( <> - - + {overallGaugeValue !== null ? ( <> {isHidden ? '**' : `${overallGaugeValue}`}% @@ -1153,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.')} @@ -1174,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.'} )} @@ -1253,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.'}

- + 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) {