From 041997e5e65d40ca9bddecd9238436efa21649e3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 13 Aug 2026 16:03:39 +0200 Subject: [PATCH] fix(feed): keep the TZID when a clock already carries seconds fmtDateTime's time-only branch appended '00' for seconds no matter what it was given. The booking import stores what KItinerary hands it, which is '10:00:00', so the value came out 17 characters long. dtLine only attaches the TZID to a well-formed 15-character value, so those events lost their zone and went out as floating times, which is the #1453 regression by another route. It pads the way the ISO branch above it already does, so every caller is covered rather than only the rental markers. --- server/src/nest/calendar/calendar.service.ts | 9 ++++++-- .../tests/unit/nest/calendar.service.test.ts | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index 886ade48d..aa232a945 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -182,10 +182,15 @@ export class CalendarService { // Pad to 15 chars (YYYYMMDDTHHMMSS) — add missing seconds return raw.length === 13 ? raw + '00' : raw; } - // Time-only: combine with reference date + // Time-only: combine with reference date. Pad the same way the branch above + // does rather than unconditionally, because the value can already carry + // seconds ("10:00:00" is what the booking import stores). Appending + // regardless made it 17 characters, which fails dtLine's shape check, drops + // the TZID and leaves a floating time in the feed (#1453 again). if (refDate && d.match(/^\d{2}:\d{2}/)) { const datePart = refDate.split('T')[0]; - return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, ''); + const raw = `${datePart}T${d.replace(/:/g, '')}`.replace(/-/g, ''); + return raw.length === 13 ? raw + '00' : raw; } return d.replace(/[-:]/g, ''); }; diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index dde66a142..5ec1b1ef2 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -1000,6 +1000,27 @@ describe('car rentals', () => { expect(ics).toContain('SUMMARY:Untimed Rental'); expect(ics).toContain('DTSTART;VALUE=DATE:20260707'); }); + + it('CAL-042: a clock that already carries seconds keeps its zone', () => { + // The booking import stores what KItinerary hands it, which is "10:00:00". + // fmtDateTime's time-only branch appended "00" regardless, so the value came + // out 17 characters long, dtLine refused to attach the TZID and the event + // went out floating — the #1453 regression by another route. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); + const reservation = createReservation(testDb, trip.id, { title: 'Seconds Rental', type: 'car' }); + testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') + .run('2026-07-07T09:00:00', '18:30:00', reservation.id); + insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00:00', '2026-07-07'); + insertEndpoint(reservation.id, 'to', 1, 'Lyon Office', 45.764, 4.8357, 'Europe/Paris', '18:30:00', '2026-07-07'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000'); + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T183000'); + // No 17-character value anywhere: that is what silently drops the zone. + expect(ics).not.toMatch(/DTSTART[^\r\n]*\d{8}T\d{8}/); + }); }); describe('folded quirk branches', () => {