feat(scheduling): ignoreAllDayEvents option
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user