Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FeaturePanel: Enhancements to the opening hours #516

Merged
merged 6 commits into from
Sep 8, 2024
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
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' });
Dlurak marked this conversation as resolved.
Show resolved Hide resolved

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
Loading