'use client'
/**
* `` — the SELF-CONTAINED roadmap LIST surface.
*
* Fetches the roadmap list via the shared `useSelfFetch` hook and renders the
* pure controlled `` (kept controlled so related-content rails can
* still pass `items`). The host configures only **api routes**: the list
* `endpoint` (default `/api/roadmap`), the per-task `buildRefreshUrl`, and the
* vote endpoint via `votingOptions`. Optional `initialItems` hydrates SSR.
*/
import { useMemo } from 'react'
import { useSearchParams } from '../../../embed-shims'
import { LoadError } from '../../ui/error-state'
import { useSelfFetch } from '../../../hooks/use-self-fetch'
import { useScrollToHash } from '../../../hooks/use-scroll-to-hash'
import type { RoadmapItem } from '../../chat/types/entities/roadmap-item'
import { RoadmapGrid } from './roadmap-grid'
import { RoadmapGridSkeleton } from './roadmap-grid-skeleton'
import type { UseRoadmapVotingOptions } from './use-roadmap-voting'
import { DEV_SECTION_PARAM_KEYS } from '../../../utils/dev-sections/dev-section-param-keys'
import { STICKY_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav'
const DEFAULT_ENDPOINT = '/api/roadmap'
// Defaults sourced from the ONE param-key registry the chrome (OPENFRAME_DEV_SECTIONS) also
// reads, so the chrome's written `?key=` and this view's read can't silently diverge.
const DEFAULT_SEARCH_PARAM_KEY = DEV_SECTION_PARAM_KEYS.search
const DEFAULT_STATUS_PARAM_KEY = DEV_SECTION_PARAM_KEYS.status
export interface RoadmapViewProps {
/** GET list endpoint (the api route). Returns `{ items }`. Default
* `/api/roadmap`. */
endpoint?: string
/** Optional SSR hydrate — skips the initial client fetch. */
initialItems?: RoadmapItem[]
showLeftMargin?: boolean
/** Per-task refresh URL builder (after a vote). Default `/api/roadmap/`. */
buildRefreshUrl?: (taskId: string) => string
/** Voting hook options (vote endpoint + storage key). */
votingOptions?: UseRoadmapVotingOptions
/** URL param key for the search input — MUST match the section chrome
* (`DevSectionView`) that writes it. Default `'search'`. */
searchParamKey?: string
/** URL param key for the status filter. Default `'status'` (the roadmap
* section's `filter.paramKey`). `'all'` means no filter. */
statusParamKey?: string
}
export function RoadmapView({
endpoint = DEFAULT_ENDPOINT,
initialItems,
showLeftMargin,
buildRefreshUrl,
votingOptions,
searchParamKey = DEFAULT_SEARCH_PARAM_KEY,
statusParamKey = DEFAULT_STATUS_PARAM_KEY,
}: RoadmapViewProps = {}) {
// Read the search + status params the section chrome (`DevSectionView`) writes
// and fold them INTO the fetch url so the url IS the cache key — the list
// refetches filtered whenever the controls change. Mirrors `ProductReleasesView`.
const searchParams = useSearchParams()
const search = searchParams.get(searchParamKey) || ''
const status = searchParams.get(statusParamKey) || 'all'
const listParams = new URLSearchParams()
if (search) listParams.set(searchParamKey, search)
if (status && status !== 'all') listParams.set(statusParamKey, status)
const qs = listParams.toString()
const url = qs ? `${endpoint}?${qs}` : endpoint
// Memoize so the SSR `initialItems` wrapper keeps a STABLE identity — else the
// hook's initialData re-sync effect fires every render and clobbers the
// optimistic vote patch below.
const initialData = useMemo(() => (initialItems ? { items: initialItems } : undefined), [initialItems])
const { data, setData, isLoading, error, reload } = useSelfFetch<{ items?: RoadmapItem[] }>(
url,
{ initialData },
)
const items = data?.items ?? []
// Deep-link hash dispatch — `?search=#roadmap-` from a chat card.
// Shared hook owns the poll-until-mount + hashchange-listener wiring
// (same instance used by DeliveryLists). The rAF poll inside the hook
// handles the Radix `` lazy-unmount: on first paint
// every quarter is collapsed; an effect in `roadmap-grid.tsx` expands
// them when `hasActiveFilters` is true (chat URL carries `?search=`).
// The card mounts one tick after `data` lands; the hook waits.
useScrollToHash(data, { headerOffset: STICKY_HEADER_OFFSET_PX })
if (error) {
return
}
// Skeleton only while the FIRST fetch is in flight (no data yet) — a malformed
// body lacking `items` renders the grid (empty), never a stuck skeleton.
if (isLoading && !data) {
return
}
return (
setData((prev) =>
prev
? { ...prev, items: (prev.items ?? []).map((it) => (it.id === updated.id ? updated : it)) }
: prev,
)
}
/>
)
}