Skip to content

Commit 011d19e

Browse files
committed
fix(frontend): emit first-of-month X-axis ticks in History year mode
Year-range History charts auto-emitted ~10 ticks across whatever data window was actually rendered, formatted as "Apr 26" (month + 2-digit year), which on sparse data read as the same string repeated — and ambiguously parsed as "April 26th" rather than "April 2026". Now we emit one tick per first-of- calendar-month within the visible range, formatted as "Apr 2026". Mirrors the existing stationMidnightSplits / withWeeklyDayXAxis pattern; the two wrappers are chained via a unified applyHistoryXAxis. Also bumps the fallback formatTime monthly format to year:"numeric" so tooltips at zoom levels read "Apr 2026" instead of "Apr 26".
1 parent 4d7c4cf commit 011d19e

2 files changed

Lines changed: 145 additions & 16 deletions

File tree

frontend/src/app/history/page.test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,64 @@ describe("HistoryPage URL-driven state", () => {
525525
expect(xAxis?.values!({}, [split], 0, 0, 0)).toEqual(["Sun"]);
526526
});
527527

528+
it("uses station-month-start x-axis splits for year mode", () => {
529+
navState.searchParams = new URLSearchParams("range=year&date=2026-01-01");
530+
stationTimezoneState.timezone = "America/New_York";
531+
historyDataState.resolution = "monthly";
532+
533+
renderWithProviders(<HistoryPage />);
534+
535+
const xAxis = upChartCalls[0]?.props.options?.axes?.[0];
536+
expect(xAxis?.splits).toBeDefined();
537+
538+
const scaleMin = zonedMidnightToUtc("America/New_York", 2026, 1, 1).getTime() / 1000;
539+
const scaleMax = zonedMidnightToUtc("America/New_York", 2027, 1, 1).getTime() / 1000;
540+
541+
const splits = xAxis!.splits!({}, 0, scaleMin, scaleMax, 0, 0);
542+
543+
expect(splits).toEqual([
544+
zonedMidnightToUtc("America/New_York", 2026, 1, 1).getTime() / 1000,
545+
zonedMidnightToUtc("America/New_York", 2026, 2, 1).getTime() / 1000,
546+
zonedMidnightToUtc("America/New_York", 2026, 3, 1).getTime() / 1000,
547+
zonedMidnightToUtc("America/New_York", 2026, 4, 1).getTime() / 1000,
548+
zonedMidnightToUtc("America/New_York", 2026, 5, 1).getTime() / 1000,
549+
zonedMidnightToUtc("America/New_York", 2026, 6, 1).getTime() / 1000,
550+
zonedMidnightToUtc("America/New_York", 2026, 7, 1).getTime() / 1000,
551+
zonedMidnightToUtc("America/New_York", 2026, 8, 1).getTime() / 1000,
552+
zonedMidnightToUtc("America/New_York", 2026, 9, 1).getTime() / 1000,
553+
zonedMidnightToUtc("America/New_York", 2026, 10, 1).getTime() / 1000,
554+
zonedMidnightToUtc("America/New_York", 2026, 11, 1).getTime() / 1000,
555+
zonedMidnightToUtc("America/New_York", 2026, 12, 1).getTime() / 1000,
556+
zonedMidnightToUtc("America/New_York", 2027, 1, 1).getTime() / 1000,
557+
]);
558+
559+
expect(xAxis!.values!({}, splits.slice(0, 3), 0, 0, 0)).toEqual([
560+
"Jan 2026",
561+
"Feb 2026",
562+
"Mar 2026",
563+
]);
564+
});
565+
566+
it("emits a single tick when the year-mode visible window spans only one month-start", () => {
567+
navState.searchParams = new URLSearchParams("range=year&date=2026-01-01");
568+
stationTimezoneState.timezone = "America/New_York";
569+
historyDataState.resolution = "monthly";
570+
571+
renderWithProviders(<HistoryPage />);
572+
573+
const xAxis = upChartCalls[0]?.props.options?.axes?.[0];
574+
// Apr 27 - May 5: only May 1 falls in range
575+
const scaleMin = zonedMidnightToUtc("America/New_York", 2026, 4, 27).getTime() / 1000;
576+
const scaleMax = zonedMidnightToUtc("America/New_York", 2026, 5, 5).getTime() / 1000;
577+
578+
const splits = xAxis!.splits!({}, 0, scaleMin, scaleMax, 0, 0);
579+
580+
expect(splits).toEqual([
581+
zonedMidnightToUtc("America/New_York", 2026, 5, 1).getTime() / 1000,
582+
]);
583+
expect(xAxis!.values!({}, splits, 0, 0, 0)).toEqual(["May 2026"]);
584+
});
585+
528586
it("clicking a range button updates URL via pushState", () => {
529587
renderWithProviders(<HistoryPage />);
530588

frontend/src/app/history/page.tsx

Lines changed: 87 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ function formatTime(unix: number, resolution: string, timezone: string): string
129129
if (resolution === "daily") {
130130
return d.toLocaleDateString([], { timeZone: timezone, month: "short", day: "numeric" });
131131
}
132-
return d.toLocaleDateString([], { timeZone: timezone, month: "short", year: "2-digit" });
132+
return d.toLocaleDateString([], { timeZone: timezone, month: "short", year: "numeric" });
133133
}
134134

135135
function isLeapYear(year: number): boolean {
@@ -235,6 +235,72 @@ function withWeeklyDayXAxis(
235235
};
236236
}
237237

238+
function stationMonthStartSplits(
239+
timezone: string,
240+
scaleMin: number,
241+
scaleMax: number,
242+
): number[] {
243+
if (!Number.isFinite(scaleMin) || !Number.isFinite(scaleMax) || scaleMin > scaleMax) {
244+
return [];
245+
}
246+
247+
const startParts = getZonedParts(timezone, new Date(scaleMin * 1000));
248+
let date: CalendarDate = {
249+
year: startParts.year,
250+
month: startParts.month,
251+
day: 1,
252+
};
253+
const splits: number[] = [];
254+
255+
for (let guard = 0; guard < 60; guard += 1) {
256+
const split =
257+
zonedMidnightToUtc(timezone, date.year, date.month, date.day).getTime() / 1000;
258+
if (split >= scaleMin && split <= scaleMax) {
259+
splits.push(split);
260+
}
261+
if (split > scaleMax) {
262+
break;
263+
}
264+
const nextMonth = date.month === 12 ? 1 : date.month + 1;
265+
const nextYear = date.month === 12 ? date.year + 1 : date.year;
266+
date = { year: nextYear, month: nextMonth, day: 1 };
267+
}
268+
269+
return splits;
270+
}
271+
272+
function withMonthlyXAxis(
273+
opts: HistoryChartOptions,
274+
enabled: boolean,
275+
timezone: string,
276+
): HistoryChartOptions {
277+
if (!enabled) return opts;
278+
279+
const splits: uPlot.Axis.Splits = (_u, _axisIdx, scaleMin, scaleMax) =>
280+
stationMonthStartSplits(timezone, scaleMin, scaleMax);
281+
const values: uPlot.Axis.Values = (_u, axisSplits) =>
282+
axisSplits.map((split) =>
283+
new Date(split * 1000).toLocaleDateString([], {
284+
timeZone: timezone,
285+
month: "short",
286+
year: "numeric",
287+
}),
288+
);
289+
290+
return {
291+
...opts,
292+
axes: opts.axes?.map((axis, index) =>
293+
index === 0
294+
? {
295+
...axis,
296+
splits,
297+
values,
298+
}
299+
: axis,
300+
),
301+
};
302+
}
303+
238304
function ChartPanel({
239305
title,
240306
children,
@@ -496,43 +562,48 @@ function HistoryPageInner() {
496562
[resolution, timezone],
497563
);
498564
const useWeeklyDayXAxis = resolution === "hourly" && range === "week" && !activeZoomRange;
499-
const applyWeeklyDayXAxis = useCallback(
565+
const useMonthlyXAxis = resolution === "monthly" && !activeZoomRange;
566+
const applyHistoryXAxis = useCallback(
500567
(opts: HistoryChartOptions) =>
501-
withWeeklyDayXAxis(opts, useWeeklyDayXAxis, timezone),
502-
[timezone, useWeeklyDayXAxis],
568+
withMonthlyXAxis(
569+
withWeeklyDayXAxis(opts, useWeeklyDayXAxis, timezone),
570+
useMonthlyXAxis,
571+
timezone,
572+
),
573+
[timezone, useWeeklyDayXAxis, useMonthlyXAxis],
503574
);
504575

505576
// Chart options — rebuilt when resolution, units, or theme change
506577
const tempOpts = useMemo(
507-
() => applyWeeklyDayXAxis(temperatureOpts(colors, tickFmt, isRaw)),
508-
[applyWeeklyDayXAxis, colors, tickFmt, isRaw],
578+
() => applyHistoryXAxis(temperatureOpts(colors, tickFmt, isRaw)),
579+
[applyHistoryXAxis, colors, tickFmt, isRaw],
509580
);
510581
const humOpts = useMemo(
511-
() => applyWeeklyDayXAxis(humidityOpts(colors, tickFmt)),
512-
[applyWeeklyDayXAxis, colors, tickFmt],
582+
() => applyHistoryXAxis(humidityOpts(colors, tickFmt)),
583+
[applyHistoryXAxis, colors, tickFmt],
513584
);
514585
const presOpts = useMemo(
515-
() => applyWeeklyDayXAxis(pressureOpts(colors, tickFmt)),
516-
[applyWeeklyDayXAxis, colors, tickFmt],
586+
() => applyHistoryXAxis(pressureOpts(colors, tickFmt)),
587+
[applyHistoryXAxis, colors, tickFmt],
517588
);
518589
const wndOpts = useMemo(
519590
() =>
520-
applyWeeklyDayXAxis(
591+
applyHistoryXAxis(
521592
useWindBars ? windOptsBucketed(colors, tickFmt) : windOpts(colors, tickFmt),
522593
),
523-
[applyWeeklyDayXAxis, colors, tickFmt, useWindBars],
594+
[applyHistoryXAxis, colors, tickFmt, useWindBars],
524595
);
525596
const rainDecimals = system === "imperial" ? 3 : 1;
526597
const rnOpts = useMemo(
527-
() => applyWeeklyDayXAxis(rainOpts(colors, tickFmt, rainDecimals)),
528-
[applyWeeklyDayXAxis, colors, tickFmt, rainDecimals],
598+
() => applyHistoryXAxis(rainOpts(colors, tickFmt, rainDecimals)),
599+
[applyHistoryXAxis, colors, tickFmt, rainDecimals],
529600
);
530601
const suvOpts = useMemo(
531602
() =>
532-
applyWeeklyDayXAxis(
603+
applyHistoryXAxis(
533604
useSolarBars ? solarUvOptsBucketed(colors, tickFmt) : solarUvOpts(colors, tickFmt),
534605
),
535-
[applyWeeklyDayXAxis, colors, tickFmt, useSolarBars],
606+
[applyHistoryXAxis, colors, tickFmt, useSolarBars],
536607
);
537608

538609
// Zoom

0 commit comments

Comments
 (0)