Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 226 additions & 59 deletions apps/web/src/pages/CycleDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,43 @@
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';
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<Priority, 'danger' | 'warning' | 'default' | 'neutral'> = {
urgent: 'danger',
high: 'danger',
medium: 'warning',
low: 'default',
none: 'neutral',
};

function formatDate(iso: string | null | undefined): string {
const d = parseISODateForDisplay(iso);
if (!d) return '—';
Expand All @@ -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 (
<div className="flex h-8 overflow-hidden rounded-lg border border-(--border-subtle) bg-(--bg-layer-1) p-0.5">
{LAYOUT_OPTIONS.map(({ key, label, Icon }) => {
const active = layout === key;
return (
<button
key={key}
type="button"
title={label}
aria-label={label}
aria-pressed={active}
onClick={() => onChange(key)}
className={
active
? 'flex size-7 items-center justify-center rounded-md bg-(--bg-layer-2) text-(--txt-primary) shadow-sm'
: 'flex size-7 items-center justify-center rounded-md text-(--txt-icon-tertiary) hover:bg-(--bg-layer-2-hover) hover:text-(--txt-secondary)'
}
>
<Icon className="size-4" />
</button>
);
})}
</div>
);
}

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<WorkspaceApiResponse | null>(null);
Expand All @@ -62,11 +119,16 @@ export function CycleDetailPage() {
const [allCycles, setAllCycles] = useState<CycleApiResponse[]>([]);
const [issues, setIssues] = useState<IssueApiResponse[]>([]);
const [states, setStates] = useState<StateApiResponse[]>([]);
const [labels, setLabels] = useState<LabelApiResponse[]>([]);
const [modules, setModules] = useState<ModuleApiResponse[]>([]);
const [members, setMembers] = useState<WorkspaceMemberApiResponse[]>([]);
const [prSummary, setPrSummary] = useState<Record<string, GitHubIssueSummaryEntry>>({});
const [progress, setProgress] = useState<CycleProgressResponse | null>(null);
const [completeModalOpen, setCompleteModalOpen] = useState(false);
const [completing, setCompleting] = useState(false);
const [completeError, setCompleteError] = useState<string | null>(null);
const [transferTargetId, setTransferTargetId] = useState('');
const [now] = useState(() => Date.now());

useDocumentTitle(loading ? 'Cycle' : (cycle?.name ?? 'Cycle'));

Expand All @@ -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);
Expand All @@ -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
Expand All @@ -112,6 +180,10 @@ export function CycleDetailPage() {
setCycle(null);
setIssues([]);
setStates([]);
setLabels([]);
setModules([]);
setMembers([]);
setPrSummary({});
setProgress(null);
})
.finally(() => {
Expand All @@ -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]);
Comment thread
aditya-vithaldas marked this conversation as resolved.

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<string, number>();
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');
Expand Down Expand Up @@ -166,9 +315,41 @@ export function CycleDetailPage() {
return <div className="p-6 text-sm text-(--txt-secondary)">Cycle not found.</div>;

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 (
<div className="space-y-6">
Expand Down Expand Up @@ -279,51 +460,37 @@ export function CycleDetailPage() {
</div>
)}

{/* ── Issues table ── */}
<div className="overflow-x-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1)">
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="bg-(--bg-layer-1)">
<tr>
<th className="px-4 py-2 font-medium text-(--txt-secondary)">Work item</th>
<th className="px-4 py-2 font-medium text-(--txt-secondary)">Priority</th>
<th className="px-4 py-2 font-medium text-(--txt-secondary)">State</th>
<th className="px-4 py-2 font-medium text-(--txt-secondary)">Due date</th>
</tr>
</thead>
<tbody>
{cycleIssues.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-(--txt-tertiary)">
No work items in this cycle.
</td>
</tr>
) : (
cycleIssues.map((issue) => (
<tr key={issue.id} className="border-t border-(--border-subtle)">
<td className="px-4 py-2">
<Link
to={`${projectBase}/issues/${issue.id}`}
className="text-(--txt-primary) no-underline hover:text-(--txt-accent-primary)"
>
{issue.name}
</Link>
</td>
<td className="px-4 py-2">
<Badge variant={priorityVariant[(issue.priority as Priority) ?? 'none']}>
{issue.priority ?? 'none'}
</Badge>
</td>
<td className="px-4 py-2 text-(--txt-secondary)">
{stateName(issue.state_id ?? undefined)}
</td>
<td className="px-4 py-2 text-(--txt-secondary)">
{formatDate(issue.target_date)}
</td>
</tr>
))
<div className="overflow-hidden rounded-md border border-(--border-subtle) bg-(--bg-surface-1)">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-(--border-subtle) px-4 py-3">
<h2 className="text-base font-semibold text-(--txt-primary)">
Work items {cycleIssues.length}
</h2>
<CycleLayoutSwitcher layout={layout} onChange={setLayout} />
</div>

{cycleIssues.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-(--txt-tertiary)">
No work items in this cycle.
</div>
) : (
<>
{layout === 'list' && (
<IssueLayoutList
{...layoutProps}
groupedIssues={groupedIssues}
hasCol={hasCol}
showEmptyGroups={cycleDisplay.showEmptyGroups}
subWorkCountByParentId={subWorkCountByParentId}
cycleName={cycleName}
moduleName={moduleName}
/>
)}
</tbody>
</table>
{layout === 'board' && <IssueLayoutBoard {...layoutProps} />}
{layout === 'spreadsheet' && <IssueLayoutSpreadsheet {...layoutProps} />}
{layout === 'calendar' && <IssueLayoutCalendar {...layoutProps} />}
{layout === 'gantt' && <IssueLayoutGantt {...layoutProps} />}
</>
)}
</div>
</div>
);
Expand Down
Loading
Loading