From 59dbfd1c088459de45c465ade4485197b7a1ac2c Mon Sep 17 00:00:00 2001 From: Yutthaphon Inchaiya Date: Sat, 1 Aug 2026 19:53:01 +0700 Subject: [PATCH] feat: add suite delivery dashboard --- .github/workflows/dashboard-pages.yml | 51 +++ .hallmark/log.json | 9 + .nojekyll | 0 assets/dashboard.css | 107 +++++ assets/dashboard.js | 65 +++ dashboard-data.json | 551 ++++++++++++++++++++++++++ index.html | 92 +++++ scripts/build-dashboard.mjs | 51 +++ tokens.css | 40 ++ 9 files changed, 966 insertions(+) create mode 100644 .github/workflows/dashboard-pages.yml create mode 100644 .hallmark/log.json create mode 100644 .nojekyll create mode 100644 assets/dashboard.css create mode 100644 assets/dashboard.js create mode 100644 dashboard-data.json create mode 100644 index.html create mode 100644 scripts/build-dashboard.mjs create mode 100644 tokens.css diff --git a/.github/workflows/dashboard-pages.yml b/.github/workflows/dashboard-pages.yml new file mode 100644 index 0000000..107998d --- /dev/null +++ b/.github/workflows/dashboard-pages.yml @@ -0,0 +1,51 @@ +name: Dashboard Pages + +on: + push: + branches: [main] + paths: + - index.html + - tokens.css + - assets/** + - scripts/build-dashboard.mjs + - .github/workflows/dashboard-pages.yml + schedule: + - cron: "17,47 * * * *" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Build dashboard data + env: + GH_TOKEN: ${{ github.token }} + PROJECTS_TOKEN: ${{ secrets.PROJECTS_TOKEN }} + run: node scripts/build-dashboard.mjs + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v4 + with: + path: . + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.hallmark/log.json b/.hallmark/log.json new file mode 100644 index 0000000..21f67dc --- /dev/null +++ b/.hallmark/log.json @@ -0,0 +1,9 @@ +[ + { + "date": "2026-08-01", + "macrostructure": "Operations Ledger", + "theme": "Control Room", + "enrichment": "CSS status rail", + "brief": "Cross-repository GitHub delivery dashboard" + } +] \ No newline at end of file diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/assets/dashboard.css b/assets/dashboard.css new file mode 100644 index 0000000..7f36a8d --- /dev/null +++ b/assets/dashboard.css @@ -0,0 +1,107 @@ +/* Hallmark - macrostructure: Operations Ledger - tone: editorial control room - anchor hue: signal orange + * pre-emit critique: Philosophy 5 / Hierarchy 5 / Execution 4 / Specificity 5 / Restraint 4 / Variety 5 + */ +@import url("../tokens.css"); +* { box-sizing: border-box; } +html, body { margin: 0; overflow-x: clip; background: var(--color-paper); color: var(--color-ink); } +body { font-family: var(--font-body); font-size: var(--text-body); line-height: 1.55; background-image: linear-gradient(90deg, transparent 49.8%, color-mix(in oklch, var(--color-rule), transparent 70%) 50%, transparent 50.2%); background-size: 4rem 100%; } +a { color: inherit; text-underline-offset: 0.22em; } +a:hover { color: var(--color-accent-deep); } +a:focus-visible, button:focus-visible { outline: 3px solid var(--color-focus); outline-offset: 3px; } +.skip-link { position: fixed; left: var(--space-md); top: var(--space-md); z-index: 20; padding: var(--space-sm) var(--space-md); background: var(--color-ink); color: var(--color-white); transform: translateY(-180%); } +.skip-link:focus { transform: translateY(0); } +.masthead { min-height: 5rem; padding: var(--space-md) clamp(1rem, 4vw, 4rem); display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); border-bottom: var(--rule-heavy) solid var(--color-ink); background: color-mix(in oklch, var(--color-paper), transparent 6%); } +.wordmark { font-family: var(--font-display); font-weight: 700; text-decoration: none; letter-spacing: -0.04em; white-space: nowrap; } +.wordmark span { color: var(--color-accent); font-weight: 500; } +nav { display: flex; align-items: center; gap: clamp(0.75rem, 2vw, 2rem); font-family: var(--font-mono); font-size: var(--text-sm); } +nav a { white-space: nowrap; } +.nav-project { padding: var(--space-sm) var(--space-md); border: var(--rule-thin) solid var(--color-ink); text-decoration: none; } +.nav-project:hover { background: var(--color-ink); color: var(--color-white); } +main { width: min(100%, 96rem); margin: 0 auto; padding: 0 clamp(1rem, 4vw, 4rem); } +.hero { min-height: 39rem; display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(16rem, 0.55fr); align-items: end; gap: var(--space-2xl); padding: var(--space-3xl) 0 var(--space-2xl); border-bottom: var(--rule-heavy) solid var(--color-ink); } +.signal { display: flex; align-items: center; gap: var(--space-sm); font-family: var(--font-mono); font-size: var(--text-sm); text-transform: uppercase; letter-spacing: 0.08em; } +.signal span { width: 0.7rem; height: 0.7rem; border-radius: 50%; background: var(--color-good); animation: pulse 2s var(--ease-in-out) infinite; } +h1, h2, h3 { margin: 0; font-family: var(--font-display); font-style: normal; line-height: 0.95; letter-spacing: -0.055em; overflow-wrap: anywhere; min-width: 0; } +h1 { max-width: 12ch; margin: var(--space-lg) 0; font-size: var(--text-display); } +h2 { font-size: clamp(2rem, 4vw, 4.5rem); } +h3 { font-size: var(--text-lg); letter-spacing: -0.025em; } +.hero-deck { max-width: 44rem; margin: 0; color: var(--color-ink-soft); font-size: var(--text-lg); } +.sync-panel { align-self: end; padding: var(--space-xl); border: var(--rule-thin) solid var(--color-ink); background: var(--color-paper-deep); } +.sync-panel span, .sync-panel strong { display: block; } +.sync-label { font-family: var(--font-mono); font-size: var(--text-sm); text-transform: uppercase; } +.sync-panel strong { margin: var(--space-sm) 0 var(--space-lg); font-family: var(--font-display); font-size: var(--text-lg); } +button { min-height: 2.75rem; padding: var(--space-sm) var(--space-md); border: var(--rule-thin) solid var(--color-ink); border-radius: var(--radius-sm); background: var(--color-ink); color: var(--color-white); font: 600 var(--text-sm) var(--font-mono); cursor: pointer; transition: transform var(--dur-fast) var(--ease-out), opacity var(--dur-fast) var(--ease-out); } +button:hover { transform: translateY(-2px); } +button:active { transform: translateY(0); } +button:disabled { cursor: wait; opacity: 0.55; transform: none; } +button[data-state="error"] { background: var(--color-danger); } +button[data-state="success"] { background: var(--color-good); } +.metric-rail { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-bottom: var(--rule-heavy) solid var(--color-ink); } +.metric-rail article { min-height: 10rem; padding: var(--space-xl) var(--space-lg); border-right: var(--rule-thin) solid var(--color-rule); display: flex; flex-direction: column; justify-content: space-between; } +.metric-rail article:last-child { border-right: 0; } +.metric-rail span, .section-head p, .section-head > span, .repo-meta, .status-legend { font-family: var(--font-mono); font-size: var(--text-sm); } +.metric-rail strong { font: 700 clamp(2.5rem, 5vw, 5rem) var(--font-display); letter-spacing: -0.07em; } +.workflow, .repositories, .ledger, .gates { padding: var(--space-3xl) 0; border-bottom: var(--rule-heavy) solid var(--color-ink); } +.section-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-xl); align-items: end; margin-bottom: var(--space-2xl); } +.section-head p { margin: 0 0 var(--space-sm); text-transform: uppercase; letter-spacing: 0.08em; color: var(--color-accent-deep); } +.section-head > a, .section-head > span { align-self: end; white-space: nowrap; } +.status-track { min-height: 4rem; display: flex; overflow: hidden; border: var(--rule-thin) solid var(--color-ink); background: var(--color-paper-deep); } +.status-segment { min-width: 0.35rem; padding: var(--space-md) var(--space-sm); color: var(--color-white); font: 500 var(--text-sm) var(--font-mono); white-space: nowrap; overflow: hidden; } +.status-segment[data-status="Backlog"] { background: var(--color-ink-soft); } +.status-segment[data-status="Ready"] { background: var(--color-focus); } +.status-segment[data-status="In Progress"] { background: var(--color-accent); } +.status-segment[data-status="In Review"] { background: var(--color-warn); color: var(--color-ink); } +.status-segment[data-status="Blocked"] { background: var(--color-danger); } +.status-segment[data-status="Done"] { background: var(--color-good); } +.status-legend { display: flex; flex-wrap: wrap; gap: var(--space-lg); margin-top: var(--space-md); } +.repo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-md); } +.repo-card { min-height: 21rem; padding: var(--space-xl); display: flex; flex-direction: column; border: var(--rule-thin) solid var(--color-ink); background: color-mix(in oklch, var(--color-paper), var(--color-white) 30%); transition: transform var(--dur-base) var(--ease-out); } +.repo-card:hover { transform: translateY(-4px); } +.repo-card h3 { margin: var(--space-xl) 0 var(--space-sm); font-size: clamp(1.9rem, 4vw, 3.4rem); } +.repo-card .repo-index { font-family: var(--font-mono); color: var(--color-accent-deep); } +.repo-card .repo-meta { margin-top: auto; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-md); padding-top: var(--space-xl); border-top: var(--rule-thin) solid var(--color-rule); } +.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-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; } +.progress { height: 0.65rem; background: var(--color-paper-deep); overflow: hidden; } +.progress span { display: block; height: 100%; background: var(--color-accent); } +.milestone-count { min-width: 6rem; text-align: right; font-family: var(--font-mono); font-size: var(--text-sm); } +.gate-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2xl); } +.gate-columns h3 { padding-bottom: var(--space-md); border-bottom: var(--rule-thin) solid var(--color-ink); } +.issue-item, .empty-state { display: block; padding: var(--space-md) 0; border-bottom: var(--rule-thin) solid var(--color-rule); } +.issue-item { text-decoration: none; } +.issue-item span { display: block; margin-top: var(--space-xs); color: var(--color-ink-soft); font-family: var(--font-mono); font-size: var(--text-sm); } +footer { width: min(100%, 96rem); margin: 0 auto; padding: var(--space-2xl) clamp(1rem, 4vw, 4rem); display: flex; justify-content: space-between; gap: var(--space-xl); font-family: var(--font-mono); font-size: var(--text-sm); } +.reveal { opacity: 0; transform: translateY(1rem); animation: reveal var(--dur-slow) var(--ease-out) forwards; } +.reveal:nth-of-type(2) { animation-delay: 90ms; } +.reveal:nth-of-type(3) { animation-delay: 150ms; } +@keyframes reveal { to { opacity: 1; transform: translateY(0); } } +@keyframes pulse { 50% { opacity: 0.35; transform: scale(0.75); } } +@media (max-width: 800px) { + .masthead { align-items: flex-start; flex-direction: column; } + nav { width: 100%; justify-content: space-between; gap: var(--space-sm); } + nav a:not(.nav-project) { display: none; } + .hero { min-height: auto; grid-template-columns: minmax(0, 1fr); padding-top: var(--space-2xl); } + .metric-rail { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .metric-rail article:nth-child(2) { border-right: 0; } + .metric-rail article:nth-child(-n+2) { border-bottom: var(--rule-thin) solid var(--color-rule); } + .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-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; } +} +@media (max-width: 420px) { + h1 { font-size: clamp(2.65rem, 15vw, 4rem); } + .metric-rail { grid-template-columns: minmax(0, 1fr); } + .metric-rail article { min-height: 7rem; border-right: 0; border-bottom: var(--rule-thin) solid var(--color-rule); } + .repo-card .repo-meta { grid-template-columns: minmax(0, 1fr); } + .status-segment { font-size: 0; } +} +@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 new file mode 100644 index 0000000..1e72824 --- /dev/null +++ b/assets/dashboard.js @@ -0,0 +1,65 @@ +const DATA_URL = "dashboard-data.json"; +const $ = (selector) => document.querySelector(selector); +const escapeHtml = (value = "") => String(value).replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[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 render(data) { + $("#last-sync").textContent = `${dateTime(data.generatedAt)} ICT`; + $("#metric-repos").textContent = number(data.totals.repositories); + $("#metric-open").textContent = number(data.totals.openIssues); + $("#metric-milestones").textContent = number(data.totals.openMilestones); + $("#metric-blocked").textContent = number(data.project.blocked.length); + + const statuses = Object.entries(data.project.statusCounts || {}); + const statusTotal = statuses.reduce((sum, [, count]) => sum + count, 0) || 1; + $("#status-track").innerHTML = statuses.map(([status, count]) => `
${escapeHtml(status)} ${count}
`).join(""); + $("#status-legend").innerHTML = statuses.map(([status, count]) => `${escapeHtml(status)} / ${number(count)} / ${Math.round((count / statusTotal) * 100)}%`).join(""); + + $("#repo-grid").innerHTML = data.repositories.map((repo, index) => ` +
+ 0${index + 1} / ${escapeHtml(repo.key.toUpperCase())} +

${escapeHtml(repo.displayName)}

+

${escapeHtml(repo.description || "Repository description pending.")}

+
+
Open issues${number(repo.openIssues)}
+
Milestones${number(repo.openMilestones)}
+
Latest CI${escapeHtml(repo.latestWorkflow || "--")}
+
+ Inspect repository +
`).join(""); + 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.

'; + + 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."); + $("#blocked-list").innerHTML = issueList(data.project.blocked, "No blocked work."); +} + +async function load() { + const button = $("#refresh-data"); + button.disabled = true; + button.textContent = "Refreshing..."; + try { + const response = await fetch(`${DATA_URL}?t=${Date.now()}`, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + render(await response.json()); + button.dataset.state = "success"; + button.textContent = "Data refreshed"; + } catch (error) { + button.dataset.state = "error"; + button.textContent = "Refresh failed"; + console.error(error); + } finally { + setTimeout(() => { button.disabled = false; button.dataset.state = ""; button.textContent = "Refresh data"; }, 1400); + } +} +$("#refresh-data").addEventListener("click", load); +load(); \ No newline at end of file diff --git a/dashboard-data.json b/dashboard-data.json new file mode 100644 index 0000000..900741a --- /dev/null +++ b/dashboard-data.json @@ -0,0 +1,551 @@ +{ + "generatedAt": "2026-08-01T12:53:00.367Z", + "project": { + "available": false, + "statusCounts": {}, + "blocked": [], + "requiredGates": [] + }, + "repositories": [ + { + "key": "shared", + "name": "shared-localization", + "displayName": "Shared Localization", + "url": "https://github.com/SubMaRk/shared-localization", + "description": "Shared contracts, conformance, runtime foundations, catalogs, and suite governance.", + "openIssues": 27, + "closedIssues": 2, + "openMilestones": 16, + "milestones": [ + { + "title": "Planning and Specification Freeze", + "openIssues": 7, + "closedIssues": 2, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/1" + }, + { + "title": "v0.1.0-alpha - Contract Foundation", + "openIssues": 3, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/2" + }, + { + "title": "v0.2.0-alpha - Runtime Vertical Slice", + "openIssues": 4, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/3" + }, + { + "title": "v0.3.0-beta - Product Foundation", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/4" + }, + { + "title": "v0.4.0-beta - Windows Hardening", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/5" + }, + { + "title": "v1.0.0-rc - Foundation Candidate", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/6" + }, + { + "title": "v1.0.0 - Windows Foundation", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/7" + }, + { + "title": "v1.1.0 - Translation and Provider Foundation", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/8" + }, + { + "title": "v1.2.0 - Resource Store and Download Center", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/9" + }, + { + "title": "v1.3.0 - Model Runtime and Benchmark Foundation", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/10" + }, + { + "title": "v1.4.0 - Knowledge and Context Foundation", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/11" + }, + { + "title": "v1.5.0 - Teams LAN Foundation", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/12" + }, + { + "title": "v1.6.0 - Automation Foundation", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/13" + }, + { + "title": "v1.7.0 - Plugin Foundation", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/14" + }, + { + "title": "v1.8.0 - Cross-Suite Lifecycle", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/15" + }, + { + "title": "v1.9.0 - Self-Hosted Teams Foundation", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/shared-localization/milestone/16" + } + ], + "latestWorkflow": "success" + }, + { + "key": "document", + "name": "document-localization", + "displayName": "Document Localization", + "url": "https://github.com/SubMaRk/document-localization", + "description": "Document localization application, domain contracts, tests, and releases.", + "openIssues": 26, + "closedIssues": 0, + "openMilestones": 17, + "milestones": [ + { + "title": "Planning and Specification Freeze", + "openIssues": 5, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/1" + }, + { + "title": "v0.1.0-alpha - Foundation Spike", + "openIssues": 3, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/2" + }, + { + "title": "v0.2.0-alpha - DOCX Vertical Slice", + "openIssues": 4, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/3" + }, + { + "title": "v0.3.0-beta - Usable DOCX Preview", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/4" + }, + { + "title": "v0.4.0-beta - DOCX Feature Complete", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/5" + }, + { + "title": "v1.0.0-rc - DOCX Candidate", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/6" + }, + { + "title": "v1.0.0 - Windows DOCX Production", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/7" + }, + { + "title": "v1.1.0 - Translation Productivity", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/8" + }, + { + "title": "v1.2.0 - Digital PDF", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/9" + }, + { + "title": "v1.3.0 - Scanned and Hybrid", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/10" + }, + { + "title": "v1.4.0 - Presentations", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/11" + }, + { + "title": "v1.5.0 - Spreadsheets", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/12" + }, + { + "title": "v1.6.0 - Format Adapter Platform", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/13" + }, + { + "title": "v1.7.0 - Recoverable Batch", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/14" + }, + { + "title": "v1.8.0 - Workflow Automation", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/15" + }, + { + "title": "v1.9.0 - Teams LAN", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/16" + }, + { + "title": "v1.10.0 - Self-Hosted Teams", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/document-localization/milestone/17" + } + ], + "latestWorkflow": "success" + }, + { + "key": "manga", + "name": "manga-localization", + "displayName": "Manga Localization", + "url": "https://github.com/SubMaRk/manga-localization", + "description": "Manga localization application, domain contracts, tests, and releases.", + "openIssues": 27, + "closedIssues": 0, + "openMilestones": 18, + "milestones": [ + { + "title": "Planning and Specification Freeze", + "openIssues": 5, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/1" + }, + { + "title": "v0.1.0-alpha - One-Page Foundation", + "openIssues": 3, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/2" + }, + { + "title": "v0.2.0-alpha - Manual Chapter Slice", + "openIssues": 4, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/3" + }, + { + "title": "v0.3.0-beta - Usable Manual Preview", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/4" + }, + { + "title": "v0.4.0-beta - Assisted Production", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/5" + }, + { + "title": "v1.0.0-rc - Chapter Candidate", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/6" + }, + { + "title": "v1.0.0 - Windows Chapter Production", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/7" + }, + { + "title": "v1.1.0 - Translation Productivity", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/8" + }, + { + "title": "v1.2.0 - Visual Cleanup Assistance", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/9" + }, + { + "title": "v1.3.0 - Advanced SFX Typography", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/10" + }, + { + "title": "v1.4.0 - Art Restoration", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/11" + }, + { + "title": "v1.5.0 - Upscale and Delivery", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/12" + }, + { + "title": "v1.6.0 - Recoverable Batch", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/13" + }, + { + "title": "v1.7.0 - Series Intelligence", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/14" + }, + { + "title": "v1.8.0 - Plugin SDK", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/15" + }, + { + "title": "v1.9.0 - Provider Evaluation", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/16" + }, + { + "title": "v1.10.0 - Teams LAN", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/17" + }, + { + "title": "v1.11.0 - Self-Hosted Teams", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/manga-localization/milestone/18" + } + ], + "latestWorkflow": "success" + }, + { + "key": "video", + "name": "video-localization", + "displayName": "Video Localization", + "url": "https://github.com/SubMaRk/video-localization", + "description": "Video localization application, domain contracts, tests, and releases.", + "openIssues": 43, + "closedIssues": 0, + "openMilestones": 18, + "milestones": [ + { + "title": "Planning and Specification Freeze", + "openIssues": 11, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/1" + }, + { + "title": "v0.1.0-alpha - Subtitle Slice", + "openIssues": 9, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/2" + }, + { + "title": "v0.2.0-alpha - Manual Editor Slice", + "openIssues": 8, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/3" + }, + { + "title": "v0.3.0-beta - Usable Subtitle Preview", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/4" + }, + { + "title": "v0.4.0-beta - Production Completion", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/5" + }, + { + "title": "v1.0.0-rc - Subtitle Candidate", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/6" + }, + { + "title": "v1.0.0 - Windows Subtitle Production", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/7" + }, + { + "title": "v1.1.0 - Local Speech Assistance", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/8" + }, + { + "title": "v1.2.0 - Translation Assistance", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/9" + }, + { + "title": "v1.3.0 - Teams LAN", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/10" + }, + { + "title": "v1.4.0 - Visual Text Analysis", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/11" + }, + { + "title": "v1.5.0 - Visual Text Replacement", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/12" + }, + { + "title": "v1.6.0 - Context Intelligence", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/13" + }, + { + "title": "v1.7.0 - Recoverable Batch", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/14" + }, + { + "title": "v1.8.0 - Workflow Designer", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/15" + }, + { + "title": "v1.9.0 - Plugin SDK", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/16" + }, + { + "title": "v1.10.0 - Capability Pack Platform", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/17" + }, + { + "title": "v1.11.0 - Self-Hosted Teams", + "openIssues": 1, + "closedIssues": 0, + "dueOn": null, + "url": "https://github.com/SubMaRk/video-localization/milestone/18" + } + ], + "latestWorkflow": "success" + } + ], + "totals": { + "repositories": 4, + "openIssues": 123, + "openMilestones": 69 + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..c217163 --- /dev/null +++ b/index.html @@ -0,0 +1,92 @@ + + + + + + + + Localization Suite | Delivery Desk + + + + + + + + + +
+ SUBMARK / LOCALIZATION + +
+ +
+
+
+

Suite delivery desk

+

Four products.
One operational truth.

+

A public, read-only view across Shared, Document, Manga, and Video. GitHub remains the source of record.

+
+
+ Last synchronized + Loading... + +
+
+ +
+
Repositories4
+
Open issues--
+
Milestones--
+
Blocked--
+
+ +
+
+

Current flow

Work distribution

+ View board +
+
+
+
+ +
+
+

Source repositories

Product lines

+ Collecting repository health... +
+
+
+ +
+
+

Release authority

Active milestones

+ Progress is calculated from linked issues. +
+
+
+ +
+
+

Attention queue

Release gates and blockers

+ Inspect gates +
+
+

Required gates

+

Blocked work

+
+
+
+ + + + + \ No newline at end of file diff --git a/scripts/build-dashboard.mjs b/scripts/build-dashboard.mjs new file mode 100644 index 0000000..4bdfa6c --- /dev/null +++ b/scripts/build-dashboard.mjs @@ -0,0 +1,51 @@ +import { writeFile } from "node:fs/promises"; +const owner = "SubMaRk"; +const repositories = [ + ["shared-localization", "Shared Localization", "shared"], + ["document-localization", "Document Localization", "document"], + ["manga-localization", "Manga Localization", "manga"], + ["video-localization", "Video Localization", "video"] +]; +const restToken = process.env.GH_TOKEN || process.env.PROJECTS_TOKEN; +const projectToken = process.env.PROJECTS_TOKEN || process.env.GH_TOKEN; +const headers = { Accept: "application/vnd.github+json", "User-Agent": "localization-suite-dashboard", ...(restToken ? { Authorization: `Bearer ${restToken}` } : {}) }; +async function api(path) { const response = await fetch(`https://api.github.com${path}`, { headers }); if (!response.ok) throw new Error(`${path}: ${response.status}`); return response.json(); } +async function repoData([name, displayName, key]) { + const [repo, issues, milestones, runs] = await Promise.all([ + api(`/repos/${owner}/${name}`), + api(`/repos/${owner}/${name}/issues?state=all&per_page=100`), + api(`/repos/${owner}/${name}/milestones?state=all&per_page=100`), + api(`/repos/${owner}/${name}/actions/runs?per_page=1`) + ]); + const realIssues = issues.filter((issue) => !issue.pull_request); + const openMilestones = milestones.filter((item) => item.state === "open").map((item) => ({ title: item.title, openIssues: item.open_issues, closedIssues: item.closed_issues, dueOn: item.due_on, url: item.html_url })); + return { key, name, displayName, url: repo.html_url, description: repo.description, openIssues: realIssues.filter((item) => item.state === "open").length, closedIssues: realIssues.filter((item) => item.state === "closed").length, openMilestones: openMilestones.length, milestones: openMilestones, latestWorkflow: runs.workflow_runs?.[0]?.conclusion || runs.workflow_runs?.[0]?.status || "--" }; +} +function valueMap(item) { + const values = {}; + for (const node of item.fieldValues?.nodes || []) { + const name = node.field?.name; + if (name) values[name] = node.name ?? node.text ?? node.date ?? null; + } + return values; +} +async function projectData() { + if (!projectToken) return { available: false, statusCounts: {}, blocked: [], requiredGates: [] }; + const query = `query($after:String){organization(login:"SubMaRk"){projectV2(number:1){title url items(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{content{... on Issue{title url number state repository{nameWithOwner}}}fieldValues(first:30){nodes{... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2FieldCommon{name}}}... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2FieldCommon{name}}}... on ProjectV2ItemFieldDateValue{date field{... on ProjectV2FieldCommon{name}}}}}}}}}}}`; + const items = []; let after = null; + do { + const response = await fetch("https://api.github.com/graphql", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${projectToken}`, "User-Agent": "localization-suite-dashboard" }, body: JSON.stringify({ query, variables: { after } }) }); + if (!response.ok) throw new Error(`Project API: ${response.status}`); + const payload = await response.json(); if (payload.errors) throw new Error(payload.errors[0].message); + const page = payload.data.organization.projectV2.items; items.push(...page.nodes); after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; + } while (after); + const normalized = items.filter((item) => item.content?.url).map((item) => { const fields = valueMap(item); return { title: item.content.title, url: item.content.url, repository: item.content.repository?.nameWithOwner || "Draft", status: fields.Status || "No Status", product: fields.Product || "Unknown", targetVersion: fields["Target Version"] || "", releaseGate: fields["Release Gate"] || "Not Applicable", priority: fields.Priority || "", area: fields.Area || "" }; }); + const statusCounts = normalized.reduce((counts, item) => ({ ...counts, [item.status]: (counts[item.status] || 0) + 1 }), {}); + return { available: true, statusCounts, blocked: normalized.filter((item) => item.status === "Blocked"), requiredGates: normalized.filter((item) => ["Required", "Failed"].includes(item.releaseGate) && item.status !== "Done") }; +} +const repoResults = await Promise.all(repositories.map(repoData)); +let project; +try { project = await projectData(); } catch (error) { console.error(error.message); project = { available: false, statusCounts: {}, blocked: [], requiredGates: [] }; } +const data = { generatedAt: new Date().toISOString(), project, repositories: repoResults, totals: { repositories: repoResults.length, openIssues: repoResults.reduce((sum, repo) => sum + repo.openIssues, 0), openMilestones: repoResults.reduce((sum, repo) => sum + repo.openMilestones, 0) } }; +await writeFile(new URL("../dashboard-data.json", import.meta.url), `${JSON.stringify(data, null, 2)}\n`, "utf8"); +console.log(`Dashboard snapshot: ${data.totals.openIssues} open issues, ${data.project.blocked.length} blocked.`); \ No newline at end of file diff --git a/tokens.css b/tokens.css new file mode 100644 index 0000000..53afc04 --- /dev/null +++ b/tokens.css @@ -0,0 +1,40 @@ +:root { + --color-paper: oklch(96% 0.018 80); + --color-paper-deep: oklch(91% 0.03 76); + --color-ink: oklch(20% 0.035 248); + --color-ink-soft: oklch(42% 0.035 248); + --color-rule: oklch(72% 0.035 75); + --color-accent: oklch(62% 0.19 38); + --color-accent-deep: oklch(48% 0.17 34); + --color-good: oklch(58% 0.13 151); + --color-warn: oklch(72% 0.14 82); + --color-danger: oklch(55% 0.2 27); + --color-focus: oklch(48% 0.15 244); + --color-white: oklch(100% 0 0); + --font-display: "Space Grotesk", sans-serif; + --font-body: "IBM Plex Sans Thai", sans-serif; + --font-mono: "IBM Plex Mono", monospace; + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2rem; + --space-2xl: 3rem; + --space-3xl: 5rem; + --text-sm: 0.78rem; + --text-body: 1rem; + --text-lg: 1.25rem; + --text-xl: 1.75rem; + --text-display: clamp(2.8rem, 8vw, 7rem); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in: cubic-bezier(0.7, 0, 0.84, 0); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --dur-fast: 120ms; + --dur-base: 260ms; + --dur-slow: 620ms; + --rule-thin: 1px; + --rule-heavy: 3px; + --radius-sm: 0.2rem; + --radius-md: 0.65rem; + --radius-pill: 999px; +} \ No newline at end of file