diff --git a/.cspell.yaml b/.cspell.yaml index 5e62550e..c2d87787 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -42,11 +42,14 @@ words: - evenodd - ffigen - fintech + - frontmatter + - graphql - geocodes - hotfixes - jank - janky - keyrings + - linearapp - lavamoat - libapp - libexec diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 854382e6..8937839e 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,10 @@ 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..c4a294c7 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,21 @@ 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 b6c7e9de..ed258d16 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -106,6 +106,7 @@ export default defineConfig({ collapsed: true, items: [{ 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..0d755eba --- /dev/null +++ b/src/pages/roadmap.astro @@ -0,0 +1,291 @@ +--- +// 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)); +} + +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[] = []; +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( + filter: { labels: { name: { eq: "Public Roadmap" } } } + first: 50 + ) { + nodes { + id + name + description + state + targetDate + priority + } + } + }`, + }), + }); + + clearTimeout(timeoutId); + + 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 +// 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); + } 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 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. +

+ + { + fetchError ? ( +

+ Roadmap data is not available in this environment. +

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

{label}

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

{p.name}

+ {STATUS_LABEL[p.state] && ( + + {STATUS_LABEL[p.state]} + + )} + {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}

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

{p.description}

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

+ Last updated: {buildTime} +

+ + ) + } +
+ +