Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
9fe8ee7724 fix: compute sub-issue progress from database instead of paginated client cache
The sub-issue progress indicator (e.g. "0/2") was undercounting because
it was computed from the client-side issue list, which only loads the
first 50 done issues. Sub-issues marked as done beyond that page were
excluded from both the total and done counts.

Added a dedicated backend endpoint (GET /api/issues/child-progress) that
aggregates child issue counts directly from the database, ensuring
accurate totals regardless of client-side pagination or filtering.

Fixes MUL-702
2026-04-13 18:53:24 +08:00
11 changed files with 113 additions and 50 deletions

View File

@@ -249,6 +249,10 @@ export class ApiClient {
return this.fetch(`/api/issues/${id}/children`);
}
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
return this.fetch("/api/issues/child-progress");
}
async deleteIssue(id: string): Promise<void> {
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
}

View File

@@ -97,6 +97,7 @@ export function useCreateIssue() {
// Invalidate parent's children query so sub-issues list updates immediately
if (newIssue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
onSettled: () => {
@@ -171,6 +172,7 @@ export function useUpdateIssue() {
qc.invalidateQueries({
queryKey: issueKeys.children(wsId, ctx.parentId),
});
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});
@@ -205,6 +207,7 @@ export function useDeleteIssue() {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.parentIssueId) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});
@@ -278,10 +281,11 @@ export function useBatchDeleteIssues() {
},
onSettled: (_data, _err, _ids, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.parentIssueIds) {
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
for (const parentId of ctx.parentIssueIds) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
}
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});

View File

@@ -14,6 +14,8 @@ export const issueKeys = {
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "children", id] as const,
childProgress: (wsId: string) =>
[...issueKeys.all(wsId), "child-progress"] as const,
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
@@ -89,6 +91,20 @@ export function issueDetailOptions(wsId: string, id: string) {
});
}
export function childIssueProgressOptions(wsId: string) {
return queryOptions({
queryKey: issueKeys.childProgress(wsId),
queryFn: () => api.getChildIssueProgress(),
select: (data) => {
const map = new Map<string, { done: number; total: number }>();
for (const entry of data.progress) {
map.set(entry.parent_issue_id, { done: entry.done, total: entry.total });
}
return map;
},
});
}
export function childIssuesOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.children(wsId, id),

View File

@@ -20,6 +20,7 @@ export function onIssueCreated(
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
}
@@ -66,6 +67,9 @@ export function onIssueUpdated(
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
}
}
@@ -96,5 +100,6 @@ export function onIssueDeleted(
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
if (deleted?.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
}

View File

@@ -14,7 +14,7 @@ import { BOARD_STATUSES } from "@multica/core/issues/config";
import { useWorkspaceStore } from "@multica/core/workspace";
import { WorkspaceAvatar } from "../../workspace/workspace-avatar";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueListOptions } from "@multica/core/issues/queries";
import { issueListOptions, childIssueProgressOptions } from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { IssuesHeader } from "./issues-header";
@@ -59,22 +59,9 @@ export function IssuesPage() {
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject],
);
// 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]);
// Fetch sub-issue progress from the backend so counts are accurate
// regardless of client-side pagination or filtering of done issues.
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
const visibleStatuses = useMemo(() => {
if (statusFilters.length > 0)

View File

@@ -20,7 +20,7 @@ import { ListView } from "../../issues/components/list-view";
import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar";
import { registerViewStoreForWorkspaceSync } from "@multica/core/issues/stores/view-store";
import { useWorkspaceId } from "@multica/core/hooks";
import { myIssueListOptions, type MyIssuesFilter } from "@multica/core/issues/queries";
import { myIssueListOptions, childIssueProgressOptions, type MyIssuesFilter } from "@multica/core/issues/queries";
import { useUpdateIssue, useLoadMoreDoneIssues } from "@multica/core/issues/mutations";
import { myIssuesViewStore } from "@multica/core/issues/stores/my-issues-view-store";
import { MyIssuesHeader } from "./my-issues-header";
@@ -88,21 +88,7 @@ export function MyIssuesPage() {
[myIssues, statusFilters, priorityFilters],
);
const childProgressMap = useMemo(() => {
const map = new Map<string, { done: number; total: number }>();
for (const issue of myIssues) {
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;
}, [myIssues]);
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
const visibleStatuses = useMemo(() => {
if (statusFilters.length > 0)

View File

@@ -12,7 +12,7 @@ import { projectDetailOptions } from "@multica/core/projects/queries";
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
import { pinListOptions } from "@multica/core/pins";
import { useCreatePin, useDeletePin } from "@multica/core/pins";
import { issueListOptions } from "@multica/core/issues/queries";
import { issueListOptions, childIssueProgressOptions } from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -92,6 +92,7 @@ function PropRow({
const projectViewStore = createIssueViewStore("project_issues_view");
function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
const wsId = useWorkspaceId();
const viewMode = useViewStore((s) => s.viewMode);
const statusFilters = useViewStore((s) => s.statusFilters);
const priorityFilters = useViewStore((s) => s.priorityFilters);
@@ -108,21 +109,7 @@ function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
[projectIssues],
);
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 { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
const visibleStatuses = useMemo(() => {
if (statusFilters.length > 0)

View File

@@ -198,6 +198,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
// Issues
r.Route("/api/issues", func(r chi.Router) {
r.Get("/search", h.SearchIssues)
r.Get("/child-progress", h.ChildIssueProgress)
r.Get("/", h.ListIssues)
r.Post("/", h.CreateIssue)
r.Post("/batch-update", h.BatchUpdateIssues)

View File

@@ -727,6 +727,34 @@ func (h *Handler) ListChildIssues(w http.ResponseWriter, r *http.Request) {
})
}
func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) {
wsID := resolveWorkspaceID(r)
wsUUID := parseUUID(wsID)
rows, err := h.Queries.ChildIssueProgress(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get child issue progress")
return
}
type progressEntry struct {
ParentIssueID string `json:"parent_issue_id"`
Total int64 `json:"total"`
Done int64 `json:"done"`
}
resp := make([]progressEntry, len(rows))
for i, row := range rows {
resp[i] = progressEntry{
ParentIssueID: uuidToString(row.ParentIssueID),
Total: row.Total,
Done: row.Done,
}
}
writeJSON(w, http.StatusOK, map[string]any{
"progress": resp,
})
}
type CreateIssueRequest struct {
Title string `json:"title"`
Description *string `json:"description"`

View File

@@ -11,6 +11,42 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const childIssueProgress = `-- name: ChildIssueProgress :many
SELECT parent_issue_id,
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status IN ('done', 'cancelled'))::bigint AS done
FROM issue
WHERE workspace_id = $1
AND parent_issue_id IS NOT NULL
GROUP BY parent_issue_id
`
type ChildIssueProgressRow struct {
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Total int64 `json:"total"`
Done int64 `json:"done"`
}
func (q *Queries) ChildIssueProgress(ctx context.Context, workspaceID pgtype.UUID) ([]ChildIssueProgressRow, error) {
rows, err := q.db.Query(ctx, childIssueProgress, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ChildIssueProgressRow{}
for rows.Next() {
var i ChildIssueProgressRow
if err := rows.Scan(&i.ParentIssueID, &i.Total, &i.Done); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const countCreatedIssueAssignees = `-- name: CountCreatedIssueAssignees :many
SELECT
assignee_type,

View File

@@ -103,4 +103,13 @@ WHERE workspace_id = $1
AND assignee_id IS NOT NULL
GROUP BY assignee_type, assignee_id;
-- name: ChildIssueProgress :many
SELECT parent_issue_id,
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status IN ('done', 'cancelled'))::bigint AS done
FROM issue
WHERE workspace_id = $1
AND parent_issue_id IS NOT NULL
GROUP BY parent_issue_id;
-- SearchIssues: moved to handler (dynamic SQL for multi-word search support).