From 35290acf12ecb4a68a0c5571579ae3da79889c23 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 | 17 ++ .../_components/status-board.tsx | 235 ++++++++++++++++++ .../app/design-system-status/page.mdx | 11 + apps/apollo-vertex/lib/jira-resolve.ts | 225 +++++++++++++++++ apps/apollo-vertex/lib/jira.ts | 103 ++++++++ 6 files changed, 592 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..0d43db67f --- /dev/null +++ b/apps/apollo-vertex/app/api/jira/route.ts @@ -0,0 +1,17 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { fetchJiraIssues } from '@/lib/jira'; +import { processIssues } from '@/lib/jira-resolve'; + +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': 'no-store' }, + }); + } 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..1574fbc8a --- /dev/null +++ b/apps/apollo-vertex/app/design-system-status/_components/status-board.tsx @@ -0,0 +1,235 @@ +import Link from "next/link"; +import { + ArrowUpRight, + CheckCircle2, + Clock, + Inbox, + TriangleAlert, +} from "lucide-react"; +import { Badge } from "@/registry/badge/badge"; +import { fetchJiraIssues } from "@/lib/jira"; +import { + processIssues, + type BadgeLabel, + type BoardData, + type ProcessedCard, +} from "@/lib/jira-resolve"; + +// ─── 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: React.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="Nothing shipped in the last 30 days." + /> + +
} + 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..5e076aec2 --- /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..610f7ea8c --- /dev/null +++ b/apps/apollo-vertex/lib/jira-resolve.ts @@ -0,0 +1,225 @@ +import { type JiraIssue, extractAdfText } 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 }; +} + +function toSection(statusName: string): Section { + const s = statusName.toLowerCase(); + if (s === "closed" || s === "done") return "delivered"; + if (s === "in progress" || s === "in review" || s === "review") + return "coming-soon"; + return "backlog"; +} + +function toBadge(labels: string[]): BadgeLabel { + if (labels.includes("ai-legal-required")) return "required"; + if (labels.includes("ai-legal-best-practice")) return "best-practice"; + return 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() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/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" }; +} + +export function processIssues( + issues: JiraIssue[], + jiraBaseUrl: string, +): BoardData { + const delivered: ProcessedCard[] = []; + const comingSoon: ProcessedCard[] = []; + const backlog: ProcessedCard[] = []; + const linkStats = { explicit: 0, convention: 0, jiraFallback: 0 }; + + for (const issue of issues) { + const statusName = issue.fields.status.name; + const section = toSection(statusName); + const jiraUrl = `${jiraBaseUrl}/browse/${issue.key}`; + + let link: string; + let linkSource: LinkSource; + + if (section === "delivered") { + const resolved = resolveDeliveredLink(issue, jiraUrl); + link = resolved.url; + linkSource = resolved.source; + if (linkSource === "explicit") linkStats.explicit++; + else if (linkSource === "convention") linkStats.convention++; + else linkStats.jiraFallback++; + } else { + link = jiraUrl; + linkSource = "jira"; + } + + const parentIsEpic = + issue.fields.parent?.fields.issuetype.name === "Epic" || + issue.fields.parent?.fields.issuetype.hierarchyLevel === 1; + + const card: ProcessedCard = { + 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, + }; + + if (section === "delivered") { + delivered.push(card); + } else if (section === "coming-soon") { + // Review/In Review sorts before In Progress — closer to landing + const sl = statusName.toLowerCase(); + if (sl === "in review" || sl === "review") comingSoon.unshift(card); + else comingSoon.push(card); + } else { + backlog.push(card); + } + } + + return { delivered, comingSoon, backlog, linkStats }; +} diff --git a/apps/apollo-vertex/lib/jira.ts b/apps/apollo-vertex/lib/jira.ts new file mode 100644 index 000000000..58264f9e9 --- /dev/null +++ b/apps/apollo-vertex/lib/jira.ts @@ -0,0 +1,103 @@ +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; + + 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), + cache: "no-store", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Jira API error ${res.status}: ${text}`); + } + + const data = (await res.json()) as { + issues?: JiraIssue[]; + nextPageToken?: string; + }; + issues.push(...(data.issues ?? [])); + nextPageToken = data.nextPageToken; + } while (nextPageToken); + + return issues; +}