mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 00:19:29 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/j/84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fe8ee7724 |
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user