diff --git a/apps/web/src/pages/CycleDetailPage.tsx b/apps/web/src/pages/CycleDetailPage.tsx index 824eeadc..df4a83b3 100644 --- a/apps/web/src/pages/CycleDetailPage.tsx +++ b/apps/web/src/pages/CycleDetailPage.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; +import { useEffect, useMemo, useState, type ComponentType } from 'react'; +import { Link, useParams, useSearchParams } from 'react-router-dom'; +import { CalendarDays, ChartGantt, Columns3, List, Table2 } from 'lucide-react'; import { Badge, Button, Modal } from '../components/ui'; import { CycleBurndownChart } from '../components/cycles/CycleBurndownChart'; import { workspaceService } from '../services/workspaceService'; @@ -7,26 +8,36 @@ import { projectService } from '../services/projectService'; import { cycleService, type CycleProgressResponse } from '../services/cycleService'; import { issueService } from '../services/issueService'; import { stateService } from '../services/stateService'; +import { labelService } from '../services/labelService'; +import { moduleService } from '../services/moduleService'; +import { integrationService } from '../services/integrationService'; import { cycleMatchesPathSegment } from '../lib/cycle'; import { useDocumentTitle } from '../hooks/useDocumentTitle'; +import { IssueLayoutList } from '../components/work-item/layouts/IssueLayoutList'; +import { IssueLayoutBoard } from '../components/work-item/layouts/IssueLayoutBoard'; +import { IssueLayoutSpreadsheet } from '../components/work-item/layouts/IssueLayoutSpreadsheet'; +import { IssueLayoutCalendar } from '../components/work-item/layouts/IssueLayoutCalendar'; +import { IssueLayoutGantt } from '../components/work-item/layouts/IssueLayoutGantt'; +import { + parseIssueLayout, + type IssueLayout, +} from '../components/work-item/layouts/IssueLayoutTypes'; +import { buildGroupedIssues } from '../lib/issueListGroupAndSort'; +import { cloneDefaultProjectIssuesDisplay } from '../lib/projectIssuesDisplay'; +import type { SavedViewDisplayPropertyId } from '../lib/projectSavedViewDisplay'; import type { CycleApiResponse, + GitHubIssueSummaryEntry, IssueApiResponse, + LabelApiResponse, + ModuleApiResponse, ProjectApiResponse, StateApiResponse, + WorkspaceMemberApiResponse, 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 '—'; @@ -48,12 +59,58 @@ function ProgressBar({ value, max, color }: { value: number; max: number; color: ); } +const LAYOUT_OPTIONS: { + key: IssueLayout; + label: string; + Icon: ComponentType<{ className?: string }>; +}[] = [ + { key: 'list', label: 'List', Icon: List }, + { key: 'board', label: 'Board', Icon: Columns3 }, + { key: 'calendar', label: 'Calendar', Icon: CalendarDays }, + { key: 'spreadsheet', label: 'Spreadsheet', Icon: Table2 }, + { key: 'gantt', label: 'Timeline', Icon: ChartGantt }, +]; + +function CycleLayoutSwitcher({ + layout, + onChange, +}: { + layout: IssueLayout; + onChange: (layout: IssueLayout) => void; +}) { + return ( +
+ {LAYOUT_OPTIONS.map(({ key, label, Icon }) => { + const active = layout === key; + return ( + + ); + })} +
+ ); +} + export function CycleDetailPage() { const { workspaceSlug, projectId, cycleId } = useParams<{ workspaceSlug: string; projectId: string; cycleId: string; }>(); + const [searchParams, setSearchParams] = useSearchParams(); const [loading, setLoading] = useState(() => Boolean(workspaceSlug && projectId && cycleId)); const [workspace, setWorkspace] = useState(null); @@ -62,11 +119,16 @@ export function CycleDetailPage() { const [allCycles, setAllCycles] = useState([]); const [issues, setIssues] = useState([]); const [states, setStates] = useState([]); + const [labels, setLabels] = useState([]); + const [modules, setModules] = useState([]); + const [members, setMembers] = useState([]); + const [prSummary, setPrSummary] = useState>({}); const [progress, setProgress] = useState(null); const [completeModalOpen, setCompleteModalOpen] = useState(false); const [completing, setCompleting] = useState(false); const [completeError, setCompleteError] = useState(null); const [transferTargetId, setTransferTargetId] = useState(''); + const [now] = useState(() => Date.now()); useDocumentTitle(loading ? 'Cycle' : (cycle?.name ?? 'Cycle')); @@ -83,10 +145,13 @@ export function CycleDetailPage() { workspaceService.getBySlug(workspaceSlug), projectService.get(workspaceSlug, projectId), cycleService.list(workspaceSlug, projectId), - issueService.list(workspaceSlug, projectId, { limit: 500 }), + issueService.list(workspaceSlug, projectId, { limit: 1000 }), stateService.list(workspaceSlug, projectId), + labelService.list(workspaceSlug, projectId), + moduleService.list(workspaceSlug, projectId), + workspaceService.listMembers(workspaceSlug), ]) - .then(([w, p, cycles, allIssues, st]) => { + .then(([w, p, cycles, allIssues, st, lab, mod, mem]) => { if (cancelled) return; setWorkspace(w ?? null); setProject(p ?? null); @@ -95,6 +160,9 @@ export function CycleDetailPage() { setAllCycles(cycles ?? []); setIssues(allIssues ?? []); setStates(st ?? []); + setLabels(lab ?? []); + setModules(mod ?? []); + setMembers(mem ?? []); // Fetch progress separately so it doesn't block the main render. if (found && workspaceSlug && projectId) { cycleService @@ -112,6 +180,10 @@ export function CycleDetailPage() { setCycle(null); setIssues([]); setStates([]); + setLabels([]); + setModules([]); + setMembers([]); + setPrSummary({}); setProgress(null); }) .finally(() => { @@ -127,8 +199,85 @@ export function CycleDetailPage() { 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 ?? '—') : '—'; + const cycleIssueIDsKey = useMemo( + () => + cycleIssues + .map((i) => i.id) + .sort() + .join(','), + [cycleIssues], + ); + + useEffect(() => { + if (!workspaceSlug || !projectId) return; + let cancelled = false; + const ids = cycleIssueIDsKey ? cycleIssueIDsKey.split(',') : []; + if (ids.length === 0) { + queueMicrotask(() => { + if (!cancelled) setPrSummary({}); + }); + return () => { + cancelled = true; + }; + } + integrationService + .githubIssueSummary(workspaceSlug, projectId, ids) + .then((map) => { + if (!cancelled) setPrSummary(map); + }) + .catch((err) => { + if (!cancelled) { + console.warn('Could not load GitHub PR summaries for cycle issues.', err); + setPrSummary({}); + } + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, projectId, cycleIssueIDsKey]); + + const cycleDisplay = useMemo(() => { + const display = cloneDefaultProjectIssuesDisplay(); + display.displayProperties.delete('cycle'); + display.groupBy = 'none'; + display.orderBy = 'last_created'; + return display; + }, []); + + const subWorkCountByParentId = useMemo(() => { + const counts = new Map(); + for (const issue of issues) { + const parentId = issue.parent_id?.trim(); + if (parentId) counts.set(parentId, (counts.get(parentId) ?? 0) + 1); + } + return counts; + }, [issues]); + + const groupedIssues = useMemo( + () => + buildGroupedIssues({ + baseForGrouping: cycleIssues, + groupBy: cycleDisplay.groupBy, + orderBy: cycleDisplay.orderBy, + showEmptyGroups: cycleDisplay.showEmptyGroups, + states, + cycles: cycle ? [cycle] : [], + modules, + labels, + members, + }), + [ + cycleIssues, + cycleDisplay.groupBy, + cycleDisplay.orderBy, + cycleDisplay.showEmptyGroups, + states, + cycle, + modules, + labels, + members, + ], + ); // Other cycles this one's incomplete work can be transferred into on completion. const transferTargets = allCycles.filter((c) => c.id !== cycle?.id && c.status !== 'completed'); @@ -166,9 +315,41 @@ export function CycleDetailPage() { return
Cycle not found.
; const projectBase = `/${workspace.slug}/projects/${project.id}`; + const layout = parseIssueLayout(searchParams.get('layout')); + const setLayout = (nextLayout: IssueLayout) => { + const next = new URLSearchParams(searchParams); + if (nextLayout === 'list') next.delete('layout'); + else next.set('layout', nextLayout); + setSearchParams(next, { replace: true }); + }; const total = progress?.total_issues ?? cycleIssues.length; const completed = progress?.completed_issues ?? 0; const completionPct = total > 0 ? Math.round((completed / total) * 100) : 0; + const hasCol = (id: SavedViewDisplayPropertyId) => cycleDisplay.displayProperties.has(id); + const cycleName = (issue: IssueApiResponse) => { + const id = issue.cycle_ids?.[0]; + return id === cycle.id ? cycle.name : '—'; + }; + const moduleName = (issue: IssueApiResponse) => { + const id = issue.module_ids?.[0]; + return id ? (modules.find((m) => m.id === id)?.name ?? '—') : '—'; + }; + const layoutIssues = groupedIssues.isFlat + ? (groupedIssues.groups.get(groupedIssues.order[0]) ?? []) + : cycleIssues; + const issueHref = (id: string) => `${projectBase}/issues/${id}`; + const layoutProps = { + workspaceSlug: workspace.slug, + project, + issues: layoutIssues, + states, + labels, + members, + prSummary, + baseUrl: projectBase, + issueHref, + now, + }; return (
@@ -279,51 +460,37 @@ export function CycleDetailPage() {
)} - {/* ── Issues table ── */} -
- - - - - - - - - - - {cycleIssues.length === 0 ? ( - - - - ) : ( - cycleIssues.map((issue) => ( - - - - - - - )) +
+
+

+ Work items {cycleIssues.length} +

+ +
+ + {cycleIssues.length === 0 ? ( +
+ No work items in this cycle. +
+ ) : ( + <> + {layout === 'list' && ( + )} -
-
Work itemPriorityStateDue date
- No work items in this cycle. -
- - {issue.name} - - - - {issue.priority ?? 'none'} - - - {stateName(issue.state_id ?? undefined)} - - {formatDate(issue.target_date)} -
+ {layout === 'board' && } + {layout === 'spreadsheet' && } + {layout === 'calendar' && } + {layout === 'gantt' && } + + )}
); diff --git a/apps/web/src/services/integrationService.ts b/apps/web/src/services/integrationService.ts index 0b973b80..5e95dfb8 100644 --- a/apps/web/src/services/integrationService.ts +++ b/apps/web/src/services/integrationService.ts @@ -8,6 +8,8 @@ import type { WorkspaceIntegrationApiResponse, } from '../api/types'; +const GITHUB_ISSUE_SUMMARY_BATCH_SIZE = 100; + /** * Integration API service. * @@ -172,6 +174,7 @@ export const integrationService = { * GET .../integrations/github/issue-summary/?ids=a,b,c * Returns aggregated PR counts per issue ID for badges on the issues list page. * Pass an empty array to get an empty map (no request made). + * Requests are chunked because this endpoint currently accepts IDs via query string. */ async githubIssueSummary( workspaceSlug: string, @@ -179,10 +182,16 @@ export const integrationService = { issueIds: string[], ): Promise { if (issueIds.length === 0) return {}; - const { data } = await apiClient.get( - `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/integrations/github/issue-summary/`, - { params: { ids: issueIds.join(',') } }, - ); - return data.summary ?? {}; + + const summary: GitHubIssueSummaryResponse['summary'] = {}; + for (let i = 0; i < issueIds.length; i += GITHUB_ISSUE_SUMMARY_BATCH_SIZE) { + const batch = issueIds.slice(i, i + GITHUB_ISSUE_SUMMARY_BATCH_SIZE); + const { data } = await apiClient.get( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/integrations/github/issue-summary/`, + { params: { ids: batch.join(',') } }, + ); + Object.assign(summary, data.summary ?? {}); + } + return summary; }, };