diff --git a/eslint-local-rules/index.js b/eslint-local-rules/index.js index dc89c4c4de..154647faed 100644 --- a/eslint-local-rules/index.js +++ b/eslint-local-rules/index.js @@ -15,4 +15,5 @@ module.exports = { 'no-actions-in-effects': require('./rules/no-actions-in-effects'), 'no-multi-entity-effect': require('./rules/no-multi-entity-effect'), 'no-adapter-in-tx': require('./rules/no-adapter-in-tx'), + 'require-text-locale': require('./rules/require-text-locale'), }; diff --git a/eslint-local-rules/rules/require-text-locale.js b/eslint-local-rules/rules/require-text-locale.js new file mode 100644 index 0000000000..78e0ebfd64 --- /dev/null +++ b/eslint-local-rules/rules/require-text-locale.js @@ -0,0 +1,230 @@ +/** + * ESLint rule: require-text-locale + * + * Spelled-out weekday/month names must be formatted with + * `DateTimeFormatService.textLocale()`, never `currentLocale()` and never the + * implicit browser locale. + * + * Why: the ISO 8601 date option persists `dateTimeLocale = 'sv'` as a + * backward-compatible sync marker (it yields YYYY-MM-DD + a 24h clock). ISO has + * no spelled-out names of its own, so any name formatted with `currentLocale()` + * comes out Swedish regardless of the UI language — "ons 15 juli 2026", + * "Weekly on onsdag". That is #8987, which recurred across three PRs (#9013, + * #9055, #9056) because `currentLocale()` is the obvious-looking default at + * every new call site. + * + * `textLocale()` is `isoTextLocale() ?? currentLocale()`, so for every non-ISO + * option it IS `currentLocale()`. For spelled-out names it is therefore never + * worse and sometimes right — which is why this is an error, not a heuristic. + * + * Flagged: `.toLocaleDateString()` / `.toLocaleString()` / `new + * Intl.DateTimeFormat()` whose options contain a spelled-out field (`weekday`, + * `month: 'long'|'short'|'narrow'`, `dateStyle: 'full'|'long'|'medium'`, `era`, + * `dayPeriod`) and no clock time, when the locale argument is `currentLocale()` + * — directly, or via a `const` initialised from it (the shape the original + * #8987 bug had) — or is absent/`undefined`, which silently uses the browser + * locale and ignores both the configured locale AND the UI language. + * + * Deliberately NOT detected (pinned as `valid` cases in the spec so the boundary + * is explicit and a change that starts catching them trips the spec): + * - a locale threaded through a parameter — `formatDayStr(dateStr, locale)`, + * `getWeekdaysMin(locale)`: the rule cannot see what the caller passed, so + * the obligation sits with the caller + * - a reassigned locale variable, or one built by a helper/ternary + * - a non-literal options object (variable or spread) + * - `.toLocaleTimeString()`: a clock time, whose locale is pinned by the 24h + * rule below + * - ANY options object that also formats a clock time (`hour`, `timeStyle`), + * whatever else it renders — `{ hour, minute }`, `{ hour, minute, dayPeriod }`, + * `{ weekday, hour }`, `{ dateStyle, timeStyle }`. See + * `rendersSpelledOutName` for why the rule cannot advise on these. + * + * A clean run does NOT prove a file is free of #8987 — it proves the direct + * call sites are. + */ + +/** Options fields that render a spelled-out name whatever their value. */ +const ALWAYS_SPELLED_OUT = new Set(['weekday', 'era', 'dayPeriod']); + +/** + * Fields that render a spelled-out name only for certain values — each with its + * OWN value set, because the two invert and a shared set would be a bug: + * `month: 'short'` is "Jul" (spelled out) but `dateStyle: 'short'` is + * "2026-07-15" (numeric). Flagging `dateStyle: 'short'` would push the reader to + * route ISO's YYYY-MM-DD through textLocale() and break it — the mirror of the + * clock-time trap below. Values not listed are digits and must keep + * `currentLocale()` so ISO day-first ordering survives. + */ +const SPELLED_OUT_BY_VALUE = new Map([ + ['month', new Set(['long', 'short', 'narrow'])], + ['dateStyle', new Set(['full', 'long', 'medium'])], +]); + +/** Options fields that format a clock time. */ +const CLOCK_TIME_FIELDS = new Set(['hour', 'timeStyle']); + +const NAME_FORMATTERS = new Set(['toLocaleDateString', 'toLocaleString']); + +/** The key of a statically-readable options property, or `null`. */ +const propKey = (prop) => { + if (prop.type !== 'Property' || prop.computed) return null; + return prop.key.name || prop.key.value; +}; + +/** True for an options object literal that also formats a clock time. */ +const rendersClockTime = (optsNode) => + optsNode.properties.some((prop) => CLOCK_TIME_FIELDS.has(propKey(prop))); + +/** True when the property renders a spelled-out name rather than digits. */ +const isSpelledOutProp = (prop) => { + const key = propKey(prop); + if (key === null) return false; + if (ALWAYS_SPELLED_OUT.has(key)) return true; + const spelledOutValues = SPELLED_OUT_BY_VALUE.get(key); + return ( + !!spelledOutValues && + prop.value.type === 'Literal' && + spelledOutValues.has(prop.value.value) + ); +}; + +/** + * True for an options object literal that renders at least one spelled-out name + * and no clock time. + * + * A clock time in the same options object pins the locale: it renders 24h under + * the ISO `sv` sentinel but 12h under most UI languages, so swapping the whole + * call to `textLocale()` would trade a Swedish name for a broken clock — "onsdag + * 13:05" becomes "Wednesday 1:05 PM", the very ISO regression this rule family + * exists to prevent. Such a format has no single correct locale; it has to be + * split (names on `textLocale()`, clock on `currentLocale()` — see + * `plannedStartDateStr`), which is more than a one-locale message can advise. + * Staying silent costs a blind spot on `{ weekday, hour }`; firing would cost + * confidently wrong advice at `error` severity. + */ +const rendersSpelledOutName = (optsNode) => { + if (!optsNode || optsNode.type !== 'ObjectExpression') return false; + if (rendersClockTime(optsNode)) return false; + return optsNode.properties.some(isSpelledOutProp); +}; + +/** True for `.currentLocale()`. */ +const isCurrentLocaleCall = (node) => + node && + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + !node.callee.computed && + node.callee.property.name === 'currentLocale'; + +const findVariable = (scope, name) => { + for (let s = scope; s; s = s.upper) { + const found = s.variables.find((v) => v.name === name); + if (found) return found; + } + return null; +}; + +/** + * True when `node` is `currentLocale()` or an identifier that can only hold its + * result. The single-write check keeps this to variables that are never + * reassigned, so we never guess at a value the rule cannot actually see. + */ +const resolvesToCurrentLocale = (node, scope) => { + if (isCurrentLocaleCall(node)) return true; + if (!node || node.type !== 'Identifier' || !scope) return false; + + const variable = findVariable(scope, node.name); + if (!variable || variable.defs.length !== 1) return false; + + const def = variable.defs[0]; + if (def.type !== 'Variable' || !def.node.init) return false; + if (variable.references.filter((ref) => ref.isWrite()).length !== 1) return false; + + return isCurrentLocaleCall(def.node.init); +}; + +/** `undefined` / omitted / `null` all fall back to the browser's locale. */ +const isImplicitLocale = (node) => + !node || + (node.type === 'Identifier' && node.name === 'undefined') || + (node.type === 'Literal' && node.value === null); + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Spelled-out weekday/month names must be formatted with textLocale(), not currentLocale() or the implicit browser locale', + category: 'Possible Errors', + recommended: false, + }, + messages: { + numericLocaleForName: + 'This formats a spelled-out {{field}} with currentLocale(). Under the ISO 8601 option currentLocale() is the `sv` sentinel, so the name renders in Swedish whatever the UI language (#8987). Use DateTimeFormatService.textLocale() — it equals currentLocale() for every non-ISO option. Numeric-only parts (month: "numeric", dateStyle: "short", day, year) should keep currentLocale().', + implicitLocaleForName: + 'This formats a spelled-out {{field}} with no locale, so it follows the *browser* locale and ignores both the configured date locale and the UI language. Use DateTimeFormatService.textLocale().', + }, + schema: [], + }, + + create(context) { + const sourceCode = context.sourceCode || context.getSourceCode(); + + /** Name the offending field so the message points at the actual culprit. */ + const spelledOutField = (optsNode) => { + const prop = optsNode.properties.find(isSpelledOutProp); + return prop ? propKey(prop) : 'name'; + }; + + /** Both `d.toLocaleDateString(locale, opts)` and `new Intl.DateTimeFormat(locale, opts)`. */ + const check = (node) => { + const [localeArg, optsArg] = node.arguments; + if (!rendersSpelledOutName(optsArg)) return; + + const field = spelledOutField(optsArg); + const scope = sourceCode.getScope ? sourceCode.getScope(node) : null; + + if (isImplicitLocale(localeArg)) { + context.report({ + node: localeArg || node, + messageId: 'implicitLocaleForName', + data: { field }, + }); + return; + } + + if (resolvesToCurrentLocale(localeArg, scope)) { + context.report({ + node: localeArg, + messageId: 'numericLocaleForName', + data: { field }, + }); + } + }; + + return { + CallExpression(node) { + if ( + node.callee.type === 'MemberExpression' && + !node.callee.computed && + NAME_FORMATTERS.has(node.callee.property.name) + ) { + check(node); + } + }, + // `new Intl.DateTimeFormat(locale, opts)` is the same trap in constructor form. + NewExpression(node) { + const callee = node.callee; + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.object.name === 'Intl' && + callee.property.name === 'DateTimeFormat' + ) { + check(node); + } + }, + }; + }, +}; diff --git a/eslint-local-rules/rules/require-text-locale.spec.js b/eslint-local-rules/rules/require-text-locale.spec.js new file mode 100644 index 0000000000..9fda7b3d1c --- /dev/null +++ b/eslint-local-rules/rules/require-text-locale.spec.js @@ -0,0 +1,227 @@ +/** + * Tests for require-text-locale ESLint rule + */ +const { RuleTester } = require('eslint'); +const rule = require('./require-text-locale'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}); + +ruleTester.run('require-text-locale', rule, { + valid: [ + // The blessed pattern: spelled-out names go through textLocale(). + { + code: ` + const label = date.toLocaleDateString(this._dateTimeFormatService.textLocale(), { + weekday: 'short', + month: 'short', + day: 'numeric', + }); + `, + }, + // Numeric-only parts MUST keep currentLocale() so ISO day-first survives. + { + code: ` + const dayAndMonth = date.toLocaleDateString(this._dateTimeFormatService.currentLocale(), { + day: 'numeric', + month: 'numeric', + }); + `, + }, + // The whole-date numeric case (ISO yyyy-MM-dd) — no spelled-out field at all. + { + code: `const raw = date.toLocaleDateString(this._dateTimeFormatService.currentLocale());`, + }, + // toLocaleTimeString: a clock time, which must follow currentLocale so the + // ISO 24h clock is preserved. Deliberately out of scope. + { + code: ` + const t = date.toLocaleTimeString(this._dateTimeFormatService.currentLocale(), { + hour: 'numeric', + minute: 'numeric', + }); + `, + }, + // A spelled-out dayPeriod in a clock time still keeps currentLocale: routing + // it to textLocale() would flip the ISO 24h clock to 12h ("13:05" -> "1:05 + // in the afternoon"). dayPeriod only renders under a 12h clock at all. + { + code: ` + const t = date.toLocaleString(this._dateTimeFormatService.currentLocale(), { + hour: 'numeric', + minute: '2-digit', + dayPeriod: 'short', + }); + `, + }, + { + code: ` + const f = new Intl.DateTimeFormat(this._dateTimeFormatService.currentLocale(), { + hour: 'numeric', + minute: '2-digit', + dayPeriod: 'short', + }); + `, + }, + // A mixed date+time format has no single correct locale — textLocale() would + // fix the weekday but break the clock ("onsdag 13:05" -> "Wednesday 1:05 + // PM"). It must be split instead, so the rule stays out of it (blind spot). + { + code: ` + const s = date.toLocaleString(this._dateTimeFormatService.currentLocale(), { + weekday: 'long', + hour: 'numeric', + minute: '2-digit', + }); + `, + }, + // dateStyle: 'short' is NUMERIC ("2026-07-15") — the inversion vs month: + // 'short' ("Jul"). It must keep currentLocale() or ISO YYYY-MM-DD breaks. + { + code: `const s = date.toLocaleDateString(this._dateTimeFormatService.currentLocale(), { dateStyle: 'short' });`, + }, + // timeStyle is a clock time, so dateStyle+timeStyle is the mixed case again + // ("onsdag 15 juli 2026 kl. 13:05" -> "Wednesday, July 15, 2026 at 1:05 PM"). + { + code: ` + const s = date.toLocaleString(this._dateTimeFormatService.currentLocale(), { + dateStyle: 'full', + timeStyle: 'short', + }); + `, + }, + { + code: `const t = date.toLocaleString(this._dateTimeFormatService.currentLocale(), { timeStyle: 'short' });`, + }, + // A locale threaded through a parameter: the rule cannot see the caller's + // value, so the obligation sits with the caller (getWeekdaysMin, formatDayStr). + { + code: ` + export const formatDayStr = (dateStr, locale) => + new Date(dateStr).toLocaleDateString(locale, { weekday: 'short' }); + `, + }, + // An explicit literal locale is a deliberate choice, not the sentinel trap. + { + code: `const s = date.toLocaleDateString('en-US', { weekday: 'long' });`, + }, + // Reassigned variable — the rule refuses to guess at a value it can't pin. + { + code: ` + let locale = this._dateTimeFormatService.currentLocale(); + locale = pickSomethingElse(); + const s = date.toLocaleDateString(locale, { weekday: 'long' }); + `, + }, + // Non-literal options object — not statically inspectable. + { + code: `const s = date.toLocaleDateString(this._dateTimeFormatService.currentLocale(), opts);`, + }, + // No options at all renders a numeric date, so there is no name to localize. + // (It still follows the browser locale, but that is not this rule's job.) + { + code: `const s = date.toLocaleDateString();`, + }, + // Intl.DateTimeFormat with textLocale() — the blessed constructor form. + { + code: `const f = new Intl.DateTimeFormat(this._dateTimeFormatService.textLocale(), { weekday: 'short' });`, + }, + // Clock times via Intl.DateTimeFormat MUST keep currentLocale() so the ISO + // 24h format survives — no spelled-out field, so no report (schedule-week). + { + code: ` + const formatter = new Intl.DateTimeFormat(this._dateTimeFormatService.currentLocale(), { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + `, + }, + // The isoTextLocale-guarded form: the ternary keeps Angular's CLDR path for + // non-ISO, so the locale is an isoTextLocale value, never currentLocale(). + { + code: ` + const isoTextLocale = this._dateTimeFormatService.isoTextLocale(); + const weekdayFormatter = isoTextLocale + ? new Intl.DateTimeFormat(isoTextLocale, { weekday: 'short' }) + : null; + `, + }, + ], + + invalid: [ + // Direct currentLocale() + weekday — the quick-setting-label shape. + { + code: ` + const s = refDate.toLocaleDateString(this._dateTimeFormatService.currentLocale(), { + weekday: 'long', + }); + `, + errors: [{ messageId: 'numericLocaleForName', data: { field: 'weekday' } }], + }, + // currentLocale() via a const — the exact shape the original #8987 bug had + // in plannedStartDateStr. A rule that missed this would have missed the bug. + { + code: ` + const locale = this._dateTimeFormatService.currentLocale(); + const formatted = date.toLocaleDateString(locale, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }); + `, + errors: [{ messageId: 'numericLocaleForName' }], + }, + // month: 'short' is spelled out — the add-task-bar date-chip shape. + { + code: ` + const dateStr = date.toLocaleDateString(this._dateTimeFormatService.currentLocale(), { + month: 'short', + day: 'numeric', + }); + `, + errors: [{ messageId: 'numericLocaleForName', data: { field: 'month' } }], + }, + // Implicit browser locale — the planner-calendar-nav monthLabel shape. + { + code: `const s = date.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });`, + errors: [{ messageId: 'implicitLocaleForName', data: { field: 'month' } }], + }, + // toLocaleString is the same trap. + { + code: `const s = date.toLocaleString(this._dateTimeFormatService.currentLocale(), { weekday: 'narrow' });`, + errors: [{ messageId: 'numericLocaleForName' }], + }, + // Intl.DateTimeFormat is the same trap in constructor form — the gap that + // would otherwise let #8987 back in through a different syntax. + { + code: `const f = new Intl.DateTimeFormat(this._dateTimeFormatService.currentLocale(), { weekday: 'short' });`, + errors: [{ messageId: 'numericLocaleForName', data: { field: 'weekday' } }], + }, + // Constructor form with the implicit browser locale. + { + code: `const f = new Intl.DateTimeFormat(undefined, { month: 'long' });`, + errors: [{ messageId: 'implicitLocaleForName', data: { field: 'month' } }], + }, + // dateStyle: 'full' renders the canonical #8987 string under the sentinel — + // "onsdag 15 juli 2026" — without naming weekday/month at all. + { + code: `const s = date.toLocaleDateString(this._dateTimeFormatService.currentLocale(), { dateStyle: 'full' });`, + errors: [{ messageId: 'numericLocaleForName', data: { field: 'dateStyle' } }], + }, + // 'medium' is spelled out too ("15 juli 2026"), unlike 'short'. + { + code: `const s = date.toLocaleDateString(this._dateTimeFormatService.currentLocale(), { dateStyle: 'medium' });`, + errors: [{ messageId: 'numericLocaleForName', data: { field: 'dateStyle' } }], + }, + { + code: `const f = new Intl.DateTimeFormat(undefined, { dateStyle: 'long' });`, + errors: [{ messageId: 'implicitLocaleForName', data: { field: 'dateStyle' } }], + }, + ], +}); diff --git a/eslint.config.js b/eslint.config.js index 9a1db9a1ef..97df05e0b7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -224,6 +224,21 @@ module.exports = tseslint.config( 'local-rules/no-multi-entity-effect': 'warn', }, }, + // Spelled-out weekday/month names must be formatted with textLocale(), not + // currentLocale() (the ISO option's `sv` sentinel) or the implicit browser + // locale — see #8987, which recurred across three PRs. Specs are excluded: + // computing an expected string against an explicit locale is a legitimate + // test technique, and the invariant is about what the product renders. + { + files: ['src/app/**/*.ts'], + ignores: ['**/*.spec.ts'], + plugins: { + 'local-rules': localRules, + }, + rules: { + 'local-rules/require-text-locale': 'error', + }, + }, // Op-log persistence: inside an adapter.transaction() callback only the tx // handle may be used — adapter methods enqueue behind the transaction's own // FIFO queue slot on the SQLite backend and deadlock (see diff --git a/src/app/features/metric/dialog-focus-session-edit/dialog-focus-session-edit.component.ts b/src/app/features/metric/dialog-focus-session-edit/dialog-focus-session-edit.component.ts index fbe7d87d54..e88206db5e 100644 --- a/src/app/features/metric/dialog-focus-session-edit/dialog-focus-session-edit.component.ts +++ b/src/app/features/metric/dialog-focus-session-edit/dialog-focus-session-edit.component.ts @@ -175,10 +175,7 @@ export class DialogFocusSessionEditComponent { // Spelled-out weekday/month names follow the UI language under the ISO 8601 // option (the `sv` sentinel would otherwise leak Swedish). #8987 follow-up. - const textLocale = - this._dateTimeFormatService.isoTextLocale() ?? - this._dateTimeFormatService.currentLocale(); - return new Date(date).toLocaleDateString(textLocale, { + return new Date(date).toLocaleDateString(this._dateTimeFormatService.textLocale(), { weekday: 'long', month: 'long', day: 'numeric', diff --git a/src/app/features/planner/planner-calendar-nav/planner-calendar-nav.component.spec.ts b/src/app/features/planner/planner-calendar-nav/planner-calendar-nav.component.spec.ts index 73e0584727..a2ab6fd146 100644 --- a/src/app/features/planner/planner-calendar-nav/planner-calendar-nav.component.spec.ts +++ b/src/app/features/planner/planner-calendar-nav/planner-calendar-nav.component.spec.ts @@ -11,6 +11,13 @@ import { } from './planner-calendar-gesture-handler'; import { parseDbDateStr } from '../../../util/parse-db-date-str'; import { getWeekRange } from '../../../util/get-week-range'; +import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service'; + +// The app locale the component must follow. Deliberately NOT the browser's: +// monthLabel used to pass no locale at all, so a German browser rendered +// "Juli 2026" in an English app. Asserting against a fixed locale here would +// pass either way if it matched the runner's browser, so it must not. +const MOCK_TEXT_LOCALE = 'fr-FR'; describe('PlannerCalendarNavComponent', () => { let fixture: ComponentFixture; @@ -48,6 +55,11 @@ describe('PlannerCalendarNavComponent', () => { provide: GlobalTrackingIntervalService, useValue: mockGlobalTrackingIntervalService, }, + { + provide: DateTimeFormatService, + // monthLabel formats a spelled-out month, so it reads textLocale(). + useValue: { textLocale: () => MOCK_TEXT_LOCALE }, + }, ], }); @@ -195,7 +207,7 @@ describe('PlannerCalendarNavComponent', () => { const weeks = component.weeks(); const midDay = weeks[0][3]; const midDate = parseDbDateStr(midDay.dateStr); - const expected = midDate.toLocaleDateString(undefined, { + const expected = midDate.toLocaleDateString(MOCK_TEXT_LOCALE, { month: 'long', year: 'numeric', }); @@ -211,7 +223,7 @@ describe('PlannerCalendarNavComponent', () => { const midWeekIdx = Math.floor(weeks.length / 2); const midDay = weeks[midWeekIdx][3]; const midDate = parseDbDateStr(midDay.dateStr); - const expected = midDate.toLocaleDateString(undefined, { + const expected = midDate.toLocaleDateString(MOCK_TEXT_LOCALE, { month: 'long', year: 'numeric', }); diff --git a/src/app/features/planner/planner-calendar-nav/planner-calendar-nav.component.ts b/src/app/features/planner/planner-calendar-nav/planner-calendar-nav.component.ts index a6922476a8..75fc29c71c 100644 --- a/src/app/features/planner/planner-calendar-nav/planner-calendar-nav.component.ts +++ b/src/app/features/planner/planner-calendar-nav/planner-calendar-nav.component.ts @@ -14,6 +14,7 @@ import { viewChild, } from '@angular/core'; import { DEFAULT_FIRST_DAY_OF_WEEK } from '../../../core/locale.constants'; +import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service'; import { GlobalConfigService } from '../../config/global-config.service'; import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service'; import { getWeekRange } from '../../../util/get-week-range'; @@ -45,6 +46,7 @@ interface CalendarDay { }) export class PlannerCalendarNavComponent { private _globalConfigService = inject(GlobalConfigService); + private _dateTimeFormatService = inject(DateTimeFormatService); private _globalTrackingIntervalService = inject(GlobalTrackingIntervalService); private _cdr = inject(ChangeDetectorRef); private _elRef = inject(ElementRef); @@ -127,6 +129,12 @@ export class PlannerCalendarNavComponent { }); monthLabel = computed(() => { + // The spelled-out month name follows textLocale(): passing no locale would + // use the *browser's*, ignoring both the configured date locale and the UI + // language (a German browser showed "Juli 2026" in an English app). Under + // the ISO 8601 option textLocale() is the UI language rather than the `sv` + // sentinel, so the name isn't shown in Swedish either. #8987 follow-up. + const locale = this._dateTimeFormatService.textLocale(); const allWeeks = this.weeks(); const weekIdx = this.isExpanded() ? Math.floor(allWeeks.length / 2) @@ -134,11 +142,11 @@ export class PlannerCalendarNavComponent { const week = allWeeks[weekIdx]; if (week?.length > 0) { const date = parseDbDateStr(week[Math.floor(week.length / 2)].dateStr); - return date.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); + return date.toLocaleDateString(locale, { month: 'long', year: 'numeric' }); } const visibleDay = this.visibleDayDate() || this._globalTrackingIntervalService.todayDateStr(); - return parseDbDateStr(visibleDay).toLocaleDateString(undefined, { + return parseDbDateStr(visibleDay).toLocaleDateString(locale, { month: 'long', year: 'numeric', }); diff --git a/src/app/features/simple-counter/habit-tracker/habit-tracker.component.spec.ts b/src/app/features/simple-counter/habit-tracker/habit-tracker.component.spec.ts index 84afb6f931..1c64bcffe8 100644 --- a/src/app/features/simple-counter/habit-tracker/habit-tracker.component.spec.ts +++ b/src/app/features/simple-counter/habit-tracker/habit-tracker.component.spec.ts @@ -51,6 +51,8 @@ describe('HabitTrackerComponent', () => { useValue: { currentLocale: () => 'sv', isoTextLocale: () => 'en-US', + // Mirrors the real service: isoTextLocale() ?? currentLocale(). + textLocale: () => 'en-US', }, }, ], diff --git a/src/app/features/simple-counter/habit-tracker/habit-tracker.component.ts b/src/app/features/simple-counter/habit-tracker/habit-tracker.component.ts index b58c996227..300b5e1280 100644 --- a/src/app/features/simple-counter/habit-tracker/habit-tracker.component.ts +++ b/src/app/features/simple-counter/habit-tracker/habit-tracker.component.ts @@ -122,9 +122,7 @@ export class HabitTrackerComponent { // Spelled-out `month: 'short'` name follows the UI language under the ISO // 8601 option (the `sv` sentinel would otherwise leak Swedish). #8987 f/u. - const locale = - this._dateTimeFormatService.isoTextLocale() ?? - this._dateTimeFormatService.currentLocale(); + const locale = this._dateTimeFormatService.textLocale(); const formatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }; const firstStr = first.toLocaleDateString(locale, formatOptions); const lastStr = last.toLocaleDateString(locale, formatOptions); diff --git a/src/app/features/worklog/worklog.service.spec.ts b/src/app/features/worklog/worklog.service.spec.ts index 396d63d868..dfb10c5433 100644 --- a/src/app/features/worklog/worklog.service.spec.ts +++ b/src/app/features/worklog/worklog.service.spec.ts @@ -86,7 +86,7 @@ describe('WorklogService context-aware loading', () => { }, { provide: DateTimeFormatService, - useValue: { currentLocale: () => 'en-US', isoTextLocale: () => null }, + useValue: { textLocale: () => 'en-US' }, }, ], }); diff --git a/src/app/features/worklog/worklog.service.ts b/src/app/features/worklog/worklog.service.ts index f05d69ffe6..b215dc0321 100644 --- a/src/app/features/worklog/worklog.service.ts +++ b/src/app/features/worklog/worklog.service.ts @@ -226,8 +226,7 @@ export class WorklogService { // Only feeds formatDayStr's spelled-out weekday, which must follow the UI // language under the ISO 8601 option (the `sv` sentinel would otherwise // leak Swedish weekday names in worklog day headers). #8987 follow-up. - this._dateTimeFormatService.isoTextLocale() ?? - this._dateTimeFormatService.currentLocale(), + this._dateTimeFormatService.textLocale(), ); return { worklog, diff --git a/src/app/pages/scheduled-list-page/scheduled-list-page.component.ts b/src/app/pages/scheduled-list-page/scheduled-list-page.component.ts index fa9c0b943c..32f9afc925 100644 --- a/src/app/pages/scheduled-list-page/scheduled-list-page.component.ts +++ b/src/app/pages/scheduled-list-page/scheduled-list-page.component.ts @@ -71,11 +71,7 @@ export class ScheduledListPageComponent { // so under the ISO 8601 option we follow the UI language (isoTextLocale) rather // than the `sv` sentinel — which would otherwise leak Swedish ("ons, 15 juli"). // #8987 follow-up. - readonly locale = computed( - () => - this._dateTimeFormatService.isoTextLocale() ?? - this._dateTimeFormatService.currentLocale(), - ); + readonly locale = computed(() => this._dateTimeFormatService.textLocale()); T: typeof T = T; TODAY_TAG: Tag = TODAY_TAG; taskRepeatCfgs$ = this._store.select(selectTaskRepeatCfgsSortedByTitleAndProject); diff --git a/src/app/ui/pipes/scheduled-date-group.pipe.spec.ts b/src/app/ui/pipes/scheduled-date-group.pipe.spec.ts index 6e2640d593..4a7eeb2c75 100644 --- a/src/app/ui/pipes/scheduled-date-group.pipe.spec.ts +++ b/src/app/ui/pipes/scheduled-date-group.pipe.spec.ts @@ -11,9 +11,9 @@ describe('ScheduledDateGroupPipe', () => { beforeEach(() => { mockDateTimeFormatService = jasmine.createSpyObj('DateTimeFormatService', [], { - currentLocale: () => 'en-US', - // null = non-ISO option: the pipe falls back to currentLocale. - isoTextLocale: () => null, + // The pipe formats a spelled-out weekday, so it reads textLocale() — + // which the real service resolves to isoTextLocale() ?? currentLocale(). + textLocale: () => 'en-US', }); mockTranslateService = jasmine.createSpyObj('TranslateService', ['instant']); mockTranslateService.instant.and.callFake((key: string) => { @@ -103,7 +103,7 @@ describe('ScheduledDateGroupPipe', () => { it('should respect configured locale for weekday names', () => { // Change locale to German - Object.defineProperty(mockDateTimeFormatService, 'currentLocale', { + Object.defineProperty(mockDateTimeFormatService, 'textLocale', { get: () => () => 'de-DE', }); @@ -112,13 +112,10 @@ describe('ScheduledDateGroupPipe', () => { expect(result).toMatch(/Mi/i); }); - it('should follow the UI language (isoTextLocale) for the weekday under the ISO option (#8987)', () => { - // ISO 8601 option: currentLocale is the sv sentinel, but isoTextLocale + it('should follow the UI language for the weekday under the ISO option (#8987)', () => { + // ISO 8601 option: currentLocale would be the sv sentinel, but textLocale // carries the UI language ('de-DE'); the weekday must not leak Swedish. - Object.defineProperty(mockDateTimeFormatService, 'currentLocale', { - get: () => () => 'sv', - }); - Object.defineProperty(mockDateTimeFormatService, 'isoTextLocale', { + Object.defineProperty(mockDateTimeFormatService, 'textLocale', { get: () => () => 'de-DE', }); diff --git a/src/app/ui/pipes/scheduled-date-group.pipe.ts b/src/app/ui/pipes/scheduled-date-group.pipe.ts index 1bc3f65d71..73808ad46b 100644 --- a/src/app/ui/pipes/scheduled-date-group.pipe.ts +++ b/src/app/ui/pipes/scheduled-date-group.pipe.ts @@ -47,9 +47,7 @@ export class ScheduledDateGroupPipe implements PipeTransform { // This is a compact group-header label, so the whole (short) format follows // the UI language when ISO is active rather than splitting weekday vs numeric // and losing the locale-native separator. #8987 follow-up. - const locale = - this._dateTimeFormatService.isoTextLocale() ?? - this._dateTimeFormatService.currentLocale(); + const locale = this._dateTimeFormatService.textLocale(); // Format with weekday and date: "Wed 1/15" const formatter = new Intl.DateTimeFormat(locale, {