From a31681424e8a1e046c520b01bfa13389f0bb8070 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:50:35 +0700 Subject: [PATCH 1/2] ux(web): versus scores and battle cards show the product's own rating DOS/Rate#1470 (part): the versus scoreboard showed 4.98 and 5.00 while the comparison matrix on the same page showed 5.0 and 5.0, and called a leader on a 0.02 gap. - The versus scoreboard's base score is the product's stored buyer rating (userScore5), the one the matrix, cards and compare show, instead of the leaderboard composite; persona weights still blend in the criteria. - Scores, the verdict and the tie read at one decimal, like the matrix: 5.0 against 5.0 is a tie. - Home and compare battle cards show that rating at one decimal. The product page's headline stays the Bayesian estimate (#1144); whether lists and versus should show it too is open on #1470. Refs #1470 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/[locale]/page.tsx | 8 +++---- .../components/compare/CompareWorkspace.tsx | 8 +++---- .../compare/VersusInteractiveScoreboard.tsx | 23 ++++++++++--------- .../src/lib/__tests__/locale-format.test.ts | 7 +++--- apps/web/src/lib/versus-verdict.ts | 9 ++++---- .../__tests__/core-rate-architecture.test.ts | 16 ++++++++----- 6 files changed, 39 insertions(+), 32 deletions(-) diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 109a1aa2d..e5f676818 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -656,9 +656,9 @@ export default async function Home({
★{' '} {formatDecimal( - battle.productA.combinedScore5, + battle.productA.userScore5, locale, - 2 + 1 )}
@@ -672,9 +672,9 @@ export default async function Home({
★{' '} {formatDecimal( - battle.productB.combinedScore5, + battle.productB.userScore5, locale, - 2 + 1 )}
diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index 93367a9e3..c800e99ff 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -1980,9 +1980,9 @@ export default function CompareWorkspace({
★{' '} {formatDecimal( - battle.productA.combinedScore5, + battle.productA.userScore5, locale, - 2 + 1 )}
@@ -2000,9 +2000,9 @@ export default function CompareWorkspace({
★{' '} {formatDecimal( - battle.productB.combinedScore5, + battle.productB.userScore5, locale, - 2 + 1 )}
diff --git a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx index 5cb00b572..fca7902ce 100644 --- a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx +++ b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx @@ -123,7 +123,8 @@ export function computeVersusWeightedResult( const rawSumW = wScreen + wPerf + wCam + wBat + wVal; const calcWeighted = (item: ScoredVersusItem): number => { - const baseScore5 = sanitizeVersusScore5(item.combinedScore5); + // The product's rating as every page shows it (DOS/Rate#1470). + const baseScore5 = sanitizeVersusScore5(item.userScore5); if (persona === 'default') { return baseScore5; } @@ -162,7 +163,7 @@ export function computeVersusWeightedResult( return { finalA, finalB, - delta: Math.abs(finalA - finalB).toFixed(2), + delta: Math.abs(finalA - finalB).toFixed(1), winner, // Level at the shown precision: no lead for either (DOS/Rate#1209). tie: isVersusScoreTie(finalA, finalB), @@ -265,8 +266,8 @@ export default function VersusInteractiveScoreboard({ const safePriceAdv = sanitizeVersusMarkdownCell(priceAdvantageLabel); const leadScore = weightedResult.finalA >= weightedResult.finalB - ? weightedResult.finalA.toFixed(2) - : weightedResult.finalB.toFixed(2); + ? weightedResult.finalA.toFixed(1) + : weightedResult.finalB.toFixed(1); const lines = [ `### ${t('md1v1Heading', { nameA: safeNameA, nameB: safeNameB })}`, weightedResult.tie @@ -276,7 +277,7 @@ export default function VersusInteractiveScoreboard({ '', `| ${t('mdColCriterion')} | ${safeNameA} | ${safeNameB} | ${t('mdColAdvantage')} |`, `| --- | --- | --- | --- |`, - `| **${t('mdRowWeightedScore')}** | **${weightedResult.finalA.toFixed(2)}/5.0** | **${weightedResult.finalB.toFixed(2)}/5.0** | **${weightedResult.tie ? sanitizeVersusMarkdownCell(t('mdRowTie')) : safeWinnerName}** |`, + `| **${t('mdRowWeightedScore')}** | **${weightedResult.finalA.toFixed(1)}/5.0** | **${weightedResult.finalB.toFixed(1)}/5.0** | **${weightedResult.tie ? sanitizeVersusMarkdownCell(t('mdRowTie')) : safeWinnerName}** |`, ...criteriaRows.map((r) => { const winName = r.aVal > r.bVal @@ -314,8 +315,8 @@ export default function VersusInteractiveScoreboard({ [ t('csvRowWeightedScore5'), 100, - weightedResult.finalA.toFixed(2), - weightedResult.finalB.toFixed(2), + weightedResult.finalA.toFixed(1), + weightedResult.finalB.toFixed(1), weightedResult.tie ? t('mdRowTie') : weightedResult.winner.name, ], ...criteriaRows.map((r) => [ @@ -642,9 +643,9 @@ export default function VersusInteractiveScoreboard({ )}
- {scoreA.brand}: {formatDecimal(weightedResult.finalA, locale, 2)}/ + {scoreA.brand}: {formatDecimal(weightedResult.finalA, locale, 1)}/ {formatDecimal(5, locale, 1)} • {scoreB.brand}:{' '} - {formatDecimal(weightedResult.finalB, locale, 2)}/ + {formatDecimal(weightedResult.finalB, locale, 1)}/ {formatDecimal(5, locale, 1)}
@@ -660,7 +661,7 @@ export default function VersusInteractiveScoreboard({
- ★ {formatDecimal(weightedResult.finalA, locale, 2)}/ + ★ {formatDecimal(weightedResult.finalA, locale, 1)}/ {formatDecimal(5, locale, 1)} @@ -684,7 +685,7 @@ export default function VersusInteractiveScoreboard({
- ★ {formatDecimal(weightedResult.finalB, locale, 2)}/ + ★ {formatDecimal(weightedResult.finalB, locale, 1)}/ {formatDecimal(5, locale, 1)} diff --git a/apps/web/src/lib/__tests__/locale-format.test.ts b/apps/web/src/lib/__tests__/locale-format.test.ts index 308c6ff99..e182d50da 100644 --- a/apps/web/src/lib/__tests__/locale-format.test.ts +++ b/apps/web/src/lib/__tests__/locale-format.test.ts @@ -300,17 +300,18 @@ assert.deepEqual( ); assert.deepEqual(versusVerdictCopy(versusInput, 'vi').values, { winner: 'A', - score: '4.92', + // One decimal, like every rating on the site (DOS/Rate#1470). + score: '4.9', expertSuffix: ' (9.1/10 Lab)', }); assert.deepEqual(versusVerdictCopy(versusInput, 'cs').values, { winner: 'A', - score: '4,92', + score: '4,9', expertSuffix: ' (9,1/10 Lab)', }); assert.deepEqual( versusVerdictCopy({ ...versusInput, tie: true }, 'cs').values, - { score: '4,92' } + { score: '4,9' } ); // cs.json: a placeholder before % or ₫ is joined by one U+00A0, and no diff --git a/apps/web/src/lib/versus-verdict.ts b/apps/web/src/lib/versus-verdict.ts index 0e0864f99..1eac07024 100644 --- a/apps/web/src/lib/versus-verdict.ts +++ b/apps/web/src/lib/versus-verdict.ts @@ -1,12 +1,13 @@ import { formatDecimal } from './locale-format'; /** - * Whether two 1v1 scores are level as the page shows them, at two decimals - * (DOS/Rate#1209): 5.00 vs 5.00 is a tie, not a lead for either model. + * Whether two 1v1 scores are level as the page shows them (DOS/Rate#1209): + * at one decimal, like the comparison matrix and every rating on the site + * (DOS/Rate#1470). 5.0 vs 5.0 is a tie, not a lead for either model. */ export function isVersusScoreTie(scoreA: number, scoreB: number): boolean { if (!Number.isFinite(scoreA) || !Number.isFinite(scoreB)) return false; - return scoreA.toFixed(2) === scoreB.toFixed(2); + return scoreA.toFixed(1) === scoreB.toFixed(1); } export interface VersusVerdictInput { @@ -40,7 +41,7 @@ export function versusVerdictCopy( input: VersusVerdictInput, locale?: string | null ): VersusVerdictCopy { - const score = formatDecimal(input.score, locale, 2); + const score = formatDecimal(input.score, locale, 1); if (input.tie) { return { key: 'versusAutoVerdictTie', diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 4eaa67bc3..c095f4921 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -11350,10 +11350,12 @@ async function runCoreArchitectureSuite() { 'custom' ); assert.equal(selfRes.tie, true); - assert.equal(selfRes.delta, '0.00'); + assert.equal(selfRes.delta, '0.0'); const { isVersusScoreTie } = await import('../../../lib/versus-verdict'); - assert.equal(isVersusScoreTie(5, 4.996), true, 'equal at two decimals'); - assert.equal(isVersusScoreTie(4.98, 4.97), false); + // One decimal, as the matrix and every rating show it (DOS/Rate#1470). + assert.equal(isVersusScoreTie(5, 4.96), true, 'equal at one decimal'); + assert.equal(isVersusScoreTie(4.98, 5), true, '5.0 against 5.0'); + assert.equal(isVersusScoreTie(4.9, 4.8), false); assert.equal(isVersusScoreTie(Number.NaN, 5), false); // The verdict follows the persona: A leads on the default weights, B on // the camera persona, and the verdict names each in turn with its @@ -11365,6 +11367,7 @@ async function runCoreArchitectureSuite() { slug: 'cam-a', name: 'Model A', combinedScore5: 4.9, + userScore5: 4.9, expertScore10: null, criteria: { screen: 5, performance: 5, camera: 3, battery: 5, value: 5 }, }; @@ -11373,6 +11376,7 @@ async function runCoreArchitectureSuite() { slug: 'cam-b', name: 'Model B', combinedScore5: 4.6, + userScore5: 4.6, expertScore10: null, criteria: { screen: 4, performance: 4, camera: 5, battery: 4, value: 4 }, }; @@ -11403,9 +11407,9 @@ async function runCoreArchitectureSuite() { byCamera.key === 'versusAutoVerdict' ) { assert.equal(byDefault.values.winner, 'Model A'); - assert.equal(byDefault.values.score, '4.90'); + assert.equal(byDefault.values.score, '4.9'); assert.equal(byCamera.values.winner, 'Model B'); - assert.equal(byCamera.values.score, '4.50'); + assert.equal(byCamera.values.score, '4.5'); } assert.equal(byDefault.weightsLabel, null); assert.equal(byCamera.weightsLabel, 'Chụp ảnh / Âm thanh'); @@ -11420,7 +11424,7 @@ async function runCoreArchitectureSuite() { }), { key: 'versusAutoVerdictTie', - values: { score: '5.00' }, + values: { score: '5.0' }, weightsLabel: null, } ); From 27a184db72f85ff87a7ed61fc61a0c5e04c6828b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:57:05 +0700 Subject: [PATCH 2/2] fix(web): versus keeps the composite for models with no buyer rating Review of #1478: a model with no buyer rating falls back to its composite instead of scoring 0; the gap, the leading note and the JSON export read at one decimal, from the scores as shown; battle cards go back to the composite they are ranked and paired by. Refs #1470 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/[locale]/page.tsx | 8 ++++---- .../components/compare/CompareWorkspace.tsx | 8 ++++---- .../compare/VersusInteractiveScoreboard.tsx | 18 ++++++++++++------ .../__tests__/core-rate-architecture.test.ts | 17 +++++++++++++++++ 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index e5f676818..109a1aa2d 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -656,9 +656,9 @@ export default async function Home({
★{' '} {formatDecimal( - battle.productA.userScore5, + battle.productA.combinedScore5, locale, - 1 + 2 )}
@@ -672,9 +672,9 @@ export default async function Home({
★{' '} {formatDecimal( - battle.productB.userScore5, + battle.productB.combinedScore5, locale, - 1 + 2 )}
diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index c800e99ff..93367a9e3 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -1980,9 +1980,9 @@ export default function CompareWorkspace({
★{' '} {formatDecimal( - battle.productA.userScore5, + battle.productA.combinedScore5, locale, - 1 + 2 )}
@@ -2000,9 +2000,9 @@ export default function CompareWorkspace({
★{' '} {formatDecimal( - battle.productB.userScore5, + battle.productB.combinedScore5, locale, - 1 + 2 )}
diff --git a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx index fca7902ce..d2802502b 100644 --- a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx +++ b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx @@ -123,8 +123,11 @@ export function computeVersusWeightedResult( const rawSumW = wScreen + wPerf + wCam + wBat + wVal; const calcWeighted = (item: ScoredVersusItem): number => { - // The product's rating as every page shows it (DOS/Rate#1470). - const baseScore5 = sanitizeVersusScore5(item.userScore5); + // The product's rating as every page shows it (DOS/Rate#1470); a model + // with no buyer rating yet keeps its composite (expert or criteria). + const baseScore5 = sanitizeVersusScore5( + item.userScore5 > 0 ? item.userScore5 : item.combinedScore5 + ); if (persona === 'default') { return baseScore5; } @@ -163,7 +166,10 @@ export function computeVersusWeightedResult( return { finalA, finalB, - delta: Math.abs(finalA - finalB).toFixed(1), + // The gap between the scores as shown, at one decimal. + delta: Math.abs( + Number(finalA.toFixed(1)) - Number(finalB.toFixed(1)) + ).toFixed(1), winner, // Level at the shown precision: no lead for either (DOS/Rate#1209). tie: isVersusScoreTie(finalA, finalB), @@ -410,7 +416,7 @@ export default function VersusInteractiveScoreboard({ slug: safeSlugA, name: scoreA.name, brand: scoreA.brand, - weightedScore5: weightedResult.finalA, + weightedScore5: Number(weightedResult.finalA.toFixed(1)), expertScore10: scoreA.expertScore10, minPriceVnd: scoreA.minPrice, dealRadar: radarExportFields(sigA), @@ -419,7 +425,7 @@ export default function VersusInteractiveScoreboard({ slug: safeSlugB, name: scoreB.name, brand: scoreB.brand, - weightedScore5: weightedResult.finalB, + weightedScore5: Number(weightedResult.finalB.toFixed(1)), expertScore10: scoreB.expertScore10, minPriceVnd: scoreB.minPrice, dealRadar: radarExportFields(sigB), @@ -635,7 +641,7 @@ export default function VersusInteractiveScoreboard({ delta: formatDecimal( Number(weightedResult.delta), locale, - 2 + 1 ), })} diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index c095f4921..f331d7796 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -11356,6 +11356,23 @@ async function runCoreArchitectureSuite() { assert.equal(isVersusScoreTie(5, 4.96), true, 'equal at one decimal'); assert.equal(isVersusScoreTie(4.98, 5), true, '5.0 against 5.0'); assert.equal(isVersusScoreTie(4.9, 4.8), false); + // A model with no buyer rating keeps its composite as the base, and the + // gap is the one between the scores as shown (DOS/Rate#1470). + const expertOnly = computeVersusWeightedResult( + { ...scoreItemA, userScore5: 0, combinedScore5: 4.5 }, + { ...scoreItemB, userScore5: 4.2, combinedScore5: 4.2 }, + { screen: 20, performance: 20, camera: 20, battery: 20, value: 20 }, + 'default' + ); + assert.equal(expertOnly.finalA, 4.5); + assert.equal(expertOnly.delta, '0.3'); + const shownGap = computeVersusWeightedResult( + { ...scoreItemA, userScore5: 4.46 }, + { ...scoreItemB, userScore5: 4.34 }, + { screen: 20, performance: 20, camera: 20, battery: 20, value: 20 }, + 'default' + ); + assert.equal(shownGap.delta, '0.2', '4.5 against 4.3'); assert.equal(isVersusScoreTie(Number.NaN, 5), false); // The verdict follows the persona: A leads on the default weights, B on // the camera persona, and the verdict names each in turn with its