Skip to content

Commit

Permalink
hotfix: Downtime notice for 2024-12-11 (#2896)
Browse files Browse the repository at this point in the history
* Add support for showing timezone in date format (#2887)

(cherry picked from commit 4177bf2)

* Add global notices component with upcoming downtime message (#2888)

(cherry picked from commit 3c86fff)

* Downtime notice for 2024-12-11

---------

Co-authored-by: Nateowami <[email protected]>
Co-authored-by: Nateowami <[email protected]>
  • Loading branch information
3 people authored Dec 10, 2024
1 parent 50ef858 commit bd525fa
Show file tree
Hide file tree
Showing 9 changed files with 147 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@
<!-- The cdkScrollable attribute is needed so the CDK can listen to scroll events within this container -->
<div #appContent cdkScrollable class="app-content" [dir]="i18n.direction">
<div>
<app-global-notices></app-global-notices>
<router-outlet></router-outlet>
@if (showCheckingDisabled) {
<p class="checking-unavailable">{{ t("scripture_checking_not_available") }}</p>
Expand Down
4 changes: 3 additions & 1 deletion src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { ProjectComponent } from './project/project.component';
import { ScriptureChooserDialogComponent } from './scripture-chooser-dialog/scripture-chooser-dialog.component';
import { DeleteProjectDialogComponent } from './settings/delete-project-dialog/delete-project-dialog.component';
import { SettingsComponent } from './settings/settings.component';
import { GlobalNoticesComponent } from './shared/global-notices/global-notices.component';
import { SharedModule } from './shared/shared.module';
import { TextNoteDialogComponent } from './shared/text/text-note-dialog/text-note-dialog.component';
import { SyncComponent } from './sync/sync.component';
Expand Down Expand Up @@ -75,7 +76,8 @@ import { UsersModule } from './users/users.module';
AppRoutingModule,
SharedModule,
AvatarComponent,
MatRipple
MatRipple,
GlobalNoticesComponent
],
providers: [
CookieService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<ng-container *transloco="let t; read: 'global_notices'">
@if (showDowntimeNotice) {
<app-notice class="downtime-notice" type="warning" mode="fill-dark" icon="construction">
<div class="downtime-notice-content-wrapper">
<div>
{{
t("upcoming_maintenance", {
duration,
startTime: i18n.formatDate(upcomingDowntime.start, { showTimeZone: true })
})
}}
<a [href]="upcomingDowntime.detailsUrl" target="_blank">{{ t("learn_more") }}</a>
</div>
<button mat-icon-button (click)="showDowntimeNotice = false" [matTooltip]="t('close')">
<mat-icon>close</mat-icon>
</button>
</div>
</app-notice>
}
</ng-container>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.downtime-notice {
margin: 0 auto 12px;
padding-block: 6px;
width: 65em;
max-width: 100%;
}

.downtime-notice-content-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslocoModule } from '@ngneat/transloco';
import { I18nService } from 'xforge-common/i18n.service';
import { NoticeComponent } from '../notice/notice.component';

@Component({
selector: 'app-global-notices',
standalone: true,
imports: [CommonModule, NoticeComponent, MatIconModule, MatButtonModule, MatTooltipModule, TranslocoModule],
templateUrl: './global-notices.component.html',
styleUrl: './global-notices.component.scss'
})
export class GlobalNoticesComponent implements OnInit {
// This is only an input so that the Storybook can turn this on even when it's off in the app
@Input() showDowntimeNotice = false;

upcomingDowntime = {
start: new Date('2024-12-11 16:30 UTC'),
durationMin: 2,
durationMax: 3,
durationUnit: 'hour',
detailsUrl: 'https://software.sil.org/scriptureforge/news/'
} as const;

constructor(readonly i18n: I18nService) {}

get duration(): string {
return this.i18n.translateStatic(`global_notices.${this.upcomingDowntime.durationUnit}_range`, {
min: this.upcomingDowntime.durationMin,
max: this.upcomingDowntime.durationMax
});
}

ngOnInit(): void {
const fifteenMinutesMs = 15 * 60 * 1000;
const fifteenMinutesPastStart = new Date(this.upcomingDowntime.start.getTime() + fifteenMinutesMs);
// Show the downtime notice up until 15 minutes past the start time.
this.showDowntimeNotice = new Date() <= fifteenMinutesPastStart;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Meta, StoryObj } from '@storybook/angular';
import { GlobalNoticesComponent } from './global-notices.component';

const meta: Meta<GlobalNoticesComponent> = {
title: 'Shared/Global Notices',
component: GlobalNoticesComponent
};

export default meta;
type Story = StoryObj<GlobalNoticesComponent>;

export const Default: Story = {
args: { showDowntimeNotice: true }
};
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,13 @@
"font_size": {
"text_size": "Text size"
},
"global_notices": {
"close": "Close",
"hour_range": "{{ min }} to {{ max }} hours",
"learn_more": "Learn more",
"minute_range": "{{ min }} to {{ max }} minutes",
"upcoming_maintenance": "Scripture Forge will be down for maintenance for about {{ duration }} starting at {{ startTime }}."
},
"issue_email": {
"heading": "Please explain the problem.",
"technical_details": "Technical details",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,20 @@ describe('I18nService', () => {
expect(service.formatDate(date)).toEqual('25.11.1991 17:28');
});

it('should support including the timezone in the date', () => {
const date = new Date('November 25, 1991 17:28');
const service = getI18nService();

// look for ending with something like " UTC+5" or " EST"
const trailingTimezoneRegex = / (UTC[\-+]\d+|[A-Z]+)$/;

service.setLocale('fr');
expect(service.formatDate(date, { showTimeZone: true })).toMatch(trailingTimezoneRegex);

service.setLocale('az');
expect(service.formatDate(date, { showTimeZone: true })).toMatch(trailingTimezoneRegex);
});

it('should interpolate translations around and within numbered template tags', done => {
when(mockedTranslocoService.selectTranslate<string>('app.settings', anything())).thenReturn(
of('A quick brown { 1 }fox{ 2 } jumps over the lazy { 3 }dog{ 4 }.')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { Locale, LocaleDirection } from './models/i18n-locale';
import { PseudoLocalization } from './pseudo-localization';
import { ASP_CULTURE_COOKIE_NAME, aspCultureCookieValue, getAspCultureCookieLanguage, getI18nLocales } from './utils';

export type DateFormat = Intl.DateTimeFormatOptions | ((date: Date) => string);
export type DateFormat = Intl.DateTimeFormatOptions | ((date: Date, options: { showTimeZone?: boolean }) => string);

export interface TextAroundTemplate {
before: string;
Expand Down Expand Up @@ -74,8 +74,13 @@ export class I18nService {
en: { month: 'short' },
'en-GB': { month: 'short', hour12: true },
// Chrome formats az dates as en-US. This manual override is the format Firefox uses for az
az: (d: Date) =>
`${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`,
az: (d: Date, options) => {
let s = `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (options.showTimeZone) {
s += ` ${I18nService.getHumanReadableTimeZoneOffset('az', d)}`;
}
return s;
},
[PseudoLocalization.locale.canonicalTag]: PseudoLocalization.dateFormat
};

Expand Down Expand Up @@ -234,6 +239,13 @@ export class I18nService {
return this.transloco.selectTranslate<string>(key, params);
}

/** Returns a translation for the given I18nKey. A string is returned, so this cannot be used to keep a localization
* updated without re-calling this whenever the locale changes. Avoid using this when observing a localization is
* possible. */
translateStatic(key: I18nKey, params: object = {}): string {
return this.transloco.translate(key, params);
}

translateAndInsertTags(key: I18nKey, params: object = {}): string {
return this.transloco.translate(key, {
...params,
Expand All @@ -249,15 +261,23 @@ export class I18nService {
});
}

formatDate(date: Date): string {
formatDate(date: Date, options: { showTimeZone?: boolean } = {}): string {
// fall back to en in the event the language code isn't valid
const format = I18nService.dateFormats[this.localeCode] || {};
return typeof format === 'function'
? format(date)
? format(date, options)
: date.toLocaleString(
[this.localeCode, I18nService.defaultLocale.canonicalTag],
// Browser default is all numeric, but includes seconds. This is same as default, but without seconds
{ month: 'numeric', year: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', ...format }
{
month: 'numeric',
year: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
...(options?.showTimeZone ? { timeZoneName: 'short' } : {}),
...format
}
);
}

Expand Down Expand Up @@ -369,6 +389,12 @@ export class I18nService {
}
}

static getHumanReadableTimeZoneOffset(localeCode: string, date: Date): string {
return new Intl.DateTimeFormat(localeCode, { timeZoneName: 'short' })
.formatToParts(date)
.find(e => e.type === 'timeZoneName').value;
}

private getTranslation(key: I18nKey): string {
return (
this.transloco.getTranslation(this.transloco.getActiveLang())[key] ??
Expand Down

0 comments on commit bd525fa

Please sign in to comment.