@wix/himalaya-cli 0.844.0 → 0.845.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 (47) hide show
  1. package/dist/cli.mjs +9 -2
  2. package/guides/release-notes.md +19 -0
  3. package/package.json +2 -2
  4. package/templates/beauty-salon-owner/dev/index.ts +858 -0
  5. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +59 -9
  6. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-analytics/index.ts +7 -1
  7. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-analytics/report.ts +690 -0
  8. package/templates/community-club-owner/dev/index.ts +858 -0
  9. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +59 -9
  10. package/templates/community-club-owner/tier5-src/himi-owner-lite-analytics/index.ts +7 -1
  11. package/templates/community-club-owner/tier5-src/himi-owner-lite-analytics/report.ts +690 -0
  12. package/templates/events-meetups-owner/dev/index.ts +858 -0
  13. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +59 -9
  14. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-analytics/index.ts +7 -1
  15. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-analytics/report.ts +690 -0
  16. package/templates/gym-fitness-owner/dev/index.ts +858 -0
  17. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +59 -9
  18. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-analytics/index.ts +7 -1
  19. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-analytics/report.ts +690 -0
  20. package/templates/nonprofit-cause-owner/dev/index.ts +858 -0
  21. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +59 -9
  22. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-analytics/index.ts +7 -1
  23. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-analytics/report.ts +690 -0
  24. package/templates/owner-lite/dev/index.ts +858 -0
  25. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +59 -9
  26. package/templates/owner-lite/tier5-src/himi-owner-lite-analytics/index.ts +7 -1
  27. package/templates/owner-lite/tier5-src/himi-owner-lite-analytics/report.ts +690 -0
  28. package/templates/podcast-creator-owner/dev/index.ts +858 -0
  29. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +59 -9
  30. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-analytics/index.ts +7 -1
  31. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-analytics/report.ts +690 -0
  32. package/templates/portfolio-creative-owner/dev/index.ts +858 -0
  33. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +59 -9
  34. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-analytics/index.ts +7 -1
  35. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-analytics/report.ts +690 -0
  36. package/templates/restaurant-owner/dev/index.ts +858 -0
  37. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +59 -9
  38. package/templates/restaurant-owner/tier5-src/himi-owner-lite-analytics/index.ts +7 -1
  39. package/templates/restaurant-owner/tier5-src/himi-owner-lite-analytics/report.ts +690 -0
  40. package/templates/retail-store-owner/dev/index.ts +858 -0
  41. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +59 -9
  42. package/templates/retail-store-owner/tier5-src/himi-owner-lite-analytics/index.ts +7 -1
  43. package/templates/retail-store-owner/tier5-src/himi-owner-lite-analytics/report.ts +690 -0
  44. package/templates/yoga-studio-owner/dev/index.ts +858 -0
  45. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +59 -9
  46. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-analytics/index.ts +7 -1
  47. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-analytics/report.ts +690 -0
@@ -0,0 +1,690 @@
1
+ // Tier-5 service: __HIMI_APP_NAME__ — Analytics REPORTS.
2
+ //
3
+ // The second service in this bundle. `index.ts` owns `ownerLiteAnalytics` (the
4
+ // six-measure highlights strip off `site-analytics/data`); this owns
5
+ // `ownerLiteAnalyticsReport`, which drives the public Semantic Model reporting
6
+ // engine. They share a bundle because they are one screen family and the
7
+ // contract scopes one built artifact; they share no state.
8
+ //
9
+ // WHY A GENERIC SCREEN. The Semantic Model API is a reporting engine, not an
10
+ // endpoint: a site exposes N subject-area models (traffic, revenue,
11
+ // `crm-people-subscribers`, ingested Google Search Console), each with its own
12
+ // dimensions and measures. One screen per model would need a release per model.
13
+ // This screen reads the list, so a model appearing on the site appears here with
14
+ // no code change.
15
+ //
16
+ // FOUR THINGS HERE ARE NOT ARBITRARY. Every one is a documented sharp edge of
17
+ // this API, and every one fails SILENTLY — a confident wrong number under a
18
+ // green suite, which is the exact species of defect this screen family has been
19
+ // paying down:
20
+ //
21
+ // 1. `missingFields` IS THE SCREEN'S POINT. A field name that does not exist
22
+ // in the model, or one whose `dependencies` were not also requested, is
23
+ // DROPPED FROM A 200 rather than rejected. A caller that ignores this
24
+ // renders a blank column and sees nothing wrong. So a non-empty
25
+ // `missingFields` is rendered as a named gap and never swallowed.
26
+ //
27
+ // 2. FIELD NAMES COME ONLY FROM `getSemanticModel`. They are not derivable
28
+ // from the model slug — `crm-people-subscribers` exposes
29
+ // `people.contacts_count`. Both pickers are populated from the model, and
30
+ // this worker never synthesises a field name.
31
+ //
32
+ // 3. THE ID COMES ONLY FROM `listSemanticModels`. A plausible GUID returns
33
+ // SEMANTIC_MODEL_NOT_FOUND, so `selectSubject` refuses an id it has not
34
+ // seen in the list rather than passing it through.
35
+ //
36
+ // 4. TOTALS ARE THE SERVER'S. We ask with `totalsIncluded` and render what
37
+ // comes back; we never sum the rows. Summing is wrong for any `unique`
38
+ // measure (unique visitors are not additive across buckets) and would
39
+ // double-count — plausibly, and in the direction that flatters.
40
+ //
41
+ // A fifth, inherited from #2356: a chart's `valuesPath` cannot be a `$item.`
42
+ // placeholder, because both native substituters stringify a non-scalar to "".
43
+ // This screen renders a DataTable rather than a per-row sparkline for that
44
+ // reason; do not add one to a row template.
45
+
46
+ import { defineService } from "@himalaya/state";
47
+ import { formatNumber } from "../_shared/engine-intl.js";
48
+ import { copyParam, stashCopy } from "../_shared/himi-copy.js";
49
+ import {
50
+ getSiteTimeZone,
51
+ intervalForDays,
52
+ listSemanticModels,
53
+ getSemanticModel,
54
+ querySemanticModelData,
55
+ type SemanticModel,
56
+ type SemanticModelField,
57
+ type SemanticModelSummary,
58
+ type SemanticValue,
59
+ } from "@himalaya/wix-analytics";
60
+
61
+ /** One row of a picker list. `state` is the trailing marker the descriptor binds. */
62
+ interface PickRow { id: string; label: string; state: string }
63
+
64
+ /** One table row, keyed by the descriptor's declared `DataTableColumn.key`s. */
65
+ interface ReportRow { dimension: string; measure: string }
66
+
67
+ interface ReportState {
68
+ status: "loading" | "ready" | "error";
69
+
70
+ /** Subject-area models this site actually has. Never a hardcoded list. */
71
+ subjects: PickRow[];
72
+ subjectId: string;
73
+ subjectLabel: string;
74
+
75
+ /** Populated from `getSemanticModel(subjectId)`, never from a fixture. */
76
+ dimensions: PickRow[];
77
+ measures: PickRow[];
78
+ dimensionName: string;
79
+ dimensionLabel: string;
80
+ measureName: string;
81
+ measureLabel: string;
82
+
83
+ rangeId: string;
84
+ range: string;
85
+ /**
86
+ * "<measure> by <dimension>" — what the table is actually showing.
87
+ *
88
+ * The table's own column titles are declared literals (the proto field is not
89
+ * bindable), so this is the only place the chosen field names can reach the
90
+ * screen. Without it the table reads as two anonymous columns of numbers.
91
+ */
92
+ caption: string;
93
+
94
+ rows: ReportRow[];
95
+ /**
96
+ * NULL, not an empty row, when the owner has not asked for totals.
97
+ *
98
+ * `totalsPath` binds ONE ROW OBJECT, and the table is shown as soon as there
99
+ * are rows — so publishing `{dimension:"",measure:""}` here handed the
100
+ * renderer a present-but-blank totals row on every ordinary query. Absence
101
+ * has to be expressed as absence.
102
+ */
103
+ totals: ReportRow | null;
104
+ rowCount: string;
105
+
106
+ /**
107
+ * Rendered text for a non-empty `missingFields`, "" when the query answered
108
+ * what was asked. NOT a boolean: the owner needs the field NAME, because the
109
+ * remedy differs — an unknown name is a bad pick, an unmet dependency is a
110
+ * query this screen built wrong.
111
+ */
112
+ missingFields: string;
113
+
114
+ // Owner-lite's four-state gates are all INVERSES: `hiddenWhen` collapses on
115
+ // truthy, so each flag says "hide me". Every owner-lite screen emits exactly
116
+ // this set and the descriptor tests assert on it by name.
117
+ notLoading: boolean;
118
+ notErrored: boolean;
119
+ hideContent: boolean;
120
+ noEmpty: boolean;
121
+ /** No subject chosen yet ⇒ the field pickers have nothing to offer. */
122
+ noFields: boolean;
123
+ /** No `missingFields` gap to report ⇒ the callout collapses entirely. */
124
+ noGap: boolean;
125
+ /** No rows ⇒ collapse the table rather than render a lone header row. */
126
+ hideTable: boolean;
127
+ /** Totals are opt-in; also true when the server returned none. */
128
+ noTotals: boolean;
129
+ totalsState: string;
130
+ emptyText: string;
131
+ error: string;
132
+ }
133
+
134
+ /** The ranges this screen offers. Ids are the day counts the reducer parses. */
135
+ const RANGES = [7, 30, 60];
136
+
137
+ const empty: ReportState = {
138
+ status: "loading",
139
+ subjects: [], subjectId: "", subjectLabel: "",
140
+ dimensions: [], measures: [],
141
+ dimensionName: "", dimensionLabel: "", measureName: "", measureLabel: "",
142
+ rangeId: "30", range: "Last 30 days", caption: "",
143
+ rows: [], totals: null, rowCount: "",
144
+ missingFields: "",
145
+ notLoading: false, notErrored: true, hideContent: true, noEmpty: true,
146
+ noFields: true, noGap: true, hideTable: true, noTotals: true, totalsState: "Off",
147
+ emptyText: "", error: "",
148
+ };
149
+
150
+ function rangeLabel(days: number): string {
151
+ return copyParam(`ownerReport.range${days}`, `Last ${days} days`);
152
+ }
153
+
154
+ /**
155
+ * A field's display label.
156
+ *
157
+ * `description` first, because the model's own prose is what a human reads;
158
+ * the wire `name` is a dotted path (`people.contacts_count`) and is shown only
159
+ * when there is nothing better. The `id` stays the wire name either way — that
160
+ * is what goes back in `fields`, and the two must not drift.
161
+ */
162
+ function fieldLabel(f: SemanticModelField): string {
163
+ const described = (f.description ?? "").trim();
164
+ if (described && described.length <= 40) return described;
165
+ const leaf = f.name.split(".").pop() ?? f.name;
166
+ return leaf.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
167
+ }
168
+
169
+ function subjectLabelOf(m: SemanticModelSummary): string {
170
+ const described = (m.description ?? "").trim();
171
+ if (described && described.length <= 40) return described;
172
+ return (m.slug || m.id).replace(/[-_]/g, " ").replace(/^./, (c) => c.toUpperCase());
173
+ }
174
+
175
+ /**
176
+ * One decoded cell as display text.
177
+ *
178
+ * `formatted` wins when the server sent it — it carries the site's currency and
179
+ * locale, which this worker cannot reconstruct. Otherwise: key presence has
180
+ * already been resolved by the SDK, so `null` here means genuinely no value and
181
+ * renders as a dash rather than as 0. `formatNumber` and not `toLocaleString`,
182
+ * which is ECMA-402 and absent on QuickJS-ng (watchOS/Wear OS).
183
+ */
184
+ function cellText(v: SemanticValue | undefined): string {
185
+ if (!v) return "—";
186
+ if (v.formatted !== undefined && v.formatted !== "") return v.formatted;
187
+ const raw = v.value;
188
+ if (raw === null || raw === undefined) return "—";
189
+ if (typeof raw === "number") return formatNumber(raw);
190
+ if (typeof raw === "boolean") return raw ? "Yes" : "No";
191
+ if (typeof raw === "string") return raw;
192
+ // A dimension can legitimately be an ARRAY or OBJECT field. Rendering
193
+ // `[object Object]` into a table cell is worse than admitting the shape, and
194
+ // "—" would claim there is no value when there is one this table cannot show.
195
+ if (Array.isArray(raw)) return copyParam("ownerReport.cellList", `${raw.length} values`);
196
+ return copyParam("ownerReport.cellObject", "(not shown)");
197
+ }
198
+
199
+ /**
200
+ * Read a string out of an action argument.
201
+ *
202
+ * The host dispatches with the `params` OBJECT, never a bare scalar, and a
203
+ * SegmentedControl carries no params of its own — the renderer injects the
204
+ * chosen option as `{ selectedId }` at dispatch time. A reducer that reads only
205
+ * one spelling ships an inert control that works under test. Same helper, same
206
+ * reason, as the highlights service.
207
+ */
208
+ function argStr(arg: unknown, field: string): string {
209
+ if (typeof arg === "string") return arg;
210
+ const o = arg as Record<string, unknown> | undefined;
211
+ const v = o?.[field] ?? o?.selectedId ?? o?.value ?? o?.id ?? o?.text;
212
+ return v === undefined || v === null ? "" : String(v);
213
+ }
214
+
215
+ /**
216
+ * Publish a state and record it as the newest.
217
+ *
218
+ * `latest` has to mean "the newest state the screen has been given", because
219
+ * every staleness decision reads it. It was only assigned on a SUCCESSFUL
220
+ * query, so a state published by a decline — the range chip tapped before any
221
+ * subject exists — left `latest` older than the caller's own snapshot, and the
222
+ * next action then treated the newer edit as the stale one. Everything that
223
+ * returns a state to the host goes through here.
224
+ */
225
+ function publish(next: ReportState): ReportState {
226
+ latest = next;
227
+ return next;
228
+ }
229
+
230
+ /**
231
+ * Generation guard. Tapping two subjects quickly starts two request chains and
232
+ * whichever RESOLVES last would win, leaving the picker, the table and the
233
+ * caption describing different queries. A superseded chain yields the newest
234
+ * state we have rather than its own stale snapshot.
235
+ */
236
+ let seq = 0;
237
+ let latest: ReportState | null = null;
238
+
239
+ /** The models this worker has actually seen. Guard rail 3 reads this. */
240
+ let known: SemanticModelSummary[] = [];
241
+ /**
242
+ * The chosen model's schema, KEYED to the subject it describes.
243
+ *
244
+ * It was a bare singleton, and the `seq` guard deliberately permits overlapping
245
+ * actions — so `selectSubject(B)` assigned it before B's query published, and a
246
+ * range/field/totals action still dispatching from A's snapshot would read B's
247
+ * schema together with A's `subjectId`. That builds a MIXED query (A's model id,
248
+ * B's field metadata — wrong dependencies, wrong sortability) and, worse, bumps
249
+ * `seq` and cancels the subject switch the owner just made. Keyed, a stale
250
+ * dispatch simply finds no schema for its own subject and declines to query.
251
+ */
252
+ let schema: { id: string; model: SemanticModel } | null = null;
253
+
254
+ /**
255
+ * The subject a switch is in flight for, with the GENERATION that owns the
256
+ * latch; null when none.
257
+ *
258
+ * Keying the schema closed the late half of the switch window (B's schema
259
+ * assigned, B's query unpublished). This closes the EARLY half: between the tap
260
+ * on B and `getSemanticModel(B)` resolving, the schema is still A's, so an
261
+ * action carrying A's snapshot matched, bumped `seq`, and made B's pending
262
+ * `selectSubject` fail its own generation check — the switch the owner just made
263
+ * was cancelled and the screen stayed on A. A latch is needed because the state
264
+ * alone cannot say "a different subject is already on its way".
265
+ *
266
+ * Keyed by `seq`, not by id alone. Keying by the id looked sufficient and was
267
+ * not: tap the SAME subject twice while the first `getSemanticModel` is pending,
268
+ * and the superseded first action's `finally` saw its own id sitting in the
269
+ * latch and released the one the SECOND request owned — re-opening the very
270
+ * window the latch exists to close. Only the generation that took it may
271
+ * release it.
272
+ */
273
+ let pendingSubject: { id: string; seq: number } | null = null;
274
+
275
+ /** The schema ONLY if it describes `subjectId`; otherwise null. */
276
+ function schemaFor(subjectId: string): SemanticModel | null {
277
+ return schema && schema.id === subjectId ? schema.model : null;
278
+ }
279
+
280
+ function markSelected(rows: PickRow[], selectedId: string): PickRow[] {
281
+ return rows.map((r) => ({
282
+ ...r,
283
+ state: r.id === selectedId ? copyParam("ownerReport.selected", "Selected") : "",
284
+ }));
285
+ }
286
+
287
+ function errorState(s: ReportState | undefined, message: string): ReportState {
288
+ return {
289
+ ...empty,
290
+ ...(s ?? {}),
291
+ status: "error",
292
+ notLoading: true,
293
+ notErrored: false,
294
+ hideContent: true,
295
+ rows: [], totals: null, rowCount: "", caption: "",
296
+ missingFields: "", noGap: true, hideTable: true,
297
+ error: message,
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Run the query for the current selection.
303
+ *
304
+ * Returns `s` untouched when the selection is incomplete — a query needs a
305
+ * model id AND at least one field, and `querySemanticModelData` rejects on
306
+ * either, so firing early would paint an error the owner did not cause.
307
+ */
308
+ /**
309
+ * Why a query did not run. `null` conflated these two and that was the bug: an
310
+ * INCOMPLETE selection means the caller's own edit is the newest thing there
311
+ * is and must be kept, while a STALE dispatch means something newer already
312
+ * exists and the caller's snapshot must not be republished over it.
313
+ */
314
+ type Declined = "incomplete" | "stale";
315
+
316
+ async function runQuery(s: ReportState, days: number): Promise<ReportState | Declined> {
317
+ // A string means DECLINED and says why; a state means a query ran, or was
318
+ // superseded and this is the WINNER's state. Three outcomes, because the
319
+ // right answer differs for each: keep the caller's edit, yield to what is
320
+ // newer, or take the result.
321
+ //
322
+ // Never a bare `s`. The host publishes whatever a reducer returns and `s` is
323
+ // the snapshot the dispatch was CREATED with, so republishing it reverts the
324
+ // screen to a state the owner has already left.
325
+ if (!s.subjectId || !s.dimensionName || !s.measureName) return "incomplete";
326
+
327
+ // A query is only safe to build from the schema of the subject in THIS state.
328
+ // Anything else is a mixed query, so a stale dispatch declines rather than
329
+ // guessing — and declining also leaves `seq` alone, so it cannot cancel a
330
+ // subject switch that is still in flight.
331
+ // A switch to a DIFFERENT subject is already under way: this dispatch belongs
332
+ // to the subject the owner is leaving, so it declines before touching `seq`.
333
+ // Advancing the generation here is what cancelled the switch.
334
+ if (pendingSubject && pendingSubject.id !== s.subjectId) return "stale";
335
+ // A subject IS selected in this snapshot but the schema is not its own, so
336
+ // the screen has moved on: stale, not incomplete.
337
+ const model = schemaFor(s.subjectId);
338
+ if (!model) return "stale";
339
+ const mine = ++seq;
340
+ const dimension = model.dimensions.find((f) => f.name === s.dimensionName);
341
+ const measure = model.measures.find((f) => f.name === s.measureName);
342
+
343
+ // DEPENDENCIES ARE REQUESTED, NOT ASSUMED. A field whose `dependencies` are
344
+ // absent from the same query returns no data and is dropped from the 200 —
345
+ // so omitting them would surface as a blank column, or (now) as a
346
+ // `missingFields` gap blaming the field the owner picked. Union, because a
347
+ // dependency can be shared and the API caps `fields` at 60.
348
+ const fields = [...new Set([
349
+ s.dimensionName,
350
+ s.measureName,
351
+ ...(dimension?.dependencies ?? []),
352
+ ...(measure?.dependencies ?? []),
353
+ ])].filter(Boolean);
354
+
355
+ try {
356
+ const tz = (await getSiteTimeZone()) ?? "UTC";
357
+ const result = await querySemanticModelData({
358
+ semanticModelId: s.subjectId,
359
+ interval: intervalForDays(days, tz),
360
+ fields,
361
+ // `nullsLast` only affects DESC, and without it a nullable measure sorts
362
+ // its NULLS FIRST — so a "top N" query returns a page of blanks and reads
363
+ // as a site with no data. Sorting is skipped entirely for a measure the
364
+ // model declares unsortable, rather than sent and silently ignored.
365
+ sort: measure?.sortable === false
366
+ ? undefined
367
+ : { fieldName: s.measureName, order: "DESC", nullsLast: true },
368
+ paging: { limit: 50 },
369
+ // The server's own formatting carries the site's currency and locale.
370
+ formattingEnabled: true,
371
+ // Opt-in totals. Asked for only when the owner wants them, and rendered
372
+ // from the RESPONSE — never summed here (guard rail 4).
373
+ totalsIncluded: !s.noTotals,
374
+ });
375
+ if (mine !== seq) return latest ?? s;
376
+
377
+ const rows: ReportRow[] = result.rows.map((r) => ({
378
+ dimension: cellText(r[s.dimensionName]),
379
+ measure: cellText(r[s.measureName]),
380
+ }));
381
+
382
+ // A non-empty `missingFields` means the query DID NOT ANSWER what was
383
+ // asked. It is reported as a failure of the query, with the names, and the
384
+ // rows that did come back are still shown — a blank column with no
385
+ // explanation is the one outcome this screen exists to prevent.
386
+ const gap = result.missingFields.length > 0
387
+ ? copyParam("ownerReport.missingFields", "No data returned for: ") + result.missingFields.join(", ")
388
+ : "";
389
+
390
+ // `noTotals` is the owner's REQUEST and stays theirs; only they change it.
391
+ // When they asked and the server sent no total, that is reported as a dash —
392
+ // an absent number the owner asked for must be visible, not silently dropped
393
+ // under a control still reading "On".
394
+ const totals: ReportRow | null = s.noTotals
395
+ ? null
396
+ : {
397
+ dimension: copyParam("ownerReport.totalsLabel", "Total"),
398
+ measure: result.totals ? cellText(result.totals[s.measureName]) : "—",
399
+ };
400
+
401
+ const out: ReportState = {
402
+ ...s,
403
+ status: "ready",
404
+ caption: copyParam("ownerReport.caption", "")
405
+ + `${s.measureLabel} ${copyParam("ownerReport.by", "by")} ${s.dimensionLabel}`,
406
+ rows,
407
+ totals,
408
+ rowCount: rows.length > 0
409
+ ? `${result.paging.count || rows.length} ${copyParam("ownerReport.rows", "rows")}`
410
+ : "",
411
+ missingFields: gap,
412
+ noGap: gap === "",
413
+ // Rows OR totals. The API can answer `results: []` WITH a `totals` object
414
+ // — the SDK has a test for exactly that shape — and gating on rows alone
415
+ // hid the server's total and put "Nothing to show" over it. Opting into
416
+ // Show total would then have thrown away the only answer that came back.
417
+ hideTable: rows.length === 0 && !totals,
418
+ notLoading: true,
419
+ notErrored: true,
420
+ hideContent: false,
421
+ // An empty result is a real answer — a quiet period, or a filter that
422
+ // matches nothing — and is shown as an empty state rather than an error.
423
+ // It is NOT conflated with a dropped field: the SDK reports no
424
+ // `missingFields` when nothing came back at all, precisely because the two
425
+ // are indistinguishable there, so this arm never accuses a field.
426
+ noEmpty: rows.length > 0 || totals !== null,
427
+ emptyText: rows.length === 0 && !totals
428
+ ? copyParam("ownerReport.empty", "No data for this range.")
429
+ : "",
430
+ // Carried through untouched: see the totals comment above.
431
+ noTotals: s.noTotals,
432
+ error: "",
433
+ };
434
+ latest = out;
435
+ return out;
436
+ } catch (e) {
437
+ if (mine !== seq) return latest ?? s;
438
+ const out = errorState(s, String((e as Error)?.message ?? e));
439
+ latest = out;
440
+ return out;
441
+ }
442
+ }
443
+
444
+ const svc = defineService<ReportState, {
445
+ load: (s: ReportState, arg?: unknown) => Promise<ReportState>;
446
+ selectSubject: (s: ReportState, arg?: unknown) => Promise<ReportState>;
447
+ selectDimension: (s: ReportState, arg?: unknown) => Promise<ReportState>;
448
+ selectMeasure: (s: ReportState, arg?: unknown) => Promise<ReportState>;
449
+ setRange: (s: ReportState, arg?: unknown) => Promise<ReportState>;
450
+ toggleTotals: (s: ReportState) => Promise<ReportState>;
451
+ }>("ownerLiteAnalyticsReport", {
452
+ state: { ...empty },
453
+ actions: {
454
+ /**
455
+ * Step 1 — which subject areas this site has. Also the error state's retry,
456
+ * so it re-reads the list rather than assuming the previous one survived.
457
+ */
458
+ load: async (s, arg) => {
459
+ stashCopy(arg);
460
+ const mine = ++seq;
461
+ try {
462
+ const models = await listSemanticModels();
463
+ if (mine !== seq) return latest ?? s ?? { ...empty };
464
+ known = models;
465
+ const subjects = models.map((m) => ({ id: m.id, label: subjectLabelOf(m), state: "" }));
466
+ const base: ReportState = {
467
+ ...empty,
468
+ ...(s ?? {}),
469
+ subjects,
470
+ subjectId: "", subjectLabel: "",
471
+ dimensions: [], measures: [],
472
+ dimensionName: "", dimensionLabel: "", measureName: "", measureLabel: "",
473
+ rows: [], totals: null, rowCount: "", missingFields: "",
474
+ // Reset too: it names the fields of a query that no longer has a
475
+ // subject, so leaving it stands a stale caption over an empty table.
476
+ caption: "",
477
+ noGap: true, hideTable: true,
478
+ status: "ready",
479
+ notLoading: true, notErrored: true, hideContent: false,
480
+ noFields: true, noEmpty: true, error: "",
481
+ emptyText: subjects.length === 0
482
+ ? copyParam("ownerReport.noSubjects", "This site has no analytics reports yet.")
483
+ : "",
484
+ };
485
+ if (subjects.length === 0) {
486
+ latest = { ...base, noEmpty: false };
487
+ return latest;
488
+ }
489
+ latest = base;
490
+ return base;
491
+ } catch (e) {
492
+ // The guard covers the FAILURE path too. Without it a stale rejection —
493
+ // the module-scope boot load, or an older retry — lands after a newer
494
+ // load or query has advanced `seq` and paints the error arm over a ready
495
+ // report. `runQuery`'s catch already did this; these two did not, which
496
+ // made the failure path inconsistent with the success path right beside
497
+ // it. (AI-review finding on #2533.)
498
+ if (mine !== seq) return latest ?? s ?? { ...empty };
499
+ const out = errorState(s, String((e as Error)?.message ?? e));
500
+ latest = out;
501
+ return out;
502
+ }
503
+ },
504
+
505
+ /**
506
+ * Step 2 — read the chosen model's schema and query it.
507
+ *
508
+ * The id is checked against the list this worker actually fetched. A
509
+ * fabricated or stale id returns SEMANTIC_MODEL_NOT_FOUND, and refusing it
510
+ * here keeps that out of the error state, where it would read as the site
511
+ * being broken rather than as a bad pick.
512
+ */
513
+ selectSubject: async (s, arg) => {
514
+ stashCopy(arg);
515
+ const id = argStr(arg, "id");
516
+ if (!known.some((m) => m.id === id)) return latest ?? s;
517
+ const mine = ++seq;
518
+ pendingSubject = { id, seq: mine };
519
+ try {
520
+ const model = await getSemanticModel(id);
521
+ if (mine !== seq) return latest ?? s;
522
+ schema = { id, model };
523
+ // First dimension and first measure, so the screen shows a real report
524
+ // rather than a third empty picker. Both are the model's own, in its own
525
+ // order — never inferred from the slug (guard rail 2).
526
+ const dimension = model.dimensions[0];
527
+ const measure = model.measures[0];
528
+ const chosen: ReportState = {
529
+ ...s,
530
+ subjectId: id,
531
+ subjectLabel: s.subjects.find((r) => r.id === id)?.label ?? subjectLabelOf(model),
532
+ subjects: markSelected(s.subjects, id),
533
+ dimensions: markSelected(
534
+ model.dimensions.map((f) => ({ id: f.name, label: fieldLabel(f), state: "" })),
535
+ dimension?.name ?? "",
536
+ ),
537
+ measures: markSelected(
538
+ model.measures.map((f) => ({ id: f.name, label: fieldLabel(f), state: "" })),
539
+ measure?.name ?? "",
540
+ ),
541
+ dimensionName: dimension?.name ?? "",
542
+ dimensionLabel: dimension ? fieldLabel(dimension) : "",
543
+ measureName: measure?.name ?? "",
544
+ measureLabel: measure ? fieldLabel(measure) : "",
545
+ noFields: false,
546
+ rows: [], totals: null, rowCount: "", missingFields: "",
547
+ noGap: true, hideTable: true,
548
+ };
549
+ // A model with no measures cannot be queried. Say so instead of firing a
550
+ // request the SDK will reject for having no fields.
551
+ if (!dimension || !measure) {
552
+ const out: ReportState = {
553
+ ...chosen,
554
+ noEmpty: false,
555
+ emptyText: copyParam("ownerReport.noFieldsForModel", "This report has no measures to chart."),
556
+ };
557
+ latest = out;
558
+ return out;
559
+ }
560
+ // The range from the NEWEST state, not from our snapshot. `setRange`
561
+ // deliberately does not advance `seq` when no subject exists, so a chip
562
+ // tapped while this schema fetch was in flight lives in `latest` and
563
+ // not in `s` — resuming with `s.rangeId` published a 30-day report and
564
+ // repainted the chip the owner had already moved to 7.
565
+ const days = parseInt((latest ?? s).rangeId, 10) || 30;
566
+ const ranged = { ...chosen, rangeId: String(days), range: rangeLabel(days) };
567
+ const ran = await runQuery(ranged, days);
568
+ return typeof ran === "string" ? publish(ranged) : ran;
569
+ } catch (e) {
570
+ // Same hole, same reason: tap subject A then B, and A's rejection
571
+ // arriving late must not replace B's selection with the error arm.
572
+ if (mine !== seq) return latest ?? s;
573
+ const out = errorState(s, String((e as Error)?.message ?? e));
574
+ latest = out;
575
+ return out;
576
+ } finally {
577
+ // Only the generation that TOOK the latch may release it — comparing
578
+ // the id instead let a superseded same-subject tap clear a newer
579
+ // request's latch.
580
+ if (pendingSubject && pendingSubject.seq === mine) pendingSubject = null;
581
+ }
582
+ },
583
+
584
+ /** Step 3 — re-query on a dimension pick. Field names come from the schema. */
585
+ selectDimension: async (s, arg) => {
586
+ stashCopy(arg);
587
+ const name = argStr(arg, "id");
588
+ const field = schemaFor(s.subjectId)?.dimensions.find((f) => f.name === name);
589
+ if (!field) return latest ?? s;
590
+ const picked = {
591
+ ...s,
592
+ dimensionName: name,
593
+ dimensionLabel: fieldLabel(field),
594
+ dimensions: markSelected(s.dimensions, name),
595
+ };
596
+ const ran = await runQuery(picked, parseInt(s.rangeId, 10) || 30);
597
+ // Stale: something newer is already on screen, so yield to it rather than
598
+ // republishing this pick over it. Incomplete: this pick IS the newest.
599
+ if (ran === "stale") return latest ?? s;
600
+ return ran === "incomplete" ? publish(picked) : ran;
601
+ },
602
+
603
+ /** Step 3 — re-query on a measure pick. */
604
+ selectMeasure: async (s, arg) => {
605
+ stashCopy(arg);
606
+ const name = argStr(arg, "id");
607
+ const field = schemaFor(s.subjectId)?.measures.find((f) => f.name === name);
608
+ if (!field) return latest ?? s;
609
+ const picked = {
610
+ ...s,
611
+ measureName: name,
612
+ measureLabel: fieldLabel(field),
613
+ measures: markSelected(s.measures, name),
614
+ };
615
+ const ran = await runQuery(picked, parseInt(s.rangeId, 10) || 30);
616
+ if (ran === "stale") return latest ?? s;
617
+ return ran === "incomplete" ? publish(picked) : ran;
618
+ },
619
+
620
+ /**
621
+ * The range chips. `selectedId` is what the renderer injects (see argStr).
622
+ *
623
+ * The chosen window is the owner's and STICKS, whether or not a query was
624
+ * possible. Before a subject is picked `runQuery` declines — correctly,
625
+ * there is nothing to query — and returning its result alone dropped the
626
+ * new `rangeId`: the chip moved to "7 days" and the state stayed on 30, so
627
+ * the first report the owner ran came back as 30 days under a chip reading
628
+ * 7. That is the inert-control class this bundle's sibling documents, and
629
+ * the derived crawl flagged it the moment it appeared.
630
+ *
631
+ * A dispatch from a subject the owner has LEFT is the one case that must
632
+ * not move the chip, since the chip now belongs to a different report.
633
+ */
634
+ setRange: async (s, arg) => {
635
+ const n = parseInt(argStr(arg, "selectedId") || "30", 10);
636
+ const days = RANGES.includes(n) ? n : 30;
637
+ if (latest && s.subjectId !== latest.subjectId) return latest;
638
+ const next = { ...s, rangeId: String(days), range: rangeLabel(days) };
639
+ const out = await runQuery(next, days);
640
+ if (out === "stale") return latest ?? s;
641
+ // INCOMPLETE: nothing to query yet, so the owner's chip sticks on the
642
+ // newest state. RAN or SUPERSEDED: `out` already carries the window that
643
+ // actually produced the rows on screen — stamping this tap's label onto a
644
+ // newer result would show the newer query's rows under the older chip.
645
+ return out === "incomplete"
646
+ ? publish({ ...(latest ?? next), rangeId: next.rangeId, range: next.range })
647
+ : out;
648
+ },
649
+
650
+ /**
651
+ * Totals are opt-in and come from the server.
652
+ *
653
+ * Re-queries rather than summing the rows on hand: a `unique` measure is not
654
+ * additive (the same visitor in two buckets is one visitor), so a local sum
655
+ * would over-report — silently, and upward.
656
+ */
657
+ toggleTotals: async (s) => {
658
+ const next = { ...s, noTotals: !s.noTotals };
659
+ next.totalsState = next.noTotals
660
+ ? copyParam("ownerReport.off", "Off")
661
+ : copyParam("ownerReport.on", "On");
662
+ // Same rule as the range chip: the owner's toggle sticks when there is
663
+ // nothing to re-query, and yields when the screen has moved on.
664
+ const ran = await runQuery(next, parseInt(s.rangeId, 10) || 30);
665
+ if (ran === "stale") return latest ?? s;
666
+ return ran === "incomplete" ? publish(next) : ran;
667
+ },
668
+ },
669
+ });
670
+
671
+ // Boot the list, at module scope, exactly as the highlights service does.
672
+ //
673
+ // This was originally left out on the reasoning that both services share a
674
+ // bundle, so a module-scope request here also fires when the HIGHLIGHTS screen
675
+ // is the one on screen — a wasted `listSemanticModels()` per open. The derived
676
+ // crawl rejected that, and it was right: it judges every service a bundle
677
+ // registers, on every screen that bundle serves, and a service sitting at
678
+ // `status: "loading"` forever is the stuck-skeleton class —
679
+ //
680
+ // service "ownerLiteAnalyticsReport" never left its loading state
681
+ //
682
+ // on both `himi_owner_lite_analytics` and `himi_owner_lite_analytics_measure`.
683
+ // The initial `loading` status is load-bearing for THIS screen's first paint
684
+ // (there is no intermediate publish; the skeleton is what the pre-load snapshot
685
+ // renders), so the state cannot simply start settled. One cheap list GET on a
686
+ // sibling screen is the honest price of a service that always settles.
687
+ svc.load();
688
+
689
+ export { svc };
690
+ export type { ReportState, ReportRow, PickRow };