Project chronological date: YMD admin, month/year public, sort/recent

- Serialize and save project.date as date-only YYYY-MM-DD (no TZ drift)
- Admin label + hint; projects list shows Date column
- Home Recent and portfolio date sort use project.date (nulls last)
- Public display stays month+year / year-only via safe formatters
This commit is contained in:
2026-08-01 11:50:17 -04:00
parent ee4412d911
commit bf2732c936
10 changed files with 126 additions and 18 deletions
+33
View File
@@ -0,0 +1,33 @@
/**
* Project chronological dates are calendar days (YEAR-MONTH-DAY), not timestamps.
* Always parse/format as date-only so TZ shifts never move the day/month.
*/
const YMD = /^(\d{4})-(\d{2})-(\d{2})/;
/** Parse admin/API input "YYYY-MM-DD" (or ISO prefix) into a Date suitable for @db.Date. */
export function parseProjectDate(value: string | null | undefined): Date | null {
if (!value || typeof value !== "string") return null;
const m = value.trim().match(YMD);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
// UTC noon avoids edge cases when drivers convert Date ↔ DATE
return new Date(Date.UTC(y, mo - 1, d, 12, 0, 0));
}
/** Serialize a Prisma Date / ISO string as "YYYY-MM-DD" for the API. */
export function formatProjectDate(value: Date | string | null | undefined): string | null {
if (value == null) return null;
if (typeof value === "string") {
const m = value.match(YMD);
return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
}
if (!(value instanceof Date) || Number.isNaN(value.getTime())) return null;
const y = value.getUTCFullYear();
const mo = String(value.getUTCMonth() + 1).padStart(2, "0");
const d = String(value.getUTCDate()).padStart(2, "0");
return `${y}-${mo}-${d}`;
}
+5
View File
@@ -1,3 +1,5 @@
import { formatProjectDate } from "./dates.js";
export const projectPublicInclude = {
categories: { include: { category: true } },
tags: { include: { tag: true } },
@@ -10,10 +12,13 @@ export function serializeProject<
categories: { category: unknown }[];
tags: { tag: unknown }[];
externalLinks: unknown;
date?: Date | string | null;
},
>(p: T) {
return {
...p,
// Always expose chronological project date as YYYY-MM-DD (date-only)
date: formatProjectDate(p.date),
categories: p.categories.map((c) => c.category),
tags: p.tags.map((t) => t.tag),
externalLinks: p.externalLinks ?? [],
+4 -4
View File
@@ -7,11 +7,13 @@ import { uniqueProjectSlug } from "../lib/slug.js";
import { projectPublicInclude, serializeProject } from "../lib/project-include.js";
import { indexProject, removeProjectFromIndex } from "../lib/typesense.js";
import { parseVideoUrl, saveImageUpload, saveVideoUpload } from "../lib/media.js";
import { parseProjectDate } from "../lib/dates.js";
const projectBody = z.object({
title: z.string().min(1),
description: z.string().optional().default(""),
shortDescription: z.string().optional().nullable(),
/** Chronological project date as YYYY-MM-DD (or null to clear). */
date: z.string().optional().nullable(),
software: z.array(z.string()).optional().default([]),
externalLinks: z
@@ -112,7 +114,7 @@ export async function adminProjectRoutes(app: FastifyInstance) {
slug,
description: body.description,
shortDescription: body.shortDescription,
date: body.date ? new Date(body.date) : null,
date: parseProjectDate(body.date),
software: body.software,
externalLinks: body.externalLinks,
featured: body.featured,
@@ -154,9 +156,7 @@ export async function adminProjectRoutes(app: FastifyInstance) {
...(body.shortDescription !== undefined
? { shortDescription: body.shortDescription }
: {}),
...(body.date !== undefined
? { date: body.date ? new Date(body.date) : null }
: {}),
...(body.date !== undefined ? { date: parseProjectDate(body.date) } : {}),
...(body.software !== undefined ? { software: body.software } : {}),
...(body.externalLinks !== undefined ? { externalLinks: body.externalLinks } : {}),
...(body.featured !== undefined ? { featured: body.featured } : {}),
+12 -10
View File
@@ -132,7 +132,11 @@ export async function publicRoutes(app: FastifyInstance) {
: {}),
},
include: projectPublicInclude,
orderBy: [{ displayPriority: "asc" }, { date: "desc" }, { title: "asc" }],
orderBy: [
{ displayPriority: "asc" },
{ date: { sort: "desc", nulls: "last" } },
{ title: "asc" },
],
});
const score = (p: (typeof projects)[0]) => {
@@ -232,15 +236,13 @@ export async function publicRoutes(app: FastifyInstance) {
: {}),
};
let orderBy:
| { displayPriority: "asc" | "desc" }[]
| { date: "asc" | "desc" }[]
| { title: "asc" | "desc" }[]
| object[] = [{ displayPriority: "asc" }, { date: "desc" }];
if (sort === "date") orderBy = [{ date: "desc" }, { displayPriority: "asc" }];
// Chronological `date` drives Recent / date sort (nulls last).
const byDateDesc = { date: { sort: "desc" as const, nulls: "last" as const } };
let orderBy: object[] = [{ displayPriority: "asc" }, byDateDesc];
if (sort === "date") orderBy = [byDateDesc, { displayPriority: "asc" }];
else if (sort === "title" || sort === "alphabetical")
orderBy = [{ title: "asc" }, { displayPriority: "asc" }];
else if (sort === "priority") orderBy = [{ displayPriority: "asc" }, { date: "desc" }];
else if (sort === "priority") orderBy = [{ displayPriority: "asc" }, byDateDesc];
const [total, projects] = await Promise.all([
prisma.project.count({ where }),
@@ -294,7 +296,7 @@ export async function publicRoutes(app: FastifyInstance) {
const projects = await prisma.project.findMany({
where: { visibility: Visibility.published, featured: true },
include: projectPublicInclude,
orderBy: [{ displayPriority: "asc" }, { date: "desc" }],
orderBy: [{ displayPriority: "asc" }, { date: { sort: "desc", nulls: "last" } }],
take: 12,
});
return projects.map(serializeProject);
@@ -361,7 +363,7 @@ ${urls
app.get("/rss.xml", async (_req, reply) => {
const projects = await prisma.project.findMany({
where: { visibility: Visibility.published },
orderBy: { date: "desc" },
orderBy: { date: { sort: "desc", nulls: "last" } },
take: 30,
select: {
title: true,