+
@if (showCheckingDisabled) {
{{ t("scripture_checking_not_available") }}
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts
index 1fbdf4f17a..6cc5897130 100644
--- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts
+++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts
@@ -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';
@@ -75,7 +76,8 @@ import { UsersModule } from './users/users.module';
AppRoutingModule,
SharedModule,
AvatarComponent,
- MatRipple
+ MatRipple,
+ GlobalNoticesComponent
],
providers: [
CookieService,
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.html
new file mode 100644
index 0000000000..82d34333d5
--- /dev/null
+++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.html
@@ -0,0 +1,20 @@
+
+ @if (showDowntimeNotice) {
+
+
+
+ {{
+ t("upcoming_maintenance", {
+ duration,
+ startTime: i18n.formatDate(upcomingDowntime.start, { showTimeZone: true })
+ })
+ }}
+
{{ t("learn_more") }}
+
+
+
+
+ }
+
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.scss
new file mode 100644
index 0000000000..0c76028533
--- /dev/null
+++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.scss
@@ -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;
+}
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.ts
new file mode 100644
index 0000000000..8aefd8a25a
--- /dev/null
+++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.ts
@@ -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;
+ }
+}
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.stories.ts
new file mode 100644
index 0000000000..da8dc93fbe
--- /dev/null
+++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.stories.ts
@@ -0,0 +1,14 @@
+import { Meta, StoryObj } from '@storybook/angular';
+import { GlobalNoticesComponent } from './global-notices.component';
+
+const meta: Meta
= {
+ title: 'Shared/Global Notices',
+ component: GlobalNoticesComponent
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: { showDowntimeNotice: true }
+};
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json
index 3f42391573..36543a08f7 100644
--- a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json
+++ b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json
@@ -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",
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts
index 5d26862555..1540c065ad 100644
--- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts
+++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts
@@ -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('app.settings', anything())).thenReturn(
of('A quick brown { 1 }fox{ 2 } jumps over the lazy { 3 }dog{ 4 }.')
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts
index a21fd1f10f..b4f05f05c3 100644
--- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts
+++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts
@@ -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;
@@ -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
};
@@ -234,6 +239,13 @@ export class I18nService {
return this.transloco.selectTranslate(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,
@@ -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
+ }
);
}
@@ -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] ??