From a350f7c68e14909746427e716c5fdc3144d1df71 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 22 Aug 2026 11:43:29 +0200 Subject: [PATCH] feat: show the date on the runs dashboard chart axes (#10808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: assert the unpacked repo symlink without following it `unpack_keeps_a_link_that_stays_in_the_repo` read through the link it had just unpacked. Windows stores a symlink's target verbatim and its object manager rejects the `/` in a POSIX one, so `read_to_string` came back with `ERROR_INVALID_NAME` and the release's `cargo_test_windows` job was red. Pin what the function is responsible for on every platform — the link is kept and materialized — and read through it only where a POSIX relative target resolves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P3WRtxdKNGdomWX9vaAYGx * test: key the cli sync-map fixtures with the platform separator A sync map is keyed with the platform separator on both sides — `FSFSElement` walks the tree with `path.join`, and the remote `ZipFSElement` starts at `"." + SEP` and joins from there — while an `!inline` reference is always forward-slash. `lock_dedup.ts` follows that convention; the fixtures did not, so on Windows they built a map shape the CLI never produces and 12 of them failed. `getTypeStrFromPath` is the same story: it matches `"dependencies" + SEP`, and the test handed it a forward-slashed path. Build the fixture keys through the separator, leaving the `!inline` references and the `present` map forward-slash, as `sync.ts` hands them over. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P3WRtxdKNGdomWX9vaAYGx * ci: skip the discord comment relay when the thread lookup returns none A rate-limited or unauthorized Discord response carries no thread list, and under `bash -e` that aborted the step — jq cannot iterate null, nor parse the HTML error page Cloudflare answers a 429 with — before it reached the "thread not found, skipping" branch right below. Three comment relays failed that way on the 1.794.0 head. Keep the step green for both, but tell them apart: a response with no thread list is a delivery that was dropped for a reason worth seeing, so it warns with the body it got, while a PR that genuinely has no thread stays quiet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P3WRtxdKNGdomWX9vaAYGx * feat: show the date on the runs dashboard chart axes Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019UA8Kikr1QD28g2fyWoSbj * fix: keep the runs chart date visible on sub-day ranges Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019UA8Kikr1QD28g2fyWoSbj --------- Co-authored-by: Claude Opus 5 (1M context) --- .../lib/components/ConcurrentJobsChart.svelte | 3 +- frontend/src/lib/components/RunChart.svelte | 3 +- .../chartjs-wrappers/timeTicks.test.ts | 41 ++++++++++++++++ .../components/chartjs-wrappers/timeTicks.ts | 47 +++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/components/chartjs-wrappers/timeTicks.test.ts create mode 100644 frontend/src/lib/components/chartjs-wrappers/timeTicks.ts 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}` + } + } +}