diff --git a/packages/app/cypress/component/scatter-graph.cy.tsx b/packages/app/cypress/component/scatter-graph.cy.tsx index a4b6c380..3ee91849 100644 --- a/packages/app/cypress/component/scatter-graph.cy.tsx +++ b/packages/app/cypress/component/scatter-graph.cy.tsx @@ -1369,6 +1369,7 @@ describe('ChartDisplay engine comparison guard', () => { selectedSequence: Sequence.AgenticTraces, selectedXAxisMode: 'interactivity' as const, selectedXAxisMetric: 'p90_ttft', + bestPerSku: false, activeHwTypes: new Set([officialKeys[0]]), hwTypesWithData: new Set(officialKeys), resolveComparisonSelection: resolveSelection, diff --git a/packages/app/cypress/support/mock-data.ts b/packages/app/cypress/support/mock-data.ts index e6c5c8a2..c66bf850 100644 --- a/packages/app/cypress/support/mock-data.ts +++ b/packages/app/cypress/support/mock-data.ts @@ -166,6 +166,8 @@ export function createMockInferenceContext( toggleHwType: namedStub('toggleHwType'), removeHwType: namedStub('removeHwType'), selectAllHwTypes: namedStub('selectAllHwTypes'), + bestPerSku: true, + setBestPerSku: namedStub('setBestPerSku'), resolveComparisonSelection: (proposed) => ({ result: proposed, keptGroup: null, diff --git a/packages/app/src/components/inference/InferenceContext.tsx b/packages/app/src/components/inference/InferenceContext.tsx index b5932802..23ddf01b 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -69,6 +69,7 @@ import { comparisonExclusion as resolveComparisonExclusion, } from './utils/comparison-exclusion'; import { resolveLabelState, serializeLabelState } from './utils/label-defaults'; +import { bestSeriesPerSku } from './utils/best-series-per-sku'; import { EMPTY_QUICK_FILTERS, parseDeploymentModes, @@ -327,6 +328,9 @@ export function InferenceProvider({ }); const [hideNonOptimal, setHideNonOptimal] = useState(() => getUrlParam('i_optimal') !== '0'); + const [bestPerSku, setBestPerSku] = useState( + () => activeTab === 'inference' && getUrlParam('i_best') !== '0', + ); const labelScenarioKind = sequenceKind(effectiveSequence); const initialLabelState = useMemo( () => @@ -893,6 +897,37 @@ export function InferenceProvider({ extractHwKey, ); + const bestHwTypes = useMemo(() => { + const wantedType = selectedXAxisMode === 'interactivity' ? 'interactivity' : 'e2e'; + const graph = graphs.find((candidate) => candidate.chartDefinition.chartType === wantedType); + if (!graph) return hwTypesWithData; + const direction = + graph.chartDefinition[ + `${selectedYAxisMetric}_roofline` as keyof typeof graph.chartDefinition + ]; + if ( + direction !== 'upper_right' && + direction !== 'upper_left' && + direction !== 'lower_left' && + direction !== 'lower_right' + ) { + return hwTypesWithData; + } + const best = bestSeriesPerSku(graph.data, direction); + return best.size > 0 ? best : hwTypesWithData; + }, [graphs, hwTypesWithData, selectedXAxisMode, selectedYAxisMetric]); + + const setBestPerSkuAndApply = useCallback( + (enabled: boolean) => { + setBestPerSku(enabled); + const target = enabled ? bestHwTypes : hwTypesWithData; + setActiveHwTypes(resolveHwSelection(target).result); + setActivePresetId(null); + presetHwFilterRef.current = null; + }, + [bestHwTypes, hwTypesWithData, resolveHwSelection, setActiveHwTypes], + ); + // Direct fallback: apply pendingHwFilter when hwTypesWithData is already populated // but useChartDataFilter didn't fire (e.g. re-selecting the same preset). useEffect(() => { @@ -911,6 +946,7 @@ export function InferenceProvider({ const next = toggleComparisonSelection(activeHwTypes, hw, hwTypesWithData); if (!next) return; setActiveHwTypes(next); + setBestPerSku(false); setActivePresetId(null); presetHwFilterRef.current = null; }, @@ -920,6 +956,7 @@ export function InferenceProvider({ const removeHwType = useCallback( (hw: string) => { removeHwRaw(hw); + setBestPerSku(false); setActivePresetId(null); presetHwFilterRef.current = null; }, @@ -941,6 +978,7 @@ export function InferenceProvider({ ); const removeActiveDate = useCallback((id: string) => removeDateRaw(id), [removeDateRaw]); const selectAllHwTypes = useCallback(() => { + setBestPerSku(false); if (exclusion) { const { result, droppedGroups } = resolveHwSelection(hwTypesWithData, activeHwTypes); setActiveHwTypes(result); @@ -979,7 +1017,7 @@ export function InferenceProvider({ const precisionsKey = effectivePrecisions.join(','); const hwResetKey = `${selectedModel}|${effectiveSequence}|${precisionsKey}|${ isUnofficialRun ? 'preview' : 'official' - }`; + }|${selectedYAxisMetric}|${selectedXAxisMode}`; const lastHwResetKeyRef = useRef(''); // Restore legend-active selection from URL on first availability of @@ -1052,23 +1090,26 @@ export function InferenceProvider({ // Scenarios that restrict standard-token engines (8K/1K, AgentX) keep one // sticky group so their charts remain useful; variant-only rules retain // the existing clear-all behavior. - const { result, droppedGroups } = resolveHwSelection(hwTypesWithData); + const automaticSelection = bestPerSku ? bestHwTypes : hwTypesWithData; + const { result, droppedGroups } = resolveHwSelection(automaticSelection); setActiveHwTypes(result); if (droppedGroups.length > 0) { setEngineConflict({ kind: 'resolved', - ...exclusionResolutionFamilies(hwTypesWithData, result, exclusion), + ...exclusionResolutionFamilies(automaticSelection, result, exclusion), }); } return; } - setActiveHwTypes(hwTypesWithData); + setActiveHwTypes(bestPerSku ? bestHwTypes : hwTypesWithData); }, [ selectedModel, effectiveSequence, precisionsKey, hwResetKey, hwTypesWithData, + bestHwTypes, + bestPerSku, exclusion, pendingActiveHwTypes, resolveHwSelection, @@ -1177,6 +1218,16 @@ export function InferenceProvider({ // it equals the full set of items with data. Keeps share URLs short. const iActiveStr = useMemo(() => { if (activeHwTypes.size === 0) return ''; + if (bestPerSku && activeHwTypes.size === bestHwTypes.size) { + let same = true; + for (const k of activeHwTypes) { + if (!bestHwTypes.has(k)) { + same = false; + break; + } + } + if (same) return ''; + } if (activeHwTypes.size === hwTypesWithData.size) { let same = true; for (const k of activeHwTypes) { @@ -1188,7 +1239,7 @@ export function InferenceProvider({ if (same) return ''; } return [...activeHwTypes].toSorted().join(','); - }, [activeHwTypes, hwTypesWithData]); + }, [activeHwTypes, hwTypesWithData, bestHwTypes, bestPerSku]); const serializedLabelState = serializeLabelState(labelScenarioKind, { showPointLabels, @@ -1205,6 +1256,7 @@ export function InferenceProvider({ i_dstart: selectedDateRange.startDate, i_dend: selectedDateRange.endDate, i_optimal: hideNonOptimal ? '' : '0', + i_best: bestPerSku ? '' : '0', i_label: serializedLabelState.i_label, i_hc: highContrast ? '1' : '', i_log: logScale ? '1' : '', @@ -1234,6 +1286,7 @@ export function InferenceProvider({ selectedDates, selectedDateRange, hideNonOptimal, + bestPerSku, showPointLabels, highContrast, logScale, @@ -1374,6 +1427,8 @@ export function InferenceProvider({ toggleHwType, removeHwType, selectAllHwTypes, + bestPerSku, + setBestPerSku: setBestPerSkuAndApply, resolveComparisonSelection: resolveHwSelection, toggleComparisonSelection, hardwareConfig, @@ -1465,6 +1520,8 @@ export function InferenceProvider({ toggleHwType, removeHwType, selectAllHwTypes, + bestPerSku, + setBestPerSkuAndApply, resolveHwSelection, toggleComparisonSelection, diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index 65724153..71cb9d86 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -743,6 +743,9 @@ export interface InferenceChartContextType { toggleHwType: (hw: string) => void; removeHwType: (hw: string) => void; selectAllHwTypes: () => void; + /** Whether clean dashboard loads automatically keep the best configuration per physical SKU. */ + bestPerSku: boolean; + setBestPerSku: (enabled: boolean) => void; /** Resolve automatic official + `overlay:` hardware selections under the active scope rule. */ resolveComparisonSelection: ( proposed: Set, diff --git a/packages/app/src/components/inference/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index 9635cc75..f80f823b 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -24,6 +24,7 @@ import { import { dataRunsForDate } from '@/components/inference/utils/runEnumeration'; import { matchesQuickFilters } from '@/components/inference/utils/quickFilters'; import { canonicalNormalizedFrontierIds } from '@/components/inference/utils/canonicalFrontier'; +import { bestSeriesPerSku } from '@/components/inference/utils/best-series-per-sku'; import InferenceTable from '@/components/inference/ui/InferenceTable'; import ScatterGraph from '@/components/inference/ui/ScatterGraph'; import { Card } from '@/components/ui/card'; @@ -209,6 +210,7 @@ export default function ChartDisplay() { selectedRunDate, setIsLegendExpanded, activeHwTypes, + bestPerSku, activeDates, selectedPercentile, compareGpuPair, @@ -446,6 +448,37 @@ export default function ChartDisplay() { } return eligibleKeys; }, [graphs, selectedPrecisions, quickFilters]); + const scopedBestSelections = useMemo(() => { + if (!bestPerSku) return { official: officialScope, overlay: overlayScope }; + const wantedType = selectedXAxisMode === 'interactivity' ? 'interactivity' : 'e2e'; + const graph = graphs.find((candidate) => candidate.chartDefinition.chartType === wantedType); + const direction = + graph?.chartDefinition[`${selectedYAxisMetric}_roofline` as keyof ChartDefinition]; + if ( + !graph || + (direction !== 'upper_right' && + direction !== 'upper_left' && + direction !== 'lower_left' && + direction !== 'lower_right') + ) { + return { official: officialScope, overlay: overlayScope }; + } + const overlay = overlayDataByChartType[wantedType]; + const officialBest = bestSeriesPerSku(graph.data, direction); + const overlayBest = bestSeriesPerSku(overlay?.data ?? [], direction); + return { + official: officialBest.size > 0 ? officialBest : officialScope, + overlay: overlayBest.size > 0 ? overlayBest : overlayScope, + }; + }, [ + bestPerSku, + graphs, + officialScope, + overlayDataByChartType, + overlayScope, + selectedXAxisMode, + selectedYAxisMetric, + ]); const overlayRowsScopeKey = `${selectedModel}|${selectedSequence}|${selectedPrecisions.join( ',', )}|${unofficialRunInfos.map((run) => run.url).join(',')}`; @@ -463,8 +496,8 @@ export default function ChartDisplay() { const activeScopedOverlayKeys = new Set( [...activeOverlayHwTypes].filter((key) => overlayScope.has(key)), ); - return overlayRowsScopeChanged ? overlayScope : activeScopedOverlayKeys; - }, [activeOverlayHwTypes, overlayScope, overlayRowsScopeChanged]); + return overlayRowsScopeChanged ? scopedBestSelections.overlay : activeScopedOverlayKeys; + }, [activeOverlayHwTypes, overlayScope, overlayRowsScopeChanged, scopedBestSelections.overlay]); useEffect(() => { const merged = new Set(activeOverlayHwTypes); overlayScope.forEach((key) => merged.delete(key)); @@ -482,7 +515,7 @@ export default function ChartDisplay() { // A scope change can render once before its official graphs arrive. Do not // persist that transient empty set as an intentional legend selection. if (overlayRowsScopeChanged && (!loading || officialScope.size > 0)) { - setLocalOfficialOverride(officialScope); + setLocalOfficialOverride(scopedBestSelections.official); setAppliedOverlayRowsScopeKey(overlayRowsScopeKey); } }, [ @@ -491,6 +524,7 @@ export default function ChartDisplay() { activeOverlayHwTypes, loading, officialScope, + scopedBestSelections.official, overlayScope, scopedActiveOverlayHwTypes, setActiveOverlayHwTypes, diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index 4a244f61..921bda54 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -91,6 +91,7 @@ import { renderKnownIssueAnnotations, } from '@/components/inference/utils/knownIssueAnnotations'; import { matchesQuickFilters } from '@/components/inference/utils/quickFilters'; +import { bestSeriesPerSku } from '@/components/inference/utils/best-series-per-sku'; import { changelogConfigToHwKey } from '@/components/inference/utils/changelogFormatters'; import { buildFrontierContinuations, @@ -393,6 +394,7 @@ const SCATTER_STRINGS = { en: { logScale: 'Log Scale', optimalOnly: 'Optimal Only', + bestPerSku: 'Best per SKU', optimalInfo: 'On agentic, optimal points must be Pareto-optimal on the selected x-axis and also belong to the E2E Normalized Interactivity frontier.', labels: 'Labels', @@ -408,6 +410,7 @@ const SCATTER_STRINGS = { zh: { logScale: '对数缩放', optimalOnly: '仅最优', + bestPerSku: '每个 SKU 仅显示最佳配置', optimalInfo: '在智能体场景中,最优点既必须在当前横轴上满足 Pareto 最优,也必须属于端到端归一化交互性的 Pareto 前沿。', labels: '标签', @@ -443,6 +446,8 @@ const ScatterGraph = React.memo( }: ScatterGraphProps) => { const { activeHwTypes, + bestPerSku, + setBestPerSku, hardwareConfig: contextHardwareConfig, toggleHwType, removeHwType, @@ -3259,6 +3264,37 @@ const ScatterGraph = React.memo( track('latency_legend_expanded', { expanded }); }} switches={[ + { + id: 'scatter-best-per-sku', + label: legendT.bestPerSku, + checked: bestPerSku, + onCheckedChange: (checked: boolean) => { + setBestPerSku(checked); + if (overlayData) { + if (checked) { + const direction = + chartDefinition[ + `${selectedYAxisMetric}_roofline` as keyof ChartDefinition + ]; + if ( + direction === 'upper_right' || + direction === 'upper_left' || + direction === 'lower_left' || + direction === 'lower_right' + ) { + const selection = bestSeriesPerSku(data, direction); + for (const key of bestSeriesPerSku(overlayData.data, direction)) { + selection.add(`overlay:${key}`); + } + commitUnifiedSelection(selection); + } + } else { + resetUnifiedSelection(); + } + } + track('inference_best_per_sku_toggled', { enabled: checked }); + }, + }, ...(selectedYAxisMetric === 'y_inputTputPerGpu' ? [] : [ diff --git a/packages/app/src/components/inference/utils/best-series-per-sku.test.ts b/packages/app/src/components/inference/utils/best-series-per-sku.test.ts new file mode 100644 index 00000000..266b7570 --- /dev/null +++ b/packages/app/src/components/inference/utils/best-series-per-sku.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import type { InferenceData } from '@/components/inference/types'; + +import { baseSku, bestSeriesPerSku } from './best-series-per-sku'; + +function point(hw: string, hwKey: string, x: number, y: number): InferenceData { + return { hw, hwKey, x, y } as InferenceData; +} + +describe('bestSeriesPerSku', () => { + it('selects the highest normalized frontier AUC within each SKU', () => { + const selected = bestSeriesPerSku( + [ + point('B200-8', 'b200_trt', 10, 100), + point('B200-8', 'b200_trt', 20, 80), + point('B200-8', 'b200_sglang', 10, 90), + point('B200-8', 'b200_sglang', 20, 60), + point('H200-8', 'h200_vllm', 10, 40), + ], + 'upper_left', + ); + + expect([...selected].toSorted()).toEqual(['b200_trt', 'h200_vllm']); + }); + + it('uses only the shared measured domain instead of rewarding wider coverage', () => { + const selected = bestSeriesPerSku( + [ + point('B200-8', 'b200_wide', 8, 70), + point('B200-8', 'b200_wide', 10, 60), + point('B200-8', 'b200_wide', 20, 50), + point('B200-8', 'b200_narrow', 8, 90), + point('B200-8', 'b200_narrow', 10, 80), + ], + 'upper_left', + ); + + expect(selected).toEqual(new Set(['b200_narrow'])); + }); + + it('inverts the score for lower-is-better metrics', () => { + const selected = bestSeriesPerSku( + [ + point('B200-8', 'b200_cheap', 10, 1), + point('B200-8', 'b200_cheap', 20, 2), + point('B200-8', 'b200_expensive', 10, 2), + point('B200-8', 'b200_expensive', 20, 3), + ], + 'lower_right', + ); + + expect(selected).toEqual(new Set(['b200_cheap'])); + }); + + it('groups framework variants by physical hardware SKU', () => { + expect(baseSku(point('GB200-NVL72', 'gb200_dynamo-trt', 1, 1))).toBe('GB200'); + }); + + it('ranks unofficial-run overlay series with the same SKU policy', () => { + const overlayPoint = (hwKey: string, x: number, y: number) => + ({ + ...point('B200-8', hwKey, x, y), + run_url: 'https://github.com/example/actions/runs/123', + }) as InferenceData; + const selected = bestSeriesPerSku( + [ + overlayPoint('b200_overlay_vllm', 10, 80), + overlayPoint('b200_overlay_vllm', 20, 60), + overlayPoint('b200_overlay_sglang', 10, 100), + overlayPoint('b200_overlay_sglang', 20, 75), + ], + 'upper_left', + ); + + expect(selected).toEqual(new Set(['b200_overlay_sglang'])); + }); +}); diff --git a/packages/app/src/components/inference/utils/best-series-per-sku.ts b/packages/app/src/components/inference/utils/best-series-per-sku.ts new file mode 100644 index 00000000..db863e0d --- /dev/null +++ b/packages/app/src/components/inference/utils/best-series-per-sku.ts @@ -0,0 +1,98 @@ +import { hermiteInterpolate, monotoneSlopes } from '@/components/calculator/interpolation'; +import type { InferenceData } from '@/components/inference/types'; +import { isFrontierEligible, paretoFrontForDirection } from '@/lib/chart-utils'; + +type Direction = 'upper_right' | 'upper_left' | 'lower_left' | 'lower_right'; + +// Named separately so the sampling density is obvious in test failures and can +// be changed without leaving a magic number in the scoring loop. +const SAMPLE_COUNT = 9; + +/** The physical SKU portion shared by framework/speculative-decoding variants. */ +export function baseSku(point: Pick): string { + const rawHardware = String(point.hw || '').split('-')[0]; + return rawHardware || String(point.hwKey).split(/[_-]/u)[0]; +} + +interface ScoredSeries { + key: string; + points: InferenceData[]; + minX: number; + maxX: number; +} + +/** + * Select one configuration line per physical SKU using normalized frontier AUC. + * + * Every candidate is sampled over the common measured x-domain for that SKU, + * so a curve cannot win merely because it spans a wider range. The chart's + * existing Pareto direction and monotone interpolation are reused to keep the + * ranking aligned with the line users see. Ties are deterministic by hwKey. + */ +export function bestSeriesPerSku(points: InferenceData[], direction: Direction): Set { + const bySku = new Map>(); + for (const point of points) { + if (!isFrontierEligible(point) || !Number.isFinite(point.y)) continue; + const sku = baseSku(point); + const key = String(point.hwKey); + let series = bySku.get(sku); + if (!series) { + series = new Map(); + bySku.set(sku, series); + } + const rows = series.get(key) ?? []; + rows.push(point); + series.set(key, rows); + } + + const selected = new Set(); + const higherYIsBetter = direction.startsWith('upper'); + + for (const series of bySku.values()) { + const candidates: ScoredSeries[] = [...series.entries()].map(([key, rows]) => { + const frontier = paretoFrontForDirection(direction)([...rows]).toSorted((a, b) => a.x - b.x); + return { + key, + points: frontier, + minX: frontier[0]?.x ?? Infinity, + maxX: frontier.at(-1)?.x ?? -Infinity, + }; + }); + const usable = candidates.filter((candidate) => candidate.points.length > 0); + if (usable.length === 0) continue; + if (usable.length === 1) { + selected.add(usable[0].key); + continue; + } + + const commonMin = Math.max(...usable.map((candidate) => candidate.minX)); + const commonMax = Math.min(...usable.map((candidate) => candidate.maxX)); + const hasCommonDomain = commonMin <= commonMax; + + const score = (candidate: ScoredSeries): number => { + const xs = candidate.points.map((point) => point.x); + const ys = candidate.points.map((point) => point.y); + if (!hasCommonDomain) { + // No honest AUC comparison is possible. Prefer the best measured point; + // the key tie-break below keeps the choice stable. + return higherYIsBetter ? Math.max(...ys) : -Math.min(...ys); + } + const slopes = monotoneSlopes(xs, ys); + let total = 0; + for (let index = 0; index < SAMPLE_COUNT; index++) { + const fraction = index / (SAMPLE_COUNT - 1); + const x = commonMin + (commonMax - commonMin) * fraction; + total += hermiteInterpolate(xs, ys, slopes, x); + } + const mean = total / SAMPLE_COUNT; + return higherYIsBetter ? mean : -mean; + }; + + const winner = usable + .map((candidate) => ({ candidate, score: score(candidate) })) + .toSorted((a, b) => b.score - a.score || a.candidate.key.localeCompare(b.candidate.key))[0]; + selected.add(winner.candidate.key); + } + + return selected; +} diff --git a/packages/app/src/lib/url-state.test.ts b/packages/app/src/lib/url-state.test.ts index d61dba9c..28374f1e 100644 --- a/packages/app/src/lib/url-state.test.ts +++ b/packages/app/src/lib/url-state.test.ts @@ -65,6 +65,7 @@ describe('PARAM_DEFAULTS', () => { it('has empty string defaults for legend-active params', async () => { const { PARAM_DEFAULTS } = await import('@/lib/url-state'); expect(PARAM_DEFAULTS.i_active).toBe(''); + expect(PARAM_DEFAULTS.i_best).toBe(''); expect(PARAM_DEFAULTS.e_active).toBe(''); expect(PARAM_DEFAULTS.r_active).toBe(''); }); diff --git a/packages/app/src/lib/url-state.ts b/packages/app/src/lib/url-state.ts index c0216233..ed509261 100644 --- a/packages/app/src/lib/url-state.ts +++ b/packages/app/src/lib/url-state.ts @@ -33,6 +33,7 @@ const URL_STATE_KEYS = [ 'i_dstart', 'i_dend', 'i_optimal', + 'i_best', 'i_label', // Legacy alias of `i_label` with inverted semantics — read-only on load so // pre-rename share links (?i_nolabel=1) keep hiding point labels even if the @@ -105,6 +106,7 @@ export const PARAM_DEFAULTS: Record = { i_dstart: '', i_dend: '', i_optimal: '', + i_best: '', i_label: '', i_nolabel: '', i_hc: '', diff --git a/packages/app/src/lib/visit-tracking.test.ts b/packages/app/src/lib/visit-tracking.test.ts index 3381bb9f..5707f507 100644 --- a/packages/app/src/lib/visit-tracking.test.ts +++ b/packages/app/src/lib/visit-tracking.test.ts @@ -25,7 +25,12 @@ let mockLocal: ReturnType; let mockSession: ReturnType; function setNow(iso: string) { - vi.setSystemTime(new Date(iso)); + // Interpret the timestamp in the local time zone to match visit-tracking's + // local-calendar-day logic (getFullYear/getMonth/getDate). A trailing 'Z' + // forces UTC parsing, which shifts the calendar day in non-UTC zones — e.g. + // 2026-05-15T22:00:00Z is 2026-05-16 in Asia/Singapore (UTC+8) — making the + // day/month boundary assertions flaky depending on where the suite runs. + vi.setSystemTime(new Date(iso.replace(/Z$/u, ''))); } function currentMonth(): string {