Cannabis Freedom Index

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; }

    ↑↓ to move, ↵ to open.