5ed3d2bc5f
First-party booking system on top of Stalwart calendars (no third-party scheduling dependency). Hosts expose public booking pages; visitors pick a slot computed from the host's live Stalwart free/busy, and confirming writes the event to the host's calendar and sends a dezky-branded confirmation with an .ics. platform-api (services/platform-api/src/scheduling): - Schemas: Host, StalwartCredential (AES-256-GCM at rest), AvailabilitySchedule, EventType, Booking, SlotLock (unique (hostId,startUtc) + TTL). - StalwartCalendarModule: JMAP gateway (free/busy via Principal/getAvailability, event create/delete, scheduleAgent=client) + on-behalf app-password provisioning. CredentialCipher for at-rest encryption. - DST-correct slot engine (Luxon) with unit tests; two-layer double-booking guard (atomic SlotLock + live free/busy re-check). - Booking confirm/cancel/reschedule, branded email + .ics via JMAP submission, self-service manage tokens. /api/v1 public + tenant-gated admin routes, per-IP rate limiting. apps/booking: standalone public, whitelabel booking app (booking.dezky.eu) — path-based tenant resolution, per-tenant brand colour, booking + manage flows. apps/portal: admin scheduling page (hosts, event types, availability, bookings with edit/delete + admin cancel/reschedule) and proxy routes. infra: booking dev service in docker-compose; scheduling env vars.
68 lines
2.7 KiB
TypeScript
68 lines
2.7 KiB
TypeScript
import { DateTime } from 'luxon'
|
||
|
||
// Visitor timezone detection (browser); falls back to UTC on the server / when
|
||
// unavailable. All slot times are transmitted as UTC and rendered in this zone.
|
||
export function detectTimezone(): string {
|
||
try {
|
||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||
} catch {
|
||
return 'UTC'
|
||
}
|
||
}
|
||
|
||
// UTC [from, to) covering one local calendar day in `tz`.
|
||
export function dayWindowUtc(isoDate: string, tz: string): { from: string; to: string } {
|
||
const start = DateTime.fromISO(isoDate, { zone: tz }).startOf('day')
|
||
return { from: start.toUTC().toISO()!, to: start.plus({ days: 1 }).toUTC().toISO()! }
|
||
}
|
||
|
||
export interface DayOption {
|
||
iso: string // yyyy-MM-dd in tz
|
||
weekday: string
|
||
day: string
|
||
month: string
|
||
}
|
||
|
||
// The next `count` calendar days starting today, in the visitor's tz.
|
||
export function nextDays(tz: string, count: number): DayOption[] {
|
||
const today = DateTime.now().setZone(tz).startOf('day')
|
||
return Array.from({ length: count }, (_, i) => {
|
||
const d = today.plus({ days: i })
|
||
return { iso: d.toFormat('yyyy-MM-dd'), weekday: d.toFormat('ccc'), day: d.toFormat('d'), month: d.toFormat('LLL') }
|
||
})
|
||
}
|
||
|
||
export function fmtTime(utc: string, tz: string): string {
|
||
return DateTime.fromISO(utc).setZone(tz).toFormat('HH:mm')
|
||
}
|
||
|
||
export function fmtDateLong(utc: string, tz: string): string {
|
||
return DateTime.fromISO(utc).setZone(tz).toFormat('cccc d LLLL yyyy')
|
||
}
|
||
|
||
export function fmtRange(startUtc: string, endUtc: string, tz: string): string {
|
||
const s = DateTime.fromISO(startUtc).setZone(tz)
|
||
const e = DateTime.fromISO(endUtc).setZone(tz)
|
||
return `${s.toFormat('cccc d LLLL')} · ${s.toFormat('HH:mm')}–${e.toFormat('HH:mm')} (${tz})`
|
||
}
|
||
|
||
// Google Calendar "add to calendar" template link (UTC basic format).
|
||
export function googleCalUrl(p: { title: string; startUtc: string; endUtc: string; details?: string; location?: string }): string {
|
||
const fmt = (iso: string) => DateTime.fromISO(iso).toUTC().toFormat("yyyyLLdd'T'HHmmss'Z'")
|
||
const params = new URLSearchParams({
|
||
action: 'TEMPLATE',
|
||
text: p.title,
|
||
dates: `${fmt(p.startUtc)}/${fmt(p.endUtc)}`,
|
||
})
|
||
if (p.details) params.set('details', p.details)
|
||
if (p.location) params.set('location', p.location)
|
||
return `https://calendar.google.com/calendar/render?${params.toString()}`
|
||
}
|
||
|
||
// Common IANA zones for the manual switcher (visitor tz is added if missing).
|
||
export const COMMON_TIMEZONES = [
|
||
'Europe/Copenhagen', 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Madrid',
|
||
'Europe/Stockholm', 'Europe/Helsinki', 'America/New_York', 'America/Chicago', 'America/Los_Angeles',
|
||
'Asia/Dubai', 'Asia/Kolkata', 'Asia/Singapore', 'Asia/Tokyo', 'Australia/Sydney', 'UTC',
|
||
]
|