import type { CSSProperties, DragEvent as ReactDragEvent, } from "react"; import type { CalendarEvent, CalendarEventDeltaResponse, } from "../../api/calendar"; export type CalendarMode = | "continuous" | "month" | "week" | "workweek" | "day"; export type Range = { start: Date; end: Date }; export type AgendaGroup = { key: string; date: Date; events: CalendarEvent[]; }; export type ContinuousViewport = { scrollTop: number; height: number }; export type CalendarDayDropTarget = { kind: "day"; key: string }; export type CalendarTimeDropTarget = { kind: "time"; key: string; minuteOfDay: number; }; export type CalendarDropTarget = | CalendarDayDropTarget | CalendarTimeDropTarget | null; export type CalendarDragAction = | { kind: "move"; event: CalendarEvent } | { kind: "resize-start"; event: CalendarEvent } | { kind: "resize-end"; event: CalendarEvent }; export type CalendarResizeEdge = "start" | "end"; export type CalendarViewPreferences = { dimWeekends: boolean; dimOffHours: boolean; workdayStartHour: number; workdayEndHour: number; continuousVirtualization: boolean; continuousOverscanWeeks: number; alternateContinuousMonths: boolean; }; export type TimedEventLayoutItem = TimedEventSegment & { column: number; columns: number; }; export type TimedOverflowLayoutItem = { key: string; top: number; column: number; columns: number; events: CalendarEvent[]; }; type TimedEventSegment = { event: CalendarEvent; start: Date; end: Date; top: number; height: number; }; export const HOUR_ROW_HEIGHT = 64; export const INITIAL_SCROLL_HOUR = 7; export const CONTINUOUS_WEEK_ROW_HEIGHT = 92; export const DEFAULT_CALENDAR_COLOR = "#5aa99b"; const MAX_TIMED_EVENT_COLUMNS = 3; const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode"; const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY = "govoplan.calendar.viewPreferences"; export const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = { dimWeekends: true, dimOffHours: true, workdayStartHour: 6, workdayEndHour: 20, continuousVirtualization: true, continuousOverscanWeeks: 6, alternateContinuousMonths: true, }; export function calendarEventInstanceId(event: CalendarEvent): string { return event.instance_id || event.id; } export function rangeForMode( mode: CalendarMode, focusDate: Date, continuousWeeks: { before: number; after: number }, ): Range { if (mode === "month") { const start = startOfWeek(startOfMonth(focusDate)); return { start, end: addDays(start, 42) }; } if (mode === "day") { return { start: startOfDay(focusDate), end: addDays(startOfDay(focusDate), 1), }; } if (mode === "continuous") { const start = addDays( startOfWeek(focusDate), -continuousWeeks.before * 7, ); const end = addDays( startOfWeek(focusDate), continuousWeeks.after * 7, ); return { start, end }; } const start = startOfWeek(focusDate); return { start, end: addDays(start, mode === "workweek" ? 5 : 7) }; } export function daysForMode( mode: CalendarMode, focusDate: Date, continuousWeeks: { before: number; after: number }, ): Date[] { const range = rangeForMode(mode, focusDate, continuousWeeks); return daysBetween(range.start, range.end); } export function continuousEventWindow( days: Date[], viewport: ContinuousViewport, options: {overscanWeeks?: number;minimumWeeks?: number;stepWeeks?: number;} = {}, ): Range { if (days.length === 0) { const now = startOfDay(new Date()); return { start: now, end: addDays(now, 7) }; } const totalWeeks = Math.max(1, Math.ceil(days.length / 7)); const overscanWeeks = Math.max(0, Math.floor(options.overscanWeeks ?? 2)); const minimumWeeks = Math.max(1, Math.floor(options.minimumWeeks ?? 10)); const stepWeeks = Math.max(1, Math.floor(options.stepWeeks ?? 4)); const firstVisibleWeek = Math.max( 0, Math.min(totalWeeks - 1, Math.floor(viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT)), ); const visibleWeeks = Math.max( 1, Math.ceil(Math.max(viewport.height, CONTINUOUS_WEEK_ROW_HEIGHT) / CONTINUOUS_WEEK_ROW_HEIGHT), ); const desiredWeeks = Math.min( totalWeeks, Math.max(minimumWeeks, visibleWeeks + overscanWeeks * 2), ); let startWeek = Math.max( 0, Math.floor(Math.max(0, firstVisibleWeek - overscanWeeks) / stepWeeks) * stepWeeks, ); if (startWeek + desiredWeeks > totalWeeks) { startWeek = Math.max(0, totalWeeks - desiredWeeks); } const endWeek = Math.min(totalWeeks, startWeek + desiredWeeks); return { start: days[startWeek * 7], end: addDays(days[Math.min(days.length - 1, endWeek * 7 - 1)], 1), }; } export function groupEventsByDay( events: CalendarEvent[], ): Map { const grouped = new Map(); for (const event of events) { const start = new Date(event.start_at); const end = event.end_at ? new Date(event.end_at) : start; let cursor = startOfDay(start); let last = event.all_day && event.end_at ? addDays(startOfDay(end), -1) : startOfDay(end); if (last < cursor) last = cursor; while (cursor <= last) { const key = dayKey(cursor); grouped.set(key, [...(grouped.get(key) ?? []), event]); cursor = addDays(cursor, 1); } } for (const [key, items] of grouped.entries()) { grouped.set( key, items.sort( (left, right) => left.start_at.localeCompare(right.start_at) || left.summary.localeCompare(right.summary), ), ); } return grouped; } export function agendaGroupsForDays( days: Date[], eventsByDay: Map, ): AgendaGroup[] { const groups: AgendaGroup[] = []; for (const day of days) { const key = dayKey(day); const events = (eventsByDay.get(key) ?? []) .slice() .sort(compareAgendaEvents); if (events.length > 0) groups.push({ key, date: day, events }); } return groups; } export function eventTimeLabel(event: CalendarEvent): string { if (event.all_day) return "i18n:govoplan-calendar.all_day.11457433"; const start = new Date(event.start_at); const end = event.end_at ? new Date(event.end_at) : null; return end ? `${timeLabel(start)}-${timeLabel(end)}` : timeLabel(start); } export function dateTimeLabel(value: Date): string { return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short", }).format(value); } export function headingForMode( mode: CalendarMode, focusDate: Date, range: Range, ): string { if (mode === "day") return dayMonthYearLabel(focusDate); if (mode === "month") return monthYearLabel(focusDate); return `${dayMonthYearLabel(range.start)} - ${dayMonthYearLabel( addDays(range.end, -1), )}`; } export function chunk(items: T[], size: number): T[][] { const rows: T[][] = []; for (let index = 0; index < items.length; index += size) { rows.push(items.slice(index, index + size)); } return rows; } export function startOfDay(value: Date): Date { return new Date(value.getFullYear(), value.getMonth(), value.getDate()); } export function startOfWeek(value: Date): Date { const day = value.getDay() || 7; return addDays(startOfDay(value), 1 - day); } export function startOfMonth(value: Date): Date { return new Date(value.getFullYear(), value.getMonth(), 1); } export function addDays(value: Date, days: number): Date { const next = new Date(value); next.setDate(next.getDate() + days); return next; } export function addMonths(value: Date, months: number): Date { const next = new Date(value); next.setMonth(next.getMonth() + months); return next; } export function addHours(value: Date, hours: number): Date { const next = new Date(value); next.setHours(next.getHours() + hours); return next; } export function addMinutes(value: Date, minutes: number): Date { const next = new Date(value); next.setMinutes(next.getMinutes() + minutes); return next; } export function daysBetween(start: Date, end: Date): Date[] { const days: Date[] = []; for ( let cursor = startOfDay(start); cursor < end; cursor = addDays(cursor, 1) ) { days.push(cursor); } return days; } export function dayKey(value: Date): string { return toInputDate(value); } export function sameDay(left: Date, right: Date): boolean { return ( left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate() ); } export function sameMonth(left: Date, right: Date): boolean { return ( left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() ); } export function toInputDate(value: Date): string { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart( 2, "0", )}-${String(value.getDate()).padStart(2, "0")}`; } export function toInputTime(value: Date): string { return `${String(value.getHours()).padStart(2, "0")}:${String( value.getMinutes(), ).padStart(2, "0")}`; } export function localDateTime(date: string, time: string): Date { return new Date(`${date}T${time || "00:00"}:00`); } export function loadCalendarViewPreferences(): CalendarViewPreferences { if (typeof window === "undefined") return DEFAULT_CALENDAR_VIEW_PREFERENCES; try { const raw = window.localStorage.getItem( CALENDAR_VIEW_PREFERENCES_STORAGE_KEY, ); if (!raw) return DEFAULT_CALENDAR_VIEW_PREFERENCES; const parsed = JSON.parse(raw) as Partial; let workdayStartHour = clampNumber( parsed.workdayStartHour, 0, 23, DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour, ); let workdayEndHour = clampNumber( parsed.workdayEndHour, 1, 24, DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour, ); if (workdayEndHour <= workdayStartHour) { workdayStartHour = DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour; workdayEndHour = DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour; } return { dimWeekends: typeof parsed.dimWeekends === "boolean" ? parsed.dimWeekends : DEFAULT_CALENDAR_VIEW_PREFERENCES.dimWeekends, dimOffHours: typeof parsed.dimOffHours === "boolean" ? parsed.dimOffHours : DEFAULT_CALENDAR_VIEW_PREFERENCES.dimOffHours, workdayStartHour, workdayEndHour, continuousVirtualization: typeof parsed.continuousVirtualization === "boolean" ? parsed.continuousVirtualization : DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousVirtualization, continuousOverscanWeeks: clampNumber( parsed.continuousOverscanWeeks, 2, 20, DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousOverscanWeeks, ), alternateContinuousMonths: typeof parsed.alternateContinuousMonths === "boolean" ? parsed.alternateContinuousMonths : DEFAULT_CALENDAR_VIEW_PREFERENCES.alternateContinuousMonths, }; } catch { return DEFAULT_CALENDAR_VIEW_PREFERENCES; } } export function loadCalendarMode(): CalendarMode { if (typeof window === "undefined") return "continuous"; try { const storedMode = window.localStorage.getItem( CALENDAR_MODE_STORAGE_KEY, ); return isCalendarMode(storedMode) ? storedMode : "continuous"; } catch { return "continuous"; } } export function saveCalendarMode(mode: CalendarMode): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(CALENDAR_MODE_STORAGE_KEY, mode); } catch { // Storage can be unavailable in locked-down browsers. } } export function normalizeHexColor( value: string | null | undefined, ): string | null { if (!value) return null; const trimmed = value.trim(); if (/^#[0-9a-fA-F]{8}$/.test(trimmed)) { return trimmed.slice(0, 7).toLowerCase(); } return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed.toLowerCase() : null; } export function calendarEventColorStyle( color: string | null | undefined, ): CSSProperties { const normalizedColor = normalizeHexColor(color) || DEFAULT_CALENDAR_COLOR; return { "--calendar-event-color": normalizedColor, "--calendar-event-bg": mixHexWithWhite(normalizedColor, 0.88), "--calendar-event-border": mixHexWithWhite(normalizedColor, 0.58), } as CSSProperties; } export function continuousVirtualWindow( weekCount: number, viewport: ContinuousViewport, overscanWeeks: number, ): { start: number; end: number; topSpacerHeight: number; bottomSpacerHeight: number; } { if (weekCount === 0) { return { start: 0, end: 0, topSpacerHeight: 0, bottomSpacerHeight: 0, }; } const visibleStart = Math.floor( viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT, ); const visibleCount = Math.max( 1, Math.ceil( (viewport.height || CONTINUOUS_WEEK_ROW_HEIGHT * 8) / CONTINUOUS_WEEK_ROW_HEIGHT, ), ); const start = Math.max(0, visibleStart - overscanWeeks); const end = Math.min( weekCount, visibleStart + visibleCount + overscanWeeks, ); return { start, end, topSpacerHeight: start * CONTINUOUS_WEEK_ROW_HEIGHT, bottomSpacerHeight: Math.max( 0, (weekCount - end) * CONTINUOUS_WEEK_ROW_HEIGHT, ), }; } export function timeGridStyle( columns: string, preferences: CalendarViewPreferences, ): CSSProperties { return { gridTemplateColumns: columns, "--calendar-work-start-y": `${ preferences.workdayStartHour * HOUR_ROW_HEIGHT }px`, "--calendar-work-end-y": `${ preferences.workdayEndHour * HOUR_ROW_HEIGHT }px`, "--calendar-off-hours-opacity": preferences.dimOffHours ? "1" : "0", } as CSSProperties; } export function moveEventToDay( event: CalendarEvent, day: Date, ): { startAt: Date; endAt: Date | null; allDay: boolean } { const start = new Date(event.start_at); const end = event.end_at ? new Date(event.end_at) : null; if (event.all_day) { const durationDays = Math.max( 1, Math.round( daysBetweenCount( startOfDay(start), end ? startOfDay(end) : addDays(startOfDay(start), 1), ), ), ); const startAt = startOfDay(day); return { startAt, endAt: addDays(startAt, durationDays), allDay: true, }; } const startAt = new Date( day.getFullYear(), day.getMonth(), day.getDate(), start.getHours(), start.getMinutes(), ); const durationMs = eventDurationMs(event, 60 * 60 * 1000); return { startAt, endAt: new Date(startAt.getTime() + durationMs), allDay: false, }; } export function moveEventToTime( event: CalendarEvent, day: Date, minuteOfDay: number, ): { startAt: Date; endAt: Date | null; allDay: boolean } { const snappedMinute = Math.min( 23 * 60 + 45, Math.max(0, snapMinute(minuteOfDay)), ); const startAt = dateAtMinuteOfDay(day, snappedMinute); const durationMs = event.all_day ? 60 * 60 * 1000 : eventDurationMs(event, 60 * 60 * 1000); return { startAt, endAt: new Date(startAt.getTime() + durationMs), allDay: false, }; } export function resizeEventToTime( event: CalendarEvent, edge: CalendarResizeEdge, day: Date, minuteOfDay: number, ): { startAt: Date; endAt: Date | null; allDay: boolean } { const target = dateAtMinuteOfDay( day, Math.min(23 * 60 + 45, Math.max(0, snapMinute(minuteOfDay))), ); const currentStart = new Date(event.start_at); const fallbackEnd = addMinutes(currentStart, 30); const currentEnd = event.end_at && new Date(event.end_at) > currentStart ? new Date(event.end_at) : fallbackEnd; const minimumDurationMs = 15 * 60_000; if (edge === "start") { const latestStart = new Date( currentEnd.getTime() - minimumDurationMs, ); return { startAt: target < latestStart ? target : latestStart, endAt: currentEnd, allDay: false, }; } const earliestEnd = new Date( currentStart.getTime() + minimumDurationMs, ); return { startAt: currentStart, endAt: target > earliestEnd ? target : earliestEnd, allDay: false, }; } export function dropMinuteOfDay( event: ReactDragEvent, ): number { const rect = event.currentTarget.getBoundingClientRect(); const offset = Math.min( Math.max(event.clientY - rect.top, 0), HOUR_ROW_HEIGHT * 24, ); return Math.floor((offset / HOUR_ROW_HEIGHT) * 60); } export function dropSlotMinuteOfDay( event: ReactDragEvent, ): number { return Math.min( 23 * 60 + 45, Math.max(0, snapMinute(dropMinuteOfDay(event))), ); } export function minuteOfDayLabel(value: number): string { const minute = Math.min(23 * 60 + 45, Math.max(0, value)); return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String( minute % 60, ).padStart(2, "0")}`; } export function isWeekend(value: Date): boolean { const day = value.getDay(); return day === 0 || day === 6; } export function daysBetweenCount(start: Date, end: Date): number { return Math.round( (startOfDay(end).getTime() - startOfDay(start).getTime()) / 86_400_000, ); } export function addMinutesToTime( time: string, minutes: number, ): string { const [hourPart, minutePart] = time.split(":"); const total = Math.min( 23 * 60 + 59, Math.max( 0, Number(hourPart || 0) * 60 + Number(minutePart || 0) + minutes, ), ); return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String( total % 60, ).padStart(2, "0")}`; } export function layoutTimedEventsForDay( events: CalendarEvent[], day: Date, ): { items: TimedEventLayoutItem[]; overflows: TimedOverflowLayoutItem[]; } { const segments = timedSegmentsForDay(events, day); const clusters = clusterTimedSegments(segments); const items: TimedEventLayoutItem[] = []; const overflows: TimedOverflowLayoutItem[] = []; for (const cluster of clusters) { const columnEnds: Date[] = []; const assigned = cluster.map((segment) => { let column = columnEnds.findIndex((end) => end <= segment.start); if (column === -1) column = columnEnds.length; columnEnds[column] = segment.end; return { ...segment, column }; }); const totalColumns = Math.max(1, columnEnds.length); const visibleColumns = totalColumns > MAX_TIMED_EVENT_COLUMNS ? MAX_TIMED_EVENT_COLUMNS : totalColumns; const overflowColumn = MAX_TIMED_EVENT_COLUMNS - 1; const hidden = totalColumns > MAX_TIMED_EVENT_COLUMNS ? assigned.filter((item) => item.column >= overflowColumn) : []; for (const item of assigned) { if (hidden.includes(item)) continue; items.push({ ...item, columns: visibleColumns }); } if (hidden.length > 0) { overflows.push({ key: `${dayKey(day)}-${calendarEventInstanceId(hidden[0].event)}-overflow`, top: Math.min(...hidden.map((item) => item.top)), column: overflowColumn, columns: MAX_TIMED_EVENT_COLUMNS, events: hidden.map((item) => item.event), }); } } return { items, overflows }; } export function timedEventStyle( item: TimedEventLayoutItem, color?: string | null, ): CSSProperties { return { top: `${item.top}px`, height: `${item.height}px`, left: `calc(${(item.column * 100) / item.columns}% + 4px)`, width: `calc(${100 / item.columns}% - 8px)`, ...calendarEventColorStyle(color), }; } export function timedOverflowStyle( item: TimedOverflowLayoutItem, ): CSSProperties { return { top: `${item.top}px`, left: `calc(${(item.column * 100) / item.columns}% + 4px)`, width: `calc(${100 / item.columns}% - 8px)`, }; } export function monthYearLabel(value: Date): string { return new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric", }).format(value); } export function dayMonthLabel(value: Date): string { return new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "short", }).format(value); } export function dayMonthYearLabel(value: Date): string { return new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "short", year: "numeric", }).format(value); } export function agendaDateLabel(value: Date): string { return new Intl.DateTimeFormat(undefined, { weekday: "short", day: "2-digit", month: "short", year: "numeric", }).format(value); } export function weekdayLong(value: Date): string { return new Intl.DateTimeFormat(undefined, { weekday: "short", }).format(value); } export function calendarDraftKey(value: unknown): string { return JSON.stringify(value); } export function mergeCalendarEventDelta( current: CalendarEvent[], response: CalendarEventDeltaResponse, ): CalendarEvent[] { if (response.full) { return response.events.slice().sort(compareCalendarEvents); } const rows = new Map(current.map((event) => [event.id, event])); for (const deleted of response.deleted) { if ( !deleted.resource_type || deleted.resource_type === "calendar_event" ) { rows.delete(deleted.id); } } for (const event of response.events) rows.set(event.id, event); return Array.from(rows.values()).sort(compareCalendarEvents); } export function errorText(error: unknown): string { if (error instanceof Error) return error.message; return "i18n:govoplan-calendar.calendar_request_failed.ae8a54f4"; } function isCalendarMode(value: unknown): value is CalendarMode { return ( value === "continuous" || value === "month" || value === "week" || value === "workweek" || value === "day" ); } function mixHexWithWhite(hex: string, whiteRatio: number): string { const normalized = normalizeHexColor(hex) || DEFAULT_CALENDAR_COLOR; const ratio = Math.min(1, Math.max(0, whiteRatio)); const red = Number.parseInt(normalized.slice(1, 3), 16); const green = Number.parseInt(normalized.slice(3, 5), 16); const blue = Number.parseInt(normalized.slice(5, 7), 16); return `rgb(${Math.round(red * (1 - ratio) + 255 * ratio)}, ${Math.round( green * (1 - ratio) + 255 * ratio, )}, ${Math.round(blue * (1 - ratio) + 255 * ratio)})`; } function eventDurationMs(event: CalendarEvent, fallback: number): number { if (!event.end_at) return fallback; const start = new Date(event.start_at); const end = new Date(event.end_at); return Math.max(30 * 60 * 1000, end.getTime() - start.getTime()); } function dateAtMinuteOfDay(day: Date, minuteOfDay: number): Date { return new Date( day.getFullYear(), day.getMonth(), day.getDate(), Math.floor(minuteOfDay / 60), minuteOfDay % 60, ); } function snapMinute(value: number): number { return Math.round(value / 15) * 15; } function clampNumber( value: unknown, min: number, max: number, fallback: number, ): number { if (typeof value !== "number" || Number.isNaN(value)) return fallback; return Math.min(max, Math.max(min, Math.round(value))); } function timedSegmentsForDay( events: CalendarEvent[], day: Date, ): TimedEventSegment[] { const dayStart = startOfDay(day); const dayEnd = addDays(dayStart, 1); return events .filter((event) => !event.all_day) .map((event): TimedEventSegment | null => { const eventStart = new Date(event.start_at); const rawEventEnd = event.end_at ? new Date(event.end_at) : addMinutes(eventStart, 30); const eventEnd = rawEventEnd > eventStart ? rawEventEnd : addMinutes(eventStart, 30); const start = eventStart < dayStart ? dayStart : eventStart; const end = eventEnd > dayEnd ? dayEnd : eventEnd; if (end <= start) return null; const startMinutes = minutesBetween(dayStart, start); const durationMinutes = minutesBetween(start, end); return { event, start, end, top: Math.max(0, (startMinutes / 60) * HOUR_ROW_HEIGHT), height: Math.max( 26, (durationMinutes / 60) * HOUR_ROW_HEIGHT, ), }; }) .filter( (segment): segment is TimedEventSegment => segment !== null, ) .sort( (left, right) => left.start.getTime() - right.start.getTime() || right.end.getTime() - left.end.getTime(), ); } function clusterTimedSegments( segments: TimedEventSegment[], ): TimedEventSegment[][] { const clusters: TimedEventSegment[][] = []; let current: TimedEventSegment[] = []; let currentEnd: Date | null = null; for (const segment of segments) { if (!current.length || (currentEnd && segment.start < currentEnd)) { current.push(segment); if (!currentEnd || segment.end.getTime() > currentEnd.getTime()) { currentEnd = segment.end; } continue; } clusters.push(current); current = [segment]; currentEnd = segment.end; } if (current.length) clusters.push(current); return clusters; } function minutesBetween(start: Date, end: Date): number { return Math.max(0, (end.getTime() - start.getTime()) / 60_000); } function compareAgendaEvents( left: CalendarEvent, right: CalendarEvent, ): number { if (left.all_day !== right.all_day) return left.all_day ? -1 : 1; return ( left.start_at.localeCompare(right.start_at) || left.summary.localeCompare(right.summary) ); } function timeLabel(value: Date): string { return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", }).format(value); } function compareCalendarEvents( left: CalendarEvent, right: CalendarEvent, ): number { return ( new Date(left.start_at).getTime() - new Date(right.start_at).getTime() || left.summary.localeCompare(right.summary) || left.id.localeCompare(right.id) ); }