@acms-subir/core 0.3.62 → 0.3.64

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 (41) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/cli/migrations.generated.mjs +31 -1
  3. package/docs/calendar.md +61 -1
  4. package/docs/google-calendar.md +81 -4
  5. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +32 -17
  6. package/mobile-template/recipes/booking/files/app/book/details.tsx +35 -25
  7. package/mobile-template/recipes/booking/files/app/book/index.tsx +11 -5
  8. package/mobile-template/recipes/booking/files/app/book/manage.tsx +93 -0
  9. package/mobile-template/recipes/booking/recipe.json +6 -3
  10. package/package.json +1 -1
  11. package/src/components/booking/BookingFlow.astro +16 -8
  12. package/src/lib/booking/public.ts +40 -0
  13. package/src/lib/booking/service.ts +7 -3
  14. package/src/lib/booking/view.ts +6 -0
  15. package/src/lib/calendar/data.ts +25 -31
  16. package/src/lib/calendar/google/bookings.ts +296 -0
  17. package/src/lib/calendar/google/inbound.ts +7 -1
  18. package/src/lib/calendar/google/links.ts +3 -0
  19. package/src/lib/calendar/google/map.ts +9 -5
  20. package/src/lib/calendar/google/match.ts +6 -2
  21. package/src/lib/calendar/google/migration.ts +34 -0
  22. package/src/lib/calendar/google/outbound.ts +9 -2
  23. package/src/lib/calendar/google/store.ts +38 -5
  24. package/src/lib/calendar/google/types.ts +17 -0
  25. package/src/lib/display/board.ts +5 -3
  26. package/src/lib/editor/schedule.ts +2 -0
  27. package/src/lib/mobile/calendar.ts +30 -6
  28. package/src/lib/mobile/guest.ts +4 -2
  29. package/src/lib/mobile/manage.ts +8 -4
  30. package/src/lib/mobile/openapi.ts +4 -4
  31. package/src/lib/mobile/schemas.ts +6 -1
  32. package/src/lib/repo/migrations.ts +13 -1
  33. package/src/lib/repo/sql.ts +10 -5
  34. package/src/lib/schema.ts +19 -5
  35. package/src/lib/sections.ts +4 -2
  36. package/src/pages/admin/calendar/resources/link.astro +14 -1
  37. package/src/pages/book/[resource]/[link].astro +10 -5
  38. package/src/pages/book/[resource]/index.astro +14 -8
  39. package/src/pages/book/index.astro +8 -3
  40. package/src/pages/book/manage/[token].astro +5 -3
  41. package/src/pages/embed/book/[resource]/[link].astro +11 -3
@@ -0,0 +1,93 @@
1
+ import { useState } from 'react';
2
+ import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
3
+ import { Stack, useLocalSearchParams } from 'expo-router';
4
+ import { isAcmsError } from '@acms-subir/client';
5
+ import { Button, ErrorView, bookingToken, fill, formatBookingDate, lengthText, useCancelBooking, useLayout, useLocale, useManagedBooking, useRescheduleAvailability, useRescheduleBooking, useStrings, useTheme } from '@acms-subir/native';
6
+
7
+ // The booker's own booking, from its private token: its status and time, a
8
+ // new time, or cancel. The confirmation screen keeps the token in memory and
9
+ // passes a key for it (rememberBookingToken), never the token: on the web a
10
+ // route is the page address. After a restart the key means nothing, and the
11
+ // screen points to the email's link. Needs the site's
12
+ // App access switch for booking changes (site.features.bookingChanges); the
13
+ // link in every booking email does the same on the website either way. The
14
+ // booking link's change cutoff applies (can.reschedule / can.cancel).
15
+ export default function ManageBookingScreen() {
16
+ const t = useTheme();
17
+ const w = useStrings();
18
+ const { locale } = useLocale();
19
+ const { gutter } = useLayout();
20
+ const { booking } = useLocalSearchParams<{ booking: string }>();
21
+ const token = bookingToken(booking);
22
+ const managed = useManagedBooking(token);
23
+ const cancel = useCancelBooking(token);
24
+ const move = useRescheduleBooking(token);
25
+ const [picking, setPicking] = useState(false);
26
+ const [asking, setAsking] = useState(false);
27
+ const [moved, setMoved] = useState(false);
28
+ // a link with several lengths offers new times of the booking's own length
29
+ const length = managed.data?.link?.mode === 'range' ? Math.round((Date.parse(managed.data.booking.end) - Date.parse(managed.data.booking.start)) / 60_000) : undefined;
30
+ const slots = useRescheduleAvailability(token, picking ? { days: 14, minutes: length } : null);
31
+ // opened again after a restart: the key no longer stands for the token
32
+ if (!token) return (
33
+ <View style={{ flex: 1, backgroundColor: t.colors.bg, padding: gutter }}>
34
+ <Stack.Screen options={{ title: w.yourBooking }} />
35
+ <Text style={t.styles.text.muted}>{w.changeByEmail}</Text>
36
+ </View>
37
+ );
38
+ if (managed.error && !managed.data) return <ErrorView error={managed.error} retry={() => managed.refetch()} />;
39
+ if (!managed.data) return <ActivityIndicator color={t.colors.accent} style={{ marginTop: 32 }} />;
40
+ const { booking: b, title, can, link } = managed.data;
41
+ const when = formatBookingDate({ start: b.start, end: b.end, allDay: b.allDay, timezone: b.timezone }, locale);
42
+ const status = b.status === 'confirmed' ? w.confirmed : b.status === 'pending' ? w.awaitingApproval : b.status === 'cancelled' ? w.bookCancelled : b.status === 'declined' ? w.bookDeclined : w.notConfirmed;
43
+ const day = (date: string) => new Date(`${date}T12:00:00Z`).toLocaleDateString(locale, { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' });
44
+ const time = (iso: string) => new Date(iso).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', timeZone: slots.data?.timezone ?? b.timezone });
45
+ const failure = cancel.error ?? move.error;
46
+ const open = (slots.data?.days ?? []).filter((d) => d.slots.length);
47
+ return (
48
+ <ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }}>
49
+ <Stack.Screen options={{ title: w.yourBooking }} />
50
+ <View style={[t.styles.card, { gap: 4 }]}>
51
+ <Text style={t.styles.text.heading}>{title}</Text>
52
+ <Text style={t.styles.text.body}>{when}</Text>
53
+ <Text style={t.styles.text.muted}>{status}</Text>
54
+ </View>
55
+ {moved && <Text style={t.styles.text.body} accessibilityLiveRegion="polite">{w.bookMoved}</Text>}
56
+ {failure && <Text style={t.styles.text.muted} accessibilityLiveRegion="polite">{isAcmsError(failure) && failure.status === 0 ? w.checkConnection : failure.message}</Text>}
57
+ {can.closed && <Text style={t.styles.text.small}>{fill(w.bookChangesClosed, { hours: String(can.cutoffHours) })}</Text>}
58
+
59
+ {can.reschedule && !picking && <Button label={w.bookMove} variant="outline" onPress={() => { setMoved(false); setPicking(true); }} />}
60
+ {picking && (
61
+ <View style={{ gap: 12 }}>
62
+ {slots.isPending && <ActivityIndicator color={t.colors.accent} />}
63
+ {!slots.isPending && !open.length && <Text style={t.styles.text.muted}>{w.bookNoTimes}</Text>}
64
+ {open.map((d) => (
65
+ <View key={d.date} style={{ gap: 8 }}>
66
+ <Text style={t.styles.text.label}>{day(d.date)}</Text>
67
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
68
+ {d.slots.map((s) => (
69
+ <Pressable key={`${s.start}:${s.minutes}`} accessibilityRole="button" disabled={move.isPending}
70
+ onPress={() => move.mutate({ start: s.start, minutes: s.minutes }, { onSuccess: () => { setPicking(false); setMoved(true); } })}
71
+ style={({ pressed }) => ({ minWidth: 96, paddingVertical: 10, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: t.colors.line, backgroundColor: pressed ? t.colors.soft : t.colors.panel, alignItems: 'center', opacity: move.isPending ? 0.5 : 1 })}>
72
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{link?.mode === 'day' ? lengthText(s.minutes, w, locale) : time(s.start)}</Text>
73
+ </Pressable>
74
+ ))}
75
+ </View>
76
+ </View>
77
+ ))}
78
+ {move.isPending && <Text style={t.styles.text.small}>{w.bookMoving}</Text>}
79
+ <Button label={w.keepIt} variant="outline" onPress={() => setPicking(false)} />
80
+ </View>
81
+ )}
82
+
83
+ {can.cancel && !asking && <Button label={w.bookCancel} variant="outline" onPress={() => setAsking(true)} />}
84
+ {can.cancel && asking && (
85
+ <View style={[t.styles.card, { gap: 10 }]}>
86
+ <Text style={t.styles.text.body}>{w.bookCancelAsk}</Text>
87
+ <Button label={cancel.isPending ? w.cancelling : w.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate(undefined, { onSuccess: () => setAsking(false) })} />
88
+ <Button label={w.keepIt} variant="outline" onPress={() => setAsking(false)} />
89
+ </View>
90
+ )}
91
+ </ScrollView>
92
+ );
93
+ }
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "kind": "client",
3
3
  "title": "Booking",
4
- "description": "Resources and booking links → open slots → details form → confirmation, with retry-safe submits.",
4
+ "description": "Resources and booking links → open times (length chips, whole days) → details form → confirmed or waiting for approval, with retry-safe submits; then change or cancel. English and Spanish built in.",
5
5
  "packages": [
6
6
  "expo-crypto"
7
7
  ],
8
8
  "routes": [
9
9
  "/book",
10
10
  "/book/[resource]",
11
- "/book/details"
11
+ "/book/details",
12
+ "/book/manage"
12
13
  ],
13
14
  "next": [
14
15
  "Link to it from a screen: <Link href=\"/book\">Book</Link> (expo-router)",
15
- "Bookings must be enabled on the site (Admin → Bookings) with at least one public booking link."
16
+ "Bookings must be enabled on the site (Admin → Bookings) with at least one public booking link.",
17
+ "Name each booking link in your other languages (the link's Name in Español), so the app shows it in the person's language.",
18
+ "Change or cancel in the app needs Admin → Mobile app → App access → Change bookings from the app (on by default); otherwise the screen points to the link in the confirmation email."
16
19
  ]
17
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.62",
3
+ "version": "0.3.64",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -4,7 +4,9 @@
4
4
  // is a plain link or form (?date=…&len=…&start=…), so it works without
5
5
  // JavaScript and each state is shareable. Used by the page and the embed.
6
6
  import type { Booking, BookingLink, Resource, Settings } from '../../lib/schema';
7
+ import { linkWords, roomName } from '../../lib/schema';
7
8
  import { bookingLinkUrl, resourceUrl } from '../../lib/calendar/config';
9
+ import { localePath } from '../../lib/i18n';
8
10
  import { describeLength, describeLink, linkLengths, openDays, slotsForDay, ymd, type AvailabilityContext } from '../../lib/booking/availability';
9
11
  import { addMonths, fmt, monthCells, slotLabel } from '../../lib/booking/view';
10
12
  import { confirmsInstantly, linkApproval } from '../../lib/booking/approval';
@@ -19,8 +21,13 @@ interface Props {
19
21
  values?: Record<string, string>;
20
22
  embed?: boolean;
21
23
  reschedule?: Booking | null;
24
+ /** the page's language: the link and room are named in it, and links stay in it ('' = the main one) */
25
+ locale?: string;
22
26
  }
23
- const { resource, link, ctx, settings, url, error = '', values = {}, embed = false, reschedule = null } = Astro.props;
27
+ const { resource, link, ctx, settings, url, error = '', values = {}, embed = false, reschedule = null, locale = '' } = Astro.props;
28
+ // the booking link and room in the page's language (the rest of the flow is in English)
29
+ const words = linkWords(link, locale);
30
+ const room = roomName(resource, locale);
24
31
  const tz = ctx.resource.timezone || settings.timezone || 'UTC';
25
32
  // "rules" links usually confirm at once, but may not — promise only what's certain
26
33
  const instant = confirmsInstantly(linkApproval(link, settings));
@@ -42,10 +49,11 @@ const startParam = Number(url.searchParams.get('start'));
42
49
  const chosen = slots.find((s) => s.start === startParam && (link.mode !== 'day' || s.minutes === Number(url.searchParams.get('len') || s.minutes)));
43
50
  const minMonth = today.slice(0, 7);
44
51
  const maxMonth = ymd(ctx.now + link.maxAdvance * 86_400_000, tz).slice(0, 7);
45
- const base = embed ? `/embed/book/${resource.slug}/${link.slug}` : bookingLinkUrl(resource.slug, link.slug);
52
+ const base = embed ? `/embed/book/${resource.slug}/${link.slug}` : localePath(bookingLinkUrl(resource.slug, link.slug), locale);
46
53
  const q = (patch: Record<string, string | number | undefined>) => {
47
54
  const p = new URLSearchParams();
48
- for (const k of ['len', 'm', 'date', 'start', 'reschedule']) {
55
+ // an embed keeps its ?lang= (embeds are never under /es)
56
+ for (const k of ['len', 'm', 'date', 'start', 'reschedule', ...(embed ? ['lang'] : [])]) {
49
57
  const v = k in patch ? patch[k] : url.searchParams.get(k) ?? undefined;
50
58
  if (v !== undefined && v !== '' && v !== null) p.set(k, String(v));
51
59
  }
@@ -62,10 +70,10 @@ const val = (k: string) => values[k] ?? '';
62
70
 
63
71
  <div class={`pc-bk${embed ? ' is-embed' : ''}`}>
64
72
  <aside class="pc-bk-info">
65
- {!embed && <a class="pc-bk-back" href={resourceUrl(resource.slug)}>‹ {resource.name}</a>}
66
- {embed && <span class="pc-bk-kicker">{resource.name}</span>}
67
- <h1 class="pc-bk-title">{link.name}</h1>
68
- {link.description && <p class="pc-bk-desc">{link.description}</p>}
73
+ {!embed && <a class="pc-bk-back" href={localePath(resourceUrl(resource.slug), locale)}>‹ {room}</a>}
74
+ {embed && <span class="pc-bk-kicker">{room}</span>}
75
+ <h1 class="pc-bk-title">{words.name}</h1>
76
+ {words.description && <p class="pc-bk-desc">{words.description}</p>}
69
77
  <ul class="pc-bk-rules">{rules.map((r) => <li>{r}</li>)}</ul>
70
78
  {link.mode === 'range' && lengths.length > 1 && (
71
79
  <div class="pc-bk-lens" role="group" aria-label="How long">
@@ -111,7 +119,7 @@ const val = (k: string) => values[k] ?? '';
111
119
  ) : (
112
120
  <section class="pc-bk-form">
113
121
  <div class="pc-bk-summary">
114
- <b>{resource.name} · {link.name}</b>
122
+ <b>{room} · {words.name}</b>
115
123
  <span>{f.dayLong.format(chosen.start)} · {slotLabel(chosen, link, f)}</span>
116
124
  <a href={q({ start: undefined })}>Change</a>
117
125
  </div>
@@ -0,0 +1,40 @@
1
+ // Confirmed room bookings as schedule items: /calendar, embeds and TV boards
2
+ // (lib/calendar/data.ts) and the app API's `bookings` (lib/mobile/calendar.ts).
3
+ // Publicly a booking is named by its booking link in the reader's language
4
+ // ("Rehearsal", "Ensayo"; the calendar's "Reserved" when the link is gone)
5
+ // and never says who booked: no name, email, phone, notes, answers or manage
6
+ // token, and not the booking's id (publicBookingKey stands in for it).
7
+ import type { ContentRepository } from '../repo/types';
8
+ import { linkWords, type Booking, type BookingLink, type Resource } from '../schema';
9
+ import { sha256Hex } from '../api-keys';
10
+
11
+ export interface ScheduleBooking { booking: Booking; resource: Resource; link: BookingLink | null }
12
+
13
+ /** Confirmed bookings of these rooms overlapping [from, to), with their room and booking link, by start. */
14
+ export async function scheduleBookings(repo: ContentRepository, rooms: Resource[], from: number, to: number, limit = 5000): Promise<ScheduleBooking[]> {
15
+ if (!rooms.length) return [];
16
+ const byId = new Map(rooms.map((r) => [r.id, r]));
17
+ const [booked, links] = await Promise.all([
18
+ repo.listBookings({ from, to, statuses: ['confirmed'], kinds: ['booking'] }),
19
+ repo.listBookingLinks(),
20
+ ]);
21
+ const linkById = new Map(links.map((l) => [l.id, l]));
22
+ const out: ScheduleBooking[] = [];
23
+ for (const b of booked) {
24
+ const resource = byId.get(b.resourceId);
25
+ if (resource) out.push({ booking: b, resource, link: linkById.get(b.linkId) ?? null });
26
+ if (out.length >= limit) break;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /** Whether a booking reads as all day: its link books whole days (without its link: a day long, give or take a clock change). */
32
+ export const bookingAllDay = (b: Pick<Booking, 'start' | 'end'>, link: Pick<BookingLink, 'mode'> | null) =>
33
+ link ? link.mode === 'day' : b.end - b.start >= 86_400_000 - 3_600_000;
34
+
35
+ /** A booking's public title: its link's name in `locale`, else `reserved` (the calendar's word in that language). */
36
+ export const publicBookingTitle = (link: BookingLink | null, locale: string | null | undefined, reserved: string) =>
37
+ (link && linkWords(link, locale).name) || reserved;
38
+
39
+ /** A stable key for a booking in public data that doesn't reveal its id. */
40
+ export const publicBookingKey = async (b: Pick<Booking, 'id'>) => `b${(await sha256Hex(`public-booking:${b.id}`)).slice(0, 24)}`;
@@ -6,6 +6,7 @@ import { collectResponse, displayAnswers, staffAnswers } from '../forms/service'
6
6
  import type { ContentRepository } from '../repo/types';
7
7
  import type { Booking, BookingLink, BookingStatus, CalEvent, Resource, Settings } from '../schema';
8
8
  import { roomName } from '../schema';
9
+ import { bookingTitle } from './view';
9
10
  import { loadFeed, expandVEvents } from '../calendar/ics';
10
11
  import { emitWebhook } from '../webhooks';
11
12
  import { parseFrom } from '../email/config';
@@ -109,8 +110,8 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
109
110
  const tz = resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
110
111
  return {
111
112
  resource, link, event: null as CalEvent | null, tz,
112
- // the room in the booking's language (its main name without one)
113
- title: [resource && roomName(resource, locale), link?.name].filter(Boolean).join(' · ') || 'Booking',
113
+ // the room and its booking link in the booking's language (their main names without one)
114
+ title: bookingTitle(resource, link, locale) || 'Booking',
114
115
  location: resource ? roomName(resource, locale) : '',
115
116
  when: whenText(b, tz, settings, link?.mode === 'day'),
116
117
  };
@@ -304,8 +305,11 @@ export async function createResourceBooking(
304
305
  const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now());
305
306
  if (args.source !== 'admin' && !isBookable(ctx, args.start, args.end)) throw new BookingError(409, 'That time was just taken or isn’t available. Please pick another.');
306
307
  const approval = linkApproval(link, settings);
308
+ // "the first booking from that email": only a confirmed booking (staff approved it, or it
309
+ // confirmed by itself) makes them returning; one still waiting, declined or cancelled doesn't.
310
+ // The address is all a booking without an account has to go on.
307
311
  const returning = approval.mode === 'rules' && approval.rules.firstTime
308
- ? (await repo.listBookings({ email: booker.email, statuses: ['pending', 'confirmed'], kinds: ['booking'] })).length > 0
312
+ ? (await repo.listBookings({ email: booker.email, statuses: ['confirmed'], kinds: ['booking'], limit: 1 })).length > 0
309
313
  : true;
310
314
  const status: BookingStatus = args.status ?? decideStatus(approval, { start: args.start, end: args.end, seats: 1, returning });
311
315
  const b = await repo.createBooking({
@@ -1,5 +1,6 @@
1
1
  // Display helpers for the public booking pages.
2
2
  import type { BookingLink, Resource, Settings } from '../schema';
3
+ import { linkWords, roomName } from '../schema';
3
4
  import { siteLocale } from '../dates';
4
5
  import { addDaysYmd, localMidnight, resourceZone, ymd, type Slot } from './availability';
5
6
 
@@ -52,3 +53,8 @@ export function todayIn(resource: Resource, settings: Settings, now: number) {
52
53
  const tz = resourceZone(resource, settings.timezone);
53
54
  return { tz, today: ymd(now, tz), midnight: localMidnight(ymd(now, tz), tz) };
54
55
  }
56
+
57
+ /** "Sanctuary · Rehearsal": the room and booking link in `locale` (each its main name without one); '' when both are gone. */
58
+ export function bookingTitle(resource: Resource | null | undefined, link: BookingLink | null | undefined, locale?: string | null): string {
59
+ return [resource && roomName(resource, locale), link && linkWords(link, locale).name].filter(Boolean).join(' · ');
60
+ }
@@ -7,7 +7,8 @@
7
7
  // details, lib/display: published events only, private calendars when it
8
8
  // names them, bookings with the booker's name, never emails, phones or notes).
9
9
  import type { ContentRepository } from '../repo/types';
10
- import { roomColor, roomName, type BookingLink, type CalEvent, type Calendar, type EventLocation, type Resource, type Settings } from '../schema';
10
+ import { linkWords, roomColor, roomName, type CalEvent, type Calendar, type EventLocation, type Resource, type Settings } from '../schema';
11
+ import { bookingAllDay, publicBookingTitle, scheduleBookings } from '../booking/public';
11
12
  import { eventIsListed, eventIsPublic, eventRoomIds, expandEvents, locationLine, safeColor, type OccurrenceMove } from './events';
12
13
  import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
13
14
  import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
@@ -48,13 +49,14 @@ export interface ViewScope {
48
49
  includeUncategorized?: boolean;
49
50
  // feeds are network calls — views that don't need them can skip
50
51
  includeFeeds?: boolean;
51
- // "Reserved" blocks from resources set to show on the calendar
52
+ // confirmed bookings of rooms set to show on the calendar (named by their
53
+ // booking link; with the booker's name for staff and private screens)
52
54
  includeBookings?: boolean;
53
55
  // the address's cal/tag/notag/room (filter.ts); its calendars narrow
54
56
  // calendarIds when both are given
55
57
  filter?: ResolvedEventFilter;
56
- // the request's language (requestLocale): translated titles, event links
57
- // under its /<locale> prefix, and "Reserved" in its words
58
+ // the request's language (requestLocale): translated titles, room and
59
+ // booking link names, event links under its /<locale> prefix
58
60
  locale?: string;
59
61
  }
60
62
 
@@ -158,36 +160,28 @@ export async function collectViewEvents(
158
160
  events.push(...results.flat());
159
161
  }
160
162
 
161
- // resources marked "show on calendar": confirmed bookings appear as
162
- // Reserved blocks (no guest names on public views) — only on unscoped views.
163
- // A screen also shows the bookings of the rooms it names, as "<booking
164
- // type> · <booker>" (the name only: never their email, phone or notes).
163
+ // rooms marked "show on calendar": their confirmed bookings appear named by
164
+ // their booking link in the view's language ("Rehearsal", "Ensayo"; "Reserved"
165
+ // when the link is gone), never who booked, on views that name no calendar
166
+ // (a booking belongs to none): unfiltered, or by room (each in its own room),
167
+ // never with a tag. A screen also shows the bookings of the rooms it names, as
168
+ // "<booking link> · <booker>" (the name only: never their email, phone or notes).
165
169
  if (!requested && (scope.includeBookings ?? true) && CALENDAR.bookings) {
166
170
  const named = scope.audience === 'screen' ? f?.roomIds ?? [] : [];
167
171
  const resources = (await repo.listResources()).filter((r) => (r.showOnCalendar || named.includes(r.id)) && (!f || untaggedMatches(f, { roomId: r.id })));
168
- if (resources.length) {
169
- const [booked, links] = await Promise.all([
170
- repo.listBookings({ from: win.windowStartMs, to: win.windowEndMs, statuses: ['confirmed'], kinds: ['booking'] }),
171
- scope.audience === 'screen' ? repo.listBookingLinks() : Promise.resolve([] as BookingLink[]),
172
- ]);
173
- const byId = new Map(resources.map((r) => [r.id, r]));
174
- const linkName = new Map(links.map((l): [string, string] => [l.id, l.name]));
175
- for (const b of booked) {
176
- const r = byId.get(b.resourceId);
177
- if (!r) continue;
178
- const allDay = b.end - b.start >= 86_400_000 - 3_600_000;
179
- // the room in the view's language (its main name without one)
180
- const room = roomName(r, scope.locale);
181
- const title = scope.audience === 'admin' ? `${r.name} · ${b.name}`
182
- : scope.audience === 'screen' ? [linkName.get(b.linkId) || room, b.name].filter(Boolean).join(' · ')
183
- : `${words.reserved} · ${room}`;
184
- events.push({
185
- start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
186
- title, location: room, description: '', cal: inScope.length, color: '#8e919a', calendarName: room,
187
- url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
188
- rooms: [{ id: r.id, slug: r.slug, name: room, color: roomColor(r) }],
189
- });
190
- }
172
+ for (const { booking: b, resource: r, link } of await scheduleBookings(repo, resources, win.windowStartMs, win.windowEndMs)) {
173
+ const allDay = bookingAllDay(b, link);
174
+ // the room and link in the view's language (their main names without one)
175
+ const room = roomName(r, scope.locale);
176
+ const title = scope.audience === 'admin' ? `${r.name} · ${b.name}`
177
+ : scope.audience === 'screen' ? [link ? linkWords(link, scope.locale).name : room, b.name].filter(Boolean).join(' · ')
178
+ : publicBookingTitle(link, scope.locale, words.reserved);
179
+ events.push({
180
+ start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
181
+ title, location: room, description: '', cal: inScope.length, color: '#8e919a', calendarName: room,
182
+ url: scope.audience === 'public' ? localePath(resourceUrl(r.slug), scope.locale ?? '') : undefined,
183
+ rooms: [{ id: r.id, slug: r.slug, name: room, color: roomColor(r) }],
184
+ });
191
185
  }
192
186
  }
193
187