From e1ca4c01e95079369f8a294ad75dc77efba98da5 Mon Sep 17 00:00:00 2001 From: miguelaenlle Date: Thu, 21 May 2026 14:29:38 -0500 Subject: [PATCH 1/5] Fix blog dates rendering in UTC instead of author's timezone Frontmatter date-only strings like "2026-05-21" were parsed by `new Date()` as UTC midnight, which displayed as the previous day for any viewer west of UTC and caused the PrairieLearn news popup's "X hours ago" to be off by up to a full day. Switch the five existing posts to full ISO timestamps with US Central offsets, and parse dates with date-fns `parseISO` (treats date-only strings as local time) in the blog index, post layout, RSS feed, and sort. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/BlogMarkdownLayout.tsx | 4 ++-- src/lib/blog.ts | 3 ++- src/lib/rss.ts | 3 ++- src/pages/about/blog/document-cameras/index.mdx | 2 +- src/pages/about/blog/index.tsx | 4 ++-- src/pages/about/blog/introducing-ai-grading/index.mdx | 2 +- src/pages/about/blog/linting-pit-of-success/index.mdx | 2 +- src/pages/about/blog/self-enrollment-overhaul/index.mdx | 2 +- src/pages/about/blog/stylish-mustaches/index.mdx | 2 +- 9 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/BlogMarkdownLayout.tsx b/src/components/BlogMarkdownLayout.tsx index 5620dba0..cf10a1e8 100644 --- a/src/components/BlogMarkdownLayout.tsx +++ b/src/components/BlogMarkdownLayout.tsx @@ -5,7 +5,7 @@ import ReactMarkdown from "react-markdown"; import { MDXProvider } from "@mdx-js/react"; import Head from "next/head"; import Modal from "react-bootstrap/Modal"; -import { format } from "date-fns"; +import { format, parseISO } from "date-fns"; import mdxComponents from "../lib/mdxComponents"; import { PageBanner } from "./Banner"; @@ -30,7 +30,7 @@ export const BlogMarkdownLayout: React.FC = ({ const { title, summary, date, author, tags, ogImage } = meta; if (!title) throw new Error("Missing title"); - const formattedDate = date ? format(new Date(date), "MMMM d, yyyy") : null; + const formattedDate = date ? format(parseISO(date), "MMMM d, yyyy") : null; const hasMeta = formattedDate || author || (tags && tags.length > 0); return ( diff --git a/src/lib/blog.ts b/src/lib/blog.ts index 7a750458..731edf14 100644 --- a/src/lib/blog.ts +++ b/src/lib/blog.ts @@ -1,5 +1,6 @@ import fs from "fs"; import path from "path"; +import { parseISO } from "date-fns"; const postsDirectory = path.join( process.cwd(), @@ -36,7 +37,7 @@ export async function getAllPosts(): Promise { ); const sortedPosts = posts.toSorted( - (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), + (a, b) => parseISO(b.date).getTime() - parseISO(a.date).getTime(), ); return sortedPosts; diff --git a/src/lib/rss.ts b/src/lib/rss.ts index 59f02836..7f76417b 100644 --- a/src/lib/rss.ts +++ b/src/lib/rss.ts @@ -1,6 +1,7 @@ import fs from "fs/promises"; import path from "path"; import RSS from "rss"; +import { parseISO } from "date-fns"; import { BlogPostWithSlug } from "./blog"; const SITE_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL @@ -28,7 +29,7 @@ export async function generateRssFeed( guid: `${BLOG_URL}/${post.slug}`, categories: post.tags || [], author: post.author, - date: new Date(post.date), + date: parseISO(post.date), }); } diff --git a/src/pages/about/blog/document-cameras/index.mdx b/src/pages/about/blog/document-cameras/index.mdx index 07be6fb3..f479e28c 100644 --- a/src/pages/about/blog/document-cameras/index.mdx +++ b/src/pages/about/blog/document-cameras/index.mdx @@ -17,7 +17,7 @@ import ptExamLabel from "./pt-exam-label.png"; export const meta = { title: "Document cameras make CBTF exams more flexible", - date: "2026-04-21", + date: "2026-04-21T00:00:00-05:00", author: "Jim Sosnowski", tags: ["CBTF", "Case study"], summary: diff --git a/src/pages/about/blog/index.tsx b/src/pages/about/blog/index.tsx index 59f2f1b5..a1db91a5 100644 --- a/src/pages/about/blog/index.tsx +++ b/src/pages/about/blog/index.tsx @@ -6,7 +6,7 @@ import { PageBanner } from "../../../components/Banner"; import { Heading } from "../../../components/Heading"; import Stack from "../../../components/Stack"; import { TagList } from "../../../components/Tag"; -import { format } from "date-fns"; +import { format, parseISO } from "date-fns"; import { getAllPosts, BlogPostWithSlug } from "../../../lib/blog"; import { generateRssFeed } from "../../../lib/rss"; @@ -17,7 +17,7 @@ interface BlogIndexProps { } const BlogPostCard = ({ post }: { post: BlogPostWithSlug }) => { - const formattedDate = format(new Date(post.date), "MMMM d, yyyy"); + const formattedDate = format(parseISO(post.date), "MMMM d, yyyy"); return (
diff --git a/src/pages/about/blog/introducing-ai-grading/index.mdx b/src/pages/about/blog/introducing-ai-grading/index.mdx index dfe30208..d5ae7f5e 100644 --- a/src/pages/about/blog/introducing-ai-grading/index.mdx +++ b/src/pages/about/blog/introducing-ai-grading/index.mdx @@ -13,7 +13,7 @@ import submissionExplanation from "./submission-explanation.png"; export const meta = { title: "Introducing AI grading", - date: "2026-05-21", + date: "2026-05-21T13:32:00-05:00", author: "Miguel Aenlle", tags: ["RELEASE"], }; diff --git a/src/pages/about/blog/linting-pit-of-success/index.mdx b/src/pages/about/blog/linting-pit-of-success/index.mdx index 24c37dd9..a455620e 100644 --- a/src/pages/about/blog/linting-pit-of-success/index.mdx +++ b/src/pages/about/blog/linting-pit-of-success/index.mdx @@ -10,7 +10,7 @@ import reviewFunnel from "./review-funnel.png"; export const meta = { title: "Linting into 'The Pit of Success'", - date: "2025-11-26", + date: "2025-11-26T00:00:00-06:00", author: "Peter Stenger", tags: ["Technical", "Development"], }; diff --git a/src/pages/about/blog/self-enrollment-overhaul/index.mdx b/src/pages/about/blog/self-enrollment-overhaul/index.mdx index 7259e0f5..5ab3883b 100644 --- a/src/pages/about/blog/self-enrollment-overhaul/index.mdx +++ b/src/pages/about/blog/self-enrollment-overhaul/index.mdx @@ -16,7 +16,7 @@ import studentsPage from "./students_page.png"; export const meta = { title: "Overhauled enrollment management", - date: "2026-01-01", + date: "2026-01-01T00:00:00-06:00", author: "Peter Stenger", tags: ["Release"], }; diff --git a/src/pages/about/blog/stylish-mustaches/index.mdx b/src/pages/about/blog/stylish-mustaches/index.mdx index 17027a48..ea96f93d 100644 --- a/src/pages/about/blog/stylish-mustaches/index.mdx +++ b/src/pages/about/blog/stylish-mustaches/index.mdx @@ -12,7 +12,7 @@ import linterScreenshot from "./linter.png"; export const meta = { title: "Stylish mustaches", - date: "2026-02-25", + date: "2026-02-25T00:00:00-06:00", author: "Peter Stenger", tags: ["Technical", "Development"], }; From 23b01ac49a02665069e868fb916aca57384d2440 Mon Sep 17 00:00:00 2001 From: miguelaenlle Date: Thu, 21 May 2026 14:41:51 -0500 Subject: [PATCH 2/5] Format blog dates in a fixed timezone to avoid hydration shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `format(parseISO(date), ...)` formats in the local timezone, so the static HTML rendered on Vercel (UTC) and the client hydration in the viewer's timezone could disagree by a day for west-of-UTC viewers — the same off-by- one symptom as before. Extract a small `formatBlogDate` helper that uses `Intl.DateTimeFormat` pinned to America/Chicago, so the rendered calendar date is identical regardless of where the code runs. Lives in a separate file from `lib/blog.ts` (which imports `fs`) to keep the client bundle clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/BlogMarkdownLayout.tsx | 5 ++--- src/lib/blogDate.ts | 12 ++++++++++++ src/pages/about/blog/index.tsx | 4 ++-- 3 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 src/lib/blogDate.ts diff --git a/src/components/BlogMarkdownLayout.tsx b/src/components/BlogMarkdownLayout.tsx index cf10a1e8..9aa4fdee 100644 --- a/src/components/BlogMarkdownLayout.tsx +++ b/src/components/BlogMarkdownLayout.tsx @@ -5,9 +5,8 @@ import ReactMarkdown from "react-markdown"; import { MDXProvider } from "@mdx-js/react"; import Head from "next/head"; import Modal from "react-bootstrap/Modal"; -import { format, parseISO } from "date-fns"; - import mdxComponents from "../lib/mdxComponents"; +import { formatBlogDate } from "../lib/blogDate"; import { PageBanner } from "./Banner"; import { TagList } from "./Tag"; @@ -30,7 +29,7 @@ export const BlogMarkdownLayout: React.FC = ({ const { title, summary, date, author, tags, ogImage } = meta; if (!title) throw new Error("Missing title"); - const formattedDate = date ? format(parseISO(date), "MMMM d, yyyy") : null; + const formattedDate = date ? formatBlogDate(date) : null; const hasMeta = formattedDate || author || (tags && tags.length > 0); return ( diff --git a/src/lib/blogDate.ts b/src/lib/blogDate.ts new file mode 100644 index 00000000..4507a3b9 --- /dev/null +++ b/src/lib/blogDate.ts @@ -0,0 +1,12 @@ +import { parseISO } from "date-fns"; + +const BLOG_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "America/Chicago", +}); + +export function formatBlogDate(date: string): string { + return BLOG_DATE_FORMATTER.format(parseISO(date)); +} diff --git a/src/pages/about/blog/index.tsx b/src/pages/about/blog/index.tsx index a1db91a5..e396f895 100644 --- a/src/pages/about/blog/index.tsx +++ b/src/pages/about/blog/index.tsx @@ -6,8 +6,8 @@ import { PageBanner } from "../../../components/Banner"; import { Heading } from "../../../components/Heading"; import Stack from "../../../components/Stack"; import { TagList } from "../../../components/Tag"; -import { format, parseISO } from "date-fns"; import { getAllPosts, BlogPostWithSlug } from "../../../lib/blog"; +import { formatBlogDate } from "../../../lib/blogDate"; import { generateRssFeed } from "../../../lib/rss"; import styles from "./index.module.scss"; @@ -17,7 +17,7 @@ interface BlogIndexProps { } const BlogPostCard = ({ post }: { post: BlogPostWithSlug }) => { - const formattedDate = format(parseISO(post.date), "MMMM d, yyyy"); + const formattedDate = formatBlogDate(post.date); return (
From 90f085b5358870c6efb67cb60c6e2f82aa36c1a1 Mon Sep 17 00:00:00 2001 From: miguelaenlle Date: Thu, 21 May 2026 15:13:52 -0500 Subject: [PATCH 3/5] Consolidate formatBlogDate into lib/blog.ts Per review, drop the standalone lib/blogDate.ts and move formatBlogDate back to lib/blog.ts. Moves getAllPosts (the only fs-using piece, used solely in this page's getStaticProps) inline into pages/about/blog/index.tsx so lib/blog.ts stays free of node-only imports and is safe to bundle into the client component that calls formatBlogDate. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/BlogMarkdownLayout.tsx | 2 +- src/lib/blog.ts | 36 ++++++--------------------- src/lib/blogDate.ts | 12 --------- src/pages/about/blog/index.tsx | 33 ++++++++++++++++++++++-- 4 files changed, 40 insertions(+), 43 deletions(-) delete mode 100644 src/lib/blogDate.ts diff --git a/src/components/BlogMarkdownLayout.tsx b/src/components/BlogMarkdownLayout.tsx index 9aa4fdee..4371ed86 100644 --- a/src/components/BlogMarkdownLayout.tsx +++ b/src/components/BlogMarkdownLayout.tsx @@ -6,7 +6,7 @@ import { MDXProvider } from "@mdx-js/react"; import Head from "next/head"; import Modal from "react-bootstrap/Modal"; import mdxComponents from "../lib/mdxComponents"; -import { formatBlogDate } from "../lib/blogDate"; +import { formatBlogDate } from "../lib/blog"; import { PageBanner } from "./Banner"; import { TagList } from "./Tag"; diff --git a/src/lib/blog.ts b/src/lib/blog.ts index 731edf14..8f5bfcac 100644 --- a/src/lib/blog.ts +++ b/src/lib/blog.ts @@ -1,15 +1,5 @@ -import fs from "fs"; -import path from "path"; import { parseISO } from "date-fns"; -const postsDirectory = path.join( - process.cwd(), - "src", - "pages", - "about", - "blog", -); - export interface BlogPost { title: string; date: string; @@ -22,23 +12,13 @@ export interface BlogPostWithSlug extends BlogPost { slug: string; } -export async function getAllPosts(): Promise { - const items = fs.readdirSync(postsDirectory, { withFileTypes: true }); - const slugs = items - .filter((item) => item.isDirectory()) - .map((item) => item.name); - const posts = await Promise.all( - slugs.map(async (slug) => { - const { meta } = (await import( - `../pages/about/blog/${slug}/index.mdx` - )) as { meta: BlogPost }; - return { slug, ...meta }; - }), - ); - - const sortedPosts = posts.toSorted( - (a, b) => parseISO(b.date).getTime() - parseISO(a.date).getTime(), - ); +const BLOG_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "America/Chicago", +}); - return sortedPosts; +export function formatBlogDate(date: string): string { + return BLOG_DATE_FORMATTER.format(parseISO(date)); } diff --git a/src/lib/blogDate.ts b/src/lib/blogDate.ts deleted file mode 100644 index 4507a3b9..00000000 --- a/src/lib/blogDate.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { parseISO } from "date-fns"; - -const BLOG_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { - year: "numeric", - month: "long", - day: "numeric", - timeZone: "America/Chicago", -}); - -export function formatBlogDate(date: string): string { - return BLOG_DATE_FORMATTER.format(parseISO(date)); -} diff --git a/src/pages/about/blog/index.tsx b/src/pages/about/blog/index.tsx index e396f895..347f3864 100644 --- a/src/pages/about/blog/index.tsx +++ b/src/pages/about/blog/index.tsx @@ -1,13 +1,15 @@ +import fs from "fs"; +import path from "path"; import React from "react"; import Head from "next/head"; import Link from "next/link"; import { GetStaticProps } from "next"; +import { parseISO } from "date-fns"; import { PageBanner } from "../../../components/Banner"; import { Heading } from "../../../components/Heading"; import Stack from "../../../components/Stack"; import { TagList } from "../../../components/Tag"; -import { getAllPosts, BlogPostWithSlug } from "../../../lib/blog"; -import { formatBlogDate } from "../../../lib/blogDate"; +import { BlogPost, BlogPostWithSlug, formatBlogDate } from "../../../lib/blog"; import { generateRssFeed } from "../../../lib/rss"; import styles from "./index.module.scss"; @@ -80,6 +82,33 @@ export default function BlogIndex({ posts }: BlogIndexProps) { ); } +async function getAllPosts(): Promise { + const postsDirectory = path.join( + process.cwd(), + "src", + "pages", + "about", + "blog", + ); + + const items = fs.readdirSync(postsDirectory, { withFileTypes: true }); + const slugs = items + .filter((item) => item.isDirectory()) + .map((item) => item.name); + const posts = await Promise.all( + slugs.map(async (slug) => { + const { meta } = (await import(`./${slug}/index.mdx`)) as { + meta: BlogPost; + }; + return { slug, ...meta }; + }), + ); + + return posts.toSorted( + (a, b) => parseISO(b.date).getTime() - parseISO(a.date).getTime(), + ); +} + export const getStaticProps: GetStaticProps = async () => { const posts = await getAllPosts(); From 50402ef576a3bf164bfa8c2a8b138955663e9b6a Mon Sep 17 00:00:00 2001 From: miguelaenlle Date: Thu, 21 May 2026 15:15:16 -0500 Subject: [PATCH 4/5] Explain why blog date timeZone is pinned Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/blog.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/blog.ts b/src/lib/blog.ts index 8f5bfcac..9f9b8f1f 100644 --- a/src/lib/blog.ts +++ b/src/lib/blog.ts @@ -16,6 +16,8 @@ const BLOG_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric", + // Pinned so server and client render the same calendar day; revisit if + // we ever display times rather than dates. timeZone: "America/Chicago", }); From 27524d1f94f7b059253da614464e58c66c650586 Mon Sep 17 00:00:00 2001 From: miguelaenlle Date: Thu, 21 May 2026 15:18:49 -0500 Subject: [PATCH 5/5] Edited content --- src/lib/blog.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/blog.ts b/src/lib/blog.ts index 9f9b8f1f..cb3c13bd 100644 --- a/src/lib/blog.ts +++ b/src/lib/blog.ts @@ -16,8 +16,8 @@ const BLOG_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric", - // Pinned so server and client render the same calendar day; revisit if - // we ever display times rather than dates. + // timeZone is set so server and client render the same calendar day. + // Revisit if we ever display times along with dates. timeZone: "America/Chicago", });