mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 03:03:56 +02:00
* feat(projects): scheduled-only Gantt data source + WS reactivity (MUL-1881) Project Gantt now fetches its own scheduled-only data instead of riding the Board/List pagination cache. The Unscheduled drawer and pagination warning banner are gone, and any WS-driven issue change (create / update / delete) invalidates the new cache so the timeline stays live. - Backend: `GET /api/issues?scheduled=true` adds an `(i.start_date IS NOT NULL OR i.due_date IS NOT NULL)` predicate on both ListIssues and CountIssues. New SQL filter is plumbed through sqlc + handler. - Frontend: new `projectGanttIssuesOptions(wsId, projectId)` issues a single fetch and lives under its own cache key. WS handlers and mutations invalidate the prefix on create/update/delete so the bar reacts to start_date / due_date changes from other tabs and from this tab without waiting on the WS round-trip. - GanttView: drops the Unscheduled section, the pagination warning banner, and the load-all button; renders only scheduled rows. - Removes now-dead `useLoadAllRemaining`, `myIssueListPaginationOptions`, `summarizeIssueListPagination`, and the gantt locale strings that supported the old plumbing. Co-authored-by: multica-agent <github@multica.ai> * fix(projects): page through Gantt fetch and isolate per-view data sources - Walk paginated `scheduled=true` issues until total is reached so projects with more than 500 scheduled bars no longer silently truncate. - Gantt mode disables the bucketed Board/List query and reads its own scheduled cache for the project empty-state check, so the page never short-circuits Gantt with a Board-derived "no issues" CTA. - `onIssueLabelsChanged` patches matching rows in the Project Gantt cache in-place, keeping label filters consistent after attach/detach from other tabs or agents. MUL-1881 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
171 lines
5.6 KiB
TypeScript
171 lines
5.6 KiB
TypeScript
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
|
import {
|
|
agentActivityKeys,
|
|
agentRunCountsKeys,
|
|
agentTaskSnapshotKeys,
|
|
agentTasksKeys,
|
|
} from "../agents/queries";
|
|
import { labelKeys } from "../labels/queries";
|
|
import type { Issue, ListIssuesCache } from "../types";
|
|
import { findIssueLocation, removeIssueFromBuckets } from "./cache-helpers";
|
|
import { issueKeys } from "./queries";
|
|
|
|
export type DeletedIssueCacheMetadata = {
|
|
parentIssueIds: string[];
|
|
};
|
|
|
|
function collectParentId(
|
|
parentIssueIds: Set<string>,
|
|
parentId: string | null | undefined,
|
|
) {
|
|
if (parentId) parentIssueIds.add(parentId);
|
|
}
|
|
|
|
function collectParentFromListCache(
|
|
parentIssueIds: Set<string>,
|
|
data: ListIssuesCache | undefined,
|
|
issueId: string,
|
|
) {
|
|
const parentId = data
|
|
? findIssueLocation(data, issueId)?.issue.parent_issue_id
|
|
: undefined;
|
|
collectParentId(parentIssueIds, parentId);
|
|
}
|
|
|
|
function parentIdFromChildrenKey(key: QueryKey) {
|
|
const parentId = key[key.length - 1];
|
|
return typeof parentId === "string" ? parentId : null;
|
|
}
|
|
|
|
export function collectDeletedIssueCacheMetadata(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
): DeletedIssueCacheMetadata {
|
|
const parentIssueIds = new Set<string>();
|
|
|
|
const detail = qc.getQueryData<Issue>(issueKeys.detail(wsId, issueId));
|
|
collectParentId(parentIssueIds, detail?.parent_issue_id);
|
|
|
|
collectParentFromListCache(
|
|
parentIssueIds,
|
|
qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId)),
|
|
issueId,
|
|
);
|
|
|
|
for (const [, data] of qc.getQueriesData<ListIssuesCache>({
|
|
queryKey: issueKeys.myAll(wsId),
|
|
})) {
|
|
collectParentFromListCache(parentIssueIds, data, issueId);
|
|
}
|
|
|
|
for (const [key, data] of qc.getQueriesData<Issue[]>({
|
|
queryKey: [...issueKeys.all(wsId), "children"],
|
|
})) {
|
|
const child = data?.find((issue) => issue.id === issueId);
|
|
if (!child) continue;
|
|
collectParentId(parentIssueIds, child.parent_issue_id);
|
|
collectParentId(parentIssueIds, parentIdFromChildrenKey(key));
|
|
}
|
|
|
|
return { parentIssueIds: Array.from(parentIssueIds) };
|
|
}
|
|
|
|
export function pruneDeletedIssueFromListCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
|
old ? removeIssueFromBuckets(old, issueId) : old,
|
|
);
|
|
|
|
for (const [key] of qc.getQueriesData<ListIssuesCache>({
|
|
queryKey: issueKeys.myAll(wsId),
|
|
})) {
|
|
qc.setQueryData<ListIssuesCache>(key, (old) =>
|
|
old ? removeIssueFromBuckets(old, issueId) : old,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function pruneDeletedIssueFromParentChildrenCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
metadata: DeletedIssueCacheMetadata,
|
|
) {
|
|
for (const parentId of metadata.parentIssueIds) {
|
|
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
|
old?.filter((issue) => issue.id !== issueId),
|
|
);
|
|
}
|
|
}
|
|
|
|
export function invalidateDeletedIssueParentCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
metadata: DeletedIssueCacheMetadata,
|
|
) {
|
|
if (metadata.parentIssueIds.length === 0) return;
|
|
for (const parentId of metadata.parentIssueIds) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
|
|
}
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
}
|
|
|
|
export function invalidateDeletedIssueDependentCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
) {
|
|
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) });
|
|
qc.invalidateQueries({ queryKey: agentActivityKeys.last30d(wsId) });
|
|
qc.invalidateQueries({ queryKey: agentRunCountsKeys.last30d(wsId) });
|
|
qc.invalidateQueries({ queryKey: agentTasksKeys.all(wsId) });
|
|
}
|
|
|
|
export function invalidateIssueScopedCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.usage(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.attachments(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.tasks(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
|
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
|
}
|
|
|
|
export function cleanupDeletedIssueCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
metadata = collectDeletedIssueCacheMetadata(qc, wsId, issueId),
|
|
) {
|
|
pruneDeletedIssueFromListCaches(qc, wsId, issueId);
|
|
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, issueId, metadata);
|
|
invalidateDeletedIssueParentCaches(qc, wsId, metadata);
|
|
|
|
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.usage(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.attachments(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.tasks(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
|
qc.removeQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
|
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
|
// Project Gantt cache lives outside `myAll`, so it needs an explicit
|
|
// refresh when an issue is removed — the deleted row may have been a
|
|
// scheduled bar visible right now.
|
|
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
|
invalidateDeletedIssueDependentCaches(qc, wsId);
|
|
}
|