@mohasinac/appkit 4.45.0 β†’ 4.46.0

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 (152) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
  3. package/dist/_internal/server/features/categories/live-counts.js +72 -0
  4. package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
  5. package/dist/_internal/server/features/orders/adapters.js +29 -0
  6. package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
  7. package/dist/_internal/server/features/parts/assembly.js +246 -0
  8. package/dist/_internal/server/features/parts/data.d.ts +65 -0
  9. package/dist/_internal/server/features/parts/data.js +170 -0
  10. package/dist/_internal/server/features/parts/og.d.ts +34 -0
  11. package/dist/_internal/server/features/parts/og.js +66 -0
  12. package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
  13. package/dist/_internal/server/features/search/search-everything.js +120 -0
  14. package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
  15. package/dist/_internal/server/features/search/search-sources.js +138 -0
  16. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
  17. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
  18. package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
  19. package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
  20. package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
  21. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
  22. package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
  23. package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
  24. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
  26. package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
  27. package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
  28. package/dist/_internal/shared/actions/action-registry.js +36 -0
  29. package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
  30. package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
  31. package/dist/_internal/shared/features/shipping/protections.js +127 -0
  32. package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
  33. package/dist/_internal/shared/search/part-aliases.js +104 -0
  34. package/dist/constants/api-endpoints.d.ts +18 -12
  35. package/dist/constants/api-endpoints.js +6 -8
  36. package/dist/constants/field-names.d.ts +0 -2
  37. package/dist/constants/field-names.js +0 -2
  38. package/dist/constants/index.d.ts +1 -1
  39. package/dist/constants/index.js +1 -1
  40. package/dist/core/index.d.ts +0 -3
  41. package/dist/core/index.js +0 -2
  42. package/dist/errors/messages.d.ts +0 -7
  43. package/dist/errors/messages.js +0 -8
  44. package/dist/features/admin/components/AdminSectionsView.js +83 -32
  45. package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
  46. package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
  47. package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
  48. package/dist/features/admin/schemas/firestore.d.ts +15 -0
  49. package/dist/features/admin/types/index.d.ts +0 -1
  50. package/dist/features/auth/permissions/constants.d.ts +1 -1
  51. package/dist/features/auth/permissions/constants.js +0 -2
  52. package/dist/features/categories/api/[id]/description-template/route.js +40 -1
  53. package/dist/features/categories/components/BrandDetailPageView.js +14 -3
  54. package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
  55. package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
  56. package/dist/features/categories/repository/categories.repository.d.ts +65 -0
  57. package/dist/features/categories/repository/categories.repository.js +168 -22
  58. package/dist/features/categories/repository/category-counts.d.ts +143 -0
  59. package/dist/features/categories/repository/category-counts.js +224 -0
  60. package/dist/features/categories/schemas/category-counts.d.ts +155 -0
  61. package/dist/features/categories/schemas/category-counts.js +116 -0
  62. package/dist/features/categories/schemas/firestore.d.ts +0 -2
  63. package/dist/features/categories/schemas/firestore.js +0 -1
  64. package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
  65. package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
  66. package/dist/features/corporate/schemas/index.d.ts +4 -4
  67. package/dist/features/orders/schemas/firestore.d.ts +13 -0
  68. package/dist/features/orders/types/index.d.ts +30 -0
  69. package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
  70. package/dist/features/parts/components/AssemblyDrawer.js +250 -0
  71. package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
  72. package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
  73. package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
  74. package/dist/features/parts/components/PartDetailPageView.js +20 -0
  75. package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
  76. package/dist/features/parts/components/PartsIndexPageView.js +17 -0
  77. package/dist/features/parts/schemas/firestore.d.ts +257 -0
  78. package/dist/features/parts/schemas/firestore.js +181 -0
  79. package/dist/features/parts/utils/colourway-match.d.ts +62 -0
  80. package/dist/features/parts/utils/colourway-match.js +98 -0
  81. package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
  82. package/dist/features/parts/utils/part-breakdown.js +77 -0
  83. package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
  84. package/dist/features/products/components/ProductDetailPageView.js +22 -3
  85. package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
  86. package/dist/features/products/components/ProductTabsShell.js +4 -1
  87. package/dist/features/products/repository/category-assignment.d.ts +132 -0
  88. package/dist/features/products/repository/category-assignment.js +304 -0
  89. package/dist/features/products/repository/products.repository.d.ts +58 -1
  90. package/dist/features/products/repository/products.repository.js +255 -28
  91. package/dist/features/products/schemas/firestore.d.ts +147 -6
  92. package/dist/features/products/schemas/firestore.js +49 -0
  93. package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
  94. package/dist/features/search/components/SearchResultsPageView.js +21 -0
  95. package/dist/features/seller/components/SellerProductsView.js +129 -3
  96. package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
  97. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
  98. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
  99. package/dist/features/tester/seed-data/authored/index.js +2 -0
  100. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
  101. package/dist/index.d.ts +11 -16
  102. package/dist/index.js +26 -18
  103. package/dist/next/routing/route-map.d.ts +6 -2
  104. package/dist/next/routing/route-map.js +14 -1
  105. package/dist/providers/db-firebase/base.repository.d.ts +31 -0
  106. package/dist/providers/db-firebase/base.repository.js +31 -0
  107. package/dist/repositories/index.d.ts +1 -2
  108. package/dist/repositories/index.js +0 -1
  109. package/dist/seed/_helpers/category-content.d.ts +29 -0
  110. package/dist/seed/_helpers/category-content.js +98 -1
  111. package/dist/seed/_helpers/category-forest.js +104 -24
  112. package/dist/seed/_helpers/category-parts.d.ts +9 -0
  113. package/dist/seed/_helpers/category-parts.js +4594 -0
  114. package/dist/seed/carousel-slides-seed-data.js +81 -8
  115. package/dist/seed/categories-seed-data.js +44 -6
  116. package/dist/seed/homepage-sections-seed-data.js +170 -9
  117. package/dist/seed/index.d.ts +2 -0
  118. package/dist/seed/index.js +13 -0
  119. package/dist/seed/model-composition-seed-data.d.ts +15 -0
  120. package/dist/seed/model-composition-seed-data.js +8918 -0
  121. package/dist/seed/part-glossary-seed-data.d.ts +15 -0
  122. package/dist/seed/part-glossary-seed-data.js +10729 -0
  123. package/dist/seed/products-standard-seed-data.js +52 -0
  124. package/dist/seed/seed-purpose.d.ts +103 -4
  125. package/dist/seed/seed-purpose.js +162 -11
  126. package/dist/seo/json-ld.d.ts +10 -1
  127. package/dist/seo/json-ld.js +9 -1
  128. package/dist/server.d.ts +7 -0
  129. package/dist/server.js +11 -0
  130. package/dist/styles.css +1 -1
  131. package/dist/tailwind-utilities.css +1 -1
  132. package/dist/utils/id-generators.d.ts +30 -0
  133. package/dist/utils/id-generators.js +31 -1
  134. package/dist/utils/search-txt-builders.js +22 -0
  135. package/dist/utils/search-txt.d.ts +22 -0
  136. package/dist/utils/search-txt.js +17 -0
  137. package/dist/values/success-messages.d.ts +0 -4
  138. package/dist/values/success-messages.js +0 -5
  139. package/package.json +1 -1
  140. package/scripts/seed-cli.mjs +292 -16
  141. package/dist/core/copilot-log.repository.d.ts +0 -44
  142. package/dist/core/copilot-log.repository.js +0 -61
  143. package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
  144. package/dist/features/copilot/components/AdminCopilotView.js +0 -51
  145. package/dist/features/copilot/components/index.d.ts +0 -2
  146. package/dist/features/copilot/components/index.js +0 -1
  147. package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
  148. package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
  149. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
  150. package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
  151. package/dist/features/copilot/index.d.ts +0 -3
  152. package/dist/features/copilot/index.js +0 -3
@@ -0,0 +1,155 @@
1
+ /**
2
+ * G3b — LISTING COUNTS, maintained incrementally leaf→root.
3
+ *
4
+ * πŸ›‘ WHY THIS IS A SIBLING COLLECTION AND NOT A FIELD ON `categories`.
5
+ *
6
+ * `handleCategoryWrite` calls `notifyRevalidate({ collection: "categories" })`
7
+ * as its FIRST statement, unconditionally, before any branch β€” so ANY write to
8
+ * a category document drops that category page's ISR entry. Under leaf→root,
9
+ * publishing one listing touches up to five ancestors, so it would drop five
10
+ * ISR entries per publish. This project was paused with **ISR writes at 3.1M
11
+ * against a 200K cap (15.5Γ—)**, which settles the question.
12
+ *
13
+ * It also removes the Root Cause #92 hazard outright: a trigger that writes
14
+ * back to the collection it watches produced 1,017,548 invocations in 24 hours
15
+ * and a billing denial that blocked `firebase deploy`.
16
+ *
17
+ * πŸ›‘ AND `metrics.*` ON `categories` STAYS β€” a constraint the plan did not
18
+ * record. Two Sieve SORTS order by `metrics.productCount` through Firestore
19
+ * (`CategoriesIndexListing`'s "Most Products" and
20
+ * `AdminSublistingCategoriesView`'s "Most listings"), and a sibling collection
21
+ * cannot serve an `orderBy` on the categories query. So:
22
+ *
23
+ * - `metrics.*` β€” written ONLY by the nightly `countersReconcile`, as
24
+ * today. The two sorts keep working at exactly today's
25
+ * accuracy, so there is no regression, and the ISR cost
26
+ * is unchanged from the status quo (one drop per
27
+ * category per night, already happening).
28
+ * - `categoryCounts` β€” the LIVE value, written per product mutation. Fires no
29
+ * revalidation, because nothing triggers on it.
30
+ *
31
+ * Readers prefer `categoryCounts` and fall back to `metrics`. That ordering is
32
+ * what makes the feature additive: a category with no counts document yet
33
+ * renders exactly as it does today rather than as zero.
34
+ *
35
+ * πŸ›‘ HOW FRESH IT ACTUALLY IS, stated precisely rather than as "live".
36
+ *
37
+ * The count is of AVAILABLE rows β€” `isListingRowAvailable`, the same predicate
38
+ * `countersReconcile` and the listing query use. That predicate is partly
39
+ * TIME-dependent: `auction` is the one type with an inequality clause
40
+ * (`auctionEndDate`), so an auction becomes unavailable by the clock with no
41
+ * product write, and no incremental counter can observe that.
42
+ *
43
+ * In practice the window is minutes, not a day: `auctionSettlement` writes
44
+ * `isSold: true` when it settles, and that write re-evaluates the counter.
45
+ * Every other type's unavailability is write-driven (stock, `isSold`,
46
+ * `digitalCode.codesAvailable`, the prize-draw reveal status).
47
+ *
48
+ * So: instant for every write-driven change, and bounded by the settlement
49
+ * cadence for a just-ended auction. That is strictly better than today, where
50
+ * every change is up to 24h stale. The nightly reconciler stays as the
51
+ * authority and the audit β€” its `drifted` counter becomes a real signal that
52
+ * an increment was missed.
53
+ */
54
+ import type { ListingType } from "../../products/types";
55
+ export declare const CATEGORY_COUNTS_COLLECTION = "categoryCounts";
56
+ /**
57
+ * Per-listing-type counters.
58
+ *
59
+ * πŸ›‘ `Partial` on purpose: an absent key means "zero observed". Writing nine
60
+ * explicit zeros on every row is noise, and it would also make an untouched
61
+ * category indistinguishable from one measured as empty.
62
+ *
63
+ * A MAP rather than scalar fields, because own+total across nine listing types
64
+ * is 18 fields that grow with the union β€” and the union has grown twice
65
+ * already. A map needs no schema change for a tenth type.
66
+ */
67
+ export type ListingTypeCounts = Partial<Record<ListingType, number>>;
68
+ /**
69
+ * πŸ›‘ THE INVARIANT, and the one way to get this wrong:
70
+ *
71
+ * total = own + (every descendant's own)
72
+ *
73
+ * **`total` ALREADY INCLUDES `own`. Never add them.** `own + total` double-counts
74
+ * every item filed directly under the row, and on a leaf β€” where `total === own`
75
+ * β€” it doubles the whole number.
76
+ *
77
+ * So there are three questions and only two stored fields, because the third is
78
+ * a subtraction and a stored mirror of it would drift the first time one half
79
+ * was written without the other (Root Cause #42):
80
+ *
81
+ * | Question | Read |
82
+ * |---|---|
83
+ * | "how many are filed right here?" | `own` |
84
+ * | "how many are under my subcategories?" | **`descendantCountsFor()`** β€” `total βˆ’ own`, derived |
85
+ * | "how many will clicking this show me?" | `total` |
86
+ *
87
+ * The third is the one a buyer means, which is why `liveTotalFor` returns it
88
+ * and why the category chips and grid read it. The second exists because the
89
+ * distinction is genuinely useful to an admin β€” `CategoryTableColumns` already
90
+ * renders "own (total)" explicitly β€” and because deriving it is the only way to
91
+ * show it without a third counter to keep in step.
92
+ */
93
+ export interface CategoryCountsDocument {
94
+ /** === the category's document id. */
95
+ id: string;
96
+ /** Listings filed DIRECTLY under this category, by type. */
97
+ own: ListingTypeCounts;
98
+ /**
99
+ * Own + every descendant's, by type. Equals `own` on a leaf.
100
+ *
101
+ * πŸ›‘ INCLUDES `own` β€” see the invariant above. Never `own + total`.
102
+ */
103
+ total: ListingTypeCounts;
104
+ /**
105
+ * Multi-item LOTS, counted separately from singles.
106
+ *
107
+ * πŸ›‘ This separation is what makes a count mean one thing, and it is why the
108
+ * filing rule in `deriveTaxonomy` only has to refuse a lot at a LEAF rather
109
+ * than also refusing a single at a non-leaf. A lot and a single are never
110
+ * summed into one number, so "12 listings here" is unambiguous wherever they
111
+ * are filed. (Measured: 58 of 70 live products file at a non-leaf, so the
112
+ * symmetric rule the plan first specified would have rejected most of the
113
+ * catalogue β€” see the `isLot` docstring on `ProductDocument`.)
114
+ *
115
+ * Scalars rather than a per-type map: a lot is not type-specific in any way
116
+ * a buyer or a seller filters on, and there is no reader for "lots of
117
+ * auctions".
118
+ */
119
+ lots: {
120
+ own: number;
121
+ total: number;
122
+ };
123
+ updatedAt: Date;
124
+ }
125
+ /** Sum a counts map β€” what a category page renders as "N listings". */
126
+ export declare function sumCounts(counts: ListingTypeCounts | undefined): number;
127
+ /**
128
+ * The display count for a category, preferring the live document.
129
+ *
130
+ * πŸ›‘ `undefined` means "no counts document yet", NOT zero β€” so the caller falls
131
+ * back to `metrics`. Returning 0 here would render a confident zero on every
132
+ * category until the first mutation, which is Root Cause #72's lesson (a count
133
+ * that failed must never be indistinguishable from a count of nothing).
134
+ */
135
+ export declare function liveTotalFor(doc: Pick<CategoryCountsDocument, "total" | "lots"> | null | undefined): number | undefined;
136
+ /** Filed directly here, lots included. The "N here" half of "N here, M below". */
137
+ export declare function ownTotalFor(doc: Pick<CategoryCountsDocument, "own" | "lots"> | null | undefined): number | undefined;
138
+ /**
139
+ * How many sit under this row's DESCENDANTS and not directly on it β€” `total βˆ’ own`.
140
+ *
141
+ * πŸ›‘ DERIVED, never stored, and that is the point: a third stored counter would
142
+ * be a mirror of a subtraction, and a mirror drifts the moment one half is
143
+ * written without the other (Root Cause #42). It is also the function that
144
+ * makes the invariant un-missable β€” anyone reaching for "own plus children"
145
+ * finds this instead of writing `own + total`, which double-counts every item
146
+ * filed directly on the row.
147
+ *
148
+ * Clamped at 0. A negative value is real and means the counters drifted (an
149
+ * increment lost between the leaf write and an ancestor's), and surfacing
150
+ * "βˆ’3 in subcategories" to a visitor is worse than showing none β€” the nightly
151
+ * reconciler's `countsWritten` is where that drift is meant to be seen.
152
+ */
153
+ export declare function descendantTotalFor(doc: Pick<CategoryCountsDocument, "own" | "total" | "lots"> | null | undefined): number | undefined;
154
+ /** The per-type descendant split, same derivation and the same clamp. */
155
+ export declare function descendantCountsFor(doc: Pick<CategoryCountsDocument, "own" | "total"> | null | undefined): ListingTypeCounts | undefined;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * G3b — LISTING COUNTS, maintained incrementally leaf→root.
3
+ *
4
+ * πŸ›‘ WHY THIS IS A SIBLING COLLECTION AND NOT A FIELD ON `categories`.
5
+ *
6
+ * `handleCategoryWrite` calls `notifyRevalidate({ collection: "categories" })`
7
+ * as its FIRST statement, unconditionally, before any branch β€” so ANY write to
8
+ * a category document drops that category page's ISR entry. Under leaf→root,
9
+ * publishing one listing touches up to five ancestors, so it would drop five
10
+ * ISR entries per publish. This project was paused with **ISR writes at 3.1M
11
+ * against a 200K cap (15.5Γ—)**, which settles the question.
12
+ *
13
+ * It also removes the Root Cause #92 hazard outright: a trigger that writes
14
+ * back to the collection it watches produced 1,017,548 invocations in 24 hours
15
+ * and a billing denial that blocked `firebase deploy`.
16
+ *
17
+ * πŸ›‘ AND `metrics.*` ON `categories` STAYS β€” a constraint the plan did not
18
+ * record. Two Sieve SORTS order by `metrics.productCount` through Firestore
19
+ * (`CategoriesIndexListing`'s "Most Products" and
20
+ * `AdminSublistingCategoriesView`'s "Most listings"), and a sibling collection
21
+ * cannot serve an `orderBy` on the categories query. So:
22
+ *
23
+ * - `metrics.*` β€” written ONLY by the nightly `countersReconcile`, as
24
+ * today. The two sorts keep working at exactly today's
25
+ * accuracy, so there is no regression, and the ISR cost
26
+ * is unchanged from the status quo (one drop per
27
+ * category per night, already happening).
28
+ * - `categoryCounts` β€” the LIVE value, written per product mutation. Fires no
29
+ * revalidation, because nothing triggers on it.
30
+ *
31
+ * Readers prefer `categoryCounts` and fall back to `metrics`. That ordering is
32
+ * what makes the feature additive: a category with no counts document yet
33
+ * renders exactly as it does today rather than as zero.
34
+ *
35
+ * πŸ›‘ HOW FRESH IT ACTUALLY IS, stated precisely rather than as "live".
36
+ *
37
+ * The count is of AVAILABLE rows β€” `isListingRowAvailable`, the same predicate
38
+ * `countersReconcile` and the listing query use. That predicate is partly
39
+ * TIME-dependent: `auction` is the one type with an inequality clause
40
+ * (`auctionEndDate`), so an auction becomes unavailable by the clock with no
41
+ * product write, and no incremental counter can observe that.
42
+ *
43
+ * In practice the window is minutes, not a day: `auctionSettlement` writes
44
+ * `isSold: true` when it settles, and that write re-evaluates the counter.
45
+ * Every other type's unavailability is write-driven (stock, `isSold`,
46
+ * `digitalCode.codesAvailable`, the prize-draw reveal status).
47
+ *
48
+ * So: instant for every write-driven change, and bounded by the settlement
49
+ * cadence for a just-ended auction. That is strictly better than today, where
50
+ * every change is up to 24h stale. The nightly reconciler stays as the
51
+ * authority and the audit β€” its `drifted` counter becomes a real signal that
52
+ * an increment was missed.
53
+ */
54
+ export const CATEGORY_COUNTS_COLLECTION = "categoryCounts";
55
+ /** Sum a counts map β€” what a category page renders as "N listings". */
56
+ export function sumCounts(counts) {
57
+ if (!counts)
58
+ return 0;
59
+ let n = 0;
60
+ for (const v of Object.values(counts))
61
+ n += v ?? 0;
62
+ return n;
63
+ }
64
+ /**
65
+ * The display count for a category, preferring the live document.
66
+ *
67
+ * πŸ›‘ `undefined` means "no counts document yet", NOT zero β€” so the caller falls
68
+ * back to `metrics`. Returning 0 here would render a confident zero on every
69
+ * category until the first mutation, which is Root Cause #72's lesson (a count
70
+ * that failed must never be indistinguishable from a count of nothing).
71
+ */
72
+ export function liveTotalFor(doc) {
73
+ if (!doc)
74
+ return undefined;
75
+ return sumCounts(doc.total) + (doc.lots?.total ?? 0);
76
+ }
77
+ /** Filed directly here, lots included. The "N here" half of "N here, M below". */
78
+ export function ownTotalFor(doc) {
79
+ if (!doc)
80
+ return undefined;
81
+ return sumCounts(doc.own) + (doc.lots?.own ?? 0);
82
+ }
83
+ /**
84
+ * How many sit under this row's DESCENDANTS and not directly on it β€” `total βˆ’ own`.
85
+ *
86
+ * πŸ›‘ DERIVED, never stored, and that is the point: a third stored counter would
87
+ * be a mirror of a subtraction, and a mirror drifts the moment one half is
88
+ * written without the other (Root Cause #42). It is also the function that
89
+ * makes the invariant un-missable β€” anyone reaching for "own plus children"
90
+ * finds this instead of writing `own + total`, which double-counts every item
91
+ * filed directly on the row.
92
+ *
93
+ * Clamped at 0. A negative value is real and means the counters drifted (an
94
+ * increment lost between the leaf write and an ancestor's), and surfacing
95
+ * "βˆ’3 in subcategories" to a visitor is worse than showing none β€” the nightly
96
+ * reconciler's `countsWritten` is where that drift is meant to be seen.
97
+ */
98
+ export function descendantTotalFor(doc) {
99
+ if (!doc)
100
+ return undefined;
101
+ const total = liveTotalFor(doc) ?? 0;
102
+ const own = ownTotalFor(doc) ?? 0;
103
+ return Math.max(0, total - own);
104
+ }
105
+ /** The per-type descendant split, same derivation and the same clamp. */
106
+ export function descendantCountsFor(doc) {
107
+ if (!doc)
108
+ return undefined;
109
+ const out = {};
110
+ for (const k of Object.keys(doc.total ?? {})) {
111
+ const d = (doc.total[k] ?? 0) - (doc.own?.[k] ?? 0);
112
+ if (d > 0)
113
+ out[k] = d;
114
+ }
115
+ return out;
116
+ }
@@ -108,7 +108,6 @@ export interface CategoryDocument extends BaseDocument {
108
108
  */
109
109
  subtreeSize: number;
110
110
  metrics: CategoryDocumentMetrics;
111
- viewCount?: number;
112
111
  isFeatured: boolean;
113
112
  featuredPriority?: number;
114
113
  /** @deprecated Use categoryType === "brand". Will be removed once seed + admin form fully migrate. */
@@ -329,7 +328,6 @@ export declare const CATEGORY_FIELDS: {
329
328
  readonly FEATURED_PRIORITY: "featuredPriority";
330
329
  readonly SEO: "seo";
331
330
  readonly DISPLAY: "display";
332
- readonly VIEW_COUNT: "viewCount";
333
331
  readonly IS_ACTIVE: "isActive";
334
332
  readonly IS_SEARCHABLE: "isSearchable";
335
333
  readonly SEARCH_TXT: "searchTxt";
@@ -210,7 +210,6 @@ export const CATEGORY_FIELDS = {
210
210
  FEATURED_PRIORITY: "featuredPriority",
211
211
  SEO: "seo",
212
212
  DISPLAY: "display",
213
- VIEW_COUNT: "viewCount",
214
213
  IS_ACTIVE: "isActive",
215
214
  IS_SEARCHABLE: "isSearchable",
216
215
  SEARCH_TXT: "searchTxt",
@@ -0,0 +1,194 @@
1
+ /**
2
+ * D4b β€” ONE breadcrumb chain builder, for the category page, the brand page and
3
+ * the product detail pages.
4
+ *
5
+ * πŸ›‘ A `BreadcrumbList` is the one JSON-LD node whose entire value is the ORDER,
6
+ * and it is now emitted over a 416-node / 5-tier / 39-root forest rather than
7
+ * the 47-node / 2-tier tree it was written against. Measured 2026-10-12:
8
+ *
9
+ * per tier: t0=39 t1=23 t2=65 t3=71 t4=218
10
+ * deepest node -> 7 crumbs:
11
+ * Home > Categories > Spinning Tops > Beyblade Burst > Burst Tops > GT
12
+ * > Ace Dragon Sting Charge Zan
13
+ *
14
+ * Three surfaces render this chain and they must agree, or a crawler and a
15
+ * visitor are shown different hierarchies for one page β€” which is worse than
16
+ * emitting no breadcrumb, because a mismatched `BreadcrumbList` is a
17
+ * structured-data error against a page that looks fine:
18
+ *
19
+ * 1. the visible `BreadcrumbTrail`
20
+ * 2. this `BreadcrumbList`
21
+ * 3. `categoryNames[]` on a product β€” the denormalised display cache
22
+ *
23
+ * πŸ›‘ AND THE THIRD IS STORED LEAF-FIRST. Measured:
24
+ *
25
+ * categorySlugs: category-x-basic > category-x-tops > … > category-spinning-tops
26
+ * categoryNames: Basic Line > Beyblade X Tops > … > Spinning Tops
27
+ *
28
+ * So anything rendering `categoryNames` in stored order shows the trail
29
+ * BACKWARDS. Every current consumer takes `[0]` β€” the leaf β€” which is correct
30
+ * for a single chip and is exactly why the reversal has never shown up. This
31
+ * module reverses it once, here, so no call site has to remember.
32
+ *
33
+ * πŸ›‘ `ancestors[]` IS THE SOURCE FOR A CATEGORY; `categoryNames`+`categorySlugs`
34
+ * FOR A PRODUCT. Both `ancestors[]` and `parentIds[]` exist on a category and
35
+ * describe the same chain β€” verified equal in length on all 416 live rows β€” so
36
+ * neither is wrong, but two owners of one fact is Root Cause #42 waiting. The
37
+ * choice is `ancestors[]` because it carries the display NAME, so the visible
38
+ * trail needs no extra read; `parentIds[]` is ids only and stays what the
39
+ * repository derives `categorySlugs` from.
40
+ */
41
+ export interface BreadcrumbCrumb {
42
+ name: string;
43
+ /** Absent on the final crumb β€” the current page is not a link. */
44
+ url?: string;
45
+ }
46
+ /** The shape `CategoryDocument.ancestors[]` entries have. */
47
+ export interface AncestorLike {
48
+ id?: string | null;
49
+ name?: string | null;
50
+ tier?: number | null;
51
+ }
52
+ export interface CategoryChainInput {
53
+ /** The category's own display name. */
54
+ name?: string | null;
55
+ /** Its own URL. Omitted on the last crumb, so pass it and let the caller decide. */
56
+ url?: string;
57
+ ancestors?: readonly AncestorLike[] | null;
58
+ /** Builds a URL from an ancestor id. */
59
+ hrefFor: (id: string) => string;
60
+ }
61
+ /**
62
+ * Root β†’ leaf, from a category's `ancestors[]`.
63
+ *
64
+ * πŸ›‘ SORTED BY TIER, never taken as stored. An earlier revision of the category
65
+ * page asserted `ancestors[]` is stored root-first and used it as given; the
66
+ * visible `BreadcrumbTrail` on that same page had always sorted first, so the
67
+ * stored order was relied on nowhere. Sorting is the only safe reading, and a
68
+ * reversed breadcrumb is worse than none.
69
+ *
70
+ * πŸ›‘ A BROKEN ANCESTOR IS OMITTED, NOT RENDERED AS A GAP. An entry with no id
71
+ * or no name cannot be linked or labelled, and emitting `{ name: "" }` puts an
72
+ * empty crumb in the middle of the trail β€” which reads as a missing level
73
+ * rather than as an absent one. `deriveTaxonomy` warns and leaves a chain alone
74
+ * when a leaf does not resolve (Root Cause #101), so a partial chain is a state
75
+ * that really occurs. Measured 0 in production today, which is why the branch
76
+ * needs a test rather than trust.
77
+ *
78
+ * A ROOT yields just itself β€” 39 of the 416 live rows are tier 0 (6 category
79
+ * roots plus 28 brands and 5 bundles), so this is the common case, not an edge.
80
+ * The caller's `Home` / `Categories` prefix is what keeps it a valid list.
81
+ */
82
+ export declare function categoryCrumbs(input: CategoryChainInput): BreadcrumbCrumb[];
83
+ export interface ProductChainInput {
84
+ /** `[leaf, …ancestors, root]` β€” the STORED order. */
85
+ categorySlugs?: readonly string[] | null;
86
+ /** Parallel to `categorySlugs`, same stored leaf-first order. */
87
+ categoryNames?: readonly string[] | null;
88
+ hrefFor: (slug: string) => string;
89
+ }
90
+ /**
91
+ * Root β†’ leaf, from a product's stored chain.
92
+ *
93
+ * πŸ›‘ THE TWO ARRAYS ARE PARALLEL AND MAY DISAGREE IN LENGTH. `deriveTaxonomy`
94
+ * writes both together, but a row written before it existed β€” or one whose leaf
95
+ * did not resolve β€” can carry a `categorySlugs` with no matching name. Pairing
96
+ * by index past the shorter array would label one category with another's name,
97
+ * which is a wrong hierarchy rendered confidently. So the chain stops at the
98
+ * shorter of the two, and a slug with no name is dropped rather than shown as
99
+ * its raw id.
100
+ *
101
+ * πŸ›‘ A LOT FILES AT A NON-LEAF (G3a), so its chain is genuinely SHORTER and the
102
+ * last crumb is a line or generation node rather than a named model. That is
103
+ * correct and must not be padded: fabricating a leaf to fill the final position
104
+ * would claim the lot is one specific model, which is the same false claim the
105
+ * filing rule refuses at the write. Nothing here looks at `isLot` β€” the chain
106
+ * is simply as long as it is, which is why no special case is needed.
107
+ */
108
+ export declare function productCrumbs(input: ProductChainInput): BreadcrumbCrumb[];
109
+ /**
110
+ * I5b β€” πŸ›‘ WHY `productCrumbs` NEEDED NO CHANGE FOR MULTI-ASSIGNMENT, and why
111
+ * that must be asserted rather than admired.
112
+ *
113
+ * A product's `categorySlugs` is now the CLOSURE over every assignment, while
114
+ * `categoryNames` is the DEFAULT chain only. Pairing by index would be a bug if
115
+ * the closure were a set β€” but it is ordered default-chain-first, so
116
+ * `min(slugs.length, names.length)` clamps to exactly the default chain and the
117
+ * indices line up. Measured: a 7-entry closure with 5 names yields the 5-crumb
118
+ * default trail, root-first.
119
+ *
120
+ * That is correct BY CONSTRUCTION and it is also fragile-looking, which is the
121
+ * dangerous combination β€” someone will eventually "fix" the pairing or make the
122
+ * closure a set. `verify-breadcrumb-chain.mjs` asserts it so that change fails
123
+ * loudly instead of silently relabelling a hierarchy.
124
+ */
125
+ /**
126
+ * Crumbs to show before the middle collapses behind a dropdown.
127
+ *
128
+ * πŸ›‘ A real measurement, not a preference: a tier-4 part leaf is **7 crumbs**
129
+ * (`Home > Categories > Spinning Tops > Beyblade Metal > Metal Parts >
130
+ * Performance Tips > Rubber Flat`) and 390px cannot hold it. D4b measured that
131
+ * depth and never addressed how it wraps.
132
+ *
133
+ * 5 keeps `Home`, one collapsed group, and the last two β€” which is the pattern
134
+ * every large catalogue converges on, because the last two are the only crumbs
135
+ * a visitor navigates by.
136
+ */
137
+ export declare const TRAIL_COLLAPSE_THRESHOLD = 5;
138
+ export interface CollapsedTrail {
139
+ /** Always the first crumb. Never collapsed β€” it is the only guaranteed exit. */
140
+ lead: BreadcrumbCrumb[];
141
+ /** The middle, to be rendered behind a dropdown. Empty when the trail is short. */
142
+ collapsed: BreadcrumbCrumb[];
143
+ /** The last two: the parent you would click, and the page you are on. */
144
+ tail: BreadcrumbCrumb[];
145
+ }
146
+ /**
147
+ * Split a trail into lead / collapsed / tail for ONE row of crumbs.
148
+ *
149
+ * πŸ›‘ Presentation only. The JSON-LD must still emit the FULL trail β€” a crawler
150
+ * never receives a truncated hierarchy just because a phone cannot show it.
151
+ */
152
+ export declare function collapseTrail(crumbs: readonly BreadcrumbCrumb[], threshold?: number): CollapsedTrail;
153
+ export interface AlternateTrailsInput {
154
+ /** Every assignment, default first. */
155
+ categoryIds?: readonly string[] | null;
156
+ /** One display name per assignment, parallel to `categoryIds`. */
157
+ categoryIdNames?: readonly string[] | null;
158
+ hrefFor: (slug: string) => string;
159
+ }
160
+ export interface AlternateTrails {
161
+ /**
162
+ * `none` when there is one assignment β€” which is the overwhelming majority,
163
+ * and the case that must render EXACTLY as before with no extra crumb and no
164
+ * post-hydration shift.
165
+ */
166
+ mode: "none" | "inline" | "dropdown";
167
+ items: BreadcrumbCrumb[];
168
+ }
169
+ /**
170
+ * The assignments that are NOT the default, as one crumb's worth of links.
171
+ *
172
+ * *"if it is less than 3 we can add a single crumb to show them all, for more
173
+ * than that we can just add a dropdown"* β€” so ≀2 alternates render inline and
174
+ * 3 (the maximum, since `MAX_CATEGORY_ASSIGNMENTS` is 4) opens a dropdown.
175
+ *
176
+ * πŸ›‘ ONE ROW, never stacked trails. Two rows of crumbs above a product title is
177
+ * a pattern no competitor in the crawl uses and it costs vertical space above
178
+ * the fold on a phone.
179
+ *
180
+ * πŸ›‘ And the links must be REAL `<a>` elements in the server-rendered DOM, which
181
+ * is this function's whole reason for returning data rather than a component: a
182
+ * crawler does not open menus, so alternates injected on open would trade the
183
+ * SEO this exists for against tidiness.
184
+ */
185
+ export declare function alternateAssignments(input: AlternateTrailsInput): AlternateTrails;
186
+ /**
187
+ * Prepend the two fixed crumbs every trail starts with, and drop the final
188
+ * crumb's URL.
189
+ *
190
+ * The last crumb must not be a link β€” it is the page you are on, and Google's
191
+ * own guidance is that the final breadcrumb identifies the current page. Doing
192
+ * it here rather than at three call sites is what stops one of them forgetting.
193
+ */
194
+ export declare function withTrailHead(crumbs: readonly BreadcrumbCrumb[], head: readonly BreadcrumbCrumb[]): BreadcrumbCrumb[];