Skip to content

chore(release): promote dev to main - #1340

Merged
JOY (JOY) merged 69 commits into
mainfrom
dev
Oct 29, 2026
Merged

JOY (JOY) merged 69 commits into
mainfrom
dev

Conversation

@JOY

Copy link
Copy Markdown
Contributor

Promote dev to main.

Merge with a merge commit, never squash (Docs/Resources/RELEASE.md).

JOY (JOY) and others added 30 commits October 28, 2026 14:27
…e-less (#1183)

* fix(importer): watch band size range and list before the band key size-less

A band size range written with " - " or an en dash between two bare sizes
("Sport Loop 42mm - 46mm"), and two or more sizes listed right before a band
word ("GPS 40mm, 44mm Dây Sport Band"), name the band's sizes, not the case.
Both now key size-less. A dashed size with more text after it, a single size
before the band and a part that names the case keep their key.

Closes #1162

* fix(importer): a band-led size or a case word before the list keeps the watch case size

* fix(importer): watch range follows the last listed size; a band naming the case keeps it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt and api v1 (#1182)

* fix(web): apply category spec templates to compare, leaderboard, export and api v1

/compare, the leaderboard rows, the spec export, the key spec line and the
REST API v1 product, list and compare responses now read the category's
spec view from applyCategorySpecTemplate, like the product page (#1142).
An earbud shows no screen, RAM, ROM or camera there and a watch no 5G row.

Display mode of applyCategorySpecTemplate also drops the 5G parts of a
watch's free-text network fields ("LTE / 5G" becomes "LTE"); storage mode
keeps them.

Closes #1166

* fix(web): strip 5g qualifiers and use template headings on compare

Review follow-up for #1166: the 5G stripping removes bracketed 5G notes and
qualifiers such as "SA/NSA/Sub6", also cleans sim_type, and compare group
headings come from specGroupHeading so a watch has no "5G" heading. An
export row without a field entry stays visible, and the JSON export schema
label no longer claims ten groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…shed (#1190)

* fix(importer): kept variant keeps its own item and the left item is refreshed

With RATE_ITEM_MODEL on, a kept stored variant (manual, or title/sku/
single_size evidence on a sizeless crawl) now takes item_id from that
variant instead of the Item resolved from the title, and the Item a
listing leaves gets refresh_item_stats like the variant it leaves. The
stored listing is read in the hinted path too, for the refresh only; a
caller hint is never overridden. A matcher without a mode reads as off.
Flag off: unchanged.

Refs #1185

* fix(importer): clear a stale item_id when the kept variant has no item

Review follow-up: write item_id null explicitly in that case, and test
the stored-listing and kept-variant read failures.

Refs #1185

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mplates (#1191)

* fix(web): api v1 filters and extension lookup follow category spec templates

GET /api/v1/rate/products answers 400 for a spec filter the category's
template does not have (has5g on smartwatches; ramGb, storageGb,
compactScreen, minRefreshHz, hasNfc on earbuds), so a filter never returns
a product whose scoped body contradicts it. The extension lookup returns
the category's spec view and no storage tier for earbuds. Behaviour tests
run the list, detail and compare routes and the lookup against a stubbed
network; the regex-only API v1 checks are gone.

Refs #1184

* fix(web): reject cross-category spec filters on category=all

`all` fell back to the phone template, so `category=all&has5g=1` still
returned watches whose body says `has_5g: false`. On `all` a spec filter
must now apply to every category. The extension lookup no longer flags an
earbud listing as an ambiguous storage variant. Tests check the filters
reach search_master_products and that phone bodies stay unchanged.

Refs #1184

* fix(web): keep category=all spec filters to categories that have them

Per PO acceptance, `category=all` no longer answers 400 for a spec filter
some category lacks. It keeps only products whose own category has every
requested filter (`has5g=1` returns 5G phones, tablets and laptops, never
watches or earbuds), reading the catalogue in 500-row pages and paging
after the cut so offset and limit stay exact.

Refs #1184

* fix(web): bound the category=all spec filter scan to 40 pages

Review of the previous commit: a deep offset could read up to 100k rows
page by page, and the whole table per page on the RPC fallback. 20k rows
is well above the catalogue. Adds an `all&nfc=1` case.

Refs #1184

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): hide empty spec rows and groups on the product page

The product page printed spec rows with no value as "-": a GPS watch's
data speed, 4G bands and VoLTE, an earbud's FCC approval, and an earbud
hardware card with a single row. The rows now come from
buildProductSpecGroups, which leaves out rows with no value and groups with
no row; group numbers and the badge count follow the shown groups.
Compare leaves out a row that every column has empty, so "-" stays only
beside a value.

Refs #1186

* fix(web): compare hides rows by the localized cell text

Review of the previous commit: compare checked the raw value, so a row
whose every cell localizes "N/A" to "-" stayed. The JSON export now skips
all-empty rows like the CSV export.

Refs #1186

* test(web): e2e expects hidden empty spec groups

The seeded earbud has values only for audio, battery, Bluetooth and the
sale status, and the seeded phone read as a watch has no network value
once 5G is dropped, so #1186 now hides their empty groups. The 4G-only
bands label moves to product-spec-rows.test.ts.

Refs #1186

* fix(web): no spec toggle without hidden groups; e2e measures the hidden part

Hiding empty groups (#1186) left the phone toggle on products with three
groups or fewer, where it opens nothing; PhoneCollapsible now takes
`collapsible` and the spec section passes it only past three groups.

The collapse e2e asserted collapsed < 60% of expanded, which held for ten
full groups but not for the sparse seeded phone, now five short groups.
It now checks that collapsing removes the height of the groups it hides.

Refs #1186

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…12gb (#1198)

* test(normalizer): pin the thinkpad 21kc008pvn title to unknown, not 512gb

The 2026-10-25 "Storage: 512GB" for CellphoneS SKU 21KC008PVN (a 1TB
config) came from the registry default storage, removed by #1094
(#1101) on 2026-10-27. The current normalizer keys the storage-less
title as :unknown and the title with its config as :1024gb.

Refs #1189

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): show unknown specs as empty instead of invented values

Compare printed made-up facts for unknown specs: "4G LTE" when 5G is
unknown, stereo speakers, "USB Type-C", an official-sale status and a
Vietnam MIC certification. The product page printed "No" for an unknown
5G flag and a default commercial status. Unknown is now empty, so the
copy is removed from the locales and the spec dictionary.

Refs #1193

* fix(web): read text 5g flags and cover known spec values

Review of the previous commit: requiring a strict boolean hid 5G for older
rows that store "true" or "yes" as text. known5g reads boolean or text
flags, nested spec first, and treats anything else as unknown. Tests cover
nested-over-flat precedence, text flags, a hidden all-unknown compare row
and known availability and certification values.

Refs #1193

* test(web): e2e earbud page has no invented sale status

Without the default commercial status (#1193) the seeded earbud keeps
three spec groups: audio, battery and Bluetooth.

Refs #1193

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): no half-translated spec values in vietnamese

localizeSpecValue replaced known English phrases inside longer values and
left the rest in English, as in "không dây fast charging". A value the
phrase map cannot cover now stays whole in English; one it covers is
translated whole. Values already stored partly in Vietnamese still get
their known phrases. The map gains pixels, performance/efficiency cores
and photo/panorama, which covers every resolution, CPU and flash value in
the catalogue.

Refs #1187

* fix(web): check for untranslated words before the month rewrite

Review of the previous commit: the month was translated before the
whole-English check, so "release September 2024" came out half
translated. The check now runs on the raw value. ĩ and ũ mark a value
stored in Vietnamese; cd, in and oz are units; "ppi density" and
"dual-band" are translated, which covers the usual resolution and Wi-Fi
strings.

Refs #1187

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ter (#1202)

* fix(importer): tier 2 never links a storage-less title to a sized master

With RATE_ITEM_MODEL off, match_master_product drops its storage filter
for a NULL size, so "Laptop ASUS Zenbook S 14 UX5406AA-SU348W" scored
above the accept threshold against the 1TB master. A Tier 2 hit whose
model_key ends in a storage size is now skipped for a title with no
storage, and the seed becomes a draft (#1105 option (a)). A ':unknown'
master still takes the title; earbuds keep their legacy ':64gb' seeds.
Flag-on matching is unchanged.

Refs #1200

* fix(importer): watches keep their legacy sized seeds in tier 2

Review follow-up: "Apple Watch Series 10 42mm" has no only_storage_gb
benchmark, so its title states no storage while the seed is keyed
':64gb'. Watches, like earbuds, are sold in one storage per model.

Refs #1200

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1203)

* fix(normalizer): read apple generation words, chips and part numbers

"thế hệ 3", "Gen 3", "3rd Gen" and Roman numerals (II-IV) after a line
read as the generation number. A chip (Apple TV 4K A15/A12/A10X,
AirPods H2, HomePod S7) or a part number (MN873, MTJV3, MU8F2, ...)
fills the generation only when the title states none. Apple Pencil is
its own line instead of the iPhone fallback, also before "cho iPad".

Existing fixtures keep their keys; across 769 Apple strings in the repo
only two Pencil description lines change key.

Refs #1194

* fix(normalizer): apple generation review fixes

- "(thế hệ thứ N)" and "(Gen N)" in parentheses read as the generation.
- Only "Apple Pencil" is the Pencil line; a stylus saying "Pencil" is not.
- H2 no longer names AirPods Pro 2: the Pro 3 has it too.
- Chips and part numbers are read only from the product's own part of
  the title, before a "+" bundle or a "cho/tương thích" phrase.
- A part number without "/A" ("MN873SA") is read too.

Refs #1194

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1204)

* feat(web): sale state for published models with no in-stock listing

A published model with no in-stock listing showed "0 ₫" and "0 sources"
on its card and no reason on its page. masterSaleState derives on_sale,
sold_out, discontinued, coming_soon, not_listed or no_stock from data the
site already has: the in-stock price, the linked listings (product page),
the launch status in the specs and an optional curated
metadata.vn_sale_state. Cards and the product page show the state, and
API v1 list, detail and compare bodies carry sale_state. No data write.

Refs #1188

* fix(web): sale state never says sold out beside an in-stock offer

Review of the previous commit: the stored price counts crawled, priced
in-stock listings only, so a model whose only in-stock listing is
extension-made or unpriced read "sold out" above that offer. The listing
count is now unknown while any listing is in stock. The detail API uses the
same count as the page, OpenAPI documents sale_state on detail and compare,
and free-text "Announced ..." no longer reads as coming soon.

Refs #1188

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1205)

* fix(normalizer): a supported app or service sets no brand or model

"Smart Tivi Samsung ... hỗ trợ Apple TV" keyed apple:tv because the
brand chain read "apple" first. Mentions of an Apple app or service
after "hỗ trợ / tích hợp / ứng dụng / kết nối / tương thích / supports
/ works with" are dropped before brand and model detection, and in a
television title ("tivi", "smart tv") any Apple TV / AirPlay / HomeKit
mention is. An iMac with a bundled Magic Keyboard already keys as the
iMac; fixtures pin it.

Refs #1195

* fix(normalizer): service mention review fixes

- "Apple TV+" drops with its "+", so "iPhone 16 hỗ trợ Apple TV+"
  stays the iPhone 16, not a Plus.
- A television title drops every Apple TV mention only when it names
  another TV brand: "Apple TV 4K - Smart TV Box" stays the Apple TV.
- A colon after the lead word ("hỗ trợ: AirPlay 2") is read too.

Refs #1195

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(importer): an unknown 5g is stored as null, not false

The enricher wrote `has_5g ?? false` in its three return paths, and
the chain before and after it guessed too: the normalizer set every
non-registry phone to 5G and every tablet without "5G" in the title to
no 5G, and the master seed and enrichMasterProductById coerced an
unknown value to false. A phone or tablet title that names no 5G now
gives has_5g null (and no invented cellular block); the enricher, the
seed and the master enrichment keep null. Known values, registry
entries and the no-cellular categories (laptops, watches, earbuds) are
unchanged. Existing rows are not rewritten.

Refs #1196

* fix(importer): read "5 G" titles and let the master schema hold an unknown 5g

Review follow-up: "Galaxy A26 128GB 5 G" read as unknown (it was true
before), and MasterProductSchema rejected has_5g null. An omitted value
still defaults to false, like the column.

Refs #1196

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ge (#1206)

* fix(importer): ignore masterIdHint when the page states another storage

ingestProduct trusted masterIdHint even when the page stated a different
storage than the hinted master, so a 1TB page hinted to the 512GB master
joined it. When the title states a storage and the hinted master has
another, the hint is not applied: the listing is matched normally and
ingest.master_hint_storage_conflict is logged. A page with no stated
storage, or storage derived from the catalogue, keeps the hint.

Refs #1197

* fix(importer): narrow the storage conflict and keep the hint's item

Review of the previous commit (subagent and Codex):
- a title listing several sizes ("256/512GB") named the hinted size but
  was read as a conflict; titleNamesStorage now keeps the hint then;
- a title too bare to key would have failed the whole ingest, so it keeps
  the hint as before;
- in item mode the conflict matched from the title alone; the hint's Item
  is now passed as the item hint;
- in off mode the master the listing left kept stale market stats; it is
  refreshed with the new one.

Refs #1197

* fix(importer): read whole size lists in titles, not ram or model digits

Codex re-review: titleNamesStorage missed "128/256/512GB", "1/2TB" and
"1.5/2TB" and took "64GB RAM" or "Spark64/128GB" for storage.
titleStorageSizesGb now reads each slash list with its trailing unit,
converts TB, and skips RAM and digits glued to a model name. The item-mode
test uses a title keyed apart from the fixture, so it fails without the
item hint.

Refs #1197

* fix(importer): scan title sizes token by token

Codex re-review of the regex: nested optional spacing in the repeated list
group backtracked exponentially on long slash lists; "SSD512/1024GB" and
"ROM128/256GB" lost their first size; and "SSD 512GB RAM 16GB" lost 512GB
to a RAM lookahead. titleStorageSizesGb now folds spacing, splits the title
into tokens and reads each size token with a separator-led, linear pattern.
SSD/ROM/HDD prefixes may be glued, RAM labels bind to the size after them
when the title writes labels first and to the size before them otherwise,
and the scan reads at most 500 characters.

Refs #1197

* fix(importer): bind ram labels per size and split unit suffixes

Codex re-review: one title-wide label direction read "SSD 1TB 64GB RAM"
as storage 64GB, and "256/512GB-VN/A" lost its list to the dash suffix. A
RAM label now binds to a neighbouring size unless another size sits on its
far side, where the title's label habit decides; a unit followed by - . :
or ! ends its token; whitespace is collapsed first so every pass is linear.

Refs #1197

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ants (#1208)

* feat(importer): a new variant of a known item inherits its specs and publish state

Flag-on only (RATE_ITEM_MODEL item / nullable_variant). When the matcher
creates a missing variant under an Item that already has specs, the
variant takes the Item's specifications, key specs, description and
images, keeps only its own storage and RAM, skips the AI enrichment, and
is published when the Item is published, the size is stated and the
listing is a new device (design doc 4.1, 4.5 step 2). An Item with no
specs yet seeds the variant as before. Codex P1 left open on #1151.

Refs #1152

* feat(importer): seeds grouped by item, item specs from the seeds, series 10 storage

- flagshipSeedsByItem() groups the flat seed list into Items with their
  variants; Item specs are the first variant's without its storage.
- syncItemSeedSpecs() writes those specs onto existing rate.items rows,
  dry-run unless apply; runMasterSpecsBackfill calls it only with
  RATE_ITEM_MODEL on, and never creates Items (M1/M2 own that).
- Registry: apple:watch-series-10 with only_storage_gb 64 (M0 inventory),
  so a Series 10 title without storage keys like its ':64gb' seed. 47
  Series 10 fixtures move from ':unknown' to ':64gb'.

Refs #1152

* feat(importer): spec enrichment per item with the flag on

With RATE_ITEM_MODEL on, the matcher asks the AI enricher per Item:
the prompt and cache key carry no storage or RAM, so every size of an
Item shares one call, and the variant's own storage, RAM and storage key
spec are laid over the answer (design doc 5.3). Mode off asks per size
as before.

Refs #1152

* test(importer): use the chip key spec in the per-item enrichment test

Refs #1152

* feat(importer): a listing with no variant refreshes its item rating

persistAnalysisToSupabase takes the listing's itemId. With no variant
(cluster_id NULL, RATE_ITEM_MODEL nullable_variant) it calls the M2
refresh_item_stats RPC, which pools the rating over every listing of the
Item (design doc 4.1, Q4). With a variant, refresh_master_product_rating
runs as before (its M2 body resolves the Item). No itemId, no change.

Refs #1152

* fix(importer): item review fixes for status, sizes, flag guards and seeds

- A new variant under a draft or archived Item is a draft, with or
  without Item specs; an inherited variant also honours needs_review.
- Inherited variants take the flat screen, battery and 5G columns from
  the Item specs, and their own RAM and RAM key spec.
- Per-Item enrichment never takes storage or RAM from the AI answer,
  even when the variant's own is unknown.
- syncItemSeedSpecs and the analyzer's Item rating refresh are no-ops
  with RATE_ITEM_MODEL off; Item specs never keep storage or RAM, also
  when an existing Item row has them; seed Item names drop only a
  trailing storage and seed Items carry no RAM.
- Registry `case_mm`: the Series 10 entry describes the 42mm case, so a
  46mm title gets none of its size specs and is not benchmark-enriched.
- The pipeline passes itemId to persistAnalysisToSupabase.

Refs #1152

* fix(importer): series 10 46mm resolution and the pipeline's item mode for ratings

Codex re-check follow-up: a 46mm title no longer takes the 42mm
resolution, and persistAnalysisToSupabase takes the pipeline's own
itemModel (the env is only the default), so a pipeline configured with
the Item model on refreshes Item ratings.

Refs #1152

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1221)

* fix(normalizer): pin the series 11 registry entry to its 46mm case

The apple:watch-series-11 entry describes the 46mm case, but every
Series 11 title used its screen, battery, weight, dimensions and
resolution and was benchmark-enriched. With case_mm 46 (added for
Series 10 in #1152), a 42mm title gets none of those and is not
benchmark-enriched, so its Tier 3 master is a draft. Keys do not change.

Closes #1211

* test(normalizer): series 11 size-less title and 42mm dimensions

Refs #1211

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1220)

* fix(web): ai review summary says it covers one store's latest analysis

The summary sentence comes from the latest review_analysis row, which is
one listing's batch, while the page and the percentages beside it count
every review. "Đã phân tích 5 đánh giá" therefore read as 5 of the 15
shown. The vi/en/cs copy now says "Lần phân tích gần nhất (một nơi bán)
xét 5 đánh giá" and drops the batch's own rating, which is not the
product score. Scoring is unchanged.

Refs #1216

* fix(web): read the summary as 'đã xét' in vietnamese

Refs #1216

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1226)

* fix(web): four vietnamese copy defects on product, compare and deals

- sensor-shift OIS reads "chống rung dịch chuyển cảm biến (OIS)" once;
  the bare OIS rule no longer rewrites it again (#1210).
- the product quick-spec durability uses localizeSpecValue like the spec
  section (#1219).
- compare skips the ppi suffix when the resolution already names it; a
  bare "No" storage expansion is spelled "No card slot" so it localizes,
  and vi reads "Không hỗ trợ thẻ nhớ" like the en/cs maps (#1214).
- deals count reads as a display limit, not as devices failing the
  budget (#1218).

* fix(web): skip the compare ppi suffix for unspaced ppi too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… apart (#1224)

* fix(web): review cards label the rating weight and the trust estimate apart

Every review showed "Trọng số: 1.00x" on its badge and "Trọng số 0.50x"
in the line under it. The badge is the weight the product score uses
(helpfulness_weight, after votes and reports); the line is the 6-factor
trust model's estimate. The badge now reads "Trọng số tính điểm", and
the model's explanations start with "Độ tin cậy" (en "Rating weight" /
"Trust", cs "Váha v hodnocení" / "Důvěryhodnost"). Scoring is unchanged.

Refs #1215

* fix(web): call the trust model's figure an estimate, label merchant weights alike

Review: "Kiểm tra độ tin cậy (6 yếu tố): Độ tin cậy 0.50x" repeated the
label, and the figure (A·B·C·D·E·F) takes in depth and votes, not trust
alone. It now reads "Ước tính 0.50x" (en "Estimate", cs "Odhad"). The
merchant page's review flag names the same helpfulness weight "trọng số
tính điểm" like the review badge.

Refs #1215

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(normalizer): key garmin forerunner 265s apart from the 265

"Garmin Forerunner 265S 42mm" matched /forerunner\s*265/ and keyed
garmin:forerunner-265:8gb with the 46mm 265's registry specs. The 265S
("265S", "265 S", "265s") is now its own model, garmin:forerunner-265s,
with no registry entry (storage unknown, not benchmark-enriched). The
265 keys as before.

Closes #1222

* fix(normalizer): read 265s glued or hyphenated, not a s/m band size

Codex follow-up: "265S42mm" and "265-S 42mm" are the 265S;
"265 S/M band" and "265 S-M band" are the 265 with a band size.

Refs #1222

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ch (#1229)

* fix(importer,web): ai review summary says it covers one listing's batch

The analyzer stored "Đã phân tích N đánh giá ... (điểm JReviews có trọng
số x/5★)" as if N were the product's reviews and x its score. It now
stores the #1216 wording ("Lần phân tích gần nhất (một nơi bán) đã xét
...") without the batch score. The review summary reader rewords old
rows the same way through listingReviewSummary, so API v1
/products/{slug}, /reviews and the extension side panel print the
honest sentence; the web page localizes both stored forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ore it (#1236)

* fix(normalizer): a size that names the case wins over a band size before it

"Apple Watch Series 11 GPS + Cellular 45mm Sport Band, 46mm Titanium
Case" keyed -45mm: the band size came first outside any band phrase. A
title part that names the case ("Case", "vỏ", "mặt") with exactly one
size and no band word now gives the case size in either order. None of
the 173 watch strings in the repo change key; the #1124/#1137/#1162
fixtures pass unchanged.

Closes #1223

* fix(normalizer): a named case size must sit next to its case word

Review follow-up: the size must be next to "case", "vỏ", "viền" or
"mặt" (up to two words between), "viền" counts, and a part about a box,
a cover or a fit ("kèm vỏ hộp 42mm", "fits 42mm case") names no case.

Refs #1223

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): a 1v1 tie says tie and score gaps read as points

The versus page named a leader on a 5.00 vs 5.00 tie ("dẫn trước",
"Dẫn đầu 1v1 … +0.00"), and the featured pairs wrote the score gap as
"Chênh 0.00 đ", which reads as đồng. isVersusScoreTie compares scores at
the shown two decimals; on a tie the verdict, the banner, the Markdown
copy and the JSON export say the two are level (winner null), and the
gap badge reads "Chênh {delta} điểm".

Refs #1209

* fix(web): ties in the versus csv, api and compare pairs

Review of the previous commit: the CSV export still named a winner on a
tie; API v1 /versus had no tie flag (is_tie added, overall_winner kept as
the tie-break); the compare featured pairs showed "Chênh 0.00 điểm" where
the home page says tie; the Markdown tie line kept the "leading device"
label; vi said "Cân bằng" in one place and "Ngang điểm" in another. The
tie test now checks the vi copy that had the "đ".

Refs #1209

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(web): reuse cached master reads in merchant profiles

A cold merchant profile waited on one database round trip after another
and re-read every listed master's offers and price history uncached. It
now reads each master through the product pages' cached readers, reads
the listings while the merchant row loads, and runs the master analysis
alongside the master, category and review reads.

* fix(web): add merchant discount analysis outside the profile cache

A nested unstable_cache skips its own cache read in Next 16, so the
cached master readers called inside the profile entry re-read every
master. The cached entry now holds the profile without its discount
analysis and the listing-to-master map; the analysis is added per
request from the product pages' cached offers and price history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onest (#1239)

* fix(web): privacy policy covers the extension; ingest limit copy is honest

- /privacy-policy gains a section on the browser extension in vi, en
  and cs: what lookup sends without opt-in, what the opt-in adds, what
  stays in the browser, and that typed searches are sent (#1233).
- The ingest route comment, 429 message and OpenAPI text say the 15/min
  per-IP limit is best effort per server instance and that the RPC cap
  of 120 crowd jobs/min is the global bound (#1234). A real global
  per-IP limit needs a shared store or DB function (DOS-Me migration).

* fix(web): the ingest 429 comes only from the per-instance limit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hosts (#1241)

* fix(extension): truthful opt-in label, store doc and narrower tiktok hosts

- The opt-in label lists the ten fields the crowd signal sends; the
  payload is built by buildCrowdIngestPayload next to the labels, and a
  test ties the label to the payload keys (#1230).
- CHROMEWEBSTORE.md justifies exactly the manifest's permissions and
  hosts (no tabs or localhost; adds TikTok, Hoang Ha, Di Dong Viet,
  Phong Vu, www.rate.vn) and describes what lookup and the opt-in send;
  a test compares the doc with the manifest (#1231).
- TikTok is limited to www.tiktok.com and shop.tiktok.com, the hosts its
  product pages use (#1232).

What the extension collects is unchanged.

* docs(extension): store doc lists the session cache and side panel searches

* docs(extension): the session cache also holds the latest search

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m-aware (#1243)

* feat(importer): make arena spec sync, review risk and peer floors item-aware

Behind RATE_ITEM_MODEL (#1213, design doc 4.1, 4.4, 5.3):
- syncArenaSpecsToItem writes Arena specs to rate.items without storage
  or RAM, and fills only empty storage/RAM overrides on sized variants;
  dry-run by default, never creates Items, no-op with the flag off.
- the Arena audit counts a flagship present when its Item is (another
  size or a rate.items row) and lists Items with incomplete specs.
- resolveClusterPeerFloorPrice takes an optional variant id: a listing
  with no variant gets no floor and NULL-variant rows never enter one;
  seller review risk passes it.

Flag-off change: the Arena parser stores an unknown 5G as null and
invents no cellular defaults (#1196).

* fix(importer): arena item sync keeps one ram and writes only unchanged rows

Review findings on #1213:
- writes are a compare-and-swap on updated_at (variants also not
  archived); a row changed after the read is counted as a conflict;
- a stored RAM number or label wins over Arena, so the two never
  disagree;
- slash RAM options (8GB/12GB RAM) are RAM, never storage, and name
  no single RAM; memory variants come from the GSMArena line only;
- the audit keeps the raw item_key of an Item it cannot sanitize.

* fix(importer): a suffixed ram label wins over the arena ram

Codex round 2 on #1213: a stored key_specs.ram such as
'8GB LPDDR5X' was ignored, so the sync wrote Arena's ram_gb next to a
different label. The label's leading size is now read, and a label
that names no size blocks Arena's RAM.

* test(importer): build the upper-case arena key at run time

The shared-data key scan reads every three-part literal in the importer
test files and requires it to parse, so the upper-case fixture failed
the monorepo build (#1213).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not products (#1255)

* fix(extension,web): tiktok pdp item ids; brand and category pages are not products

- TikTok Shop /shop/vn/pdp/<slug>/<id> (and /shop/pdp/...) yields <id>
  in the extension extractor and the server URL parser (#1245).
- On slug-id stores a path ending in a bare brand (mobile/apple.html,
  dien-thoai/samsung) or "-series" is a listing, and CellphoneS paths
  below the root are categories, so none yields an item id; product
  slugs are unchanged (#1246).

* fix(web): the server url parser skips the same listing pages as the extension

Mirror the brand, "-series" and CellphoneS sub-path rules in
parseEcommerceUrlOrQuery so a pasted category link or an older client's
ingest no longer resolves to item id "apple". Accessory slugs ending in
"-series" (op-lung-...-iphone-16-series) stay products in both (#1246).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

* fix(web): keep merchant listing prices with their discount verdicts

The listing price now comes from the same cached offers the verdict was
computed on, with the store's min and max price recomputed from them. A
discarded speculative listings read no longer logs an error, and the
merchant profile tests cover offer pass-through, one read per master,
unclustered listings and the analysis staying outside the cache entry.

* fix(web): show the price a merchant listing's verdict was computed on

Taking the price from the cached offers could still disagree with a
verdict from an older price-history entry, and an offer price of 0 could
replace a valid one. A listing with a verdict now shows the verdict's own
price, always valid, and the listings stay sorted by the price shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): versus verdict follows the scoreboard's weights

The verdict under the versus title was rendered on the server from the
default weights, while the scoreboard below re-scores with the persona
the reader picks, so the two could name different leaders. The verdict
now lives in the scoreboard and reads its result through
versusVerdictCopy: it names the same leader or tie as the banner and
says which weights it uses when they are not the default ones ("(Theo
trọng số: Chơi game nặng.)"). The default view reads as before.

Refs #1235

* fix(web): announce the versus verdict and test the component's mapping

Review: a persona click changed the verdict silently for screen readers,
and the test rebuilt the component's score and label mapping instead of
testing it. versusVerdictFor now holds that mapping (leader's score, tie
score, persona label with the custom fallback) and the component calls
it; the verdict is aria-live polite. The test also checks the flipped
score and the fallback label.

Refs #1235

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JOY (JOY) and others added 20 commits October 29, 2026 01:54
* feat(web): watchlist asks for a size when an entry is an item

Price alerts stay per size. With RATE_ITEM_MODEL on, an entry the
watchlist API marks `size_required`, or a save answered 422
SIZE_REQUIRED, shows the Item's sizes from the product API; choosing one
replaces the entry with that size, its price and a target 5% below. With
the flag off the API never sends either, so nothing shows.

* fix(web): size picker keeps a watched size and moves the cloud alert

Review of the previous commit: choosing a size already on the watchlist
drops the Item entry and keeps that size's own target; the chosen size's
cloud alert is saved when signed in; when the sizes cannot be read the
entry says so and links to its product page; the size group is labelled
by its title; sync-all names SIZE_REQUIRED in the page's language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erdicts (#1287)

* fix(web): item pages live under their own category; unknown-size history

Item page follow-ups of #1154 (#1281), behind RATE_ITEM_MODEL:
- the route category is checked against the Item's category_id: an Item
  URL under another category, and an old variant URL redirected from
  one, 308 to the Item's own category; an Item whose category is no
  catalogue category is not found; an Item with no category keeps the
  requested one;
- the "storage not stated" group gets its listings' price-history
  verdicts, cached per Item and per their current prices.

Refs #1281

* fix(web): read an uncached item category, canonical of a redirected url

Codex round 1 on #1281:
- a category id missing from the cached map is read by id before the
  Item is called lost, so a new category is never a 404;
- metadata of a URL the page 308s away points its canonical at the
  redirect target, not the requested category;
- the unknown-size history cache key hashes the listings' prices.

Refs #1281

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(proposals): item model go-live runbook with vietnamese version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): item catalogue, versus and sitemap edge cases

Part 2 of the #1155 follow-ups, behind RATE_ITEM_MODEL: when the Item
links fail, the catalogue page is cut from the variants it already read
instead of reading them again; a master whose Item is not published
compares as a plain master, as with the flag off; an Item slug holding
"-vs-" keeps its variant slug as a versus side; the sitemap's versus
pairs use the same variant-to-Item map as its product URLs.

* fix(web): no versus page for a slug holding -vs-

Review of the previous commit: a side whose URL slug holds "-vs-" (Item
or variant) cannot parse back, so it is a 404 instead of a redirect to
an unparsable URL, and the sitemap skips such pairs; a failing Item
links read is logged as an error, a missing schema as a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

* fix(web): item versus links, filtered size chips and the read cap

Behind RATE_ITEM_MODEL: the compare page's 1v1 link, the matchup
switcher and the scoreboard swap link name Item pairs (no 308 hop), from
a slug map the pages pass only while the flag is on; with a ?storage=
filter a card's chips list only the matching sizes, as its price does;
a category too large to group in 20 read batches shows its variant
listing instead of a page cut short or empty.

* test(web): item slug map for in-app versus links

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): restored compare tray links item pairs without a 308

With RATE_ITEM_MODEL on, the compare page's 1v1 link also names the Item
pair for products the client added or restored from the tray: their
Item slug is read from the product API once per slug. With the flag off
the page passes no slug map and nothing is read.

* fix(web): the compare page's slug map covers slugs with no item

A slug the server resolved but found no Item for maps to itself, so the
compare workspace does not read it again on the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1296)

* fix(web): item pages live under their own category; unknown-size history

Item page follow-ups of #1154 (#1281), behind RATE_ITEM_MODEL:
- the route category is checked against the Item's category_id: an Item
  URL under another category, and an old variant URL redirected from
  one, 308 to the Item's own category; an Item whose category is no
  catalogue category is not found; an Item with no category keeps the
  requested one;
- the "storage not stated" group gets its listings' price-history
  verdicts, cached per Item and per their current prices.

Refs #1281

* fix(web): read an uncached item category, canonical of a redirected url

Codex round 1 on #1281:
- a category id missing from the cached map is read by id before the
  Item is called lost, so a new category is never a 404;
- metadata of a URL the page 308s away points its canonical at the
  redirect target, not the requested category;
- the unknown-size history cache key hashes the listings' prices.

Refs #1281

* feat(web): load item size panels on demand for items with many sizes

#1285, behind RATE_ITEM_MODEL. An Item page rendered every
size's price strip, history and offer table into the RSC payload, about
30 KB per size (#1281). Above 4 sizes, or when the other sizes' offers
and history pass about 150 KB, the page now renders only the default
size; another size loads when picked from a cached route,
/api/v1/rate/item-size/{variant slug} (revalidate 300), and shows with
the same client components (price strip, history, offers), keyed per
size. Smaller Items render as before; the page stays ISR.

- e2e: a six-size Item in the item-model seed; the spec logs the RSC
  bytes of the lazy and an eager Item and checks a picked size loads.
- client messages: the saleState labels and two loading strings.

* fix(web): size route only for a published item's sizes, fresh data

Codex round 1 on #1285:
- the size route answers only a published size of a published Item (not
  a draft Item, a retired size or a master with no Item);
- the browser shares only the request in flight per size, so a later
  pick reads fresh data instead of a page-lifetime cache;
- a failed size has a retry button;
- the 150 KB trigger counts UTF-8 bytes.

Refs #1285

* Merge remote-tracking branch 'origin/dev' into feat/1285-lazy-item-size-panels

# Conflicts:
#	apps/web/src/server/rate/item-route.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eader misses (#1292)

* fix(normalizer): a gift size never stands in for a product size the reader misses

* fix(normalizer): bare-size guards for warranty, percent, ram and os; worded watch size outside
  bands; comma gift lists

* fix(normalizer): instalment counts, gifted worded sizes and size lists are no watch or screen size

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): compare links with ?cat= open on their category

Links across the site and the extension write `/compare?cat=...` while
the compare page read only `category`, so they opened on smartphones.
The page now reads both; `category` wins when both are set.

* fix(web): an empty compare category parameter is no value

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

* fix(extension): price list, badge, empty and error states, labels

The side panel orders in-stock offers first and marks sold-out,
discontinued and bait rows (the lookup now flags bait offers), shows a
short state for not-found and accessory pages, an error card with retry
for failed lookups, and no 0.0 stars or 100% genuine without reviews.
The badge and pill warn on a bait price and never say OK for an unchosen
size or an accessory; labels are Vietnamese and the panel is labelled
for screen readers.

* fix(extension): keep the crowd signal and ignore late lookup answers

Review of the previous commit: the opt-in crowd signal is sent whatever
the lookup answered, as before (#1175); only the tab's latest lookup may
set or clear its pill, badge and panel, so a late failure for an earlier
product no longer clears the current one; an open side panel reloads
when the page's lookup fails; a bait price shows the amber verdict style
in the panel too.

* fix(extension): no crowd signal after a network error, as before

A network error used to throw before the opt-in crowd signal; the lookup
now reports it as a status-less failure, so the signal is skipped there
to keep what the extension sends unchanged (#1175).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

* fix(web): a post-login target that normalizes to //host goes home

The callbackUrl sanitizer checked the raw value for a leading '//', but
dot segments normalize into a protocol-relative path after parsing, which
the router reads as another host. The parsed pathname is now checked
too. The locale-prefix 308 also collapses leading slashes, so it never
emits a two-slash Location.

Closes #1305

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…changes (#1320)

* fix(web): a vote flushes only its product's caches, and only when it changes

#1306 (web part). Every vote request revalidated the global
products, stats and reviews tags, and the database limit counts rows per
review, so repeated actions on one review could keep the catalogue
caches cold.
- the stored vote is read first: the same vote again is not written and
  flushes nothing;
- a changed vote revalidates the review caches and its product's page
  tag, not the global products and stats tags;
- flushes are throttled per voter (6 per minute per instance); votes
  past that still count and the caches catch up on their own interval.
The database guard change is proposed on the issue for JOY.

Refs #1306

* test(web): vote route revalidates its product tag, not the global ones

Refs #1306

* fix(web): a vote refreshes only its product's review caches

Codex round 1 on #1306:
- review readers and summaries are also tagged per master
  (masterReviewsCacheTag); a vote revalidates that tag and the product's
  page tag, no longer the global reviews tag;
- a report over a stored up/down vote counts as a change (the report
  clears the vote), so it is written.

Refs #1306

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not sizes (#1317) (#1321)

* fix(normalizer): a size before "năm <year>" is kept; dates, ram, macos names and amounts are no
  size

* fix(normalizer): a size before "RAM 8GB", SSD or ROM stays the screen's; no short price units

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): reviews by product id serve published products only

* fix(web): merchant profile reads no reviews of unpublished products

* fix(web): platform stats sample no reviews of unpublished products

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): rate-limit inspect and lookup and cache their results

#1307. /api/v1/rate/inspect and /lookup ran several database
reads per distinct query with no rate limit, and their result was cached
for one request only.
- both routes share a per-IP limit of 30 per minute per instance (429
  with Retry-After); a durable shared limit is #1247;
- the inspection result is cached in the Data Cache for 2 minutes per
  normalized query (hashed key), also for the inspect page;
- the client key prefers the platform-set x-vercel-forwarded-for over a
  client-supplied forwarded chain (also for the extension routes).

Closes #1307

* fix(web): trust the vercel ip header only on vercel; skip oversized cache entries

Codex round 1 on #1307:
- x-vercel-forwarded-for is read only when VERCEL=1; elsewhere it is
  client input and the forwarded chain is used as before;
- an inspection result over 1.5 MB (Next skips entries over 2 MB) is
  cached as a marker and read uncached, instead of a failed write.

Refs #1307

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1313)

* test(e2e): print the item size-panel payload numbers to the ci log

Refs #1285

* test(e2e): log the payload numbers with console.warn

Refs #1285

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s a public one (#1315)

* fix(web): public api errors answer a fixed message unless the error is a public one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ipt nonce (#1318)

* feat(web): report-only content security policy with a per-request script nonce

* fix(web): csp report endpoint caps streamed bodies, validates and dedupes before logging

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(extension): vi, en and cs through chrome.i18n

Side panel, trust pill, toolbar badge and manifest text now come from
src/_locales (default vi, copied to dist by the build) through a typed
t() helper that falls back to the vi catalogue without chrome.i18n.
Tests pin identical keys and placeholders and fail on Vietnamese left in
src. Server text stays as the API returns it (#1304).

* fix(extension): vietnamese description within chrome's 132 characters

The Vietnamese manifest description was 145 characters, over Chrome's
limit; it is shortened with the same meaning, the store listing's short
summary uses the same text, and the drift test checks name and
description lengths in every locale and the summary against the vi
catalogue.

* fix(extension): count wording that reads right for any number

English and Czech messages with a count no longer need a plural form, and
the Czech empty-review text says recenzí as the other labels do.

* test(extension): expect the count wording for reviews

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1327)

* test(web): lock the cache mode of the home page and the category pages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

@vercel

vercel Bot commented Oct 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
rate Ready Ready Preview Oct 29, 2026 6:47pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 39441338-94cb-40d3-b980-1bf3ec8bee4c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added ci CI/CD workflows and pipelines docs Documentation changes area:frontend Frontend / Next.js / UI area:shared Shared packages / contracts / configs area:crawlers area:extension labels Oct 29, 2026
@JOY
JOY (JOY) merged commit ff67e96 into main Oct 29, 2026
47 checks passed

This branch was successfully deployed

1 active deployment
Preview — 40ddb307 Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:crawlers area:extension area:frontend Frontend / Next.js / UI area:shared Shared packages / contracts / configs ci CI/CD workflows and pipelines docs Documentation changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant