Friday Fixes #2: The Unquoted Date That Broke Drafts
Saturday morning. I was on my phone, tapped Drafts from the admin top nav, and got Safari's generic "This page couldn't load" screen. No status code, no path, no console. Just a sad triangle and a Reload button.

Dashboard worked. Images worked. Record worked. Only Drafts was broken.
Twenty minutes later it was fixed — but the path there is the actual post, because I took the wrong turn first and the bug itself is the kind of thing that will bite anyone shipping a Markdown-driven site.
The First Wrong Guess
I asked my agent to investigate. It looked at the screenshot, saw
vibescoder.dev in the truncated URL bar with no visible path, and
diagnosed the obvious thing: the user hit /drafts (no such route),
which returns a 404, and on mobile that renders as the generic Safari
error page.
The recommended fix was equally obvious: use /admin/drafts, or add
a redirect.
// next.config.ts
async redirects() {
return [
{ source: "/drafts", destination: "/admin/drafts", permanent: false },
];
}Clean answer. Wrong question.
I sent a second screenshot showing the admin top nav, where the Drafts
link clearly exists and clearly points at /admin/drafts. I hadn't typed
anything. I'd tapped a link. The link was right. The page itself was
500'ing.
This is the part of working with agents I keep relearning: the first plausible explanation that fits the screenshot is not always the right one. A truncated mobile URL bar is ambiguous evidence. Agents (and humans) will pattern-match on the visible bits and miss the structural question — how did the user get there?
The Repro
Once we agreed the page itself was broken, the loop closed fast:
gh repo clone carryologist/the-vibe-coder
gh repo clone carryologist/the-vibe-coder-content
cp -r the-vibe-coder-content/content the-vibe-coder/
cd the-vibe-coder
SESSION_SECRET=... ADMIN_PASSWORD=dev npx next build
SESSION_SECRET=... ADMIN_PASSWORD=dev npx next start -p 3939 &
curl -c jar -X POST localhost:3939/api/auth/login -d '{"password":"dev"}'
curl -b jar localhost:3939/admin/drafts -w "%{http_code}\n"
# → 500The server log had exactly one useful line in it:
⨯ TypeError: a.includes is not a function
at <unknown> (.next/server/chunks/ssr/src_0k9vqrt._.js:1:157)
at Array.map (<anonymous>)
Minified beyond useful, but the shape was enough: something inside a
.map() was calling .includes() on a value that wasn't a string.
The Bug
I grepped .includes( across the source. One of the hits was in
src/lib/format-date.ts:
export function formatDate(dateStr: string, options = {...}): string {
if (!dateStr) return "No date";
const normalized = dateStr.includes("T") ? dateStr : dateStr + "T00:00:00";
...
}The .includes("T") was deliberate. It distinguishes a date-only
frontmatter value ("2026-05-14", which needs T00:00:00 appended to
parse as local time) from a full datetime ("2026-05-14T05:00:00-07:00",
which doesn't). I'd written that helper specifically because an
earlier bug had us appending T00:00:00 to a datetime and getting
Invalid Date. It got its own Friday Fixes post: "Mobile First and the
Skill That Saved Us".
If the scheduled-publish workflow bugs in Friday Fixes #1 feel related — they are. Same week, same content pipeline. That post covers two workflow-level failures that were invisible to the main code path. This one is the data-level failure that hid behind a login boundary.
The TypeScript signature says dateStr: string. The other 35 posts in
the repo say it's a string. The helper has been in production for
weeks.
What changed?
One draft had this in its frontmatter:
title: "From Cloud Native to AI Native: Learning from Past Patterns"
date: 2026-05-14
description: "Exploring the parallels..."Look at the date. No quotes.
YAML 1.1 — which is what js-yaml and gray-matter parse by default
for compatibility — has aggressive auto-typing. 2026-05-14 matches
the ISO date pattern. The parser doesn't hand back a string; it hands
back a JavaScript Date object.
Date.prototype.includes does not exist. Throw.
node -e "
const matter = require('gray-matter');
const fs = require('fs');
for (const f of fs.readdirSync('content/posts').filter(f => f.endsWith('.mdx'))) {
const { data } = matter(fs.readFileSync('content/posts/' + f, 'utf8'));
if (typeof data.date !== 'string') {
console.log(f, '→', data.date instanceof Date ? 'Date' : typeof data.date);
}
}
"
# from-cloud-native-to-ai-native-learning-from-past-patterns.mdx → DateOne offender. Out of thirty-six.
Why Only Drafts?
Worth pausing on this, because it's the part that explains why I'd been shipping with a bomb in the codebase for who knows how long.
Public routes — the homepage, individual post pages, tag pages — filter to published posts before anything formats dates:
function _getAllPosts(): Post[] {
return files
.map(...)
.filter((post) => post.published) // ← drops the bomb here
.filter((post) => new Date(post.date) <= new Date());
}The broken post had published: false. The homepage never saw it. Its
date object never reached formatDate. From the public site's
perspective, everything was fine.
The drafts page, by definition, does the opposite. It calls
getAllPostsAdmin(), which returns every post including unpublished
ones, then maps over the unpublished ones and formats their dates.
One bad apple, one route that touched it, full 500.
This is a useful pattern to notice: the same data can be safe on one code path and explosive on another. The bug was in the data for as long as that draft has existed. The bug was visible only on one specific route, behind login, that I look at a couple of times a week.
The Fix
Two commits, two repos.
Content — quote the date so the parser hands back a string:
- date: 2026-05-14
+ date: '2026-05-14'Engine — formatDate shouldn't crash a whole page over a missing
pair of quotes. Coerce non-strings before the substring check, and
widen the type signature so TypeScript reflects what can actually
arrive:
export function formatDate(
dateStr: string | Date | null | undefined,
options = {...},
): string {
if (!dateStr) return "No date";
// Coerce Date objects (from YAML auto-parsing) to ISO strings.
const asString =
dateStr instanceof Date
? dateStr.toISOString()
: typeof dateStr === "string"
? dateStr
: String(dateStr);
const normalized = asString.includes("T") ? asString : asString + "T00:00:00";
const d = new Date(normalized);
if (isNaN(d.getTime())) return asString || "No date";
return d.toLocaleDateString("en-US", options);
}I verified the engine fix in isolation by re-breaking the content back to unquoted, rebuilding, and hitting the route — 200. Then I shipped both fixes, watched the Vercel deploys go green, and reloaded on the phone. Drafts page rendered.
Gotchas
A few things from this one worth filing away.
The TypeScript signature lied. dateStr: string made the helper
look safe. At the YAML/JSON/env-var boundary, types are aspirational.
Anything that comes from a parser is unknown until you've actually
checked it. The defense is to coerce at the boundary, not to trust the
shape upstream.
YAML 1.1 auto-typing is treacherous. The trio:
| Frontmatter | Parsed as |
|---|---|
date: 2026-05-14 | Date object |
date: '2026-05-14' | string |
date: "2026-05-14" | string |
Most YAML examples on the internet omit the quotes. Most YAML editors
preview the file fine either way. The quotes are load-bearing only at
parse time, and only for some parsers. YAML 1.2 (which people think
they're writing) is stricter, but js-yaml and most JS-ecosystem
parsers default to 1.1.
Mobile error pages hide everything. No status code, no path, no console. If the user had only sent the first screenshot, "you typed the wrong URL" was a defensible answer that would have stuck for another day until I tried it on desktop. The second screenshot of the nav source was what flipped the diagnosis.
The first plausible explanation isn't always right. When the agent's first answer fit the visible evidence but contradicted my mental model of how I'd gotten there, I sent more evidence instead of accepting the fix. That's the loop that catches this class of bug — pushing back once with a second screenshot was worth more than ten more minutes of investigation on the wrong path.
By the Numbers
- 1 unquoted date in 36 posts (2.8%) broke the page
- 2 commits across 2 repos to ship the fix
- 27 → 49 lines in
format-date.tsafter defensive coercion + JSDoc - 0 public routes affected — they filter unpublished posts before formatting
- ~20 minutes from screenshot to verified-in-prod, including the wrong turn
- 2 Vercel deploys triggered (one per repo, both green)
- 500 → 200 on
/admin/draftspost-deploy - 1 lesson, same as last time: when the agent's answer doesn't fit your model, send another screenshot