Hub: the Kanban and the Journal tabs, and /hub/journal.json

Kanban: every open issue and pull request and what closed in the last seven days, placed in To do,
In progress, In review and Done by the rules of Kira's work board, each card with its roadmap item
(its roadmap:<id> label or the item linking it) and a filter by item. Journal: every release with its
changes grouped by roadmap item, the merges no release has yet, and a weekly summary from
roadmap/journal/<year>-<week>.md. The same journal is /hub/journal.json, for Kira's #updates and posts
elsewhere: each entry names the roadmap items it moved and shipped, with their 240-character text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-25 15:35:27 +01:00
parent 68234ac407
commit c04b7afdb9
12 changed files with 435 additions and 7 deletions
+23 -1
View File
@@ -50,8 +50,30 @@ Whoever moves the work (the orchestrator on each heartbeat, or anyone in their p
item, updates their colour (by status) and description, and adds the label to the open pull requests an item
links. Label a new pull request for an item by hand or by linking it and running `labels` again. Labels of
removed items are listed, never deleted.
5. Suggestions from the website and Discord are read and triaged by the maintainers. A good one becomes an
5. Once a week, write the week's summary in `roadmap/journal/<year>-<ISO week>.md` (for example
`2026-43.md`): `# Title`, a paragraph or two for players, then `## Technical` and the same week for
contributors, with its pull requests. `build` puts it into website/data/roadmap.json; the hub's Journal tab
shows it among the releases, and `/hub/journal.json` carries it to Kira's #updates and to posts elsewhere.
6. Suggestions from the website and Discord are read and triaged by the maintainers. A good one becomes an
item in a normal pull request; none is published automatically.
CI runs `tests/test_roadmap.py` (in the Python tests), which fails when ROADMAP.md is not what `build` writes
or when the file breaks a rule; `python3 tools/roadmap.py check --offline` runs the same rules without GitHub.
## The journal
The hub's Journal tab and `https://playhorizon.dev/hub/journal.json` are built with each deploy of the website
(`website/src/hub.js`, `journalFrom`): every game and launcher release with its notes' changes, each placed under
the roadmap item it moves (the pull request's `roadmap:<id>` label, else the item that links it); the merged pull
requests no release has yet; and the weekly summaries above. Its JSON, newest first:
```json
{ "format": "horizon-journal", "version": 1, "generated": "…", "url": "https://playhorizon.dev/hub#journal",
"entries": [ { "id": "v0.2.57", "kind": "release | launcher | merged | week", "date": "…", "title": "…",
"plain": "one line for players", "url": "…", "dev": "weeks only",
"changes": [ { "section": "new | fixed | merged", "text": "…", "prs": [1787], "item": "discord-presence" } ],
"items": [ { "id", "title", "plain", "post" } ], // roadmap items the entry moved
"shipped": [ { "id", "title", "plain", "post" } ] } ] } // items that shipped in this release
```
`post` is at most 240 characters, ready to announce as is. Readers should remember the ids they have posted.
+17
View File
@@ -0,0 +1,17 @@
# Single player becomes a server, servers send their packs
Single player now runs on its own server thread, as vanilla does, and a run of fixes made shields, tridents,
elytra, maps, fishing, signs and books work there and for every server player. Servers can send you their
resource packs, the launcher got its blocky new look, and the first pieces of Horizon's mod registry landed.
Shader packs were taken out, to come back written from scratch.
## Technical
The server thread became the default (#1514), after guests and server-thread players reached parity: totems
(#1463), death drops (#1464), signs and books (#1465, #1466), shields, tridents, crossbows and spyglasses
(#1476), elytra (#1475), maps (#1510), fishing (#1509), wind charges (#1508), strikes and mob effects (#1512),
the cursor stack on close (#1513). Server resource packs: the dedicated server offers them (#1562) and the
client downloads, asks and applies them (#1569, #1570). Mod registry: index schema 1 and `.hpack` (#1634),
`cargo horizon-mod check` (#1642), the submission service (#1659). Shader packs were removed (#1561) and the
clean-room rewrite started from the format's documentation (#1584 onward). Releases now ship in batches of five
pull requests, or six hours after the oldest (#1554).
+17
View File
@@ -171,6 +171,23 @@ class GitHubState(unittest.TestCase):
self.assertTrue(any("#10 is neither" in p for p in problems))
class Journal(unittest.TestCase):
def test_weekly_summaries_newest_first_with_their_monday(self):
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as folder:
folder = Path(folder)
(folder / "2026-43.md").write_text("# A week\n\nPlain\nwords.\n\n## Technical\n\nDev (#1).\n")
(folder / "2026-42.md").write_text("# Older\n\nPlain.\n\n## Technical\n\nDev.\n")
(folder / "week-44.md").write_text("# Bad name\n")
(folder / "2026-45.md").write_text("# No technical part\n\nPlain.\n")
entries, problems = roadmap.weeks(folder)
self.assertEqual([e["week"] for e in entries], ["2026-43", "2026-42"])
self.assertEqual(entries[0], {"week": "2026-43", "date": "2026-10-19", "title": "A week",
"plain": "Plain words.", "dev": "Dev (#1)."})
self.assertEqual(len(problems), 2)
class Labels(unittest.TestCase):
def test_creates_updates_and_adds_labels(self):
data = tomllib.loads(SAMPLE)
+24
View File
@@ -30,6 +30,9 @@ SOURCE = ROOT / "roadmap" / "roadmap.toml"
OUTPUT = ROOT / "ROADMAP.md"
# What the website's hub reads (website/build.mjs), and serves at /data/roadmap.json for Kira and others.
SITE_DATA = ROOT / "website" / "data" / "roadmap.json"
# The journal's weekly summaries, one file per ISO week: roadmap/journal/2026-43.md (roadmap/README.md).
JOURNAL = ROOT / "roadmap" / "journal"
WEEK = re.compile(r"(\d{4})-(\d{2})\.md$")
REPO = "ludovic111/horizon"
SITE = "https://playhorizon.dev/hub"
@@ -153,6 +156,7 @@ def validate(data, root=ROOT):
continue
if link.startswith(("/", "http:")) or ".." in link or not (root / link).exists():
problems.append(f"{where}: link {link!r} is neither #N, https:// nor a path in the repository")
problems += weeks()[1] if root == ROOT else []
focus = sum(1 for item in items if item.get("focus"))
low, high = FOCUS_RANGE
if not low <= focus <= high:
@@ -234,6 +238,25 @@ def render(data):
return "\n".join(out) + "\n"
def weeks(directory=JOURNAL):
"""The weekly summaries, newest first: {"week", "date" (its Monday), "title", "plain", "dev"}, and problems."""
entries, problems = [], []
for path in sorted(directory.glob("*.md"), reverse=True) if directory.exists() else []:
name = WEEK.match(path.name)
text = path.read_text()
parts = re.match(r"# (.+)\n\n(.+?)\n\n## Technical\n\n(.+)", text.strip() + "\n", re.S)
if not name or not 1 <= int(name[2]) <= 53:
problems.append(f"journal/{path.name}: name it by its ISO week, like 2026-43.md")
continue
if not parts:
problems.append(f"journal/{path.name}: needs '# Title', the plain text, then '## Technical' and its text")
continue
monday = datetime.date.fromisocalendar(int(name[1]), int(name[2]), 1)
entries.append({"week": path.stem, "date": monday.isoformat(), "title": parts[1].strip(),
"plain": " ".join(parts[2].split()), "dev": " ".join(parts[3].split())})
return entries, problems
def site_data(data):
"""website/data/roadmap.json: the items as the hub and Kira read them, in the file's order."""
keys = ("id", "title", "status", "area", "plain", "dev", "links", "focus", "ongoing", "shipped_in", "post",
@@ -247,6 +270,7 @@ def site_data(data):
"areas": [{"id": key, "name": name} for key, name in areas_in_order(data)],
"items": [{key: (" ".join(item[key].split()) if isinstance(item[key], str) else item[key])
for key in keys if key in item} for item in data.get("item", [])],
"journal": weeks()[0],
}
return json.dumps(out, indent=1, ensure_ascii=False) + "\n"
+2 -2
View File
@@ -25,7 +25,7 @@ src/404.html the not-found page
src/privacy.html what the launcher can send when players allow it, retention, deletion (/privacy)
src/releases.html every release, newest first, with what it adds and fixes, its notes and downloads (/releases)
src/benchmarks.html Horizon against vanilla 26.3 on our machines, the hardware picker, players' results (/benchmarks)
src/hub.html the hub (/hub, /roadmap redirects to its Roadmap tab): Overview, Roadmap, Contribute, a Simple / Technical switch
src/hub.html the hub (/hub, /roadmap redirects to its Roadmap tab): Overview, Roadmap, Kanban, Journal, Contribute, a Simple / Technical switch
src/docs.html the template of every documentation page (/docs/...), with its left navigation
src/tokens.css the design tokens, generated from crates/horizon-theme (do not edit)
src/style.css every style, on top of the tokens
@@ -40,7 +40,7 @@ src/estimate.js estimates for unmeasured hardware from the nearest measured m
src/markdown.js a small Markdown renderer (escapes everything), shared by build and page
src/releases.js the home page's "Recently added" and the Releases page's markup, shared by build and page
src/contributors.js the contributors' filtering and markup, shared by build and page
src/hub.js the hub's markup from data/roadmap.json and GitHub's issue states, shared by build and page
src/hub.js the hub's roadmap, board and journal from data/roadmap.json, GitHub's issues and the releases, shared by build and page; /hub/journal.json
src/hub-page.js the hub page: tabs by #hash, the Simple / Technical switch (localStorage), the area filter, live states
data/roadmap.json the roadmap, generated by tools/roadmap.py from roadmap/roadmap.toml (do not edit); served at /data/roadmap.json
public/ fonts, screenshots, icons, og.png, robots.txt, benchmarks.json: copied as is (sitemap.xml is written by the build)
+9 -2
View File
@@ -16,7 +16,7 @@ import { machinesHtml, playersHtml } from "./src/bench.js";
import { CPUS, GPUS, singleThread } from "./src/hardware.js";
import { isGame, isLauncher, launcherVersion, newestFirst } from "./src/versions.js";
import { contributorsFrom, contributorsHtml } from "./src/contributors.js";
import { areaFilterHtml, helpWantedHtml, overviewHtml, roadmapHtml, slimIssue } from "./src/hub.js";
import { areaFilterHtml, boardFilterHtml, boardFrom, boardHtml, helpWantedHtml, journalFrom, journalHtml, journalJson, overviewHtml, roadmapHtml, slimIssue } from "./src/hub.js";
const site = dirname(fileURLToPath(import.meta.url));
const dist = join(site, "dist");
@@ -116,6 +116,8 @@ try {
console.warn(`hub: ${e.message}; the page will load the states itself`);
}
const hubLinked = new Map(hubLive.filter((i) => linked.has(i.n)).map((i) => [i.n, i]));
const board = boardFrom(hubLive, roadmap);
const journal = journalFrom(releases, hubLive, roadmap);
// --- The parity table ------------------------------------------------------------------
@@ -327,6 +329,9 @@ const values = {
HUB_ROADMAP: roadmapHtml(roadmap, hubLinked),
HUB_AREAS: areaFilterHtml(roadmap),
HUB_HELP_WANTED: helpWantedHtml(roadmap),
HUB_BOARD: hubLive.length ? boardHtml(board, roadmap) : `<p class="lede">The board could not be loaded here.</p>`,
HUB_BOARD_FILTER: boardFilterHtml(board, roadmap),
HUB_JOURNAL: journalHtml(journal, roadmap) || unavailable,
HUB_DATA: JSON.stringify({ roadmap, live: [...hubLinked.values()] }).replace(/</g, "\\u003c"),
YEAR: String(new Date().getUTCFullYear()),
};
@@ -431,6 +436,8 @@ for (const { file, values: own } of docPages) {
// The roadmap for other readers (Kira, scripts): /data/roadmap.json, as tools/roadmap.py wrote it.
await mkdir(join(dist, "data"), { recursive: true });
await cp(join(site, "data", "roadmap.json"), join(dist, "data", "roadmap.json"));
// The journal for Kira's #updates and its X posts: /data/journal.json, rebuilt with every deploy.
await writeFile(join(dist, "data", "journal.json"), JSON.stringify(journalJson(journal, roadmap), null, 1) + "\n");
// The pages' inline scripts (they mark the page as scripted and apply the saved theme before CSS
// applies) and the inlined stylesheet: the Content-Security-Policy in the Caddyfile allows them by hash.
@@ -447,5 +454,5 @@ await writeFile(
console.log(
`built dist/ from ${readmeFrom}: ${body.length} subsystems, ${percent}, ${harnesses} harnesses, ` +
`${ours.length} releases (${game.length} game)${launcher ? `, launcher ${launcher.tag_name}` : ""}, ${benchSummary.groups.length} benchmark rows, ${contributors.length} contributors, ${docs.length} docs pages, ${roadmap.items.length} roadmap items (${hubLinked.size}/${linked.size} links live)`,
`${ours.length} releases (${game.length} game)${launcher ? `, launcher ${launcher.tag_name}` : ""}, ${benchSummary.groups.length} benchmark rows, ${contributors.length} contributors, ${docs.length} docs pages, ${roadmap.items.length} roadmap items (${hubLinked.size}/${linked.size} links live), ${Object.values(board).flat().length} board cards, ${journal.length} journal entries`,
);
+9
View File
@@ -558,5 +558,14 @@
],
"shipped_in": "launcher-v0.15.0"
}
],
"journal": [
{
"week": "2026-43",
"date": "2026-10-19",
"title": "Single player becomes a server, servers send their packs",
"plain": "Single player now runs on its own server thread, as vanilla does, and a run of fixes made shields, tridents, elytra, maps, fishing, signs and books work there and for every server player. Servers can send you their resource packs, the launcher got its blocky new look, and the first pieces of Horizon's mod registry landed. Shader packs were taken out, to come back written from scratch.",
"dev": "The server thread became the default (#1514), after guests and server-thread players reached parity: totems (#1463), death drops (#1464), signs and books (#1465, #1466), shields, tridents, crossbows and spyglasses (#1476), elytra (#1475), maps (#1510), fishing (#1509), wind charges (#1508), strikes and mob effects (#1512), the cursor stack on close (#1513). Server resource packs: the dedicated server offers them (#1562) and the client downloads, asks and applies them (#1569, #1570). Mod registry: index schema 1 and `.hpack` (#1634), `cargo horizon-mod check` (#1642), the submission service (#1659). Shader packs were removed (#1561) and the clean-room rewrite started from the format's documentation (#1584 onward). Releases now ship in batches of five pull requests, or six hours after the oldest (#1554)."
}
]
}
+32 -1
View File
@@ -3,7 +3,7 @@
// linked issue and pull request, refreshed from GitHub's public API. Without JavaScript, every tab reads
// one after the other with what was baked at build time.
import { cached, get, setUpMenu, setUpTheme } from "./chrome.js";
import { REPO, overviewHtml, roadmapHtml, slimIssue } from "./hub.js";
import { REPO, boardFilterHtml, boardFrom, boardHtml, journalFrom, journalHtml, overviewHtml, roadmapHtml, slimIssue } from "./hub.js";
const root = document.documentElement;
const data = JSON.parse(document.getElementById("hub-data").textContent);
@@ -87,6 +87,28 @@ function filter(to) {
}
areaButtons.forEach((b) => b.addEventListener("click", () => filter(b.dataset.areaFilter)));
// --- The board: a roadmap item's cards, and the long columns' "Show more" ----------------------------------------
const boardSelect = document.querySelector("[data-board-item]");
function showItem() {
const want = boardSelect.value;
for (const card of document.querySelectorAll("[data-board] .hub-card")) {
card.hidden = want === "-" ? !card.dataset.item : want ? card.dataset.item !== want : false;
}
for (const column of document.querySelectorAll("[data-board] .hub-column")) {
column.querySelector("[data-count]").textContent = column.querySelectorAll(".hub-card:not([hidden])").length;
// A filtered column shows all its cards.
column.classList.toggle("is-open", Boolean(want) || column.classList.contains("was-opened"));
}
}
boardSelect?.addEventListener("change", showItem);
document.querySelector("[data-board]")?.addEventListener("click", (e) => {
const button = e.target.closest("[data-more-button]");
if (!button) return;
button.closest(".hub-column").classList.add("is-open", "was-opened");
button.remove();
});
// --- Live state from GitHub ------------------------------------------------------------------------------------
// The open issues and pull requests, and those closed or merged lately: four requests at most, kept ten minutes.
@@ -100,6 +122,7 @@ async function loadLive() {
const closed = await get(`https://api.github.com/repos/${REPO}/issues?state=closed&sort=updated&direction=desc&per_page=100`);
return list.concat(closed.map(slimIssue));
}
const loadReleases = () => get(`https://api.github.com/repos/${REPO}/releases?per_page=40`);
async function refresh() {
const fresh = await cached("horizon:hub-live", loadLive).catch(() => null);
@@ -109,6 +132,14 @@ async function refresh() {
document.querySelector("[data-overview]").innerHTML = overviewHtml(data.roadmap, live);
document.querySelector("[data-roadmap]").innerHTML = roadmapHtml(data.roadmap, live);
filter(area);
const board = boardFrom(fresh, data.roadmap);
const chosen = boardSelect.value;
boardSelect.innerHTML = boardFilterHtml(board, data.roadmap);
boardSelect.value = [...boardSelect.options].some((o) => o.value === chosen) ? chosen : "";
document.querySelector("[data-board]").innerHTML = boardHtml(board, data.roadmap);
showItem();
const releases = await cached("horizon:hub-releases", loadReleases).catch(() => null);
if (releases) document.querySelector("[data-journal]").innerHTML = journalHtml(journalFrom(releases, fresh, data.roadmap), data.roadmap);
const note = document.querySelector("[data-live-note]");
if (note) note.textContent = "Pull request and issue states are live from GitHub.";
}
+19
View File
@@ -36,6 +36,8 @@
<span class="tab-pill" aria-hidden="true"></span>
<button type="button" role="tab" id="tab-overview" aria-controls="overview" aria-selected="true">Overview</button>
<button type="button" role="tab" id="tab-roadmap" aria-controls="roadmap" aria-selected="false" tabindex="-1">Roadmap</button>
<button type="button" role="tab" id="tab-board" aria-controls="board" aria-selected="false" tabindex="-1">Kanban</button>
<button type="button" role="tab" id="tab-journal" aria-controls="journal" aria-selected="false" tabindex="-1">Journal</button>
<button type="button" role="tab" id="tab-contribute" aria-controls="contribute" aria-selected="false" tabindex="-1">Contribute</button>
</div>
<div class="hub-detail" role="group" aria-label="Detail">
@@ -58,6 +60,23 @@
<p class="meta hub-source">The roadmap lives in the repository: <a href="https://github.com/ludovic111/horizon/blob/main/ROADMAP.md">ROADMAP.md</a>, generated from <a href="https://github.com/ludovic111/horizon/blob/main/roadmap/roadmap.toml">roadmap/roadmap.toml</a>. Pull requests carry the <code>roadmap:&lt;id&gt;</code> label of their item. <span data-live-note></span></p>
</div>
<div class="hub-panel" role="tabpanel" id="board" aria-labelledby="tab-board">
<div class="hub-board-head">
<p class="hub-intro">Every open issue and pull request, placed by its state on GitHub, as on Kira's work board in Discord.</p>
<label class="hub-select"><span class="meta">Show</span><select data-board-item>{{HUB_BOARD_FILTER}}</select></label>
</div>
<div class="hub-board" data-board>
{{HUB_BOARD}}
</div>
</div>
<div class="hub-panel" role="tabpanel" id="journal" aria-labelledby="tab-journal">
<p class="hub-intro">Every release with what it changed, grouped by roadmap item in Technical, and a summary of each week. Also as <a href="/hub/journal.json">journal.json</a>.</p>
<div class="hub-journal" data-journal>
{{HUB_JOURNAL}}
</div>
</div>
<div class="hub-panel" role="tabpanel" id="contribute" aria-labelledby="tab-contribute">
<section class="hub-block" aria-labelledby="h-first">
<h2 class="title" id="h-first">Good first tasks</h2>
+198
View File
@@ -4,6 +4,8 @@
// twice: `plain` (.hub-plain) for players and `dev` (.hub-dev) for contributors; the page's Simple / Technical
// switch shows one of them.
import { escape, inline } from "./markdown.js";
import { parts, plainItem, sections } from "./releases.js";
import { isGame, isLauncher } from "./versions.js";
export const REPO = "ludovic111/horizon";
const issueUrl = (n) => `https://github.com/${REPO}/issues/${n}`;
@@ -173,3 +175,199 @@ export function helpWantedHtml(roadmap) {
})
.join("");
}
// --- The work board --------------------------------------------------------------------------------------------
export const COLUMNS = [
{ id: "todo", name: "To do", about: "Open and free: pick one, and say so with /claim on Discord." },
{ id: "progress", name: "In progress", about: "Claimed, assigned, or a draft pull request." },
{ id: "review", name: "In review", about: "A pull request is ready for review." },
{ id: "done", name: "Done", about: "Closed or merged in the last seven days." },
];
const DONE_DAYS = 7;
// The roadmap item an issue or pull request moves: its roadmap:<id> label, else the item linking it, else (for a
// pull request) the item linking an issue it fixes.
export function itemOf(issue, roadmap) {
const label = issue.labels.find((l) => l.startsWith("roadmap:"));
const ids = new Set(roadmap.items.map((i) => i.id));
if (label && ids.has(label.slice(8))) return label.slice(8);
const linking = (n) => roadmap.items.find((i) => (i.links || []).includes(`#${n}`))?.id;
return linking(issue.n) || issue.fixes.map(linking).find(Boolean) || null;
}
// The board from GitHub's open issues and pull requests and those closed lately, placed as Kira's work board places
// them: an issue goes to review when an open, ready pull request fixes it, to progress when a draft one does or it is
// claimed or assigned; a pull request that fixes no open issue is a card of its own.
export function boardFrom(list, roadmap, now = Date.now()) {
const columns = Object.fromEntries(COLUMNS.map((c) => [c.id, []]));
const open = list.filter((i) => i.state === "open");
const openIssues = new Set(open.filter((i) => i.kind === "issue").map((i) => i.n));
const fixing = (n) => open.filter((p) => p.kind === "pr" && p.fixes.includes(n));
const card = (i, prs = []) => ({ n: i.n, kind: i.kind, title: i.title, user: i.user, url: i.url, item: itemOf(i, roadmap) || prs.map((p) => itemOf(p, roadmap)).find(Boolean) || null, prs: prs.map((p) => p.n) });
for (const i of open) {
if (i.kind === "issue") {
const prs = fixing(i.n);
const claimed = i.assigned || i.labels.some((l) => /^(in progress|claimed)$/i.test(l));
const column = prs.some((p) => !p.draft) ? "review" : prs.length || claimed ? "progress" : "todo";
columns[column].push(card(i, prs));
} else if (!i.fixes.some((n) => openIssues.has(n))) {
columns[i.draft ? "progress" : "review"].push(card(i));
}
}
const since = now - DONE_DAYS * 864e5;
const seen = new Set();
for (const i of list) {
if (seen.has(i.n) || !["merged", "closed"].includes(i.state) || !(Date.parse(i.at) >= since)) continue;
seen.add(i.n);
columns.done.push(card(i));
}
columns.done.sort((a, b) => b.n - a.n);
return columns;
}
const SHOWN = 20; // cards per column before "Show more"
function cardHtml(c, roadmap, more) {
const item = c.item && roadmap.items.find((i) => i.id === c.item);
return `<li class="hub-card" data-item="${escape(c.item || "")}"${more ? " data-more" : ""}><a href="${escape(c.url)}"><span class="hub-card-ref">${c.kind === "pr" ? "PR" : "Issue"} #${c.n}${c.prs.length ? ` · ${c.prs.map((n) => `PR #${n}`).join(", ")}` : ""}</span><span class="hub-card-title">${escape(c.title)}</span><span class="hub-card-meta">${item ? `<span class="hub-card-item">${escape(item.title)}</span>` : ""}${c.user ? `<span>${escape(c.user)}</span>` : ""}</span></a></li>`;
}
export function boardHtml(columns, roadmap) {
return COLUMNS.map(({ id, name, about }) => {
const cards = columns[id];
return `<section class="hub-column" data-column="${id}" aria-labelledby="col-${id}">
<h3 id="col-${id}"><span>${escape(name)}</span><span class="hub-count" data-count>${cards.length}</span></h3>
<p class="meta">${escape(about)}</p>
<ol class="hub-cards-list">${cards.map((c, i) => cardHtml(c, roadmap, i >= SHOWN)).join("")}</ol>
${cards.length > SHOWN ? `<button type="button" class="button secondary small hub-more" data-more-button>Show ${cards.length - SHOWN} more</button>` : ""}
</section>`;
}).join("\n");
}
// The board's roadmap filter: every item that has a card.
export function boardFilterHtml(columns, roadmap) {
const used = new Set(Object.values(columns).flat().map((c) => c.item).filter(Boolean));
return [`<option value="">Every card</option>`, `<option value="-">Cards of a roadmap item</option>`]
.concat(roadmap.items.filter((i) => used.has(i.id)).map((i) => `<option value="${escape(i.id)}">${escape(i.title)}</option>`))
.join("");
}
// --- The journal -----------------------------------------------------------------------------------------------
export const JOURNAL_URL = "https://playhorizon.dev/hub#journal";
const ENTRIES = 40;
// The journal, newest first: each release with its changes grouped by roadmap item, the weekly summaries
// (roadmap/journal/), and what merged since the newest game release. releases: GitHub's release objects; list: the
// hub's issues and pull requests (their roadmap:<id> labels place changes the roadmap doesn't link).
export function journalFrom(releases, list, roadmap) {
const prs = new Map(list.filter((i) => i.kind === "pr").map((i) => [i.n, i]));
const placed = (numbers) => {
for (const n of numbers) {
const known = prs.get(n);
const id = known ? itemOf(known, roadmap) : roadmap.items.find((i) => (i.links || []).includes(`#${n}`))?.id;
if (id) return id;
}
return null;
};
const ours = releases.filter((r) => !r.draft && (isGame(r.tag_name) || isLauncher(r.tag_name)));
const entries = ours.map((r) => {
const changes = sections(r.body)
.filter((x) => /^(new|what's new|fixed)/i.test(x.title))
.flatMap((x) => x.items.map((text) => ({ section: /^fixed/i.test(x.title) ? "fixed" : "new", text })))
.map((c) => {
const numbers = [...c.text.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
return { ...c, text: plainItem(c.text), prs: numbers, item: placed(numbers) };
});
return {
id: r.tag_name,
kind: isLauncher(r.tag_name) ? "launcher" : "release",
date: r.published_at || "",
title: r.name || r.tag_name,
plain: parts(r).line,
url: r.html_url,
changes,
shipped: roadmap.items.filter((i) => i.status === "shipped" && i.shipped_in === r.tag_name).map((i) => i.id),
};
});
// Merged but in no release's notes yet: a release is cut from main a while after its last merge, so the newest
// releases' notes, not their dates, say what they hold. Pull requests players don't see (skip-release) never are.
const games = ours.filter((r) => isGame(r.tag_name)).map((r) => r.published_at).sort().reverse();
const since = games[Math.min(4, games.length - 1)] || "";
const noted = new Set(entries.flatMap((e) => e.changes.flatMap((c) => c.prs)));
const pending = list
.filter((i) => i.kind === "pr" && i.state === "merged" && i.at > since && !noted.has(i.n) && !i.labels.includes("skip-release"))
.sort((a, b) => b.at.localeCompare(a.at));
if (pending.length) {
entries.push({
id: "next-release",
kind: "merged",
date: pending[0].at,
title: "Merged, in the next release",
plain: `${pending.length} change${pending.length === 1 ? "" : "s"} merged since the last release.`,
url: `https://github.com/${REPO}/pulls?q=is%3Apr+is%3Amerged`,
changes: pending.map((p) => ({ section: "merged", text: p.title, prs: [p.n], item: itemOf(p, roadmap) })),
shipped: [],
});
}
for (const w of roadmap.journal || []) {
// A week's summary sits after its last day's releases.
const end = new Date(Date.parse(w.date) + 7 * 864e5 - 1).toISOString();
entries.push({ id: `week-${w.week}`, kind: "week", date: end, title: w.title, plain: w.plain, dev: w.dev, url: `${JOURNAL_URL.replace("#journal", "")}#week-${w.week}`, changes: [], shipped: [] });
}
return entries.sort((a, b) => b.date.localeCompare(a.date)).slice(0, ENTRIES);
}
// /hub/journal.json: the journal for Kira's #updates and posts elsewhere, with each shipped item's short text.
export function journalJson(entries, roadmap, generated = new Date().toISOString()) {
const item = (id) => {
const i = roadmap.items.find((x) => x.id === id);
return i && { id: i.id, title: i.title, plain: i.plain, post: i.post || i.plain };
};
return {
format: "horizon-journal",
version: 1,
generated,
url: JOURNAL_URL,
entries: entries.map((e) => ({ ...e, items: [...new Set(e.changes.map((c) => c.item).filter(Boolean))].map(item).filter(Boolean), shipped: e.shipped.map(item).filter(Boolean) })),
};
}
const KIND = { release: "Game", launcher: "Launcher", merged: "Merged", week: "This week" };
function changesHtml(entry, roadmap) {
if (!entry.changes.length) return "";
const line = (c) => `<li>${inline(c.text)}${c.prs.length ? ` ${c.prs.map((n) => `<a class="hub-ref" href="${issueUrl(n)}">#${n}</a>`).join(" ")}` : ""}</li>`;
// Simple: what is new, then how many fixes. Technical: every change, under the roadmap item it moves.
const added = entry.changes.filter((c) => c.section !== "fixed");
const fixes = entry.changes.length - added.length;
const simple = `<div class="hub-plain">${added.length ? `<ul class="hub-changes">${added.map((c) => `<li>${inline(c.text)}</li>`).join("")}</ul>` : ""}${fixes ? `<p class="meta">${added.length ? "And " : ""}${fixes} fix${fixes === 1 ? "" : "es"}.</p>` : ""}</div>`;
const groups = new Map();
for (const c of entry.changes) groups.set(c.item || "", [...(groups.get(c.item || "") || []), c]);
const order = [...groups.keys()].sort((a, b) => (a === "") - (b === ""));
const dev = `<div class="hub-dev">${order
.map((id) => {
const item = roadmap.items.find((i) => i.id === id);
return `<p class="hub-group">${item ? `<a href="#item-${escape(id)}">${escape(item.title)}</a>` : "Other changes"}</p><ul class="hub-changes">${groups.get(id).map(line).join("")}</ul>`;
})
.join("")}</div>`;
return simple + dev;
}
export function journalHtml(entries, roadmap) {
return entries
.map((e) => {
const shipped = e.shipped.map((id) => roadmap.items.find((i) => i.id === id)).filter(Boolean);
const items = [...new Set(e.changes.map((c) => c.item).filter(Boolean))].map((id) => roadmap.items.find((i) => i.id === id)).filter(Boolean);
return `<article class="hub-entry hub-entry-${e.kind}" id="${e.kind === "week" ? escape(e.id) : `journal-${escape(e.id.replace(/[^\w.-]/g, "-"))}`}">
<p class="hub-entry-meta"><span class="badge${e.kind === "release" ? " badge-game" : e.kind === "launcher" ? " badge-launcher" : ""}">${KIND[e.kind]}</span><time datetime="${escape(e.date)}">${escape(e.date.slice(0, 10))}</time></p>
<h3 class="hub-entry-title">${e.url ? `<a href="${escape(e.url)}">${escape(e.title)}</a>` : escape(e.title)}</h3>
${e.kind === "week" ? `<p class="hub-plain">${inline(e.plain)}</p><p class="hub-dev">${inline(e.dev.replace(/(^|[^\w[/`])#(\d+)\b/g, (_, b, n) => `${b}[#${n}](${issueUrl(n)})`))}</p>` : ""}
${shipped.length ? `<p class="hub-shipped">Shipped: ${shipped.map((i) => `<a href="#item-${escape(i.id)}">${escape(i.title)}</a>`).join(", ")}</p>` : ""}
${items.length ? `<p class="hub-entry-items hub-plain">${items.map((i) => `<a class="hub-link" href="#item-${escape(i.id)}">${escape(i.title)}</a>`).join("")}</p>` : ""}
${changesHtml(e, roadmap)}
</article>`;
})
.join("\n");
}
+46 -1
View File
@@ -1,7 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fixesIn, overviewHtml, progress, roadmapHtml, slimIssue } from "./hub.js";
import { boardFrom, fixesIn, itemOf, journalFrom, journalHtml, journalJson, overviewHtml, progress, roadmapHtml, slimIssue } from "./hub.js";
const roadmap = JSON.parse(readFileSync(new URL("../data/roadmap.json", import.meta.url), "utf8"));
@@ -53,3 +53,48 @@ test("the overview lists the focus items and never nests links", () => {
for (const item of roadmap.items.filter((i) => i.focus)) assert.ok(html.includes(`href="#${item.id}"`), item.id);
for (const row of html.match(/<li class="row">[\s\S]*?<\/li>/g)) assert.equal(row.split("<a ").length, 2, row);
});
test("the board places issues and pull requests as Kira's board does", () => {
const now = Date.parse("2030-01-10T00:00:00Z");
const list = [
issue(1),
issue(2),
issue(3, { assignees: [{ login: "x" }] }),
pr(10, { body: "Fixes #1" }),
pr(11, { body: "Fixes #2", draft: true }),
pr(12, { labels: [{ name: `roadmap:${roadmap.items[0].id}` }] }),
pr(13, { draft: true }),
pr(14, { state: "closed", pull_request: { merged_at: "2030-01-09T00:00:00Z" } }),
pr(15, { state: "closed", pull_request: { merged_at: "2029-12-01T00:00:00Z" } }),
].map(slimIssue);
const board = boardFrom(list, roadmap, now);
const ids = (column) => board[column].map((c) => c.n);
assert.deepEqual(ids("todo"), []);
assert.deepEqual(ids("progress"), [2, 3, 13]);
assert.deepEqual(ids("review"), [1, 12]);
assert.deepEqual(ids("done"), [14]);
assert.deepEqual(board.review[0].prs, [10]);
assert.equal(board.review[1].item, roadmap.items[0].id);
assert.equal(itemOf(slimIssue(pr(16, { body: "Fixes #1438" })), roadmap), "end-city-banners");
});
test("the journal groups a release's changes by roadmap item and lists what awaits a release", () => {
const releases = [
{ tag_name: "v0.2.2", name: "Horizon 0.2.2: Rich presence", published_at: "2030-01-02T00:00:00Z", html_url: "u2", body: "### New\n- Discord Rich Presence (#1787)\n\n### Fixed\n- A fix (#5, fixes #4)\n" },
{ tag_name: "launcher@0.2.1", name: "Horizon launcher 0.2.1", published_at: "2030-01-01T00:00:00Z", html_url: "u1", body: "### Fixed\n- Window (#6)\n" },
];
const list = [
pr(1788, { state: "closed", pull_request: { merged_at: "2030-01-03T00:00:00Z" } }),
pr(7, { state: "closed", pull_request: { merged_at: "2030-01-03T00:00:00Z" }, labels: [{ name: "skip-release" }] }),
].map(slimIssue);
const entries = journalFrom(releases, list, { ...roadmap, journal: [] });
assert.deepEqual(entries.map((e) => e.id), ["next-release", "v0.2.2", "launcher@0.2.1"]);
assert.deepEqual(entries[0].changes.map((c) => [c.prs, c.item]), [[[1788], "discord-presence"]]);
assert.deepEqual(entries[1].changes.map((c) => [c.section, c.text, c.item]), [["new", "Discord Rich Presence", "discord-presence"], ["fixed", "A fix", null]]);
const json = journalJson(entries, roadmap, "now");
assert.equal(json.format, "horizon-journal");
assert.equal(json.entries[1].items[0].post.length <= 240, true);
const html = journalHtml(entries, roadmap);
assert.ok(html.includes('href="#item-discord-presence"'));
assert.ok(html.includes("And 1 fix."));
});
+39
View File
@@ -783,7 +783,46 @@ code { border-radius: 0; background: var(--well); box-shadow: 0 0 0 1px var(--ou
.hub-rules li + li { margin-top: 10px; }
.hub-rules li::marker { color: var(--muted); font-family: var(--mono); }
.hub-source { margin-top: 40px; max-width: var(--measure); }
.hub-board-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 24px; }
.hub-board-head .hub-intro { margin-bottom: 0; }
.hub-select { display: inline-flex; align-items: center; gap: 10px; }
.hub-select select {
height: 32px; max-width: 22rem; padding: 0 10px; font: var(--text-label) / 1 var(--font); color: var(--ink);
background: var(--well); border: 0; box-shadow: 0 0 0 var(--block-outline) var(--outline);
}
.hub-board { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 24px; align-items: start; }
.hub-column { padding: 14px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); }
.hub-column h3 { display: flex; justify-content: space-between; align-items: baseline; font-size: var(--text-heading); }
.hub-count { font: var(--text-micro) / 1 var(--mono); color: var(--muted); }
.hub-column > .meta { margin: 6px 0 12px; }
.hub-cards-list { display: grid; gap: 6px; }
.js .hub-column:not(.is-open) .hub-card[data-more] { display: none; }
.hub-card a { display: grid; gap: 4px; padding: 10px 12px; background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); color: var(--ink); }
.hub-card a:hover { box-shadow: 0 0 0 var(--block-outline) var(--accent); }
.hub-card-ref, .hub-card-meta { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); }
.hub-card-title { font-size: var(--text-label); line-height: 1.4; overflow-wrap: anywhere; }
.hub-card-meta { display: flex; flex-wrap: wrap; gap: 4px 10px; }
.hub-card-item { color: var(--accent); }
.hub-more { margin-top: 10px; width: 100%; justify-content: center; }
.hub-journal { display: grid; gap: 8px; margin-top: 24px; max-width: 56rem; }
.hub-entry { display: grid; gap: 10px; padding: 18px 20px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); scroll-margin-top: calc(var(--nav) + 24px); }
.hub-entry-week { background: color-mix(in srgb, var(--accent) 8%, var(--panel)); }
.hub-entry-meta { display: flex; align-items: center; gap: 10px; font: var(--text-micro) / 1 var(--mono); color: var(--muted); }
.hub-entry-meta .badge { margin-right: 0; }
.hub-entry-title { font-size: var(--text-heading); line-height: var(--text-heading-line); }
.hub-entry-title a { color: var(--ink); }
.hub-entry-title a:hover { color: var(--accent); }
.hub-entry .hub-plain, .hub-entry .hub-dev { font-size: var(--text-ui); color: var(--ink-soft); }
.hub-entry-items { display: flex; flex-wrap: wrap; gap: 6px; }
.hub-shipped { font-size: var(--text-ui); color: var(--ink); }
.hub-changes { list-style: square; padding-left: 18px; font-size: var(--text-ui); color: var(--ink-soft); }
.hub-changes li + li { margin-top: 4px; }
.hub-ref { font: var(--text-micro) / 1 var(--mono); }
.hub-group { margin-top: 8px; font: 500 var(--text-label) / 1.4 var(--font); }
.hub-group:first-child { margin-top: 0; }
@media (max-width: 1100px) { .hub-board { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) {
.hub-board { grid-template-columns: minmax(0, 1fr); }
.hub-rows .row > a { grid-template-columns: var(--index) minmax(0, 1fr); }
.hub-rows .row-meta { grid-column: 2; text-align: left; }
}