Skip to content

Commit

Permalink
FeaturePanel: Enhancements to the opening hours (#516)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dlurak authored Sep 8, 2024
1 parent 7de1061 commit 5505fe4
Show file tree
Hide file tree
Showing 6 changed files with 198 additions and 19 deletions.
28 changes: 18 additions & 10 deletions src/components/FeaturePanel/renderers/OpeningHoursRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ToggleButton } from '../helpers/ToggleButton';
import { parseOpeningHours } from './openingHours';
import { SimpleOpeningHoursTable } from './openingHours/types';
import { useFeatureContext } from '../../utils/FeatureContext';
import { Status } from './openingHours/complex';

const Table = styled.table`
margin: 1em;
Expand All @@ -25,20 +26,27 @@ const weekDays = t('opening_hours.days_su_mo_tu_we_th_fr_sa').split('|');
const formatTimes = (times: string[]) =>
times.length ? times.map((x) => x.replace(/:00/g, '')).join(', ') : '-';

const formatDescription = (isOpen: boolean, days: SimpleOpeningHoursTable) => {
const formatDescription = (status: Status, days: SimpleOpeningHoursTable) => {
const timesByDay = Object.values(days);
const day = new Date().getDay();
const today = timesByDay[day];
const todayTime = formatTimes(today);
const isOpenedToday = today.length;

if (isOpen) {
return t('opening_hours.open', { todayTime });
switch (status) {
case 'opened':
return t('opening_hours.open', { todayTime });
case 'closed':
return isOpenedToday
? t('opening_hours.now_closed_but_today', { todayTime })
: t('opening_hours.today_closed');
case 'opens-soon':
return isOpenedToday
? t('opening_hours.opens_soon_today', { todayTime })
: t('opening_hours.opens_soon');
case 'closes-soon':
return t('opening_hours.closes_soon');
}

const isOpenedToday = today.length;
return isOpenedToday
? t('opening_hours.now_closed_but_today', { todayTime })
: t('opening_hours.today_closed');
};

const OpeningHoursRenderer = ({ v }) => {
Expand All @@ -51,7 +59,7 @@ const OpeningHoursRenderer = ({ v }) => {
state: '',
});
if (!openingHours) return null;
const { daysTable, isOpen } = openingHours;
const { daysTable, status } = openingHours;

const { ph, ...days } = daysTable;
const timesByDay = Object.values(days).map((times, idx) => ({
Expand All @@ -69,7 +77,7 @@ const OpeningHoursRenderer = ({ v }) => {
<>
<AccessTime fontSize="small" />
<div suppressHydrationWarning>
{formatDescription(isOpen, daysTable)}
{formatDescription(status, daysTable)}
<ToggleButton onClick={toggle} isShown={isExpanded} />
{isExpanded && (
<Table>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { splitDateRangeAtMidnight } from '../utils';

describe('splitDateRangeAtMidnight', () => {
it('should not split it when it isn not needed', () => {
expect(
splitDateRangeAtMidnight([
new Date(2024, 4, 4, 4, 4),
new Date(2024, 4, 4, 6, 6),
]),
).toEqual([[new Date(2024, 4, 4, 4, 4), new Date(2024, 4, 4, 6, 6)]]);
});

it('should not split it at midnight into x sub ranges', () => {
expect(
splitDateRangeAtMidnight([
new Date(2024, 4, 4, 4, 4),
new Date(2024, 4, 5, 6, 6),
]),
).toEqual([
[new Date(2024, 4, 4, 4, 4), new Date(2024, 4, 5, 0, 0)],
[new Date(2024, 4, 5, 0, 0), new Date(2024, 4, 5, 6, 6)],
]);

expect(
splitDateRangeAtMidnight([
new Date(2024, 4, 4, 4, 4),
new Date(2024, 4, 6, 6, 6),
]),
).toEqual([
[new Date(2024, 4, 4, 4, 4), new Date(2024, 4, 5, 0, 0)],
[new Date(2024, 4, 5, 0, 0), new Date(2024, 4, 6, 0, 0)],
[new Date(2024, 4, 6, 0, 0), new Date(2024, 4, 6, 6, 6)],
]);
});

it('should work when the dates start/end at midnight', () => {
// Test case where the start and end time is exactly at midnight
expect(
splitDateRangeAtMidnight([
new Date(2024, 4, 4, 0, 0),
new Date(2024, 4, 5, 0, 0),
]),
).toEqual([[new Date(2024, 4, 4, 0, 0), new Date(2024, 4, 5, 0, 0)]]);

// Test case where start time is exactly at midnight and end time is later the same day
expect(
splitDateRangeAtMidnight([
new Date(2024, 4, 4, 0, 0),
new Date(2024, 4, 4, 23, 59),
]),
).toEqual([[new Date(2024, 4, 4, 0, 0), new Date(2024, 4, 4, 23, 59)]]);

// Test case where start time is just before midnight and end time is exactly at midnight
expect(
splitDateRangeAtMidnight([
new Date(2024, 4, 4, 23, 59),
new Date(2024, 4, 5, 0, 0),
]),
).toEqual([[new Date(2024, 4, 4, 23, 59), new Date(2024, 4, 5, 0, 0)]]);

// Test case where range starts and ends on different midnights
expect(
splitDateRangeAtMidnight([
new Date(2024, 4, 4, 0, 0),
new Date(2024, 4, 6, 0, 0),
]),
).toEqual([
[new Date(2024, 4, 4, 0, 0), new Date(2024, 4, 5, 0, 0)],
[new Date(2024, 4, 5, 0, 0), new Date(2024, 4, 6, 0, 0)],
]);

// Test case where range starts at midnight and ends at a time that is not midnight
expect(
splitDateRangeAtMidnight([
new Date(2024, 4, 4, 0, 0),
new Date(2024, 4, 5, 12, 0),
]),
).toEqual([
[new Date(2024, 4, 4, 0, 0), new Date(2024, 4, 5, 0, 0)],
[new Date(2024, 4, 5, 0, 0), new Date(2024, 4, 5, 12, 0)],
]);
});
});
62 changes: 55 additions & 7 deletions src/components/FeaturePanel/renderers/openingHours/complex.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import OpeningHours from 'opening_hours';
import { isInRange } from './utils';
import { DateRange, isMidnight, splitDateRangeAtMidnight } from './utils';
import { Address, SimpleOpeningHoursTable } from './types';
import { LonLat } from '../../../../services/types';
import { intl, t } from '../../../../services/intl';
import { addDays, isAfter, isEqual, set } from 'date-fns';

type Weekday = keyof SimpleOpeningHoursTable;
const WEEKDAYS: Weekday[] = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa', 'ph'];
Expand All @@ -16,7 +18,33 @@ const weekdayMappings: Record<string, Weekday> = {
};

const fmtDate = (d: Date) =>
d.toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric' });
d.toLocaleTimeString(intl.lang, { hour: 'numeric', minute: 'numeric' });

const fmtDateRange = ([start, end]: DateRange) => {
if (isMidnight(start) && isMidnight(end)) {
return t('opening_hours.all_day');
}

return `${fmtDate(start)}-${fmtDate(end)}`;
};

export type Status = 'opens-soon' | 'closes-soon' | 'opened' | 'closed';

const getStatus = (opensInMins: number, closesInMins: number): Status => {
const isOpened = opensInMins <= 0 && closesInMins >= 0;

if (!isOpened && opensInMins <= 15) {
return 'opens-soon';
}
if (isOpened && closesInMins <= 15) {
return 'closes-soon';
}
if (isOpened) {
return 'opened';
}

return 'closed';
};

export const parseComplexOpeningHours = (
value: string,
Expand All @@ -36,8 +64,21 @@ export const parseComplexOpeningHours = (
oneWeekLater.setDate(oneWeekLater.getDate() + 7);

const intervals = oh.getOpenIntervals(today, oneWeekLater);
const splittedIntervals = intervals.flatMap(([openingDate, endDate]) =>
splitDateRangeAtMidnight([openingDate, endDate], (d1, d2) => {
const splitPoint = set(addDays(new Date(d1), 1), {
hours: 5,
minutes: 0,
seconds: 0,
milliseconds: 0,
});

return isEqual(d2, splitPoint) || isAfter(d2, splitPoint);
}),
);

const grouped = WEEKDAYS.map((w) => {
const daysIntervals = intervals.filter(
const daysIntervals = splittedIntervals.filter(
([from]) =>
w === weekdayMappings[from.toLocaleString('en', { weekday: 'short' })],
);
Expand All @@ -47,16 +88,23 @@ export const parseComplexOpeningHours = (

const daysTable = Object.fromEntries(
grouped.map((entry) => {
const strings = entry[1].map(
([from, due]) => `${fmtDate(from)}-${fmtDate(due)}`,
);
const strings = entry[1].map(fmtDateRange);

return [entry[0], strings] as const;
}),
) as unknown as SimpleOpeningHoursTable;

const currently = new Date();
const getMinsDiff = (date: Date) =>
Math.round((date.getTime() - currently.getTime()) / 60000);
// intervals are sorted from the present to the future
// so the first one is either currently opened or the next opened slot
const relevantInterval = intervals.find(([, endDate]) => endDate > currently);
const opensInMins = relevantInterval ? getMinsDiff(relevantInterval[0]) : 0;
const closesInMins = relevantInterval ? getMinsDiff(relevantInterval[1]) : 0;

return {
daysTable,
isOpen: intervals.some(([from, due]) => isInRange([from, due], new Date())),
status: getStatus(opensInMins, closesInMins),
};
};
36 changes: 34 additions & 2 deletions src/components/FeaturePanel/renderers/openingHours/utils.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,34 @@
export const isInRange = ([startDate, endDate]: [Date, Date], date: Date) =>
date.getTime() >= startDate.getTime() && date.getTime() <= endDate.getTime();
export type DateRange = [Date, Date];

const isSameDay = (date1: Date, date2: Date) =>
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();

export const isMidnight = (date: Date) =>
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0;

export function splitDateRangeAtMidnight(
[startDate, endDate]: DateRange,
shouldSplit = (d1: Date, d2: Date) => !isSameDay(d1, d2),
): DateRange[] {
const midnight = new Date(startDate);
midnight.setHours(0, 0, 0, 0);
midnight.setDate(midnight.getDate() + 1);

if (startDate.getTime() === endDate.getTime()) {
return [];
}

if (!shouldSplit(startDate, endDate)) {
return [[startDate, endDate]];
}

return [
[startDate, midnight],
...splitDateRangeAtMidnight([midnight, endDate], shouldSplit),
];
}
4 changes: 4 additions & 0 deletions src/locales/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,13 @@ export default {
'featurepanel.inline_edit_title': 'Bearbeiten',
'featurepanel.objects_around': 'Orte in der Nähe',

'opening_hours.all_day': '24 Stunden',
'opening_hours.open': 'Geöffnet: __todayTime__',
'opening_hours.now_closed_but_today': 'Geschlossen, heute: __todayTime__',
'opening_hours.today_closed': 'Heute geschlossen',
'opening_hours.opens_soon': 'Öfnet bald',
'opening_hours.opens_soon_today': 'Öffnet bald: __todayTime__',
'opening_hours.closes_soon': 'Schließt bald',
'opening_hours.days_su_mo_tu_we_th_fr_sa': 'Sonntag|Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag',

'map.github_title': 'GitHub',
Expand Down
4 changes: 4 additions & 0 deletions src/locales/vocabulary.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,13 @@ export default {
'featurepanel.more_in_openplaceguide': 'More information on __instanceName__',
'featurepanel.climbing_restriction': 'Climbing restriction',

'opening_hours.all_day': '24 hours',
'opening_hours.open': 'Open: __todayTime__',
'opening_hours.now_closed_but_today': 'Closed now - Open __todayTime__',
'opening_hours.today_closed': 'Closed today',
'opening_hours.opens_soon': 'Opens soon',
'opening_hours.opens_soon_today': 'Opens soon: __todayTime__',
'opening_hours.closes_soon': 'Closes soon',
'opening_hours.days_su_mo_tu_we_th_fr_sa': 'Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday',
'opening_hours.editor.closed': 'closed',
'opening_hours.editor.create_advanced': 'You may create more detailed opening hours in <link>YoHours tool</link>.',
Expand Down

0 comments on commit 5505fe4

Please sign in to comment.