import type { EntityObject } from '@ChronoManager/models/entity' import type { EventObject } from '@ChronoManager/models/event' import type { EventMutationObject } from '@ChronoManager/models/event-mutation' import type { CalendarInstance } from '../types/instance' import { parseCalendarDate, shiftLocalDays, startOfLocalDay } from './date' import { EpochSpan } from '@/types/date' const MINIMUM_DURATION_MS = 1 function timestamp(value: string | null | undefined): number | null { if (!value) return null const result = Date.parse(value) return Number.isFinite(result) ? result : null } function floatingDateTimestamp(value: string | null | undefined): number | null { return parseCalendarDate(value)?.getTime() ?? null } function eventTimestamp(value: string | null | undefined, timeless: boolean): number | null { return timeless ? floatingDateTimestamp(value) : timestamp(value) } function conclusionTimestamp(value: string | null | undefined): number | null { if (!value) return null const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) if (dateOnly) { return new Date( Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]), 23, 59, 59, 999, ).getTime() } return timestamp(value) } function overlapsRange(start: number, end: number, range?: EpochSpan): boolean { return !range || (start < range.end && end > range.start) } function createInstance( entity: EntityObject, sourceStartMs: number, effectiveStartMs: number, effectiveEndMs: number, recurring: boolean, timeless: boolean, mutation?: EventMutationObject, ): CalendarInstance { const event = entity.properties as EventObject const endMs = effectiveEndMs > effectiveStartMs ? effectiveEndMs : effectiveStartMs + MINIMUM_DURATION_MS const dayStartMs = startOfLocalDay(new Date(effectiveStartMs)).getTime() const lastOccupiedDayMs = startOfLocalDay(new Date(endMs - 1)).getTime() const instanceId = recurring ? new Date(sourceStartMs).toISOString() : null return { key: `${String(entity.identifier)}:${instanceId ?? 'master'}`, entity, instanceId, start: effectiveStartMs, end: effectiveEndMs, dayStart: dayStartMs, duration: endMs - effectiveStartMs, timeless: timeless || lastOccupiedDayMs !== dayStartMs, multiDay: lastOccupiedDayMs !== dayStartMs, label: mutation?.label ?? event.label, color: mutation?.color ?? event.color, } } function mutationMap(event: EventObject, timeless: boolean): Map { const mutations = new Map() for (const [key, mutation] of Object.entries(event.mutations ?? {})) { const mutationId = eventTimestamp(mutation.mutationId, timeless) ?? eventTimestamp(key, timeless) if (mutationId !== null) mutations.set(mutationId, mutation) } return mutations } function materializeInstance( entity: EntityObject, sourceStartMs: number, durationMs: number, durationDays: number | null, recurring: boolean, mutation: EventMutationObject | undefined, range?: EpochSpan, ): CalendarInstance | null { if (mutation?.mutationExclusion === true) return null const event = entity.properties as EventObject const timeless = mutation?.timeless ?? event.timeless === true const effectiveStartMs = eventTimestamp(mutation?.startsOn, timeless) ?? sourceStartMs const defaultEndMs = timeless && durationDays !== null ? shiftLocalDays(new Date(effectiveStartMs), durationDays).getTime() : effectiveStartMs + durationMs const effectiveEndMs = eventTimestamp(mutation?.endsOn, timeless) ?? defaultEndMs if (!overlapsRange(effectiveStartMs, effectiveEndMs, range)) return null return createInstance( entity, sourceStartMs, effectiveStartMs, effectiveEndMs, recurring, timeless, mutation, ) } function localDayNumber(date: Date): number { return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000) } function dailyStartIndex( start: Date, interval: number, durationMs: number, range: EpochSpan, ): number { const earliestRelevant = new Date(range.start - durationMs) const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(start) return Math.max(0, Math.floor(elapsedDays / interval) - 1) } function weeklyStartIndex( weekStart: Date, interval: number, durationMs: number, range: EpochSpan, ): number { const earliestRelevant = new Date(range.start - durationMs) const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(weekStart) return Math.max(0, Math.floor(elapsedDays / (interval * 7)) - 1) } function normalizedWeekdays(values: number[] | null, fallback: number): number[] { const weekdays = (values?.length ? values : [fallback]) .filter(value => Number.isInteger(value) && value >= 1 && value <= 7) return Array.from(new Set(weekdays)).sort((left, right) => left - right) } function expandDaily( entity: EntityObject, start: Date, duration: number, durationDays: number | null, range: EpochSpan, mutations: Map, ): CalendarInstance[] { const pattern = (entity.properties as EventObject).pattern! const interval = Math.max(1, Math.trunc(pattern.interval || 1)) const maximumOccurrences = pattern.iterations && pattern.iterations > 0 ? Math.trunc(pattern.iterations) : null const concludesMs = conclusionTimestamp(pattern.concludes) const weekdays = pattern.onDayOfWeek?.length ? new Set(normalizedWeekdays(pattern.onDayOfWeek, start.getDay() || 7)) : null let step = maximumOccurrences === null || pattern.onDayOfWeek?.length === 0 ? dailyStartIndex(start, interval, duration, range) : 0 let occurrenceCount = weekdays ? 0 : step const instances: CalendarInstance[] = [] while (true) { const candidate = shiftLocalDays(start, step * interval) const candidateMs = candidate.getTime() if (candidateMs >= range.end) break if (concludesMs !== null && candidateMs > concludesMs) break if (!weekdays || weekdays.has(candidate.getDay() || 7)) { occurrenceCount += 1 if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) break const instance = materializeInstance( entity, candidateMs, duration, durationDays, true, mutations.get(candidateMs), range, ) if (instance) instances.push(instance) } step += 1 } return instances } function expandWeekly( entity: EntityObject, start: Date, durationMs: number, durationDays: number | null, range: EpochSpan, mutations: Map, ): CalendarInstance[] { const pattern = (entity.properties as EventObject).pattern! const interval = Math.max(1, Math.trunc(pattern.interval || 1)) const maximumOccurrences = pattern.iterations && pattern.iterations > 0 ? Math.trunc(pattern.iterations) : null const concludesMs = conclusionTimestamp(pattern.concludes) const weekdays = normalizedWeekdays(pattern.onDayOfWeek, start.getDay() || 7) const weekStart = shiftLocalDays(start, -((start.getDay() || 7) - 1)) let week = maximumOccurrences === null ? weeklyStartIndex(weekStart, interval, durationMs, range) : 0 let occurrenceCount = maximumOccurrences === null ? week * weekdays.length : 0 const instances: CalendarInstance[] = [] while (true) { const intervalWeekStart = shiftLocalDays(weekStart, week * interval * 7) if (intervalWeekStart.getTime() >= range.end) break for (const weekday of weekdays) { const candidate = shiftLocalDays(intervalWeekStart, weekday - 1) const candidateMs = candidate.getTime() if (candidateMs < start.getTime()) continue if (candidateMs >= range.end) return instances if (concludesMs !== null && candidateMs > concludesMs) return instances occurrenceCount += 1 if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) return instances const instance = materializeInstance( entity, candidateMs, durationMs, durationDays, true, mutations.get(candidateMs), range, ) if (instance) instances.push(instance) } week += 1 } return instances } function appendMovedMutations( instances: CalendarInstance[], entity: EntityObject, durationMs: number, durationDays: number | null, range: VisibleDateRange, mutations: Map, ) { const present = new Set(instances.map(instance => instance.key)) for (const [sourceStartMs, mutation] of mutations) { const instance = materializeInstance( entity, sourceStartMs, durationMs, durationDays, true, mutation, range, ) if (instance && !present.has(instance.key)) { instances.push(instance) present.add(instance.key) } } } export function expandEntityInstances( entity: EntityObject, range?: EpochSpan, ): CalendarInstance[] { const event = entity.properties as EventObject const timeless = event.timeless === true const startMs = eventTimestamp(event.startsOn, timeless) if (startMs === null) return [] const parsedEndMs = eventTimestamp(event.endsOn, timeless) const durationMs = parsedEndMs !== null && parsedEndMs > startMs ? parsedEndMs - startMs : MINIMUM_DURATION_MS const durationDays = timeless && parsedEndMs !== null && parsedEndMs > startMs ? Math.max(1, localDayNumber(new Date(parsedEndMs)) - localDayNumber(new Date(startMs))) : null const pattern = event.pattern if (!pattern || !range || (pattern.precision !== 'daily' && pattern.precision !== 'weekly')) { const instance = materializeInstance( entity, startMs, durationMs, durationDays, false, undefined, range, ) return instance ? [instance] : [] } const mutations = mutationMap(event, timeless) const instances = pattern.precision === 'daily' ? expandDaily(entity, new Date(startMs), durationMs, durationDays, range, mutations) : expandWeekly(entity, new Date(startMs), durationMs, durationDays, range, mutations) appendMovedMutations(instances, entity, durationMs, durationDays, range, mutations) instances.sort((left, right) => left.start - right.start || left.key.localeCompare(right.key)) return instances }