Ending My Vinyl Buying Anxiety (pt V)
September 5, 2026 | Seattle 🌥️
Back in pt III I promised the next chapter would dig into “charts, filters, and maybe a few data viz experiments.” Then I got distracted sticking NFC pucks on everything (see pt IV). But those pucks were the whole point — they gave me data. Weeks of it. Every tap a timestamp.
A pile of timestamps is useless until you can see it. So this chapter is me finally making the data look like something.
Two timelines, because I couldn’t pick one
I built two completely different ways to look at the same listen history, and I’m keeping both because they answer different questions.
- The vertical feed — a scrolling spine of everything I’ve played and bought, newest at the top. Good for “what’s my recent life been like.”
- The horizontal day-chart — a zoomable canvas where each day is a track and each listen is a card placed at its real time of day. Good for “when do I actually listen to music.”
The vertical one is the default timeline view. The horizontal one lives at its own route (it’s /timeline-2, because naming is hard and I’m honest).
The horizontal chart is the fun one
Picture a calendar that runs sideways. Each row is a day. The x-axis is hours, midnight to midnight. Every listen is a little album-cover card dropped at the exact minute I played it.
The trick that makes the whole thing work is that the horizontal scale is a single CSS variable:
--hour-width: 300px; /* one day = 24 * hour-width */
Everything — card positions, the hour axis, the “now” marker — is a percentage of that. Change one number and the entire chart breathes. Which meant zoom became almost free.
Zoom by slider, trackpad, AND pinch
I wanted zoom to feel native no matter how you’re poking at it, so there are three input paths and they all funnel into one zoomTo():
- A slider in the header — logarithmic, so the low end has fine control and the high end goes wide fast.
- Ctrl/Cmd + wheel (trackpad pinch on a Mac shows up as this) — accumulates scroll delta and zooms toward the pointer.
- Two-finger touch pinch on a phone — measures the finger spread and scales from the midpoint.
The one detail I’m proud of: whatever you zoom on, the time under your cursor stays put. Zoom into 3pm and 3pm doesn’t run away from you — it re-anchors the scroll position in a requestAnimationFrame right after the resize:
function zoomTo(next, anchorClientX) {
const timeUnderAnchor = timeAt(anchorClientX);
setHourWidth(next);
requestAnimationFrame(() => {
scroller.scrollLeft = scrollLeftFor(timeUnderAnchor, anchorClientX);
});
}
For the touch version I had to explicitly tell the browser to not handle pinch itself (touch-action: pan-x pan-y) so the gesture would land in my code instead of zooming the whole page. Small thing, hours of my life.
“Close the Gaps” — my favorite toggle
Here’s a problem I didn’t see coming. Plotted at real time-of-day, my listens are mostly… empty space. I listen in bursts — a couple albums after work, a chunk on a Sunday — and the rest of the day is dead air. On the chart that’s a lot of lonely cards floating in a sea of nothing.
So I added a toggle: Close the Gaps. Flip it on and every listen slides left and packs together back-to-back, starting from the left edge, ignoring the actual clock. Suddenly a day of listening reads as a solid block instead of scattered confetti.
The positions are precomputed for both modes. Each listen block knows its real spot and its packed spot:
// real position
block.startMin = minutesSinceMidnight(listen);
// packed position — stack them with no gaps
block.packedStartMin = packedCursor;
packedCursor += (block.endMin - block.startMin);
block.packedEndMin = packedCursor;
The card just switches which pair it reads based on the toggle. And flipping it animates — the viewport slides with a hand-rolled ease-in-out-cubic over 420ms, remembers where you were, and puts you back when you toggle off. It honors prefers-reduced-motion too, so if you’ve asked the OS to chill, it just jumps.
Click a card, get the story
Every card is a button. Click one and a detail drawer slides in with the cover, the artist, the time span I played it, its all-time listen count, and the full tracklist. Both timelines have their own version of this drawer — the vertical feed’s is a fixed panel pinned to the right that the timeline scrolls behind, closable with Escape like a civilized piece of software.
The home page got charts
The timelines are for scrubbing. The home page is for the numbers.
There’s a proper bar chart now (recharts) with a range toggle — week / month / year — and arrows to step backward and forward through time:
const [offset, setOffset] = useState(0); // 0 = current period
<button onClick={() => setOffset(o => o + 1)}><ChevronLeft /></button>
<span>{periodLabel}</span>
<button onClick={() => setOffset(o => Math.max(0, o - 1))}
disabled={offset === 0}><ChevronRight /></button>
You can’t step into the future (the right arrow disables at the current period), and the month/year math rebuilds from the first of the period so it doesn’t do that classic “Jan 31 → March” rollover garbage. It’s paired with stat cards that compare the current frame to the previous one and surface my top artists for the window.
The tiny detail I love: sticky day headings
The activity feed groups everything by day. As you scroll, each day’s heading pins to the top under the header until its group scrolls past — the next day’s heading then shoves it up and takes over. It’s the one iOS-contacts trick everyone loves, and it’s pure CSS:
.dateHeading {
position: sticky;
top: var(--feed-header-height, 0px);
z-index: 1;
background: var(--surface000);
}
No scroll listener, no JS, no jank. Just position: sticky offset by the header height. Chef’s kiss.
Making sure I actually know when it breaks
Somewhere in here I also fully wired up Sentry — tracing, session replay, structured logs, the works. Partly because the CRON had silently died on me before (RIP, weeks of missing data), partly because it’s genuinely fun to watch a replay of myself fumbling around my own site. If something breaks now, I know before my data does.
Where this leaves me
I can finally see how I listen. Bursts after work, deep Sunday sessions, the occasional 2am “one more record” mistake. The pucks made the data; this chapter made it legible.
But looking at it every day, one thing started bugging me: the app itself was kind of ugly. Functional, sure. Pretty? No. Part VI is the redesign — where every record gets its own color world, I delete dark mode entirely (yeah, I said what I said), and I fall down a polish rabbit hole so deep I’m still not sure I’ve climbed out.
What was spinning while writing:
Random Access Memories by Daft Punk