"use client"; import { ChevronUp, Columns3, ListTree } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion, type Variants, } from "motion/react"; import { useCallback, useId, useMemo, useState } from "react"; import { Badge, type BadgeVariant } from "@/components/motion/badge"; import { EmptyState } from "@/components/motion/empty-state"; import { SegmentedControl } from "@/components/motion/segmented-control"; import { ToggleGroup, ToggleGroupItem } from "@/components/motion/toggle-group"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type RoadmapStatus = "planned" | "in-progress" | "shipped"; export type RoadmapView = "board" | "timeline"; export interface RoadmapItem { id: string; title: string; description?: string; status: RoadmapStatus; tags?: string[]; votes: number; /** The current viewer has upvoted this item. */ voted?: boolean; /** Timeline bucket, e.g. "Q4 2026". Items without one land in "Later". */ quarter?: string; } export interface RoadmapProps { items?: RoadmapItem[]; defaultItems?: RoadmapItem[]; onItemsChange?: (items: RoadmapItem[]) => void; view?: RoadmapView; defaultView?: RoadmapView; onViewChange?: (view: RoadmapView) => void; /** Visible statuses (the status filter). Default: all three. */ statuses?: RoadmapStatus[]; defaultStatuses?: RoadmapStatus[]; onStatusesChange?: (statuses: RoadmapStatus[]) => void; /** Fires after the optimistic vote toggle. */ onVote?: (id: string, voted: boolean) => void; /** Quarter order for the timeline view; defaults to first-seen order. */ quarters?: string[]; className?: string; } const STATUSES: RoadmapStatus[] = ["planned", "in-progress", "shipped"]; const STATUS_LABEL: Record = { planned: "Planned", "in-progress": "In progress", shipped: "Shipped", }; const STATUS_DOT: Record = { planned: "bg-chart-2", "in-progress": "bg-warning", shipped: "bg-success", }; const STATUS_BADGE: Record = { planned: "secondary", "in-progress": "warning", shipped: "success", }; function useControllable( value: T | undefined, defaultValue: T, onChange: ((next: T) => void) | undefined, ) { const [internal, setInternal] = useState(defaultValue); const controlled = value !== undefined; const current = controlled ? value : internal; const set = useCallback( (next: T) => { if (!controlled) setInternal(next); onChange?.(next); }, [controlled, onChange], ); return [current, set] as const; } const byVotes = (a: RoadmapItem, b: RoadmapItem) => b.votes - a.votes; export function Roadmap({ items: itemsProp, defaultItems = [], onItemsChange, view: viewProp, defaultView = "board", onViewChange, statuses: statusesProp, defaultStatuses = STATUSES, onStatusesChange, onVote, quarters: quartersProp, className, }: RoadmapProps) { const uid = useId(); const reduce = useReducedMotion() ?? false; const [items, setItems] = useControllable(itemsProp, defaultItems, onItemsChange); const [view, setView] = useControllable(viewProp, defaultView, onViewChange); const [statuses, setStatuses] = useControllable(statusesProp, defaultStatuses, onStatusesChange); const visible = useMemo(() => items.filter((i) => statuses.includes(i.status)), [items, statuses]); const vote = (id: string) => { let next = false; setItems( items.map((item) => { if (item.id !== id) return item; next = !item.voted; return { ...item, voted: next, votes: item.votes + (next ? 1 : -1) }; }), ); onVote?.(id, next); }; const quarters = useMemo(() => { const seen = quartersProp ? [...quartersProp] : []; for (const i of visible) { const q = i.quarter ?? "Later"; if (!seen.includes(q)) seen.push(q); } return seen.filter((q) => visible.some((i) => (i.quarter ?? "Later") === q)); }, [visible, quartersProp]); const visibleStatuses = STATUSES.filter((s) => statuses.includes(s)); return (
setStatuses(STATUSES.filter((s) => v.includes(s)))} > {STATUSES.map((s) => ( ))} setView(v as RoadmapView)} options={[ { value: "board", label: "Board", icon:
{visible.length === 0 ? ( ) : ( // Cards share a layoutId across views, so switching Board ↔ Timeline // flies each card to its new slot instead of re-rendering the list. {view === "board" ? (
{visibleStatuses.map((status) => { const column = visible.filter((i) => i.status === status).sort(byVotes); return (

    {column.map((item) => ( ))}
); })}
) : (
    {quarters.map((quarter, qi) => (
  1. {quarter} {qi < quarters.length - 1 && (
      {visible .filter((i) => (i.quarter ?? "Later") === quarter) .sort( (a, b) => STATUSES.indexOf(b.status) - STATUSES.indexOf(a.status) || byVotes(a, b), ) .map((item) => ( ))}
  2. ))}
)}
)}
); } function RoadmapCard({ uid, item, reduce, onVote, showStatus, }: { uid: string; item: RoadmapItem; reduce: boolean; onVote: (id: string) => void; showStatus?: boolean; }) { return (
{showStatus && ( {STATUS_LABEL[item.status]} )}

{item.title}

{item.description && (

{item.description}

)} {item.tags?.length ? (
{item.tags.map((tag) => ( {tag} ))}
) : null}
onVote(item.id)} />
); } const ROLL: Variants = { enter: (d: number) => ({ y: d > 0 ? "100%" : "-100%", opacity: 0 }), center: { y: "0%", opacity: 1 }, exit: (d: number) => ({ y: d > 0 ? "-100%" : "100%", opacity: 0 }), }; const FADE: Variants = { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } }; function UpvoteButton({ count, voted, title, reduce, onClick, }: { count: number; voted: boolean; title: string; reduce: boolean; onClick: () => void; }) { // Direction is derived during render from the last seen count (React's // "adjust state on prop change" pattern), so rolls go up on +1, down on -1. const [last, setLast] = useState(count); const [direction, setDirection] = useState(1); if (count !== last) { setDirection(count > last ? 1 : -1); setLast(count); } const variants = reduce ? FADE : ROLL; return ( ); }