mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(booking-import): read a printed 12-hour clock instead of slicing it (#2094)
splitIso reached the time by taking characters 11..16, so "2026-06-11T01:11 PM" became 01:11. The hour survived and the half of the day did not, which is why AM and 24-hour times in the same document imported correctly. The arrival then sorted before the departure and the router rolled it onto the next day, so a three-hour hop was rendered as an overnight. The client has done this properly since #1725; this is the server half, which was never pulled across. normalizeLocalDateTime rewrites a value only when it actually finds a meridiem, so a date-only value, a value carrying an offset and anything the parser does not know come back byte for byte. The mapper has only two exits for a date, toIsoString and splitIso, so this covers all eight types and every emitted field at once. It also stops sameConnection comparing a NaN timestamp, which silently merged unrelated legs into one booking.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { normalizeLocalDateTime, splitLocalDateTime } from '@trek/shared';
|
||||
import { findByIata } from '../airports/airports.data';
|
||||
import type {
|
||||
KiReservation, KiFlight, KiTrainTrip, KiBusTrip, KiBoatTrip,
|
||||
@@ -9,17 +10,31 @@ import type {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Extract a plain ISO string from either a string or a KDE QDateTime object. */
|
||||
/**
|
||||
* Extract a plain ISO string from either a string or a KDE QDateTime object.
|
||||
*
|
||||
* The value passes through normalizeLocalDateTime so a printed 12-hour clock
|
||||
* ("2026-06-11T02:30 PM") becomes 24-hour before anything downstream reads it.
|
||||
* These two functions are the mapper's only exits for a date, so this covers
|
||||
* every type and every emitted field at once, and it keeps `sameConnection`
|
||||
* from comparing a NaN timestamp (#2094).
|
||||
*/
|
||||
function toIsoString(dt: KiDateTimeish): string | null {
|
||||
if (!dt) return null;
|
||||
if (typeof dt === 'string') return dt || null;
|
||||
if (typeof dt === 'object' && dt['@type'] === 'QDateTime') return dt['@value'] || null;
|
||||
if (typeof dt === 'string') return dt ? normalizeLocalDateTime(dt) : null;
|
||||
if (typeof dt === 'object' && dt['@type'] === 'QDateTime') {
|
||||
return dt['@value'] ? normalizeLocalDateTime(dt['@value']) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitIso(dt: KiDateTimeish): { date: string | null; time: string | null } {
|
||||
const iso = toIsoString(dt);
|
||||
if (!iso) return { date: null, time: null };
|
||||
const split = splitLocalDateTime(iso);
|
||||
// The slice is the fallback for anything the normalizer does not recognise,
|
||||
// so a shape that works today cannot start returning null.
|
||||
if (split.date) return split;
|
||||
return { date: iso.slice(0, 10) || null, time: iso.length > 10 ? iso.slice(11, 16) || null : null };
|
||||
}
|
||||
|
||||
@@ -261,6 +276,9 @@ function mapLodging(r: KiReservation, source: ParsedBookingItem['source']): Pars
|
||||
|
||||
return {
|
||||
type: 'hotel',
|
||||
// Only the lodging path can carry this: it is the shape the extractor falls
|
||||
// back to when it could not read a type at all (#2076).
|
||||
...(r.trekTypeGuessed === true ? { type_guessed: true } : {}),
|
||||
title: l.name,
|
||||
confirmation_number: r.reservationNumber ?? null,
|
||||
location: formatAddress(l.address),
|
||||
|
||||
@@ -68,3 +68,42 @@ describe('kitinerary mapper — multi-leg flight grouping', () => {
|
||||
expect((items[0].metadata as any).legs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('kitinerary mapper — printed 12-hour clocks (#2094)', () => {
|
||||
it('reads a PM arrival as the afternoon instead of slicing the meridiem off', () => {
|
||||
// Before the fix the endpoint carried '01:11', because splitIso sliced
|
||||
// characters 11..16 out of '2026-06-11T01:11 PM'. The hour survived and the
|
||||
// arrival landed twelve hours early.
|
||||
const { items } = mapReservations([
|
||||
flight('PM1', FRA, BER, '2026-06-11T09:51 am', '2026-06-11T01:11 pm', 'LH 300'),
|
||||
] as any, 'meridiem.json');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
const [dep, arr] = items[0].endpoints!;
|
||||
expect(dep.local_time).toBe('09:51');
|
||||
expect(arr.local_time).toBe('13:11');
|
||||
expect(items[0].reservation_time).toBe('2026-06-11T09:51:00');
|
||||
expect(items[0].reservation_end_time).toBe('2026-06-11T13:11:00');
|
||||
});
|
||||
|
||||
it('leaves a 24-hour document byte for byte where it was', () => {
|
||||
const { items } = mapReservations([
|
||||
flight('H24', FRA, BER, '2026-06-11T10:00:00', '2026-06-11T12:00:00', 'LH 400'),
|
||||
] as any, 'plain.json');
|
||||
|
||||
expect(items[0].reservation_time).toBe('2026-06-11T10:00:00');
|
||||
expect(items[0].endpoints![1].local_time).toBe('12:00');
|
||||
});
|
||||
|
||||
it('keeps two legs apart that a NaN comparison used to merge', () => {
|
||||
// sameConnection does `new Date(value).getTime()`, which is NaN for a
|
||||
// printed meridiem, and both of its comparisons are false against NaN, so
|
||||
// the guard fell through and merged unrelated legs into one booking.
|
||||
const { items } = mapReservations([
|
||||
flight('SAME', FRA, BER, '2026-06-11T09:00 am', '2026-06-11T10:00 am', 'LH 500'),
|
||||
flight('SAME', HND, FRA, '2026-06-18T09:00 am', '2026-06-18T05:00 pm', 'LH 600'),
|
||||
] as any, 'two-legs.json');
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMeridiemClock, splitLocalDateTime, normalizeLocalDateTime } from './datetime-normalize';
|
||||
|
||||
describe('parseMeridiemClock', () => {
|
||||
it('resolves the half of the day', () => {
|
||||
expect(parseMeridiemClock('01:11 pm')).toBe('13:11');
|
||||
expect(parseMeridiemClock('09:51 am')).toBe('09:51');
|
||||
expect(parseMeridiemClock('3 PM')).toBe('15:00');
|
||||
expect(parseMeridiemClock('02:30:00 p.m.')).toBe('14:30');
|
||||
});
|
||||
|
||||
it('keeps noon at 12 and midnight at 00', () => {
|
||||
expect(parseMeridiemClock('12:05 pm')).toBe('12:05');
|
||||
expect(parseMeridiemClock('12:05 am')).toBe('00:05');
|
||||
});
|
||||
|
||||
it('returns null when there is no meridiem to resolve', () => {
|
||||
expect(parseMeridiemClock('13:11')).toBeNull();
|
||||
expect(parseMeridiemClock('')).toBeNull();
|
||||
expect(parseMeridiemClock(null)).toBeNull();
|
||||
// 13 with a meridiem is not a 12-hour clock, so it is not ours to rewrite.
|
||||
expect(parseMeridiemClock('13:11 pm')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitLocalDateTime', () => {
|
||||
it('reads a printed 12-hour clock instead of slicing it', () => {
|
||||
// The defect this pins: 'T02:30 PM'.slice(11, 16) is '02:30'. The hour
|
||||
// survives and the booking lands twelve hours early (#2094).
|
||||
expect(splitLocalDateTime('2026-06-11T02:30 PM')).toEqual({ date: '2026-06-11', time: '14:30' });
|
||||
});
|
||||
|
||||
it('leaves a 24-hour value exactly as it is', () => {
|
||||
expect(splitLocalDateTime('2026-06-11T13:11:00')).toEqual({ date: '2026-06-11', time: '13:11' });
|
||||
expect(splitLocalDateTime('2026-06-11')).toEqual({ date: '2026-06-11', time: null });
|
||||
});
|
||||
|
||||
it('has no date to give when the value does not start with one', () => {
|
||||
expect(splitLocalDateTime('Aug 23 2025 10:00')).toEqual({ date: null, time: null });
|
||||
expect(splitLocalDateTime(undefined)).toEqual({ date: null, time: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeLocalDateTime', () => {
|
||||
it('rewrites only the clock, and keeps the seconds it was given', () => {
|
||||
expect(normalizeLocalDateTime('2026-06-11T02:30 PM')).toBe('2026-06-11T14:30:00');
|
||||
expect(normalizeLocalDateTime('2026-06-11T02:30:45 pm')).toBe('2026-06-11T14:30:45');
|
||||
});
|
||||
|
||||
it('touches nothing that has no meridiem on it', () => {
|
||||
// Strictly additive: a date-only value stays date-only (the calendar
|
||||
// branches on whether the string contains a 'T' and would turn an all-day
|
||||
// event into a midnight point), an offset survives, and an unreadable
|
||||
// shape keeps whatever it meant before.
|
||||
expect(normalizeLocalDateTime('2026-06-11')).toBe('2026-06-11');
|
||||
expect(normalizeLocalDateTime('2026-06-11T13:11:00')).toBe('2026-06-11T13:11:00');
|
||||
expect(normalizeLocalDateTime('2026-06-11T13:11:00+02:00')).toBe('2026-06-11T13:11:00+02:00');
|
||||
expect(normalizeLocalDateTime('Aug 23 2025 10:00')).toBe('Aug 23 2025 10:00');
|
||||
expect(normalizeLocalDateTime('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Twelve-hour clock handling for imported booking documents (#2094).
|
||||
*
|
||||
* A confirmation mail prints "01:11 pm". Every downstream consumer in TREK
|
||||
* expects a naive 24-hour local string, and the mapper used to reach it by
|
||||
* slicing: `'2026-06-11T02:30 PM'.slice(11, 16)` yields '02:30'. The hour
|
||||
* survives, the half of the day does not, and the booking silently lands
|
||||
* twelve hours early. Because the arrival then sorts before the departure, the
|
||||
* router also rolls it onto the next day, so a three-hour domestic flight is
|
||||
* rendered as an overnight.
|
||||
*
|
||||
* The client has carried the same fix since #1725 (client/src/utils/formatters.ts);
|
||||
* this is the server half, which was never pulled across. The regex here is
|
||||
* deliberately looser than the client's: the server also sees 'HH:MM:SS AM' and
|
||||
* 'a.m.' straight out of a PDF.
|
||||
*
|
||||
* Everything here is total: a value that cannot be read comes back unchanged
|
||||
* rather than becoming null, so no format that works today can start failing.
|
||||
*/
|
||||
|
||||
const MERIDIEM_CLOCK = /^(\d{1,2})(?::(\d{2}))?(?::\d{2})?\s*([ap])\.?\s?m\.?$/i;
|
||||
|
||||
/**
|
||||
* '3:00 PM' / '02:30:00 pm' / '3 PM' → '15:00'.
|
||||
* Returns null when there is no meridiem to resolve, so callers can tell
|
||||
* "not my format" from "midnight".
|
||||
*/
|
||||
export function parseMeridiemClock(value: string | null | undefined): string | null {
|
||||
const m = MERIDIEM_CLOCK.exec((value || '').trim());
|
||||
if (!m) return null;
|
||||
let hour = Number.parseInt(m[1], 10);
|
||||
const minute = m[2] ? Number.parseInt(m[2], 10) : 0;
|
||||
if (!Number.isFinite(hour) || hour > 12) return null;
|
||||
const isPm = m[3].toLowerCase() === 'p';
|
||||
// 12 AM is midnight and 12 PM is noon: the hour does not shift at 12.
|
||||
if (hour === 12) hour = isPm ? 12 : 0;
|
||||
else if (isPm) hour += 12;
|
||||
return `${String(hour).padStart(2, '0')}:${String(Math.min(59, minute)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Split a naive date-time string into its date and its 24-hour clock. */
|
||||
export function splitLocalDateTime(value: string | null | undefined): { date: string | null; time: string | null } {
|
||||
const raw = (value || '').trim();
|
||||
if (!raw) return { date: null, time: null };
|
||||
|
||||
const dateMatch = /^(\d{4}-\d{2}-\d{2})/.exec(raw);
|
||||
if (!dateMatch) return { date: null, time: null };
|
||||
const date = dateMatch[1];
|
||||
|
||||
const rest = raw.slice(10).replace(/^[T\s]/, '').trim();
|
||||
if (!rest) return { date, time: null };
|
||||
|
||||
const meridiem = parseMeridiemClock(rest);
|
||||
if (meridiem) return { date, time: meridiem };
|
||||
|
||||
const clock = /^(\d{2}:\d{2})/.exec(rest);
|
||||
return { date, time: clock ? clock[1] : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the same naive date-time with a 24-hour clock. Anything the parser
|
||||
* cannot read comes back byte for byte, which is what keeps this additive:
|
||||
* a value that is already correct is never rewritten.
|
||||
*/
|
||||
export function normalizeLocalDateTime(value: string): string {
|
||||
if (!value) return value;
|
||||
const dateMatch = /^(\d{4}-\d{2}-\d{2})/.exec(value.trim());
|
||||
if (!dateMatch) return value;
|
||||
|
||||
// Only a printed 12-hour clock is rewritten. Everything else comes back
|
||||
// untouched, which is what makes this safe to drop into the middle of the
|
||||
// mapper: a value carrying a UTC offset, a date-only value, or a shape this
|
||||
// does not know keeps whatever it meant before.
|
||||
const rest = value.trim().slice(10).replace(/^[T\s]/, '').trim();
|
||||
const clock = parseMeridiemClock(rest);
|
||||
if (!clock) return value;
|
||||
|
||||
const seconds = /^\d{1,2}:\d{2}:(\d{2})/.exec(rest);
|
||||
return `${dateMatch[1]}T${clock}:${seconds ? seconds[1] : '00'}`;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export * from './todo/todo.schema';
|
||||
export * from './budget/budget.schema';
|
||||
export * from './reservation/reservation.schema';
|
||||
export * from './reservation/ki-reservation.schema';
|
||||
export * from './datetime/datetime-normalize';
|
||||
export * from './airtrail/airtrail.schema';
|
||||
export * from './day/day.schema';
|
||||
export * from './day/note-colors';
|
||||
|
||||
Reference in New Issue
Block a user