-
Notifications
You must be signed in to change notification settings - Fork 38
feat: add roadmap page #552
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tomarra
wants to merge
9
commits into
main
Choose a base branch
from
feat/roadmap-page
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
9de01c1
feat: roadmap page
tomarra 57fbe9d
Merge branch 'main' into feat/roadmap-page
tomarra c6d0f7e
formatting
tomarra 0b33764
spelling
tomarra 2c78ffa
update description
tomarra bfa6560
udpates
tomarra f56fa0b
add timeout just in case
tomarra f9a01ea
Merge branch 'main' into feat/roadmap-page
tomarra 5c46cd1
Merge branch 'main' into feat/roadmap-page
tomarra File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<number, string> = { | ||
| 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<string, string> = { | ||
| 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<string, LinearProject[]>(); | ||
| 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', | ||
| }); | ||
| --- | ||
|
|
||
| <StarlightPage | ||
| frontmatter={{ | ||
| title: 'Roadmap', | ||
| description: "What we're building at Shorebird", | ||
| }} | ||
| > | ||
| <p> | ||
| 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 <a href="https://discord.gg/shorebird">Discord</a> or <a href="mailto:contact@shorebird.dev">Email</a>. | ||
| </p> | ||
|
|
||
| { | ||
| fetchError ? ( | ||
| <p class="fallback"> | ||
| Roadmap data is not available in this environment. | ||
| </p> | ||
| ) : ( | ||
| <> | ||
| {sortedQuarters.map((label) => ( | ||
| <> | ||
| <h2>{label}</h2> | ||
| <div class="project-list"> | ||
| {quarterMap.get(label)!.map((p) => ( | ||
| <div class="project-card"> | ||
| <div class="project-header"> | ||
| <p class="project-name">{p.name}</p> | ||
| {STATUS_LABEL[p.state] && ( | ||
| <span class={`status-badge status-${p.state}`}> | ||
| {STATUS_LABEL[p.state]} | ||
| </span> | ||
| )} | ||
| {PRIORITY_LABEL[p.priority] && ( | ||
| <span class={`priority-badge priority-${p.priority}`}> | ||
| {PRIORITY_LABEL[p.priority]} | ||
| </span> | ||
| )} | ||
| </div> | ||
| {p.description && ( | ||
| <p class="project-desc">{p.description}</p> | ||
| )} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </> | ||
| ))} | ||
|
|
||
| <h2>Future</h2> | ||
| {futureProjects.length === 0 ? ( | ||
| <p class="empty">Nothing without a target date.</p> | ||
| ) : ( | ||
| <div class="project-list"> | ||
| {futureProjects.map((p) => ( | ||
| <div class="project-card"> | ||
| <div class="project-header"> | ||
| <p class="project-name">{p.name}</p> | ||
| {STATUS_LABEL[p.state] && ( | ||
| <span class={`status-badge status-${p.state}`}> | ||
| {STATUS_LABEL[p.state]} | ||
| </span> | ||
| )} | ||
| {PRIORITY_LABEL[p.priority] && ( | ||
| <span class={`priority-badge priority-${p.priority}`}> | ||
| {PRIORITY_LABEL[p.priority]} | ||
| </span> | ||
| )} | ||
| </div> | ||
| {p.description && ( | ||
| <p class="project-desc">{p.description}</p> | ||
| )} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| )} | ||
|
|
||
| <hr /> | ||
| <p class="build-time"> | ||
| <em>Last updated: {buildTime}</em> | ||
| </p> | ||
| </> | ||
| ) | ||
| } | ||
| </StarlightPage> | ||
|
|
||
| <style> | ||
| .fallback { | ||
| color: var(--sl-color-text-accent); | ||
| font-style: italic; | ||
| } | ||
|
|
||
| .empty { | ||
| color: var(--sl-color-gray-3); | ||
| font-style: italic; | ||
| } | ||
|
|
||
| .project-list { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.75rem; | ||
| } | ||
|
|
||
| .project-card { | ||
| border: 1px solid var(--sl-color-gray-5); | ||
| border-radius: 0.5rem; | ||
| padding: 1rem 1.25rem; | ||
| } | ||
|
|
||
| .project-header { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.6rem; | ||
| flex-wrap: wrap; | ||
| margin: 0 0 0.25rem; | ||
| } | ||
|
|
||
| .priority-badge { | ||
| font-size: 0.7rem; | ||
| font-weight: 600; | ||
| padding: 0.1rem 0.45rem; | ||
| 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 */ | ||
| .priority-4 { background: #f0fdf4; color: #15803d; } /* Low */ | ||
|
|
||
| .project-name { | ||
| font-weight: 600; | ||
| font-size: 1rem; | ||
| color: var(--sl-color-text); | ||
| margin: 0; | ||
| } | ||
|
|
||
| .project-desc { | ||
| margin: 0.4rem 0 0; | ||
| font-size: 0.9rem; | ||
| color: var(--sl-color-gray-2); | ||
| } | ||
|
|
||
| .build-time { | ||
| font-size: 0.85rem; | ||
| color: var(--sl-color-gray-3); | ||
| margin-top: 1.5rem; | ||
| } | ||
| </style> | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.