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:
@@ -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 (
|
||||
<article
|
||||
|
||||
@@ -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) || "—";
|
||||
}
|
||||
@@ -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() {
|
||||
<h1>{project.title}</h1>
|
||||
<div className="project-page__meta">
|
||||
{project.date && (
|
||||
<span>{new Date(project.date).toLocaleDateString(undefined, { year: "numeric", month: "long" })}</span>
|
||||
<span>{formatProjectMonthYear(project.date)}</span>
|
||||
)}
|
||||
{project.software.length > 0 && (
|
||||
<span>{project.software.join(" · ")}</span>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1rem" }}>
|
||||
<div className="field">
|
||||
<label htmlFor="date">Date</label>
|
||||
<label htmlFor="date">Project date (YYYY-MM-DD)</label>
|
||||
<input
|
||||
id="date"
|
||||
type="date"
|
||||
value={form.date}
|
||||
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 className="field">
|
||||
<label htmlFor="pri">Display priority</label>
|
||||
|
||||
@@ -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<Project[]>([]);
|
||||
@@ -75,6 +76,7 @@ export function AdminProjects() {
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Title</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Categories</th>
|
||||
@@ -99,6 +101,9 @@ export function AdminProjects() {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" }}>
|
||||
{formatProjectYmd(p.date)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`vis-badge vis-badge--${p.visibility}`}>{p.visibility}</span>
|
||||
</td>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user