From 6a12b09a572e7f38fe2bd0f0c328609c2bdd4399 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sun, 1 Nov 2026 02:01:02 +0700
Subject: [PATCH] fix(web): versus api ties at one decimal and unrated pairs
say so
DOS/Rate#1545 (the part that needs no new column):
- /api/v1/rate/versus decides is_tie and the winner on the /10 scores as
the response shows them, at one decimal; the composite and then the lower
price only break a tie. Before, a 2-decimal gap or a composite gap
named a leader for scores shown equal.
- The scoreboard summary and the verdict say "no rating yet" instead of
"0.0" when a model has no score, like the stars.
Refs #1545
Co-Authored-By: Claude Opus 5.5
---
apps/web/locales/cs.json | 1 +
apps/web/locales/en.json | 1 +
apps/web/locales/vi.json | 1 +
.../api/v1/rate/__tests__/spec-scope.test.ts | 15 ++++++-
apps/web/src/app/api/v1/rate/versus/route.ts | 31 +++++++-------
.../compare/VersusInteractiveScoreboard.tsx | 16 +++++---
.../src/lib/__tests__/locale-format.test.ts | 26 +++++++++++-
apps/web/src/lib/versus-verdict.ts | 40 +++++++++++++++++++
apps/web/src/server/rate/openapi.ts | 2 +-
9 files changed, 108 insertions(+), 25 deletions(-)
diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json
index f3188c6e0..beda56ed6 100644
--- a/apps/web/locales/cs.json
+++ b/apps/web/locales/cs.json
@@ -134,6 +134,7 @@
"tieDeltaNote": "(stejné skóre při aktuálních vahách)",
"tie1v1Badge": "Remíza 1v1",
"versusAutoVerdictTie": "Automatický verdikt: oba modely mají stejné celkové skóre ({score}); rozhodněte podle ceny a parametrů, které potřebujete.",
+ "versusAutoVerdictNoScore": "Automatický verdikt: ani jeden model zatím nemá hodnocení; rozhodněte podle ceny a parametrů, které potřebujete.",
"versusVerdictWeights": "(Váhy: {weights}.)",
"compareMetaTitleWithNames": "Porovnání {names} - Hodnocení a 10 skupin parametrů | Rate.vn",
"compareMetaTitleWithNamesShort": "{names} | Rate.vn",
diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json
index e290e6683..270b8366f 100644
--- a/apps/web/locales/en.json
+++ b/apps/web/locales/en.json
@@ -134,6 +134,7 @@
"tieDeltaNote": "(same score with the current weights)",
"tie1v1Badge": "1v1 Tie",
"versusAutoVerdictTie": "Automatic verdict: the two models are level on overall score ({score}); choose on price and the specs you need.",
+ "versusAutoVerdictNoScore": "Automatic verdict: neither model has a rating yet; choose on price and the specs you need.",
"versusVerdictWeights": "(Weights: {weights}.)",
"compareMetaTitleWithNames": "Compare {names} - Ratings and 10 spec groups | Rate.vn",
"compareMetaTitleWithNamesShort": "{names} | Rate.vn",
diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json
index fec693473..0eec6824f 100644
--- a/apps/web/locales/vi.json
+++ b/apps/web/locales/vi.json
@@ -134,6 +134,7 @@
"tieDeltaNote": "(cùng điểm theo trọng số hiện tại)",
"tie1v1Badge": "Ngang điểm 1v1",
"versusAutoVerdictTie": "Kết luận tự động: hai mẫu ngang điểm tổng hợp ({score}), chọn theo giá và thông số bạn cần.",
+ "versusAutoVerdictNoScore": "Kết luận tự động: cả hai mẫu chưa có đánh giá, chọn theo giá và thông số bạn cần.",
"versusVerdictWeights": "(Theo trọng số: {weights}.)",
"compareMetaTitleWithNames": "So sánh {names} - Điểm đánh giá và 10 nhóm thông số | Rate.vn",
"compareMetaTitleWithNamesShort": "{names} | Rate.vn",
diff --git a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts
index 84993d111..1a05d83ef 100644
--- a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts
+++ b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts
@@ -883,7 +883,20 @@ async function testSpecMatchesBodies() {
get(`/api/v1/rate/versus?pair=${PHONE.slug}-vs-${PHONE_2.slug}`)
);
assert.equal(vs.status, 200);
- expectPaths('/api/v1/rate/versus', await vs.json(), [
+ const vsBody = await vs.json();
+ // The tie and the winner read the /10 scores as shown, at one decimal
+ // (#1545): a tie only when they are equal, else the higher one wins.
+ const vsA10 = vsBody.product_a.custom_weighted_score_10;
+ const vsB10 = vsBody.product_b.custom_weighted_score_10;
+ assert.equal(vsBody.is_tie, vsA10 === vsB10);
+ if (!vsBody.is_tie) {
+ assert.equal(vsBody.overall_winner.side, vsA10 > vsB10 ? 'A' : 'B');
+ assert.equal(
+ vsBody.overall_winner.margin_10,
+ Number(Math.abs(vsA10 - vsB10).toFixed(1))
+ );
+ }
+ expectPaths('/api/v1/rate/versus', vsBody, [
'is_tie',
'versus_pair',
...under('deal_radar.productA', radar),
diff --git a/apps/web/src/app/api/v1/rate/versus/route.ts b/apps/web/src/app/api/v1/rate/versus/route.ts
index 76aeba948..089388a7c 100644
--- a/apps/web/src/app/api/v1/rate/versus/route.ts
+++ b/apps/web/src/app/api/v1/rate/versus/route.ts
@@ -7,6 +7,7 @@ import {
getMasterProductOffersBatch,
} from '@/server/rate';
import { radarExportFields } from '@/lib/deal-radar';
+import { versusApiWinner } from '@/lib/versus-verdict';
import {
comparePositiveMinPrices,
scoreMasterProductForLeaderboard,
@@ -305,18 +306,16 @@ export async function GET(req: NextRequest) {
scoreB.minPrice
);
- const winnerSide: 'A' | 'B' =
- customScoreA5 > customScoreB5
- ? 'A'
- : customScoreB5 > customScoreA5
- ? 'B'
- : scoreA.combinedScore5 > scoreB.combinedScore5
- ? 'A'
- : scoreB.combinedScore5 > scoreA.combinedScore5
- ? 'B'
- : priceSide === 'B'
- ? 'B'
- : 'A';
+ // The winner and the tie read the scores as the response shows them, at
+ // one decimal; the composite and then the price only break a tie
+ // (DOS/Rate#1545, D3).
+ const { tie: isTie, side: winnerSide } = versusApiWinner(
+ customScoreA10,
+ customScoreB10,
+ scoreA.combinedScore5,
+ scoreB.combinedScore5,
+ priceSide
+ );
const winnerScore = winnerSide === 'A' ? scoreA : scoreB;
const winnerCustom10 = winnerSide === 'A' ? customScoreA10 : customScoreB10;
@@ -399,11 +398,9 @@ export async function GET(req: NextRequest) {
productA: radarSignalA,
productB: radarSignalB,
},
- // Level at two decimals: overall_winner is then only the tie-break
- // (DOS/Rate#1209).
- is_tie:
- customScoreA5.toFixed(2) === customScoreB5.toFixed(2) &&
- scoreA.combinedScore5.toFixed(2) === scoreB.combinedScore5.toFixed(2),
+ // Level at the shown precision: overall_winner is then only the
+ // tie-break (DOS/Rate#1209, DOS/Rate#1545).
+ is_tie: isTie,
overall_winner: {
side: winnerSide,
slug: winnerScore.slug,
diff --git a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx
index d56406d4c..4680e6fa4 100644
--- a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx
+++ b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx
@@ -187,6 +187,10 @@ export default function VersusInteractiveScoreboard({
const scoreA = swapped ? secondScore : firstScore;
const scoreB = swapped ? firstScore : secondScore;
const t = useTranslations('compare');
+ const tStars = useTranslations('starRating');
+ // A side with no score reads like its stars, not "0.0" (DOS/Rate#1545).
+ const summaryScore = (score: number): string =>
+ score > 0 ? formatDecimal(score, locale, 1) : tStars('empty');
const locale = useLocale();
const useCaseLabelId = useId();
const slidersPanelId = useId();
@@ -479,9 +483,11 @@ export default function VersusInteractiveScoreboard({
aria-live="polite"
>
{/* Narrows the values to each key's own placeholders. */}
- {verdict.key === 'versusAutoVerdictTie'
- ? t(verdict.key, verdict.values)
- : t(verdict.key, verdict.values)}
+ {verdict.key === 'versusAutoVerdictNoScore'
+ ? t(verdict.key)
+ : verdict.key === 'versusAutoVerdictTie'
+ ? t(verdict.key, verdict.values)
+ : t(verdict.key, verdict.values)}
{verdict.weightsLabel &&
` ${t('versusVerdictWeights', { weights: verdict.weightsLabel })}`}
@@ -650,8 +656,8 @@ export default function VersusInteractiveScoreboard({
)}
- {scoreA.brand}: {formatDecimal(weightedResult.finalA, locale, 1)} •{' '}
- {scoreB.brand}: {formatDecimal(weightedResult.finalB, locale, 1)}
+ {scoreA.brand}: {summaryScore(weightedResult.finalA)} •{' '}
+ {scoreB.brand}: {summaryScore(weightedResult.finalB)}
diff --git a/apps/web/src/lib/__tests__/locale-format.test.ts b/apps/web/src/lib/__tests__/locale-format.test.ts
index e182d50da..6b53579de 100644
--- a/apps/web/src/lib/__tests__/locale-format.test.ts
+++ b/apps/web/src/lib/__tests__/locale-format.test.ts
@@ -7,7 +7,7 @@ import { radarExplanation } from '../product-detail-copy';
import { weightExplanationCopy } from '../review-display';
import { fitTitle } from '../seo';
import { localizeSpecValue } from '../spec-localizer';
-import { versusVerdictCopy } from '../versus-verdict';
+import { versusApiWinner, versusVerdictCopy } from '../versus-verdict';
import {
NBSP,
collapseWhitespace,
@@ -313,6 +313,30 @@ assert.deepEqual(
versusVerdictCopy({ ...versusInput, tie: true }, 'cs').values,
{ score: '4,9' }
);
+// Neither model has a score: no "0.0" tie (DOS/Rate#1545).
+assert.equal(
+ versusVerdictCopy({ ...versusInput, tie: true, score: 0 }).key,
+ 'versusAutoVerdictNoScore'
+);
+
+// The 1v1 API decides on the /10 scores at one decimal; the composite and
+// then the lower price only break a tie (DOS/Rate#1545).
+assert.deepEqual(versusApiWinner(8.9, 8.7, 4.0, 4.9, 'B'), {
+ tie: false,
+ side: 'A',
+});
+assert.deepEqual(versusApiWinner(9.04, 8.96, 4.0, 4.9, 'A'), {
+ tie: true,
+ side: 'B',
+});
+assert.deepEqual(versusApiWinner(9.0, 9.0, 4.5, 4.5, 'B'), {
+ tie: true,
+ side: 'B',
+});
+assert.deepEqual(versusApiWinner(9.0, 9.0, 4.5, 4.5, 'equal'), {
+ tie: true,
+ side: 'A',
+});
// cs.json: a placeholder before % or ₫ is joined by one U+00A0, and no
// rating scale is written `/ 5.0`.
diff --git a/apps/web/src/lib/versus-verdict.ts b/apps/web/src/lib/versus-verdict.ts
index 1eac07024..8ce1659d3 100644
--- a/apps/web/src/lib/versus-verdict.ts
+++ b/apps/web/src/lib/versus-verdict.ts
@@ -10,6 +10,32 @@ export function isVersusScoreTie(scoreA: number, scoreB: number): boolean {
return scoreA.toFixed(1) === scoreB.toFixed(1);
}
+/**
+ * The 1v1 API's winner and tie (DOS/Rate#1545): the persona scores on 10, at
+ * one decimal as the response shows them, decide; on a tie the composite,
+ * then the lower price, then side A only pick `overall_winner`.
+ */
+export function versusApiWinner(
+ score10A: number,
+ score10B: number,
+ combined5A: number,
+ combined5B: number,
+ lowerPriceSide: string | null | undefined
+): { tie: boolean; side: 'A' | 'B' } {
+ const a = Number.isFinite(score10A) ? Number(score10A.toFixed(1)) : 0;
+ const b = Number.isFinite(score10B) ? Number(score10B.toFixed(1)) : 0;
+ if (a !== b) return { tie: false, side: a > b ? 'A' : 'B' };
+ const side: 'A' | 'B' =
+ combined5A > combined5B
+ ? 'A'
+ : combined5B > combined5A
+ ? 'B'
+ : lowerPriceSide === 'B'
+ ? 'B'
+ : 'A';
+ return { tie: true, side };
+}
+
export interface VersusVerdictInput {
tie: boolean;
/** The leader's score under the weights in use (either side on a tie). */
@@ -21,6 +47,11 @@ export interface VersusVerdictInput {
}
export type VersusVerdictCopy =
+ | {
+ key: 'versusAutoVerdictNoScore';
+ values: Record;
+ weightsLabel: string | null;
+ }
| {
key: 'versusAutoVerdictTie';
values: { score: string };
@@ -41,6 +72,15 @@ export function versusVerdictCopy(
input: VersusVerdictInput,
locale?: string | null
): VersusVerdictCopy {
+ // Level because neither model has a score yet: say so, as the stars do,
+ // instead of a "0.0" tie (DOS/Rate#1545).
+ if (input.tie && !(input.score > 0)) {
+ return {
+ key: 'versusAutoVerdictNoScore',
+ values: {},
+ weightsLabel: input.weightsLabel,
+ };
+ }
const score = formatDecimal(input.score, locale, 1);
if (input.tie) {
return {
diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts
index 457893dcc..c6e68107a 100644
--- a/apps/web/src/server/rate/openapi.ts
+++ b/apps/web/src/server/rate/openapi.ts
@@ -627,7 +627,7 @@ function buildBaseRateOpenApiSpec(
summary:
'Get programmatic 1v1 Head-to-Head JReviews verdict, 5-criteria scoreboard & price delta',
description:
- 'Evaluates two same-category master products (`?pair=-vs-` or `?slugA=...&slugB=...`) with optional persona/custom 5-criteria weights (`persona`, `w_screen`, `w_performance`, `w_camera`, `w_battery`, `w_value`) and returns the overall winner (with `is_tie` true when both scores are level at two decimals; `overall_winner` is then only the price or side-A tie-break), per-criterion head-to-head breakdown, price delta, Deal Radar signals (`deal_radar`), and 10-group specs.',
+ 'Evaluates two same-category master products (`?pair=-vs-` or `?slugA=...&slugB=...`) with optional persona/custom 5-criteria weights (`persona`, `w_screen`, `w_performance`, `w_camera`, `w_battery`, `w_value`) and returns the overall winner (with `is_tie` true when both `custom_weighted_score_10` values are equal at one decimal; `overall_winner` is then only the tie-break: the combined score, then the lower price, then side A), per-criterion head-to-head breakdown, price delta, Deal Radar signals (`deal_radar`), and 10-group specs.',
tags: ['Comparison & Watchlist'],
parameters: [
{