mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
fix: keep the recurrence id of a daily series in another zone on the device's DST day
This commit is contained in:
@@ -38,3 +38,45 @@ describe('daily recurrence across a DST gap', () => {
|
|||||||
expect(out.map((o) => o.start.slice(11, 16))).toEqual(Array(7).fill('00:00'));
|
expect(out.map((o) => o.start.slice(11, 16))).toEqual(Array(7).fill('00:00'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a daily series in another zone on the device's DST day", () => {
|
||||||
|
// Berlin skips 02:00-03:00 on 28 March 2027; New York changed on 14 March.
|
||||||
|
it("keeps the series' 02:30 as recurrence id and time on the night the device skips it", () => {
|
||||||
|
process.env.TZ = 'Europe/Berlin';
|
||||||
|
const out = expandRecurringEvents(
|
||||||
|
[daily('2027-03-20T02:30:00', false, 'America/New_York')],
|
||||||
|
'2027-03-27T00:00:00', '2027-03-30T00:00:00',
|
||||||
|
);
|
||||||
|
const day = out.find((o) => o.recurrenceId?.startsWith('2027-03-28'));
|
||||||
|
expect(day?.recurrenceId).toBe('2027-03-28T02:30:00');
|
||||||
|
expect(day?.start).toBe('2027-03-28T02:30:00');
|
||||||
|
expect(day?.id).toBe('E1:2027-03-28T02:30:00');
|
||||||
|
// 02:30 in New York (EDT, -4) is 06:30 UTC.
|
||||||
|
expect(day?.utcStart).toBe('2027-03-28T06:30:00.000Z');
|
||||||
|
expect(out.map((o) => o.recurrenceId)).toEqual([
|
||||||
|
'2027-03-27T02:30:00', '2027-03-28T02:30:00', '2027-03-29T02:30:00',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the override stored for that night', () => {
|
||||||
|
process.env.TZ = 'Europe/Berlin';
|
||||||
|
const event = {
|
||||||
|
...daily('2027-03-20T02:30:00', false, 'America/New_York'),
|
||||||
|
recurrenceOverrides: { '2027-03-28T02:30:00': { title: 'Moved' } },
|
||||||
|
} as CalendarEvent;
|
||||||
|
const out = expandRecurringEvents([event], '2027-03-28T00:00:00', '2027-03-29T00:00:00');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].title).toBe('Moved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the same occurrences whichever zone the device is in', () => {
|
||||||
|
const run = (tz: string) => {
|
||||||
|
process.env.TZ = tz;
|
||||||
|
return expandRecurringEvents(
|
||||||
|
[daily('2027-03-10T02:30:00', false, 'America/New_York')],
|
||||||
|
'2027-03-10T00:00:00', '2027-04-01T00:00:00',
|
||||||
|
).map((o) => [o.recurrenceId, o.utcStart]);
|
||||||
|
};
|
||||||
|
expect(run('Europe/Berlin')).toEqual(run('America/New_York'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -97,6 +97,59 @@ export function seriesOccursAt(
|
|||||||
return (rules ?? []).some((rule) => generateDates(from, rule, at, until).some((date) => date.getTime() === at.getTime()));
|
return (rules ?? []).some((rule) => generateDates(from, rule, at, until).some((date) => date.getTime() === at.getTime()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dates stand for wall-clock times through their device-local fields. A
|
||||||
|
* wall time the device's clock skips (its own DST gap) can't be one: the
|
||||||
|
* Date moves past the gap. Such a Date carries the wall time it stands for
|
||||||
|
* here, so a series in another zone keeps its times on the device's DST day.
|
||||||
|
*/
|
||||||
|
const intendedWall = new WeakMap<Date, string>();
|
||||||
|
|
||||||
|
/** The LocalDateTime `date` stands for. */
|
||||||
|
function wallString(date: Date): string {
|
||||||
|
return intendedWall.get(date) ?? format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Date for the LocalDateTime `wall` ("yyyy-MM-ddTHH:mm:ss"), remembering it when the device's clock skips it. */
|
||||||
|
function dateForWall(wall: string): Date {
|
||||||
|
const date = parseISO(wall);
|
||||||
|
const wallMs = localDateTimeAsUtc(wall);
|
||||||
|
if (!isNaN(date.getTime()) && wallMs !== null && wallAsUtc(date) !== wallMs) {
|
||||||
|
intendedWall.set(date, utcFieldsString(wallMs));
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The UTC fields of `ms` as a LocalDateTime. */
|
||||||
|
function utcFieldsString(ms: number): string {
|
||||||
|
const w = new Date(ms);
|
||||||
|
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
|
||||||
|
return `${pad(w.getUTCFullYear(), 4)}-${pad(w.getUTCMonth() + 1)}-${pad(w.getUTCDate())}`
|
||||||
|
+ `T${pad(w.getUTCHours())}:${pad(w.getUTCMinutes())}:${pad(w.getUTCSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wall-clock fields of a LocalDateTime as a UTC timestamp, read from its digits. */
|
||||||
|
function localDateTimeAsUtc(value: string): number | null {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
|
||||||
|
if (!m) return null;
|
||||||
|
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wall time `y-m-d h:mi:s` names in `timeZone`: itself, or past the gap
|
||||||
|
* when that zone's clock skips it (as a device-local Date would move). Null
|
||||||
|
* for an unknown zone.
|
||||||
|
*/
|
||||||
|
function wallInZone(
|
||||||
|
fields: [number, number, number, number, number, number],
|
||||||
|
timeZone: string,
|
||||||
|
): string | null {
|
||||||
|
const formatter = getTzFormatter(timeZone);
|
||||||
|
if (!formatter) return null;
|
||||||
|
const instant = zonedWallMsToUtc(Date.UTC(...fields), formatter);
|
||||||
|
return utcFieldsString(wallClockAsUtcTimestamp(formatter, instant));
|
||||||
|
}
|
||||||
|
|
||||||
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
|
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
|
||||||
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
|
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
|
||||||
}
|
}
|
||||||
@@ -106,8 +159,10 @@ function expandEvent(
|
|||||||
rangeStart: Date,
|
rangeStart: Date,
|
||||||
rangeEnd: Date,
|
rangeEnd: Date,
|
||||||
): CalendarEvent[] {
|
): CalendarEvent[] {
|
||||||
const eventStart = parseISO(master.start);
|
const eventStart = master.showWithoutTime ? parseISO(master.start) : dateForWall(master.start.slice(0, 19));
|
||||||
if (isNaN(eventStart.getTime())) return [];
|
if (isNaN(eventStart.getTime())) return [];
|
||||||
|
// Daily occurrences are built on the event's own clock (see wallInZone).
|
||||||
|
const zone = !master.showWithoutTime && master.timeZone ? master.timeZone : undefined;
|
||||||
|
|
||||||
const rules = master.recurrenceRules || [];
|
const rules = master.recurrenceRules || [];
|
||||||
const overrides = master.recurrenceOverrides || {};
|
const overrides = master.recurrenceOverrides || {};
|
||||||
@@ -120,13 +175,13 @@ function expandEvent(
|
|||||||
for (const exRule of master.excludedRecurrenceRules || []) {
|
for (const exRule of master.excludedRecurrenceRules || []) {
|
||||||
// includeStartDate=false: "the series start is always an occurrence"
|
// includeStartDate=false: "the series start is always an occurrence"
|
||||||
// applies to recurrence rules, not to exclusion rules.
|
// applies to recurrence rules, not to exclusion rules.
|
||||||
for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) {
|
for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false, undefined, zone)) {
|
||||||
excludedDates.add(occurrenceDateKey(master, date));
|
excludedDates.add(occurrenceDateKey(master, date));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
|
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd, true, undefined, zone);
|
||||||
for (const date of dates) {
|
for (const date of dates) {
|
||||||
const dateKey = occurrenceDateKey(master, date);
|
const dateKey = occurrenceDateKey(master, date);
|
||||||
|
|
||||||
@@ -136,7 +191,7 @@ function expandEvent(
|
|||||||
|
|
||||||
const recurrenceId = master.showWithoutTime
|
const recurrenceId = master.showWithoutTime
|
||||||
? format(date, "yyyy-MM-dd'T'00:00:00")
|
? format(date, "yyyy-MM-dd'T'00:00:00")
|
||||||
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
: wallString(date);
|
||||||
|
|
||||||
const override = overrides[recurrenceId] as
|
const override = overrides[recurrenceId] as
|
||||||
| (Partial<CalendarEvent> & { excluded?: boolean })
|
| (Partial<CalendarEvent> & { excluded?: boolean })
|
||||||
@@ -153,7 +208,7 @@ function expandEvent(
|
|||||||
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
|
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
|
||||||
const override = rawOverride as Partial<CalendarEvent> & { excluded?: boolean };
|
const override = rawOverride as Partial<CalendarEvent> & { excluded?: boolean };
|
||||||
if (override.excluded) continue;
|
if (override.excluded) continue;
|
||||||
const overrideDate = parseISO(recurrenceId);
|
const overrideDate = master.showWithoutTime ? parseISO(recurrenceId) : dateForWall(recurrenceId.slice(0, 19));
|
||||||
if (isNaN(overrideDate.getTime())) continue;
|
if (isNaN(overrideDate.getTime())) continue;
|
||||||
|
|
||||||
// Overrides take precedence over excludedRecurrenceRules (they re-add
|
// Overrides take precedence over excludedRecurrenceRules (they re-add
|
||||||
@@ -288,12 +343,16 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number
|
|||||||
export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
|
export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
|
||||||
const formatter = getTzFormatter(timeZone);
|
const formatter = getTzFormatter(timeZone);
|
||||||
if (!formatter) return null;
|
if (!formatter) return null;
|
||||||
const wallMs = wallAsUtc(wall);
|
return new Date(zonedWallMsToUtc(wallAsUtc(wall), formatter));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The instant of the wall clock `wallMs` (its fields read as UTC) in the formatter's zone. */
|
||||||
|
function zonedWallMsToUtc(wallMs: number, formatter: Intl.DateTimeFormat): number {
|
||||||
let guess = wallMs;
|
let guess = wallMs;
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess);
|
guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess);
|
||||||
}
|
}
|
||||||
return new Date(guess);
|
return guess;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOccurrence(
|
function createOccurrence(
|
||||||
@@ -304,7 +363,7 @@ function createOccurrence(
|
|||||||
): CalendarEvent {
|
): CalendarEvent {
|
||||||
const startStr = master.showWithoutTime
|
const startStr = master.showWithoutTime
|
||||||
? format(date, "yyyy-MM-dd'T'00:00:00")
|
? format(date, "yyyy-MM-dd'T'00:00:00")
|
||||||
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
: wallString(date);
|
||||||
|
|
||||||
// The occurrence as the rule generates it, with its override applied as a
|
// The occurrence as the rule generates it, with its override applied as a
|
||||||
// patch: a moved or resized occurrence has its own start, time zone and
|
// patch: a moved or resized occurrence has its own start, time zone and
|
||||||
@@ -340,7 +399,10 @@ function createOccurrence(
|
|||||||
// occurrences on the other side of a DST transition in that zone keep
|
// occurrences on the other side of a DST transition in that zone keep
|
||||||
// their wall time. Reusing the master's fixed UTC offset (the fallback
|
// their wall time. Reusing the master's fixed UTC offset (the fallback
|
||||||
// below) would shift them by the DST delta.
|
// below) would shift them by the DST delta.
|
||||||
const zoned = occurrence.timeZone ? zonedWallTimeToUtc(wall, occurrence.timeZone) : null;
|
// From the digits: a wall time the device's clock skips is still this one.
|
||||||
|
const formatter = occurrence.timeZone ? getTzFormatter(occurrence.timeZone) : null;
|
||||||
|
const wallMs = localDateTimeAsUtc(start);
|
||||||
|
const zoned = formatter && wallMs !== null ? new Date(zonedWallMsToUtc(wallMs, formatter)) : null;
|
||||||
if (zoned) {
|
if (zoned) {
|
||||||
utcStart = zoned.toISOString();
|
utcStart = zoned.toISOString();
|
||||||
if (durationMs !== null) {
|
if (durationMs !== null) {
|
||||||
@@ -430,6 +492,8 @@ function generateDates(
|
|||||||
rangeEnd: Date,
|
rangeEnd: Date,
|
||||||
includeStartDate = true,
|
includeStartDate = true,
|
||||||
maxOccurrences = 500,
|
maxOccurrences = 500,
|
||||||
|
// The series' time zone: daily occurrences are built on its clock.
|
||||||
|
timeZone?: string,
|
||||||
): Date[] {
|
): Date[] {
|
||||||
const rule = addImplicitByX(rawRule, eventStart);
|
const rule = addImplicitByX(rawRule, eventStart);
|
||||||
const dates: Date[] = [];
|
const dates: Date[] = [];
|
||||||
@@ -465,7 +529,7 @@ function generateDates(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
|
const candidates = generateCandidatesForPeriod(current, rule, eventStart, timeZone);
|
||||||
|
|
||||||
const filtered = rule.bySetPosition?.length
|
const filtered = rule.bySetPosition?.length
|
||||||
? applyBySetPosition(candidates, rule.bySetPosition)
|
? applyBySetPosition(candidates, rule.bySetPosition)
|
||||||
@@ -509,6 +573,7 @@ function generateCandidatesForPeriod(
|
|||||||
periodStart: Date,
|
periodStart: Date,
|
||||||
rule: RecurrenceRule,
|
rule: RecurrenceRule,
|
||||||
eventStart: Date,
|
eventStart: Date,
|
||||||
|
timeZone?: string,
|
||||||
): Date[] {
|
): Date[] {
|
||||||
const freq = rule.frequency;
|
const freq = rule.frequency;
|
||||||
let candidates: Date[];
|
let candidates: Date[];
|
||||||
@@ -523,17 +588,27 @@ function generateCandidatesForPeriod(
|
|||||||
case 'weekly':
|
case 'weekly':
|
||||||
candidates = expandWeekly(periodStart, rule, eventStart);
|
candidates = expandWeekly(periodStart, rule, eventStart);
|
||||||
break;
|
break;
|
||||||
case 'daily':
|
case 'daily': {
|
||||||
// The start's wall-clock time on this day. The period anchor is
|
// The start's wall-clock time on this day. The period anchor is
|
||||||
// advanced day by day and keeps whatever time a DST gap pushed it to
|
// advanced day by day and keeps whatever time a DST gap pushed it to
|
||||||
// (02:30 -> 03:30, or midnight -> 01:00 for an all-day event), so using
|
// (02:30 -> 03:30, or midnight -> 01:00 for an all-day event), so using
|
||||||
// it made every later day fail the implicit byHour and the series
|
// it made every later day fail the implicit byHour and the series
|
||||||
// stopped at the gap. Mirrors the webmail (34fb9d247).
|
// stopped at the gap. Mirrors the webmail (34fb9d247).
|
||||||
candidates = [new Date(
|
// With a time zone, on that zone's clock: the device's own DST gap
|
||||||
|
// must not move an occurrence of a series in another zone.
|
||||||
|
const time = timeZone ? /T(\d{2}):(\d{2}):(\d{2})/.exec(wallString(eventStart)) : null;
|
||||||
|
const zoned = time && timeZone
|
||||||
|
? wallInZone([
|
||||||
|
periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(),
|
||||||
|
Number(time[1]), Number(time[2]), Number(time[3]),
|
||||||
|
], timeZone)
|
||||||
|
: null;
|
||||||
|
candidates = [zoned ? dateForWall(zoned) : new Date(
|
||||||
periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(),
|
periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(),
|
||||||
eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), eventStart.getMilliseconds(),
|
eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), eventStart.getMilliseconds(),
|
||||||
)];
|
)];
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'hourly':
|
case 'hourly':
|
||||||
case 'minutely':
|
case 'minutely':
|
||||||
case 'secondly':
|
case 'secondly':
|
||||||
|
|||||||
Reference in New Issue
Block a user