mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 10:05:41 +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>
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
import { queryOptions } from "@tanstack/react-query";
|
|
import { api } from "../api";
|
|
import type {
|
|
GroupedIssuesResponse,
|
|
IssueStatus,
|
|
ListGroupedIssuesParams,
|
|
ListIssuesParams,
|
|
ListIssuesCache,
|
|
} from "../types";
|
|
import { BOARD_STATUSES } from "./config";
|
|
|
|
export const issueKeys = {
|
|
all: (wsId: string) => ["issues", wsId] as const,
|
|
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
|
|
assigneeGroupsAll: (wsId: string) =>
|
|
[...issueKeys.all(wsId), "assignee-groups"] as const,
|
|
assigneeGroups: (wsId: string, filter: AssigneeGroupedIssuesFilter) =>
|
|
[...issueKeys.assigneeGroupsAll(wsId), filter] as const,
|
|
/** All "my issues" queries — use for bulk invalidation. */
|
|
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
|
|
/** Per-scope "my issues" list with filter identity baked into the key. */
|
|
myList: (wsId: string, scope: string, filter: MyIssuesFilter) =>
|
|
[...issueKeys.myAll(wsId), scope, filter] as const,
|
|
myAssigneeGroupsAll: (wsId: string) =>
|
|
[...issueKeys.myAll(wsId), "assignee-groups"] as const,
|
|
myAssigneeGroups: (
|
|
wsId: string,
|
|
scope: string,
|
|
filter: AssigneeGroupedIssuesFilter,
|
|
) => [...issueKeys.myAssigneeGroupsAll(wsId), scope, filter] as const,
|
|
/** All Project Gantt queries — prefix-match key for cross-project invalidation. */
|
|
projectGanttAll: (wsId: string) =>
|
|
[...issueKeys.all(wsId), "project-gantt"] as const,
|
|
/**
|
|
* Per-project Gantt issue list (scheduled-only). Uses its own cache key
|
|
* rather than reusing the bucketed `myList` cache so WS handlers and
|
|
* cache helpers don't have to special-case a non-bucketed shape under
|
|
* the `my` prefix.
|
|
*/
|
|
projectGantt: (wsId: string, projectId: string) =>
|
|
[...issueKeys.projectGanttAll(wsId), projectId] as const,
|
|
detail: (wsId: string, id: string) =>
|
|
[...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,
|
|
/** Full-issue timeline (single TanStack Query, no cursor). */
|
|
timeline: (issueId: string) =>
|
|
["issues", "timeline", issueId] as const,
|
|
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
|
subscribers: (issueId: string) =>
|
|
["issues", "subscribers", issueId] as const,
|
|
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
|
/** Issue-level attachments — used by the description editor so its
|
|
* inline file-card / image NodeViews can re-sign download URLs at
|
|
* click time. */
|
|
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
|
/** Per-issue task list (issue-detail Execution log section). */
|
|
tasks: (issueId: string) => ["issues", "tasks", issueId] as const,
|
|
/** Prefix-match key for invalidating tasks across all issues — used by
|
|
* the global WS task: prefix path so any task lifecycle event refreshes
|
|
* every per-issue list, regardless of which issue is currently mounted. */
|
|
tasksAll: () => ["issues", "tasks"] as const,
|
|
};
|
|
|
|
export type MyIssuesFilter = Pick<
|
|
ListIssuesParams,
|
|
"assignee_id" | "assignee_ids" | "creator_id" | "project_id" | "involves_user_id"
|
|
>;
|
|
|
|
export type AssigneeGroupedIssuesFilter = Omit<
|
|
ListGroupedIssuesParams,
|
|
"group_by" | "limit" | "offset" | "group_assignee_type" | "group_assignee_id"
|
|
>;
|
|
|
|
/** Page size per status column. */
|
|
export const ISSUE_PAGE_SIZE = 50;
|
|
|
|
/** Statuses the issues/my-issues pages paginate. Cancelled is intentionally excluded — it has never been surfaced in the list/board views. */
|
|
export const PAGINATED_STATUSES: readonly IssueStatus[] = BOARD_STATUSES;
|
|
|
|
/** Flatten a bucketed response to a single Issue[] for consumers that want the whole list. */
|
|
export function flattenIssueBuckets(data: ListIssuesCache) {
|
|
const out = [];
|
|
for (const status of PAGINATED_STATUSES) {
|
|
const bucket = data.byStatus[status];
|
|
if (bucket) out.push(...bucket.issues);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
|
|
const responses = await Promise.all(
|
|
PAGINATED_STATUSES.map((status) =>
|
|
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }),
|
|
),
|
|
);
|
|
const byStatus: ListIssuesCache["byStatus"] = {};
|
|
PAGINATED_STATUSES.forEach((status, i) => {
|
|
const res = responses[i]!;
|
|
byStatus[status] = { issues: res.issues, total: res.total };
|
|
});
|
|
return { byStatus };
|
|
}
|
|
|
|
/**
|
|
* CACHE SHAPE NOTE: The raw cache stores {@link ListIssuesCache} (buckets keyed
|
|
* by status, each with `{ issues, total }`), and `select` flattens it to
|
|
* `Issue[]` for consumers. Mutations and ws-updaters must use
|
|
* `setQueryData<ListIssuesCache>(...)` and preserve the byStatus shape.
|
|
*
|
|
* Fetches the first page of each paginated status in parallel. Use
|
|
* {@link useLoadMoreByStatus} to paginate a specific status into the cache.
|
|
*/
|
|
export function issueListOptions(wsId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.list(wsId),
|
|
queryFn: () => fetchFirstPages(),
|
|
select: flattenIssueBuckets,
|
|
});
|
|
}
|
|
|
|
export function issueAssigneeGroupsOptions(
|
|
wsId: string,
|
|
filter: AssigneeGroupedIssuesFilter,
|
|
) {
|
|
return queryOptions<GroupedIssuesResponse>({
|
|
queryKey: issueKeys.assigneeGroups(wsId, filter),
|
|
queryFn: () =>
|
|
api.listGroupedIssues({
|
|
group_by: "assignee",
|
|
limit: ISSUE_PAGE_SIZE,
|
|
offset: 0,
|
|
...filter,
|
|
}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Server-filtered issue list for the My Issues page.
|
|
* Each scope gets its own cache entry so switching tabs is instant after first load.
|
|
*/
|
|
export function myIssueListOptions(
|
|
wsId: string,
|
|
scope: string,
|
|
filter: MyIssuesFilter,
|
|
) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.myList(wsId, scope, filter),
|
|
queryFn: () => fetchFirstPages(filter),
|
|
select: flattenIssueBuckets,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Page size for the scheduled-issue fetch. The Gantt view always pulls every
|
|
* scheduled issue (no client pagination), so this is just the chunk size we
|
|
* use to walk the server's `(limit, offset)` window until we hit `total`.
|
|
*/
|
|
export const PROJECT_GANTT_PAGE_LIMIT = 500;
|
|
|
|
/**
|
|
* Paranoia cap on the loop in {@link fetchProjectGanttIssues}. Real projects
|
|
* shouldn't come close to this — a single project carrying 50k scheduled
|
|
* issues is already a product problem, not a Gantt-rendering one — but the
|
|
* guard prevents a buggy server `total` from spinning the loop forever.
|
|
*/
|
|
export const PROJECT_GANTT_MAX_ISSUES = 10_000;
|
|
|
|
async function fetchProjectGanttIssues(projectId: string) {
|
|
const issues = [];
|
|
let offset = 0;
|
|
while (offset < PROJECT_GANTT_MAX_ISSUES) {
|
|
const res = await api.listIssues({
|
|
project_id: projectId,
|
|
scheduled: true,
|
|
limit: PROJECT_GANTT_PAGE_LIMIT,
|
|
offset,
|
|
});
|
|
issues.push(...res.issues);
|
|
if (res.issues.length < PROJECT_GANTT_PAGE_LIMIT) break;
|
|
if (issues.length >= res.total) break;
|
|
offset += PROJECT_GANTT_PAGE_LIMIT;
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
/**
|
|
* One-shot fetch of every scheduled issue (`start_date` or `due_date` set)
|
|
* for a project. The Project Gantt view consumes this directly — no status
|
|
* bucketing, no client-side pagination, no Load-all affordance — because
|
|
* the scheduled subset is bounded enough to come back in a small handful of
|
|
* requests.
|
|
*
|
|
* Backed by `GET /api/issues?scheduled=true&project_id=…`; the SQL filter
|
|
* mirrors the same `(start_date IS NOT NULL OR due_date IS NOT NULL)`
|
|
* predicate the Gantt view applies on the client. Pages are walked until
|
|
* `total` is reached so an oversized project can't silently lose bars past
|
|
* the first page.
|
|
*/
|
|
export function projectGanttIssuesOptions(wsId: string, projectId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.projectGantt(wsId, projectId),
|
|
queryFn: () => fetchProjectGanttIssues(projectId),
|
|
});
|
|
}
|
|
|
|
export function myIssueAssigneeGroupsOptions(
|
|
wsId: string,
|
|
scope: string,
|
|
filter: AssigneeGroupedIssuesFilter,
|
|
) {
|
|
return queryOptions<GroupedIssuesResponse>({
|
|
queryKey: issueKeys.myAssigneeGroups(wsId, scope, filter),
|
|
queryFn: () =>
|
|
api.listGroupedIssues({
|
|
group_by: "assignee",
|
|
limit: ISSUE_PAGE_SIZE,
|
|
offset: 0,
|
|
...filter,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function issueDetailOptions(wsId: string, id: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.detail(wsId, id),
|
|
queryFn: () => api.getIssue(id),
|
|
});
|
|
}
|
|
|
|
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),
|
|
queryFn: () => api.listChildIssues(id).then((r) => r.issues),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Single-fetch timeline options. The endpoint returns the full ordered set of
|
|
* comments + activities for an issue (server caps at 2000 as a safety net).
|
|
* Cursor pagination was removed in #1929 — at observed data sizes (p99 ~30
|
|
* entries per issue) it added complexity without a UX win and broke reply
|
|
* threads at page boundaries.
|
|
*/
|
|
export function issueTimelineOptions(issueId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.timeline(issueId),
|
|
queryFn: () => api.listTimeline(issueId),
|
|
});
|
|
}
|
|
|
|
export function issueReactionsOptions(issueId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.reactions(issueId),
|
|
queryFn: async () => {
|
|
const issue = await api.getIssue(issueId);
|
|
return issue.reactions ?? [];
|
|
},
|
|
});
|
|
}
|
|
|
|
export function issueSubscribersOptions(issueId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.subscribers(issueId),
|
|
queryFn: () => api.listIssueSubscribers(issueId),
|
|
});
|
|
}
|
|
|
|
export function issueUsageOptions(issueId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.usage(issueId),
|
|
queryFn: () => api.getIssueUsage(issueId),
|
|
});
|
|
}
|
|
|
|
// Backs the description editor's fresh-sign download flow: NodeViews resolve
|
|
// an attachment id by matching the markdown URL against this list. The list
|
|
// is workspace-private metadata and lives on the same cache lifetime as the
|
|
// rest of the issue detail surface.
|
|
export function issueAttachmentsOptions(issueId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.attachments(issueId),
|
|
queryFn: () => api.listAttachments(issueId),
|
|
});
|
|
}
|