From b5abb45b070c00c1189ff9fdd293d05869b41ad2 Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 5 Aug 2026 13:13:47 -0300 Subject: [PATCH 1/3] fix(blog): compare full ISO timestamps when sorting posts by date The date comparator built a Date by concatenating a time onto post.date, which assumes the value is exactly YYYY-MM-DD. BlogPost.date is a plain string and the CMS also stores full ISO timestamps, so "2026-08-05T12:51:59Z" became "2026-08-05T12:51:59ZT00:00:00" -> Invalid Date -> getTime() is NaN. A NaN comparator result is treated as +0 and toSorted is stable, so a post with a full timestamp never moved -- it stayed wherever the records put it. Listings with sortBy "date_desc" showed yesterday's post above today's. Only append a time when the value is a bare date, and use T00:00:00Z so a bare date is UTC midnight instead of local midnight -- ordering no longer depends on the server timezone. Unparseable values fall back to 0 so NaN never reaches the comparator. Co-Authored-By: Claude --- blog/core/handlePosts.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/blog/core/handlePosts.ts b/blog/core/handlePosts.ts index fbff0674e..5faaadff5 100644 --- a/blog/core/handlePosts.ts +++ b/blog/core/handlePosts.ts @@ -3,6 +3,16 @@ import { AppContext } from "../mod.ts"; import { BlogPost, SortBy, ViewFromDatabase } from "../types.ts"; import { VALID_SORT_ORDERS } from "../utils/constants.ts"; +/** + * `BlogPost.date` may be a bare `YYYY-MM-DD` or a full ISO 8601 timestamp. + * A bare date is read as UTC midnight so ordering doesn't depend on the + * machine timezone. Unparseable values fall back to 0 instead of leaking NaN + * into the comparator (a NaN result is treated as 0, so the post never moves). + */ +const dateToTime = (date: string) => + new Date(/^\d{4}-\d{2}-\d{2}$/.test(date) ? `${date}T00:00:00Z` : date) + .getTime() || 0; + /** * Returns an sorted BlogPost list * @@ -87,8 +97,7 @@ export const sortPosts = async ( return -1; // If post b doesn't have sort method, put it after post a } const comparison = sortMethod === "date" - ? new Date(`${b.date}T00:00:00`).getTime() - - new Date(`${a.date}T00:00:00`).getTime() + ? dateToTime(b.date) - dateToTime(a.date) : a[sortMethod]?.toString().localeCompare( b[sortMethod]?.toString() ?? "", ) ?? 0; @@ -210,6 +219,7 @@ export default async function handlePosts( if (!filteredPosts || filteredPosts.length === 0) { return null; } + const sorted = await sortPosts(filteredPosts, sortBy, ctx); - return await sortPosts(filteredPosts, sortBy, ctx); + return sorted; } From c307b47f5794f2048da2b4bd4c9226c2800f05ce Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 5 Aug 2026 13:22:18 -0300 Subject: [PATCH 2/3] fix(blog): drop posts without a slug from listings A post whose slug is missing, empty or blank has no route. It still rendered in listings, producing cards that link nowhere and a broken url in the JSON-LD. Filter it out in filterPosts, ahead of every other filter and of slicePosts, so `count` still yields `count` renderable posts. Records come straight from the CMS and are cast without validation, so the guard also checks the type: a non-string slug would otherwise throw inside handlePosts, and the try/catch in the loaders would turn that into an empty listing. Co-Authored-By: Claude --- blog/core/handlePosts.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/blog/core/handlePosts.ts b/blog/core/handlePosts.ts index 5faaadff5..16bc0bae4 100644 --- a/blog/core/handlePosts.ts +++ b/blog/core/handlePosts.ts @@ -169,12 +169,25 @@ export const slicePosts = ( return posts.slice(startIndex, endIndex); }; +/** + * A record without a slug has no route, so it can never be rendered: listing it + * only produces cards linking to the listing itself. Dropped here, before + * slicePosts, so `count` still yields `count` renderable posts. + */ +export const filterRoutablePosts = (posts: BlogPost[]) => + // Records come straight from the CMS, so `slug` is only a string by + // convention: the typeof guard keeps a malformed one from throwing here and + // taking the whole listing down with it. + posts.filter(({ slug }) => typeof slug === "string" && slug.trim()); + const filterPosts = ( - posts: BlogPost[], + allPosts: BlogPost[], slug?: string | string[], postSlugs?: string[], term?: string, ): BlogPost[] => { + const posts = filterRoutablePosts(allPosts); + if (typeof slug === "string") { const firstFilter = postSlugs && postSlugs.length > 0 ? filterPostsBySlugs(posts, postSlugs) From b9309be6c60a2e993e647c08cd8630fe6f6bae3b Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 5 Aug 2026 13:27:14 -0300 Subject: [PATCH 3/3] fix(blog): pin offset-less datetimes to UTC when sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalizing only bare YYYY-MM-DD left a gap: per spec a date-only string is parsed as UTC, but an ISO datetime with no timezone designator is parsed as local time. So "2026-08-05T23:30:00" ordered differently against a bare "2026-08-06" depending on the server timezone -- the two swap places in America/Sao_Paulo but not in UTC or Asia/Tokyo. Match any ISO date or datetime lacking a designator and append Z. Covers minute precision and fractional seconds too; strings that already carry a Z or a ±hh:mm offset are left untouched. Co-Authored-By: Claude --- blog/core/handlePosts.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/blog/core/handlePosts.ts b/blog/core/handlePosts.ts index 16bc0bae4..545be46e5 100644 --- a/blog/core/handlePosts.ts +++ b/blog/core/handlePosts.ts @@ -3,15 +3,28 @@ import { AppContext } from "../mod.ts"; import { BlogPost, SortBy, ViewFromDatabase } from "../types.ts"; import { VALID_SORT_ORDERS } from "../utils/constants.ts"; +/** An ISO 8601 date or date-time carrying no timezone designator. */ +const ISO_WITHOUT_TIMEZONE = + /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?$/; + /** * `BlogPost.date` may be a bare `YYYY-MM-DD` or a full ISO 8601 timestamp. - * A bare date is read as UTC midnight so ordering doesn't depend on the - * machine timezone. Unparseable values fall back to 0 instead of leaking NaN - * into the comparator (a NaN result is treated as 0, so the post never moves). + * + * Anything without a timezone designator is pinned to UTC, so ordering never + * depends on the machine timezone. That matters for both shapes: per spec a + * bare date is already UTC, but an offset-less datetime is parsed as *local* + * time, which would otherwise reorder posts near a day boundary from one + * server to the next. + * + * Unparseable values fall back to 0 instead of leaking NaN into the comparator + * (a NaN result is treated as 0, so the post would never move). */ const dateToTime = (date: string) => - new Date(/^\d{4}-\d{2}-\d{2}$/.test(date) ? `${date}T00:00:00Z` : date) - .getTime() || 0; + new Date( + ISO_WITHOUT_TIMEZONE.test(date) + ? `${date.includes("T") ? date : `${date}T00:00:00`}Z` + : date, + ).getTime() || 0; /** * Returns an sorted BlogPost list