Day 18 — Two Bios, Two Lists, and 1,236 Stranded Photos

Day 18 — Two Bios, Two Lists, and 1,236 Stranded Photos

My friend's food blog — the WordPress-to-Next.js rebuild I've been doing with Claude Code, an AI coding agent that works from my terminal — spent today making the case that the interesting part of building this way isn't the code the model writes. It's the shape you make it work in. I switched models four times by hand, matching each to its job: light ones at automatic effort for copy edits, a heavyweight at maximum effort for the design session, back down again for mechanical cleanup. I sent a search subagent ahead of me to find things. And I made it write a design document and a fourteen-task plan before a single line of the day's real feature existed, then work those tasks one at a time — failing test, code, full test-lint-types gate, commit — fourteen times over. That is what this series means by "vibe engineering, not vibe coding": the model does the typing, the human keeps the judgment, the sequencing and the verification. What came out of it is a feature that didn't exist this morning. My friend teaches in-person classes at two cooking schools, and her workshops page was maintained by hand; it now syncs itself, verified against both schools' live systems with 28 of her real classes flowing onto the site. The best moment arrived before any code, when the recon found that both schools quietly publish JSON — the scraping problem everyone assumes they have simply wasn't one. The rest of the day was archaeology, and it escalated. A one-line request to fix an author bio uncovered two of them, drifted apart because neither was reading the CMS. Then a single stray product photo in a recipe traced back to two lists that were supposed to describe the same images and hadn't agreed in months: 1,236 duplicates stranded across 62 recipes. The same mistake both times — one thing written twice — at six hundred times the scale.


Models & effort

Who did what in this session, and at what reasoning-effort setting — the receipts behind the workflow.

Task Model Effort
Locate both bio components + exact text (Explore subagent search) Sonnet 5 auto
Quick text-only bio swap (first pass) Sonnet 5 auto
Full bio swap — reuse shared AuthorBio component (photo, heading, link) + audit gate Fable 5 auto
CMS-editable sidebarBio field — schema + migration + component wiring + tests Opus 4.8 auto
Consolidated day14–17 into one post + verification playbook + HTML + LinkedIn draft Sonnet 5 / Opus 4.8 auto
Workshops-sync feature: live recon of both venues' sites + brainstorming + design doc Fable 5 max
Workshops-sync: 14-task TDD implementation plan (complete code + tests per task) Fable 5 max
Workshops-sync: inline execution of all 14 tasks + live end-to-end verification Opus 4.8 auto
Workshops-sync: live sync-progress indicator in admin (engine sink → status endpoint → polling button) Opus 4.8 auto
Workshops-sync: on-demand revalidation + discoverable approval (after a real prod sync surfaced the gaps) Opus 4.8 auto
Workshops-sync: card date-chip polish + root-caused and fixed the "cleared after deploy" static-page bug Sonnet 5 auto
Workshops-sync: wrongly declared a caching approach broken, then (prompted by "are you sure?") checked the docs and re-verified it works Sonnet 5 → Opus 4.8 auto
Workshops-sync: fixed Pantry booking links pointing at a 404 URL (wrong one of the API's two URLs) Opus 4.8 auto
Stray inline ad images: root-cause trace across two migration scripts + diagnostic scan of all 130 recipes Sonnet 5 auto
Stray inline ad images: fixed by merging the duplicated image list into one shared module; ran cleanup across 62 recipes, verified idempotent Sonnet 5 auto
Stray inline ad images, round 2: traced a user-spotted straggler to two more root causes (ASIN URL shape gap, WP filename-variant gap) Sonnet 5 auto
Stray inline ad images, round 2: widened ASIN matching, generalized strip matching to size-variant base filenames, backfilled 3 missing equipment thumbnails, re-ran cleanup across all 130 recipes Sonnet 5 auto
Commit, push, write this post Sonnet 5 / Opus 4.8 auto

TLDR

  • Recipe pages had their own hardcoded author bio that had drifted from the nicer homepage sidebar version — no photo, flatter copy, no "Read My Story" link.
  • First pass was a literal text swap: just the description sentence. Then came the real ask — "the full change, the image and everything" — which meant deleting the recipe-page block and reusing the shared AuthorBio component instead of hand-copying more JSX.
  • Turned up an architecture wrinkle along the way: neither bio was reading the CMS at all — both were hardcoded strings, which is why they'd drifted apart in the first place. Rather than leave it as a follow-up, I closed it in the same session: added a new admin-editable sidebarBio field to the my-story global and wired AuthorBio to read it, falling back to the existing copy when it's empty.
  • That second change was a real schema change, not a copy swap — new field, a Postgres migration (ADD COLUMN sidebar_bio), component wiring, and tests for both the CMS-value and fallback paths.
  • Full audit gate throughout: 218 tests after the bio unification, 221 after the CMS field (added 3), lint clean (0 errors), tsc --noEmit clean.
  • Committed and pushed to main across several commits. No auto-deploy in this pipeline — the server still needs a manual docker compose pull to serve it, and the new migration auto-applies on the next prod boot.
  • Model changed hands several times, toggled by hand via /model (Sonnet 5 → Fable 5 → Sonnet 5 → Opus 4.8 → Fable 5) — a small illustration of matching the model and effort to the task: light models on auto for copy work, a heavyweight at max effort for the day's design session.
  • Also consolidated the day14–17 posts into one "how to verify AI work" narrative (with an 8-point playbook section and a LinkedIn draft to promote it).
  • Ended the day building the next feature — auto-syncing her real-world cooking classes from the two schools where she teaches onto the site's workshops page. Recon-first: instead of assuming we'd need scrapers, we fetched the actual sites and found both have public JSON APIs. Spec → 14-task TDD plan → all 14 tasks executed inline (each: failing test → code → audit gate → commit). Then verified end-to-end against the live venue APIs on the dev DB: 28 real classes pulled, confidence gating and the alternate-name-spelling text match both confirmed on real data. 279 tests green; feature committed and pushed, live on the next server pull.

What I set out to do

Small ask, on paper: "the posts have a bio, the main page has a better one, replace the post bio with the main page bio." A copy-paste job. It didn't stay one.


Finding the two bios

Rather than guess, I sent an Explore subagent to find both bios and report back exactly what each one rendered and where the text lived — hardcoded string, or pulled from Payload. That came back with the actual shape of the problem:

Recipe page (src/app/(frontend)/recipes/[slug]/page.tsx) — a standalone block: bold name, one flat sentence ("Baker, culinary instructor, and the person behind Her Food Blog."), then social links. No image, no CMS call at all.

Homepage sidebar (src/components/sidebar/AuthorBio.tsx) — a real component: a headshot image fetched from Payload's my-story global via getMyStory(), a first-name greeting heading, a better bio sentence, and a "Read My Story →" link to the full page.

Two independently written blocks, never reconciled, slowly saying different things about the same baker.

AI win: dispatching the lookup instead of grepping by hand meant I got a structured answer — file, line numbers, exact text, CMS-or-not for each — in one shot, which made the next decision (swap text vs. reuse the component) obvious rather than something I had to piece together myself.


The quick fix, then the real fix

First pass was the literal ask: swap the one sentence. Done in a single Edit, tests still green, looked reasonable.

Then: "tell me more about the static string part, and I need the full change — the image and everything — in the blog post as well." That's a different bar. A sentence swap doesn't get you the photo, the heading, or the "Read My Story" link — it just makes the words match while the layout stays thinner than the sidebar version.

So the recipe-page block got deleted and replaced with the actual AuthorBio component:

{/* Author bio */}
<div className="mb-10 p-5 border border-border rounded-[8px] bg-card">
  <AuthorBio />
  <div className="flex gap-3 mt-3">
    {/* Facebook / Pinterest / Instagram / YouTube — recipe-page-only, kept */}
  </div>
</div>

Same component, same photo, same heading, same "Read My Story" link, now showing up on every recipe page too. The social share links are recipe-page-specific, so those stayed, just re-indented to sit under the shared component instead of inside a hand-rolled duplicate.

One thing to watch for with this kind of "just reuse the component" edit: it's easy to leave the old block's closing tags and indentation half-migrated. I ran back through the diff by eye afterward to confirm the JSX nesting actually matched — no orphaned </div> left behind, no map still indented for a wrapper that no longer exists.


The wrinkle underneath: neither bio touches the CMS

The my-story Payload global — the one behind the actual /my-story page — already defines a bio rich-text field, and it's used on the My Story page itself. But the sidebar bio isn't reading that, or anything else from the CMS. The heading and the sentence are both strings written directly into the AuthorBio component; only the headshot image comes from Payload. That's how the two bios drifted apart in the first place: hardcoded copy in two different files, neither aware of the other, with no single admin-editable source keeping them honest.

I flagged this as a follow-up at first — wiring it up is a schema change, not a copy swap, so it didn't belong inside a "make the wording match" request. But then it became the request.

Closing the gap: a CMS-editable bio field

The decision was which CMS field the sidebar bio should read. The existing bio field is rich text — paragraphs, formatting, the works — which is right for the full My Story page but heavier than a two-sentence sidebar blurb wants. So instead of overloading that field, I added a dedicated one: a short sidebarBio textarea on the my-story global, with an admin hint describing exactly where it shows up.

{
  name: 'sidebarBio',
  type: 'textarea',
  admin: {
    description:
      'Short bio shown in the sidebar and recipe-page author card. One or two sentences.',
  },
}

Then AuthorBio reads it, with the old hardcoded copy demoted to a fallback so nothing looks broken before my friend fills the field in:

const bio = story?.sidebarBio?.trim() || DEFAULT_SIDEBAR_BIO

The part that makes this a real change rather than a copy edit is the database. On this project, schema reaches production through committed migrations, not by pushing from the running app — the production container has NODE_ENV=production, which turns the auto-push off on purpose. So a new field isn't done until there's a migration file in the repo. After a build, migrate:create add_sidebar_bio generated a clean one-line change:

ALTER TABLE "my_story" ADD COLUMN "sidebar_bio" varchar;

That commits alongside the code, and production applies it automatically on the next boot. I also wrote tests for both branches — the CMS value rendering when set, and the fallback kicking in when the field is empty or whitespace — which took the suite from 218 to 221, all green.

The upshot: my friend can now open the admin, edit one field, and change her bio everywhere it appears — no code, no deploy, no second copy to keep in sync. Which is what the CMS was for all along.


Shipping it — and a reminder that "pushed" isn't "deployed"

Full audit gate before each commit, per the house rule — it runs before any code work session and again before anything gets pushed. After the CMS field, the suite reads:

Test Files  45 passed (45)
     Tests  221 passed (221)

Lint: 0 errors, warnings all pre-existing and untouched. tsc --noEmit: clean.

Committed and pushed to main across several commits. That's as far as any of it goes on its own — CI builds the new Docker image, but there's no auto-deploy in this pipeline. The server has to run docker compose pull app && docker compose up -d --force-recreate app by hand before dev.herfoodblog.com serves the change, and on that boot the new migration applies itself. This came up directly mid-session: I was asked why the old bio was still live after I'd pushed, and the answer is the gap between "pushed to main" and "running on the server." Worth restating, because it's an easy assumption to make that a green push equals a live deploy — here it never does.


Blog housekeeping, briefly

Between the code work, the build journal itself got some attention: days 14–17 — four sessions that were really one story about AI confidently claiming things worked when they didn't — got merged into a single consolidated post, retitled a few times until it earned its LinkedIn-bait name ("4 Times My AI Said 'Fixed' and It Wasn't: How Verifying Its Work Caught Every One"), and grew an 8-point "how to verify AI work" playbook distilled from what those four days actually taught. Plus an HTML export and a LinkedIn draft to go with it. Meta-work, but the series is half the point of this project.


Designing the next feature: her classes, on her site

My friend teaches in-person baking classes at two Seattle-area cooking schools. Each school lists her classes on its own site, its own way — and her blog's workshops page knows nothing about any of it. The ask: once or twice a week, pull what she's actually teaching from both schools, show the classes as cards on her workshops page linking out to the schools' booking pages, and — because automated matching against someone else's website is never fully trustworthy — give her a weekly audit flow in the admin where she validates what the sync found and can add new search terms if something got missed.

This one we deliberately did not start coding. It's a real feature — external data ingestion, a review workflow, schema changes, UI — so it went through the full brainstorming gate: recon first, then design questions one at a time, then a committed spec, then a 14-task implementation plan where every task carries its own failing test, implementation code, audit gate, and commit. Only after all of that did the first line of feature code get written.

The recon changed the whole shape of the problem. The naive plan — and my starting assumption — was HTML scraping: parse her bio page on school #1, crawl 150 class pages on school #2 looking for her name. Before designing any of that, we fetched the actual pages and read what was really there:

  • School #1's bio page contains no class data at all. The "Upcoming Events" section is a client-side calendar component — scraping the HTML would have returned nothing, forever, silently. But the component fetches a public JSON API, and the bio page carries her team ID as a component attribute. One GET with her team ID and a date range returns exactly her classes: titles, start/end datetimes, booking URLs, tags, even seats remaining. No scraping. Structured data the site itself uses.
  • School #2 turned out to be WordPress with a public events API listing all ~130 upcoming class dates. The instructor isn't in the API's list response — but the site tags each class page with its chef, her tag exists, and the events API filters by it. Ten upcoming classes of hers, one API call.
  • And the detail that justified the whole configurability requirement: the school's chef tag spells her name differently than the "Chef [her name]" text in the class descriptions. Two spellings of the same person, on the same site, in the same week. If we'd built matching around one hardcoded string, it would have quietly missed classes. Configurable search text wasn't paranoia; the evidence for it was sitting in their HTML before we wrote a line.

So the design that came out of the Q&A: a weekly in-process job (Payload's built-in job queue — no new infrastructure) plus a "Sync now" admin button; confidence-based publishing — classes found via structured signals (her team ID, her chef tag) go live immediately, classes found only by fuzzy text search wait for her approval; everything lands in the existing workshops collection as one document per class date, with the sync owning volatile fields (dates, prices, seat counts) while anything she hand-edits (title, description, image) sticks; card images auto-extracted from the schools' class pages into our own storage with her override winning; past classes auto-age into the existing "Past" section, capped; a cancelled class gets marked "removed from venue," never hard-deleted; and an email digest that only fires when something actually needs her eyes.

The vibe-engineering note here is that the best design decision of the feature — "don't scrape, use the APIs hiding under both sites" — didn't come from the model knowing things or me guessing. It came from the same discipline as the day14–17 debugging: fetch the real thing and read it before committing to a theory about it. Recon before architecture, the same way it's evidence before fixes.

Building it, task by task

Then the execution started — inline, one plan task at a time, each with its own TDD cycle: write the failing test, watch it fail, implement, watch it pass, run the full audit gate, commit. Progress so far (all committed on main, each with tests green):

  1. Schema — ten sync fields on the workshops collection (source, externalId, reviewStatus, removedFromSource, availableCapacity, …) plus a workshops-sync settings global holding the team ID, chef-tag slugs, and search strings. One additive Postgres migration, generated and committed; nothing touches prod until a deploy. Suite 221 → 229.

  2. Domain types + fixtures — the shared SyncedClass/VenueSyncResult/SyncConfig types, and six fixtures captured verbatim from the live recon (both venues' API JSON and three class-page HTML variants: tagged, text-only, and not-hers). Every unit test runs against these — no test ever hits the network.

  3. Polite HTTP util — a fetch wrapper with the honest User-Agent, a 15-second timeout, and non-2xx-throws-with-URL, so a venue being down produces a clear error instead of a hang.

  4. Pantry client — one API call filtered by her team ID, mapped to our shape; every match is structured (auto-publish), timezone offsets converted to UTC, fetch failure returned as an error rather than thrown.

  5. Whisk client — the three-tier matcher: her chef tag (structured, and it skips fetching those class pages entirely), then a cat_-marker check and a configurable text search on the remaining pages (fuzzy → pending). It decodes WordPress HTML entities, parses "Free – $165.00" down to "$165", converts naive Los-Angeles timestamps to UTC across both PDT and PST, and — the part I care about most — degrades to the text tiers with a warning if the tag lookup fails, instead of silently returning nothing.

  6. Image extraction — pull the food photo off each venue's class page (Pantry's imgix hero, Whisk's first content upload, skipping logos) and download it into our own R2 media library, so cards don't hotlink someone else's CDN.

  7. The sync engine — the piece that ties the clients to the database: upsert one doc per class-date keyed on source + externalId, with the field-ownership rules (volatile fields refresh every run; title/image/description are create-only so her edits survive), pending→auto-approved upgrades that never touch a human's approve/reject, aging past classes into the Past section, marking vanished future classes as removed — and the empty-feed guard that refuses to mass-unpublish when a venue's API simply hiccups and returns nothing.

  8. Email digest — one summary email, only when a run actually needs her eyes (new, pending, removed, missing images, or errors), reusing the existing SMTP setup.

  9. The authed sync endpointPOST /api/sync-classes, admin-session-gated, with a 15-minute stale-safe lock so the button and the cron can't collide.

  10. The weekly job — Payload's built-in job queue, Mondays at 9 AM Seattle, calling the same engine. No external scheduler, no new infrastructure.

  11. The "Sync now" admin button — a React component injected into the settings global (with the importMap regenerated and committed, the gotcha this project has been bitten by before).

  12. Visibility query + grouping — the public page shows manual classes plus structured/approved synced ones (pending stays hidden), grouped at render time so four sourdough dates become one card.

  13. The card grid — a new ClassCard with venue badge, next date big, other dates as tappable chips, live seat counts, sold-out states, an outbound "Book at …" link, and per-date Event JSON-LD for Google rich results.

  14. Docs — a topic guide, a CLAUDE.md row, a README section.

The verification that mattered

The unit tests all pass — 279 of them, lint clean, types clean, production build green — but the check I actually trust is different. Partway through, with the engine and admin button in place, I ran the whole pipeline once against the real, live venue APIs on the dev database. It pulled 28 real class-dates, and every part of the design showed up in the output:

  • The Pantry classes came back with live seat counts — including one genuinely sold-out class at zero.
  • The Whisk tag tier auto-approved her tagged classes with prices parsed clean ($165, $140).
  • Three classes matched only by fuzzy text search and were correctly held as pending review — and one of those three matched only on the alternate spelling of her name (the school's chef tag spells it differently than the class descriptions do). The configurability requirement I'd argued for on the strength of two spellings sitting in their HTML turned out to catch a real class in her schedule that the tag alone would have missed.
  • Every one of the 28 got an image extracted. On the rendered page, the 28 dates collapsed into 10 cards, the pending classes were invisible to the public, and the JSON-LD, seat counts, and sold-out badges all came through.

That's the difference between "the tests pass" and "I watched it work against the actual thing." Both matter; only the second one would have caught the alternate-spelling class actually being in her schedule.

The feature is built, committed, and pushed. It reaches production the next time the server pulls the image — the migration (new columns, the settings global, and Payload's jobs tables) auto-applies on boot, and her first "Sync now" click populates her workshops page.

A progress bar, because a few minutes of "Syncing…" is a few minutes of doubt

One follow-up landed right after: a manual "Sync now" runs for a few minutes (the fuzzy tier politely fetches ~130 class pages at one per second), and the button just said "Syncing…" the whole time — no way to tell if it was working or wedged. So I added a live progress readout.

The interesting constraint is that a sync is one long server request, so the browser can't "see inside" it. The fix has three small parts: the sync engine now takes an optional progress sink and reports its phase and counts as it goes (Fetching The Pantry → Saving 8/15 → Checking Whisk classes 45/130 → done); the endpoint writes that to a field on the settings record, throttled so 130 ticks don't hammer the database; and the button polls a lightweight status endpoint every 1.5 seconds and shows the current phase and count. A quiet nice-to-have falls out of doing it that way: because progress is stored and polled rather than streamed down the one long request, it stays visible even if that request times out behind the proxy — which, for a multi-minute request, is a real possibility, so the progress indicator doubles as resilience.

I verified it the same way as the rest — ran the real sync against the live APIs with the progress events logged, and watched the Whisk page-fetch counter climb 2/120 → 4/120 → 7/120 at the expected one-per-second pace. The engine's progress sink is optional, so the weekly cron (which has no UI to feed) and every unit test simply pass nothing and stay exactly as they were.

What the first real production sync taught me

Then my friend ran the sync on the live site — and reported she saw no classes and no way to approve anything. The sync log proved the data was there (28 classes created, then updated on a second run), so nothing was broken. But two real gaps showed up the moment a human used it, that no amount of my own testing had surfaced:

  • The public page didn't update. The /workshops page caches for an hour (ISR), so right after a sync it still served the old, empty page. Fine in the abstract; genuinely confusing when you've just clicked "Sync now" and see nothing. Worse, it meant approving a class wouldn't show for up to an hour — a broken feeling for a review workflow. Fix: an afterChange hook on the collection that calls revalidatePath('/workshops'), so a sync or an approval refreshes the page immediately. (It's wrapped to no-op safely when the weekly cron runs it outside a request scope.)
  • "Approve" wasn't discoverable. I'd built approval as a status dropdown in the sidebar of each pending class — obvious to me because I wrote it, invisible to someone looking for an Approve button. Fixes: the "Sync now" result now shows a "Review 3 pending classes →" link straight to the filtered list, and the collection and field descriptions now spell out that pending classes are hidden until you set them to Approved.

This is the part of the workflow I keep relearning: I can verify a feature exhaustively against the code and the data and still miss the two things that only surface when the actual user, with none of my context, tries to use it for real. The engineering was right; the usability had a gap that only a real person hitting it could reveal.

"The workshops get cleared" — a scarier bug report than the actual bug

Later the same thread, a much more alarming report: after redeploying, the workshops page looked empty again — not "takes an hour," actually gone. That phrasing — cleared — points at data loss, so before touching anything I went looking for evidence instead of a theory. Grepped every workshops-related migration for DROP TABLE or TRUNCATE; found two, both inside their migration's down() (rollback) function, which only runs if someone explicitly runs migrate:down — never on a normal deploy or boot. Checked the Docker image's boot command for any seed or reset script; none. So the database was never actually at risk. Good — but that meant something else was making 28 real records look absent.

The actual cause was in the build pipeline, not the sync. The /workshops page had revalidate = 3600 and no dynamic params, which Next.js treats as fully static: pre-render once, serve the cached HTML, regenerate in the background after an hour. The build log confirmed it — ○ /workshops … 1h, the static marker. The trap: that pre-render happens during next build, which runs inside the Docker image build, where there is no database at all. The project's own Payload helpers have a guard for exactly this (isBuildPhase, returning empty data so the build doesn't crash) — which meant every fresh image shipped with a workshops page baked in at zero classes, and served that empty snapshot for up to an hour after every deploy, no matter how much real data sat in the production database.

None of the site's other ISR pages had shown this because their content — recipes, mostly — gets populated once by a migration long before any deploy, so the build-time snapshot and reality happen to agree. Workshops is the first page whose content changes on its own, automatically, independent of when anyone deploys — which is exactly the condition that turns "prerendered once, refreshed hourly" from a performance optimization into a correctness bug. The fix was to stop trusting the build-time snapshot at all: render the page fresh on every request (force-dynamic) instead of caching it. Confirmed with the same build log — the marker changed from to ƒ, dynamic. For a low-traffic page, always-fresh costs nothing meaningful and removes the failure mode entirely.

The honest accounting: I built and shipped the sync feature, verified it against live data, and still missed that its page had an architectural assumption baked in from months earlier — "this content barely changes, static-with-hourly-refresh is fine" — that stopped being true the moment the content became automated. The bug wasn't in anything I wrote today. It was in a default I inherited and never had a reason to question until today's feature made the assumption false.

The correction I had to make on myself

There's a coda to that fix I'm not proud of but want to keep in, because it's the whole ethos of this project turned back on me. My friend asked a good follow-up: could we keep some caching — just not the kind that ships empty after a deploy? I tried it: keep the page dynamic (so the build never renders it empty) but wrap just the database query in a short-lived cache with a tag that a change to any workshop would invalidate. I wired it up, tested it once in the dev server, watched the change not appear after invalidating — and declared, with more confidence than I'd earned, that the framework's tag invalidation "doesn't reliably work" in this version. I reverted the whole thing.

Then my friend pushed back — are you sure? — and that one question was enough to make me do what I should have done first: read the actual documentation instead of trusting a single confounded test. The framework's newer invalidation is stale-while-revalidate by design: after you invalidate a tag, the very next request deliberately serves the stale cached copy while it refreshes in the background, and the request after that is fresh. My test had fetched exactly once after the change, seen the stale copy, and I'd read "stale" as "broken." I'd also only tested in dev mode, never a real production build. I hadn't found a framework bug; I'd written a bad test and generalized from it.

The retest was unambiguous: invalidate, fetch (stale), fetch (still warming), fetch — and there's the change. It works. So the caching went back in, correctly this time, with the invalidation verified across multiple requests instead of one. The lesson isn't subtle and it isn't new — it's the exact thing I'd spent this whole project preaching about the AI's output, now applicable to my own conclusion: a confident-sounding diagnosis from a single unverified observation is worth nothing until you've checked it against the source of truth. The model isn't the only thing in this loop that hallucinates a plausible story and states it too firmly. I did it too, and it took a human asking "are you sure?" to catch it.


The ad cleanup that wasn't actually a cleanup

Late in the day, a much smaller report landed: two photos in her recipe text were leftover Amazon ad images — a KitchenAid stand mixer photo sitting in the middle of the panettone post, a bread-tin photo sitting in the middle of the milk bread post. Reasonable question: didn't we already move all the ads to the bottom of the page?

We had — sort of. Tracing it back through the migration scripts turned up two separate things that both happened to be true:

  1. The content migration pulled these images in on purpose, the first time. patch-body-images.ts rebuilds each recipe's intro text from the original WordPress post body, node for node — including wp:media-text blocks, which is exactly what these two images were: narrow inline product call-outs ("mediaWidth":36) linking out to Amazon, e.g. <!-- wp:media-text {"mediaId":9059,"mediaLink":".../traditional-panettone/kitchenaid/",...} -->. Nothing wrong with the migration; it faithfully copied what was actually in the post.
  2. A later feature made those same images redundant, and the cleanup script never fully caught up. When the equipment-card grid shipped — the row of "what you'll need" product cards now at the bottom of every recipe — patch-equipment-thumbnails.ts uploaded the same product photos again as card thumbnails. A companion script, strip-amazon-logo.ts, was supposed to then strip the now-duplicate copies out of the inline text. It did — but only for a hand-typed list of six filenames (dal, fenugreek, currypowder, curry, coconutmilk, jackfruit) that someone had added one at a time as they were spotted. The equipment-card map itself had grown to 46 product images in the meantime. Nobody had gone back to reconcile the two.

A quick diagnostic script (built, run against the dev DB, then deleted — not something worth keeping around) confirmed the actual size of the gap: not two recipes, 62, each still carrying one or more duplicate inline product photos the equipment-card grid had already made redundant.

The fix wasn't just adding kitchenaid and pullman-tin to the allowlist — that would've left the exact same class of bug ready to happen again the next time the equipment map grew. Instead, the ASIN→image map moved into one shared file, src/scripts/lib/asin-to-image.ts, that both scripts import. patch-equipment-thumbnails.ts uses it to know what to upload as thumbnails; strip-amazon-logo.ts derives its strip-list from the same map instead of keeping its own copy. One list, two consumers — the two can't quietly diverge again, because there's only one of them now.

Running it against the dev DB stripped 1,236 duplicate upload nodes out of those 62 recipes, followed by the pipeline's mandatory repair-intro-uploads.ts pass (a documented gotcha: strip-amazon-logo.ts reads richText at depth:2 for convenience, which means it writes back populated upload objects instead of numeric IDs unless something depopulates them afterward — skip that step and the admin Lexical editor throws on those recipes). A dry-run re-scan afterward found zero remaining matches across all 130 recipes, which is the actual verification: not "I ran the fix" but "I confirmed there's nothing left for it to fix."

It's a small bug by itself — two photos a reader might not even register as ads. But the shape of it is the same lesson the workshops-sync detour taught twice this same day: a fix that only patches the two instances someone happened to notice isn't the same as a fix that closes the class of bug. The allowlist drifting was inevitable the moment two files were allowed to each keep their own copy of the same list; the actual fix was making that structurally impossible, not just topping up the list one more time.


Round two: the fix had a straggler, and the straggler had its own bug

Not long after that fix shipped to dev, a follow-up landed: probeTM-939x1024.jpg, a photo of a probe thermometer, was still sitting in the panettone post. Fair question — hadn't I just fixed this?

The honest answer required actually checking, not reassuring. Pulled the recipe's equipment list straight from Payload and found the probe thermometer's card thumbnail was null — it had never had one. That reframes the bug entirely: this wasn't a duplicate that round 1's script should have caught and didn't; it was an image that had never been mapped to an equipment card at all, so the inline copy was its only picture, not a leftover.

Tracing why turned up a second gap in the same neighborhood: the script that assigns equipment-card thumbnails pulls the Amazon ASIN out of each product's affiliate link with a regex that only recognized amazon.com/dp/ASIN links. The probe thermometer's link used Amazon's other common shape, amazon.com/gp/aw/d/ASIN — the regex silently returned nothing, so the product got no thumbnail, full stop, no error. A search across all 130 recipes' equipment data found two more products hitting the identical wall: a Dutch oven lid (B000LEXR0K, on 4 sourdough recipes) and a set of tart rings (B0885W36M3, on the lemon meringue tart) — three products, six recipes, quietly cardless since the equipment-card feature shipped.

Re-deriving the ASIN-to-image list straight from the original WordPress export (rather than trusting the hand-maintained one again) surfaced a third, smaller gap while I was in there: choux-pastry still had pipingbags.jpg inline, a plain filename with no dimensions — a different upload of the same piping-bag product photo than the one used for its equipment thumbnail (pipingbags-1024x1014.jpg). WordPress had generated the same source image under two different filenames across two different posts, and the strip script's exact-filename match couldn't see that they were the same picture.

The fix for both: widen the ASIN regex to accept either Amazon URL shape, and change the strip script's matching from "exact filename" to "exact match on the filename with its WordPress dimension suffix stripped off" — pipingbags-1024x1014.jpg and pipingbags.jpg both normalize to pipingbags.jpg and match; curry-909x1024.jpg normalizes to curry.jpg and does not accidentally match currypowder-1024x1024.jpg, which stays a deliberate exact-equality check rather than a substring one for exactly that reason. Backfilled the three missing equipment thumbnails, re-ran the strip pass across all 130 recipes, and found 9 recipes — not 2 — still carrying duplicates round 1 had missed, 209 more upload nodes gone. A dry-run re-scan afterward found zero remaining matches, same verification discipline as round 1.

Root-causing the follow-up instead of just special-casing probeTM.jpg is what turned "one more file in a list" into fixing two structural gaps that would have quietly caught the next mismatched product too. (A different, smaller class of stragglers turned up during the WXR re-derivation and is intentionally still unfixed: a couple of images use Amazon short-links (amzn.to/...) with no /dp/ or /gp/aw/d/ ASIN in them at all, and one has no product link whatsoever — those don't fit the equipment-card system as built and need a design decision, not just a bigger regex.)


End of day

Done:

  • Recipe-page author bio now reuses the shared AuthorBio component (headshot, heading, bio copy, "Read My Story" link) instead of a separate hardcoded block
  • Recipe-page-only social links preserved underneath
  • New CMS-editable sidebarBio field on the my-story global; AuthorBio reads it with the old copy as a fallback
  • Migration written and committed (ADD COLUMN sidebar_bio), auto-applies on the next prod boot
  • Tests added for the field and the fallback path; full audit gate green: 221 tests, lint clean, tsc clean
  • Days 14–17 consolidated into one post with a "how to verify AI work" playbook, HTML export, and a LinkedIn draft
  • Workshops-sync feature built end to end — recon → spec → 14-task TDD plan → all 14 tasks implemented and committed. Both venues' public APIs, confidence-based publishing, admin review flow, "Sync now" button, weekly job, email digest, and grouped class cards. Verified against the live venue APIs (28 real classes) on the dev DB
  • Live sync-progress indicator added on top — the engine reports phase + counts, stored on the settings record and polled by the button every 1.5 s, so a multi-minute manual sync shows "Checking Whisk classes… 45 / 130" instead of a silent spinner (and stays visible even if the long request drops)
  • Two usability fixes after the first real production sync — on-demand revalidation so the page updates instantly on sync/approval (no 1h ISR wait), and a discoverable approval path (a "Review N pending classes →" link + clearer admin descriptions)
  • Card tweak — every class date now renders as a uniform tappable chip (the soonest date used to be plain text; it's now a button like the rest, each linking to that date's booking page), with the time kept on the soonest chip only
  • Found and fixed a real deploy bug — after redeploying, /workshops looked empty for up to an hour, which read like the synced classes had been wiped. They hadn't: the page was statically pre-rendered at Docker-build time, when there's no database, so every fresh image baked in an empty snapshot and served it until the cache expired. Switched the page to render fresh on every request instead of trusting a build-time snapshot
  • Then corrected myself on caching — tried to add back a short-lived query cache, mis-tested its invalidation (read stale-while-revalidate's one stale serve as "broken"), wrongly declared the framework's tag invalidation unreliable, and reverted it. A "are you sure?" from my friend sent me to the docs; the retest across multiple requests proved it works fine. Caching went back in, verified properly this time
  • Fixed broken Pantry booking links — my friend spotted that the Pantry links were wrong. The venue's API returns two URLs per class-date; I'd picked the one that turns out to 404 on their live site. Curling both confirmed it in one line (the per-date URL → 404, the class-page URL → 200). Pointed the booking links at the working page. 287 tests, lint clean, tsc clean, production build green
  • Found and fixed a much bigger version of an "already fixed" ad-cleanup bug — two spotted product photos (KitchenAid mixer, bread tin) turned out to be 2 of 62 recipes still carrying 1,236 duplicate inline product images, because a cleanup script's manually-typed allowlist never kept pace with the equipment-card image map it was supposed to mirror. Fixed by merging both into one shared module so the two can't drift apart again, then ran the cleanup + repair scripts against the dev DB and verified a re-run finds nothing left (idempotent). 287 tests, lint clean, tsc clean
  • Round 2 — the same bug reported "fixed" still had a straggler: a probe-thermometer photo left in the panettone post. Traced it to two more root causes: the equipment-thumbnail script's ASIN-extraction regex only recognized one of Amazon's two link URL shapes (/dp/ASIN but not /gp/aw/d/ASIN), so 3 products across 6 recipes had never gotten an equipment-card thumbnail in the first place — the inline photo was their only image, not a duplicate, so round 1's script correctly left it alone. Separately, the strip script matched by exact filename, and WordPress had uploaded the same product photo under different size-variant names in different posts (pipingbags.jpg vs pipingbags-1024x1014.jpg), so an exact-match list missed the alias. Fixed both: widened the ASIN regex, generalized the strip match to a dimension-stripped base filename (exact equality, not substring, to avoid curry false-matching inside currypowder), backfilled the 3 missing equipment thumbnails, and found + removed 9 more recipes' worth of duplicates (209 more upload nodes) that round 1 had missed. Re-verified idempotent; 287 tests, lint clean, tsc clean
  • Everything committed and pushed to main

Not done / follow-up:

  • An earlier image was already deployed to production and my friend ran the first real "Sync now" — that's what surfaced the two usability gaps above. The 28 classes are live in the prod database now (25 auto-approved, 3 pending review)
  • The progress indicator, on-demand revalidation, and the approval link went in after that deploy, so they need one more docker compose pull app && docker compose up -d --force-recreate app to take effect (revalidation only works on the production build, not dev). My friend is doing that pull
  • The bio field starts empty, so production keeps showing the default copy until she types her bio into the admin
  • The author-card heading is still hardcoded — could be made editable the same way, but wasn't in scope today
  • Once redeployed: the loop is tight — Sync now → live progress → "Review N pending →" link → approve → the page updates within seconds
  • The ad-image cleanup (both rounds) ran against the dev DB only — it's data-only (no migration, no new env vars), already wired into run-content-pipeline.sh, so it just needs one more pipeline run against the prod DB over the SSH tunnel to reach production
  • A handful of leftover ad images use Amazon short-links or have no product link at all, so they fall outside the ASIN-based equipment-card system entirely — flagged, not fixed; needs a design decision on how (or whether) to handle links that were never going to have a card

Honest take on day 18

It started like it would be a small session — a bio-text swap — and the interesting part at that scale wasn't the code, it was the gap between "replace the bio" and "replace the bio, the image, and everything." The first version of the fix was technically correct and still wrong: it matched the words without matching the experience. Reusing the actual shared component instead of copying more JSX by hand was the right call once the ask expanded, and it's also just less code to maintain going forward — one bio, one file, instead of two that happen to agree today.

The CMS wrinkle is the more durable lesson, and I'm glad it didn't stay a "follow-up." Two hardcoded strings had been quietly standing in for content that clearly belonged in the admin — nothing broken, nothing throwing an error, just wired the wrong way, invisible until you go looking. The fix wasn't the copy; it was making the copy someone else's to change. There's a real difference between "the two bios now match" (true after the first commit, but they'd drift again the moment anyone edited one) and "there's one bio, and it lives where the person who writes it can reach it." The first is a patch. The second is the thing actually being off, corrected — and it's the kind of correction that only happens if you follow the smell past the literal request instead of closing the ticket the moment the words line up.

It did not stay small. What followed was a full feature built from recon through a 14-task plan to a verified production sync, a deploy bug that looked like data loss and wasn't, a self-correction where I had to catch my own overconfident diagnosis the same way I'd been catching the AI's, and then — after I'd already called the ad-image bug "fixed" once — a reader-reported straggler that turned out to be two more root causes wearing the same disguise. That last part is the throughline for the whole day, not just its own section: bio drift, tag-invalidation, and ad images were three unrelated bugs that all had the identical shape — a fix that resolved the reported instance while leaving the mechanism that produced it untouched, ready to produce the next one. The CMS field, the properly-retested cache, and the shared ASIN module are the same move three times: stop where the symptom stops, or keep going until the class of bug is actually closed. Small session or not, that's the only distinction that ended up mattering today.


This is part of an ongoing series documenting the rebuild of a friend's food blog from WordPress to a custom Next.js stack, built with AI assistance.

Kumudu
Written by

Kumudu

Distributed systems, infra, and small AI experiments on weekends. New posts roughly monthly, mostly long.

Comments

Adjacent reading

all essays →
press esc to close · enter to search