mapcomplete/UI/OpeningHours/OpeningHours.ts
2022-01-26 21:40:38 +01:00

540 lines
17 KiB
TypeScript

import {Utils} from "../../Utils";
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);
}
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
* @param ohs
* @constructor
*/
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
}
}
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;
}
}
public static ParsePHRule(str: string): {
mode: string,
start?: string,
end?: string
} {
if (str === undefined) {
return null
}
str = str.trim();
if (!str.startsWith("PH")) {
return null;
}
str = str.trim();
if (str === "PH off") {
return {
mode: "off"
}
}
if (str === "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)))
}
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]
}
/*
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;
}
}