diff --git a/ui/src/components/layout/Sidebar.tsx b/ui/src/components/layout/Sidebar.tsx index 2aefe368..96ae58d7 100644 --- a/ui/src/components/layout/Sidebar.tsx +++ b/ui/src/components/layout/Sidebar.tsx @@ -21,6 +21,7 @@ import { moduleService } from '../../services/moduleService'; import { cycleService } from '../../services/cycleService'; import { viewService } from '../../services/viewService'; import { slugify } from '../../lib/slug'; +import { cyclePathSegment } from '../../lib/cycle'; import { ISSUE_VIEW_FAVORITES_CHANGED_EVENT } from '../../lib/issueViewFavoritesEvents'; import { CYCLE_FAVORITES_CHANGED_EVENT } from '../../hooks/useCycleFavorites'; @@ -1189,7 +1190,7 @@ export function Sidebar() { {favoriteCycles.map(({ projectId, cycle }) => ( diff --git a/ui/src/lib/cycle.ts b/ui/src/lib/cycle.ts new file mode 100644 index 00000000..a5af4231 --- /dev/null +++ b/ui/src/lib/cycle.ts @@ -0,0 +1,16 @@ +import type { CycleApiResponse } from '../api/types'; +import { slugify } from './slug'; + +export function cyclePathSegment(cycle: Pick): string { + const s = slugify(cycle.name); + return s || 'cycle'; +} + +export function cycleMatchesPathSegment( + cycle: Pick, + segment: string, +): boolean { + const key = segment.trim().toLowerCase(); + if (!key) return false; + return cycle.id.toLowerCase() === key || cyclePathSegment(cycle) === key; +} diff --git a/ui/src/pages/CycleDetailPage.tsx b/ui/src/pages/CycleDetailPage.tsx new file mode 100644 index 00000000..29f9737a --- /dev/null +++ b/ui/src/pages/CycleDetailPage.tsx @@ -0,0 +1,167 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { Badge } from '../components/ui'; +import { workspaceService } from '../services/workspaceService'; +import { projectService } from '../services/projectService'; +import { cycleService } from '../services/cycleService'; +import { issueService } from '../services/issueService'; +import { stateService } from '../services/stateService'; +import { cycleMatchesPathSegment } from '../lib/cycle'; +import type { + CycleApiResponse, + IssueApiResponse, + ProjectApiResponse, + StateApiResponse, + WorkspaceApiResponse, +} from '../api/types'; +import type { Priority } from '../types'; +import { parseISODateForDisplay } from '../lib/dateOnly'; + +const priorityVariant: Record = { + urgent: 'danger', + high: 'danger', + medium: 'warning', + low: 'default', + none: 'neutral', +}; + +function formatDate(iso: string | null | undefined): string { + const d = parseISODateForDisplay(iso); + if (!d) return '—'; + return d.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }); +} + +export function CycleDetailPage() { + const { workspaceSlug, projectId, cycleId } = useParams<{ + workspaceSlug: string; + projectId: string; + cycleId: string; + }>(); + + const [loading, setLoading] = useState(() => Boolean(workspaceSlug && projectId && cycleId)); + const [workspace, setWorkspace] = useState(null); + const [project, setProject] = useState(null); + const [cycle, setCycle] = useState(null); + const [issues, setIssues] = useState([]); + const [states, setStates] = useState([]); + + useEffect(() => { + if (!workspaceSlug || !projectId || !cycleId) { + return; + } + let cancelled = false; + queueMicrotask(() => { + if (!cancelled) setLoading(true); + }); + Promise.all([ + workspaceService.getBySlug(workspaceSlug), + projectService.get(workspaceSlug, projectId), + cycleService.list(workspaceSlug, projectId), + issueService.list(workspaceSlug, projectId, { limit: 500 }), + stateService.list(workspaceSlug, projectId), + ]) + .then(([w, p, cycles, allIssues, st]) => { + if (cancelled) return; + setWorkspace(w ?? null); + setProject(p ?? null); + setCycle((cycles ?? []).find((c) => cycleMatchesPathSegment(c, cycleId)) ?? null); + setIssues(allIssues ?? []); + setStates(st ?? []); + }) + .catch(() => { + if (cancelled) return; + setWorkspace(null); + setProject(null); + setCycle(null); + setIssues([]); + setStates([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, projectId, cycleId]); + + const cycleIssues = useMemo(() => { + if (!cycle) return []; + return issues.filter((i) => i.cycle_ids?.includes(cycle.id)); + }, [issues, cycle]); + + const stateName = (stateId: string | null | undefined) => + stateId ? (states.find((s) => s.id === stateId)?.name ?? '—') : '—'; + + if (loading) { + return
Loading cycle…
; + } + if (!workspace || !project || !cycle) { + return
Cycle not found.
; + } + + const projectBase = `/${workspace.slug}/projects/${project.id}`; + + return ( +
+
+ + ← Back to cycles + +

{cycle.name}

+

+ {formatDate(cycle.start_date)} — {formatDate(cycle.end_date)} · {cycle.issue_count ?? 0}{' '} + work items +

+
+ +
+ + + + + + + + + + + {cycleIssues.length === 0 ? ( + + + + ) : ( + cycleIssues.map((issue) => ( + + + + + + + )) + )} + +
Work itemPriorityStateDue date
+ No work items in this cycle. +
+ + {issue.name} + + + + {issue.priority ?? 'none'} + + + {stateName(issue.state_id ?? undefined)} + + {formatDate(issue.target_date)} +
+
+
+ ); +} diff --git a/ui/src/pages/CyclesPage.tsx b/ui/src/pages/CyclesPage.tsx index 124dda98..f21ffe9e 100644 --- a/ui/src/pages/CyclesPage.tsx +++ b/ui/src/pages/CyclesPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; +import { Link, useNavigate, useParams } from 'react-router-dom'; import { Avatar, Badge, Button, Modal } from '../components/ui'; import { UpdateCycleModal } from '../components/UpdateCycleModal'; import { workspaceService } from '../services/workspaceService'; @@ -24,6 +24,7 @@ import { } from '../lib/projectCyclesEvents'; import { useCycleFavorites } from '../hooks/useCycleFavorites'; import { parseISODateForDisplay, parseISODateLocal } from '../lib/dateOnly'; +import { cyclePathSegment } from '../lib/cycle'; import { cn, getImageUrl } from '../lib/utils'; function pad2(n: number): string { @@ -320,6 +321,7 @@ export function CyclesPage() { const [ellipsisMenuOpenId, setEllipsisMenuOpenId] = useState(null); const [editCycle, setEditCycle] = useState(null); const [deleteCycleId, setDeleteCycleId] = useState(null); + const navigate = useNavigate(); const { toggleFavorite, isFavorite } = useCycleFavorites(workspaceSlug, projectId); @@ -563,7 +565,9 @@ export function CyclesPage() { return c.status === 'completed' ? 100 : 0; }; const cyclePath = (c: CycleApiResponse) => - workspace && project ? `/${workspace.slug}/projects/${project.id}/cycles/${c.id}` : ''; + workspace && project + ? `/${workspace.slug}/projects/${project.id}/cycles/${cyclePathSegment(c)}` + : ''; const baseUrl = workspace && project ? `/${workspace.slug}/projects/${project.id}` : ''; const stateGroupMap: Record< @@ -706,6 +710,15 @@ export function CyclesPage() {
navigate(path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + navigate(path); + } + }} >
diff --git a/ui/src/routes/index.tsx b/ui/src/routes/index.tsx index 72645fe8..3b14b3b3 100644 --- a/ui/src/routes/index.tsx +++ b/ui/src/routes/index.tsx @@ -80,6 +80,9 @@ const BoardPage = lazy(() => const CyclesPage = lazy(() => import('../pages/CyclesPage').then((m) => page({ CyclesPage: m.CyclesPage })), ); +const CycleDetailPage = lazy(() => + import('../pages/CycleDetailPage').then((m) => page({ CycleDetailPage: m.CycleDetailPage })), +); const ModulesPage = lazy(() => import('../pages/ModulesPage').then((m) => page({ ModulesPage: m.ModulesPage })), ); @@ -522,6 +525,14 @@ const router = createBrowserRouter([ ), }, + { + path: 'cycles/:cycleId', + element: ( + }> + + + ), + }, { path: 'modules', element: (