Files
dezky/apps/booking/utils/time.ts
T
Ronni Baslund 5ed3d2bc5f feat(scheduling): dezky Scheduling — Calendly-style booking on Stalwart calendars
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.
2026-06-07 00:17:36 +02:00

68 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
]