3c0ea698b1
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
326 lines
10 KiB
TypeScript
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
|
|
}
|