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
+2 -1
View File
@@ -1,11 +1,12 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Project } from "../lib/api"; import type { Project } from "../lib/api";
import { thumbOf } from "../lib/api"; import { thumbOf } from "../lib/api";
import { formatProjectYear } from "../lib/dates";
import "./ProjectCard.scss"; import "./ProjectCard.scss";
export function ProjectCard({ project, index = 0 }: { project: Project; index?: number }) { export function ProjectCard({ project, index = 0 }: { project: Project; index?: number }) {
const thumb = thumbOf(project); const thumb = thumbOf(project);
const year = project.date ? new Date(project.date).getFullYear() : null; const year = formatProjectYear(project.date);
return ( return (
<article <article
+48
View File
@@ -0,0 +1,48 @@
/**
* Project chronological dates are YYYY-MM-DD from the API.
* Parse without Date timezone shifts so public month/year stays correct.
*/
const MONTHS_LONG = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
/** Extract YYYY-MM-DD prefix if present. */
export function dateOnly(value: string | null | undefined): string | null {
if (!value) return null;
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
}
/** Public project detail: "March 2024" (month + year only). */
export function formatProjectMonthYear(value: string | null | undefined): string | null {
const ymd = dateOnly(value);
if (!ymd) return null;
const [y, m] = ymd.split("-").map(Number);
if (!y || !m || m < 1 || m > 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) || "—";
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { Link, useParams } from "react-router-dom";
import Lightbox from "yet-another-react-lightbox"; import Lightbox from "yet-another-react-lightbox";
import "yet-another-react-lightbox/styles.css"; import "yet-another-react-lightbox/styles.css";
import { api, type Project, mediaSrc } from "../lib/api"; import { api, type Project, mediaSrc } from "../lib/api";
import { formatProjectMonthYear } from "../lib/dates";
import { usePageTitle } from "../lib/pageTitle"; import { usePageTitle } from "../lib/pageTitle";
import { VimeoEmbed } from "../components/VimeoEmbed"; import { VimeoEmbed } from "../components/VimeoEmbed";
import "./ProjectPage.scss"; import "./ProjectPage.scss";
@@ -83,7 +84,7 @@ export function ProjectPage() {
<h1>{project.title}</h1> <h1>{project.title}</h1>
<div className="project-page__meta"> <div className="project-page__meta">
{project.date && ( {project.date && (
<span>{new Date(project.date).toLocaleDateString(undefined, { year: "numeric", month: "long" })}</span> <span>{formatProjectMonthYear(project.date)}</span>
)} )}
{project.software.length > 0 && ( {project.software.length > 0 && (
<span>{project.software.join(" · ")}</span> <span>{project.software.join(" · ")}</span>
+6 -2
View File
@@ -47,7 +47,7 @@ export function AdminProjectEdit() {
title: p.title, title: p.title,
description: p.description, description: p.description,
shortDescription: p.shortDescription || "", 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(", "), software: (p.software || []).join(", "),
externalLinksText: (p.externalLinks || []) externalLinksText: (p.externalLinks || [])
.map((l) => `${l.label}|${l.url}`) .map((l) => `${l.label}|${l.url}`)
@@ -191,13 +191,17 @@ export function AdminProjectEdit() {
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1rem" }}>
<div className="field"> <div className="field">
<label htmlFor="date">Date</label> <label htmlFor="date">Project date (YYYY-MM-DD)</label>
<input <input
id="date" id="date"
type="date" type="date"
value={form.date} value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })} onChange={(e) => setForm({ ...form, date: e.target.value })}
/> />
<p className="field__hint">
Chronological creation date. Used for home Recent and portfolio date sort.
Public site shows month and year only.
</p>
</div> </div>
<div className="field"> <div className="field">
<label htmlFor="pri">Display priority</label> <label htmlFor="pri">Display priority</label>
+5
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api, type Project, thumbOf, mediaSrc } from "../../lib/api"; import { api, type Project, thumbOf, mediaSrc } from "../../lib/api";
import { formatProjectYmd } from "../../lib/dates";
export function AdminProjects() { export function AdminProjects() {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
@@ -75,6 +76,7 @@ export function AdminProjects() {
<tr> <tr>
<th></th> <th></th>
<th>Title</th> <th>Title</th>
<th>Date</th>
<th>Status</th> <th>Status</th>
<th>Priority</th> <th>Priority</th>
<th>Categories</th> <th>Categories</th>
@@ -99,6 +101,9 @@ export function AdminProjects() {
</span> </span>
)} )}
</td> </td>
<td style={{ fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" }}>
{formatProjectYmd(p.date)}
</td>
<td> <td>
<span className={`vis-badge vis-badge--${p.visibility}`}>{p.visibility}</span> <span className={`vis-badge vis-badge--${p.visibility}`}>{p.visibility}</span>
</td> </td>
+9
View File
@@ -181,6 +181,15 @@ h4 {
min-height: 120px; min-height: 120px;
resize: vertical; resize: vertical;
} }
&__hint {
margin: 0;
font-size: 0.78rem;
color: var(--text-dim);
text-transform: none;
letter-spacing: 0;
line-height: 1.35;
}
} }
.badge { .badge {
+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 = { export const projectPublicInclude = {
categories: { include: { category: true } }, categories: { include: { category: true } },
tags: { include: { tag: true } }, tags: { include: { tag: true } },
@@ -10,10 +12,13 @@ export function serializeProject<
categories: { category: unknown }[]; categories: { category: unknown }[];
tags: { tag: unknown }[]; tags: { tag: unknown }[];
externalLinks: unknown; externalLinks: unknown;
date?: Date | string | null;
}, },
>(p: T) { >(p: T) {
return { return {
...p, ...p,
// Always expose chronological project date as YYYY-MM-DD (date-only)
date: formatProjectDate(p.date),
categories: p.categories.map((c) => c.category), categories: p.categories.map((c) => c.category),
tags: p.tags.map((t) => t.tag), tags: p.tags.map((t) => t.tag),
externalLinks: p.externalLinks ?? [], 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 { projectPublicInclude, serializeProject } from "../lib/project-include.js";
import { indexProject, removeProjectFromIndex } from "../lib/typesense.js"; import { indexProject, removeProjectFromIndex } from "../lib/typesense.js";
import { parseVideoUrl, saveImageUpload, saveVideoUpload } from "../lib/media.js"; import { parseVideoUrl, saveImageUpload, saveVideoUpload } from "../lib/media.js";
import { parseProjectDate } from "../lib/dates.js";
const projectBody = z.object({ const projectBody = z.object({
title: z.string().min(1), title: z.string().min(1),
description: z.string().optional().default(""), description: z.string().optional().default(""),
shortDescription: z.string().optional().nullable(), shortDescription: z.string().optional().nullable(),
/** Chronological project date as YYYY-MM-DD (or null to clear). */
date: z.string().optional().nullable(), date: z.string().optional().nullable(),
software: z.array(z.string()).optional().default([]), software: z.array(z.string()).optional().default([]),
externalLinks: z externalLinks: z
@@ -112,7 +114,7 @@ export async function adminProjectRoutes(app: FastifyInstance) {
slug, slug,
description: body.description, description: body.description,
shortDescription: body.shortDescription, shortDescription: body.shortDescription,
date: body.date ? new Date(body.date) : null, date: parseProjectDate(body.date),
software: body.software, software: body.software,
externalLinks: body.externalLinks, externalLinks: body.externalLinks,
featured: body.featured, featured: body.featured,
@@ -154,9 +156,7 @@ export async function adminProjectRoutes(app: FastifyInstance) {
...(body.shortDescription !== undefined ...(body.shortDescription !== undefined
? { shortDescription: body.shortDescription } ? { shortDescription: body.shortDescription }
: {}), : {}),
...(body.date !== undefined ...(body.date !== undefined ? { date: parseProjectDate(body.date) } : {}),
? { date: body.date ? new Date(body.date) : null }
: {}),
...(body.software !== undefined ? { software: body.software } : {}), ...(body.software !== undefined ? { software: body.software } : {}),
...(body.externalLinks !== undefined ? { externalLinks: body.externalLinks } : {}), ...(body.externalLinks !== undefined ? { externalLinks: body.externalLinks } : {}),
...(body.featured !== undefined ? { featured: body.featured } : {}), ...(body.featured !== undefined ? { featured: body.featured } : {}),
+12 -10
View File
@@ -132,7 +132,11 @@ export async function publicRoutes(app: FastifyInstance) {
: {}), : {}),
}, },
include: projectPublicInclude, 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]) => { const score = (p: (typeof projects)[0]) => {
@@ -232,15 +236,13 @@ export async function publicRoutes(app: FastifyInstance) {
: {}), : {}),
}; };
let orderBy: // Chronological `date` drives Recent / date sort (nulls last).
| { displayPriority: "asc" | "desc" }[] const byDateDesc = { date: { sort: "desc" as const, nulls: "last" as const } };
| { date: "asc" | "desc" }[] let orderBy: object[] = [{ displayPriority: "asc" }, byDateDesc];
| { title: "asc" | "desc" }[] if (sort === "date") orderBy = [byDateDesc, { displayPriority: "asc" }];
| object[] = [{ displayPriority: "asc" }, { date: "desc" }];
if (sort === "date") orderBy = [{ date: "desc" }, { displayPriority: "asc" }];
else if (sort === "title" || sort === "alphabetical") else if (sort === "title" || sort === "alphabetical")
orderBy = [{ title: "asc" }, { displayPriority: "asc" }]; 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([ const [total, projects] = await Promise.all([
prisma.project.count({ where }), prisma.project.count({ where }),
@@ -294,7 +296,7 @@ export async function publicRoutes(app: FastifyInstance) {
const projects = await prisma.project.findMany({ const projects = await prisma.project.findMany({
where: { visibility: Visibility.published, featured: true }, where: { visibility: Visibility.published, featured: true },
include: projectPublicInclude, include: projectPublicInclude,
orderBy: [{ displayPriority: "asc" }, { date: "desc" }], orderBy: [{ displayPriority: "asc" }, { date: { sort: "desc", nulls: "last" } }],
take: 12, take: 12,
}); });
return projects.map(serializeProject); return projects.map(serializeProject);
@@ -361,7 +363,7 @@ ${urls
app.get("/rss.xml", async (_req, reply) => { app.get("/rss.xml", async (_req, reply) => {
const projects = await prisma.project.findMany({ const projects = await prisma.project.findMany({
where: { visibility: Visibility.published }, where: { visibility: Visibility.published },
orderBy: { date: "desc" }, orderBy: { date: { sort: "desc", nulls: "last" } },
take: 30, take: 30,
select: { select: {
title: true, title: true,