@cosmoops/connect-sdk 0.17.0 → 0.18.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.
- package/CHANGELOG.md +82 -0
- package/README.md +19 -1
- package/dist/cjs/client/components/booking-calendar.js +170 -0
- package/dist/cjs/client/components/booking-dialogs.js +124 -0
- package/dist/cjs/client/components/booking-grid.js +206 -0
- package/dist/cjs/client/index.js +15 -1
- package/dist/cjs/client/use-scheduling.js +113 -0
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +136 -0
- package/dist/cjs/server/resources/scheduling.js +32 -1
- package/dist/esm/client/components/booking-calendar.js +167 -0
- package/dist/esm/client/components/booking-dialogs.js +120 -0
- package/dist/esm/client/components/booking-grid.js +199 -0
- package/dist/esm/client/index.js +4 -0
- package/dist/esm/client/use-scheduling.js +107 -0
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +136 -0
- package/dist/esm/server/resources/scheduling.js +32 -1
- package/dist/types/client/components/booking-calendar.d.ts +38 -0
- package/dist/types/client/components/booking-dialogs.d.ts +33 -0
- package/dist/types/client/components/booking-grid.d.ts +102 -0
- package/dist/types/client/index.d.ts +5 -0
- package/dist/types/client/use-scheduling.d.ts +93 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/server/resources/scheduling.d.ts +51 -6
- package/dist/types/types.d.ts +13 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,88 @@
|
|
|
3
3
|
`@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
|
|
4
4
|
versions may carry breaking changes** and patch versions never do.
|
|
5
5
|
|
|
6
|
+
## 0.18.0
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`connect.scheduling.rescheduleBooking()`** - move an appointment without
|
|
11
|
+
cancelling and rebooking it.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const { booking, previous } = await connect.scheduling.rescheduleBooking({
|
|
15
|
+
workspaceId,
|
|
16
|
+
bookingId,
|
|
17
|
+
startAt: newSlot.startAt,
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The original is **retired, not edited**: it stays on the workspace's diary as
|
|
22
|
+
`rescheduled` with `rescheduledToId` pointing at its replacement, and both
|
|
23
|
+
come back. So a booking id you cached still resolves and still says where the
|
|
24
|
+
appointment went, and a report that already counted the original hour is not
|
|
25
|
+
rewritten under it.
|
|
26
|
+
|
|
27
|
+
**A refused move changes nothing.** The new slot is claimed before the old one
|
|
28
|
+
is released, so a `conflict` leaves the customer holding the appointment they
|
|
29
|
+
already had.
|
|
30
|
+
|
|
31
|
+
- **`excludeBookingId` on `findSlots()`** - set it when offering times to move
|
|
32
|
+
an existing appointment to. Without it the appointment counts as blocking its
|
|
33
|
+
own replacement, and the adjustment people actually make - same day, half an
|
|
34
|
+
hour later - comes back with nothing available.
|
|
35
|
+
|
|
36
|
+
- **`Booking.rescheduledToId`**, set when `status` is `rescheduled`.
|
|
37
|
+
|
|
38
|
+
- **`<ConnectBookingCalendar>`** - the workspace's week, in your app.
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { ConnectBookingCalendar } from "@cosmoops/connect-sdk/client";
|
|
42
|
+
|
|
43
|
+
<ConnectBookingCalendar timeZone="Asia/Kolkata" />
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A week grid with booking, rescheduling and cancelling built in. Clicking an
|
|
47
|
+
empty hour opens a slot picker; clicking an appointment opens it with Move
|
|
48
|
+
and Cancel. Everything goes through the bridge as the signed-in member, so
|
|
49
|
+
Connect checks *their* `calendar:read` / `calendar:manage` - pass
|
|
50
|
+
`canBook={false}` to render the read-only shape without offering a button
|
|
51
|
+
that would be refused.
|
|
52
|
+
|
|
53
|
+
**Two views, and the viewport decides.** A seven-column time grid needs about
|
|
54
|
+
640px before a column can hold a name, so below `sm:` the same week renders
|
|
55
|
+
as a grouped agenda - a different shape for a phone, not a squeezed grid.
|
|
56
|
+
|
|
57
|
+
**`timeZone` is the business's, not the viewer's.** Every instant is read
|
|
58
|
+
against it, so a workspace's working day does not shift because a member is
|
|
59
|
+
travelling. It defaults to the host's zone, which is right when staff and
|
|
60
|
+
business share one; pass it when they might not.
|
|
61
|
+
|
|
62
|
+
- **Hooks, if you would rather build your own surface**:
|
|
63
|
+
`useBookableEventTypes`, `useAvailableSlots`, `useBookings`,
|
|
64
|
+
`useBookingActions`. None of the actions is optimistic - whether a slot is
|
|
65
|
+
still free is a question only the server can answer inside the transaction
|
|
66
|
+
that decides it.
|
|
67
|
+
|
|
68
|
+
- **`layoutBookings` and friends** from `booking-grid.js`, exported so a custom
|
|
69
|
+
grid can reuse the arithmetic: zone-correct day bucketing (half-hour zones
|
|
70
|
+
and DST included), transitive overlap clustering into side-by-side columns,
|
|
71
|
+
and a minimum height so a fifteen-minute appointment stays clickable.
|
|
72
|
+
|
|
73
|
+
- **`asUser` on every `scheduling.*` method**, and the six scheduling routes it
|
|
74
|
+
exists for on `createConnectHandler`'s bridge:
|
|
75
|
+
`GET /scheduling/{event-types,slots,bookings}` and
|
|
76
|
+
`POST /scheduling/bookings{,/reschedule,/cancel}`.
|
|
77
|
+
|
|
78
|
+
With `asUser`, Connect derives the workspace from **that member's session**
|
|
79
|
+
and ignores the `workspaceId` you passed, then checks their own
|
|
80
|
+
`calendar:read` / `calendar:manage`. The bridge always sends it, and that is
|
|
81
|
+
what makes a browser-facing booking component safe: a browser must never name
|
|
82
|
+
a tenant, and an end user of your app is not automatically allowed to book on
|
|
83
|
+
their employer's calendar just because your API key can.
|
|
84
|
+
|
|
85
|
+
Without `asUser` nothing changes - the call is your server acting for a
|
|
86
|
+
workspace you name, gated on your application being installed there.
|
|
87
|
+
|
|
6
88
|
## 0.17.0
|
|
7
89
|
|
|
8
90
|
### Added
|
package/README.md
CHANGED
|
@@ -342,7 +342,7 @@ try {
|
|
|
342
342
|
| `entitlements` | `getReadiness` · `getSubscription` · `hasFeature` |
|
|
343
343
|
| `billing` | `listPlans` · `getAddonUsage` · `consumeAddon` |
|
|
344
344
|
| `drive` | `upload` · `getFileUrl` · `getPreviewUrl` |
|
|
345
|
-
| `scheduling` | `listEventTypes` · `findSlots` · `listBookings` · `createBooking` · `cancelBooking` |
|
|
345
|
+
| `scheduling` | `listEventTypes` · `findSlots` · `listBookings` · `createBooking` · `rescheduleBooking` · `cancelBooking` |
|
|
346
346
|
| `ai` | `chat` · `extract` |
|
|
347
347
|
| `otp` | `send` · `verify` |
|
|
348
348
|
| `logs` | `write` |
|
|
@@ -350,6 +350,24 @@ try {
|
|
|
350
350
|
| `sessions` | `list` · `revoke` · `revokeOthers` |
|
|
351
351
|
| `integrations` | `list` · `initiateConnect` · `disconnect` · `execute` |
|
|
352
352
|
|
|
353
|
+
### The calendar, as a component
|
|
354
|
+
|
|
355
|
+
```tsx
|
|
356
|
+
import { ConnectBookingCalendar } from "@cosmoops/connect-sdk/client";
|
|
357
|
+
|
|
358
|
+
<ConnectBookingCalendar timeZone="Asia/Kolkata" />
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
A week grid on a desktop, a grouped agenda on a phone, with booking,
|
|
362
|
+
rescheduling and cancelling built in. It reads through the bridge as the
|
|
363
|
+
signed-in member, so Connect checks their own `calendar:read` /
|
|
364
|
+
`calendar:manage` - not your API key. `canBook={false}` renders the read-only
|
|
365
|
+
shape.
|
|
366
|
+
|
|
367
|
+
`timeZone` is the **business's** zone. Every instant on the grid is read
|
|
368
|
+
against it, so a workspace's working day does not shift because a member is
|
|
369
|
+
travelling.
|
|
370
|
+
|
|
353
371
|
### Booking on a workspace's calendar
|
|
354
372
|
|
|
355
373
|
One calendar per workspace - the same one their team configures, and the same
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ConnectBookingCalendar = ConnectBookingCalendar;
|
|
5
|
+
exports.connectBookingTimeLabel = timeLabel;
|
|
6
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const react_1 = require("@heroui/react");
|
|
8
|
+
const lucide_react_1 = require("lucide-react");
|
|
9
|
+
const react_2 = require("react");
|
|
10
|
+
const empty_state_js_1 = require("../../ui/empty-state.js");
|
|
11
|
+
const use_scheduling_js_1 = require("../use-scheduling.js");
|
|
12
|
+
const booking_grid_js_1 = require("./booking-grid.js");
|
|
13
|
+
const booking_dialogs_js_1 = require("./booking-dialogs.js");
|
|
14
|
+
/**
|
|
15
|
+
* The visible band of the day. Outside it a booking is drawn at the edge and
|
|
16
|
+
* flagged rather than hidden - see `layoutBookings`.
|
|
17
|
+
*
|
|
18
|
+
* 07:00-21:00 rather than a full day: fourteen hours is the most that stays
|
|
19
|
+
* legible on a laptop without scrolling, and a booking grid whose first screen
|
|
20
|
+
* is six empty night-time hours reads as a broken calendar.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_WINDOW = { startMinute: 7 * 60, endMinute: 21 * 60 };
|
|
23
|
+
const LOCATION_ICON = {
|
|
24
|
+
phone: lucide_react_1.Phone,
|
|
25
|
+
address: lucide_react_1.CalendarDays,
|
|
26
|
+
link: lucide_react_1.Video,
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* A week of the workspace's calendar, with booking, rescheduling and
|
|
30
|
+
* cancelling.
|
|
31
|
+
*
|
|
32
|
+
* **Two views, and which one you get is a viewport decision, not a
|
|
33
|
+
* preference.** A seven-column time grid needs roughly 640px before a column
|
|
34
|
+
* is wide enough to hold a name; below that it degrades into unreadable
|
|
35
|
+
* slivers. So the grid is `sm:` and up, and the same week renders as a grouped
|
|
36
|
+
* agenda underneath - not a cut-down grid, a different and better shape for a
|
|
37
|
+
* phone. Both read the same data and open the same dialogs.
|
|
38
|
+
*
|
|
39
|
+
* Everything it does goes through the bridge as the signed-in member, so
|
|
40
|
+
* Connect checks *their* `calendar:read` / `calendar:manage` rather than the
|
|
41
|
+
* host application's key. A member who may look but not book gets the grid and
|
|
42
|
+
* a refusal on the write, which is the correct shape - see `useBookingActions`.
|
|
43
|
+
*/
|
|
44
|
+
function ConnectBookingCalendar({ timeZone, hostMemberId, window: visibleWindow = DEFAULT_WINDOW, canBook = true, className, }) {
|
|
45
|
+
const zone = timeZone ?? hostTimeZone();
|
|
46
|
+
const [weekAnchor, setWeekAnchor] = (0, react_2.useState)(() => startOfWeek(new Date(), zone));
|
|
47
|
+
const [selected, setSelected] = (0, react_2.useState)(null);
|
|
48
|
+
const [booking, setBooking] = (0, react_2.useState)(null);
|
|
49
|
+
const now = new Date();
|
|
50
|
+
const columns = (0, react_2.useMemo)(() => (0, booking_grid_js_1.dayColumns)(weekAnchor, zone, 7, now),
|
|
51
|
+
// `now` is deliberately not a dependency: it changes on every render, and
|
|
52
|
+
// the only thing it decides is which column is highlighted. Recomputing
|
|
53
|
+
// seven columns per keystroke elsewhere on the page to move a highlight
|
|
54
|
+
// that changes once a day is not a trade worth making.
|
|
55
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
56
|
+
[weekAnchor, zone]);
|
|
57
|
+
const range = (0, react_2.useMemo)(() => ({
|
|
58
|
+
from: columns[0]?.startsAt ?? weekAnchor,
|
|
59
|
+
to: new Date((columns.at(-1)?.startsAt.getTime() ?? weekAnchor.getTime()) + 24 * 60 * 60_000),
|
|
60
|
+
}), [columns, weekAnchor]);
|
|
61
|
+
const { eventTypes } = (0, use_scheduling_js_1.useBookableEventTypes)();
|
|
62
|
+
const { bookings, isLoading, error, refetch } = (0, use_scheduling_js_1.useBookings)({
|
|
63
|
+
from: range.from,
|
|
64
|
+
to: range.to,
|
|
65
|
+
hostMemberId,
|
|
66
|
+
});
|
|
67
|
+
const positioned = (0, react_2.useMemo)(() => (0, booking_grid_js_1.layoutBookings)(bookings, zone, visibleWindow), [bookings, zone, visibleWindow]);
|
|
68
|
+
const marks = (0, react_2.useMemo)(() => (0, booking_grid_js_1.hourMarks)(visibleWindow), [visibleWindow]);
|
|
69
|
+
const shiftWeek = (weeks) => setWeekAnchor((anchor) => new Date(anchor.getTime() + weeks * 7 * 24 * 60 * 60_000));
|
|
70
|
+
const eventTypeById = (0, react_2.useMemo)(() => new Map(eventTypes.map((eventType) => [eventType.id, eventType])), [eventTypes]);
|
|
71
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: `flex flex-col gap-4 ${className ?? ""}`, children: [(0, jsx_runtime_1.jsxs)("header", { className: "flex flex-wrap items-center justify-between gap-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1", children: [(0, jsx_runtime_1.jsx)(react_1.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Previous week", onPress: () => shiftWeek(-1), children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronLeft, { className: "size-4", "aria-hidden": true }) }), (0, jsx_runtime_1.jsx)(react_1.Button, { size: "sm", variant: "secondary", onPress: () => setWeekAnchor(startOfWeek(new Date(), zone)), children: "Today" }), (0, jsx_runtime_1.jsx)(react_1.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Next week", onPress: () => shiftWeek(1), children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronRight, { className: "size-4", "aria-hidden": true }) }), (0, jsx_runtime_1.jsx)("p", { className: "ml-2 text-sm font-medium", children: weekLabel(columns, zone) })] }), canBook && eventTypes.length > 0 && ((0, jsx_runtime_1.jsx)(react_1.Button, { size: "sm", variant: "primary", onPress: () => setBooking({ startAt: nextHour(now) }), children: "New appointment" }))] }), error && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: error }), isLoading ? ((0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: "h-96 w-full rounded-lg" })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_1.Card, { className: "hidden overflow-hidden sm:block", children: (0, jsx_runtime_1.jsx)(WeekGrid, { columns: columns, marks: marks, window: visibleWindow, positioned: positioned, eventTypeById: eventTypeById, zone: zone, onSelect: setSelected, onPickEmpty: canBook ? (startAt) => setBooking({ startAt }) : undefined }) }), (0, jsx_runtime_1.jsx)("div", { className: "sm:hidden", children: (0, jsx_runtime_1.jsx)(AgendaView, { columns: columns, bookings: bookings, eventTypeById: eventTypeById, zone: zone, onSelect: setSelected }) })] })), booking && ((0, jsx_runtime_1.jsx)(booking_dialogs_js_1.ConnectBookingDialog, { isOpen: true, onOpenChange: (open) => !open && setBooking(null), eventTypes: eventTypes, initialStartAt: booking.startAt, timeZone: zone, onBooked: () => {
|
|
72
|
+
setBooking(null);
|
|
73
|
+
refetch();
|
|
74
|
+
} })), selected && ((0, jsx_runtime_1.jsx)(booking_dialogs_js_1.ConnectBookingDetail, { booking: selected, eventType: eventTypeById.get(selected.eventTypeId) ?? null, timeZone: zone, onOpenChange: (open) => !open && setSelected(null), onChanged: () => {
|
|
75
|
+
setSelected(null);
|
|
76
|
+
refetch();
|
|
77
|
+
} }))] }));
|
|
78
|
+
}
|
|
79
|
+
function WeekGrid({ columns, marks, window: visibleWindow, positioned, eventTypeById, zone, onSelect, onPickEmpty, }) {
|
|
80
|
+
const span = visibleWindow.endMinute - visibleWindow.startMinute;
|
|
81
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-[3rem_repeat(7,minmax(0,1fr))]", children: [(0, jsx_runtime_1.jsx)("div", { className: "border-b border-separator" }), columns.map((column) => ((0, jsx_runtime_1.jsx)("div", { className: `border-b border-l border-separator px-2 py-2 text-center ${column.isToday ? "bg-accent/5" : ""}`, children: (0, jsx_runtime_1.jsx)("p", { className: `text-xs font-medium ${column.isToday ? "text-accent" : ""}`, children: column.label }) }, column.dateKey))), (0, jsx_runtime_1.jsx)("div", { className: "relative", style: { height: `${(span / 60) * 3}rem` }, children: marks.map((mark) => ((0, jsx_runtime_1.jsx)("span", { className: "absolute right-1 -translate-y-1/2 text-[0.625rem] text-muted", style: { top: `${((mark.minute - visibleWindow.startMinute) / span) * 100}%` }, children: mark.label }, mark.minute))) }), columns.map((column) => ((0, jsx_runtime_1.jsxs)("div", { className: `relative border-l border-separator ${column.isToday ? "bg-accent/5" : ""}`, style: { height: `${(span / 60) * 3}rem` }, children: [marks.map((mark) => ((0, jsx_runtime_1.jsx)("div", { className: "absolute inset-x-0 border-t border-separator/60", style: { top: `${((mark.minute - visibleWindow.startMinute) / span) * 100}%` } }, mark.minute))), onPickEmpty &&
|
|
82
|
+
marks.map((mark) => ((0, jsx_runtime_1.jsx)("button", { type: "button", "aria-label": `Book ${column.label} at ${mark.label}`, className: "absolute inset-x-0 cursor-pointer transition-colors hover:bg-accent/10", style: {
|
|
83
|
+
top: `${((mark.minute - visibleWindow.startMinute) / span) * 100}%`,
|
|
84
|
+
height: `${(60 / span) * 100}%`,
|
|
85
|
+
}, onClick: () => onPickEmpty(new Date(column.startsAt.getTime() + mark.minute * 60_000)) }, `slot-${mark.minute}`))), positioned
|
|
86
|
+
.filter((entry) => entry.dateKey === column.dateKey)
|
|
87
|
+
.map((entry) => {
|
|
88
|
+
const eventType = eventTypeById.get(entry.booking.eventTypeId);
|
|
89
|
+
return ((0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => onSelect(entry.booking), className: "absolute overflow-hidden rounded-[--radius] border border-accent/30 bg-accent/15 px-1 py-0.5 text-left transition-colors hover:bg-accent/25", style: {
|
|
90
|
+
top: `${entry.top * 100}%`,
|
|
91
|
+
height: `${entry.height * 100}%`,
|
|
92
|
+
// Side-by-side within an overlap cluster, with a hairline
|
|
93
|
+
// gap so two adjacent blocks read as two things.
|
|
94
|
+
left: `${(entry.column / entry.columns) * 100}%`,
|
|
95
|
+
width: `calc(${(1 / entry.columns) * 100}% - 2px)`,
|
|
96
|
+
}, children: [(0, jsx_runtime_1.jsx)("span", { className: "block truncate text-[0.625rem] font-medium text-accent", children: timeLabel(new Date(entry.booking.startAt), zone) }), (0, jsx_runtime_1.jsx)("span", { className: "block truncate text-[0.625rem]", children: entry.booking.attendee.name }), eventType && ((0, jsx_runtime_1.jsx)("span", { className: "block truncate text-[0.625rem] text-muted", children: eventType.name }))] }, entry.booking.id));
|
|
97
|
+
})] }, column.dateKey)))] }));
|
|
98
|
+
}
|
|
99
|
+
function AgendaView({ columns, bookings, eventTypeById, zone, onSelect, }) {
|
|
100
|
+
const byDay = (0, react_2.useMemo)(() => {
|
|
101
|
+
const map = new Map();
|
|
102
|
+
for (const booking of bookings) {
|
|
103
|
+
const { dateKey } = (0, booking_grid_js_1.zonedInstant)(new Date(booking.startAt), zone);
|
|
104
|
+
map.set(dateKey, [...(map.get(dateKey) ?? []), booking]);
|
|
105
|
+
}
|
|
106
|
+
return map;
|
|
107
|
+
}, [bookings, zone]);
|
|
108
|
+
// Days with nothing on them are omitted rather than rendered empty: an
|
|
109
|
+
// agenda is a list of what is happening, and seven "nothing" rows is six
|
|
110
|
+
// screens of scrolling to find the one day that matters.
|
|
111
|
+
const populated = columns.filter((column) => (byDay.get(column.dateKey)?.length ?? 0) > 0);
|
|
112
|
+
if (populated.length === 0) {
|
|
113
|
+
return ((0, jsx_runtime_1.jsx)(empty_state_js_1.ConnectEmptyState, { variant: "dashed", icon: (0, jsx_runtime_1.jsx)(lucide_react_1.CalendarDays, { "aria-hidden": true }), title: "Nothing booked this week", description: "Appointments booked here, on your public link, or by a voice agent all show up together." }));
|
|
114
|
+
}
|
|
115
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-col gap-4", children: populated.map((column) => ((0, jsx_runtime_1.jsxs)("section", { className: "flex flex-col gap-2", children: [(0, jsx_runtime_1.jsx)("h3", { className: `text-sm font-medium ${column.isToday ? "text-accent" : ""}`, children: column.label }), (byDay.get(column.dateKey) ?? []).map((booking) => {
|
|
116
|
+
const eventType = eventTypeById.get(booking.eventTypeId);
|
|
117
|
+
const Icon = eventType ? LOCATION_ICON[eventType.locationKind] : lucide_react_1.CalendarDays;
|
|
118
|
+
return ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => onSelect(booking), children: (0, jsx_runtime_1.jsx)(react_1.Card, { className: "transition-colors hover:bg-surface", children: (0, jsx_runtime_1.jsxs)(react_1.Card.Content, { className: "flex items-center gap-3 py-3 text-left", children: [(0, jsx_runtime_1.jsx)(Icon, { className: "size-4 shrink-0 text-muted", "aria-hidden": true }), (0, jsx_runtime_1.jsxs)("div", { className: "min-w-0 flex-1", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm font-medium", children: booking.attendee.name }), (0, jsx_runtime_1.jsxs)("p", { className: "truncate text-xs text-muted", children: [timeLabel(new Date(booking.startAt), zone), eventType ? ` · ${eventType.name}` : ""] })] }), booking.source === "agent-call" && ((0, jsx_runtime_1.jsxs)(react_1.Chip, { size: "sm", variant: "secondary", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Phone, { className: "size-3", "aria-hidden": true }), "Agent"] }))] }) }) }, booking.id));
|
|
119
|
+
})] }, column.dateKey))) }));
|
|
120
|
+
}
|
|
121
|
+
/* ------------------------------------------------------------------ helpers */
|
|
122
|
+
/** The host's zone, used when the caller does not name the business's. */
|
|
123
|
+
function hostTimeZone() {
|
|
124
|
+
try {
|
|
125
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return "UTC";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Monday of the week `instant` falls in, in `zone`.
|
|
133
|
+
*
|
|
134
|
+
* Monday rather than Sunday: this is a *business* calendar, and a week that
|
|
135
|
+
* splits the working days across two screens is the one thing a week view must
|
|
136
|
+
* not do.
|
|
137
|
+
*/
|
|
138
|
+
function startOfWeek(instant, zone) {
|
|
139
|
+
const { dateKey } = (0, booking_grid_js_1.zonedInstant)(instant, zone);
|
|
140
|
+
const midnight = (0, booking_grid_js_1.dayStartInstant)(dateKey, zone);
|
|
141
|
+
// `getUTCDay` on local midnight-as-an-instant is not the local weekday, so
|
|
142
|
+
// the weekday is read back through the zone.
|
|
143
|
+
const weekday = new Intl.DateTimeFormat("en-US", { timeZone: zone, weekday: "short" }).format(midnight);
|
|
144
|
+
const offset = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].indexOf(weekday);
|
|
145
|
+
return new Date(midnight.getTime() - (offset < 0 ? 0 : offset) * 24 * 60 * 60_000);
|
|
146
|
+
}
|
|
147
|
+
function weekLabel(columns, zone) {
|
|
148
|
+
const first = columns[0];
|
|
149
|
+
const last = columns.at(-1);
|
|
150
|
+
if (!first || !last)
|
|
151
|
+
return "";
|
|
152
|
+
const month = new Intl.DateTimeFormat("en-IN", { timeZone: zone, month: "long", year: "numeric" });
|
|
153
|
+
return first.monthLabel === last.monthLabel
|
|
154
|
+
? month.format(first.startsAt)
|
|
155
|
+
: `${first.monthLabel} - ${month.format(last.startsAt)}`;
|
|
156
|
+
}
|
|
157
|
+
function timeLabel(instant, zone) {
|
|
158
|
+
return new Intl.DateTimeFormat("en-IN", {
|
|
159
|
+
timeZone: zone,
|
|
160
|
+
hour: "numeric",
|
|
161
|
+
minute: "2-digit",
|
|
162
|
+
hour12: true,
|
|
163
|
+
}).format(instant);
|
|
164
|
+
}
|
|
165
|
+
/** The next whole hour - where "New appointment" opens with no slot picked. */
|
|
166
|
+
function nextHour(now) {
|
|
167
|
+
const next = new Date(now);
|
|
168
|
+
next.setMinutes(0, 0, 0);
|
|
169
|
+
return new Date(next.getTime() + 60 * 60_000);
|
|
170
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ConnectBookingDialog = ConnectBookingDialog;
|
|
5
|
+
exports.ConnectBookingDetail = ConnectBookingDetail;
|
|
6
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const react_1 = require("@heroui/react");
|
|
8
|
+
const lucide_react_1 = require("lucide-react");
|
|
9
|
+
const react_2 = require("react");
|
|
10
|
+
const use_scheduling_js_1 = require("../use-scheduling.js");
|
|
11
|
+
const SEARCH_DAYS = 14;
|
|
12
|
+
/**
|
|
13
|
+
* Picking a time, and then who it is for.
|
|
14
|
+
*
|
|
15
|
+
* The slot list is read fresh every time this opens and is **never cached
|
|
16
|
+
* across an open**: it is a snapshot of contention, and offering a time
|
|
17
|
+
* somebody took ninety seconds ago is how two people end up in the same slot.
|
|
18
|
+
* The server re-checks inside its transaction regardless, so a stale list
|
|
19
|
+
* costs a refusal rather than a double booking - this only makes the refusal
|
|
20
|
+
* rare enough to be surprising when it happens.
|
|
21
|
+
*/
|
|
22
|
+
function ConnectBookingDialog({ isOpen, onOpenChange, eventTypes, initialStartAt, timeZone, onBooked, }) {
|
|
23
|
+
const [eventTypeId, setEventTypeId] = (0, react_2.useState)(eventTypes[0]?.id ?? "");
|
|
24
|
+
const [chosen, setChosen] = (0, react_2.useState)(null);
|
|
25
|
+
const [name, setName] = (0, react_2.useState)("");
|
|
26
|
+
const [phone, setPhone] = (0, react_2.useState)("");
|
|
27
|
+
const window = (0, react_2.useMemo)(() => ({
|
|
28
|
+
// From the start of the day that was clicked, so the times on offer are
|
|
29
|
+
// the ones near where the person was looking.
|
|
30
|
+
from: new Date(initialStartAt.getTime() - 12 * 60 * 60_000),
|
|
31
|
+
to: new Date(initialStartAt.getTime() + SEARCH_DAYS * 24 * 60 * 60_000),
|
|
32
|
+
}), [initialStartAt]);
|
|
33
|
+
const { slots, isLoading } = (0, use_scheduling_js_1.useAvailableSlots)(eventTypeId ? { eventTypeId, from: window.from, to: window.to } : null);
|
|
34
|
+
const { createBooking, isPending, error, clearError } = (0, use_scheduling_js_1.useBookingActions)();
|
|
35
|
+
const canSubmit = Boolean(chosen && name.trim() && phone.trim()) && !isPending;
|
|
36
|
+
return ((0, jsx_runtime_1.jsx)(react_1.Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: (0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { children: [(0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: "New appointment" }), (0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { className: "flex flex-col gap-4", children: [eventTypes.length > 1 && ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-2", children: eventTypes.map((eventType) => ((0, jsx_runtime_1.jsx)(react_1.Button, { size: "sm", variant: eventType.id === eventTypeId ? "primary" : "secondary", onPress: () => {
|
|
37
|
+
setEventTypeId(eventType.id);
|
|
38
|
+
// The old pick belongs to the old type's grid and is
|
|
39
|
+
// almost never a legal slot on the new one.
|
|
40
|
+
setChosen(null);
|
|
41
|
+
}, children: eventType.name }, eventType.id))) })), (0, jsx_runtime_1.jsx)(SlotPicker, { slots: slots, isLoading: isLoading, chosen: chosen, onChoose: setChosen, timeZone: timeZone }), (0, jsx_runtime_1.jsxs)(react_1.TextField, { isRequired: true, variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_1.Label, { children: "Name" }), (0, jsx_runtime_1.jsx)(react_1.Input, { value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending })] }), (0, jsx_runtime_1.jsxs)(react_1.TextField, { isRequired: true, variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_1.Label, { children: "Phone" }), (0, jsx_runtime_1.jsx)(react_1.Input, { type: "tel", value: phone, onChange: (e) => setPhone(e.currentTarget.value), disabled: isPending })] }), error && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: error }), (0, jsx_runtime_1.jsxs)("div", { className: "flex justify-end gap-2", children: [(0, jsx_runtime_1.jsx)(react_1.Button, { variant: "secondary", onPress: () => onOpenChange(false), isDisabled: isPending, children: "Cancel" }), (0, jsx_runtime_1.jsx)(react_1.Button, { variant: "primary", isDisabled: !canSubmit, onPress: async () => {
|
|
42
|
+
clearError();
|
|
43
|
+
const booked = await createBooking({
|
|
44
|
+
eventTypeId,
|
|
45
|
+
startAt: chosen,
|
|
46
|
+
attendee: { name: name.trim(), phone: phone.trim(), email: null },
|
|
47
|
+
});
|
|
48
|
+
if (booked)
|
|
49
|
+
onBooked(booked);
|
|
50
|
+
}, children: isPending ? "Booking…" : "Book" })] })] })] }) }) }) }));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* One appointment, and the two things anyone opens it to do.
|
|
54
|
+
*
|
|
55
|
+
* Rescheduling re-reads the slots with `excludeBookingId` set to this booking.
|
|
56
|
+
* Without that the appointment blocks its own replacement, and moving it by
|
|
57
|
+
* half an hour - the only move most people ever make - offers nothing.
|
|
58
|
+
*/
|
|
59
|
+
function ConnectBookingDetail({ booking, eventType, timeZone, onOpenChange, onChanged, }) {
|
|
60
|
+
const [isMoving, setMoving] = (0, react_2.useState)(false);
|
|
61
|
+
const [chosen, setChosen] = (0, react_2.useState)(null);
|
|
62
|
+
const { rescheduleBooking, cancelBooking, isPending, error, clearError } = (0, use_scheduling_js_1.useBookingActions)();
|
|
63
|
+
const start = new Date(booking.startAt);
|
|
64
|
+
const window = (0, react_2.useMemo)(() => ({
|
|
65
|
+
from: new Date(start.getTime() - 12 * 60 * 60_000),
|
|
66
|
+
to: new Date(start.getTime() + SEARCH_DAYS * 24 * 60 * 60_000),
|
|
67
|
+
}), [start]);
|
|
68
|
+
const { slots, isLoading } = (0, use_scheduling_js_1.useAvailableSlots)(isMoving ? { eventTypeId: booking.eventTypeId, ...window, excludeBookingId: booking.id } : null);
|
|
69
|
+
return ((0, jsx_runtime_1.jsx)(react_1.Modal, { isOpen: true, onOpenChange: onOpenChange, children: (0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { children: [(0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: booking.attendee.name }), (0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1", children: [(0, jsx_runtime_1.jsxs)("p", { className: "flex items-center gap-2 text-sm", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Clock, { className: "size-4 text-muted", "aria-hidden": true }), longLabel(start, timeZone)] }), eventType && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: eventType.name }), (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: booking.attendee.phone }), booking.notes && (0, jsx_runtime_1.jsx)("p", { className: "pt-1 text-sm", children: booking.notes })] }), isMoving && ((0, jsx_runtime_1.jsx)(SlotPicker, { slots: slots, isLoading: isLoading, chosen: chosen, onChoose: setChosen, timeZone: timeZone })), error && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: error }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap justify-end gap-2", children: isMoving ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_1.Button, { variant: "secondary", onPress: () => setMoving(false), isDisabled: isPending, children: "Back" }), (0, jsx_runtime_1.jsx)(react_1.Button, { variant: "primary", isDisabled: !chosen || isPending, onPress: async () => {
|
|
70
|
+
clearError();
|
|
71
|
+
const moved = await rescheduleBooking({
|
|
72
|
+
bookingId: booking.id,
|
|
73
|
+
startAt: chosen,
|
|
74
|
+
});
|
|
75
|
+
if (moved)
|
|
76
|
+
onChanged();
|
|
77
|
+
}, children: isPending ? "Moving…" : "Move appointment" })] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_1.Button, { variant: "danger", isDisabled: isPending, onPress: async () => {
|
|
78
|
+
clearError();
|
|
79
|
+
const cancelled = await cancelBooking({ bookingId: booking.id });
|
|
80
|
+
if (cancelled)
|
|
81
|
+
onChanged();
|
|
82
|
+
}, children: isPending ? "Cancelling…" : "Cancel appointment" }), (0, jsx_runtime_1.jsx)(react_1.Button, { variant: "secondary", onPress: () => setMoving(true), children: "Reschedule" })] })) })] })] }) }) }) }));
|
|
83
|
+
}
|
|
84
|
+
function SlotPicker({ slots, isLoading, chosen, onChoose, timeZone, }) {
|
|
85
|
+
const byDay = (0, react_2.useMemo)(() => {
|
|
86
|
+
const groups = new Map();
|
|
87
|
+
const dayFormat = new Intl.DateTimeFormat("en-IN", {
|
|
88
|
+
timeZone,
|
|
89
|
+
weekday: "short",
|
|
90
|
+
day: "numeric",
|
|
91
|
+
month: "short",
|
|
92
|
+
});
|
|
93
|
+
for (const slot of slots) {
|
|
94
|
+
const label = dayFormat.format(new Date(slot.startAt));
|
|
95
|
+
groups.set(label, [...(groups.get(label) ?? []), slot]);
|
|
96
|
+
}
|
|
97
|
+
return [...groups.entries()];
|
|
98
|
+
}, [slots, timeZone]);
|
|
99
|
+
if (isLoading)
|
|
100
|
+
return (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Finding free times\u2026" });
|
|
101
|
+
if (byDay.length === 0) {
|
|
102
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 rounded-lg border border-dashed border-separator p-4", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CalendarX2, { className: "size-4 text-muted", "aria-hidden": true }), (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Nothing free in the next two weeks." })] }));
|
|
103
|
+
}
|
|
104
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "flex max-h-56 flex-col gap-3 overflow-y-auto", children: byDay.map(([day, daySlots]) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-xs font-medium text-muted", children: day }), (0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-2 gap-1.5 sm:grid-cols-4", children: daySlots.map((slot) => ((0, jsx_runtime_1.jsx)(react_1.Button, { size: "sm", className: "w-full", variant: slot.startAt === chosen ? "primary" : "secondary", onPress: () => onChoose(slot.startAt), children: shortTime(new Date(slot.startAt), timeZone) }, slot.startAt))) })] }, day))) }));
|
|
105
|
+
}
|
|
106
|
+
function shortTime(instant, timeZone) {
|
|
107
|
+
return new Intl.DateTimeFormat("en-IN", {
|
|
108
|
+
timeZone,
|
|
109
|
+
hour: "numeric",
|
|
110
|
+
minute: "2-digit",
|
|
111
|
+
hour12: true,
|
|
112
|
+
}).format(instant);
|
|
113
|
+
}
|
|
114
|
+
function longLabel(instant, timeZone) {
|
|
115
|
+
return new Intl.DateTimeFormat("en-IN", {
|
|
116
|
+
timeZone,
|
|
117
|
+
weekday: "long",
|
|
118
|
+
day: "numeric",
|
|
119
|
+
month: "long",
|
|
120
|
+
hour: "numeric",
|
|
121
|
+
minute: "2-digit",
|
|
122
|
+
hour12: true,
|
|
123
|
+
}).format(instant);
|
|
124
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.zonedInstant = zonedInstant;
|
|
4
|
+
exports.dayColumns = dayColumns;
|
|
5
|
+
exports.dayStartInstant = dayStartInstant;
|
|
6
|
+
exports.layoutBookings = layoutBookings;
|
|
7
|
+
exports.hourMarks = hourMarks;
|
|
8
|
+
/**
|
|
9
|
+
* An instant, as the local day and minute it lands on in `timeZone`.
|
|
10
|
+
*
|
|
11
|
+
* Built from `formatToParts` rather than by adding a fixed offset. An offset
|
|
12
|
+
* is only correct until a DST boundary, and "add 5.5 hours" is wrong the
|
|
13
|
+
* moment the same code is pointed at a zone that observes one - which is
|
|
14
|
+
* exactly the kind of bug that appears twice a year and never in a test.
|
|
15
|
+
*/
|
|
16
|
+
function zonedInstant(instant, timeZone) {
|
|
17
|
+
const parts = partsFormatter(timeZone).formatToParts(instant);
|
|
18
|
+
const read = (type) => parts.find((part) => part.type === type)?.value ?? "00";
|
|
19
|
+
// `hourCycle: "h23"` so midnight is 00 rather than 24 - `hour12: false`
|
|
20
|
+
// alone still yields 24 in some engines, and a 24 would push midnight onto
|
|
21
|
+
// the wrong day.
|
|
22
|
+
return {
|
|
23
|
+
dateKey: `${read("year")}-${read("month")}-${read("day")}`,
|
|
24
|
+
minutes: Number(read("hour")) * 60 + Number(read("minute")),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const formatterCache = new Map();
|
|
28
|
+
function partsFormatter(timeZone) {
|
|
29
|
+
let formatter = formatterCache.get(timeZone);
|
|
30
|
+
if (!formatter) {
|
|
31
|
+
// Cached because a grid calls this once per booking per render, and
|
|
32
|
+
// constructing an `Intl.DateTimeFormat` is the expensive part by a wide
|
|
33
|
+
// margin.
|
|
34
|
+
formatter = new Intl.DateTimeFormat("en-CA", {
|
|
35
|
+
timeZone,
|
|
36
|
+
year: "numeric",
|
|
37
|
+
month: "2-digit",
|
|
38
|
+
day: "2-digit",
|
|
39
|
+
hour: "2-digit",
|
|
40
|
+
minute: "2-digit",
|
|
41
|
+
hourCycle: "h23",
|
|
42
|
+
});
|
|
43
|
+
formatterCache.set(timeZone, formatter);
|
|
44
|
+
}
|
|
45
|
+
return formatter;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `count` consecutive days starting at the local day `anchor` falls on.
|
|
49
|
+
*
|
|
50
|
+
* Days are stepped by adding 24 hours and re-reading the zone rather than by
|
|
51
|
+
* incrementing a date field, so a day that is 23 or 25 hours long still
|
|
52
|
+
* produces the right sequence of `dateKey`s - the midday anchor is what keeps
|
|
53
|
+
* a DST shift from landing the step on the previous or next day.
|
|
54
|
+
*/
|
|
55
|
+
function dayColumns(anchor, timeZone, count, now) {
|
|
56
|
+
const todayKey = zonedInstant(now, timeZone).dateKey;
|
|
57
|
+
const columns = [];
|
|
58
|
+
for (let index = 0; index < count; index += 1) {
|
|
59
|
+
const probe = new Date(anchor.getTime() + index * 24 * 60 * 60_000);
|
|
60
|
+
const { dateKey } = zonedInstant(probe, timeZone);
|
|
61
|
+
const startsAt = dayStartInstant(dateKey, timeZone);
|
|
62
|
+
columns.push({
|
|
63
|
+
dateKey,
|
|
64
|
+
label: new Intl.DateTimeFormat("en-IN", {
|
|
65
|
+
timeZone,
|
|
66
|
+
weekday: "short",
|
|
67
|
+
day: "numeric",
|
|
68
|
+
}).format(startsAt),
|
|
69
|
+
monthLabel: new Intl.DateTimeFormat("en-IN", { timeZone, month: "short" }).format(startsAt),
|
|
70
|
+
startsAt,
|
|
71
|
+
isToday: dateKey === todayKey,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return columns;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Local midnight of `dateKey` in `timeZone`, as an instant.
|
|
78
|
+
*
|
|
79
|
+
* Solved by probing rather than by arithmetic: guess UTC midnight, read what
|
|
80
|
+
* local time that actually is, and subtract the difference. One correction is
|
|
81
|
+
* always enough because a zone's offset cannot change within the few hours the
|
|
82
|
+
* correction spans - and unlike a fixed-offset formula it is right across DST
|
|
83
|
+
* and for the half-hour zones (`Asia/Kolkata`, `Australia/Adelaide`) that a
|
|
84
|
+
* naive implementation quietly breaks on.
|
|
85
|
+
*/
|
|
86
|
+
function dayStartInstant(dateKey, timeZone) {
|
|
87
|
+
const utcGuess = new Date(`${dateKey}T00:00:00.000Z`);
|
|
88
|
+
const { dateKey: landedOn, minutes } = zonedInstant(utcGuess, timeZone);
|
|
89
|
+
// Whether the guess landed on the day before, the day itself, or the day
|
|
90
|
+
// after decides which way to correct.
|
|
91
|
+
const dayDelta = compareDateKeys(landedOn, dateKey);
|
|
92
|
+
return new Date(utcGuess.getTime() - (minutes + dayDelta * 1440) * 60_000);
|
|
93
|
+
}
|
|
94
|
+
function compareDateKeys(a, b) {
|
|
95
|
+
if (a === b)
|
|
96
|
+
return 0;
|
|
97
|
+
return a < b ? -1 : 1;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A fifteen-minute appointment on a twelve-hour grid is two percent tall -
|
|
101
|
+
* unreadable and unclickable. Short appointments are drawn at a floor and
|
|
102
|
+
* allowed to overhang, which is what every calendar does and what makes a
|
|
103
|
+
* back-to-back pair still look like two things.
|
|
104
|
+
*/
|
|
105
|
+
const MIN_VISIBLE_FRACTION = 0.02;
|
|
106
|
+
/**
|
|
107
|
+
* Places bookings on the grid, side by side where they collide.
|
|
108
|
+
*
|
|
109
|
+
* Overlap handling is the part worth stating. Two appointments at the same
|
|
110
|
+
* time are not an error - a workspace with two hosts has them constantly - and
|
|
111
|
+
* stacking them would hide one entirely. So overlapping bookings are grouped
|
|
112
|
+
* into *clusters* and each cluster is split into equal columns. The cluster is
|
|
113
|
+
* built transitively: A overlapping B and B overlapping C puts all three in one
|
|
114
|
+
* cluster even when A and C do not touch, because anything else lets two of
|
|
115
|
+
* them land on the same column.
|
|
116
|
+
*/
|
|
117
|
+
function layoutBookings(bookings, timeZone, window) {
|
|
118
|
+
const span = window.endMinute - window.startMinute;
|
|
119
|
+
if (span <= 0)
|
|
120
|
+
return [];
|
|
121
|
+
const byDay = new Map();
|
|
122
|
+
for (const booking of bookings) {
|
|
123
|
+
const start = zonedInstant(new Date(booking.startAt), timeZone);
|
|
124
|
+
const end = zonedInstant(new Date(booking.endAt), timeZone);
|
|
125
|
+
// An appointment that crosses local midnight is clamped to the end of the
|
|
126
|
+
// day it started on. Splitting it across two columns would be more correct
|
|
127
|
+
// and is not worth the complexity for a booking grid, where the longest
|
|
128
|
+
// event type is capped at eight hours server-side.
|
|
129
|
+
const endMinutes = end.dateKey === start.dateKey ? end.minutes : 1440;
|
|
130
|
+
const entries = byDay.get(start.dateKey) ?? [];
|
|
131
|
+
entries.push({ booking, start: start.minutes, end: Math.max(endMinutes, start.minutes + 1) });
|
|
132
|
+
byDay.set(start.dateKey, entries);
|
|
133
|
+
}
|
|
134
|
+
const positioned = [];
|
|
135
|
+
for (const [dateKey, entries] of byDay) {
|
|
136
|
+
entries.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
137
|
+
for (const cluster of clusterOverlapping(entries)) {
|
|
138
|
+
cluster.forEach((entry, column) => {
|
|
139
|
+
const clampedStart = Math.max(entry.start, window.startMinute);
|
|
140
|
+
const clampedEnd = Math.min(entry.end, window.endMinute);
|
|
141
|
+
positioned.push({
|
|
142
|
+
booking: entry.booking,
|
|
143
|
+
dateKey,
|
|
144
|
+
top: (clampedStart - window.startMinute) / span,
|
|
145
|
+
height: Math.max((clampedEnd - clampedStart) / span, MIN_VISIBLE_FRACTION),
|
|
146
|
+
column,
|
|
147
|
+
columns: cluster.length,
|
|
148
|
+
clipped: entry.start < window.startMinute || entry.end > window.endMinute,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return positioned;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Transitive overlap grouping - see `layoutBookings`.
|
|
157
|
+
*
|
|
158
|
+
* Entries arrive sorted by start. A cluster stays open while the next entry
|
|
159
|
+
* begins before the *furthest* end seen so far, not before the previous
|
|
160
|
+
* entry's end: a long appointment that swallows several short ones has to keep
|
|
161
|
+
* the whole run together.
|
|
162
|
+
*/
|
|
163
|
+
function clusterOverlapping(entries) {
|
|
164
|
+
const clusters = [];
|
|
165
|
+
let current = [];
|
|
166
|
+
let clusterEnd = -Infinity;
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
if (current.length > 0 && entry.start >= clusterEnd) {
|
|
169
|
+
clusters.push(current);
|
|
170
|
+
current = [];
|
|
171
|
+
clusterEnd = -Infinity;
|
|
172
|
+
}
|
|
173
|
+
current.push(entry);
|
|
174
|
+
clusterEnd = Math.max(clusterEnd, entry.end);
|
|
175
|
+
}
|
|
176
|
+
if (current.length > 0)
|
|
177
|
+
clusters.push(current);
|
|
178
|
+
return clusters;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The hour labels down the left edge, inclusive of both ends.
|
|
182
|
+
*
|
|
183
|
+
* Takes no timezone, deliberately: these label *wall-clock hours on the grid*,
|
|
184
|
+
* not instants. The grid is already drawn in the business's zone, so "10 am"
|
|
185
|
+
* means the business's ten - running the label through a zone conversion would
|
|
186
|
+
* shift it away from the row it sits on.
|
|
187
|
+
*/
|
|
188
|
+
function hourMarks(window) {
|
|
189
|
+
const marks = [];
|
|
190
|
+
const formatter = new Intl.DateTimeFormat("en-IN", {
|
|
191
|
+
timeZone: "UTC",
|
|
192
|
+
hour: "numeric",
|
|
193
|
+
hour12: true,
|
|
194
|
+
});
|
|
195
|
+
const firstHour = Math.ceil(window.startMinute / 60);
|
|
196
|
+
const lastHour = Math.floor(window.endMinute / 60);
|
|
197
|
+
for (let hour = firstHour; hour <= lastHour; hour += 1) {
|
|
198
|
+
marks.push({
|
|
199
|
+
minute: hour * 60,
|
|
200
|
+
// A synthetic UTC instant formatted in UTC - the round trip exists only
|
|
201
|
+
// to borrow `Intl`'s "10 am" / "12 pm" spelling, not to convert anything.
|
|
202
|
+
label: formatter.format(new Date(Date.UTC(2000, 0, 1, hour % 24))),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return marks;
|
|
206
|
+
}
|