Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions src/__tests__/unit/client-chart-labels.test.ts
Original file line number Diff line number Diff line change
@@ -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"));

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.

});

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"]) {

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.

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.

const where = `${lang} ${range} ${label}`;
expect(out, where).not.toContain("NaN");
expect(out, where).not.toContain("undefined");
expect(out.trim(), where).not.toBe("");
}
}
});
});
28 changes: 16 additions & 12 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.

// 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.

// 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';

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.

}
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('-');

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.

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' });

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.

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.

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.

}

// Humanizes a canonical bucket label ("YYYY-MM", "YYYY-MM-DD", or
Expand Down