diff --git a/netlify.toml b/netlify.toml index f2c7517c..efc4d90f 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,7 +1,7 @@ [build] command = "npm install -g bun && bun install && bun run build && mkdir -p _netlify/builders && cp -r dist _netlify/builders/developers" publish = "_netlify/builders" - environment = { NETLIFY = "true" } + environment = { NETLIFY = "true", ROADMAP_API_URL = "https://local-linear-api-roadmap.ngrok.dev/" } [context.production] command = "npm install -g bun && bun install && bun run build && mkdir -p _netlify/builders && cp -r dist _netlify/builders/developers" @@ -11,7 +11,7 @@ [context.preview] command = "npm install -g bun && bun install && bun run build && mkdir -p _netlify/builders && cp -r dist _netlify/builders/developers" publish = "_netlify/builders" - environment = { NETLIFY = "true" } + environment = { NETLIFY = "true", ROADMAP_API_URL = "https://local-linear-api-roadmap.ngrok.dev/" } [context.deploy-preview] command = "npm install -g bun && bun install && bun run build && mkdir -p _netlify/builders && cp -r dist _netlify/builders/developers" diff --git a/src/components/RoadmapBoard.astro b/src/components/RoadmapBoard.astro new file mode 100644 index 00000000..68e5e7b8 --- /dev/null +++ b/src/components/RoadmapBoard.astro @@ -0,0 +1,843 @@ +--- +import type { ApiProject, BoardTeam } from '../types/roadmap' + +interface Props { + projects?: ApiProject[] + teams?: BoardTeam[] +} + +const { projects = [], teams = [] }: Props = Astro.props + +// ─── Icon resolution ───────────────────────────────────────────────────────── + +// Linear named icons → nearest Unicode emoji +const LINEAR_ICON_EMOJI: Record = { + AiDocument: '📄', + AlarmClock: '⏰', + Asterisk: '✳️', + Basket: '🧺', + Bookmark: '🔖', + Bug: '🐛', + Calendar: '📅', + Chart: '📊', + Chrome: '🌐', + CodeBlock: '💻', + CreditCard: '💳', + Dashboard: '📊', + Direction: '➡️', + Dollar: '💲', + DollarBill: '💵', + Education: '🎓', + Favorite: '⭐', + GitHub: '🐙', + Golf: '⛳', + Hack: '💻', + IssueStatusBacklog: '⏳', + Leaf: '🍃', + Lock: '🔒', + Mic: '🎤', + MobilePhone: '📱', + Modem: '📡', + MoneyStack: '💰', + NotePad: '📝', + Phone: '📞', + Project: '📁', + Robot: '🤖', + Rocket: '🚀', + Routing: '🔀', + Shrug: '🤷', + Stadium: '🏟️', + Surfer: '🏄', + Users: '👥', + Write: '✍️' +} + +// Slack-style shortcode → Unicode emoji (as returned by the Linear API) +const SLACK_EMOJI: Record = { + ':flag-ca:': '🇨🇦', + ':flag-eu:': '🇪🇺', + ':flag-za:': '🇿🇦', + ':us:': '🇺🇸' +} + +function resolveIcon(icon: string | null | undefined): string | null { + if (!icon) return null + if (SLACK_EMOJI[icon]) return SLACK_EMOJI[icon] + if (LINEAR_ICON_EMOJI[icon]) return LINEAR_ICON_EMOJI[icon] + // Already a proper emoji character — pass it through + if (/\p{Emoji_Presentation}/u.test(icon)) return icon + return null +} + +// ─── Month helpers ────────────────────────────────────────────────────────── + +function monthStart(year: number, month: number): Date { + return new Date(Date.UTC(year, month, 1)) +} + +function monthEnd(year: number, month: number): Date { + return new Date(Date.UTC(year, month + 1, 0, 23, 59, 59, 999)) +} + +// ─── Collect all relevant dates ───────────────────────────────────────────── + +const allDates: Date[] = [] +for (const proj of projects) { + if (proj.startDate) allDates.push(new Date(proj.startDate)) + if (proj.targetDate) allDates.push(new Date(proj.targetDate)) + for (const ms of proj.milestones) { + if (ms.targetDate) allDates.push(new Date(ms.targetDate)) + } +} + +const fallbackDate = new Date() +const minDate = allDates.length + ? new Date(Math.min(...allDates.map((d) => d.getTime()))) + : fallbackDate +const maxDate = allDates.length + ? new Date(Math.max(...allDates.map((d) => d.getTime()))) + : fallbackDate + +// ─── Build months array ───────────────────────────────────────────────────── + +interface MonthEntry { + label: string + year: number + month: number // 0-11 + start: Date + end: Date + isQuarterStart: boolean + // grid column index (1-based), label col = 1, months start at 2 + col: number +} + +const months: MonthEntry[] = [] +let cm = minDate.getUTCMonth() +let cmy = minDate.getUTCFullYear() +const endYear = maxDate.getUTCFullYear() +const endMonth = maxDate.getUTCMonth() + +while (cmy < endYear || (cmy === endYear && cm <= endMonth)) { + months.push({ + label: new Date(Date.UTC(cmy, cm, 1)).toLocaleString('en-US', { + month: 'short' + }), + year: cmy, + month: cm, + start: monthStart(cmy, cm), + end: monthEnd(cmy, cm), + isQuarterStart: cm % 3 === 0, + col: months.length + 2 // col 1 = label, months start at col 2 + }) + cm++ + if (cm > 11) { + cm = 0 + cmy++ + } +} + +// ─── Build quarter header spans with explicit column positions ─────────────── + +interface QuarterHeader { + label: string + q: number + year: number + colStart: number // grid column start (1-based) + span: number // number of month columns +} + +const quarterHeaders: QuarterHeader[] = [] +let mi = 0 +while (mi < months.length) { + const m = months[mi] + const q = Math.floor(m.month / 3) + 1 + let span = 0 + while (mi + span < months.length) { + const nm = months[mi + span] + if (Math.floor(nm.month / 3) + 1 !== q || nm.year !== m.year) break + span++ + } + quarterHeaders.push({ + label: `Q${q} ${m.year}`, + q, + year: m.year, + colStart: m.col, + span + }) + mi += span +} + +const nMonths = months.length +const timelineStart = months[0].start.getTime() +const timelineEnd = months[months.length - 1].end.getTime() +const totalMs = timelineEnd - timelineStart + +// ─── Timeline positioning ─────────────────────────────────────────────────── + +function pctLeft(date: Date): number { + return Math.max( + 0, + Math.min(100, ((date.getTime() - timelineStart) / totalMs) * 100) + ) +} + +function pctWidth(start: Date, end: Date): number { + return Math.max(((end.getTime() - start.getTime()) / totalMs) * 100, 0.5) +} + +// ─── Color utilities ───────────────────────────────────────────────────────── + +const now = new Date() +const nowYear = now.getUTCFullYear() +const nowMonth = now.getUTCMonth() +const nowLeft = pctLeft(now).toFixed(2) + +// ─── Team grouping ─────────────────────────────────────────────────────────── + +const teamMap = new Map(teams.map((t) => [t.id, t])) + +// Root teams: not referenced as a child by any other team +const allChildIds = new Set(teams.flatMap((t) => t.childrenIds)) +const rootTeams = teams.filter((t) => !allChildIds.has(t.id)) + +// Index projects by their direct team ID +const projectsByTeamId = new Map() +for (const proj of projects) { + if (!proj.team) continue + const tid = proj.team.id + if (!projectsByTeamId.has(tid)) projectsByTeamId.set(tid, []) + projectsByTeamId.get(tid)!.push(proj) +} + +// Collect all projects for a root team (own + children), sorted by sortOrder +function collectTeamProjects(teamId: string): ApiProject[] { + const team = teamMap.get(teamId) + if (!team) return [] + const own = projectsByTeamId.get(teamId) ?? [] + const fromChildren = team.childrenIds.flatMap( + (cid) => projectsByTeamId.get(cid) ?? [] + ) + return [...own, ...fromChildren].sort((a, b) => a.sortOrder - b.sortOrder) +} + +// Build flat grid item list with pre-assigned row numbers +type GridItem = + | { type: 'team-header'; team: BoardTeam; row: number; teamColor: string } + | { type: 'project'; project: ApiProject; row: number; teamColor: string } + +const gridItems: GridItem[] = [] +let currentRow = 3 // rows 1–2 are quarter/month headers + +for (const rootTeam of rootTeams) { + const teamProjects = collectTeamProjects(rootTeam.id) + if (teamProjects.length === 0) continue + const teamColor = rootTeam.color ?? '#888888' + gridItems.push({ + type: 'team-header', + team: rootTeam, + row: currentRow, + teamColor + }) + currentRow++ + for (const proj of teamProjects) { + gridItems.push({ + type: 'project', + project: proj, + row: currentRow, + teamColor + }) + currentRow++ + } +} + +// Append uncategorised projects (no team, or team not in the hierarchy) +const assignedIds = new Set( + gridItems.flatMap((i) => (i.type === 'project' ? [i.project.id] : [])) +) +const uncategorised = projects + .filter((p) => !assignedIds.has(p.id)) + .sort((a, b) => a.sortOrder - b.sortOrder) +for (const proj of uncategorised) { + const teamColor = proj.team?.color ?? '#888888' + gridItems.push({ type: 'project', project: proj, row: currentRow, teamColor }) + currentRow++ +} +--- + +
+
+ +
+ +
+ Project +
+ + + { + quarterHeaders.map((qh) => ( +
+ {qh.label} +
+ )) + } + + + { + months.map((m) => ( +
+ {m.label} + {m.year === nowYear && m.month === nowMonth && ( + Now + )} +
+ )) + } + + + { + gridItems.map((item) => { + if (item.type === 'team-header') { + const { team, row, teamColor } = item + return ( +
+
+ + {team.name} +
+
+ ) + } + + const { project: proj, row, teamColor } = item + const projColor = proj.color ?? '#888' + const iconEmoji = resolveIcon(proj.icon) + const hasDates = !!(proj.startDate && proj.targetDate) + const barLeft = hasDates + ? pctLeft(new Date(proj.startDate!)).toFixed(2) + : null + const barWidth = hasDates + ? pctWidth( + new Date(proj.startDate!), + new Date(proj.targetDate!) + ).toFixed(2) + : null + const datedMilestones = proj.milestones.filter((ms) => ms.targetDate) + + // Completed project with no targetDate but a completedAt + const hasCompletedBar = !!( + proj.startDate && + !proj.targetDate && + proj.completedAt + ) + const completedBarLeft = hasCompletedBar + ? pctLeft(new Date(proj.startDate!)).toFixed(2) + : null + const completedBarWidth = hasCompletedBar + ? pctWidth( + new Date(proj.startDate!), + new Date(proj.completedAt!) + ).toFixed(2) + : null + + // Milestones that extend past the project's targetDate + const lastMilestoneDate = + datedMilestones.length > 0 + ? new Date( + Math.max( + ...datedMilestones.map((ms) => + new Date(ms.targetDate!).getTime() + ) + ) + ) + : null + const hasExtension = + hasDates && + lastMilestoneDate !== null && + lastMilestoneDate > new Date(proj.targetDate!) + const extensionLeft = hasExtension + ? pctLeft(new Date(proj.targetDate!)).toFixed(2) + : null + const extensionWidth = + hasExtension && lastMilestoneDate + ? pctWidth(new Date(proj.targetDate!), lastMilestoneDate).toFixed( + 2 + ) + : null + + return ( + <> + {/* Project label — col 1, explicit row */} +
+ {proj.url ? ( + + {iconEmoji && ( + + )} + {proj.name} + + ) : ( + + {iconEmoji && ( + + )} + {proj.name} + + )} +
+ + {/* Project timeline track — cols 2 to last, explicit row */} +
+ {/* "Today" marker */} +
+ + {/* Project duration bar */} + {hasDates && ( +
+ )} + + {/* Milestone overflow extension beyond targetDate */} + {hasExtension && ( +
+ )} + + {/* Completed bar (no targetDate, resolved via completedAt) */} + {hasCompletedBar && ( +
+ )} + + {/* Inline milestone diamond markers */} + {datedMilestones.map((ms) => { + const left = pctLeft(new Date(ms.targetDate!)).toFixed(2) + return ( +
+ + {ms.name} +
+ ) + })} +
+ + ) + }) + } +
+
+
+ + + + diff --git a/src/layouts/RoadmapLayout.astro b/src/layouts/RoadmapLayout.astro new file mode 100644 index 00000000..3e6573f9 --- /dev/null +++ b/src/layouts/RoadmapLayout.astro @@ -0,0 +1,162 @@ +--- +import FoundationFooter from '../components/pages/FoundationFooter.astro' +import FoundationHeader from '../components/pages/FoundationHeader.astro' +import '../styles/pages.css' + +interface Props { + title: string + description?: string + canonicalURL?: string +} + +const { + title, + description, + canonicalURL = new URL(Astro.url.pathname, Astro.site).href +} = Astro.props +--- + + + + + + + + + + + {title ? `${title} | Interledger Foundation` : 'Interledger Foundation'} + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/pages/roadmap.astro b/src/pages/roadmap.astro new file mode 100644 index 00000000..49e1fc1f --- /dev/null +++ b/src/pages/roadmap.astro @@ -0,0 +1,111 @@ +--- +import RoadmapLayout from '../layouts/RoadmapLayout.astro' +import RoadmapBoard from '../components/RoadmapBoard.astro' +import type { ApiProject, BoardTeam } from '../types/roadmap' + +let projects: ApiProject[] = [] +let teams: BoardTeam[] = [] +let fetchError = false + +try { + const url = (import.meta.env.ROADMAP_API_URL ?? '').trim().replace(/\/$/, '') + const res = await fetch(`${url}/api/roadmap.json`) + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`) + const data = await res.json() + projects = data.projects ?? [] + teams = data.teams ?? [] +} catch { + fetchError = true +} +--- + + +
+ +
+
+

Developers Roadmap

+

+ Track milestones, active work, and shipped items across the + Interledger open-source ecosystem. +

+
+
+ + +
+ { + fetchError ? ( +
+

+ The roadmap could not be loaded right now. Please check back + shortly or visit our{' '} + + GitHub organisation + {' '} + for the latest project status. +

+
+ ) : projects.length === 0 ? ( +
+

No roadmap data available yet. Check back soon!

+
+ ) : ( + + ) + } +
+
+
+ + diff --git a/src/types/roadmap.ts b/src/types/roadmap.ts new file mode 100644 index 00000000..0868c0ff --- /dev/null +++ b/src/types/roadmap.ts @@ -0,0 +1,53 @@ +export interface BoardMilestone { + id: string + name: string + targetDate: string | null +} + +export interface BoardProject { + id: string + name: string + description: string | null + color: string | null + state: string + icon: string | null + priority: number + progress: number + sortOrder: number + startDate: string | null + targetDate: string | null + completedAt: string | null + url: string | null + milestones: BoardMilestone[] +} + +// Project as returned by the API (has nested team object) +export interface ApiProject extends BoardProject { + team: { id: string; name: string; key: string; color: string } | null +} + +// Team as returned by the API +export interface BoardTeam { + id: string + name: string + key: string + color: string | null + childrenIds: string[] + projectCount: number +} + +// Internal component types (used by RoadmapBoard) +export interface BoardGroup { + id: string + name: string + description: string | null + color: string | null + icon: string | null + status: string | null + startDate: string | null + targetDate: string | null +} + +export interface BoardRow extends BoardGroup { + projects: BoardProject[] +}