2 Commits
Author SHA1 Message Date
Vincent van der Wal 1d7d9d3a53 scroll behaviour 2026-07-25 14:14:49 +02:00
Vincent van der Wal c7924bb0ae visual updates 2026-07-25 13:56:16 +02:00
13 changed files with 64 additions and 25 deletions
View File
View File
View File
View File
View File
View File
View File
View File
View File
+3
View File
@@ -23,3 +23,6 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
AGENTS.md AGENTS.md
# Local agent/editor config
.claude/
+1 -1
View File
@@ -6,7 +6,7 @@
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'; import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
export const buttonVariants = tv({ export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs', default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
@@ -57,8 +57,10 @@
// defer to after layout so the measured positions and scroll width are final // defer to after layout so the measured positions and scroll width are final
requestAnimationFrame(() => { requestAnimationFrame(() => {
// scroll so the first day card sits exactly at the content edge (aligned // scroll so the first day card sits exactly at the content edge (aligned
// with the page hero), leaving the "past days" button fully off to the left // with the page hero), leaving the "past days" button fully off to the
el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left; // left. The scroll's pl-3 keeps the lifted/scaled card from being clipped,
// so subtract it here to land the card on the content edge.
el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left - 12;
}); });
}); });
@@ -129,7 +131,7 @@
clipped, while the first card still lines up with the page content edge. --> clipped, while the first card still lines up with the page content edge. -->
<div <div
bind:this={scrollEl} bind:this={scrollEl}
class="day-scroll flex min-w-0 flex-1 gap-2 overflow-x-auto pt-3 pb-3 md:pt-5 md:pb-11" class="day-scroll -ml-3 flex min-w-0 flex-1 gap-2 overflow-x-auto pt-3 pb-3 pl-3 md:pt-5 md:pb-11"
class:scrolling class:scrolling
onscroll={onScroll} onscroll={onScroll}
> >
@@ -281,32 +281,66 @@
: null : null
); );
// ─── Start the scroll at 06:00 on day change (mobile) ─────────────────────── // ─── Auto-scroll the (overflowing) table on day / interval change ────────────
// When the table overflows sideways, scroll so the 06:00 column is the leftmost // • Day change → today lands on the current-hour cell, other days on 06:00.
// visible one — the early hours are rarely of interest and this keeps the // • Interval change (3h ↔ 1h) → keep whatever cell is currently on the left.
// daytime in view on every day switch.
let tableScrollEl = $state<HTMLDivElement>(); let tableScrollEl = $state<HTMLDivElement>();
let autoScrolledDay = -1; let autoScrolledDay = -1;
let lastInterval = 0;
let viewLeftHour = 6; // zoned hour at the left edge (plain, non-reactive)
function colWidthPx(): number {
return (tableWidth - headerColWidth) / cellData.length;
}
function scrollToIdx(idx: number) {
const el = tableScrollEl;
if (!el) return;
if (el.scrollWidth <= el.clientWidth + 4) {
el.scrollLeft = 0;
return;
}
el.scrollLeft = idx <= 0 ? 0 : Math.max(0, idx * colWidthPx());
}
// track the left-most visible cell's hour so an interval switch can restore it
function onTableScroll() {
const el = tableScrollEl;
if (!el || cellData.length === 0 || tableWidth === 0) return;
const idx = Math.min(
cellData.length - 1,
Math.max(0, Math.round(el.scrollLeft / colWidthPx()))
);
viewLeftHour = getZonedHour(cellData[idx].date, data.timezone);
}
$effect(() => { $effect(() => {
const day = selectedDay.getTime(); // re-run on day switch const day = selectedDay.getTime();
const interval = is3h ? 3 : 1;
const el = tableScrollEl; const el = tableScrollEl;
if (!el || cellData.length === 0 || tableWidth === 0 || headerColWidth === 0) return; if (!el || cellData.length === 0 || tableWidth === 0 || headerColWidth === 0) return;
if (autoScrolledDay === day) return; const dayChanged = autoScrolledDay !== day;
const intervalChanged = lastInterval !== interval;
if (!dayChanged && !intervalChanged) return;
autoScrolledDay = day; autoScrolledDay = day;
requestAnimationFrame(() => { lastInterval = interval;
// only when it actually overflows (i.e. mobile / narrow)
if (el.scrollWidth <= el.clientWidth + 4) { let targetIdx: number;
el.scrollLeft = 0; if (dayChanged) {
return; // today → the cell whose block contains "now"; otherwise → 06:00
} const nowMs = today.getTime();
const sixIdx = cellData.findIndex((c) => getZonedHour(c.date, data.timezone) >= 6); const stepMs = interval * 3600 * 1000;
if (sixIdx <= 0) { const nowIdx = cellData.findIndex(
el.scrollLeft = 0; (c) => nowMs >= c.date.getTime() && nowMs < c.date.getTime() + stepMs
return; );
} targetIdx =
const colWidth = (tableWidth - headerColWidth) / cellData.length; nowIdx >= 0 ? nowIdx : cellData.findIndex((c) => getZonedHour(c.date, data.timezone) >= 6);
el.scrollLeft = Math.max(0, sixIdx * colWidth); viewLeftHour = targetIdx >= 0 ? getZonedHour(cellData[targetIdx].date, data.timezone) : 6;
}); } else {
// interval change only: keep the same cell on the left
targetIdx = cellData.findIndex((c) => getZonedHour(c.date, data.timezone) >= viewLeftHour);
}
requestAnimationFrame(() => scrollToIdx(targetIdx));
}); });
// ─── Chart-hover mirror ───────────────────────────────────────────────────── // ─── Chart-hover mirror ─────────────────────────────────────────────────────
@@ -415,7 +449,7 @@
<!-- Below the min-width the table scrolls sideways instead of squeezing; <!-- Below the min-width the table scrolls sideways instead of squeezing;
1h needs far more room than 3h (24 vs 8 columns) --> 1h needs far more room than 3h (24 vs 8 columns) -->
<div class="overflow-x-auto" bind:this={tableScrollEl}> <div class="overflow-x-auto" bind:this={tableScrollEl} onscroll={onTableScroll}>
<div <div
class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}" class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}"
bind:clientWidth={tableWidth} bind:clientWidth={tableWidth}