{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.'}
- {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))}
- {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!'}
-
- {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))}
- {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))}
{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/todo.md b/todo.md
index 852ae551..b8b09bb8 100644
--- a/todo.md
+++ b/todo.md
@@ -134,9 +134,12 @@
- [~] 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
From a112ebf45f106da180fcf0faa3f5145c8f5cb023 Mon Sep 17 00:00:00 2001
From: Alessio Speranza <33428827+alessiospera@users.noreply.github.com>
Date: Wed, 19 Aug 2026 15:47:45 +0200
Subject: [PATCH 2/2] Add automatic cohort factor relaxation and make the demo
showcase the redesigned Comparison page
Custom comparison cohorts now broaden themselves when the exact requested
factor combination has too few matches, dropping household then life stage
then career while never touching geography, and always tell the user which
factors the result actually used. The main comparison also shows a running
"X of N people so far" count while the community is still small.
The public demo account previously hit the opt-in wall and, for the factor
customizer, "compare by country" and behaviour sections, called endpoints
that don't exist without a real backend. It now ships with consent already
granted, realistic cohort/population numbers, and mocked responses for those
endpoints - including a demo-only preview of the new relaxation behavior.
---
CHANGELOG.md | 20 +++++++
scripts/roadmap-items.json | 12 ++++
server/__tests__/similarUsers.test.ts | 80 +++++++++++++++++++++++++-
server/src/services/customBenchmark.ts | 48 +++++++++-------
server/src/services/similarUsers.ts | 62 +++++++++++++++++++-
src/contexts/MockAuthContext.tsx | 20 +++++++
src/data/demoData.ts | 23 ++++++++
src/data/roadmapData.ts | 12 ++++
src/hooks/useDemoServices.ts | 66 ++++++++++++++++++++-
src/i18n/locales/de.json | 3 +-
src/i18n/locales/en.json | 3 +-
src/i18n/locales/es.json | 3 +-
src/i18n/locales/fr.json | 3 +-
src/i18n/locales/it.json | 3 +-
src/i18n/locales/pt-BR.json | 3 +-
src/sections/Comparison.tsx | 33 ++++++++++-
src/services/rankingService.ts | 7 +++
todo.md | 1 +
18 files changed, 370 insertions(+), 32 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 180078e6..358391bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -146,6 +146,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
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 63518469..2683e7e5 100644
--- a/scripts/roadmap-items.json
+++ b/scripts/roadmap-items.json
@@ -526,5 +526,17 @@
"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 45f73d40..b1f8ef8a 100644
--- a/src/data/roadmapData.ts
+++ b/src/data/roadmapData.ts
@@ -428,6 +428,18 @@ const roadmapData: RoadmapItem[] = [
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 e626d7ba..5764983f 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -1217,10 +1217,11 @@
"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}).",
+ "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" }
},
"cards": {
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index ad347df6..9de2f4cb 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -1290,10 +1290,11 @@
"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}).",
+ "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",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index a7bfc587..b6132580 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -1219,10 +1219,11 @@
"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}).",
+ "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" }
},
"cards": {
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 12c83e8f..08c4d02c 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -1217,10 +1217,11 @@
"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}).",
+ "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" }
},
"cards": {
diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json
index 4b725932..4880767a 100644
--- a/src/i18n/locales/it.json
+++ b/src/i18n/locales/it.json
@@ -1290,10 +1290,11 @@
"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}).",
+ "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",
diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json
index a7f9850f..9969c032 100644
--- a/src/i18n/locales/pt-BR.json
+++ b/src/i18n/locales/pt-BR.json
@@ -1217,10 +1217,11 @@
"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}).",
+ "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" }
},
"cards": {
diff --git a/src/sections/Comparison.tsx b/src/sections/Comparison.tsx
index 96996324..50c15446 100644
--- a/src/sections/Comparison.tsx
+++ b/src/sections/Comparison.tsx
@@ -41,7 +41,7 @@ import Tooltip from '@mui/material/Tooltip';
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';
@@ -470,6 +470,7 @@ const CohortCard = styled.section`
.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)'}; }
@@ -611,7 +612,7 @@ const GaugeArc = ({ value, theme, size = 176 }) => {
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 navigate = useLocalizedNavigate();
const t = translations.comparison;
@@ -744,6 +745,12 @@ 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 = [
@@ -775,6 +782,12 @@ function Comparison({ theme, userData, isHidden }) {
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,
@@ -1194,6 +1207,13 @@ function Comparison({ theme, userData, isHidden }) {
{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))}
+
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 b8b09bb8..93832efa 100644
--- a/todo.md
+++ b/todo.md
@@ -130,6 +130,7 @@
- [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