mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* 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:
parent
95d3b212bc
commit
631e6e9710
3 changed files with 270 additions and 0 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
111
src/app/core/date-time-format/custom-date-adapter.spec.ts
Normal file
111
src/app/core/date-time-format/custom-date-adapter.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue