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.',