From bf2732c936f1a0112c7a7ce286159b6750db3d86 Mon Sep 17 00:00:00 2001 From: jmartin Date: Sat, 1 Aug 2026 11:50:17 -0400 Subject: [PATCH] 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 --- client/src/components/ProjectCard.tsx | 3 +- client/src/lib/dates.ts | 48 +++++++++++++++++++++ client/src/pages/ProjectPage.tsx | 3 +- client/src/pages/admin/AdminProjectEdit.tsx | 8 +++- client/src/pages/admin/AdminProjects.tsx | 5 +++ client/src/styles/global.scss | 9 ++++ server/src/lib/dates.ts | 33 ++++++++++++++ server/src/lib/project-include.ts | 5 +++ server/src/routes/admin-projects.ts | 8 ++-- server/src/routes/public.ts | 22 +++++----- 10 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 client/src/lib/dates.ts create mode 100644 server/src/lib/dates.ts diff --git a/client/src/components/ProjectCard.tsx b/client/src/components/ProjectCard.tsx index bf94b2a..d274658 100644 --- a/client/src/components/ProjectCard.tsx +++ b/client/src/components/ProjectCard.tsx @@ -1,11 +1,12 @@ import { Link } from "react-router-dom"; import type { Project } from "../lib/api"; import { thumbOf } from "../lib/api"; +import { formatProjectYear } from "../lib/dates"; import "./ProjectCard.scss"; export function ProjectCard({ project, index = 0 }: { project: Project; index?: number }) { const thumb = thumbOf(project); - const year = project.date ? new Date(project.date).getFullYear() : null; + const year = formatProjectYear(project.date); return (
12) return null; + return `${MONTHS_LONG[m - 1]} ${y}`; +} + +/** Public cards: year only. */ +export function formatProjectYear(value: string | null | undefined): number | null { + const ymd = dateOnly(value); + if (!ymd) return null; + const y = Number(ymd.slice(0, 4)); + return Number.isFinite(y) ? y : null; +} + +/** Admin list: YYYY-MM-DD as stored. */ +export function formatProjectYmd(value: string | null | undefined): string { + return dateOnly(value) || "—"; +} diff --git a/client/src/pages/ProjectPage.tsx b/client/src/pages/ProjectPage.tsx index 26d782a..c90c35b 100644 --- a/client/src/pages/ProjectPage.tsx +++ b/client/src/pages/ProjectPage.tsx @@ -3,6 +3,7 @@ import { Link, useParams } from "react-router-dom"; import Lightbox from "yet-another-react-lightbox"; import "yet-another-react-lightbox/styles.css"; import { api, type Project, mediaSrc } from "../lib/api"; +import { formatProjectMonthYear } from "../lib/dates"; import { usePageTitle } from "../lib/pageTitle"; import { VimeoEmbed } from "../components/VimeoEmbed"; import "./ProjectPage.scss"; @@ -83,7 +84,7 @@ export function ProjectPage() {

{project.title}

{project.date && ( - {new Date(project.date).toLocaleDateString(undefined, { year: "numeric", month: "long" })} + {formatProjectMonthYear(project.date)} )} {project.software.length > 0 && ( {project.software.join(" · ")} diff --git a/client/src/pages/admin/AdminProjectEdit.tsx b/client/src/pages/admin/AdminProjectEdit.tsx index 9784713..cbcebff 100644 --- a/client/src/pages/admin/AdminProjectEdit.tsx +++ b/client/src/pages/admin/AdminProjectEdit.tsx @@ -47,7 +47,7 @@ export function AdminProjectEdit() { title: p.title, description: p.description, shortDescription: p.shortDescription || "", - date: p.date ? p.date.slice(0, 10) : "", + date: p.date ? p.date.slice(0, 10) : "", // API sends YYYY-MM-DD software: (p.software || []).join(", "), externalLinksText: (p.externalLinks || []) .map((l) => `${l.label}|${l.url}`) @@ -191,13 +191,17 @@ export function AdminProjectEdit() {
- + setForm({ ...form, date: e.target.value })} /> +

+ Chronological creation date. Used for home Recent and portfolio date sort. + Public site shows month and year only. +

diff --git a/client/src/pages/admin/AdminProjects.tsx b/client/src/pages/admin/AdminProjects.tsx index f9cb903..ddf64bf 100644 --- a/client/src/pages/admin/AdminProjects.tsx +++ b/client/src/pages/admin/AdminProjects.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { api, type Project, thumbOf, mediaSrc } from "../../lib/api"; +import { formatProjectYmd } from "../../lib/dates"; export function AdminProjects() { const [projects, setProjects] = useState([]); @@ -75,6 +76,7 @@ export function AdminProjects() { Title + Date Status Priority Categories @@ -99,6 +101,9 @@ export function AdminProjects() { )} + + {formatProjectYmd(p.date)} + {p.visibility} diff --git a/client/src/styles/global.scss b/client/src/styles/global.scss index c468083..c746032 100644 --- a/client/src/styles/global.scss +++ b/client/src/styles/global.scss @@ -181,6 +181,15 @@ h4 { min-height: 120px; resize: vertical; } + + &__hint { + margin: 0; + font-size: 0.78rem; + color: var(--text-dim); + text-transform: none; + letter-spacing: 0; + line-height: 1.35; + } } .badge { diff --git a/server/src/lib/dates.ts b/server/src/lib/dates.ts new file mode 100644 index 0000000..aa25cbf --- /dev/null +++ b/server/src/lib/dates.ts @@ -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}`; +} diff --git a/server/src/lib/project-include.ts b/server/src/lib/project-include.ts index 1977ef7..4b82bc8 100644 --- a/server/src/lib/project-include.ts +++ b/server/src/lib/project-include.ts @@ -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 ?? [], diff --git a/server/src/routes/admin-projects.ts b/server/src/routes/admin-projects.ts index 8c6c556..600a2f1 100644 --- a/server/src/routes/admin-projects.ts +++ b/server/src/routes/admin-projects.ts @@ -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 } : {}), diff --git a/server/src/routes/public.ts b/server/src/routes/public.ts index 6ed909b..8b68b94 100644 --- a/server/src/routes/public.ts +++ b/server/src/routes/public.ts @@ -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,