@wix/himalaya-cli 0.1581.0 → 0.1583.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 (50) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +8 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +26 -2
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/validator.d.ts +18 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +5 -1
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +5 -1
  6. package/dist/cli.mjs +17195 -17232
  7. package/dist/preview-assets.generated.bin +0 -0
  8. package/guides/component-reference.json +4 -3
  9. package/guides/component-reference.md +24 -2
  10. package/package.json +2 -2
  11. package/templates/beauty-salon/config.ts +20 -1
  12. package/templates/beauty-salon/dev/index.ts +1751 -629
  13. package/templates/beauty-salon/tier5-src/_shared/booking-location.ts +147 -0
  14. package/templates/beauty-salon/tier5-src/_shared/booking-session.ts +222 -0
  15. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +277 -14
  16. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +351 -92
  17. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +25 -5
  18. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
  19. package/templates/branded-lite/config.ts +20 -1
  20. package/templates/branded-lite/dev/index.ts +1688 -566
  21. package/templates/branded-lite/l10n/en.json +2 -2
  22. package/templates/branded-lite/tier5-src/_shared/booking-location.ts +147 -0
  23. package/templates/branded-lite/tier5-src/_shared/booking-session.ts +222 -0
  24. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +277 -14
  25. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +351 -92
  26. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +25 -5
  27. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
  28. package/templates/community-club/config.ts +20 -1
  29. package/templates/events-meetups/config.ts +20 -1
  30. package/templates/gym-fitness/config.ts +20 -1
  31. package/templates/gym-fitness/dev/index.ts +1751 -629
  32. package/templates/gym-fitness/tier5-src/_shared/booking-location.ts +147 -0
  33. package/templates/gym-fitness/tier5-src/_shared/booking-session.ts +222 -0
  34. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +277 -14
  35. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +351 -92
  36. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +25 -5
  37. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
  38. package/templates/nonprofit-cause/config.ts +20 -1
  39. package/templates/podcast-creator/config.ts +20 -1
  40. package/templates/portfolio-creative/config.ts +20 -1
  41. package/templates/restaurant/config.ts +20 -1
  42. package/templates/retail-store/config.ts +20 -1
  43. package/templates/yoga-studio/config.ts +20 -1
  44. package/templates/yoga-studio/dev/index.ts +1533 -411
  45. package/templates/yoga-studio/tier5-src/_shared/booking-location.ts +147 -0
  46. package/templates/yoga-studio/tier5-src/_shared/booking-session.ts +222 -0
  47. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +277 -14
  48. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +351 -92
  49. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +25 -5
  50. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
@@ -36,53 +36,24 @@ import { compareText } from "../_shared/engine-intl.js";
36
36
  import { assertMemberAccess, memberAccess, memberAccessChanged, type MemberAccess } from "../_shared/member-access.js";
37
37
  import { atLeast } from "../_shared/member-account-routes.js";
38
38
  import { get as getAppInfo } from "@himalaya/app-info";
39
+ import {
40
+ distinctChoices, effectiveLocation, locationMenu, readSharedLocation, serviceLocationChoices, writeSharedLocation,
41
+ type LocationChoice, type LocationOptionRow,
42
+ } from "../_shared/booking-location.js";
43
+ import {
44
+ bySession, markWaitlisted, placesIn, placesOf, sessionControl, sessionKey, sessionOf, spotsLabel,
45
+ type MemberPlace, type Place, type Session,
46
+ } from "../_shared/booking-session.js";
47
+
48
+ // The session model is shared with the service screen opened from a row; re-exported for the tests.
49
+ export { bySession, markWaitlisted, placesOf, sessionKey, sessionOf };
50
+ export type { MemberPlace, Place, Session };
39
51
 
40
52
  /** How far past the selected day the list reaches. */
41
53
  export const AGENDA_DAYS = 28;
42
54
  /** Services whose sessions are fixed events: a class's are each booked on their own, a course's
43
55
  * are listed but the course is booked as a whole (on its service screen). */
44
56
  const SESSION_TYPES = new Set(["CLASS", "COURSE"]);
45
- /** A booking in one of these no longer holds a place. */
46
- const RELEASED = new Set(["CANCELED", "CANCELLED", "DECLINED"]);
47
-
48
- export type Place = "booked" | "waitlisted" | "";
49
-
50
- /** One session as the screen needs it. `key` is the row's identity: the Wix session id when the
51
- * entry has one — two sessions of a class at the same time are two rows — else service + start. */
52
- export interface Session {
53
- key: string;
54
- /** The Wix session id — what a waitlist registers against. "" when the entry has none. */
55
- sessionId: string;
56
- serviceId: string;
57
- /** A class session is booked on its own; a course session belongs to a course booked whole. */
58
- kind: "class" | "course";
59
- title: string;
60
- /** The service's fixed price ("₪60"), "" when it has none to show (varied, custom, free). */
61
- price: string;
62
- start: string; // ISO instant with offset
63
- end: string;
64
- day: string; // LOCAL day key
65
- staff: string;
66
- location: string;
67
- total: number;
68
- open: number;
69
- bookable: boolean;
70
- /** The session's waitlist: its size (0 = it has none) and the places left on it. */
71
- waitlistTotal: number;
72
- /** A booking policy closes it (too early, too late, online booking off): no Book, no Waitlist. */
73
- policyClosed: boolean;
74
- /** The ONLY policy reason is that booking has not opened yet ("Not open yet", not "Closed"). */
75
- tooEarly: boolean;
76
- waitlistOpen: number;
77
- }
78
-
79
- export interface MemberPlace { bookingId: string; revision: string; place: Place; registrationId?: string; confirmed?: boolean }
80
-
81
- const count = (v: unknown): number => {
82
- const n = Number(v ?? 0);
83
- return Number.isFinite(n) && n > 0 ? n : 0;
84
- };
85
-
86
57
  /** The `Agenda` wire shape: declared fields plus a flat string `extra` map for the row. */
87
58
  export interface AgendaEvent {
88
59
  id: string;
@@ -138,6 +109,22 @@ interface S extends LoadFlags {
138
109
  sheetBusy: boolean;
139
110
  /** The sheet session's service: View class reads its declared route (`nav`) from it. "" with no sheet. */
140
111
  sheetServiceId: string;
112
+ /** …and its session (id and start), so the service screen opens on it. */
113
+ sheetSessionId: string;
114
+ sheetStart: string;
115
+ // The location picker (two or more locations only): `locationMenu()` in booking-location.ts.
116
+ locationOptions: LocationOptionRow[];
117
+ locationLabel: string;
118
+ locationA11y: string;
119
+ hasLocationMenu: boolean;
120
+ noLocationMenu: boolean;
121
+ /** A location is chosen: the pin turns accent and the line under the title names it. */
122
+ locationActive: boolean;
123
+ noLocationActive: boolean;
124
+ locationSheetOpen: boolean;
125
+ /** A location is chosen and has no session in the loaded days: its own empty state. */
126
+ locationEmpty: boolean;
127
+ notLocationEmpty: boolean;
141
128
  }
142
129
 
143
130
  const flag = (on: boolean): string => (on ? "true" : "");
@@ -155,107 +142,11 @@ export function lengthLabel(start: string, end: string): string {
155
142
  /** The parts that are known, joined by a middle dot: "Omer Levi · Studio Zen". */
156
143
  const joined = (...parts: string[]): string => parts.filter(Boolean).join(" · ");
157
144
 
158
- /** A slot's instant as an ISO string WITH an offset — iOS parses wire dates strictly. */
159
- function instantIso(slot: { startDate?: string; timezone?: unknown }, field: "startDate" | "endDate"): string {
160
- const ms = slotStartInstant({ startDate: (slot as Record<string, unknown>)[field] as string | undefined, timezone: slot.timezone });
161
- return Number.isFinite(ms) ? new Date(ms).toISOString() : "";
162
- }
163
-
164
- export const sessionKey = (serviceId: string, startIso: string): string => `${serviceId}|${Date.parse(startIso)}`;
165
-
166
- /** A raw availability entry → a Session, or null when it has no usable start. */
167
- export function sessionOf(entry: WixAvailabilityEntry, service: WixService): Session | null {
168
- const slot = (entry.slot ?? {}) as Record<string, unknown> & { startDate?: string; endDate?: string; timezone?: unknown };
169
- const start = instantIso(slot, "startDate");
170
- if (!start) return null;
171
- const end = instantIso(slot, "endDate") || start;
172
- const raw = entry as unknown as {
173
- totalSpots?: number; openSpots?: number; waitingList?: { totalSpots?: number; openSpots?: number };
174
- bookingPolicyViolations?: { tooEarlyToBook?: boolean; tooLateToBook?: boolean; bookOnlineDisabled?: boolean; validSubmissionRequired?: boolean };
175
- };
176
- const policy = raw.bookingPolicyViolations;
177
- const tooLate = policy?.tooLateToBook === true, offline = policy?.bookOnlineDisabled === true;
178
- const tooEarly = policy?.tooEarlyToBook === true;
179
- // A session that also needs a form submitted is not just "not open yet": it never opens for the app.
180
- const needsForm = policy?.validSubmissionRequired === true;
181
- const resource = slot.resource as { name?: string } | undefined;
182
- const location = slot.location as { name?: string; formattedAddress?: string } | undefined;
183
- const serviceId = String(slot.serviceId ?? service.id ?? "");
184
- const sessionId = typeof slot.sessionId === "string" ? slot.sessionId : "";
185
- return {
186
- key: sessionId || sessionKey(serviceId, start),
187
- sessionId,
188
- serviceId,
189
- kind: ((service as WixService & { type?: string }).type ?? "").toUpperCase() === "COURSE" ? "course" : "class",
190
- title: service.name ?? "",
191
- price: formatServicePrice(service),
192
- start, end,
193
- day: dayKey(start),
194
- staff: resource?.name?.trim() ?? "",
195
- location: (location?.name || location?.formattedAddress || "").trim(),
196
- total: count(raw.totalSpots),
197
- open: count(raw.openSpots),
198
- bookable: entry.bookable !== false,
199
- waitlistTotal: count(raw.waitingList?.totalSpots),
200
- waitlistOpen: count(raw.waitingList?.openSpots),
201
- policyClosed: tooEarly || tooLate || offline || needsForm,
202
- tooEarly: tooEarly && !tooLate && !offline && !needsForm,
203
- };
204
- }
205
-
206
- /** The member's bookings in the window → their place, keyed by the booking's session id when it
207
- * names one, else by service + start. `bySession` turns that into each row's own key. */
208
- export function placesOf(bookings: WixBooking[], email: string): Map<string, MemberPlace> {
209
- const out = new Map<string, MemberPlace>();
210
- for (const b of bookings) {
211
- if (!b.id || b.contactDetails?.email?.trim().toLowerCase() !== email) continue;
212
- const status = (b.status || "").toUpperCase();
213
- if (RELEASED.has(status)) continue;
214
- const slot = b.bookedEntity?.slot;
215
- const serviceId = slot?.serviceId ?? "";
216
- const start = b.startDate || (slot ? instantIso(slot, "startDate") : "");
217
- if (!serviceId || !start) continue;
218
- // Its session when the booking names one, so a parallel session is not marked too.
219
- const bookedSession = (slot as { sessionId?: unknown } | undefined)?.sessionId;
220
- out.set(typeof bookedSession === "string" && bookedSession ? bookedSession : sessionKey(serviceId, start), {
221
- bookingId: b.id,
222
- revision: String((b as WixBooking & { revision?: string | number }).revision ?? ""),
223
- place: status === "WAITING_LIST" ? "waitlisted" : "booked",
224
- confirmed: status === "CONFIRMED",
225
- });
226
- }
227
- return out;
228
- }
229
-
230
- /** Each session's place, under the session's own key: by its session id, else by service + start. */
231
- export function bySession(found: Map<string, MemberPlace>, inWindow: Session[]): Map<string, MemberPlace> {
232
- const out = new Map<string, MemberPlace>();
233
- for (const x of inWindow) {
234
- const place = (x.sessionId ? found.get(x.sessionId) : undefined) ?? found.get(sessionKey(x.serviceId, x.start));
235
- if (place) out.set(x.key, place);
236
- }
237
- return out;
238
- }
239
-
240
- /** "4 spots left", "1 spot left", or "Full". Empty when the site reports no capacity, and for a
241
- * course: a session's open spots do not say whether the course has room. */
242
- function spotsLabel(s: Session): string {
243
- if (s.kind === "course" || s.total <= 0) return "";
244
- if (s.open <= 0) return copyParam("agenda.full", "Full");
245
- return s.open === 1 ? copyParam("agenda.spotLeft", "1 spot left") : copyTemplate("agenda.spotsLeft", "{n} spots left", { n: s.open });
246
- }
247
-
248
- /** One session → one Agenda row. Exactly one of the trailing controls is visible. */
249
- export function eventOf(s: Session, place: MemberPlace | undefined): AgendaEvent {
250
- const mine = place?.place ?? "";
145
+ /** One session → one Agenda row. Exactly one of the trailing controls is visible. `lastOfDay`: the
146
+ * day's last row draws no divider — the next day's header band starts right under it. */
147
+ export function eventOf(s: Session, place: MemberPlace | undefined, lastOfDay = false): AgendaEvent {
148
+ const { control, closedLabel } = sessionControl(s, place);
251
149
  const course = s.kind === "course";
252
- const full = s.total > 0 && s.open <= 0;
253
- // A course is booked whole on its service screen: its sessions offer View Course, never Book.
254
- const canBook = !course && !mine && s.bookable && !full && !s.policyClosed;
255
- // Availability is capacity AND policy: a full session the policy closes is not joinable either.
256
- const canWait = !course && !mine && full && s.waitlistOpen > 0 && !!s.sessionId && !s.policyClosed;
257
- const viewCourse = course && !mine;
258
- const closed = !course && !mine && !canBook && !canWait;
259
150
  const time = timeLabel(s.start);
260
151
  return {
261
152
  id: s.key,
@@ -276,14 +167,14 @@ export function eventOf(s: Session, place: MemberPlace | undefined): AgendaEvent
276
167
  ? copyTemplate("agenda.detailsA11y", "{title}, {time}, with {staff}", { title: s.title, time, staff: s.staff })
277
168
  : [s.title, time].filter(Boolean).join(", "),
278
169
  bookA11y: copyTemplate("agenda.bookA11y", "Book {title}, {time}", { title: s.title, time }),
279
- hideBook: flag(!canBook),
280
- hideWaitlist: flag(!canWait),
281
- hideCourse: flag(!viewCourse),
282
- hideBooked: flag(mine !== "booked"),
283
- hideOnWaitlist: flag(mine !== "waitlisted"),
284
- hideClosed: flag(!closed),
285
- closedLabel: full ? copyParam("agenda.full", "Full")
286
- : s.tooEarly ? copyParam("agenda.notOpenYet", "Not open yet") : copyParam("agenda.closed", "Closed"),
170
+ hideBook: flag(control !== "book"),
171
+ hideWaitlist: flag(control !== "waitlist"),
172
+ hideCourse: flag(control !== "course"),
173
+ hideBooked: flag(control !== "booked"),
174
+ hideOnWaitlist: flag(control !== "waitlisted"),
175
+ hideClosed: flag(control !== "closed"),
176
+ closedLabel,
177
+ lastOfDay: flag(lastOfDay),
287
178
  },
288
179
  };
289
180
  }
@@ -397,6 +288,17 @@ let places = new Map<string, MemberPlace>();
397
288
  let loadedWindow = { start: "", end: "" };
398
289
  let member: MemberAccess | undefined;
399
290
  let sheetKey = "";
291
+ /** The location the member chose (here, on Services or on Choose Time): "" is every location. */
292
+ let chosenLocation = { id: "", label: "" };
293
+ let locationSheet = false;
294
+ /** Until when a scroll report is ignored: a tap's (or a page's) jump is still settling, and a
295
+ * report the list sent before it must not move the selection back. */
296
+ let quietUntil = 0;
297
+ /** Bumped by a location change: the list's target becomes "day|n", so the renderer applies it again
298
+ * even when the day is the same one — rows above it came or went, and the offset no longer shows
299
+ * it. Cleared by the list's own scroll and a calendar tap, whose plain day the renderer compares
300
+ * with what it reported (an echo of the member's own scroll never moves the list). */
301
+ let scrollNonce = 0;
400
302
  let busy = false;
401
303
  /** An Add to Calendar flow is running (a permission prompt, a save, the calendar page). Its own
402
304
  * flag, not `busy`: the session sheet must still close while it runs. */
@@ -438,67 +340,17 @@ async function sessionsIn(start: string, end: string): Promise<Session[]> {
438
340
  if (session) out.push(session);
439
341
  }
440
342
  });
343
+ return ordered(out);
344
+ }
345
+
346
+ /** Sessions once each, by start then title — the list's order. */
347
+ function ordered(list: Session[]): Session[] {
441
348
  const seen = new Set<string>();
442
- return out
349
+ return list
443
350
  .filter((s) => (seen.has(s.key) ? false : (seen.add(s.key), true)))
444
351
  .sort((a, b) => Date.parse(a.start) - Date.parse(b.start) || compareText(a.title, b.title));
445
352
  }
446
353
 
447
- /** Mark the places that are WAITLIST registrations, with the registration id leaving needs.
448
- * A registration's booking is an ordinary pending booking, so the booking alone cannot say
449
- * "on the waitlist" — only the session's waitlist can, and only sessions with one are asked. */
450
- export function markWaitlisted(found: Map<string, MemberPlace>, sessionsByKey: Map<string, Session>, lists: { waitingResource?: string; registrations?: { id?: string; bookingId?: string }[] }[]): void {
451
- const byBooking = new Map<string, string>();
452
- for (const list of lists) for (const r of list.registrations ?? []) if (r.bookingId && r.id) byBooking.set(r.bookingId, r.id);
453
- for (const [key, place] of found) {
454
- const registrationId = byBooking.get(place.bookingId);
455
- if (registrationId && sessionsByKey.has(key)) found.set(key, { ...place, place: "waitlisted", registrationId });
456
- }
457
- }
458
-
459
- /** The signed-in member's bookings in the window, with the member they were read for: the two
460
- * are committed together, so a write checks against the member its place belongs to. A visitor
461
- * has none; a read that fails fails the load. */
462
- async function placesIn(start: string, end: string, inWindow: Session[], attempt = 0): Promise<{ who: MemberAccess | undefined; found: Map<string, MemberPlace> }> {
463
- const who = await memberAccess().catch(() => undefined);
464
- if (!who?.email) return { who, found: new Map() };
465
- try {
466
- const from = new Date(`${start}T00:00:00`).toISOString();
467
- const to = new Date(`${dayKey(addDays(end, 1))}T00:00:00`).toISOString();
468
- // Every page, not the first 100: a booking past it would read Book again. Bounded.
469
- const bookings: WixBooking[] = [];
470
- for (let page = 0; page < 5; page++) {
471
- const res = await queryBookings({ query: { filter: { "contactDetails.email": { $eq: who.email }, startDate: { $gte: from, $lt: to } }, paging: { limit: 100, offset: page * 100 } } } as never);
472
- const batch = res.bookings ?? [];
473
- bookings.push(...batch);
474
- if (batch.length < 100) break;
475
- }
476
- // The same member still? A sign-out or a switch during the read must not paint one member's
477
- // bookings for another (the shared member-bookings read checks the same way).
478
- await assertMemberAccess(who);
479
- const found = bySession(placesOf(bookings, who.email), inWindow);
480
- const byKey = new Map(inWindow.map((x) => [x.key, x] as const));
481
- const waitlisted = [...found.keys()].map((k) => byKey.get(k)).filter((x): x is Session => !!x && x.waitlistTotal > 0 && !!x.sessionId);
482
- if (waitlisted.length) {
483
- // Only the session's waitlist can tell a waitlist place from a pending booking. If it cannot
484
- // be read, that place is neither Booked nor free: the load fails (error state, Retry) rather
485
- // than offer Book or Waitlist on a session the member already holds.
486
- markWaitlisted(found, byKey, (await listWaitlists(waitlisted.map((x) => x.sessionId))).list ?? []);
487
- // The lookup awaited too: still the same member?
488
- await assertMemberAccess(who);
489
- }
490
- return { who, found };
491
- } catch (err) {
492
- // A failed read is not "no bookings": showing Book on a session the member holds invites a
493
- // duplicate. It fails the load (the error state, with Retry), as My Bookings does.
494
- if ((err as { name?: string } | null)?.name !== "MemberAccessChanged") throw err;
495
- // A sign-out or a switch during the read: read again, for whoever is signed in now — never
496
- // commit them with places nobody read for them. A second switch fails the load.
497
- if (attempt < 1) return placesIn(start, end, inWindow, attempt + 1);
498
- throw err;
499
- }
500
- }
501
-
502
354
  /** Is `owner` — the member a place was loaded for — still the one signed in, to the same account
503
355
  * and business? Asked right before a write, so a sign-out or a switch since the load never
504
356
  * writes for the wrong person. */
@@ -550,23 +402,53 @@ function newView(s: S): S {
550
402
  const emptyFlags = (noClasses: boolean, nothingAhead: boolean) =>
551
403
  ({ noClasses, nothingAhead, notNoClasses: !noClasses, notNothingAhead: !nothingAhead });
552
404
 
405
+ /** The locations the member can choose from: every class and course service's own locations, then
406
+ * any the loaded sessions name that no service lists, and the location chosen on another screen
407
+ * (Services, Choose Time) even when nothing here is at it — so the choice is kept, and its empty
408
+ * state says so, rather than silently showing everything. */
409
+ export function agendaLocations(list: readonly WixService[], inWindow: readonly Session[], chosen: { id: string; label: string }): LocationChoice[] {
410
+ return distinctChoices([
411
+ ...list.flatMap((svc) => serviceLocationChoices(svc.locations)),
412
+ ...inWindow.map((x) => ({ key: x.locationKey, label: x.location, sub: x.locationSub })),
413
+ ...(chosen.id && chosen.label ? [{ key: chosen.id, label: chosen.label, sub: "" }] : []),
414
+ ]);
415
+ }
416
+
417
+ /** The day the list scrolls to for `selected`: that day, else the next one with a session, else the
418
+ * last one (a day after every loaded session still shows the list's end, not an empty page). */
419
+ export function scrollDayFor(days: readonly string[], selected: string): string {
420
+ return days.find((d) => d >= selected) ?? days[days.length - 1] ?? "";
421
+ }
422
+
553
423
  /** Rebuild everything the screen reads from the module state. */
554
424
  function derive(s: S): S {
555
- const events = rowsOk === false ? [] : sessions.filter((x) => x.day >= s.selectedDate).map((x) => eventOf(x, places.get(x.key)));
425
+ const choices = agendaLocations(services ?? [], sessions, chosenLocation);
426
+ const locationKey = effectiveLocation(choices, chosenLocation.id);
427
+ // From today on (the loaded days never start before it), at the chosen location. The list is
428
+ // one run of days: a tap on the calendar SCROLLS it to a day, and scrolling it moves the
429
+ // selection, so it never starts at the selected day (the days above it would vanish).
430
+ const shown = sessions.filter((x) => x.day >= s.today && (!locationKey || x.locationKey === locationKey));
431
+ const events = rowsOk === false ? [] : shown.map((x, i) => eventOf(x, places.get(x.key), shown[i + 1]?.day !== x.day));
556
432
  const sheet = sessions.find((x) => x.key === sheetKey);
557
433
  const sheetPlace = sheet ? places.get(sheet.key)?.place ?? "" : "";
558
434
  const loaded = s.status === "ready" && rowsOk !== false && !!services;
435
+ const locationEmpty = loaded && !!locationKey && shown.length === 0;
559
436
  return {
560
437
  ...s,
561
- decorations: decorationsOf(sessions, s.today, s.rangeStart, s.rangeEnd),
438
+ ...locationMenu(choices, chosenLocation.id),
439
+ locationSheetOpen: locationSheet,
440
+ locationEmpty,
441
+ notLocationEmpty: !locationEmpty,
442
+ // The dots follow the location too: a day with a session only elsewhere has none.
443
+ decorations: decorationsOf(shown, s.today, s.rangeStart, s.rangeEnd),
562
444
  events,
563
- scrollTo: events[0] ? dayKey(events[0].start) : "",
445
+ scrollTo: ((day) => (day && scrollNonce && suffixOk !== false ? `${day}|${scrollNonce}` : day))(scrollDayFor([...new Set(shown.map((x) => x.day))], s.selectedDate)),
564
446
  // The only thing an update-required screen says: no tap or page clears it.
565
447
  updateMessage: rowsOk === false ? copyParam("agenda.updateRequired", "Update the app to see the class schedule.") : "",
566
448
  signedIn: !!member?.email,
567
449
  updateRequired: rowsOk === false,
568
450
  listHidden: s.busy === true || rowsOk === false || events.length === 0,
569
- ...emptyFlags(loaded && services!.length === 0, loaded && services!.length > 0 && events.length === 0),
451
+ ...emptyFlags(loaded && services!.length === 0, loaded && services!.length > 0 && events.length === 0 && !locationEmpty),
570
452
  sheetOpen: !!sheet && !!sheetPlace,
571
453
  sheetTitle: sheet?.title ?? "",
572
454
  sheetWhen: sheet ? joined(dayLabel(sheet.start, { absolute: true }), timeLabel(sheet.start), lengthLabel(sheet.start, sheet.end)) : "",
@@ -576,6 +458,8 @@ function derive(s: S): S {
576
458
  sheetHideLeave: sheetPlace !== "waitlisted",
577
459
  sheetBusy: busy || calendarBusy,
578
460
  sheetServiceId: sheet?.serviceId ?? "",
461
+ sheetSessionId: sheet?.sessionId ?? "",
462
+ sheetStart: sheet?.start ?? "",
579
463
  };
580
464
  }
581
465
 
@@ -602,8 +486,16 @@ async function leave(place: MemberPlace, session: Session, owner: MemberAccess |
602
486
  * not as a descriptor floor: a release takes its highest screen floor as its own. */
603
487
  export const AGENDA_MIN_ANDROID_BINARY = "1.6.0";
604
488
  let rowsOk: boolean | undefined;
489
+ /** A `scrollTo` of "day|nonce" jumps again to the same day (after a location change). A native
490
+ * renderer before 1.8.0 reads the whole value as the day and finds no row, so it gets the plain
491
+ * day: the list then does not re-land on an unchanged day, but it does reach a new one. */
492
+ export const AGENDA_SCROLL_SUFFIX_MIN_BINARY = "1.8.0";
493
+ let suffixOk: boolean | undefined;
494
+
605
495
  async function rowsSupported(): Promise<boolean> {
606
496
  const info = await getAppInfo().catch(() => undefined);
497
+ const native = !!info && (info.platform === "ios" || info.platform === "android" || info.platform === "macos");
498
+ suffixOk = !native || atLeast(info!.binaryVersion, AGENDA_SCROLL_SUFFIX_MIN_BINARY);
607
499
  if (!info || info.platform !== "android") return true;
608
500
  return atLeast(info.binaryVersion, AGENDA_MIN_ANDROID_BINARY);
609
501
  }
@@ -624,6 +516,20 @@ function ask(s: S, view: Partial<View>): S {
624
516
  return { ...s, ...wanted };
625
517
  }
626
518
 
519
+ /** The longest run of days the list grows to before a far page replaces it. */
520
+ export const AGENDA_MAX_DAYS = 120;
521
+
522
+ /** The days to read when `need` is not loaded: the loaded days GROWN to reach it when the two touch
523
+ * (the member scrolled or paged on — the rows above them must stay, or the list jumps under their
524
+ * finger), else `need` alone (a far page). Capped at AGENDA_MAX_DAYS. */
525
+ export function grownWindow(loaded: { start: string; end: string }, need: { start: string; end: string }): { start: string; end: string } {
526
+ if (!loaded.start || need.start > dayKey(addDays(loaded.end, 1)) || need.end < dayKey(addDays(loaded.start, -1))) return need;
527
+ const start = loaded.start < need.start ? loaded.start : need.start;
528
+ const end = loaded.end > need.end ? loaded.end : need.end;
529
+ const span = Math.round((Date.parse(end) - Date.parse(start)) / 86_400_000);
530
+ return span > AGENDA_MAX_DAYS ? need : { start, end };
531
+ }
532
+
627
533
  const covers = (win: { start: string; end: string }, need: { start: string; end: string }): boolean =>
628
534
  !!win.start && win.start <= need.start && win.end >= need.end;
629
535
  const needOf = (s: S) => windowFor(s.today, s.rangeStart, s.rangeEnd, s.selectedDate);
@@ -645,15 +551,34 @@ async function ensureLoaded(base: S, force = false): Promise<S | undefined> {
645
551
  // page report can finish its read while `load`, dispatched with "loading", still waits.
646
552
  if (covers(loadedWindow, need) && !force) return derive({ ...s, ...READY });
647
553
  const seq = ++loadSeq;
648
- const win = covers(loadedWindow, need) ? loadedWindow : need;
554
+ const win = covers(loadedWindow, need) ? loadedWindow : grownWindow(loadedWindow, need);
649
555
  // A result that an action produced while this read ran (Add to Calendar, a failed cancel) is
650
556
  // newer than the state this read started from: the commit keeps it rather than closing it.
651
557
  const resultAt = resultSeq;
652
558
  const fresh = (x: S): S => (resultSeq === resultAt ? x : keepResult(x) ?? x);
653
559
  try {
654
- const nextSessions = await sessionsIn(win.start, win.end);
655
- const { who, found: nextPlaces } = await placesIn(win.start, win.end, nextSessions);
560
+ // A window grown to reach new days reads only those days, and keeps what it has: a scroll that
561
+ // extends the list must not re-read weeks of every class (and the member's bookings) each time.
562
+ // A forced reload (the screen re-appeared) and a far page read the whole window.
563
+ const grown = !force && win !== need && !!loadedWindow.start && win.start <= loadedWindow.start && win.end >= loadedWindow.end;
564
+ const parts = grown
565
+ ? [
566
+ ...(win.start < loadedWindow.start ? [{ start: win.start, end: dayKey(addDays(loadedWindow.start, -1)) }] : []),
567
+ ...(win.end > loadedWindow.end ? [{ start: dayKey(addDays(loadedWindow.end, 1)), end: win.end }] : []),
568
+ ]
569
+ : [win];
570
+ const reads = await Promise.all(parts.map(async (part) => {
571
+ const inPart = await sessionsIn(part.start, part.end);
572
+ const { who: reader, found } = await placesIn(part.start, part.end, inPart);
573
+ return { inPart, reader, found };
574
+ }));
575
+ const nextSessions = grown ? ordered([...sessions, ...reads.flatMap((r) => r.inPart)]) : reads[0]!.inPart;
576
+ const nextPlaces = grown ? new Map([...places, ...reads.flatMap((r) => [...r.found])]) : reads[0]!.found;
577
+ const who = reads[0]?.reader ?? member;
656
578
  if (seq !== loadSeq) return undefined;
579
+ // The new days were read for another member than the loaded ones (a sign-out or a switch while
580
+ // the member scrolled): never mix two members' places — read the whole window for this one.
581
+ if (grown && reads.some((r) => (r.reader?.email ?? "") !== (member?.email ?? ""))) return ensureLoaded({ ...s, ...wanted }, true);
657
582
  const now = { ...s, ...wanted };
658
583
  if (!covers(win, needOf(now))) {
659
584
  // The member paged past what this read covers while it ran. The days already loaded may
@@ -701,10 +626,28 @@ function initial(): S {
701
626
  ...LOADING, firstWeekday: 0, today: "", selectedDate: "", rangeStart: "", rangeEnd: "",
702
627
  decorations: [], events: [], scrollTo: "", signedIn: false, updateRequired: false, updateMessage: "", listHidden: true,
703
628
  ...emptyFlags(false, false), resultOpen: false, resultOpenB: false, resultText: "", resultOk: false, resultInfo: false,
704
- sheetOpen: false, sheetTitle: "", sheetWhen: "", sheetMeta: "", sheetHideCalendar: true, sheetHideCancel: true, sheetHideLeave: true, sheetBusy: false, sheetServiceId: "",
629
+ sheetOpen: false, sheetTitle: "", sheetWhen: "", sheetMeta: "", sheetHideCalendar: true, sheetHideCancel: true, sheetHideLeave: true, sheetBusy: false, sheetServiceId: "", sheetSessionId: "", sheetStart: "",
630
+ locationOptions: [], locationLabel: "", locationA11y: "", hasLocationMenu: false, noLocationMenu: true,
631
+ locationActive: false, noLocationActive: true, locationSheetOpen: false, locationEmpty: false, notLocationEmpty: true,
705
632
  };
706
633
  }
707
634
 
635
+ /** How long a jump the worker asked for (a calendar tap, a page, a location) takes to settle. */
636
+ export const JUMP_SETTLE_MS = 900;
637
+ const settleJump = () => { quietUntil = Date.now() + JUMP_SETTLE_MS; };
638
+
639
+ /** A selected day with no session moves to the next day that has one ON THE SAME PAGE (the week
640
+ * or month on screen), so the calendar and the list name the same day — the same rule on every
641
+ * platform. With none left on the page the day stays selected, and the list shows the sessions
642
+ * after it. Never off the page: that would page the calendar away under the member's finger. */
643
+ async function settled(next: S | undefined): Promise<S | undefined> {
644
+ // The day the list scrolls to (a location change adds a "|n" suffix that re-applies it).
645
+ const day = next?.scrollTo.split("|")[0] ?? "";
646
+ if (!next || next.status !== "ready" || !day || day === next.selectedDate) return next;
647
+ if (day < next.selectedDate || day > titledDays(next.rangeStart, next.rangeEnd).last) return next;
648
+ return ensureLoaded(ask(next, { selectedDate: day }));
649
+ }
650
+
708
651
  /** How the app opens its sign-in screen, from the appear params ("" = push). */
709
652
  let signInPresentation: "" | "sheet" | "full_screen" = "";
710
653
 
@@ -721,9 +664,14 @@ const svc = defineService<S, {
721
664
  addToCalendar: (s: S) => Promise<S>;
722
665
  cancelBooking: (s: S) => Promise<S | undefined>;
723
666
  joinWaitlist: (s: S, p?: unknown) => Promise<S | undefined>;
667
+ visibleDate: (s: S, p?: unknown) => Promise<S | undefined>;
668
+ openLocations: (s: S) => S;
669
+ closeLocations: (s: S) => S;
670
+ selectLocation: (s: S, p?: unknown) => Promise<S | undefined>;
724
671
  }>("himiBrandedAgenda", {
725
- // An action's result belongs to that visit: a cold start must not replay it from saved state.
726
- notHydrated: ["resultOpen", "resultOpenB", "resultText", "resultOk", "resultInfo"],
672
+ // An action's result belongs to that visit: a cold start must not replay it from saved state,
673
+ // nor reopen the location sheet.
674
+ notHydrated: ["resultOpen", "resultOpenB", "resultText", "resultOk", "resultInfo", "locationSheetOpen"],
727
675
  state: initial(),
728
676
  actions: {
729
677
  load: async (s, p) => {
@@ -744,9 +692,12 @@ const svc = defineService<S, {
744
692
  const asked = first
745
693
  ? ask(newView(s), { firstWeekday: fw, today, selectedDate: today, rangeStart: week[0], rangeEnd: week[6] })
746
694
  : ask(newView(s), { firstWeekday: fw, today });
695
+ // The location chosen on any bookings screen, read again on every appearance: Services or
696
+ // Choose Time may have changed it since.
697
+ chosenLocation = await readSharedLocation();
747
698
  rowsOk ??= await rowsSupported();
748
699
  if (!rowsOk) return derive({ ...asked, ...READY });
749
- return ensureLoaded(asked, !first);
700
+ return first ? settled(await ensureLoaded(asked)) : ensureLoaded(asked, true);
750
701
  },
751
702
  // A view action: it closes the result, as every other one does through `newView`.
752
703
  retry: async (s) => { services = null; return ensureLoaded(newView({ ...s, ...LOADING }), true); },
@@ -757,6 +708,8 @@ const svc = defineService<S, {
757
708
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || date === (wanted ?? s).selectedDate) return s;
758
709
  // Past days are not selectable — also before the load has dimmed them on the calendar.
759
710
  if (date < ((wanted ?? s).today || dayKey(new Date()))) return s;
711
+ settleJump();
712
+ scrollNonce = 0;
760
713
  // A month grid's padding day (iOS draws them) belongs to the next month, and the grid moves
761
714
  // there without reporting a page: move this page with it, so paging back is still a change.
762
715
  const cur = wanted ?? s;
@@ -764,9 +717,9 @@ const svc = defineService<S, {
764
717
  const page = titledDays(cur.rangeStart, cur.rangeEnd);
765
718
  if (span > 7 && (date < page.first || date > page.last)) {
766
719
  const grid = monthGridOf(date, s.firstWeekday);
767
- if (grid) return ensureLoaded(ask(newView(s), { selectedDate: date, rangeStart: grid.start, rangeEnd: grid.end }));
720
+ if (grid) return settled(await ensureLoaded(ask(newView(s), { selectedDate: date, rangeStart: grid.start, rangeEnd: grid.end })));
768
721
  }
769
- return ensureLoaded(ask(newView(s), { selectedDate: date }));
722
+ return settled(await ensureLoaded(ask(newView(s), { selectedDate: date })));
770
723
  },
771
724
  rangeChanged: async (s, p) => {
772
725
  const start = argOf(p, "startDate"), end = argOf(p, "endDate");
@@ -776,7 +729,14 @@ const svc = defineService<S, {
776
729
  // on screen rather than a week the user has paged away from.
777
730
  const keep = keepsSelection(cur, start, end);
778
731
  const selectedDate = keep ? cur.selectedDate : selectionForRange(cur.today || dayKey(new Date()), start, end, cur.selectedDate);
779
- return ensureLoaded(ask(newView(s), { rangeStart: start, rangeEnd: end, selectedDate }));
732
+ // The calendar following the list's scroll pages too, and keeps its selection: only a page
733
+ // that moves the selection jumps the list.
734
+ if (!keep) { settleJump(); scrollNonce = 0; }
735
+ // A page that only followed the selection (the list's scroll moved it) is not a view change:
736
+ // a result on screen stays, as it does for the scroll itself.
737
+ return keep
738
+ ? ensureLoaded(ask(s, { rangeStart: start, rangeEnd: end, selectedDate }))
739
+ : settled(await ensureLoaded(ask(newView(s), { rangeStart: start, rangeEnd: end, selectedDate })));
780
740
  },
781
741
  // An older binary has no week mode and reports only the month. Its grid then spans the
782
742
  // month's whole weeks; load those, so dots still show on every visible day.
@@ -790,6 +750,7 @@ const svc = defineService<S, {
790
750
  if (!start || !last || (start === cur.rangeStart && last === cur.rangeEnd)) return s;
791
751
  const keep = keepsSelection(cur, start, last);
792
752
  const selectedDate = keep ? cur.selectedDate : selectionForRange(cur.today || dayKey(new Date()), start, last, cur.selectedDate);
753
+ if (!keep) settleJump();
793
754
  return ensureLoaded(ask(newView(s), { rangeStart: start, rangeEnd: last, selectedDate }));
794
755
  },
795
756
  openSheet: (s, p) => {
@@ -806,7 +767,9 @@ const svc = defineService<S, {
806
767
  viewSession: (s) => {
807
768
  const session = sessions.find((x) => x.key === sheetKey);
808
769
  sheetKey = "";
809
- return session ? navigate(derive(s), { screenId: "himi_service", args: { id: session.serviceId } }) : derive(s);
770
+ return session
771
+ ? navigate(derive(s), { screenId: "himi_service", args: { id: session.serviceId, sessionId: session.sessionId, start: session.start } })
772
+ : derive(s);
810
773
  },
811
774
  // From one slot's own close or dismissal (`slot`: "a" | "b"), or both when it names none.
812
775
  closeResult: (s, p) => {
@@ -941,6 +904,42 @@ const svc = defineService<S, {
941
904
  return derive({ ...(svc.getState() as S), ...result(copyParam("agenda.waitlistFailed", "Couldn't join the waitlist. Try again."), false) });
942
905
  }
943
906
  },
907
+ // The list's scroll (`onVisibleDateChange`): the day the member reads becomes the selected day,
908
+ // so the calendar (and its week or month) follows the list. Not a view action — a result on
909
+ // screen stays. The list's scroll target follows too, so a return from a session's screen comes
910
+ // back to this day; the renderer does not scroll for its own day (it is where the list is).
911
+ visibleDate: async (s, p) => {
912
+ const date = argOf(p, "date");
913
+ const cur = wanted ?? s;
914
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || date === cur.selectedDate) return s;
915
+ // A report from before a jump the worker asked for: the jump wins.
916
+ if (Date.now() < quietUntil) return s;
917
+ if (date < (cur.today || dayKey(new Date()))) return s;
918
+ scrollNonce = 0;
919
+ return ensureLoaded(ask(s, { selectedDate: date }));
920
+ },
921
+ openLocations: (s) => {
922
+ locationSheet = true;
923
+ return derive({ ...s, ...wanted });
924
+ },
925
+ closeLocations: (s) => {
926
+ if (!locationSheet) return s;
927
+ locationSheet = false;
928
+ return derive({ ...s, ...wanted });
929
+ },
930
+ // A row of the location sheet: "" is every location. Kept for Services and Choose Time too.
931
+ selectLocation: async (s, p): Promise<S | undefined> => {
932
+ const id = argOf(p, "locationId");
933
+ const choice = agendaLocations(services ?? [], sessions, chosenLocation).find((c) => c.key === id);
934
+ chosenLocation = id && choice ? { id, label: choice.label } : { id: "", label: "" };
935
+ locationSheet = false;
936
+ settleJump();
937
+ // Rows above the selected day may have come or gone: the list must land on it again.
938
+ scrollNonce += 1;
939
+ // Not awaited (it never throws): the new list shows at once, whatever the store does.
940
+ void writeSharedLocation(chosenLocation);
941
+ return settled(derive(newView({ ...s, ...wanted })));
942
+ },
944
943
  },
945
944
  });
946
945
  export { svc };