@wix/himalaya-cli 0.1038.0 → 0.1040.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 (89) hide show
  1. package/dist/cli.mjs +1950 -1220
  2. package/dist/preview-assets.generated.bin +0 -0
  3. package/guides/designing-himi-screens.md +3 -2
  4. package/guides/getting-started.md +3 -2
  5. package/guides/headless-loop.md +4 -3
  6. package/guides/himi-skill.md +381 -0
  7. package/guides/index.json +4 -0
  8. package/guides/recipe-dynamic-list.md +3 -3
  9. package/guides/recipe-form-submit.md +3 -3
  10. package/guides/recipe-onboarding.md +3 -3
  11. package/guides/recipe-search.md +3 -3
  12. package/guides/skill.md +122 -0
  13. package/guides/wix-site-to-himi-app.md +4 -2
  14. package/package.json +2 -2
  15. package/templates/beauty-salon/dev/index.ts +58 -22
  16. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +144 -29
  17. package/templates/beauty-salon-owner/config.ts +1 -1
  18. package/templates/beauty-salon-owner/dev/index.ts +507 -35
  19. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +237 -4
  20. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  21. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  22. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  23. package/templates/branded-lite/dev/index.ts +61 -25
  24. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +144 -29
  25. package/templates/community-club-owner/config.ts +1 -1
  26. package/templates/community-club-owner/dev/index.ts +507 -35
  27. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +237 -4
  28. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  29. package/templates/community-club-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  30. package/templates/community-club-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  31. package/templates/events-meetups-owner/config.ts +1 -1
  32. package/templates/events-meetups-owner/dev/index.ts +507 -35
  33. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +237 -4
  34. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  35. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  36. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  37. package/templates/gym-fitness/dev/index.ts +58 -22
  38. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +144 -29
  39. package/templates/gym-fitness-owner/config.ts +1 -1
  40. package/templates/gym-fitness-owner/dev/index.ts +507 -35
  41. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +237 -4
  42. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  43. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  44. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  45. package/templates/nonprofit-cause-owner/config.ts +1 -1
  46. package/templates/nonprofit-cause-owner/dev/index.ts +507 -35
  47. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +237 -4
  48. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  49. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  50. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  51. package/templates/owner-lite/config.ts +1 -1
  52. package/templates/owner-lite/dev/index.ts +507 -35
  53. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +237 -4
  54. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  55. package/templates/owner-lite/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  56. package/templates/owner-lite/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  57. package/templates/podcast-creator-owner/config.ts +1 -1
  58. package/templates/podcast-creator-owner/dev/index.ts +507 -35
  59. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +237 -4
  60. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  61. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  62. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  63. package/templates/portfolio-creative-owner/config.ts +1 -1
  64. package/templates/portfolio-creative-owner/dev/index.ts +507 -35
  65. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +237 -4
  66. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  67. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  68. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  69. package/templates/restaurant-owner/config.ts +1 -1
  70. package/templates/restaurant-owner/dev/index.ts +507 -35
  71. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +237 -4
  72. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  73. package/templates/restaurant-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  74. package/templates/restaurant-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  75. package/templates/retail-store/dev/index.ts +1 -1
  76. package/templates/retail-store-owner/config.ts +1 -1
  77. package/templates/retail-store-owner/dev/index.ts +507 -35
  78. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +237 -4
  79. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  80. package/templates/retail-store-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  81. package/templates/retail-store-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  82. package/templates/yoga-studio/dev/index.ts +58 -22
  83. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +144 -29
  84. package/templates/yoga-studio-owner/config.ts +1 -1
  85. package/templates/yoga-studio-owner/dev/index.ts +507 -35
  86. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +237 -4
  87. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  88. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  89. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
@@ -24629,6 +24629,44 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
24629
24629
  }
24630
24630
  }
24631
24631
  },
24632
+ {
24633
+ "id": "himi_time.days",
24634
+ "body": {
24635
+ "package": "himalaya",
24636
+ "type": "FilterChips",
24637
+ "props": {
24638
+ "chips": [],
24639
+ "optionsPath": "$service:himiBookingTime.days",
24640
+ "selectedIds": [
24641
+ "$service:himiBookingTime.selectedDay"
24642
+ ],
24643
+ "multiSelect": false,
24644
+ "onChange": {
24645
+ "actionId": "tier5.himiBookingTime.selectDay"
24646
+ }
24647
+ }
24648
+ },
24649
+ "modifiers": {
24650
+ "accessibilityLabel": "Choose a day",
24651
+ "hiddenPath": "$service:himiBookingTime.noWindowSlots"
24652
+ }
24653
+ },
24654
+ {
24655
+ "id": "himi_time.day_heading",
24656
+ "body": {
24657
+ "package": "himalaya",
24658
+ "type": "Text",
24659
+ "props": {
24660
+ "text": "$service:himiBookingTime.selectedDayLabel",
24661
+ "style": "title3",
24662
+ "maxLines": 1,
24663
+ "collapseWhenEmpty": true
24664
+ }
24665
+ },
24666
+ "modifiers": {
24667
+ "hiddenPath": "$service:himiBookingTime.noWindowSlots"
24668
+ }
24669
+ },
24632
24670
  {
24633
24671
  "id": "himi_time.slots",
24634
24672
  "body": {
@@ -24648,27 +24686,6 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
24648
24686
  "spacing": 0,
24649
24687
  "alignment": "leading",
24650
24688
  "children": [
24651
- {
24652
- "id": "himi_time.day_header",
24653
- "body": {
24654
- "package": "himalaya",
24655
- "type": "Text",
24656
- "props": {
24657
- "text": "$item.dayHeader",
24658
- "style": "title3",
24659
- "maxLines": 1
24660
- }
24661
- },
24662
- "modifiers": {
24663
- "padding": {
24664
- "leading": 2,
24665
- "trailing": 2,
24666
- "top": 10,
24667
- "bottom": 2
24668
- },
24669
- "hiddenPath": "$item.noHeader"
24670
- }
24671
- },
24672
24689
  {
24673
24690
  "id": "",
24674
24691
  "body": {
@@ -24792,6 +24809,25 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
24792
24809
  }
24793
24810
  ]
24794
24811
  }
24812
+ },
24813
+ "modifiers": {
24814
+ "hiddenPath": "$service:himiBookingTime.noDaySlots"
24815
+ }
24816
+ },
24817
+ {
24818
+ "id": "himi_time.day_empty",
24819
+ "body": {
24820
+ "package": "himalaya",
24821
+ "type": "Text",
24822
+ "props": {
24823
+ "text": "$service:himiBookingTime.dayEmptyText",
24824
+ "style": "body",
24825
+ "color": "textSecondary",
24826
+ "collapseWhenEmpty": true
24827
+ }
24828
+ },
24829
+ "modifiers": {
24830
+ "hiddenPath": "$service:himiBookingTime.hasDaySlots"
24795
24831
  }
24796
24832
  },
24797
24833
  {
@@ -25025,7 +25061,7 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
25025
25061
  "top": 14,
25026
25062
  "bottom": 14
25027
25063
  },
25028
- "hiddenPath": "$service:himiBookingTime.isMember"
25064
+ "hiddenPath": "$service:himiBookingTime.hideSignIn"
25029
25065
  }
25030
25066
  },
25031
25067
  {
@@ -3,7 +3,7 @@ import { defineService } from "@himalaya/state";
3
3
  // Local-day keying + shared day/time labelling. `addDays` below stays local and
4
4
  // UTC-based on purpose: it bounds the availability QUERY window, which is an
5
5
  // instant range, not a calendar day.
6
- import { dayKey, dayLabel, timeLabel, toDate } from "@himalaya/time";
6
+ import { addDays as addLocalDays, dayKey, dayLabel, timeLabel, toDate } from "@himalaya/time";
7
7
  import { dispatch } from "@himalaya/dispatch";
8
8
  import { bookSlot, getService, queryServiceAvailability, type WixBookingSlotFull } from "@himalaya/wix-bookings";
9
9
  import { createCheckout, createOrder } from "@himalaya/wix-ecom";
@@ -29,25 +29,21 @@ async function cachePut(row: DbEntity): Promise<void> { try { await dbUpsert(ENT
29
29
  async function cacheDrop(id: string): Promise<void> { try { await dbRemove(ENTITY, id); } catch { /* capability not granted */ } }
30
30
  async function queueWrite(actionId: string, params: Record<string, string>): Promise<void> { try { await dbEnqueue(actionId, params); } catch { /* capability not granted */ } }
31
31
 
32
- /** One bookable slot, in the `agendaList` row shape.
32
+ /** One bookable slot row. Every row is a real slot.
33
33
  *
34
- * Every row is a real slot. The day heading rides on the FIRST row of each day
35
- * (`dayHeader`) and collapses elsewhere via the single `noHeader` inverse
36
- * boolean — so one template renders one row kind.
37
- *
38
- * This replaces the previous mixed-row encoding, where a row was EITHER a
39
- * synthetic day-header or a slot, and the template carried two alternative
40
- * subtrees gated by four mirror booleans (isHeader/notHeader/isSlot/notSlot).
41
- * Under that shape `slots.length` counted headers too, so every consumer had
42
- * to remember to filter on `isSlot` first. */
34
+ * #3276 — the screen used to render the WHOLE 14-day window as one flat agenda
35
+ * (the day heading riding on each day's first row), capped at 24 rows, with the
36
+ * sign-in card under all of it. It now shows ONE day at a time: `days` feeds a
37
+ * day strip, `slots` holds only the selected day's rows, and the strip is the
38
+ * heading — so rows no longer carry a per-row one. */
43
39
  interface SlotRow {
44
40
  id: string;
45
- /** Day heading on the first row of each day; "" on the rest. */
46
- dayHeader: string;
47
- /** Inverse of `dayHeader` — collapses the heading on non-first rows. */
48
- noHeader: boolean;
41
+ /** Local calendar-day key (`YYYY-MM-DD`) the slot starts on. */
42
+ dayKey: string;
49
43
  /** Day label carried for the selected-line copy, on every row. */
50
44
  day: string;
45
+ /** Compact day label for the strip chip ("Sun 27"). */
46
+ dayChip: string;
51
47
  title: string;
52
48
  subtitle: string;
53
49
  selected: boolean;
@@ -70,7 +66,22 @@ interface S {
70
66
  * booking through the hosted Wix bookings checkout (GPV) instead of the free
71
67
  * bookSlot rung. Set from the service payload during load. */
72
68
  serviceIsPaid: boolean;
69
+ /** Day-strip chips: every day of the window (a day with no times shows a zero
70
+ * count rather than being silently skipped), in the FilterChips option shape. */
71
+ days: DayOption[];
72
+ /** The day whose slots are shown — defaults to the first day that has any. */
73
+ selectedDay: string;
74
+ /** Full heading for the selected day ("Today", "Mon, Sep 28"). */
75
+ selectedDayLabel: string;
76
+ /** Only the selected day's slots. */
73
77
  slots: SlotRow[];
78
+ /** Copy for a selected day with no times; "" when the day has slots. */
79
+ dayEmptyText: string;
80
+ hasDaySlots: boolean;
81
+ noDaySlots: boolean;
82
+ /** No times anywhere in the window — the day strip and heading step aside
83
+ * for the screen-level empty state. */
84
+ noWindowSlots: boolean;
74
85
  selectedSlotId: string;
75
86
  selectedLine: string;
76
87
  emptyText: string;
@@ -86,6 +97,9 @@ interface S {
86
97
  noEmpty: boolean;
87
98
  hasSelected: boolean;
88
99
  noSelected: boolean;
100
+ /** #3276 — the sign-in/register card belongs to the moment of booking: hidden
101
+ * for a member, and for a visitor until they have picked a time. */
102
+ hideSignIn: boolean;
89
103
  canBook: boolean;
90
104
  cannotBook: boolean;
91
105
  showChoosePrompt: boolean;
@@ -106,7 +120,25 @@ interface S {
106
120
  bookingsScreenId: string;
107
121
  }
108
122
 
123
+ /** One day-strip chip — the `FilterChips` option shape `dateStrip` binds. */
124
+ interface DayOption {
125
+ id: string;
126
+ label: string;
127
+ count: number;
128
+ hasSlots: boolean;
129
+ }
130
+
109
131
  let slotsById = new Map<string, WixBookingSlotFull>();
132
+ /** Every slot row in the window, across all days. Kept out of worker state so
133
+ * the state the host receives holds one day's rows, not the whole window. */
134
+ let allRows: SlotRow[] = [];
135
+
136
+ /** Days the strip offers, from today. */
137
+ // No row cap. The old flat list capped the WHOLE window at 24 rows, which
138
+ // silently dropped every day after the first one or two — times a customer
139
+ // could neither see nor book. Worker state now carries one day's rows, so the
140
+ // bound that list needed is gone, and the availability query has no paging.
141
+ const WINDOW_DAYS = 14;
110
142
 
111
143
  function inputId(): string {
112
144
  // WS-C — `himalaya.input.id` is the PRIMARY contract: hosts thread the row's
@@ -227,8 +259,57 @@ function serviceImageUrl(value: unknown): string {
227
259
  return "";
228
260
  }
229
261
 
262
+ /** The day id out of a chip-selection payload: FilterChips' onChange contract is
263
+ * `{ selectedIds }` on every renderer; the string / `{ dayId }` / `{ id }` arms
264
+ * keep direct dispatches working. A re-tap of the active chip emits `[]`. */
265
+ function chosenDay(arg: unknown): string {
266
+ if (typeof arg === "string") return arg;
267
+ const a = arg as { selectedIds?: unknown; dayId?: unknown; id?: unknown } | undefined;
268
+ const ids = a?.selectedIds;
269
+ if (Array.isArray(ids)) return typeof ids[0] === "string" ? ids[0] : "";
270
+ if (typeof ids === "string") return ids;
271
+ if (typeof a?.dayId === "string") return a.dayId;
272
+ return typeof a?.id === "string" ? a.id : "";
273
+ }
274
+
275
+ /** Every day of the window from today (local), plus any day a slot lands on
276
+ * outside it, sorted — each with its slot count. */
277
+ function daysOf(rows: SlotRow[], now: Date = new Date()): DayOption[] {
278
+ const counts = new Map<string, number>();
279
+ const labels = new Map<string, string>();
280
+ for (const row of rows) {
281
+ counts.set(row.dayKey, (counts.get(row.dayKey) ?? 0) + 1);
282
+ if (!labels.has(row.dayKey)) labels.set(row.dayKey, row.dayChip);
283
+ }
284
+ for (let offset = 0; offset < WINDOW_DAYS; offset++) {
285
+ const at = addLocalDays(now, offset);
286
+ const key = at ? dayKey(at) : "";
287
+ // Compact ("Sun 27"), not the full heading: these are pills in a strip.
288
+ if (key && !labels.has(key)) labels.set(key, dayLabel(at, { format: "compact" }));
289
+ }
290
+ return [...labels.keys()].sort().map((key) => {
291
+ const count = counts.get(key) ?? 0;
292
+ return { id: key, label: labels.get(key) ?? key, count, hasSlots: count > 0 };
293
+ });
294
+ }
295
+
296
+ /** Show `day`: its rows (selection flags from the current selection) + heading. */
297
+ function withDay(s: S, day: string): S {
298
+ const rows = allRows.filter((row) => row.dayKey === day);
299
+ const noon = day ? new Date(`${day}T12:00:00`) : null;
300
+ return {
301
+ ...s,
302
+ selectedDay: day,
303
+ selectedDayLabel: rows[0]?.day ?? (noon && !Number.isNaN(noon.getTime()) ? dayLabel(noon) : ""),
304
+ slots: rows.map((row) => ({ ...row, selected: row.id === s.selectedSlotId, notSelected: row.id !== s.selectedSlotId })),
305
+ };
306
+ }
307
+
230
308
  function derive(s: S): S {
231
- const hasSlots = s.slots.length > 0;
309
+ // `hasSlots` is the WHOLE window (it drives the screen-level empty state); the
310
+ // selected day having none is a per-day line, not an empty screen.
311
+ const hasSlots = s.days.some((d) => d.hasSlots) || s.slots.length > 0;
312
+ const hasDaySlots = s.slots.length > 0;
232
313
  const hasSelected = !!s.selectedSlotId;
233
314
  const hasSuccess = !!s.successMessage;
234
315
  const canBook = s.isMember && hasSelected && !s.bookingInFlight && !hasSuccess;
@@ -239,6 +320,15 @@ function derive(s: S): S {
239
320
  hasSlots,
240
321
  hasSelected,
241
322
  noSelected: !hasSelected,
323
+ hideSignIn: s.isMember || !hasSelected,
324
+ hasDaySlots,
325
+ noDaySlots: !hasDaySlots,
326
+ noWindowSlots: !hasSlots,
327
+ // Only a quiet DAY in a window that has times gets the per-day line; a
328
+ // window with no times at all is the screen-level empty state's job.
329
+ dayEmptyText: hasDaySlots || !s.selectedDay || !hasSlots
330
+ ? ""
331
+ : copyParam("time.dayEmptyText", "No times on this day. Try another day."),
242
332
  hasSuccess,
243
333
  noSuccess: !hasSuccess,
244
334
  canBook,
@@ -336,6 +426,7 @@ const id = inputId();
336
426
  const svc = defineService<S, {
337
427
  load: (s: S, arg?: unknown) => Promise<S>;
338
428
  refreshMember: (s: S, arg?: unknown) => Promise<S>;
429
+ selectDay: (s: S, arg?: unknown) => S;
339
430
  selectSlot: (s: S, arg?: unknown) => S;
340
431
  setEmail: (s: S, arg?: unknown) => S;
341
432
  setPassword: (s: S, arg?: unknown) => S;
@@ -358,7 +449,14 @@ const svc = defineService<S, {
358
449
  servicePrice: "",
359
450
  serviceDuration: "",
360
451
  serviceIsPaid: false,
452
+ days: [],
453
+ selectedDay: "",
454
+ selectedDayLabel: "",
361
455
  slots: [],
456
+ dayEmptyText: "",
457
+ hasDaySlots: false,
458
+ noDaySlots: true,
459
+ noWindowSlots: true,
362
460
  selectedSlotId: "",
363
461
  selectedLine: "",
364
462
  emptyText: "",
@@ -374,6 +472,7 @@ const svc = defineService<S, {
374
472
  noEmpty: true,
375
473
  hasSelected: false,
376
474
  noSelected: true,
475
+ hideSignIn: true,
377
476
  canBook: false,
378
477
  cannotBook: true,
379
478
  showChoosePrompt: false,
@@ -409,32 +508,39 @@ const svc = defineService<S, {
409
508
  // Group by LOCAL calendar day, not by the formatted heading: two
410
509
  // distinct days can render the same text, and a UTC-derived key can
411
510
  // name a different day than the heading beside it.
412
- let lastKey = "";
413
511
  for (const [index, entry] of (availability.availabilityEntries ?? [])
414
512
  .filter((candidate) => candidate.bookable !== false && candidate.slot?.startDate)
415
- .slice(0, 24)
416
513
  .entries()) {
417
514
  const slot = entry.slot as WixBookingSlotFull;
515
+ const key = dayKey(slot.startDate);
516
+ if (!key) continue;
418
517
  const slotId = String(slot.sessionId ?? slot.startDate ?? `slot-${index}`);
419
518
  slotsById.set(slotId, slot);
420
519
  const label = fmtRange(slot.startDate, slot.endDate);
421
- const key = dayKey(slot.startDate);
422
- const firstOfDay = !!key && key !== lastKey;
423
- if (firstOfDay) lastKey = key;
424
520
  rows.push({
425
521
  id: slotId,
426
- dayHeader: firstOfDay ? label.day : "",
427
- noHeader: !firstOfDay,
522
+ dayKey: key,
428
523
  day: label.day,
524
+ dayChip: dayLabel(slot.startDate, { format: "compact" }),
429
525
  title: label.title,
430
526
  subtitle: label.subtitle,
431
527
  selected: false,
432
528
  notSelected: true,
433
529
  });
434
530
  }
531
+ allRows = rows;
532
+ const days = daysOf(rows);
533
+ // Keep the customer's day across a reload when it still has times;
534
+ // otherwise open on the first day that has any.
535
+ const keep = days.find((d) => d.id === next.selectedDay && d.hasSlots);
536
+ const initialDay = keep?.id ?? days.find((d) => d.hasSlots)?.id ?? days[0]?.id ?? "";
537
+ const selectionAlive = slotsById.has(next.selectedSlotId);
435
538
  const header = serviceHeaderFields(serviceRes.service);
436
- return derive({
539
+ return derive(withDay({
437
540
  ...next,
541
+ days,
542
+ selectedSlotId: selectionAlive ? next.selectedSlotId : "",
543
+ selectedLine: selectionAlive ? next.selectedLine : "",
438
544
  status: "ready",
439
545
  errored: false,
440
546
  serviceName: serviceRes.service?.name || copyParam("time.serviceNameFallback", "Choose a time"),
@@ -445,19 +551,28 @@ const svc = defineService<S, {
445
551
  servicePrice: header.price,
446
552
  serviceDuration: header.duration,
447
553
  serviceIsPaid: header.paid,
448
- slots: rows,
449
- });
554
+ }, initialDay));
450
555
  } catch {
451
556
  slotsById = new Map();
452
- return derive({ ...next, status: "ready", errored: true, slots: [] });
557
+ allRows = [];
558
+ return derive({ ...next, status: "ready", errored: true, days: [], selectedDay: "", selectedDayLabel: "", slots: [] });
453
559
  }
454
560
  },
561
+ // #3276 — show one day's times. Re-tapping the active chip emits an empty
562
+ // selection; the strip is single-select with no "none", so the day stays.
563
+ // A time picked on another day stays selected (the selected line and the
564
+ // Book button keep naming it): switching days to compare is not a reset.
565
+ selectDay: (s, arg) => {
566
+ const day = chosenDay(arg);
567
+ if (!day || !s.days.some((d) => d.id === day)) return derive(s);
568
+ return derive(withDay(s, day));
569
+ },
455
570
  refreshMember: (s, arg) => { stashCopy(arg); return probeMember(s); },
456
571
  selectSlot: (s, arg) => {
457
572
  const slotId = typeof arg === "string"
458
573
  ? arg
459
574
  : ((arg as { id?: string; slotId?: string })?.slotId ?? (arg as { id?: string })?.id ?? "");
460
- const selected = s.slots.find((slot) => slot.id === slotId);
575
+ const selected = allRows.find((slot) => slot.id === slotId);
461
576
  return derive({
462
577
  ...s,
463
578
  selectedSlotId: selected ? slotId : s.selectedSlotId,
@@ -592,7 +707,7 @@ const svc = defineService<S, {
592
707
  const email = (await signedInEmail()) || my.email || my.contactEmail || s.email;
593
708
  const name = contactName(displayName(my) || email);
594
709
  // Optimistic local write — My Bookings repaints from cache immediately (offline or not).
595
- const selectedSlot = s.slots.find((r) => r.id === s.selectedSlotId);
710
+ const selectedSlot = allRows.find((r) => r.id === s.selectedSlotId);
596
711
  const localId = `local-${Date.now()}`;
597
712
  const localRow = (id: string): DbEntity => ({
598
713
  id,
@@ -37,7 +37,7 @@ export default defineApp({
37
37
  actions: "http://localhost:8190/v1/actions",
38
38
  sync: "http://localhost:8190/v1/sync",
39
39
  },
40
- capabilities: ["live_activity","push","share","secure_storage","open_url","local_database","clipboard","ui","camera","presigned_upload"],
40
+ capabilities: ["live_activity","push","share","secure_storage","open_url","local_database","clipboard","ui","speech","camera","presigned_upload"],
41
41
  designTokensPath: "./tokens.json",
42
42
  navigation: {
43
43
  "navKind": "responsive",