From acbe0ad5ca16571124a68b4fc01b300f4d67ffa3 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Mon, 29 Jun 2026 22:19:38 -0400 Subject: [PATCH] feat: recurrence expansion. Signed-off-by: Sebastian Krupinski --- src/components/AgendaView.vue | 10 +- src/components/DaysView.vue | 22 +-- src/components/MonthView.vue | 14 +- src/stores/chronoOperationsStore.ts | 5 +- src/types/occurrence.ts | 2 + src/utils/occurrenceIndex.ts | 54 ++---- src/utils/recurrence.ts | 283 ++++++++++++++++++++++++++++ tests/js/unit/recurrence.test.ts | 160 ++++++++++++++++ 8 files changed, 478 insertions(+), 72 deletions(-) create mode 100644 src/utils/recurrence.ts create mode 100644 tests/js/unit/recurrence.test.ts diff --git a/src/components/AgendaView.vue b/src/components/AgendaView.vue index dc06740..402cd94 100644 --- a/src/components/AgendaView.vue +++ b/src/components/AgendaView.vue @@ -37,7 +37,7 @@ - {{ occurrence.entity.properties?.label || 'Untitled' }} + {{ occurrence.label || 'Untitled' }} {{ occurrence.timeless ? 'All day' : `${formatOccurrenceTime(occurrence.startMs)} - ${formatOccurrenceTime(occurrence.endMs)}` }} @@ -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' }); } diff --git a/src/components/DaysView.vue b/src/components/DaysView.vue index de1369f..5eaab97 100644 --- a/src/components/DaysView.vue +++ b/src/components/DaysView.vue @@ -56,7 +56,7 @@ @mouseenter="emit('event-hover', { event: $event, entity: segment.occurrence.entity })" @mouseleave="emit('event-hover-end')" > - {{ segment.occurrence.entity.properties?.label || 'Untitled' }} + {{ segment.occurrence.label || 'Untitled' }} @@ -85,10 +85,10 @@
{{ formatOccurrenceTime(occurrence.startMs) }} - {{ formatOccurrenceTime(occurrence.endMs) }}
-
{{ getEventProperties(occurrence.entity).label || 'Untitled' }}
+
{{ occurrence.label || 'Untitled' }}
@@ -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), }; } diff --git a/src/components/MonthView.vue b/src/components/MonthView.vue index cf71607..afcd2b2 100644 --- a/src/components/MonthView.vue +++ b/src/components/MonthView.vue @@ -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'; } @@ -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' }}
- {{ segment.occurrence.entity.properties?.label || 'Untitled' }} + {{ segment.occurrence.label || 'Untitled' }}
diff --git a/src/stores/chronoOperationsStore.ts b/src/stores/chronoOperationsStore.ts index 0b04980..e918153 100644 --- a/src/stores/chronoOperationsStore.ts +++ b/src/stores/chronoOperationsStore.ts @@ -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) : []) diff --git a/src/types/occurrence.ts b/src/types/occurrence.ts index 8b20986..6e0c314 100644 --- a/src/types/occurrence.ts +++ b/src/types/occurrence.ts @@ -10,6 +10,8 @@ export interface CalendarOccurrence { durationMs: number timeless: boolean multiDay: boolean + label: string | null + color: string | null } export interface CalendarOccurrenceIndex { diff --git a/src/utils/occurrenceIndex.ts b/src/utils/occurrenceIndex.ts index bf78d53..df1388d 100644 --- a/src/utils/occurrenceIndex.ts +++ b/src/utils/occurrenceIndex.ts @@ -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,12 +39,14 @@ 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.sorted.push(occurrence) - index.byEntity.set(String(entity.identifier), [occurrence]) - appendToDayBuckets(index, occurrence) + index.byEntity.set(String(entity.identifier), entityOccurrences) + for (const occurrence of entityOccurrences) { + index.sorted.push(occurrence) + appendToDayBuckets(index, occurrence) + } } sortOccurrences(index.sorted) diff --git a/src/utils/recurrence.ts b/src/utils/recurrence.ts new file mode 100644 index 0000000..806fa05 --- /dev/null +++ b/src/utils/recurrence.ts @@ -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 { + const mutations = new Map() + + 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, +): 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, +): 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, +) { + 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 +} diff --git a/tests/js/unit/recurrence.test.ts b/tests/js/unit/recurrence.test.ts new file mode 100644 index 0000000..64ae3c5 --- /dev/null +++ b/tests/js/unit/recurrence.test.ts @@ -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> = {}, + 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') + }) +})