Ending My Vinyl Buying Anxiety (pt IV)

July 12, 2026 | Seattle 🌤️

When I started this project the whole point was to never buy the same record twice. Mission accomplished ages ago. But somewhere along the way the question changed from “do I own this?” to “when did I actually last play this?”

I’ve got a wall of records I love and no idea which ones actually get spun versus which ones just look cool sitting there. So I decided the records should tell me themselves.

Enter the NFC pucks.

The dumb, perfect idea

You can buy little NFC stickers — NTAG215 pucks — for basically nothing. Each one is a tiny chip a phone can read by tapping it. Slap one on the back of a sleeve, tap my phone, and boom: a listen gets logged.

No app to open. No form to fill out. Pull a record, tap the puck, drop the needle. That’s the whole interaction.

The tag itself is stupid simple — it just stores one URL:

https://records.manhart.io/listen/{nfc_uid}

Where nfc_uid is the tag’s hardware serial number. Android sees the URL on tap, opens it, and my site does the rest.

Reading tags in the browser

Here’s the part that made me way too excited: you can read NFC tags straight from Chrome on Android. No native app. The Web NFC API is real and it rules (sorry iPhone, you get nothing here).

Pairing a puck to a record happens right in the browser:

const reader = new NDEFReader();
await reader.scan();

reader.addEventListener("reading", ({ serialNumber }) => {
  const uid = normalizeNfcUid(serialNumber); // lowercase, strip colons
  pairTagToRecord(uid, releaseId);
});

I normalize the UID (lowercase, strip the colons the hardware loves to add) and validate it’s the right length before trusting it:

export const isValidNfcUid = (uid: string) =>
  /^([0-9a-f]{8}|[0-9a-f]{14}|[0-9a-f]{20})$/.test(uid);

If the tag’s fresh, I also write the listen URL onto it so a tap works from anywhere, not just my paired session. There’s a manual UID-paste fallback too, because Web NFC loves to flake and sometimes you just want to type the damn thing in.

The database side

Two new tables, both dead simple.

nfc_tags maps a physical puck to a record:

CREATE TABLE nfc_tags (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  nfc_uid     TEXT UNIQUE NOT NULL,
  release_id  INTEGER NOT NULL,
  created_at  TIMESTAMPTZ DEFAULT now()
);

And listens is the actual log — one row per spin:

CREATE TABLE listens (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  release_id   INTEGER NOT NULL,
  listened_at  TIMESTAMPTZ DEFAULT now(),
  source       TEXT DEFAULT 'nfc'
);

The source column matters more than it looks. Most listens come from a tag tap ('nfc'), but I can also add them by hand ('manual') for the times I forget to tap or I’m listening somewhere the pucks aren’t. Same log, honest about where each row came from.

What actually happens when you tap

The whole flow is embarrassingly short:

  1. Tap the puck → Android opens /listen/{uid}
  2. My ListenRedirect route looks up the tag by nfc_uid
  3. It inserts a listens row for that record
  4. It bounces you to the record’s detail page

That last step is a nice touch — tap a record, and your phone lands on that album’s page. Feels intentional even though it’s basically free.

The admin side (and an honest confession)

Obviously I don’t want randos pairing tags or logging fake listens on my site. So the pairing tools, the manual “log a listen” flow, and an admin dashboard all sit behind a password gate.

Here’s the confession: it’s not real auth. It’s a client-side password check against an env var, stashed in localStorage:

const GATE_PASSWORD = import.meta.env.VITE_ADMIN_PASSWORD;

function login(attempt: string) {
  if (attempt === GATE_PASSWORD) {
    localStorage.setItem("admin-authenticated", "true");
  }
}

This is a UI convenience, not authorization. Anyone determined could poke the Supabase tables directly — the row-level security is intentionally wide open because there’s no user system here. For a personal record wall that only I touch, that’s a totally fine trade. I’m just not going to pretend it’s Fort Knox.

The manual add is a floating + button (a FAB) that only shows up when I’m “logged in.” It opens a little dialog, I search for the record, pick a time, done.

The timezone rabbit hole

Here’s the bug that ate an evening: listens were logging on the wrong day.

I’m in Seattle. Postgres stores everything in UTC. So a record I played at 8pm Tuesday would sometimes bucket into Wednesday, because 8pm Pacific is already the next day in UTC. Infuriating.

The fix was to be religious about America/Los_Angeles everywhere. Client side, I build the manual-add time from local components instead of trusting new Date():

// prefill a datetime-local input with *local* wall-clock time
const toDateTimeLocalValue = (d: Date) => {
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
         `T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};

And server side, the daily-stats rollups bucket on the LA date, not the UTC one:

(listened_at AT TIME ZONE 'America/Los_Angeles')::date

Boring? Yes. But now “yesterday” means yesterday.

It maintains itself now too

Somewhere in here I also got the sync fully hands-off. The Discogs importer runs on a GitHub Actions cron every morning:

on:
  schedule:
    - cron: "0 17 * * *"   # 9am Pacific

And I wrapped it in Sentry Crons check-ins, so if the job silently dies I actually find out instead of discovering weeks later that my collection’s been frozen (which, uh, has happened). A separate weekly cron dumps the whole database to a backup, because losing my listen history would genuinely make me sad.

Where this leaves me

The collection isn’t a catalog anymore. It’s a log. Every tap is a little timestamp that says “this one, right now.” After a few weeks of tapping, I’ve got the beginnings of a real listening history — which of course means I immediately needed to see it.

Part V is all about that: turning a pile of timestamps into charts, a zoomable timeline you can scrub through, and the weirdly satisfying problem of what to do with all the dead air between spins. Stay tuned.

What was spinning while writing:

John Prine by John Prine