From fde765a5a882ed7605db78e07e2b011addc39254 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 9 Feb 2026 17:59:03 -0800 Subject: [PATCH] Wire roadmap page to API data with voting --- __tests__/roadmap-page.test.tsx | 136 +++++------- data/roadmap.json | 107 +++++++++ src/app/api/roadmap/route.ts | 26 +++ src/app/api/roadmap/vote/route.ts | 45 ++++ src/app/roadmap/[id]/page.tsx | 22 +- src/app/roadmap/page.tsx | 353 ++++++++++++------------------ src/lib/server/roadmapStore.ts | 55 +++++ 7 files changed, 445 insertions(+), 299 deletions(-) create mode 100644 data/roadmap.json create mode 100644 src/app/api/roadmap/route.ts create mode 100644 src/app/api/roadmap/vote/route.ts create mode 100644 src/lib/server/roadmapStore.ts diff --git a/__tests__/roadmap-page.test.tsx b/__tests__/roadmap-page.test.tsx index 3a87b2d3..e96ea391 100644 --- a/__tests__/roadmap-page.test.tsx +++ b/__tests__/roadmap-page.test.tsx @@ -1,104 +1,82 @@ /** @jest-environment jsdom */ import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import RoadmapPage from "../src/app/roadmap/page"; -type LinkProps = { - href: string; - children?: React.ReactNode; -} & React.AnchorHTMLAttributes; +const mockItems = [ + { + id: "workspace-profiles", + title: "Workspace Profiles", + description: "Save and switch between project-specific agent configurations.", + status: "planned", + votes: 42, + }, + { + id: "roadmap-voting-api", + title: "Roadmap Voting API", + description: "Public API support for roadmap voting and analytics.", + status: "in-progress", + votes: 74, + }, + { + id: "public-skill-registry", + title: "Public Skill Registry", + description: "Searchable index of verified skills and metadata.", + status: "completed", + votes: 96, + }, +]; -jest.mock("next/link", () => { - const LinkMock = ({ href, children, ...props }: LinkProps) => ( - - {children} - - ); - LinkMock.displayName = "Link"; - return LinkMock; -}); +describe("Roadmap Page", () => { + beforeEach(() => { + global.fetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); -jest.mock("next/navigation", () => ({ - usePathname: jest.fn(() => "/roadmap"), -})); + if (url === "/api/roadmap" && (!init || init.method === undefined)) { + return Promise.resolve({ + ok: true, + json: async () => ({ items: mockItems, total: mockItems.length }), + } as Response); + } -describe("Roadmap Page", () => { - it("renders the roadmap page", () => { - const { container } = render(); - expect(container).toBeInTheDocument(); - }); + if (url === "/api/roadmap/vote" && init?.method === "POST") { + return Promise.resolve({ + ok: true, + json: async () => ({ id: "workspace-profiles", votes: 43 }), + } as Response); + } - it("displays the page title", () => { - render(); - expect(screen.getByText("Product Roadmap")).toBeInTheDocument(); + return Promise.resolve({ ok: false } as Response); + }) as jest.Mock; }); - it("displays the page description", () => { - render(); - expect( - screen.getByText(/See what we're building/i) - ).toBeInTheDocument(); + afterEach(() => { + jest.resetAllMocks(); }); - it("displays category filter buttons", () => { + it("renders roadmap columns from API data", async () => { render(); - expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Platform" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Skills" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Community" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "API" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Enterprise" })).toBeInTheDocument(); - }); - it("displays sort dropdown", () => { - render(); - expect(screen.getByText("Sort by:")).toBeInTheDocument(); - const select = screen.getByRole("combobox"); - expect(select).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText("Workspace Profiles")).toBeInTheDocument(); + }); - it("displays all status columns", () => { - render(); - expect(screen.getByText("Planned")).toBeInTheDocument(); expect(screen.getByText("In Progress")).toBeInTheDocument(); - expect(screen.getByText("Shipped")).toBeInTheDocument(); - expect(screen.getByText("Considering")).toBeInTheDocument(); + expect(screen.getByText("Completed")).toBeInTheDocument(); }); - it("displays roadmap items", () => { + it("votes on planned items", async () => { render(); - // Check for at least one item from the seed data - expect(screen.getByText("Real-time Agent Monitoring")).toBeInTheDocument(); - }); - it("allows filtering by category", () => { - render(); - const platformButton = screen.getByRole("button", { name: "Platform" }); - fireEvent.click(platformButton); - expect(screen.getByText("Real-time Agent Monitoring")).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText("Workspace Profiles")).toBeInTheDocument(); + }); - it("allows sorting items", () => { - render(); - const select = screen.getByRole("combobox"); - fireEvent.change(select, { target: { value: "newest" } }); - expect(select).toHaveValue("newest"); - }); + fireEvent.click(screen.getByRole("button", { name: "Vote" })); - it("displays upvote and comment counts", () => { - render(); - // Check that vote/comment icons and counts are rendered - const cards = screen.getAllByRole("link"); - expect(cards.length).toBeGreaterThan(0); - }); - - it("items link to detail pages", () => { - render(); - const links = screen.getAllByRole("link"); - const roadmapLink = links.find((link) => - link.getAttribute("href")?.startsWith("/roadmap/") - ); - expect(roadmapLink).toBeTruthy(); + await waitFor(() => { + expect(screen.getByText("43 votes")).toBeInTheDocument(); + }); }); }); diff --git a/data/roadmap.json b/data/roadmap.json new file mode 100644 index 00000000..ec4d439e --- /dev/null +++ b/data/roadmap.json @@ -0,0 +1,107 @@ +[ + { + "id": "workspace-profiles", + "title": "Workspace Profiles", + "description": "Save and switch between project-specific agent configurations.", + "status": "planned", + "votes": 42 + }, + { + "id": "smart-context-packs", + "title": "Smart Context Packs", + "description": "Auto-load relevant docs and memories based on the current task.", + "status": "planned", + "votes": 57 + }, + { + "id": "voice-story-mode", + "title": "Voice Story Mode", + "description": "One-click narrated summaries and updates using built-in TTS.", + "status": "planned", + "votes": 33 + }, + { + "id": "team-handoffs", + "title": "Team Handoffs", + "description": "Structured lane handoff notes between agents and teammates.", + "status": "planned", + "votes": 61 + }, + { + "id": "prompt-diff-view", + "title": "Prompt Diff View", + "description": "Compare instruction changes over time with inline highlights.", + "status": "planned", + "votes": 25 + }, + { + "id": "roadmap-voting-api", + "title": "Roadmap Voting API", + "description": "Public API support for roadmap voting and analytics.", + "status": "in-progress", + "votes": 74 + }, + { + "id": "agent-observability", + "title": "Agent Observability", + "description": "Latency, token, and failure dashboards for agent runs.", + "status": "in-progress", + "votes": 89 + }, + { + "id": "marketplace-reviews", + "title": "Marketplace Reviews", + "description": "Community ratings and written reviews for shared artifacts.", + "status": "in-progress", + "votes": 52 + }, + { + "id": "premium-entitlements-v2", + "title": "Premium Entitlements v2", + "description": "Granular usage limits and team-level premium features.", + "status": "in-progress", + "votes": 48 + }, + { + "id": "changelog-digests", + "title": "Changelog Digests", + "description": "Weekly digest emails and API feed summaries.", + "status": "in-progress", + "votes": 40 + }, + { + "id": "public-skill-registry", + "title": "Public Skill Registry", + "description": "Searchable index of verified skills and metadata.", + "status": "completed", + "votes": 96 + }, + { + "id": "agent-verification-badges", + "title": "Agent Verification Badges", + "description": "Visual trust indicators for validated agents and MCP servers.", + "status": "completed", + "votes": 87 + }, + { + "id": "digest-api", + "title": "Digest API", + "description": "Programmatic digest endpoint for external clients.", + "status": "completed", + "votes": 65 + }, + { + "id": "artifacts-rss-feeds", + "title": "Artifacts RSS Feeds", + "description": "RSS/JSON feeds for artifact publishing and updates.", + "status": "completed", + "votes": 58 + }, + { + "id": "search-quota-controls", + "title": "Search Quota Controls", + "description": "Per-user search quota tracking and upgrade prompts.", + "status": "completed", + "votes": 71 + } +] diff --git a/src/app/api/roadmap/route.ts b/src/app/api/roadmap/route.ts new file mode 100644 index 00000000..87599ad4 --- /dev/null +++ b/src/app/api/roadmap/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getRoadmapItems, isRoadmapStatus } from "@/lib/server/roadmapStore"; + +export async function GET(request: NextRequest) { + try { + const items = await getRoadmapItems(); + const status = request.nextUrl.searchParams.get("status"); + + if (status && !isRoadmapStatus(status)) { + return NextResponse.json( + { error: "Invalid status. Use planned, in-progress, or completed." }, + { status: 400 } + ); + } + + const filteredItems = status ? items.filter((item) => item.status === status) : items; + + return NextResponse.json( + { items: filteredItems, total: filteredItems.length }, + { headers: { "Cache-Control": "no-store" } } + ); + } catch (error) { + console.error("Failed to load roadmap items", error); + return NextResponse.json({ error: "Failed to load roadmap" }, { status: 500 }); + } +} diff --git a/src/app/api/roadmap/vote/route.ts b/src/app/api/roadmap/vote/route.ts new file mode 100644 index 00000000..b0bb6ef4 --- /dev/null +++ b/src/app/api/roadmap/vote/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { checkRateLimit, getClientIp } from "@/lib/requestLimits"; +import { incrementRoadmapVote } from "@/lib/server/roadmapStore"; + +const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as { itemId?: unknown }; + const itemId = typeof body.itemId === "string" ? body.itemId.trim() : ""; + + if (!itemId) { + return NextResponse.json({ error: "itemId is required" }, { status: 400 }); + } + + const ip = getClientIp(request); + const limited = checkRateLimit(`roadmap-vote:${ip}:${itemId}`, { + windowMs: RATE_LIMIT_WINDOW_MS, + max: 1, + }); + + if (!limited.ok) { + return NextResponse.json( + { error: "Vote limit reached for this item. Try again later." }, + { + status: 429, + headers: { + "Retry-After": String(limited.retryAfterSec), + }, + } + ); + } + + const vote = await incrementRoadmapVote(itemId); + + if (!vote) { + return NextResponse.json({ error: "Roadmap item not found" }, { status: 404 }); + } + + return NextResponse.json(vote); + } catch (error) { + console.error("Failed to vote on roadmap item", error); + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } +} diff --git a/src/app/roadmap/[id]/page.tsx b/src/app/roadmap/[id]/page.tsx index 873c7816..cd800fcd 100644 --- a/src/app/roadmap/[id]/page.tsx +++ b/src/app/roadmap/[id]/page.tsx @@ -7,7 +7,27 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import roadmapData from "@/data/roadmap.json"; -import type { RoadmapItem, RoadmapStatus, RoadmapCategory } from "../page"; + +type RoadmapStatus = "Planned" | "In Progress" | "Shipped" | "Considering"; +type RoadmapCategory = "Platform" | "Skills" | "Community" | "API" | "Enterprise"; + +type RoadmapItem = { + id: string; + title: string; + description: string; + fullDescription: string; + category: RoadmapCategory; + status: RoadmapStatus; + upvotes: number; + commentCount: number; + createdAt: string; + updatedAt: string; + timeline: Array<{ + date: string; + status: RoadmapStatus; + note: string; + }>; +}; const categoryColors: Record = { Platform: "bg-cyan/10 text-cyan border-cyan/30", diff --git a/src/app/roadmap/page.tsx b/src/app/roadmap/page.tsx index f929cd22..87f626e6 100644 --- a/src/app/roadmap/page.tsx +++ b/src/app/roadmap/page.tsx @@ -1,256 +1,171 @@ "use client"; -import { useState, useMemo } from "react"; -import Link from "next/link"; -import { Badge } from "@/components/ui/badge"; +import { useEffect, useMemo, useState } from "react"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import roadmapData from "@/data/roadmap.json"; -export type RoadmapStatus = "Planned" | "In Progress" | "Shipped" | "Considering"; -export type RoadmapCategory = "Platform" | "Skills" | "Community" | "API" | "Enterprise"; +type RoadmapStatus = "planned" | "in-progress" | "completed"; -export interface RoadmapItem { +type RoadmapItem = { id: string; title: string; description: string; - fullDescription: string; - category: RoadmapCategory; status: RoadmapStatus; - upvotes: number; - commentCount: number; - createdAt: string; - updatedAt: string; - timeline: Array<{ - date: string; - status: RoadmapStatus; - note: string; - }>; -} - -type SortOption = "most-voted" | "recently-updated" | "newest"; - -const categoryColors: Record = { - Platform: "bg-cyan/10 text-cyan border-cyan/30", - Skills: "bg-purple/10 text-purple border-purple/30", - Community: "bg-green/10 text-green border-green/30", - API: "bg-orange/10 text-orange border-orange/30", - Enterprise: "bg-blue/10 text-blue border-blue/30", + votes: number; }; -const statusColumns: RoadmapStatus[] = ["Planned", "In Progress", "Shipped", "Considering"]; - -const statusColors: Record = { - Planned: "border-slate-600/50 bg-slate-900/40", - "In Progress": "border-cyan/30 bg-cyan/5", - Shipped: "border-green/30 bg-green/5", - Considering: "border-purple/30 bg-purple/5", -}; +const statusColumns: Array<{ key: RoadmapStatus; label: string; cardClass: string }> = [ + { + key: "planned", + label: "Planned", + cardClass: "border-slate-600/50 bg-slate-900/40", + }, + { + key: "in-progress", + label: "In Progress", + cardClass: "border-cyan/30 bg-cyan/5", + }, + { + key: "completed", + label: "Completed", + cardClass: "border-green/30 bg-green/5", + }, +]; export default function RoadmapPage() { - const [selectedCategory, setSelectedCategory] = useState("all"); - const [sortBy, setSortBy] = useState("most-voted"); - - const items = roadmapData as RoadmapItem[]; - - // Filter items by category - const filteredItems = useMemo(() => { - if (selectedCategory === "all") return items; - return items.filter((item) => item.category === selectedCategory); - }, [selectedCategory, items]); - - // Sort items - const sortedItems = useMemo(() => { - const sorted = [...filteredItems]; - switch (sortBy) { - case "most-voted": - return sorted.sort((a, b) => b.upvotes - a.upvotes); - case "recently-updated": - return sorted.sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() - ); - case "newest": - return sorted.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - default: - return sorted; + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [votingIds, setVotingIds] = useState>(new Set()); + + useEffect(() => { + async function loadRoadmap() { + try { + const response = await fetch("/api/roadmap", { cache: "no-store" }); + + if (!response.ok) { + throw new Error("Failed to fetch roadmap"); + } + + const data = (await response.json()) as { items: RoadmapItem[] }; + setItems(data.items); + } catch (err) { + console.error(err); + setError("Unable to load roadmap right now."); + } finally { + setLoading(false); + } } - }, [filteredItems, sortBy]); - // Group items by status + void loadRoadmap(); + }, []); + const itemsByStatus = useMemo(() => { - const grouped: Record = { - Planned: [], - "In Progress": [], - Shipped: [], - Considering: [], + return { + planned: items.filter((item) => item.status === "planned"), + "in-progress": items.filter((item) => item.status === "in-progress"), + completed: items.filter((item) => item.status === "completed"), }; - sortedItems.forEach((item) => { - grouped[item.status].push(item); - }); - return grouped; - }, [sortedItems]); + }, [items]); - const categories: Array = [ - "all", - "Platform", - "Skills", - "Community", - "API", - "Enterprise", - ]; + const handleVote = async (itemId: string) => { + if (votingIds.has(itemId)) { + return; + } + + setVotingIds((prev) => new Set(prev).add(itemId)); + + try { + const response = await fetch("/api/roadmap/vote", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ itemId }), + }); + + if (!response.ok) { + throw new Error("Vote failed"); + } + + const updated = (await response.json()) as { id: string; votes: number }; + + setItems((prev) => + prev.map((item) => + item.id === updated.id ? { ...item, votes: updated.votes } : item + ) + ); + } catch (err) { + console.error(err); + } finally { + setVotingIds((prev) => { + const next = new Set(prev); + next.delete(itemId); + return next; + }); + } + }; return (
- {/* Header */}
-

- Product Roadmap -

+

Product Roadmap

- See what we're building, what's shipped, and what we're considering next. - Vote on features you want to see and join the discussion. + See what we're building and what's already shipped. Vote on planned features to help us prioritize.

- {/* Filters and Sort */} -
- {/* Category Filter */} -
- {categories.map((category) => ( - - ))} -
- - {/* Sort Options */} -
- Sort by: - -
-
- - {/* Kanban Board */} -
- {statusColumns.map((status) => ( -
- {/* Column Header */} -
-

- {status} - - {itemsByStatus[status].length} - -

-
- - {/* Column Items */} -
- {itemsByStatus[status].map((item) => ( - - + {loading ? ( +

Loading roadmap...

+ ) : error ? ( +

{error}

+ ) : ( +
+ {statusColumns.map((column) => ( +
+
+

+ {column.label} + + {itemsByStatus[column.key].length} + +

+
+ +
+ {itemsByStatus[column.key].map((item) => ( + - {/* Category Badge */} - - {item.category} - - - {/* Title */} -

- {item.title} -

- - {/* Description */} -

- {item.description} -

- - {/* Metadata */} -
-
- {item.title} +

{item.description}

+ +
+ {item.votes} votes + + {item.status === "planned" && ( +
-
- - - - {item.commentCount} -
+ {votingIds.has(item.id) ? "Voting..." : "Vote"} + + )}
- - ))} - - {itemsByStatus[status].length === 0 && ( -
-

No items

-
- )} + ))} + + {itemsByStatus[column.key].length === 0 && ( +
+

No items

+
+ )} +
-
- ))} -
- - {/* Empty State */} - {sortedItems.length === 0 && ( -
-

- No roadmap items found for this category. -

+ ))}
)}
diff --git a/src/lib/server/roadmapStore.ts b/src/lib/server/roadmapStore.ts new file mode 100644 index 00000000..c9eb1321 --- /dev/null +++ b/src/lib/server/roadmapStore.ts @@ -0,0 +1,55 @@ +import "server-only"; + +import { promises as fs } from "fs"; +import path from "path"; + +export type RoadmapStatus = "planned" | "in-progress" | "completed"; + +export type RoadmapItem = { + id: string; + title: string; + description: string; + status: RoadmapStatus; + votes: number; +}; + +const ROADMAP_PATH = path.join(process.cwd(), "data", "roadmap.json"); + +// In-memory vote deltas so votes can change without mutating source files. +const voteDeltas = new Map(); + +export function isRoadmapStatus(value: string | null): value is RoadmapStatus { + return value === "planned" || value === "in-progress" || value === "completed"; +} + +async function readRoadmapFile(): Promise { + const raw = await fs.readFile(ROADMAP_PATH, "utf-8"); + return JSON.parse(raw) as RoadmapItem[]; +} + +export async function getRoadmapItems(): Promise { + const items = await readRoadmapFile(); + + return items.map((item) => ({ + ...item, + votes: item.votes + (voteDeltas.get(item.id) ?? 0), + })); +} + +export async function incrementRoadmapVote(itemId: string): Promise<{ id: string; votes: number } | null> { + const items = await readRoadmapFile(); + const item = items.find((candidate) => candidate.id === itemId); + + if (!item) { + return null; + } + + const currentDelta = voteDeltas.get(itemId) ?? 0; + const nextDelta = currentDelta + 1; + voteDeltas.set(itemId, nextDelta); + + return { + id: itemId, + votes: item.votes + nextDelta, + }; +}