Files
chrono/src/utils/recurrence.ts
T
Sebastian 3c0ea698b1 refactor: code clean up
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-06 22:42:31 -04:00

326 lines
10 KiB
TypeScript

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<number, EventMutationObject> {
const mutations = new Map<number, EventMutationObject>()
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<number, EventMutationObject>,
): 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<number, EventMutationObject>,
): 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<number, EventMutationObject>,
) {
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
}