Changelog 2273e77tree, with Claude Opus 5.5
Changelog: pages of 50, time, short hash and author, uncommitted work apart
- Each commit shows its date and time (in the author's own time zone), short hash and author, with co-authors read from Co-Authored-By lines; the commit page shows the same. - The list is split into pages of 50 (/changelog/, /changelog/page/2/ and on), with newer/older buttons and page numbers. The page is ChangelogPage.astro. - Uncommitted work in a local build sits in its own block above the commits on the first page, outside the page count, timed in local time.
6 files of code, design or docs +153 −54 lines
Code, design and documents 6 files
src/components/ChangelogPage.astro +89 −0
@@ -0,0 +1,89 @@ +--- +// One page of the changelog (src/pages/changelog/): the commits on page `n`, grouped by month, with +// their time, short hash and author, and the pages around it. On the first page of a local build +// the uncommitted changes come first, apart from the commits (src/lib/gitlog.js). +import Base from "../layouts/Base.astro"; +import UncommittedReload from "./UncommittedReload.astro"; +import { commits, committed, pageOf, pageCount, PAGE_SIZE, summaryOf, when, byline } from "../lib/gitlog.js"; + +const { n } = Astro.props; +const base = import.meta.env.BASE_URL; +const pages = pageCount(), items = pageOf(n), total = committed().length; +const local = n === 1 ? commits.find((c) => c.local) : null; +const pageUrl = (k) => (k === 1 ? `${base}changelog/` : `${base}changelog/page/${k}/`); +// grouped by month, newest first +const months = []; +for (const c of items) { + const m = c.date.slice(0, 7); + if (months.at(-1)?.m !== m) months.push({ m, label: new Date(`${m}-15`).toLocaleDateString("en-GB", { month: "long", year: "numeric" }), items: [] }); + months.at(-1).items.push(c); +} +// the page links: the first, the last, and two either side of this one, with gaps marked +const shown = [...new Set([1, n - 2, n - 1, n, n + 1, n + 2, pages])].filter((k) => k >= 1 && k <= pages).sort((a, b) => a - b); +const first = (n - 1) * PAGE_SIZE + 1, last = Math.min(n * PAGE_SIZE, total); +const row = (c) => ({ c, url: `${base}changelog/${c.short}/`, sum: summaryOf(c) }); +--- +<Base title={n === 1 ? "Changelog" : `Changelog, page ${n}`} description="Every change made to the Cannabis Freedom Index, its data and its texts, newest first, each with exactly what changed."> + <main class="changes-page gl"> + <section class="hero hero-sm"> + <p class="eyebrow">Changelog <span>{total} commits</span>{pages > 1 && <span>page {n} of {pages}</span>}</p> + <h1>Every change, and what exactly changed</h1> + {n === 1 && <p class="lede">Everything done to the index, newest first: new history, rescored countries, rewritten articles, new datasets and changes to the site itself. Open one to see it word by word. Only the changes that moved a country's score are on <a href={`${base}score-changes/`}>Score changes</a>.</p>} + </section> + + {local && ( + <section class="section gl-month gl-uncommitted"> + <h2 class="d-h">Not committed yet <span>only on this computer</span></h2> + {import.meta.env.DEV && <UncommittedReload at={local.date} />} + <ol class="gl-list"> + {[row(local)].map(({ c, url, sum }) => ( + <li class="gl-local"> + <a href={url}> + <span class="gl-meta"><time datetime={c.date}>{when(c.date)}</time><code>uncommitted</code><span>{c.files.length} {c.files.length === 1 ? "file" : "files"}</span></span> + <b>{c.subject}</b> + <span class="gl-sum">{sum.map(([k, what]) => <span><em>{k}</em> {what}</span>)}</span> + <span class="gl-lines"><ins>+{c.add.toLocaleString("en-GB")}</ins> <del>−{c.del.toLocaleString("en-GB")}</del></span> + </a> + </li> + ))} + </ol> + </section> + )} + + {months.map((g) => ( + <section class="section gl-month"> + <h2 class="d-h">{g.label} <span>{g.items.length} {g.items.length === 1 ? "commit" : "commits"}{n > 1 || pages > 1 ? " on this page" : ""}</span></h2> + <ol class="gl-list"> + {g.items.map(row).map(({ c, url, sum }) => ( + <li> + <a href={url}> + <span class="gl-meta"><time datetime={c.date}>{when(c.date)}</time><code>{c.short}</code><span class="gl-author">{byline(c)}</span></span> + <b>{c.subject}</b> + <span class="gl-sum">{sum.map(([k, what]) => <span><em>{k}</em> {what}</span>)}</span> + <span class="gl-lines"><ins>+{c.add.toLocaleString("en-GB")}</ins> <del>−{c.del.toLocaleString("en-GB")}</del></span> + </a> + </li> + ))} + </ol> + </section> + ))} + + {pages > 1 && ( + <nav class="section gl-pages" aria-label="Changelog pages"> + <p class="gl-range">Commits {first}–{last} of {total}</p> + <div> + {n > 1 ? <a class="btn" href={pageUrl(n - 1)} rel="prev">← Newer</a> : <span class="btn" aria-disabled="true">← Newer</span>} + <ol> + {shown.map((k, i) => ( + <> + {i > 0 && k - shown[i - 1] > 1 && <li class="gl-gap" aria-hidden="true">…</li>} + <li>{k === n ? <span aria-current="page">{k}</span> : <a href={pageUrl(k)}>{k}</a>}</li> + </> + ))} + </ol> + {n < pages ? <a class="btn" href={pageUrl(n + 1)} rel="next">Older →</a> : <span class="btn" aria-disabled="true">Older →</span>} + </div> + </nav> + )} + </main> +</Base>
src/lib/gitlog.js +25 −5
@@ -167,11 +167,13 @@ function workOut(c) { } -// every commit, newest first: { hash, short, date, subject, body, parent, files, add, del } +// every commit, newest first: { hash, short, date, author, coauthors, subject, body, parent, files, add, del } +// `coauthors` are the names in the commit's Co-Authored-By lines, without their addresses function readLog() { const SEP = "\u001e", END = "\u001f"; - const out = git("log", "--no-renames", "--numstat", `--format=${END}%H${SEP}%P${SEP}%aI${SEP}%s${SEP}%b${SEP}`); + const out = git("log", "--no-renames", "--numstat", `--format=${END}%H${SEP}%P${SEP}%aI${SEP}%an${SEP}%s${SEP}%b${SEP}`); if (!out) return []; return out.split(END).filter(Boolean).map((chunk) => { - const [hash, parents, date, subject, body, stat] = chunk.split(SEP); + const [hash, parents, date, author, subject, body, stat] = chunk.split(SEP); + const coauthors = [...new Set([...(body ?? "").matchAll(/^Co-Authored-By:\s*(.+?)\s*(?:<[^>]*>)?\s*$/gim)].map((m) => m[1]))]; const files = (stat ?? "").trim().split("\n").filter(Boolean).map((l) => { const [add, del, ...f] = l.split("\t"); @@ -179,5 +181,5 @@ function readLog() { }); return { - hash, short: hash.slice(0, 7), date, subject: subject.trim(), body: (body ?? "").replace(/\n?Co-Authored-By:.*$/gim, "").trim(), + hash, short: hash.slice(0, 7), date, author: author.trim(), coauthors, subject: subject.trim(), body: (body ?? "").replace(/\n?Co-Authored-By:.*$/gim, "").trim(), parent: parents.split(" ")[0] || null, files, add: files.reduce((s, f) => s + f.add, 0), del: files.reduce((s, f) => s + f.del, 0), @@ -191,4 +193,9 @@ function readLog() { // started. The changelog pages call reloadUncommitted() on each request, and the page has a Reload // button for when it is left open while files change. +// now, as an ISO date with this computer's time zone, like git writes a commit's date +const localIso = (d = new Date()) => { + const off = -d.getTimezoneOffset(), pad = (x) => String(Math.floor(Math.abs(x))).padStart(2, "0"); + return new Date(d.getTime() + off * 6e4).toISOString().slice(0, 19) + (off >= 0 ? "+" : "-") + pad(off / 60) + ":" + pad(off % 60); +}; function uncommitted() { if (process.env.CI) return []; @@ -205,5 +212,5 @@ function uncommitted() { if (!files.length) return []; return [{ - hash: WORKTREE, short: "uncommitted", date: new Date().toISOString(), subject: "Uncommitted changes", body: "Work on this computer that is not committed yet. Shown only in local builds.", + hash: WORKTREE, short: "uncommitted", date: localIso(), author: git("config", "user.name")?.trim() || "this computer", coauthors: [], subject: "Uncommitted changes", body: "Work on this computer that is not committed yet. Shown only in local builds.", parent: head, files, add: files.reduce((s, f) => s + f.add, 0), del: files.reduce((s, f) => s + f.del, 0), local: true, }]; @@ -227,4 +234,17 @@ export function reloadUncommitted() { export { EMPTY as emptyGroups }; +// The changelog in pages of PAGE_SIZE commits, newest first; the uncommitted changes are kept apart +export const PAGE_SIZE = 50; +export const committed = () => commits.filter((c) => !c.local); +export const pageCount = () => Math.max(1, Math.ceil(committed().length / PAGE_SIZE)); +export const pageOf = (n) => committed().slice((n - 1) * PAGE_SIZE, n * PAGE_SIZE); +// "27 October 2026, 16:51" in the author's own time zone, as git recorded it +export const when = (iso) => { + const d = new Date(`${iso.slice(0, 10)}T12:00:00Z`).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }); + return `${d}, ${iso.slice(11, 16)}`; +}; +// "tree, with Claude Opus 5.5" +export const byline = (c) => [c.author, ...(c.coauthors?.length ? [`with ${c.coauthors.join(" and ")}`] : [])].join(", "); + // one line of what a commit did: [[count, label, the id of its section on the commit's page]] export function summaryOf(c) {
src/pages/changelog/[hash].astro +2 −2
@@ -3,5 +3,5 @@ import Base from "../../layouts/Base.astro"; import UncommittedReload from "../../components/UncommittedReload.astro"; -import { commits, emptyGroups, reloadUncommitted, summaryOf } from "../../lib/gitlog.js"; +import { commits, emptyGroups, reloadUncommitted, summaryOf, when, byline } from "../../lib/gitlog.js"; import { formatDate, places, changes, SEGMENTS, segmentPoints } from "../../lib/countries.js"; import { cultureEntries } from "../../lib/culture.js"; @@ -52,5 +52,5 @@ const moves = g.places.filter((p) => p.scores?.length || (p.status === "added" & <main class="changes-page gl gl-commit"> <section class="hero hero-sm"> - <p class="eyebrow"><a href={`${base}changelog/`}>Changelog</a> <span><time datetime={c.date}>{formatDate(c.date.slice(0, 10))}</time></span> <span>{c.short}</span></p> + <p class="eyebrow"><a href={`${base}changelog/`}>Changelog</a> <span><time datetime={c.date}>{when(c.date)}</time></span> <span>{c.short}</span>{!c.local && c.author && <span>{byline(c)}</span>}</p> <h1>{c.subject}</h1> {c.body && <p class="lede gl-body">{c.body}</p>}
src/pages/changelog/index.astro +7 −43
@@ -1,10 +1,9 @@ --- -// Every change made to the site, newest first: one row per commit, with what it did in plain words. -// Each opens a page showing exactly what changed (src/lib/gitlog.js). The changes to countries' -// scores alone are on /score-changes/. -import Base from "../../layouts/Base.astro"; -import UncommittedReload from "../../components/UncommittedReload.astro"; -import { commits, reloadUncommitted, summaryOf } from "../../lib/gitlog.js"; -import { formatDate } from "../../lib/countries.js"; +// Every change made to the site, newest first, in pages (src/components/ChangelogPage.astro); this +// is the first page, with the uncommitted changes of a local build above the commits. Each commit +// opens a page showing exactly what changed (src/lib/gitlog.js). The changes to countries' scores +// alone are on /score-changes/. +import ChangelogPage from "../../components/ChangelogPage.astro"; +import { reloadUncommitted } from "../../lib/gitlog.js"; if (import.meta.env.DEV) { @@ -12,39 +11,4 @@ if (import.meta.env.DEV) { reloadUncommitted(); } -const local = commits.find((c) => c.local); -const base = import.meta.env.BASE_URL; -// grouped by month, newest first -const months = []; -for (const c of commits) { - const m = c.date.slice(0, 7); - if (months.at(-1)?.m !== m) months.push({ m, label: new Date(`${m}-15`).toLocaleDateString("en-GB", { month: "long", year: "numeric" }), items: [] }); - months.at(-1).items.push(c); -} --- -<Base title="Changelog" description="Every change made to the Cannabis Freedom Index, its data and its texts, newest first, each with exactly what changed."> - <main class="changes-page gl"> - <section class="hero hero-sm"> - <p class="eyebrow">Changelog <span>{commits.length} changes</span></p> - <h1>Every change, and what exactly changed</h1> - <p class="lede">Everything done to the index, newest first: new history, rescored countries, rewritten articles, new datasets and changes to the site itself. Open one to see it word by word. Only the changes that moved a country's score are on <a href={`${base}score-changes/`}>Score changes</a>.</p> - {import.meta.env.DEV && local && <UncommittedReload at={local.date} />} - </section> - {months.map((g) => ( - <section class="section gl-month"> - <h2 class="d-h">{g.label} <span>{g.items.length} {g.items.length === 1 ? "change" : "changes"}</span></h2> - <ol class="gl-list"> - {g.items.map((c) => ( - <li class:list={{ "gl-local": c.local }}> - <a href={`${base}changelog/${c.short}/`}> - <time datetime={c.date}>{formatDate(c.date.slice(0, 10))}</time> - <b>{c.subject}</b> - <span class="gl-sum">{summaryOf(c).map(([n, what]) => <span><em>{n}</em> {what}</span>)}</span> - <span class="gl-lines"><ins>+{c.add.toLocaleString("en-GB")}</ins> <del>−{c.del.toLocaleString("en-GB")}</del></span> - </a> - </li> - ))} - </ol> - </section> - ))} - </main> -</Base> +<ChangelogPage n={1} />
src/pages/changelog/page/[n].astro +11 −0
@@ -0,0 +1,11 @@ +--- +// The changelog's later pages, /changelog/page/2/ and on; the first page is /changelog/ itself. +import ChangelogPage from "../../../components/ChangelogPage.astro"; +import { pageCount } from "../../../lib/gitlog.js"; + +export function getStaticPaths() { + return Array.from({ length: pageCount() - 1 }, (_, i) => ({ params: { n: String(i + 2) }, props: { n: i + 2 } })); +} +const { n } = Astro.props; +--- +<ChangelogPage n={n} />
src/styles/global.css +19 −4
@@ -3091,7 +3091,10 @@ a.ch-db-track:hover { background: color-mix(in srgb, var(--text) 6%, transparent .gl-commit .eyebrow a:hover { color: var(--text); } .gl-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border); } -.gl-list a { display: grid; grid-template-columns: 120px minmax(0, 1fr) auto; gap: 4px 18px; padding: 14px 0; border-bottom: 1px solid var(--border); color: inherit; text-decoration: none; } +.gl-list a { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 18px; padding: 14px 0; border-bottom: 1px solid var(--border); color: inherit; text-decoration: none; } .gl-list a:hover b { text-decoration: underline; text-underline-offset: 3px; } -.gl-list time { font: 400 12px var(--mono); color: var(--muted); padding-top: 4px; } +/* the line above each commit: time, short hash, author */ +.gl-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; font: 400 12px var(--mono); color: var(--muted); } +.gl-meta code { padding: 0 5px; border: 1px solid var(--border); border-radius: 2px; color: var(--text-2); font: inherit; } +.gl-author { color: var(--text-2); } .gl-list b { font: 400 22px/1.2 var(--display); } .gl-local a { border-left: 3px dashed #f0b54a; padding-left: 12px; } @@ -3100,6 +3103,18 @@ a.ch-db-track:hover { background: color-mix(in srgb, var(--text) 6%, transparent .gl-asof .btn { padding: 5px 12px; font-size: 13px; } .gl-asof .btn:disabled { opacity: .6; cursor: progress; } -.gl-list .gl-sum { grid-column: 2; } -.gl-list .gl-lines { grid-row: 1; grid-column: 3; } +.gl-list .gl-sum { grid-column: 1; } +.gl-list .gl-lines { grid-row: 2; grid-column: 2; align-self: center; } +/* the uncommitted changes, kept apart above the commits */ +.gl-uncommitted .d-h { color: #f0b54a; } +/* page links under the list */ +.gl-pages { display: grid; gap: 10px; justify-items: center; } +.gl-pages > div { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px; } +.gl-pages ol { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; padding: 0; list-style: none; } +.gl-pages ol a, .gl-pages ol span { display: inline-block; min-width: 34px; padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--r); text-align: center; font: 400 13px var(--mono); color: var(--text-2); text-decoration: none; } +.gl-pages ol a:hover { color: var(--text); border-color: var(--muted); } +.gl-pages [aria-current="page"] { color: var(--bg); background: var(--text); border-color: var(--text); } +.gl-pages .gl-gap { padding: 5px 2px; color: var(--muted); } +.gl-pages .btn[aria-disabled="true"] { opacity: .4; pointer-events: none; } +.gl-range { margin: 0; font: 400 12px var(--mono); color: var(--muted); } .gl-sum { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13.5px; color: var(--text-2); } .gl-sum em { font-style: normal; color: var(--text); font-variant-numeric: tabular-nums; }