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