@stapel/listings-react 0.23.0 → 0.25.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 (104) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/MODULE.md +4 -0
  3. package/README.md +75 -0
  4. package/dist/api/generated/schema.d.ts +126 -11
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/listingsApi.d.ts +5 -3
  7. package/dist/api/listingsApi.d.ts.map +1 -1
  8. package/dist/api/listingsApi.js.map +1 -1
  9. package/dist/api/types.d.ts +51 -4
  10. package/dist/api/types.d.ts.map +1 -1
  11. package/dist/api/types.js.map +1 -1
  12. package/dist/default/CardBadges.d.ts +47 -0
  13. package/dist/default/CardBadges.d.ts.map +1 -0
  14. package/dist/default/CardBadges.js +38 -0
  15. package/dist/default/CardBadges.js.map +1 -0
  16. package/dist/default/ListingCard.d.ts.map +1 -1
  17. package/dist/default/ListingCard.js +6 -6
  18. package/dist/default/ListingCard.js.map +1 -1
  19. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  20. package/dist/default/ListingDetailPane.js +12 -19
  21. package/dist/default/ListingDetailPane.js.map +1 -1
  22. package/dist/default/ListingPhoto.d.ts.map +1 -1
  23. package/dist/default/ListingPhoto.js +12 -8
  24. package/dist/default/ListingPhoto.js.map +1 -1
  25. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  26. package/dist/default/ListingSerpCard.js +3 -5
  27. package/dist/default/ListingSerpCard.js.map +1 -1
  28. package/dist/default/ListingSpecList.d.ts +74 -0
  29. package/dist/default/ListingSpecList.d.ts.map +1 -0
  30. package/dist/default/ListingSpecList.js +90 -0
  31. package/dist/default/ListingSpecList.js.map +1 -0
  32. package/dist/default/cardGallery.d.ts +81 -0
  33. package/dist/default/cardGallery.d.ts.map +1 -0
  34. package/dist/default/cardGallery.js +266 -0
  35. package/dist/default/cardGallery.js.map +1 -0
  36. package/dist/default/index.d.ts +6 -0
  37. package/dist/default/index.d.ts.map +1 -1
  38. package/dist/default/index.js +5 -0
  39. package/dist/default/index.js.map +1 -1
  40. package/dist/headless/ListingComposer.d.ts +20 -2
  41. package/dist/headless/ListingComposer.d.ts.map +1 -1
  42. package/dist/headless/ListingComposer.js +19 -4
  43. package/dist/headless/ListingComposer.js.map +1 -1
  44. package/dist/i18n/es.d.ts.map +1 -1
  45. package/dist/i18n/es.js +1 -0
  46. package/dist/i18n/es.js.map +1 -1
  47. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  48. package/dist/i18n/generated/errors.es.gen.js +1 -1
  49. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  50. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  51. package/dist/i18n/generated/errors.gen.js +3 -0
  52. package/dist/i18n/generated/errors.gen.js.map +1 -1
  53. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  54. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  55. package/dist/i18n/ru.d.ts.map +1 -1
  56. package/dist/i18n/ru.js +3 -0
  57. package/dist/i18n/ru.js.map +1 -1
  58. package/dist/index.d.ts +3 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +3 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/model/cardBadges.d.ts +134 -0
  63. package/dist/model/cardBadges.d.ts.map +1 -0
  64. package/dist/model/cardBadges.js +131 -0
  65. package/dist/model/cardBadges.js.map +1 -0
  66. package/dist/model/draft.d.ts +10 -2
  67. package/dist/model/draft.d.ts.map +1 -1
  68. package/dist/model/draft.js +22 -4
  69. package/dist/model/draft.js.map +1 -1
  70. package/dist/model/featureText.d.ts +85 -0
  71. package/dist/model/featureText.d.ts.map +1 -0
  72. package/dist/model/featureText.js +153 -0
  73. package/dist/model/featureText.js.map +1 -0
  74. package/dist/model/features.d.ts.map +1 -1
  75. package/dist/model/features.js +59 -0
  76. package/dist/model/features.js.map +1 -1
  77. package/llms.txt +5 -2
  78. package/manifest.json +47 -1
  79. package/nav-manifest.json +1 -1
  80. package/package.json +10 -10
  81. package/src/analytics/generated/events.json +1 -1
  82. package/src/api/generated/schema.ts +126 -11
  83. package/src/api/listingsApi.ts +5 -3
  84. package/src/api/types.ts +60 -6
  85. package/src/default/CardBadges.tsx +105 -0
  86. package/src/default/ListingCard.tsx +7 -21
  87. package/src/default/ListingDetailPane.tsx +13 -37
  88. package/src/default/ListingPhoto.tsx +54 -26
  89. package/src/default/ListingSerpCard.tsx +10 -28
  90. package/src/default/ListingSpecList.tsx +195 -0
  91. package/src/default/cardGallery.ts +287 -0
  92. package/src/default/index.ts +22 -0
  93. package/src/headless/ListingComposer.tsx +39 -5
  94. package/src/i18n/es.ts +3 -0
  95. package/src/i18n/generated/errors.es.gen.ts +1 -1
  96. package/src/i18n/generated/errors.gen.ts +3 -0
  97. package/src/i18n/generated/errors.json +9 -0
  98. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  99. package/src/i18n/ru.ts +5 -0
  100. package/src/index.ts +15 -0
  101. package/src/model/cardBadges.ts +237 -0
  102. package/src/model/draft.ts +23 -5
  103. package/src/model/featureText.ts +251 -0
  104. package/src/model/features.ts +60 -0
package/src/index.ts CHANGED
@@ -166,6 +166,21 @@ export {
166
166
  unreadableFeatureCount,
167
167
  } from "./model/features.js";
168
168
  export type { FeatureCopySource } from "./model/features.js";
169
+ export { featureUnit, formatSpecValue } from "./model/featureText.js";
170
+
171
+ // ── model: the card badge contract (stapel-listings 0.21.3) ──────────────────
172
+ export {
173
+ badgePresentation,
174
+ badgeValueText,
175
+ cardBadgeText,
176
+ cardBadgeTexts,
177
+ hasCardBadgeContract,
178
+ } from "./model/cardBadges.js";
179
+ export type {
180
+ CardBadgeContract,
181
+ CardBadgePresentation,
182
+ CardBadgeRow,
183
+ } from "./model/cardBadges.js";
169
184
 
170
185
  // ── model: the engagement axis (already-seen / view count / the overlay) ─────
171
186
  export {
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The CARD BADGE CONTRACT — what a badge on a result card is allowed to say.
3
+ *
4
+ * ── What was on screen ────────────────────────────────────────────────────
5
+ *
6
+ * A live card's badge line read "Brick · 3 · 9". Three true facts about a
7
+ * flat, and two of them unreadable: 3 what, 9 what. The projection carried
8
+ * the VALUES and the display half printed them, because that is all a stored
9
+ * `features_badges` row could offer — a number with no unit and no question
10
+ * beside it.
11
+ *
12
+ * stapel-listings 0.21.3 adds the missing half to each element:
13
+ *
14
+ * `value` the raw answer
15
+ * `label` its resolved copy, where the answer is an option
16
+ * `unit` what the number is measured in
17
+ * `name` the question — the feature's own display name
18
+ * `presentation` which of those to print, decided by the SERVER
19
+ *
20
+ * `presentation` is the load-bearing key, and it is the server's decision
21
+ * rather than a rule guessed here, because the right badge for a value
22
+ * depends on the CATEGORY: "3 rooms" wants its name, "Brick" is a
23
+ * boolean whose name IS the badge, "20 000 km" wants its unit and no name at
24
+ * all, and no client-side heuristic gets all three right for every catalogue
25
+ * in the fleet.
26
+ *
27
+ * ── The four presentations ───────────────────────────────────────────────
28
+ *
29
+ * `value` the value alone — "Brick" (an option label)
30
+ * `value_unit` value and unit — "20 000 km"
31
+ * `name_value` name and value — "Floor 3": a SPACE and never a colon,
32
+ * because a card is a caption and not a
33
+ * form. The unit is kept when the
34
+ * element carries one — a named row
35
+ * that dropped it reads "Mileage 20 000"
36
+ * `name` the name alone — a TRUE boolean, whose whole content
37
+ * is that the flat is brick. A FALSE
38
+ * one renders nothing: "not brick" is
39
+ * not a selling point, and a card is a
40
+ * summary rather than a form.
41
+ *
42
+ * ── Why a local type extension and not a regenerated schema ───────────────
43
+ *
44
+ * The generated `FeatureDao` union is already unusable on this side (the
45
+ * discriminator defect, `api/types.ts`), `features_badges` is a `JSONField`
46
+ * on the serializer, and this pair reads the row through
47
+ * {@link ListingFeatureDao} — a hand-written mirror of the runtime shape —
48
+ * for exactly that reason. The contract is an additive extension of the same
49
+ * mirror, so a regeneration cannot take it away and an older backend that
50
+ * sends none of the five keys is simply a row where
51
+ * {@link hasCardBadgeContract} is false.
52
+ *
53
+ * ── The fallback is the whole compatibility story ─────────────────────────
54
+ *
55
+ * A row with no `presentation` is a row from a server that predates the
56
+ * contract, and it is rendered exactly as it is rendered today — through
57
+ * `@stapel/attributes-react`'s `<FeatureBadges>`, off the stored DAO's own
58
+ * config. Nothing about this module is required for a card to draw.
59
+ */
60
+ import type { ListingCardBadgeElement, ListingFeatureDao } from "../api/types.js";
61
+
62
+ /**
63
+ * Which parts of a badge element the server asked to be printed.
64
+ *
65
+ * Read off the GENERATED element rather than spelled again here: the four
66
+ * readings are the server's enum, and a fifth added upstream must turn this
67
+ * build red instead of falling through {@link badgePresentation}'s default in
68
+ * silence.
69
+ */
70
+ export type CardBadgePresentation = ListingCardBadgeElement["presentation"];
71
+
72
+ /**
73
+ * The keys stapel-listings 0.21.3 adds to a `features_badges` /
74
+ * `features_title` element.
75
+ *
76
+ * The NAMES and their types come from the generated element; the optionality
77
+ * is this pair's own, and is the whole compatibility story — the contract
78
+ * makes them required, and a server that predates it sends none of them. Same
79
+ * argument as {@link ListingEngagementFields}, one file over: a generated type
80
+ * is a promise about the contract, not about the bytes a deployment sends.
81
+ */
82
+ export type CardBadgeContract = {
83
+ /** The raw answer. Absent on a redacted row, which is why the renderers
84
+ * below all tolerate it, and `unknown` because the DAO union types it per
85
+ * feature type and a badge prints whatever it was given. */
86
+ readonly value?: unknown;
87
+ } & {
88
+ readonly [K in "label" | "unit" | "name"]?: ListingCardBadgeElement[K] | null;
89
+ } & {
90
+ /** Widened back to `string` on purpose: {@link badgePresentation} exists to
91
+ * REFUSE a reading it does not know, and a field typed as the four would
92
+ * make that refusal unreachable — and untestable — while a server is still
93
+ * free to send a fifth. */
94
+ readonly presentation?: string | null;
95
+ };
96
+
97
+ /** A stored badge row, with the contract's keys where the server sends them. */
98
+ export type CardBadgeRow = ListingFeatureDao & CardBadgeContract;
99
+
100
+ /**
101
+ * `presentation` as one of the four, or `undefined`.
102
+ *
103
+ * An unrecognised spelling is `undefined` rather than a guess: the whole
104
+ * point of the key is that the SERVER decided, and a client that invented a
105
+ * fifth reading would print one category's rule on another category's card.
106
+ */
107
+ export function badgePresentation(row: CardBadgeRow): CardBadgePresentation | undefined {
108
+ const raw = row.presentation;
109
+ if (typeof raw !== "string") return undefined;
110
+ switch (raw) {
111
+ case "value":
112
+ case "value_unit":
113
+ case "name_value":
114
+ case "name":
115
+ return raw;
116
+ default:
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Does this projection speak the contract at all?
123
+ *
124
+ * ONE row declaring a `presentation` is enough: the server writes the whole
125
+ * list in one pass, so a mixed list means a row the server deliberately left
126
+ * plain, and that row still renders (as its value alone) rather than
127
+ * dragging the other four back to the old path.
128
+ */
129
+ export function hasCardBadgeContract(rows: readonly CardBadgeRow[]): boolean {
130
+ return rows.some((row) => badgePresentation(row) !== undefined);
131
+ }
132
+
133
+ /** Trimmed copy, or `""` — `null` is the shape the wire uses for absent. */
134
+ function text(value: string | null | undefined): string {
135
+ return typeof value === "string" ? value.trim() : "";
136
+ }
137
+
138
+ /**
139
+ * How many decimals the wire actually stated.
140
+ *
141
+ * A card badge must not invent precision and must not lose it: the server
142
+ * sends "2.0" for an engine volume and `2` for a number of rooms, and JS
143
+ * cannot tell `2.0` from `2` once it is a number — so a STRING value keeps
144
+ * the decimals it was written with and a number keeps its own.
145
+ */
146
+ function statedDecimals(raw: unknown): number {
147
+ const written = typeof raw === "string" ? raw : String(raw);
148
+ const tail = written.split(".")[1];
149
+ return tail === undefined ? 0 : tail.replace(/[^0-9]/g, "").length;
150
+ }
151
+
152
+ /**
153
+ * The element's answer, as a person reads it: the server's `label` when there
154
+ * is one, the value with its digits grouped when it is a number, the value
155
+ * verbatim otherwise.
156
+ */
157
+ export function badgeValueText(row: CardBadgeRow, locale?: string): string {
158
+ const label = text(row.label);
159
+ if (label.length > 0) return label;
160
+ const raw = row.value;
161
+ if (raw === null || raw === undefined || raw === "") return "";
162
+ if (typeof raw === "boolean") return "";
163
+ const parsed = typeof raw === "number" ? raw : Number(raw);
164
+ if (typeof raw !== "object" && Number.isFinite(parsed)) {
165
+ const digits = statedDecimals(raw);
166
+ try {
167
+ return new Intl.NumberFormat(locale, {
168
+ minimumFractionDigits: digits,
169
+ maximumFractionDigits: digits,
170
+ }).format(parsed);
171
+ } catch {
172
+ return parsed.toFixed(digits);
173
+ }
174
+ }
175
+ return typeof raw === "object" ? "" : String(raw);
176
+ }
177
+
178
+ /** Is this element's value the `true` a `name`-presented boolean needs? */
179
+ function isTrue(raw: unknown): boolean {
180
+ return raw === true || raw === 1 || raw === "true";
181
+ }
182
+
183
+ /**
184
+ * One badge element → the text a card prints, or `undefined` for an element
185
+ * that has nothing to say (a false boolean, a blank value).
186
+ *
187
+ * The one place the four presentations are read. Every card surface calls
188
+ * this and none of them re-derives it, for the same reason `CardTarget`
189
+ * exists: three cards each re-deciding what a badge says is three places for
190
+ * "Brick · 3 · 9" to come back.
191
+ */
192
+ export function cardBadgeText(row: CardBadgeRow, locale?: string): string | undefined {
193
+ const presentation = badgePresentation(row);
194
+ const name = text(row.name);
195
+ const unit = text(row.unit);
196
+
197
+ if (presentation === "name") {
198
+ // The name IS the badge, and only while the answer is yes.
199
+ return isTrue(row.value) && name.length > 0 ? name : undefined;
200
+ }
201
+
202
+ const value = badgeValueText(row, locale);
203
+ if (value.length === 0) return undefined;
204
+
205
+ const withUnit = unit.length > 0 ? `${value} ${unit}` : value;
206
+
207
+ switch (presentation) {
208
+ case "value_unit":
209
+ return withUnit;
210
+ case "name_value":
211
+ // A SPACE, not a colon. "Floor 3" is a caption; "Floor: 3" is a form
212
+ // field, and a card is neither a form nor a table — the colon is the
213
+ // punctuation the spec list uses because a spec list IS the table.
214
+ // The unit rides along: a named row that dropped it reads "Mileage
215
+ // 20 000", which is the defect this contract exists to close.
216
+ return name.length > 0 ? `${name} ${withUnit}` : withUnit;
217
+ default:
218
+ // `value`, and an element the server left plain in a list that speaks
219
+ // the contract. The value alone, never the unit — the server said so.
220
+ return value;
221
+ }
222
+ }
223
+
224
+ /** Every element that has something to say, in the server's order. */
225
+ export function cardBadgeTexts(
226
+ rows: readonly CardBadgeRow[],
227
+ locale?: string
228
+ ): readonly { readonly slug: string; readonly text: string }[] {
229
+ const out: { slug: string; text: string }[] = [];
230
+ for (const row of rows) {
231
+ const printed = cardBadgeText(row, locale);
232
+ if (printed !== undefined && typeof row.slug === "string" && row.slug.length > 0) {
233
+ out.push({ slug: row.slug, text: printed });
234
+ }
235
+ }
236
+ return out;
237
+ }
@@ -171,7 +171,11 @@ export function draftValuesFromDetail(
171
171
  ): ListingDraftValues {
172
172
  const base = emptyDraftValues(options);
173
173
  return {
174
- categoryId: detail.category_id,
174
+ // `text()` for the same reason as the draft seed above: 0.21.4 answers
175
+ // `category_id: null` for a row created before its category was chosen,
176
+ // and a seed that took it verbatim put `null` where a string is declared
177
+ // and crashed the first control that measured its length.
178
+ categoryId: text(detail.category_id),
175
179
  title: text(detail.title),
176
180
  description: text(detail.description),
177
181
  price: text(detail.price),
@@ -236,7 +240,11 @@ export function draftPatchFromValues(
236
240
  features: readonly FeatureDef[]
237
241
  ): ListingDraftPatch {
238
242
  return {
239
- category_id: values.categoryId,
243
+ // Omitted while unchosen rather than sent as `""`: a draft is allowed to
244
+ // have no category (0.21.4), and `""` is not "no category" on the wire —
245
+ // it is an empty id the serializer refuses. The category is written by
246
+ // whichever save follows the pick.
247
+ ...(values.categoryId.length > 0 ? { category_id: values.categoryId } : {}),
240
248
  title_draft: values.title,
241
249
  description_draft: values.description,
242
250
  price_draft: values.price.length > 0 ? values.price : null,
@@ -261,16 +269,26 @@ export function draftPatchFromValues(
261
269
  }
262
270
 
263
271
  /**
264
- * The body for CREATING a draft: `category_id` and nothing else.
272
+ * The body for CREATING a draft: the category if there is one, `{}` if there
273
+ * is not.
265
274
  *
266
275
  * `perform_create` forces `owner` and `status`, and everything else has a
267
276
  * model default, so a create that also carried the form's current contents
268
277
  * would be a second write of data the very next `save-draft` sends anyway —
269
278
  * and would fail the whole submission on a field the person could still fix.
270
279
  * Create the row, then save into it.
280
+ *
281
+ * `{}` IS a valid create body since stapel-listings 0.21.4 made `category_id`
282
+ * nullable on a draft: a draft may exist before its category is chosen, and
283
+ * `publish` is where the category becomes mandatory (`publish_validation
284
+ * _failed` naming `category_id`). Not being able to create the row first is
285
+ * what left an analysis job addressed by the draft id with no id to start
286
+ * from (D261).
271
287
  */
272
- export function createDraftBody(categoryId: string): ListingDraftPatch {
273
- return { category_id: categoryId };
288
+ export function createDraftBody(categoryId?: string): ListingDraftPatch {
289
+ return categoryId !== undefined && categoryId.length > 0
290
+ ? { category_id: categoryId }
291
+ : {};
274
292
  }
275
293
 
276
294
  /**
@@ -0,0 +1,251 @@
1
+ /**
2
+ * A stored feature value as a person READS it — with its unit, and with its
3
+ * digits grouped the way the reader's locale groups them.
4
+ *
5
+ * ── What was on screen ────────────────────────────────────────────────────
6
+ *
7
+ * Measured on the live listing page: "Power 173", "Mileage 20000", "Engine
8
+ * volume 2.0". Three defects in three rows:
9
+ *
10
+ * - **no unit.** 173 what. 20000 what. A spec row whose value is a bare
11
+ * number is a row a buyer has to guess at, and mileage is the single
12
+ * number a used-car buyer decides on;
13
+ * - **no digit grouping.** `20000` is read digit by digit; "20 000" is read
14
+ * at a glance. `formatFeatureValue`'s integer branch is `String(value)`
15
+ * (attributes-react `src/format.ts:196`), deliberately — it mirrors the
16
+ * engine's own `str(value)` — and the engine renders for a machine-read
17
+ * API, not for a spec table;
18
+ * - **the wrong decimal mark.** `2.0` in a Russian storefront. The engine
19
+ * writes `toFixed`, which is invariant by construction.
20
+ *
21
+ * ── Where the unit comes from ─────────────────────────────────────────────
22
+ *
23
+ * There is NO generic `unit` key anywhere in this fleet's feature contract —
24
+ * not on `FeatureDef` (`stapel-attributes/base.py:154-208`), not on
25
+ * `IntConfig`/`FloatConfig` (`attributes-react/src/generated/featureDef.ts:163`),
26
+ * not on the stored DAO. The unit of an `int`/`float` IS its `postfix`, free
27
+ * text on the type's config, with `postfix1000` as the abbreviated unit the
28
+ * engine switches to at a thousand. `convertible_unit` is the one type with
29
+ * real unit semantics (`unitType`/`unit_m`/`unit_i`), and its own formatter
30
+ * already appends the resolved code — so this module leaves it alone.
31
+ *
32
+ * So "render the unit from the definition" means: read `postfix`. It reaches
33
+ * a display surface two ways, and `model/features.ts` now tries both — the
34
+ * stored row's own config first (`dto_to_dao` copies `postfix` at write
35
+ * time), then the CATEGORY's definition of the same slug, which is the path
36
+ * that repairs every listing published before a catalogue gained its units.
37
+ *
38
+ * ── Why the whole formatter is not delegated ──────────────────────────────
39
+ *
40
+ * `formatFeatureValue` is the fleet's one formatter and stays it: every type
41
+ * but `int` and `float` goes straight through it, untouched. The two numeric
42
+ * types are intercepted because the change is not a different ANSWER, it is
43
+ * the same answer typeset — same value, same precision rule, same
44
+ * `postfix1000` switch at a thousand, same translated unit, with the digits
45
+ * run through `Intl.NumberFormat` instead of `String()`.
46
+ *
47
+ * ── Which numbers are grouped, and which are not (D307) ────────────────────
48
+ *
49
+ * Grouping every number is how a card came to read "2 024" for a model year.
50
+ * A year is not a quantity: it is an identifier spelled in digits, and so is
51
+ * a house number, a floor, a room count. Grouping them is not a nicety
52
+ * applied too widely, it is the wrong reading.
53
+ *
54
+ * The rule is the one fact the catalogue already states, plus a magnitude no
55
+ * identifier reaches:
56
+ *
57
+ * - a feature that carries a UNIT (`prefix`, `postfix`, `postfix1000`) is a
58
+ * measurement — "20 000 km", "173 hp", "42 m²" — and is grouped;
59
+ * - a unitless value is grouped only from 10 000 up. A year is four digits
60
+ * for the next eight thousand of them and a count of doors is one, so the
61
+ * threshold sorts years from counts WITHOUT a "does this slug look like a
62
+ * year" heuristic — which is guesswork the moment a deployment names the
63
+ * slug in its own language.
64
+ *
65
+ * Grouping is switched off through `Intl` rather than around it, so a unitless
66
+ * float still gets the reader's DECIMAL MARK ("2,5", not "2.5"): the two are
67
+ * separate defects and only one of them is being repaired here.
68
+ */
69
+ import type { FeatureDef, FeatureValueDto, FormatOptions } from "@stapel/attributes-react";
70
+ import { featureConfig, featureType, formatFeatureValue } from "@stapel/attributes-react";
71
+
72
+ /** The engine's own defaults (`types/int/type.py`, `types/float/type.py`). */
73
+ const DEFAULT_DIGITS: Readonly<Record<"int" | "float", number>> = {
74
+ int: 1,
75
+ float: 2,
76
+ };
77
+
78
+ function str(value: unknown): string {
79
+ return typeof value === "string" ? value : "";
80
+ }
81
+
82
+ /**
83
+ * A config string the engine declares to be a translation key, resolved.
84
+ *
85
+ * `prefix`/`postfix`/`postfix1000` are collected by upstream's
86
+ * `get_translation_keys` unconditionally, so they go through the host's
87
+ * catalogue exactly as `formatFeatureValue` sends them — a Russian storefront
88
+ * was reading "L" and "each" off its own spec table before that landed, and
89
+ * this path must not reintroduce it.
90
+ */
91
+ function translated(options: FormatOptions | undefined, raw: unknown): string {
92
+ const text = str(raw);
93
+ if (text.length === 0) return "";
94
+ const t = options?.t;
95
+ if (t === undefined) return text;
96
+ const resolved = t(text);
97
+ return resolved.length > 0 ? resolved : text;
98
+ }
99
+
100
+ /**
101
+ * The unit a numeric feature is measured in, as the reader sees it — or `""`.
102
+ *
103
+ * `postfix1000` when the engine's own thousand switch applies, `postfix`
104
+ * otherwise, so the unit and the number can never disagree about scale.
105
+ */
106
+ export function featureUnit(
107
+ feature: FeatureDef,
108
+ dto: FeatureValueDto | undefined,
109
+ options?: FormatOptions
110
+ ): string {
111
+ const type = featureType(feature);
112
+ if (type !== "int" && type !== "float") return "";
113
+ const config = featureConfig(feature);
114
+ const parsed = Number(dto?.value);
115
+ const big = str(config["postfix1000"]).length > 0 && Number.isFinite(parsed) && Math.abs(parsed) >= 1000;
116
+ return translated(options, config[big ? "postfix1000" : "postfix"]);
117
+ }
118
+
119
+ /**
120
+ * `Intl.NumberFormat` for one value, or `undefined` when the runtime has no
121
+ * `Intl` for this locale to offer.
122
+ *
123
+ * A bad BCP-47 tag throws `RangeError`, and a spec table that renders nothing
124
+ * because a host passed `"en_US"` would be a worse outcome than an ungrouped
125
+ * number — so the caller falls back rather than the page failing.
126
+ */
127
+ function localized(
128
+ value: number,
129
+ digits: number,
130
+ locale: string | undefined,
131
+ useGrouping: boolean
132
+ ): string | undefined {
133
+ try {
134
+ return new Intl.NumberFormat(locale, {
135
+ minimumFractionDigits: digits,
136
+ maximumFractionDigits: digits,
137
+ useGrouping,
138
+ }).format(value);
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ /** Digits below which a unitless number is an identifier rather than a
145
+ * quantity — see the module header. */
146
+ const GROUPING_FLOOR = 10_000;
147
+
148
+ /**
149
+ * Does this feature measure something?
150
+ *
151
+ * The RAW config, not the translated unit: whether a value is a measurement
152
+ * is a fact about the catalogue, and it must not change because a host's
153
+ * message catalogue happens to resolve a unit key to an empty string.
154
+ */
155
+ function hasUnit(config: Readonly<Record<string, unknown>>): boolean {
156
+ return (
157
+ str(config["prefix"]).length > 0 ||
158
+ str(config["postfix"]).length > 0 ||
159
+ str(config["postfix1000"]).length > 0
160
+ );
161
+ }
162
+
163
+ /**
164
+ * The one numeric row, typeset — see the module header for what each piece
165
+ * is repairing.
166
+ *
167
+ * Returns `undefined` for a value that is not a number, so the caller falls
168
+ * back to the shared formatter and an unreadable row keeps saying so.
169
+ */
170
+ function formatNumeric(
171
+ feature: FeatureDef,
172
+ dto: FeatureValueDto,
173
+ kind: "int" | "float",
174
+ options: FormatOptions | undefined
175
+ ): string | undefined {
176
+ // `Number(null)` is 0 and `Number("")` is 0, so a blank row would render as
177
+ // a confident zero. The shared formatter's own `isBlank` gate is upstream of
178
+ // this branch and must be restated here rather than assumed.
179
+ const raw = dto.value;
180
+ if (raw === null || raw === undefined || raw === "") return undefined;
181
+ const parsed = typeof raw === "number" ? raw : Number(raw);
182
+ if (!Number.isFinite(parsed)) return undefined;
183
+ const config = featureConfig(feature);
184
+ const precision = config["precision"];
185
+ const digits =
186
+ typeof precision === "number" && precision >= 0 ? precision : DEFAULT_DIGITS[kind];
187
+
188
+ const prefix = translated(options, config["prefix"]);
189
+ const postfix1000 = str(config["postfix1000"]);
190
+
191
+ // The engine switches unit AND scale at exactly a thousand
192
+ // (`format_value`: `value / 1000`, trailing zeros stripped, `postfix1000`
193
+ // as the unit). Grouping a scaled number is harmless and keeps one code
194
+ // path; the trailing-zero strip is the engine's, done on the plain digits
195
+ // before the locale ever sees them, because "1.50" and "1,5" are the same
196
+ // decision made in two places.
197
+ if (postfix1000.length > 0 && Math.abs(parsed) >= 1000) {
198
+ const scaled = Number((parsed / 1000).toFixed(digits));
199
+ const body =
200
+ localized(scaled, decimalsOf(scaled, digits), options?.locale, true) ??
201
+ String(scaled);
202
+ return join(prefix, body, translated(options, postfix1000));
203
+ }
204
+
205
+ // `precision` drives ONLY the scaled branch of an int — the engine's plain
206
+ // branch is `str(value)`, and an integer never grows a decimal tail
207
+ // whatever the config says (D26: a live category shipped `precision: 1` on
208
+ // a year field and the card read "2024.0").
209
+ const value = kind === "int" ? Math.trunc(parsed) : parsed;
210
+ const fraction = kind === "int" ? 0 : digits;
211
+ // A measurement is grouped; a unitless number is an identifier until it is
212
+ // too big to be one (D307 — the card read "2 024" for a model year).
213
+ const group = hasUnit(config) || Math.abs(value) >= GROUPING_FLOOR;
214
+ const body =
215
+ localized(value, fraction, options?.locale, group) ?? value.toFixed(fraction);
216
+ return join(prefix, body, translated(options, config["postfix"]));
217
+ }
218
+
219
+ /** How many decimals a scaled value actually has, capped at the configured
220
+ * precision — the engine's `.rstrip('0').rstrip('.')`, expressed as a count
221
+ * so `Intl` can do the rendering. */
222
+ function decimalsOf(value: number, cap: number): number {
223
+ const tail = value.toFixed(cap).split(".")[1] ?? "";
224
+ return tail.replace(/0+$/, "").length;
225
+ }
226
+
227
+ /** `prefix` + body + unit, with the single space the engine puts between a
228
+ * number and its unit and no space at all where there is no unit. */
229
+ function join(prefix: string, body: string, unit: string): string {
230
+ return unit.length > 0 ? `${prefix}${body} ${unit}` : `${prefix}${body}`;
231
+ }
232
+
233
+ /**
234
+ * A stored value as a spec row prints it: `formatFeatureValue` for every type
235
+ * but the two numeric ones, which are typeset here instead.
236
+ *
237
+ * Signature-compatible with `formatFeatureValue` on purpose — a surface
238
+ * swaps one for the other and nothing else changes.
239
+ */
240
+ export function formatSpecValue(
241
+ feature: FeatureDef,
242
+ dto: FeatureValueDto | undefined,
243
+ options?: FormatOptions
244
+ ): string | undefined {
245
+ const type = featureType(feature);
246
+ if (dto !== undefined && (type === "int" || type === "float")) {
247
+ const typeset = formatNumeric(feature, dto, type, options);
248
+ if (typeset !== undefined) return typeset;
249
+ }
250
+ return formatFeatureValue(feature, dto, options);
251
+ }
@@ -317,6 +317,64 @@ function selectOptions(
317
317
  return [...table].map(([value, label]) => ({ value, label }));
318
318
  }
319
319
 
320
+ /**
321
+ * The config keys that carry a value's UNIT, in the order they belong to.
322
+ *
323
+ * There is no generic `unit` key anywhere in this contract — the unit of an
324
+ * `int`/`float` is its `postfix` (free text on the type's config, with
325
+ * `postfix1000` as the abbreviated unit the engine switches to at a
326
+ * thousand), and `convertible_unit` states its own with `unitType`/`unit_m`/
327
+ * `unit_i`. `prefix` rides along because it is the same fact on the other
328
+ * side of the number (a currency mark, a «~»).
329
+ *
330
+ * `precision` is deliberately NOT here. `dto_to_dao` writes it on every
331
+ * numeric row (defaulted, never absent), so it could only ever be adopted
332
+ * onto a row from some older server — where the stored value has already
333
+ * been ROUNDED to the precision it was written with, and re-rendering it at
334
+ * today's would print decimals the record does not contain.
335
+ */
336
+ const UNIT_KEYS: readonly string[] = [
337
+ "prefix",
338
+ "postfix",
339
+ "postfix1000",
340
+ "unitType",
341
+ "unit_m",
342
+ "unit_i",
343
+ ];
344
+
345
+ /**
346
+ * The unit the CATEGORY declares, for a stored row that carries none.
347
+ *
348
+ * Measured on the live listing page: "Power 173", "Mileage 20000" — bare
349
+ * numbers, in a deployment whose detail page holds the category's own feature
350
+ * defs and passes them in for exactly this kind of repair. `dto_to_dao`
351
+ * copies `postfix` at WRITE time, so a listing published before its category
352
+ * gained a unit keeps printing without one for the rest of its life, and so
353
+ * does every row written by a server that predates the key.
354
+ *
355
+ * Same rule as the option table two functions up, and for the same reason:
356
+ * the stored row wins wherever it said anything, because it is what the
357
+ * listing was published with, and the category fills only the silence. An
358
+ * empty string counts as silence — `dto_to_dao` writes `postfix=None` as
359
+ * absent, but a catalogue that once held `""` should not out-rank a
360
+ * catalogue that now holds "km".
361
+ */
362
+ function adoptedUnits(
363
+ config: Readonly<Record<string, unknown>>,
364
+ categoryDef: FeatureDef | undefined
365
+ ): Readonly<Record<string, string>> | undefined {
366
+ const declared = categoryDef?.config;
367
+ if (declared === undefined) return undefined;
368
+ const out: Record<string, string> = {};
369
+ for (const key of UNIT_KEYS) {
370
+ const stored = config[key];
371
+ if (typeof stored === "string" && stored.length > 0) continue;
372
+ const value = (declared as Readonly<Record<string, unknown>>)[key];
373
+ if (typeof value === "string" && value.length > 0) out[key] = value;
374
+ }
375
+ return Object.keys(out).length === 0 ? undefined : out;
376
+ }
377
+
320
378
  /**
321
379
  * The category's option TREE, adopted whole for a `hierarchical_select` that
322
380
  * stored none — see {@link TREE_VALUED}.
@@ -365,6 +423,8 @@ function featureView(
365
423
  const options =
366
424
  selectOptions(dao, config, categoryDef) ?? adoptedTree(dao, config, categoryDef);
367
425
  if (options !== undefined) config["options"] = options;
426
+ const units = adoptedUnits(config, categoryDef);
427
+ if (units !== undefined) Object.assign(config, units);
368
428
 
369
429
  const visibility = storedVisibility(dao.visibility);
370
430
  const feature: FeatureDef = {