@hanzo/booker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +78 -0
  2. package/dist/cjs/Booker.cjs +153 -0
  3. package/dist/cjs/Booker.native.js +158 -0
  4. package/dist/cjs/Booker.native.js.map +1 -0
  5. package/dist/cjs/BookingForm.cjs +197 -0
  6. package/dist/cjs/BookingForm.native.js +203 -0
  7. package/dist/cjs/BookingForm.native.js.map +1 -0
  8. package/dist/cjs/CalendarEmbed.cjs +121 -0
  9. package/dist/cjs/CalendarEmbed.native.js +125 -0
  10. package/dist/cjs/CalendarEmbed.native.js.map +1 -0
  11. package/dist/cjs/Confirmation.cjs +177 -0
  12. package/dist/cjs/Confirmation.native.js +185 -0
  13. package/dist/cjs/Confirmation.native.js.map +1 -0
  14. package/dist/cjs/EventHeader.cjs +112 -0
  15. package/dist/cjs/EventHeader.native.js +118 -0
  16. package/dist/cjs/EventHeader.native.js.map +1 -0
  17. package/dist/cjs/MonthCalendar.cjs +177 -0
  18. package/dist/cjs/MonthCalendar.native.js +191 -0
  19. package/dist/cjs/MonthCalendar.native.js.map +1 -0
  20. package/dist/cjs/TimeSlots.cjs +119 -0
  21. package/dist/cjs/TimeSlots.native.js +126 -0
  22. package/dist/cjs/TimeSlots.native.js.map +1 -0
  23. package/dist/cjs/client.cjs +136 -0
  24. package/dist/cjs/client.native.js +267 -0
  25. package/dist/cjs/client.native.js.map +1 -0
  26. package/dist/cjs/hooks.cjs +193 -0
  27. package/dist/cjs/hooks.native.js +221 -0
  28. package/dist/cjs/hooks.native.js.map +1 -0
  29. package/dist/cjs/index.cjs +71 -0
  30. package/dist/cjs/index.native.js +74 -0
  31. package/dist/cjs/index.native.js.map +1 -0
  32. package/dist/cjs/time.cjs +145 -0
  33. package/dist/cjs/time.native.js +150 -0
  34. package/dist/cjs/time.native.js.map +1 -0
  35. package/dist/cjs/useBooker.cjs +151 -0
  36. package/dist/cjs/useBooker.native.js +173 -0
  37. package/dist/cjs/useBooker.native.js.map +1 -0
  38. package/dist/esm/Booker.mjs +128 -0
  39. package/dist/esm/Booker.mjs.map +1 -0
  40. package/dist/esm/Booker.native.js +130 -0
  41. package/dist/esm/Booker.native.js.map +1 -0
  42. package/dist/esm/BookingForm.mjs +172 -0
  43. package/dist/esm/BookingForm.mjs.map +1 -0
  44. package/dist/esm/BookingForm.native.js +175 -0
  45. package/dist/esm/BookingForm.native.js.map +1 -0
  46. package/dist/esm/CalendarEmbed.mjs +96 -0
  47. package/dist/esm/CalendarEmbed.mjs.map +1 -0
  48. package/dist/esm/CalendarEmbed.native.js +97 -0
  49. package/dist/esm/CalendarEmbed.native.js.map +1 -0
  50. package/dist/esm/Confirmation.mjs +152 -0
  51. package/dist/esm/Confirmation.mjs.map +1 -0
  52. package/dist/esm/Confirmation.native.js +157 -0
  53. package/dist/esm/Confirmation.native.js.map +1 -0
  54. package/dist/esm/EventHeader.mjs +87 -0
  55. package/dist/esm/EventHeader.mjs.map +1 -0
  56. package/dist/esm/EventHeader.native.js +90 -0
  57. package/dist/esm/EventHeader.native.js.map +1 -0
  58. package/dist/esm/MonthCalendar.mjs +152 -0
  59. package/dist/esm/MonthCalendar.mjs.map +1 -0
  60. package/dist/esm/MonthCalendar.native.js +163 -0
  61. package/dist/esm/MonthCalendar.native.js.map +1 -0
  62. package/dist/esm/TimeSlots.mjs +94 -0
  63. package/dist/esm/TimeSlots.mjs.map +1 -0
  64. package/dist/esm/TimeSlots.native.js +98 -0
  65. package/dist/esm/TimeSlots.native.js.map +1 -0
  66. package/dist/esm/client.mjs +104 -0
  67. package/dist/esm/client.mjs.map +1 -0
  68. package/dist/esm/client.native.js +232 -0
  69. package/dist/esm/client.native.js.map +1 -0
  70. package/dist/esm/hooks.mjs +165 -0
  71. package/dist/esm/hooks.mjs.map +1 -0
  72. package/dist/esm/hooks.native.js +190 -0
  73. package/dist/esm/hooks.native.js.map +1 -0
  74. package/dist/esm/index.js +13 -0
  75. package/dist/esm/index.js.map +1 -0
  76. package/dist/esm/index.mjs +13 -0
  77. package/dist/esm/index.mjs.map +1 -0
  78. package/dist/esm/index.native.js +13 -0
  79. package/dist/esm/index.native.js.map +1 -0
  80. package/dist/esm/time.mjs +107 -0
  81. package/dist/esm/time.mjs.map +1 -0
  82. package/dist/esm/time.native.js +109 -0
  83. package/dist/esm/time.native.js.map +1 -0
  84. package/dist/esm/useBooker.mjs +126 -0
  85. package/dist/esm/useBooker.mjs.map +1 -0
  86. package/dist/esm/useBooker.native.js +145 -0
  87. package/dist/esm/useBooker.native.js.map +1 -0
  88. package/package.json +61 -0
  89. package/src/Booker.tsx +152 -0
  90. package/src/BookingForm.tsx +180 -0
  91. package/src/CalendarEmbed.tsx +111 -0
  92. package/src/Confirmation.tsx +145 -0
  93. package/src/EventHeader.tsx +85 -0
  94. package/src/MonthCalendar.tsx +179 -0
  95. package/src/TimeSlots.tsx +102 -0
  96. package/src/client.ts +218 -0
  97. package/src/hooks.ts +180 -0
  98. package/src/index.ts +72 -0
  99. package/src/time.ts +131 -0
  100. package/src/useBooker.ts +187 -0
  101. package/types/Booker.d.ts +16 -0
  102. package/types/Booker.d.ts.map +1 -0
  103. package/types/BookingForm.d.ts +13 -0
  104. package/types/BookingForm.d.ts.map +1 -0
  105. package/types/CalendarEmbed.d.ts +15 -0
  106. package/types/CalendarEmbed.d.ts.map +1 -0
  107. package/types/Confirmation.d.ts +12 -0
  108. package/types/Confirmation.d.ts.map +1 -0
  109. package/types/EventHeader.d.ts +10 -0
  110. package/types/EventHeader.d.ts.map +1 -0
  111. package/types/MonthCalendar.d.ts +14 -0
  112. package/types/MonthCalendar.d.ts.map +1 -0
  113. package/types/TimeSlots.d.ts +15 -0
  114. package/types/TimeSlots.d.ts.map +1 -0
  115. package/types/client.d.ts +77 -0
  116. package/types/client.d.ts.map +1 -0
  117. package/types/hooks.d.ts +43 -0
  118. package/types/hooks.d.ts.map +1 -0
  119. package/types/index.d.ts +12 -0
  120. package/types/index.d.ts.map +1 -0
  121. package/types/time.d.ts +38 -0
  122. package/types/time.d.ts.map +1 -0
  123. package/types/useBooker.d.ts +41 -0
  124. package/types/useBooker.d.ts.map +1 -0
package/src/client.ts ADDED
@@ -0,0 +1,218 @@
1
+ // Data plane for the Hanzo Calendar (Cal API-v2 shapes).
2
+ // Pure fetch layer — no React, no DOM. Runs on web and React Native.
3
+
4
+ export const DEFAULT_API_URL = 'https://api.hanzo.ai/v1/calendar'
5
+ export const CAL_API_VERSION = '2024-08-13'
6
+
7
+ export interface Location {
8
+ type?: string
9
+ link?: string
10
+ address?: string
11
+ }
12
+
13
+ export interface Profile {
14
+ name?: string | null
15
+ username?: string | null
16
+ image?: string | null
17
+ avatarUrl?: string | null
18
+ }
19
+
20
+ export interface EventType {
21
+ id: number
22
+ slug: string
23
+ title: string
24
+ description: string
25
+ /** duration in minutes */
26
+ length: number
27
+ locations: Location[]
28
+ price: number
29
+ currency: string
30
+ profile?: Profile
31
+ }
32
+
33
+ export interface Slot {
34
+ /** ISO-8601 UTC instant, e.g. "2026-07-24T16:00:00Z" */
35
+ time: string
36
+ }
37
+
38
+ /** Slots grouped by day key "YYYY-MM-DD" (in the requested time zone). */
39
+ export type SlotsByDay = Record<string, Slot[]>
40
+
41
+ export interface Attendee {
42
+ name?: string
43
+ email?: string
44
+ timeZone?: string
45
+ }
46
+
47
+ export interface Booking {
48
+ uid: string
49
+ status: string
50
+ start: string
51
+ end: string
52
+ title: string
53
+ location?: string
54
+ attendees?: Attendee[]
55
+ }
56
+
57
+ export interface BookingResponses {
58
+ name: string
59
+ email: string
60
+ notes?: string
61
+ }
62
+
63
+ export interface CreateBookingInput {
64
+ eventTypeSlug: string
65
+ username: string
66
+ /** ISO instant of the chosen slot */
67
+ start: string
68
+ timeZone: string
69
+ responses: BookingResponses
70
+ }
71
+
72
+ export interface SlotsInput {
73
+ eventTypeSlug: string
74
+ username: string
75
+ startTime: string
76
+ endTime: string
77
+ timeZone: string
78
+ }
79
+
80
+ /** Error carrying the HTTP status; `conflict` marks a 409 (slot just taken). */
81
+ export class CalendarError extends Error {
82
+ status: number
83
+ conflict: boolean
84
+ constructor(message: string, status: number) {
85
+ super(message)
86
+ this.name = 'CalendarError'
87
+ this.status = status
88
+ this.conflict = status === 409
89
+ }
90
+ }
91
+
92
+ interface Envelope<T> {
93
+ data?: T
94
+ error?: { message?: string }
95
+ message?: string
96
+ }
97
+
98
+ function messageOf(body: unknown, status: number): string {
99
+ const b = body as Envelope<unknown> | null
100
+ return b?.error?.message || b?.message || `Request failed (${status})`
101
+ }
102
+
103
+ async function readJson(res: Response): Promise<unknown> {
104
+ return res.json().catch(() => null)
105
+ }
106
+
107
+ async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
108
+ const res = await fetch(url, { headers: { Accept: 'application/json' }, signal })
109
+ const body = await readJson(res)
110
+ if (!res.ok) throw new CalendarError(messageOf(body, res.status), res.status)
111
+ return (body as Envelope<T>).data as T
112
+ }
113
+
114
+ function normalizeEventType(raw: Record<string, unknown>): EventType {
115
+ const length =
116
+ (raw.length as number) ??
117
+ (raw.lengthInMinutes as number) ??
118
+ (raw.durationMinutes as number) ??
119
+ 30
120
+ return {
121
+ id: Number(raw.id ?? 0),
122
+ slug: String(raw.slug ?? ''),
123
+ title: String(raw.title ?? ''),
124
+ description: String(raw.description ?? ''),
125
+ length,
126
+ locations: (raw.locations as Location[]) ?? [],
127
+ price: Number(raw.price ?? 0),
128
+ currency: String(raw.currency ?? 'usd'),
129
+ profile: raw.profile as Profile | undefined,
130
+ }
131
+ }
132
+
133
+ export async function fetchEventType(
134
+ api: string,
135
+ eventSlug: string,
136
+ username: string,
137
+ signal?: AbortSignal
138
+ ): Promise<EventType> {
139
+ const url = `${api}/atoms/event-types/${encodeURIComponent(eventSlug)}/public?username=${encodeURIComponent(username)}`
140
+ const raw = await getJson<Record<string, unknown>>(url, signal)
141
+ return normalizeEventType(raw)
142
+ }
143
+
144
+ export async function fetchSlots(
145
+ api: string,
146
+ input: SlotsInput,
147
+ signal?: AbortSignal
148
+ ): Promise<SlotsByDay> {
149
+ const q = new URLSearchParams({
150
+ eventTypeSlug: input.eventTypeSlug,
151
+ usernameList: input.username,
152
+ startTime: input.startTime,
153
+ endTime: input.endTime,
154
+ timeZone: input.timeZone,
155
+ })
156
+ const data = await getJson<{ slots?: SlotsByDay } | SlotsByDay>(
157
+ `${api}/slots/available?${q.toString()}`,
158
+ signal
159
+ )
160
+ // API returns { data: { slots: {...} } }; tolerate a bare map too.
161
+ const slots = (data as { slots?: SlotsByDay }).slots ?? (data as SlotsByDay)
162
+ return slots ?? {}
163
+ }
164
+
165
+ export async function createBooking(
166
+ api: string,
167
+ input: CreateBookingInput,
168
+ signal?: AbortSignal
169
+ ): Promise<Booking> {
170
+ const res = await fetch(`${api}/bookings`, {
171
+ method: 'POST',
172
+ headers: {
173
+ 'Content-Type': 'application/json',
174
+ Accept: 'application/json',
175
+ 'cal-api-version': CAL_API_VERSION,
176
+ },
177
+ body: JSON.stringify({
178
+ eventTypeSlug: input.eventTypeSlug,
179
+ user: input.username,
180
+ start: input.start,
181
+ timeZone: input.timeZone,
182
+ responses: input.responses,
183
+ }),
184
+ signal,
185
+ })
186
+ const body = await readJson(res)
187
+ if (!res.ok) throw new CalendarError(messageOf(body, res.status), res.status)
188
+ return (body as Envelope<Booking>).data as Booking
189
+ }
190
+
191
+ export async function fetchBooking(
192
+ api: string,
193
+ uid: string,
194
+ signal?: AbortSignal
195
+ ): Promise<Booking> {
196
+ return getJson<Booking>(`${api}/bookings/${encodeURIComponent(uid)}`, signal)
197
+ }
198
+
199
+ export async function cancelBooking(
200
+ api: string,
201
+ uid: string,
202
+ reason?: string,
203
+ signal?: AbortSignal
204
+ ): Promise<Booking> {
205
+ const res = await fetch(`${api}/bookings/${encodeURIComponent(uid)}/cancel`, {
206
+ method: 'POST',
207
+ headers: {
208
+ 'Content-Type': 'application/json',
209
+ Accept: 'application/json',
210
+ 'cal-api-version': CAL_API_VERSION,
211
+ },
212
+ body: JSON.stringify({ reason: reason ?? '' }),
213
+ signal,
214
+ })
215
+ const body = await readJson(res)
216
+ if (!res.ok) throw new CalendarError(messageOf(body, res.status), res.status)
217
+ return (body as Envelope<Booking>).data as Booking
218
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,180 @@
1
+ // React data hooks over the client. useState/useEffect only — no query lib.
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import {
5
+ createBooking,
6
+ fetchBooking,
7
+ fetchEventType,
8
+ fetchSlots,
9
+ CalendarError,
10
+ type Booking,
11
+ type BookingResponses,
12
+ type EventType,
13
+ type SlotsByDay,
14
+ } from './client'
15
+
16
+ export interface AsyncState<T> {
17
+ data: T | null
18
+ loading: boolean
19
+ error: string | null
20
+ }
21
+
22
+ export function useEventType(
23
+ api: string,
24
+ eventSlug: string,
25
+ username: string
26
+ ): AsyncState<EventType> {
27
+ const [state, setState] = useState<AsyncState<EventType>>({
28
+ data: null,
29
+ loading: true,
30
+ error: null,
31
+ })
32
+ useEffect(() => {
33
+ const ctrl = new AbortController()
34
+ setState({ data: null, loading: true, error: null })
35
+ fetchEventType(api, eventSlug, username, ctrl.signal)
36
+ .then((data) => setState({ data, loading: false, error: null }))
37
+ .catch((err: unknown) => {
38
+ if (ctrl.signal.aborted) return
39
+ setState({ data: null, loading: false, error: errorText(err) })
40
+ })
41
+ return () => ctrl.abort()
42
+ }, [api, eventSlug, username])
43
+ return state
44
+ }
45
+
46
+ export interface SlotsQuery {
47
+ eventTypeSlug: string
48
+ username: string
49
+ startTime: string
50
+ endTime: string
51
+ timeZone: string
52
+ enabled?: boolean
53
+ }
54
+
55
+ export interface SlotsState extends AsyncState<SlotsByDay> {
56
+ refetch: () => void
57
+ }
58
+
59
+ export function useAvailableSlots(api: string, query: SlotsQuery): SlotsState {
60
+ const { eventTypeSlug, username, startTime, endTime, timeZone, enabled = true } = query
61
+ const [state, setState] = useState<AsyncState<SlotsByDay>>({
62
+ data: null,
63
+ loading: enabled,
64
+ error: null,
65
+ })
66
+ const [nonce, setNonce] = useState(0)
67
+ const refetch = useCallback(() => setNonce((n) => n + 1), [])
68
+
69
+ useEffect(() => {
70
+ if (!enabled) return
71
+ const ctrl = new AbortController()
72
+ setState((s) => ({ data: s.data, loading: true, error: null }))
73
+ fetchSlots(
74
+ api,
75
+ { eventTypeSlug, username, startTime, endTime, timeZone },
76
+ ctrl.signal
77
+ )
78
+ .then((data) => setState({ data, loading: false, error: null }))
79
+ .catch((err: unknown) => {
80
+ if (ctrl.signal.aborted) return
81
+ setState({ data: null, loading: false, error: errorText(err) })
82
+ })
83
+ return () => ctrl.abort()
84
+ }, [api, eventTypeSlug, username, startTime, endTime, timeZone, enabled, nonce])
85
+
86
+ return { ...state, refetch }
87
+ }
88
+
89
+ export type SubmitResult =
90
+ | { ok: true; booking: Booking }
91
+ | { ok: false; conflict: boolean; message: string }
92
+
93
+ export interface CreateBookingState {
94
+ submit: (input: {
95
+ eventTypeSlug: string
96
+ username: string
97
+ start: string
98
+ timeZone: string
99
+ responses: BookingResponses
100
+ }) => Promise<SubmitResult>
101
+ submitting: boolean
102
+ error: string | null
103
+ conflict: boolean
104
+ reset: () => void
105
+ }
106
+
107
+ export function useCreateBooking(api: string): CreateBookingState {
108
+ const [submitting, setSubmitting] = useState(false)
109
+ const [error, setError] = useState<string | null>(null)
110
+ const [conflict, setConflict] = useState(false)
111
+ const mounted = useRef(true)
112
+ useEffect(() => {
113
+ mounted.current = true
114
+ return () => {
115
+ mounted.current = false
116
+ }
117
+ }, [])
118
+
119
+ const reset = useCallback(() => {
120
+ setError(null)
121
+ setConflict(false)
122
+ }, [])
123
+
124
+ const submit = useCallback(
125
+ async (input: {
126
+ eventTypeSlug: string
127
+ username: string
128
+ start: string
129
+ timeZone: string
130
+ responses: BookingResponses
131
+ }): Promise<SubmitResult> => {
132
+ setSubmitting(true)
133
+ setError(null)
134
+ setConflict(false)
135
+ try {
136
+ const booking = await createBooking(api, input)
137
+ return { ok: true, booking }
138
+ } catch (err: unknown) {
139
+ const isConflict = err instanceof CalendarError && err.conflict
140
+ const message = errorText(err)
141
+ if (mounted.current) {
142
+ setError(message)
143
+ setConflict(isConflict)
144
+ }
145
+ return { ok: false, conflict: isConflict, message }
146
+ } finally {
147
+ if (mounted.current) setSubmitting(false)
148
+ }
149
+ },
150
+ [api]
151
+ )
152
+
153
+ return { submit, submitting, error, conflict, reset }
154
+ }
155
+
156
+ export function useBooking(api: string, uid: string | undefined): AsyncState<Booking> {
157
+ const [state, setState] = useState<AsyncState<Booking>>({
158
+ data: null,
159
+ loading: !!uid,
160
+ error: null,
161
+ })
162
+ useEffect(() => {
163
+ if (!uid) return
164
+ const ctrl = new AbortController()
165
+ setState({ data: null, loading: true, error: null })
166
+ fetchBooking(api, uid, ctrl.signal)
167
+ .then((data) => setState({ data, loading: false, error: null }))
168
+ .catch((err: unknown) => {
169
+ if (ctrl.signal.aborted) return
170
+ setState({ data: null, loading: false, error: errorText(err) })
171
+ })
172
+ return () => ctrl.abort()
173
+ }, [api, uid])
174
+ return state
175
+ }
176
+
177
+ function errorText(err: unknown): string {
178
+ if (err instanceof Error) return err.message
179
+ return 'Something went wrong'
180
+ }
package/src/index.ts ADDED
@@ -0,0 +1,72 @@
1
+ // @hanzo/calendar — universal (web + native) booking UI on the Hanzo Calendar
2
+ // backend, built with hanzogui primitives.
3
+
4
+ export { Booker, type BookerProps } from './Booker'
5
+ export { CalendarEmbed, type CalendarEmbedProps } from './CalendarEmbed'
6
+ export { Confirmation, type ConfirmationProps } from './Confirmation'
7
+
8
+ // Composable sub-views (use these to build a custom flow).
9
+ export { EventHeader, type EventHeaderProps } from './EventHeader'
10
+ export { MonthCalendar, type MonthCalendarProps } from './MonthCalendar'
11
+ export { TimeSlots, type TimeSlotsProps } from './TimeSlots'
12
+ export { BookingForm, type BookingFormProps } from './BookingForm'
13
+
14
+ // Orchestration hook + data hooks.
15
+ export {
16
+ useBooker,
17
+ type UseBookerOptions,
18
+ type BookerModel,
19
+ type BookerPhase,
20
+ } from './useBooker'
21
+ export {
22
+ useEventType,
23
+ useAvailableSlots,
24
+ useCreateBooking,
25
+ useBooking,
26
+ type AsyncState,
27
+ type SlotsQuery,
28
+ type SlotsState,
29
+ type CreateBookingState,
30
+ type SubmitResult,
31
+ } from './hooks'
32
+
33
+ // Data plane.
34
+ export {
35
+ DEFAULT_API_URL,
36
+ CAL_API_VERSION,
37
+ CalendarError,
38
+ fetchEventType,
39
+ fetchSlots,
40
+ createBooking,
41
+ fetchBooking,
42
+ cancelBooking,
43
+ type EventType,
44
+ type Slot,
45
+ type SlotsByDay,
46
+ type Booking,
47
+ type Attendee,
48
+ type Location,
49
+ type Profile,
50
+ type BookingResponses,
51
+ type CreateBookingInput,
52
+ type SlotsInput,
53
+ } from './client'
54
+
55
+ // Time plane (Intl/Date helpers).
56
+ export {
57
+ visitorTimeZone,
58
+ ymd,
59
+ monthMatrix,
60
+ monthRange,
61
+ weekdayLabels,
62
+ formatMonthLabel,
63
+ formatDayLong,
64
+ formatSlotTime,
65
+ formatBookingWhen,
66
+ dateFromKey,
67
+ startOfMonth,
68
+ addMonths,
69
+ sameMonth,
70
+ isBeforeToday,
71
+ type MonthCell,
72
+ } from './time'
package/src/time.ts ADDED
@@ -0,0 +1,131 @@
1
+ // Time plane — pure date/timezone helpers via Intl + Date only.
2
+ // No external date library. Web and React Native safe.
3
+
4
+ /** The visitor's IANA time zone, e.g. "America/New_York". */
5
+ export function visitorTimeZone(): string {
6
+ try {
7
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
8
+ } catch {
9
+ return 'UTC'
10
+ }
11
+ }
12
+
13
+ /** Calendar date "YYYY-MM-DD" from a Date's local components. */
14
+ export function ymd(d: Date): string {
15
+ const y = d.getFullYear()
16
+ const m = String(d.getMonth() + 1).padStart(2, '0')
17
+ const day = String(d.getDate()).padStart(2, '0')
18
+ return `${y}-${m}-${day}`
19
+ }
20
+
21
+ export function startOfMonth(d: Date): Date {
22
+ return new Date(d.getFullYear(), d.getMonth(), 1)
23
+ }
24
+
25
+ export function addMonths(d: Date, n: number): Date {
26
+ return new Date(d.getFullYear(), d.getMonth() + n, 1)
27
+ }
28
+
29
+ export function sameMonth(a: Date, b: Date): boolean {
30
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth()
31
+ }
32
+
33
+ /** True if the day key is strictly before today (lexicographic works on YYYY-MM-DD). */
34
+ export function isBeforeToday(key: string): boolean {
35
+ return key < ymd(new Date())
36
+ }
37
+
38
+ export interface MonthCell {
39
+ date: Date
40
+ key: string
41
+ inMonth: boolean
42
+ }
43
+
44
+ /**
45
+ * A 6×7 grid of days covering the month of `viewDate`, including the leading
46
+ * and trailing days needed to fill whole weeks.
47
+ */
48
+ export function monthMatrix(viewDate: Date, weekStartsOn = 0): MonthCell[] {
49
+ const year = viewDate.getFullYear()
50
+ const month = viewDate.getMonth()
51
+ const firstWeekday = new Date(year, month, 1).getDay()
52
+ const startOffset = (firstWeekday - weekStartsOn + 7) % 7
53
+ const cells: MonthCell[] = []
54
+ for (let i = 0; i < 42; i++) {
55
+ const date = new Date(year, month, 1 - startOffset + i)
56
+ cells.push({ date, key: ymd(date), inMonth: date.getMonth() === month })
57
+ }
58
+ return cells
59
+ }
60
+
61
+ /**
62
+ * ISO start/end covering the visible month, clamped so the start is never in
63
+ * the past (the backend rejects past ranges anyway).
64
+ */
65
+ export function monthRange(viewDate: Date): { startTime: string; endTime: string } {
66
+ const year = viewDate.getFullYear()
67
+ const month = viewDate.getMonth()
68
+ const startOfView = new Date(year, month, 1, 0, 0, 0, 0)
69
+ const now = new Date()
70
+ const start = startOfView.getTime() > now.getTime() ? startOfView : now
71
+ const endOfView = new Date(year, month + 1, 0, 23, 59, 59, 999)
72
+ return { startTime: start.toISOString(), endTime: endOfView.toISOString() }
73
+ }
74
+
75
+ /** Short weekday headers ordered from `weekStartsOn` (0 = Sunday). */
76
+ export function weekdayLabels(weekStartsOn = 0): string[] {
77
+ const fmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' })
78
+ const out: string[] = []
79
+ for (let i = 0; i < 7; i++) {
80
+ // 2023-01-01 is a Sunday; offset from there.
81
+ const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7))
82
+ out.push(fmt.format(d))
83
+ }
84
+ return out
85
+ }
86
+
87
+ export function formatMonthLabel(d: Date): string {
88
+ return new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }).format(d)
89
+ }
90
+
91
+ export function formatDayLong(d: Date): string {
92
+ return new Intl.DateTimeFormat(undefined, {
93
+ weekday: 'long',
94
+ month: 'long',
95
+ day: 'numeric',
96
+ }).format(d)
97
+ }
98
+
99
+ /** Slot time in the visitor's zone, e.g. "9:00 AM". */
100
+ export function formatSlotTime(iso: string, timeZone: string): string {
101
+ return new Intl.DateTimeFormat(undefined, {
102
+ hour: 'numeric',
103
+ minute: '2-digit',
104
+ timeZone,
105
+ }).format(new Date(iso))
106
+ }
107
+
108
+ /** Full human "when" for a confirmation, e.g. "Monday, July 27, 2026 at 9:00 AM PDT". */
109
+ export function formatBookingWhen(iso: string, timeZone: string): string {
110
+ const at = new Date(iso)
111
+ const date = new Intl.DateTimeFormat(undefined, {
112
+ weekday: 'long',
113
+ month: 'long',
114
+ day: 'numeric',
115
+ year: 'numeric',
116
+ timeZone,
117
+ }).format(at)
118
+ const time = new Intl.DateTimeFormat(undefined, {
119
+ hour: 'numeric',
120
+ minute: '2-digit',
121
+ timeZoneName: 'short',
122
+ timeZone,
123
+ }).format(at)
124
+ return `${date} at ${time}`
125
+ }
126
+
127
+ /** A parsed local Date from a "YYYY-MM-DD" key (noon avoids DST edge slips). */
128
+ export function dateFromKey(key: string): Date {
129
+ const [y, m, d] = key.split('-').map(Number)
130
+ return new Date(y, (m ?? 1) - 1, d ?? 1, 12, 0, 0, 0)
131
+ }