diff --git a/lib/structured-mail/__tests__/validate-resolve.test.ts b/lib/structured-mail/__tests__/validate-resolve.test.ts index 188aa5a92..c0d7116a4 100644 --- a/lib/structured-mail/__tests__/validate-resolve.test.ts +++ b/lib/structured-mail/__tests__/validate-resolve.test.ts @@ -122,6 +122,20 @@ describe('resolveCards', () => { expect((card as { checkin: { time?: string } }).checkin.time).toBeUndefined(); }); + it('ends a date-only event end at midnight local time even across a DST change', async () => { + const event = (country?: string) => ({ + '@context': ctx, '@type': 'EventReservation', reservationNumber: 'EV1', + reservationFor: { + '@type': 'Event', name: 'Herbstmesse', startDate: '2026-10-23T18:00:00+02:00', endDate: '2026-10-26', + location: { '@type': 'Place', name: 'Messe', ...(country ? { address: { addressCountry: country } } : {}) }, + }, + }); + const [withVenue] = await resolved(event('DE')); + expect(withVenue).toMatchObject({ end: { date: '2026-10-26', time: '23:59:59', zone: 'Europe/Berlin', utc: '2026-10-26T22:59:59Z' } }); + const [noVenueCountry] = await resolved(event()); + expect(noVenueCountry).toMatchObject({ end: { date: '2026-10-26', time: '23:59:59', zone: 'Etc/GMT-2', utc: '2026-10-26T21:59:59Z' } }); + }); + it('reads bus times in the single zone of the stop\'s country', async () => { const [card] = (await resolved({ '@context': ctx, '@type': 'BusReservation', reservationNumber: 'B', diff --git a/lib/structured-mail/normalize.ts b/lib/structured-mail/normalize.ts index e389f4b35..7c7bdbb4d 100644 --- a/lib/structured-mail/normalize.ts +++ b/lib/structured-mail/normalize.ts @@ -231,12 +231,6 @@ function base(kind: BookingKind, res: Obj, source: CardSource) { const MISSING: Moment = { date: '' }; -function offsetSuffix(minutes: number): string { - const sign = minutes < 0 ? '-' : '+'; - const abs = Math.abs(minutes); - return `${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`; -} - function reservation(kind: BookingKind, res: Obj, source: CardSource): StructuredCard | undefined { if (kind === 'flight' || kind === 'train' || kind === 'bus') return transport(kind, res, source); const target = obj(res.reservationFor) ?? {}; @@ -275,8 +269,9 @@ function reservation(kind: BookingKind, res: Obj, source: CardSource): Structure let end = moment(target.endDate); // An end date without a time means the end of that day, when the // event starts at a time; with a date-only start it's an all-day span. - if (end && !end.time && start?.time) end = { ...end, time: '23:59:59', ...(start.offsetMinutes !== undefined ? { offsetMinutes: start.offsetMinutes } : {}) }; - if (end?.offsetMinutes !== undefined && end.time && !end.utc) end = moment(`${end.date}T${end.time}${offsetSuffix(end.offsetMinutes)}`); + // The end carries no offset: the day may end on the other side of a + // DST change, so resolve.ts reads it in the zone the start resolves to. + if (end && !end.time && start?.time) end = { ...end, time: '23:59:59' }; return { ...base(kind, res, source), kind, diff --git a/lib/structured-mail/resolve.ts b/lib/structured-mail/resolve.ts index a9aba0a47..88f289920 100644 --- a/lib/structured-mail/resolve.ts +++ b/lib/structured-mail/resolve.ts @@ -116,8 +116,14 @@ function resolveCard(card: StructuredCard, zoneOf: ZoneOf | undefined): Structur case 'event': { const named = (m: Moment) => namedZone(card.venue, card.kind, zoneOf, m.offsetMinutes === undefined); const start = resolveMoment(card.start, named(card.start)); - const end = card.end ? resolveMoment(card.end, named(card.end)) : undefined; - if (start === 'drop' || end === 'drop') return null; + if (start === 'drop') return null; + // An end without its own offset (a date-only end turned into the end of + // that day) belongs to the start's zone when the venue names none. + const endNamed = card.end && card.end.offsetMinutes === undefined && !named(card.end) && start.zone + ? { zone: start.zone, source: 'country' as const } + : card.end ? named(card.end) : undefined; + const end = card.end ? resolveMoment(card.end, endNamed) : undefined; + if (end === 'drop') return null; if (end && !notBefore(end, start)) return null; return { ...card, start, ...(end ? { end } : {}) }; }