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}`; +}