feat: recurrence expansion.

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-29 22:19:38 -04:00
parent cd270e9e99
commit acbe0ad5ca
8 changed files with 478 additions and 72 deletions
+283
View File
@@ -0,0 +1,283 @@
import type { EntityObject } from '@ChronoManager/models/entity'
import type { EventObject } from '@ChronoManager/models/event'
import type { EventMutationObject } from '@ChronoManager/models/event-mutation'
import type { CalendarOccurrence } from '../types/occurrence'
import type { VisibleDateRange } from '../types'
import { shiftLocalDays, startOfLocalDay } from './dateRanges'
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 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(startMs: number, endMs: number, range?: VisibleDateRange): boolean {
return !range || (startMs < range.endMs && endMs > range.startMs)
}
function createOccurrence(
entity: EntityObject,
sourceStartMs: number,
effectiveStartMs: number,
effectiveEndMs: number,
recurring: boolean,
mutation?: EventMutationObject,
): CalendarOccurrence {
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 occurrenceId = recurring ? new Date(sourceStartMs).toISOString() : null
return {
key: `${String(entity.identifier)}:${occurrenceId ?? 'master'}`,
entity,
occurrenceId,
startMs: effectiveStartMs,
endMs,
dayStartMs,
durationMs: endMs - effectiveStartMs,
timeless: (mutation?.timeless ?? event.timeless === true) || lastOccupiedDayMs !== dayStartMs,
multiDay: lastOccupiedDayMs !== dayStartMs,
label: mutation?.label ?? event.label,
color: mutation?.color ?? event.color,
}
}
function mutationMap(event: EventObject): Map<number, EventMutationObject> {
const mutations = new Map<number, EventMutationObject>()
for (const [key, mutation] of Object.entries(event.mutations ?? {})) {
const mutationId = timestamp(mutation.mutationId) ?? timestamp(key)
if (mutationId !== null) mutations.set(mutationId, mutation)
}
return mutations
}
function materializeOccurrence(
entity: EntityObject,
sourceStartMs: number,
durationMs: number,
recurring: boolean,
mutation: EventMutationObject | undefined,
range?: VisibleDateRange,
): CalendarOccurrence | null {
if (mutation?.mutationExclusion === true) return null
const effectiveStartMs = timestamp(mutation?.startsOn) ?? sourceStartMs
const effectiveEndMs = timestamp(mutation?.endsOn) ?? effectiveStartMs + durationMs
if (!overlapsRange(effectiveStartMs, effectiveEndMs, range)) return null
return createOccurrence(entity, sourceStartMs, effectiveStartMs, effectiveEndMs, recurring, 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: VisibleDateRange,
): number {
const earliestRelevant = new Date(range.startMs - 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: VisibleDateRange,
): number {
const earliestRelevant = new Date(range.startMs - 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,
durationMs: number,
range: VisibleDateRange,
mutations: Map<number, EventMutationObject>,
): CalendarOccurrence[] {
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, durationMs, range)
: 0
let occurrenceCount = weekdays ? 0 : step
const occurrences: CalendarOccurrence[] = []
while (true) {
const candidate = shiftLocalDays(start, step * interval)
const candidateMs = candidate.getTime()
if (candidateMs >= range.endMs) break
if (concludesMs !== null && candidateMs > concludesMs) break
if (!weekdays || weekdays.has(candidate.getDay() || 7)) {
occurrenceCount += 1
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) break
const occurrence = materializeOccurrence(
entity,
candidateMs,
durationMs,
true,
mutations.get(candidateMs),
range,
)
if (occurrence) occurrences.push(occurrence)
}
step += 1
}
return occurrences
}
function expandWeekly(
entity: EntityObject,
start: Date,
durationMs: number,
range: VisibleDateRange,
mutations: Map<number, EventMutationObject>,
): CalendarOccurrence[] {
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 occurrences: CalendarOccurrence[] = []
while (true) {
const intervalWeekStart = shiftLocalDays(weekStart, week * interval * 7)
if (intervalWeekStart.getTime() >= range.endMs) break
for (const weekday of weekdays) {
const candidate = shiftLocalDays(intervalWeekStart, weekday - 1)
const candidateMs = candidate.getTime()
if (candidateMs < start.getTime()) continue
if (candidateMs >= range.endMs) return occurrences
if (concludesMs !== null && candidateMs > concludesMs) return occurrences
occurrenceCount += 1
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) return occurrences
const occurrence = materializeOccurrence(
entity,
candidateMs,
durationMs,
true,
mutations.get(candidateMs),
range,
)
if (occurrence) occurrences.push(occurrence)
}
week += 1
}
return occurrences
}
function appendMovedMutations(
occurrences: CalendarOccurrence[],
entity: EntityObject,
durationMs: number,
range: VisibleDateRange,
mutations: Map<number, EventMutationObject>,
) {
const present = new Set(occurrences.map(occurrence => occurrence.key))
for (const [sourceStartMs, mutation] of mutations) {
const occurrence = materializeOccurrence(
entity,
sourceStartMs,
durationMs,
true,
mutation,
range,
)
if (occurrence && !present.has(occurrence.key)) {
occurrences.push(occurrence)
present.add(occurrence.key)
}
}
}
export function expandEntityOccurrences(
entity: EntityObject,
range?: VisibleDateRange,
): CalendarOccurrence[] {
const event = entity.properties as EventObject
const startMs = timestamp(event.startsOn)
if (startMs === null) return []
const parsedEndMs = timestamp(event.endsOn)
const durationMs = parsedEndMs !== null && parsedEndMs > startMs
? parsedEndMs - startMs
: MINIMUM_DURATION_MS
const pattern = event.pattern
if (!pattern || !range || (pattern.precision !== 'daily' && pattern.precision !== 'weekly')) {
const occurrence = materializeOccurrence(entity, startMs, durationMs, false, undefined, range)
return occurrence ? [occurrence] : []
}
const mutations = mutationMap(event)
const occurrences = pattern.precision === 'daily'
? expandDaily(entity, new Date(startMs), durationMs, range, mutations)
: expandWeekly(entity, new Date(startMs), durationMs, range, mutations)
appendMovedMutations(occurrences, entity, durationMs, range, mutations)
occurrences.sort((left, right) => left.startMs - right.startMs || left.key.localeCompare(right.key))
return occurrences
}