Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions __tests__/formatSchedule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* @format
*/

import {formatSchedule} from '../src/map/formatSchedule';

const NOW = new Date(2026, 6, 27);

const schedule = (
overrides: Partial<Parameters<typeof formatSchedule>[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');
});
12 changes: 1 addition & 11 deletions src/map/ElementDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RootStackParamList, 'ElementDetail'>;

Expand Down Expand Up @@ -205,17 +206,6 @@ function Section({
);
}

function formatSchedule(schedule: NonNullable<ElementDetail['schedule']>) {
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: {
Expand Down
85 changes: 85 additions & 0 deletions src/map/formatSchedule.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Loading