feat: recurrence expansion.
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -37,7 +37,7 @@
|
||||
<template #prepend>
|
||||
<v-icon :color="getEventColor(occurrence)">mdi-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ occurrence.entity.properties?.label || 'Untitled' }}</v-list-item-title>
|
||||
<v-list-item-title>{{ occurrence.label || 'Untitled' }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ occurrence.timeless ? 'All day' : `${formatOccurrenceTime(occurrence.startMs)} - ${formatOccurrenceTime(occurrence.endMs)}` }}
|
||||
</v-list-item-subtitle>
|
||||
@@ -52,7 +52,6 @@ import { ref, computed, watch } from 'vue';
|
||||
import { AGENDA_VIEW_SPANS, spanToDays, type AgendaViewSpan } from '@/types/spans';
|
||||
import type { EntityObject } from '@ChronoManager/models/entity';
|
||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||
import { EventObject } from '@ChronoManager/models/event';
|
||||
import { daysVisibleRange, shiftLocalDays } from '@/utils/dateRanges';
|
||||
import { occurrencesForRange } from '@/utils/occurrenceIndex';
|
||||
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
|
||||
@@ -149,16 +148,11 @@ const groupedEvents = computed(() => {
|
||||
|
||||
function getEventColor(occurrence: CalendarOccurrence): string {
|
||||
const entity = occurrence.entity;
|
||||
const event = getEventProperties(entity);
|
||||
if (event.color) return event.color;
|
||||
if (occurrence.color) return occurrence.color;
|
||||
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
||||
return calendar?.properties?.color || '#1976D2';
|
||||
}
|
||||
|
||||
function getEventProperties(entity: CalendarEntity): EventObject {
|
||||
return entity.properties as EventObject;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
@mouseenter="emit('event-hover', { event: $event, entity: segment.occurrence.entity })"
|
||||
@mouseleave="emit('event-hover-end')"
|
||||
>
|
||||
<span v-if="segment.isStart" class="event-label">{{ segment.occurrence.entity.properties?.label || 'Untitled' }}</span>
|
||||
<span v-if="segment.isStart" class="event-label">{{ segment.occurrence.label || 'Untitled' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,10 +85,10 @@
|
||||
<div class="event-time">
|
||||
{{ formatOccurrenceTime(occurrence.startMs) }} - {{ formatOccurrenceTime(occurrence.endMs) }}
|
||||
</div>
|
||||
<div class="event-title">{{ getEventProperties(occurrence.entity).label || 'Untitled' }}</div>
|
||||
<div class="event-title">{{ occurrence.label || 'Untitled' }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="event-title-compact">{{ getEventProperties(occurrence.entity).label || 'Untitled' }}</div>
|
||||
<div class="event-title-compact">{{ occurrence.label || 'Untitled' }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +110,6 @@ import { shiftLocalDays } from '@/utils/dateRanges';
|
||||
import { DAYS_VIEW_SPANS, spanToDays, type DaysViewSpan } from '@/types/spans';
|
||||
import type { EntityObject } from '@ChronoManager/models/entity';
|
||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||
import { EventObject } from '@ChronoManager/models/event';
|
||||
import { occurrencesForDay, occurrencesForRange } from '@/utils/occurrenceIndex';
|
||||
import type { CalendarOccurrence, CalendarOccurrenceIndex } from '@/types/occurrence';
|
||||
|
||||
@@ -223,20 +222,15 @@ function getTimedEvents(date: Date): CalendarOccurrence[] {
|
||||
return occurrencesForDay(props.occurrenceIndex, date).filter(occurrence => !occurrence.timeless);
|
||||
}
|
||||
|
||||
function getEventProperties(entity: CalendarEntity): EventObject {
|
||||
return entity.properties as EventObject;
|
||||
}
|
||||
|
||||
function getEventColor(entity: CalendarEntity): string {
|
||||
const event = getEventProperties(entity);
|
||||
if (event.color) return event.color;
|
||||
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
||||
function getEventColor(occurrence: CalendarOccurrence): string {
|
||||
if (occurrence.color) return occurrence.color;
|
||||
const calendar = props.calendars.find(cal => cal.identifier === occurrence.entity.collection);
|
||||
return calendar?.properties?.color || '#1976D2';
|
||||
}
|
||||
|
||||
function getAllDayEventStyle(segment: MultiDaySegment) {
|
||||
return {
|
||||
backgroundColor: getEventColor(segment.occurrence.entity),
|
||||
backgroundColor: getEventColor(segment.occurrence),
|
||||
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
|
||||
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
|
||||
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
|
||||
@@ -251,7 +245,7 @@ function getEventStyle(occurrence: CalendarOccurrence) {
|
||||
return {
|
||||
top: `${(start / 1440) * 100}%`,
|
||||
height: `${(duration / 1440) * 100}%`,
|
||||
backgroundColor: getEventColor(occurrence.entity),
|
||||
backgroundColor: getEventColor(occurrence),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -161,9 +161,9 @@ function isToday(date: Date): boolean {
|
||||
return date.toDateString() === today.toDateString();
|
||||
}
|
||||
|
||||
function getEventColor(entity: CalendarOccurrence['entity']): string {
|
||||
if (entity.properties?.color) return entity.properties.color;
|
||||
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
||||
function getEventColor(occurrence: CalendarOccurrence): string {
|
||||
if (occurrence.color) return occurrence.color;
|
||||
const calendar = props.calendars.find(cal => cal.identifier === occurrence.entity.collection);
|
||||
return calendar?.properties?.color || '#1976D2';
|
||||
}
|
||||
</script>
|
||||
@@ -214,12 +214,12 @@ function getEventColor(entity: CalendarOccurrence['entity']): string {
|
||||
v-for="occurrence in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
|
||||
:key="occurrence.key"
|
||||
class="single-day-event"
|
||||
:style="{ backgroundColor: getEventColor(occurrence.entity) }"
|
||||
:style="{ backgroundColor: getEventColor(occurrence) }"
|
||||
@click.stop="$emit('event-click', occurrence.entity)"
|
||||
@mouseenter="$emit('event-hover', { event: $event, entity: occurrence.entity })"
|
||||
@mouseleave="$emit('event-hover-end')"
|
||||
>
|
||||
{{ occurrence.entity.properties?.label || 'Untitled' }}
|
||||
{{ occurrence.label || 'Untitled' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getHiddenCount(cell, week.laneCount) > 0"
|
||||
@@ -243,7 +243,7 @@ function getEventColor(entity: CalendarOccurrence['entity']): string {
|
||||
'is-end': segment.isEnd,
|
||||
}"
|
||||
:style="{
|
||||
backgroundColor: getEventColor(segment.occurrence.entity),
|
||||
backgroundColor: getEventColor(segment.occurrence),
|
||||
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
|
||||
width: `calc(${segment.span} / 7 * 100% - 8px)`,
|
||||
top: `${34 + segment.lane * EVENT_HEIGHT}px`,
|
||||
@@ -253,7 +253,7 @@ function getEventColor(entity: CalendarOccurrence['entity']): string {
|
||||
@mouseleave="$emit('event-hover-end')"
|
||||
>
|
||||
<span v-if="segment.isStart" class="event-label">
|
||||
{{ segment.occurrence.entity.properties?.label || 'Untitled' }}
|
||||
{{ segment.occurrence.label || 'Untitled' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,10 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
||||
return events.value.filter(event => visibleCalendarIds.includes(event.collection))
|
||||
})
|
||||
const filteredTasks = computed(() => tasks.value)
|
||||
const occurrenceIndex = computed(() => buildOccurrenceIndex(filteredEvents.value))
|
||||
const occurrenceIndex = computed(() => buildOccurrenceIndex(
|
||||
filteredEvents.value,
|
||||
visibleRange.value ?? undefined,
|
||||
))
|
||||
const visibleOccurrences = computed(() => visibleRange.value
|
||||
? occurrencesForRange(occurrenceIndex.value, visibleRange.value)
|
||||
: [])
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface CalendarOccurrence {
|
||||
durationMs: number
|
||||
timeless: boolean
|
||||
multiDay: boolean
|
||||
label: string | null
|
||||
color: string | null
|
||||
}
|
||||
|
||||
export interface CalendarOccurrenceIndex {
|
||||
|
||||
@@ -1,46 +1,11 @@
|
||||
import type { EntityObject } from '@ChronoManager/models/entity'
|
||||
import type { EventObject } from '@ChronoManager/models/event'
|
||||
import type {
|
||||
CalendarOccurrence,
|
||||
CalendarOccurrenceIndex,
|
||||
} from '../types/occurrence'
|
||||
import type { VisibleDateRange } from '../types'
|
||||
import { shiftLocalDays, startOfLocalDay } from './dateRanges'
|
||||
|
||||
const MINIMUM_DURATION_MS = 1
|
||||
|
||||
function eventTimestamp(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
|
||||
const timestamp = Date.parse(value)
|
||||
return Number.isFinite(timestamp) ? timestamp : null
|
||||
}
|
||||
|
||||
function occurrenceFromEntity(entity: EntityObject): CalendarOccurrence | null {
|
||||
const event = entity.properties as EventObject
|
||||
const startMs = eventTimestamp(event.startsOn)
|
||||
if (startMs === null) return null
|
||||
|
||||
const parsedEndMs = eventTimestamp(event.endsOn)
|
||||
const endMs = parsedEndMs !== null && parsedEndMs > startMs
|
||||
? parsedEndMs
|
||||
: startMs + MINIMUM_DURATION_MS
|
||||
const dayStartMs = startOfLocalDay(new Date(startMs)).getTime()
|
||||
const lastOccupiedDayMs = startOfLocalDay(new Date(endMs - 1)).getTime()
|
||||
const entityKey = String(entity.identifier)
|
||||
|
||||
return {
|
||||
key: `${entityKey}:master`,
|
||||
entity,
|
||||
occurrenceId: null,
|
||||
startMs,
|
||||
endMs,
|
||||
dayStartMs,
|
||||
durationMs: endMs - startMs,
|
||||
timeless: event.timeless === true || lastOccupiedDayMs !== dayStartMs,
|
||||
multiDay: lastOccupiedDayMs !== dayStartMs,
|
||||
}
|
||||
}
|
||||
import { expandEntityOccurrences } from './recurrence'
|
||||
|
||||
function appendToDayBuckets(index: CalendarOccurrenceIndex, occurrence: CalendarOccurrence) {
|
||||
const lastOccupiedDayMs = startOfLocalDay(new Date(occurrence.endMs - 1)).getTime()
|
||||
@@ -63,7 +28,10 @@ function sortOccurrences(occurrences: CalendarOccurrence[]) {
|
||||
))
|
||||
}
|
||||
|
||||
export function buildOccurrenceIndex(entities: EntityObject[]): CalendarOccurrenceIndex {
|
||||
export function buildOccurrenceIndex(
|
||||
entities: EntityObject[],
|
||||
range?: VisibleDateRange,
|
||||
): CalendarOccurrenceIndex {
|
||||
const index: CalendarOccurrenceIndex = {
|
||||
byDay: new Map(),
|
||||
byEntity: new Map(),
|
||||
@@ -71,13 +39,15 @@ export function buildOccurrenceIndex(entities: EntityObject[]): CalendarOccurren
|
||||
}
|
||||
|
||||
for (const entity of entities) {
|
||||
const occurrence = occurrenceFromEntity(entity)
|
||||
if (!occurrence) continue
|
||||
const entityOccurrences = expandEntityOccurrences(entity, range)
|
||||
if (entityOccurrences.length === 0) continue
|
||||
|
||||
index.byEntity.set(String(entity.identifier), entityOccurrences)
|
||||
for (const occurrence of entityOccurrences) {
|
||||
index.sorted.push(occurrence)
|
||||
index.byEntity.set(String(entity.identifier), [occurrence])
|
||||
appendToDayBuckets(index, occurrence)
|
||||
}
|
||||
}
|
||||
|
||||
sortOccurrences(index.sorted)
|
||||
for (const bucket of index.byDay.values()) sortOccurrences(bucket)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { EntityObject } from '../../../../chrono_manager/src/models/entity'
|
||||
import type { EventOccurrence, EventMutation } from '../../../../chrono_manager/src/types/event'
|
||||
import { daysVisibleRange } from '../../../src/utils/dateRanges'
|
||||
import { expandEntityOccurrences } from '../../../src/utils/recurrence'
|
||||
|
||||
function localIso(year: number, month: number, day: number, hour = 9): string {
|
||||
return new Date(year, month, day, hour).toISOString()
|
||||
}
|
||||
|
||||
function recurringEntity(
|
||||
pattern: EventOccurrence,
|
||||
mutations: Record<string, Partial<EventMutation>> = {},
|
||||
startsOn = localIso(2026, 5, 1),
|
||||
): EntityObject {
|
||||
return {
|
||||
identifier: 'calendar:event-1',
|
||||
properties: {
|
||||
startsOn,
|
||||
endsOn: new Date(new Date(startsOn).getTime() + 60 * 60 * 1000).toISOString(),
|
||||
timeless: false,
|
||||
label: 'Standup',
|
||||
color: '#123456',
|
||||
pattern,
|
||||
mutations,
|
||||
},
|
||||
} as unknown as EntityObject
|
||||
}
|
||||
|
||||
describe('daily recurrence', () => {
|
||||
it('expands interval rules only inside the requested range', () => {
|
||||
const entity = recurringEntity({
|
||||
pattern: 'absolute',
|
||||
precision: 'daily',
|
||||
interval: 2,
|
||||
})
|
||||
const occurrences = expandEntityOccurrences(
|
||||
entity,
|
||||
daysVisibleRange(new Date(2026, 5, 4), 6),
|
||||
)
|
||||
|
||||
expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([5, 7, 9])
|
||||
expect(occurrences.every(occurrence => occurrence.durationMs === 60 * 60 * 1000)).toBe(true)
|
||||
expect(new Set(occurrences.map(occurrence => occurrence.key)).size).toBe(3)
|
||||
})
|
||||
|
||||
it('honors iteration limits before the requested range', () => {
|
||||
const entity = recurringEntity({
|
||||
pattern: 'absolute',
|
||||
precision: 'daily',
|
||||
interval: 1,
|
||||
iterations: 3,
|
||||
})
|
||||
const occurrences = expandEntityOccurrences(
|
||||
entity,
|
||||
daysVisibleRange(new Date(2026, 5, 10), 3),
|
||||
)
|
||||
|
||||
expect(occurrences).toEqual([])
|
||||
})
|
||||
|
||||
it('treats a date-only conclusion as an inclusive local date', () => {
|
||||
const entity = recurringEntity({
|
||||
pattern: 'absolute',
|
||||
precision: 'daily',
|
||||
interval: 1,
|
||||
concludes: '2026-06-03',
|
||||
})
|
||||
const occurrences = expandEntityOccurrences(
|
||||
entity,
|
||||
daysVisibleRange(new Date(2026, 5, 1), 5),
|
||||
)
|
||||
|
||||
expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('preserves local wall-clock time across daylight-saving changes', () => {
|
||||
const entity = recurringEntity({
|
||||
pattern: 'absolute',
|
||||
precision: 'daily',
|
||||
interval: 1,
|
||||
}, {}, localIso(2026, 2, 7, 9))
|
||||
const occurrences = expandEntityOccurrences(
|
||||
entity,
|
||||
daysVisibleRange(new Date(2026, 2, 7), 3),
|
||||
)
|
||||
|
||||
expect(occurrences.map(occurrence => new Date(occurrence.startMs).getHours())).toEqual([9, 9, 9])
|
||||
})
|
||||
})
|
||||
|
||||
describe('weekly recurrence', () => {
|
||||
it('expands selected ISO weekdays in chronological order', () => {
|
||||
const entity = recurringEntity({
|
||||
pattern: 'absolute',
|
||||
precision: 'weekly',
|
||||
interval: 1,
|
||||
onDayOfWeek: [1, 3],
|
||||
})
|
||||
const occurrences = expandEntityOccurrences(
|
||||
entity,
|
||||
daysVisibleRange(new Date(2026, 5, 1), 10),
|
||||
)
|
||||
|
||||
expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([1, 3, 8, 10])
|
||||
})
|
||||
})
|
||||
|
||||
describe('recurrence mutations', () => {
|
||||
it('removes excluded occurrences', () => {
|
||||
const excludedId = localIso(2026, 5, 2)
|
||||
const entity = recurringEntity({
|
||||
pattern: 'absolute',
|
||||
precision: 'daily',
|
||||
interval: 1,
|
||||
}, {
|
||||
[excludedId]: {
|
||||
mutationId: excludedId,
|
||||
mutationExclusion: true,
|
||||
},
|
||||
})
|
||||
const occurrences = expandEntityOccurrences(
|
||||
entity,
|
||||
daysVisibleRange(new Date(2026, 5, 1), 3),
|
||||
)
|
||||
|
||||
expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('moves and restyles a modified occurrence without duplicating it', () => {
|
||||
const sourceId = localIso(2026, 5, 2)
|
||||
const movedStart = localIso(2026, 5, 4, 14)
|
||||
const entity = recurringEntity({
|
||||
pattern: 'absolute',
|
||||
precision: 'daily',
|
||||
interval: 1,
|
||||
iterations: 2,
|
||||
}, {
|
||||
[sourceId]: {
|
||||
mutationId: sourceId,
|
||||
mutationExclusion: false,
|
||||
startsOn: movedStart,
|
||||
endsOn: new Date(new Date(movedStart).getTime() + 2 * 60 * 60 * 1000).toISOString(),
|
||||
label: 'Moved standup',
|
||||
color: '#abcdef',
|
||||
},
|
||||
})
|
||||
const occurrences = expandEntityOccurrences(
|
||||
entity,
|
||||
daysVisibleRange(new Date(2026, 5, 1), 5),
|
||||
)
|
||||
const moved = occurrences.find(occurrence => occurrence.occurrenceId === sourceId)
|
||||
|
||||
expect(occurrences).toHaveLength(2)
|
||||
expect(new Date(moved!.startMs).getHours()).toBe(14)
|
||||
expect(moved!.durationMs).toBe(2 * 60 * 60 * 1000)
|
||||
expect(moved!.label).toBe('Moved standup')
|
||||
expect(moved!.color).toBe('#abcdef')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user