From bf0507ddb50a467993321693bdad7ab0596f2b69 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 20:28:23 +0200 Subject: [PATCH] fix(planner): Google hours that share a half of the day convert whole to 24h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google writes a range that stays in one half of the day with the meridiem on the closing time only, "1:00 – 3:00 PM" for one to three in the afternoon. The 24-hour conversion only saw times carrying their own AM/PM, so the opener stayed "1:00" beside a converted "15:00". The closing meridiem is handed back to an opener that has none before converting; a range spelled on both sides, the 12h display and lines without a meridiem are untouched. Closes #2412 --- .../Planner/placeHoursFormat.test.ts | 56 +++++++++++++++++++ .../components/Planner/placeHoursFormat.ts | 12 +++- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 client/src/components/Planner/placeHoursFormat.test.ts diff --git a/client/src/components/Planner/placeHoursFormat.test.ts b/client/src/components/Planner/placeHoursFormat.test.ts new file mode 100644 index 000000000..1f7af1898 --- /dev/null +++ b/client/src/components/Planner/placeHoursFormat.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat' + +describe('convertHoursLine', () => { + it('FE-PLANNER-HOURS-001: a range that stays in one half of the day carries the meridiem back to its opener', () => { + // Google writes "1:00 – 3:00 PM" for one to three in the afternoon (#2412). The + // opener used to stay "1:00" next to a converted "15:00". + expect(convertHoursLine('Wednesday: 1:00 – 3:00 PM, 5:00 – 8:00 PM', '24h')) + .toBe('Wednesday: 13:00 – 15:00, 17:00 – 20:00') + }) + + it('FE-PLANNER-HOURS-002: a range spelled on both sides is converted as written', () => { + expect(convertHoursLine('Monday: 11:00 AM – 1:00 PM', '24h')).toBe('Monday: 11:00 – 13:00') + expect(convertHoursLine('Friday: 5:00 PM – 2:00 AM', '24h')).toBe('Friday: 17:00 – 02:00') + }) + + it('FE-PLANNER-HOURS-003: twelve stays twelve in the afternoon and turns to zero after midnight', () => { + expect(convertHoursLine('Sunday: 12:00 – 3:00 PM', '24h')).toBe('Sunday: 12:00 – 15:00') + expect(convertHoursLine('Sunday: 12:00 – 1:00 AM', '24h')).toBe('Sunday: 00:00 – 01:00') + }) + + it('FE-PLANNER-HOURS-004: a morning range keeps its morning', () => { + expect(convertHoursLine('Tuesday: 8:00 – 11:30 AM', '24h')).toBe('Tuesday: 08:00 – 11:30') + }) + + it('FE-PLANNER-HOURS-005: a plain hyphen separates the same way the en dash does', () => { + expect(convertHoursLine('Thursday: 1:00-3:00 PM', '24h')).toBe('Thursday: 13:00-15:00') + }) + + it('FE-PLANNER-HOURS-006: the 12h display and lines without a meridiem are untouched', () => { + expect(convertHoursLine('Wednesday: 1:00 – 3:00 PM', '12h')).toBe('Wednesday: 1:00 – 3:00 PM') + expect(convertHoursLine('Montag: 11:30–23:00', '24h')).toBe('Montag: 11:30–23:00') + expect(convertHoursLine('Saturday: Open 24 hours', '24h')).toBe('Saturday: Open 24 hours') + expect(convertHoursLine('', '24h')).toBe('') + }) + + it('FE-PLANNER-HOURS-007: 24h lines become 12h, with the German "Uhr" dropped', () => { + expect(convertHoursLine('Montag: 09:00 – 21:00 Uhr', '12h')).toBe('Montag: 9:00 AM – 9:00 PM') + expect(convertHoursLine('Monday: 00:30 – 12:00', '12h')).toBe('Monday: 12:30 AM – 12:00 PM') + }) +}) + +describe('splitHoursLine', () => { + it('FE-PLANNER-HOURS-008: splits on the first colon only, the fullwidth one included', () => { + expect(splitHoursLine('Monday: 09:00-18:00')).toEqual(['Monday', '09:00-18:00']) + expect(splitHoursLine('月曜日:9:00~18:00')).toEqual(['月曜日', '9:00~18:00']) + expect(splitHoursLine('Open 24 hours')).toEqual(['', 'Open 24 hours']) + }) +}) + +describe('isUnknownHoursLine', () => { + it('FE-PLANNER-HOURS-009: a bare question mark is the tag saying nothing', () => { + expect(isUnknownHoursLine(' ? ')).toBe(true) + expect(isUnknownHoursLine('09:00-18:00')).toBe(false) + }) +}) diff --git a/client/src/components/Planner/placeHoursFormat.ts b/client/src/components/Planner/placeHoursFormat.ts index 02f08b8a6..656584bb5 100644 --- a/client/src/components/Planner/placeHoursFormat.ts +++ b/client/src/components/Planner/placeHoursFormat.ts @@ -31,8 +31,18 @@ export function convertHoursLine(line: string, timeFormat: string): string { }) } if (timeFormat !== '12h' && hasAmPm) { + // Google spells a range that stays in one half of the day with the meridiem on + // the closing time only: "1:00 – 3:00 PM" is one to three in the afternoon. The + // conversion below only sees times that carry their own AM/PM, so the opener + // stayed "1:00" next to a "15:00" (#2412). Hand the closing meridiem back to an + // opener that has none. It fires only where nothing but the dash sits between + // the two times, so "11:00 AM – 1:00 PM" keeps both halves as written. + const paired = line.replace( + /(\d{1,2}:\d{2})(\s*[–—-]\s*)(\d{1,2}:\d{2}\s*)(AM|PM)\b/gi, + (_, open, sep, close, p) => `${open} ${p}${sep}${close}${p}`, + ) // 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00" - return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => { + return paired.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => { let hour = Number.parseInt(h) if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12 if (p.toUpperCase() === 'AM' && hour === 12) hour = 0