feat(scheduling): ignoreAllDayEvents option

This commit is contained in:
Ronni Baslund
2026-06-07 08:53:31 +02:00
parent 2cb13a1a14
commit f41475ac3b
7 changed files with 184 additions and 5 deletions
+8 -1
View File
@@ -25,6 +25,7 @@ interface EventType {
maximumDaysInFuture: number maximumDaysInFuture: number
availabilityScheduleId: string availabilityScheduleId: string
locationType: string locationType: string
ignoreAllDayEvents: boolean
isActive: boolean isActive: boolean
} }
interface MinuteInterval { startMinute: number; endMinute: number } interface MinuteInterval { startMinute: number; endMinute: number }
@@ -258,6 +259,7 @@ const etForm = reactive({
maximumDaysInFuture: 60, maximumDaysInFuture: 60,
availabilityScheduleId: '', availabilityScheduleId: '',
locationType: 'jitsi', locationType: 'jitsi',
ignoreAllDayEvents: true,
}) })
const etEditingId = ref<string | null>(null) const etEditingId = ref<string | null>(null)
const etSlugTouched = ref(false) const etSlugTouched = ref(false)
@@ -271,7 +273,7 @@ function openEt(et?: EventType) {
slotIntervalMinutes: et.slotIntervalMinutes, bufferBeforeMinutes: et.bufferBeforeMinutes, slotIntervalMinutes: et.slotIntervalMinutes, bufferBeforeMinutes: et.bufferBeforeMinutes,
bufferAfterMinutes: et.bufferAfterMinutes, minimumNoticeMinutes: et.minimumNoticeMinutes, bufferAfterMinutes: et.bufferAfterMinutes, minimumNoticeMinutes: et.minimumNoticeMinutes,
maximumDaysInFuture: et.maximumDaysInFuture, availabilityScheduleId: et.availabilityScheduleId, maximumDaysInFuture: et.maximumDaysInFuture, availabilityScheduleId: et.availabilityScheduleId,
locationType: et.locationType, locationType: et.locationType, ignoreAllDayEvents: et.ignoreAllDayEvents ?? true,
}) })
etSlugTouched.value = true // don't auto-rewrite an existing slug etSlugTouched.value = true // don't auto-rewrite an existing slug
} else { } else {
@@ -280,6 +282,7 @@ function openEt(et?: EventType) {
title: '', slug: '', durationMinutes: 30, slotIntervalMinutes: 15, title: '', slug: '', durationMinutes: 30, slotIntervalMinutes: 15,
bufferBeforeMinutes: 0, bufferAfterMinutes: 0, minimumNoticeMinutes: 60, bufferBeforeMinutes: 0, bufferAfterMinutes: 0, minimumNoticeMinutes: 60,
maximumDaysInFuture: 60, availabilityScheduleId: availability.value[0]?._id ?? '', locationType: 'jitsi', maximumDaysInFuture: 60, availabilityScheduleId: availability.value[0]?._id ?? '', locationType: 'jitsi',
ignoreAllDayEvents: true,
}) })
etSlugTouched.value = false etSlugTouched.value = false
} }
@@ -608,6 +611,10 @@ const detailTabs = computed(() => [
<option value="in_person">In person</option><option value="custom">Custom</option> <option value="in_person">In person</option><option value="custom">Custom</option>
</select> </select>
</label> </label>
<label class="daytoggle">
<input type="checkbox" v-model="etForm.ignoreAllDayEvents" />
Ignore all-day events when checking availability
</label>
</div> </div>
<template #footer> <template #footer>
<UiButton variant="ghost" @click="etOpen = false">Cancel</UiButton> <UiButton variant="ghost" @click="etOpen = false">Cancel</UiButton>
@@ -1,4 +1,5 @@
import { import {
IsBoolean,
IsEnum, IsEnum,
IsHexColor, IsHexColor,
IsInt, IsInt,
@@ -80,6 +81,10 @@ export class CreateEventTypeDto {
@IsOptional() @IsOptional()
@IsHexColor() @IsHexColor()
color?: string color?: string
@IsOptional()
@IsBoolean()
ignoreAllDayEvents?: boolean
} }
// All fields optional for PATCH. (Avoids a mapped-types dependency.) // All fields optional for PATCH. (Avoids a mapped-types dependency.)
@@ -96,4 +101,5 @@ export class UpdateEventTypeDto {
@IsOptional() @IsEnum(LOCATIONS) locationType?: LocationType @IsOptional() @IsEnum(LOCATIONS) locationType?: LocationType
@IsOptional() @IsString() @MaxLength(500) locationDetails?: string @IsOptional() @IsString() @MaxLength(500) locationDetails?: string
@IsOptional() @IsHexColor() color?: string @IsOptional() @IsHexColor() color?: string
@IsOptional() @IsBoolean() ignoreAllDayEvents?: boolean
} }
@@ -17,6 +17,7 @@ export interface EventTypeInput {
locationType?: LocationType locationType?: LocationType
locationDetails?: string locationDetails?: string
color?: string color?: string
ignoreAllDayEvents?: boolean
} }
@Injectable() @Injectable()
@@ -37,7 +37,9 @@ export class SlotService {
let calendarBusy let calendarBusy
try { try {
calendarBusy = await this.gateway.getBusyIntervals(access, fromUtc, toUtc) calendarBusy = await this.gateway.getBusyIntervals(access, fromUtc, toUtc, {
ignoreAllDayEvents: eventType.ignoreAllDayEvents,
})
} catch { } catch {
throw new ServiceUnavailableException('Calendar is temporarily unavailable — please retry.') throw new ServiceUnavailableException('Calendar is temporarily unavailable — please retry.')
} }
@@ -37,10 +37,18 @@ export interface BookingEvent {
attendeeEmail: string attendeeEmail: string
} }
// Per-call tuning for free/busy resolution.
export interface BusyOptions {
// When true, all-day events are excluded from the returned busy intervals
// (best-effort — see JmapCalendarGateway.getBusyIntervals). Default false:
// every busy marker counts.
ignoreAllDayEvents?: boolean
}
export interface CalendarGateway { export interface CalendarGateway {
listCalendars(access: HostCalendarAccess): Promise<CalendarRef[]> listCalendars(access: HostCalendarAccess): Promise<CalendarRef[]>
// Busy intervals within [fromUtc, toUtc), recurrence already expanded, in UTC. // Busy intervals within [fromUtc, toUtc), recurrence already expanded, in UTC.
getBusyIntervals(access: HostCalendarAccess, fromUtc: Date, toUtc: Date): Promise<Interval[]> getBusyIntervals(access: HostCalendarAccess, fromUtc: Date, toUtc: Date, options?: BusyOptions): Promise<Interval[]>
// Returns the server-assigned event id (distinct from the UID). // Returns the server-assigned event id (distinct from the UID).
createEvent(access: HostCalendarAccess, event: BookingEvent): Promise<{ uid: string; id: string }> createEvent(access: HostCalendarAccess, event: BookingEvent): Promise<{ uid: string; id: string }>
deleteEvent(access: HostCalendarAccess, eventId: string): Promise<void> deleteEvent(access: HostCalendarAccess, eventId: string): Promise<void>
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common' import { Injectable, Logger } from '@nestjs/common'
import type { import type {
BookingEvent, BookingEvent,
BusyOptions,
CalendarGateway, CalendarGateway,
CalendarRef, CalendarRef,
HostCalendarAccess, HostCalendarAccess,
@@ -88,7 +89,12 @@ export class JmapCalendarGateway implements CalendarGateway {
return list.map((c) => ({ id: c.id, name: c.name })) return list.map((c) => ({ id: c.id, name: c.name }))
} }
async getBusyIntervals(access: HostCalendarAccess, fromUtc: Date, toUtc: Date): Promise<Interval[]> { async getBusyIntervals(
access: HostCalendarAccess,
fromUtc: Date,
toUtc: Date,
options?: BusyOptions,
): Promise<Interval[]> {
const session = await this.session(access) const session = await this.session(access)
const accountId = this.calAccountId(session) const accountId = this.calAccountId(session)
const resp = await this.call( const resp = await this.call(
@@ -109,9 +115,85 @@ export class JmapCalendarGateway implements CalendarGateway {
} }
const list = (out[1]?.list ?? []) as Array<{ utcStart: string; utcEnd: string; busyStatus?: string }> const list = (out[1]?.list ?? []) as Array<{ utcStart: string; utcEnd: string; busyStatus?: string }>
// Drop free/tentative-cancelled markers; count confirmed + tentative as busy. // Drop free/tentative-cancelled markers; count confirmed + tentative as busy.
return list const busy = list
.filter((b) => b.busyStatus !== 'free') .filter((b) => b.busyStatus !== 'free')
.map((b) => ({ startUtc: new Date(b.utcStart), endUtc: new Date(b.utcEnd) })) .map((b) => ({ startUtc: new Date(b.utcStart), endUtc: new Date(b.utcEnd) }))
if (!options?.ignoreAllDayEvents) return busy
// Best-effort all-day exclusion. Principal/getAvailability collapses every
// event to a plain UTC interval with no all-day flag (Phase 0 finding —
// reference_stalwart_calendar_jmap), so we cannot tell an all-day event from
// a same-length timed event by its busy interval alone. To honour the flag we
// separately query CalendarEvent over the window and read each event's
// showWithoutTime / date-only `start` to recognise all-day events, then drop
// any busy interval that an all-day event covers.
//
// This is intentionally fail-soft: if the (occasionally flaky) CalendarEvent
// query errors, we keep ALL busy intervals (fail toward over-blocking, never
// toward a double-book) rather than propagating and breaking slot computation.
try {
const allDay = await this.allDayIntervals(access, accountId, fromUtc, toUtc)
if (allDay.length === 0) return busy
return busy.filter((b) => !allDay.some((ad) => coversInterval(ad, b)))
} catch (err) {
this.logger.warn(
`ignoreAllDayEvents: all-day detection failed for ${access.email}, keeping all busy intervals: ${(err as Error).message}`,
)
return busy
}
}
// Returns the UTC spans of all-day events in [fromUtc, toUtc). An all-day event
// in JSCalendar is signalled by showWithoutTime:true and/or a date-only `start`
// ("YYYY-MM-DD") with no `timeZone`; multi-day all-day events carry a P..D
// duration. We expand each to [start-of-day, start-of-day + duration) in the
// event's local zone (falling back to UTC), which is enough to overlap-match
// the busy intervals getAvailability produced for the same events.
private async allDayIntervals(
access: HostCalendarAccess,
accountId: string,
fromUtc: Date,
toUtc: Date,
): Promise<Interval[]> {
const resp = await this.call(
access,
[CORE, CALENDARS],
[
[
'CalendarEvent/query',
{
accountId,
filter: { before: toUtc.toISOString(), after: fromUtc.toISOString() },
},
'q',
],
[
'CalendarEvent/get',
{
accountId,
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
properties: ['start', 'duration', 'timeZone', 'showWithoutTime', 'recurrenceOverrides'],
},
'g',
],
],
)
const got = resp.find((r) => r[0] === 'CalendarEvent/get')
if (!got) return []
const events = (got[1]?.list ?? []) as Array<{
start?: string
duration?: string
timeZone?: string
showWithoutTime?: boolean
}>
const out: Interval[] = []
for (const ev of events) {
if (!isAllDay(ev)) continue
const span = allDaySpan(ev)
if (span) out.push(span)
}
return out
} }
async createEvent(access: HostCalendarAccess, event: BookingEvent): Promise<{ uid: string; id: string }> { async createEvent(access: HostCalendarAccess, event: BookingEvent): Promise<{ uid: string; id: string }> {
@@ -197,6 +279,73 @@ function toJsCalLocal(utc: Date, tz: string): string {
return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}` return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}`
} }
// True if a JSCalendar event represents an all-day event: either the explicit
// showWithoutTime flag, or a date-only `start` ("YYYY-MM-DD" with no time part).
function isAllDay(ev: { start?: string; showWithoutTime?: boolean }): boolean {
if (ev.showWithoutTime === true) return true
return typeof ev.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(ev.start)
}
// Convert an all-day event to a concrete UTC interval. `start` is a local date
// (date-only) or local date-time; `duration` defaults to one day. We anchor the
// span at local midnight in the event's zone (or UTC if absent) so it overlaps
// the busy interval getAvailability emitted for the same event.
function allDaySpan(ev: { start?: string; duration?: string; timeZone?: string }): Interval | null {
if (!ev.start) return null
const datePart = ev.start.slice(0, 10)
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null
const startUtc = localMidnightToUtc(datePart, ev.timeZone)
if (!startUtc) return null
const days = Math.max(1, durationToDays(ev.duration))
const endUtc = new Date(startUtc.getTime() + days * 86_400_000)
return { startUtc, endUtc }
}
// Interpret "YYYY-MM-DD" at 00:00 in `tz` (default UTC) as a UTC instant. We
// derive the zone offset for that date via Intl and subtract it from the naive
// UTC midnight.
function localMidnightToUtc(date: string, tz?: string): Date | null {
const naive = new Date(`${date}T00:00:00Z`)
if (Number.isNaN(naive.getTime())) return null
if (!tz) return naive
try {
const offsetMs = zoneOffsetMs(naive, tz)
return new Date(naive.getTime() - offsetMs)
} catch {
return naive
}
}
// Offset (ms) of `tz` from UTC at instant `at` (positive east of UTC).
function zoneOffsetMs(at: Date, tz: string): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
}).formatToParts(at)
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? '0')
const hour = get('hour') === 24 ? 0 : get('hour')
const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), hour, get('minute'), get('second'))
return asUtc - at.getTime()
}
// Whole days in an ISO-8601 duration like "P2D" / "P1W". All-day events use
// day/week granularity; sub-day parts (PT..) round up to at least one day.
function durationToDays(duration?: string): number {
if (!duration) return 1
const weeks = Number(/(\d+)W/.exec(duration)?.[1] ?? 0)
const days = Number(/(\d+)D/.exec(duration)?.[1] ?? 0)
const total = weeks * 7 + days
return total > 0 ? total : 1
}
// True if all-day interval `ad` fully covers busy interval `b` (so `b` is the
// busy span of an all-day event and should be dropped when ignoring all-day).
function coversInterval(ad: Interval, b: Interval): boolean {
return ad.startUtc.getTime() <= b.startUtc.getTime() && ad.endUtc.getTime() >= b.endUtc.getTime()
}
// Milliseconds → ISO-8601 duration (whole minutes are enough for bookings). // Milliseconds → ISO-8601 duration (whole minutes are enough for bookings).
function isoDuration(ms: number): string { function isoDuration(ms: number): string {
const totalMinutes = Math.round(ms / 60000) const totalMinutes = Math.round(ms / 60000)
@@ -59,6 +59,12 @@ export class EventType {
@Prop({ trim: true }) @Prop({ trim: true })
color?: string color?: string
// When true (default), all-day calendar events on the host's calendar are NOT
// treated as busy — so e.g. an all-day "On leave" marker or a holiday does not
// block every slot of the day. Set false to honour all-day events as busy.
@Prop({ default: true })
ignoreAllDayEvents!: boolean
@Prop({ default: true, index: true }) @Prop({ default: true, index: true })
isActive!: boolean isActive!: boolean
} }