Skip to content

fix(api): expand recurring events when serving range queries#382

Merged
ridafkih merged 1 commit into
ridafkih:mainfrom
agurod42:fix/expand-recurring-events
May 23, 2026
Merged

fix(api): expand recurring events when serving range queries#382
ridafkih merged 1 commit into
ridafkih:mainfrom
agurod42:fix/expand-recurring-events

Conversation

@agurod42
Copy link
Copy Markdown
Contributor

Summary

getEventsInRange matches event rows with a flat eventStatesTable.startTime BETWEEN start AND end filter. For recurring events the master row's startTime holds only the first occurrence, so DAILY / WEEKLY / etc. masters whose first occurrence falls outside the queried window are dropped entirely — every later occurrence is lost. This makes the ICS provider effectively unusable as a source of truth for any feed with recurring events (Bankingly feed in my case, but the same applies to anything pulled from Outlook / Exchange / Google ICS exports).

Repro against a fresh keeper instance synced with an ICS feed that has a DAILY event:

GET /api/events?from=2026-05-18T00:00:00Z&to=2026-05-18T23:59:59Z

returns only the one-off VEVENTs scheduled that day. The daily recurring meeting that should land at 10:00 is missing because its master startTime is two months earlier.

Why

ICS ingest in packages/calendar/src/ics/utils/parse-ics-events.ts correctly reads recurrenceRule and exceptionDates and stores them on the master row in event_states. The destination/write path consumes them via extendByRecurrenceRule in hasActiveFutureOccurrence to decide whether a recurring master is still active.

The read path never materialises occurrences. getEventsInRange only queries eventStatesTable.startTime BETWEEN start AND end, so the master row is included iff its first occurrence is in-window. If the rule has run for months, every subsequent occurrence is missing from the response.

This is symmetric with what the destination path already does (it knows recurring masters are special) — the read path just hadn't caught up.

Changes

Two source files + one new test file.

services/api/src/queries/expand-recurring-event.ts (new)

Pure function that takes a stored master row and the [windowStart, windowEnd] instants and returns the occurrences that fall in the window:

const expandRecurringEvent = (
  row: RecurringEventRow,
  windowStart: Date,
  windowEnd: Date,
): RecurringOccurrence[] => {
  const rule = parseRecurrenceRuleFromJson(row.recurrenceRule);
  if (!rule) {
    if (isWithinInclusive(row.startTime, windowStart, windowEnd)) {
      return [/* master as-is */];
    }
    return [];
  }
  const exceptions = parseExceptionDatesFromJson(row.exceptionDates);
  const durationMs = Math.max(
    row.endTime.getTime() - row.startTime.getTime(),
    0,
  );
  const dates = extendByRecurrenceRule(rule, {
    start: row.startTime,
    end: windowEnd,
    exceptions,
  });
  // Filter to [windowStart, windowEnd] and emit one occurrence per date,
  // inheriting the master duration.
};

Each occurrence gets a synthetic id of the form ${masterId}_${occurrenceISO} so downstream consumers can distinguish instances. The master's duration is carried onto each occurrence.

services/api/src/queries/get-events-in-range.ts

The synced-events query now considers two row classes:

or(
  and(
    isNull(eventStatesTable.recurrenceRule),
    gte(eventStatesTable.startTime, start),
    lte(eventStatesTable.startTime, end),
  ),
  and(
    isNotNull(eventStatesTable.recurrenceRule),
    lte(eventStatesTable.startTime, end),
  ),
)

Non-recurring rows still match by startTime in-range. Recurring masters match when their first occurrence is at-or-before the window end (later occurrences may still land inside the window even if the master is far in the past); the in-app expansion step then filters each materialised occurrence to [start, end].

packages/calendar/src/index.ts

Exports parseRecurrenceRuleFromJson and parseExceptionDatesFromJson so the API service can reuse them without reaching into internal paths.

userEventsTable doesn't have recurrenceRule / exceptionDates columns, so the user-events query is unchanged.

Testing

Added services/api/tests/queries/expand-recurring-event.test.ts covering:

  • DAILY rule emits one occurrence per day in the window
  • master duration preserved on each occurrence
  • EXDATE removes the matching occurrence
  • WEEKLY master whose first occurrence is months before the window still emits the in-window occurrences
  • until terminator caps the expansion
  • count terminator caps the expansion
  • missing/unparseable RRULE falls back to the master row when in-window, drops it when out
  • synthetic ids are stable per occurrence

bun run types clean across services/api and packages/calendar.

Manual verification against a Bankingly ICS feed: the previously-missing Daily HN recurring event now shows up in get_events responses for every day in the queried window; EXDATE'd days are correctly skipped.

Compatibility

  • KeeperEvent.id is typed as string, which the synthetic id format respects. Web clients that store ids as React keys / map entries keep working — keys are stable per occurrence.
  • The new branch keeps backwards-compatible behavior for events without a recurrenceRule (the existing startTime BETWEEN start AND end filter).
  • No schema changes.

Out of scope

  • RECURRENCE-ID overrides. The ingest path doesn't persist per-instance overrides (edited single occurrence, single-occurrence cancellation beyond plain EXDATE) yet, so the read path can't surface them either. That's a separate change — likely a recurrence_overrides table keyed by (master_id, occurrenceISO) — and worth its own PR once the storage shape is decided. For the common case of plain RRULE + EXDATE, the current PR is sufficient.
  • get_event(synthetic_id). The MCP get_event tool still expects a real master UUID and won't dereference a synthetic occurrence id. The expansion only affects get_events (list); fetching a recurring instance by its synthetic id would require a separate routing change. Mentioned for completeness but I don't see a current consumer that needs it.
  • Pagination. getEventsInRange doesn't paginate today; expanding recurring events makes large windows (e.g. a year of dailies) potentially noisy. The expansion is bounded by windowEnd, so as long as callers use reasonable windows (the web frontend uses 7-day pages) this is fine. If pagination is added later, the expansion will need to be re-considered to keep page boundaries deterministic.

`getEventsInRange` was matching events with `eventStatesTable.startTime
BETWEEN start AND end`. For recurring events, `startTime` holds the
first occurrence only, so DAILY/WEEKLY/etc. masters whose first
occurrence falls outside the window never matched — every later
occurrence was lost. This made the ICS provider effectively unusable as
a source of truth for any feed with recurring events.

Materialise occurrences at query time using `ts-ics`'s
`extendByRecurrenceRule`, honouring EXDATE and the rule's `until` /
`count` terminators. The synced query now selects:
  - one-off rows whose `startTime` is in-window, OR
  - recurring masters whose `startTime <= windowEnd`
and the in-app expansion step filters each materialised occurrence to
[start, end]. The master's duration is preserved on each occurrence.

Each materialised occurrence gets a synthetic id of the form
`<masterId>_<occurrenceISO>` so downstream consumers can distinguish
instances. RECURRENCE-ID overrides (per-instance edits beyond plain
EXDATE) are not yet persisted by the ingest path and so are not
honoured here — that requires a separate schema change.

Exports `parseRecurrenceRuleFromJson` / `parseExceptionDatesFromJson`
from `@keeper.sh/calendar` so the API can reuse them. Adds unit tests
covering DAILY/WEEKLY rules, EXDATE handling, UNTIL/COUNT terminators,
out-of-window masters, and synthetic-id stability.
@ridafkih ridafkih merged commit 81185f4 into ridafkih:main May 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants