diff --git a/CHANGELOG.md b/CHANGELOG.md index e49657ed..358391bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,40 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and in-place. ### Changed +- Comparison page, fully rethought: replaced the dense always-visible stat + grids and the dead motivational-popup/legacy-rankings-tab code with a + narrative "financial mirror" layout - a single percentile gauge and + plain-language headline insight up front, everything else (cashflow, + savings rate, asset allocation, spending by category, behavior) tucked + behind a progressive-disclosure accordion so the page shows one clear + thing at a time instead of everything at once. Every number still comes + from the same anonymous, privacy-gated cohort data as before (same + `MIN_COHORT` threshold, same consent flow) - this is a presentation + rewrite, not a data change. New: a "compare by country" view that isolates + geography as the only cohort factor, and a "Region & city" placeholder + that's honest about not collecting that data yet while laying the + groundwork (see `todo.md`) for a future clickable map and location/job + change simulator. +- Custom comparison cohorts (the factor customizer and the new "compare by + country" view) now relax automatically instead of just reporting "not + enough data": when the exact combination of factors you picked doesn't + reach the privacy threshold, the server progressively drops household, + then life stage, then career and retries - geography is never dropped + automatically, since cost of living dominates nominal financial + differences more than any other single factor. The comparison always says + plainly which factors it actually ended up using when it had to broaden. + The main percentile comparison also now shows a running "X of 20 people + so far" count instead of a bare "not available yet" while the platform is + still small, so it's clear the wait is about community size, not something + broken. +- The public demo account ("Try Demo") now shows the redesigned Comparison + page fully populated instead of behind its opt-in wall or in an empty + state: demo data ships with benchmark consent already granted and realistic + cohort/population numbers, and the demo now mocks the behaviour-benchmark + and custom-cohort endpoints (factor customizer, "compare by country") + instead of leaving them to hit a real backend that doesn't exist in demo + mode - including a demo-only simulation of the new automatic factor + relaxation, so a visitor can see that in action too. - Migrated Tailwind CSS from v3 to v4: switched from the PostCSS plugin to the official `@tailwindcss/vite` plugin (removes `postcss.config.js` entirely), dropped `autoprefixer`/`postcss` (built into v4), and kept the diff --git a/scripts/roadmap-items.json b/scripts/roadmap-items.json index b9272dc0..2683e7e5 100644 --- a/scripts/roadmap-items.json +++ b/scripts/roadmap-items.json @@ -514,5 +514,29 @@ "category": "feature", "icon": "⚖️", "completedDate": "2026-08" + }, + { + "id": "comparison-redesign", + "todoMatch": "Comparison page redesign", + "title": { "it": "Nuova Pagina Confronto", "en": "Comparison Page Redesign" }, + "description": { + "it": "La pagina Confronto è ora uno specchio finanziario: un gauge di percentile, intuizioni in linguaggio semplice e dettagli progressivi al posto delle griglie dense di sempre, più un nuovo confronto per Paese completamente anonimo.", + "en": "The Comparison page is now a financial mirror: a percentile gauge, plain-language insight and progressive detail instead of the old dense stat grids, plus a new fully anonymous by-country comparison." + }, + "category": "ux", + "icon": "🪞", + "completedDate": "2026-08" + }, + { + "id": "cohort-relaxation", + "todoMatch": "Automatic cohort factor relaxation", + "title": { "it": "Rilassamento Automatico del Confronto", "en": "Automatic Cohort Relaxation" }, + "description": { + "it": "Se troppo pochi utenti corrispondono a tutti i criteri scelti, il confronto si allarga automaticamente rinunciando prima a casa/famiglia, poi età, poi lavoro — mai all'area geografica — mostrando sempre in modo chiaro su quali criteri si basa il risultato.", + "en": "When too few people match every chosen factor, the comparison automatically broadens by dropping household, then age, then career - never geography - and always shows plainly which factors the result is actually based on." + }, + "category": "feature", + "icon": "🎚️", + "completedDate": "2026-08" } ] diff --git a/server/__tests__/similarUsers.test.ts b/server/__tests__/similarUsers.test.ts index aa676e14..0ec91363 100644 --- a/server/__tests__/similarUsers.test.ts +++ b/server/__tests__/similarUsers.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vitest" import { - similarityScore, selectCohort, normalizeComparisonFactorGroups, MIN_COHORT, MAX_COHORT, - type ProfileTagIds, type OrdinalTagMeta + similarityScore, selectCohort, selectCustomSimilarUserIdsWithRelaxation, + normalizeComparisonFactorGroups, FACTOR_RELAXATION_ORDER, MIN_COHORT, MAX_COHORT, + type ProfileTagIds, type OrdinalTagMeta, type ProfilesSnapshot } from "../src/services/similarUsers" // age tags: client_index 0..4 (5 brackets), yearsOfExperience: 0..3 (4 brackets) @@ -172,3 +173,78 @@ describe("selectCohort", () => { expect(result.userIds.length).toBeGreaterThan(0) }) }) + +describe("selectCustomSimilarUserIdsWithRelaxation", () => { + it("drops household, then lifeStage, then career, in that fixed priority order - never location", () => { + expect(FACTOR_RELAXATION_ORDER).toEqual(["household", "lifeStage", "career"]) + }) + + + // 5 profiles matching the reference on every field (career+location+lifeStage+household). + const perfectMatches = Array.from({ length: 5 }, (_, i) => profile({ id: `perfect${i}` })) + // 20 profiles matching only workTime+country+remoteType (weight 13/100 - below the 0.2 floor + // with all 4 groups active, but 13/63 ≈ 0.206 once household+lifeStage are dropped and the + // remaining career+location weight (63) becomes the new denominator). + const partialMatches = Array.from({ length: 20 }, (_, i) => profile({ + id: `partial${i}`, + job_tag_id: null, job_type_tag_id: null, years_of_experience_tag_id: null, + job_country_tag_id: null, age_tag_id: null, + housing_type_tag_id: null, living_situation_tag_id: null, children_tag_id: null + })) + const reference = profile({ id: "ref" }) + const snapshot: ProfilesSnapshot = { profiles: [reference, ...perfectMatches, ...partialMatches], tagMeta } + + it("uses the exact requested combination when it already clears the threshold", () => { + const plentySnapshot: ProfilesSnapshot = { + profiles: [reference, ...Array.from({ length: 25 }, (_, i) => profile({ id: `match${i}` }))], + tagMeta + } + const result = selectCustomSimilarUserIdsWithRelaxation(plentySnapshot, "ref", ["career", "location"]) + expect(result.appliedFactors).toEqual(["career", "location"]) + expect(result.droppedFactors).toEqual([]) + expect(result.userIds.length).toBeGreaterThanOrEqual(MIN_COHORT) + }) + + it("drops household then lifeStage, in that order, to reach MIN_COHORT", () => { + const result = selectCustomSimilarUserIdsWithRelaxation(snapshot, "ref", ["career", "location", "lifeStage", "household"]) + expect(result.appliedFactors).toEqual(["career", "location"]) + expect(result.droppedFactors).toEqual(["household", "lifeStage"]) + expect(result.userIds.length).toBeGreaterThanOrEqual(MIN_COHORT) + expect(result.insufficientData).toBe(false) + }) + + it("stops relaxing once the threshold is met instead of dropping further factors", () => { + // perfectMatches alone (25 -> 5) already clear MIN_COHORT with career+location+lifeStage + // (household would be dropped first, lifeStage should never need to go). + const soloSnapshot: ProfilesSnapshot = { + profiles: [reference, ...perfectMatches, ...Array.from({ length: 20 }, (_, i) => profile({ id: `extra${i}` }))], + tagMeta + } + const result = selectCustomSimilarUserIdsWithRelaxation(soloSnapshot, "ref", ["career", "location", "lifeStage", "household"]) + expect(result.appliedFactors).toEqual(["career", "location", "lifeStage", "household"]) + expect(result.droppedFactors).toEqual([]) + }) + + it("never drops location, even when nothing else is left to relax", () => { + // A tiny population where even the maximally-relaxed cohort can't reach MIN_COHORT. + const tinySnapshot: ProfilesSnapshot = { + profiles: [reference, ...Array.from({ length: 3 }, (_, i) => profile({ id: `tiny${i}` }))], + tagMeta + } + const result = selectCustomSimilarUserIdsWithRelaxation(tinySnapshot, "ref", ["location"]) + expect(result.appliedFactors).toEqual(["location"]) + expect(result.droppedFactors).toEqual([]) + }) + + it("only relaxes within the requested factors, never adding one back", () => { + // Requesting career+household only: relaxation may drop household, then career, but must + // never introduce location or lifeStage since they were never requested. + const result = selectCustomSimilarUserIdsWithRelaxation(snapshot, "ref", ["career", "household"]) + for (const applied of result.appliedFactors) { + expect(["career", "household"]).toContain(applied) + } + for (const dropped of result.droppedFactors) { + expect(["career", "household"]).toContain(dropped) + } + }) +}) diff --git a/server/src/services/customBenchmark.ts b/server/src/services/customBenchmark.ts index 7475fea2..d47cd7de 100644 --- a/server/src/services/customBenchmark.ts +++ b/server/src/services/customBenchmark.ts @@ -12,27 +12,31 @@ import similarUsers, { const CACHE_TTL_SECONDS = 300 const PREVIEW_CACHE_TTL_SECONDS = 60 +type CohortInfo = { + size: number, + populationSize: number, + minimumSize: number, + averageSimilarity: number | null +} + export type CustomBenchmarkPreview = { + /** Factor groups actually requested by the client. */ + requestedFactors: ComparisonFactorGroup[], + /** Factor groups the returned cohort is actually built from - a subset of requestedFactors once relaxed. */ factors: ComparisonFactorGroup[], + /** True once one or more requestedFactors had to be dropped to reach the privacy threshold. */ + relaxed: boolean, available: boolean, - cohort: { - size: number, - populationSize: number, - minimumSize: number, - averageSimilarity: number | null - } + cohort: CohortInfo } export type CustomBenchmark = { available: boolean, + requestedFactors: ComparisonFactorGroup[], factors: ComparisonFactorGroup[], + relaxed: boolean, generatedAt: string, - cohort: { - size: number, - populationSize: number, - minimumSize: number, - averageSimilarity: number | null - }, + cohort: CohortInfo, averages: { balances: number | null, incomes: number | null, @@ -71,8 +75,8 @@ function previewCacheKey(userId: string, factors: ComparisonFactorGroup[]) { /** Returns cohort size and quality without fetching financial metrics. */ async function previewCustomBenchmark(userId: string, rawFactors: unknown): Promise { - const factors = normalizeComparisonFactorGroups(rawFactors) - const key = previewCacheKey(userId, factors) + const requestedFactors = normalizeComparisonFactorGroups(rawFactors) + const key = previewCacheKey(userId, requestedFactors) try { const cached = await redis.get(key) if (cached) return cached @@ -81,9 +85,11 @@ async function previewCustomBenchmark(userId: string, rawFactors: unknown): Prom } const snapshot = await similarUsers.fetchProfilesSnapshot() - const cohort = similarUsers.selectCustomSimilarUserIds(snapshot, userId, factors) + const cohort = similarUsers.selectCustomSimilarUserIdsWithRelaxation(snapshot, userId, requestedFactors) const result: CustomBenchmarkPreview = { - factors, + requestedFactors, + factors: cohort.appliedFactors, + relaxed: cohort.droppedFactors.length > 0, available: !cohort.insufficientData && cohort.userIds.length >= MIN_COHORT, cohort: { size: cohort.userIds.length, @@ -107,8 +113,8 @@ async function previewCustomBenchmark(userId: string, rawFactors: unknown): Prom * any financial details in the browser or cache key. */ async function getCustomBenchmark(userId: string, rawFactors: unknown): Promise { - const factors = normalizeComparisonFactorGroups(rawFactors) - const key = cacheKey(userId, factors) + const requestedFactors = normalizeComparisonFactorGroups(rawFactors) + const key = cacheKey(userId, requestedFactors) let cached: CustomBenchmark | null = null try { cached = await redis.get(key) @@ -118,11 +124,13 @@ async function getCustomBenchmark(userId: string, rawFactors: unknown): Promise< if (cached) return cached const snapshot = await similarUsers.fetchProfilesSnapshot() - const cohort = similarUsers.selectCustomSimilarUserIds(snapshot, userId, factors) + const cohort = similarUsers.selectCustomSimilarUserIdsWithRelaxation(snapshot, userId, requestedFactors) const generatedAt = new Date().toISOString() const base = { - factors, + requestedFactors, + factors: cohort.appliedFactors, + relaxed: cohort.droppedFactors.length > 0, generatedAt, cohort: { size: cohort.userIds.length, diff --git a/server/src/services/similarUsers.ts b/server/src/services/similarUsers.ts index 7c912ecd..a3cf2106 100644 --- a/server/src/services/similarUsers.ts +++ b/server/src/services/similarUsers.ts @@ -363,6 +363,65 @@ export function selectCustomSimilarUserIds( return selectCohort(scored, eligible.length) } +/** + * Order in which factor groups are dropped when a requested combination is + * too narrow to reach MIN_COHORT: household first, then lifeStage, then + * career. `location` is deliberately never auto-dropped here - across every + * WEIGHTS table above, jobCountry/housingType are consistently the highest- + * or near-highest-weighted single field (cost of living dominates nominal + * balance/income/outflow differences), so a comparison that drops geography + * to hit a headcount stops being meaningful. The other three groups are + * ordered by how narrow they typically make a cohort (household combines 3 + * categorical fields - the most ways to mismatch - down to lifeStage's + * single ordinal field), not by their similarity-score weight. + */ +export const FACTOR_RELAXATION_ORDER: ComparisonFactorGroup[] = ["household", "lifeStage", "career"] + +export type RelaxedSimilarUsersResult = SimilarUsersResult & { + /** Factor groups actually used to build the cohort - a subset of the request, in canonical order. */ + appliedFactors: ComparisonFactorGroup[], + /** Requested factor groups dropped to reach the privacy threshold, in the order they were dropped. */ + droppedFactors: ComparisonFactorGroup[] +} + +/** + * Same cohort as selectCustomSimilarUserIds, but when the exact requested + * combination doesn't reach MIN_COHORT, progressively drops factor groups + * (per FACTOR_RELAXATION_ORDER) and retries until one combination clears the + * threshold or there is nothing left it's allowed to drop. Never adds a + * factor group the caller didn't request, and never drops `location` + * automatically - see FACTOR_RELAXATION_ORDER. Lets a comparison that would + * otherwise be "not enough data" on an exact multi-factor match instead + * degrade gracefully to a broader, still-labeled comparison as the + * platform's population grows. + */ +export function selectCustomSimilarUserIdsWithRelaxation( + snapshot: ProfilesSnapshot, + referenceUserId: string, + factorGroups: ComparisonFactorGroup[], + opts: { ignoreTestUsers?: boolean } = {} +): RelaxedSimilarUsersResult { + const requested = FACTOR_GROUP_NAMES.filter((group) => factorGroups.includes(group)) + + let working = requested + const dropped: ComparisonFactorGroup[] = [] + let result = selectCustomSimilarUserIds(snapshot, referenceUserId, working, opts) + + while (result.insufficientData || result.userIds.length < MIN_COHORT) { + const toDrop = FACTOR_RELAXATION_ORDER.find((group) => working.includes(group)) + if (!toDrop) break + working = working.filter((group) => group !== toDrop) + dropped.push(toDrop) + result = selectCustomSimilarUserIds(snapshot, referenceUserId, working, opts) + } + + return { + ...result, + appliedFactors: working, + droppedFactors: dropped + } +} + /** * Convenience one-shot wrapper around fetchProfilesSnapshot + selectSimilarUserIds * for callers resolving a single cohort (e.g. one-off scripts, tests). Callers @@ -379,5 +438,6 @@ async function getSimilarUserIds( } export default { - getSimilarUserIds, fetchProfilesSnapshot, fetchMonthlyProfilesSnapshot, selectSimilarUserIds, selectCustomSimilarUserIds, similarityScore, selectCohort + getSimilarUserIds, fetchProfilesSnapshot, fetchMonthlyProfilesSnapshot, selectSimilarUserIds, + selectCustomSimilarUserIds, selectCustomSimilarUserIdsWithRelaxation, similarityScore, selectCohort } diff --git a/src/contexts/MockAuthContext.tsx b/src/contexts/MockAuthContext.tsx index 4cc182e3..cd9a0004 100644 --- a/src/contexts/MockAuthContext.tsx +++ b/src/contexts/MockAuthContext.tsx @@ -404,7 +404,13 @@ export const mockUserData = { { typology: 'commodities', value: 8000, date: new Date().toISOString().split('T')[0] } ], + // On by default so the Comparison page renders fully in local dev too. + benchmarkConsent: true, + // Averages data from /stats/averages API + // `benchmark` mirrors server/src/cache/items/averages.ts's BenchmarkMetadata + // shape - Comparison.tsx needs populationSize/cohortSizes/minimumCohortSize + // from it to treat a comparison as "available". averages: { all: { balances: 5591.08, @@ -416,6 +422,13 @@ export const mockUserData = { 1: 120, 2: 80, 3: 350, 4: 450, 5: 600, 6: 200, 7: 150, 8: 500, 9: 100, 10: 50, 11: 300, 12: 80, 13: 0, 14: 60, 15: 120, 9999: 40 + }, + benchmark: { + generatedAt: new Date().toISOString(), + populationSize: 1284, + minimumCohortSize: 20, + cohortSizes: { balances: 1284, incomes: 1284, expenses: 1284, savingsRates: 1284 }, + averageSimilarity: { balances: null, incomes: null, expenses: null, savingsRates: null } } }, similar: { @@ -428,6 +441,13 @@ export const mockUserData = { 1: 235, 2: 437, 3: 3024, 4: 1978, 5: 2348, 6: 1571, 7: 1037, 8: 3902, 9: 674, 10: 52, 11: 2431, 12: 165, 13: 0, 14: 178, 15: 868, 9999: 105 + }, + benchmark: { + generatedAt: new Date().toISOString(), + populationSize: 214, + minimumCohortSize: 20, + cohortSizes: { balances: 41, incomes: 46, expenses: 38, savingsRates: 44 }, + averageSimilarity: { balances: 0.74, incomes: 0.71, expenses: 0.69, savingsRates: 0.7 } } } }, diff --git a/src/data/demoData.ts b/src/data/demoData.ts index 019aa91a..92e46a67 100644 --- a/src/data/demoData.ts +++ b/src/data/demoData.ts @@ -282,6 +282,11 @@ export const generateDemoData = () => { }; // ── Averages ── + // `benchmark` on each bucket mirrors server/src/cache/items/averages.ts's + // BenchmarkMetadata shape - Comparison.tsx reads populationSize/cohortSizes/ + // minimumCohortSize from it to decide whether a comparison is "available", + // so without it the redesigned page would show its own empty state in demo. + const benchmarkGeneratedAt = new Date().toISOString(); const averages = { all: { balances: 5591, @@ -294,6 +299,13 @@ export const generateDemoData = () => { 6: 200, 7: 150, 8: 500, 9: 100, 10: 50, 11: 300, 12: 80, 13: 0, 14: 60, 15: 120, 9999: 40, }, + benchmark: { + generatedAt: benchmarkGeneratedAt, + populationSize: 1284, + minimumCohortSize: 20, + cohortSizes: { balances: 1284, incomes: 1284, expenses: 1284, savingsRates: 1284 }, + averageSimilarity: { balances: null, incomes: null, expenses: null, savingsRates: null }, + }, }, similar: { balances: 36859, @@ -306,6 +318,13 @@ export const generateDemoData = () => { 6: 1571, 7: 1037, 8: 3902, 9: 674, 10: 52, 11: 2431, 12: 165, 13: 0, 14: 178, 15: 868, 9999: 105, }, + benchmark: { + generatedAt: benchmarkGeneratedAt, + populationSize: 214, + minimumCohortSize: 20, + cohortSizes: { balances: 41, incomes: 46, expenses: 38, savingsRates: 44 }, + averageSimilarity: { balances: 0.74, incomes: 0.71, expenses: 0.69, savingsRates: 0.7 }, + }, }, }; @@ -357,6 +376,10 @@ export const generateDemoData = () => { userType: 'demo', username: 'PaciDemo', profileCompletionPercentage: 82, + // On by default in the demo so the Comparison page shows its full + // potential immediately instead of behind an opt-in wall a visitor + // would have to know to click. + benchmarkConsent: true, // Currency currency: 'EUR', diff --git a/src/data/roadmapData.ts b/src/data/roadmapData.ts index b036b7f4..b1f8ef8a 100644 --- a/src/data/roadmapData.ts +++ b/src/data/roadmapData.ts @@ -416,6 +416,30 @@ const roadmapData: RoadmapItem[] = [ icon: '⚖️', completedDate: '2026-08', }, + { + id: 'comparison-redesign', + title: { it: 'Nuova Pagina Confronto', en: 'Comparison Page Redesign' }, + description: { + it: 'La pagina Confronto è ora uno specchio finanziario: un gauge di percentile, intuizioni in linguaggio semplice e dettagli progressivi al posto delle griglie dense di sempre, più un nuovo confronto per Paese completamente anonimo.', + en: 'The Comparison page is now a financial mirror: a percentile gauge, plain-language insight and progressive detail instead of the old dense stat grids, plus a new fully anonymous by-country comparison.' + }, + status: 'completed', + category: 'ux', + icon: '🪞', + completedDate: '2026-08', + }, + { + id: 'cohort-relaxation', + title: { it: 'Rilassamento Automatico del Confronto', en: 'Automatic Cohort Relaxation' }, + description: { + it: 'Se troppo pochi utenti corrispondono a tutti i criteri scelti, il confronto si allarga automaticamente rinunciando prima a casa/famiglia, poi età, poi lavoro — mai all\'area geografica — mostrando sempre in modo chiaro su quali criteri si basa il risultato.', + en: 'When too few people match every chosen factor, the comparison automatically broadens by dropping household, then age, then career - never geography - and always shows plainly which factors the result is actually based on.' + }, + status: 'completed', + category: 'feature', + icon: '🎚️', + completedDate: '2026-08', + }, /* ──────────── IN PROGRESS ──────────── */ { id: 'push-notifications', diff --git a/src/hooks/useDemoServices.ts b/src/hooks/useDemoServices.ts index 662f263a..5747029f 100644 --- a/src/hooks/useDemoServices.ts +++ b/src/hooks/useDemoServices.ts @@ -27,8 +27,9 @@ import type { InvestmentSettingsDto, InvestmentDividendDto, InvestmentDividendSummaryResponse, InvestmentTransactionDto, InvestmentTransactionsGetResponse, LiquidityAccountDto, LiquidityAccountHistoryDto, RecurringTransactionDto, - GoalDto, SharedExpenseReceivableDto, + GoalDto, SharedExpenseReceivableDto, BehaviourBenchmarkResponse, BenchmarkConsentResponse, } from '../types/api'; +import type { ComparisonFactorGroup, CustomBenchmark, CustomBenchmarkPreview } from '../services/rankingService'; // A recent date a few days in the past, so "last updated"/"last paid" // fields never look stale no matter when the demo is opened. @@ -113,6 +114,42 @@ const DEMO_SHARED_EXPENSES: SharedExpenseReceivableDto[] = [ { id: -601, date: daysAgoDate(6), notes: 'Cena di compleanno - gruppo di 4', totalAmount: 160, ownShare: 40, receivableAmount: 120, settledAmount: 0, status: 'pending', expenseId: null }, ]; +const DEMO_BEHAVIOUR_BENCHMARK: BehaviourBenchmarkResponse = { + 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 }, +}; + +const ALL_FACTOR_GROUPS: ComparisonFactorGroup[] = ['career', 'location', 'lifeStage', 'household']; + +/** + * Shared by getCustomBenchmark/previewCustomBenchmark so both agree, and so + * the demo also shows off automatic factor relaxation - mirroring the + * priority order in server/src/services/similarUsers.ts's + * FACTOR_RELAXATION_ORDER (household dropped first, location never dropped). + * A broad, everything-selected request demonstrates a broadened cohort; a + * narrower one (e.g. the "compare by country" card, which asks for + * `location` alone) is already specific enough and comes back exact. + */ +const demoRelaxedFactors = (rawFactors: ComparisonFactorGroup[]) => { + const requestedFactors = rawFactors.length > 0 ? rawFactors : ALL_FACTOR_GROUPS; + const relaxed = requestedFactors.length >= 3 && requestedFactors.includes('household'); + const factors = relaxed ? requestedFactors.filter((factor) => factor !== 'household') : requestedFactors; + return { requestedFactors, factors, relaxed }; +}; + +/** Same balances/income/expenses/ranking figures as averages.similar/rankings + * in demoData.ts, so the customizer and "compare by country" card feel + * consistent with the hero gauge and accordion instead of contradicting them. */ +const demoCustomBenchmarkCohort = (relaxed: boolean) => ({ + size: relaxed ? 46 : 34, + populationSize: 214, + minimumSize: 20, + averageSimilarity: relaxed ? 0.68 : 0.74, +}); + export const useDemoServices = () => { const services = useServices(); const { isDemoMode } = useAuth(); @@ -147,6 +184,33 @@ export const useDemoServices = () => { changePassword: async () => { throw new Error('Not available in demo mode'); }, generateRecoveryCode: async () => { throw new Error('Not available in demo mode'); }, getRecoveryCodeStatus: async () => ({ configured: false, generated_at: null }), + // Demo data already ships with benchmarkConsent: true (see demoData.ts), + // so this only matters if something explicitly re-toggles it. + setBenchmarkConsent: async (contribute: boolean): Promise => ({ benchmarkConsent: contribute }), + }, + statsService: { + ...services.statsService, + getBehaviourBenchmark: async (): Promise => DEMO_BEHAVIOUR_BENCHMARK, + }, + rankingService: { + ...services.rankingService, + previewCustomBenchmark: async (rawFactors: ComparisonFactorGroup[]): Promise => { + const { requestedFactors, factors, relaxed } = demoRelaxedFactors(rawFactors); + return { requestedFactors, factors, relaxed, available: true, cohort: demoCustomBenchmarkCohort(relaxed) }; + }, + getCustomBenchmark: async (rawFactors: ComparisonFactorGroup[]): Promise => { + const { requestedFactors, factors, relaxed } = demoRelaxedFactors(rawFactors); + return { + available: true, + requestedFactors, + factors, + relaxed, + generatedAt: new Date().toISOString(), + cohort: demoCustomBenchmarkCohort(relaxed), + averages: { balances: 36859, incomes: 2506, expenses: 1358, assetAllocation: { liquid: 38, investments: 50, crypto: 12 } }, + rankings: { balance: 78, incomes: 68, outflows: 38 }, + }; + }, }, investmentService: { ...services.investmentService, diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index fa86aae0..5764983f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1187,83 +1187,67 @@ } }, "comparison": { - "layers": {"title":"So liest du die Daten","you":"Du","group":"Vergleichsgruppe","general":"Allgemeine Referenz","hint":"Daten anderer Nutzer erscheinen nur bei erfüllter Datenschutzschwelle."}, - "rankingsAccessory": {"title":"Verhaltensindikatoren","accessoryLabel":"Zusatzansicht","description":"Diese Ranglisten bewerten weder Vermögen, Einkommen noch absolute Renditen. Sie beschreiben beobachtbare Gewohnheiten und den Fortschritt zu deinen Zielen.","savingConsistency":"Kontinuität beim Sparen","savingConsistencyDescription":"Monate, in denen du dein Sparziel erreicht hast.","investmentRegularity":"Regelmäßigkeit der Investitionen","investmentRegularityDescription":"Monate mit mindestens einem Investitionsbeitrag.","contributionFrequency":"Beitragshäufigkeit","contributionFrequencyDescription":"Anzahl der Beiträge im Beobachtungszeitraum, nur für ausreichend große Gruppen.","goalProgress":"Zielfortschritt","goalProgressDescription":"Fortschritt zu den von dir überwachten Zielen.","waitingForMetric":"Indikator wird vorbereitet","waitingForMetricDescription":"Vergleichbare Daten und eine ausreichend große Gruppe sind erforderlich.","waitingForGroup":"Vergleichsgruppe noch nicht verfügbar","waitingForGroupDescription":"Der Indikator erscheint ab {minimum} Teilnehmern."}, - "guide": {"title": "Nützliche Vergleiche, ohne deine Daten offenzulegen", "description": "Deine Werte fließen nur in anonyme, zusammengefasste Statistiken ein.", "youTitle": "Du", "youDescription": "Deine Daten, nur für dich sichtbar.", "similarTitle": "Vergleichsgruppe", "similarDescription": "Eine anonyme Gruppe anhand der von dir gewählten Profilmerkmale.", "allTitle": "Allgemeine Referenz", "allDescription": "Der allgemeine Community-Vergleich mit weniger Personalisierung."}, - "title": "Anonymer Vergleich", - "subtitle": "Vergleiche deine finanzielle Leistung anonym mit ähnlichen Nutzern", + "title": "Dein finanzieller Spiegel", + "subtitle": "Sieh, wie es dir geht – völlig anonym, ohne Ranglisten, nur nützliche Erkenntnisse für dich.", + "hero": { + "eyebrow": "Nur aggregierte, anonyme Daten", + "gaugeLabel": "Perzentil", + "gaugeCaption": "Dein Vermögen im Vergleich zu Personen mit ähnlichem Profil.", + "gaugeLockedDescription": "Wir zeigen dies, sobald deine Vergleichsgruppe die Mindestgröße zum Datenschutz erreicht.", + "balanceLabel": "Vermögen", + "incomeLabel": "Einkommen", + "outflowsLabel": "Sparsamkeit" + }, + "rankingsAccessory": {"title":"Verhaltensindikatoren","description":"Diese Ranglisten bewerten weder Vermögen, Einkommen noch absolute Renditen. Sie beschreiben beobachtbare Gewohnheiten und den Fortschritt zu deinen Zielen.","savingConsistency":"Kontinuität beim Sparen","savingConsistencyDescription":"Monate, in denen du dein Sparziel erreicht hast.","investmentRegularity":"Regelmäßigkeit der Investitionen","investmentRegularityDescription":"Monate mit mindestens einem Investitionsbeitrag.","contributionFrequency":"Beitragshäufigkeit","contributionFrequencyDescription":"Anzahl der Beiträge im Beobachtungszeitraum, nur für ausreichend große Gruppen.","goalProgress":"Zielfortschritt","goalProgressDescription":"Fortschritt zu den von dir überwachten Zielen."}, + "accordion": { + "sectionLabel": "Details erkunden", + "cashflowTitle": "Einnahmen & Ausgaben", + "cashflowDescription": "Dein letzter vollständiger Monat, im Vergleich zu deiner Vergleichsgruppe", + "behaviourTitle": "Finanzielles Verhalten" + }, "benchmarkOverview": { - "title": "Dein persönlicher Benchmark", - "description": "Vermögen, Einnahmen und Ausgaben verwenden eigene Vergleichsgruppen, da ähnliches Einkommen nicht automatisch ähnliche Ausgaben oder Lebensphasen bedeutet.", - "editProfile": "Profil bearbeiten", - "show": "Vergleich anzeigen", - "hide": "Einklappen", - "groupHelp": "Die Vergleichsgruppe umfasst Nutzer mit ähnlichen Profilmerkmalen und zeigt nur aggregierte Ergebnisse bei ausreichender Größe.", - "optInTitle": "Vergleiche mit ähnlichen Nutzern freischalten", "optInDescription": "Aktiviere die Einwilligung für aggregierte Benchmarks. Transaktionen, Notizen und Identität werden nie geteilt.", "optInTitleSelfHosted": "Vergleiche mit anderen Nutzern dieser Instanz freischalten", "optInDescriptionSelfHosted": "Aktiviere die Einwilligung, um dich nur mit anderen Nutzern dieser selbstgehosteten Instanz zu vergleichen. Der instanzübergreifende Community-Vergleich ist eine geplante Funktion und noch nicht verfügbar. Transaktionen, Notizen und Identität werden nie geteilt.", "optInAction": "Vergleich aktivieren", "optInSaving": "Wird aktiviert...", "optInError": "Der Community-Vergleich konnte nicht aktiviert werden. Bitte erneut versuchen.", - "basedOn": "Vergleich basiert auf", + "title": "Dein persönlicher Vergleich", + "description": "Aktiviere den Vergleich, um Vermögen, Einkommen und Ausgaben im Vergleich zu einer Gruppe von Nutzern mit ähnlichem Profil zu sehen.", + "groupHelp": "Die Vergleichsgruppe umfasst Nutzer mit ähnlichen Profilmerkmalen. Ergebnisse werden aggregiert und nur bei ausreichender Gruppengröße angezeigt.", + "optInTitle": "Vergleiche mit ähnlichen Nutzern freischalten", "optInDescription": "Aktiviere die Einwilligung für aggregierte Benchmarks. Transaktionen, Notizen und Identität werden nie geteilt.", "optInTitleSelfHosted": "Vergleiche mit anderen Nutzern dieser Instanz freischalten", "optInDescriptionSelfHosted": "Aktiviere die Einwilligung, um dich nur mit anderen Nutzern dieser selbstgehosteten Instanz zu vergleichen. Der instanzübergreifende Community-Vergleich ist eine geplante Funktion und noch nicht verfügbar. Transaktionen, Notizen und Identität werden nie geteilt.", "optInSaving": "Wird aktiviert...", "optInError": "Der Community-Vergleich konnte nicht aktiviert werden. Bitte erneut versuchen.", "waiting": "Vergleichsgruppe wird vorbereitet", "privacy": "Nur aggregierte Daten. Gruppen: {count} Nutzer; Mindestgröße zum Datenschutz: {minimum}. Aktualisiert: {updated}.", - "customizeTitle": "Ähnliche Nutzer anpassen", + "customizeTitle": "Deine Vergleichsgruppe", "customizeDescription": "Wähle, welche Teile deines Profils im Vergleich zählen. Die Daten bleiben aggregiert und anonym.", "applyFactors": "Vergleich aktualisieren", "resetFactors": "Empfohlenen Vergleich verwenden", "calculating": "Wird berechnet...", - "noCohort": "Für diese Gruppe gibt es noch nicht genügend vergleichbare Profile.", "comparisonUnavailable": "Vergleichsgruppe noch nicht verfügbar", "comparisonUnavailableDescription": "Der Vergleich wird angezeigt, sobald die Gruppe die Datenschutzschwelle von {minimum} Teilnehmern erreicht. Bis dahin zeigen wir keine Statistiken anderer Nutzer als Ersatz.", + "noCohort": "Für diese Gruppe gibt es noch nicht genügend vergleichbare Profile.", "comparisonUnavailable": "Vergleichsgruppe noch nicht verfügbar", "comparisonUnavailableDescription": "Der Vergleich wird angezeigt, sobald die Gruppe die Datenschutzschwelle von {minimum} Teilnehmern erreicht. Bis dahin zeigen wir keine Statistiken anderer Nutzer als Ersatz.", "comparisonUnavailableProgress": "Bisher {count} von {minimum} Personen.", "customError": "Der Vergleich konnte nicht aktualisiert werden. Bitte versuche es erneut.", "factorUnavailable": "Vervollständige diesen Teil deines Profils, um ihn im Vergleich zu verwenden.", "preview": "Vorschau: {count} vergleichbare Profile (mindestens {minimum}).", - "median": "Median", - "interquartileRange": "Mittlerer Bereich", - "monthsAgo": "vor {months} Monaten", - "reliability": {"low": "Anfängliche Zuverlässigkeit", "medium": "Mittlere Zuverlässigkeit", "high": "Hohe Zuverlässigkeit"}, + "relaxedNotice": "Es gibt noch nicht genug Personen, die all deinen Kriterien entsprechen, daher verwendet dieser Vergleich eine breitere Gruppe basierend auf: {factors}.", "factors": { "career": "Arbeit und Karriere", "location": "Region", "lifeStage": "Lebensphase", "household": "Wohnen und Familie" } }, - "sections": { - "rankings": { - "title": "Ranglisten", - "description": "Sieh, wie du im Vergleich zu anderen Nutzern abschneidest" - }, - "insights": { - "title": "Finanzvergleich", - "description": "Entdecke Muster und Chancen" - }, - "spending": { - "title": "Ausgabenanalyse", - "description": "Vergleiche deine Ausgabengewohnheiten" - }, - "benchmarks": { - "title": "Benchmarks", - "description": "Branchen- und demografische Vergleiche" - } - }, "cards": { "avgBalance": { - "title": "Durchschnittliches Guthaben", - "description": "Vergleiche dein aktuelles Guthaben und das 12-Monats-Wachstum mit anderen Nutzern", - "yourBalance": "Dein Guthaben", - "avgSimilar": "Durchschn. ähnlicher Nutzer", - "avgAll": "Durchschn. aller Nutzer", + "title": "Vermögen", + "description": "Dein Vermögen und dessen Wachstum in den letzten 12 Monaten", + "avgSimilar": "Median der Vergleichsgruppe", + "avgAll": "Allgemeine Referenz", "growth12Months": "letzte 12 Monate", "noGrowthData": "Wachstumsdaten nicht verfügbar" }, "avgIncome": { "title": "Monatliche Einnahmen", - "description": "Vergleiche die Einnahmen des letzten vollständigen Monats", "yourIncome": "Deine Einnahmen", - "avgSimilar": "Durchschn. ähnlicher Nutzer", - "avgAll": "Durchschn. aller Nutzer" + "avgSimilar": "Median der Vergleichsgruppe", + "avgAll": "Allgemeine Referenz" }, "avgOutflows": { "title": "Monatliche Ausgaben", - "description": "Vergleiche die Ausgaben des letzten vollständigen Monats", "yourOutflows": "Deine Ausgaben", - "avgSimilar": "Durchschn. ähnlicher Nutzer", - "avgAll": "Durchschn. aller Nutzer" + "avgSimilar": "Median der Vergleichsgruppe", + "avgAll": "Allgemeine Referenz" }, "savingsRate": { "title": "Sparquote", "description": "Deine Ersparnisse als Prozentsatz des Einkommens", - "yourRate": "Deine Quote", "avgAll": "Durchschn. aller Nutzer", "avgSimilar": "Durchschn. ähnlicher Nutzer", "last12Months": "Letzte 12 Monate", @@ -1275,7 +1259,6 @@ "liquid": "Liquide Mittel", "investments": "Investitionen", "crypto": "Krypto", - "other": "Sonstiges", "avgSimilar": "Durchschnitt ähnlicher Nutzer", "avgAll": "Durchschnitt aller Nutzer", "noAssets": "Keine Vermögenswerte erfasst" @@ -1283,17 +1266,10 @@ "spendingCategories": { "title": "Ausgaben nach Kategorie", "description": "Wofür du dein Geld ausgibst (letzte 12 Monate)", - "topCategories": "Top-Kategorien", - "otherCategories": "Weitere Kategorien", "noExpenses": "Keine Ausgaben erfasst", "avgSimilar": "Durchschn. ähnlicher Nutzer", "avgAll": "Durchschn. aller Nutzer" - }, - "showMore": "Alle anzeigen", - "showLess": "Weniger anzeigen" - }, - "insights": { - "average": "Du liegst ungefähr im Durchschnitt" + } }, "actionableInsights": { "percentileTitle": "Vermögen: Top {rank}% unter ähnlichen Profilen", @@ -1306,12 +1282,21 @@ "balancedDescription": "Es gibt keine wesentlichen Abweichungen. Beobachte weiterhin den Trend, der aussagekräftiger als ein einzelner Monat ist." }, "profileBanner": { - "title": "🚀 Schalte personalisierte Vergleiche frei!", - "description": "Vervollständige dein Profil auf der Kontoseite, um anonyme und automatische Vergleiche mit ähnlichen Nutzern zu erhalten. Entdecke, wie du im Vergleich zu anderen Berufstätigen stehst!", - "action": "Profil vervollständigen" - }, - "tips": { - "title": "Personalisierte Tipps" + "title": "Mach deine Vergleichsgruppe genauer", + "description": "Vervollständige dein Profil, um wirklich ähnliche Nutzer zu finden." + }, + "geography": { + "title": "Geografie", + "countryTitle": "Nach Land vergleichen", + "countryDescription": "Isoliere die Geografie von deinen anderen Profilmerkmalen, um zu sehen, wie du im Vergleich zu Personen aus deinem Land abschneidest.", + "countryNeedsConsent": "Aktiviere oben den Vergleich, um dies zu nutzen.", + "countryProfileIncomplete": "Füge dein Land zu deinem Profil hinzu", + "countryCTA": "Meinen Ländervergleich ansehen", + "countryUnavailable": "Noch nicht genügend Personen aus deinem Land für einen Vergleich.", + "regionComingSoon": "Demnächst verfügbar", + "regionTitle": "Region & Stadt", + "regionDescription": "Wir erfassen noch keine Region oder Stadt, daher können wir auf dieser Ebene nicht vergleichen. Das steht auf der Roadmap, zusammen mit einer klickbaren Karte und einer an die Lebenshaltungskosten angepassten Ansicht.", + "mapFutureNote": "Ein zukünftiger Schritt: simulieren, wie sich ein Job- oder Ortswechsel auf deine Zahlen auswirken könnte – immer als Annahme dargestellt, nie als Ratschlag." } }, "leaderboard": { @@ -1337,15 +1322,10 @@ "title": "Deine Ranglisten", "monthData": "Daten von", "generalRanking": "Allgemeine Rangliste", - "generalSubtitle": "Vergleich mit allen Nutzern", "similarRanking": "Ähnliche Nutzer", - "similarSubtitle": "Vergleich mit ähnlichen Profilen", "balance": "Guthaben", "income": "Einnahmen", "outflows": "Ausgaben", - "topPerformance": "Tolle Position!", - "goodPerformance": "Gute Position", - "canImprove": "Raum für Wachstum", "noData": "Daten nicht verfügbar", "descriptions": { "balance": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8eb2db32..9de2f4cb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1255,45 +1255,46 @@ } }, "comparison": { - "layers": {"title":"How to read this","you":"You","group":"Comparison group","general":"General reference","hint":"Other users' data appears only when the privacy threshold is met."}, - "rankingsAccessory": {"title":"Behavior indicators","accessoryLabel":"Secondary view","description":"These rankings do not judge wealth, income or absolute returns. They describe observable habits and progress toward your own goals.","savingConsistency":"Saving consistency","savingConsistencyDescription":"Months in which you reached your saving target.","investmentRegularity":"Investment regularity","investmentRegularityDescription":"Continuity of months with at least one investment contribution.","contributionFrequency":"Contribution frequency","contributionFrequencyDescription":"Number of contributions in the observed period, shown only for sufficiently large groups.","goalProgress":"Goal progress","goalProgressDescription":"Progress toward the goals you chose to track.","waitingForMetric":"Indicator being prepared","waitingForMetricDescription":"Comparable history and a sufficiently large group are required before this comparison can be published.","waitingForGroup":"Comparison group not available yet","waitingForGroupDescription":"This indicator will appear when the group reaches at least {minimum} participants."}, - "guide": {"title": "Useful comparisons without exposing your data", "description": "When you participate, your values are used only in anonymous, aggregated statistics.", "youTitle": "You", "youDescription": "Your data, visible only to you.", "similarTitle": "Comparison group", "similarDescription": "An anonymous group built from the profile factors you choose.", "allTitle": "General reference", "allDescription": "The broader community reference, with less personalization."}, - "title": "Anonymous Comparison", - "subtitle": "Compare your financial performance with similar users anonymously", + "title": "Your financial mirror", + "subtitle": "See how you're doing, fully anonymously — no leaderboards, just useful insight for you.", + "hero": { + "eyebrow": "Aggregate, anonymous data only", + "gaugeLabel": "Percentile", + "gaugeCaption": "Your net worth compared to people with a similar profile.", + "gaugeLockedDescription": "We'll show this as soon as your comparison group reaches the minimum privacy threshold.", + "balanceLabel": "Net worth", + "incomeLabel": "Income", + "outflowsLabel": "Frugality" + }, + "rankingsAccessory": {"title":"Behavior indicators","description":"These rankings do not judge wealth, income or absolute returns. They describe observable habits and progress toward your own goals.","savingConsistency":"Saving consistency","savingConsistencyDescription":"Months in which you reached your saving target.","investmentRegularity":"Investment regularity","investmentRegularityDescription":"Continuity of months with at least one investment contribution.","contributionFrequency":"Contribution frequency","contributionFrequencyDescription":"Number of contributions in the observed period, shown only for sufficiently large groups.","goalProgress":"Goal progress","goalProgressDescription":"Progress toward the goals you chose to track."}, + "accordion": { + "sectionLabel": "Explore the detail", + "cashflowTitle": "Income & outflows", + "cashflowDescription": "Your last complete month, against your comparison group", + "behaviourTitle": "Financial behavior" + }, "benchmarkOverview": { "title": "Your personal comparison", - "description": "Compare net worth, income and outflows with groups of users sharing a similar profile.", - "editProfile": "Profile", - "show": "View comparison", - "hide": "Collapse", + "description": "Enable comparison to see net worth, income and outflows against a group of users with a similar profile.", "groupHelp": "A comparison group contains users with similar profile characteristics. Results are aggregated and shown only when the group is sufficiently large.", "optInTitle": "Unlock comparisons with similar users", "optInDescription": "Opt in to receive aggregated benchmarks. We never share transactions, notes or identity.", "optInTitleSelfHosted": "Unlock comparisons with other users of this instance", "optInDescriptionSelfHosted": "Opt in to compare against other users of this self-hosted instance only. Cross-instance community comparison is a planned feature, not available yet. We never share transactions, notes or identity.", - "optInAction": "Enable comparison", "optInSaving": "Activating...", "optInError": "Unable to activate community comparison. Please try again.", - "basedOn": "Group based on", "waiting": "Group being prepared", "privacy": "Aggregated data only. Group: {count} users; minimum privacy threshold: {minimum}. Updated: {updated}.", - "customizeTitle": "Customize similar users", + "customizeTitle": "Your comparison group", "customizeDescription": "Choose which parts of your profile matter in the comparison. Data stays aggregated and anonymous.", "applyFactors": "Update comparison", "resetFactors": "Use recommended comparison", "calculating": "Calculating...", - "noCohort": "There are not enough comparable profiles for this group yet.", "comparisonUnavailable": "Comparison group not available yet", "comparisonUnavailableDescription": "We will show the comparison when the group reaches the minimum privacy threshold of {minimum} participants. Until then, we do not show other users' statistics as a substitute.", + "noCohort": "There are not enough comparable profiles for this group yet.", "comparisonUnavailable": "Comparison group not available yet", "comparisonUnavailableDescription": "We will show the comparison when the group reaches the minimum privacy threshold of {minimum} participants. Until then, we do not show other users' statistics as a substitute.", "comparisonUnavailableProgress": "{count} of {minimum} people so far.", "customError": "Unable to update the comparison. Try again.", "factorUnavailable": "Complete this part of your profile to use it in the comparison.", "preview": "Preview: {count} comparable profiles (minimum {minimum}).", - "median": "Median", - "interquartileRange": "Middle range", - "monthsAgo": "{months} months ago", - "reliability": { - "low": "Early reliability", - "medium": "Medium reliability", - "high": "High reliability" - }, + "relaxedNotice": "Not enough people matched your full selection yet, so this comparison uses a broader group based on: {factors}.", "factors": { "career": "Work and career", "location": "Geographic area", @@ -1301,29 +1302,10 @@ "household": "Home and family" } }, - "sections": { - "rankings": { - "title": "Rankings", - "description": "See how you rank compared to other users" - }, - "insights": { - "title": "Financial Comparison", - "description": "Discover patterns and opportunities" - }, - "spending": { - "title": "Spending Analysis", - "description": "Compare your spending habits" - }, - "benchmarks": { - "title": "Benchmarks", - "description": "Industry and demographic comparisons" - } - }, "cards": { "avgBalance": { - "title": "Average Balance", - "description": "Compare your current balance and 12-month growth with other users", - "yourBalance": "Your Balance", + "title": "Net Worth", + "description": "Your net worth and its growth over the last 12 months", "avgSimilar": "Comparison-group median", "avgAll": "General reference", "growth12Months": "last 12 months", @@ -1331,14 +1313,12 @@ }, "avgIncome": { "title": "Monthly Income", - "description": "Compare income from the last complete month", "yourIncome": "Your Income", "avgSimilar": "Comparison-group median", "avgAll": "General reference" }, "avgOutflows": { "title": "Monthly Outflows", - "description": "Compare outflows from the last complete month", "yourOutflows": "Your Outflows", "avgSimilar": "Comparison-group median", "avgAll": "General reference" @@ -1346,7 +1326,6 @@ "savingsRate": { "title": "Savings Rate", "description": "Your savings as percentage of income", - "yourRate": "Your rate", "avgAll": "All Users Avg", "avgSimilar": "Similar Users Avg", "last12Months": "Last 12 months", @@ -1358,25 +1337,17 @@ "liquid": "Liquid", "investments": "Investments", "crypto": "Crypto", - "other": "Other", "avgSimilar": "Similar Users Avg", "avgAll": "All Users Avg", "noAssets": "No assets recorded" }, "spendingCategories": { "title": "Spending by Category", - "description": "Where you spend your money (last 12 months)", - "topCategories": "Top categories", - "otherCategories": "Other categories", + "description": "Where your money goes (last 12 months)", "noExpenses": "No expenses recorded", "avgSimilar": "Similar Users Avg", "avgAll": "All Users Avg" - }, - "showMore": "Show all", - "showLess": "Show less" - }, - "insights": { - "average": "You're performing around average" + } }, "actionableInsights": { "percentileTitle": "Net worth: top {rank}% among similar profiles", @@ -1390,11 +1361,20 @@ }, "profileBanner": { "title": "Make your comparison group more accurate", - "description": "Complete your profile to find genuinely similar users. This improves the comparison but does not enable participation: anonymous consent is managed separately below.", - "action": "Complete profile" - }, - "tips": { - "title": "Personalized Tips" + "description": "Complete your profile to find genuinely similar users." + }, + "geography": { + "title": "Geography", + "countryTitle": "Compare by country", + "countryDescription": "Isolate geography from your other profile factors to see how you compare to people in your country.", + "countryNeedsConsent": "Enable comparison above to use this.", + "countryProfileIncomplete": "Add your country to your profile", + "countryCTA": "See my country comparison", + "countryUnavailable": "Not enough people from your country compare yet.", + "regionComingSoon": "Coming soon", + "regionTitle": "Region & city", + "regionDescription": "We don't collect region or city yet, so we can't compare at that level. It's on the roadmap, along with a clickable map and a cost-of-living-adjusted view.", + "mapFutureNote": "A future step: simulate how a job or location change could affect your numbers, always shown as an assumption, never as advice." } }, "leaderboard": { @@ -1420,15 +1400,10 @@ "title": "Your Rankings", "monthData": "Data from", "generalRanking": "General Ranking", - "generalSubtitle": "Comparison with all users", "similarRanking": "Similar Users", - "similarSubtitle": "Comparison with profiles similar to yours", "balance": "Balance", "income": "Income", "outflows": "Outflows", - "topPerformance": "Great position!", - "goodPerformance": "Good position", - "canImprove": "Room for growth", "noData": "Data not available", "descriptions": { "balance": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 47a18308..b6132580 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1189,95 +1189,78 @@ } }, "comparison": { - "layers": {"title":"Cómo leer los datos","you":"Tú","group":"Grupo de comparación","general":"Referencia general","hint":"Los datos de otros usuarios aparecen solo cuando se respeta el umbral de privacidad."}, - "rankingsAccessory": {"title":"Indicadores de comportamiento","accessoryLabel":"Vista secundaria","description":"Estas clasificaciones no juzgan patrimonio, ingresos ni rendimientos absolutos. Describen hábitos observables y el progreso hacia tus objetivos.","savingConsistency":"Constancia del ahorro","savingConsistencyDescription":"Meses en los que alcanzaste tu objetivo de ahorro.","investmentRegularity":"Regularidad de las inversiones","investmentRegularityDescription":"Continuidad de meses con al menos una contribución de inversión.","contributionFrequency":"Frecuencia de contribuciones","contributionFrequencyDescription":"Número de contribuciones en el periodo observado, solo para grupos suficientemente grandes.","goalProgress":"Progreso de objetivos","goalProgressDescription":"Avance hacia los objetivos que elegiste seguir.","waitingForMetric":"Indicador en preparación","waitingForMetricDescription":"Se necesitan datos comparables y un grupo suficientemente grande.","waitingForGroup":"Grupo de comparación aún no disponible","waitingForGroupDescription":"El indicador aparecerá cuando el grupo alcance {minimum} participantes."}, - "guide": {"title": "Comparaciones útiles sin exponer tus datos", "description": "Al participar, tus valores solo se usan en estadísticas anónimas y agregadas.", "youTitle": "Tú", "youDescription": "Tus datos, visibles solo para ti.", "similarTitle": "Grupo de comparación", "similarDescription": "Un grupo anónimo creado con los factores de perfil que elijas.", "allTitle": "Referencia general", "allDescription": "La referencia general de la comunidad, menos personalizada."}, - "title": "Comparación anónima", - "subtitle": "Compara tu rendimiento financiero con usuarios similares de forma anónima", + "title": "Tu espejo financiero", + "subtitle": "Descubre cómo te va, de forma totalmente anónima — sin clasificaciones, solo información útil para ti.", + "hero": { + "eyebrow": "Solo datos agregados y anónimos", + "gaugeLabel": "Percentil", + "gaugeCaption": "Tu patrimonio comparado con personas de perfil similar.", + "gaugeLockedDescription": "Lo mostraremos en cuanto tu grupo de comparación alcance el umbral mínimo de privacidad.", + "balanceLabel": "Patrimonio", + "incomeLabel": "Ingresos", + "outflowsLabel": "Frugalidad" + }, + "rankingsAccessory": {"title":"Indicadores de comportamiento","description":"Estas clasificaciones no juzgan patrimonio, ingresos ni rendimientos absolutos. Describen hábitos observables y el progreso hacia tus objetivos.","savingConsistency":"Constancia del ahorro","savingConsistencyDescription":"Meses en los que alcanzaste tu objetivo de ahorro.","investmentRegularity":"Regularidad de las inversiones","investmentRegularityDescription":"Continuidad de meses con al menos una contribución de inversión.","contributionFrequency":"Frecuencia de contribuciones","contributionFrequencyDescription":"Número de contribuciones en el periodo observado, solo para grupos suficientemente grandes.","goalProgress":"Progreso de objetivos","goalProgressDescription":"Avance hacia los objetivos que elegiste seguir."}, + "accordion": { + "sectionLabel": "Profundiza en el detalle", + "cashflowTitle": "Ingresos y gastos", + "cashflowDescription": "Tu último mes completo, frente a tu grupo de comparación", + "behaviourTitle": "Comportamiento financiero" + }, "benchmarkOverview": { - "title": "Tu benchmark personal", - "description": "Patrimonio, ingresos y gastos usan grupos de comparación específicos, porque tener ingresos similares no implica compartir gastos o etapa vital.", - "editProfile": "Editar perfil", - "show": "Ver comparación", - "hide": "Reducir", + "title": "Tu comparación personal", + "description": "Activa la comparación para ver tu patrimonio, ingresos y gastos frente a un grupo de usuarios con un perfil similar.", "groupHelp": "El grupo de comparación reúne usuarios con perfiles similares y muestra solo resultados agregados cuando hay suficientes participantes.", - "optInTitle": "Desbloquea la comparación con usuarios similares", "optInDescription": "Activa el consentimiento para recibir benchmarks agregados. Nunca compartimos transacciones, notas ni identidad.", "optInTitleSelfHosted": "Desbloquea la comparación con otros usuarios de esta instancia", "optInDescriptionSelfHosted": "Activa el consentimiento para compararte solo con otros usuarios de esta instancia self-hosted. La comparación comunitaria entre instancias es una función planificada, aún no disponible. Nunca compartimos transacciones, notas ni identidad.", "optInAction": "Activar comparación", "optInSaving": "Activando...", "optInError": "No se pudo activar la comparación comunitaria. Inténtalo de nuevo.", - "basedOn": "Comparación basada en", + "optInTitle": "Desbloquea la comparación con usuarios similares", "optInDescription": "Activa el consentimiento para recibir benchmarks agregados. Nunca compartimos transacciones, notas ni identidad.", "optInTitleSelfHosted": "Desbloquea la comparación con otros usuarios de esta instancia", "optInDescriptionSelfHosted": "Activa el consentimiento para compararte solo con otros usuarios de esta instancia self-hosted. La comparación comunitaria entre instancias es una función planificada, aún no disponible. Nunca compartimos transacciones, notas ni identidad.", "optInSaving": "Activando...", "optInError": "No se pudo activar la comparación comunitaria. Inténtalo de nuevo.", "waiting": "Grupo de comparación en preparación", "privacy": "Solo datos agregados. Grupos de comparación: {count} usuarios; umbral mínimo de privacidad: {minimum}. Actualizado: {updated}.", - "customizeTitle": "Personaliza usuarios similares", + "customizeTitle": "Tu grupo de comparación", "customizeDescription": "Elige qué partes de tu perfil cuentan en la comparación. Los datos siguen siendo agregados y anónimos.", "applyFactors": "Actualizar comparación", "resetFactors": "Usar comparación recomendada", "calculating": "Calculando...", - "noCohort": "Aún no hay suficientes perfiles comparables para este grupo.", "comparisonUnavailable": "Grupo de comparación aún no disponible", "comparisonUnavailableDescription": "Mostraremos la comparación cuando el grupo alcance el umbral de privacidad de {minimum} participantes. Mientras tanto, no mostramos estadísticas de otros usuarios como sustituto.", + "noCohort": "Aún no hay suficientes perfiles comparables para este grupo.", "comparisonUnavailable": "Grupo de comparación aún no disponible", "comparisonUnavailableDescription": "Mostraremos la comparación cuando el grupo alcance el umbral de privacidad de {minimum} participantes. Mientras tanto, no mostramos estadísticas de otros usuarios como sustituto.", "comparisonUnavailableProgress": "{count} de {minimum} personas hasta ahora.", "customError": "No se pudo actualizar la comparación. Inténtalo de nuevo.", "factorUnavailable": "Completa esta parte de tu perfil para usarla en la comparación.", "preview": "Vista previa: {count} perfiles comparables (mínimo {minimum}).", - "median": "Mediana", - "interquartileRange": "Rango central", - "monthsAgo": "Hace {months} meses", - "reliability": {"low": "Fiabilidad inicial", "medium": "Fiabilidad media", "high": "Fiabilidad alta"}, + "relaxedNotice": "Todavía no hay suficientes personas que cumplan todos tus criterios, así que esta comparación usa un grupo más amplio basado en: {factors}.", "factors": { "career": "Trabajo y carrera", "location": "Área geográfica", "lifeStage": "Etapa vital", "household": "Hogar y familia" } }, - "sections": { - "rankings": { - "title": "Clasificaciones", - "description": "Ve cómo te posicionas en comparación con otros usuarios" - }, - "insights": { - "title": "Comparación financiera", - "description": "Descubre patrones y oportunidades" - }, - "spending": { - "title": "Análisis de gastos", - "description": "Compara tus hábitos de gasto" - }, - "benchmarks": { - "title": "Benchmarks", - "description": "Comparaciones por industria y demografía" - } - }, "cards": { "avgBalance": { - "title": "Balance medio", - "description": "Compara tu balance actual y crecimiento de 12 meses con otros usuarios", - "yourBalance": "Tu balance", - "avgSimilar": "Media usuarios similares", - "avgAll": "Media todos los usuarios", + "title": "Patrimonio", + "description": "Tu patrimonio y su crecimiento en los últimos 12 meses", + "avgSimilar": "Mediana del grupo de comparación", + "avgAll": "Referencia general", "growth12Months": "últimos 12 meses", "noGrowthData": "Datos de crecimiento no disponibles" }, "avgIncome": { "title": "Ingresos mensuales", - "description": "Compara los ingresos del último mes completo", "yourIncome": "Tus ingresos", - "avgSimilar": "Media usuarios similares", - "avgAll": "Media todos los usuarios" + "avgSimilar": "Mediana del grupo de comparación", + "avgAll": "Referencia general" }, "avgOutflows": { "title": "Gastos mensuales", - "description": "Compara los gastos del último mes completo", "yourOutflows": "Tus gastos", - "avgSimilar": "Media usuarios similares", - "avgAll": "Media todos los usuarios" + "avgSimilar": "Mediana del grupo de comparación", + "avgAll": "Referencia general" }, "savingsRate": { "title": "Tasa de ahorro", "description": "Tus ahorros como porcentaje de los ingresos", - "yourRate": "Tu tasa", "avgAll": "Media todos los usuarios", "avgSimilar": "Media usuarios similares", "last12Months": "Últimos 12 meses", "noData": "Datos insuficientes" }, "assetAllocation": { - "title": "Asignación de activos", + "title": "Asignación de patrimonio", "description": "Cómo distribuyes tu patrimonio", "liquid": "Líquido", "investments": "Inversiones", "crypto": "Cripto", - "other": "Otros", "avgSimilar": "Promedio de usuarios similares", "avgAll": "Promedio de todos los usuarios", "noAssets": "Sin activos registrados" @@ -1285,17 +1268,10 @@ "spendingCategories": { "title": "Gastos por categoría", "description": "Dónde gastas tu dinero (últimos 12 meses)", - "topCategories": "Categorías principales", - "otherCategories": "Otras categorías", "noExpenses": "Sin gastos registrados", "avgSimilar": "Media usuarios similares", "avgAll": "Media todos los usuarios" - }, - "showMore": "Mostrar todo", - "showLess": "Mostrar menos" - }, - "insights": { - "average": "Tu rendimiento está en la media" + } }, "actionableInsights": { "percentileTitle": "Patrimonio: top {rank}% entre perfiles similares", @@ -1308,12 +1284,21 @@ "balancedDescription": "No destacan diferencias relevantes. Sigue observando la tendencia, que es más útil que un solo mes." }, "profileBanner": { - "title": "🚀 ¡Desbloquea comparaciones personalizadas!", - "description": "Completa tu perfil en la página de Cuenta para obtener comparaciones anónimas y automáticas con usuarios similares. ¡Descubre cómo te posicionas frente a otros profesionales!", - "action": "Completar perfil" - }, - "tips": { - "title": "Consejos personalizados" + "title": "Haz tu grupo de comparación más preciso", + "description": "Completa tu perfil para encontrar usuarios realmente similares." + }, + "geography": { + "title": "Geografía", + "countryTitle": "Comparar por país", + "countryDescription": "Aísla la geografía del resto de factores de tu perfil para ver cómo te comparas con personas de tu país.", + "countryNeedsConsent": "Activa la comparación arriba para usar esto.", + "countryProfileIncomplete": "Añade tu país a tu perfil", + "countryCTA": "Ver mi comparación por país", + "countryUnavailable": "Aún no hay suficientes personas de tu país para comparar.", + "regionComingSoon": "Próximamente", + "regionTitle": "Región y ciudad", + "regionDescription": "Todavía no recopilamos región ni ciudad, así que no podemos comparar a ese nivel. Está en el roadmap, junto con un mapa interactivo y una vista ajustada al coste de vida.", + "mapFutureNote": "Un paso futuro: simular cómo un cambio de trabajo o de ubicación podría afectar a tus números, siempre mostrado como una suposición, nunca como un consejo." } }, "leaderboard": { @@ -1339,15 +1324,10 @@ "title": "Tus clasificaciones", "monthData": "Datos de", "generalRanking": "Clasificación general", - "generalSubtitle": "Comparación con todos los usuarios", "similarRanking": "Usuarios similares", - "similarSubtitle": "Comparación con perfiles similares al tuyo", "balance": "Balance", "income": "Ingresos", "outflows": "Gastos", - "topPerformance": "¡Gran posición!", - "goodPerformance": "Buena posición", - "canImprove": "Margen de mejora", "noData": "Datos no disponibles", "descriptions": { "balance": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 50753aaf..08c4d02c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1187,83 +1187,67 @@ } }, "comparison": { - "layers": {"title":"Comment lire les données","you":"Toi","group":"Groupe de comparaison","general":"Référence générale","hint":"Les données d'autres utilisateurs apparaissent uniquement si le seuil de confidentialité est respecté."}, - "rankingsAccessory": {"title":"Indicateurs de comportement","accessoryLabel":"Vue secondaire","description":"Ces classements n'évaluent ni le patrimoine, ni les revenus, ni les rendements absolus. Ils décrivent des habitudes observables et la progression vers tes objectifs.","savingConsistency":"Régularité de l'épargne","savingConsistencyDescription":"Mois où tu as atteint ton objectif d'épargne.","investmentRegularity":"Régularité des investissements","investmentRegularityDescription":"Continuité des mois avec au moins une contribution d'investissement.","contributionFrequency":"Fréquence des contributions","contributionFrequencyDescription":"Nombre de contributions sur la période observée, uniquement pour les groupes assez grands.","goalProgress":"Progression des objectifs","goalProgressDescription":"Progression vers les objectifs que tu as choisi de suivre.","waitingForMetric":"Indicateur en préparation","waitingForMetricDescription":"Un historique comparable et un groupe assez grand sont nécessaires.","waitingForGroup":"Groupe de comparaison indisponible","waitingForGroupDescription":"L'indicateur apparaîtra à partir de {minimum} participants."}, - "guide": {"title": "Des comparaisons utiles sans exposer tes données", "description": "En participant, tes valeurs servent uniquement à des statistiques anonymes et agrégées.", "youTitle": "Toi", "youDescription": "Tes données, visibles uniquement par toi.", "similarTitle": "Groupe de comparaison", "similarDescription": "Un groupe anonyme fondé sur les critères de profil que tu choisis.", "allTitle": "Référence générale", "allDescription": "La référence générale de la communauté, moins personnalisée."}, - "title": "Comparaison anonyme", - "subtitle": "Compare tes performances financières avec des utilisateurs similaires de manière anonyme", + "title": "Ton miroir financier", + "subtitle": "Découvre comment tu t'en sors, en toute anonymité — pas de classement, juste des repères utiles pour toi.", + "hero": { + "eyebrow": "Uniquement des données agrégées et anonymes", + "gaugeLabel": "Percentile", + "gaugeCaption": "Ton patrimoine comparé à des personnes au profil similaire.", + "gaugeLockedDescription": "Nous l'afficherons dès que ton groupe de comparaison atteindra le seuil minimal de confidentialité.", + "balanceLabel": "Patrimoine", + "incomeLabel": "Revenus", + "outflowsLabel": "Frugalité" + }, + "rankingsAccessory": {"title":"Indicateurs de comportement","description":"Ces classements n'évaluent ni le patrimoine, ni les revenus, ni les rendements absolus. Ils décrivent des habitudes observables et la progression vers tes objectifs.","savingConsistency":"Régularité de l'épargne","savingConsistencyDescription":"Mois où tu as atteint ton objectif d'épargne.","investmentRegularity":"Régularité des investissements","investmentRegularityDescription":"Continuité des mois avec au moins une contribution d'investissement.","contributionFrequency":"Fréquence des contributions","contributionFrequencyDescription":"Nombre de contributions sur la période observée, uniquement pour les groupes assez grands.","goalProgress":"Progression des objectifs","goalProgressDescription":"Progression vers les objectifs que tu as choisi de suivre."}, + "accordion": { + "sectionLabel": "Explorer le détail", + "cashflowTitle": "Revenus & sorties", + "cashflowDescription": "Ton dernier mois complet, comparé à ton groupe de comparaison", + "behaviourTitle": "Comportement financier" + }, "benchmarkOverview": { - "title": "Ton benchmark personnel", - "description": "Le patrimoine, les revenus et les dépenses utilisent des groupes de comparaison dédiés, car des revenus similaires n'impliquent pas les mêmes dépenses ou la même étape de vie.", - "editProfile": "Modifier le profil", - "show": "Voir la comparaison", - "hide": "Réduire", - "groupHelp": "Le groupe de comparaison rassemble des utilisateurs aux profils similaires et affiche seulement des résultats agrégés lorsqu'il est assez grand.", - "optInTitle": "Débloquer la comparaison avec des utilisateurs similaires", "optInDescription": "Active ton consentement pour recevoir des benchmarks agrégés. Les transactions, notes et identités ne sont jamais partagées.", "optInTitleSelfHosted": "Débloquer la comparaison avec les autres utilisateurs de cette instance", "optInDescriptionSelfHosted": "Active ton consentement pour te comparer uniquement aux autres utilisateurs de cette instance auto-hébergée. La comparaison communautaire entre instances est une fonctionnalité prévue, pas encore disponible. Les transactions, notes et identités ne sont jamais partagées.", "optInAction": "Activer la comparaison", "optInSaving": "Activation...", "optInError": "Impossible d'activer la comparaison communautaire. Réessayez.", - "basedOn": "Comparaison basée sur", + "title": "Ta comparaison personnelle", + "description": "Active la comparaison pour voir ton patrimoine, tes revenus et tes sorties par rapport à un groupe d'utilisateurs au profil similaire.", + "groupHelp": "Le groupe de comparaison rassemble des utilisateurs aux profils similaires. Les résultats sont agrégés et affichés uniquement lorsque le groupe est assez grand.", + "optInTitle": "Débloquer la comparaison avec des utilisateurs similaires", "optInDescription": "Active ton consentement pour recevoir des benchmarks agrégés. Les transactions, notes et identités ne sont jamais partagées.", "optInTitleSelfHosted": "Débloquer la comparaison avec les autres utilisateurs de cette instance", "optInDescriptionSelfHosted": "Active ton consentement pour te comparer uniquement aux autres utilisateurs de cette instance auto-hébergée. La comparaison communautaire entre instances est une fonctionnalité prévue, pas encore disponible. Les transactions, notes et identités ne sont jamais partagées.", "optInSaving": "Activation...", "optInError": "Impossible d'activer la comparaison communautaire. Réessayez.", "waiting": "Groupe de comparaison en préparation", "privacy": "Données agrégées uniquement. Groupes de comparaison : {count} utilisateurs ; seuil minimal de confidentialité : {minimum}. Mis à jour : {updated}.", - "customizeTitle": "Personnaliser les utilisateurs similaires", + "customizeTitle": "Ton groupe de comparaison", "customizeDescription": "Choisis quelles parties de ton profil comptent dans la comparaison. Les données restent agrégées et anonymes.", "applyFactors": "Mettre à jour la comparaison", "resetFactors": "Utiliser la comparaison recommandée", "calculating": "Calcul en cours...", - "noCohort": "Il n'y a pas encore assez de profils comparables pour ce groupe.", "comparisonUnavailable": "Groupe de comparaison indisponible", "comparisonUnavailableDescription": "La comparaison apparaîtra lorsque le groupe atteindra le seuil de confidentialité de {minimum} participants. En attendant, nous n'affichons pas les statistiques d'autres utilisateurs comme remplacement.", + "noCohort": "Il n'y a pas encore assez de profils comparables pour ce groupe.", "comparisonUnavailable": "Groupe de comparaison indisponible", "comparisonUnavailableDescription": "La comparaison apparaîtra lorsque le groupe atteindra le seuil de confidentialité de {minimum} participants. En attendant, nous n'affichons pas les statistiques d'autres utilisateurs comme remplacement.", "comparisonUnavailableProgress": "{count} personnes sur {minimum} jusqu'à présent.", "customError": "Impossible de mettre à jour la comparaison. Réessaie.", "factorUnavailable": "Complète cette partie de ton profil pour l'utiliser dans la comparaison.", "preview": "Aperçu : {count} profils comparables (minimum {minimum}).", - "median": "Médiane", - "interquartileRange": "Plage centrale", - "monthsAgo": "Il y a {months} mois", - "reliability": {"low": "Fiabilité initiale", "medium": "Fiabilité moyenne", "high": "Fiabilité élevée"}, + "relaxedNotice": "Il n'y a pas encore assez de personnes correspondant à tous tes critères, donc cette comparaison utilise un groupe plus large basé sur : {factors}.", "factors": { "career": "Travail et carrière", "location": "Zone géographique", "lifeStage": "Étape de vie", "household": "Logement et famille" } }, - "sections": { - "rankings": { - "title": "Classements", - "description": "Découvre comment tu te classes par rapport aux autres utilisateurs" - }, - "insights": { - "title": "Comparaison financière", - "description": "Découvre des tendances et des opportunités" - }, - "spending": { - "title": "Analyse des dépenses", - "description": "Compare tes habitudes de dépenses" - }, - "benchmarks": { - "title": "Références", - "description": "Comparaisons par secteur et par profil démographique" - } - }, "cards": { "avgBalance": { - "title": "Solde moyen", - "description": "Compare ton solde actuel et ta croissance sur 12 mois avec les autres utilisateurs", - "yourBalance": "Ton solde", - "avgSimilar": "Moy. utilisateurs similaires", - "avgAll": "Moy. tous les utilisateurs", + "title": "Patrimoine", + "description": "Ton patrimoine et sa croissance sur les 12 derniers mois", + "avgSimilar": "Médiane du groupe de comparaison", + "avgAll": "Référence générale", "growth12Months": "12 derniers mois", "noGrowthData": "Données de croissance non disponibles" }, "avgIncome": { "title": "Revenus mensuels", - "description": "Compare les revenus du dernier mois complet", "yourIncome": "Tes revenus", - "avgSimilar": "Moy. utilisateurs similaires", - "avgAll": "Moy. tous les utilisateurs" + "avgSimilar": "Médiane du groupe de comparaison", + "avgAll": "Référence générale" }, "avgOutflows": { "title": "Sorties mensuelles", - "description": "Compare les sorties du dernier mois complet", "yourOutflows": "Tes sorties", - "avgSimilar": "Moy. utilisateurs similaires", - "avgAll": "Moy. tous les utilisateurs" + "avgSimilar": "Médiane du groupe de comparaison", + "avgAll": "Référence générale" }, "savingsRate": { "title": "Taux d'épargne", "description": "Ton épargne en pourcentage de tes revenus", - "yourRate": "Ton taux", "avgAll": "Moy. tous les utilisateurs", "avgSimilar": "Moy. utilisateurs similaires", "last12Months": "12 derniers mois", @@ -1275,7 +1259,6 @@ "liquid": "Liquidités", "investments": "Investissements", "crypto": "Crypto", - "other": "Autre", "avgSimilar": "Moyenne des utilisateurs similaires", "avgAll": "Moyenne de tous les utilisateurs", "noAssets": "Aucun actif enregistré" @@ -1283,17 +1266,10 @@ "spendingCategories": { "title": "Dépenses par catégorie", "description": "Où tu dépenses ton argent (12 derniers mois)", - "topCategories": "Catégories principales", - "otherCategories": "Autres catégories", "noExpenses": "Aucune dépense enregistrée", "avgSimilar": "Moy. utilisateurs similaires", "avgAll": "Moy. tous les utilisateurs" - }, - "showMore": "Tout afficher", - "showLess": "Afficher moins" - }, - "insights": { - "average": "Tu es dans la moyenne" + } }, "actionableInsights": { "percentileTitle": "Patrimoine : top {rank}% parmi les profils similaires", @@ -1306,12 +1282,21 @@ "balancedDescription": "Aucun écart important ne ressort. Continue à suivre la tendance, plus utile qu'un seul mois." }, "profileBanner": { - "title": "🚀 Débloque les comparaisons personnalisées !", - "description": "Complète ton profil sur la page Compte pour obtenir des comparaisons anonymes et automatiques avec des utilisateurs similaires. Découvre comment tu te positionnes par rapport aux autres professionnels !", - "action": "Compléter le profil" - }, - "tips": { - "title": "Conseils personnalisés" + "title": "Affine ton groupe de comparaison", + "description": "Complète ton profil pour trouver des utilisateurs vraiment similaires." + }, + "geography": { + "title": "Géographie", + "countryTitle": "Comparer par pays", + "countryDescription": "Isole la géographie des autres facteurs de ton profil pour voir comment tu te compares aux personnes de ton pays.", + "countryNeedsConsent": "Active la comparaison ci-dessus pour utiliser cette fonctionnalité.", + "countryProfileIncomplete": "Ajoute ton pays à ton profil", + "countryCTA": "Voir ma comparaison par pays", + "countryUnavailable": "Pas encore assez de personnes de ton pays pour comparer.", + "regionComingSoon": "Bientôt disponible", + "regionTitle": "Région et ville", + "regionDescription": "Nous ne recueillons pas encore la région ou la ville, donc nous ne pouvons pas comparer à ce niveau. C'est prévu sur la feuille de route, avec une carte cliquable et une vue ajustée au coût de la vie.", + "mapFutureNote": "Une prochaine étape : simuler l'effet d'un changement de travail ou de lieu sur tes chiffres, toujours présenté comme une hypothèse, jamais comme un conseil." } }, "leaderboard": { @@ -1337,15 +1322,10 @@ "title": "Tes classements", "monthData": "Données de", "generalRanking": "Classement général", - "generalSubtitle": "Comparaison avec tous les utilisateurs", "similarRanking": "Utilisateurs similaires", - "similarSubtitle": "Comparaison avec des profils similaires au tien", "balance": "Solde", "income": "Revenus", "outflows": "Sorties", - "topPerformance": "Excellente position !", - "goodPerformance": "Bonne position", - "canImprove": "Marge de progression", "noData": "Données non disponibles", "descriptions": { "balance": { diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 90e43181..4880767a 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1255,45 +1255,46 @@ } }, "comparison": { - "layers": {"title":"Come leggere i dati","you":"Tu","group":"Gruppo di confronto","general":"Riferimento generale","hint":"I dati degli altri utenti compaiono solo quando la soglia privacy è rispettata."}, - "rankingsAccessory": {"title":"Indicatori comportamentali","accessoryLabel":"Vista accessoria","description":"Le classifiche non valutano patrimonio, reddito o rendimento assoluto. Misurano solo abitudini osservabili e progresso verso i tuoi obiettivi.","savingConsistency":"Costanza nel risparmio","savingConsistencyDescription":"Mesi in cui hai raggiunto il tuo obiettivo di risparmio.","investmentRegularity":"Regolarità degli investimenti","investmentRegularityDescription":"Continuità dei mesi con almeno un contributo d'investimento.","contributionFrequency":"Frequenza dei contributi","contributionFrequencyDescription":"Numero di contributi nei mesi osservati, confrontato solo con gruppi sufficientemente grandi.","goalProgress":"Progresso sugli obiettivi","goalProgressDescription":"Avanzamento rispetto agli obiettivi che hai scelto di monitorare.","waitingForMetric":"Indicatore in preparazione","waitingForMetricDescription":"Servono dati storici omogenei e un gruppo sufficiente prima di pubblicare questo confronto.","waitingForGroup":"Gruppo di confronto non ancora disponibile","waitingForGroupDescription":"L'indicatore apparirà quando il gruppo raggiungerà almeno {minimum} partecipanti."}, - "guide": {"title": "Un confronto utile, senza esporre i tuoi dati", "description": "Partecipando, i tuoi valori confluiscono solo in statistiche aggregate e anonime.", "youTitle": "Tu", "youDescription": "I tuoi dati, visibili soltanto a te.", "similarTitle": "Gruppo di confronto", "similarDescription": "Un gruppo anonimo costruito con i fattori del profilo che scegli.", "allTitle": "Riferimento generale", "allDescription": "La statistica generale della community, meno personalizzata."}, - "title": "Confronto Anonimo", - "subtitle": "Confronta le tue performance finanziarie con utenti simili in modo anonimo", + "title": "Il tuo specchio finanziario", + "subtitle": "Capisci come stai andando, in modo del tutto anonimo — niente classifiche, solo spunti utili per migliorare la tua situazione.", + "hero": { + "eyebrow": "Solo dati aggregati e anonimi", + "gaugeLabel": "Percentile", + "gaugeCaption": "Il tuo patrimonio rispetto a persone con un profilo simile al tuo.", + "gaugeLockedDescription": "Lo mostreremo appena il tuo gruppo di confronto raggiungerà la soglia minima di privacy.", + "balanceLabel": "Patrimonio", + "incomeLabel": "Entrate", + "outflowsLabel": "Sobrietà di spesa" + }, + "rankingsAccessory": {"title":"Indicatori comportamentali","description":"Le classifiche non valutano patrimonio, reddito o rendimento assoluto. Misurano solo abitudini osservabili e progresso verso i tuoi obiettivi.","savingConsistency":"Costanza nel risparmio","savingConsistencyDescription":"Mesi in cui hai raggiunto il tuo obiettivo di risparmio.","investmentRegularity":"Regolarità degli investimenti","investmentRegularityDescription":"Continuità dei mesi con almeno un contributo d'investimento.","contributionFrequency":"Frequenza dei contributi","contributionFrequencyDescription":"Numero di contributi nei mesi osservati, confrontato solo con gruppi sufficientemente grandi.","goalProgress":"Progresso sugli obiettivi","goalProgressDescription":"Avanzamento rispetto agli obiettivi che hai scelto di monitorare."}, + "accordion": { + "sectionLabel": "Approfondisci", + "cashflowTitle": "Entrate e uscite", + "cashflowDescription": "L'ultimo mese completo, a confronto con il tuo gruppo", + "behaviourTitle": "Comportamento finanziario" + }, "benchmarkOverview": { "title": "Il tuo confronto personale", - "description": "Confronta patrimonio, entrate e uscite con gruppi di utenti dal profilo simile.", - "editProfile": "Profilo", - "show": "Vedi confronto", - "hide": "Riduci", + "description": "Attiva il confronto per vedere patrimonio, entrate e uscite rispetto a un gruppo di utenti dal profilo simile.", "groupHelp": "Un gruppo di confronto riunisce utenti con caratteristiche di profilo simili. Mostriamo solo risultati aggregati quando la numerosità è sufficiente.", "optInTitle": "Sblocca il confronto con utenti simili", "optInDescription": "Attiva il consenso per ricevere benchmark aggregati. Non condividiamo transazioni, note o identità.", "optInTitleSelfHosted": "Sblocca il confronto con gli altri utenti di questa istanza", "optInDescriptionSelfHosted": "Attiva il consenso per confrontarti solo con altri utenti di questa istanza self-hosted. Il confronto community tra istanze diverse è una funzionalità pianificata, non ancora disponibile. Non condividiamo transazioni, note o identità.", - "optInAction": "Attiva confronto", "optInSaving": "Attivazione...", "optInError": "Non è stato possibile attivare il confronto community. Riprova.", - "basedOn": "Gruppo basato su", "waiting": "Gruppo in preparazione", "privacy": "Solo dati aggregati. Gruppo: {count} utenti; soglia privacy minima: {minimum}. Aggiornato: {updated}.", - "customizeTitle": "Personalizza utenti simili", + "customizeTitle": "Il tuo gruppo di confronto", "customizeDescription": "Scegli quali parti del profilo contano nel confronto. I dati restano aggregati e anonimi.", "applyFactors": "Aggiorna confronto", "resetFactors": "Usa confronto consigliato", "calculating": "Calcolo in corso...", - "noCohort": "Non ci sono ancora abbastanza profili comparabili per questo gruppo.", "comparisonUnavailable": "Gruppo di confronto non ancora disponibile", "comparisonUnavailableDescription": "Mostreremo il confronto quando il gruppo raggiungerà la soglia minima di privacy di {minimum} partecipanti. Nel frattempo non mostriamo statistiche di altri utenti come sostituto.", + "noCohort": "Non ci sono ancora abbastanza profili comparabili per questo gruppo.", "comparisonUnavailable": "Gruppo di confronto non ancora disponibile", "comparisonUnavailableDescription": "Mostreremo il confronto quando il gruppo raggiungerà la soglia minima di privacy di {minimum} partecipanti. Nel frattempo non mostriamo statistiche di altri utenti come sostituto.", "comparisonUnavailableProgress": "{count} persone su {minimum} finora.", "customError": "Non è stato possibile aggiornare il confronto. Riprova.", "factorUnavailable": "Completa questa parte del profilo per usarla nel confronto.", "preview": "Anteprima: {count} profili comparabili (minimo {minimum}).", - "median": "Mediana", - "interquartileRange": "Intervallo centrale", - "monthsAgo": "{months} mesi fa", - "reliability": { - "low": "Affidabilità iniziale", - "medium": "Affidabilità media", - "high": "Affidabilità alta" - }, + "relaxedNotice": "Non ci sono ancora abbastanza persone che rispettano tutti i criteri scelti: questo confronto usa quindi un gruppo più ampio basato su: {factors}.", "factors": { "career": "Lavoro e carriera", "location": "Area geografica", @@ -1301,29 +1302,10 @@ "household": "Casa e famiglia" } }, - "sections": { - "rankings": { - "title": "Classifiche", - "description": "Vedi come ti posizioni rispetto ad altri utenti" - }, - "insights": { - "title": "Confronto Finanziario", - "description": "Scopri pattern e opportunità" - }, - "spending": { - "title": "Analisi Uscite", - "description": "Confronta le tue abitudini di uscita" - }, - "benchmarks": { - "title": "Benchmark", - "description": "Confronti per settore e demografia" - } - }, "cards": { "avgBalance": { - "title": "Patrimonio Medio", - "description": "Confronta il tuo patrimonio attuale e la crescita negli ultimi 12 mesi con altri utenti", - "yourBalance": "Il Tuo Patrimonio", + "title": "Patrimonio", + "description": "Il tuo patrimonio e la sua crescita negli ultimi 12 mesi", "avgSimilar": "Mediana gruppo di confronto", "avgAll": "Riferimento generale", "growth12Months": "ultimi 12 mesi", @@ -1331,14 +1313,12 @@ }, "avgIncome": { "title": "Entrate Mensili", - "description": "Confronta le entrate dell'ultimo mese completo", "yourIncome": "Le tue entrate", "avgSimilar": "Mediana gruppo di confronto", "avgAll": "Riferimento generale" }, "avgOutflows": { "title": "Uscite Mensili", - "description": "Confronta le uscite dell'ultimo mese completo", "yourOutflows": "Le tue uscite", "avgSimilar": "Mediana gruppo di confronto", "avgAll": "Riferimento generale" @@ -1346,37 +1326,28 @@ "savingsRate": { "title": "Tasso di Risparmio", "description": "I tuoi risparmi come percentuale delle entrate", - "yourRate": "Il tuo tasso", "avgAll": "Media Tutti gli Utenti", "avgSimilar": "Media Utenti Simili", "last12Months": "Ultimi 12 mesi", "noData": "Dati insufficienti" }, "assetAllocation": { - "title": "Allocazione Asset", + "title": "Allocazione Patrimonio", "description": "Come distribuisci la tua ricchezza", "liquid": "Liquidità", "investments": "Investimenti", "crypto": "Crypto", - "other": "Altro", "avgSimilar": "Media Utenti Simili", "avgAll": "Media Tutti gli Utenti", "noAssets": "Nessun asset registrato" }, "spendingCategories": { - "title": "Uscite per Categoria", - "description": "Dove spendi i tuoi soldi (ultimi 12 mesi)", - "topCategories": "Categorie principali", - "otherCategories": "Altre categorie", + "title": "Spese per Categoria", + "description": "Dove vanno i tuoi soldi (ultimi 12 mesi)", "noExpenses": "Nessuna spesa registrata", "avgSimilar": "Media Utenti Simili", "avgAll": "Media Tutti gli Utenti" - }, - "showMore": "Mostra tutto", - "showLess": "Riduci" - }, - "insights": { - "average": "Stai performando nella media" + } }, "actionableInsights": { "percentileTitle": "Patrimonio: top {rank}% tra profili simili", @@ -1390,11 +1361,20 @@ }, "profileBanner": { "title": "Rendi più preciso il tuo gruppo di confronto", - "description": "Completa il profilo per trovare utenti davvero simili a te. Questo migliora il confronto, ma non attiva la partecipazione: il consenso anonimo si gestisce separatamente qui sotto.", - "action": "Completa profilo" - }, - "tips": { - "title": "Suggerimenti Personalizzati" + "description": "Completa il profilo per trovare utenti davvero simili a te." + }, + "geography": { + "title": "Geografia", + "countryTitle": "Confronto per Paese", + "countryDescription": "Isola la posizione geografica dagli altri fattori del profilo per vedere come ti confronti con chi vive nel tuo Paese.", + "countryNeedsConsent": "Attiva il confronto qui sopra per usare questa funzione.", + "countryProfileIncomplete": "Aggiungi il tuo Paese al profilo", + "countryCTA": "Vedi il confronto per Paese", + "countryUnavailable": "Non ci sono ancora abbastanza persone del tuo Paese per un confronto anonimo.", + "regionComingSoon": "Prossimamente", + "regionTitle": "Regione e città", + "regionDescription": "Non raccogliamo ancora regione o città, quindi non possiamo confrontarti a questo livello. È nella roadmap, insieme a una mappa cliccabile e a un confronto corretto per il costo della vita.", + "mapFutureNote": "Un passo futuro: simulare come un cambio di lavoro o città potrebbe influire sui tuoi numeri — sempre mostrato come ipotesi, mai come consiglio." } }, "leaderboard": { @@ -1420,15 +1400,10 @@ "title": "Le tue classifiche", "monthData": "Dati di", "generalRanking": "Classifica Generale", - "generalSubtitle": "Confronto con tutti gli utenti", "similarRanking": "Utenti Simili", - "similarSubtitle": "Confronto con profili simili al tuo", "balance": "Patrimonio", "income": "Entrate", "outflows": "Uscite", - "topPerformance": "Ottima posizione!", - "goodPerformance": "Buona posizione", - "canImprove": "Spazio per crescere", "noData": "Dati non disponibili", "descriptions": { "balance": { diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index d6461538..9969c032 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1187,95 +1187,78 @@ } }, "comparison": { - "layers": {"title":"Como ler os dados","you":"Você","group":"Grupo de comparação","general":"Referência geral","hint":"Os dados de outros usuários aparecem apenas quando o limite de privacidade é atendido."}, - "rankingsAccessory": {"title":"Indicadores de comportamento","accessoryLabel":"Visão secundária","description":"Estas classificações não avaliam patrimônio, renda ou retornos absolutos. Elas descrevem hábitos observáveis e o progresso em relação aos seus objetivos.","savingConsistency":"Consistência da poupança","savingConsistencyDescription":"Meses em que você atingiu sua meta de poupança.","investmentRegularity":"Regularidade dos investimentos","investmentRegularityDescription":"Continuidade dos meses com pelo menos uma contribuição de investimento.","contributionFrequency":"Frequência das contribuições","contributionFrequencyDescription":"Número de contribuições no período observado, apenas para grupos suficientemente grandes.","goalProgress":"Progresso dos objetivos","goalProgressDescription":"Progresso em relação aos objetivos que você escolheu acompanhar.","waitingForMetric":"Indicador em preparação","waitingForMetricDescription":"É necessário histórico comparável e um grupo suficientemente grande.","waitingForGroup":"Grupo de comparação ainda indisponível","waitingForGroupDescription":"O indicador aparecerá quando o grupo atingir {minimum} participantes."}, - "guide": {"title": "Comparações úteis sem expor seus dados", "description": "Ao participar, seus valores entram apenas em estatísticas anônimas e agregadas.", "youTitle": "Você", "youDescription": "Seus dados, visíveis somente para você.", "similarTitle": "Grupo de comparação", "similarDescription": "Um grupo anônimo criado com os fatores de perfil que você escolher.", "allTitle": "Referência geral", "allDescription": "A referência geral da comunidade, menos personalizada."}, - "title": "Comparação anônima", - "subtitle": "Compare seu desempenho financeiro com usuários semelhantes de forma anônima", + "title": "Seu espelho financeiro", + "subtitle": "Veja como você está indo, de forma totalmente anônima — sem rankings, só percepções úteis para você.", + "hero": { + "eyebrow": "Apenas dados agregados e anônimos", + "gaugeLabel": "Percentil", + "gaugeCaption": "Seu patrimônio comparado a pessoas com perfil semelhante.", + "gaugeLockedDescription": "Vamos mostrar isso assim que seu grupo de comparação atingir o limite mínimo de privacidade.", + "balanceLabel": "Patrimônio", + "incomeLabel": "Renda", + "outflowsLabel": "Frugalidade" + }, + "rankingsAccessory": {"title":"Indicadores de comportamento","description":"Estas classificações não avaliam patrimônio, renda ou retornos absolutos. Elas descrevem hábitos observáveis e o progresso em relação aos seus objetivos.","savingConsistency":"Consistência da poupança","savingConsistencyDescription":"Meses em que você atingiu sua meta de poupança.","investmentRegularity":"Regularidade dos investimentos","investmentRegularityDescription":"Continuidade dos meses com pelo menos uma contribuição de investimento.","contributionFrequency":"Frequência das contribuições","contributionFrequencyDescription":"Número de contribuições no período observado, apenas para grupos suficientemente grandes.","goalProgress":"Progresso dos objetivos","goalProgressDescription":"Progresso em relação aos objetivos que você escolheu acompanhar."}, + "accordion": { + "sectionLabel": "Explore os detalhes", + "cashflowTitle": "Receitas e saídas", + "cashflowDescription": "Seu último mês completo, comparado ao seu grupo de comparação", + "behaviourTitle": "Comportamento financeiro" + }, "benchmarkOverview": { - "title": "Seu benchmark pessoal", - "description": "Patrimônio, receitas e saídas usam coortes específicas, pois renda semelhante não significa necessariamente gastos ou fase de vida semelhantes.", - "editProfile": "Editar perfil", - "show": "Ver comparação", - "hide": "Recolher", - "groupHelp": "O grupo de comparação reúne usuários com perfis semelhantes e mostra resultados agregados apenas quando há participantes suficientes.", - "optInTitle": "Desbloqueie comparações com usuários semelhantes", "optInDescription": "Ative o consentimento para receber benchmarks agregados. Nunca compartilhamos transações, notas ou identidade.", "optInTitleSelfHosted": "Desbloqueie comparações com outros usuários desta instância", "optInDescriptionSelfHosted": "Ative o consentimento para comparar apenas com outros usuários desta instância self-hosted. A comparação da comunidade entre instâncias é um recurso planejado, ainda não disponível. Nunca compartilhamos transações, notas ou identidade.", "optInAction": "Ativar comparação", "optInSaving": "Ativando...", "optInError": "Não foi possível ativar a comparação da comunidade. Tente novamente.", - "basedOn": "Comparação baseada em", - "waiting": "Coorte em preparação", + "title": "Sua comparação pessoal", + "description": "Ative a comparação para ver seu patrimônio, renda e saídas em relação a um grupo de usuários com perfil semelhante.", + "groupHelp": "O grupo de comparação reúne usuários com perfis semelhantes. Os resultados são agregados e exibidos apenas quando o grupo é suficientemente grande.", + "optInTitle": "Desbloqueie comparações com usuários semelhantes", "optInDescription": "Ative o consentimento para receber benchmarks agregados. Nunca compartilhamos transações, notas ou identidade.", "optInTitleSelfHosted": "Desbloqueie comparações com outros usuários desta instância", "optInDescriptionSelfHosted": "Ative o consentimento para comparar apenas com outros usuários desta instância self-hosted. A comparação da comunidade entre instâncias é um recurso planejado, ainda não disponível. Nunca compartilhamos transações, notas ou identidade.", "optInSaving": "Ativando...", "optInError": "Não foi possível ativar a comparação da comunidade. Tente novamente.", + "waiting": "Grupo de comparação em preparação", "privacy": "Somente dados agregados. Coortes: {count} usuários; limite mínimo de privacidade: {minimum}. Atualizado: {updated}.", - "customizeTitle": "Personalizar usuários semelhantes", + "customizeTitle": "Seu grupo de comparação", "customizeDescription": "Escolha quais partes do seu perfil contam na comparação. Os dados permanecem agregados e anônimos.", "applyFactors": "Atualizar comparação", "resetFactors": "Usar comparação recomendada", "calculating": "Calculando...", - "noCohort": "Ainda não há perfis comparáveis suficientes para este grupo.", "comparisonUnavailable": "Grupo de comparação ainda indisponível", "comparisonUnavailableDescription": "Mostraremos a comparação quando o grupo atingir o limite de privacidade de {minimum} participantes. Até lá, não exibimos estatísticas de outros usuários como substituto.", + "noCohort": "Ainda não há perfis comparáveis suficientes para este grupo.", "comparisonUnavailable": "Grupo de comparação ainda indisponível", "comparisonUnavailableDescription": "Mostraremos a comparação quando o grupo atingir o limite de privacidade de {minimum} participantes. Até lá, não exibimos estatísticas de outros usuários como substituto.", "comparisonUnavailableProgress": "{count} de {minimum} pessoas até agora.", "customError": "Não foi possível atualizar a comparação. Tente novamente.", "factorUnavailable": "Complete esta parte do seu perfil para usá-la na comparação.", "preview": "Prévia: {count} perfis comparáveis (mínimo {minimum}).", - "median": "Mediana", - "interquartileRange": "Faixa central", - "monthsAgo": "Há {months} meses", - "reliability": {"low": "Confiabilidade inicial", "medium": "Confiabilidade média", "high": "Confiabilidade alta"}, + "relaxedNotice": "Ainda não há pessoas suficientes que atendam a todos os seus critérios, então esta comparação usa um grupo mais amplo baseado em: {factors}.", "factors": { "career": "Trabalho e carreira", "location": "Área geográfica", "lifeStage": "Fase da vida", "household": "Casa e família" } }, - "sections": { - "rankings": { - "title": "Rankings", - "description": "Veja como você se classifica em relação a outros usuários" - }, - "insights": { - "title": "Comparação financeira", - "description": "Descubra padrões e oportunidades" - }, - "spending": { - "title": "Análise de gastos", - "description": "Compare seus hábitos de gastos" - }, - "benchmarks": { - "title": "Benchmarks", - "description": "Comparações por setor e demografia" - } - }, "cards": { "avgBalance": { - "title": "Saldo médio", - "description": "Compare seu saldo atual e crescimento de 12 meses com outros usuários", - "yourBalance": "Seu saldo", - "avgSimilar": "Média de usuários semelhantes", - "avgAll": "Média de todos os usuários", + "title": "Patrimônio", + "description": "Seu patrimônio e seu crescimento nos últimos 12 meses", + "avgSimilar": "Mediana do grupo de comparação", + "avgAll": "Referência geral", "growth12Months": "últimos 12 meses", "noGrowthData": "Dados de crescimento não disponíveis" }, "avgIncome": { "title": "Receita mensal", - "description": "Compare a receita do último mês completo", "yourIncome": "Sua receita", - "avgSimilar": "Média de usuários semelhantes", - "avgAll": "Média de todos os usuários" + "avgSimilar": "Mediana do grupo de comparação", + "avgAll": "Referência geral" }, "avgOutflows": { "title": "Saídas mensais", - "description": "Compare as saídas do último mês completo", "yourOutflows": "Suas saídas", - "avgSimilar": "Média de usuários semelhantes", - "avgAll": "Média de todos os usuários" + "avgSimilar": "Mediana do grupo de comparação", + "avgAll": "Referência geral" }, "savingsRate": { "title": "Taxa de economia", "description": "Sua economia como porcentagem da receita", - "yourRate": "Sua taxa", "avgAll": "Média de todos os usuários", "avgSimilar": "Média de usuários semelhantes", "last12Months": "Últimos 12 meses", "noData": "Dados insuficientes" }, "assetAllocation": { - "title": "Alocação de ativos", + "title": "Alocação de patrimônio", "description": "Como você distribui seu patrimônio", "liquid": "Liquidez", "investments": "Investimentos", "crypto": "Cripto", - "other": "Outros", "avgSimilar": "Média de usuários semelhantes", "avgAll": "Média de todos os usuários", "noAssets": "Nenhum ativo registrado" @@ -1283,17 +1266,10 @@ "spendingCategories": { "title": "Gastos por categoria", "description": "Onde você gasta seu dinheiro (últimos 12 meses)", - "topCategories": "Principais categorias", - "otherCategories": "Outras categorias", "noExpenses": "Nenhuma despesa registrada", "avgSimilar": "Média de usuários semelhantes", "avgAll": "Média de todos os usuários" - }, - "showMore": "Mostrar tudo", - "showLess": "Mostrar menos" - }, - "insights": { - "average": "Você está na média" + } }, "actionableInsights": { "percentileTitle": "Patrimônio: top {rank}% entre perfis semelhantes", @@ -1306,12 +1282,21 @@ "balancedDescription": "Nenhuma diferença relevante se destaca. Continue acompanhando a tendência, mais útil que um único mês." }, "profileBanner": { - "title": "🚀 Desbloqueie comparações personalizadas!", - "description": "Complete seu perfil na página Conta para obter comparações anônimas e automatizadas com usuários semelhantes. Descubra como você se posiciona em relação a outros profissionais!", - "action": "Completar perfil" - }, - "tips": { - "title": "Dicas personalizadas" + "title": "Deixe seu grupo de comparação mais preciso", + "description": "Complete seu perfil para encontrar usuários realmente semelhantes." + }, + "geography": { + "title": "Geografia", + "countryTitle": "Comparar por país", + "countryDescription": "Isole a geografia dos demais fatores do seu perfil para ver como você se compara a pessoas do seu país.", + "countryNeedsConsent": "Ative a comparação acima para usar isso.", + "countryProfileIncomplete": "Adicione seu país ao perfil", + "countryCTA": "Ver minha comparação por país", + "countryUnavailable": "Ainda não há pessoas suficientes do seu país para comparar.", + "regionComingSoon": "Em breve", + "regionTitle": "Região e cidade", + "regionDescription": "Ainda não coletamos região ou cidade, então não podemos comparar nesse nível. Está no roadmap, junto com um mapa clicável e uma visão ajustada ao custo de vida.", + "mapFutureNote": "Um passo futuro: simular como uma mudança de emprego ou local poderia afetar seus números, sempre mostrado como suposição, nunca como conselho." } }, "leaderboard": { @@ -1337,15 +1322,10 @@ "title": "Seus rankings", "monthData": "Dados de", "generalRanking": "Ranking geral", - "generalSubtitle": "Comparação com todos os usuários", "similarRanking": "Usuários semelhantes", - "similarSubtitle": "Comparação com perfis semelhantes ao seu", "balance": "Saldo", "income": "Receita", "outflows": "Saídas", - "topPerformance": "Ótima posição!", - "goodPerformance": "Boa posição", - "canImprove": "Espaço para crescer", "noData": "Dados não disponíveis", "descriptions": { "balance": { diff --git a/src/sections/Comparison.tsx b/src/sections/Comparison.tsx index d96b87c0..50c15446 100644 --- a/src/sections/Comparison.tsx +++ b/src/sections/Comparison.tsx @@ -1,11 +1,8 @@ -import React, { useState, useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { useLocalizedNavigate } from '../hooks/useLocalizedNavigate'; import { Section } from '../styles/MyStyled'; -import { +import { getTotalValue, - getPercentageRankOnBalance, - getPercentageRankOnIncomes, - getPercentageRankOnOutflows, getPercentageRankOnBalanceSimilar, getPercentageRankOnIncomesSimilar, getPercentageRankOnOutflowsSimilar, @@ -16,395 +13,58 @@ import { getTotalOutflowsParentCategoryPerMonth, getAveragesAllSavingsRates, getAveragesSimilarSavingsRates, - getAveragesAllExpensesByCategory, getAveragesSimilarExpensesByCategory } from '../utils/userDataSelectors'; -import { - StyledMonth, - StyledLabel, - StyledRankingsSection, - StandardPageTitleGreen, - StyledRankingPage, - CenteredRankings -} from '../styles/MyStyled'; import InfoIcon from '@mui/icons-material/Info'; import { resolveTagKeyFromLocalized, translateTag } from '../data/tagTranslations'; import TrendingUpIcon from '@mui/icons-material/TrendingUp'; import TrendingDownIcon from '@mui/icons-material/TrendingDown'; import EqualIcon from '@mui/icons-material/DragHandle'; -import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; -import BarChartIcon from '@mui/icons-material/BarChart'; -import PieChartIcon from '@mui/icons-material/PieChart'; import AccountBalanceIcon from '@mui/icons-material/AccountBalance'; import SavingsIcon from '@mui/icons-material/Savings'; import MonetizationOnIcon from '@mui/icons-material/MonetizationOn'; import TipsAndUpdatesIcon from '@mui/icons-material/TipsAndUpdates'; import PersonIcon from '@mui/icons-material/Person'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; -import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; -import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium'; -import GroupIcon from '@mui/icons-material/Group'; -import PublicIcon from '@mui/icons-material/Public'; -import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; -import StarIcon from '@mui/icons-material/Star'; -import LocalFireDepartmentIcon from '@mui/icons-material/LocalFireDepartment'; -import ThumbUpIcon from '@mui/icons-material/ThumbUp'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'; import TuneIcon from '@mui/icons-material/Tune'; import QueryStatsIcon from '@mui/icons-material/QueryStats'; +import PieChartIcon from '@mui/icons-material/PieChart'; +import BarChartIcon from '@mui/icons-material/BarChart'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import PublicIcon from '@mui/icons-material/Public'; +import FlagCircleIcon from '@mui/icons-material/FlagCircle'; +import MapIcon from '@mui/icons-material/Map'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; +import ScheduleIcon from '@mui/icons-material/Schedule'; import Tooltip from '@mui/material/Tooltip'; -import styled from 'styled-components'; +import styled, { keyframes } from 'styled-components'; import { LanguageContext } from '../contexts/LanguageContext'; import { CurrencyContext } from '../contexts/CurrencyContext'; -import { useServices } from '../contexts/ServiceContext'; +import { useDemoServices } from '../hooks/useDemoServices'; import { useDeployment } from '../contexts/DeploymentContext'; import { getCategoryColor } from '../data/categoryColors'; -import Leaderboard from './Leaderboard'; - -const ComparisonContainer = styled.div` - display: flex; - flex-direction: column; - gap: 1.5rem; - padding: 1rem; - max-width: 1400px; - margin: 0 auto; - padding-bottom: 6rem; - - @media (max-width: 768px) { - padding: 0.5rem; - gap: 1.25rem; - padding-bottom: 4rem; - } -`; - -const SectionHeader = styled.div` - text-align: center; - margin-bottom: 1.25rem; - - h1 { - background: ${props => `linear-gradient(135deg, white 0%, white 70%, ${props.theme.secondaryColor} 100%)`}; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - font-size: 1.8rem; - font-weight: 700; - margin-bottom: 0.4rem; - - @media (max-width: 768px) { - font-size: 1.5rem; - } - } - - p { - color: ${props => props.theme.textColor}; - font-size: 1rem; - - @media (max-width: 768px) { - font-size: 0.9rem; - } - } -`; - -export const SectionTabs = styled.div` - display: flex; - justify-content: center; - margin-bottom: 0.5rem; - background: ${props => props.theme.cardBackgroundColor}; - border-radius: 12px; - padding: 0.5rem; - box-shadow: 0 4px 12px rgba(0,0,0,0.1); - width: fit-content; - max-width: 600px; - margin-left: auto; - margin-right: auto; - @media (max-width: 768px) { - flex-wrap: wrap; - gap: 0.5rem; - width: 100%; - max-width: 100%; - } +/** + * Comparison — an anonymous "mirror", not a leaderboard. The page never + * shows another individual user's data (server-side privacy floor is + * MIN_COHORT=20 participants, enforced by server/src/services/similarUsers.ts + * and customBenchmark.ts — see docs referenced in AGENTS.md/todo.md): every + * number here is either the user's own, or an aggregate (percentile/median) + * over an anonymous group. The page leads with a small number of plain- + * language insights about the user's own situation, and keeps every raw + * number one tap away in a collapsed accordion instead of showing a dense + * grid of stats up front. + */ + +const fadeInUp = keyframes` + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } `; -const TabButton = styled.button` - padding: 0.75rem 1.5rem; - border: none; - border-radius: 8px; - background: ${props => props.active ? props.theme.buttonBackgroundColor : 'transparent'}; - color: ${props => props.active ? 'white' : props.theme.textColor}; - font-weight: ${props => props.active ? '600' : '400'}; - cursor: pointer; - transition: all 0.3s ease; - display: flex; - align-items: center; - gap: 0.5rem; - - &:hover { - background: ${props => props.active ? props.theme.buttonBackgroundColor : `${props.theme.buttonBackgroundColor}15`}; - } - - @media (max-width: 768px) { - padding: 0.5rem 1rem; - font-size: 0.9rem; - } -`; - -const GridContainer = styled.div` - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 1.25rem; - - @media (max-width: 768px) { - grid-template-columns: 1fr; - gap: 0.75rem; - } -`; - -const TopGrid = styled.div` - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1.25rem; - - @media (max-width: 1100px) { - grid-template-columns: repeat(2, 1fr); - } - - @media (max-width: 600px) { - grid-template-columns: 1fr; - gap: 0.75rem; - } -`; - -const BottomGrid = styled.div` - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1.25rem; - margin-top: 1.25rem; - - @media (max-width: 768px) { - grid-template-columns: 1fr; - gap: 0.75rem; - margin-top: 0.75rem; - } -`; - -const BenchmarkOverview = styled.section` - background: ${props => props.theme.mode === 'dark' ? 'linear-gradient(145deg, rgba(22,32,43,.96), rgba(17,25,35,.96))' : 'linear-gradient(145deg, #ffffff, #f8fafc)'}; - border: 1px solid ${props => props.theme.borderColor || 'rgba(15, 23, 42, 0.1)'}; - border-radius: 18px; - padding: 1.4rem; - margin-bottom: 1.25rem; - - .overview-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1rem; - } - - h2 { - color: ${props => props.theme.textColor}; - font-size: 1.15rem; - margin: 0 0 0.25rem; - } - - p { - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.65)' : 'rgba(15,23,42,0.65)'}; - font-size: 0.86rem; - line-height: 1.45; - margin: 0; - } - - .profile-action { - align-items: center; - background: transparent; - border: 1px solid ${props => props.theme.borderColor || 'rgba(15, 23, 42, 0.16)'}; - border-radius: 6px; - color: ${props => props.theme.textColor}; - cursor: pointer; - display: flex; - flex: 0 0 auto; - gap: 0.35rem; - padding: 0.5rem 0.7rem; - } - - .overview-actions { display: flex; flex: 0 0 auto; gap: 0.45rem; } - .benchmark-toggle { background: ${props => props.theme.buttonBackgroundColor}; border: 1px solid ${props => props.theme.buttonBackgroundColor}; border-radius: 10px; color: white; cursor: pointer; font-weight: 700; padding: 0.6rem 0.85rem; } - .benchmark-unavailable { background: ${props => props.theme.mode === 'dark' ? 'rgba(251,191,36,.08)' : '#fffbeb'}; border: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(251,191,36,.24)' : '#fde68a'}; border-radius: 12px; color: ${props => props.theme.textColor}; margin-top: 1rem; padding: .9rem 1rem; } - .benchmark-unavailable strong { display: block; font-size: .88rem; margin-bottom: .25rem; } - .benchmark-unavailable p { font-size: .78rem; margin: 0; opacity: .75; } - - @media (max-width: 600px) { - padding: 1rem; - .overview-heading { align-items: stretch; flex-direction: column; } - .overview-actions { align-items: center; display: grid; grid-template-columns: auto 1fr 1fr; } - .profile-action, .benchmark-toggle { justify-content: center; } - } -`; - -const ComparisonGuide = styled.section` - display: grid; - grid-template-columns: minmax(250px, 1.25fr) repeat(3, minmax(150px, 1fr)); - gap: 0.75rem; - padding: 1rem; - border: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(16,185,129,.22)' : 'rgba(5,150,105,.2)'}; - border-radius: 16px; - background: ${props => props.theme.mode === 'dark' ? 'rgba(6,78,59,.12)' : 'rgba(236,253,245,.72)'}; - - .guide-intro, .guide-item { display: flex; gap: .7rem; align-items: flex-start; } - .guide-intro svg { color: ${props => props.theme.buttonBackgroundColor}; margin-top: .1rem; } - .guide-copy { min-width: 0; } - strong { color: ${props => props.theme.textColor}; display: block; font-size: .88rem; margin-bottom: .18rem; } - p { color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,.68)' : '#64748b'}; font-size: .78rem; line-height: 1.42; margin: 0; } - .guide-number { align-items: center; background: ${props => props.theme.buttonBackgroundColor}20; border-radius: 8px; color: ${props => props.theme.buttonBackgroundColor}; display: inline-flex; flex: 0 0 auto; font-size: .76rem; font-weight: 800; height: 25px; justify-content: center; width: 25px; } - - @media (max-width: 900px) { grid-template-columns: 1fr 1fr; } - @media (max-width: 560px) { grid-template-columns: 1fr; padding: .9rem; } -`; - -const ComparisonLayers = styled.div` - display: flex; - flex-wrap: wrap; - align-items: center; - gap: .55rem; - margin: .25rem 0 1rem; - padding: .75rem 1rem; - border-radius: 14px; - background: ${props => props.theme.mode === 'dark' ? 'rgba(15,23,42,.72)' : '#f8fafc'}; - border: 1px solid ${props => props.theme.borderColor || 'rgba(148,163,184,.2)'}; - color: ${props => props.theme.textColor}; - .layer { display: inline-flex; align-items: center; gap: .4rem; border-radius: 999px; padding: .36rem .62rem; font-size: .78rem; font-weight: 700; } - .layer.you { background: ${props => props.theme.buttonBackgroundColor}22; color: ${props => props.theme.buttonBackgroundColor}; } - .layer.group { background: rgba(59,130,246,.14); color: ${props => props.theme.mode === 'dark' ? '#93c5fd' : '#1d4ed8'}; } - .layer.general { background: ${props => props.theme.mode === 'dark' ? 'rgba(148,163,184,.14)' : '#e2e8f0'}; color: ${props => props.theme.mode === 'dark' ? '#cbd5e1' : '#475569'}; } - small { opacity: .7; font-weight: 500; } -`; - -const InfoTrigger = styled.span` - align-items: center; - background: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,.1)' : 'rgba(15,23,42,.07)'}; - border: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,.14)' : 'rgba(15,23,42,.1)'}; - border-radius: 50%; - color: ${props => props.theme.mode === 'dark' ? '#f8fafc' : '#334155'}; - cursor: help; - display: inline-flex; - flex: 0 0 auto; - height: 28px; - justify-content: center; - width: 28px; - svg { color: currentColor; font-size: 17px; } -`; - -const BenchmarkRankGrid = styled.div` - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.65rem; - margin-bottom: 1rem; - - @media (max-width: 600px) { - grid-template-columns: 1fr; - } -`; - -const BenchmarkRank = styled.div` - background: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.035)' : '#f6f8fa'}; - border-radius: 6px; - padding: 0.8rem; - - span { color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.62)' : '#64748b'}; font-size: 0.76rem; } - strong { color: ${props => props.theme.textColor}; display: block; font-size: 1.15rem; margin-top: 0.2rem; } -`; - -const DistributionGrid = styled.div` - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.65rem; - margin-bottom: 1rem; - - .distribution-card { background: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.025)' : '#fbfcfd'}; border: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.07)' : '#e5e7eb'}; border-radius: 6px; padding: 0.75rem; } - span { color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.62)' : '#64748b'}; display: block; font-size: 0.74rem; } - strong { color: ${props => props.theme.textColor}; display: block; font-size: 0.92rem; margin-top: 0.2rem; } - small { color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.52)' : '#64748b'}; display: block; font-size: 0.7rem; margin-top: 0.25rem; } - @media (max-width: 600px) { grid-template-columns: 1fr; } -`; - -const LongitudinalGrid = styled.div` - border-top: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : '#e5e7eb'}; - display: grid; - gap: 0.6rem; - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin: 1rem 0; - padding-top: 1rem; - .trend-card { background: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.025)' : '#fbfcfd'}; border-radius: 6px; padding: 0.75rem; } - span { color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.62)' : '#64748b'}; display: block; font-size: 0.74rem; } - strong { color: ${props => props.theme.textColor}; display: block; font-size: 0.92rem; margin-top: 0.25rem; } - small { color: ${props => props.theme.mode === 'dark' ? '#86efac' : '#15803d'}; display: block; font-size: 0.7rem; margin-top: 0.25rem; } - @media (max-width: 600px) { grid-template-columns: 1fr; } -`; - -const BenchmarkOptInCard = styled.div` - align-items: center; - background: ${props => props.theme.mode === 'dark' ? 'rgba(7,145,100,0.10)' : '#effaf5'}; - border: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(52,211,153,0.28)' : '#b7ead2'}; - border-radius: 8px; - display: flex; - gap: 0.8rem; - justify-content: space-between; - margin-top: 1rem; - padding: 0.85rem; - - strong { color: ${props => props.theme.textColor}; display: block; font-size: 0.86rem; margin-bottom: 0.2rem; } - p { color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.7)' : '#475569'}; font-size: 0.76rem; margin: 0; } - button { background: ${props => props.theme.buttonBackgroundColor}; border: 0; border-radius: 6px; color: white; cursor: pointer; flex: 0 0 auto; font-weight: 700; padding: 0.55rem 0.7rem; } - button:disabled { cursor: wait; opacity: 0.65; } - @media (max-width: 600px) { align-items: stretch; flex-direction: column; button { width: 100%; } } -`; - -const CohortDetails = styled.div` - border-top: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : '#e5e7eb'}; - display: grid; - gap: 0.75rem; - padding-top: 0.9rem; - - .cohort-line { align-items: center; display: flex; flex-wrap: wrap; gap: 0.45rem; } - .cohort-line > svg { color: ${props => props.theme.buttonBackgroundColor}; font-size: 1.05rem; } - .cohort-label { color: ${props => props.theme.textColor}; font-size: 0.8rem; font-weight: 600; margin-right: 0.2rem; } - .factor-chip { - background: ${props => props.theme.mode === 'dark' ? 'rgba(7,145,100,0.15)' : '#e8f7f1'}; - border-radius: 999px; - color: ${props => props.theme.mode === 'dark' ? '#65d6ae' : '#087554'}; - font-size: 0.74rem; - padding: 0.28rem 0.55rem; - } - .privacy-note { align-items: center; display: flex; gap: 0.45rem; } - .privacy-note svg { color: ${props => props.theme.mode === 'dark' ? '#94a3b8' : '#64748b'}; font-size: 1rem; } -`; - -const CohortCustomizer = styled.div` - border-top: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : '#e5e7eb'}; - margin-top: 1rem; - padding-top: 1rem; - - .customizer-header { align-items: flex-start; display: flex; gap: 0.65rem; justify-content: space-between; } - h3 { color: ${props => props.theme.textColor}; font-size: 0.9rem; margin: 0 0 0.15rem; } - p { color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.62)' : '#64748b'}; font-size: 0.78rem; margin: 0; } - .factor-options { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.8rem; } - .factor-option { - align-items: center; background: transparent; border: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.16)' : '#dbe2ea'}; - border-radius: 999px; color: ${props => props.theme.textColor}; cursor: pointer; display: inline-flex; font-size: 0.78rem; gap: 0.35rem; padding: 0.42rem 0.62rem; - } - .factor-option.selected { background: ${props => props.theme.buttonBackgroundColor}; border-color: ${props => props.theme.buttonBackgroundColor}; color: white; } - .factor-option:disabled { cursor: not-allowed; opacity: 0.42; } - .customizer-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 0.65rem; margin-top: 0.85rem; } - .apply-factors { background: ${props => props.theme.buttonBackgroundColor}; border: 0; border-radius: 6px; color: white; cursor: pointer; font-weight: 700; padding: 0.52rem 0.8rem; } - .apply-factors:disabled { cursor: wait; opacity: 0.65; } - .reset-factors { background: transparent; border: 0; color: ${props => props.theme.buttonBackgroundColor}; cursor: pointer; font-size: 0.78rem; font-weight: 600; padding: 0.35rem; } - .customizer-error { color: #dc2626; font-size: 0.78rem; } - .cohort-preview { color: ${props => props.theme.mode === 'dark' ? '#fbbf24' : '#a16207'}; font-size: 0.78rem; } - .cohort-preview.ready { color: ${props => props.theme.mode === 'dark' ? '#86efac' : '#15803d'}; } +const drawArc = keyframes` + from { stroke-dashoffset: var(--arc-full); } + to { stroke-dashoffset: var(--arc-offset); } `; const DEFAULT_FACTOR_GROUPS = ['career', 'location', 'lifeStage', 'household']; @@ -426,704 +86,550 @@ export const getBenchmarkOptInCopy = (selfHosted, benchmarkOverview) => ( } ); -const ExpandableCardContent = styled.div` - max-height: ${props => props.expanded ? 'none' : '280px'}; - overflow: hidden; - position: relative; - transition: max-height 0.35s ease; - - ${props => !props.expanded && ` - &::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 48px; - background: linear-gradient(transparent, ${props.theme.mode === 'dark' ? props.theme.primaryColor : 'white'}); - pointer-events: none; - } - `} -`; +/* ─── Layout ─── */ -const ExpandToggle = styled.button` +const PageContainer = styled.div` display: flex; - align-items: center; - justify-content: center; - gap: 0.3rem; + flex-direction: column; + gap: 1.75rem; width: 100%; - padding: 0.4rem 0; - margin-top: 0.25rem; - background: none; - border: none; - border-top: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}; - color: ${props => props.theme.buttonBackgroundColor}; - font-size: 0.78rem; - font-weight: 600; - cursor: pointer; - transition: opacity 0.2s; - - &:hover { - opacity: 0.8; + max-width: 980px; + margin: 0 auto; + padding: 1.25rem 1rem 6rem; + + @media (max-width: 768px) { + gap: 1.25rem; + padding: 0.75rem 0.75rem 4rem; } `; -const ComparisonCard = styled.div` - background: ${props => props.theme.mode === 'dark' ? props.theme.primaryColor : 'white'}; - border-radius: 16px; - padding: 1.25rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.08); - border: 1px solid ${props => props.theme.borderColor || 'transparent'}; - transition: all 0.25s ease; +/* ─── Hero ─── */ + +const HeroCard = styled.section` position: relative; overflow: hidden; - font-family: 'Inter', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif; - font-weight: 500; - - &:hover { - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(0,0,0,0.1); - } - + text-align: center; + padding: 2.25rem 1.5rem 1.75rem; + border-radius: 24px; + background: ${p => p.theme.mode === 'dark' + ? 'linear-gradient(160deg, rgba(7,145,100,0.14) 0%, rgba(15,23,42,0.55) 55%)' + : 'linear-gradient(160deg, rgba(7,145,100,0.09) 0%, rgba(255,255,255,0.92) 55%)'}; + border: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.06)'}; + box-shadow: ${p => p.theme.mode === 'dark' ? '0 20px 60px rgba(0,0,0,0.35)' : '0 14px 40px rgba(15,23,42,0.08)'}; + &::before { content: ''; position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: ${props => props.accent || props.theme.buttonBackgroundColor}; - opacity: 0.7; - border-radius: 0 0 2px 2px; + top: -60%; + right: -20%; + width: 60%; + height: 220%; + background: radial-gradient(circle, ${p => p.theme.buttonBackgroundColor}22 0%, transparent 70%); + pointer-events: none; } - - @media (max-width: 768px) { - padding: 1rem; + + @media (max-width: 600px) { + padding: 1.75rem 1rem 1.25rem; + border-radius: 18px; } `; -const CardHeader = styled.div` - display: flex; +const HeroEyebrow = styled.div` + display: inline-flex; align-items: center; - justify-content: space-between; - margin-bottom: 0.75rem; - - h3 { - color: ${props => props.theme.textColor}; - font-size: 1.1rem; - font-weight: 600; - margin: 0; - display: flex; - align-items: center; - gap: 0.5rem; - } + gap: 0.4rem; + padding: 0.32rem 0.75rem; + border-radius: 999px; + background: ${p => p.theme.buttonBackgroundColor}18; + color: ${p => p.theme.buttonBackgroundColor}; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + margin-bottom: 1rem; `; -const MetricRow = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.75rem 0; - border-bottom: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.1)' : '#eee'}; - - &:last-child { - border-bottom: none; - } - - .label { - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}; - font-size: 0.9rem; - font-weight: 500; - } - - .value { - color: ${props => props.theme.textColor}; - font-weight: 600; - font-size: 1.1rem; - display: flex; - align-items: center; - gap: 0.5rem; - } +const HeroTitle = styled.h1` + margin: 0 0 0.5rem; + font-size: clamp(1.6rem, 3.2vw, 2.25rem); + font-weight: 800; + letter-spacing: -0.01em; + background: ${p => p.theme.mode === 'dark' + ? `linear-gradient(135deg, #ffffff 0%, #ffffff 55%, ${p.theme.buttonBackgroundColor} 100%)` + : `linear-gradient(135deg, #0f172a 0%, #0f172a 55%, ${p.theme.buttonBackgroundColor} 100%)`}; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; `; -const BalanceValueContainer = styled.div` - display: flex; +const HeroSubtitle = styled.p` + margin: 0 auto 1.5rem; + max-width: 480px; + color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.72)' : 'rgba(15,23,42,0.68)'}; + font-size: 0.98rem; + line-height: 1.5; +`; + +const GaugeFigure = styled.div` + position: relative; + display: inline-flex; flex-direction: column; - align-items: flex-end; - gap: 0.25rem; - - .main-value { - color: ${props => props.theme.textColor}; - font-weight: 600; - font-size: 1.1rem; - display: flex; - align-items: center; - gap: 0.5rem; - } - - .growth-value { - color: ${props => props.growth > 0 ? '#27ae60' : props.growth < 0 ? '#e74c3c' : props.theme.mode === 'dark' ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'}; - font-size: 0.8rem; - font-weight: 500; - text-align: right; - } + align-items: center; + gap: 0.6rem; + animation: ${fadeInUp} 0.5s ease-out both; `; -const ComingSoonCard = styled(ComparisonCard)` +const GaugeValue = styled.div` + position: absolute; + top: 54%; + left: 50%; + transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; - justify-content: center; - text-align: center; - min-height: 200px; - background: linear-gradient(135deg, ${props => props.theme.mode === 'dark' ? props.theme.primaryColor : 'white'} 0%, ${props => props.theme.buttonBackgroundColor}15 100%); - - h3 { - color: ${props => props.theme.textColor}; - font-weight: 600; - margin: 0.5rem 0; - } - - p { - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}; - font-weight: 500; + + strong { + font-size: 2.1rem; + font-weight: 800; + color: ${p => p.theme.textColor}; + line-height: 1; } - - .coming-soon-text { - color: ${props => props.theme.buttonBackgroundColor}; - font-size: 1.2rem; + span { + margin-top: 0.2rem; + font-size: 0.72rem; font-weight: 600; - margin-top: 1rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.55)' : 'rgba(15,23,42,0.5)'}; } `; -const InsightCard = styled.div` - background: linear-gradient(135deg, ${props => props.theme.buttonBackgroundColor}10 0%, ${props => props.theme.buttonBackgroundColor}05 100%); - border-radius: 12px; - padding: 1rem 1.25rem; - margin: 0.75rem 0; - border-left: 3px solid ${props => props.theme.buttonBackgroundColor}aa; - font-family: 'Inter', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif; - - h4 { - color: ${props => props.theme.textColor}; - margin: 0 0 0.5rem 0; - display: flex; - align-items: center; - gap: 0.5rem; - font-weight: 600; - } - - p { - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.8)'}; - margin: 0; - line-height: 1.5; - font-weight: 500; - } +const GaugeCaption = styled.p` + margin: 0; + 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; + line-height: 1.45; `; -// Progress bar for percentages -const ProgressBarContainer = styled.div` - width: 100%; - margin: 0.5rem 0; +const HeadlineChips = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.6rem; + margin-top: 1.5rem; `; -const ProgressBarRow = styled.div` +const HeadlineChip = styled.div` display: flex; align-items: center; - gap: 0.75rem; - margin-bottom: 0.5rem; - - .label { - min-width: 80px; - font-size: 0.85rem; - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.7)'}; - font-weight: 500; - } - - .bar-wrapper { - flex: 1; - height: 10px; - background: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.1)' : '#e5e7eb'}; - border-radius: 5px; - overflow: hidden; - } - - .bar-fill { - height: 100%; - border-radius: 5px; - transition: width 0.5s ease; - } - - .percentage { - min-width: 45px; - text-align: right; - font-size: 0.85rem; - font-weight: 600; - color: ${props => props.theme.textColor}; - } -`; + gap: 0.5rem; + padding: 0.55rem 0.9rem; + border-radius: 14px; + background: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.85)'}; + border: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.06)'}; + animation: ${fadeInUp} 0.45s ease-out both; + animation-delay: ${p => p.$delay || '0s'}; -const AllocationBenchmarks = styled.div` - margin: -0.15rem 0 0.85rem 80px; - padding-left: 0.75rem; - border-left: 2px solid ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}; + svg { font-size: 1.15rem; color: ${p => p.theme.buttonBackgroundColor}; } - .benchmark-row { - display: flex; - justify-content: space-between; - gap: 0.75rem; - padding: 0.15rem 0; - font-size: 0.75rem; - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.52)'}; - } + .chip-value { font-weight: 800; font-size: 0.98rem; color: ${p => p.theme.textColor}; } + .chip-label { font-size: 0.74rem; color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.55)' : 'rgba(15,23,42,0.55)'}; } +`; - .benchmark-value { font-weight: 600; } +const HeroCTA = styled.button` + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-top: 1.4rem; + padding: 0.75rem 1.4rem; + border: 0; + border-radius: 12px; + background: ${p => p.theme.buttonBackgroundColor}; + color: white; + font-weight: 700; + font-size: 0.92rem; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + box-shadow: 0 10px 24px ${p => p.theme.buttonBackgroundColor}35; - @media (max-width: 520px) { - margin-left: 0; - } + &:hover:not(:disabled) { transform: translateY(-1px); } + &:disabled { opacity: 0.65; cursor: wait; } `; -const SavingsRateDisplay = styled.div` +/* ─── Insight cards ─── */ + +const InsightList = styled.div` display: flex; flex-direction: column; - align-items: center; - padding: 0.75rem 0; - - .rate-value { - font-size: 2rem; - font-weight: 700; - color: ${props => props.positive ? '#27ae60' : props.negative ? '#e74c3c' : props.theme.textColor}; + gap: 0.65rem; +`; + +const InsightCard = styled.div` + display: flex; + gap: 0.8rem; + padding: 1rem 1.1rem; + border-radius: 14px; + background: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.035)' : '#ffffff'}; + border: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.07)' : 'rgba(15,23,42,0.06)'}; + border-left: 3px solid ${p => p.$tone === 'warning' ? '#f59e0b' : p.theme.buttonBackgroundColor}; + animation: ${fadeInUp} 0.4s ease-out both; + + .insight-icon { + flex: 0 0 auto; + width: 34px; + height: 34px; + border-radius: 10px; display: flex; - align-items: baseline; - gap: 0.25rem; - - span { - font-size: 1.2rem; - } - } - - .rate-label { - font-size: 0.8rem; - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'}; - margin-top: 0.25rem; + align-items: center; + justify-content: center; + background: ${p => (p.$tone === 'warning' ? '#f59e0b' : p.theme.buttonBackgroundColor)}18; + color: ${p => p.$tone === 'warning' ? '#f59e0b' : p.theme.buttonBackgroundColor}; + svg { font-size: 1.1rem; } } + h4 { margin: 0 0 0.2rem; font-size: 0.92rem; font-weight: 700; color: ${p => p.theme.textColor}; } + p { margin: 0; font-size: 0.84rem; line-height: 1.5; color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.72)' : 'rgba(15,23,42,0.68)'}; } `; -const CategoryBar = styled.div` +/* ─── Profile nudge ─── */ + +const ProfileNudge = styled.button` display: flex; align-items: center; - gap: 0.5rem; - padding: 0.4rem 0; - - .color-dot { - width: 12px; - height: 12px; + gap: 0.75rem; + width: 100%; + padding: 0.9rem 1.1rem; + border-radius: 14px; + border: 1px dashed ${p => p.theme.buttonBackgroundColor}55; + background: ${p => p.theme.buttonBackgroundColor}0d; + color: ${p => p.theme.textColor}; + cursor: pointer; + text-align: left; + transition: background 0.2s ease; + + &:hover { background: ${p => p.theme.buttonBackgroundColor}18; } + + .nudge-icon { + flex: 0 0 auto; + width: 36px; + height: 36px; border-radius: 50%; - flex-shrink: 0; - } - - .category-name { - flex: 1; - font-size: 0.85rem; - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.7)'}; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .category-value { - font-size: 0.85rem; - font-weight: 600; - color: ${props => props.theme.textColor}; - min-width: 60px; - text-align: right; - } - - .category-percent { - font-size: 0.8rem; - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'}; - min-width: 40px; - text-align: right; + display: flex; + align-items: center; + justify-content: center; + background: ${p => p.theme.buttonBackgroundColor}; + color: white; + svg { font-size: 1.1rem; } } + strong { display: block; font-size: 0.86rem; } + span { display: block; font-size: 0.78rem; opacity: 0.72; margin-top: 0.1rem; } + .nudge-arrow { margin-left: auto; flex: 0 0 auto; opacity: 0.6; } +`; + +/* ─── Accordion ─── */ + +const SectionLabel = styled.h2` + margin: 0.25rem 0 0; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.45)' : 'rgba(15,23,42,0.42)'}; `; -const ProfileBanner = styled.div` - background: linear-gradient(135deg, ${props => props.theme.buttonBackgroundColor}15 0%, ${props => props.theme.buttonBackgroundColor}08 100%); - border: 1px solid ${props => props.theme.buttonBackgroundColor}30; +const AccordionList = styled.div` + display: flex; + flex-direction: column; + gap: 0.6rem; +`; + +const AccordionItem = styled.div` border-radius: 16px; - padding: 1.25rem; - margin-bottom: 1.25rem; + background: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.035)' : '#ffffff'}; + border: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.07)' : 'rgba(15,23,42,0.06)'}; + overflow: hidden; + transition: border-color 0.2s ease; +`; + +const AccordionHeader = styled.button` display: flex; align-items: center; - gap: 1rem; + gap: 0.85rem; + width: 100%; + padding: 1rem 1.1rem; + background: none; + border: 0; cursor: pointer; - transition: all 0.25s ease; - font-family: 'Inter', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif; - position: relative; - overflow: hidden; - - &:hover { - transform: translateY(-1px); - box-shadow: 0 6px 16px ${props => props.theme.buttonBackgroundColor}20; - border-color: ${props => props.theme.buttonBackgroundColor}50; + text-align: left; + + .accordion-icon { + flex: 0 0 auto; + width: 38px; + height: 38px; + border-radius: 11px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, ${p => p.theme.buttonBackgroundColor}, ${p => p.theme.buttonBackgroundColor}bb); + color: white; + svg { font-size: 1.15rem; } } - - &::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(90deg, ${props => props.theme.buttonBackgroundColor}, ${props => props.theme.buttonBackgroundColor}88); - opacity: 0.6; + .accordion-copy { flex: 1; min-width: 0; } + .accordion-copy strong { display: block; font-size: 0.94rem; color: ${p => p.theme.textColor}; } + .accordion-copy span { display: block; font-size: 0.78rem; margin-top: 0.15rem; color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.55)' : 'rgba(15,23,42,0.55)'}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + + .accordion-chevron { + flex: 0 0 auto; + display: flex; + color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.5)' : 'rgba(15,23,42,0.45)'}; + transition: transform 0.25s ease; + transform: rotate(${p => p.$open ? '180deg' : '0deg'}); } - - @media (max-width: 768px) { - flex-direction: column; - text-align: center; - padding: 1rem; - gap: 0.75rem; +`; + +const AccordionBody = styled.div` + display: grid; + grid-template-rows: ${p => p.$open ? '1fr' : '0fr'}; + transition: grid-template-rows 0.3s ease; + + > div { overflow: hidden; } + + .accordion-body-inner { + padding: 0 1.1rem 1.1rem; + border-top: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)'}; + padding-top: 0.9rem; } `; -const BannerIcon = styled.div` - background: linear-gradient(135deg, ${props => props.theme.buttonBackgroundColor}, ${props => props.theme.buttonBackgroundColor}dd); - border-radius: 50%; - padding: 1rem; +/* ─── Comparison rows (inside accordion bodies) ─── */ + +const CompareRow = styled.div` display: flex; align-items: center; - justify-content: center; - box-shadow: 0 4px 12px ${props => props.theme.buttonBackgroundColor}30; - min-width: 56px; - height: 56px; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(15,23,42,0.045)'}; + font-size: 0.86rem; + + &:last-child { border-bottom: none; } + + .compare-label { color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.62)' : 'rgba(15,23,42,0.6)'}; } + .compare-value { display: flex; align-items: center; gap: 0.35rem; font-weight: 700; color: ${p => p.theme.textColor}; } + .compare-value svg { font-size: 1rem; } `; -const BannerContent = styled.div` - flex: 1; - - h3 { - color: ${props => props.theme.textColor}; - font-size: 1.15rem; - font-weight: 700; - margin: 0 0 0.35rem 0; - - @media (max-width: 768px) { - font-size: 1.05rem; - } - } - - p { - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.7)'}; - font-size: 0.9rem; - line-height: 1.5; - margin: 0; - - @media (max-width: 768px) { - font-size: 0.85rem; - } - } +const BigStat = styled.div` + text-align: center; + padding: 0.4rem 0 1rem; + + strong { display: block; font-size: 1.7rem; font-weight: 800; color: ${p => p.theme.textColor}; } + small { display: block; margin-top: 0.2rem; font-size: 0.78rem; color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.55)' : 'rgba(15,23,42,0.55)'}; } `; -const BannerAction = styled.div` +const BarRow = styled.div` display: flex; align-items: center; - gap: 0.5rem; - color: ${props => props.theme.buttonBackgroundColor}; - font-weight: 600; - font-size: 1rem; - - @media (max-width: 768px) { - justify-content: center; - margin-top: 0.5rem; - } + gap: 0.65rem; + padding: 0.3rem 0; + + .bar-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; } + .bar-name { flex: 1; min-width: 0; font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.78)' : 'rgba(15,23,42,0.72)'}; } + .bar-track { flex: 1; height: 7px; border-radius: 4px; background: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.06)'}; overflow: hidden; } + .bar-fill { height: 100%; border-radius: 4px; transition: width 0.6s ease; } + .bar-value { flex: 0 0 auto; min-width: 46px; text-align: right; font-size: 0.8rem; font-weight: 700; color: ${p => p.theme.textColor}; } `; -// Modern Rankings Components -const RankingsContainer = styled.div` - display: flex; - flex-direction: column; - gap: 1.5rem; - width: 100%; - max-width: none; - .benchmark-unavailable { background: ${props => props.theme.mode === 'dark' ? 'rgba(148,163,184,.08)' : 'rgba(226,232,240,.55)'}; border: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(148,163,184,.2)' : 'rgba(148,163,184,.35)'}; border-radius: 12px; padding: 1rem; } - .benchmark-unavailable strong { color: ${props => props.theme.textColor}; display: block; font-size: .9rem; margin-bottom: .25rem; } - .benchmark-unavailable p { color: ${props => props.theme.textColor}; font-size: .8rem; margin: 0; opacity: .75; } - - @media (max-width: 768px) { - gap: 1rem; - } +const EmptyState = styled.div` + padding: 0.9rem 1rem; + border-radius: 12px; + background: ${p => p.theme.mode === 'dark' ? 'rgba(148,163,184,0.08)' : 'rgba(226,232,240,0.55)'}; + border: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(148,163,184,0.2)' : 'rgba(148,163,184,0.35)'}; + + strong { display: block; font-size: 0.85rem; color: ${p => p.theme.textColor}; margin-bottom: 0.2rem; } + p { margin: 0; font-size: 0.78rem; color: ${p => p.theme.textColor}; opacity: 0.75; line-height: 1.45; } `; -const RankingsHeader = styled.div` - text-align: center; - background: linear-gradient(135deg, ${props => props.theme.mode === 'dark' ? 'rgba(31, 41, 55, 0.6)' : 'rgba(255, 255, 255, 0.85)'} 0%, ${props => props.theme.mode === 'dark' ? 'rgba(17, 24, 39, 0.7)' : '#f8fafc'} 100%); +/* ─── Cohort card ─── */ + +const CohortCard = styled.section` border-radius: 18px; - padding: 1.5rem; - border: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(75, 85, 99, 0.25)' : 'rgba(156, 163, 175, 0.15)'}; - box-shadow: ${props => props.theme.mode === 'dark' ? '0 4px 10px -2px rgba(0, 0, 0, 0.3)' : '0 2px 8px -2px rgba(0, 0, 0, 0.08)'}; - - h2 { - color: ${props => props.theme.textColor}; - font-size: 1.5rem; - font-weight: 700; - margin: 0 0 0.4rem 0; - display: flex; - align-items: center; - justify-content: center; - gap: 0.6rem; - - @media (max-width: 768px) { - font-size: 1.25rem; - flex-direction: column; - gap: 0.4rem; - } - } - - .month-indicator { - background: linear-gradient(135deg, ${props => props.theme.buttonBackgroundColor}, ${props => props.theme.buttonBackgroundColor}cc); - color: white; - padding: 0.5rem 1rem; - border-radius: 20px; - font-size: 0.9rem; - font-weight: 600; - display: flex; - align-items: center; - gap: 0.5rem; - margin: 1rem auto 0; - width: fit-content; - box-shadow: 0 4px 12px ${props => props.theme.buttonBackgroundColor}40; - } + padding: 1.25rem; + background: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.035)' : '#ffffff'}; + border: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.07)' : 'rgba(15,23,42,0.06)'}; + + .cohort-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.9rem; } + h3 { margin: 0 0 0.25rem; font-size: 1rem; font-weight: 700; color: ${p => p.theme.textColor}; } + .cohort-head p { margin: 0; font-size: 0.82rem; line-height: 1.5; color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.65)' : 'rgba(15,23,42,0.6)'}; } + + .factor-options { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.9rem 0; } + .customizer-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.65rem; margin-top: 0.6rem; } + .apply-factors { background: ${p => p.theme.buttonBackgroundColor}; border: 0; border-radius: 10px; color: white; cursor: pointer; font-weight: 700; font-size: 0.82rem; padding: 0.55rem 0.9rem; } + .apply-factors:disabled { cursor: wait; opacity: 0.6; } + .reset-factors { background: none; border: 0; color: ${p => p.theme.buttonBackgroundColor}; cursor: pointer; font-size: 0.78rem; font-weight: 600; } + .customizer-error { color: #ef4444; font-size: 0.78rem; } + .cohort-preview { font-size: 0.78rem; color: ${p => p.theme.mode === 'dark' ? '#fbbf24' : '#a16207'}; } + .cohort-preview.ready { color: ${p => p.theme.mode === 'dark' ? '#86efac' : '#15803d'}; } + .cohort-relaxed-note { display: block; margin-top: 0.3rem; font-size: 0.76rem; line-height: 1.4; color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.55)' : 'rgba(15,23,42,0.55)'}; } + + .privacy-line { display: flex; align-items: center; gap: 0.5rem; margin-top: 1rem; padding-top: 0.9rem; border-top: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)'}; } + .privacy-line svg { flex: 0 0 auto; font-size: 1rem; color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.5)' : 'rgba(15,23,42,0.45)'}; } + .privacy-line p { margin: 0; font-size: 0.76rem; line-height: 1.45; color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.55)' : 'rgba(15,23,42,0.55)'}; } +`; + +const FactorChip = styled.button` + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.42rem 0.75rem; + border-radius: 999px; + border: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.14)' : '#dbe2ea'}; + background: ${p => p.$selected ? p.theme.buttonBackgroundColor : 'transparent'}; + color: ${p => p.$selected ? 'white' : p.theme.textColor}; + border-color: ${p => p.$selected ? p.theme.buttonBackgroundColor : undefined}; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + + &:disabled { cursor: not-allowed; opacity: 0.4; } `; -const RankingsGrid = styled.div` +/* ─── Geography ─── */ + +const GeographyGrid = styled.div` display: grid; grid-template-columns: 1fr 1fr; - gap: 1.25rem; - width: 100%; - - @media (max-width: 1024px) { + gap: 0.9rem; + + @media (max-width: 700px) { grid-template-columns: 1fr; - gap: 1rem; } `; -const RankingGroup = styled.div` - background: ${props => props.theme.mode === 'dark' ? 'rgba(31, 41, 55, 0.5)' : 'rgba(255, 255, 255, 0.8)'}; - border-radius: 16px; +const GeographyCard = styled.section` + position: relative; + border-radius: 18px; padding: 1.25rem; - border: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(75, 85, 99, 0.25)' : 'rgba(156, 163, 175, 0.15)'}; - backdrop-filter: blur(10px); - transition: all 0.25s ease; - - &:hover { - transform: translateY(-2px); - box-shadow: ${props => props.theme.mode === 'dark' ? '0 10px 20px -4px rgba(0, 0, 0, 0.4)' : '0 6px 16px -3px rgba(0, 0, 0, 0.08)'}; - } - - .group-header { + background: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.035)' : '#ffffff'}; + border: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.07)' : 'rgba(15,23,42,0.06)'}; + display: flex; + flex-direction: column; + gap: 0.7rem; + + .geo-icon { + width: 40px; + height: 40px; + border-radius: 12px; display: flex; align-items: center; - gap: 0.75rem; - margin-bottom: 1rem; - padding-bottom: 0.75rem; - border-bottom: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(75, 85, 99, 0.25)' : 'rgba(156, 163, 175, 0.15)'}; - - .icon-container { - background: linear-gradient(135deg, ${props => props.theme.buttonBackgroundColor}, ${props => props.theme.buttonBackgroundColor}dd); - border-radius: 50%; - padding: 0.75rem; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 4px 10px ${props => props.theme.buttonBackgroundColor}25; - } - - h3 { - color: ${props => props.theme.textColor}; - font-size: 1.15rem; - font-weight: 700; - margin: 0; - } + justify-content: center; + background: linear-gradient(135deg, ${p => p.theme.buttonBackgroundColor}, ${p => p.theme.buttonBackgroundColor}bb); + color: white; + svg { font-size: 1.2rem; } } + h3 { margin: 0; font-size: 0.96rem; font-weight: 700; color: ${p => p.theme.textColor}; } + p { margin: 0; font-size: 0.82rem; line-height: 1.5; color: ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.65)' : 'rgba(15,23,42,0.6)'}; } `; -const RankingCard = styled.div` - background: ${props => { - if (props.isTop) return `linear-gradient(135deg, ${props.theme.buttonBackgroundColor}15, ${props.theme.buttonBackgroundColor}08)`; - if (props.isLow) return props.theme.mode === 'dark' ? 'rgba(99, 102, 241, 0.06)' : 'rgba(99, 102, 241, 0.04)'; - return props.theme.mode === 'dark' ? 'rgba(75, 85, 99, 0.15)' : 'rgba(243, 244, 246, 0.7)'; - }}; - border: 1px solid ${props => { - if (props.isTop) return props.theme.buttonBackgroundColor + '30'; - if (props.isLow) return props.theme.mode === 'dark' ? 'rgba(99, 102, 241, 0.2)' : 'rgba(99, 102, 241, 0.15)'; - return 'transparent'; - }}; - border-radius: 12px; - padding: 1rem; - margin-bottom: 0.65rem; - transition: all 0.25s ease; - cursor: pointer; - position: relative; - overflow: hidden; - - &:hover { - transform: translateX(3px); - box-shadow: 0 4px 14px rgba(0,0,0,0.08); - } - - &::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 3px; - opacity: 0.7; - background: ${props => { - if (props.isTop) return `linear-gradient(180deg, ${props.theme.buttonBackgroundColor}, ${props.theme.buttonBackgroundColor}aa)`; - if (props.isLow) return 'linear-gradient(180deg, #6366f1, #4f46e5)'; - return 'linear-gradient(180deg, #9ca3af, #6b7280)'; - }}; - } - - .rank-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0.5rem; - - h4 { - color: ${props => props.theme.textColor}; - font-size: 1rem; - font-weight: 600; - margin: 0; - display: flex; - align-items: center; - gap: 0.4rem; - } - - .rank-badge { - background: ${props => { - if (props.isTop) return `linear-gradient(135deg, ${props.theme.buttonBackgroundColor}, ${props.theme.buttonBackgroundColor}dd)`; - if (props.isLow) return 'linear-gradient(135deg, #6366f1, #4f46e5)'; - return 'linear-gradient(135deg, #6b7280, #4b5563)'; - }}; - color: white; - padding: 0.25rem 0.75rem; - border-radius: 20px; - font-size: 0.8rem; - font-weight: 700; - display: flex; - align-items: center; - gap: 0.25rem; - } - } - - .rank-description { - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.6)'}; - font-size: 0.85rem; - line-height: 1.4; - margin: 0; - } +const ComingSoonBadge = styled.span` + position: absolute; + top: 1.1rem; + right: 1.1rem; + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.28rem 0.6rem; + border-radius: 999px; + background: ${p => p.theme.mode === 'dark' ? 'rgba(251,191,36,0.14)' : '#fffbeb'}; + color: ${p => p.theme.mode === 'dark' ? '#fbbf24' : '#a16207'}; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + svg { font-size: 0.85rem; } `; -const MotivationalPopup = styled.div` - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: ${props => props.theme.mode === 'dark' ? 'rgba(17, 24, 39, 0.95)' : 'rgba(255, 255, 255, 0.95)'}; - backdrop-filter: blur(20px); - border-radius: 20px; - padding: 2rem; - max-width: 440px; - width: 90%; - border: 1px solid ${props => props.theme.mode === 'dark' ? 'rgba(75, 85, 99, 0.3)' : 'rgba(156, 163, 175, 0.2)'}; - box-shadow: ${props => props.theme.mode === 'dark' ? '0 25px 50px -12px rgba(0, 0, 0, 0.8)' : '0 25px 50px -12px rgba(0, 0, 0, 0.25)'}; - z-index: 1000; - text-align: center; - animation: popupSlideIn 0.3s ease-out; - - @keyframes popupSlideIn { - from { - opacity: 0; - transform: translate(-50%, -60%); - } - to { - opacity: 1; - transform: translate(-50%, -50%); - } - } - - .popup-icon { - font-size: 3rem; - margin-bottom: 0.75rem; - } - - h3 { - color: ${props => props.theme.textColor}; - font-size: 1.3rem; - font-weight: 700; - margin: 0 0 0.75rem 0; - } - - p { - color: ${props => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.7)'}; - font-size: 0.95rem; - line-height: 1.5; - margin: 0 0 1.5rem 0; - } - - button { - background: linear-gradient(135deg, ${props => props.theme.buttonBackgroundColor}, ${props => props.theme.buttonBackgroundColor}dd); - color: white; - border: none; - padding: 0.75rem 2rem; - border-radius: 20px; - font-weight: 600; - cursor: pointer; - transition: all 0.3s ease; - - &:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px ${props => props.theme.buttonBackgroundColor}40; - } - } +const GeoCountryAction = styled.button` + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.2rem; + padding: 0.55rem 0.85rem; + border-radius: 10px; + border: 0; + background: ${p => p.theme.buttonBackgroundColor}; + color: white; + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + + &:disabled { opacity: 0.6; cursor: wait; } `; -const PopupOverlay = styled.div` - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 999; +const CountryResult = styled.div` + margin-top: 0.4rem; + padding-top: 0.7rem; + border-top: 1px solid ${p => p.theme.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)'}; `; -function Comparison({ theme, userData, isHidden}) { +/* ─── Small building blocks ─── */ + +const GaugeArc = ({ value, theme, size = 176 }) => { + const stroke = 14; + const radius = (size - stroke) / 2; + const half = 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 trackColor = theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.07)'; + return ( + + ); +}; + +function Comparison({ theme, userData, isHidden }) { const { language, translations } = useContext(LanguageContext); const { formatAmount } = useContext(CurrencyContext); - const { rankingService, userService, statsService } = useServices(); + const { rankingService, userService, statsService } = useDemoServices(); const { selfHosted } = useDeployment(); - const optInCopy = getBenchmarkOptInCopy(selfHosted, translations.comparison.benchmarkOverview); - const [activeTab, setActiveTab] = useState('insights'); - const [expandedCards, setExpandedCards] = useState({}); - const [showMotivationalPopup, setShowMotivationalPopup] = useState(false); - const [popupContent, setPopupContent] = useState({ type: '', title: '', message: '', icon: '' }); + const navigate = useLocalizedNavigate(); + const t = translations.comparison; + const optInCopy = getBenchmarkOptInCopy(selfHosted, t.benchmarkOverview); + + const [expandedSections, setExpandedSections] = useState({}); const [selectedFactorGroups, setSelectedFactorGroups] = useState(DEFAULT_FACTOR_GROUPS); const [customBenchmark, setCustomBenchmark] = useState(null); const [isCustomBenchmarkLoading, setIsCustomBenchmarkLoading] = useState(false); const [customBenchmarkError, setCustomBenchmarkError] = useState(''); const [cohortPreview, setCohortPreview] = useState(null); - const [isBenchmarkExpanded, setIsBenchmarkExpanded] = useState(false); const [hasBenchmarkConsent, setHasBenchmarkConsent] = useState(userData?.benchmarkConsent === true); const [isSavingBenchmarkConsent, setIsSavingBenchmarkConsent] = useState(false); const [behaviourBenchmark, setBehaviourBenchmark] = useState(null); - const navigate = useLocalizedNavigate(); + const [countryBenchmark, setCountryBenchmark] = useState(null); + const [isCountryBenchmarkLoading, setIsCountryBenchmarkLoading] = useState(false); + const [countryBenchmarkError, setCountryBenchmarkError] = useState(''); useEffect(() => { let cancelled = false; @@ -1163,116 +669,62 @@ function Comparison({ theme, userData, isHidden}) { const result = await rankingService.getCustomBenchmark(selectedFactorGroups); setCustomBenchmark(result); if (!result?.available) { - setCustomBenchmarkError(translations.comparison.benchmarkOverview?.noCohort || 'Not enough comparable profiles for this selection yet.'); + setCustomBenchmarkError(t.benchmarkOverview?.noCohort || 'Not enough comparable profiles for this selection yet.'); } } catch { - setCustomBenchmarkError(translations.comparison.benchmarkOverview?.customError || 'Unable to refresh the comparison. Try again.'); + setCustomBenchmarkError(t.benchmarkOverview?.customError || 'Unable to refresh the comparison. Try again.'); } finally { setIsCustomBenchmarkLoading(false); } }; + const resetCustomBenchmark = () => { + setSelectedFactorGroups(DEFAULT_FACTOR_GROUPS); + setCustomBenchmark(null); + setCustomBenchmarkError(''); + }; + const enableCommunityComparison = async () => { if (!userService?.setBenchmarkConsent || isSavingBenchmarkConsent) return; setIsSavingBenchmarkConsent(true); try { const result = await userService.setBenchmarkConsent(true); setHasBenchmarkConsent(result?.benchmarkConsent === true); - setIsBenchmarkExpanded(true); } catch { - setCustomBenchmarkError( - translations.comparison.benchmarkOverview?.optInError - || 'Unable to activate community comparison. Please try again.' - ); + setCustomBenchmarkError(t.benchmarkOverview?.optInError || 'Unable to activate community comparison. Please try again.'); } finally { setIsSavingBenchmarkConsent(false); } }; - const resetCustomBenchmark = () => { - setSelectedFactorGroups(DEFAULT_FACTOR_GROUPS); - setCustomBenchmark(null); - setCustomBenchmarkError(''); + const toggleSection = (id) => { + setExpandedSections((prev) => ({ ...prev, [id]: !prev[id] })); }; - // Funzioni helper per Rankings - const getRankLevel = (rank) => { - if (!rank || rank === '' || isNaN(rank)) return 'none'; - const numRank = parseFloat(rank); - if (numRank <= 20) return 'top'; - if (numRank >= 70) return 'low'; - return 'medium'; - }; - - const getRankDescription = (rank, category, isExpense = false) => { - if (!rank || rank === '' || isNaN(rank)) return translations.leaderboard.rankings.noData; - - const numRank = Math.min(parseFloat(rank), 99); - const level = getRankLevel(numRank); - - const categoryKey = isExpense ? 'outflows' : category; - const descriptions = translations.leaderboard.rankings.descriptions[categoryKey]; - - if (descriptions) { - return descriptions[level] || descriptions.medium; + const loadCountryBenchmark = async () => { + if (!rankingService?.getCustomBenchmark || isCountryBenchmarkLoading) return; + setIsCountryBenchmarkLoading(true); + setCountryBenchmarkError(''); + try { + const result = await rankingService.getCustomBenchmark(['location']); + setCountryBenchmark(result); + if (!result?.available) { + setCountryBenchmarkError(t.geography?.countryUnavailable || 'Not enough people from your country compare yet.'); + } + } catch { + setCountryBenchmarkError(t.benchmarkOverview?.customError || 'Unable to refresh the comparison. Try again.'); + } finally { + setIsCountryBenchmarkLoading(false); } - - // Fallback generico - if (level === 'top') return `${translations.leaderboard.rankings.topPerformance} Top ${numRank}%`; - if (level === 'low') return `${translations.leaderboard.rankings.canImprove} Top ${numRank}%`; - return `${translations.leaderboard.rankings.goodPerformance} Top ${numRank}%`; - }; - - const showMotivationalMessage = (rank, category, isExpense = false) => { - if (!rank || rank === '' || isNaN(rank)) return; - - const numRank = parseFloat(rank); - const level = getRankLevel(numRank); - - const categoryKey = isExpense ? 'outflows' : category; - const motivationalTexts = translations.leaderboard.rankings.motivational[level]; - - const content = { - type: level, - title: motivationalTexts.title, - message: motivationalTexts[categoryKey] || motivationalTexts.balance, - icon: level === 'top' ? '🏆' : level === 'medium' ? '⭐' : '💪' - }; - - setPopupContent(content); - setShowMotivationalPopup(true); - }; - - const getCurrentMonth = () => { - const monthDate = userData?.preMonthDate || new Date(); - - // Get the month number and year - const date = new Date(monthDate); - const monthNumber = date.getMonth(); // 0-based month (0 = January, 8 = September) - const year = date.getFullYear(); - - // Manual mapping for reliable translation - const monthNames = { - it: ['gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno', - 'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre'], - en: ['January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December'] - }; - - const monthName = monthNames[language] ? monthNames[language][monthNumber] : monthNames.en[monthNumber]; - return `${monthName} ${year}`; }; // Index 0 is the partial current month. Community monthly benchmarks use // the last complete month, so compare the user's index 1 to the same period. const userIncomesArray = getIncomesArray(userData) || []; const userOutflowsArray = getOutflowsArray(userData) || []; - - const ProfileCompletionPercentage = getProfileCompletionPercentage(userData); - const getLastCompleteMonth = (array) => Number(array?.[1]) || 0; - // Get averages from userData (fetched from /stats/averages API) + const ProfileCompletionPercentage = getProfileCompletionPercentage(userData); const userAverages = userData?.averages || { all: {}, similar: {} }; const benchmarkMetadata = customBenchmark?.available ? { generatedAt: customBenchmark.generatedAt, @@ -1293,27 +745,33 @@ function Comparison({ theme, userData, isHidden}) { && Object.values(benchmarkMetadata?.cohortSizes ?? {}).some((size) => size >= minimumBenchmarkSize); const allComparisonAvailable = hasBenchmarkConsent && (userAverages.all?.benchmark?.populationSize ?? 0) >= minimumBenchmarkSize; + // How many other people are consenting so far, regardless of whether that's enough yet - + // turns "not available yet" into a legible, growing number instead of an open-ended wait. + const communityPopulationSize = Math.max( + userAverages.all?.benchmark?.populationSize ?? 0, + benchmarkMetadata?.populationSize ?? 0 + ); const hasProfileValue = (field) => field && field.index !== -1 && Boolean(field.label); const profileFactorGroups = [ { id: 'career', - label: translations.comparison.benchmarkOverview?.factors?.career || 'Work and career', + label: t.benchmarkOverview?.factors?.career || 'Work and career', fields: ['job', 'jobType', 'workTime', 'yearsOfExperience'] }, { id: 'location', - label: translations.comparison.benchmarkOverview?.factors?.location || 'Geographic area', + label: t.benchmarkOverview?.factors?.location || 'Geographic area', fields: ['jobCountry', 'country', 'remoteType'] }, { id: 'lifeStage', - label: translations.comparison.benchmarkOverview?.factors?.lifeStage || 'Life stage', + label: t.benchmarkOverview?.factors?.lifeStage || 'Life stage', fields: ['age'] }, { id: 'household', - label: translations.comparison.benchmarkOverview?.factors?.household || 'Home and family', + label: t.benchmarkOverview?.factors?.household || 'Home and family', fields: ['livingSituation', 'housingType', 'children'] } ].map(group => ({ ...group, available: group.fields.some(field => hasProfileValue(userData?.profile?.[field])) })); @@ -1322,12 +780,20 @@ function Comparison({ theme, userData, isHidden}) { ? profileFactorGroups.filter(group => customBenchmark.factors.includes(group.id)) : profileFactorGroups.filter(group => group.available); + const countryFactorAvailable = profileFactorGroups.find(group => group.id === 'location')?.available ?? false; + + // Labels for whichever factors a relaxed cohort actually ended up using, so the + // "we broadened this" copy can name them instead of just saying "some factors". + const factorLabel = (id) => profileFactorGroups.find(group => group.id === id)?.label || id; + const relaxedNoticeFor = (appliedFactors) => (t.benchmarkOverview?.relaxedNotice || 'Not enough people matched your full selection yet, so this comparison uses a broader group based on: {factors}.') + .replace('{factors}', appliedFactors.map(factorLabel).join(', ')); + const similarRanks = { balance: similarComparisonAvailable ? (customBenchmark?.available ? customBenchmark.rankings.balance : getPercentageRankOnBalanceSimilar(userData)) : 0, incomes: similarComparisonAvailable ? (customBenchmark?.available ? customBenchmark.rankings.incomes : getPercentageRankOnIncomesSimilar(userData)) : 0, outflows: similarComparisonAvailable ? (customBenchmark?.available ? customBenchmark.rankings.outflows : getPercentageRankOnOutflowsSimilar(userData)) : 0 }; - + const comparisonData = { avgBalance: { user: { @@ -1335,12 +801,10 @@ function Comparison({ theme, userData, isHidden}) { growth12Months: getBalanceGrowth12Months(userData) }, similarUsers: { - current: similarComparisonAvailable ? (customBenchmark?.available ? customBenchmark.averages.balances : userAverages.similar?.balances ?? null) : null, - growth12Months: null // Will be added when API provides this data + current: similarComparisonAvailable ? (customBenchmark?.available ? customBenchmark.averages.balances : userAverages.similar?.balances ?? null) : null }, allUsers: { - current: allComparisonAvailable ? userAverages.all?.balances ?? null : null, - growth12Months: null // Will be added when API provides this data + current: allComparisonAvailable ? userAverages.all?.balances ?? null : null } }, avgIncome: { @@ -1355,73 +819,53 @@ function Comparison({ theme, userData, isHidden}) { } }; - // Calculate Savings Rate (last 12 months) const calculateSavingsRate = () => { const totalIncomes = userIncomesArray.slice(0, 12).reduce((sum, val) => sum + (val || 0), 0); const totalOutflows = userOutflowsArray.slice(0, 12).reduce((sum, val) => sum + (val || 0), 0); - if (totalIncomes <= 0) return null; return ((totalIncomes - totalOutflows) / totalIncomes) * 100; }; - const userSavingsRate = calculateSavingsRate(); - - // Get averages savings rates from API const allUsersSavingsRate = allComparisonAvailable ? getAveragesAllSavingsRates(userData) : null; const similarUsersSavingsRate = similarComparisonAvailable ? getAveragesSimilarSavingsRates(userData) : null; - // Get averages expenses by category from API - const allUsersExpensesByCategory = allComparisonAvailable ? getAveragesAllExpensesByCategory(userData) : null; const similarUsersExpensesByCategory = similarComparisonAvailable ? getAveragesSimilarExpensesByCategory(userData) : null; - // Calculate Asset Allocation const calculateAssetAllocation = () => { const currentBalance = userData?.balances?.[0]?.balance || {}; const totalValue = getTotalValue(userData) || 0; - if (totalValue <= 0) return []; - - // Group assets into categories const liquid = (currentBalance.cash || 0) + (currentBalance.bank || 0) + (currentBalance.digitalServices || 0) + (currentBalance.emergencyFund || 0); const investments = (currentBalance.stocks || 0) + (currentBalance.etf || 0) + (currentBalance.bonds || 0) + (currentBalance.funds || 0) + (currentBalance.commodities || 0); const crypto = (currentBalance.bitcoin || 0) + (currentBalance.crypto || 0); - const similarAllocation = similarComparisonAvailable && customBenchmark?.available ? customBenchmark.averages.assetAllocation : similarComparisonAvailable ? userAverages.similar?.assetAllocation : null; const allAllocation = allComparisonAvailable ? userAverages.all?.assetAllocation : null; - const allocations = [ - { key: 'liquid', name: translations.comparison.cards.assetAllocation.liquid || 'Liquidity', value: liquid, percentage: (liquid / totalValue) * 100, color: '#3498db' }, - { key: 'investments', name: translations.comparison.cards.assetAllocation.investments || 'Investments', value: investments, percentage: (investments / totalValue) * 100, color: '#27ae60' }, - { key: 'crypto', name: translations.comparison.cards.assetAllocation.crypto || 'Crypto', value: crypto, percentage: (crypto / totalValue) * 100, color: '#f39c12' } + return [ + { key: 'liquid', name: t.cards.assetAllocation.liquid || 'Liquidity', value: liquid, percentage: (liquid / totalValue) * 100, color: '#3498db' }, + { key: 'investments', name: t.cards.assetAllocation.investments || 'Investments', value: investments, percentage: (investments / totalValue) * 100, color: '#27ae60' }, + { key: 'crypto', name: t.cards.assetAllocation.crypto || 'Crypto', value: crypto, percentage: (crypto / totalValue) * 100, color: '#f39c12' } ].map(asset => ({ ...asset, similarPercentage: similarAllocation?.[asset.key] ?? null, allPercentage: allAllocation?.[asset.key] ?? null - })); - - return allocations.sort((a, b) => b.percentage - a.percentage); + })).sort((a, b) => b.percentage - a.percentage); }; - const assetAllocation = calculateAssetAllocation(); - // Calculate Spending by Category (last 12 months) const calculateSpendingByCategory = () => { const totalOutflowsPerCategory = getTotalOutflowsParentCategoryPerMonth(userData); const categoryTotals = {}; - - // Sum up all categories across 12 months for (let i = 0; i < 12; i++) { const monthData = totalOutflowsPerCategory[i] || {}; Object.entries(monthData).forEach(([category, amount]) => { categoryTotals[category] = (categoryTotals[category] || 0) + amount; }); } - const totalSpending = Object.values(categoryTotals).reduce((sum, val) => sum + val, 0); if (totalSpending <= 0) return []; - - const categories = Object.entries(categoryTotals) + return Object.entries(categoryTotals) .map(([name, value]) => { const tagKey = resolveTagKeyFromLocalized(name, 'en', 'expense') || @@ -1437,10 +881,7 @@ function Comparison({ theme, userData, isHidden}) { }; }) .sort((a, b) => b.value - a.value); - - return categories; }; - const spendingByCategory = calculateSpendingByCategory(); const formatCurrency = (value) => { @@ -1452,10 +893,9 @@ function Comparison({ theme, userData, isHidden}) { const formatGrowthPercentage = (value) => { if (isHidden) return '****'; if (value === null || value === undefined) return ''; - if (value === 0) return translations.comparison.cards.avgBalance.noGrowthData; - + if (value === 0) return t.cards.avgBalance.noGrowthData; const sign = value > 0 ? '+' : ''; - return `${sign}${value.toFixed(1)}% ${translations.comparison.cards.avgBalance.growth12Months}`; + return `${sign}${value.toFixed(1)}% ${t.cards.avgBalance.growth12Months}`; }; const getComparisonIcon = (userValue, compareValue) => { @@ -1465,23 +905,14 @@ function Comparison({ theme, userData, isHidden}) { return ; }; - const getBalanceComparisonIcon = (userBalance, compareBalance) => { - if (compareBalance?.current === null || compareBalance?.current === undefined) return null; - if (userBalance.current > compareBalance.current) return ; - if (userBalance.current < compareBalance.current) return ; - return ; - }; - const generateInsights = () => { const insights = []; - const { avgOutflows } = comparisonData; - if (similarRanks.balance > 0) { insights.push({ type: 'positive', - title: (translations.comparison.actionableInsights?.percentileTitle || 'Net worth: top {rank}% among similar profiles') + title: (t.actionableInsights?.percentileTitle || 'Net worth: top {rank}% among similar profiles') .replace('{rank}', Math.min(similarRanks.balance, 100)), - description: translations.comparison.actionableInsights?.percentileDescription || 'The percentile uses the specific cohort for net worth, not the undifferentiated average of all users.' + description: t.actionableInsights?.percentileDescription || 'The percentile uses the specific cohort for net worth, not the undifferentiated average of all users.' }); } @@ -1489,9 +920,7 @@ function Comparison({ theme, userData, isHidden}) { 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; + 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); @@ -1502,9 +931,8 @@ function Comparison({ theme, userData, isHidden}) { const contribution = totalGap > 0 ? Math.round((opportunity.difference / totalGap) * 100) : 0; insights.push({ type: 'warning', - title: (translations.comparison.actionableInsights?.categoryTitle || 'Dig deeper: {category}') - .replace('{category}', opportunity.displayName), - description: (translations.comparison.actionableInsights?.categoryDescription || 'Over the last 12 months you spent {difference} more than your cohort average in this parent category: it accounts for {contribution}% of the detected deviations.') + title: (t.actionableInsights?.categoryTitle || 'Dig deeper: {category}').replace('{category}', opportunity.displayName), + description: (t.actionableInsights?.categoryDescription || 'Over the last 12 months you spent {difference} more than your cohort average in this parent category: it accounts for {contribution}% of the detected deviations.') .replace('{difference}', formatCurrency(opportunity.difference)) .replace('{contribution}', contribution) }); @@ -1514,816 +942,441 @@ function Comparison({ theme, userData, isHidden}) { const gap = similarUsersSavingsRate - userSavingsRate; insights.push({ type: 'warning', - title: translations.comparison.actionableInsights?.savingsTitle || 'Room on your savings rate', - description: (translations.comparison.actionableInsights?.savingsDescription || 'Your cohort saves on average {gap} percentage points more. Above-average categories can suggest where to start.') + title: t.actionableInsights?.savingsTitle || 'Room on your savings rate', + description: (t.actionableInsights?.savingsDescription || 'Your cohort saves on average {gap} percentage points more. Above-average categories can suggest where to start.') .replace('{gap}', gap.toFixed(1)) }); - } else if (insights.length === 0 && avgOutflows.similarUsers !== null) { + } else if (insights.length === 0 && comparisonData.avgOutflows.similarUsers !== null) { insights.push({ type: 'positive', - title: translations.comparison.actionableInsights?.balancedTitle || 'Profile in balance with your cohort', - description: translations.comparison.actionableInsights?.balancedDescription || 'No significant deviations found. Keep monitoring the trend, which is more useful than any single month.' + title: t.actionableInsights?.balancedTitle || 'Profile in balance with your cohort', + description: t.actionableInsights?.balancedDescription || 'No significant deviations found. Keep monitoring the trend, which is more useful than any single month.' }); } - return insights; }; - - const renderBenchmarkOverview = () => { - const cohortSizes = benchmarkMetadata?.cohortSizes - ? Object.values(benchmarkMetadata.cohortSizes).filter(size => size > 0) - : []; - const minCohortSize = cohortSizes.length > 0 ? Math.min(...cohortSizes) : null; - const maxCohortSize = cohortSizes.length > 0 ? Math.max(...cohortSizes) : null; - const cohortLabel = minCohortSize === null - ? (translations.comparison.benchmarkOverview?.waiting || 'Cohort being prepared') - : minCohortSize === maxCohortSize - ? `${minCohortSize}` - : `${minCohortSize}-${maxCohortSize}`; - const updatedAt = benchmarkMetadata?.generatedAt - ? new Intl.DateTimeFormat(language === 'it' ? 'it-IT' : 'en-GB', { - day: '2-digit', month: 'short', year: 'numeric' - }).format(new Date(benchmarkMetadata.generatedAt)) - : '--'; - - const rankCards = [ - { label: translations.leaderboard.rankings.balance, value: similarRanks.balance }, - { label: translations.leaderboard.rankings.income, value: similarRanks.incomes }, - { label: translations.leaderboard.rankings.outflows, value: similarRanks.outflows } - ]; - const distributions = customBenchmark?.available ? null : userAverages.similar?.distributions; - const distributionCards = [ - {key: 'balances', label: translations.comparison.cards.avgBalance.title}, - {key: 'incomes', label: translations.comparison.cards.avgIncome.title}, - {key: 'expenses', label: translations.comparison.cards.avgOutflows.title} - ].map(({key, label}) => ({key, label, summary: distributions?.[key]})).filter(({summary}) => summary?.count > 0); - const longitudinal = customBenchmark?.available ? [] : (userAverages.similar?.longitudinal || []); - - return ( - -
-
-

{translations.comparison.benchmarkOverview?.title || 'Your personal comparison'}

-

{translations.comparison.benchmarkOverview?.description || 'Compare net worth, income and outflows with a group of users with a similar profile.'}

-
-
- - - - - -
-
- - {!hasBenchmarkConsent && ( - -
- {optInCopy.title} -

{optInCopy.description}

-
- -
- )} - - {isBenchmarkExpanded && hasBenchmarkConsent && similarComparisonAvailable && <> - - {rankCards.map(card => ( - - {card.label} - {card.value > 0 ? `Top ${Math.min(card.value, 100)}%` : '--'} - - ))} - - - {distributionCards.length > 0 && ( - - {distributionCards.map(({key, label, summary}) => ( -
- {label} - {(translations.comparison.benchmarkOverview?.median || 'Median')}: {formatCurrency(summary.median)} - {(translations.comparison.benchmarkOverview?.interquartileRange || 'Interquartile range')}: {formatCurrency(summary.firstQuartile)} - {formatCurrency(summary.thirdQuartile)} · n={summary.count} -
- ))} -
- )} - - {longitudinal.length > 0 && ( - - {longitudinal.map((point) => ( -
- {(translations.comparison.benchmarkOverview?.monthsAgo || '{months} months ago').replace('{months}', point.monthsAgo)} - {(translations.comparison.benchmarkOverview?.median || 'Median')}: {formatCurrency(point.balances)} - {(translations.comparison.benchmarkOverview?.reliability?.[point.reliability] || 'Affidabilità in aggiornamento')} · n={point.contributorCount} -
- ))} -
- )} - - -
- - {translations.comparison.benchmarkOverview?.basedOn || 'Comparison based on'} - {displayedFactorGroups.map(group => {group.label})} -
-
- -

{(translations.comparison.benchmarkOverview?.privacy || 'Aggregated data only. Cohorts: {count} users; minimum privacy threshold: {minimum}. Updated: {updated}.') - .replace('{count}', cohortLabel) - .replace('{minimum}', benchmarkMetadata?.minimumCohortSize || 20) - .replace('{updated}', updatedAt)}

-
-
- - -
-
-

{translations.comparison.benchmarkOverview?.customizeTitle || 'Customize similar users'}

-

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

-
- -
-
- {profileFactorGroups.map(group => ( - - ))} -
-
- - {customBenchmark && ( - - )} - {customBenchmarkError && {customBenchmarkError}} - {cohortPreview && ( - - {(translations.comparison.benchmarkOverview?.preview || 'Preview: {count} comparable profiles (minimum {minimum}).') - .replace('{count}', cohortPreview.cohort.size) - .replace('{minimum}', cohortPreview.cohort.minimumSize)} - - )} -
-
- } - {isBenchmarkExpanded && hasBenchmarkConsent && !similarComparisonAvailable && ( -
- {translations.comparison.benchmarkOverview?.comparisonUnavailable || 'Comparison group not available yet'} -

{(translations.comparison.benchmarkOverview?.comparisonUnavailableDescription || 'We will show the comparison when the group reaches the minimum privacy threshold of {minimum} participants. Until then, no other users’ statistics are shown as a substitute.') - .replace('{minimum}', String(minimumBenchmarkSize))}

-
- )} -
- ); - }; - - const renderProfileBanner = () => ( - navigate('/profile')} - data-umami-event="comparison-complete-profile" - > - - - - -

{translations.comparison.profileBanner?.title || '🚀 Unlock personalized comparisons!'}

-

- {translations.comparison.profileBanner?.description || 'Complete your profile on the Account page to get anonymous, automated comparisons with users similar to you. Find out how you rank against other professionals!'} -

-
- - {translations.comparison.profileBanner?.action || 'Complete profile'} - - -
- ); - - const toggleCardExpand = (cardId) => { - setExpandedCards(prev => ({ ...prev, [cardId]: !prev[cardId] })); - }; - - const renderInsightsTab = () => ( - <> - {ProfileCompletionPercentage !== 100 && renderProfileBanner()} - -
- -
- {translations.comparison.guide.title} -

{translations.comparison.guide.description}

-
-
-
1
{translations.comparison.guide.youTitle}

{translations.comparison.guide.youDescription}

-
2
{translations.comparison.guide.similarTitle}

{translations.comparison.guide.similarDescription}

-
3
{translations.comparison.guide.allTitle}

{translations.comparison.guide.allDescription}

-
- {renderBenchmarkOverview()} - - {translations.comparison.layers.title} - 1 · {translations.comparison.layers.you} - 2 · {translations.comparison.layers.group} - 3 · {translations.comparison.layers.general} - {translations.comparison.layers.hint} - - - - -

{translations.comparison.cards.avgBalance.title}

- - - -
- - {translations.comparison.cards.avgBalance.yourBalance} - -
- {formatCurrency(comparisonData.avgBalance.user.current)} -
-
- {formatGrowthPercentage(comparisonData.avgBalance.user.growth12Months)} -
-
-
- - {translations.comparison.cards.avgBalance.avgSimilar} - -
- {formatCurrency(comparisonData.avgBalance.similarUsers.current)} - {getBalanceComparisonIcon(comparisonData.avgBalance.user, comparisonData.avgBalance.similarUsers)} -
-
- {formatGrowthPercentage(comparisonData.avgBalance.similarUsers.growth12Months)} -
-
-
- - {translations.comparison.cards.avgBalance.avgAll} - -
- {formatCurrency(comparisonData.avgBalance.allUsers.current)} - {getBalanceComparisonIcon(comparisonData.avgBalance.user, comparisonData.avgBalance.allUsers)} -
-
- {formatGrowthPercentage(comparisonData.avgBalance.allUsers.growth12Months)} -
-
-
-
- - - -

{translations.comparison.cards.avgIncome.title}

- - - -
- - {translations.comparison.cards.avgIncome.yourIncome} - - {formatCurrency(comparisonData.avgIncome.user)} - - - - {translations.comparison.cards.avgIncome.avgSimilar} - - {formatCurrency(comparisonData.avgIncome.similarUsers)} - {getComparisonIcon(comparisonData.avgIncome.user, comparisonData.avgIncome.similarUsers)} + const insights = similarComparisonAvailable ? generateInsights() : []; + + const behaviourCards = [ + { key: 'savingConsistency', title: t.rankingsAccessory.savingConsistency, description: t.rankingsAccessory.savingConsistencyDescription, suffix: '%' }, + { key: 'investmentRegularity', title: t.rankingsAccessory.investmentRegularity, description: t.rankingsAccessory.investmentRegularityDescription, suffix: '%' }, + { key: 'contributionFrequency', title: t.rankingsAccessory.contributionFrequency, description: t.rankingsAccessory.contributionFrequencyDescription, suffix: '' }, + { key: 'goalProgress', title: t.rankingsAccessory.goalProgress, description: t.rankingsAccessory.goalProgressDescription, suffix: '%' }, + ]; + + const cohortSizes = benchmarkMetadata?.cohortSizes ? Object.values(benchmarkMetadata.cohortSizes).filter(size => size > 0) : []; + const minCohortSize = cohortSizes.length > 0 ? Math.min(...cohortSizes) : null; + const maxCohortSize = cohortSizes.length > 0 ? Math.max(...cohortSizes) : null; + const cohortLabel = minCohortSize === null + ? (t.benchmarkOverview?.waiting || 'Cohort being prepared') + : minCohortSize === maxCohortSize ? `${minCohortSize}` : `${minCohortSize}-${maxCohortSize}`; + const updatedAt = benchmarkMetadata?.generatedAt + ? new Intl.DateTimeFormat(language === 'it' ? 'it-IT' : 'en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).format(new Date(benchmarkMetadata.generatedAt)) + : '--'; + + const accordionSections = [ + { + id: 'balance', + icon: , + title: t.cards.avgBalance.title, + teaser: t.cards.avgBalance.description, + render: () => ( + <> + + {formatCurrency(comparisonData.avgBalance.user.current)} + {formatGrowthPercentage(comparisonData.avgBalance.user.growth12Months) || t.cards.avgBalance.yourBalance} + + + {t.cards.avgBalance.avgSimilar} + + {comparisonData.avgBalance.similarUsers.current !== null ? <>{formatCurrency(comparisonData.avgBalance.similarUsers.current)}{getComparisonIcon(comparisonData.avgBalance.user.current, comparisonData.avgBalance.similarUsers.current)} : (translations.general.comingSoon || 'Coming soon')} - - - {translations.comparison.cards.avgIncome.avgAll} - - {formatCurrency(comparisonData.avgIncome.allUsers)} - {getComparisonIcon(comparisonData.avgIncome.user, comparisonData.avgIncome.allUsers)} + + + {t.cards.avgBalance.avgAll} + + {comparisonData.avgBalance.allUsers.current !== null ? <>{formatCurrency(comparisonData.avgBalance.allUsers.current)}{getComparisonIcon(comparisonData.avgBalance.user.current, comparisonData.avgBalance.allUsers.current)} : (translations.general.comingSoon || 'Coming soon')} - -
- - - -

{translations.comparison.cards.avgOutflows.title}

- - - -
- - {translations.comparison.cards.avgOutflows.yourOutflows} - - {formatCurrency(comparisonData.avgOutflows.user)} + + + ) + }, + { + id: 'cashflow', + icon: , + title: t.accordion?.cashflowTitle || 'Income & outflows', + teaser: t.accordion?.cashflowDescription || 'Last complete month, compared to your cohort', + render: () => ( + <> + + {t.cards.avgIncome.yourIncome} + {formatCurrency(comparisonData.avgIncome.user)} + + + {t.cards.avgIncome.avgSimilar} + {formatCurrency(comparisonData.avgIncome.similarUsers)}{getComparisonIcon(comparisonData.avgIncome.user, comparisonData.avgIncome.similarUsers)} + + + {t.cards.avgOutflows.yourOutflows} + {formatCurrency(comparisonData.avgOutflows.user)} + + + {t.cards.avgOutflows.avgSimilar} + {formatCurrency(comparisonData.avgOutflows.similarUsers)}{getComparisonIcon(comparisonData.avgOutflows.similarUsers, comparisonData.avgOutflows.user)} + + + ) + }, + { + id: 'savings', + icon: , + title: t.cards.savingsRate.title, + teaser: t.cards.savingsRate.description, + render: () => userSavingsRate !== null ? ( + <> + + = 20 ? '#27ae60' : userSavingsRate < 0 ? '#e74c3c' : theme.textColor }}> + {isHidden ? '****' : `${userSavingsRate.toFixed(1)}%`} + + {t.cards.savingsRate.last12Months} + + + {t.cards.savingsRate.avgSimilar} + + {similarUsersSavingsRate !== null ? <>{isHidden ? '****' : `${similarUsersSavingsRate.toFixed(1)}%`}{getComparisonIcon(userSavingsRate, similarUsersSavingsRate)} : (translations.general.comingSoon || 'Coming soon')} - - - {translations.comparison.cards.avgOutflows.avgSimilar} - - {formatCurrency(comparisonData.avgOutflows.similarUsers)} - {getComparisonIcon(comparisonData.avgOutflows.similarUsers, comparisonData.avgOutflows.user)} + + + {t.cards.savingsRate.avgAll} + + {allUsersSavingsRate !== null ? <>{isHidden ? '****' : `${allUsersSavingsRate.toFixed(1)}%`}{getComparisonIcon(userSavingsRate, allUsersSavingsRate)} : (translations.general.comingSoon || 'Coming soon')} - - - {translations.comparison.cards.avgOutflows.avgAll} - - {formatCurrency(comparisonData.avgOutflows.allUsers)} - {getComparisonIcon(comparisonData.avgOutflows.allUsers, comparisonData.avgOutflows.user)} + + + ) : ( +

{t.cards.savingsRate.noData}

+ ) + }, + { + id: 'assets', + icon: , + title: t.cards.assetAllocation.title, + teaser: t.cards.assetAllocation.description, + render: () => assetAllocation.length > 0 ? assetAllocation.map((asset) => ( +
+ + + {asset.name} + + {isHidden ? '**%' : `${asset.percentage.toFixed(0)}%`} + + {asset.similarPercentage !== null && ( + + {t.cards.assetAllocation.avgSimilar} + {isHidden ? '**%' : `${asset.similarPercentage.toFixed(0)}%`} + + )} +
+ )) : ( +

{t.cards.assetAllocation.noAssets}

+ ) + }, + { + id: 'spending', + icon: , + 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)} + + )) : ( +

{t.cards.spendingCategories.noExpenses}

+ ) + }, + { + id: 'behaviour', + icon: , + title: t.accordion?.behaviourTitle || t.rankingsAccessory.title, + teaser: t.rankingsAccessory.description, + render: () => behaviourCards.map((card) => { + const personalValue = behaviourBenchmark?.available && behaviourBenchmark.personal ? behaviourBenchmark.personal[card.key] : null; + const rankValue = behaviourBenchmark?.available && behaviourBenchmark.rankings ? behaviourBenchmark.rankings[card.key] : null; + return ( + + {card.title} + + {personalValue != null + ? `${personalValue.toFixed(1)}${card.suffix} · Top ${Math.max(1, 100 - (rankValue ?? 100))}%` + : (translations.general.comingSoon || 'Coming soon')} -
-
- - {/* Savings Rate Card */} - - -

{translations.comparison.cards.savingsRate.title}

- - - -
- {userSavingsRate !== null ? ( + + ); + }) + } + ]; + + const overallGaugeValue = similarComparisonAvailable && similarRanks.balance > 0 ? Math.min(similarRanks.balance, 100) : null; + + return ( +
+ + + {t.hero?.eyebrow || 'Anonymous & aggregate only'} + {t.title} + {t.subtitle} + + {!hasBenchmarkConsent ? ( <> - = 20} negative={userSavingsRate < 0}> -
- {isHidden ? '****' : `${userSavingsRate.toFixed(1)}`}% -
-
{translations.comparison.cards.savingsRate.last12Months}
-
- - {translations.comparison.cards.savingsRate.yourRate} - = 20 ? '#27ae60' : userSavingsRate < 0 ? '#e74c3c' : theme.textColor }}> - {isHidden ? '****' : `${userSavingsRate.toFixed(1)}%`} - - - - {translations.comparison.cards.savingsRate.avgSimilar} - - {similarUsersSavingsRate !== null ? ( - <> - {isHidden ? '****' : `${similarUsersSavingsRate.toFixed(1)}%`} - {getComparisonIcon(userSavingsRate, similarUsersSavingsRate)} - - ) : ( - translations.general.comingSoon || 'Coming soon' - )} - - - - {translations.comparison.cards.savingsRate.avgAll} - - {allUsersSavingsRate !== null ? ( - <> - {isHidden ? '****' : `${allUsersSavingsRate.toFixed(1)}%`} - {getComparisonIcon(userSavingsRate, allUsersSavingsRate)} - - ) : ( - translations.general.comingSoon || 'Coming soon' - )} - - + + + + + {optInCopy.description} + + {isSavingBenchmarkConsent ? (t.benchmarkOverview?.optInSaving || 'Activating...') : optInCopy.title} + ) : ( -
- {translations.comparison.cards.savingsRate.noData} -
- )} - - - - - {/* Asset Allocation Card */} - - -

{translations.comparison.cards.assetAllocation.title}

- - - -
- {assetAllocation.length > 0 ? ( <> - - - {assetAllocation.map((asset, index) => ( - - - {asset.name} -
-
-
- - {isHidden ? '**%' : `${asset.percentage.toFixed(0)}%`} - - - -
- {translations.comparison.cards.assetAllocation.avgSimilar || 'Similar Users Average'} - {asset.similarPercentage === null ? (translations.general.comingSoon || '—') : (isHidden ? '**%' : `${asset.similarPercentage.toFixed(0)}%`)} -
-
- {translations.comparison.cards.assetAllocation.avgAll || 'All Users Average'} - {asset.allPercentage === null ? (translations.general.comingSoon || '—') : (isHidden ? '**%' : `${asset.allPercentage.toFixed(0)}%`)} -
-
- - ))} -
- {assetAllocation.map((asset, index) => ( - -
- {asset.name} - - {isHidden ? '****' : formatCurrency(asset.value)} - - - ))} -
- - - {assetAllocation.length > 3 && ( - toggleCardExpand('assets')}> - {expandedCards['assets'] ? ( - <> {translations.comparison.cards.showLess || 'Show less'} + + + + {overallGaugeValue !== null ? ( + <> + {isHidden ? '**' : `${overallGaugeValue}`}% + {t.hero?.gaugeLabel || 'Percentile'} + ) : ( - <> {translations.comparison.cards.showMore || 'Show more'} + )} - - )} + + + + {overallGaugeValue !== null + ? (t.hero?.gaugeCaption || 'Your net worth compared to people with a similar profile.') + : (t.hero?.gaugeLockedDescription || 'We will show this as soon as your comparison group reaches the minimum privacy threshold.')} + + + + +
{similarRanks.balance > 0 ? `${Math.min(similarRanks.balance, 100)}%` : '—'}
{t.hero?.balanceLabel || 'Net worth'}
+
+ + +
{similarRanks.incomes > 0 ? `${Math.min(similarRanks.incomes, 100)}%` : '—'}
{t.hero?.incomeLabel || 'Income'}
+
+ + +
{similarRanks.outflows > 0 ? `${Math.min(similarRanks.outflows, 100)}%` : '—'}
{t.hero?.outflowsLabel || 'Frugality'}
+
+
- ) : ( -
- {translations.comparison.cards.assetAllocation.noAssets} -
)} - - - {/* Spending by Category Card */} - - -

{translations.comparison.cards.spendingCategories.title}

- - - -
- {spendingByCategory.length > 0 ? ( - <> - -
- {translations.comparison.cards.spendingCategories.topCategories} + + + {ProfileCompletionPercentage !== 100 && ( + navigate('/profile')} data-umami-event="comparison-complete-profile"> + +
+ {t.profileBanner?.title || 'Complete your profile'} + {t.profileBanner?.description} +
+ +
+ )} + + {hasBenchmarkConsent && insights.length > 0 && ( + + {insights.map((insight, index) => ( + + +
+

{insight.title}

+

{insight.description}

- {spendingByCategory.slice(0, 5).map((category, index) => { - // Find the category index to look up averages. - // category.name is the EN translation of the label (see - // userDataTransformers), so it's compared against the - // same i18n translation: tags from the DB no longer - // carry a translations field. - const categoryIndex = userData?.tags?.outflowsTags?.find( - t => t.label === category.tagKey || translateTag(t.label, 'en', 'expense') === category.name - )?.index; - - const similarAvg = categoryIndex !== undefined && similarUsersExpensesByCategory ? similarUsersExpensesByCategory[categoryIndex] : null; - const allAvg = categoryIndex !== undefined && allUsersExpensesByCategory ? allUsersExpensesByCategory[categoryIndex] : null; - +
+ ))} +
+ )} + + {hasBenchmarkConsent && !similarComparisonAvailable && !allComparisonAvailable && ( + + {t.benchmarkOverview?.comparisonUnavailable || 'Comparison group not available yet'} +

{(t.benchmarkOverview?.comparisonUnavailableDescription || 'We will show the comparison when the group reaches the minimum privacy threshold of {minimum} participants.').replace('{minimum}', String(minimumBenchmarkSize))}

+ {communityPopulationSize > 0 && ( +

+ {(t.benchmarkOverview?.comparisonUnavailableProgress || '{count} of {minimum} people so far.') + .replace('{count}', String(communityPopulationSize)) + .replace('{minimum}', String(minimumBenchmarkSize))} +

+ )} +
+ )} + + {hasBenchmarkConsent && (similarComparisonAvailable || allComparisonAvailable) && ( + <> + {t.accordion?.sectionLabel || 'Explore the detail'} + + {accordionSections.map((section) => { + const open = Boolean(expandedSections[section.id]); return ( -
- -
- {category.displayName} - - {isHidden ? '****' : formatCurrency(category.value)} - - - {isHidden ? '**%' : `${category.percentage.toFixed(0)}%`} + + toggleSection(section.id)} aria-expanded={open}> + {section.icon} + + {section.title} + {section.teaser} - - {(similarAvg !== null && similarAvg !== undefined && similarAvg > 0) && ( -
- {translations.comparison.cards.spendingCategories.avgSimilar || translations.comparison.cards.avgOutflows.avgSimilar} - - {isHidden ? '****' : formatCurrency(similarAvg)} - {getComparisonIcon(similarAvg, category.value)} - + + + +
+
{section.render()}
- )} - {(allAvg !== null && allAvg !== undefined && allAvg > 0) && ( -
- {translations.comparison.cards.spendingCategories.avgAll || translations.comparison.cards.avgOutflows.avgAll} - - {isHidden ? '****' : formatCurrency(allAvg)} - {getComparisonIcon(allAvg, category.value)} - -
- )} -
+ +
); })} - {spendingByCategory.length > 5 && ( - <> -
- {translations.comparison.cards.spendingCategories.otherCategories} ({spendingByCategory.length - 5}) -
- -
- {translations.comparison.cards.assetAllocation.other || 'Other'} - - {isHidden ? '****' : formatCurrency(spendingByCategory.slice(5).reduce((sum, c) => sum + c.value, 0))} - - - {isHidden ? '**%' : `${spendingByCategory.slice(5).reduce((sum, c) => sum + c.percentage, 0).toFixed(0)}%`} - - - - )} - - toggleCardExpand('spending')}> - {expandedCards['spending'] ? ( - <> {translations.comparison.cards.showLess || 'Show less'} - ) : ( - <> {translations.comparison.cards.showMore || 'Show more'} - )} - - - ) : ( -
- {translations.comparison.cards.spendingCategories.noExpenses} -
- )} - - - - {generateInsights().map((insight, index) => ( - -

{insight.title}

-

{insight.description}

-
- ))} - - ); - - const renderLegacyRankingsTab = () => { - const balanceRank = allComparisonAvailable ? getPercentageRankOnBalance(userData) : 0; - const incomeRank = allComparisonAvailable ? getPercentageRankOnIncomes(userData) : 0; - const expenseRank = allComparisonAvailable ? getPercentageRankOnOutflows(userData) : 0; - const balanceSimilarRank = similarComparisonAvailable ? (customBenchmark?.available ? customBenchmark.rankings.balance : getPercentageRankOnBalanceSimilar(userData)) : 0; - const incomeSimilarRank = similarComparisonAvailable ? (customBenchmark?.available ? customBenchmark.rankings.incomes : getPercentageRankOnIncomesSimilar(userData)) : 0; - const expenseSimilarRank = similarComparisonAvailable ? (customBenchmark?.available ? customBenchmark.rankings.outflows : getPercentageRankOnOutflowsSimilar(userData)) : 0; - - if (!allComparisonAvailable && !similarComparisonAvailable) { - return -

{translations.leaderboard.rankings.title}

-
- {translations.comparison.benchmarkOverview?.comparisonUnavailable || 'Comparison group not available yet'} -

{(translations.comparison.benchmarkOverview?.comparisonUnavailableDescription || 'We will show rankings when the privacy threshold is met: {minimum}.').replace('{minimum}', String(minimumBenchmarkSize))}

-
-
; - } - - const RankCard = ({ title, rank, icon, isExpense = false, category }) => ( - showMotivationalMessage(rank, category, isExpense)} - > -
-

- {icon} - {title} -

-
- {getRankLevel(rank) === 'top' && } - {getRankLevel(rank) === 'low' && } - {getRankLevel(rank) === 'medium' && } - {rank && !isNaN(rank) ? `Top ${Math.min(parseFloat(rank), 99)}%` : 'N/A'} -
-
-

- {isHidden ? '****' : getRankDescription(rank, category, isExpense)} -

-
- ); - - return ( - - {ProfileCompletionPercentage !== 100 && renderProfileBanner()} - - -

- - {translations.leaderboard.rankings.title} -

-
- - {translations.leaderboard.rankings.monthData} {getCurrentMonth()} -
-
- - - {/* Classifica Generale */} - -
-
- -
-
-

{translations.leaderboard.rankings.generalRanking}

-

- {translations.leaderboard.rankings.generalSubtitle} -

-
-
- - } - category="balance" - /> - - } - category="income" - /> - - } - category="outflows" - isExpense={true} - /> -
- - {/* Classifica Utenti Simili */} - -
-
- -
-
-

{translations.leaderboard.rankings.similarRanking}

-

- {translations.leaderboard.rankings.similarSubtitle} -

-
-
- - } - category="balance" - /> - - } - category="income" - /> - - } - category="outflows" - isExpense={true} - /> -
-
- - {/* Popup Motivazionale */} - {showMotivationalPopup && ( - <> - setShowMotivationalPopup(false)} /> - -
{popupContent.icon}
-

{popupContent.title}

-

{popupContent.message}

- -
+ )} -
- ); - }; - const renderRankingsTab = () => { - const t = translations.comparison.rankingsAccessory; - const cards = [ - {key: 'savingConsistency', title: t.savingConsistency, description: t.savingConsistencyDescription, suffix: '%'}, - {key: 'investmentRegularity', title: t.investmentRegularity, description: t.investmentRegularityDescription, suffix: '%'}, - {key: 'contributionFrequency', title: t.contributionFrequency, description: t.contributionFrequencyDescription, suffix: ''}, - {key: 'goalProgress', title: t.goalProgress, description: t.goalProgressDescription, suffix: '%'}, - ]; - return - -

{t.title}

-
{t.accessoryLabel}
-
-

{t.description}

- - {cards.map((card) => -
-
-

{card.title}

{card.description}

+ {t.benchmarkOverview?.customizeTitle || 'Your comparison group'} + +
+
+

{t.benchmarkOverview?.customizeTitle || 'Customize similar users'}

+

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

+
+ + +
- {behaviourBenchmark?.available && behaviourBenchmark.personal && behaviourBenchmark.rankings ?
- {behaviourBenchmark.personal[card.key] == null ? '—' : `${behaviourBenchmark.personal[card.key]!.toFixed(1)}${card.suffix}`} · {behaviourBenchmark.rankings[card.key] == null ? '—' : `Top ${Math.max(1, 100 - behaviourBenchmark.rankings[card.key]!)}%`} -

{t.waitingForMetricDescription}

-
:
- {similarComparisonAvailable ? t.waitingForMetric : t.waitingForGroup} -

{similarComparisonAvailable ? t.waitingForMetricDescription : t.waitingForGroupDescription.replace('{minimum}', String(minimumBenchmarkSize))}

-
} - )} - - ; - }; - return ( -
- - -

{translations.comparison.title}

-

{translations.comparison.subtitle}

-
- - - setActiveTab('insights')} - data-umami-event="comparison-tab-insights" - > - - {translations.comparison.sections.insights.title} - - setActiveTab('rankings')} - data-umami-event="comparison-tab-rankings" - > - - {translations.comparison.rankingsAccessory.title} - - - - {activeTab === 'insights' && renderInsightsTab()} - {activeTab === 'rankings' && renderRankingsTab()} -
+ {hasBenchmarkConsent ? ( + <> +
+ {profileFactorGroups.map((group) => ( + toggleFactorGroup(group.id)} + disabled={!group.available} + title={!group.available ? (t.benchmarkOverview?.factorUnavailable || 'Complete this part of your profile to use it.') : undefined} + aria-pressed={group.available && selectedFactorGroups.includes(group.id)} + > + {group.label} + + ))} +
+
+ + {customBenchmark && ( + + )} + {customBenchmarkError && {customBenchmarkError}} + {cohortPreview && ( + + {(t.benchmarkOverview?.preview || 'Preview: {count} comparable profiles (minimum {minimum}).') + .replace('{count}', cohortPreview.cohort.size) + .replace('{minimum}', cohortPreview.cohort.minimumSize)} + {cohortPreview.relaxed && ( + {relaxedNoticeFor(cohortPreview.factors)} + )} + + )} +
+ {customBenchmark?.available && customBenchmark.relaxed && ( +
+ +

{relaxedNoticeFor(customBenchmark.factors)}

+
+ )} + {displayedFactorGroups.length > 0 && ( +
+ +

{(t.benchmarkOverview?.privacy || 'Aggregated data only. Cohorts: {count} users; minimum privacy threshold: {minimum}. Updated: {updated}.') + .replace('{count}', cohortLabel) + .replace('{minimum}', benchmarkMetadata?.minimumCohortSize || 20) + .replace('{updated}', updatedAt)}

+
+ )} + + ) : ( + +

{t.benchmarkOverview?.description || 'Compare net worth, income and outflows with a group of users with a similar profile.'}

+
+ )} + + + {t.geography?.title || 'Geography'} + + + +

{t.geography?.countryTitle || 'Compare by country'}

+

{t.geography?.countryDescription || 'Isolate geography from your other profile factors to see how you compare to people in your country.'}

+ {!hasBenchmarkConsent ? ( +

{t.geography?.countryNeedsConsent || 'Enable comparison above to use this.'}

+ ) : !countryFactorAvailable ? ( + navigate('/profile')}> + {t.geography?.countryProfileIncomplete || 'Add your country to your profile'} + + ) : ( + <> + + + {isCountryBenchmarkLoading ? (t.benchmarkOverview?.calculating || 'Calculating...') : (t.geography?.countryCTA || 'See my country comparison')} + + {countryBenchmarkError && {countryBenchmarkError}} + {countryBenchmark?.available && ( + + + {t.hero?.balanceLabel || 'Net worth'} + {`Top ${Math.min(countryBenchmark.rankings.balance, 100)}%`} + + + {t.hero?.incomeLabel || 'Income'} + {`Top ${Math.min(countryBenchmark.rankings.incomes, 100)}%`} + + + {t.hero?.outflowsLabel || 'Frugality'} + {`Top ${Math.min(countryBenchmark.rankings.outflows, 100)}%`} + + + {(t.benchmarkOverview?.preview || 'Preview: {count} comparable profiles (minimum {minimum}).') + .replace('{count}', countryBenchmark.cohort.size) + .replace('{minimum}', countryBenchmark.cohort.minimumSize)} + + + )} + + )} +
+ + + {t.geography?.regionComingSoon || 'Coming soon'} + +

{t.geography?.regionTitle || 'Region & city'}

+

{t.geography?.regionDescription || "We don't collect region or city yet, so we can't compare at that level. It's on the roadmap, along with a clickable map and a cost-of-living-adjusted view."}

+

{t.geography?.mapFutureNote || 'A future step: simulate how a job or location change could affect your numbers, always shown as an assumption, never as advice.'}

+
+
+
); } diff --git a/src/services/rankingService.ts b/src/services/rankingService.ts index 1b1c9441..040c1759 100644 --- a/src/services/rankingService.ts +++ b/src/services/rankingService.ts @@ -18,7 +18,12 @@ export type ComparisonFactorGroup = 'career' | 'location' | 'lifeStage' | 'house export interface CustomBenchmark { available: boolean; + /** Factor groups the client asked for. */ + requestedFactors: ComparisonFactorGroup[]; + /** Factor groups the cohort actually uses - a subset of requestedFactors once relaxed. */ factors: ComparisonFactorGroup[]; + /** True once one or more requestedFactors were dropped to reach the privacy threshold. */ + relaxed: boolean; generatedAt: string; cohort: { size: number; @@ -46,7 +51,9 @@ export interface AssetAllocation { } export interface CustomBenchmarkPreview { + requestedFactors: ComparisonFactorGroup[]; factors: ComparisonFactorGroup[]; + relaxed: boolean; available: boolean; cohort: CustomBenchmark['cohort']; } diff --git a/todo.md b/todo.md index 852ae551..93832efa 100644 --- a/todo.md +++ b/todo.md @@ -130,13 +130,17 @@ - [x] Separate explicit consent to contribute to hosted benchmarks, with revocation/deletion available - [x] Median, quartiles and the actual contributor count per metric (not relying on the average alone) - [x] Cohort personalization: dynamic selection of job/career, geographic area, life stage and household; short-lived Redis cache, on-demand aggregate computation, a live preview of cohort size and a hard cutoff below the privacy threshold +- [x] Automatic cohort factor relaxation: when the exact requested factor combination has too few matches, progressively drops household, then life stage, then career (geography is never dropped automatically, since it dominates nominal financial differences) until the privacy threshold is met - shown to the user as which factors the comparison actually ended up using, never silently - [x] Monthly snapshot of standard cohorts: save the profile buckets and the algorithm version at each monthly refresh, so every benchmark stays reproducible for the whole month without reacting to later profile changes. Balances, income or outflows are never part of the similarity definition. - [~] Derived insights: highlight the parent category with the largest economic gap vs the cohort; add trend and percentage contribution to the gap - [x] Longitudinal 3/6/12-month benchmarks using a stable group, with an update date, sample size and a reliability indicator - [ ] Benchmarks for emergency runway, fixed costs/income ratio, saving rate and asset diversification +- [x] Comparison page redesign: narrative, progressive-disclosure "mirror" layout (percentile gauge, plain-language insights, collapsed detail sections) replacing the dense always-visible stat grids, plus a compare-by-country view that isolates geography as the only cohort factor via the existing custom-cohort endpoint - [ ] Comparisons by job, experience, work region, remote work and household composition; always show range and sample size - [ ] Cost-of-living normalization by geographic area, while keeping the nominal comparison visible too - [ ] Job/location change simulator as an observational scenario with explicit assumptions, never presented as advice or causation +- [ ] Region/city as a profile field (beyond the existing country), to unlock comparison at that finer granularity +- [ ] Clickable map view for geographic comparison (region/city/country); likely a lightweight SVG-map dependency such as `react-simple-maps` + `topojson-client` — not installed today, evaluate when this is picked up - [~] Opt-in community stats protocol for self-hosted instances: sending only rounded monthly buckets and aggregates, never transactions — v1 spec in `docs/COMMUNITY_STATS_PROTOCOL.md`; endpoint and signing still to be implemented - [ ] Signed/versioned benchmark snapshots for self-hosted instances, short contribution retention and verifiable revocation - [ ] Anti-differencing/Sybil protections, a contribution quality score, and bias auditing for rare cohorts