From c04b7afdb9b515b57cde9459f2e01386e4e327bd Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:35:27 +0100 Subject: [PATCH] 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: 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/-.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 --- roadmap/README.md | 24 ++++- roadmap/journal/2026-43.md | 17 ++++ tests/test_roadmap.py | 17 ++++ tools/roadmap.py | 24 +++++ website/README.md | 4 +- website/build.mjs | 11 ++- website/data/roadmap.json | 9 ++ website/src/hub-page.js | 33 ++++++- website/src/hub.html | 19 ++++ website/src/hub.js | 198 +++++++++++++++++++++++++++++++++++++ website/src/hub.test.mjs | 47 ++++++++- website/src/style.css | 39 ++++++++ 12 files changed, 435 insertions(+), 7 deletions(-) create mode 100644 roadmap/journal/2026-43.md diff --git a/roadmap/README.md b/roadmap/README.md index 656303464..f16e83f8c 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -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/-.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:` 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. diff --git a/roadmap/journal/2026-43.md b/roadmap/journal/2026-43.md new file mode 100644 index 000000000..388f5d544 --- /dev/null +++ b/roadmap/journal/2026-43.md @@ -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). diff --git a/tests/test_roadmap.py b/tests/test_roadmap.py index 605843f25..6c7b14e31 100644 --- a/tests/test_roadmap.py +++ b/tests/test_roadmap.py @@ -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) diff --git a/tools/roadmap.py b/tools/roadmap.py index f09224147..ab0147e70 100644 --- a/tools/roadmap.py +++ b/tools/roadmap.py @@ -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" diff --git a/website/README.md b/website/README.md index 8f35fbcf4..56fa515b9 100644 --- a/website/README.md +++ b/website/README.md @@ -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) diff --git a/website/build.mjs b/website/build.mjs index 563bc6fa4..17768e5fc 100644 --- a/website/build.mjs +++ b/website/build.mjs @@ -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) : `

The board could not be loaded here.

`, + HUB_BOARD_FILTER: boardFilterHtml(board, roadmap), + HUB_JOURNAL: journalHtml(journal, roadmap) || unavailable, HUB_DATA: JSON.stringify({ roadmap, live: [...hubLinked.values()] }).replace(/ 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."; } diff --git a/website/src/hub.html b/website/src/hub.html index 5aff05639..a6e146080 100644 --- a/website/src/hub.html +++ b/website/src/hub.html @@ -36,6 +36,8 @@ + +
@@ -58,6 +60,23 @@

The roadmap lives in the repository: ROADMAP.md, generated from roadmap/roadmap.toml. Pull requests carry the roadmap:<id> label of their item.

+
+
+

Every open issue and pull request, placed by its state on GitHub, as on Kira's work board in Discord.

+ +
+
+{{HUB_BOARD}} +
+
+ +
+

Every release with what it changed, grouped by roadmap item in Technical, and a summary of each week. Also as journal.json.

+
+{{HUB_JOURNAL}} +
+
+

Good first tasks

diff --git a/website/src/hub.js b/website/src/hub.js index ecfb4da80..cda3b4e5b 100644 --- a/website/src/hub.js +++ b/website/src/hub.js @@ -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: 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 `
  • ${c.kind === "pr" ? "PR" : "Issue"} #${c.n}${c.prs.length ? ` · ${c.prs.map((n) => `PR #${n}`).join(", ")}` : ""}${escape(c.title)}${item ? `${escape(item.title)}` : ""}${c.user ? `${escape(c.user)}` : ""}
  • `; +} + +export function boardHtml(columns, roadmap) { + return COLUMNS.map(({ id, name, about }) => { + const cards = columns[id]; + return `
    +

    ${escape(name)}${cards.length}

    +

    ${escape(about)}

    +
      ${cards.map((c, i) => cardHtml(c, roadmap, i >= SHOWN)).join("")}
    + ${cards.length > SHOWN ? `` : ""} +
    `; + }).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 [``, ``] + .concat(roadmap.items.filter((i) => used.has(i.id)).map((i) => ``)) + .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: 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) => `
  • ${inline(c.text)}${c.prs.length ? ` ${c.prs.map((n) => `#${n}`).join(" ")}` : ""}
  • `; + // 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 = `
    ${added.length ? `
      ${added.map((c) => `
    • ${inline(c.text)}
    • `).join("")}
    ` : ""}${fixes ? `

    ${added.length ? "And " : ""}${fixes} fix${fixes === 1 ? "" : "es"}.

    ` : ""}
    `; + 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 = `
    ${order + .map((id) => { + const item = roadmap.items.find((i) => i.id === id); + return `

    ${item ? `${escape(item.title)}` : "Other changes"}

      ${groups.get(id).map(line).join("")}
    `; + }) + .join("")}
    `; + 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 `
    + +

    ${e.url ? `${escape(e.title)}` : escape(e.title)}

    + ${e.kind === "week" ? `

    ${inline(e.plain)}

    ${inline(e.dev.replace(/(^|[^\w[/`])#(\d+)\b/g, (_, b, n) => `${b}[#${n}](${issueUrl(n)})`))}

    ` : ""} + ${shipped.length ? `

    Shipped: ${shipped.map((i) => `${escape(i.title)}`).join(", ")}

    ` : ""} + ${items.length ? `

    ${items.map((i) => `${escape(i.title)}`).join("")}

    ` : ""} + ${changesHtml(e, roadmap)} +
    `; + }) + .join("\n"); +} diff --git a/website/src/hub.test.mjs b/website/src/hub.test.mjs index eb9b8a087..a6337399b 100644 --- a/website/src/hub.test.mjs +++ b/website/src/hub.test.mjs @@ -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(/
  • [\s\S]*?<\/li>/g)) assert.equal(row.split(" { + 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.")); +}); diff --git a/website/src/style.css b/website/src/style.css index 4ec688812..f32e24a30 100644 --- a/website/src/style.css +++ b/website/src/style.css @@ -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; } }