From dd3390c5a5ccc206d10f5d344230513b14461b17 Mon Sep 17 00:00:00 2001 From: Yutthaphon Inchaiya Date: Sat, 1 Aug 2026 20:07:19 +0700 Subject: [PATCH] feat: add repository milestone tabs --- assets/dashboard.css | 11 ++++++- assets/dashboard.js | 68 +++++++++++++++++++++++++++++++++++++++----- index.html | 5 ++-- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/assets/dashboard.css b/assets/dashboard.css index 7f36a8d..0db1673 100644 --- a/assets/dashboard.css +++ b/assets/dashboard.css @@ -63,6 +63,12 @@ button[data-state="success"] { background: var(--color-good); } .repo-meta strong, .repo-meta span { display: block; } .repo-meta strong { font: 600 var(--text-lg) var(--font-display); } .repo-card > a { margin-top: var(--space-lg); font-family: var(--font-mono); font-size: var(--text-sm); } +.milestone-tabs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: var(--rule-thin) solid var(--color-ink); border-bottom: 0; } +.milestone-tab { display: flex; justify-content: space-between; gap: var(--space-md); padding: var(--space-md) var(--space-lg); border: 0; border-right: var(--rule-thin) solid var(--color-ink); background: var(--color-paper); color: var(--color-ink); font: 600 var(--text-sm)/1 var(--font-display); text-align: left; cursor: pointer; } +.milestone-tab:last-child { border-right: 0; } +.milestone-tab:hover { background: var(--color-paper-deep); } +.milestone-tab[aria-selected="true"] { background: var(--color-ink); color: var(--color-paper); } +.milestone-tab-count { font-family: var(--font-mono); color: var(--color-accent); } .milestone-list { border-top: var(--rule-thin) solid var(--color-ink); } .milestone-row { display: grid; grid-template-columns: minmax(12rem, 0.8fr) minmax(0, 2fr) auto; gap: var(--space-xl); align-items: center; padding: var(--space-lg) 0; border-bottom: var(--rule-thin) solid var(--color-rule); } .milestone-row a { font-family: var(--font-display); font-size: var(--text-lg); font-weight: 600; } @@ -91,6 +97,9 @@ footer { width: min(100%, 96rem); margin: 0 auto; padding: var(--space-2xl) clam .section-head { grid-template-columns: minmax(0, 1fr); align-items: start; } .section-head > a, .section-head > span { justify-self: start; white-space: normal; } .repo-grid, .gate-columns { grid-template-columns: minmax(0, 1fr); } + .milestone-tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .milestone-tab:nth-child(2) { border-right: 0; } + .milestone-tab:nth-child(-n + 2) { border-bottom: var(--rule-thin) solid var(--color-ink); } .milestone-row { grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-md); } .milestone-row .progress { grid-column: 1 / -1; grid-row: 2; } footer { flex-direction: column; } @@ -104,4 +113,4 @@ footer { width: min(100%, 96rem); margin: 0 auto; padding: var(--space-2xl) clam } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 1ms !important; animation-iteration-count: 1 !important; transition-duration: 1ms !important; } -} \ No newline at end of file +} diff --git a/assets/dashboard.js b/assets/dashboard.js index 1e72824..ea68d0e 100644 --- a/assets/dashboard.js +++ b/assets/dashboard.js @@ -4,6 +4,65 @@ const escapeHtml = (value = "") => String(value).replace(/[&<>'"]/g, (char) => ( const number = (value) => new Intl.NumberFormat("en-US").format(value || 0); const dateTime = (value) => value ? new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeStyle: "short", timeZone: "Asia/Bangkok" }).format(new Date(value)) : "Not available"; +function renderMilestoneTabs(repositories) { + const definitions = [ + { key: "video", label: "Video" }, + { key: "shared", label: "Shared" }, + { key: "document", label: "Document" }, + { key: "manga", label: "Manga" } + ]; + const repositoryByKey = new Map(repositories.map((repo) => [repo.key, repo])); + const available = definitions.filter(({ key }) => repositoryByKey.has(key)); + const requestedKey = location.hash.match(/(?:^#|&)milestones=([a-z]+)/)?.[1]; + let activeKey = available.some(({ key }) => key === requestedKey) ? requestedKey : "video"; + if (!repositoryByKey.has(activeKey)) activeKey = available[0]?.key; + + const tabs = $("#milestone-tabs"); + const list = $("#milestone-list"); + tabs.innerHTML = available.map(({ key, label }) => { + const count = repositoryByKey.get(key).milestones.length; + const selected = key === activeKey; + return ``; + }).join(""); + + const selectRepository = (key, moveFocus = false) => { + const repo = repositoryByKey.get(key); + if (!repo) return; + activeKey = key; + tabs.querySelectorAll("[role=tab]").forEach((tab) => { + const selected = tab.dataset.repo === key; + tab.setAttribute("aria-selected", selected); + tab.tabIndex = selected ? 0 : -1; + }); + list.setAttribute("aria-labelledby", `milestone-tab-${key}`); + list.innerHTML = repo.milestones.length ? repo.milestones.map((item) => { + const total = item.openIssues + item.closedIssues; + const percent = total ? Math.round((item.closedIssues / total) * 100) : 0; + return `
${escapeHtml(item.title)}
${item.closedIssues}/${total} done
`; + }).join("") : `

No open milestones for ${escapeHtml(repo.displayName)}.

`; + history.replaceState(null, "", `${location.pathname}${location.search}#milestones=${key}`); + if (moveFocus) tabs.querySelector(`[data-repo="${key}"]`)?.focus(); + }; + + tabs.addEventListener("click", (event) => { + const tab = event.target.closest("[role=tab]"); + if (tab) selectRepository(tab.dataset.repo); + }); + tabs.addEventListener("keydown", (event) => { + const keys = available.map(({ key }) => key); + const current = keys.indexOf(activeKey); + let next = current; + if (event.key === "ArrowRight") next = (current + 1) % keys.length; + else if (event.key === "ArrowLeft") next = (current - 1 + keys.length) % keys.length; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = keys.length - 1; + else return; + event.preventDefault(); + selectRepository(keys[next], true); + }); + selectRepository(activeKey); +} + function render(data) { $("#last-sync").textContent = `${dateTime(data.generatedAt)} ICT`; $("#metric-repos").textContent = number(data.totals.repositories); @@ -31,12 +90,7 @@ function render(data) { const healthy = data.repositories.filter((repo) => repo.latestWorkflow === "success" || repo.latestWorkflow === "--").length; $("#repo-health").textContent = `${healthy} of ${data.repositories.length} repositories clear`; - const milestones = data.repositories.flatMap((repo) => repo.milestones.map((milestone) => ({ ...milestone, repo: repo.displayName }))); - $("#milestone-list").innerHTML = milestones.length ? milestones.slice(0, 16).map((item) => { - const total = item.openIssues + item.closedIssues; - const percent = total ? Math.round((item.closedIssues / total) * 100) : 0; - return `
${escapeHtml(item.repo)} / ${escapeHtml(item.title)}
${item.closedIssues}/${total} done
`; - }).join("") : '

No open milestones.

'; + renderMilestoneTabs(data.repositories); const issueList = (items, empty) => items.length ? items.slice(0, 12).map((item) => `${escapeHtml(item.title)}${escapeHtml(item.repository)} / ${escapeHtml(item.targetVersion || "No target")}`).join("") : `

${empty}

`; $("#gate-list").innerHTML = issueList(data.project.requiredGates, "No required gates."); @@ -62,4 +116,4 @@ async function load() { } } $("#refresh-data").addEventListener("click", load); -load(); \ No newline at end of file +load(); diff --git a/index.html b/index.html index c217163..adaf33d 100644 --- a/index.html +++ b/index.html @@ -68,7 +68,8 @@

Four products.
One operational truth.

Release authority

Active milestones

Progress is calculated from linked issues. -
+
+
@@ -89,4 +90,4 @@

Four products.
One operational truth.

- \ No newline at end of file +