@wix/himalaya-cli 0.923.0 → 0.925.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 (46) hide show
  1. package/dist/cli.mjs +2 -1
  2. package/package.json +2 -2
  3. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
  4. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
  5. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
  6. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
  7. package/templates/community-club-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
  8. package/templates/community-club-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
  9. package/templates/community-club-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
  10. package/templates/community-club-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
  11. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
  12. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
  13. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
  14. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
  15. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
  16. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
  17. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
  18. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
  19. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
  20. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
  21. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
  22. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
  23. package/templates/owner-lite/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
  24. package/templates/owner-lite/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
  25. package/templates/owner-lite/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
  26. package/templates/owner-lite/tier5-src/himi-owner-lite-ask/live.ts +98 -3
  27. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
  28. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
  29. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
  30. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
  31. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
  32. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
  33. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
  34. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
  35. package/templates/restaurant-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
  36. package/templates/restaurant-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
  37. package/templates/restaurant-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
  38. package/templates/restaurant-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
  39. package/templates/retail-store-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
  40. package/templates/retail-store-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
  41. package/templates/retail-store-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
  42. package/templates/retail-store-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
  43. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
  44. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
  45. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
  46. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
package/dist/cli.mjs CHANGED
@@ -5241,7 +5241,6 @@ function resolveTranslation(value, catalog, sourceCatalog) {
5241
5241
  var BINDING_SIGILS, BINDING_SIGIL_RE, TRANSLATION_PREFIX, TRANSLATION_KEY_RE;
5242
5242
  var init_bindings = __esm({
5243
5243
  "../../core/ts/src/bindings.ts"() {
5244
- "use strict";
5245
5244
  BINDING_SIGILS = ["service", "flag", "item", "t"];
5246
5245
  BINDING_SIGIL_RE = new RegExp(
5247
5246
  `^\\$(?:${BINDING_SIGILS.filter((s) => s !== "t").join("|")})\\b|^\\$t:`
@@ -18526,6 +18525,7 @@ function shouldRevealError(args) {
18526
18525
  var EMAIL_RE, URL_HOST_RE, INTEGER_RE, NUMBER_RE;
18527
18526
  var init_validation = __esm({
18528
18527
  "../../core/ts/src/validation.ts"() {
18528
+ "use strict";
18529
18529
  EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/;
18530
18530
  URL_HOST_RE = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*(:[0-9]+)?$/;
18531
18531
  INTEGER_RE = /^[+-]?[0-9]+$/;
@@ -21949,6 +21949,7 @@ function validateWorkerSubtree(raw, slotId, policy, actionCatalog) {
21949
21949
  var WORKER_SUBTREE_CEILINGS, ID_PART, ACTION_ID;
21950
21950
  var init_worker_subtree = __esm({
21951
21951
  "../../core/ts/src/worker-subtree.ts"() {
21952
+ "use strict";
21952
21953
  init_worker_subtree_safe_types_generated();
21953
21954
  WORKER_SUBTREE_CEILINGS = Object.freeze({
21954
21955
  maxEncodedBytes: 65536,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.923.0",
3
+ "version": "0.925.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -64,5 +64,5 @@
64
64
  "artifactId": "himalaya-cli"
65
65
  }
66
66
  },
67
- "falconPackageHash": "262349f863d8ab9c7b65ca75bd82e84b2c0943fc0e412bdfa9eeb40c"
67
+ "falconPackageHash": "f7e299bf4530163cb8143700d0c7ad83fed1966b5f6f73a47e960356"
68
68
  }
@@ -20,7 +20,7 @@ import type { EmptyStateProps } from "@himalaya/core";
20
20
  import type { AskBlock, AskPlan, Tone } from "./grammar.js";
21
21
  import { MAX_DETAIL_FIELDS, MAX_ROWS, MAX_STATS } from "./grammar.js";
22
22
  import {
23
- DETAIL_SCREEN, ENTITY_FIELDS, ENTITY_MEDIA, IMAGE_KEY, hasField, isEntity, titleFieldFor, type QueryResult,
23
+ DETAIL_SCREEN, ENTITY_FIELDS, ENTITY_MEDIA, IMAGE_KEY, STRICT_ENTITIES, hasField, isEntity, titleFieldFor, type QueryResult,
24
24
  } from "./catalog.js";
25
25
 
26
26
  /** Typed pass-through so `tsc` checks EmptyState's prop NAMES against the schema.
@@ -256,7 +256,94 @@ function statsBlock(
256
256
  .map((stat) => {
257
257
  const label = cleanCopy(stat.label);
258
258
  const named = queryNameOf(stat.valuePath);
259
- if (named && results[named]) return { label, path: rewritePath(stat.valuePath), tone: stat.tone };
259
+ if (named && results[named]) {
260
+ // A ROW-INDEXED stat (`$q.x.rows.0.value`) is only honest when that row
261
+ // exists AND is unambiguous. Two ways it used to lie, both silent:
262
+ //
263
+ // * The query returned NOTHING. `stats` is not in DATA_KINDS, so the
264
+ // empty-state swap below never fires for it — the card bound to a
265
+ // row that is not there and the renderer painted its "—" boot
266
+ // placeholder for ever. Every refusal path in this worker produces
267
+ // an empty result, so every refusal rendered as three dashes.
268
+ // * The query returned MANY. `rows.0` is then whichever row the
269
+ // fetch happened to build first, presented as the answer. For
270
+ // metrics that is always the 7-day sessions row, so an unnarrowed
271
+ // "how did sales do over 60 days" answered with 7-day traffic.
272
+ //
273
+ // Both are dropped with a named repair instead, which the owner sees.
274
+ // ANCHORED to the whole path, not a substring match on `.rows.<n>.`.
275
+ // An unanchored test matched anywhere, so `$q.traffic.groups.0.rows.0.value`
276
+ // entered the row branch, `field` came out as "value", `hasField` passed,
277
+ // and a binding was emitted through a `groups.0.rows` path the result does
278
+ // not have — recreating the blank stat this guard exists to prevent.
279
+ // The accepted shape is exactly `$q.<query>.rows.<n>.<field>`, and the
280
+ // capture groups give the index and the field directly rather than being
281
+ // sliced back out of the string.
282
+ const rowRef = /^\$q\.[A-Za-z][A-Za-z0-9_-]{0,63}\.rows\.(\d+)\.([A-Za-z][A-Za-z0-9_]*)$/
283
+ .exec(stat.valuePath ?? "");
284
+ const result = results[named];
285
+ // MATCHES, not `rows.length`. `runQuery` computes `count` BEFORE
286
+ // applying `limit` and slices `rows` after, so `limit:1` made a query
287
+ // that matched all three windows look like a single unambiguous row —
288
+ // and `rows.0` then rendered the 7-day figure as the answer to a
289
+ // 60-day question. Judging cardinality on the post-limit array is the
290
+ // same bug the guard was added to close, one layer down.
291
+ const matches = result.count;
292
+ const strict = STRICT_ENTITIES.includes(result.entity);
293
+ if (rowRef) {
294
+ const wanted = Number(rowRef[1]);
295
+ if (matches === 0) {
296
+ repairs.push(`stat "${label}": that query found nothing — dropped`);
297
+ return { label, path: "", tone: stat.tone };
298
+ }
299
+ // ANY index, not just 0. The invariant is "no row-indexed stat over
300
+ // a multi-row strict result" — `rows.1` of a broad metrics query is
301
+ // exactly as arbitrary as `rows.0`, and scoping the guard to index 0
302
+ // left `limit:2` as a way back in.
303
+ if (strict && matches > 1) {
304
+ repairs.push(`stat "${label}": ${matches} rows matched, so no single figure — dropped`);
305
+ return { label, path: "", tone: stat.tone };
306
+ }
307
+ if (wanted >= result.rows.length) {
308
+ repairs.push(`stat "${label}": row ${wanted} of ${result.rows.length} does not exist — dropped`);
309
+ return { label, path: "", tone: stat.tone };
310
+ }
311
+ // The FIELD has to exist too. `$q.traffic.rows.0.typo` is
312
+ // syntactically valid, so `rewritePath` happily emits a binding for
313
+ // it and the renderer draws a blank where the figure belongs — a
314
+ // missing number that reports itself as nothing rather than as a
315
+ // dropped figure. The declared contract is the authority, exactly as
316
+ // it is for a list's `titleField`.
317
+ const field = rowRef[2];
318
+ if (!hasField(result.entity, field)) {
319
+ repairs.push(`stat "${label}": no field "${field}" on ${result.entity || "that query"} — dropped`);
320
+ return { label, path: "", tone: stat.tone };
321
+ }
322
+ } else if (strict) {
323
+ // NOT A ROW PATH, on a strict entity — refused, whatever shape it is.
324
+ //
325
+ // This is an ALLOWLIST, and it is deliberately the third revision of
326
+ // this guard rather than a fourth special case. Each earlier version
327
+ // blocked the bad shape it had just been shown and left the next one
328
+ // open: `.count` was refused by name, so `$q.traffic.value` walked
329
+ // straight past it — `named` resolves, there is no row reference, it
330
+ // does not end in `.count`, and `rewritePath` emitted a binding for a
331
+ // top-level field the result does not have. Another blank where the
332
+ // figure belongs.
333
+ //
334
+ // A strict entity keeps its figure at `rows.<n>.<field>` and nowhere
335
+ // else, so that is the ONLY shape accepted. `.count` keeps its own
336
+ // message because it is the mistake the response schema actively
337
+ // invites, and saying "that is a row count" is more use than "wrong
338
+ // shape".
339
+ const why = /\.count$/.test(stat.valuePath ?? "")
340
+ ? ".count is a row count, not a measure"
341
+ : "a measure lives at rows.<n>.<field>, not at the top level";
342
+ repairs.push(`stat "${label}": ${why} — dropped`);
343
+ return { label, path: "", tone: stat.tone };
344
+ }
345
+ return { label, path: rewritePath(stat.valuePath), tone: stat.tone };
346
+ }
260
347
  const why = named ? `unknown query "${named}"` : "malformed valuePath";
261
348
  if (names.length === 1) {
262
349
  repairs.push(`stat "${label}": ${why} → bound to ${names[0]}`);
@@ -18,7 +18,7 @@ import {
18
18
  DEMO_PRODUCTS, DEMO_SERVICES, DEMO_ORDERS, DEMO_POSTS, DEMO_EVENTS, DEMO_APPOINTMENTS, demoOrderDate, demoAppointmentWindow,
19
19
  } from "../_shared/owner-preview-demo.js";
20
20
  import { ENTITIES, QUERY_LIMIT, type AskQuery, type EntityName } from "./grammar.js";
21
- import { compareText, formatWeekdayDateTime } from "../_shared/engine-intl.js";
21
+ import { compareText, formatWeekdayDateTime, formatNumber } from "../_shared/engine-intl.js";
22
22
 
23
23
  export type Row = Record<string, string>;
24
24
 
@@ -31,6 +31,13 @@ export const ENTITY_FIELDS: Record<EntityName, string[]> = {
31
31
  posts: ["id", "title", "status", "category", "published"],
32
32
  events: ["id", "title", "date", "location", "status", "registration"],
33
33
  appointments: ["id", "service", "client", "time", "status"],
34
+ // Site analytics. One row PER MEASURE, not per day: the row carries
35
+ // its own provenance, because the acceptance bar is that an answer "names the
36
+ // window it used and the measure it read" — carrying those as FIELDS makes
37
+ // that structural instead of something the prompt has to remember to say.
38
+ // `value` is the formatted figure a `stats` block binds via
39
+ // `$q.<name>.rows.0.value`; `amount` is the same number unformatted, for sorting.
40
+ metrics: ["id", "measure", "value", "amount", "window", "days"],
34
41
  };
35
42
 
36
43
  /**
@@ -58,6 +65,9 @@ export const ENTITY_FIELDS: Record<EntityName, string[]> = {
58
65
  export const ENTITY_MEDIA: Record<EntityName, { image?: string; badge?: string }> = {
59
66
  products: { image: "image" },
60
67
  services: {},
68
+ // A measure has no image and no status. `value` is the figure itself, not a
69
+ // badge — styling it as one would read as a state rather than a number.
70
+ metrics: {},
61
71
  orders: { badge: "payment" },
62
72
  posts: { image: "image", badge: "status" },
63
73
  events: { image: "image", badge: "status" },
@@ -77,14 +87,113 @@ export const DETAIL_SCREEN: Record<EntityName, string> = {
77
87
  posts: "himi_owner_lite_post",
78
88
  events: "himi_owner_lite_event",
79
89
  appointments: "",
90
+ // A measure has no per-record detail screen; `openEntity` already resolves an
91
+ // empty value to nothing rather than a dead push.
92
+ metrics: "",
80
93
  };
81
94
 
82
95
  /** Human label per entity, used in summaries and the "open" affordance. */
83
96
  const SINGULAR: Record<EntityName, string> = {
84
97
  products: "product", services: "service", orders: "order",
85
98
  posts: "post", events: "event", appointments: "appointment",
99
+ metrics: "measure",
86
100
  };
87
101
 
102
+ /**
103
+ * The measures Ask can answer for, and the windows it can answer over.
104
+ *
105
+ * Deliberately the SAME six measures and the SAME 7/30/60 windows as
106
+ * `himi_owner_lite_analytics`, off the same endpoint. That is what makes "the
107
+ * number can be reconciled against the Analytics screen" true by construction
108
+ * rather than by coincidence — two engines answering one question differently
109
+ * is the worst outcome an analytics feature can have.
110
+ *
111
+ * No 90-day window: the endpoint retains 62 days, so it was never servable.
112
+ */
113
+ export const METRIC_MEASURES: { key: string; api: string; label: string; money?: boolean }[] = [
114
+ { key: "sessions", api: "TOTAL_SESSIONS", label: "Site sessions" },
115
+ { key: "visitors", api: "TOTAL_UNIQUE_VISITORS", label: "Unique visitors" },
116
+ { key: "sales", api: "TOTAL_SALES", label: "Total sales", money: true },
117
+ { key: "orders", api: "TOTAL_ORDERS", label: "Total orders" },
118
+ { key: "forms", api: "TOTAL_FORMS_SUBMITTED", label: "Forms submitted" },
119
+ { key: "contacts", api: "CLICKS_TO_CONTACT", label: "Clicks to contact" },
120
+ ];
121
+
122
+ export const METRIC_WINDOWS = [7, 30, 60];
123
+
124
+ /**
125
+ * Shape fetched measurements into metric rows. PURE, and separate from the
126
+ * fetch in live.ts precisely so the rule below is testable without a network.
127
+ *
128
+ * A measure the site does not report is OMITTED, never zeroed. "0 sales" and
129
+ * "we have no sales figure" are different answers and only one of them is true
130
+ * — this is the same rule the Semantic Model expresses as `missingFields`, and
131
+ * it is the one that keeps a silent gap from rendering as a real zero.
132
+ */
133
+ export function metricRowsFrom(
134
+ windows: { days: number; data: { type?: string; total?: unknown; values?: { value?: unknown }[] }[] }[],
135
+ ): Row[] {
136
+ const rows: Row[] = [];
137
+ for (const { days, data } of windows) {
138
+ for (const m of METRIC_MEASURES) {
139
+ const found = data.find((d) => d?.type === m.api);
140
+ if (!found) continue;
141
+ // `total` is OPTIONAL on the wire and some measures arrive as the daily
142
+ // series alone. The Analytics screen sums the series in that case
143
+ // (`total()`, himi-owner-lite-analytics/index.ts:224); omitting the
144
+ // measure here instead would make the two screens disagree about the
145
+ // same window, which is the one thing reading this endpoint rules out.
146
+ const hasTotal = typeof found.total === "number";
147
+ // At least one FINITE NUMERIC point, not merely a non-empty array. A
148
+ // series of entries whose `value` is absent or non-numeric summed to 0,
149
+ // `Number.isFinite(0)` passed, and Ask rendered 0 as though the endpoint
150
+ // had reported it — reintroducing the exact omission-vs-reported-zero
151
+ // confusion this rule exists to prevent, one layer in.
152
+ const points = (found.values ?? [])
153
+ .map((v) => v?.value)
154
+ .filter((v): v is number => typeof v === "number" && Number.isFinite(v));
155
+ if (!hasTotal && points.length === 0) continue;
156
+ const total = hasTotal
157
+ ? (found.total as number)
158
+ : points.reduce((sum, v) => sum + v, 0);
159
+ // A NaN/Infinity scalar total is not a figure either.
160
+ if (!Number.isFinite(total)) continue;
161
+ rows.push({
162
+ id: `${m.key}-${days}`,
163
+ measure: m.label,
164
+ // Rounded EXACTLY as himi_owner_lite_analytics rounds it, so the two
165
+ // screens cannot disagree in the last digit about the same measure.
166
+ // NO currency symbol, and that is not an omission. The Analytics
167
+ // screen's fmtMoney is `formatNumber(round(n*100)/100)` with no symbol
168
+ // (himi-owner-lite-analytics/index.ts:241). Adding one here would make
169
+ // the two screens render the SAME number differently — breaking the
170
+ // reconciliation this endpoint choice exists to guarantee — and would
171
+ // show a euro sign to a USD, GBP or ILS site. Every other money value
172
+ // in this bundle comes from the API's own formattedAmount for that
173
+ // same reason.
174
+ value: m.money
175
+ ? formatNumber(Math.round(total * 100) / 100)
176
+ : String(Math.round(total)),
177
+ // The same number unformatted, so orderBy:amount sorts numerically
178
+ // rather than lexically ("9" > "1,240" as text).
179
+ amount: String(total),
180
+ window: `Last ${days} days`,
181
+ days: String(days),
182
+ });
183
+ }
184
+ }
185
+ return rows;
186
+ }
187
+
188
+ /**
189
+ * Entities whose queries must FAIL rather than broaden (see `runQuery`).
190
+ *
191
+ * The test is "would an over-broad answer still look like an answer?" A list
192
+ * shows its rows and the owner can see it is wider than they asked. A single
193
+ * figure shows one number and nothing about it says which window produced it.
194
+ */
195
+ export const STRICT_ENTITIES: readonly string[] = ["metrics"];
196
+
88
197
  export function isEntity(name: string): name is EntityName {
89
198
  return (ENTITIES as readonly string[]).includes(name);
90
199
  }
@@ -93,6 +202,7 @@ export function isEntity(name: string): name is EntityName {
93
202
  const TITLE_FIELD: Record<EntityName, string> = {
94
203
  products: "name", services: "name", orders: "number",
95
204
  posts: "title", events: "title", appointments: "service",
205
+ metrics: "measure",
96
206
  };
97
207
 
98
208
  export function titleFieldFor(entity: EntityName): string {
@@ -114,6 +224,14 @@ export function singular(entity: EntityName): string {
114
224
  * (web preview, RFC 0049). On a device this is never what the owner sees. */
115
225
  export function demoRows(entity: EntityName): Row[] {
116
226
  switch (entity) {
227
+ // NO sample metrics, and this is the point rather than an omission.
228
+ // A sample product reads as a sample product; a sample "1,240 sessions"
229
+ // is indistinguishable from the owner's real traffic, and inventing one
230
+ // is the single failure this screen family refuses — the Analytics screen
231
+ // deliberately has no demo mode for the same reason. An empty result
232
+ // renders the honest empty block instead.
233
+ case "metrics":
234
+ return [];
117
235
  case "products":
118
236
  return DEMO_PRODUCTS.map((p) => ({
119
237
  id: p.id, name: p.name, price: p.price, sku: p.sku,
@@ -194,8 +312,10 @@ function matches(row: Row, field: string, op: string, value: string): boolean {
194
312
  }
195
313
 
196
314
  /** Execute one declared query against ALREADY-LOADED rows. Unknown entity →
197
- * EMPTY_RESULT; unknown field in a where/orderBy/groupBy → that clause is
198
- * ignored (fail-soft, never guess).
315
+ * EMPTY_RESULT. Unknown field in a where/orderBy/groupBy → that clause is
316
+ * ignored (fail-soft, never guess) for an ordinary entity, and the whole query
317
+ * returns empty for a STRICT_ENTITIES one, where broadening would read as an
318
+ * answer rather than as a wider list.
199
319
  *
200
320
  * Pure and synchronous BY DESIGN: fetching is `loadEntityRows`, one call per
201
321
  * distinct entity per turn (`index.ts`), so three queries over `orders` cost
@@ -205,14 +325,45 @@ export function runQuery(query: AskQuery, all: Row[]): QueryResult {
205
325
  if (!isEntity(query.entity)) return EMPTY_RESULT;
206
326
  const fields = ENTITY_FIELDS[query.entity];
207
327
 
328
+ // STRICT ENTITIES FAIL CLOSED ON AN UNKNOWN CLAUSE.
329
+ //
330
+ // The default below is permissive: an undeclared `where` field is skipped and
331
+ // the query BROADENS. For a product list that is a mildly over-wide list. For
332
+ // a measure it is a total for the wrong window rendered as the answer — and
333
+ // invisibly, because nothing records the skip (`QueryResult` has no repair
334
+ // channel, and `repairs`/`dropped` are populated only in blocks.ts, which
335
+ // never sees the clause). Worse, `summary` below reads `filtered` off
336
+ // `count !== all.length`, so a dropped filter makes it say "12 orders"
337
+ // instead of "3 of 12", concealing that a filter was asked for at all.
338
+ //
339
+ // That is precisely the "confident wrong number" this screen family exists to
340
+ // refuse, so `metrics` refuses instead: an unsatisfiable clause yields NO
341
+ // rows, and the block renders its honest empty state.
342
+ const strict = STRICT_ENTITIES.includes(query.entity);
343
+ /** A strict refusal, worded so it reaches the provenance line. An empty
344
+ * `summary` contributes nothing to `index.ts`'s note, which would leave a
345
+ * refusal indistinguishable on screen from the broadening it replaced. */
346
+ const refuse = (why: string): QueryResult => ({
347
+ ...EMPTY_RESULT, entity: query.entity, summary: `no ${SINGULAR[query.entity]} matched ${"—"} ${why}`,
348
+ });
349
+
350
+ // A clause the GRAMMAR could not parse never reaches the loop below, so it
351
+ // has to be refused here or the query silently runs unfiltered.
352
+ if (strict && query.malformed) return refuse("that filter isn't expressible");
353
+
208
354
  let rows = all;
209
355
  for (const clause of query.where ?? []) {
210
- if (!fields.includes(clause.field)) continue;
356
+ if (!fields.includes(clause.field)) {
357
+ if (strict) return refuse(`no such field "${clause.field}"`);
358
+ continue;
359
+ }
211
360
  rows = rows.filter((row) => matches(row, clause.field, clause.op ?? "eq", clause.value));
212
361
  }
213
362
  const count = rows.length;
214
363
 
215
364
  const order = query.orderBy;
365
+ if (order && strict && !fields.includes(order.field)) return refuse(`no such field "${order.field}"`);
366
+ if (query.groupBy && strict && !fields.includes(query.groupBy)) return refuse(`no such field "${query.groupBy}"`);
216
367
  if (order && fields.includes(order.field)) {
217
368
  const dir = order.direction === "asc" ? 1 : -1;
218
369
  rows = rows.slice().sort((a, b) => {
@@ -22,7 +22,7 @@
22
22
  // what doesn't hold). Robustness beats schema elegance at the model boundary.
23
23
 
24
24
  /** Entity names the model may query. Mirrors ENTITY_FIELDS in catalog.ts. */
25
- export const ENTITIES = ["products", "services", "orders", "posts", "events", "appointments"] as const;
25
+ export const ENTITIES = ["products", "services", "orders", "posts", "events", "appointments", "metrics"] as const;
26
26
  export type EntityName = (typeof ENTITIES)[number];
27
27
 
28
28
  /** The action ids a GENERATED tree may reference. Deliberately narrower than the
@@ -84,6 +84,10 @@ export interface AskWhere {
84
84
  }
85
85
 
86
86
  export interface AskQuery {
87
+ /** A `where` segment the grammar could not parse. Fail-soft entities ignore
88
+ * it; STRICT ones refuse, because a filter that silently disappeared is the
89
+ * difference between the answer asked for and a wider one. */
90
+ malformed?: boolean;
87
91
  /** Referenced by blocks as `$q.<name>.…`. */
88
92
  name: string;
89
93
  entity: EntityName;
@@ -118,11 +122,24 @@ export function parseQueryLine(line: unknown): AskQuery | null {
118
122
  if (colon < 0) continue;
119
123
  const key = segment.slice(0, colon).trim().toLowerCase();
120
124
  const value = segment.slice(colon + 1).trim();
121
- if (!value) continue;
125
+ if (!value) {
126
+ // An empty value on a NARROWING key is a filter the model meant to apply
127
+ // and did not manage to express — `where:` with nothing after it. Skipping
128
+ // it silently (as this did) drops the constraint before `runQuery` can see
129
+ // it, which is the same hole the unparseable-segment case had: a strict
130
+ // entity cannot refuse what never reached it, so the query broadens.
131
+ if (key === "where" || key === "orderby" || key === "groupby") query.malformed = true;
132
+ continue;
133
+ }
122
134
  if (key === "where") {
123
135
  // field=value | field!=value | field~value (contains)
124
136
  const match = value.match(/^([A-Za-z][A-Za-z0-9_]*)\s*(!=|~|=)\s*(.+)$/);
125
- if (!match) continue;
137
+ // A segment this grammar cannot express (`days>=30`, `days > 30`,
138
+ // `measure contains sessions`) used to vanish here, BEFORE `runQuery`
139
+ // could see it — so a strict entity could not refuse what it never knew
140
+ // was asked for, and the query ran unfiltered. Record it instead; the
141
+ // fail-soft entities still ignore it exactly as before.
142
+ if (!match) { query.malformed = true; continue; }
126
143
  const op = match[2] === "!=" ? "ne" : match[2] === "~" ? "contains" : "eq";
127
144
  (query.where ??= []).push({ field: match[1], op, value: match[3] });
128
145
  } else if (key === "groupby") {
@@ -228,7 +245,14 @@ export const ASK_RESPONSE_SCHEMA: Record<string, unknown> = {
228
245
  label: { type: "string" },
229
246
  // Descriptions carry real weight in structured output — without
230
247
  // these the model invented its own path syntax (`@queries[0]`).
231
- valuePath: { type: "string", description: "Must be exactly $q.<queryName>.count" },
248
+ valuePath: {
249
+ type: "string",
250
+ description:
251
+ "Either $q.<queryName>.count (how many rows matched) or "
252
+ + "$q.<queryName>.rows.0.<field> (a field of the single matching row). "
253
+ + "For a metrics query ALWAYS use rows.0.value — count is the number of "
254
+ + "matching rows, not the measurement, and is never the answer.",
255
+ },
232
256
  tone: { type: "string", enum: [...TONES] },
233
257
  },
234
258
  },
@@ -291,7 +315,9 @@ export const ASK_SYSTEM_PROMPT = [
291
315
  " (where also supports field!=value and field~value for contains). Give each a short `name`.",
292
316
  "2. Reference query results from blocks with `$q.<name>.<field>`, where <field> is",
293
317
  " one of: count (a number), rows (the records), groups (present only when the",
294
- " query sets groupBy), summary (a short human string).",
318
+ " query sets groupBy), summary (a short human string). A single row's field is",
319
+ " `$q.<name>.rows.0.<field>` — this is how a stats block shows a measure,",
320
+ " because count is a ROW COUNT and is never the figure a measure question wants.",
295
321
  "3. NEVER put record values, names, numbers or dates in your output. Those come",
296
322
  " from the query at render time. Only labels, headings and explanatory prose",
297
323
  " are yours to write. Inventing an order number or a customer name is a bug.",
@@ -311,6 +337,25 @@ export const ASK_SYSTEM_PROMPT = [
311
337
  "- callout — the finding itself when it needs to outrank the data ('2 unpaid orders').",
312
338
  "- note — explanation or a caveat.",
313
339
  "",
340
+ "Analytics questions (traffic, visitors, sales, orders, forms, clicks to contact):",
341
+ "use the `metrics` entity. It holds ONE ROW PER MEASURE PER WINDOW, and the only",
342
+ "windows that exist are days=7, days=30 and days=60 — the endpoint retains 62 days,",
343
+ "so anything longer has no answer and you must say so rather than approximate it.",
344
+ "Select with BOTH a measure and a window, e.g.:",
345
+ ' "traffic;metrics;where:measure=Site sessions;where:days=30;limit:1"',
346
+ "then bind the figure with `$q.traffic.rows.0.value`, and name the window and the",
347
+ "measure in your own label — `$q.traffic.rows.0.window` and `.measure` carry them,",
348
+ "so the owner can reconcile the number against the Analytics screen.",
349
+ "To compare measures, order by `amount` and never by `value`: `value` is the",
350
+ "display string (\"1,240\"), and sorting it numerically stops at the group",
351
+ "separator, so it would rank 1,240 below 9. `amount` is the same number raw.",
352
+ "NEVER write a date into a metrics query. `where:days=30` is relative and stays",
353
+ "correct when this plan is replayed tomorrow; `where:date=2026-09-21` would be",
354
+ "replayed as yesterday's window while the screen says the figures are live.",
355
+ "If the question needs a measure or a window that is not in the list above, say so",
356
+ "in a `note` and do not substitute a nearer one — a number for a window the owner",
357
+ "did not ask about is worse than telling them it is not available.",
358
+ "",
314
359
  "Proposing a change: set onTap.verb on a list block when the owner would",
315
360
  "plausibly want to act on those rows (`delete` for junk, `hide` for a product",
316
361
  "that shouldn't be on sale). You are only SUGGESTING it — the app asks the",
@@ -26,8 +26,12 @@ import { queryServices, queryBookings, deleteService, type WixService, type WixB
26
26
  import { queryPosts, queryDraftPosts } from "@himalaya/wix-blog";
27
27
  import { queryEvents, type WixEvent } from "@himalaya/wix-events";
28
28
  import { QUERY_LIMIT, type EntityName } from "./grammar.js";
29
- import { demoRows, type Row } from "./catalog.js";
29
+ import { demoRows, metricRowsFrom, METRIC_MEASURES, METRIC_WINDOWS, type Row } from "./catalog.js";
30
30
  import { formatMonthDay } from "../_shared/engine-intl.js";
31
+ import {
32
+ getSiteAnalytics, getSiteTimeZone, localDateRange,
33
+ type WixAnalyticsMeasurement, type WixMeasure,
34
+ } from "@himalaya/wix-analytics";
31
35
 
32
36
  //
33
37
  // One fetch per entity, mapped onto the DECLARED fields. Deliberately flatter
@@ -207,8 +211,74 @@ function bookingRow(b: WixBooking): Row {
207
211
 
208
212
  /* eslint-enable @typescript-eslint/no-explicit-any */
209
213
 
214
+ /**
215
+ * WHY EVERY COMBINATION IS FETCHED, rather than the one the question asked for.
216
+ *
217
+ * `fetchRows` receives an entity and nothing else — the query's `where` clauses
218
+ * are applied CLIENT-SIDE by `runQuery` afterwards. For a product list that is
219
+ * harmless. For a measure it would be a disaster: the window is a SERVER-side
220
+ * parameter, so a `where:window=…` clause filtered client-side over rows fetched
221
+ * for some other window would answer the wrong period while looking right.
222
+ *
223
+ * So every (measure × window) pair is materialised — 6 × 3 = 18 rows, far under
224
+ * QUERY_LIMIT — and `where:days=…` then selects among rows that are each
225
+ * genuinely what they claim. A window we do not serve matches no row, and
226
+ * `metrics` being strict (STRICT_ENTITIES) makes an unknown field refuse
227
+ * outright rather than broaden.
228
+ *
229
+ * Shaping lives in catalog.ts (`metricRowsFrom`) so it is testable without a
230
+ * network; this function is only the fetch.
231
+ */
232
+ async function metricRows(): Promise<{ rows: Row[]; failed: number[] }> {
233
+ // The SITE's zone on every query. The endpoint buckets by whatever zone the
234
+ // query names and defaults to UTC, so an owner in UTC+3 asking at 01:00 would
235
+ // otherwise see three hours credited to yesterday — plausibly, and silently.
236
+ // A FAILED lookup rejects into loadEntityRows' catch; only an ABSENT zone
237
+ // legitimately falls back to UTC, which is the API's own default.
238
+ const tz = (await getSiteTimeZone()) ?? "UTC";
239
+ const types = METRIC_MEASURES.map((m) => m.api as WixMeasure);
240
+
241
+ // Per-window, NOT all-or-nothing. `Promise.all` rejects whole on one failure,
242
+ // which sent every measure to "metrics couldn't be read" — including the
243
+ // window the owner actually asked about, whose request had succeeded. The
244
+ // Analytics screen faced the same choice for its comparison window and rode
245
+ // an ok-flag with the response rather than letting one failure poison the
246
+ // rest (himi-owner-lite-analytics/index.ts:408). Same here. A window that
247
+ // fails simply has no rows, so `where:days=<it>` finds nothing and the
248
+ // answer is refused for THAT window only.
249
+ const windows = await Promise.all(METRIC_WINDOWS.map(async (days) => {
250
+ // End-EXCLUSIVE: passing today as the end returns days-1 under an "n days"
251
+ // label. `localDateRange` also reports the zone it could actually honour
252
+ // (UTC where there is no ECMA-402), and we send THAT.
253
+ const { startDate, endDate, effectiveZone } = localDateRange(days, tz);
254
+ try {
255
+ const res = await getSiteAnalytics({ startDate, endDate, measurementTypes: types, timeZone: effectiveZone });
256
+ return { days, data: (res.data ?? []) as WixAnalyticsMeasurement[], ok: true };
257
+ } catch {
258
+ // Swallowing this to `[]` made a transient, auth or server failure
259
+ // indistinguishable from a site with genuinely no traffic: the rows were
260
+ // simply missing and the turn reported no error at all. The window is
261
+ // carried out as FAILED so the provenance can say which one could not be
262
+ // read, while the windows that succeeded still answer.
263
+ return { days, data: [] as WixAnalyticsMeasurement[], ok: false };
264
+ }
265
+ }));
266
+
267
+ return {
268
+ rows: metricRowsFrom(windows.filter((w) => w.ok)),
269
+ failed: windows.filter((w) => !w.ok).map((w) => w.days),
270
+ };
271
+ }
272
+
210
273
  async function fetchRows(entity: EntityName): Promise<Row[]> {
211
274
  switch (entity) {
275
+ // Unreachable: `loadEntityRows` branches on metrics BEFORE calling this,
276
+ // because a metric fetch has a second return channel (which windows
277
+ // failed) that `Promise<Row[]>` cannot carry. The arm stays so the switch
278
+ // remains exhaustive over EntityName — that exhaustiveness is what forces
279
+ // a new entity to be implemented rather than silently returning nothing.
280
+ case "metrics":
281
+ throw new Error("metrics rows load through loadEntityRows, not fetchRows");
212
282
  case "orders": {
213
283
  const res = await searchOrders({
214
284
  search: { filter: { archived: false }, sort: [{ fieldName: "createdDate", order: "DESC" }], cursorPaging: { limit: QUERY_LIMIT } },
@@ -263,11 +333,36 @@ async function fetchRows(entity: EntityName): Promise<Row[]> {
263
333
  * An empty LIVE vertical returns [] and stays empty: "you have no unpaid
264
334
  * orders" is a correct answer, and faking rows to avoid it would be a lie. */
265
335
  export async function loadEntityRows(entity: EntityName): Promise<{ rows: Row[]; demo: boolean; error: string }> {
266
- if (isOwnerPreviewDemo()) return { rows: demoRows(entity), demo: true, error: "" };
336
+ // `metrics` opts out of BOTH sample arms, not just the bridge one. Returning
337
+ // zero rows flagged `demo:true` made the turn announce "sample data — sign in
338
+ // as the site owner…" while nothing had been read and no error was reported:
339
+ // the owner was told samples were being shown when none existed.
340
+ if (isOwnerPreviewDemo()) {
341
+ return entity === "metrics"
342
+ ? { rows: [], demo: false, error: "analytics needs your real site" }
343
+ : { rows: demoRows(entity), demo: true, error: "" };
344
+ }
267
345
  try {
346
+ if (entity === "metrics") {
347
+ const { rows, failed } = await metricRows();
348
+ return {
349
+ rows,
350
+ demo: false,
351
+ // Partial availability is reported, not hidden. A window that could not
352
+ // be read is NOT a window with no traffic, and the difference is the
353
+ // whole point of this entity.
354
+ error: failed.length
355
+ ? `analytics for the ${failed.join(" and ")} day window${failed.length > 1 ? "s" : ""} couldn't be read`
356
+ : "",
357
+ };
358
+ }
268
359
  return { rows: await fetchRows(entity), demo: false, error: "" };
269
360
  } catch (e) {
270
- if (isBridgeUnavailable(e)) return { rows: demoRows(entity), demo: true, error: "" };
361
+ // `metrics` opts OUT of the sample fallback. For every other entity a
362
+ // labelled demo row keeps the web preview useful; for a measure it would be
363
+ // fabricated traffic and revenue, which is the one thing this screen family
364
+ // refuses. It takes the error arm below instead, exactly as a timeout does.
365
+ if (isBridgeUnavailable(e) && entity !== "metrics") return { rows: demoRows(entity), demo: true, error: "" };
271
366
  return { rows: [], demo: false, error: `${entity} couldn't be read` };
272
367
  }
273
368
  }