mapcomplete/UI/OpeningHours/OpeningHours.ts

668 lines
22 KiB
TypeScript

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,
},
},
<any>{ 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))
}
}