From 021203b5a421d4a51c09066f4a91a7eec29c8d38 Mon Sep 17 00:00:00 2001 From: wurongjie Date: Wed, 14 Oct 2026 17:16:57 +0800 Subject: [PATCH] fix(chart): compose axis labels with the locale, not with string glue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../unit/client-chart-labels.test.ts | 114 ++++++++++++++++++ src/client.ts | 28 +++-- 2 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/unit/client-chart-labels.test.ts diff --git a/src/__tests__/unit/client-chart-labels.test.ts b/src/__tests__/unit/client-chart-labels.test.ts new file mode 100644 index 0000000..19e41e9 --- /dev/null +++ b/src/__tests__/unit/client-chart-labels.test.ts @@ -0,0 +1,114 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +// The admin client's chart x-axis labels. fmtLabel() composes a canonical +// bucket label ("YYYY-MM-DD HH" hourly, "YYYY-MM-DD" daily and weekly, +// "YYYY-MM" monthly) into the text the axis shows, and that composition is a +// locale decision: which of the month and the day comes first, what separates +// them, and whether the day or the year carries a counter. +// +// These tests cut the shipped function out of adminClientScript() and run it +// with UI_LANG set, the same way the other client.ts tests do. The chunk is +// evaluated with no t() in scope, so a version that went back to gluing +// translated month names onto day numbers throws here rather than rendering a +// wrong label. +// +// Assertions name the components that carry meaning (month, day, year) and +// the order they appear in, not the whole string: ICU punctuation has shifted +// between runtime versions. The server-side sibling, formatBucketLabel, is +// pinned the same way in big-chart.test.ts. +import { describe, expect, it } from "vitest"; +import { adminClientScript } from "../../client"; +import type { Translations } from "../../i18n/types"; +import { extractTopLevelChunk } from "../client-script"; + +function loadFmtLabel(lang: string) { + const script = adminClientScript("1.0.0", {} as unknown as Translations); + const code = [ + `var UI_LANG = ${JSON.stringify(lang)};`, + extractTopLevelChunk(script, /^function fmtLabel\(/), + "return fmtLabel;", + ].join("\n"); + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const factory = new Function(code) as () => (label: string, range: string) => string; + return factory(); +} + +describe("client.ts fmtLabel", () => { + it("formats an hourly bucket as the hour", () => { + expect(loadFmtLabel("en")("2026-07-16 09", "24h")).toBe("09:00"); + }); + + it("puts the month first for en", () => { + const out = loadFmtLabel("en")("2026-07-16", "7d"); + expect(out).toContain("Jul"); + expect(out).toContain("16"); + expect(out.indexOf("16")).toBeGreaterThan(out.search(/[A-Za-z]/)); + }); + + it("puts the day first for sv and id", () => { + for (const lang of ["sv", "id"]) { + const out = loadFmtLabel(lang)("2026-07-16", "7d"); + expect(out, `${lang} must carry the day`).toContain("16"); + expect(out.indexOf("16"), `${lang} puts the day before the month`).toBeLessThan( + out.search(/[A-Za-z]/), + ); + } + }); + + it("uses the Chinese month and day counter for zh", () => { + // "日" is the day counter zh ICU appends. Gluing the translated month name + // onto the day number produced "7月 16", which never carried one. + const out = loadFmtLabel("zh")("2026-07-16", "7d"); + expect(out).toContain("7月"); + expect(out).toContain("16"); + expect(out).toContain("日"); + expect(out).not.toBe("7月 16"); + }); + + it("spells the year out on a monthly bucket", () => { + // The glued form rendered a two-digit year, so a zh viewer read "7月 26" + // as the 26th of July. A two-digit year is not an option either: zh ICU + // renders it "26年7月", which reads as the year 26. + const out = loadFmtLabel("zh")("2026-07", "all"); + expect(out).toContain("2026"); + expect(out).toContain("7月"); + expect(out).not.toContain("NaN"); + }); + + it("keeps a monthly bucket distinct from the month's first day", () => { + // A monthly label is built on the 1st. Falling through to the daily shape + // would render that day instead, which is the bug this pins. + const fmt = loadFmtLabel("zh"); + expect(fmt("2026-07", "all")).not.toBe(fmt("2026-07-01", "all")); + }); + + it("treats a weekly bucket label like a daily one", () => { + // 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")); + }); + + it("renders no NaN, undefined or empty label for any bucket shape or language", () => { + const cases: Array<[string, string]> = [ + ["2026-07-16 09", "24h"], + ["2026-07-16", "7d"], + ["2026-07-16", "30d"], + ["2026-07-16", "90d"], + ["2026-07-13", "1y"], + ["2026-07-16", "all"], + ["2026-07-13", "all"], + ["2026-07", "all"], + ]; + for (const lang of ["en", "id", "sv", "zh"]) { + for (const [label, range] of cases) { + const out = loadFmtLabel(lang)(label, range); + const where = `${lang} ${range} ${label}`; + expect(out, where).not.toContain("NaN"); + expect(out, where).not.toContain("undefined"); + expect(out.trim(), where).not.toBe(""); + } + } + }); +}); diff --git a/src/client.ts b/src/client.ts index 77565c2..e3450ac 100644 --- a/src/client.ts +++ b/src/client.ts @@ -958,24 +958,28 @@ function fmtAvgPerDay(totalClicks, range, createdAt) { return String(Math.round(avg)); } -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 +// 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 +// onto a day number, because which of the two comes first, and what sits +// between them, are locale decisions: en puts the month first ("Jul 16"), sv +// and id put the day first ("16 juli", "16 Jul"), and zh needs a day counter +// ("7月16日"). The glue rendered a monthly bucket as "7月 26", which reads as +// the 26th of July rather than July 2026, and a two-digit year is not the fix +// either, because zh renders that as "26年7月", the year 26. function fmtLabel(label, range) { if (range === '24h') { // "YYYY-MM-DD HH" → "HH:00" return label.slice(11) + ':00'; } - if (range === '7d' || range === '30d' || range === '90d' || range === '1y') { - var parts = label.split('-'); - 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('-'); - if (parts.length === 3) { - return monthName(parseInt(parts[1], 10)) + ' ' + parseInt(parts[2], 10); - } - return monthName(parseInt(parts[1], 10)) + ' ' + parts[0].slice(2); + 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' }); + return dt.toLocaleDateString(UI_LANG, { month: 'short', day: 'numeric', timeZone: 'UTC' }); } // Humanizes a canonical bucket label ("YYYY-MM", "YYYY-MM-DD", or