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
17 changes: 13 additions & 4 deletions docs/v1.5/core-concepts/schedules.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,17 @@ The end time must be after the start. Hours use `0` through `23`; week-day value

### Daylight Saving Time (DST) Transitions

Rotations in OpsKnight are anchored to wall-clock hours in the schedule's configured IANA timezone:
OpsKnight resolves schedule boundaries against the configured IANA timezone rather than a fixed UTC offset:

- **Daily and Multi-Day Rotations**: Evaluated with calendar-day arithmetic so shifts always start at the intended local hour (e.g. 09:00 AM) across 23-hour spring-forward and 25-hour fall-back transitions.
- **Sub-Daily Rotations (1h, 2h, 4h, 6h, 8h, 12h)**: Derived from local day boundaries rather than continuous millisecond addition, preventing rotation drift and eliminating 1-hour overlap or gap defects.
- **Daily and Multi-Day Rotations**: Evaluated with calendar-day arithmetic so shifts stay anchored to the intended local wall-clock hour across 23-hour, 24-hour, and 25-hour days.
- **Sub-Daily Wall-Clock Rotations (1h, 2h, 3h, 4h, 6h, 8h, 12h)**: Intervals that divide a local day are anchored to local wall-clock boundaries. A skipped spring-forward boundary can collapse a nominal slot to zero duration; a repeated fall-back hour can lengthen a slot. Coverage remains contiguous because a full-duty slot always ends at the next resolved rotation boundary.
- **Arbitrary Sub-Daily Rotations**: Durations such as `5h` or `7h` use elapsed-time semantics. They remain exact-duration and contiguous across DST, so their displayed local handoff hour may move when the UTC offset changes.
- **Non-1-Hour Transitions**: Resolution uses the timezone database and does not assume DST always changes by exactly one hour; half-hour transitions such as `Australia/Lord_Howe` are handled by the same logic.
- **User-Entered Transition Times**: A local timestamp that does not exist during spring-forward, or occurs twice during fall-back, is rejected instead of being silently shifted to a different instant. Choose an unambiguous local time and save again.
- **Overrides**: Strictly filter for active users (`status === 'ACTIVE'`) to prevent deactivated users from holding scheduled coverage.

Generated recurrence boundaries use deterministic Temporal-compatible disambiguation: the earlier occurrence during a fall-back overlap and the later representable wall-clock time during a spring-forward gap. This rule applies only to generated recurrence boundaries; ambiguous user input is not silently accepted.

## Manage participants

Add active users to a layer and order them in the sequence they should rotate. Moving a participant changes future rotation order. Removing the only participant leaves the layer without coverage.
Expand Down Expand Up @@ -113,7 +118,11 @@ Verify participant order, rotation start, rotation length, schedule timezone, la

### Handoff is an hour early or late

Confirm the IANA timezone and inspect the date for a daylight-saving transition. Avoid treating a fixed UTC offset as a timezone.
Confirm the IANA timezone and inspect the date for a daylight-saving transition. Avoid treating a fixed UTC offset as a timezone. Also note that not every DST change is one hour.

### A transition-time form value is rejected

The selected local time is either nonexistent (spring-forward) or ambiguous (fall-back) in the schedule timezone. Choose an unambiguous wall-clock time; OpsKnight intentionally refuses to guess which instant you meant.

### An override has no effect

Expand Down
174 changes: 165 additions & 9 deletions src/lib/__tests__/oncall.timezone.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { describe, expect, it } from 'vitest';
import { buildScheduleBlocks } from '../oncall';

function expectContinuousCoverage(blocks: Array<{ start: Date; end: Date }>) {
let previous: { start: Date; end: Date } | undefined;
for (const block of blocks) {
if (previous) {
expect(previous.end.getTime()).toBe(block.start.getTime());
}
previous = block;
}
}

// Regression: restrictions should be evaluated in the schedule timezone, not server/local time.
describe('buildScheduleBlocks timezone-aware restrictions', () => {
it('skips a block when local day is allowed but schedule timezone day is not', () => {
Expand Down Expand Up @@ -29,7 +39,6 @@ describe('buildScheduleBlocks timezone-aware restrictions', () => {
});

it('preserves wall-clock start time across DST spring-forward boundary', () => {
// 2026-03-07 is before DST (EST, UTC-5), 2026-03-08 is transition, 2026-03-09 is after (EDT, UTC-4)
const layerStart = new Date('2026-03-07T05:00:00Z'); // 2026-03-07 00:00 EST
const layer = {
id: 'layer-dst',
Expand All @@ -50,11 +59,8 @@ describe('buildScheduleBlocks timezone-aware restrictions', () => {
const blocks = buildScheduleBlocks([layer], [], windowStart, windowEnd, 'America/New_York');

expect(blocks.length).toBe(3);
// Day 0: 2026-03-07 00:00 EST -> 05:00 UTC
expect(blocks[0].start.toISOString()).toBe('2026-03-07T05:00:00.000Z');
// Day 1: 2026-03-08 00:00 EST -> 05:00 UTC
expect(blocks[1].start.toISOString()).toBe('2026-03-08T05:00:00.000Z');
// Day 2: 2026-03-09 00:00 EDT -> 04:00 UTC (not 05:00 UTC!)
expect(blocks[2].start.toISOString()).toBe('2026-03-09T04:00:00.000Z');
});

Expand Down Expand Up @@ -100,8 +106,159 @@ describe('buildScheduleBlocks timezone-aware restrictions', () => {
expect(blocks[0].end.getTime() - blocks[0].start.getTime()).toBe(13 * 3600_000);
});

it('keeps 1-hour wall-clock rotations continuous through spring-forward', () => {
const layer = {
id: 'hourly-spring',
name: 'Hourly',
start: new Date('2026-03-08T05:00:00.000Z'), // 00:00 EST
end: null,
rotationLengthHours: 1,
users: [
{ userId: 'u1', user: { name: 'User One' }, position: 0 },
{ userId: 'u2', user: { name: 'User Two' }, position: 1 },
],
};
const blocks = buildScheduleBlocks(
[layer],
[],
layer.start,
new Date('2026-03-08T12:00:00.000Z'),
'America/New_York'
);

expectContinuousCoverage(blocks);
expect(blocks[0].start.toISOString()).toBe('2026-03-08T05:00:00.000Z');
expect(blocks[blocks.length - 1].end.toISOString()).toBe('2026-03-08T12:00:00.000Z');
expect(blocks.every(block => block.end > block.start)).toBe(true);
});

it('keeps 1-hour wall-clock rotations continuous through fall-back', () => {
const layer = {
id: 'hourly-fall',
name: 'Hourly',
start: new Date('2026-11-01T04:00:00.000Z'), // 00:00 EDT
end: null,
rotationLengthHours: 1,
users: [
{ userId: 'u1', user: { name: 'User One' }, position: 0 },
{ userId: 'u2', user: { name: 'User Two' }, position: 1 },
],
};
const blocks = buildScheduleBlocks(
[layer],
[],
layer.start,
new Date('2026-11-01T10:00:00.000Z'),
'America/New_York'
);

expectContinuousCoverage(blocks);
expect(blocks[1].end.getTime() - blocks[1].start.getTime()).toBe(2 * 3600_000);
});

it('never moves a layer that starts in the second fall-back occurrence before its exact start', () => {
const layer = {
id: 'fall-second-occurrence',
name: 'Second Occurrence',
start: new Date('2026-11-01T06:30:00.000Z'), // 01:30 EST, the second 01:30
end: null,
rotationLengthHours: 1,
users: [
{ userId: 'u1', user: { name: 'User One' }, position: 0 },
{ userId: 'u2', user: { name: 'User Two' }, position: 1 },
],
};

const blocks = buildScheduleBlocks(
[layer],
[],
new Date('2026-11-01T05:00:00.000Z'),
new Date('2026-11-01T10:00:00.000Z'),
'America/New_York'
);

expect(blocks[0].start.toISOString()).toBe('2026-11-01T06:30:00.000Z');
expect(blocks.every(block => block.start >= layer.start)).toBe(true);
expectContinuousCoverage(blocks);
});

it('keeps full-day timezone jumps monotonic without duplicate coverage', () => {
const layer = {
id: 'apia-hourly',
name: 'Apia Hourly',
start: new Date('2011-12-29T10:00:00.000Z'), // 2011-12-29 00:00 -10
end: null,
rotationLengthHours: 1,
users: [
{ userId: 'u1', user: { name: 'User One' }, position: 0 },
{ userId: 'u2', user: { name: 'User Two' }, position: 1 },
{ userId: 'u3', user: { name: 'User Three' }, position: 2 },
],
};

const windowEnd = new Date('2012-01-02T10:00:00.000Z');
const blocks = buildScheduleBlocks([layer], [], layer.start, windowEnd, 'Pacific/Apia');
const uniqueIntervals = new Set(
blocks.map(block => `${block.start.getTime()}-${block.end.getTime()}`)
);

expect(blocks).toHaveLength(96);
expect(uniqueIntervals.size).toBe(blocks.length);
expect(blocks[0].start.getTime()).toBe(layer.start.getTime());
expect(blocks[blocks.length - 1].end.getTime()).toBe(windowEnd.getTime());
expect(blocks.every(block => block.end > block.start)).toBe(true);
expectContinuousCoverage(blocks);
});

it('fast-forwards safely past a full-day timezone jump', () => {
const layer = {
id: 'apia-fast-forward',
name: 'Apia Fast Forward',
start: new Date('2011-12-29T10:00:00.000Z'),
end: null,
rotationLengthHours: 1,
users: [
{ userId: 'u1', user: { name: 'User One' }, position: 0 },
{ userId: 'u2', user: { name: 'User Two' }, position: 1 },
],
};
const windowStart = new Date('2012-02-01T10:00:00.000Z');
const windowEnd = new Date('2012-02-02T10:00:00.000Z');

const blocks = buildScheduleBlocks([layer], [], windowStart, windowEnd, 'Pacific/Apia');
expect(blocks).toHaveLength(24);
expect(blocks[0].start.getTime()).toBe(windowStart.getTime());
expect(blocks[blocks.length - 1].end.getTime()).toBe(windowEnd.getTime());
expectContinuousCoverage(blocks);
});

it('uses fixed elapsed semantics for arbitrary sub-daily rotations without DST gaps', () => {
const layer = {
id: 'five-hour',
name: 'Five Hour',
start: new Date('2026-03-08T05:00:00.000Z'), // 00:00 EST
end: null,
rotationLengthHours: 5,
users: [
{ userId: 'u1', user: { name: 'User One' }, position: 0 },
{ userId: 'u2', user: { name: 'User Two' }, position: 1 },
],
};
const blocks = buildScheduleBlocks(
[layer],
[],
layer.start,
new Date('2026-03-09T06:00:00.000Z'),
'America/New_York'
);

expectContinuousCoverage(blocks);
for (const block of blocks) {
expect(block.end.getTime() - block.start.getTime()).toBe(5 * 3600_000);
}
});

it('splits multi-day rotation block into hourly sub-blocks matching restriction window', () => {
// 168h (1 week) rotation starting Mon Dec 1 2025 00:00 UTC
const layerStart = new Date('2025-12-01T00:00:00Z');
const layer = {
id: 'layer-restricted',
Expand All @@ -117,16 +274,15 @@ describe('buildScheduleBlocks timezone-aware restrictions', () => {
},
};

const windowStart = new Date('2025-12-01T00:00:00Z'); // Monday 00:00 UTC
const windowEnd = new Date('2025-12-08T00:00:00Z'); // Next Monday 00:00 UTC
const windowStart = new Date('2025-12-01T00:00:00Z');
const windowEnd = new Date('2025-12-08T00:00:00Z');

const blocks = buildScheduleBlocks([layer], [], windowStart, windowEnd, 'UTC');

// 5 business days, 1 block per day from 09:00 to 17:00
expect(blocks.length).toBe(5);
expect(blocks[0].start.toISOString()).toBe('2025-12-01T09:00:00.000Z');
expect(blocks[0].end.toISOString()).toBe('2025-12-01T17:00:00.000Z');
expect(blocks[4].start.toISOString()).toBe('2025-12-05T09:00:00.000Z');
expect(blocks[4].end.toISOString()).toBe('2025-12-05T17:00:00.000Z');
});
});
});
61 changes: 61 additions & 0 deletions src/lib/__tests__/timezone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
addDaysToDateKey,
formatDateKeyInTimeZone,
parseDateTimeInTimeZone,
resolveLocalDateTimeInTimeZone,
startOfDayFromDateKey,
startOfNextDayFromDateKey,
} from '../timezone';
Expand Down Expand Up @@ -55,4 +57,63 @@ describe('timezone helpers', () => {
}
}
});

it('rejects nonexistent wall-clock input during spring-forward', () => {
expect(parseDateTimeInTimeZone('2026-03-08T02:30', 'America/New_York')).toBeNull();
});

it('rejects ambiguous wall-clock input during fall-back', () => {
expect(parseDateTimeInTimeZone('2026-11-01T01:30', 'America/New_York')).toBeNull();
});

it('resolves generated recurrence boundaries with Temporal-compatible DST semantics', () => {
const spring = resolveLocalDateTimeInTimeZone(
{ year: 2026, month: 3, day: 8, hour: 2, minute: 30 },
'America/New_York',
'compatible'
);
expect(spring?.toISOString()).toBe('2026-03-08T07:30:00.000Z'); // 03:30 EDT

const fallEarlier = resolveLocalDateTimeInTimeZone(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
'America/New_York',
'compatible'
);
const fallLater = resolveLocalDateTimeInTimeZone(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
'America/New_York',
'later'
);
expect(fallEarlier?.toISOString()).toBe('2026-11-01T05:30:00.000Z');
expect(fallLater?.toISOString()).toBe('2026-11-01T06:30:00.000Z');
});

it('handles 30-minute DST transitions without assuming a one-hour change', () => {
expect(parseDateTimeInTimeZone('2026-10-04T02:15', 'Australia/Lord_Howe')).toBeNull();
expect(parseDateTimeInTimeZone('2026-04-05T01:45', 'Australia/Lord_Howe')).toBeNull();

const compatible = resolveLocalDateTimeInTimeZone(
{ year: 2026, month: 10, day: 4, hour: 2, minute: 15 },
'Australia/Lord_Howe',
'compatible'
);
expect(compatible?.toISOString()).toBe('2026-10-03T15:45:00.000Z'); // 02:45 +11
});

it('handles full-day political timezone jumps', () => {
expect(parseDateTimeInTimeZone('2011-12-30T12:00', 'Pacific/Apia')).toBeNull();

const compatible = resolveLocalDateTimeInTimeZone(
{ year: 2011, month: 12, day: 30, hour: 12, minute: 0 },
'Pacific/Apia',
'compatible'
);
expect(compatible?.toISOString()).toBe('2011-12-30T22:00:00.000Z'); // 2011-12-31 12:00 +14
});

it('rejects invalid calendar values instead of normalizing them', () => {
expect(parseDateTimeInTimeZone('2026-02-30T10:00', 'UTC')).toBeNull();
expect(parseDateTimeInTimeZone('2026-13-01T10:00', 'UTC')).toBeNull();
expect(parseDateTimeInTimeZone('2026-01-01T24:00', 'UTC')).toBeNull();
});
});
Loading
Loading