@asteby/metacore-runtime-react 23.4.0 → 23.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 23.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - beb7af1: DynamicKanban: los contadores de cada columna muestran el total real de la etapa desde el primer render, sin necesidad de scrollear. Tras la carga inicial del tablero se dispara en paralelo una consulta liviana (`per_page=1`) por etapa, respetando la búsqueda y los filtros activos, para leer su total. El header ahora se lee `N` cuando ya están cargadas todas las tarjetas (antes `N/N`) y `N/M` cuando la etapa está parcialmente cargada. Los totales se refrescan al cambiar filtros/búsqueda y mantienen el ajuste optimista tras arrastrar una tarjeta.
8
+
3
9
  ## 23.4.0
4
10
 
5
11
  ### Minor Changes
@@ -42,6 +42,16 @@ export declare function applyOptimisticMove(grouped: Map<string, any[]>, cardId:
42
42
  export declare function applyLaneTotalsOnMove<T extends {
43
43
  total: number | null;
44
44
  }>(pagination: Record<string, T>, fromStage: string, toStage: string): Record<string, T>;
45
+ /**
46
+ * Formats a lane header's count badge. Three cases:
47
+ * - A lane filter/search is active → `shown/loaded` (the client-side narrowed
48
+ * count over what this lane has loaded).
49
+ * - Otherwise, when the stage's server total is known → `shown` alone once
50
+ * everything is loaded (`shown >= total`), else `shown/total` (partial).
51
+ * - Total still unknown → just `shown`.
52
+ * Pure — exported for unit tests.
53
+ */
54
+ export declare function formatLaneCount(shown: number, loaded: number, serverTotal: number | null, laneActive: boolean): string;
45
55
  /**
46
56
  * Picks the columns shown on a card: a `title` column (first searchable column,
47
57
  * else first text-ish column) and up to `maxFields` secondary columns. Excludes
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
@@ -132,6 +132,23 @@ export function applyLaneTotalsOnMove(pagination, fromStage, toStage) {
132
132
  bump(toStage, +1);
133
133
  return next;
134
134
  }
135
+ /**
136
+ * Formats a lane header's count badge. Three cases:
137
+ * - A lane filter/search is active → `shown/loaded` (the client-side narrowed
138
+ * count over what this lane has loaded).
139
+ * - Otherwise, when the stage's server total is known → `shown` alone once
140
+ * everything is loaded (`shown >= total`), else `shown/total` (partial).
141
+ * - Total still unknown → just `shown`.
142
+ * Pure — exported for unit tests.
143
+ */
144
+ export function formatLaneCount(shown, loaded, serverTotal, laneActive) {
145
+ if (laneActive)
146
+ return `${shown}/${loaded}`;
147
+ if (serverTotal != null) {
148
+ return shown >= serverTotal ? String(serverTotal) : `${shown}/${serverTotal}`;
149
+ }
150
+ return String(shown);
151
+ }
135
152
  /**
136
153
  * Picks the columns shown on a card: a `title` column (first searchable column,
137
154
  * else first text-ish column) and up to `maxFields` secondary columns. Excludes
@@ -233,6 +250,10 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
233
250
  const [lanePagination, setLanePagination] = useState({});
234
251
  // Active drag card id (for the DragOverlay + drop-zone highlighting).
235
252
  const [activeId, setActiveId] = useState(null);
253
+ // Monotonic token for the current board load. Bumped on every fetchData so a
254
+ // slow eager-totals response from a superseded filter set can't inject stale
255
+ // stage totals into the fresh board.
256
+ const fetchGenRef = useRef(0);
236
257
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
237
258
  // ---- metadata fetch (same path as DynamicTable) ----
238
259
  useEffect(() => {
@@ -280,6 +301,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
280
301
  const fetchData = useCallback(async () => {
281
302
  if (!metadata)
282
303
  return;
304
+ const gen = ++fetchGenRef.current;
283
305
  setLoadingData(true);
284
306
  try {
285
307
  const res = (await api.get(endpoint || `/data/${model}`, {
@@ -295,6 +317,50 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
295
317
  setLoadingData(false);
296
318
  }
297
319
  setLanePagination({});
320
+ // Eager per-lane totals: so every lane header shows the REAL stage count
321
+ // on first render (not just what the global page happened to load), fire
322
+ // one lightweight `per_page=1` request per declared stage — in parallel,
323
+ // scoped by the SAME active filters/search (`f_<group_by>=<stage>` on top
324
+ // of filterParams). We read only `meta.total`; the row itself is ignored
325
+ // (the lane loads its real cards via loadMoreLane on scroll). The
326
+ // "unassigned" lane can't be stage-scoped, so it keeps its loaded count.
327
+ const gb = metadata.group_by;
328
+ const declaredStages = deriveStages(metadata);
329
+ if (gb && declaredStages.length > 0) {
330
+ void Promise.all(declaredStages.map(async (stage) => {
331
+ try {
332
+ const r = (await api.get(endpoint || `/data/${model}`, {
333
+ params: {
334
+ ...filterParams,
335
+ page: 1,
336
+ per_page: 1,
337
+ [`f_${gb}`]: stage.key,
338
+ },
339
+ }));
340
+ const total = r.data.meta?.total ?? r.data.meta?.count ?? null;
341
+ if (total == null || gen !== fetchGenRef.current)
342
+ return;
343
+ setLanePagination((p) => {
344
+ const existing = p[stage.key];
345
+ // A real page fetch already learned this lane's total.
346
+ if (existing?.total != null)
347
+ return p;
348
+ return {
349
+ ...p,
350
+ [stage.key]: {
351
+ nextPage: existing?.nextPage ?? 1,
352
+ total,
353
+ loading: existing?.loading ?? false,
354
+ done: existing?.done ?? total === 0,
355
+ },
356
+ };
357
+ });
358
+ }
359
+ catch (err) {
360
+ // A failed total just leaves the header on the loaded count.
361
+ }
362
+ }));
363
+ }
298
364
  }, [api, endpoint, model, metadata, pageSize, filterParams]);
299
365
  // Load the next page for ONE lane/stage and append it (deduped by id) into
300
366
  // the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
@@ -648,11 +714,7 @@ function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMor
648
714
  opacity: dimmed ? 0.45 : 1,
649
715
  outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
650
716
  outlineOffset: 2,
651
- }, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive
652
- ? `${count}/${totalCount}`
653
- : serverTotal != null
654
- ? `${count}/${serverTotal}`
655
- : count })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
717
+ }, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: formatLaneCount(count, totalCount, serverTotal, laneActive) })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
656
718
  defaultValue: 'Buscar en la columna',
657
719
  }), children: [_jsx(Search, { className: "h-3.5 w-3.5" }), queryActive && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" }))] }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange })] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
658
720
  if (e.key === 'Escape') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "23.4.0",
3
+ "version": "23.5.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -131,6 +131,15 @@ function stageCalls(api: ApiClient, stage: string) {
131
131
  (c: any[]) => c[1]?.params?.f_stage === stage,
132
132
  )
133
133
  }
134
+ // A lane's real page-load (per_page 25), as opposed to the eager per_page=1
135
+ // total probe fired on the initial board load.
136
+ function stagePageCalls(api: ApiClient, stage: string) {
137
+ return stageCalls(api, stage).filter((c: any[]) => c[1]?.params?.per_page === 25)
138
+ }
139
+ // The eager total probe (per_page 1) for a stage.
140
+ function stageTotalCalls(api: ApiClient, stage: string) {
141
+ return stageCalls(api, stage).filter((c: any[]) => c[1]?.params?.per_page === 1)
142
+ }
134
143
  function globalDataCalls(api: ApiClient) {
135
144
  return (api.get as any).mock.calls.filter(
136
145
  (c: any[]) =>
@@ -171,15 +180,20 @@ describe('DynamicKanban per-lane infinite scroll', () => {
171
180
  )
172
181
  // Initial board page painted its single backlog card.
173
182
  expect(await screen.findByText('Backlog One')).toBeTruthy()
174
- expect(stageCalls(api, 'backlog')).toHaveLength(0)
183
+ // No real page-load fired yet — only the eager per_page=1 total probe.
184
+ expect(stagePageCalls(api, 'backlog')).toHaveLength(0)
185
+ // The eager total lands, so the header shows the real stage count (1/5)
186
+ // BEFORE the user scrolls.
187
+ await waitFor(() => expect(stageTotalCalls(api, 'backlog').length).toBeGreaterThan(0))
188
+ expect(await screen.findByText('1/5')).toBeTruthy()
175
189
 
176
190
  // Sentinels enter view → each declared lane tops up its OWN stage.
177
191
  expect(observers.length).toBeGreaterThan(0)
178
192
  observers.forEach((o) => o.fire(true))
179
193
 
180
194
  // The backlog top-up ran with the right scoped params...
181
- await waitFor(() => expect(stageCalls(api, 'backlog').length).toBeGreaterThan(0))
182
- const call = stageCalls(api, 'backlog')[0]
195
+ await waitFor(() => expect(stagePageCalls(api, 'backlog').length).toBeGreaterThan(0))
196
+ const call = stagePageCalls(api, 'backlog')[0]
183
197
  expect(call[1].params).toMatchObject({ f_stage: 'backlog', page: 1, per_page: 25 })
184
198
 
185
199
  // ...its rows were appended and the header shows count/serverTotal (3/5).
@@ -0,0 +1,161 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DynamicKanban eager lane totals (#521 follow-up): every lane header shows the
4
+ // REAL stage count on first render — before the user scrolls — by firing one
5
+ // lightweight `per_page=1` probe per declared stage after the initial board
6
+ // page. Covers:
7
+ // 1. formatLaneCount — the pure `N` vs `N/M` header formatter.
8
+ // 2. Totals visible without scrolling (partial `N/M` and complete `N`).
9
+ // 3. Totals refetch (scoped by the new filters) when the filters change.
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
12
+
13
+ vi.mock('@tanstack/react-router', () => ({
14
+ useNavigate: () => () => {},
15
+ }))
16
+ const I18N = { t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, i18n: { language: 'es' } }
17
+ vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
18
+
19
+ import { formatLaneCount, DynamicKanban } from '../dynamic-kanban'
20
+ import { ApiProvider, type ApiClient } from '../api-context'
21
+ import { useMetadataCache } from '../metadata-cache'
22
+ import type { TableMetadata } from '../types'
23
+
24
+ afterEach(cleanup)
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // 1. Pure formatter
28
+ // ---------------------------------------------------------------------------
29
+
30
+ describe('formatLaneCount', () => {
31
+ it('shows just the total when everything is loaded (shown === total)', () => {
32
+ expect(formatLaneCount(41, 41, 41, false)).toBe('41')
33
+ })
34
+ it('shows shown/total when the lane is partial', () => {
35
+ expect(formatLaneCount(22, 22, 41, false)).toBe('22/41')
36
+ })
37
+ it('shows just the loaded count when the total is unknown', () => {
38
+ expect(formatLaneCount(7, 7, null, false)).toBe('7')
39
+ })
40
+ it('shows shown/loaded when a lane filter is active', () => {
41
+ expect(formatLaneCount(3, 22, 41, true)).toBe('3/22')
42
+ })
43
+ it('collapses to the total once shown catches up (never N/N)', () => {
44
+ expect(formatLaneCount(5, 5, 5, false)).toBe('5')
45
+ })
46
+ })
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // 2 + 3. Component: eager totals on load and on filter change
50
+ // ---------------------------------------------------------------------------
51
+
52
+ const STAGES = [
53
+ { key: 'backlog', label: 'Backlog', color: 'slate', order: 0 },
54
+ { key: 'done', label: 'Done', color: 'green', order: 1 },
55
+ ]
56
+
57
+ function meta(over: Partial<TableMetadata> = {}): TableMetadata {
58
+ return {
59
+ title: 'Issues',
60
+ endpoint: '/data/issue',
61
+ view_type: 'kanban',
62
+ group_by: 'stage',
63
+ stages: STAGES,
64
+ columns: [
65
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
66
+ { key: 'stage', label: 'Stage', type: 'status', sortable: false, filterable: true, options: STAGES.map((s) => ({ value: s.key, label: s.label })) },
67
+ ],
68
+ actions: [],
69
+ perPageOptions: [50],
70
+ defaultPerPage: 50,
71
+ searchPlaceholder: 'Buscar...',
72
+ enableCRUDActions: false,
73
+ hasActions: false,
74
+ ...over,
75
+ }
76
+ }
77
+
78
+ // Initial global page: one backlog card + two done cards. The eager per_page=1
79
+ // probes report a backlog total of 5 (partial) and a done total of 2 (complete
80
+ // — the two done cards are already loaded).
81
+ function fakeApi(): ApiClient {
82
+ const ok = (data: unknown, m?: Record<string, unknown>) => ({
83
+ data: { success: true, data, ...(m ? { meta: m } : {}) },
84
+ })
85
+ return {
86
+ get: vi.fn(async (url: string, cfg?: any) => {
87
+ if (url.startsWith('/metadata/table/')) return ok(meta())
88
+ const stage = cfg?.params?.f_stage
89
+ if (stage === 'backlog') return ok([{ id: 1, title: 'Backlog One', stage: 'backlog' }], { total: 5 })
90
+ if (stage === 'done') return ok([{ id: 2, title: 'Done Two', stage: 'done' }], { total: 2 })
91
+ // initial global board page
92
+ return ok([
93
+ { id: 1, title: 'Backlog One', stage: 'backlog' },
94
+ { id: 2, title: 'Done Two', stage: 'done' },
95
+ { id: 3, title: 'Done Three', stage: 'done' },
96
+ ])
97
+ }),
98
+ post: vi.fn(async () => ok(null)),
99
+ put: vi.fn(async () => ok(null)),
100
+ delete: vi.fn(async () => ok(null)),
101
+ }
102
+ }
103
+
104
+ function totalProbeCalls(api: ApiClient) {
105
+ return (api.get as any).mock.calls.filter(
106
+ (c: any[]) => c[1]?.params?.f_stage !== undefined && c[1]?.params?.per_page === 1,
107
+ )
108
+ }
109
+
110
+ describe('DynamicKanban eager lane totals', () => {
111
+ beforeEach(() => {
112
+ // No IntersectionObserver: nothing scrolls, so any total shown is proof
113
+ // it arrived from the eager probe, not from a lane top-up.
114
+ ;(globalThis as any).IntersectionObserver = class {
115
+ observe() {}
116
+ disconnect() {}
117
+ }
118
+ })
119
+ afterEach(() => {
120
+ delete (globalThis as any).IntersectionObserver
121
+ })
122
+
123
+ it('shows the real stage total on first render without scrolling', async () => {
124
+ useMetadataCache.getState().setMetadata('issue', meta())
125
+ const api = fakeApi()
126
+ render(
127
+ <ApiProvider client={api}>
128
+ <DynamicKanban model="issue" />
129
+ </ApiProvider>,
130
+ )
131
+ await screen.findByText('Backlog One')
132
+ // Backlog: 1 loaded of 5 → partial. Done: 2 loaded of 2 → collapses to "2".
133
+ expect(await screen.findByText('1/5')).toBeTruthy()
134
+ expect(await screen.findByText('2')).toBeTruthy()
135
+ // One probe per declared stage.
136
+ await waitFor(() => expect(totalProbeCalls(api)).toHaveLength(2))
137
+ })
138
+
139
+ it('refetches the totals (scoped by the new filters) when the filters change', async () => {
140
+ useMetadataCache.getState().setMetadata('issue', meta())
141
+ const api = fakeApi()
142
+ const { rerender } = render(
143
+ <ApiProvider client={api}>
144
+ <DynamicKanban model="issue" />
145
+ </ApiProvider>,
146
+ )
147
+ await screen.findByText('1/5')
148
+ await waitFor(() => expect(totalProbeCalls(api)).toHaveLength(2))
149
+
150
+ rerender(
151
+ <ApiProvider client={api}>
152
+ <DynamicKanban model="issue" defaultFilters={{ priority: 'high' }} />
153
+ </ApiProvider>,
154
+ )
155
+
156
+ // The filter change re-fires the eager probes (now carrying the filter).
157
+ await waitFor(() => expect(totalProbeCalls(api).length).toBeGreaterThan(2))
158
+ const probe = totalProbeCalls(api).at(-1)!
159
+ expect(probe[1].params).toMatchObject({ f_priority: 'high', per_page: 1 })
160
+ })
161
+ })
@@ -238,6 +238,28 @@ export function applyLaneTotalsOnMove<T extends { total: number | null }>(
238
238
  return next
239
239
  }
240
240
 
241
+ /**
242
+ * Formats a lane header's count badge. Three cases:
243
+ * - A lane filter/search is active → `shown/loaded` (the client-side narrowed
244
+ * count over what this lane has loaded).
245
+ * - Otherwise, when the stage's server total is known → `shown` alone once
246
+ * everything is loaded (`shown >= total`), else `shown/total` (partial).
247
+ * - Total still unknown → just `shown`.
248
+ * Pure — exported for unit tests.
249
+ */
250
+ export function formatLaneCount(
251
+ shown: number,
252
+ loaded: number,
253
+ serverTotal: number | null,
254
+ laneActive: boolean,
255
+ ): string {
256
+ if (laneActive) return `${shown}/${loaded}`
257
+ if (serverTotal != null) {
258
+ return shown >= serverTotal ? String(serverTotal) : `${shown}/${serverTotal}`
259
+ }
260
+ return String(shown)
261
+ }
262
+
241
263
  /**
242
264
  * Picks the columns shown on a card: a `title` column (first searchable column,
243
265
  * else first text-ish column) and up to `maxFields` secondary columns. Excludes
@@ -450,6 +472,11 @@ export function DynamicKanban({
450
472
  // Active drag card id (for the DragOverlay + drop-zone highlighting).
451
473
  const [activeId, setActiveId] = useState<string | null>(null)
452
474
 
475
+ // Monotonic token for the current board load. Bumped on every fetchData so a
476
+ // slow eager-totals response from a superseded filter set can't inject stale
477
+ // stage totals into the fresh board.
478
+ const fetchGenRef = useRef(0)
479
+
453
480
  const sensors = useSensors(
454
481
  useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
455
482
  )
@@ -507,6 +534,7 @@ export function DynamicKanban({
507
534
  // change (fetchData's identity changes with filterParams).
508
535
  const fetchData = useCallback(async () => {
509
536
  if (!metadata) return
537
+ const gen = ++fetchGenRef.current
510
538
  setLoadingData(true)
511
539
  try {
512
540
  const res = (await api.get(endpoint || `/data/${model}`, {
@@ -519,6 +547,49 @@ export function DynamicKanban({
519
547
  setLoadingData(false)
520
548
  }
521
549
  setLanePagination({})
550
+ // Eager per-lane totals: so every lane header shows the REAL stage count
551
+ // on first render (not just what the global page happened to load), fire
552
+ // one lightweight `per_page=1` request per declared stage — in parallel,
553
+ // scoped by the SAME active filters/search (`f_<group_by>=<stage>` on top
554
+ // of filterParams). We read only `meta.total`; the row itself is ignored
555
+ // (the lane loads its real cards via loadMoreLane on scroll). The
556
+ // "unassigned" lane can't be stage-scoped, so it keeps its loaded count.
557
+ const gb = metadata.group_by
558
+ const declaredStages = deriveStages(metadata)
559
+ if (gb && declaredStages.length > 0) {
560
+ void Promise.all(
561
+ declaredStages.map(async (stage) => {
562
+ try {
563
+ const r = (await api.get(endpoint || `/data/${model}`, {
564
+ params: {
565
+ ...filterParams,
566
+ page: 1,
567
+ per_page: 1,
568
+ [`f_${gb}`]: stage.key,
569
+ },
570
+ })) as { data: ApiResponse<any[]> & { meta?: any } }
571
+ const total = r.data.meta?.total ?? r.data.meta?.count ?? null
572
+ if (total == null || gen !== fetchGenRef.current) return
573
+ setLanePagination((p) => {
574
+ const existing = p[stage.key]
575
+ // A real page fetch already learned this lane's total.
576
+ if (existing?.total != null) return p
577
+ return {
578
+ ...p,
579
+ [stage.key]: {
580
+ nextPage: existing?.nextPage ?? 1,
581
+ total,
582
+ loading: existing?.loading ?? false,
583
+ done: existing?.done ?? total === 0,
584
+ },
585
+ }
586
+ })
587
+ } catch (err) {
588
+ // A failed total just leaves the header on the loaded count.
589
+ }
590
+ }),
591
+ )
592
+ }
522
593
  }, [api, endpoint, model, metadata, pageSize, filterParams])
523
594
 
524
595
  // Load the next page for ONE lane/stage and append it (deduped by id) into
@@ -1257,11 +1328,7 @@ function KanbanLane({
1257
1328
  {t(stage.label, { defaultValue: stage.label })}
1258
1329
  </Badge>
1259
1330
  <span className="text-xs font-medium tabular-nums text-muted-foreground">
1260
- {laneActive
1261
- ? `${count}/${totalCount}`
1262
- : serverTotal != null
1263
- ? `${count}/${serverTotal}`
1264
- : count}
1331
+ {formatLaneCount(count, totalCount, serverTotal, laneActive)}
1265
1332
  </span>
1266
1333
  </div>
1267
1334
  {/* Lane actions — always visible in muted (a hidden hover-reveal