Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
eea3774240 feat(views): refine sub-issue progress UI and add to board view
- Move progress badge right after issue title (not pushed to far right)
- Increase progress ring size from 11px to 14px for better visibility
- Add sub-issue progress indicator to board card view
- Thread childProgressMap through BoardView → BoardColumn → BoardCard
2026-04-09 16:48:43 +08:00
Jiang Bohan
874520bda3 feat(views): show sub-issue progress indicator in issue list rows
When an issue has sub-issues, display a circular progress ring with
done/total count (e.g. "2/3") in the list row. Progress is computed
from the already-loaded issue list without additional API calls.

Extracts ProgressRing into a shared component reused by both
issue-detail and list-row.
2026-04-09 16:36:57 +08:00
10 changed files with 164 additions and 63 deletions

View File

@@ -15,6 +15,8 @@ import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
import { PRIORITY_CONFIG } from "@multica/core/issues/config";
import type { CardProperties } from "@multica/core/issues/stores/view-store";
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { ProgressRing } from "./progress-ring";
import type { ChildProgress } from "./list-row";
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
@@ -39,9 +41,11 @@ function PickerWrapper({ children }: { children: React.ReactNode }) {
export const BoardCardContent = memo(function BoardCardContent({
issue,
editable = false,
childProgress,
}: {
issue: Issue;
editable?: boolean;
childProgress?: ChildProgress;
}) {
const storeProperties = useViewStore((s) => s.cardProperties);
const priorityCfg = PRIORITY_CONFIG[issue.priority];
@@ -73,6 +77,16 @@ export const BoardCardContent = memo(function BoardCardContent({
{issue.title}
</p>
{/* Sub-issue progress */}
{childProgress && (
<div className="mt-1.5 inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5">
<ProgressRing done={childProgress.done} total={childProgress.total} size={14} />
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
{childProgress.done}/{childProgress.total}
</span>
</div>
)}
{/* Description */}
{showDescription && (
<p className="mt-1 text-xs text-muted-foreground line-clamp-1">
@@ -173,7 +187,7 @@ const animateLayoutChanges: AnimateLayoutChanges = (args) => {
return defaultAnimateLayoutChanges(args);
};
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) {
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue, childProgress }: { issue: Issue; childProgress?: ChildProgress }) {
const {
attributes,
listeners,
@@ -204,7 +218,7 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: {
href={`/issues/${issue.id}`}
className={`group block transition-colors ${isDragging ? "pointer-events-none" : ""}`}
>
<BoardCardContent issue={issue} editable />
<BoardCardContent issue={issue} editable childProgress={childProgress} />
</AppLink>
</div>
);

View File

@@ -18,17 +18,20 @@ import { useModalStore } from "@multica/core/modals";
import { useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
import { StatusIcon } from "./status-icon";
import { DraggableBoardCard } from "./board-card";
import type { ChildProgress } from "./list-row";
export function BoardColumn({
status,
issueIds,
issueMap,
childProgressMap,
totalCount,
footer,
}: {
status: IssueStatus;
issueIds: string[];
issueMap: Map<string, Issue>;
childProgressMap?: Map<string, ChildProgress>;
totalCount?: number;
footer?: ReactNode;
}) {
@@ -102,7 +105,7 @@ export function BoardColumn({
>
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{resolvedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
<DraggableBoardCard key={issue.id} issue={issue} childProgress={childProgressMap?.get(issue.id)} />
))}
</SortableContext>
{issueIds.length === 0 && (

View File

@@ -33,6 +33,7 @@ import { StatusIcon } from "./status-icon";
import { BoardColumn } from "./board-column";
import { BoardCardContent } from "./board-card";
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
import type { ChildProgress } from "./list-row";
const COLUMN_IDS = new Set<string>(ALL_STATUSES);
@@ -93,12 +94,15 @@ function findColumn(
return null;
}
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
export function BoardView({
issues,
allIssues,
visibleStatuses,
hiddenStatuses,
onMoveIssue,
childProgressMap = EMPTY_PROGRESS_MAP,
}: {
issues: Issue[];
allIssues: Issue[];
@@ -109,6 +113,7 @@ export function BoardView({
newStatus: IssueStatus,
newPosition?: number
) => void;
childProgressMap?: Map<string, ChildProgress>;
}) {
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
@@ -275,6 +280,7 @@ export function BoardView({
status={status}
issueIds={columns[status] ?? []}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
totalCount={status === "done" ? doneTotal : undefined}
footer={
status === "done" && hasMore ? (
@@ -295,7 +301,7 @@ export function BoardView({
<DragOverlay dropAnimation={null}>
{activeIssue ? (
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
<BoardCardContent issue={activeIssue} />
<BoardCardContent issue={activeIssue} childProgress={childProgressMap.get(activeIssue.id)} />
</div>
) : null}
</DragOverlay>

View File

@@ -83,58 +83,7 @@ import { useModalStore } from "@multica/core/modals";
import { timeAgo } from "@multica/core/utils";
import { cn } from "@multica/ui/lib/utils";
/**
* Tiny circular progress ring used in the "Sub-issue of …" line and the
* Sub-issues section header. Renders an open ring when in-progress and
* fills to a solid arc when complete.
*/
function ProgressRing({
done,
total,
size = 12,
}: {
done: number;
total: number;
size?: number;
}) {
const stroke = 1.5;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const ratio = total > 0 ? Math.min(done / total, 1) : 0;
const offset = circumference * (1 - ratio);
const isComplete = total > 0 && done >= total;
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={isComplete ? "text-info" : "text-primary"}
aria-hidden="true"
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
);
}
import { ProgressRing } from "./progress-ring";
function shortDate(date: string | null): string {
if (!date) return "—";

View File

@@ -57,6 +57,23 @@ export function IssuesPage() {
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
);
// Compute sub-issue progress for each parent from the full (unfiltered) issue list
const childProgressMap = useMemo(() => {
const map = new Map<string, { done: number; total: number }>();
for (const issue of allIssues) {
if (!issue.parent_issue_id) continue;
const entry = map.get(issue.parent_issue_id);
const isDone = issue.status === "done" || issue.status === "cancelled";
if (entry) {
entry.total++;
if (isDone) entry.done++;
} else {
map.set(issue.parent_issue_id, { done: isDone ? 1 : 0, total: 1 });
}
}
return map;
}, [allIssues]);
const visibleStatuses = useMemo(() => {
if (statusFilters.length > 0)
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
@@ -146,9 +163,10 @@ export function IssuesPage() {
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} />
)}
</div>
)}

View File

@@ -6,6 +6,12 @@ import type { Issue } from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { PriorityIcon } from "./priority-icon";
import { ProgressRing } from "./progress-ring";
export interface ChildProgress {
done: number;
total: number;
}
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
@@ -14,7 +20,13 @@ function formatDate(date: string): string {
});
}
export const ListRow = memo(function ListRow({ issue }: { issue: Issue }) {
export const ListRow = memo(function ListRow({
issue,
childProgress,
}: {
issue: Issue;
childProgress?: ChildProgress;
}) {
const selected = useIssueSelectionStore((s) => s.selectedIds.has(issue.id));
const toggle = useIssueSelectionStore((s) => s.toggle);
@@ -45,7 +57,17 @@ export const ListRow = memo(function ListRow({ issue }: { issue: Issue }) {
<span className="w-16 shrink-0 text-xs text-muted-foreground">
{issue.identifier}
</span>
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="truncate">{issue.title}</span>
{childProgress && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5">
<ProgressRing done={childProgress.done} total={childProgress.total} size={14} />
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
{childProgress.done}/{childProgress.total}
</span>
</span>
)}
</span>
{issue.due_date && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatDate(issue.due_date)}

View File

@@ -13,15 +13,19 @@ import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { sortIssues } from "../utils/sort";
import { StatusIcon } from "./status-icon";
import { ListRow } from "./list-row";
import { ListRow, type ChildProgress } from "./list-row";
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
export function ListView({
issues,
visibleStatuses,
childProgressMap = EMPTY_PROGRESS_MAP,
}: {
issues: Issue[];
visibleStatuses: IssueStatus[];
childProgressMap?: Map<string, ChildProgress>;
}) {
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
@@ -133,7 +137,7 @@ export function ListView({
{statusIssues.length > 0 ? (
<>
{statusIssues.map((issue) => (
<ListRow key={issue.id} issue={issue} />
<ListRow key={issue.id} issue={issue} childProgress={childProgressMap.get(issue.id)} />
))}
{status === "done" && hasMore && (
<InfiniteScrollSentinel onVisible={loadMore} loading={loadingMore} />

View File

@@ -0,0 +1,51 @@
/**
* Tiny circular progress ring. Renders an open ring when in-progress and
* fills to a solid arc when complete.
*/
export function ProgressRing({
done,
total,
size = 12,
}: {
done: number;
total: number;
size?: number;
}) {
const stroke = 1.5;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const ratio = total > 0 ? Math.min(done / total, 1) : 0;
const offset = circumference * (1 - ratio);
const isComplete = total > 0 && done >= total;
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={isComplete ? "text-info" : "text-primary"}
aria-hidden="true"
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
);
}

View File

@@ -99,6 +99,22 @@ export function MyIssuesPage() {
[myIssues, statusFilters, priorityFilters],
);
const childProgressMap = useMemo(() => {
const map = new Map<string, { done: number; total: number }>();
for (const issue of allIssues) {
if (!issue.parent_issue_id) continue;
const entry = map.get(issue.parent_issue_id);
const isDone = issue.status === "done" || issue.status === "cancelled";
if (entry) {
entry.total++;
if (isDone) entry.done++;
} else {
map.set(issue.parent_issue_id, { done: isDone ? 1 : 0, total: 1 });
}
}
return map;
}, [allIssues]);
const visibleStatuses = useMemo(() => {
if (statusFilters.length > 0)
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
@@ -187,9 +203,10 @@ export function MyIssuesPage() {
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} />
)}
</div>
)}

View File

@@ -96,6 +96,22 @@ function ProjectIssuesTab({ projectIssues }: { projectIssues: Issue[] }) {
[projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
);
const childProgressMap = useMemo(() => {
const map = new Map<string, { done: number; total: number }>();
for (const issue of projectIssues) {
if (!issue.parent_issue_id) continue;
const entry = map.get(issue.parent_issue_id);
const isDone = issue.status === "done" || issue.status === "cancelled";
if (entry) {
entry.total++;
if (isDone) entry.done++;
} else {
map.set(issue.parent_issue_id, { done: isDone ? 1 : 0, total: 1 });
}
}
return map;
}, [projectIssues]);
const visibleStatuses = useMemo(() => {
if (statusFilters.length > 0)
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
@@ -144,9 +160,10 @@ function ProjectIssuesTab({ projectIssues }: { projectIssues: Issue[] }) {
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} />
)}
</div>
);