Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions scripts/roadmap-items.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
80 changes: 78 additions & 2 deletions server/__tests__/similarUsers.test.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)
}
})
})
48 changes: 28 additions & 20 deletions server/src/services/customBenchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<CustomBenchmarkPreview> {
const factors = normalizeComparisonFactorGroups(rawFactors)
const key = previewCacheKey(userId, factors)
const requestedFactors = normalizeComparisonFactorGroups(rawFactors)
const key = previewCacheKey(userId, requestedFactors)
try {
const cached = await redis.get<CustomBenchmarkPreview>(key)
if (cached) return cached
Expand All @@ -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,
Expand All @@ -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<CustomBenchmark> {
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<CustomBenchmark>(key)
Expand All @@ -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,
Expand Down
62 changes: 61 additions & 1 deletion server/src/services/similarUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -379,5 +438,6 @@ async function getSimilarUserIds(
}

export default {
getSimilarUserIds, fetchProfilesSnapshot, fetchMonthlyProfilesSnapshot, selectSimilarUserIds, selectCustomSimilarUserIds, similarityScore, selectCohort
getSimilarUserIds, fetchProfilesSnapshot, fetchMonthlyProfilesSnapshot, selectSimilarUserIds,
selectCustomSimilarUserIds, selectCustomSimilarUserIdsWithRelaxation, similarityScore, selectCohort
}
20 changes: 20 additions & 0 deletions src/contexts/MockAuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: {
Expand All @@ -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 }
}
}
},
Expand Down
Loading
Loading