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, ): 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; }