// CIL[BEGIN chunk_id="PrioritizationPage_module_header_1784761401" type="module_header" identity="module_header" parent="none"] // CIL[DOC-MACRO purpose="Render a paginated sprint carousel for Engineering prioritization with a fixed backlog column"] // CIL[DOC-RESPONSIBILITY responsibility="Fetch sprints and work items; show 3 sprints at a time plus a fixed fourth backlog column; support drag/drop between sprints and backlog"] // CIL[DOC-CONTEXT module="workbench.pages.work.PrioritizationPage" layer="frontend-page" related_files="[\"custom/apps/workbench/components/work/WorkItemCard.tsx\",\"custom/apps/workbench/pages/work/WorkItemDetailPage.tsx\",\"custom/apps/workbench/pages/work/WorkBacklogPage.tsx\"]"] // CIL[DOC-CONTRACT inputs="none" outputs="JSX prioritization page" errors="fetch/patch errors rendered inline" side_effects="calls /api/admin-data, PATCHes work items on drop"] // CIL[DOC-MICRO logic_summary="Loads all sprints and work items; sorts sprints by start_at; starts the carousel at the active sprint (phase active or current); shows up to 3 sprint lanes plus a fixed fourth backlog column; drag-and-drop assigns sprint_id and reorders" notable_decisions=["Sprint and epic are decoupled in this view; epic_id is preserved but ignored"] // CIL[DOC-LEARNING topic="runtime-type:boolean-formatting" content="admin-data sanitizes boolean fields for display (e.g. true -> 'Yes'); lane grouping must not rely on strict boolean checks against is_blocked because it breaks after a page reload"] // CIL[DOC-LEARNING topic="workbench:order" content="A single order field is sufficient because each lane is a distinct group (backlog, sprint); orders are only compared within the group"] // CIL[DOC-LEARNING topic="react:fragment-key-import" content="Use the named Fragment import () instead of React.Fragment when React is not imported as a namespace; otherwise Vite throws 'React is not defined' at runtime"] // CIL[DOC-LEARNING topic="workbench:backlog-null-grouping" content="Strict equality misses backlog/crashers items whose epic_id or sprint_id is missing (undefined) rather than explicitly null; use a sameId helper that treats null and undefined as equivalent when grouping and comparing source/target lanes"] // CIL[DOC-LEARNING topic="workbench:sprint-active-phase" content="The active sprint is detected by phase 'active' with 'current' accepted as a legacy fallback until the sprint type options are migrated"] // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"] // CIL[DOC-STABILITY stability="experimental"] // CIL[DOC-AUDIENCE audience="developer"] import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useAppPath } from '@core/hooks/useAppPath' import { apiFetch } from '@core/lib/api' import { cn } from '@core/lib/utils' import { Button } from '@core/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@core/components/ui/card' import { Badge } from '@core/components/ui/badge' import { ChevronLeft, ChevronRight } from 'lucide-react' import { WorkItemCard } from '../../components/work/WorkItemCard' import { useWorkbenchRole } from '../../hooks/useWorkbenchRole' // CIL[END chunk_id="PrioritizationPage_module_header_1784761401"] // CIL[BEGIN chunk_id="PrioritizationPage_prioritizationPage_1784761401" type="function" identity="PrioritizationPage" parent="none"] // CIL[DOC-MACRO description="Implementation of PrioritizationPage" macro="Renders and manages PrioritizationPage UI state"] // CIL[DOC-RESPONSIBILITY responsibility="Render sprint carousel, pagination controls, drag-and-drop handlers, and fixed fourth backlog column"] // CIL[DOC-CONTEXT module="workbench.pages.work.PrioritizationPage" layer="frontend-page" related_files="[]"] // CIL[DOC-CONTRACT inputs="none" outputs="JSX" errors="none" side_effects="none"] // CIL[DOC-MICRO logic_summary="Manages sprints, work items, pagination offset, and drop handlers for sprint/backlog lanes" notable_decisions=["Sprint carousel starts at active sprint; drag-and-drop preserves epic_id"]] // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"] // CIL[DOC-STABILITY stability="experimental"] // CIL[DOC-NOTE type="caution" content="SortableDropZone computes the drop index from the live DOM, which still contains the dragged item. handleDrop converts that index to an insertion index relative to the target group with the dragged item removed so reordering within a lane places the item at the visual indicator position."] // CIL[DOC-NOTE type="caution" content="Sprints are sorted by start_at and the carousel starts at the first active sprint. Pagination moves the window by 3 sprints in either direction."] // CIL[DOC-LEARNING topic="workbench:prioritization-sprint-carousel" content="The prioritization page now shows a flat sprint carousel instead of epic-based lanes; this lets sprints live independently of epics and supports continuous development"] type DropTarget = | { type: 'sprint'; sprintId: string } | { type: 'backlog' } interface SprintShape { id: string title: string start_at?: string phase?: string } const PAGE_SIZE = 3 function isActiveSprint(sprint: any): boolean { const phase = sprint?.data?.phase return phase === 'active' || phase === 'current' } function matchesTeam(team: string | undefined | null, selectedTeam: string): boolean { if (selectedTeam === '*') return true const t = team || 'unassigned' return t === 'unassigned' || t === '*' || t === selectedTeam } function sortByOrder(a: any, b: any): number { return (a.data?.order ?? Infinity) - (b.data?.order ?? Infinity) } export default function PrioritizationPage() { const navigate = useNavigate() const appPath = useAppPath() const { role } = useWorkbenchRole() const selectedTeam = role const [sprints, setSprints] = useState([]) const [workItems, setWorkItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [offset, setOffset] = useState(0) const load = async () => { setLoading(true) setError(null) try { const [sprintsRes, itemsRes] = await Promise.all([ apiFetch('/api/admin-data?entity=items&type_slug=workbench_sprint&limit=500'), apiFetch('/api/admin-data?entity=items&type_slug=workbench_work_item&limit=500'), ]) const sprintsJson = await sprintsRes.json() const itemsJson = await itemsRes.json() setSprints(Array.isArray(sprintsJson?.data) ? sprintsJson.data : []) setWorkItems(Array.isArray(itemsJson?.data) ? itemsJson.data : []) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load prioritization data') } finally { setLoading(false) } } useEffect(() => { load() }, []) const teamSprints = useMemo(() => { if (selectedTeam === '*') return sprints return sprints.filter(s => s.data?.team === selectedTeam || s.data?.team === '*') }, [sprints, selectedTeam]) const visibleWorkItems = useMemo(() => { return workItems.filter(i => i.data?.work_type !== 'idea' && i.data?.status !== 'parking') }, [workItems]) const sortedSprints = useMemo(() => { return [...teamSprints].sort((a, b) => { const aPhase = a.data?.phase || '' const bPhase = b.data?.phase || '' const aActive = isActiveSprint(a) const bActive = isActiveSprint(b) if (aActive !== bActive) return aActive ? -1 : 1 if (aPhase === 'completed' && bPhase !== 'completed') return 1 if (aPhase !== 'completed' && bPhase === 'completed') return -1 if (aPhase === 'completed' && bPhase === 'completed') { const ea = a.data?.end_at || a.updated_at const eb = b.data?.end_at || b.updated_at return String(eb).localeCompare(String(ea)) } const oa = a.data?.order ?? Infinity const ob = b.data?.order ?? Infinity if (oa !== ob) return oa - ob const sa = a.data?.start_at || a.created_at const sb = b.data?.start_at || b.created_at return String(sa).localeCompare(String(sb)) }) }, [teamSprints]) const activeIndex = useMemo(() => { return sortedSprints.findIndex(isActiveSprint) }, [sortedSprints]) useEffect(() => { setOffset(activeIndex >= 0 ? activeIndex : 0) }, [activeIndex, selectedTeam]) const loadItems = async () => { try { const res = await apiFetch('/api/admin-data?entity=items&type_slug=workbench_work_item&limit=500') const json = await res.json() setWorkItems(Array.isArray(json?.data) ? json.data : []) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to refresh items') } } const handleDragStart = (e: React.DragEvent, itemId: string) => { e.dataTransfer.setData('text/plain', itemId) e.dataTransfer.effectAllowed = 'move' } const handleDrop = async (itemId: string, target: DropTarget, index: number) => { const item = workItems.find(i => i.id === itemId) if (!item) return const targetSprintId = target.type === 'sprint' ? target.sprintId : null const sourceSprintId = item.data?.sprint_id ?? null const sameId = (a: any, b: any) => a === b || (a == null && b == null) const isSameGroup = sameId(sourceSprintId, targetSprintId) let insertIndex = index if (isSameGroup) { const fullTargetGroup = workItems .filter(i => sameId(i.data?.sprint_id, targetSprintId) && i.id !== itemId) .sort(sortByOrder) const originalIndex = fullTargetGroup.findIndex(i => i.id === itemId) if (originalIndex !== -1 && index > originalIndex) { insertIndex = index - 1 } } const targetGroupItems = workItems .filter(i => sameId(i.data?.sprint_id, targetSprintId) && i.id !== itemId) .sort(sortByOrder) const newGroup = [...targetGroupItems] newGroup.splice(insertIndex, 0, item) const targetSprint = targetSprintId ? sprints.find(s => s.id === targetSprintId) : null const targetTeam = targetSprint?.data?.team const updates: { id: string; data: any }[] = [] newGroup.forEach((i, order) => { const baseData = { ...(i.data || {}), order } if (i.id === itemId) { const moveData: any = { ...baseData, sprint_id: targetSprintId } if (targetSprintId && targetTeam) { moveData.team = targetTeam } moveData.status = targetSprintId ? 'assigned' : 'backlog' updates.push({ id: i.id, data: moveData, }) } else if (i.data?.order !== order) { updates.push({ id: i.id, data: baseData }) } }) if (!isSameGroup) { const sourceGroupItems = workItems .filter(i => sameId(i.data?.sprint_id, sourceSprintId) && i.id !== itemId) .sort(sortByOrder) sourceGroupItems.forEach((i, order) => { if (i.data?.order !== order) { updates.push({ id: i.id, data: { ...(i.data || {}), order } }) } }) } try { await Promise.all( updates.map(u => apiFetch(`/api/admin-data?entity=items&id=${u.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type_slug: 'workbench_work_item', data: u.data }), }) ) ) await loadItems() } catch (err) { setError(err instanceof Error ? err.message : 'Update failed') } } const DropIndicator = () => (
) function getDropIndex(e: React.DragEvent, container: HTMLElement | null): number { if (!container) return 0 const itemEls = Array.from(container.querySelectorAll('[data-item="true"]')) if (itemEls.length === 0) return 0 const containerRect = container.getBoundingClientRect() const mouseY = e.clientY - containerRect.top for (let i = 0; i < itemEls.length; i++) { const rect = itemEls[i].getBoundingClientRect() const centerY = rect.top + rect.height / 2 - containerRect.top if (mouseY < centerY) return i } return itemEls.length } const SortableDropZone = ({ target, className, header, items, renderItem, onDrop, }: { target: DropTarget className?: string header: React.ReactNode items: any[] renderItem: (item: any) => React.ReactNode onDrop: (itemId: string, target: DropTarget, index: number) => void }) => { const [overCount, setOverCount] = useState(0) const [insertIndex, setInsertIndex] = useState(null) const listRef = useRef(null) const computeIndex = useCallback((e: React.DragEvent) => getDropIndex(e, listRef.current), []) const over = overCount > 0 return (
{ e.preventDefault() e.stopPropagation() setOverCount(c => c + 1) }} onDragLeave={e => { e.preventDefault() e.stopPropagation() setOverCount(c => Math.max(0, c - 1)) setInsertIndex(null) }} onDragOver={e => { e.preventDefault() e.stopPropagation() e.dataTransfer.dropEffect = 'move' setInsertIndex(computeIndex(e)) }} onDrop={e => { e.preventDefault() e.stopPropagation() setOverCount(0) const index = computeIndex(e) setInsertIndex(null) const itemId = e.dataTransfer.getData('text/plain') if (itemId) onDrop(itemId, target, index) }} > {header}
{items.map((item, i) => ( {insertIndex === i && } {renderItem(item)} ))} {insertIndex === items.length && }
) } const renderWorkItem = (item: any) => (
handleDragStart(e, item.id)} className="cursor-grab active:cursor-grabbing" > navigate(appPath(`/work/${role}/items/${item.id}`))} />
) const SprintLane = ({ sprint, label }: { sprint: SprintShape; label: string }) => { const items = visibleWorkItems .filter(i => i.data?.sprint_id === sprint.id) .sort(sortByOrder) return (
{label}: {sprint.title || 'Untitled Sprint'}
{items.length} {sprint.phase && {sprint.phase}}
) } const visibleSprints = useMemo(() => { return sortedSprints.slice(offset, offset + PAGE_SIZE) }, [sortedSprints, offset]) const canGoBack = offset > 0 const canGoForward = offset + PAGE_SIZE < sortedSprints.length const backlogItems = visibleWorkItems .filter(i => !i.data?.sprint_id && matchesTeam(i.data?.team, selectedTeam)) .sort(sortByOrder) return (

Prioritization

{sortedSprints.length > 0 ? `${offset + 1}–${Math.min(offset + PAGE_SIZE, sortedSprints.length)} of ${sortedSprints.length}` : '0 sprints'}
{error &&
{error}
} {loading ? (
Loading prioritization…
) : (
{(() => { const activeInView = visibleSprints.some(isActiveSprint) return visibleSprints.map((sprint, i) => { const label = activeInView ? (i === 0 ? 'Current' : i === 1 ? '+1' : '++1') : 'Sprint' return (
) }) })()}
Backlog {backlogItems.length}
)}
) } // CIL[END chunk_id="PrioritizationPage_prioritizationPage_1784761401"]