From 7e78dd5f24f311f52045f4b34d3d75ba515d5cde Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Tue, 5 May 2026 14:46:49 +0400 Subject: [PATCH 1/3] feat(cycles): add Cycle Detail Page and navigation from Cycles list --- ui/src/pages/CycleDetailPage.tsx | 165 +++++++++++++++++++++++++++++++ ui/src/pages/CyclesPage.tsx | 12 ++- ui/src/routes/index.tsx | 11 +++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/CycleDetailPage.tsx diff --git a/ui/src/pages/CycleDetailPage.tsx b/ui/src/pages/CycleDetailPage.tsx new file mode 100644 index 00000000..1af19cfc --- /dev/null +++ b/ui/src/pages/CycleDetailPage.tsx @@ -0,0 +1,165 @@ +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 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(true); + 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) { + setLoading(false); + return; + } + let cancelled = false; + 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) => c.id === 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..4253feaf 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'; @@ -320,6 +320,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); @@ -706,6 +707,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: ( From cea3214cba48f73be01afe8787c4b48b28b3fec2 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Tue, 5 May 2026 15:04:39 +0400 Subject: [PATCH 2/3] feat(cycle): use slugified name in cycle URL segments --- ui/src/components/layout/Sidebar.tsx | 3 ++- ui/src/lib/cycle.ts | 16 ++++++++++++++++ ui/src/pages/CycleDetailPage.tsx | 3 ++- ui/src/pages/CyclesPage.tsx | 5 ++++- 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 ui/src/lib/cycle.ts 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 index 1af19cfc..67e658ca 100644 --- a/ui/src/pages/CycleDetailPage.tsx +++ b/ui/src/pages/CycleDetailPage.tsx @@ -6,6 +6,7 @@ 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, @@ -62,7 +63,7 @@ export function CycleDetailPage() { if (cancelled) return; setWorkspace(w ?? null); setProject(p ?? null); - setCycle((cycles ?? []).find((c) => c.id === cycleId) ?? null); + setCycle((cycles ?? []).find((c) => cycleMatchesPathSegment(c, cycleId)) ?? null); setIssues(allIssues ?? []); setStates(st ?? []); }) diff --git a/ui/src/pages/CyclesPage.tsx b/ui/src/pages/CyclesPage.tsx index 4253feaf..f21ffe9e 100644 --- a/ui/src/pages/CyclesPage.tsx +++ b/ui/src/pages/CyclesPage.tsx @@ -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 { @@ -564,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< From bc55e1ce86a15d57d78f734b83e2faf9f03ac033 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Tue, 5 May 2026 15:06:38 +0400 Subject: [PATCH 3/3] fix(ui): initialize loading state based on route params --- ui/src/pages/CycleDetailPage.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/CycleDetailPage.tsx b/ui/src/pages/CycleDetailPage.tsx index 67e658ca..29f9737a 100644 --- a/ui/src/pages/CycleDetailPage.tsx +++ b/ui/src/pages/CycleDetailPage.tsx @@ -38,7 +38,7 @@ export function CycleDetailPage() { cycleId: string; }>(); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(() => Boolean(workspaceSlug && projectId && cycleId)); const [workspace, setWorkspace] = useState(null); const [project, setProject] = useState(null); const [cycle, setCycle] = useState(null); @@ -47,11 +47,12 @@ export function CycleDetailPage() { useEffect(() => { if (!workspaceSlug || !projectId || !cycleId) { - setLoading(false); return; } let cancelled = false; - setLoading(true); + queueMicrotask(() => { + if (!cancelled) setLoading(true); + }); Promise.all([ workspaceService.getBySlug(workspaceSlug), projectService.get(workspaceSlug, projectId),