From 6febd1f61816ba4d3697f6b30125fbead36d95c4 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Tue, 26 May 2026 18:07:07 +0100 Subject: [PATCH 1/3] Switch activity graph to canvas --- .../pages/Home/signedIn/ActivityGraph.svelte | 134 +++++++++++++++--- 1 file changed, 115 insertions(+), 19 deletions(-) diff --git a/app/javascript/pages/Home/signedIn/ActivityGraph.svelte b/app/javascript/pages/Home/signedIn/ActivityGraph.svelte index 1f0bf39b0..bbe02eb78 100644 --- a/app/javascript/pages/Home/signedIn/ActivityGraph.svelte +++ b/app/javascript/pages/Home/signedIn/ActivityGraph.svelte @@ -1,5 +1,5 @@
-
- {#each dates as date} - {@const seconds = data.duration_by_date[date] ?? 0} - -   - - {/each} -
+ (hoveredDate = null)} + onclick={onClick} + onkeydown={onKeydown} + >

Calculated in {data.timezone_label} From c5ff65f84b71864342d23285a00fddac5ff38047 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Tue, 26 May 2026 18:32:40 +0100 Subject: [PATCH 2/3] Switch to CSS vars --- app/assets/tailwind/main.css | 24 ++++----------- .../pages/Home/signedIn/ActivityGraph.svelte | 30 ++++++++----------- 2 files changed, 19 insertions(+), 35 deletions(-) diff --git a/app/assets/tailwind/main.css b/app/assets/tailwind/main.css index 1faa877e9..db172ebe4 100644 --- a/app/assets/tailwind/main.css +++ b/app/assets/tailwind/main.css @@ -247,40 +247,28 @@ select { animation: spin 1s linear infinite; } -.activity-cell--0 { - background-color: color-mix( +:root { + --activity-cell-0: color-mix( in oklab, var(--color-surface-content) 12%, var(--color-surface) ); -} - -.activity-cell--1 { - background-color: color-mix( + --activity-cell-1: color-mix( in oklab, var(--color-success) 35%, var(--color-surface) ); -} - -.activity-cell--2 { - background-color: color-mix( + --activity-cell-2: color-mix( in oklab, var(--color-success) 50%, var(--color-surface) ); -} - -.activity-cell--3 { - background-color: color-mix( + --activity-cell-3: color-mix( in oklab, var(--color-success) 68%, var(--color-surface) ); -} - -.activity-cell--4 { - background-color: color-mix( + --activity-cell-4: color-mix( in oklab, var(--color-success) 85%, var(--color-surface) diff --git a/app/javascript/pages/Home/signedIn/ActivityGraph.svelte b/app/javascript/pages/Home/signedIn/ActivityGraph.svelte index bbe02eb78..ef03bd9db 100644 --- a/app/javascript/pages/Home/signedIn/ActivityGraph.svelte +++ b/app/javascript/pages/Home/signedIn/ActivityGraph.svelte @@ -38,29 +38,25 @@ return out; } - function intensityClass(seconds: number, busiest: number): string { - if (seconds < 60) return "activity-cell--0"; + function intensityLevel(seconds: number, busiest: number): number { + if (seconds < 60) return 0; const r = seconds / busiest; - if (r >= 0.8) return "activity-cell--4"; - if (r >= 0.5) return "activity-cell--3"; - if (r >= 0.2) return "activity-cell--2"; - return "activity-cell--1"; + if (r >= 0.8) return 4; + if (r >= 0.5) return 3; + if (r >= 0.2) return 2; + return 1; } - function activityColors(): Record { - const probe = document.createElement("div"); - probe.style.position = "absolute"; - probe.style.visibility = "hidden"; - document.body.appendChild(probe); + function activityColors(): string[] { + const styles = getComputedStyle(canvas); + const colors: string[] = []; - const colors: Record = {}; for (let level = 0; level <= 4; level++) { - probe.className = `activity-cell--${level}`; - colors[`activity-cell--${level}`] = - getComputedStyle(probe).backgroundColor; + colors[level] = styles + .getPropertyValue(`--activity-cell-${level}`) + .trim(); } - probe.remove(); return colors; } @@ -85,7 +81,7 @@ const column = Math.floor(index / rows); const row = index % rows; context.fillStyle = - colors[intensityClass(seconds, data.busiest_day_seconds)]; + colors[intensityLevel(seconds, data.busiest_day_seconds)]; context.beginPath(); context.roundRect( column * (cellSize + cellGap), From 13856a6e532d636eaeee9267ede08c33bedefcf9 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Tue, 26 May 2026 18:45:52 +0100 Subject: [PATCH 3/3] Fix activity graph keyboard navigation --- .../pages/Home/signedIn/ActivityGraph.svelte | 80 +++++++++++++++---- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/app/javascript/pages/Home/signedIn/ActivityGraph.svelte b/app/javascript/pages/Home/signedIn/ActivityGraph.svelte index ef03bd9db..e4f98e68d 100644 --- a/app/javascript/pages/Home/signedIn/ActivityGraph.svelte +++ b/app/javascript/pages/Home/signedIn/ActivityGraph.svelte @@ -13,17 +13,25 @@ let canvas: HTMLCanvasElement; let hoveredDate = $state(null); + let focusedIndex = $state(null); + let canvasHasFocus = $state(false); const dates = $derived(buildDateRange(data.start_date, data.end_date)); const columns = $derived(Math.ceil(dates.length / rows)); - const graphWidth = $derived(columns * cellSize + (columns - 1) * cellGap); + const graphWidth = $derived( + columns > 0 ? columns * cellSize + (columns - 1) * cellGap : 0, + ); const graphHeight = rows * cellSize + (rows - 1) * cellGap; + const focusedDate = $derived( + focusedIndex === null ? null : (dates[focusedIndex] ?? null), + ); + const activeDate = $derived(hoveredDate ?? focusedDate); const hoveredSeconds = $derived( - hoveredDate ? (data.duration_by_date[hoveredDate] ?? 0) : 0, + activeDate ? (data.duration_by_date[activeDate] ?? 0) : 0, ); const hoveredTitle = $derived( - hoveredDate - ? `you hacked for ${durationInWords(hoveredSeconds)} on ${hoveredDate}` + activeDate + ? `you hacked for ${durationInWords(hoveredSeconds)} on ${activeDate}` : "Daily coding activity graph", ); @@ -76,32 +84,50 @@ context.clearRect(0, 0, graphWidth, graphHeight); const colors = activityColors(); + const focusColor = getComputedStyle(canvas) + .getPropertyValue("--color-surface-content") + .trim(); for (const [index, date] of dates.entries()) { const seconds = data.duration_by_date[date] ?? 0; const column = Math.floor(index / rows); const row = index % rows; + const x = column * (cellSize + cellGap); + const y = row * (cellSize + cellGap); context.fillStyle = colors[intensityLevel(seconds, data.busiest_day_seconds)]; context.beginPath(); - context.roundRect( - column * (cellSize + cellGap), - row * (cellSize + cellGap), - cellSize, - cellSize, - 2, - ); + context.roundRect(x, y, cellSize, cellSize, 2); context.fill(); + + if (canvasHasFocus && index === focusedIndex) { + context.strokeStyle = focusColor; + context.lineWidth = 2; + context.stroke(); + } } } + function clampIndex(index: number): number { + return Math.max(0, Math.min(index, dates.length - 1)); + } + + function onFocus() { + canvasHasFocus = true; + focusedIndex ??= 0; + } + + function onBlur() { + canvasHasFocus = false; + } + function dateFromPointer(event: MouseEvent): string | null { const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const column = Math.floor(x / (cellSize + cellGap)); const row = Math.floor(y / (cellSize + cellGap)); - const inCellX = x % (cellSize + cellGap) <= cellSize; - const inCellY = y % (cellSize + cellGap) <= cellSize; + const inCellX = x % (cellSize + cellGap) < cellSize; + const inCellY = y % (cellSize + cellGap) < cellSize; const index = column * rows + row; return inCellX && inCellY && row < rows && index < dates.length @@ -119,9 +145,31 @@ } function onKeydown(event: KeyboardEvent) { - if ((event.key === "Enter" || event.key === " ") && hoveredDate) { + if (!dates.length) return; + + focusedIndex ??= 0; + + if (event.key === "ArrowRight") { + event.preventDefault(); + focusedIndex = clampIndex(focusedIndex + rows); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + focusedIndex = clampIndex(focusedIndex - rows); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + focusedIndex = clampIndex(focusedIndex + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + focusedIndex = clampIndex(focusedIndex - 1); + } else if (event.key === "Home") { + event.preventDefault(); + focusedIndex = 0; + } else if (event.key === "End") { + event.preventDefault(); + focusedIndex = dates.length - 1; + } else if ((event.key === "Enter" || event.key === " ") && focusedDate) { event.preventDefault(); - router.visit(`?date=${hoveredDate}`); + router.visit(`?date=${focusedDate}`); } } @@ -136,6 +184,8 @@ title={hoveredTitle} role="button" tabindex="0" + onfocus={onFocus} + onblur={onBlur} onpointermove={onPointerMove} onpointerleave={() => (hoveredDate = null)} onclick={onClick}