;
+ nextChange?: Date;
+ }>;
- updateTimer() {
- if (typeof this.openingHours !== 'string') {
- return;
- }
- clearTimeout(this.timer);
-
- const ohObject = new opening_hours(this.openingHours, {
- address: {
- country_code: 'de',
- state: 'Hessen',
- },
- lon: 8.667_97,
- lat: 50.129_16,
- });
-
- const millisecondsRemaining =
- // eslint-disable-next-line unicorn/prefer-date-now
- (ohObject.getNextChange()?.getTime() ?? 0) - new Date().getTime() + 1000;
-
- if (millisecondsRemaining > 1_209_600_000) {
- // setTimeout has upper bound of 0x7FFFFFFF
- // ignore everything over a week
- return;
- }
-
- if (millisecondsRemaining > 0) {
- this.timer = setTimeout(() => {
- // pseudo update value to tigger openingHours pipe
- this.openingHours = `${this.openingHours}`;
- this.updateTimer();
- }, millisecondsRemaining);
- }
- }
-
- ngOnInit() {
- this.updateTimer();
- }
-
- ngOnDestroy() {
- clearTimeout(this.timer);
+ @Input() set openingHours(value: string | undefined) {
+ if (!value) return;
+ this.openingHours$ = fromOpeningHours(value).pipe(
+ map(({isUnknown, isOpen, changesSoon, nextChange, comment}) => ({
+ color: isUnknown ? 'light' : changesSoon ? 'warning' : isOpen ? 'success' : 'danger',
+ statusName: `common.openingHours.state_${isUnknown ? 'maybe' : isOpen ? 'open' : 'closed'}`,
+ statusText: comment,
+ nextChangeAction: isOpen ? 'closing' : 'opening',
+ nextChangeSoon: changesSoon
+ ? interval(60_000).pipe(
+ startWith(nextChange),
+ map(() => nextChange),
+ )
+ : undefined,
+ nextChange,
+ })),
+ );
}
}
diff --git a/frontend/app/src/app/util/opening-hours.html b/frontend/app/src/app/util/opening-hours.html
index bf548cf2..8ac3b7ce 100644
--- a/frontend/app/src/app/util/opening-hours.html
+++ b/frontend/app/src/app/util/opening-hours.html
@@ -13,18 +13,20 @@
~ this program. If not, see .
-->
-
-
-
-
- {{ openingHours | openingHours | slice : 1 : 2 }}
-
-
- {{ openingHours | openingHours | slice : 1 : 2 }}
- {{ openingHours | openingHours | slice : 2 : 3 }}
-
-
+
+
+ {{ openingHours.statusName | translate }}
+
+ {{ openingHours.statusName | translate }}
+ {{openingHours.statusText}}
+
+
+ {{ ('common.openingHours.' + openingHours.nextChangeAction + '_soon') | translate: {duration:
+ (openingHours.nextChangeSoon | async | dfnsFormatDistanceToNowStrict: {unit: 'minute'})} }}
+
+
+ {{ ('common.openingHours.' + openingHours.nextChangeAction) | translate: {date: openingHours.nextChange
+ | dfnsFormatRelativeToNow} }}
+
+
+
diff --git a/frontend/app/src/app/util/opening-hours.scss b/frontend/app/src/app/util/opening-hours.scss
new file mode 100644
index 00000000..23a05d71
--- /dev/null
+++ b/frontend/app/src/app/util/opening-hours.scss
@@ -0,0 +1,3 @@
+ion-badge {
+ vertical-align: bottom;
+}
diff --git a/frontend/app/src/app/util/opening-hours.ts b/frontend/app/src/app/util/opening-hours.ts
new file mode 100644
index 00000000..c67244e3
--- /dev/null
+++ b/frontend/app/src/app/util/opening-hours.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2023 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+import type {nominatim_object} from 'opening_hours';
+import {from, Observable, map, expand, of, delay} from 'rxjs';
+import {lazy} from './rxjs/lazy';
+import {isAfter, subHours} from 'date-fns';
+
+export const OPENING_HOURS_REFERENCE = {
+ address: {
+ country_code: 'de',
+ state: 'Hessen',
+ },
+ lon: 8.667_97,
+ lat: 50.129_16,
+} satisfies nominatim_object;
+
+export interface OpeningHoursInfo {
+ isOpen: boolean;
+ changesSoon: boolean;
+ isUnknown: boolean;
+ nextChange?: Date;
+ comment?: string;
+}
+
+/**
+ * Opening Hours is a pretty huge CommonJS module,
+ * so we lazy-load it
+ */
+const OpeningHours = lazy(() => import('opening_hours').then(it => it.default));
+
+/**
+ * Create an observable from opening hours
+ * @param openingHours The opening hours string
+ * @param soonThresholdHours The number of hours before which a change is marked as "soon"
+ */
+export function fromOpeningHours(openingHours: string, soonThresholdHours = 1): Observable {
+ return from(OpeningHours).pipe(
+ map(OpeningHours => new OpeningHours(openingHours, OPENING_HOURS_REFERENCE)),
+ expand(it => {
+ const now = new Date();
+ const nextChange = it.getNextChange(now);
+ const changesSoon = nextChange ? isAfter(now, subHours(nextChange, soonThresholdHours)) : false;
+
+ const changeTime = nextChange && !changesSoon ? subHours(nextChange, soonThresholdHours) : nextChange;
+ return changeTime ? of(it).pipe(delay(changeTime)) : of();
+ }),
+ map(it => {
+ const now = new Date();
+ const nextChange = it.getNextChange(now);
+
+ return {
+ isOpen: it.getState(now),
+ isUnknown: it.getUnknown(now),
+ changesSoon: nextChange ? isAfter(now, subHours(nextChange, soonThresholdHours)) : false,
+ comment: it.getComment(now),
+ nextChange,
+ };
+ }),
+ );
+}
diff --git a/frontend/app/src/app/util/rxjs/lazy.ts b/frontend/app/src/app/util/rxjs/lazy.ts
new file mode 100644
index 00000000..4b75bdf2
--- /dev/null
+++ b/frontend/app/src/app/util/rxjs/lazy.ts
@@ -0,0 +1,17 @@
+import {Observable} from 'rxjs';
+
+/**
+ * Lazy-load something
+ * @param construct the constructing function
+ * @example lazy(() => import('module').then(it => it.default))
+ */
+export function lazy(construct: () => Promise): Observable {
+ let value: Promise;
+ return new Observable(subscriber => {
+ value ??= construct();
+ value.then(it => {
+ subscriber.next(it);
+ subscriber.complete();
+ });
+ });
+}
diff --git a/frontend/app/src/app/util/pause-when.ts b/frontend/app/src/app/util/rxjs/pause-when.ts
similarity index 100%
rename from frontend/app/src/app/util/pause-when.ts
rename to frontend/app/src/app/util/rxjs/pause-when.ts
diff --git a/frontend/app/src/app/util/util.module.ts b/frontend/app/src/app/util/util.module.ts
index b36edc67..60b91b37 100644
--- a/frontend/app/src/app/util/util.module.ts
+++ b/frontend/app/src/app/util/util.module.ts
@@ -32,9 +32,18 @@ import {SearchbarAutofocusDirective} from './searchbar-autofocus.directive';
import {SectionComponent} from './section.component';
import {RouterModule} from '@angular/router';
import {IonContentParallaxDirective} from './ion-content-parallax.directive';
+import {FormatDistanceToNowStrictPipeModule, FormatRelativeToNowPipeModule} from 'ngx-date-fns';
@NgModule({
- imports: [BrowserModule, IonicModule, TranslateModule, ThingTranslateModule.forChild(), RouterModule],
+ imports: [
+ BrowserModule,
+ IonicModule,
+ TranslateModule,
+ ThingTranslateModule.forChild(),
+ RouterModule,
+ FormatRelativeToNowPipeModule,
+ FormatDistanceToNowStrictPipeModule,
+ ],
declarations: [
IonContentParallaxDirective,
ElementSizeChangeDirective,
diff --git a/frontend/app/src/assets/i18n/de.json b/frontend/app/src/assets/i18n/de.json
index bbd07cb6..a49a6a89 100644
--- a/frontend/app/src/assets/i18n/de.json
+++ b/frontend/app/src/assets/i18n/de.json
@@ -69,15 +69,13 @@
},
"common": {
"openingHours": {
- "closing": "Schließt {{relativeDateTime}}",
- "closing_soon_warning": "Schließt bald! Um {{time}} Uhr",
- "closing_today": "Schließt um {{time}} Uhr",
+ "closing": "Schließt {{date}}",
+ "closing_soon": "Schließt in {{duration}}",
"state_closed": "Geschlossen",
"state_maybe": "Vielleicht Geöffnet",
"state_open": "Geöffnet",
- "opening": "Öffnet {{relativeDateTime}}",
- "opening_today": "Öffnet um {{time}} Uhr",
- "opening_soon_warning": "Öffnet bald! Um {{time}} Uhr"
+ "opening": "Öffnet {{date}}",
+ "opening_soon": "Öffnet in {{duration}}"
}
},
"dashboard": {
diff --git a/frontend/app/src/assets/i18n/en.json b/frontend/app/src/assets/i18n/en.json
index 3b6350ff..115792f7 100644
--- a/frontend/app/src/assets/i18n/en.json
+++ b/frontend/app/src/assets/i18n/en.json
@@ -69,15 +69,13 @@
},
"common": {
"openingHours": {
- "closing": "Closing {{relativeDateTime}}",
- "closing_soon_warning": "Closing soon! At {{time}}",
- "closing_today": "Closing at {{time}}",
+ "closing": "Closing {{date}}",
+ "closing_soon": "Closing in {{duration}}",
"state_closed": "Closed",
"state_maybe": "Maybe open",
"state_open": "Open",
- "opening": "Opens {{relativeDateTime}}",
- "opening_today": "Opens at {{time}}",
- "opening_soon_warning": "Opens soon! At {{time}}"
+ "opening": "Opens {{date}}",
+ "opening_soon": "Opens in {{duration}}"
}
},
"dashboard": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8dedc830..09f728a4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -836,6 +836,9 @@ importers:
cordova-plugin-calendar:
specifier: 5.1.6
version: 5.1.6
+ date-fns:
+ specifier: 2.30.0
+ version: 2.30.0
deepmerge:
specifier: 4.3.1
version: 4.3.1
@@ -863,6 +866,9 @@ importers:
moment:
specifier: 2.29.4
version: 2.29.4
+ ngx-date-fns:
+ specifier: 10.0.1
+ version: 10.0.1(@angular/common@16.1.4)(@angular/core@16.1.4)(date-fns@2.30.0)
ngx-logger:
specifier: 5.0.12
version: 5.0.12(rxjs@7.8.1)
@@ -1003,8 +1009,8 @@ importers:
specifier: 0.15.4
version: 0.15.4
cypress:
- specifier: 12.17.1
- version: 12.17.1
+ specifier: 13.2.0
+ version: 13.2.0
eslint:
specifier: 8.43.0
version: 8.43.0
@@ -2656,7 +2662,7 @@ packages:
rxjs: ^5.5.0 || ^6.5.0 || ^7.3.0
dependencies:
'@awesome-cordova-plugins/core': 5.45.0(rxjs@7.8.1)
- '@types/cordova': 11.0.0
+ '@types/cordova': 11.0.1
rxjs: 7.8.1
dev: false
@@ -2665,7 +2671,7 @@ packages:
peerDependencies:
rxjs: ^5.5.0 || ^6.5.0 || ^7.3.0
dependencies:
- '@types/cordova': 11.0.0
+ '@types/cordova': 11.0.1
rxjs: 7.8.1
dev: false
@@ -5352,8 +5358,8 @@ packages:
postcss-selector-parser: 6.0.13
dev: true
- /@cypress/request@2.88.11:
- resolution: {integrity: sha512-M83/wfQ1EkspjkE2lNWNV5ui2Cv7UCv1swW1DqljahbzLVWltcsexQh8jYtuS/vzFXP+HySntGM83ZXA9fn17w==}
+ /@cypress/request@3.0.1:
+ resolution: {integrity: sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==}
engines: {node: '>= 6'}
dependencies:
aws-sign2: 0.7.0
@@ -5371,7 +5377,7 @@ packages:
performance-now: 2.1.0
qs: 6.10.4
safe-buffer: 5.2.1
- tough-cookie: 2.5.0
+ tough-cookie: 4.1.3
tunnel-agent: 0.6.0
uuid: 8.3.2
dev: true
@@ -5755,7 +5761,7 @@ packages:
peerDependencies:
rxjs: ^5.5.0 || ^6.5.0
dependencies:
- '@types/cordova': 11.0.0
+ '@types/cordova': 11.0.1
rxjs: 7.8.1
dev: false
@@ -5767,7 +5773,7 @@ packages:
rxjs: ^5.5.0 || ^6.5.0
dependencies:
'@ionic-native/core': 5.36.0(rxjs@7.8.1)
- '@types/cordova': 11.0.0
+ '@types/cordova': 11.0.1
rxjs: 7.8.1
dev: false
optional: true
@@ -5780,7 +5786,7 @@ packages:
rxjs: ^5.5.0 || ^6.5.0
dependencies:
'@ionic-native/core': 5.36.0(rxjs@7.8.1)
- '@types/cordova': 11.0.0
+ '@types/cordova': 11.0.1
rxjs: 7.8.1
dev: false
optional: true
@@ -5793,7 +5799,7 @@ packages:
rxjs: ^5.5.0 || ^6.5.0
dependencies:
'@ionic-native/core': 5.36.0(rxjs@7.8.1)
- '@types/cordova': 11.0.0
+ '@types/cordova': 11.0.1
rxjs: 7.8.1
dev: false
optional: true
@@ -5806,7 +5812,7 @@ packages:
rxjs: ^5.5.0 || ^6.5.0
dependencies:
'@ionic-native/core': 5.36.0(rxjs@7.8.1)
- '@types/cordova': 11.0.0
+ '@types/cordova': 11.0.1
rxjs: 7.8.1
dev: false
optional: true
@@ -6728,8 +6734,8 @@ packages:
resolution: {integrity: sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==}
dev: true
- /@types/cordova@11.0.0:
- resolution: {integrity: sha512-AtBm1IAqqXsXszJe6XxuA2iXLhraNCj25p/FHRyikPeW0Z3YfgM6qzWb+VJglJTmZc5lqRNy84cYM/sQI5v6Vw==}
+ /@types/cordova@11.0.1:
+ resolution: {integrity: sha512-Zd6LAhYUAdn0mL0SbxHeF4fO/3uzkcW3fzE0ZIK1wDlTRCWlI4/0i+Phb+otP9ryziyeW2LKofRNSP5yil85hA==}
dev: false
/@types/cors@2.8.13:
@@ -6964,6 +6970,10 @@ packages:
/@types/node@18.15.3:
resolution: {integrity: sha512-p6ua9zBxz5otCmbpb5D3U4B5Nanw6Pk3PPyX05xnxbB/fRv71N7CPmORg7uAD5P70T0xmx1pzAx/FUfa5X+3cw==}
+ /@types/node@18.17.17:
+ resolution: {integrity: sha512-cOxcXsQ2sxiwkykdJqvyFS+MLQPLvIdwh5l6gNg8qF6s+C7XSkEWOZjK+XhUZd+mYvHV/180g2cnCcIl4l06Pw==}
+ dev: true
+
/@types/nodemailer@6.4.7:
resolution: {integrity: sha512-f5qCBGAn/f0qtRcd4SEn88c8Fp3Swct1731X4ryPKqS61/A3LmmzN8zaEz7hneJvpjFbUUgY7lru/B/7ODTazg==}
dependencies:
@@ -9471,15 +9481,15 @@ packages:
resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==}
dev: true
- /cypress@12.17.1:
- resolution: {integrity: sha512-eKfBgO6t8waEyhegL4gxD7tcI6uTCGttu+ZU7y9Hq8BlpMztd7iLeIF4AJFAnbZH1xjX+wwgg4cRKFNSvv3VWQ==}
- engines: {node: ^14.0.0 || ^16.0.0 || >=18.0.0}
+ /cypress@13.2.0:
+ resolution: {integrity: sha512-AvDQxBydE771GTq0TR4ZUBvv9m9ffXuB/ueEtpDF/6gOcvFR96amgwSJP16Yhqw6VhmwqspT5nAGzoxxB+D89g==}
+ engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
hasBin: true
requiresBuild: true
dependencies:
- '@cypress/request': 2.88.11
+ '@cypress/request': 3.0.1
'@cypress/xvfb': 1.2.4(supports-color@8.1.1)
- '@types/node': 14.18.38
+ '@types/node': 18.17.17
'@types/sinonjs__fake-timers': 8.1.1
'@types/sizzle': 2.3.3
arch: 2.2.0
@@ -9512,6 +9522,7 @@ packages:
minimist: 1.2.8
ospath: 1.2.2
pretty-bytes: 5.6.0
+ process: 0.11.10
proxy-from-env: 1.0.0
request-progress: 3.0.0
semver: 7.5.4
@@ -14272,6 +14283,19 @@ packages:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
dev: true
+ /ngx-date-fns@10.0.1(@angular/common@16.1.4)(@angular/core@16.1.4)(date-fns@2.30.0):
+ resolution: {integrity: sha512-8IdwrblaMULNQgqwA0Yo8HPpUMQDLKC1pvkoFTToElf7gZDPWroOva2cogUrrNXXWLkwA9YDpc1skZVfI+mmwA==}
+ peerDependencies:
+ '@angular/common': '>=14'
+ '@angular/core': '>=14'
+ date-fns: '>=2'
+ dependencies:
+ '@angular/common': 16.1.4(@angular/core@16.1.4)(rxjs@7.8.1)
+ '@angular/core': 16.1.4(rxjs@7.8.1)(zone.js@0.13.1)
+ date-fns: 2.30.0
+ tslib: 2.4.1
+ dev: false
+
/ngx-logger@5.0.12(rxjs@7.8.1):
resolution: {integrity: sha512-4kTtPvxQoV2ka6pigtvkbtaLKpMYWqZm7Slu0YQVcwzBKoVR2K+oLmMVcA50S6kCxkZXq7iKcrXUKR2vhMXPqQ==}
peerDependencies:
@@ -15419,6 +15443,11 @@ packages:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
dev: true
+ /process@0.11.10:
+ resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
+ engines: {node: '>= 0.6.0'}
+ dev: true
+
/progress@1.1.8:
resolution: {integrity: sha512-UdA8mJ4weIkUBO224tIarHzuHs4HuYiJvsuGT7j/SPQiUJVjYvNDBIPa0hAorduOfjGohB/qHWRa/lrrWX/mXw==}
engines: {node: '>=0.4.0'}
@@ -15563,6 +15592,10 @@ packages:
engines: {node: '>=0.6'}
dev: true
+ /querystringify@2.2.0:
+ resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
+ dev: true
+
/queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -17574,6 +17607,16 @@ packages:
punycode: 2.3.0
dev: true
+ /tough-cookie@4.1.3:
+ resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==}
+ engines: {node: '>=6'}
+ dependencies:
+ psl: 1.9.0
+ punycode: 2.3.0
+ universalify: 0.2.0
+ url-parse: 1.5.10
+ dev: true
+
/tr46@1.0.1:
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
dependencies:
@@ -18116,6 +18159,11 @@ packages:
engines: {node: '>= 4.0.0'}
dev: true
+ /universalify@0.2.0:
+ resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
+ engines: {node: '>= 4.0.0'}
+ dev: true
+
/universalify@2.0.0:
resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==}
engines: {node: '>= 10.0.0'}
@@ -18157,6 +18205,13 @@ packages:
resolution: {integrity: sha512-1WJ8YX1Kcec9wgxy8d/ATzGP1ayO6BRnd3iB6NlM+7cOnn6U8p5PKppRTCPLobh3CSdJ4d0TdPjopzyU2KcVFw==}
dev: true
+ /url-parse@1.5.10:
+ resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
+ dependencies:
+ querystringify: 2.2.0
+ requires-port: 1.0.0
+ dev: true
+
/url-value-parser@2.2.0:
resolution: {integrity: sha512-yIQdxJpgkPamPPAPuGdS7Q548rLhny42tg8d4vyTNzFqvOnwqrgHXvgehT09U7fwrzxi3RxCiXjoNUNnNOlQ8A==}
engines: {node: '>=6.0.0'}