@remit/calendar-service 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/scope.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  serializeCalendar,
10
10
  } from "./parse.js";
11
11
  import { hasRecurrence } from "./project.js";
12
- import { dtStartTzid, resolveTime, toUtcIso } from "./time.js";
12
+ import { dtStartTzid, resolveTime, toUtcIso, tzidOf } from "./time.js";
13
13
 
14
14
  export type RecurrenceScopeValue =
15
15
  (typeof RecurrenceScope)[keyof typeof RecurrenceScope];
@@ -308,17 +308,12 @@ const shiftProperty = (
308
308
  property.setValue(shifted);
309
309
  };
310
310
 
311
- /** Where an occurrence is drawn, which a moved one no longer shares with its slot. */
312
311
  const shownStart = (occurrence: FoundOccurrence): ICAL.Time | null => {
313
312
  if (occurrence.details) return occurrence.details.startDate;
314
313
  const start = occurrence.override?.getFirstPropertyValue("dtstart");
315
314
  return start instanceof ICAL.Time ? start : null;
316
315
  };
317
316
 
318
- /**
319
- * The slot a value named under the old DTSTART, named under the new one: the
320
- * same day of the series, at the series' new time and in its new zone.
321
- */
322
317
  const reslot = (
323
318
  value: ICAL.Time,
324
319
  before: ICAL.Time,
@@ -329,70 +324,440 @@ const reslot = (
329
324
  return moved;
330
325
  };
331
326
 
332
- /**
333
- * Keeps every override and excluded date on the slot it was written for once
334
- * the series' own start has moved. A RECURRENCE-ID or an EXDATE matches a slot
335
- * by its exact time, so leaving them behind would detach a moved occurrence
336
- * from the one it replaces and bring a deleted one back.
337
- */
327
+ interface SeriesShape {
328
+ start: ICAL.Time;
329
+ tzid: string;
330
+ rule: ICAL.Recur | null;
331
+ durationSeconds: number;
332
+ }
333
+
334
+ interface SeriesSlot {
335
+ time: ICAL.Time;
336
+ instantMs: number;
337
+ }
338
+
339
+ const seriesShape = (master: ICAL.Component): SeriesShape | null => {
340
+ const start = master.getFirstPropertyValue("dtstart");
341
+ if (!(start instanceof ICAL.Time)) return null;
342
+ const rule = master.getFirstPropertyValue("rrule");
343
+ return {
344
+ start: start.clone(),
345
+ tzid: dtStartTzid(master),
346
+ rule: rule instanceof ICAL.Recur ? rule.clone() : null,
347
+ durationSeconds: new ICAL.Event(master).duration.toSeconds(),
348
+ };
349
+ };
350
+
351
+ const ruleSlots = (
352
+ shape: SeriesShape,
353
+ collectionTimezone: string,
354
+ stop: (instantMs: number, count: number) => boolean,
355
+ ): SeriesSlot[] => {
356
+ if (!shape.rule) return [];
357
+ const slots: SeriesSlot[] = [];
358
+ const iterator = shape.rule.iterator(shape.start);
359
+ let next: ICAL.Time | null = iterator.next();
360
+ while (next && slots.length < CALENDAR_WINDOW_MAX_STEPS) {
361
+ const instantMs = resolveTime(
362
+ next,
363
+ shape.tzid,
364
+ collectionTimezone,
365
+ ).instantMs;
366
+ if (stop(instantMs, slots.length)) break;
367
+ slots.push({ time: next.clone(), instantMs });
368
+ next = iterator.next();
369
+ }
370
+ return slots;
371
+ };
372
+
373
+ const WEEKDAYS: readonly string[] = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
374
+
375
+ const WORKWEEK: readonly number[] = [1, 2, 3, 4, 5];
376
+
377
+ const BY_WEEKDAY = /^([+-]?\d{1,2})?(SU|MO|TU|WE|TH|FR|SA)$/;
378
+
379
+ interface ByWeekday {
380
+ ordinal: number;
381
+ weekday: number;
382
+ }
383
+
384
+ interface FollowedRule {
385
+ rule: ICAL.Recur;
386
+ startShift: number;
387
+ }
388
+
389
+ const readByWeekday = (value: string): ByWeekday | null => {
390
+ const match = BY_WEEKDAY.exec(value);
391
+ if (!match) return null;
392
+ return {
393
+ ordinal: Number(match[1] ?? 0),
394
+ weekday: WEEKDAYS.indexOf(match[2] ?? ""),
395
+ };
396
+ };
397
+
398
+ const weekdayOf = (time: ICAL.Time): number => time.dayOfWeek() - 1;
399
+
400
+ const weekdayOfDate = (year: number, month: number, day: number): number =>
401
+ new Date(Date.UTC(year, month - 1, day)).getUTCDay();
402
+
403
+ const ordinalOf = (
404
+ position: number,
405
+ length: number,
406
+ original: number,
407
+ ): number => {
408
+ const fromStart = Math.floor((position - 1) / 7) + 1;
409
+ const fromLast = -(Math.floor((length - position) / 7) + 1);
410
+ const preferred = original < 0 ? fromLast : fromStart;
411
+ if (Math.abs(preferred) < 5 || Math.abs(original) >= 5) return preferred;
412
+ return original < 0 ? fromStart : fromLast;
413
+ };
414
+
415
+ const nthWeekday = (
416
+ year: number,
417
+ month: number,
418
+ weekday: number,
419
+ ordinal: number,
420
+ ): number | null => {
421
+ const length = ICAL.Time.daysInMonth(month, year);
422
+ if (ordinal > 0) {
423
+ const first = 1 + ((weekday - weekdayOfDate(year, month, 1) + 7) % 7);
424
+ const day = first + 7 * (ordinal - 1);
425
+ return day <= length ? day : null;
426
+ }
427
+ const last =
428
+ length - ((weekdayOfDate(year, month, length) - weekday + 7) % 7);
429
+ const day = last + 7 * (ordinal + 1);
430
+ return day >= 1 ? day : null;
431
+ };
432
+
433
+ const firstNumberedSlot = (
434
+ from: ICAL.Time,
435
+ month: number,
436
+ yearly: boolean,
437
+ weekday: number,
438
+ ordinal: number,
439
+ ): ICAL.Time | null => {
440
+ for (let step = 0; step < 48; step += 1) {
441
+ const monthIndex = yearly ? month - 1 : from.month - 1 + step;
442
+ const year = from.year + (yearly ? step : Math.floor(monthIndex / 12));
443
+ const candidateMonth = (monthIndex % 12) + 1;
444
+ const day = nthWeekday(year, candidateMonth, weekday, ordinal);
445
+ if (day !== null) {
446
+ const slot = from.clone();
447
+ slot.adjust(
448
+ civilDay(ICAL.Time.fromData({ year, month: candidateMonth, day })) -
449
+ civilDay(from),
450
+ 0,
451
+ 0,
452
+ 0,
453
+ );
454
+ return slot;
455
+ }
456
+ }
457
+ return null;
458
+ };
459
+
460
+ const unmovable = <T>(after: ICAL.Time, reason: string): CalendarResult<T> =>
461
+ calendarFailure(
462
+ "UnmovableRecurrenceRule",
463
+ `This series repeats ${reason}, so it cannot move to ${after.toString().slice(0, 10)}. Pick a new repeat together with the new date, or move only this event.`,
464
+ );
465
+
466
+ const followStart = (
467
+ rule: ICAL.Recur,
468
+ seriesStart: ICAL.Time,
469
+ newStart: ICAL.Time,
470
+ anchor: ICAL.Time,
471
+ ): CalendarResult<FollowedRule> => {
472
+ const days = civilDay(newStart) - civilDay(seriesStart);
473
+ if (days === 0) return { ok: true, value: { rule, startShift: 0 } };
474
+ const anchorBefore = anchor.clone();
475
+ anchorBefore.adjust(-days, 0, 0, 0);
476
+ const moved = rule.clone();
477
+ const parts = moved.parts;
478
+ const kept = (startShift: number): CalendarResult<FollowedRule> => ({
479
+ ok: true,
480
+ value: { rule: moved, startShift },
481
+ });
482
+
483
+ const pinned = (["BYSETPOS", "BYYEARDAY", "BYWEEKNO"] as const).some(
484
+ (name) => (parts[name]?.length ?? 0) > 0,
485
+ );
486
+ if (pinned) return unmovable(anchor, "on a pattern of positions");
487
+
488
+ const byMonthDay = parts.BYMONTHDAY ?? [];
489
+ if (byMonthDay.length > 0) {
490
+ if (byMonthDay.length !== 1 || byMonthDay[0] !== anchorBefore.day) {
491
+ return unmovable(anchor, "on several days of the month");
492
+ }
493
+ parts.BYMONTHDAY = [anchor.day];
494
+ }
495
+
496
+ const byMonth = parts.BYMONTH ?? [];
497
+ if (byMonth.length > 0 && anchor.month !== anchorBefore.month) {
498
+ if (byMonth.length !== 1 || byMonth[0] !== anchorBefore.month) {
499
+ return unmovable(anchor, "in several months");
500
+ }
501
+ parts.BYMONTH = [anchor.month];
502
+ }
503
+
504
+ const byDay = parts.BYDAY ?? [];
505
+ const weekdays = byDay
506
+ .map(readByWeekday)
507
+ .filter((weekday): weekday is ByWeekday => weekday !== null);
508
+ if (weekdays.length !== byDay.length) {
509
+ return unmovable(anchor, "on days the calendar cannot read");
510
+ }
511
+ if (weekdays.length === 0) return kept(0);
512
+
513
+ const numbered = weekdays.find((weekday) => weekday.ordinal !== 0);
514
+ if (numbered) {
515
+ if (weekdays.length !== 1) {
516
+ return unmovable(anchor, "on more than one numbered weekday");
517
+ }
518
+ const yearly = moved.freq === "YEARLY";
519
+ if (yearly && (parts.BYMONTH?.length ?? 0) === 0) {
520
+ return unmovable(anchor, "on a numbered weekday of the year");
521
+ }
522
+ const ordinal = ordinalOf(
523
+ anchor.day,
524
+ ICAL.Time.daysInMonth(anchor.month, anchor.year),
525
+ numbered.ordinal,
526
+ );
527
+ const weekday = weekdayOf(anchor);
528
+ parts.BYDAY = [`${ordinal}${WEEKDAYS[weekday]}`];
529
+ const first = firstNumberedSlot(
530
+ seriesStart,
531
+ anchor.month,
532
+ yearly,
533
+ weekday,
534
+ ordinal,
535
+ );
536
+ if (!first) return unmovable(anchor, "on a weekday that never comes");
537
+ return kept(civilDay(first) - civilDay(newStart));
538
+ }
539
+
540
+ const set = weekdays.map((weekday) => weekday.weekday);
541
+ const workweek =
542
+ set.length === WORKWEEK.length &&
543
+ WORKWEEK.every((weekday) => set.includes(weekday));
544
+ if (moved.freq !== "WEEKLY" || workweek) {
545
+ if (!set.includes(weekdayOf(anchor))) {
546
+ return unmovable(anchor, "on a set of weekdays without that day");
547
+ }
548
+ return kept(-days);
549
+ }
550
+
551
+ const shift = (((weekdayOf(newStart) - weekdayOf(seriesStart)) % 7) + 7) % 7;
552
+ if (moved.interval > 1 && set.length > 1) {
553
+ const weekStart = moved.wkst - 1;
554
+ const wraps = set.map(
555
+ (weekday) => ((weekday - weekStart + 7) % 7) + shift >= 7,
556
+ );
557
+ if (wraps.some((wrap) => wrap !== wraps[0])) {
558
+ return unmovable(anchor, "on several days every few weeks");
559
+ }
560
+ }
561
+ parts.BYDAY = set.map((weekday) => WEEKDAYS[(weekday + shift) % 7] ?? "");
562
+ return kept(0);
563
+ };
564
+
565
+ const shiftStart = (master: ICAL.Component, days: number): void => {
566
+ if (days === 0) return;
567
+ shiftProperty(master, "dtstart", days);
568
+ shiftProperty(master, "dtend", days);
569
+ };
570
+
571
+ const followRule = (
572
+ master: ICAL.Component,
573
+ before: SeriesShape,
574
+ daysIn: number,
575
+ ): CalendarResult<null> => {
576
+ const property = master.getFirstProperty("rrule");
577
+ const rule = property?.getFirstValue();
578
+ const start = master.getFirstPropertyValue("dtstart");
579
+ if (!property || !(rule instanceof ICAL.Recur)) {
580
+ return { ok: true, value: null };
581
+ }
582
+ if (!(start instanceof ICAL.Time)) return { ok: true, value: null };
583
+ const anchor = start.clone();
584
+ anchor.adjust(daysIn, 0, 0, 0);
585
+ const followed = followStart(rule, before.start, start, anchor);
586
+ if (!followed.ok) return followed;
587
+ property.setValue(followed.value.rule);
588
+ shiftStart(master, followed.value.startShift);
589
+ return { ok: true, value: null };
590
+ };
591
+
592
+ const untilAt = (slot: SeriesSlot, shape: SeriesShape): ICAL.Time => {
593
+ const floating =
594
+ shape.tzid === "" && slot.time.zone !== ICAL.Timezone.utcTimezone;
595
+ if (slot.time.isDate || floating) return slot.time.clone();
596
+ return ICAL.Time.fromJSDate(new Date(slot.instantMs), true);
597
+ };
598
+
599
+ const followUntil = (
600
+ master: ICAL.Component,
601
+ before: SeriesShape,
602
+ collectionTimezone: string,
603
+ ): void => {
604
+ if (!before.rule || before.rule.until === null) return;
605
+ const property = master.getFirstProperty("rrule");
606
+ const rule = property?.getFirstValue();
607
+ const now = seriesShape(master);
608
+ if (!property || !(rule instanceof ICAL.Recur) || !now) return;
609
+ if (rule.until === null) return;
610
+
611
+ const previous = ruleSlots(before, collectionTimezone, () => false);
612
+ if (previous.length === 0 || previous.length >= CALENDAR_WINDOW_MAX_STEPS) {
613
+ return;
614
+ }
615
+ const open = rule.clone();
616
+ open.until = null;
617
+ const next = ruleSlots(
618
+ { ...now, rule: open },
619
+ collectionTimezone,
620
+ (_instantMs, count) => count >= previous.length,
621
+ );
622
+ const last = next[previous.length - 1];
623
+ if (!last) return;
624
+ rule.until = untilAt(last, now);
625
+ property.setValue(rule);
626
+ };
627
+
628
+ const replaceTime = (
629
+ component: ICAL.Component,
630
+ name: string,
631
+ time: ICAL.Time,
632
+ tzid: string,
633
+ ): void => {
634
+ component.removeAllProperties(name);
635
+ component.addProperty(timeProperty(name, time, tzid));
636
+ };
637
+
338
638
  const followSlots = (
339
639
  calendar: ParsedCalendar,
340
- before: ICAL.Time,
341
- after: ICAL.Time,
640
+ collectionTimezone: string,
641
+ before: SeriesShape,
642
+ after: SeriesShape,
342
643
  ): void => {
343
- if (before.isDate !== after.isDate) return;
344
- const tzid = dtStartTzid(calendar.master);
644
+ const instantOf = (value: ICAL.Time, tzid: string): number =>
645
+ resolveTime(value, tzid, collectionTimezone).instantMs;
646
+
647
+ const exceptionInstants: number[] = [];
345
648
  for (const override of calendar.overrides) {
346
- const recurrenceId = override.getFirstPropertyValue("recurrence-id");
649
+ const property = override.getFirstProperty("recurrence-id");
650
+ const value = property?.getFirstValue();
651
+ if (value instanceof ICAL.Time) {
652
+ exceptionInstants.push(instantOf(value, tzidOf(property)));
653
+ }
654
+ }
655
+ for (const property of calendar.master.getAllProperties("exdate")) {
656
+ for (const value of property.getValues()) {
657
+ if (value instanceof ICAL.Time) {
658
+ exceptionInstants.push(instantOf(value, tzidOf(property)));
659
+ }
660
+ }
661
+ }
662
+ const latest = Math.max(Number.NEGATIVE_INFINITY, ...exceptionInstants);
663
+
664
+ const previous = ruleSlots(
665
+ before,
666
+ collectionTimezone,
667
+ (instantMs) => instantMs > latest,
668
+ );
669
+ const indexByInstant = new Map(
670
+ previous.map((slot, index) => [slot.instantMs, index]),
671
+ );
672
+ const next = ruleSlots(
673
+ after,
674
+ collectionTimezone,
675
+ (_instantMs, count) => count >= previous.length,
676
+ );
677
+ const moved = (value: ICAL.Time, tzid: string): ICAL.Time => {
678
+ const index = indexByInstant.get(instantOf(value, tzid));
679
+ const slot = index === undefined ? undefined : next[index];
680
+ return slot ? slot.time.clone() : reslot(value, before.start, after.start);
681
+ };
682
+
683
+ for (const override of calendar.overrides) {
684
+ const property = override.getFirstProperty("recurrence-id");
685
+ const recurrenceId = property?.getFirstValue();
347
686
  if (!(recurrenceId instanceof ICAL.Time)) continue;
348
- override.removeAllProperties("recurrence-id");
349
- override.addProperty(
350
- timeProperty("recurrence-id", reslot(recurrenceId, before, after), tzid),
351
- );
687
+ const slot = moved(recurrenceId, tzidOf(property));
688
+ const start = override.getFirstPropertyValue("dtstart");
689
+ const untouched =
690
+ start instanceof ICAL.Time &&
691
+ instantOf(start, dtStartTzid(override)) ===
692
+ instantOf(recurrenceId, tzidOf(property)) &&
693
+ new ICAL.Event(override).duration.toSeconds() === before.durationSeconds;
694
+ replaceTime(override, "recurrence-id", slot, after.tzid);
695
+ if (!untouched) continue;
696
+ const end = slot.clone();
697
+ end.addDuration(ICAL.Duration.fromSeconds(after.durationSeconds));
698
+ override.removeAllProperties("duration");
699
+ replaceTime(override, "dtstart", slot, after.tzid);
700
+ replaceTime(override, "dtend", end, after.tzid);
352
701
  }
702
+
353
703
  for (const name of ["exdate", "rdate"]) {
354
704
  for (const property of calendar.master.getAllProperties(name)) {
355
705
  const values = property.getValues();
356
706
  if (!values.every((value: unknown) => value instanceof ICAL.Time)) {
357
707
  continue;
358
708
  }
709
+ const tzid = tzidOf(property);
359
710
  calendar.master.removeProperty(property);
360
711
  for (const value of values as ICAL.Time[]) {
361
- calendar.master.addProperty(
362
- timeProperty(name, reslot(value, before, after), tzid),
363
- );
712
+ const target =
713
+ name === "exdate"
714
+ ? moved(value, tzid)
715
+ : reslot(value, before.start, after.start);
716
+ calendar.master.addProperty(timeProperty(name, target, after.tzid));
364
717
  }
365
718
  }
366
719
  }
367
720
  };
368
721
 
369
- /**
370
- * A whole-series edit made from one occurrence of it.
371
- *
372
- * A start or end in the patch is that occurrence's new time, so each is moved
373
- * back by the days between where that occurrence is drawn and where the series
374
- * begins. The series keeps its first day and its weekday, and only what was
375
- * edited changes.
376
- */
377
- const applyToSeriesFrom = async (
722
+ const occurrenceOffset = (
378
723
  calendar: ParsedCalendar,
379
724
  collectionTimezone: string,
380
725
  recurrenceId: string,
381
- patch: Partial<CalendarEventFields>,
382
- ): Promise<CalendarResult<ScopedWrite>> => {
726
+ seriesStart: ICAL.Time,
727
+ ): CalendarResult<number> => {
728
+ if (recurrenceId === "") return { ok: true, value: 0 };
383
729
  const found = findOccurrence(calendar, collectionTimezone, recurrenceId);
384
730
  if (!found.ok) return found;
385
- const seriesStart = calendar.master.getFirstPropertyValue("dtstart");
386
731
  const shown = shownStart(found.value);
387
- if (!(seriesStart instanceof ICAL.Time) || !shown) {
732
+ if (!shown) {
388
733
  return calendarFailure(
389
734
  "UnknownOccurrence",
390
735
  `${recurrenceId} has no start to move the series from`,
391
736
  );
392
737
  }
393
- const before = seriesStart.clone();
394
- const beforeTzid = dtStartTzid(calendar.master);
395
- const daysIn = civilDay(shown) - civilDay(before);
738
+ return { ok: true, value: civilDay(shown) - civilDay(seriesStart) };
739
+ };
740
+
741
+ const applyToSeries = async (
742
+ calendar: ParsedCalendar,
743
+ collectionTimezone: string,
744
+ recurrenceId: string,
745
+ patch: Partial<CalendarEventFields>,
746
+ ): Promise<CalendarResult<ScopedWrite>> => {
747
+ const before = seriesShape(calendar.master);
748
+ if (!before) {
749
+ return calendarFailure(
750
+ "UnknownOccurrence",
751
+ "this series has no start to move",
752
+ );
753
+ }
754
+ const daysIn = occurrenceOffset(
755
+ calendar,
756
+ collectionTimezone,
757
+ recurrenceId,
758
+ before.start,
759
+ );
760
+ if (!daysIn.ok) return daysIn;
396
761
 
397
762
  const applied = await applyEventFields(
398
763
  calendar.master,
@@ -401,20 +766,19 @@ const applyToSeriesFrom = async (
401
766
  );
402
767
  if (!applied.ok) return applied;
403
768
  if (patch.start !== undefined) {
404
- shiftProperty(calendar.master, "dtstart", -daysIn);
769
+ shiftProperty(calendar.master, "dtstart", -daysIn.value);
405
770
  }
406
771
  if (patch.end !== undefined) {
407
- shiftProperty(calendar.master, "dtend", -daysIn);
772
+ shiftProperty(calendar.master, "dtend", -daysIn.value);
408
773
  }
409
-
410
- const after = calendar.master.getFirstPropertyValue("dtstart");
411
- if (
412
- after instanceof ICAL.Time &&
413
- (after.toString() !== before.toString() ||
414
- dtStartTzid(calendar.master) !== beforeTzid)
415
- ) {
416
- followSlots(calendar, before, after);
774
+ if (patch.recurrenceRule === undefined) {
775
+ const followed = followRule(calendar.master, before, daysIn.value);
776
+ if (!followed.ok) return followed;
777
+ followUntil(calendar.master, before, collectionTimezone);
417
778
  }
779
+
780
+ const after = seriesShape(calendar.master);
781
+ if (after) followSlots(calendar, collectionTimezone, before, after);
418
782
  return { ok: true, value: replaceWith(calendar) };
419
783
  };
420
784
 
@@ -506,14 +870,10 @@ export const applyScopedUpdate = async (
506
870
  patch: Partial<CalendarEventFields>,
507
871
  ): Promise<CalendarResult<ScopedWrite>> => {
508
872
  if (input.scope === RecurrenceScope.All) {
509
- if (
510
- input.recurrenceId === "" ||
511
- !hasRecurrence(calendar) ||
512
- !touchesTime(patch)
513
- ) {
873
+ if (!hasRecurrence(calendar) || !touchesTime(patch)) {
514
874
  return applyToMaster(calendar, collectionTimezone, patch);
515
875
  }
516
- return applyToSeriesFrom(
876
+ return applyToSeries(
517
877
  calendar,
518
878
  collectionTimezone,
519
879
  input.recurrenceId,
@@ -20,6 +20,7 @@ import {
20
20
  type CalendarWindowRepositories,
21
21
  listBusySpans,
22
22
  listCalendarInstances,
23
+ listObjectInstances,
23
24
  mergeBusySpans,
24
25
  } from "./window.js";
25
26
 
@@ -369,6 +370,78 @@ describe("listCalendarInstances", () => {
369
370
  });
370
371
  });
371
372
 
373
+ describe("listObjectInstances", () => {
374
+ const ownRows = (resource: StoredResource) => ({
375
+ calendarEventIndex: {
376
+ listForObject: async () => resource.rows,
377
+ },
378
+ });
379
+
380
+ it("reads one resource's own rows where its index reaches the window", async () => {
381
+ const calendar = collection();
382
+ const weekly = await store(
383
+ calendar,
384
+ "weekly.ics",
385
+ ical(
386
+ "BEGIN:VCALENDAR",
387
+ "VERSION:2.0",
388
+ "BEGIN:VEVENT",
389
+ "UID:weekly@example.com",
390
+ "DTSTART:20260907T090000Z",
391
+ "DTEND:20260907T100000Z",
392
+ "SUMMARY:Stand-up",
393
+ "RRULE:FREQ=WEEKLY;COUNT=4",
394
+ "END:VEVENT",
395
+ "END:VCALENDAR",
396
+ ),
397
+ );
398
+
399
+ const instances = await listObjectInstances(
400
+ ownRows(weekly),
401
+ calendar,
402
+ weekly.object,
403
+ { from: "2026-09-10T00:00:00Z", to: "2026-09-25T00:00:00Z" },
404
+ );
405
+
406
+ assert.deepEqual(
407
+ instances.map((instance) => instance.startAt),
408
+ ["2026-09-14T09:00:00Z", "2026-09-21T09:00:00Z"],
409
+ );
410
+ });
411
+
412
+ it("expands the resource live where its index stops before the window", async () => {
413
+ const calendar = collection();
414
+ const openEnded = await store(
415
+ calendar,
416
+ "open.ics",
417
+ ical(
418
+ "BEGIN:VCALENDAR",
419
+ "VERSION:2.0",
420
+ "BEGIN:VEVENT",
421
+ "UID:open@example.com",
422
+ "DTSTART:20200907T090000Z",
423
+ "DTEND:20200907T100000Z",
424
+ "SUMMARY:Weekly one-to-one",
425
+ "RRULE:FREQ=WEEKLY",
426
+ "END:VEVENT",
427
+ "END:VCALENDAR",
428
+ ),
429
+ );
430
+
431
+ const instances = await listObjectInstances(
432
+ ownRows(openEnded),
433
+ calendar,
434
+ openEnded.object,
435
+ { from: "2026-09-14T00:00:00Z", to: "2026-09-28T00:00:00Z" },
436
+ );
437
+
438
+ assert.deepEqual(
439
+ instances.map((instance) => instance.startAt),
440
+ ["2026-09-14T09:00:00Z", "2026-09-21T09:00:00Z"],
441
+ );
442
+ });
443
+ });
444
+
372
445
  describe("mergeBusySpans", () => {
373
446
  it("collapses overlapping and touching spans", () => {
374
447
  assert.deepEqual(
package/src/window.ts CHANGED
@@ -158,6 +158,55 @@ export const listCalendarInstances = async (
158
158
  .sort((left, right) => left.startAt.localeCompare(right.startAt));
159
159
  };
160
160
 
161
+ /**
162
+ * The occurrences of one resource in a window, read from that resource alone:
163
+ * its own index rows where the index reaches the window, and a live expansion
164
+ * of it where it does not.
165
+ */
166
+ export const listObjectInstances = async (
167
+ repositories: {
168
+ calendarEventIndex: Pick<ICalendarEventIndexRepository, "listForObject">;
169
+ },
170
+ collection: CalendarCollectionItem,
171
+ object: CalendarObjectItem,
172
+ window: CalendarWindow,
173
+ ): Promise<CalendarInstance[]> => {
174
+ const fromMs = Date.parse(window.from);
175
+ const toMs = Date.parse(window.to);
176
+ const indexReaches =
177
+ object.expandedThrough === "" || object.expandedThrough >= window.to;
178
+
179
+ const occurrences = indexReaches
180
+ ? await repositories.calendarEventIndex.listForObject(
181
+ collection.calendarId,
182
+ object.calendarObjectId,
183
+ )
184
+ : await expandObject(object, collection.timezone, fromMs, toMs);
185
+
186
+ return occurrences
187
+ .map((occurrence) => instanceOf(collection, object, occurrence))
188
+ .filter((instance) => overlapsWindow(instance, fromMs, toMs))
189
+ .sort((left, right) => left.startAt.localeCompare(right.startAt));
190
+ };
191
+
192
+ const expandObject = async (
193
+ object: CalendarObjectItem,
194
+ timezone: string,
195
+ fromMs: number,
196
+ toMs: number,
197
+ ): Promise<CalendarOccurrenceInput[]> => {
198
+ const parsed = await parseCalendar(object.icalData);
199
+ if (!parsed.ok) {
200
+ throw new Error(
201
+ `stored calendar object ${object.calendarObjectId} no longer parses: ${parsed.error.message}`,
202
+ );
203
+ }
204
+ return expandCalendarWindow(parsed.value, timezone, {
205
+ fromMs: fromMs - LOOKBACK_MS,
206
+ toMs,
207
+ });
208
+ };
209
+
161
210
  const overlapsWindow = (
162
211
  instance: CalendarInstance,
163
212
  fromMs: number,