feat(frontend): Phase 6 Locale + Date/Time Discipline — close I18N-H1 + I18N-H2 + I18N-H3 + I18N-M2

Closes the Phase 6 batch from cowork/frontend-design-audit.html: makes
every timestamp in the dashboard byte-identical to its server-audit-log
equivalent under UTC, makes every number format browser-locale-aware,
and builds the i18n-ready boundary without shipping a full i18n
framework (deferred to Phase 10).

═════════════════════════ AUDIT VERIFICATION ═════════════════════════

  • Q1 utils.ts hardcoded 'en-US' at lines 3 + 8 — confirmed
  • Q2 raw new Date(x).toLocaleString() sites — verified 8 sites
    across 6 pages (audit said "7+"):
      SessionsPage:178, SessionsPage:181        (last_seen, abs_expires)
      BreakglassPage:236, BreakglassPage:248    (last_pw_change, locked_until)
      GroupMappingsPage:206                     (created_at)
      OIDCProvidersPage:434                     (created_at)
      ApprovalsPage:379                         (created_at)
      ObservabilityPage:71                      (server_started)
  • Q3 no i18n framework — confirmed (no i18next/react-intl/@formatjs/
    date-fns in web/package.json)
  • Q4 zero Intl.NumberFormat usage — confirmed (audit-accurate)
  • Q5 Tooltip API — `<Tooltip content={…}>{singleChild}</Tooltip>`,
    Floating-UI-backed, aria-describedby wired
  • Q6 toFixed sites — 1 site in dashboard/charts.tsx (Recharts tooltip
    rate formatter); audit was vague but actual is minimal

═════════════════════════════ CLOSURES ═══════════════════════════════

I18N-H1 — drop hardcoded en-US in utils.ts
  • formatDate / formatDateTime now pass `undefined` for the locale
    arg, meaning the runtime uses navigator.language. Output SHAPE
    stable (month: 'short' etc.); LANGUAGE follows the browser.
  • New formatDateUTC / formatDateTimeUTC siblings force timeZone:
    'UTC' for byte-equivalent display vs server audit log + journalctl.
  • New formatDateTimeInZone(iso, ianaTz) backs the Custom-TZ branch
    in operator settings; falls back to UTC on invalid IANA name
    (Intl throws RangeError; we catch + degrade gracefully).
  • Existing tests in utils.test.ts already used locale-tolerant
    assertions (.toContain('Jun')) so no test update needed.

I18N-H3 — UTC display + operator-local hover + preference toggle
  • web/src/components/Timestamp.tsx — wraps a UTC-default string in
    the Phase 1 Tooltip showing the operator-local equivalent. Three
    modes:
      utc    — display UTC (default; screen ≡ logs).
      local  — display browser-local, hover shows UTC.
      custom — display configured IANA tz, hover shows UTC.
  • web/src/api/timestampPref.ts — typed localStorage helper with
    `certctl:timestamp-pref-changed` CustomEvent so live <Timestamp>
    components re-render without a page reload when the operator
    flips the toggle.
  • New "Timestamp display" card on AuthSettingsPage with radio
    selector + IANA-tz input that appears only when mode='custom'.

I18N-H2 — migrate raw toLocaleString sites + CI guard
  • 8/8 raw `new Date(x).toLocaleString()` / `.toLocaleDateString()`
    sites migrated:
      SessionsPage    — Timestamp (×2, last_seen + abs_expires)
      BreakglassPage  — Timestamp (×2, last_password_change + locked_until)
      ApprovalsPage   — Timestamp (created_at)
      ObservabilityPage — Timestamp (server_started)
      GroupMappingsPage — formatDate (date-only column)
      OIDCProvidersPage — formatDate (date-only column)
  • scripts/ci-guards/no-raw-toLocaleString.sh fails CI on any new
    raw new Date(x).toLocaleString[Date]Date call outside the
    canonical utils.ts impls. Tests + utils.ts itself are excluded.

I18N-M2 — Intl.NumberFormat helpers
  • New web/src/api/format.ts exports formatNumber / formatCompact /
    formatPercent / formatBytes — all backed by Intl.NumberFormat
    constructed once at module load (NumberFormat construction is
    the expensive part; .format() is cheap).
  • Locale-tolerant test fixtures assert format SHAPE (e.g.
    "5[ .,]?432") not exact strings — so the CI runner's locale
    doesn't break assertions.
  • formatBytes uses SI-decimal scaling (1KB=1000B); manual fallback
    for old Safari that doesn't support `style: 'unit'`.

═══════════════════════════ AUDIT-ACCURACY CALLOUTS ════════════════════

  (1) Audit said "7+ pages with raw .toLocaleString" — verified 8 raw
      SITES across 6 PAGES. Direction was right; counts were vague.
  (2) Audit said "no i18n framework + no Intl.NumberFormat" — both
      verified accurate (zero matches in production tsx).
  (3) Audit suggested SessionsPage / BreakglassPage / GroupMappings /
      OIDCProviders / Approvals / Observability "and others" — all six
      named confirmed; no "others" found. List was complete.

═══════════════════════════ VERIFICATION ════════════════════════════

  • npx tsc --noEmit — exits 0
  • New tests: utils 18/18 (preserved) + format 14/14 + Timestamp 6/6
    = 38 new test assertions
  • Component suite (270/270 across api + Timestamp + Tooltip + sibs)
  • 7 migrated page suites — 62/62 green (Sessions / Approvals /
    Breakglass / GroupMappings / OIDCProviders / AuthSettings /
    Observability)
  • All 34 CI guards pass locally (new no-raw-toLocaleString.sh +
    existing no-unbound-label baseline bumped 132→134 for the 2
    wrap-style implicit-association labels added on AuthSettings
    timestamp preference card; guard's blunt grep can't distinguish
    wrap from sibling labels — documented in the guard header).
  • npx vite build — ✓ in 2.69s
  • grep "'en-US'" web/src/api/utils.ts → 0 matches
  • grep "new Date.*\.toLocaleString\(\)" web/src --include='*.tsx'
    --exclude='*.test.*' → 0 raw sites outside utils.ts

═══════════════════════════ RESIDUAL RISK ════════════════════════════

  • UTC default may surprise non-engineering users who expect their
    local timezone. Mitigation: the AuthSettings toggle gives them
    a one-click out to Local mode. Default UTC is the right safe
    default for an audit-log-paired tool.
  • formatBytes SI vs binary: the helper uses SI-decimal (1KB=1000B)
    by default. If memory/disk numbers in Observability tiles need
    binary scaling (1KiB=1024B), add a formatBytesBinary in a
    follow-up; for now those tiles either don't surface bytes or
    use server-provided pre-formatted strings.
  • i18n framework deferred: no react-i18next, no extraction pass.
    Phase 10 (when first multi-language customer asks) will swap the
    `undefined` locale arg here for a thread-through value; display
    code never touches Date.prototype.toLocaleString directly thanks
    to the no-raw-toLocaleString CI guard.
This commit is contained in:
shankar0123
2026-05-14 17:10:19 +00:00
parent 508c7530e9
commit 1fcb05181d
16 changed files with 628 additions and 11 deletions

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { formatNumber, formatCompact, formatPercent, formatBytes } from './format';
describe('format', () => {
describe('formatNumber', () => {
it('formats integers with thousand separator', () => {
// Locale-tolerant: any of "5,432" (en) / "5.432" (de) / "5 432" (fr) is fine.
const out = formatNumber(5432);
expect(out).toMatch(/^5[ .,]?432$/);
});
it('limits fraction digits to 2', () => {
const out = formatNumber(1.23456);
expect(out).toMatch(/^1[.,]23$/);
});
it('returns dash for NaN / Infinity', () => {
expect(formatNumber(NaN)).toBe('—');
expect(formatNumber(Infinity)).toBe('—');
});
});
describe('formatCompact', () => {
it('compacts thousands to K', () => {
// English: "5.4K"; some locales drop the K. The compact notation
// is locale-defined; assert only that the magnitude SCALE is right
// (length < raw "5432") rather than pinning a string.
const out = formatCompact(5432);
expect(out.length).toBeLessThan('5432'.length + 2);
});
it('compacts millions to M', () => {
const out = formatCompact(1_200_000);
// any rendering should be much shorter than "1,200,000".
expect(out.length).toBeLessThan(10);
});
it('returns dash for NaN', () => {
expect(formatCompact(NaN)).toBe('—');
});
});
describe('formatPercent', () => {
it('renders 0.995 as 99.5%', () => {
const out = formatPercent(0.995);
// en: "99.5%"; fr: "99,5 %"; both contain "99" + ("5" or no fraction)
expect(out).toMatch(/99[.,]?5?\s?%/);
});
it('renders 0 as 0%', () => {
expect(formatPercent(0)).toMatch(/^0\s?%$/);
});
it('returns dash for NaN', () => {
expect(formatPercent(NaN)).toBe('—');
});
});
describe('formatBytes', () => {
it('formats < 1KB as bytes', () => {
expect(formatBytes(512)).toMatch(/^512 B$/);
});
it('formats KB scale', () => {
const out = formatBytes(5_400);
expect(out).toMatch(/KB$/);
});
it('formats MB scale', () => {
const out = formatBytes(5_400_000);
expect(out).toMatch(/MB$/);
});
it('formats GB scale', () => {
const out = formatBytes(5_400_000_000);
expect(out).toMatch(/GB$/);
});
it('returns dash for NaN', () => {
expect(formatBytes(NaN)).toBe('—');
});
});
});

133
web/src/api/format.ts Normal file
View File

@@ -0,0 +1,133 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Number / byte / percent formatting helpers — Phase 6 closure for
// I18N-M2 (zero Intl.NumberFormat usage; cert counts via
// .toLocaleString() on numbers — browser-locale-aware — sit alongside
// .toFixed(1) not localized at all).
//
// All helpers route through `Intl.NumberFormat` with `undefined` for
// the locale (browser default; same i18n-ready boundary policy as
// utils.ts). The format objects are constructed ONCE at module load
// rather than per call — Intl.NumberFormat construction is the
// expensive part; .format() is cheap.
//
// When the i18n framework lands (Phase 10) the only change here is
// to thread a `locale` arg through; the display code that imports
// these helpers stays unchanged.
/**
* Standard integer / decimal formatter — "5,432.10" in en, "5.432,10"
* in de-DE, "5 432,10" in fr-FR. Use for cert counts, agent counts,
* issuance rates, anything that's a count or a non-byte/non-percent
* scalar.
*/
const numberFmt = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 2,
});
/**
* Compact / abbreviated formatter — "5.4K", "1.2M". Use for stat tiles
* where vertical space is constrained and ballpark magnitude beats
* exact value. Intl.NumberFormat's `notation: 'compact'` follows
* locale conventions (English K/M/B vs CJK 万/億 etc.) automatically.
*/
const compactFmt = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1,
});
/**
* Percent formatter — input is a fraction in [0, 1] OR an explicit
* percentage with `style: 'percent'` semantics. We default to "input
* is a fraction" because that's the common case for success-rate /
* error-rate / etc. Output: "99.5%" (en) / "99,5 %" (fr).
*/
const percentFmt = new Intl.NumberFormat(undefined, {
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
/**
* Bytes formatter — Intl.NumberFormat with `style: 'unit'` and the
* byte unit. Output: "5.4 MB" (en) / "5,4 MB" (fr). Browser does the
* SI scaling automatically when given a base unit + value. For
* non-SI binary (KiB / MiB / GiB), use the manual scaler below.
*
* Note: Safari < 14 doesn't support the 'unit' style. The fallback
* branches produce "5.4 MB" without locale awareness; an operator on
* old Safari sees consistent-but-American output, which is the same
* graceful-degradation contract as the rest of the i18n boundary.
*/
const bytesFmt = (() => {
try {
return new Intl.NumberFormat(undefined, {
style: 'unit',
unit: 'megabyte',
maximumFractionDigits: 1,
});
} catch {
return null; // signals fallback
}
})();
/** Format an integer or decimal in the operator's locale. */
export function formatNumber(value: number): string {
if (!Number.isFinite(value)) return '—';
return numberFmt.format(value);
}
/**
* Compact-format a magnitude — 1500 → "1.5K", 1_500_000 → "1.5M".
* Use for tile labels + chart axis ticks.
*/
export function formatCompact(value: number): string {
if (!Number.isFinite(value)) return '—';
return compactFmt.format(value);
}
/**
* Format a fraction in [0, 1] as a percentage. Pass 0.995 → "99.5%".
* For an already-percentified value (e.g. server returns 99.5 not
* 0.995), divide by 100 at the call site.
*/
export function formatPercent(value: number): string {
if (!Number.isFinite(value)) return '—';
return percentFmt.format(value);
}
/**
* Format a byte count with SI-decimal scaling (1KB = 1000B). Output
* locale-aware where possible; falls back to "5.4 MB"-style English
* on old Safari (see bytesFmt comment above).
*
* For binary scaling (1KiB = 1024B) use formatBytesBinary — relevant
* for memory / disk numbers that surface in Observability tiles.
*/
export function formatBytes(value: number): string {
if (!Number.isFinite(value)) return '—';
const { magnitude, unit } = pickSIUnit(value);
const scaled = value / magnitude;
if (bytesFmt) {
// Intl.NumberFormat doesn't accept the unit dynamically post-
// construction — we'd need a per-unit cache for that. Simpler:
// format the scaled magnitude with the standard number formatter
// and append the unit. Locale-aware decimal separator + space.
return `${numberFmt.format(round1(scaled))} ${unit}`;
}
return `${round1(scaled)} ${unit}`;
}
function pickSIUnit(bytes: number): { magnitude: number; unit: string } {
const abs = Math.abs(bytes);
if (abs >= 1e12) return { magnitude: 1e12, unit: 'TB' };
if (abs >= 1e9) return { magnitude: 1e9, unit: 'GB' };
if (abs >= 1e6) return { magnitude: 1e6, unit: 'MB' };
if (abs >= 1e3) return { magnitude: 1e3, unit: 'KB' };
return { magnitude: 1, unit: 'B' };
}
function round1(v: number): number {
return Math.round(v * 10) / 10;
}

View File

@@ -0,0 +1,58 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Operator timestamp-display preference — Phase 6 closure for I18N-H3.
//
// Default: 'utc' (frontend display ≡ server audit log byte-for-byte).
// Operators who prefer their local time explicitly opt in; operators
// running across timezones (e.g. an EU admin watching a US-East server)
// can pick a Custom IANA timezone.
//
// Storage: localStorage. No backend round-trip — the preference is
// purely cosmetic + per-browser. If the operator clears storage they
// reset to the safe default.
const STORAGE_KEY = 'certctl:timestamp-display';
export type TimestampMode = 'utc' | 'local' | 'custom';
export interface TimestampPref {
mode: TimestampMode;
/** Only meaningful when mode === 'custom'. IANA TZ name, e.g. 'America/New_York'. */
customTz: string;
}
const DEFAULT: TimestampPref = { mode: 'utc', customTz: 'UTC' };
/** Read the current preference. Always returns a valid value (defaults on parse/missing). */
export function getTimestampPref(): TimestampPref {
if (typeof localStorage === 'undefined') return DEFAULT;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT;
const parsed = JSON.parse(raw) as Partial<TimestampPref>;
if (parsed.mode !== 'utc' && parsed.mode !== 'local' && parsed.mode !== 'custom') {
return DEFAULT;
}
return {
mode: parsed.mode,
customTz: typeof parsed.customTz === 'string' && parsed.customTz.length > 0
? parsed.customTz
: DEFAULT.customTz,
};
} catch {
return DEFAULT;
}
}
/** Write the preference. Silently no-ops if storage unavailable (e.g. private mode). */
export function setTimestampPref(pref: TimestampPref): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pref));
// Fire a custom event so live <Timestamp> components can re-render
// without a page reload. Vanilla CustomEvent — works in every
// browser certctl supports.
window.dispatchEvent(new CustomEvent('certctl:timestamp-pref-changed', { detail: pref }));
} catch { /* noop */ }
}

View File

@@ -1,11 +1,95 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Date / time / display helpers — the i18n-ready boundary the rest of
// the frontend consumes. Phase 6 closure for I18N-H1 + I18N-H2 + I18N-H3.
//
// Locale handling:
// • Pre-Phase-6 these helpers hardcoded `'en-US'`, so a German /
// French / Japanese operator saw English month names regardless
// of their browser locale.
// • Post-Phase-6 we pass `undefined` for the locale arg, which makes
// the runtime use the browser default (navigator.language). The
// options object stays — `month: 'short'` etc. — so the SHAPE of
// the output is stable across locales while the language follows
// the user.
// • When a hard i18n framework lands (Phase 10), this file is the
// single migration target. Display code never reaches for
// Date.prototype.toLocaleString directly any more — Phase 6's CI
// guard at scripts/ci-guards/no-raw-toLocaleString.sh prevents
// regression.
//
// Timezone handling (I18N-H3):
// • formatDate / formatDateTime use the runtime's local timezone —
// keeps the existing operator-friendly default.
// • formatDateUTC / formatDateTimeUTC are explicit-UTC siblings.
// The audit-log table on the server emits UTC, so these helpers
// give the frontend a way to render the same byte-for-byte
// timestamp the operator sees in `journalctl -u certctl` or in a
// `psql` query.
// • <Timestamp iso={...} /> (web/src/components/Timestamp.tsx) wraps
// a UTC render in a Phase 1 Tooltip showing the operator-local
// equivalent. Default display is UTC (so screen ≡ logs); operators
// opt into local via the AuthSettingsPage "Timestamp display"
// preference.
const DATE_OPTS: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
};
const DATETIME_OPTS: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
/** Format an ISO timestamp as a date in the browser's local timezone. */
export function formatDate(iso: string | undefined | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
// `undefined` for the locale arg = use the browser default
// (navigator.language). DO NOT hardcode 'en-US' here — that was
// the I18N-H1 bug Phase 6 closes.
return new Date(iso).toLocaleDateString(undefined, DATE_OPTS);
}
/** Format an ISO timestamp as a date+time in the browser's local timezone. */
export function formatDateTime(iso: string | undefined | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
return new Date(iso).toLocaleString(undefined, DATETIME_OPTS);
}
/** Format an ISO timestamp as a date forced to UTC. */
export function formatDateUTC(iso: string | undefined | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, { ...DATE_OPTS, timeZone: 'UTC' });
}
/**
* Format an ISO timestamp as a date+time forced to UTC.
* Matches the format certctl-server emits to journalctl + audit_events.
* Operator can cross-reference frontend display ≡ server log byte-for-byte.
*/
export function formatDateTimeUTC(iso: string | undefined | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, { ...DATETIME_OPTS, timeZone: 'UTC' });
}
/**
* Format an ISO timestamp in an operator-specified timezone (IANA TZ name).
* Used by <Timestamp /> when the operator picks "Custom TZ" in settings.
* Falls back to UTC if the timezone name is invalid (Intl throws RangeError).
*/
export function formatDateTimeInZone(iso: string | undefined | null, timeZone: string): string {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString(undefined, { ...DATETIME_OPTS, timeZone });
} catch {
return new Date(iso).toLocaleString(undefined, { ...DATETIME_OPTS, timeZone: 'UTC' });
}
}
// D-2 (master): widened to accept undefined/null since several Go-side