feat: date-fns

This commit is contained in:
2023-09-27 15:53:13 +02:00
parent fe517fb4aa
commit d2d577c012
19 changed files with 139 additions and 428 deletions

View File

@@ -101,7 +101,7 @@ export function initializerFactory(
// this language will be used as a fallback when a translation isn't found in the current language
translateService.setDefaultLang('en');
translateService.use(languageCode);
const dateFnsLocale = await getDateFnsLocale(languageCode as SCLanguageCode);
const dateFnsLocale = await getDateFnsLocale(languageCode as SCLanguageCode, translateService);
setDefaultOptions({locale: dateFnsLocale});
dateFnsConfigurationService.setLocale(dateFnsLocale);

View File

@@ -32,8 +32,8 @@
>
<ion-list-header>
{{ frequency.children[0].item.repeatFrequency ? (frequency.children[0].item.repeatFrequency |
dfnsParseDuration | dfnsFormatFrequency | sentencecase) : ('data.chips.add_events.popover.SINGLE' |
translate | titlecase) }}
dfnsParseDuration | dfnsFormatFrequencyPure | sentencecase) :
('data.chips.add_events.popover.SINGLE' | translate | titlecase) }}
</ion-list-header>
</ion-checkbox>
</ion-item>

View File

@@ -111,8 +111,8 @@ import {
ParseIsoPipeModule,
} from 'ngx-date-fns';
import {ParseDurationPipe} from '../../translation/date-time/parse-duration.pipe';
import {DfnsFormatFrequencyPipe} from '../../translation/date-time/format-frequency.pipe';
import {FormatRelativeDatePipe} from '../../translation/date-time/format-relative-date.pipe';
import {DfnsFormatFrequencyPurePipe} from '../../translation/date-time/format-frequency.pipe';
import {DfnsFormatRelativeDatePurePipe} from '../../translation/date-time/format-relative-date.pipe';
/**
* Module for handling data
@@ -207,9 +207,9 @@ import {FormatRelativeDatePipe} from '../../translation/date-time/format-relativ
ParseDurationPipe,
FormatDurationPipeModule,
FormatRelativeToNowPipeModule,
DfnsFormatFrequencyPipe,
DfnsFormatFrequencyPurePipe,
FormatDistanceToNowPipeModule,
FormatRelativeDatePipe,
DfnsFormatRelativeDatePurePipe,
],
providers: [
CoordinatedSearchProvider,

View File

@@ -16,7 +16,7 @@ import {ChangeDetectionStrategy, Component} from '@angular/core';
import {MapPosition, PositionService} from '../../map/position.service';
import {Geolocation} from '@capacitor/geolocation';
import {BehaviorSubject, from} from 'rxjs';
import {pauseWhen} from '../../../util/pause-when';
import {pauseWhen} from '../../../util/rxjs/pause-when';
import {map, retry, startWith, take} from 'rxjs/operators';
import {SCSearchFilter, SCSearchSort} from '@openstapps/core';

View File

@@ -19,10 +19,10 @@
<ion-segment [(ngModel)]="selectedDay" mode="md">
<ion-segment-button *ngFor="let day of dishes | keyvalue" [value]="day.key">
<ion-label class="ion-hide-sm-down"
>{{ day.key | dfnsParseIso | dfnsFormatRelativeDate | sentencecase }}</ion-label
>{{ day.key | dfnsParseIso | dfnsFormatRelativeDatePure | sentencecase }}</ion-label
>
<ion-label class="ion-hide-sm-up"
>{{ day.key | dfnsParseIso | dfnsFormatRelativeDate | sentencecase }}</ion-label
>{{ day.key | dfnsParseIso | dfnsFormatRelativeDatePure | sentencecase }}</ion-label
>
</ion-segment-button>
</ion-segment>

View File

@@ -7,9 +7,9 @@
<ion-item slot="header">
<!-- TODO: when using date-fns, use https://date-fns.org/v2.30.0/docs/formatRelative -->
<ion-label
>{{ myCoursesDay[0] | dfnsParseIso | dfnsFormatRelativeDate | titlecase }} - {{ ('profile.courses.' +
(myCoursesDay[1].length === 0 ? 'NO' : myCoursesDay[1].length === 1 ? 'ONE' : 'MANY' ) + '_EVENT') |
translate: {count: myCoursesDay[1].length} }}</ion-label
>{{ myCoursesDay[0] | dfnsParseIso | dfnsFormatRelativeDatePure | titlecase }} - {{
('profile.courses.' + (myCoursesDay[1].length === 0 ? 'NO' : myCoursesDay[1].length === 1 ? 'ONE' :
'MANY' ) + '_EVENT') | translate: {count: myCoursesDay[1].length} }}</ion-label
>
<ion-icon class="ion-accordion-toggle-icon" name="expand_more"></ion-icon>
</ion-item>

View File

@@ -27,7 +27,7 @@ import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {DataModule} from '../data/data.module';
import {MyCoursesComponent} from './page/my-courses.component';
import {FormatPipeModule, ParseIsoPipeModule} from 'ngx-date-fns';
import {FormatRelativeDatePipe} from '../../translation/date-time/format-relative-date.pipe';
import {DfnsFormatRelativeDatePurePipe} from '../../translation/date-time/format-relative-date.pipe';
const routes: Routes = [
{
@@ -50,7 +50,7 @@ const routes: Routes = [
ThingTranslateModule,
DataModule,
FormatPipeModule,
FormatRelativeDatePipe,
DfnsFormatRelativeDatePurePipe,
ParseIsoPipeModule,
],
})

View File

@@ -96,79 +96,6 @@ export class StringSplitPipe implements PipeTransform {
return this.value as never;
}
}
@Injectable()
@Pipe({
name: 'durationLocalized',
pure: true,
})
export class DurationLocalizedPipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value: string;
frequencyPrefixes: {[iso6391Code: string]: string} = {
de: 'alle',
en: 'every',
es: 'cada',
pt: 'a cada',
fr: 'tous les',
cn: '每',
ru: 'kаждые',
};
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
/**
* @param value An ISO 8601 duration string
* @param isFrequency Boolean indicating if this duration is to be interpreted as repeat frequency
*/
transform(value: string | unknown, isFrequency = false): string {
this.updateValue(value, isFrequency);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(value, isFrequency);
});
}
return this.value;
}
updateValue(value: string | unknown, isFrequency = false): void {
if (typeof value !== 'string') {
logger.warn(`durationLocalized pipe unable to parse input: ${value}`);
return;
}
if (isFrequency) {
const fequencyPrefix = Object.keys(this.frequencyPrefixes).filter(element =>
this.locale.includes(element),
);
this.value = [
fequencyPrefix.length > 0 ? this.frequencyPrefixes[fequencyPrefix[0]] : this.frequencyPrefixes.en,
moment.duration(value).humanize(),
].join(' ');
} else {
this.value = moment.duration(value).humanize();
}
}
}
@Injectable()
@Pipe({

View File

@@ -1,68 +1,14 @@
import {Injectable, OnDestroy, Pipe, PipeTransform} from '@angular/core';
import {Subscription} from 'rxjs';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {formatDuration} from 'date-fns';
import {Injectable, Pipe, PipeTransform} from '@angular/core';
import {formatFrequency} from './format-frequency';
@Injectable()
@Pipe({
name: 'dfnsFormatFrequency',
name: 'dfnsFormatFrequencyPure',
standalone: true,
pure: false,
pure: true,
})
export class DfnsFormatFrequencyPipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value: string;
frequencyPrefixes: {[iso6391Code: string]: string} = {
de: 'alle',
en: 'every',
es: 'cada',
pt: 'a cada',
fr: 'tous les',
cn: '每',
ru: 'kаждые',
};
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
/**
* @param value An ISO 8601 duration string
*/
transform(value: Duration): string {
this.updateValue(value);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(value);
});
}
return this.value;
}
updateValue(value: Duration): void {
const fequencyPrefix = Object.keys(this.frequencyPrefixes).filter(element =>
this.locale.includes(element),
);
this.value = [
fequencyPrefix.length > 0 ? this.frequencyPrefixes[fequencyPrefix[0]] : this.frequencyPrefixes.en,
formatDuration(value),
].join(' ');
export class DfnsFormatFrequencyPurePipe implements PipeTransform {
transform(duration: Duration): string {
return formatFrequency(duration);
}
}

View File

@@ -0,0 +1,22 @@
import {Duration, formatDuration, getDefaultOptions} from 'date-fns';
import {LocaleExtension} from '../dfns-locale';
/**
* Formats a duration to a frequency
* @param duration the duration to format as a frequency
* @example "alle 3 Tage"
*/
export function formatFrequency(duration: Duration): string {
const {locale} = getDefaultOptions() as {locale: LocaleExtension};
locale.formatFrequencyOptions;
// This will break...
const formatted = formatDuration(duration);
const plural = new Intl.PluralRules(locale.code).select(Number(/\d+/.exec(formatted)?.[0]));
const formatString = locale.formatFrequencyOptions[plural] ?? locale.formatFrequencyOptions['many']!;
return formatString
.replaceAll(/(['"](?=\w))|((?<=\w)['"])/g, '')
.replaceAll(/{{\s*duration\s*}}/g, formatted)
.replaceAll(/{{\s*suffix\s*}}/g, formatted.replace(/^\d+\s*/, ''));
}

View File

@@ -1,67 +1,13 @@
import {Pipe, PipeTransform} from '@angular/core';
import {DateFnsConfigurationService} from 'ngx-date-fns';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {formatRelative} from 'date-fns';
const formatRelativeLocaleEn = {
lastWeek: "'last' eeee",
yesterday: "'yesterday'",
today: "'today'",
tomorrow: "'tomorrow'",
nextWeek: "'next' eeee",
other: 'pp',
};
const formatRelativeLocaleDe = {
lastWeek: "'letzten' eeee",
yesterday: "'gestern'",
today: "'heute'",
tomorrow: "'morgen'",
nextWeek: 'eeee',
other: 'pp',
};
import {formatRelativeDate} from './format-relative-date';
@Pipe({
name: 'dfnsFormatRelativeDate',
pure: false,
name: 'dfnsFormatRelativeDatePure',
pure: true,
standalone: true,
})
export class FormatRelativeDatePipe implements PipeTransform {
locale: Locale;
value: string;
input: Date | number;
base: Date | number;
constructor(private dfnsConfig: DateFnsConfigurationService) {
this.updateLocale();
dfnsConfig.localeChanged.pipe(takeUntilDestroyed()).subscribe(this.updateLocale.bind(this));
}
private updateLocale() {
this.locale = {...this.dfnsConfig.locale()!};
const format = {
['de']: formatRelativeLocaleDe,
['en-US']: formatRelativeLocaleEn,
}[this.locale.code!];
this.locale.formatRelative = function (token) {
return format![token as keyof typeof format] as string;
};
this.updateValue();
}
transform(value: Date | number, other: Date | number = Date.now()): string {
this.input = value;
this.base = other;
this.updateValue();
return this.value;
}
updateValue() {
if (!this.input || !this.base) return;
this.value = formatRelative(this.input, this.base, {locale: this.locale});
export class DfnsFormatRelativeDatePurePipe implements PipeTransform {
transform(date: Date | number, baseDate: Date | number = Date.now()): string {
return formatRelativeDate(date, baseDate);
}
}

View File

@@ -0,0 +1,23 @@
import {formatRelative, getDefaultOptions} from 'date-fns';
import {LocaleExtension} from '../dfns-locale';
/**
* @see {formatRelative}
*/
export function formatRelativeDate(
date: Date | number,
baseDate: Date | number,
options?: {
locale?: LocaleExtension;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
},
): string {
const locale = options?.locale ?? (getDefaultOptions() as {locale: LocaleExtension}).locale;
const customLocale: LocaleExtension = {
...locale,
formatRelative(token: keyof LocaleExtension['formatRelativeDateOptions']) {
return locale.formatRelativeDateOptions![token];
},
};
return formatRelative(date, baseDate, {locale: customLocale, weekStartsOn: options?.weekStartsOn});
}

View File

@@ -1,57 +0,0 @@
import {OnDestroy, Pipe, PipeTransform} from '@angular/core';
import {Subscription} from 'rxjs';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {formatDuration} from 'date-fns';
@Pipe({
name: 'dfnsFormatRelativeLive',
pure: false,
standalone: true,
})
export class FormatRelativeLivePipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value: string;
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
/**
* @param value An ISO 8601 duration string
*/
transform(value: Duration): string {
this.updateValue(value);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(value);
});
}
return this.value;
}
updateValue(value: Duration): void {
const fequencyPrefix = Object.keys(this.frequencyPrefixes).filter(element =>
this.locale.includes(element),
);
this.value = [
fequencyPrefix.length > 0 ? this.frequencyPrefixes[fequencyPrefix[0]] : this.frequencyPrefixes.en,
formatDuration(value),
].join(' ');
}
}

View File

@@ -1,141 +0,0 @@
import {Injectable, OnDestroy, Pipe, PipeTransform} from '@angular/core';
import {Subscription} from 'rxjs';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {logger} from '../../_helpers/ts-logger';
import opening_hours from 'opening_hours';
import {addHours, formatRelative, isBefore, isToday} from 'date-fns';
@Injectable()
@Pipe({
name: 'openingHours',
standalone: true,
pure: false,
})
export class OpeningHoursPipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value: string[] = [];
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
transform(aString: string | unknown): string[] {
this.updateValue(aString);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(aString);
});
}
return this.value;
}
updateValue(aString: string | unknown) {
if (typeof aString !== 'string') {
logger.warn(`openingHours pipe unable to parse input: ${aString}`);
return;
}
let openingHours;
try {
openingHours = new opening_hours(aString, {
address: {
country_code: 'de',
state: 'Hessen',
},
lon: 8.667_97,
lat: 50.129_16,
});
} catch (error) {
logger.warn(error);
this.value = [];
return;
}
const isOpen: boolean = openingHours.getState();
const isUnknown: boolean = openingHours.getUnknown();
const nextChange = openingHours.getNextChange();
const nextChangeIsOpen: boolean = openingHours.getState(nextChange);
const nextChangeUnknown: boolean = openingHours.getUnknown(nextChange);
const nextChangeIsToday: boolean = isToday(nextChange!);
let stateKey = isOpen ? 'common.openingHours.state_open' : 'common.openingHours.state_closed';
stateKey = isUnknown ? 'common.openingHours.state_maybe' : stateKey;
this.value = [isOpen ? 'success' : 'danger', `${this.translate.instant(stateKey)}`];
if (isUnknown) {
const comment = openingHours.getComment();
this.value = ['light', `${this.translate.instant(stateKey)}`];
if (typeof comment === 'string') {
this.value.push(comment);
}
return;
}
if (nextChangeUnknown) {
return;
}
let nextChangeKey: string | undefined;
let formattedCalender = formatRelative(nextChange!, Date.now());
if (isBefore(nextChange!, addHours(Date.now(), 1))) {
this.value[0] = 'warning';
nextChangeKey = nextChangeIsOpen
? 'common.openingHours.opening_soon_warning'
: 'common.openingHours.closing_soon_warning';
this.value.push(
`${this.translate.instant(nextChangeKey, {
time: new Intl.DateTimeFormat(this.locale, {
timeStyle: 'short',
}).format(nextChange),
})}`,
);
return;
}
if (nextChangeIsToday) {
nextChangeKey = nextChangeIsOpen
? 'common.openingHours.opening_today'
: 'common.openingHours.closing_today';
this.value.push(
`${this.translate.instant(nextChangeKey, {
time: new Intl.DateTimeFormat(this.locale, {
timeStyle: 'short',
}).format(nextChange),
})}`,
);
return;
}
nextChangeKey = nextChangeIsOpen ? 'common.openingHours.opening' : 'common.openingHours.closing';
formattedCalender = formattedCalender.slice(0, 1).toUpperCase() + formattedCalender.slice(1);
this.value.push(
`${this.translate.instant(nextChangeKey, {
relativeDateTime: formattedCalender,
})}`,
);
return;
}
}

View File

@@ -14,6 +14,19 @@
*/
import {SCLanguageCode} from '@openstapps/core';
import type {Locale} from 'date-fns';
import {TranslateService} from '@ngx-translate/core';
export interface LocaleExtension extends Locale {
formatFrequencyOptions: Partial<Record<Intl.LDMLPluralRule, string>>;
formatRelativeDateOptions: {
lastWeek: string;
yesterday: string;
today: string;
tomorrow: string;
nextWeek: string;
other: string;
};
}
type LocalesMap = Record<SCLanguageCode, () => Promise<{default: Locale}>>;
@@ -25,11 +38,23 @@ const LOCALES = {
/**
* Get a Date Fns Locale
*/
export async function getDateFnsLocale(code: SCLanguageCode): Promise<Locale> {
if (code in LOCALES) {
return LOCALES[code as keyof typeof LOCALES]().then(it => it.default);
} else {
export async function getDateFnsLocale(
code: SCLanguageCode,
translator: TranslateService,
): Promise<LocaleExtension> {
if (!(code in LOCALES)) {
console.warn(`Unknown Locale "${code}" for Date Fns. Falling back to English.`);
return LOCALES.en().then(it => it.default);
}
const key = code in LOCALES ? (code as keyof typeof LOCALES) : 'en';
const translations = translator.translations[translator.currentLang];
const frequencyExtension = translations['dateFns']['FORMAT_FREQUENCY'];
const relativeDateExtension = translations['dateFns']['FORMAT_RELATIVE_DATE'];
return LOCALES[key]().then(it => {
const locale = it.default as LocaleExtension;
locale.formatFrequencyOptions = {...frequencyExtension};
locale.formatRelativeDateOptions = {...relativeDateExtension};
return locale;
});
}

View File

@@ -54,7 +54,7 @@ export class ThingTranslateService {
/** set the default language from configuration */
this.translateService.onLangChange.pipe(takeUntilDestroyed()).subscribe((event: LangChangeEvent) => {
this.translator.language = event.lang as keyof SCTranslations<SCLanguage>;
getDateFnsLocale(event.lang as SCLanguageCode).then(locale => {
getDateFnsLocale(event.lang as SCLanguageCode, translateService).then(locale => {
setDefaultOptions({locale});
this.dfnsConfiguration.setLocale(locale);
});

View File

@@ -8,6 +8,20 @@
"export": "Exportieren",
"share": "Teilen",
"timeSuffix": "Uhr",
"dateFns": {
"FORMAT_FREQUENCY": {
"one": "'jede' {{suffix}}",
"many": "'alle' {{duration}}"
},
"FORMAT_RELATIVE_DATE": {
"lastWeek": "'letzten' eeee",
"yesterday": "'gestern'",
"today": "'heute'",
"tomorrow": "'morgen'",
"nextWeek": "'nächsten' eeee",
"other": "pp"
}
},
"ratings": {
"thank_you": "Vielen Dank für die Bewertung!"
},

View File

@@ -8,6 +8,20 @@
"export": "Export",
"share": "Share",
"timeSuffix": "",
"dateFns": {
"FORMAT_FREQUENCY": {
"one": "'every' {{suffix}}",
"many": "'every' {{duration}}"
},
"FORMAT_RELATIVE_DATE": {
"lastWeek": "'last' eeee",
"yesterday": "'yesterday'",
"today": "'today'",
"tomorrow": "'tomorrow'",
"nextWeek": "'next' eeee",
"other": "pp"
}
},
"ratings": {
"thank_you": "Thank you for your feedback!"
},

22
pnpm-lock.yaml generated
View File

@@ -842,6 +842,9 @@ importers:
deepmerge:
specifier: 4.3.1
version: 4.3.1
duration-fns:
specifier: 3.0.2
version: 3.0.2
form-data:
specifier: 4.0.0
version: 4.0.0
@@ -863,9 +866,6 @@ importers:
material-symbols:
specifier: 0.10.0
version: 0.10.0
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)
@@ -875,9 +875,6 @@ importers:
ngx-markdown:
specifier: 16.0.0
version: 16.0.0(@angular/common@16.1.4)(@angular/core@16.1.4)(@angular/platform-browser@16.1.4)(@types/marked@4.3.1)(marked@4.3.0)(rxjs@7.8.1)(zone.js@0.13.1)
ngx-moment:
specifier: 6.0.2
version: 6.0.2(moment@2.29.4)
opening_hours:
specifier: 3.8.0
version: 3.8.0
@@ -10328,6 +10325,10 @@ packages:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
dev: true
/duration-fns@3.0.2:
resolution: {integrity: sha512-w82IXh/6aWNHFA0qlQazJYJrZTWieTItuuGTE7YX4cxPaZTWhmVImbsBBiMK1/OhGDgiinuCpJoSFILYLDSKDg==}
dev: false
/eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -14333,15 +14334,6 @@ packages:
prismjs: 1.29.0
dev: false
/ngx-moment@6.0.2(moment@2.29.4):
resolution: {integrity: sha512-HUvDyoJPZKLA3tc+GMQqDpVyCVT2SPfEiV7/CGj2Dwwsn//JhhQ8eTr+RzKqBzLysrXkCwlzulVVJaJ5A0FJEA==}
peerDependencies:
moment: ^2.19.3
dependencies:
moment: 2.29.4
tslib: 2.4.1
dev: false
/nice-napi@1.0.2:
resolution: {integrity: sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA==}
os: ['!win32']