From 9de01c199dfe52a8dad78b11149d101423dd12f0 Mon Sep 17 00:00:00 2001 From: Tom Arra Date: Wed, 22 Apr 2026 08:45:26 -0500 Subject: [PATCH 1/6] feat: roadmap page --- .cspell.yaml | 2 + .github/workflows/main.yaml | 7 +- README.md | 16 +++ astro.config.mjs | 1 + src/pages/roadmap.astro | 232 ++++++++++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 src/pages/roadmap.astro diff --git a/.cspell.yaml b/.cspell.yaml index f4e78157..e7cd01c4 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -40,9 +40,11 @@ words: - elif - evenodd - ffigen + - graphql - jank - janky - keyrings + - linearapp - lavamoat - libapp - libflutter diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index b8cf9893..4495f248 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,6 +1,9 @@ name: ci on: + schedule: + - cron: '0 6 * * *' # 6am UTC / midnight CST β€” keeps roadmap data fresh + workflow_dispatch: pull_request: push: branches: @@ -23,6 +26,8 @@ jobs: - name: πŸ“¦ Build Docs uses: ./.github/actions/astro_site + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} deploy: needs: build @@ -31,7 +36,7 @@ jobs: # https://github.com/CloudCannon/pagefind/issues/574 runs-on: macos-latest - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} environment: name: github-pages diff --git a/README.md b/README.md index 9bf54eb9..8a940b97 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,22 @@ Home of the [docs.shorebird.dev](https://docs.shorebird.dev) site. +## πŸ—ΊοΈ Roadmap page + +The `/roadmap` page fetches live data from Linear at build time. To see real +data locally, export your Linear API key before running the dev server: + +```sh +export LINEAR_API_KEY=your_key_here +npm run dev +``` + +You can create a personal API key at **Linear β†’ Settings β†’ API β†’ Personal API +Keys**. Without the key the page renders a fallback message instead of +crashing. + +In CI the key is read from the `LINEAR_API_KEY` repository secret. + ## 🧞 Commands All commands are run from the root of the project, from a terminal: diff --git a/astro.config.mjs b/astro.config.mjs index 6b6a86a6..3067f2b0 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -103,6 +103,7 @@ export default defineConfig({ collapsed: true, autogenerate: { directory: 'flutter-concepts' }, }, + { label: 'Roadmap', link: '/roadmap/' }, ], plugins: [ starlightThemeNova(), diff --git a/src/pages/roadmap.astro b/src/pages/roadmap.astro new file mode 100644 index 00000000..b6d4e87d --- /dev/null +++ b/src/pages/roadmap.astro @@ -0,0 +1,232 @@ +--- +// cspell:words linearapp graphql roadmap +import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; + +interface LinearProject { + id: string; + name: string; + description: string; + state: string; + targetDate: string | null; + priority: number; +} + +const PRIORITY_LABEL: Record = { + 1: 'Urgent', + 2: 'High', + 3: 'Medium', + 4: 'Low', +}; + +function getQuarterLabel(iso: string): string { + const d = new Date(iso); + const q = Math.ceil((d.getUTCMonth() + 1) / 3); + return `Q${q} ${d.getUTCFullYear()}`; +} + +// Returns a sortable numeric key for a quarter label like "Q2 2025" β†’ 20252 +function quarterSortKey(label: string): number { + const [q, year] = label.split(' '); + return parseInt(year) * 10 + parseInt(q.slice(1)); +} + +const LINEAR_API_KEY = import.meta.env.LINEAR_API_KEY; + +let projects: LinearProject[] = []; +let fetchError = false; + +if (LINEAR_API_KEY) { + try { + const response = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: LINEAR_API_KEY, + }, + body: JSON.stringify({ + query: `{ + projects( + filter: { labels: { name: { eq: "Public Roadmap" } } } + first: 50 + ) { + nodes { + id + name + description + state + targetDate + priority + } + } + }`, + }), + }); + + if (!response.ok) { + fetchError = true; + } else { + const json = await response.json(); + projects = json?.data?.projects?.nodes ?? []; + } + } catch { + fetchError = true; + } +} else { + fetchError = true; +} + +// Group by quarter; projects without a targetDate go to the "Future" bucket +const quarterMap = new Map(); +const futureProjects: LinearProject[] = []; + +for (const p of projects) { + if (p.targetDate) { + const label = getQuarterLabel(p.targetDate); + if (!quarterMap.has(label)) quarterMap.set(label, []); + quarterMap.get(label)!.push(p); + } else { + futureProjects.push(p); + } +} + +const sortedQuarters = [...quarterMap.keys()].sort( + (a, b) => quarterSortKey(a) - quarterSortKey(b), +); + +const buildTime = new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', +}); +--- + + +

+ Here's what we're working on at Shorebird. This roadmap is pulled directly + from our Linear backlog and reflects our current priorities. +

+ + { + fetchError ? ( +

+ Roadmap data is not available in this environment. +

+ ) : ( + <> + {sortedQuarters.map((label) => ( + <> +

{label}

+
+ {quarterMap.get(label)!.map((p) => ( +
+
+

{p.name}

+ {PRIORITY_LABEL[p.priority] && ( + + {PRIORITY_LABEL[p.priority]} + + )} +
+ {p.description && ( +

{p.description}

+ )} +
+ ))} +
+ + ))} + +

Future

+ {futureProjects.length === 0 ? ( +

Nothing without a target date.

+ ) : ( +
+ {futureProjects.map((p) => ( +
+

{p.name}

+ {p.description && ( +

{p.description}

+ )} +
+ ))} +
+ )} + +
+

+ Last updated: {buildTime} +

+ + ) + } +
+ + From c6d0f7ef6bafbf46a935b02e15f17b7745af2bda Mon Sep 17 00:00:00 2001 From: Tom Arra Date: Mon, 8 Jun 2026 11:13:06 -0500 Subject: [PATCH 2/6] formatting --- .github/workflows/main.yaml | 5 ++++- README.md | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 4495f248..f1b47d96 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -36,7 +36,10 @@ jobs: # https://github.com/CloudCannon/pagefind/issues/574 runs-on: macos-latest - if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + if: + ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || + github.event_name == 'schedule' || github.event_name == + 'workflow_dispatch' }} environment: name: github-pages diff --git a/README.md b/README.md index 8a940b97..c4a294c7 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,7 @@ npm run dev ``` You can create a personal API key at **Linear β†’ Settings β†’ API β†’ Personal API -Keys**. Without the key the page renders a fallback message instead of -crashing. +Keys**. Without the key the page renders a fallback message instead of crashing. In CI the key is read from the `LINEAR_API_KEY` repository secret. From 0b337644d9ea3d83ad76c1da6cac4ea1fe680b83 Mon Sep 17 00:00:00 2001 From: Tom Arra Date: Mon, 8 Jun 2026 11:15:34 -0500 Subject: [PATCH 3/6] spelling --- .cspell.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.cspell.yaml b/.cspell.yaml index e7cd01c4..73d99145 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -40,6 +40,7 @@ words: - elif - evenodd - ffigen + - frontmatter - graphql - jank - janky From 2c78ffa520293dbe811d72cd8dff13ade7a7dd95 Mon Sep 17 00:00:00 2001 From: Tom Arra Date: Mon, 8 Jun 2026 11:24:50 -0500 Subject: [PATCH 4/6] update description --- src/pages/roadmap.astro | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/roadmap.astro b/src/pages/roadmap.astro index b6d4e87d..41465a9a 100644 --- a/src/pages/roadmap.astro +++ b/src/pages/roadmap.astro @@ -107,8 +107,7 @@ const buildTime = new Date().toLocaleDateString('en-US', { }} >

- Here's what we're working on at Shorebird. This roadmap is pulled directly - from our Linear backlog and reflects our current priorities. + Here's what we're working on at Shorebird. This roadmap is pulled directly from our Linear backlog and reflects our current engineering and product priorities. We keep it public because we believe in building in the open. If something you care about isn't here, let us know via Discord or Email.

{ From bfa656067c9f5aea8cb1bc0cefac6ae7c1ef516d Mon Sep 17 00:00:00 2001 From: Tom Arra Date: Mon, 8 Jun 2026 11:54:27 -0500 Subject: [PATCH 5/6] udpates --- src/pages/roadmap.astro | 56 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/pages/roadmap.astro b/src/pages/roadmap.astro index 41465a9a..5640234d 100644 --- a/src/pages/roadmap.astro +++ b/src/pages/roadmap.astro @@ -30,6 +30,24 @@ function quarterSortKey(label: string): number { return parseInt(year) * 10 + parseInt(q.slice(1)); } +function currentQuarterCutoff(): Date { + const now = new Date(); + const quarter = Math.ceil((now.getUTCMonth() + 1) / 3); + // Start of one quarter behind current + const cutoffQuarter = quarter === 1 ? 4 : quarter - 1; + const cutoffYear = quarter === 1 ? now.getUTCFullYear() - 1 : now.getUTCFullYear(); + const cutoffMonth = (cutoffQuarter - 1) * 3; // 0-indexed month start of that quarter + return new Date(Date.UTC(cutoffYear, cutoffMonth, 1)); +} + +const STATUS_LABEL: Record = { + planned: 'Planned', + started: 'In Progress', + paused: 'Paused', + completed: 'Completed', + cancelled: 'Cancelled', +}; + const LINEAR_API_KEY = import.meta.env.LINEAR_API_KEY; let projects: LinearProject[] = []; @@ -76,11 +94,14 @@ if (LINEAR_API_KEY) { } // Group by quarter; projects without a targetDate go to the "Future" bucket +// Filter out anything with a targetDate before the start of one quarter ago +const cutoff = currentQuarterCutoff(); const quarterMap = new Map(); const futureProjects: LinearProject[] = []; for (const p of projects) { if (p.targetDate) { + if (new Date(p.targetDate) < cutoff) continue; const label = getQuarterLabel(p.targetDate); if (!quarterMap.has(label)) quarterMap.set(label, []); quarterMap.get(label)!.push(p); @@ -125,6 +146,11 @@ const buildTime = new Date().toLocaleDateString('en-US', {

{p.name}

+ {STATUS_LABEL[p.state] && ( + + {STATUS_LABEL[p.state]} + + )} {PRIORITY_LABEL[p.priority] && ( {PRIORITY_LABEL[p.priority]} @@ -147,7 +173,19 @@ const buildTime = new Date().toLocaleDateString('en-US', {
{futureProjects.map((p) => (
-

{p.name}

+
+

{p.name}

+ {STATUS_LABEL[p.state] && ( + + {STATUS_LABEL[p.state]} + + )} + {PRIORITY_LABEL[p.priority] && ( + + {PRIORITY_LABEL[p.priority]} + + )} +
{p.description && (

{p.description}

)} @@ -203,8 +241,24 @@ const buildTime = new Date().toLocaleDateString('en-US', { border-radius: 999px; text-transform: uppercase; letter-spacing: 0.04em; + margin-left: auto; } + .status-badge { + font-size: 0.7rem; + font-weight: 600; + padding: 0.1rem 0.45rem; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .status-planned { background: #e0f2fe; color: #0369a1; } + .status-started { background: #ede9fe; color: #6d28d9; } + .status-paused { background: #f3f4f6; color: #4b5563; } + .status-completed { background: #dcfce7; color: #15803d; } + .status-cancelled { background: #f1f5f9; color: #64748b; } + .priority-1 { background: #fee2e2; color: #b91c1c; } /* Urgent */ .priority-2 { background: #ffedd5; color: #c2410c; } /* High */ .priority-3 { background: #fef9c3; color: #a16207; } /* Medium */ From f56fa0b9e2a23a64e541b8e61ae78952677e28da Mon Sep 17 00:00:00 2001 From: Tom Arra Date: Mon, 8 Jun 2026 13:24:29 -0500 Subject: [PATCH 6/6] add timeout just in case --- src/pages/roadmap.astro | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pages/roadmap.astro b/src/pages/roadmap.astro index 5640234d..0d755eba 100644 --- a/src/pages/roadmap.astro +++ b/src/pages/roadmap.astro @@ -55,12 +55,16 @@ let fetchError = false; if (LINEAR_API_KEY) { try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + const response = await fetch('https://api.linear.app/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: LINEAR_API_KEY, }, + signal: controller.signal, body: JSON.stringify({ query: `{ projects( @@ -80,6 +84,8 @@ if (LINEAR_API_KEY) { }), }); + clearTimeout(timeoutId); + if (!response.ok) { fetchError = true; } else {