From 030ad06a741261f28257e4babb97763950f87c0d Mon Sep 17 00:00:00 2001 From: Dennis Falling Date: Mon, 27 Jul 2026 14:29:51 +0100 Subject: [PATCH] Render schedules the way people say them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail screen printed raw ISO fragments in a machine-ish order — "2026-03-14 – 2026-03-16 · 09:15:00–17:00:00" — which reads as data, not as a plan. Lead with the time and follow with the day it falls on, and drop everything redundant: a shared date is written once, the year only appears when the schedule isn't in the current year. Dates are split by hand rather than parsed through Date, which would reinterpret the element's own timezone as the device's and slide the day. Co-Authored-By: Claude Opus 5 (1M context) --- __tests__/formatSchedule.test.ts | 86 ++++++++++++++++++++++++++++++++ src/map/ElementDetailScreen.tsx | 12 +---- src/map/formatSchedule.ts | 85 +++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 __tests__/formatSchedule.test.ts create mode 100644 src/map/formatSchedule.ts diff --git a/__tests__/formatSchedule.test.ts b/__tests__/formatSchedule.test.ts new file mode 100644 index 0000000..33c2ba0 --- /dev/null +++ b/__tests__/formatSchedule.test.ts @@ -0,0 +1,86 @@ +/** + * @format + */ + +import {formatSchedule} from '../src/map/formatSchedule'; + +const NOW = new Date(2026, 6, 27); + +const schedule = ( + overrides: Partial[0]>, +) => ({ + allDay: false, + startDate: '2026-03-14', + endDate: '2026-03-14', + startTime: null, + endTime: null, + ...overrides, +}); + +test('an all-day single day is just the date', () => { + expect(formatSchedule(schedule({allDay: true}), NOW)).toBe('Mar 14'); +}); + +test('an all-day range reads as a date range', () => { + expect( + formatSchedule(schedule({allDay: true, endDate: '2026-03-16'}), NOW), + ).toBe('Mar 14 to Mar 16'); +}); + +test('times on a single day share the date', () => { + expect( + formatSchedule(schedule({startTime: '09:00:00', endTime: '17:30:00'}), NOW), + ).toBe('9 AM to 5:30 PM, Mar 14'); +}); + +test('a multi-day range puts each time before its own date', () => { + expect( + formatSchedule( + schedule({ + endDate: '2026-03-16', + startTime: '09:15:00', + endTime: '17:00:00', + }), + NOW, + ), + ).toBe('9:15 AM, Mar 14 to 5 PM, Mar 16'); +}); + +test('a start time alone still leads', () => { + expect(formatSchedule(schedule({startTime: '12:00:00'}), NOW)).toBe( + '12 PM, Mar 14', + ); +}); + +test('an end time alone reads as a deadline', () => { + expect(formatSchedule(schedule({endTime: '00:30:00'}), NOW)).toBe( + 'Until 12:30 AM, Mar 14', + ); +}); + +test('only one of the times is present on a range', () => { + expect( + formatSchedule( + schedule({endDate: '2026-03-16', startTime: '09:00:00'}), + NOW, + ), + ).toBe('9 AM, Mar 14 to Mar 16'); +}); + +test('all day wins over stale times', () => { + expect( + formatSchedule( + schedule({allDay: true, startTime: '09:00:00', endTime: '17:00:00'}), + NOW, + ), + ).toBe('Mar 14'); +}); + +test('other years show the year on both ends of the range', () => { + expect( + formatSchedule( + schedule({startDate: '2026-12-30', endDate: '2027-01-02', allDay: true}), + NOW, + ), + ).toBe('Dec 30, 2026 to Jan 2, 2027'); +}); diff --git a/src/map/ElementDetailScreen.tsx b/src/map/ElementDetailScreen.tsx index 4fe173b..2dd68b3 100644 --- a/src/map/ElementDetailScreen.tsx +++ b/src/map/ElementDetailScreen.tsx @@ -19,6 +19,7 @@ import type {RootStackParamList} from '../navigation/types'; import {photoImageSource} from '../photos/photoImageSource'; import type {Theme} from '../theme/colors'; import {useTheme} from '../theme/useTheme'; +import {formatSchedule} from './formatSchedule'; type Props = NativeStackScreenProps; @@ -205,17 +206,6 @@ function Section({ ); } -function formatSchedule(schedule: NonNullable) { - const {allDay, startDate, endDate, startTime, endTime} = schedule; - const range = startDate === endDate ? startDate : `${startDate} – ${endDate}`; - if (allDay || (!startTime && !endTime)) return range; - const time = - startTime && endTime - ? `${startTime}–${endTime}` - : (startTime ?? endTime ?? ''); - return `${range} · ${time}`; -} - const makeStyles = (theme: Theme) => StyleSheet.create({ screen: { diff --git a/src/map/formatSchedule.ts b/src/map/formatSchedule.ts new file mode 100644 index 0000000..1922f32 --- /dev/null +++ b/src/map/formatSchedule.ts @@ -0,0 +1,85 @@ +// Render a Schedule the way a person would say it out loud: the time comes +// first, then the day it falls on ("9 AM, Mar 14 to 5 PM, Mar 16"), and +// anything redundant is dropped — a shared date is written once, the year only +// shows up when the schedule isn't in the current year. +// +// Dates and times arrive as ISO8601 fragments (`2026-03-14`, `14:30:00`) in the +// element's own timezone, so they're split by hand rather than fed to `Date`, +// which would reinterpret them in the device's zone and slide the day around. + +export type FormattableSchedule = { + allDay: boolean; + startDate: string; + endDate: string; + startTime?: string | null; + endTime?: string | null; +}; + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +export function formatSchedule( + schedule: FormattableSchedule, + now: Date = new Date(), +): string { + const {allDay, startDate, endDate} = schedule; + // An all-day schedule may still carry times from an earlier edit; ignore them. + const startTime = allDay ? null : schedule.startTime; + const endTime = allDay ? null : schedule.endTime; + + // Either date needing a year forces it onto both, so a range that straddles + // New Year's doesn't read as "Dec 30 to Jan 2, 2027". + const withYear = + yearOf(startDate) !== now.getFullYear() || + yearOf(endDate) !== now.getFullYear(); + const start = formatDate(startDate, withYear); + const end = formatDate(endDate, withYear); + + if (startDate === endDate) { + if (startTime && endTime) { + return `${formatTime(startTime)} to ${formatTime(endTime)}, ${start}`; + } + if (startTime) return `${formatTime(startTime)}, ${start}`; + if (endTime) return `Until ${formatTime(endTime)}, ${start}`; + return start; + } + + const from = startTime ? `${formatTime(startTime)}, ${start}` : start; + const to = endTime ? `${formatTime(endTime)}, ${end}` : end; + return `${from} to ${to}`; +} + +function yearOf(date: string): number { + return Number(date.slice(0, 4)); +} + +// `2026-03-14` -> `Mar 14` / `Mar 14, 2026` +function formatDate(date: string, withYear: boolean): string { + const [year, month, day] = date.split('-').map(Number); + const name = MONTHS[month - 1]; + if (!name || !day) return date; + return withYear ? `${name} ${day}, ${year}` : `${name} ${day}`; +} + +// `14:30:00` -> `2:30 PM`; a whole hour drops the minutes -> `2 PM` +function formatTime(time: string): string { + const [hours, minutes] = time.split(':').map(Number); + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return time; + const meridiem = hours < 12 ? 'AM' : 'PM'; + const hour = hours % 12 || 12; + return minutes === 0 + ? `${hour} ${meridiem}` + : `${hour}:${String(minutes).padStart(2, '0')} ${meridiem}`; +}