mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 21:30:46 +02:00
* MUL-3903 refactor project issue surface state Co-authored-by: multica-agent <github@multica.ai> * Refactor project issue surface ownership Co-authored-by: multica-agent <github@multica.ai> * Extract shared issue surface entrypoints Co-authored-by: multica-agent <github@multica.ai> * Fix issue surface create defaults and selection reset Co-authored-by: multica-agent <github@multica.ai> * test(editor): add missing AbortSignal to suggestion items() calls The suggestion items() contract gained a required signal param; the mention/slash test call sites were never updated, breaking pnpm typecheck for @multica/views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(issues): server-side assignee_types filter on ListIssues ListGroupedIssues has taken assignee_types since squads shipped, but ListIssues never did — so the workspace Members/Agents tabs had to fetch the unfiltered workspace list and post-filter loaded pages client-side, which made column totals and load-more pagination reflect the unfiltered counts. Add the same parse + WHERE clause to ListIssues (count query shares the WHERE, so totals agree), thread the param through the TS client, and widen MyIssuesFilter so scoped list caches can carry it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(issues): route issue cache writes through a membership-aware coordinator useUpdateIssue, useBatchUpdateIssues, and the WS issue:updated handler each maintained their own similar-but-diverging patch/invalidate rules. Consolidate them into cache-coordinator.ts (applyIssueChange / rollbackIssueChange / invalidateIssueDerivatives) so local writes and remote echoes follow one rules table by construction. The coordinator is membership-aware via surface/membership.ts (true | false | unknown against each list cache's own filter contract): - a change that moves an issue off a filtered surface removes the card surgically (bucket total decremented) — fixes assignee changes leaving stale cards on My Assigned with no local safety net (previously only the WS echo recovered it), and replaces the blanket invalidate-myAll net for project moves (MUL-3669) with per-key precision - possible entry into a loaded list marks that key stale — never hard-insert; page/slot is server knowledge - stale keys flush on settle for mutations (a mid-flight refetch would stomp the optimistic state) and immediately for WS - batch updates now patch detail + inbox like single updates; the off-screen bucket-count recovery previously exclusive to the WS path now covers local mutations too Preserved invariants: synchronous optimistic patches (dnd-kit), MUL-3375 control-field stripping, and no refetch of surgically reconciled lists (the drag-flicker fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(issues): resolve surfaces via core query plan/repository with window-keyed remount Read-path convergence and the loading/empty semantics that fall out of it: - scope -> API params moves from scope.ts helpers into surface/query-plan.ts; workspace members/agents become server-filtered scoped plans (assignee_types) and the client postFilter machinery is deleted — tab counts and load-more are now exact - query selection moves behind surface/repository.ts; the views data hook no longer branches on workspace-vs-scoped plumbing - IssueSurfaceContent remounts on data-window change (wsId + scope): keepPreviousData placeholders keep sort/filter changes flicker-free within one window but must never let project A's (or workspace A's) cards impersonate B's with no loading state — cold window shows the skeleton, warm window hits cache instantly - isEmpty is only asserted from full-window data; the gantt scheduled-only projection can't prove the window is empty, so GanttView's own "no scheduled issues" empty state renders instead of the generic create-issue one - per-card project lookups hoist into a surface-level projectMap (drops a per-card useQuery), create-defaults typing tightens to IssueCreateDefaults Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(issues): count-only arithmetic for off-window status/membership changes An issue beyond a list's loaded page window used to force a full first-page refetch just to fix two column counts. When the change is CERTAIN (base entity known, membership definitive) the coordinator now does the arithmetic locally: - stayed a member + status changed: move one unit of total between the two buckets (loaded arrays untouched; hasMore stays consistent) - left the list (reassigned / re-projected): old status bucket total -1 - member-to-member reassignment: counts unaffected, not even a stale key Entering a list and any uncertainty (no base, unknown membership) still refetch — the right page/slot is server knowledge. Branches on membership OUTCOMES, not on which field changed, so future dimensions (team) join automatically. Biggest win is the WS path: agents flipping off-screen statuses no longer trigger refetch storms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(issues): deferred view-refresh indicator during placeholder revalidation Sort/date changes (and any grouped-board filter change) revalidate behind the previous snapshot — correct, but on a slow network the click felt dead: content stays put and isLoading never fires. Surface the state as isRefreshing (isPlaceholderData of the active query) and render a shared ViewRefreshIndicator in every issues header: a fixed-width slot (zero layout shift) whose spinner fades in after 300ms, so sub-second responses show nothing (NN/g) while slow ones get a working signal. Bound to the revalidation STATE, not to any particular control — any current or future server-side view change lights it automatically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
170 lines
5.4 KiB
TypeScript
170 lines
5.4 KiB
TypeScript
import type {
|
|
Issue,
|
|
IssueStatus,
|
|
IssueStatusBucket,
|
|
ListIssuesCache,
|
|
} from "../types";
|
|
import { PAGINATED_STATUSES } from "./queries";
|
|
|
|
const EMPTY_BUCKET: IssueStatusBucket = { issues: [], total: 0 };
|
|
|
|
export function getBucket(
|
|
resp: ListIssuesCache,
|
|
status: IssueStatus,
|
|
): IssueStatusBucket {
|
|
return resp.byStatus[status] ?? EMPTY_BUCKET;
|
|
}
|
|
|
|
export function setBucket(
|
|
resp: ListIssuesCache,
|
|
status: IssueStatus,
|
|
bucket: IssueStatusBucket,
|
|
): ListIssuesCache {
|
|
return { ...resp, byStatus: { ...resp.byStatus, [status]: bucket } };
|
|
}
|
|
|
|
/** Locate which status bucket holds `id`, if any. */
|
|
export function findIssueLocation(
|
|
resp: ListIssuesCache,
|
|
id: string,
|
|
): { status: IssueStatus; issue: Issue } | null {
|
|
for (const status of PAGINATED_STATUSES) {
|
|
const bucket = resp.byStatus[status];
|
|
const found = bucket?.issues.find((i) => i.id === id);
|
|
if (found) return { status, issue: found };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Add an issue to its status bucket (no-op if already present). */
|
|
export function addIssueToBuckets(
|
|
resp: ListIssuesCache,
|
|
issue: Issue,
|
|
): ListIssuesCache {
|
|
const bucket = getBucket(resp, issue.status);
|
|
if (bucket.issues.some((i) => i.id === issue.id)) return resp;
|
|
return setBucket(resp, issue.status, {
|
|
issues: [...bucket.issues, issue],
|
|
total: bucket.total + 1,
|
|
});
|
|
}
|
|
|
|
/** Remove an issue from whichever bucket contains it. */
|
|
export function removeIssueFromBuckets(
|
|
resp: ListIssuesCache,
|
|
id: string,
|
|
): ListIssuesCache {
|
|
const loc = findIssueLocation(resp, id);
|
|
if (!loc) return resp;
|
|
const bucket = getBucket(resp, loc.status);
|
|
return setBucket(resp, loc.status, {
|
|
issues: bucket.issues.filter((i) => i.id !== id),
|
|
total: Math.max(0, bucket.total - 1),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Count-only reconcile for an issue BEYOND the loaded window: its status
|
|
* changed server-side, so one unit of `total` moves between buckets while
|
|
* the loaded arrays stay untouched. `hasMore` (loaded < total) stays
|
|
* consistent for free.
|
|
*/
|
|
export function moveBucketTotal(
|
|
resp: ListIssuesCache,
|
|
from: IssueStatus,
|
|
to: IssueStatus,
|
|
): ListIssuesCache {
|
|
if (from === to) return resp;
|
|
const fromBucket = getBucket(resp, from);
|
|
const toBucket = getBucket(resp, to);
|
|
let next = setBucket(resp, from, {
|
|
...fromBucket,
|
|
total: Math.max(0, fromBucket.total - 1),
|
|
});
|
|
next = setBucket(next, to, { ...toBucket, total: toBucket.total + 1 });
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Count-only reconcile for an issue BEYOND the loaded window that LEFT the
|
|
* list (reassigned / moved project): the bucket it was counted in loses one.
|
|
*/
|
|
export function decrementBucketTotal(
|
|
resp: ListIssuesCache,
|
|
status: IssueStatus,
|
|
): ListIssuesCache {
|
|
const bucket = getBucket(resp, status);
|
|
return setBucket(resp, status, {
|
|
...bucket,
|
|
total: Math.max(0, bucket.total - 1),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Insert `issue` into `issues` at the slot implied by `position ASC` — the same
|
|
* ordering the board renders (server `ORDER BY position ASC`). Returns a new
|
|
* array; the input is not mutated.
|
|
*
|
|
* Inserting at the right slot (instead of appending to the end) is what keeps an
|
|
* optimistic move from snapping: the card lands where it will be after the
|
|
* server confirms, so no later cache refresh teleports it to the column tail.
|
|
*/
|
|
export function insertByPosition(issues: Issue[], issue: Issue): Issue[] {
|
|
const idx = issues.findIndex((i) => i.position > issue.position);
|
|
if (idx === -1) return [...issues, issue];
|
|
return [...issues.slice(0, idx), issue, ...issues.slice(idx)];
|
|
}
|
|
|
|
/**
|
|
* Merge `patch` into the issue with `id`. If `patch.status` differs from the
|
|
* current bucket, the issue moves to the new bucket and both buckets' totals
|
|
* are adjusted. The moved card — and a same-column card whose `position`
|
|
* changed — is re-inserted at its `position`-sorted slot rather than appended,
|
|
* so the cache order stays consistent with what the board renders. A plain
|
|
* field update (no status/position change) keeps the card in place.
|
|
*/
|
|
export function patchIssueInBuckets(
|
|
resp: ListIssuesCache,
|
|
id: string,
|
|
patch: Partial<Issue>,
|
|
): ListIssuesCache {
|
|
const loc = findIssueLocation(resp, id);
|
|
if (!loc) return resp;
|
|
const merged: Issue = { ...loc.issue, ...patch };
|
|
const nextStatus = patch.status ?? loc.status;
|
|
|
|
if (nextStatus === loc.status) {
|
|
const bucket = getBucket(resp, loc.status);
|
|
const positionChanged =
|
|
patch.position !== undefined && patch.position !== loc.issue.position;
|
|
if (!positionChanged) {
|
|
// Plain field update (labels, metadata, title, …): keep the slot so a
|
|
// remote edit never reorders an otherwise-untouched column.
|
|
return setBucket(resp, loc.status, {
|
|
...bucket,
|
|
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
|
|
});
|
|
}
|
|
// Same-column reorder: lift the card out and re-insert at its new slot.
|
|
return setBucket(resp, loc.status, {
|
|
...bucket,
|
|
issues: insertByPosition(
|
|
bucket.issues.filter((i) => i.id !== id),
|
|
merged,
|
|
),
|
|
});
|
|
}
|
|
|
|
const fromBucket = getBucket(resp, loc.status);
|
|
const toBucket = getBucket(resp, nextStatus);
|
|
let next = setBucket(resp, loc.status, {
|
|
issues: fromBucket.issues.filter((i) => i.id !== id),
|
|
total: Math.max(0, fromBucket.total - 1),
|
|
});
|
|
next = setBucket(next, nextStatus, {
|
|
issues: insertByPosition(toBucket.issues, merged),
|
|
total: toBucket.total + 1,
|
|
});
|
|
return next;
|
|
}
|