mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* refactor(locale): collapse inlined textLocale copies into the helper No behavior change: textLocale() is defined as isoTextLocale() ?? currentLocale(), which is exactly what these five sites had inlined. #9056 was based on master and so could not use the helper #9055 adds — it re-inlined the expression at focus-session, habit-tracker, worklog, scheduled-list and scheduled-date-group. Now that both have landed, collapse them so there is one canonical spelling instead of six. Caller specs mock textLocale() directly rather than the isoTextLocale/ currentLocale pair, mirroring what each SUT actually calls. * fix(locale): planner month label followed the browser locale, not the app's monthLabel passed no locale to toLocaleDateString, so the spelled-out month followed the *browser's* locale and ignored both the configured date locale and the UI language: a German browser rendered 'Juli 2026' in an English app. Same family as #8987 but reachable without the ISO option at all. Route it through textLocale(), which is the UI language under the ISO option and currentLocale() otherwise. The two existing specs computed their expected value with the same undefined locale, so they mirrored the bug and could never have caught it. They now pin a fixed app locale, deliberately not the runner's browser locale — otherwise they would pass either way. * test(lint): add require-text-locale rule and enforce it over src/app Guards the #8987 invariant that kept recurring: spelled-out weekday/month names must be formatted with textLocale(), never currentLocale() (the ISO option's 'sv' sentinel) and never the implicit browser locale. Three PRs chased this bug class site-by-site because currentLocale() is the obvious-looking default at every new call site. - Resolves `const locale = ...currentLocale()` through the scope chain: that is the shape the original bug had in plannedStartDateStr, so a rule matching only direct calls would have missed the very bug it exists to prevent. - Covers new Intl.DateTimeFormat() too — the same trap in constructor form, used at ~9 sites. Stays silent on clock times (hour/minute/dayPeriod), which must keep currentLocale() so the ISO 24h format survives. - Specs excluded: computing an expected string against an explicit locale is a legitimate test technique; the invariant is about what the product renders. - Documents its own blind spots (locale threaded through a parameter, reassigned variables, non-literal options) and pins them as valid cases, per the no-multi-entity-effect convention. Error severity is safe: textLocale() equals currentLocale() for every non-ISO option, so for spelled-out names it is never worse. Zero violations remain. * fix(lint): keep require-text-locale silent on clock-time formats The rule documented itself as staying silent on clock times, but `dayPeriod` sat in ALWAYS_SPELLED_OUT and only `.toLocaleTimeString()` was excluded — so `{ hour, minute, dayPeriod }` via `Intl.DateTimeFormat`/`toLocaleString` did fire, and its message told the reader to switch to textLocale(). Following that advice flips the ISO 24h clock to 12h: "13:05" -> "1:05 in the afternoon". The same holds for any mixed date+time options object: `{ weekday, hour }` goes from "onsdag 13:05" to "Wednesday 1:05 PM" — a Swedish name traded for a broken clock, the exact ISO regression this rule family exists to prevent. A format that mixes a spelled-out name with a clock has no single correct locale; it has to be split (names on textLocale(), clock on currentLocale(), as plannedStartDateStr does), which is more than a one-locale message can advise. So skip any options object containing `hour`, and say so. This costs a blind spot on `{ weekday, hour }` — cheaper than confidently wrong advice at `error` severity. No call site changes: `dayPeriod`/`era` have zero uses in src/, so this was latent. All four real bug shapes are still caught; src/app stays at zero violations. The two clock-time `valid` cases named dayPeriod in their comments but only ever tested `{ hour, minute }` in their code, which is how this slipped through — pin the actual shapes instead. * fix(lint): catch dateStyle in require-text-locale The rule missed `dateStyle` entirely, so the canonical #8987 shape walked straight past it: `toLocaleDateString(currentLocale(), { dateStyle: 'full' })` renders "onsdag 15 juli 2026" under the sentinel — a spelled-out weekday and month — without naming weekday or month at all. Zero call sites today, so this was latent, but guarding call sites that do not exist yet is the rule's whole job. `dateStyle` needs its own value set rather than month's: the two invert. `month: 'short'` is "Jul" (spelled out) but `dateStyle: 'short'` is "2026-07-15" (numeric), so reusing SPELLED_OUT_VALUES would have flagged dateStyle:'short' and pushed the reader to route ISO's YYYY-MM-DD through textLocale() — the mirror of the clock-time trap. Modelled as a per-field map so the inversion is stated where it can't be conflated, and pinned from both sides: 'short' as valid, 'full'/'medium'/'long' as invalid. Sabotage-verified — swapping in month's value set fails the spec. `timeStyle` joins `hour` as a clock-time field: `{ dateStyle, timeStyle }` is the mixed date+time case again ("onsdag 15 juli 2026 kl. 13:05" -> "Wednesday, July 15, 2026 at 1:05 PM"), and it carries no `hour` key for the existing guard to catch. Verified: 6/6 real bug shapes flagged, 0/6 false positives on correct usage, src/app still at zero violations.
This commit is contained in:
parent
d11815a4ab
commit
57cee868bf
14 changed files with 512 additions and 32 deletions
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
|||
230
eslint-local-rules/rules/require-text-locale.js
Normal file
230
eslint-local-rules/rules/require-text-locale.js
Normal file
|
|
@ -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 `<anything>.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);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
227
eslint-local-rules/rules/require-text-locale.spec.js
Normal file
227
eslint-local-rules/rules/require-text-locale.spec.js
Normal file
|
|
@ -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' } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<PlannerCalendarNavComponent>;
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ describe('HabitTrackerComponent', () => {
|
|||
useValue: {
|
||||
currentLocale: () => 'sv',
|
||||
isoTextLocale: () => 'en-US',
|
||||
// Mirrors the real service: isoTextLocale() ?? currentLocale().
|
||||
textLocale: () => 'en-US',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ describe('WorklogService context-aware loading', () => {
|
|||
},
|
||||
{
|
||||
provide: DateTimeFormatService,
|
||||
useValue: { currentLocale: () => 'en-US', isoTextLocale: () => null },
|
||||
useValue: { textLocale: () => 'en-US' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue