fix(locale): localize ISO 8601 calendar weekday/month names (#8987) (#9013)

* docs(plugins): add microsoft 365 calendar provider plan

* docs(plugins): add microsoft 365 calendar provider plan

* docs(ios): plan internal testflight builds

* fix(locale): localize ISO calendar weekday/month names (#8987)

PR #8991 localized ISO 8601 weekday labels only in the custom
Schedule/Habits/Planner components. Material <mat-calendar> (schedule-task
dialog, deadline dialog, date-picker inputs, repeat-task heatmap) still
rendered month + weekday names via the global adapter locale 'sv' (the ISO
sentinel), so they showed Swedish and ignored the app language.

Override getDayOfWeekNames, getMonthNames and the spelled-out branch of
format() in CustomDateAdapter to run under a temporary swap to
isoTextLocale() (the UI language) when the ISO option is active. Numeric
dateInput and time-only formats keep the configured locale, so ISO stays
YYYY-MM-DD and the 24h clock is preserved. The swap assigns this.locale
directly (not setLocale) so it fires no spurious localeChanges.

Add unit coverage for the adapter and a real <mat-calendar> integration
spec asserting UI-language headers, a live language switch, and the
non-ISO fallback.
This commit is contained in:
Johannes Millan 2026-07-14 21:03:15 +02:00 committed by GitHub
parent 95d3b212bc
commit 631e6e9710
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 270 additions and 0 deletions

View file

@ -0,0 +1,109 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_DATE_LOCALE,
MAT_NATIVE_DATE_FORMATS,
} from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { CustomDateAdapter } from './custom-date-adapter';
import { DateTimeFormatService } from './date-time-format.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
/**
* Integration coverage for the #8987 follow-up: the Material `<mat-calendar>`
* (used by the schedule-task dialog, deadline dialog, etc.) must render its
* weekday header in the UI language when the ISO 8601 option is active, and
* update live when the language changes while the adapter locale itself stays
* `sv` (the ISO sentinel).
*/
@Component({
standalone: true,
imports: [MatDatepickerModule],
template: `<mat-calendar [startAt]="startAt"></mat-calendar>`,
})
class CalendarHostComponent {
startAt = new Date(2026, 6, 14);
}
describe('CustomDateAdapter × mat-calendar weekday header', () => {
let fixture: ComponentFixture<CalendarHostComponent>;
let adapter: DateAdapter<Date>;
let isoTextLocale: string | null;
const longWeekdayNames = (): string[] => {
const host = fixture.nativeElement as HTMLElement;
return Array.from(
host.querySelectorAll<HTMLElement>(
'.mat-calendar-table-header th .cdk-visually-hidden',
),
(el) => el.textContent?.trim() ?? '',
);
};
beforeEach(() => {
isoTextLocale = 'en';
const dateTimeFormatServiceMock: Partial<DateTimeFormatService> = {
isoTextLocale: (() => isoTextLocale) as DateTimeFormatService['isoTextLocale'],
dateFormat: (() => ({
raw: 'yyyy-MM-dd',
humanReadable: 'YYYY-MM-DD',
})) as DateTimeFormatService['dateFormat'],
formatDate: (date: Date) => date.toISOString().slice(0, 10),
};
TestBed.configureTestingModule({
imports: [CalendarHostComponent, NoopAnimationsModule],
providers: [
{ provide: DateAdapter, useClass: CustomDateAdapter },
{ provide: MAT_DATE_LOCALE, useValue: 'sv' },
{ provide: MAT_DATE_FORMATS, useValue: MAT_NATIVE_DATE_FORMATS },
{ provide: DateTimeFormatService, useValue: dateTimeFormatServiceMock },
{
provide: GlobalConfigService,
useValue: { localization: () => ({ firstDayOfWeek: 1 }) },
},
],
});
adapter = TestBed.inject(DateAdapter);
// Mirror the running app: the ISO 8601 option keeps `sv` as the adapter locale.
adapter.setLocale('sv');
fixture = TestBed.createComponent(CalendarHostComponent);
fixture.detectChanges();
});
it('renders the weekday header in the UI language, not Swedish', () => {
const names = longWeekdayNames();
expect(names).toContain('Monday');
expect(names).not.toContain('måndag');
// Adapter locale is untouched, so numeric/date formatting stays ISO.
expect((adapter as unknown as { locale: string }).locale).toBe('sv');
});
it('updates the header live when the UI language changes', () => {
expect(longWeekdayNames()).toContain('Monday');
// Simulate a language switch: the service effect re-applies the adapter
// locale, whose localeChanges event makes the calendar re-read the names.
isoTextLocale = 'de';
adapter.setLocale('sv');
fixture.detectChanges();
const names = longWeekdayNames();
expect(names).toContain('Montag');
expect(names).not.toContain('Monday');
});
it('falls back to the adapter locale for non-ISO options (null text locale)', () => {
isoTextLocale = null;
adapter.setLocale('sv');
fixture.detectChanges();
expect(longWeekdayNames()).toContain('måndag');
});
});

View file

@ -0,0 +1,111 @@
import { TestBed } from '@angular/core/testing';
import { MAT_DATE_LOCALE } from '@angular/material/core';
import { CustomDateAdapter } from './custom-date-adapter';
import { DateTimeFormatService } from './date-time-format.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
describe('CustomDateAdapter (ISO weekday/month localization)', () => {
let adapter: CustomDateAdapter;
let isoTextLocale: string | null;
const RAW_NUMERIC_FORMAT = 'yyyy-MM-dd';
const NUMERIC_SENTINEL = 'NUMERIC-2026-07-14';
// July 14, 2026 (month index 6). Local components are UTC-normalized by the
// adapter's `_format`, so the rendered month/weekday is stable across zones.
const testDate = new Date(2026, 6, 14);
beforeEach(() => {
isoTextLocale = null;
const dateTimeFormatServiceMock: Partial<DateTimeFormatService> = {
isoTextLocale: (() => isoTextLocale) as DateTimeFormatService['isoTextLocale'],
dateFormat: (() => ({
raw: RAW_NUMERIC_FORMAT,
humanReadable: RAW_NUMERIC_FORMAT.toUpperCase(),
})) as DateTimeFormatService['dateFormat'],
formatDate: () => NUMERIC_SENTINEL,
};
TestBed.configureTestingModule({
providers: [
CustomDateAdapter,
{ provide: MAT_DATE_LOCALE, useValue: 'sv' },
{ provide: DateTimeFormatService, useValue: dateTimeFormatServiceMock },
{
provide: GlobalConfigService,
useValue: { localization: () => ({ firstDayOfWeek: 1 }) },
},
],
});
adapter = TestBed.inject(CustomDateAdapter);
// Mirror the app: the ISO 8601 option keeps `sv` as the adapter locale.
adapter.setLocale('sv');
});
describe('with an ISO text locale set (ISO 8601 option active)', () => {
it('renders weekday names in the UI language', () => {
isoTextLocale = 'en';
expect(adapter.getDayOfWeekNames('long')).toContain('Monday');
isoTextLocale = 'de';
expect(adapter.getDayOfWeekNames('long')).toContain('Montag');
});
it('renders month names in the UI language', () => {
isoTextLocale = 'en';
expect(adapter.getMonthNames('long')[6]).toBe('July');
isoTextLocale = 'de';
expect(adapter.getMonthNames('long')[6]).toBe('Juli');
});
it('renders the spelled-out month/year header in the UI language', () => {
isoTextLocale = 'en';
expect(adapter.format(testDate, { year: 'numeric', month: 'long' })).toBe(
'July 2026',
);
});
it('keeps the numeric date format on the configured locale', () => {
isoTextLocale = 'en';
expect(adapter.format(testDate, RAW_NUMERIC_FORMAT)).toBe(NUMERIC_SENTINEL);
});
it('keeps time-only formats on the adapter locale (24h preserved)', () => {
isoTextLocale = 'en-US';
const withTime = new Date(2026, 6, 14, 13, 0, 0);
expect(adapter.format(withTime, { hour: 'numeric', minute: '2-digit' })).toBe(
'13:00',
);
});
it('restores the adapter locale after a localized read', () => {
isoTextLocale = 'de';
adapter.getMonthNames('long');
adapter.getDayOfWeekNames('narrow');
adapter.format(testDate, { year: 'numeric', month: 'long' });
expect((adapter as unknown as { locale: string }).locale).toBe('sv');
});
});
describe('without an ISO text locale (every non-ISO option)', () => {
it('leaves weekday names on the adapter locale', () => {
isoTextLocale = null;
expect(adapter.getDayOfWeekNames('long')).toContain('måndag');
});
it('leaves month names on the adapter locale', () => {
isoTextLocale = null;
expect(adapter.getMonthNames('long')[6]).toBe('juli');
});
it('leaves the month/year header on the adapter locale', () => {
isoTextLocale = null;
expect(adapter.format(testDate, { year: 'numeric', month: 'long' })).toBe(
'juli 2026',
);
});
});
});

View file

@ -52,7 +52,57 @@ export class CustomDateAdapter extends NativeDateAdapter {
return this._dateTimeFormatService.formatDate(date);
}
// Spelled-out month/weekday labels (e.g. the calendar's "July 2026" header)
// follow the UI language when the ISO 8601 option is active. Numeric and
// time-only formats keep the configured locale, so ISO stays YYYY-MM-DD and
// the 24h clock is preserved (#8987 follow-up).
if (this._hasSpelledOutName(displayFormat)) {
return this._withTextLocale(() => super.format(date, displayFormat));
}
// For other formats, use default
return super.format(date, displayFormat);
}
// Calendar weekday header row ('M T W ...'). Localized to the UI language for
// the ISO 8601 option; unchanged for every other locale.
override getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] {
return this._withTextLocale(() => super.getDayOfWeekNames(style));
}
// Month names shown in the calendar's year view.
override getMonthNames(style: 'long' | 'short' | 'narrow'): string[] {
return this._withTextLocale(() => super.getMonthNames(style));
}
/**
* Run `fn` with the adapter locale temporarily swapped to the ISO text locale
* (the UI language, exposed only when the ISO 8601 option is selected). When
* no ISO text locale is set the callback runs unchanged. Direct field
* assignment is used instead of `setLocale()` so no `localeChanges` event
* fires during the swap.
*/
private _withTextLocale<T>(fn: () => T): T {
const textLocale = this._dateTimeFormatService.isoTextLocale();
if (!textLocale) return fn();
const prevLocale = this.locale;
this.locale = textLocale;
try {
return fn();
} finally {
this.locale = prevLocale;
}
}
private _hasSpelledOutName(
displayFormat: Intl.DateTimeFormatOptions | string,
): boolean {
if (typeof displayFormat === 'string') return false;
const spelledOut = ['long', 'short', 'narrow'];
return (
spelledOut.includes(displayFormat.weekday as string) ||
spelledOut.includes(displayFormat.month as string)
);
}
}