@mohasinac/appkit 4.53.1 โ†’ 4.53.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/theme/ThemeProvider.js +15 -1
  3. package/dist/_internal/server/features/categories/price-guidance-resolve.d.ts +66 -0
  4. package/dist/_internal/server/features/categories/price-guidance-resolve.js +138 -0
  5. package/dist/_internal/server/features/price-index/market.d.ts +57 -0
  6. package/dist/_internal/server/features/price-index/market.js +120 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +12 -0
  8. package/dist/_internal/server/features/products/list-public.js +10 -1
  9. package/dist/_internal/server/features/products/listing-tab-counts.d.ts +25 -1
  10. package/dist/_internal/server/features/products/listing-tab-counts.js +128 -10
  11. package/dist/_internal/server/features/stores/tabs.d.ts +7 -2
  12. package/dist/_internal/server/features/stores/tabs.js +5 -3
  13. package/dist/_internal/shared/search/match.js +28 -2
  14. package/dist/constants/api-endpoints.d.ts +7 -0
  15. package/dist/constants/api-endpoints.js +7 -0
  16. package/dist/features/auctions/components/AuctionDetailPageView.js +8 -7
  17. package/dist/features/categories/api/[id]/price-guidance/route.js +1 -24
  18. package/dist/features/categories/components/BrandDetailPageView.js +14 -6
  19. package/dist/features/categories/components/BrandDetailTabs.d.ts +7 -1
  20. package/dist/features/categories/components/BrandDetailTabs.js +18 -3
  21. package/dist/features/categories/components/BundleCollage.js +3 -2
  22. package/dist/features/categories/components/BundleItemsPicker.js +3 -4
  23. package/dist/features/categories/components/CategoryDetailPageView.js +17 -7
  24. package/dist/features/categories/components/CategoryDetailTabs.d.ts +7 -1
  25. package/dist/features/categories/components/CategoryDetailTabs.js +29 -7
  26. package/dist/features/classified/components/ClassifiedDetailPageView.js +10 -1
  27. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +10 -1
  28. package/dist/features/live/components/LiveItemDetailPageView.js +10 -1
  29. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +8 -7
  30. package/dist/features/price-index/components/PriceIndexToolView.d.ts +21 -0
  31. package/dist/features/price-index/components/PriceIndexToolView.js +230 -27
  32. package/dist/features/price-index/lookup-target.d.ts +48 -0
  33. package/dist/features/price-index/lookup-target.js +107 -0
  34. package/dist/features/products/components/AvailabilityScopeHint.d.ts +22 -0
  35. package/dist/features/products/components/AvailabilityScopeHint.js +27 -0
  36. package/dist/features/products/components/CompareOverlay.d.ts +2 -1
  37. package/dist/features/products/components/CompareOverlay.js +2 -1
  38. package/dist/features/products/components/GroupSettingsPanel.js +2 -1
  39. package/dist/features/products/components/ProductDetailPageView.js +7 -2
  40. package/dist/features/products/components/SublistingCarouselSection.js +2 -1
  41. package/dist/features/products/repository/products.repository.d.ts +1 -0
  42. package/dist/features/products/repository/products.repository.js +5 -1
  43. package/dist/features/products/utils/group-member-image.d.ts +9 -2
  44. package/dist/features/products/utils/group-member-image.js +2 -1
  45. package/dist/features/seller/components/SellerProductsView.js +3 -2
  46. package/dist/features/stores/components/StoreAuctionsListing.js +2 -1
  47. package/dist/features/tester/seed-data/authored/content-discovery__authenticity-checker.js +114 -2
  48. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +12 -0
  49. package/dist/seed/products-beyblade-x-seed-data.js +36 -1
  50. package/dist/server.d.ts +2 -0
  51. package/dist/server.js +8 -0
  52. package/package.json +1 -1
  53. package/scripts/seed-cli.mjs +133 -27
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.53.1",
4
- "head": "d45b9f65d4b73c4c4a079a8ee5553600ddd0c251",
3
+ "version": "4.53.2",
4
+ "head": "94f4715428f35ab362a11a468072ef933ca8a46d",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-19T21:36:59.723Z"
7
+ "builtAt": "2026-10-19T22:24:09.397Z"
8
8
  }
@@ -20,7 +20,21 @@ function readModePreference() {
20
20
  catch (_err) {
21
21
  void normalizeError(_err); /* localStorage unavailable in privacy/iframe context */
22
22
  }
23
- return "auto";
23
+ /*
24
+ * ๐Ÿ›‘ LIGHT, not "auto" โ€” a product decision, not a technical one.
25
+ *
26
+ * "auto" follows `prefers-color-scheme`, and most phones and an increasing
27
+ * share of desktops now report dark. That made dark the de-facto default
28
+ * for a storefront whose product photography is shot on white: every
29
+ * listing tile rendered a bright cut-out against a near-black card, which
30
+ * is the opposite of how the catalogue was designed to read.
31
+ *
32
+ * This changes the DEFAULT only. A visitor who has chosen either mode has
33
+ * "light"/"dark"/"auto" in localStorage and is returned above, untouched โ€”
34
+ * including anyone who deliberately picked "auto". The dark theme itself is
35
+ * unchanged and one click away.
36
+ */
37
+ return "light";
24
38
  }
25
39
  function resolveEffectiveMode(preference) {
26
40
  if (preference === "light" || preference === "dark")
@@ -0,0 +1,66 @@
1
+ import type { CategoryPriceGuidance } from "../../../../features/categories/schemas/category-content";
2
+ /**
3
+ * How far up the chain to look.
4
+ *
5
+ * `parentIds` holds the FULL ancestor chain (CLAUDE.md ยง "Category Tree"), and
6
+ * the forest is four tiers, so eight is slack rather than a limit. It exists so
7
+ * a malformed chain cannot turn one lookup into an unbounded read.
8
+ */
9
+ export declare const MAX_GUIDANCE_ANCESTORS = 8;
10
+ /** The fields of a category document this resolution reads. */
11
+ export interface GuidanceLeafDoc {
12
+ id?: string;
13
+ name?: string;
14
+ parentIds?: string[];
15
+ productDefaults?: {
16
+ priceGuidance?: CategoryPriceGuidance;
17
+ };
18
+ }
19
+ /**
20
+ * A resolved band together with WHERE it came from.
21
+ *
22
+ * ๐Ÿ›‘ The provenance is not decoration, and a caller that drops it will
23
+ * misattribute. `MARKET_BANDS` is keyed on era and part leaves, never on the
24
+ * ~216 named-model leaves, so a model lookup almost always resolves an
25
+ * ancestor's band โ€” "typical for Burst DB" is a true statement about the data
26
+ * and "typical for Rage Longinus" is not. A renderer needs to be able to say
27
+ * which one it is showing.
28
+ */
29
+ export interface ResolvedGuidance {
30
+ guidance: CategoryPriceGuidance;
31
+ /** The node the band was read off โ€” the leaf itself, or an ancestor. */
32
+ fromCategoryId: string;
33
+ fromName?: string;
34
+ /** `false` when the leaf carried its own band. */
35
+ inherited: boolean;
36
+ }
37
+ /**
38
+ * Nearest-first ancestor walk for one leaf.
39
+ *
40
+ * The leaf's own band wins; only a leaf with none falls back to the nearest
41
+ * ancestor that has one.
42
+ *
43
+ * ๐Ÿ›‘ ONE bounded `findByIds`, never a sequential walk. A seller form hits this
44
+ * on every category change, and paying up to eight round trips for a hint is
45
+ * how a hint becomes the slowest thing on the page.
46
+ *
47
+ * ๐Ÿ›‘ `parentIds` is stored ROOT-FIRST, so it is reversed to walk nearest-first.
48
+ * Walking it as stored would hand a model leaf the ROOT's band โ€” and since the
49
+ * root carries a tax default but no market band, that would usually resolve to
50
+ * nothing and read as "no data" for a leaf whose era has a measured band.
51
+ */
52
+ export declare function resolveInheritedGuidance(leaf: GuidanceLeafDoc): Promise<CategoryPriceGuidance | undefined>;
53
+ /**
54
+ * The same resolution for MANY leaves, in two batched reads total.
55
+ *
56
+ * Read 1 fetches the leaves; read 2 fetches the UNION of every ancestor id
57
+ * none of them resolved locally. A per-leaf `resolveInheritedGuidance` would be
58
+ * N+1 reads against Rule #6's ~3-round-trip budget, and the tool resolves up to
59
+ * `PRICE_INDEX_SEARCH_LIMIT` leaves at once.
60
+ *
61
+ * Leaves that resolve to nothing are ABSENT from the returned map rather than
62
+ * present with `undefined` โ€” the caller distinguishes "no band applies here"
63
+ * from "this id does not exist", and both are legitimate answers it must be
64
+ * able to tell apart.
65
+ */
66
+ export declare function resolveInheritedGuidanceMany(ids: readonly string[]): Promise<Map<string, ResolvedGuidance>>;
@@ -0,0 +1,138 @@
1
+ /*
2
+ * WHY: Two surfaces now need "the price guidance that applies to this leaf":
3
+ * the seller form's hint (`/api/categories/[id]/price-guidance`) and the
4
+ * public realised-price tool's gated market half
5
+ * (`/api/tools/price-index/market`). The ancestor walk was written
6
+ * privately inside the first of those, which is exactly the shape Root
7
+ * Cause #75 describes โ€” a rule reachable by copy-paste gets copied, and
8
+ * the copy never learns about the branch added later.
9
+ * WHAT: The nearest-first inherited `priceGuidance` resolution, for one leaf
10
+ * and for many, in one place.
11
+ *
12
+ * EXPORTS:
13
+ * MAX_GUIDANCE_ANCESTORS โ€” the walk bound
14
+ * ResolvedGuidance โ€” a band PLUS which node it came from
15
+ * resolveInheritedGuidance โ€” one leaf document -> its applicable guidance
16
+ * resolveInheritedGuidanceMany โ€” N leaf ids -> a Map, two batched reads
17
+ *
18
+ * ๐Ÿ›‘ Why a leaf's OWN band wins. `MARKET_BANDS` is keyed on era / part /
19
+ * launcher / stadium leaves (`category-burst-db`, `category-metal-energy-rings`),
20
+ * never on the ~216 named-model leaves โ€” the crawl measured a generation,
21
+ * not a bey. So a model leaf has no band of its own and inherits its era's,
22
+ * which is the whole reason this walk exists rather than a direct read.
23
+ *
24
+ * @tag domain:categories
25
+ * @tag layer:server
26
+ * @tag pattern:resolver
27
+ * @tag access:server-only
28
+ * @tag consumers:categories/api/[id]/price-guidance,price-index/market
29
+ * @tag sideEffects:none
30
+ */
31
+ import { categoriesRepository } from "../../../../features/categories/repository/categories.repository.js";
32
+ /**
33
+ * How far up the chain to look.
34
+ *
35
+ * `parentIds` holds the FULL ancestor chain (CLAUDE.md ยง "Category Tree"), and
36
+ * the forest is four tiers, so eight is slack rather than a limit. It exists so
37
+ * a malformed chain cannot turn one lookup into an unbounded read.
38
+ */
39
+ export const MAX_GUIDANCE_ANCESTORS = 8;
40
+ /**
41
+ * Nearest-first ancestor walk for one leaf.
42
+ *
43
+ * The leaf's own band wins; only a leaf with none falls back to the nearest
44
+ * ancestor that has one.
45
+ *
46
+ * ๐Ÿ›‘ ONE bounded `findByIds`, never a sequential walk. A seller form hits this
47
+ * on every category change, and paying up to eight round trips for a hint is
48
+ * how a hint becomes the slowest thing on the page.
49
+ *
50
+ * ๐Ÿ›‘ `parentIds` is stored ROOT-FIRST, so it is reversed to walk nearest-first.
51
+ * Walking it as stored would hand a model leaf the ROOT's band โ€” and since the
52
+ * root carries a tax default but no market band, that would usually resolve to
53
+ * nothing and read as "no data" for a leaf whose era has a measured band.
54
+ */
55
+ export async function resolveInheritedGuidance(leaf) {
56
+ if (leaf.productDefaults?.priceGuidance)
57
+ return leaf.productDefaults.priceGuidance;
58
+ const parentIds = (leaf.parentIds ?? []).slice(0, MAX_GUIDANCE_ANCESTORS);
59
+ if (parentIds.length === 0)
60
+ return undefined;
61
+ const nearestFirst = [...parentIds].reverse();
62
+ const ancestors = await categoriesRepository.findByIds(nearestFirst);
63
+ const byId = new Map(ancestors.map((a) => [a.id, a]));
64
+ for (const pid of nearestFirst) {
65
+ const guidance = byId.get(pid)?.productDefaults?.priceGuidance;
66
+ if (guidance)
67
+ return guidance;
68
+ }
69
+ return undefined;
70
+ }
71
+ /**
72
+ * The same resolution for MANY leaves, in two batched reads total.
73
+ *
74
+ * Read 1 fetches the leaves; read 2 fetches the UNION of every ancestor id
75
+ * none of them resolved locally. A per-leaf `resolveInheritedGuidance` would be
76
+ * N+1 reads against Rule #6's ~3-round-trip budget, and the tool resolves up to
77
+ * `PRICE_INDEX_SEARCH_LIMIT` leaves at once.
78
+ *
79
+ * Leaves that resolve to nothing are ABSENT from the returned map rather than
80
+ * present with `undefined` โ€” the caller distinguishes "no band applies here"
81
+ * from "this id does not exist", and both are legitimate answers it must be
82
+ * able to tell apart.
83
+ */
84
+ export async function resolveInheritedGuidanceMany(ids) {
85
+ const out = new Map();
86
+ const unique = [...new Set(ids.filter((id) => typeof id === "string" && id.length > 0))];
87
+ if (unique.length === 0)
88
+ return out;
89
+ const leaves = (await categoriesRepository.getManyByIds(unique));
90
+ /*
91
+ * Everything still unanswered after the leaves' own bands, paired with the
92
+ * nearest-first chain it needs โ€” collected before the second read so that
93
+ * read is one call for every leaf at once.
94
+ */
95
+ const pending = [];
96
+ const wanted = new Set();
97
+ for (const leaf of leaves) {
98
+ const id = leaf.id;
99
+ if (!id)
100
+ continue;
101
+ const own = leaf.productDefaults?.priceGuidance;
102
+ if (own) {
103
+ out.set(id, {
104
+ guidance: own,
105
+ fromCategoryId: id,
106
+ ...(leaf.name ? { fromName: leaf.name } : {}),
107
+ inherited: false,
108
+ });
109
+ continue;
110
+ }
111
+ const chain = [...(leaf.parentIds ?? []).slice(0, MAX_GUIDANCE_ANCESTORS)].reverse();
112
+ if (chain.length === 0)
113
+ continue;
114
+ pending.push({ id, chain });
115
+ for (const pid of chain)
116
+ wanted.add(pid);
117
+ }
118
+ if (pending.length === 0)
119
+ return out;
120
+ const ancestors = (await categoriesRepository.findByIds([...wanted]));
121
+ const byId = new Map(ancestors.filter((a) => a.id).map((a) => [a.id, a]));
122
+ for (const { id, chain } of pending) {
123
+ for (const pid of chain) {
124
+ const ancestor = byId.get(pid);
125
+ const guidance = ancestor?.productDefaults?.priceGuidance;
126
+ if (guidance) {
127
+ out.set(id, {
128
+ guidance,
129
+ fromCategoryId: pid,
130
+ ...(ancestor?.name ? { fromName: ancestor.name } : {}),
131
+ inherited: true,
132
+ });
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ return out;
138
+ }
@@ -0,0 +1,57 @@
1
+ import { PRICE_GUIDANCE_TAX_BASIS } from "../../../shared/features/categories/price-guidance";
2
+ export interface PriceIndexMarketRow {
3
+ categoryId: string;
4
+ /**
5
+ * The typical ASK, not a transaction.
6
+ *
7
+ * ๐Ÿ›‘ `median` is the only required figure, and the three optional ones are
8
+ * optional because the crawl genuinely did not measure them everywhere โ€” Hot
9
+ * Wheels has a median over n=7,418 and a RANGE, never quartiles, and the
10
+ * Beyblade-top table carries no per-leaf `n` at all. An absent field must
11
+ * render as absent; filling one in to make the shape look complete is the
12
+ * single thing `price-bands.md` warns about most.
13
+ */
14
+ median: number;
15
+ p25?: number;
16
+ p75?: number;
17
+ sampleSize?: number;
18
+ asOf?: string;
19
+ /**
20
+ * The share of competitor listings at this level that are SOLD OUT (0.85,
21
+ * measured across the five largest catalogues).
22
+ *
23
+ * Carried because it is what stops the band being read as a clearing price:
24
+ * most asks at this level did not convert. The renderer must say so.
25
+ */
26
+ soldOutShare?: number;
27
+ /** Which crawl, and any caveat โ€” pooling, a disagreeing source, a small n. */
28
+ source?: string;
29
+ /**
30
+ * `true` when the band came from an ANCESTOR rather than this leaf.
31
+ *
32
+ * ๐Ÿ›‘ Load-bearing, not decoration. `MARKET_BANDS` is keyed on era and part
33
+ * leaves, never on the ~216 named-model leaves, so a model lookup resolves
34
+ * its generation's band โ€” "typical for Burst DB" is a true statement and
35
+ * "typical for Rage Longinus" is not. The renderer names the source leaf so
36
+ * the reader can tell which they are being told.
37
+ */
38
+ inherited: boolean;
39
+ /** The ancestor the band actually came from, when `inherited`. */
40
+ viaCategoryId?: string;
41
+ viaName?: string;
42
+ /** The basis every figure above is on. Always inclusive; stated, not assumed. */
43
+ taxBasis: typeof PRICE_GUIDANCE_TAX_BASIS;
44
+ }
45
+ /**
46
+ * Market bands for the leaves the client already resolved.
47
+ *
48
+ * Bounded by `PRICE_INDEX_SEARCH_LIMIT` โ€” the same cap the public half uses, so
49
+ * a crafted request cannot demand an unbounded read, and the two halves of one
50
+ * lookup can never disagree about how many rows a lookup covers.
51
+ *
52
+ * A leaf with no band at any level is ABSENT from the result rather than
53
+ * present with a null band. "We have no market evidence for this" and "this id
54
+ * does not exist" are different answers and the caller renders them
55
+ * differently; collapsing them is how a missing band becomes a confident โ‚น0.
56
+ */
57
+ export declare function getPriceIndexMarketForIds(rawIds: readonly string[]): Promise<PriceIndexMarketRow[]>;
@@ -0,0 +1,120 @@
1
+ /*
2
+ * WHY: The realised-price tool rendered "No completed sales recorded here yet"
3
+ * for every single lookup, because the only data it read was OUR completed
4
+ * sales and we have none for most leaves. Meanwhile the competitor crawl
5
+ * measured ~60 real bands (`MARKET_BANDS` in
6
+ * `seed/_helpers/category-content.ts`, evidence in
7
+ * `docs/research/competitor-crawl-2026-10/price-bands.md`) which were
8
+ * seeded onto the taxonomy and then read by exactly one surface โ€” the
9
+ * seller form's hint. The tool had a starting point the whole time and
10
+ * never looked at it.
11
+ * WHAT: The MARKET half of the tool, resolved by the shared nearest-first
12
+ * ancestor walk. Signed-in only โ€” see the gate note below.
13
+ *
14
+ * EXPORTS:
15
+ * PriceIndexMarketRow โ€” one leaf's market band, API shape
16
+ * getPriceIndexMarketForIds โ€” N category ids -> their market bands
17
+ *
18
+ * โ”€โ”€ ๐Ÿ›‘ WHY THIS IS A SEPARATE, AUTHENTICATED ENDPOINT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
19
+ *
20
+ * The sold half (`searchPriceIndex`, `getPriceIndexBandsForIds`) is PUBLIC and
21
+ * edge-cached, deliberately: a day-stale aggregate of our own completed sales
22
+ * is the strongest content we can publish and is not what the guest price gate
23
+ * protects. The market half is the opposite call, and the reasoning is already
24
+ * written down at `categories/api/[id]/price-guidance/route.ts`: publishing a
25
+ * competitor-derived band on an unauthenticated, publicly-cacheable response
26
+ * "would be exactly the 'a neighbouring surface publishes the range' leak that
27
+ * gate exists to close".
28
+ *
29
+ * So the two halves cannot share a response. Merging `market` into
30
+ * `getPriceIndexBandsForIds` would either poison that public cache with
31
+ * viewer-dependent content โ€” one signed-in lookup populating the entry every
32
+ * guest then reads, the exact failure `cachedPublicRead`'s rules exist to
33
+ * prevent โ€” or force the public half to go private and lose its cache. A
34
+ * sibling endpoint keeps both postures correct, and it is the same split the
35
+ * codebase already uses for `description-template` (public) against
36
+ * `price-guidance` (gated).
37
+ *
38
+ * โ”€โ”€ ๐Ÿ›‘ WHAT IS NOT DONE HERE: the seller tax-basis conversion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
39
+ *
40
+ * The seller route restates the band on the caller's own `pricesIncludeGst`
41
+ * basis. This one does not, for two reasons. The figures are tax-inclusive
42
+ * (`PRICE_GUIDANCE_TAX_BASIS`) and so is every price a buyer sees anywhere on
43
+ * the site, so for the tool's audience no conversion is the correct answer
44
+ * rather than a missing feature. And the conversion needs a store read per
45
+ * lookup, which on a tool that resolves up to eight leaves at a time would be
46
+ * a per-request cost paid to restate a number most callers want as stored. The
47
+ * renderer labels the basis instead.
48
+ *
49
+ * @tag domain:price-index
50
+ * @tag layer:server
51
+ * @tag pattern:projection
52
+ * @tag access:server-only
53
+ * @tag consumers:api/tools/price-index/market
54
+ * @tag sideEffects:none
55
+ */
56
+ import { resolveInheritedGuidanceMany } from "../categories/price-guidance-resolve.js";
57
+ import { PRICE_GUIDANCE_TAX_BASIS } from "../../../shared/features/categories/price-guidance.js";
58
+ import { PRICE_INDEX_SEARCH_LIMIT } from "./search.js";
59
+ /**
60
+ * Market bands for the leaves the client already resolved.
61
+ *
62
+ * Bounded by `PRICE_INDEX_SEARCH_LIMIT` โ€” the same cap the public half uses, so
63
+ * a crafted request cannot demand an unbounded read, and the two halves of one
64
+ * lookup can never disagree about how many rows a lookup covers.
65
+ *
66
+ * A leaf with no band at any level is ABSENT from the result rather than
67
+ * present with a null band. "We have no market evidence for this" and "this id
68
+ * does not exist" are different answers and the caller renders them
69
+ * differently; collapsing them is how a missing band becomes a confident โ‚น0.
70
+ */
71
+ export async function getPriceIndexMarketForIds(rawIds) {
72
+ const ids = [...new Set(rawIds.filter((id) => typeof id === "string" && id.length > 0))].slice(0, PRICE_INDEX_SEARCH_LIMIT);
73
+ if (ids.length === 0)
74
+ return [];
75
+ const guidanceById = await resolveInheritedGuidanceMany(ids);
76
+ const rows = [];
77
+ for (const id of ids) {
78
+ const resolved = guidanceById.get(id);
79
+ const market = resolved?.guidance.market;
80
+ // No band anywhere up the chain. Omitted, never zeroed.
81
+ if (!market || !resolved)
82
+ continue;
83
+ rows.push({
84
+ categoryId: id,
85
+ median: market.median,
86
+ ...(market.p25 === undefined ? {} : { p25: market.p25 }),
87
+ ...(market.p75 === undefined ? {} : { p75: market.p75 }),
88
+ ...(market.sampleSize === undefined ? {} : { sampleSize: market.sampleSize }),
89
+ ...(toIso(market.asOf) ? { asOf: toIso(market.asOf) } : {}),
90
+ ...(market.soldOutShare === undefined ? {} : { soldOutShare: market.soldOutShare }),
91
+ ...(market.source ? { source: market.source } : {}),
92
+ inherited: resolved.inherited,
93
+ /*
94
+ * Only when inherited. Naming the leaf as its own source would be noise
95
+ * on a card that is already headed by that leaf's name.
96
+ */
97
+ ...(resolved.inherited
98
+ ? {
99
+ viaCategoryId: resolved.fromCategoryId,
100
+ ...(resolved.fromName ? { viaName: resolved.fromName } : {}),
101
+ }
102
+ : {}),
103
+ taxBasis: PRICE_GUIDANCE_TAX_BASIS,
104
+ });
105
+ }
106
+ return rows;
107
+ }
108
+ /** Firestore `Timestamp` | `Date` | ISO string -> ISO string. */
109
+ function toIso(v) {
110
+ if (!v)
111
+ return undefined;
112
+ if (v instanceof Date)
113
+ return v.toISOString();
114
+ if (typeof v === "string")
115
+ return v;
116
+ const t = v;
117
+ if (typeof t.toDate === "function")
118
+ return t.toDate().toISOString();
119
+ return undefined;
120
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ViewerLike } from "../tester/visibility";
2
2
  import type { JsonValue } from "../../../../schemas/types";
3
+ import type { UnavailableClause } from "../../../shared/listing-types/availability";
3
4
  import { type AvailabilityFilter } from "../../../../constants/field-names";
4
5
  /** Vercel Hobby Fluid Compute ceiling (CLAUDE.md Rule #6) โ€” never fetch more at once. */
5
6
  export declare const PUBLIC_PRODUCT_MAX_PAGE_SIZE = 50;
@@ -250,6 +251,17 @@ export declare function parsePublicProductParams(params: URLSearchParams | Recor
250
251
  */
251
252
  availability?: AvailabilityFilter;
252
253
  }): PublicProductListInput;
254
+ /** Render one `UnavailableClause` as a Sieve clause against a fixed `now`. */
255
+ /**
256
+ * One unavailable clause โ†’ a Sieve fragment, with `"NOW"` bound to this
257
+ * query's timestamp.
258
+ *
259
+ * Exported so `listing-tab-counts` builds its clause queries with THIS
260
+ * function rather than a second copy. A rule reachable by copy-paste is one
261
+ * that gets copied wrong (Root Cause #75), and the two would only diverge the
262
+ * day someone adds a third `op`.
263
+ */
264
+ export declare function clauseToSieve(clause: UnavailableClause, now: Date): string;
253
265
  export declare function listPublicProducts(input: PublicProductListInput, opts?: PublicProductListOptions): Promise<PublicProductListResult | null>;
254
266
  /**
255
267
  * One store tab's worth of listings.
@@ -440,7 +440,16 @@ async function defaultExecutor(query) {
440
440
  };
441
441
  }
442
442
  /** Render one `UnavailableClause` as a Sieve clause against a fixed `now`. */
443
- function clauseToSieve(clause, now) {
443
+ /**
444
+ * One unavailable clause โ†’ a Sieve fragment, with `"NOW"` bound to this
445
+ * query's timestamp.
446
+ *
447
+ * Exported so `listing-tab-counts` builds its clause queries with THIS
448
+ * function rather than a second copy. A rule reachable by copy-paste is one
449
+ * that gets copied wrong (Root Cause #75), and the two would only diverge the
450
+ * day someone adds a third `op`.
451
+ */
452
+ export function clauseToSieve(clause, now) {
444
453
  const value = clause.value === "NOW" ? now.toISOString() : clause.value;
445
454
  return clause.op === "lt"
446
455
  ? sieveFilter(clause.field, SIEVE_OP.LT, value)
@@ -31,6 +31,28 @@ export interface ListingTabScope {
31
31
  * `undefined` as "keep the tab visible" (Root Cause #59).
32
32
  */
33
33
  export type ListingTabCounts = Record<string, number | undefined>;
34
+ /**
35
+ * The two numbers a tab bar needs, and they are NOT the same number.
36
+ *
37
+ * ๐Ÿ›‘ A badge showing `total` while the panel below it defaults to the
38
+ * AVAILABLE scope is a tab that advertises rows it will not render. That was
39
+ * live: a category holding one `prizeRevealStatus: "closed"` draw showed
40
+ * "Prize Draws 1" above "No prize draws found." โ€” accurate counting of the
41
+ * wrong question, Root Cause #72's family.
42
+ *
43
+ * `available` โ€” what the default scope will render. The BADGE.
44
+ * `total` โ€” every published row in scope, across all availability
45
+ * scopes. The hide-at-zero decision, and the "N ended โ€”
46
+ * view All" hint under an empty panel.
47
+ *
48
+ * ๐Ÿ›‘ Hiding must stay keyed on `total`. A tab whose rows have all ended still
49
+ * has a browsable archive behind the Ended/All scopes; hiding it at
50
+ * `available === 0` would make that archive unreachable.
51
+ */
52
+ export interface ListingTabCountResult {
53
+ available: ListingTabCounts;
54
+ total: ListingTabCounts;
55
+ }
34
56
  /**
35
57
  * Count every tab in `tabs` within `scope`, in one parallel batch.
36
58
  *
@@ -41,4 +63,6 @@ export type ListingTabCounts = Record<string, number | undefined>;
41
63
  * `stores` is not counted here: only the category page has that tab and it
42
64
  * already resolves its store list eagerly to render the tab body.
43
65
  */
44
- export declare function listingTabCounts(tabs: readonly ListingTab[], scope: ListingTabScope): Promise<ListingTabCounts>;
66
+ export declare function listingTabCounts(tabs: readonly ListingTab[], scope: ListingTabScope, opts?: {
67
+ now?: Date;
68
+ }): Promise<ListingTabCountResult>;