From 22ad156f27a47de2d65fcbe7e01f8603e27d64ed Mon Sep 17 00:00:00 2001 From: hfrancis31 Date: Thu, 23 Jul 2026 15:47:08 -0500 Subject: [PATCH] feat(apollo-vertex): add Roadmap Status page with live Jira data Adds a /design-system-status page (nav: "Roadmap status", positioned directly under Introduction) that reads live from the VS Horizontal UX Jira board and displays three sections: Recently Delivered, Coming Soon, and Backlog. - lib/jira.ts: Jira Cloud REST API v3 client with cursor pagination and ADF text extraction (handles plain text and smartlink inlineCard nodes) - lib/jira-resolve.ts: maps raw issues to ProcessedCard objects, resolves delivered links via explicit Vertex URL > convention slug > Jira fallback, extracts epic from parent field - app/api/jira/route.ts: GET handler returning board data as JSON - app/design-system-status/page.mdx + _components/status-board.tsx: async server component rendering the three-column card board - app/_meta.ts: adds "Roadmap status" nav entry below Introduction Credentials required: JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN in .env.local (local) or Vercel environment variables (production). Board filter: label = "horizontal-ux"; delivered filter: label = "ds-delivered". Co-Authored-By: Claude Sonnet 4.6 --- apps/apollo-vertex/app/_meta.ts | 1 + apps/apollo-vertex/app/api/jira/route.ts | 22 ++ .../_components/status-board.tsx | 235 ++++++++++++++++ .../app/design-system-status/page.mdx | 11 + apps/apollo-vertex/lib/jira-resolve.ts | 259 ++++++++++++++++++ apps/apollo-vertex/lib/jira.ts | 105 +++++++ 6 files changed, 633 insertions(+) create mode 100644 apps/apollo-vertex/app/api/jira/route.ts create mode 100644 apps/apollo-vertex/app/design-system-status/_components/status-board.tsx create mode 100644 apps/apollo-vertex/app/design-system-status/page.mdx create mode 100644 apps/apollo-vertex/lib/jira-resolve.ts create mode 100644 apps/apollo-vertex/lib/jira.ts diff --git a/apps/apollo-vertex/app/_meta.ts b/apps/apollo-vertex/app/_meta.ts index 152665709..d737f8f11 100644 --- a/apps/apollo-vertex/app/_meta.ts +++ b/apps/apollo-vertex/app/_meta.ts @@ -1,5 +1,6 @@ export default { index: "Introduction", + "design-system-status": "Roadmap status", foundation: "Foundation", components: "Components", patterns: "Patterns", diff --git a/apps/apollo-vertex/app/api/jira/route.ts b/apps/apollo-vertex/app/api/jira/route.ts new file mode 100644 index 000000000..d455bf616 --- /dev/null +++ b/apps/apollo-vertex/app/api/jira/route.ts @@ -0,0 +1,22 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { fetchJiraIssues } from "@/lib/jira"; +import { processIssues } from "@/lib/jira-resolve"; + +// Intentionally public — this endpoint surfaces cross-team design system status. +// No auth guard is by design; confirmed with @ruudandriessen. +export async function GET(_req: NextRequest) { + try { + const issues = await fetchJiraIssues(); + const jiraBaseUrl = + process.env.JIRA_BASE_URL ?? "https://uipath.atlassian.net"; + const data = processIssues(issues, jiraBaseUrl); + return NextResponse.json(data, { + headers: { + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60", + }, + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/apollo-vertex/app/design-system-status/_components/status-board.tsx b/apps/apollo-vertex/app/design-system-status/_components/status-board.tsx new file mode 100644 index 000000000..bb661829c --- /dev/null +++ b/apps/apollo-vertex/app/design-system-status/_components/status-board.tsx @@ -0,0 +1,235 @@ +import { + ArrowUpRight, + CheckCircle2, + Clock, + Inbox, + TriangleAlert, +} from "lucide-react"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import { fetchJiraIssues } from "@/lib/jira"; +import { + type BadgeLabel, + type BoardData, + type ProcessedCard, + processIssues, +} from "@/lib/jira-resolve"; +import { Badge } from "@/registry/badge/badge"; + +// ─── status tag ────────────────────────────────────────────────────────────── + +function StatusTag({ status }: { status: string }) { + const s = status.toLowerCase(); + + if (s === "in review" || s === "review") { + return ( + + In Review + + ); + } + if (s === "in progress") { + return In Progress; + } + if (s === "closed" || s === "done") { + return ( + + Delivered + + ); + } + return {status}; +} + +// ─── label badge ───────────────────────────────────────────────────────────── + +function LegalBadge({ label }: { label: BadgeLabel }) { + if (label === "required") { + return ( + + Required + + ); + } + if (label === "best-practice") { + return ( + + Best practice + + ); + } + return null; +} + +// ─── card ──────────────────────────────────────────────────────────────────── + +function StatusCard({ card }: { card: ProcessedCard }) { + const isExternal = card.link.startsWith("http"); + + return ( + +
+
+ + {card.badge && } +
+ +
+ +

+ {card.summary} +

+ +
+ {card.epicName && ( +

{card.epicName}

+ )} +

{card.key}

+
+ + ); +} + +// ─── section ───────────────────────────────────────────────────────────────── + +function Section({ + title, + description, + icon, + cards, + empty, +}: { + title: string; + description: string; + icon: ReactNode; + cards: ProcessedCard[]; + empty: string; +}) { + return ( +
+
+ {icon} +
+

{title}

+

{description}

+
+ + {cards.length} + +
+ + {cards.length === 0 ? ( +

+ {empty} +

+ ) : ( +
+ {cards.map((card) => ( + + ))} +
+ )} +
+ ); +} + +// ─── error state ───────────────────────────────────────────────────────────── + +function SetupError({ message }: { message: string }) { + const isMissingConfig = message.includes("Missing Jira configuration"); + return ( +
+
+ + + {isMissingConfig + ? "Jira credentials not configured" + : "Could not load Jira data"} + +
+ {isMissingConfig ? ( +
+

+ Add{" "} + + JIRA_BASE_URL + + ,{" "} + + JIRA_EMAIL + + , and{" "} + + JIRA_API_TOKEN + {" "} + to{" "} + + apps/apollo-vertex/.env.local + + , then restart the dev server. +

+
+ ) : ( +

{message}

+ )} +
+ ); +} + +// ─── board ─────────────────────────────────────────────────────────────────── + +export async function StatusBoard() { + let data: BoardData | null = null; + let error: string | null = null; + + try { + const issues = await fetchJiraIssues(); + const jiraBaseUrl = + process.env.JIRA_BASE_URL ?? "https://uipath.atlassian.net"; + data = processIssues(issues, jiraBaseUrl); + } catch (e) { + error = e instanceof Error ? e.message : String(e); + } + + if (error) { + return ( +
+ +
+ ); + } + + if (!data) return null; + + return ( +
+
} + cards={data.delivered} + empty="No delivered items yet." + /> + +
} + cards={data.comingSoon} + empty="Nothing in progress right now." + /> + +
} + cards={data.backlog} + empty="Backlog is empty." + /> +
+ ); +} diff --git a/apps/apollo-vertex/app/design-system-status/page.mdx b/apps/apollo-vertex/app/design-system-status/page.mdx new file mode 100644 index 000000000..576949204 --- /dev/null +++ b/apps/apollo-vertex/app/design-system-status/page.mdx @@ -0,0 +1,11 @@ +--- +title: Roadmap status +--- + +import { StatusBoard } from './_components/status-board'; + +# Roadmap status + +Live view of design system work for developers building on Vertex. Jira is the single source of truth. [View the VS Horizontal UX board →](https://uipath.atlassian.net/jira/software/projects/DESIGN/boards) + + diff --git a/apps/apollo-vertex/lib/jira-resolve.ts b/apps/apollo-vertex/lib/jira-resolve.ts new file mode 100644 index 000000000..b47000e76 --- /dev/null +++ b/apps/apollo-vertex/lib/jira-resolve.ts @@ -0,0 +1,259 @@ +import { extractAdfText, type JiraIssue } from "./jira"; + +// All known routable slugs per section, derived from the site's _meta.ts files +const SLUGS_BY_SECTION: Record = { + components: [ + "accordion", + "alert", + "alert-dialog", + "aspect-ratio", + "avatar", + "badge", + "breadcrumb", + "button", + "button-group", + "calendar", + "card", + "carousel", + "chart", + "line-chart", + "multi-line-chart", + "bar-chart", + "distribution-chart", + "kpi-chart", + "table-chart", + "checkbox", + "collapsible", + "combobox", + "command", + "context-menu", + "data-table", + "date-picker", + "dialog", + "drawer", + "dropdown-menu", + "empty", + "field", + "feature-flags", + "filter-dropdown", + "form", + "form-wizard", + "hover-card", + "input", + "input-group", + "input-otp", + "item", + "kbd", + "label", + "menubar", + "navigation-menu", + "pagination", + "popover", + "progress", + "radio-group", + "resizable", + "scroll-area", + "select", + "separator", + "sheet", + "sidebar", + "skeleton", + "slider", + "sonner", + "spinner", + "switch", + "table", + "tabs", + "textarea", + "toggle", + "toggle-group", + "tooltip", + ], + patterns: [ + "ai-chat", + "feedback-vote-widget", + "metric-card", + "page-header", + "shell", + ], + templates: ["list-page", "settings", "solution-tests"], + guidelines: ["ai-toolkit", "notifications"], + foundation: ["colors", "spacing", "grid", "typography", "icons", "logos"], +}; + +// Flat map: slug → absolute path +const SLUG_PATH_MAP = new Map(); +for (const [section, slugs] of Object.entries(SLUGS_BY_SECTION)) { + for (const slug of slugs) { + SLUG_PATH_MAP.set(slug, `/${section}/${slug}`); + } +} + +export type LinkSource = "explicit" | "convention" | "jira"; +export type Section = "delivered" | "coming-soon" | "backlog"; +export type BadgeLabel = "required" | "best-practice" | null; + +export interface ProcessedCard { + key: string; + summary: string; + status: string; + section: Section; + badge: BadgeLabel; + link: string; + linkSource: LinkSource; + jiraUrl: string; + updated: string; + epicName: string | null; + epicKey: string | null; +} + +export interface BoardData { + delivered: ProcessedCard[]; + comingSoon: ProcessedCard[]; + backlog: ProcessedCard[]; + linkStats: { explicit: number; convention: number; jiraFallback: number }; +} + +const SECTION_BY_STATUS = { + closed: "delivered", + done: "delivered", + "in progress": "coming-soon", + "in review": "coming-soon", + review: "coming-soon", +} satisfies Record; + +function toSection(statusName: string): Section { + const key = statusName.trim().toLowerCase(); + return ( + (SECTION_BY_STATUS as Record)[key] ?? "backlog" + ); +} + +const BADGE_BY_LABEL = { + "ai-legal-required": "required", + "ai-legal-best-practice": "best-practice", +} as const satisfies Record>; + +const BADGE_LABEL_PRIORITY = [ + "ai-legal-required", + "ai-legal-best-practice", +] as const; + +function toBadge(labels: readonly string[]): BadgeLabel { + const label = BADGE_LABEL_PRIORITY.find((candidate) => + labels.includes(candidate), + ); + return label ? BADGE_BY_LABEL[label] : null; +} + +function resolveDeliveredLink( + issue: JiraIssue, + jiraUrl: string, +): { url: string; source: LinkSource } { + const descText = extractAdfText(issue.fields.description); + + // Priority 1: explicit "Vertex: https://..." or "Vertex URL: https://..." in description + const vertexMatch = descText.match( + /Vertex(?:\s+URL)?:\s*(https?:\/\/[^\s\n)]+)/i, + ); + if (vertexMatch) { + return { url: vertexMatch[1].trim(), source: "explicit" }; + } + + // Priority 2: "Component: " convention — slugify and look up known paths + const componentMatch = descText.match(/^Component:\s*(.+)$/im); + if (componentMatch) { + const rawSlug = componentMatch[1].match(/^([^\s(,]+)/)?.[1]; + if (rawSlug) { + const slug = rawSlug + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/(^-|-$)/g, ""); + const path = SLUG_PATH_MAP.get(slug); + if (path) return { url: path, source: "convention" }; + } + } + + // Priority 3: fall back to Jira ticket + return { url: jiraUrl, source: "jira" }; +} + +function buildCard(issue: JiraIssue, jiraBaseUrl: string): ProcessedCard { + const statusName = issue.fields.status.name; + const section = toSection(statusName); + const jiraUrl = `${jiraBaseUrl}/browse/${issue.key}`; + + const { url: link, source: linkSource } = + section === "delivered" + ? resolveDeliveredLink(issue, jiraUrl) + : { url: jiraUrl, source: "jira" as LinkSource }; + + const parentIsEpic = + issue.fields.parent?.fields.issuetype.name === "Epic" || + issue.fields.parent?.fields.issuetype.hierarchyLevel === 1; + + return { + key: issue.key, + summary: issue.fields.summary, + status: statusName, + section, + badge: toBadge(issue.fields.labels), + link, + linkSource, + jiraUrl, + updated: issue.fields.updated, + epicName: parentIsEpic + ? (issue.fields.parent?.fields.summary ?? null) + : null, + epicKey: parentIsEpic ? (issue.fields.parent?.key ?? null) : null, + }; +} + +function isReviewStatus(s: string): boolean { + const sl = s.toLowerCase(); + return sl === "review" || sl === "in review"; +} + +function sortComingSoon(cards: ProcessedCard[]): ProcessedCard[] { + return cards.toSorted((a, b) => { + const aReview = isReviewStatus(a.status); + const bReview = isReviewStatus(b.status); + if (aReview !== bReview) return aReview ? -1 : 1; + return 0; + }); +} + +function countLinkStats(cards: ProcessedCard[]): BoardData["linkStats"] { + return cards.reduce( + (acc, c) => { + if (c.linkSource === "explicit") acc.explicit++; + else if (c.linkSource === "convention") acc.convention++; + else acc.jiraFallback++; + return acc; + }, + { explicit: 0, convention: 0, jiraFallback: 0 }, + ); +} + +export function processIssues( + issues: JiraIssue[], + jiraBaseUrl: string, +): BoardData { + const delivered: ProcessedCard[] = []; + const comingSoon: ProcessedCard[] = []; + const backlog: ProcessedCard[] = []; + + for (const issue of issues) { + const card = buildCard(issue, jiraBaseUrl); + if (card.section === "delivered") delivered.push(card); + else if (card.section === "coming-soon") comingSoon.push(card); + else backlog.push(card); + } + + return { + delivered, + comingSoon: sortComingSoon(comingSoon), + backlog, + linkStats: countLinkStats(delivered), + }; +} diff --git a/apps/apollo-vertex/lib/jira.ts b/apps/apollo-vertex/lib/jira.ts new file mode 100644 index 000000000..5614cae79 --- /dev/null +++ b/apps/apollo-vertex/lib/jira.ts @@ -0,0 +1,105 @@ +const JQL = + 'project = DESIGN AND labels = "horizontal-ux" AND status != "On hold" AND (statusCategory != Done OR labels = "ds-delivered") ORDER BY updated DESC'; + +const FIELDS = [ + "summary", + "status", + "labels", + "updated", + "resolutiondate", + "description", + "parent", +]; + +interface AdfNode { + type: string; + text?: string; + content?: AdfNode[]; + attrs?: Record; +} + +export interface JiraIssue { + key: string; + fields: { + summary: string; + status: { name: string }; + labels: string[]; + updated: string; + resolutiondate: string | null; + description: AdfNode | null; + parent?: { + key: string; + fields: { + summary: string; + issuetype: { name: string; hierarchyLevel: number }; + }; + } | null; + }; +} + +export function extractAdfText(node: AdfNode | null | undefined): string { + if (!node) return ""; + if (node.type === "text") return node.text ?? ""; + if (node.type === "inlineCard") return node.attrs?.url ?? ""; + if (!node.content) return ""; + const parts = node.content.map(extractAdfText); + const isBlock = + node.type === "paragraph" || + node.type === "heading" || + node.type === "listItem" || + node.type === "bulletList" || + node.type === "orderedList"; + return isBlock ? `${parts.join("")}\n` : parts.join(""); +} + +export async function fetchJiraIssues(): Promise { + const base = process.env.JIRA_BASE_URL; + const email = process.env.JIRA_EMAIL; + const token = process.env.JIRA_API_TOKEN; + + if (!base || !email || !token) { + throw new Error( + "Missing Jira configuration. Add JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN to .env.local", + ); + } + + const auth = Buffer.from(`${email}:${token}`).toString("base64"); + const issues: JiraIssue[] = []; + let nextPageToken: string | undefined; + + do { + const body: Record = { + jql: JQL, + fields: FIELDS, + maxResults: 50, + }; + if (nextPageToken) body["nextPageToken"] = nextPageToken; + + // eslint-disable-next-line no-await-in-loop -- cursor pagination is inherently sequential + const res = await fetch(`${base}/rest/api/3/search/jql`, { + method: "POST", + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + next: { revalidate: 300 }, + }); + + if (!res.ok) { + // eslint-disable-next-line no-await-in-loop -- cursor pagination is inherently sequential + const body = await res.text(); + console.error(`Jira API error body (${res.status}):`, body); + throw new Error(`Jira API error: ${res.status} ${res.statusText}`); + } + + // eslint-disable-next-line no-await-in-loop -- cursor pagination is inherently sequential + const json: unknown = await res.json(); + const data = json as { issues?: JiraIssue[]; nextPageToken?: string }; + issues.push(...(data.issues ?? [])); + nextPageToken = data.nextPageToken; + } while (nextPageToken); + + return issues; +}