diff --git a/src/components/FeaturePanel/renderers/OpeningHoursRenderer.tsx b/src/components/FeaturePanel/renderers/OpeningHoursRenderer.tsx
index 906b631d..a1f2b702 100644
--- a/src/components/FeaturePanel/renderers/OpeningHoursRenderer.tsx
+++ b/src/components/FeaturePanel/renderers/OpeningHoursRenderer.tsx
@@ -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;
@@ -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 }) => {
@@ -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) => ({
@@ -69,7 +77,7 @@ const OpeningHoursRenderer = ({ v }) => {
<>
- {formatDescription(isOpen, daysTable)}
+ {formatDescription(status, daysTable)}
{isExpanded && (
diff --git a/src/components/FeaturePanel/renderers/openingHours/__tests__/utils.test.ts b/src/components/FeaturePanel/renderers/openingHours/__tests__/utils.test.ts
new file mode 100644
index 00000000..ee3aa6e9
--- /dev/null
+++ b/src/components/FeaturePanel/renderers/openingHours/__tests__/utils.test.ts
@@ -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)],
+ ]);
+ });
+});
diff --git a/src/components/FeaturePanel/renderers/openingHours/complex.ts b/src/components/FeaturePanel/renderers/openingHours/complex.ts
index ca59b1cd..d4216f43 100644
--- a/src/components/FeaturePanel/renderers/openingHours/complex.ts
+++ b/src/components/FeaturePanel/renderers/openingHours/complex.ts
@@ -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'];
@@ -16,7 +18,33 @@ const weekdayMappings: Record = {
};
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,
@@ -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' })],
);
@@ -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),
};
};
diff --git a/src/components/FeaturePanel/renderers/openingHours/utils.ts b/src/components/FeaturePanel/renderers/openingHours/utils.ts
index 7b6faacd..05de0708 100644
--- a/src/components/FeaturePanel/renderers/openingHours/utils.ts
+++ b/src/components/FeaturePanel/renderers/openingHours/utils.ts
@@ -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),
+ ];
+}
diff --git a/src/locales/de.js b/src/locales/de.js
index a7dae419..6aaed7c8 100644
--- a/src/locales/de.js
+++ b/src/locales/de.js
@@ -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',
diff --git a/src/locales/vocabulary.js b/src/locales/vocabulary.js
index de9825b8..544756d9 100644
--- a/src/locales/vocabulary.js
+++ b/src/locales/vocabulary.js
@@ -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 YoHours tool.',