Skip to content

Compose chart axis labels with the locale, not with string glue - #77

Closed
myml wants to merge 1 commit into
oddbit:mainfrom
myml:fix/chart-axis-locale
Closed

myml wants to merge 1 commit into
oddbit:mainfrom
myml:fix/chart-axis-locale

Conversation

@myml

@myml myml commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

What

The chart's x-axis built its label by gluing a translated month name onto a day number, which hardcodes a month-first order and a space separator:

return monthName(parseInt(parts[1], 10)) + ' ' + parseInt(parts[2], 10);

That composition is wrong in three ways. A lang=zh viewer read 7月 16 for a day bucket where zh wants 7月16日, and 7月 26 for a monthly bucket, which reads as the 26th of July rather than July 2026. Swedish and Indonesian order the day first (16 juli, 16 Jul), and got the month first. The month branch also took the year from parts[0].slice(2), so the same label was ambiguous in every locale.

fmtLabel now builds a UTC Date from the bucket label and formats it with Intl.DateTimeFormat, which is the call the tooltip formatter (fmtBucketDate) and the server-side formatBucketLabel already make. The month branch carries a full year: a two-digit year is not an option, because zh ICU renders it 26年7月, which reads as the year 26.

Two consequences worth naming:

  • The shape of the label now picks the branch, not the range, so the range list that had to stay in agreement with the bucketing in click-repository.ts is gone. A weekly bucket label (YYYY-MM-DD) is handled the same as a daily one, which the 1y and long all ranges emit.
  • MONTH_KEYS and monthName are deleted: nothing else read them. The month.* translation keys stay in the locale tables for now. The client no longer reads them, but they cannot be dropped one locale at a time, because src/i18n.test.ts compares each locale's key set against en, and the zh locale is in flight in Add Simplified Chinese to the admin dashboard #71. A follow-up can drop all of them together once that lands.

English monthly axis labels grow by two characters (Jul 26 becomes Jul 2026). Daily labels are unchanged in English (Jul 16).

Tests

New src/__tests__/unit/client-chart-labels.test.ts cuts the shipped function out of adminClientScript() output and runs it per language, the way the other client.ts tests do. The chunk is evaluated with no t() in scope, so a return to the glue throws instead of rendering a wrong label. Assertions name the components that carry meaning (month, day, year) and their order rather than the whole string, following formatBucketLabel in big-chart.test.ts. Mutating the year option to 2-digit, or dropping the day option, fails the suite.

  • yarn test: 103 files, 1604 tests pass.
  • yarn e2e: 32 tests pass.
  • yarn tsc --noEmit: clean.

Context

Raised in the review of #71, which adds Simplified Chinese to the admin. The axis-label defect is independent of that locale: it is the composition that is wrong, and zh is the locale that makes it visible.

The x-axis composed its label by gluing a translated month name onto a day
number, which hardcodes a Latin month-first shape and a space separator. A
lang=zh viewer read "7月 16" for a day bucket, where zh wants 7月16日, and
"7月 26" for a monthly bucket, which reads as the 26th of July rather than
July 2026. The same glue put the month first for sv and id, whose locales
order the day first ("16 juli", "16 Jul").

fmtLabel now builds a UTC Date from the bucket label and formats it with
Intl.DateTimeFormat, the call the tooltip formatter (fmtBucketDate) and the
server-side formatBucketLabel already make. The month branch carries a full
year: a two-digit year is not an option, because zh ICU renders it
"26年7月", which reads as the year 26.

The shape of the label now picks the branch, not the range, which drops the
range list that had to agree with the bucketing in click-repository.ts. The
month.* translation keys are left in place: the client no longer reads them,
but zh.ts is in flight in another change, and dropping keys from one locale
table at a time fails the key-parity test.

New client-chart-labels.test.ts extracts the shipped function from
adminClientScript() output and runs it per language. The chunk is evaluated
with no t() in scope, so a return to the glue throws instead of rendering a
wrong label. Mutating the year option or dropping the day option fails the
suite.

@DennisAlund DennisAlund left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review (xhigh). 10 findings, inline below. The two worst: the hourly axis branch still glues strings and disagrees with the tooltip in en/id, and day/week labels omit the year on axes that cross a year boundary.

Checked by running fmtLabel under Node 22 / ICU 77 for en, id, sv, zh and computing label widths from the chart geometry. vitest and e2e were not run.

Comment thread src/client.ts
function fmtLabel(label, range) {
if (range === '24h') {
// "YYYY-MM-DD HH" → "HH:00"
return label.slice(11) + ':00';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hourly axis label still glues strings and keys off range. The branch still builds label.slice(11) + ':00' and is selected by range === '24h', so the PR's claims (no string glue, label shape picks the branch) don't hold here. The axis also disagrees with the tooltip (fmtBucketDate) on the same chart.

Scenario: 24h range, lang=en: axis shows 09:00, tooltip for the same point shows Jul 16, 2026, 09:00 AM (12-hour). lang=id: tooltip 09.00, axis 09:00. If a non-24h range ever returns hourly labels, parseInt("16 09") renders them as the date.

Suggest: detect hourly labels by the space, as fmtBucketDate does, format with toLocaleTimeString(UI_LANG, {hour:'2-digit', minute:'2-digit', timeZone:'UTC'}), and drop range. The only hourly test covers en.

Comment thread src/client.ts
var day = monthly ? 1 : parseInt(parts[2], 10);
var dt = new Date(Date.UTC(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, day));
if (monthly) return dt.toLocaleDateString(UI_LANG, { year: 'numeric', month: 'short', timeZone: 'UTC' });
return dt.toLocaleDateString(UI_LANG, { month: 'short', day: 'numeric', timeZone: 'UTC' });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Daily/weekly labels omit the year across a year boundary. Same ambiguity the PR fixed for monthly buckets.

Scenario: 1y range on 2026-10-20 (53 weekly buckets, dotInterval 5) shows Oct 20 | Nov 24 | … | Aug 31 | Oct 5 | Today. Oct 20 is 2025, Oct 5 is 2026, and nothing tells them apart. The all range with weekly buckets (91 to 730 days) shows every month name twice.

Suggest: add year when the first and last bucket fall in different years, or at least on the first label and each January label.

Comment thread src/client.ts
var monthly = parts.length === 2;
var day = monthly ? 1 : parseInt(parts[2], 10);
var dt = new Date(Date.UTC(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, day));
if (monthly) return dt.toLocaleDateString(UI_LANG, { year: 'numeric', month: 'short', timeZone: 'UTC' });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Longer monthly labels now collide with the final Today label. Jul 26 became Jul 2026 (zh 2026年7月, sv sep. 2026), and renderTimeline has no collision guard.

Scenario: all range with 26 monthly buckets: dotInterval 3 draws labels at i=24 and i=25 (Today), 756/25 = 30.2 viewBox units apart. At font-size 9 mono (~5.4 units/char), en Sep 2026 is 43.2 wide and Today 27: half-sum 35.1 > 30.2, so they overlap. The old Sep 26 gave 29.7 and fit. zh gives 31.5 and sv sep. 2026/Idag 35.1, both overlapping. sv daily labels also widen, since ICU dec./juli replaces the translators' dec/jul.

Comment thread src/client.ts
var MONTH_KEYS = ['month.jan','month.feb','month.mar','month.apr','month.may','month.jun','month.jul','month.aug','month.sep','month.oct','month.nov','month.dec'];
function monthName(m) { return t(MONTH_KEYS[m - 1]); }

// The chart's x-axis label for one bucket. Bucket labels are canonical and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

month.* keys are now dead but still ship on every admin page. Nothing reads the 12 keys per locale (en/id/sv/zh) after this change, yet they serialize into var T = …. #71 has merged to main (c0f33a9), so the PR's reason for deferring their removal is gone.

Impact: 48 dead strings per admin page, and translators keep maintaining month names the UI never shows (sv jul vs ICU juli on the axis). Removing the keys from all four locales in this PR keeps the key-parity test in i18n.test.ts green.

["2026-07-13", "all"],
["2026-07", "all"],
];
for (const lang of ["en", "id", "sv", "zh"]) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Locale sweep hardcodes ["en", "id", "sv", "zh"]. i18n.test.ts on main now derives its locale lists from SUPPORTED_LANGUAGES so a new locale is covered as soon as it is registered.

Scenario: adding a fifth language (e.g. ja) to SUPPORTED_LANGUAGES leaves its chart labels unchecked, and nothing fails. Iterate SUPPORTED_LANGUAGES from ../../i18n instead.

// The 1y range and a long all range bucket by week and still emit
// YYYY-MM-DD, so the range must not pick the shape, only the label can.
const fmt = loadFmtLabel("en");
expect(fmt("2026-07-13", "1y")).toBe(fmt("2026-07-13", "all"));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"treats a weekly bucket label like a daily one" cannot fail. fmtLabel ignores range unless it is '24h', so fmt(x,'1y') === fmt(x,'all') always holds, and the test never checks that a weekly label shows the day.

Scenario: a change that renders YYYY-MM-DD labels without the day, or as month+year, keeps this green because both sides change together. Hardening: assert the output contains 13 and Jul, and compare against a known daily label.

Comment thread src/client.ts
return monthName(parseInt(parts[1], 10)) + ' ' + parseInt(parts[2], 10);
}
// "all": label can be YYYY-MM-DD (daily/weekly) or YYYY-MM (monthly)
var parts = label.split('-');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bucket-label parsing duplicated. fmtLabel repeats the parsing fmtBucketDate does a few lines below (split on -, parseInt, detect the 2-part monthly shape, Date.UTC), and the server-side formatBucketLabel does it a third time.

Risk: a fix for one label shape lands in the axis or the tooltip but not both. Suggest extracting a bucketDate(label) returning {dt, kind} and having both functions format from it with their own option sets.

Comment thread src/client.ts
var monthly = parts.length === 2;
var day = monthly ? 1 : parseInt(parts[2], 10);
var dt = new Date(Date.UTC(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, day));
if (monthly) return dt.toLocaleDateString(UI_LANG, { year: 'numeric', month: 'short', timeZone: 'UTC' });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formatter rebuilt per label. toLocaleDateString with options builds a new Intl.DateTimeFormat on every call, on every render and range switch. The script already caches numberFormatter = new Intl.NumberFormat(UI_LANG) at the top; two top-level formatters (day, month-year) built once from UI_LANG would follow that pattern.

];
for (const lang of ["en", "id", "sv", "zh"]) {
for (const [label, range] of cases) {
const out = loadFmtLabel(lang)(label, range);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadFmtLabel(lang) called inside the inner loop. Each call regenerates the full adminClientScript() string, splits it, and compiles it with new Function, so the sweep compiles the client script 32 times (4 langs × 8 cases) where 4 would do. Hoist const fmt = loadFmtLabel(lang) into the outer loop.

Comment thread src/client.ts
// UTC: "YYYY-MM-DD HH" for hourly, "YYYY-MM-DD" for daily and weekly, and
// "YYYY-MM" for monthly.
//
// The label is composed by Intl rather than by gluing a translated month name

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment breaks the CLAUDE.md writing rules. "Active voice by default. Passive only when actor is unknown or irrelevant." and "Split sentences with more than two clauses." Line 965 reads "The label is composed by Intl rather than by gluing…": passive with Intl named as actor. Lines 965 to 969 form one sentence of four or more clauses, and lines 969 to 971 chain three more.

@DennisAlund

Copy link
Copy Markdown
Member

Thanks @myml. This work continues in #83, which carries your commit unchanged (you stay its author) and addresses the review findings on top. Closing this one so the work lives in one place.

@DennisAlund DennisAlund closed this Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants