mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
2 Commits
feat/cli-v
...
agent/j/cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eea3774240 | ||
|
|
874520bda3 |
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "—";
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
51
packages/views/issues/components/progress-ring.tsx
Normal file
51
packages/views/issues/components/progress-ring.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user