Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {
DateOnlySchema,
DateTimeSchema,
TimeZoneSchema,
} from "@core/types/domain-primitives";
import { type EventSchedule } from "@core/types/event.contracts";
import { shiftSeriesScheduleByOccurrenceEdit } from "@core/util/event/shift-series-schedule-by-occurrence-edit";
import { describe, expect, it } from "bun:test";

const timed = (start: string, end: string): EventSchedule => ({
kind: "timed",
start: DateTimeSchema.parse(start),
end: DateTimeSchema.parse(end),
timeZone: TimeZoneSchema.parse("UTC"),
});

const allDay = (start: string, end: string): EventSchedule => ({
kind: "allDay",
start: DateOnlySchema.parse(start),
end: DateOnlySchema.parse(end),
});

describe("shiftSeriesScheduleByOccurrenceEdit", () => {
it("shifts a timed series master by a middle occurrence's day delta", () => {
const base = timed("2026-05-04T09:00:00.000Z", "2026-05-04T10:00:00.000Z");
const edited = timed(
"2026-05-07T09:00:00.000Z",
"2026-05-07T10:00:00.000Z",
);

expect(
shiftSeriesScheduleByOccurrenceEdit(
base,
"2026-05-06T09:00:00.000Z",
edited,
),
).toEqual(timed("2026-05-05T09:00:00+00:00", "2026-05-05T10:00:00+00:00"));
});

it("propagates a timed duration change onto the master", () => {
const base = timed("2026-05-04T09:00:00.000Z", "2026-05-04T10:00:00.000Z");
const edited = timed(
"2026-05-06T09:00:00.000Z",
"2026-05-06T11:00:00.000Z",
);

expect(
shiftSeriesScheduleByOccurrenceEdit(
base,
"2026-05-06T09:00:00.000Z",
edited,
),
).toEqual(timed("2026-05-04T09:00:00+00:00", "2026-05-04T11:00:00+00:00"));
});

it("returns the timed base unchanged for a zero delta", () => {
const base = timed("2026-05-04T09:00:00.000Z", "2026-05-04T10:00:00.000Z");
const edited = timed(
"2026-05-06T09:00:00.000Z",
"2026-05-06T10:00:00.000Z",
);

expect(
shiftSeriesScheduleByOccurrenceEdit(
base,
"2026-05-06T09:00:00.000Z",
edited,
),
).toBe(base);
});

it("shifts an all-day series master by a middle occurrence's day delta", () => {
const base = allDay("2026-05-04", "2026-05-05");
const edited = allDay("2026-05-07", "2026-05-08");

expect(
shiftSeriesScheduleByOccurrenceEdit(
base,
"2026-05-06T00:00:00.000Z",
edited,
),
).toEqual(allDay("2026-05-05", "2026-05-06"));
});

it("returns the all-day base unchanged for a zero delta", () => {
const base = allDay("2026-05-04", "2026-05-05");
const edited = allDay("2026-05-06", "2026-05-07");

expect(
shiftSeriesScheduleByOccurrenceEdit(
base,
"2026-05-06T00:00:00.000Z",
edited,
),
).toBe(base);
});

it("returns edited unchanged on a timed ↔ allDay kind flip", () => {
const base = timed("2026-05-04T09:00:00.000Z", "2026-05-04T10:00:00.000Z");
const edited = allDay("2026-05-07", "2026-05-08");

expect(
shiftSeriesScheduleByOccurrenceEdit(
base,
"2026-05-06T09:00:00.000Z",
edited,
),
).toBe(edited);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { DateOnlySchema, DateTimeSchema } from "@core/types/domain-primitives";
import { type EventSchedule } from "@core/types/event.contracts";
import dayjs from "@core/util/date/dayjs";

/**
* Apply an occurrence edit's schedule delta onto the series base.
*
* Clients send the occurrence's absolute schedule on replace. Treating that
* absolute value as the series DTSTART would shift the whole series by the
* occurrence's offset from the master. Infer the pre-edit occurrence end from
* the master's duration so a duration change on the occurrence still
* propagates.
*
* Kind flips (timed ↔ allDay) return `edited` unchanged — there is no
* unambiguous delta onto a differently-shaped master schedule.
*/
export function shiftSeriesScheduleByOccurrenceEdit(
base: EventSchedule,
occurrenceStart: string,
edited: EventSchedule,
): EventSchedule {
if (base.kind !== edited.kind) {
return edited;
}

if (base.kind === "timed" && edited.kind === "timed") {
const originalStart = dayjs(occurrenceStart);
const originalEnd = originalStart.add(
dayjs(base.end).diff(base.start),
"millisecond",
);
const startDelta = dayjs(edited.start).diff(originalStart);
const endDelta = dayjs(edited.end).diff(originalEnd);
if (
startDelta === 0 &&
endDelta === 0 &&
base.timeZone === edited.timeZone
) {
return base;
}
return {
kind: "timed",
start: DateTimeSchema.parse(
dayjs(base.start).add(startDelta, "millisecond").format(),
),
end: DateTimeSchema.parse(
dayjs(base.end).add(endDelta, "millisecond").format(),
),
timeZone: edited.timeZone,
};
}

// allDay: occurrence ids embed midnight-Z; schedules use YYYY-MM-DD.
const originalStart = dayjs(occurrenceStart).utc().format("YYYY-MM-DD");
const baseDurationDays = dayjs(base.end).diff(base.start, "day");
const originalEnd = dayjs(originalStart)
.add(baseDurationDays, "day")
.format("YYYY-MM-DD");
const startDeltaDays = dayjs(edited.start).diff(dayjs(originalStart), "day");
const endDeltaDays = dayjs(edited.end).diff(dayjs(originalEnd), "day");
if (startDeltaDays === 0 && endDeltaDays === 0) {
return base;
}
return {
kind: "allDay",
start: DateOnlySchema.parse(
dayjs(base.start).add(startDeltaDays, "day").format("YYYY-MM-DD"),
),
end: DateOnlySchema.parse(
dayjs(base.end).add(endDeltaDays, "day").format("YYYY-MM-DD"),
),
};
}
Loading