diff --git a/frontend/src/lib/components/ConcurrentJobsChart.svelte b/frontend/src/lib/components/ConcurrentJobsChart.svelte index 5ba2ad4e37..b0a68169d7 100644 --- a/frontend/src/lib/components/ConcurrentJobsChart.svelte +++ b/frontend/src/lib/components/ConcurrentJobsChart.svelte @@ -15,6 +15,7 @@ import type { CompletedJob, ExtendedJobs } from '$lib/gen' import { getDbClockNow } from '$lib/forLater' import { Line } from '$lib/components/chartjs-wrappers/chartJs' + import { timeTicksWithDate } from '$lib/components/chartjs-wrappers/timeTicks' interface Props { extendedJobs?: ExtendedJobs | undefined @@ -236,7 +237,7 @@ }, min: minMaxTimes.min, max: minMaxTimes.max, - ticks: { maxRotation: 0, minRotation: 0 } + ticks: timeTicksWithDate(minMaxTimes.min, minMaxTimes.max) }, y: { grid: { diff --git a/frontend/src/lib/components/RunChart.svelte b/frontend/src/lib/components/RunChart.svelte index e2f2adca38..1b5c7eed5a 100644 --- a/frontend/src/lib/components/RunChart.svelte +++ b/frontend/src/lib/components/RunChart.svelte @@ -17,6 +17,7 @@ import type { CompletedJob } from '$lib/gen' import { getDbClockNow } from '$lib/forLater' import { Scatter } from '$lib/components/chartjs-wrappers/chartJs' + import { timeTicksWithDate } from '$lib/components/chartjs-wrappers/timeTicks' import DarkModeObserver from './DarkModeObserver.svelte' interface Props { @@ -269,7 +270,7 @@ }, min: minMaxTime.minTime.getTime(), max: minMaxTime.maxTime.getTime(), - ticks: { maxRotation: 0, minRotation: 0 } + ticks: timeTicksWithDate(minMaxTime.minTime, minMaxTime.maxTime) }, y: { grid: { diff --git a/frontend/src/lib/components/chartjs-wrappers/timeTicks.test.ts b/frontend/src/lib/components/chartjs-wrappers/timeTicks.test.ts new file mode 100644 index 0000000000..7952aca9c6 --- /dev/null +++ b/frontend/src/lib/components/chartjs-wrappers/timeTicks.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest' +import type { Tick } from 'chart.js' +import { timeTicksWithDate } from './timeTicks' + +const year = new Date().getFullYear() + +function labels(dates: Date[]): string[] { + const ticks = dates.map((d) => ({ value: d.getTime() }) as Tick) + const { callback } = timeTicksWithDate(dates[0], dates[dates.length - 1]) + return ticks.map((t, i) => callback(t.value, i, ticks)) +} + +describe('timeTicksWithDate', () => { + test('dates the leftmost tick and each day boundary, not the ticks in between', () => { + expect( + labels([ + new Date(year, 7, 21, 20, 0), + new Date(year, 7, 21, 22, 0), + new Date(year, 7, 22, 0, 0), + new Date(year, 7, 22, 2, 0) + ]) + ).toEqual(['Aug 21 8PM', '10PM', 'Aug 22', '2AM']) + }) + + test('sub-hour ticks keep the same AM/PM casing as the hourly ones', () => { + expect(labels([new Date(year, 7, 21, 23, 45), new Date(year, 7, 22, 0, 15)])).toEqual([ + 'Aug 21 11:45 PM', + 'Aug 22 12:15 AM' + ]) + }) + + test('major ticks are enabled only once the axis spans more than one day', () => { + const within = timeTicksWithDate(new Date(year, 7, 21, 6, 0), new Date(year, 7, 21, 23, 0)) + const across = timeTicksWithDate(new Date(year, 7, 21, 23, 0), new Date(year, 7, 22, 1, 0)) + + // Majors keep day boundaries through autoSkip, but drop tick 0 — the only dated tick an + // axis inside a single day has. + expect(within.major.enabled).toBe(false) + expect(across.major.enabled).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/chartjs-wrappers/timeTicks.ts b/frontend/src/lib/components/chartjs-wrappers/timeTicks.ts new file mode 100644 index 0000000000..7057784a65 --- /dev/null +++ b/frontend/src/lib/components/chartjs-wrappers/timeTicks.ts @@ -0,0 +1,47 @@ +import type { Tick } from 'chart.js' +import { format, isSameDay, startOfDay } from 'date-fns' + +const SECOND = 1000 +const MINUTE = 60 * SECOND +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +function dateFormat(time: number): string { + return new Date(time).getFullYear() === new Date().getFullYear() ? 'MMM d' : 'MMM d, yyyy' +} + +function clockFormat(spacing: number): string { + if (spacing < SECOND) return 'h:mm:ss.SSS a' + if (spacing < MINUTE) return 'h:mm:ss a' + if (spacing < HOUR) return 'h:mm a' + return 'ha' +} + +/** + * Ticks for a chart.js time axis that stay unambiguous about the day: sub-day ticks are bare + * clock times ("6PM"), except the leftmost one and the first tick of each day, which spell out + * the date. Without them a range that never reaches a day boundary carries no date at all. + * + * `min`/`max` are the bounds the axis is configured with. + */ +export function timeTicksWithDate(min: Date, max: Date) { + return { + maxRotation: 0, + minRotation: 0, + // Major ticks pin autoSkip's grid to the day boundaries, which is what keeps whole days + // worth of ticks aligned — and dated — on a wide axis. They also un-pin it from tick 0, + // whose date is the only one an axis within a single day has, so they stay off there. + major: { enabled: !isSameDay(min, max) }, + callback(value: number | string, index: number, ticks: Tick[]): string { + const time = Number(value) + // chart.js also calls this with a lone tick to size a sample label, hence the fallback. + const spacing = ticks.length > 1 ? Math.abs(ticks[1].value - ticks[0].value) : HOUR + if (spacing >= 27 * DAY) return format(time, 'MMM yyyy') + const date = format(time, dateFormat(time)) + if (spacing >= DAY) return date + const clock = format(time, clockFormat(spacing)) + if (index > 0 && isSameDay(ticks[index - 1].value, time)) return clock + return time === startOfDay(time).getTime() ? date : `${date} ${clock}` + } + } +}