@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.
- package/CHANGELOG.md +27 -0
- package/cli/migrations.generated.mjs +31 -1
- package/docs/calendar.md +61 -1
- package/docs/google-calendar.md +81 -4
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +32 -17
- package/mobile-template/recipes/booking/files/app/book/details.tsx +35 -25
- package/mobile-template/recipes/booking/files/app/book/index.tsx +11 -5
- package/mobile-template/recipes/booking/files/app/book/manage.tsx +93 -0
- package/mobile-template/recipes/booking/recipe.json +6 -3
- package/package.json +1 -1
- package/src/components/booking/BookingFlow.astro +16 -8
- package/src/lib/booking/public.ts +40 -0
- package/src/lib/booking/service.ts +7 -3
- package/src/lib/booking/view.ts +6 -0
- package/src/lib/calendar/data.ts +25 -31
- package/src/lib/calendar/google/bookings.ts +296 -0
- package/src/lib/calendar/google/inbound.ts +7 -1
- package/src/lib/calendar/google/links.ts +3 -0
- package/src/lib/calendar/google/map.ts +9 -5
- package/src/lib/calendar/google/match.ts +6 -2
- package/src/lib/calendar/google/migration.ts +34 -0
- package/src/lib/calendar/google/outbound.ts +9 -2
- package/src/lib/calendar/google/store.ts +38 -5
- package/src/lib/calendar/google/types.ts +17 -0
- package/src/lib/display/board.ts +5 -3
- package/src/lib/editor/schedule.ts +2 -0
- package/src/lib/mobile/calendar.ts +30 -6
- package/src/lib/mobile/guest.ts +4 -2
- package/src/lib/mobile/manage.ts +8 -4
- package/src/lib/mobile/openapi.ts +4 -4
- package/src/lib/mobile/schemas.ts +6 -1
- package/src/lib/repo/migrations.ts +13 -1
- package/src/lib/repo/sql.ts +10 -5
- package/src/lib/schema.ts +19 -5
- package/src/lib/sections.ts +4 -2
- package/src/pages/admin/calendar/resources/link.astro +14 -1
- package/src/pages/book/[resource]/[link].astro +10 -5
- package/src/pages/book/[resource]/index.astro +14 -8
- package/src/pages/book/index.astro +8 -3
- package/src/pages/book/manage/[token].astro +5 -3
- 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
|
|
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
|
@@ -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
|
-
|
|
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)}>‹ {
|
|
66
|
-
{embed && <span class="pc-bk-kicker">{
|
|
67
|
-
<h1 class="pc-bk-title">{
|
|
68
|
-
{
|
|
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>{
|
|
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 (
|
|
113
|
-
title:
|
|
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: ['
|
|
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({
|
package/src/lib/booking/view.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/calendar/data.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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,
|
|
57
|
-
// under its /<locale> prefix
|
|
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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|