@wix/himalaya-cli 0.1529.0 → 0.1530.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 (70) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/bookings-v2/src/index.d.ts +24 -0
  2. package/dist/cli.mjs +717 -484
  3. package/dist/preview-assets.generated.bin +0 -0
  4. package/guides/release-notes.md +17 -0
  5. package/guides/templates.md +11 -1
  6. package/guides/tier-5-add-a-screen.md +4 -0
  7. package/package.json +2 -2
  8. package/templates/beauty-salon/dev/index.ts +1767 -2698
  9. package/templates/beauty-salon/tier5-src/_shared/collection-service.ts +23 -5
  10. package/templates/beauty-salon/tier5-src/_shared/engine-intl.ts +18 -0
  11. package/templates/beauty-salon/tier5-src/_shared/member-bookings.ts +50 -7
  12. package/templates/beauty-salon/tier5-src/himi-booking-bookings/index.ts +385 -74
  13. package/templates/beauty-salon-owner/tier5-src/_shared/collection-service.ts +23 -5
  14. package/templates/beauty-salon-owner/tier5-src/_shared/engine-intl.ts +18 -0
  15. package/templates/branded-lite/dev/index.ts +1753 -2697
  16. package/templates/branded-lite/tier5-src/_shared/collection-service.ts +23 -5
  17. package/templates/branded-lite/tier5-src/_shared/engine-intl.ts +18 -0
  18. package/templates/branded-lite/tier5-src/_shared/member-bookings.ts +50 -7
  19. package/templates/branded-lite/tier5-src/himi-booking-bookings/index.ts +385 -74
  20. package/templates/community-club/tier5-src/_shared/collection-service.ts +23 -5
  21. package/templates/community-club/tier5-src/_shared/engine-intl.ts +18 -0
  22. package/templates/community-club/tier5-src/_shared/member-bookings.ts +50 -7
  23. package/templates/community-club-owner/tier5-src/_shared/collection-service.ts +23 -5
  24. package/templates/community-club-owner/tier5-src/_shared/engine-intl.ts +18 -0
  25. package/templates/events-meetups/tier5-src/_shared/collection-service.ts +23 -5
  26. package/templates/events-meetups/tier5-src/_shared/engine-intl.ts +18 -0
  27. package/templates/events-meetups/tier5-src/_shared/member-bookings.ts +50 -7
  28. package/templates/events-meetups-owner/tier5-src/_shared/collection-service.ts +23 -5
  29. package/templates/events-meetups-owner/tier5-src/_shared/engine-intl.ts +18 -0
  30. package/templates/gym-fitness/dev/index.ts +1753 -2697
  31. package/templates/gym-fitness/tier5-src/_shared/collection-service.ts +23 -5
  32. package/templates/gym-fitness/tier5-src/_shared/engine-intl.ts +18 -0
  33. package/templates/gym-fitness/tier5-src/_shared/member-bookings.ts +50 -7
  34. package/templates/gym-fitness/tier5-src/himi-booking-bookings/index.ts +385 -74
  35. package/templates/gym-fitness-owner/tier5-src/_shared/collection-service.ts +23 -5
  36. package/templates/gym-fitness-owner/tier5-src/_shared/engine-intl.ts +18 -0
  37. package/templates/nonprofit-cause/tier5-src/_shared/collection-service.ts +23 -5
  38. package/templates/nonprofit-cause/tier5-src/_shared/engine-intl.ts +18 -0
  39. package/templates/nonprofit-cause/tier5-src/_shared/member-bookings.ts +50 -7
  40. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-service.ts +23 -5
  41. package/templates/nonprofit-cause-owner/tier5-src/_shared/engine-intl.ts +18 -0
  42. package/templates/owner-lite/tier5-src/_shared/collection-service.ts +23 -5
  43. package/templates/owner-lite/tier5-src/_shared/engine-intl.ts +18 -0
  44. package/templates/podcast-creator/tier5-src/_shared/collection-service.ts +23 -5
  45. package/templates/podcast-creator/tier5-src/_shared/engine-intl.ts +18 -0
  46. package/templates/podcast-creator/tier5-src/_shared/member-bookings.ts +50 -7
  47. package/templates/podcast-creator-owner/tier5-src/_shared/collection-service.ts +23 -5
  48. package/templates/podcast-creator-owner/tier5-src/_shared/engine-intl.ts +18 -0
  49. package/templates/portfolio-creative/tier5-src/_shared/collection-service.ts +23 -5
  50. package/templates/portfolio-creative/tier5-src/_shared/engine-intl.ts +18 -0
  51. package/templates/portfolio-creative/tier5-src/_shared/member-bookings.ts +50 -7
  52. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-service.ts +23 -5
  53. package/templates/portfolio-creative-owner/tier5-src/_shared/engine-intl.ts +18 -0
  54. package/templates/restaurant/tier5-src/_shared/collection-service.ts +23 -5
  55. package/templates/restaurant/tier5-src/_shared/engine-intl.ts +18 -0
  56. package/templates/restaurant/tier5-src/_shared/member-bookings.ts +50 -7
  57. package/templates/restaurant-owner/tier5-src/_shared/collection-service.ts +23 -5
  58. package/templates/restaurant-owner/tier5-src/_shared/engine-intl.ts +18 -0
  59. package/templates/retail-store/tier5-src/_shared/collection-service.ts +23 -5
  60. package/templates/retail-store/tier5-src/_shared/engine-intl.ts +18 -0
  61. package/templates/retail-store/tier5-src/_shared/member-bookings.ts +50 -7
  62. package/templates/retail-store-owner/tier5-src/_shared/collection-service.ts +23 -5
  63. package/templates/retail-store-owner/tier5-src/_shared/engine-intl.ts +18 -0
  64. package/templates/yoga-studio/dev/index.ts +1755 -2699
  65. package/templates/yoga-studio/tier5-src/_shared/collection-service.ts +23 -5
  66. package/templates/yoga-studio/tier5-src/_shared/engine-intl.ts +18 -0
  67. package/templates/yoga-studio/tier5-src/_shared/member-bookings.ts +50 -7
  68. package/templates/yoga-studio/tier5-src/himi-booking-bookings/index.ts +385 -74
  69. package/templates/yoga-studio-owner/tier5-src/_shared/collection-service.ts +23 -5
  70. package/templates/yoga-studio-owner/tier5-src/_shared/engine-intl.ts +18 -0
@@ -1,37 +1,64 @@
1
1
  import { defineBackendCollection } from "../_shared/collection-service.js";
2
- import { cancelBookingAsMember, isBookingPolicyViolation } from "@himalaya/wix-bookings";
2
+ import { cancelBookingAsMember, isBookingPolicyViolation, queryServices, type WixService } from "@himalaya/wix-bookings";
3
+ import { createEvent, getPermissionStatus, requestPermission } from "@himalaya/calendar";
4
+ import { open } from "@himalaya/open-url";
5
+ import { wixMediaUrl, WIX_LIST_IMAGE } from "@himalaya/wix-media";
3
6
  import { navigate } from "@himalaya/state";
4
- import { copyParam } from "../_shared/himi-copy.js";
7
+ import { copyParam, copyTemplate } from "../_shared/himi-copy.js";
5
8
  import { memberAccess, assertMemberAccess, memberAccessChanged, memberAccessDenied, type MemberAccess } from "../_shared/member-access.js";
6
- import { fetchMemberBookings, type MemberBookingRow } from "../_shared/member-bookings.js";
9
+ import { fetchMemberBookings, CANCELLED_STATUSES, type MemberBookingRow } from "../_shared/member-bookings.js";
7
10
  import { emitHostEvent } from "../_shared/host-events.js";
8
- import { dayKey, dayLabel } from "@himalaya/time";
9
- import { localPreferences } from "../_shared/local-preferences.js";
11
+ import { formatClock, formatDateMedium, formatMonthDay, formatWeekdayMonthDay, formatWeekdayMonthDayLong } from "../_shared/engine-intl.js";
10
12
  import { parseWeeklyGoal, weeklyGoalProgress, weeklySessionsFromPages } from "../_shared/weekly-goal.js";
11
13
 
12
- const input = (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input;
13
- const validDate = (value: unknown): value is string => typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && dayKey(new Date(`${value}T12:00:00`)) === value;
14
- const views = localPreferences("booking.schedule", value => ["agenda", "day", "month"].includes(String(value)) ? String(value) : "agenda");
15
- let mode = validDate(input?.date) ? "day" : views.read();
16
- // "" until the schedule is first shown, NOT today's date: the release build hashes this worker's
17
- // initial state, so a date read off the build machine's clock made every build of unchanged
18
- // source a different release (#3268, the `nondeterministic-initial-state` lint). `derive()` fills
19
- // in today the first time the schedule is visible, which is never at build time (no member).
20
- let selectedDate = validDate(input?.date) ? input.date : "";
21
- const today = () => dayKey(new Date());
14
+ // My Bookings: Upcoming / Past / Canceled tabs over the member's own bookings, a booking sheet with
15
+ // the details and the actions (add to calendar, directions, Book Again, Cancel Booking with a
16
+ // confirm step), and a non-modal result sheet. The list is the shared backend collection (paging,
17
+ // the backend filter and sort); the tab sets the filter and the sort together.
18
+
19
+ type Tab = "upcoming" | "past" | "cancelled";
20
+ // `query: ""` — the screen has no search field: a booking-ID search the old toolbar persisted must
21
+ // not narrow every tab with nothing on screen to clear it.
22
+ const TABS: Record<Tab, { filter: string; sort: string; query: string }> = {
23
+ upcoming: { filter: "value:Upcoming", sort: "default", query: "" },
24
+ past: { filter: "value:Past", sort: "za", query: "" },
25
+ cancelled: { filter: "value:Cancelled", sort: "za", query: "" },
26
+ };
27
+ const runtime = () => (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya;
28
+
29
+ let tab: Tab = "upcoming";
22
30
  let pageMember: MemberAccess | undefined;
23
- let queryNow = ""; // set by the first-page fetch, never at boot (see selectedDate)
24
- let cancelTarget: { row: MemberBookingRow; member: MemberAccess } | undefined;
31
+ let queryNow = ""; // set by the first-page fetch, never at boot (the `nondeterministic-initial-state` lint)
32
+ /** A member's page is on screen (the tabs show). Kept while a tab switch loads. */
33
+ let memberShown = false;
34
+ /** The last failure was an access refusal: the sign-in state shows, not the error state. */
35
+ let lastErrorDenied = false;
36
+ /** The booking whose sheet is open, and whether the sheet is on its confirm-cancel step. */
37
+ let openId = "";
38
+ let confirming = false;
39
+ let sheetMessage = "";
25
40
  let cancelling = false;
41
+ let cancelMember: MemberAccess | undefined;
42
+ /** The result sheet (non-modal): a done action (check icon) or a failure / info (info icon). */
43
+ let result: { text: string; done: boolean } | null = null;
44
+ /** A deep link's `bookingId`, applied once to the first page that loads after it arrives. */
45
+ let pendingBookingId = "";
46
+ let consumedBookingId = "";
26
47
  // The weekly session goal (`provisioning.goals.weeklySessions`), from the onAppear `weeklyGoal`
27
48
  // param — 0 when the app provisions none, which keeps the "This week" card hidden for good. The
28
49
  // count comes from a dedicated read of this week's PAST bookings on each first-page fetch, NOT
29
- // from `rows` (those follow the list's filter — Upcoming by default — and would read 0).
50
+ // from `rows` (those follow the list's tab — Upcoming by default — and would read 0).
30
51
  let weeklyGoal = 0;
31
52
  /** The count of the page that APPLIED — set in `apply`, never by a fetch, so a superseded fetch
32
53
  * (another member, a refresh) cannot overwrite it: it travels in that page's metadata and is
33
54
  * dropped with it. */
34
55
  let weeklyDone: number | null = null;
56
+
57
+ /** Service photos by service id ("" = the service has none). The booking record has no photo, so
58
+ * one `queryServices` call per page fetches the ones not yet known. Kept for the worker's life. */
59
+ const photos = new Map<string, string>();
60
+ const photosInFlight = new Set<string>();
61
+
35
62
  /** This week's sessions from the PAST bookings, newest first (paged only while still inside this
36
63
  * week). null when a read fails, so the card hides rather than claiming "0 of N". */
37
64
  async function fetchWeeklySessionsDone(member: MemberAccess, now: number): Promise<number | null> {
@@ -43,48 +70,248 @@ async function fetchWeeklySessionsDone(member: MemberAccess, now: number): Promi
43
70
  return null;
44
71
  }
45
72
  }
73
+
74
+ function serviceImage(service: WixService): string {
75
+ const media = service.media ?? {};
76
+ for (const item of [media.mainMedia, ...(media.items ?? []), media.coverMedia]) {
77
+ const url = wixMediaUrl(item?.image?.url, WIX_LIST_IMAGE);
78
+ if (url) return url;
79
+ }
80
+ // Older records carry the photo in other shapes; the service screen reads them the same way.
81
+ return legacyImageUrl(service);
82
+ }
83
+
84
+ function legacyImageUrl(value: unknown, depth = 0): string {
85
+ if (depth > 4) return "";
86
+ if (typeof value === "string") return wixMediaUrl(value, WIX_LIST_IMAGE);
87
+ if (!value || typeof value !== "object") return "";
88
+ if (Array.isArray(value)) {
89
+ for (const item of value) {
90
+ const url = legacyImageUrl(item, depth + 1);
91
+ if (url) return url;
92
+ }
93
+ return "";
94
+ }
95
+ const node = value as Record<string, unknown>;
96
+ for (const direct of [node.url, node.src, node.imageUrl, node.imageSrc]) {
97
+ const url = wixMediaUrl(direct, WIX_LIST_IMAGE);
98
+ if (url) return url;
99
+ }
100
+ for (const key of ["image", "thumbnail", "mediaItem", "mainMedia", "main", "media", "items", "photos", "gallery", "coverImage"]) {
101
+ const url = legacyImageUrl(node[key], depth + 1);
102
+ if (url) return url;
103
+ }
104
+ return "";
105
+ }
106
+
107
+ /** Fetch the photos of the services on this page that are not known yet: one call, at most 50 ids.
108
+ * A failure keeps the placeholders and never fails the list. */
109
+ function loadPhotos(rows: MemberBookingRow[]): void {
110
+ const ids = [...new Set(rows.map(row => row.serviceId).filter(id => id && !photos.has(id) && !photosInFlight.has(id)))].slice(0, 50);
111
+ if (!ids.length) return;
112
+ ids.forEach(id => photosInFlight.add(id));
113
+ queryServices({ query: { filter: { id: { $in: ids } }, paging: { limit: 50 } } } as Parameters<typeof queryServices>[0])
114
+ .then(response => {
115
+ const found = new Map((response.services ?? []).map(service => [service.id, serviceImage(service)] as const));
116
+ ids.forEach(id => photos.set(id, found.get(id) ?? ""));
117
+ ids.forEach(id => photosInFlight.delete(id));
118
+ if (found.size) svc.collectionDataChanged();
119
+ })
120
+ .catch(() => { ids.forEach(id => photosInFlight.delete(id)); });
121
+ }
122
+
123
+ // ---- Projection -------------------------------------------------------------------
124
+
125
+ const cancelledStatus = (row: MemberBookingRow) => CANCELLED_STATUSES.includes(row.statusKey);
126
+ const date = (iso: string) => new Date(iso);
127
+
128
+ function tagOf(row: MemberBookingRow): string {
129
+ switch (row.statusKey) {
130
+ // Wix answers PENDING_APPROVAL; PENDING is kept for older records.
131
+ case "PENDING": case "PENDING_APPROVAL": return copyParam("bookings.statusPending", "Pending approval");
132
+ case "WAITING_LIST": return copyParam("bookings.statusWaitlist", "Waitlist");
133
+ case "CANCELED": case "CANCELLED": return copyParam("bookings.statusCancelled", "Canceled");
134
+ case "DECLINED": return copyParam("bookings.statusDeclined", "Declined");
135
+ }
136
+ return row.isCourse ? copyParam("bookings.coursePrefix", "Course") : "";
137
+ }
138
+
139
+ function placeOf(row: MemberBookingRow): string {
140
+ return row.placeType === "CUSTOMER" ? copyParam("bookingCommon.clientPlace", "Client's place") : row.placeName;
141
+ }
142
+
143
+ function courseRange(row: MemberBookingRow, last: (d: Date) => string): string {
144
+ if (!row.courseStart) return "";
145
+ return row.courseEnd ? `${formatMonthDay(date(row.courseStart))} – ${last(date(row.courseEnd))}` : formatMonthDay(date(row.courseStart));
146
+ }
147
+
148
+ /** "Tue, Nov 4 · 10:00 AM", or "Course · Nov 10 – Dec 15" for a course. */
149
+ function whenOf(row: MemberBookingRow): string {
150
+ if (row.isCourse && row.courseStart) return `${copyParam("bookings.coursePrefix", "Course")} · ${courseRange(row, formatMonthDay)}`;
151
+ return row.start ? `${formatWeekdayMonthDay(date(row.start))} · ${formatClock(date(row.start))}` : row.subtitle;
152
+ }
153
+
154
+ function staffLine(row: MemberBookingRow): string {
155
+ return row.staff ? copyTemplate("bookings.withStaff", "with {staff}", { staff: row.staff }) : "";
156
+ }
157
+
158
+ /** "10:00 – 11:15 AM" (one meridiem when both share it), "11:30 AM – 12:45 PM" otherwise. */
159
+ function timeRange(start: Date, end: Date): string {
160
+ const a = formatClock(start), b = formatClock(end);
161
+ const ma = /\s(\S+)$/.exec(a)?.[1], mb = /\s(\S+)$/.exec(b)?.[1];
162
+ return ma && ma === mb ? `${a.slice(0, -ma.length).trim()} – ${b}` : `${a} – ${b}`;
163
+ }
164
+
165
+ function durationText(minutes: number): string {
166
+ const h = Math.floor(minutes / 60), m = minutes % 60;
167
+ const hours = h ? copyTemplate("bookings.durationHours", "{n} hr", { n: h }) : "";
168
+ const mins = m ? copyTemplate("bookings.durationMinutes", "{n} min", { n: m }) : "";
169
+ return [hours, mins].filter(Boolean).join(" ");
170
+ }
171
+
172
+ /** The sheet's second date line: "10:00 – 11:15 AM · 1 hr 15 min". */
173
+ function timeLine(row: MemberBookingRow): string {
174
+ if (row.isCourse || !row.start) return "";
175
+ const start = date(row.start), end = row.end ? date(row.end) : null;
176
+ if (!end || !Number.isFinite(end.getTime()) || end <= start) return formatClock(start);
177
+ const minutes = Math.round((end.getTime() - start.getTime()) / 60000);
178
+ return `${timeRange(start, end)} · ${durationText(minutes)}`;
179
+ }
180
+
181
+ function dateLine(row: MemberBookingRow): string {
182
+ if (row.isCourse) return courseRange(row, formatDateMedium);
183
+ return row.start ? formatWeekdayMonthDayLong(date(row.start)) : row.subtitle;
184
+ }
185
+
186
+ function paymentOf(row: MemberBookingRow): string {
187
+ switch (row.paymentStatus) {
188
+ case "PAID": return copyParam("bookings.payPaid", "Paid");
189
+ case "NOT_PAID": return copyParam("bookings.payNotPaid", "Not paid yet");
190
+ case "PARTIALLY_PAID": return copyParam("bookings.payPartly", "Partly paid");
191
+ case "REFUNDED": return copyParam("bookings.payRefunded", "Refunded");
192
+ }
193
+ return "";
194
+ }
195
+
196
+ function noticeOf(row: MemberBookingRow): string {
197
+ if (row.statusKey === "PENDING" || row.statusKey === "PENDING_APPROVAL") return copyParam("bookings.pendingNotice", "The business must approve this booking. You get an email when they reply.");
198
+ if (row.statusKey === "WAITING_LIST") return copyParam("bookings.waitlistNotice", "You are on the waitlist. You get an email if a spot opens.");
199
+ return "";
200
+ }
201
+
202
+ export interface BookingListRow extends MemberBookingRow {
203
+ image: string; hasImage: boolean; noImage: boolean; when: string; detail: string; tag: string; noTag: boolean; a11y: string;
204
+ }
205
+
206
+ function listRow(row: MemberBookingRow): BookingListRow {
207
+ const image = photos.get(row.serviceId) ?? "";
208
+ const tag = tagOf(row), place = placeOf(row), staff = staffLine(row);
209
+ const fullWhen = row.isCourse ? dateLine(row) : row.start ? `${formatWeekdayMonthDayLong(date(row.start))}, ${formatClock(date(row.start))}` : row.subtitle;
210
+ return { ...row, image, hasImage: !!image, noImage: !image, when: whenOf(row), detail: [staff, place].filter(Boolean).join(" · "),
211
+ tag, noTag: !tag, a11y: [row.title, fullWhen, staff, place, tag].filter(Boolean).join(", ") };
212
+ }
213
+
46
214
  interface S {
47
- status: "loading" | "ready" | "error"; rows: MemberBookingRow[]; isMember: boolean; identityLine: string;
48
- emptyText: string; isLoading: boolean; notLoading: boolean; errored: boolean; notErrored: boolean; noEmpty: boolean;
49
- actionMessage: string; cancelPresented: boolean; cancelTitle: string;
50
- scheduleMode: string; selectedDate: string; dayRows: MemberBookingRow[]; dayHeading: string;
51
- hideAgenda: boolean; hideCalendar: boolean; hideMonth: boolean; hideSchedule: boolean;
52
- dayEmptyText: string; decorations: Array<{ date: string; dotCount: number; dotColorToken: string }>;
215
+ status: "loading" | "ready" | "error"; rows: MemberBookingRow[]; bookingRows: BookingListRow[];
216
+ isMember: boolean; isLoading: boolean; notLoading: boolean; errored: boolean; notErrored: boolean; noEmpty: boolean;
217
+ tab: Tab; hideTabs: boolean; noRows: boolean; hideSignIn: boolean; hideHelp: boolean;
218
+ hideEmptyUpcoming: boolean; hideEmptyPast: boolean; hideEmptyCancelled: boolean;
219
+ sheetOpen: boolean; sheetConfirming: boolean; sheetNotConfirming: boolean;
220
+ sheetTitle: string; sheetImage: string; sheetHasImage: boolean; sheetNoImage: boolean; sheetTag: string; sheetNoTag: boolean;
221
+ sheetDate: string; sheetTime: string; sheetCanAddToCalendar: boolean; sheetCannotAddToCalendar: boolean;
222
+ sheetStaff: string; sheetNoStaff: boolean; sheetPlace: string; sheetAddress: string; sheetNoPlace: boolean;
223
+ sheetHasDirections: boolean; sheetNoDirections: boolean; sheetHidePlainPlace: boolean; sheetDirectionsLabel: string;
224
+ sheetParticipants: string; sheetNoParticipants: boolean; sheetPayment: string; sheetNoPayment: boolean;
225
+ sheetNotice: string; sheetNoNotice: boolean; sheetCanRebook: boolean; sheetCannotRebook: boolean;
226
+ sheetCanCancel: boolean; sheetCannotCancel: boolean; sheetLine: string; sheetMessage: string; cancelling: boolean;
227
+ resultOpen: boolean; resultText: string; resultDone: boolean; resultNotDone: boolean;
53
228
  weeklyLabel: string; weeklyRemainingLabel: string; weeklyPct: number; weeklyMet: boolean; hideTracker: boolean;
54
229
  nextCursor?: string; complete?: boolean; loadingMore?: boolean; pageError?: boolean; total?: number;
55
230
  }
231
+
232
+ const openRow = (s: Pick<S, "rows">): MemberBookingRow | undefined => openId ? s.rows.find(row => row.id === openId) : undefined;
233
+ const canAddToCalendar = (row: MemberBookingRow) => !row.isCourse && !!row.start && Date.parse(row.start) > Date.now() && !cancelledStatus(row);
234
+ const hasDirections = (row: MemberBookingRow) => !!row.placeAddress && row.placeType !== "CUSTOMER";
235
+
56
236
  function derive(s: S): S {
57
- const hideSchedule = !s.isMember || s.isLoading;
58
- if (!selectedDate && !hideSchedule) selectedDate = today();
59
- const dayRows = selectedDate ? s.rows.filter(row => row.day === selectedDate) : [];
60
- const counts = new Map<string, number>();
61
- for (const row of s.rows) if (row.day) counts.set(row.day, (counts.get(row.day) ?? 0) + 1);
237
+ const loading = s.status === "loading", errored = s.status === "error";
238
+ const rowsShown = s.rows.length > 0;
239
+ // A row that left the list (a refresh, another tab) closes its sheet.
240
+ if (openId && !openRow(s) && !loading && !cancelling) { openId = ""; confirming = false; sheetMessage = ""; }
241
+ const row = openRow(s);
242
+ const listed = row ? listRow(row) : undefined;
243
+ const place = row ? placeOf(row) : "";
244
+ const showSignIn = !loading && ((s.status === "ready" && !s.isMember) || (errored && lastErrorDenied && !rowsShown));
245
+ const emptyFor = (t: Tab) => !(s.status === "ready" && s.isMember && !rowsShown && tab === t);
62
246
  // Member-only, and only once this week's count was actually read (a failed read hides the card
63
247
  // rather than claiming "0 of N").
64
- const weekly = weeklyGoal > 0 && !hideSchedule && weeklyDone !== null ? weeklyGoalProgress(weeklyDone, weeklyGoal) : null;
65
- return { ...s, scheduleMode: mode,
248
+ const weekly = weeklyGoal > 0 && memberShown && weeklyDone !== null ? weeklyGoalProgress(weeklyDone, weeklyGoal) : null;
249
+ const participants = row && row.participants > 1 ? copyTemplate("bookings.participants", "{count} participants", { count: row.participants }) : "";
250
+ const payment = row ? paymentOf(row) : "", notice = row ? noticeOf(row) : "";
251
+ const canCancel = !!row && row.canCancel && Date.parse(row.start) > Date.now();
252
+ return { ...s, tab, bookingRows: s.rows.map(listRow),
253
+ isLoading: loading, notLoading: !(loading && !rowsShown), errored, notErrored: !(errored && !lastErrorDenied && !rowsShown), noEmpty: true,
254
+ hideTabs: !memberShown, noRows: !rowsShown, hideSignIn: !showSignIn,
255
+ hideHelp: (loading && !rowsShown) || (errored && !lastErrorDenied && !rowsShown),
256
+ hideEmptyUpcoming: emptyFor("upcoming"), hideEmptyPast: emptyFor("past"), hideEmptyCancelled: emptyFor("cancelled"),
257
+ sheetOpen: !!row, sheetConfirming: !!row && confirming, sheetNotConfirming: !row || !confirming,
258
+ sheetTitle: row?.title ?? "", sheetImage: listed?.image ?? "", sheetHasImage: !!listed?.hasImage, sheetNoImage: !listed?.hasImage,
259
+ sheetTag: listed?.tag ?? "", sheetNoTag: !listed?.tag,
260
+ sheetDate: row ? dateLine(row) : "", sheetTime: row ? timeLine(row) : "",
261
+ sheetCanAddToCalendar: !!row && canAddToCalendar(row), sheetCannotAddToCalendar: !row || !canAddToCalendar(row),
262
+ sheetStaff: row ? staffLine(row) : "", sheetNoStaff: !row?.staff,
263
+ sheetPlace: place, sheetNoPlace: !place,
264
+ // The address under the name only when the row shows a name of its own (never the client's place).
265
+ sheetAddress: row && row.placeType !== "CUSTOMER" && row.placeName !== row.placeAddress ? row.placeAddress : "",
266
+ sheetHasDirections: !!row && hasDirections(row), sheetNoDirections: !row || !hasDirections(row),
267
+ sheetHidePlainPlace: !place || (!!row && hasDirections(row)),
268
+ sheetDirectionsLabel: place ? copyTemplate("bookings.directionsLabel", "Get directions to {place}", { place }) : "",
269
+ sheetParticipants: participants, sheetNoParticipants: !participants,
270
+ sheetPayment: payment, sheetNoPayment: !payment, sheetNotice: notice, sheetNoNotice: !notice,
271
+ sheetCanRebook: !!row?.serviceId, sheetCannotRebook: !row?.serviceId,
272
+ sheetCanCancel: canCancel, sheetCannotCancel: !canCancel,
273
+ sheetLine: row ? [row.title, whenOf(row)].join(" · ") : "", sheetMessage, cancelling,
274
+ resultOpen: !!result, resultText: result?.text ?? "", resultDone: !!result?.done, resultNotDone: !result?.done,
66
275
  weeklyLabel: weekly?.label ?? "", weeklyRemainingLabel: weekly?.remainingLabel ?? "", weeklyPct: weekly?.fraction ?? 0,
67
- weeklyMet: weekly?.met ?? false, hideTracker: !weekly, selectedDate, dayRows, dayHeading: selectedDate ? dayLabel(selectedDate) : "", hideSchedule,
68
- hideAgenda: hideSchedule || mode !== "agenda", hideCalendar: hideSchedule || mode === "agenda", hideMonth: mode !== "month",
69
- decorations: [...counts].map(([date, n]) => ({ date, dotCount: Math.min(n, 3), dotColorToken: "accentPrimary" })),
70
- dayEmptyText: dayRows.length ? "" : s.complete ? copyParam("bookings.emptyDay", "No bookings on this day.") : copyParam("bookings.emptyLoadedDay", "No bookings on this day in loaded results. Load more to check later pages."),
71
- cancelPresented: !!cancelTarget, cancelTitle: cancelTarget ? `${cancelTarget.row.title} · ${cancelTarget.row.subtitle}` : "" };
276
+ weeklyMet: weekly?.met ?? false, hideTracker: !weekly };
72
277
  }
278
+
279
+ const closeSheet = () => { openId = ""; confirming = false; sheetMessage = ""; };
280
+ const showResult = (s: S, text: string, done: boolean): S => { closeSheet(); result = { text, done }; return derive(s); };
281
+
73
282
  const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings", {
74
283
  onlineWrites: true,
284
+ // Offline, Cancel Booking says so in the confirm step, next to the button (the sheet stays open).
285
+ onlineWriteMessageFields: ["sheetMessage"],
75
286
  restorePagesOnLoad: true,
76
- state: derive({ status: "loading", rows: [], isMember: false, identityLine: "", emptyText: "", isLoading: true, notLoading: false,
77
- errored: false, notErrored: true, noEmpty: true, actionMessage: "", cancelPresented: false, cancelTitle: "",
78
- scheduleMode: mode, selectedDate, dayRows: [], dayHeading: "", hideAgenda: true, hideCalendar: true, hideMonth: true, hideSchedule: true, dayEmptyText: "", decorations: [],
79
- weeklyLabel: "", weeklyRemainingLabel: "", weeklyPct: 0, weeklyMet: false, hideTracker: true }),
287
+ state: derive({ status: "loading", rows: [], bookingRows: [], isMember: false, isLoading: true, notLoading: false,
288
+ errored: false, notErrored: true, noEmpty: true, tab: "upcoming", hideTabs: true, noRows: true, hideSignIn: true, hideHelp: true,
289
+ hideEmptyUpcoming: true, hideEmptyPast: true, hideEmptyCancelled: true,
290
+ sheetOpen: false, sheetConfirming: false, sheetNotConfirming: true, sheetTitle: "", sheetImage: "", sheetHasImage: false, sheetNoImage: true,
291
+ sheetTag: "", sheetNoTag: true, sheetDate: "", sheetTime: "", sheetCanAddToCalendar: false, sheetCannotAddToCalendar: true,
292
+ sheetStaff: "", sheetNoStaff: true, sheetPlace: "", sheetAddress: "", sheetNoPlace: true, sheetHasDirections: false, sheetNoDirections: true, sheetHidePlainPlace: true,
293
+ sheetDirectionsLabel: "", sheetParticipants: "", sheetNoParticipants: true, sheetPayment: "", sheetNoPayment: true,
294
+ sheetNotice: "", sheetNoNotice: true, sheetCanRebook: false, sheetCannotRebook: true, sheetCanCancel: false, sheetCannotCancel: true,
295
+ sheetLine: "", sheetMessage: "", cancelling: false, resultOpen: false, resultText: "", resultDone: false, resultNotDone: true,
296
+ weeklyLabel: "", weeklyRemainingLabel: "", weeklyPct: 0, weeklyMet: false, hideTracker: true, nextCursor: "" }),
80
297
  onLoad(arg) {
81
298
  const goal = parseWeeklyGoal((arg as Record<string, unknown> | undefined)?.weeklyGoal);
82
299
  if (goal) weeklyGoal = goal;
300
+ // A deep link (the home "next booking", the place summary) names a booking: open its sheet once
301
+ // its row is on the first page. `date` alone does nothing now (there is no day view).
302
+ const linked = runtime()?.input?.bookingId;
303
+ if (typeof linked === "string" && linked && linked !== consumedBookingId) pendingBookingId = linked;
304
+ // The screen opens on Upcoming every time, not on the last persisted filter.
305
+ tab = "upcoming";
306
+ return TABS.upcoming;
83
307
  },
84
308
  source: {
85
309
  id: row => row.id,
86
310
  clearOnError: memberAccessDenied,
87
- errorMessage: error => memberAccessDenied(error) ? copyParam("bookings.accessError", "Bookings are unavailable for this account. Sign in again.") : copyParam("bookings.errorText", "Bookings could not be loaded."),
311
+ errorMessage: error => {
312
+ lastErrorDenied = memberAccessDenied(error);
313
+ return lastErrorDenied ? copyParam("bookings.accessError", "Bookings are unavailable for this account. Sign in again.") : copyParam("bookings.errorText", "Bookings could not be loaded.");
314
+ },
88
315
  async fetch(criteria, cursor) {
89
316
  const member = await memberAccess();
90
317
  if (cursor && (!pageMember || pageMember.email !== member.email || pageMember.scope !== member.scope)) throw memberAccessChanged();
@@ -97,59 +324,143 @@ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings",
97
324
  },
98
325
  apply(s, page) {
99
326
  const member = { email: String(page.metadata?.email ?? ""), scope: String(page.metadata?.scope ?? "") };
100
- if (page.status === "loading" || !member.email) { pageMember = undefined; cancelTarget = undefined; weeklyDone = null; }
327
+ const ready = page.status === "ready", errored = page.status === "error", loading = page.status === "loading";
328
+ // A change of member (or sign-out) drops everything that belonged to the last one.
329
+ if (ready && !page.loadingMore && pageMember && pageMember.email !== member.email) { closeSheet(); result = null; }
330
+ if ((loading && !page.items.length) || (ready && !member.email)) { pageMember = undefined; weeklyDone = null; }
331
+ if (ready || page.loadingMore) lastErrorDenied = false;
101
332
  const weekly = page.metadata?.weeklyDone;
102
- if (page.status === "ready" && !page.loadingMore && (typeof weekly === "number" || weekly === null)) weeklyDone = weekly;
103
- if (page.status === "ready" && !page.loadingMore && !page.pageError) pageMember = member;
104
- const loading = page.status === "loading", errored = page.status === "error";
105
- return derive({ ...s, status: loading ? "loading" : errored ? "error" : "ready", rows: page.items, isMember: !!member.email,
106
- identityLine: member.email, nextCursor: page.nextCursor, complete: page.complete, total: page.total, loadingMore: page.loadingMore, pageError: page.pageError,
107
- isLoading: loading, notLoading: !loading, errored, notErrored: !errored, noEmpty: true,
108
- actionMessage: member.email ? s.actionMessage : "", emptyText: errored ? page.error : "" });
333
+ if (ready && !page.loadingMore && (typeof weekly === "number" || weekly === null)) weeklyDone = weekly;
334
+ if (ready && !page.loadingMore && !page.pageError) pageMember = member;
335
+ if (ready && !page.loadingMore && !page.provisional) memberShown = !!member.email;
336
+ if (errored && lastErrorDenied) { memberShown = false; closeSheet(); }
337
+ if (ready && !page.loadingMore && !page.provisional) {
338
+ loadPhotos(page.items);
339
+ if (pendingBookingId) {
340
+ if (page.items.some(row => row.id === pendingBookingId)) { openId = pendingBookingId; confirming = false; sheetMessage = ""; }
341
+ consumedBookingId = pendingBookingId; pendingBookingId = "";
342
+ }
343
+ }
344
+ return derive({ ...s, status: loading ? "loading" : errored ? "error" : "ready", rows: page.items,
345
+ isMember: loading ? s.isMember : !!member.email,
346
+ nextCursor: page.nextCursor, complete: page.complete, total: page.total, loadingMore: page.loadingMore, pageError: page.pageError });
109
347
  },
110
348
  actions: {
111
- scheduleView(s, arg) { const value = (arg as { selectedId?: string })?.selectedId; if (["agenda", "day", "month"].includes(value || "")) { mode = value!; views.write(mode); } return derive(s); },
112
- selectDate(s, arg) { const date = (arg as { date?: unknown })?.date; if (validDate(date)) selectedDate = date; return derive(s); },
113
- previousDay(s) { const date = new Date(`${selectedDate || today()}T12:00:00`); date.setDate(date.getDate() - 1); selectedDate = dayKey(date); return derive(s); },
114
- nextDay(s) { const date = new Date(`${selectedDate || today()}T12:00:00`); date.setDate(date.getDate() + 1); selectedDate = dayKey(date); return derive(s); },
349
+ async selectTab(s, arg) {
350
+ const value = (arg as { selectedId?: string })?.selectedId;
351
+ if (value !== "upcoming" && value !== "past" && value !== "cancelled") return s;
352
+ if (value === tab) return derive(s);
353
+ tab = value; closeSheet();
354
+ // Filter and sort in one criteria change: one query, through the collection's own path.
355
+ await svc.collectionCriteria(TABS[value]);
356
+ return undefined;
357
+ },
358
+ openBooking(s, arg) {
359
+ const id = (arg as { id?: string })?.id;
360
+ if (!id || !s.rows.some(row => row.id === id)) return s;
361
+ openId = id; confirming = false; sheetMessage = "";
362
+ return derive(s);
363
+ },
364
+ closeBooking(s) { if (!cancelling) closeSheet(); return derive(s); },
365
+ closeResult(s) { result = null; return derive(s); },
115
366
  async rebook(s, arg) {
116
- const member = pageMember, row = s.rows.find(row => row.id === (arg as { id?: string })?.id);
367
+ const member = pageMember, row = s.rows.find(row => row.id === ((arg as { id?: string })?.id || openId));
117
368
  if (!member || !row?.serviceId) return s;
118
369
  // A returned `_nav`, not `dispatch("himi.open")`: a worker's nav dispatch went to the flat
119
370
  // native ActionDispatcher, which has no `himi.open` handler, so Rebook silently refreshed on
120
371
  // every binary shipped before #3116. `_nav` routes through each host's nav router on all of them.
121
- try { await assertMemberAccess(member); return navigate(s, { screenId: "himi_service", args: { id: row.serviceId } }); }
122
- catch { await svc.refresh(); return undefined; }
372
+ try { await assertMemberAccess(member); closeSheet(); return navigate(derive(s), { screenId: "himi_service", args: { id: row.serviceId } }); }
373
+ catch { closeSheet(); await svc.refresh(); return undefined; }
123
374
  },
124
- // The visible row and swipe/menu review paths prepare the SAME confirmation.
375
+ // Step 2 of the sheet: confirm the cancel. Nothing is written until confirmCancel. An `{ id }`
376
+ // param (older callers) opens that booking first.
125
377
  async cancel(s, arg) {
126
- const member = pageMember, row = s.rows.find(row => row.id === (arg as { id?: string })?.id);
378
+ const id = (arg as { id?: string })?.id || openId;
379
+ const member = pageMember, row = s.rows.find(row => row.id === id);
127
380
  if (!member || !row?.canCancel || Date.parse(row.start) <= Date.now() || cancelling) return s;
128
- try { await assertMemberAccess(member); cancelTarget = { row, member }; return derive(s); }
129
- catch { await svc.refresh(); return undefined; }
381
+ try { await assertMemberAccess(member); openId = row.id; confirming = true; sheetMessage = ""; cancelMember = member; return derive(s); }
382
+ catch { closeSheet(); await svc.refresh(); return undefined; }
130
383
  },
131
- dismissCancel(s) { if (!cancelling) cancelTarget = undefined; return derive(s); },
384
+ keepBooking(s) { if (!cancelling) { confirming = false; sheetMessage = ""; } return derive(s); },
132
385
  async confirmCancel(s): Promise<S | undefined> {
133
- if (!cancelTarget || cancelling) return s;
134
- const target = cancelTarget; cancelling = true;
386
+ const row = openRow(s), member = cancelMember;
387
+ if (!row || !member || !confirming || cancelling) return s;
388
+ cancelling = true;
135
389
  try {
136
- await assertMemberAccess(target.member);
137
- if (Date.parse(target.row.start) <= Date.now()) throw new Error("Booking already started");
138
- await cancelBookingAsMember(target.row.id, target.row.revision);
139
- await assertMemberAccess(target.member);
140
- cancelTarget = undefined;
390
+ await assertMemberAccess(member);
391
+ if (Date.parse(row.start) <= Date.now()) throw new Error("Booking already started");
392
+ await cancelBookingAsMember(row.id, row.revision);
393
+ await assertMemberAccess(member);
394
+ cancelling = false; closeSheet();
141
395
  // Told to the app hosting this screen (an embed's `onAction`); dropped where nothing hosts it.
142
- await emitHostEvent("booking.cancelled", { bookingId: target.row.id, serviceId: target.row.serviceId,
143
- serviceName: target.row.title, start: target.row.start, end: target.row.end });
396
+ await emitHostEvent("booking.cancelled", { bookingId: row.id, serviceId: row.serviceId,
397
+ serviceName: row.title, start: row.start, end: row.end });
398
+ result = { text: copyParam("bookings.cancelDone", "Booking canceled."), done: true };
144
399
  await svc.refresh();
145
- return derive({ ...(svc.getState() as unknown as S), actionMessage: copyParam("bookings.cancelDone", "Booking cancelled.") });
400
+ return derive(svc.getState() as unknown as S);
146
401
  } catch (error) {
147
- try { await assertMemberAccess(target.member); } catch { cancelTarget = undefined; await svc.refresh(); return undefined; }
148
- if (memberAccessDenied(error)) { cancelTarget = undefined; await svc.refresh(); return undefined; }
149
- return derive({ ...s, actionMessage: isBookingPolicyViolation(error) ? copyParam("bookings.cancelPolicy", "It's too late to cancel online — contact the business.") : copyParam("bookings.cancelFailed", "The booking couldn't be cancelled. Try again.") });
402
+ cancelling = false;
403
+ try { await assertMemberAccess(member); } catch { closeSheet(); await svc.refresh(); return undefined; }
404
+ if (memberAccessDenied(error)) { closeSheet(); await svc.refresh(); return undefined; }
405
+ // The refusal stays next to the action, in step 2.
406
+ sheetMessage = isBookingPolicyViolation(error)
407
+ ? copyParam("bookings.cancelPolicy", "It's too late to cancel online. Contact the business to change this booking.")
408
+ : copyParam("bookings.cancelFailed", "Couldn't cancel the booking. Try again.");
409
+ return derive(s);
150
410
  } finally { cancelling = false; }
151
411
  },
412
+ async addToCalendar(s): Promise<S> {
413
+ const row = openRow(s);
414
+ if (!row || !canAddToCalendar(row)) return s;
415
+ const start = Date.parse(row.start);
416
+ const endAt = Date.parse(row.end);
417
+ const end = Number.isFinite(endAt) && endAt > start ? endAt : start + 60 * 60 * 1000;
418
+ const place = placeOf(row);
419
+ const location = [row.placeType === "CUSTOMER" ? "" : place, row.placeType === "CUSTOMER" || row.placeName === row.placeAddress ? "" : row.placeAddress].filter(Boolean).join(", ");
420
+ const notes = staffLine(row);
421
+ const opened = copyParam("bookings.calendarOpened", "Opened your calendar.");
422
+ const failed = copyParam("bookings.calendarFailed", "Couldn't add the booking to your calendar.");
423
+ const fallback = async () => (await open(calendarFallbackUrl(row.title, start, end, location, notes), "in_app_browser")).opened;
424
+ try {
425
+ let permission = await getPermissionStatus();
426
+ if (permission !== "granted" && permission !== "limited") permission = await requestPermission("writeOnly");
427
+ if (permission !== "granted" && permission !== "limited") return showResult(s, (await fallback()) ? opened : failed, false);
428
+ const created = await createEvent({ title: row.title, start, end, ...(location ? { location } : {}), ...(notes ? { notes } : {}) });
429
+ if (!created.id) return showResult(s, (await fallback()) ? opened : failed, false);
430
+ return showResult(s, copyParam("bookings.calendarAdded", "Added to your calendar."), true);
431
+ } catch {
432
+ return showResult(s, failed, false);
433
+ }
434
+ },
435
+ async openDirections(s): Promise<S> {
436
+ const row = openRow(s);
437
+ if (!row || !hasDirections(row)) return s;
438
+ try {
439
+ if ((await open(directionsUrl(row.placeAddress), "in_app_browser")).opened) return s;
440
+ } catch { /* the result sheet says so below */ }
441
+ return showResult(s, copyParam("bookings.mapsFailed", "Couldn't open maps."), false);
442
+ },
152
443
  },
153
444
  });
445
+
446
+ function calendarStamp(ms: number): string {
447
+ return new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
448
+ }
449
+
450
+ /** The Google Calendar "render" (TEMPLATE) fallback URL, built with encodeURIComponent — NOT
451
+ * URLSearchParams, which the JavaScriptCore worker engine on iOS/macOS and the wrist's QuickJS-ng do
452
+ * not provide (see `calendarFallbackUrl` in himi-events-detail, the pattern this copies). */
453
+ function calendarFallbackUrl(title: string, start: number, end: number, location: string, details: string): string {
454
+ const parts = ["action=TEMPLATE", `text=${encodeURIComponent(title || "Booking")}`, `dates=${calendarStamp(start)}/${calendarStamp(end)}`];
455
+ if (location) parts.push(`location=${encodeURIComponent(location)}`);
456
+ if (details) parts.push(`details=${encodeURIComponent(details)}`);
457
+ return `https://calendar.google.com/calendar/render?${parts.join("&")}`;
458
+ }
459
+
460
+ /** A maps search for the address — the same URL the service screen opens. */
461
+ function directionsUrl(address: string): string {
462
+ return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
463
+ }
464
+
154
465
  svc.load();
155
466
  export { svc };
@@ -12,11 +12,15 @@ export interface CollectionSaving {
12
12
  type State = Record<string, unknown>;
13
13
  interface BackendCollection {
14
14
  restorePagesOnLoad?: boolean;
15
- /** Sees the load action's argument (the screen's onAppear params) BEFORE the query runs. */
16
- onLoad?(arg: unknown): void;
15
+ /** Sees the load action's argument (the screen's onAppear params) BEFORE the query runs. A returned
16
+ * `{ filter, sort }` resets the criteria for that load (a screen that always opens on one view). */
17
+ onLoad?(arg: unknown): CollectionCriteriaReset | void;
17
18
  controller: ReturnType<typeof createCollectionQuery<CollectionRow>>;
18
19
  apply(state: State, page: CollectionQueryState<CollectionRow>): State;
19
20
  }
21
+ /** A filter and a sort set together (`collectionCriteria`, `onLoad`). Values the collection's contract does
22
+ * not list are ignored. */
23
+ export interface CollectionCriteriaReset { filter?: string; sort?: string; query?: string }
20
24
  type Reducer = (s: State, ...args: unknown[]) => State | undefined | Promise<State | undefined>;
21
25
  const object = (x: unknown): State => x && typeof x === 'object' && !Array.isArray(x) ? x as State : {};
22
26
  const str = (arg: unknown, key: string): string => typeof arg === 'string' ? arg : String(object(arg)[key] ?? '');
@@ -262,6 +266,16 @@ export function decorateCollection(name: string, def: { state: State; actions?:
262
266
  if (backend && config.backendQuery && criteriaBefore !== criteriaKey()) return backend.controller.query(criteria()).then(() => undefined);
263
267
  return derive(s);
264
268
  };
269
+ /** Filter and sort in ONE criteria change, so a tab that sets both runs one query, not two. */
270
+ const setCriteria = (arg: unknown): void => {
271
+ const input = object(arg);
272
+ const sort = String(input.sort ?? '');
273
+ if (sort === 'default' || sort === 'az' || sort === 'za') prefs.sort = sort;
274
+ const filter = String(input.filter ?? '');
275
+ if (filter === 'all' || (filter.startsWith('value:') && (config.filterValues ?? []).includes(filter.slice(6)))) prefs.filter = filter;
276
+ // A screen with no search field clears a query an older screen persisted, or it narrows every view for good.
277
+ if (typeof input.query === 'string') prefs.query = input.query.slice(0, 256);
278
+ };
265
279
  const criteria = () => ({ query: prefs.query, sort: prefs.sort, filter: prefs.filter, savedIds: prefs.filter === 'saved' ? prefs.savedIds : [] });
266
280
  const criteriaKey = () => JSON.stringify(criteria());
267
281
  actions.collectionDismissFilters = change(() => { filtersPresented = false; });
@@ -273,6 +287,7 @@ export function decorateCollection(name: string, def: { state: State; actions?:
273
287
  actions.collectionSort = change(arg => { const value = str(arg, 'selectedId'); if (value === 'default' || value === 'az' || value === 'za') prefs.sort = value; });
274
288
  actions.collectionFilter = change(arg => { const values = object(arg).selectedIds; prefs.filter = Array.isArray(values) && typeof values[0] === 'string' ? values[0] : 'all'; });
275
289
  actions.collectionSearch = change(arg => { prefs.query = str(arg, 'value').slice(0, 256); });
290
+ actions.collectionCriteria = change(arg => setCriteria(arg));
276
291
  actions.collectionViewName = change(arg => { viewName = str(arg, 'value').slice(0, 40); viewMessage = ''; });
277
292
  actions.collectionRememberView = change(() => {
278
293
  const name = viewName.trim();
@@ -305,7 +320,8 @@ export function decorateCollection(name: string, def: { state: State; actions?:
305
320
  actions.collectionDataChanged = s => derive(backend.apply(s, backend.controller.getState()));
306
321
  actions.load = (_s, arg) => {
307
322
  stashCopy(arg); syncSaved(); applyNavigationCriteria(arg, _s);
308
- backend.onLoad?.(arg);
323
+ const reset = backend.onLoad?.(arg);
324
+ if (reset) { setCriteria(reset); persisted.write(prefs); }
309
325
  // A load of what the screen already shows is the screen coming back: it revalidates behind its rows.
310
326
  return backend.controller.query(criteria(), backend.restorePagesOnLoad, true).then(() => undefined);
311
327
  };
@@ -334,11 +350,13 @@ export function defineBackendCollection<S extends object, Row extends object>(na
334
350
  register?: typeof register;
335
351
  /** Explicit mutation preflight; unrelated collection services keep their current reducers. */
336
352
  onlineWrites?: boolean;
353
+ /** Where the offline refusal is written (default `actionMessage`) — the field the screen shows next to the action. */
354
+ onlineWriteMessageFields?: readonly string[];
337
355
  /** Revalidate already opened pages on navigation return; explicit refresh resets. */
338
356
  restorePagesOnLoad?: boolean;
339
357
  /** The load action's argument (onAppear params beyond `copy`), before the query runs — for a
340
358
  * domain setting that rides the descriptor, e.g. My Bookings' `weeklyGoal`. */
341
- onLoad?(arg: unknown): void;
359
+ onLoad?(arg: unknown): CollectionCriteriaReset | void;
342
360
  state: S;
343
361
  source: CollectionQuerySource<Row>;
344
362
  apply(state: S, page: CollectionQueryState<Row>): S;
@@ -353,7 +371,7 @@ export function defineBackendCollection<S extends object, Row extends object>(na
353
371
  controller: controller as unknown as BackendCollection['controller'],
354
372
  apply: (state, page) => options.apply(state as S, page as unknown as CollectionQueryState<Row>) as State,
355
373
  });
356
- const registerService = options.onlineWrites ? withOnlineWrites(options.register ?? register, ['confirmCancel', 'confirmOperation', 'retryOperation', 'commitWrite'], { messageFields: ['actionMessage'] }) : (options.register ?? register);
374
+ const registerService = options.onlineWrites ? withOnlineWrites(options.register ?? register, ['confirmCancel', 'confirmOperation', 'retryOperation', 'commitWrite'], { messageFields: options.onlineWriteMessageFields ?? ['actionMessage'] }) : (options.register ?? register);
357
375
  const handle = registerService(name, definition);
358
376
  // Registration restored the state a cold boot saved: the page it showed is this run's, before the first load. Only
359
377
  // when the service's own rows came back with it: a bundle that keeps them out of the restore (its `notHydrated`)