diff --git a/client_v2/src/lib/api/query.test.ts b/client_v2/src/lib/api/query.test.ts index 925a397d..fc5b0382 100644 --- a/client_v2/src/lib/api/query.test.ts +++ b/client_v2/src/lib/api/query.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildCatalogQuery, buildGroupQuery, buildVendorTotalsQuery } from './query'; +import { allPages, buildCatalogQuery, buildGroupQuery, buildVendorTotalsQuery } from './query'; import type { LibraryState } from '$lib/library/params'; // A filament with no spools is the one you need to re-order, and grouping by @@ -171,3 +171,35 @@ describe('buildCatalogQuery', () => { expect(q.filters).toEqual({ material: ['PLA'], vendorId: ['1', '4'] }); }); }); + +// The dashboard and the filament inspector used to stop at their first page +// (1000 groups, 100 spools) and show the rest as if it didn't exist. +describe('allPages', () => { + const rows = Array.from({ length: 25 }, (_, i) => i); + const pager = (size: number) => { + const offsets: number[] = []; + const fetchPage = async (offset: number) => { + offsets.push(offset); + return { items: rows.slice(offset, offset + size), total: rows.length }; + }; + return { offsets, fetchPage }; + }; + + it('keeps fetching until it has every row', async () => { + const { offsets, fetchPage } = pager(10); + expect(await allPages(10, fetchPage)).toEqual(rows); + expect(offsets).toEqual([0, 10, 20]); + }); + + it('stops after one request when the first page holds everything', async () => { + const { offsets, fetchPage } = pager(100); + expect(await allPages(100, fetchPage)).toEqual(rows); + expect(offsets).toEqual([0]); + }); + + it('stops on an exact multiple without asking for an empty page', async () => { + const { offsets, fetchPage } = pager(5); + expect(await allPages(5, fetchPage)).toHaveLength(25); + expect(offsets).toEqual([0, 5, 10, 15, 20]); + }); +}); diff --git a/client_v2/src/lib/api/query.ts b/client_v2/src/lib/api/query.ts index 56e401a6..20deb24e 100644 --- a/client_v2/src/lib/api/query.ts +++ b/client_v2/src/lib/api/query.ts @@ -1,4 +1,4 @@ -import type { GroupField, GroupQuery, GroupSummary, SortField, SpoolQuery } from './types'; +import type { GroupField, GroupQuery, GroupSummary, Page, SortField, SpoolQuery } from './types'; import { isSpoolScopedFilter, type CatalogView, type LibraryState } from '$lib/library/params'; import { resolveSortField, resolveGroupSortField, catalogSortField } from '$lib/utils/library'; import { settings } from '$lib/stores/settings.svelte'; @@ -170,3 +170,20 @@ export function buildFlatSpoolQuery(state: LibraryState, signal?: AbortSignal): signal }; } + +/** + * Every row of a paged list, fetched `size` at a time: for views that need the + * whole set (every dashboard card, every spool of one filament) without asking + * the server for all of it in one response. + */ +export async function allPages( + size: number, + fetchPage: (offset: number) => Promise> +): Promise { + const all: T[] = []; + for (;;) { + const page = await fetchPage(all.length); + all.push(...page.items); + if (page.items.length < size || all.length >= page.total) return all; + } +} diff --git a/client_v2/src/lib/components/library/FilamentInspector.svelte b/client_v2/src/lib/components/library/FilamentInspector.svelte index c25d7c15..3d207636 100644 --- a/client_v2/src/lib/components/library/FilamentInspector.svelte +++ b/client_v2/src/lib/components/library/FilamentInspector.svelte @@ -34,6 +34,7 @@ import { pct, weightAuto } from '$lib/utils/format'; import { usageLabel } from '$lib/utils/library'; import { spoolSource } from '$lib/api/spoolSource'; + import { allPages } from '$lib/api/query'; import { isAbortError } from '$lib/api/http'; import { classifyDeleteFailure, planFilamentDelete } from '$lib/library/deletion'; import { toasts } from '$lib/stores/toasts.svelte'; @@ -80,6 +81,7 @@ // fetch's own cache upsert (which replaces the `filament` prop object) doesn't // re-trigger this effect in an infinite loop. let filamentId = $derived(filament.id); + const SPOOL_PAGE = 100; let spools = $state([]); // Bumped by live spool events so adding/removing/editing a spool refetches // this filament's list (the fetch below is server-ordered, not read from the @@ -96,24 +98,33 @@ const id = filamentId; const archived = showArchived; void revision; // refetch on live spool events - let cancelled = false; - spoolSource - .listSpools({ + const ctrl = new AbortController(); + // Every spool, not the first page: the count and total above are read off + // this list. That renders all of them; a filament with thousands of spools + // would want a "show more" here and the group endpoint's totals for the header. + allPages(SPOOL_PAGE, (offset) => + spoolSource.listSpools({ filters: {}, - sort: [{ field: 'last_used', dir: 'desc' }], + sort: [ + { field: 'last_used', dir: 'desc' }, + { field: 'id', dir: 'asc' } + ], groupScope: { field: 'filament', key: id }, allowArchived: archived, - limit: 100, - offset: 0, - lowThreshold: settings.lowThreshold + limit: SPOOL_PAGE, + offset, + lowThreshold: settings.lowThreshold, + signal: ctrl.signal }) - .then((page) => { - if (!cancelled) spools = page.items; + ) + .then((items) => { + // A spool used mid-load moves up the sort and can turn up on two pages. + if (!ctrl.signal.aborted) spools = [...new Map(items.map((s) => [s.id, s])).values()]; }) - .catch((e) => console.error('Failed to load filament spools', e)); - return () => { - cancelled = true; - }; + .catch((e) => { + if (!isAbortError(e, ctrl.signal)) console.error('Failed to load filament spools', e); + }); + return () => ctrl.abort(); }); const saver = makeSaver>((id, patch) => diff --git a/client_v2/src/routes/dashboard/+page.svelte b/client_v2/src/routes/dashboard/+page.svelte index 08d698c7..4439b9ec 100644 --- a/client_v2/src/routes/dashboard/+page.svelte +++ b/client_v2/src/routes/dashboard/+page.svelte @@ -8,6 +8,7 @@ import { settings } from '$lib/stores/settings.svelte'; import { fields } from '$lib/stores/fields.svelte'; import { spoolSource } from '$lib/api/spoolSource'; + import { allPages } from '$lib/api/query'; import { live, type LiveEvent } from '$lib/api/live'; import { isAbortError } from '$lib/api/http'; import { mapSpool } from '$lib/api/map'; @@ -36,6 +37,8 @@ const FLIP = 160; /** Spools fetched per request when filling (or extending) a card. */ const PAGE = 30; + /** Groups fetched per request when listing the cards. */ + const GROUP_PAGE = 1000; /** Distance from the bottom of a card's list at which the next page is fetched. */ const SCROLL_MARGIN = 120; /** The unassigned card's key: no value for the grouped-by field. */ @@ -170,20 +173,24 @@ // controller for a later view. const signal = pageAbort.signal; try { - const page = await spoolSource.listGroups({ - field: fieldKey, - filters: {}, - sort: [{ field: 'group.title', dir: 'asc' }], - limit: 1000, - offset: 0, - lowThreshold: settings.lowThreshold, - signal - }); + // Every group gets a card, so page through all of them rather than stopping at + // the first page; a group past it would never show up at all. + const groups = await allPages(GROUP_PAGE, (offset) => + spoolSource.listGroups({ + field: fieldKey, + filters: {}, + sort: [{ field: 'group.title', dir: 'asc' }], + limit: GROUP_PAGE, + offset, + lowThreshold: settings.lowThreshold, + signal + }) + ); // A NULL value and an empty-string one are distinct rows to the database but the // same "unassigned" card here, so counts are summed by key. // eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state const totals = new Map(); - for (const g of page.items) totals.set(g.key, (totals.get(g.key) ?? 0) + g.spoolCount); + for (const g of groups) totals.set(g.key, (totals.get(g.key) ?? 0) + g.spoolCount); for (const [key, total] of totals) { const b = bucket(key);