import {Utils} from "../../Utils"; import opening_hours from "opening_hours"; export interface OpeningHour { weekday: number, // 0 is monday, 1 is tuesday, ... startHour: number, startMinutes: number, endHour: number, endMinutes: number } /** * Various utilities manipulating opening hours */ export class OH { private static readonly days = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] private static readonly daysIndexed = { mo: 0, tu: 1, we: 2, th: 3, fr: 4, sa: 5, su: 6 } public static hhmm(h: number, m: number): string { if (h == 24) { return "00:00"; } return Utils.TwoDigits(h) + ":" + Utils.TwoDigits(m); } /** * const rules = [{weekday: 6,endHour: 17,endMinutes: 0,startHour: 13,startMinutes: 0}, * {weekday: 1,endHour: 12,endMinutes: 0,startHour: 10,startMinutes: 0}] * OH.ToString(rules) // => "Tu 10:00-12:00; Su 13:00-17:00" * * const rules = [{weekday: 3,endHour: 17,endMinutes: 0,startHour: 13,startMinutes: 0}, {weekday: 1,endHour: 12,endMinutes: 0,startHour: 10,startMinutes: 0}] * OH.ToString(rules) // => "Tu 10:00-12:00; Th 13:00-17:00" * * const rules = [ { weekday: 1, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0 }, { weekday: 1, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }]); * OH.ToString(rules) // => "Tu 10:00-12:00, 13:00-17:00" * * const rules = [ { weekday: 0, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }, { weekday: 0, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0}, { weekday: 1, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0 }, { weekday: 1, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }]; * OH.ToString(rules) // => "Mo-Tu 10:00-12:00, 13:00-17:00" * * // should merge overlapping opening hours * const timerange0 = {weekday: 1, endHour: 23, endMinutes: 30, startHour: 23, startMinutes: 0 } * const touchingTimeRange = { weekday: 1, endHour: 0, endMinutes: 0, startHour: 23, startMinutes: 30 } * OH.ToString(OH.MergeTimes([timerange0, touchingTimeRange])) // => "Tu 23:00-00:00" * * // should merge touching opening hours * const timerange0 = {weekday: 1, endHour: 23, endMinutes: 30, startHour: 23, startMinutes: 0 } * const overlappingTimeRange = { weekday: 1, endHour: 24, endMinutes: 0, startHour: 23, startMinutes: 30 } * OH.ToString(OH.MergeTimes([timerange0, overlappingTimeRange])) // => "Tu 23:00-00:00" * */ public static ToString(ohs: OpeningHour[]) { if (ohs.length == 0) { return ""; } const partsPerWeekday: string [][] = [[], [], [], [], [], [], []]; for (const oh of ohs) { partsPerWeekday[oh.weekday].push(OH.hhmm(oh.startHour, oh.startMinutes) + "-" + OH.hhmm(oh.endHour, oh.endMinutes)); } const stringPerWeekday = partsPerWeekday.map(parts => parts.sort().join(", ")); const rules = []; let rangeStart = 0; let rangeEnd = 0; function pushRule() { const rule = stringPerWeekday[rangeStart]; if (rule === "") { return; } if (rangeStart == (rangeEnd - 1)) { rules.push( `${OH.days[rangeStart]} ${rule}` ); } else { rules.push( `${OH.days[rangeStart]}-${OH.days[rangeEnd - 1]} ${rule}` ); } } for (; rangeEnd < 7; rangeEnd++) { if (stringPerWeekday[rangeStart] != stringPerWeekday[rangeEnd]) { pushRule(); rangeStart = rangeEnd } } pushRule(); const oh = rules.join("; ") if (oh === "Mo-Su 00:00-00:00") { return "24/7" } return oh; } /** * Merge duplicate opening-hour element in place. * Returns true if something changed * * // should merge overlapping opening hours * const oh1: OpeningHour = { weekday: 0, startHour: 10, startMinutes: 0, endHour: 11, endMinutes: 0 }; * const oh0: OpeningHour = { weekday: 0, startHour: 10, startMinutes: 30, endHour: 12, endMinutes: 0 }; * OH.MergeTimes([oh0, oh1]) // => [{ weekday: 0, startHour: 10, startMinutes: 0, endHour: 12, endMinutes: 0 }] * * // should merge touching opening hours * const oh1: OpeningHour = { weekday: 0, startHour: 10, startMinutes: 0, endHour: 11, endMinutes: 0 }; * const oh0: OpeningHour = { weekday: 0, startHour: 11, startMinutes: 0, endHour: 12, endMinutes: 0 }; * OH.MergeTimes([oh0, oh1]) // => [{ weekday: 0, startHour: 10, startMinutes: 0, endHour: 12, endMinutes: 0 }] */ public static MergeTimes(ohs: OpeningHour[]): OpeningHour[] { const queue = ohs.map(oh => { if (oh.endHour === 0 && oh.endMinutes === 0) { const newOh = { ...oh } newOh.endHour = 24 return newOh } return oh; }); const newList = []; while (queue.length > 0) { let maybeAdd = queue.pop(); let doAddEntry = true; if (maybeAdd.weekday == undefined) { doAddEntry = false; } for (let i = newList.length - 1; i >= 0 && doAddEntry; i--) { let guard = newList[i]; if (maybeAdd.weekday != guard.weekday) { // Not the same day continue } if (OH.startTimeLiesInRange(maybeAdd, guard) && OH.endTimeLiesInRange(maybeAdd, guard)) { // Guard fully covers 'maybeAdd': we can safely ignore maybeAdd doAddEntry = false; break; } if (OH.startTimeLiesInRange(guard, maybeAdd) && OH.endTimeLiesInRange(guard, maybeAdd)) { // 'maybeAdd' fully covers Guard - the guard is killed newList.splice(i, 1); break; } if (OH.startTimeLiesInRange(maybeAdd, guard) || OH.endTimeLiesInRange(maybeAdd, guard) || OH.startTimeLiesInRange(guard, maybeAdd) || OH.endTimeLiesInRange(guard, maybeAdd)) { // At this point, the maybeAdd overlaps the guard: we should extend the guard and retest it newList.splice(i, 1); let startHour = guard.startHour; let startMinutes = guard.startMinutes; if (OH.startTime(maybeAdd) < OH.startTime(guard)) { startHour = maybeAdd.startHour; startMinutes = maybeAdd.startMinutes; } let endHour = guard.endHour; let endMinutes = guard.endMinutes; if (OH.endTime(maybeAdd) > OH.endTime(guard)) { endHour = maybeAdd.endHour; endMinutes = maybeAdd.endMinutes; } queue.push({ startHour: startHour, startMinutes: startMinutes, endHour: endHour, endMinutes: endMinutes, weekday: guard.weekday }); doAddEntry = false; break; } } if (doAddEntry) { newList.push(maybeAdd); } } // New list can only differ from the old list by merging entries // This means that the list is changed only if the lengths are different. // If the lengths are the same, we might just as well return the old list and be a bit more stable if (newList.length !== ohs.length) { return newList; } else { return ohs; } } /** * Gives the number of hours since the start of day. * E.g. * startTime({startHour: 9, startMinuts: 15}) == 9.25 * @param oh */ public static startTime(oh: OpeningHour): number { return oh.startHour + oh.startMinutes / 60; } public static endTime(oh: OpeningHour): number { return oh.endHour + oh.endMinutes / 60; } public static startTimeLiesInRange(checked: OpeningHour, mightLieIn: OpeningHour) { return OH.startTime(mightLieIn) <= OH.startTime(checked) && OH.startTime(checked) <= OH.endTime(mightLieIn) } public static endTimeLiesInRange(checked: OpeningHour, mightLieIn: OpeningHour) { return OH.startTime(mightLieIn) <= OH.endTime(checked) && OH.endTime(checked) <= OH.endTime(mightLieIn) } public static parseHHMMRange(hhmmhhmm: string): { startHour: number, startMinutes: number, endHour: number, endMinutes: number } { if (hhmmhhmm == "off") { return null; } const timings = hhmmhhmm.split("-"); const start = OH.parseHHMM(timings[0]) const end = OH.parseHHMM(timings[1]); return { startHour: start.hours, startMinutes: start.minutes, endHour: end.hours, endMinutes: end.minutes } } /** * Converts an OH-syntax rule into an object * * * const rules = OH.ParsePHRule("PH 12:00-17:00") * rules.mode // => " " * rules.start // => "12:00" * rules.end // => "17:00" * * OH.ParseRule("PH 12:00-17:00") // => null * OH.ParseRule("Th[-1] off") // => null * * const rules = OH.Parse("24/7"); * rules.length // => 7 * rules[0].startHour // => 0 * OH.ToString(rules) // => "24/7" * * const rules = OH.ParseRule("11:00-19:00"); * rules.length // => 7 * rules[0].weekday // => 0 * rules[0].startHour // => 11 * rules[3].endHour // => 19 * * const rules = OH.ParseRule("Mo-Th 11:00-19:00"); * rules.length // => 4 * rules[0].weekday // => 0 * rules[0].startHour // => 11 * rules[3].endHour // => 19 * */ public static ParseRule(rule: string): OpeningHour[] { try { if (rule.trim() == "24/7") { return OH.multiply([0, 1, 2, 3, 4, 5, 6], [{ startHour: 0, startMinutes: 0, endHour: 24, endMinutes: 0 }]); } const split = rule.trim().replace(/, */g, ",").split(" "); if (split.length == 1) { // First, try to parse this rule as a rule without weekdays let timeranges = OH.ParseHhmmRanges(rule); let weekdays = [0, 1, 2, 3, 4, 5, 6]; return OH.multiply(weekdays, timeranges); } if (split.length == 2) { const weekdays = OH.ParseWeekdayRanges(split[0]); const timeranges = OH.ParseHhmmRanges(split[1]); return OH.multiply(weekdays, timeranges); } return null; } catch (e) { console.log("Could not parse weekday rule ", rule); return null; } } /** * * OH.ParsePHRule("PH Off") // => {mode: "off"} * OH.ParsePHRule("PH OPEN") // => {mode: "open"} * OH.ParsePHRule("PH 10:00-12:00") // => {mode: " ", start: "10:00", end: "12:00"} * OH.ParsePHRule(undefined) // => null * OH.ParsePHRule(null) // => null * OH.ParsePHRule("some random string") // => null */ public static ParsePHRule(str: string): { mode: string, start?: string, end?: string } { if (str === undefined || str === null) { return null } str = str.trim(); if (!str.startsWith("PH")) { return null; } str = str.trim(); if (str.toLowerCase() === "ph off") { return { mode: "off" } } if (str.toLowerCase() === "ph open") { return { mode: "open" } } if (!str.startsWith("PH ")) { return null; } try { const timerange = OH.parseHHMMRange(str.substring(2)); if (timerange === null) { return null; } return { mode: " ", start: OH.hhmm(timerange.startHour, timerange.startMinutes), end: OH.hhmm(timerange.endHour, timerange.endMinutes), } } catch (e) { return null; } } public static simplify(str: string): string { return OH.ToString(OH.MergeTimes(OH.Parse(str))) } /** * Parses a string into Opening Hours */ public static Parse(rules: string): OpeningHour[] { if (rules === undefined || rules === "") { return [] } const ohs: OpeningHour[] = [] const split = rules.split(";"); for (const rule of split) { if (rule === "") { continue; } try { const parsed = OH.ParseRule(rule) if (parsed !== null) { ohs.push(...parsed); } } catch (e) { console.error("Could not parse ", rule, ": ", e) } } return ohs; } /* This function converts a number of ranges (generated by OpeningHours.js) into all the hours of day that a change occurs. E.g. Monday, some business is opended from 9:00 till 17:00 Tuesday from 9:30 till 18:00 Wednesday from 9:30 till 12:30 This function will extract all those moments of change and will return 9:00, 9:30, 12:30, 17:00 and 18:00 This list will be sorted */ public static allChangeMoments(ranges: { isOpen: boolean, isSpecial: boolean, comment: string, startDate: Date, endDate: Date }[][]): [number[], string[]] { const changeHours: number[] = [] const changeHourText: string[] = []; const extrachangeHours: number[] = [] const extrachangeHourText: string[] = []; for (const weekday of ranges) { for (const range of weekday) { if (!range.isOpen && !range.isSpecial) { continue; } const startOfDay: Date = new Date(range.startDate); startOfDay.setHours(0, 0, 0, 0); // The number of seconds since the start of the day // @ts-ignore const changeMoment: number = (range.startDate - startOfDay) / 1000; if (changeHours.indexOf(changeMoment) < 0) { changeHours.push(changeMoment); changeHourText.push(OH.hhmm(range.startDate.getHours(), range.startDate.getMinutes())) } // The number of seconds till between the start of the day and closing // @ts-ignore let changeMomentEnd: number = (range.endDate - startOfDay) / 1000; if (changeMomentEnd >= 24 * 60 * 60) { if (extrachangeHours.indexOf(changeMomentEnd) < 0) { extrachangeHours.push(changeMomentEnd); extrachangeHourText.push(OH.hhmm(range.endDate.getHours(), range.endDate.getMinutes())) } } else if (changeHours.indexOf(changeMomentEnd) < 0) { changeHours.push(changeMomentEnd); changeHourText.push(OH.hhmm(range.endDate.getHours(), range.endDate.getMinutes())) } } } // Note that 'changeHours' and 'changeHourText' will be more or less in sync - one is in numbers, the other in 'HH:MM' format. // But both can be sorted without problem; they'll stay in sync changeHourText.sort(); changeHours.sort(); extrachangeHourText.sort(); extrachangeHours.sort(); changeHourText.push(...extrachangeHourText); changeHours.push(...extrachangeHours); return [changeHours, changeHourText] } public static CreateOhObject(tags: object & {_lat: number, _lon: number, _country?: string}, textToParse: string){ // noinspection JSPotentiallyInvalidConstructorUsage return new opening_hours(textToParse, { lat: tags._lat, lon: tags._lon, address: { country_code: tags._country.toLowerCase(), state: undefined }, }, {tag_key: "opening_hours"}); } /* Calculates when the business is opened (or on holiday) between two dates. Returns a matrix of ranges, where [0] is a list of ranges when it is opened on monday, [1] is a list of ranges for tuesday, ... */ public static GetRanges(oh: any, from: Date, to: Date): ({ isOpen: boolean, isSpecial: boolean, comment: string, startDate: Date, endDate: Date }[])[] { const values = [[], [], [], [], [], [], []]; const start = new Date(from); // We go one day more into the past, in order to force rendering of holidays in the start of the period start.setDate(from.getDate() - 1); const iterator = oh.getIterator(start); let prevValue = undefined; while (iterator.advance(to)) { if (prevValue) { prevValue.endDate = iterator.getDate() as Date } const endDate = new Date(iterator.getDate()) as Date; endDate.setHours(0, 0, 0, 0) endDate.setDate(endDate.getDate() + 1); const value = { isSpecial: iterator.getUnknown(), isOpen: iterator.getState(), comment: iterator.getComment(), startDate: iterator.getDate() as Date, endDate: endDate // Should be overwritten by the next iteration } prevValue = value; if (value.comment === undefined && !value.isOpen && !value.isSpecial) { // simply closed, nothing special here continue; } if (value.startDate < from) { continue; } // Get day: sunday is 0, monday is 1. We move everything so that monday == 0 values[(value.startDate.getDay() + 6) % 7].push(value); } return values; } private static parseHHMM(hhmm: string): { hours: number, minutes: number } { if (hhmm === undefined || hhmm == null) { return null; } const spl = hhmm.trim().split(":"); if (spl.length != 2) { return null; } const hm = {hours: Number(spl[0].trim()), minutes: Number(spl[1].trim())}; if (isNaN(hm.hours) || isNaN(hm.minutes)) { return null; } return hm; } private static ParseHhmmRanges(hhmms: string): { startHour: number, startMinutes: number, endHour: number, endMinutes: number }[] { if (hhmms === "off") { return []; } return hhmms.split(",") .map(s => s.trim()) .filter(str => str !== "") .map(OH.parseHHMMRange) .filter(v => v != null) } private static ParseWeekday(weekday: string): number { return OH.daysIndexed[weekday.trim().toLowerCase()]; } private static ParseWeekdayRange(weekdays: string): number[] { const split = weekdays.split("-"); if (split.length == 1) { const parsed = OH.ParseWeekday(weekdays); if (parsed == null) { return null; } return [parsed]; } else if (split.length == 2) { let start = OH.ParseWeekday(split[0]); let end = OH.ParseWeekday(split[1]); if ((start ?? null) === null || (end ?? null) === null) { return null; } let range = []; for (let i = start; i <= end; i++) { range.push(i); } return range; } else { return null; } } private static ParseWeekdayRanges(weekdays: string): number[] { let ranges = []; let split = weekdays.split(","); for (const weekday of split) { const parsed = OH.ParseWeekdayRange(weekday) if (parsed === undefined || parsed === null) { return null; } ranges.push(...parsed); } return ranges; } private static multiply(weekdays: number[], timeranges: { startHour: number, startMinutes: number, endHour: number, endMinutes: number }[]) { if ((weekdays ?? null) == null || (timeranges ?? null) == null) { return null; } const ohs: OpeningHour[] = [] for (const timerange of timeranges) { for (const weekday of weekdays) { ohs.push({ weekday: weekday, startHour: timerange.startHour, startMinutes: timerange.startMinutes, endHour: timerange.endHour, endMinutes: timerange.endMinutes, }); } } return ohs; } public static getMondayBefore(d) { d = new Date(d); const day = d.getDay(); const diff = d.getDate() - day + (day == 0 ? -6 : 1); // adjust when day is sunday return new Date(d.setDate(diff)); } }