Files
ersatztv/web/src/screens/MultiCollectionsScreen.tsx
T
timothyandClaude Opus 5 94182cdd53 fix(644): split loadAllPages by list class; bound media-library pickers to one page
Cold adversarial review of fe342a6a found the blanket loadAllPages-everywhere fix
dangerous for the three getLibraryBrowseItems pickers (RerunCollectionsScreen,
PlaylistsScreen, FillerPresetsScreen): paging Episode/Song/Image/Movie/MusicVideo
to completeness can mean ~200 serial requests against a 20k-row library, each more
expensive than the last, to populate a <select> with thousands of <option> nodes.

- Class A (bounded-by-construction lists: rerun collections, multi-collections,
  playlists) keep loadAllPages. Class B (media-library pickers) now fetch ONE
  bounded page and surface truncation via a `Showing the first N of M` hint wired
  to the real totalCount, instead of paging to completeness or truncating silently.
- loadAllPages: reports `{ items, complete }` instead of just `T[]` so a caller
  can no longer mistake a defensive empty-page break for a full list (F4); accepts
  an optional AbortSignal so a superseded loop stops issuing further page requests
  (F2); baseParams is now required via a conditional rest-tuple whenever the
  loader's params type has a field beyond pageNum/pageSize (F6); pushes into the
  accumulator instead of re-spreading it every page (F7).
- MultiCollectionsScreen/RerunCollectionsScreen/SchedulesScreen: add a seqRef +
  AbortController guard around the list/bootstrap loads so a stale loadAllPages
  loop can't resolve after a newer one and resurrect deleted rows (F3); log and
  surface an incomplete load rather than rendering it as whole.
- docs/spa-conventions.md §3b rewritten for the Class A / Class B split; new
  decision record docs/decisions/records/spa/list-completeness-vs-bounded-pickers.md
  (spa.list-completeness-vs-bounded-pickers), catalog regenerated.
- Tests: paging.test.ts covers null/undefined totalCount, null page, a
  short-but-non-empty page, a page-2 rejection, the complete:false flag, and
  cancellation (asserting fetch call COUNT stays put after abort), plus a
  compile-time @ts-expect-error pinning the F6 typing fix. Screen-level tests
  pin a real second HTTP request for a >100-item Class A list
  (MultiCollectionsScreen) and exactly one /library/browse request plus the
  truncation hint for a Class B picker (RerunCollectionsScreen).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:04:55 +02:00

633 lines
22 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, Check, Layers, Plus, Trash2, TriangleAlert } from 'lucide-react';
import { Badge, Button, Card, ConfirmDialog, IconButton, Input, Select, Spinner, Switch } from '../components';
import { usePrimaryAction } from '../primaryAction';
import {
ApiError,
createMultiCollection,
deleteMultiCollection,
getCollections,
getMultiCollectionWithMeta,
getMultiCollections,
getSmartCollections,
loadAllPages,
messageFromMultiCollectionError,
updateMultiCollection,
type MediaCollection,
type MultiCollection,
type MultiCollectionItemRequest,
type SmartCollection
} from '../api';
/* ---------- data hook ---------- */
type ListState =
| { data: MultiCollection[]; error: null; incomplete: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
function useMultiCollectionsData() {
const [state, setState] = useState<ListState>({ data: null, error: null, status: 'loading' });
const activeRef = useRef(true);
// Monotonic request id (spa-conventions §3): `refresh()` is reachable repeatedly (delete/save),
// and now that a load is a multi-request `loadAllPages` loop, an older loop can resolve after a
// newer one — guarding on `activeRef` (still mounted) alone isn't enough (#644 follow-up F3).
const seqRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
abortRef.current?.abort();
};
}, []);
const load = useCallback(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const seq = (seqRef.current += 1);
loadAllPages(getMultiCollections, undefined, undefined, controller.signal)
.then(({ complete, items }) => {
if (activeRef.current && seqRef.current === seq) {
if (!complete) {
// #644 follow-up F4: a partial result is otherwise indistinguishable from a complete
// one. Surfaced via `incomplete` below; also logged so it shows up outside the UI.
console.warn('MultiCollectionsScreen: multi-collections list load did not complete; some items may be missing');
}
setState({ data: items, error: null, incomplete: !complete, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current && seqRef.current === seq) {
setState({ data: null, error: messageFromMultiCollectionError(error), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const refresh = useCallback(
(quiet = false) => {
if (!quiet) {
setState({ data: null, error: null, status: 'loading' });
}
load();
},
[load]
);
return { refresh, state };
}
/* ---------- editor ---------- */
// Per-source weight bounds. Must mirror the API's MultiCollectionItemWeight validator (1..1000) so a
// client-side value never provokes a raw 400 (docs/decisions.md 2026-07-17, #404).
const WEIGHT_MIN = 1;
const WEIGHT_MAX = 1000;
// Coerce the editor's free-text weight to a valid integer share. Empty / non-numeric / out-of-range
// all snap to the [1,1000] bound — 1 (the floor, = fair share) when the field is blank or invalid.
function clampWeight(raw: string): number {
const parsed = Math.trunc(Number(raw));
if (!Number.isFinite(parsed)) {
return WEIGHT_MIN;
}
return Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, parsed));
}
// One row in the editor: a manual collection or a smart collection, plus its schedule-as-group flag
// and per-source weight. `weight` is held as a string so the number field edits smoothly (transient
// empty / partial values are allowed); it is clamped to the API bounds on blur and again at save.
// Names are resolved from the fetched collection lists.
interface DraftItem {
id: number;
kind: 'manual' | 'smart';
name: string;
scheduleAsGroup: boolean;
weight: string;
}
function itemsFromMultiCollection(mc: MultiCollection): DraftItem[] {
return mc.items.map((item) =>
item.collectionId != null
? {
id: item.collectionId,
kind: 'manual',
name: item.name,
scheduleAsGroup: item.scheduleAsGroup,
// Round-trip the persisted weight — the update REPLACES the item list, so dropping it here
// would silently reset every weight to 1 on save (the API returns it for exactly this
// reason). Default 1 for rows created before weights existed (docs/decisions.md, #404).
weight: String(item.weight ?? WEIGHT_MIN)
}
: {
id: item.smartCollectionId ?? 0,
kind: 'smart',
name: item.name,
scheduleAsGroup: item.scheduleAsGroup,
weight: String(item.weight ?? WEIGHT_MIN)
}
);
}
function toItemRequest(item: DraftItem): MultiCollectionItemRequest {
return {
collectionId: item.kind === 'manual' ? item.id : null,
playbackOrder: 'Chronological',
scheduleAsGroup: item.scheduleAsGroup,
smartCollectionId: item.kind === 'smart' ? item.id : null,
weight: clampWeight(item.weight)
};
}
function MultiCollectionEditor({
initial,
onBack,
onSaved
}: {
initial: MultiCollection | null;
onBack: () => void;
onSaved: () => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [items, setItems] = useState<DraftItem[]>(initial ? itemsFromMultiCollection(initial) : []);
const [collections, setCollections] = useState<MediaCollection[]>([]);
const [smartCollections, setSmartCollections] = useState<SmartCollection[]>([]);
const [selectedCollection, setSelectedCollection] = useState('');
const [selectedSmart, setSelectedSmart] = useState('');
const [loadError, setLoadError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [conflictOpen, setConflictOpen] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const activeRef = useRef(true);
// Concurrency ETag (issue #253): captured from the single-record GET when editing an existing
// multi-collection, sent as If-Match on save. There's no rotation-on-save because the editor
// always returns to the list after a successful save (see onSaved below).
const etagRef = useRef<string | null>(null);
useEffect(() => {
activeRef.current = true;
Promise.all([
getCollections(),
getSmartCollections(),
initial ? getMultiCollectionWithMeta(initial.id) : Promise.resolve(null)
])
.then(([manual, smart, mcMeta]) => {
if (activeRef.current) {
setCollections(manual);
setSmartCollections(smart);
if (mcMeta) {
etagRef.current = mcMeta.etag;
setName(mcMeta.data.name ?? '');
setItems(itemsFromMultiCollection(mcMeta.data));
}
setLoadError(null);
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setLoadError(messageFromMultiCollectionError(error, 'Unable to load collections'));
}
});
return () => {
activeRef.current = false;
};
}, [initial, reloadKey]);
const hasManual = (id: number) => items.some((item) => item.kind === 'manual' && item.id === id);
const hasSmart = (id: number) => items.some((item) => item.kind === 'smart' && item.id === id);
const addCollection = () => {
const id = Number(selectedCollection);
if (!id || hasManual(id)) {
return;
}
const collection = collections.find((entry) => entry.id === id);
setItems((current) => [
...current,
{ id, kind: 'manual', name: collection?.name ?? `#${id}`, scheduleAsGroup: false, weight: String(WEIGHT_MIN) }
]);
setSelectedCollection('');
};
const addSmart = () => {
const id = Number(selectedSmart);
if (!id || hasSmart(id)) {
return;
}
const collection = smartCollections.find((entry) => entry.id === id);
setItems((current) => [
...current,
{ id, kind: 'smart', name: collection?.name ?? `#${id}`, scheduleAsGroup: false, weight: String(WEIGHT_MIN) }
]);
setSelectedSmart('');
};
const removeItem = (target: DraftItem) => {
setItems((current) => current.filter((item) => !(item.kind === target.kind && item.id === target.id)));
};
const setScheduleAsGroup = (target: DraftItem, next: boolean) => {
setItems((current) =>
current.map((item) => (item.kind === target.kind && item.id === target.id ? { ...item, scheduleAsGroup: next } : item))
);
};
// Free-text while typing (allow transient empty / partial); the value is clamped to the API bounds
// on blur (normalizeWeight) and again at save (clampWeight in toItemRequest).
const setWeight = (target: DraftItem, next: string) => {
setItems((current) =>
current.map((item) => (item.kind === target.kind && item.id === target.id ? { ...item, weight: next } : item))
);
};
const normalizeWeight = (target: DraftItem) => {
setItems((current) =>
current.map((item) =>
item.kind === target.kind && item.id === target.id ? { ...item, weight: String(clampWeight(item.weight)) } : item
)
);
};
// Fair share = every source at weight 1 (docs/decisions.md 2026-07-17: fair-share is not a separate
// mode, just WeightedShuffle with equal weights — so this resets, it does not write a different order).
const resetToFairShare = () => {
setItems((current) => current.map((item) => ({ ...item, weight: String(WEIGHT_MIN) })));
};
const trimmedName = name.trim();
const save = async () => {
if (trimmedName.length === 0 || saving) {
return;
}
setSaving(true);
setSaveError(null);
try {
const body = { items: items.map(toItemRequest), name: trimmedName };
if (initial) {
await updateMultiCollection(initial.id, body, etagRef.current);
} else {
await createMultiCollection(body);
}
onSaved();
} catch (error) {
if (error instanceof ApiError && error.status === 412) {
// Another edit landed since we loaded — force a reload rather than overwriting it (#253).
setConflictOpen(true);
} else {
setSaveError(messageFromMultiCollectionError(error, 'Unable to save multi-collection'));
}
} finally {
setSaving(false);
}
};
const reloadAfterConflict = () => {
setConflictOpen(false);
setSaveError(null);
setReloadKey((key) => key + 1);
};
const collectionOptions = [
{ label: 'Select a collection…', value: '' },
...collections
.filter((entry) => !hasManual(entry.id))
.map((entry) => ({ label: entry.name ?? `#${entry.id}`, value: String(entry.id) }))
];
const smartOptions = [
{ label: 'Select a smart collection…', value: '' },
...smartCollections
.filter((entry) => !hasSmart(entry.id))
.map((entry) => ({ label: entry.name ?? `#${entry.id}`, value: String(entry.id) }))
];
const sortedItems = [...items].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
// Percentages are a DISPLAY concern (the wire format is integer relative shares) — a 3:1 weight
// shows as 75% / 25% (#404). Computed from clamped weights so a transient out-of-range edit never
// skews the shares mid-type.
const totalWeight = items.reduce((sum, item) => sum + clampWeight(item.weight), 0);
const weightPercent = (item: DraftItem): number =>
totalWeight > 0 ? Math.round((clampWeight(item.weight) / totalWeight) * 100) : 0;
// Fair share = every source already at weight 1; nothing to reset in that state.
const alreadyFairShare = items.length > 0 && items.every((item) => clampWeight(item.weight) === WEIGHT_MIN);
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Button onClick={onBack} size="sm" startIcon={<ArrowLeft aria-hidden="true" size={14} />} variant="ghost">
All multi-collections
</Button>
<span className="ctv-collections-detail-title">{initial ? 'Edit multi-collection' : 'New multi-collection'}</span>
<span className="ctv-channels-spacer" />
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
<Button
disabled={trimmedName.length === 0 || saving}
loading={saving}
onClick={() => void save()}
size="sm"
startIcon={<Check aria-hidden="true" size={14} />}
>
{initial ? 'Save multi-collection' : 'Add multi-collection'}
</Button>
</div>
{loadError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{loadError}</span>
</div>
)}
<Card title="Multi-collection">
<Input
label="Name"
onChange={(event) => setName(event.target.value)}
placeholder="Multi-collection name"
value={name}
/>
<div style={{ alignItems: 'flex-end', display: 'flex', gap: 8, marginTop: 16 }}>
<Select
label="Collections"
onChange={(event) => setSelectedCollection(event.target.value)}
options={collectionOptions}
value={selectedCollection}
/>
<Button
disabled={selectedCollection === ''}
onClick={addCollection}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
variant="secondary"
>
Add Collection
</Button>
</div>
<div style={{ alignItems: 'flex-end', display: 'flex', gap: 8, marginTop: 12 }}>
<Select
label="Smart Collections"
onChange={(event) => setSelectedSmart(event.target.value)}
options={smartOptions}
value={selectedSmart}
/>
<Button
disabled={selectedSmart === ''}
onClick={addSmart}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
variant="secondary"
>
Add Smart Collection
</Button>
</div>
</Card>
<Card padded={false}>
{sortedItems.length === 0 ? (
<div className="ctv-collections-empty">No collections added yet.</div>
) : (
<>
{sortedItems.map((item, index) => (
<div
className="ctv-settings-flush-row"
key={`${item.kind}:${item.id}`}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<Layers aria-hidden="true" color="var(--ctv-accent)" size={15} />
<span className="ctv-settings-flush-row-main ctv-settings-flush-row-title">{item.name}</span>
<Badge tone="neutral">{item.kind === 'smart' ? 'Smart' : 'Manual'}</Badge>
<div
className="ctv-collections-weight"
title="Relative share of airtime under the Weighted Shuffle playback order"
>
<span className="ctv-collections-weight-label" aria-hidden="true">
Weight
</span>
<Input
ariaLabel={`Weight for ${item.name}`}
fullWidth={false}
inputMode="numeric"
max={WEIGHT_MAX}
min={WEIGHT_MIN}
onBlur={() => normalizeWeight(item)}
onChange={(event) => setWeight(item, event.target.value)}
size="sm"
style={{ width: 68 }}
type="number"
value={item.weight}
/>
<span className="ctv-collections-weight-pct">{weightPercent(item)}%</span>
</div>
<label className="ctv-collections-order-toggle" title="Schedule as group">
<Switch
checked={item.scheduleAsGroup}
onChange={(next) => setScheduleAsGroup(item, next)}
size="sm"
/>
<span>Schedule as group</span>
</label>
<IconButton
onClick={() => removeItem(item)}
size="sm"
title={`Remove ${item.name}`}
variant="ghost"
>
<Trash2 aria-hidden="true" size={14} />
</IconButton>
</div>
))}
<div className="ctv-settings-flush-footer ctv-collections-weight-footer">
<span className="ctv-collections-weight-hint">
Weights set each source&rsquo;s share of airtime under the <strong>Weighted Shuffle</strong> playback
order (set on the schedule item). Equal weights = fair share.
</span>
<span className="ctv-channels-spacer" />
<Button
disabled={alreadyFairShare}
onClick={resetToFairShare}
size="sm"
variant="ghost"
>
Reset to fair share
</Button>
</div>
</>
)}
</Card>
<ConfirmDialog
cancelLabel="Keep editing"
confirmLabel="Reload"
message="This was changed elsewhere since you opened it. Reload to get the latest — your unsaved changes will be discarded."
onCancel={() => setConflictOpen(false)}
onConfirm={reloadAfterConflict}
open={conflictOpen}
title="Multi-collection changed elsewhere"
tone="danger"
/>
</div>
);
}
/* ---------- screen ---------- */
export function MultiCollectionsScreen() {
const { refresh, state } = useMultiCollectionsData();
const [editing, setEditing] = useState<{ kind: 'new' } | { kind: 'edit'; item: MultiCollection } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<MultiCollection | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
// TopBar "Add Multi-Collection" primary action.
usePrimaryAction('multiCollections', () => setEditing({ kind: 'new' }));
if (editing) {
return (
<MultiCollectionEditor
initial={editing.kind === 'edit' ? editing.item : null}
onBack={() => setEditing(null)}
onSaved={() => {
setEditing(null);
refresh(true);
}}
/>
);
}
if (state.status === 'loading') {
return (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading multi-collections</span>
</div>
);
}
if (state.status === 'error') {
return (
<div className="ctv-collections">
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{state.error}</span>
<span className="ctv-channels-spacer" />
<Button onClick={() => refresh()} size="sm" variant="secondary">
Retry
</Button>
</div>
</div>
);
}
const multiCollections = [...state.data].sort((a, b) =>
(a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' })
);
const confirmDelete = async () => {
if (!deleteTarget) {
return;
}
setDeleteBusy(true);
setDeleteError(null);
try {
await deleteMultiCollection(deleteTarget.id);
setDeleteTarget(null);
refresh(true);
} catch (error) {
setDeleteError(messageFromMultiCollectionError(error, 'Unable to delete multi-collection'));
} finally {
setDeleteBusy(false);
}
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Badge tone="neutral">{multiCollections.length} multi-collections</Badge>
{state.incomplete && (
<Badge tone="warn">List may be incomplete retry to reload</Badge>
)}
<span className="ctv-channels-spacer" />
<Button onClick={() => setEditing({ kind: 'new' })} size="sm" startIcon={<Plus aria-hidden="true" size={14} />}>
New multi-collection
</Button>
</div>
<Card padded={false}>
{multiCollections.length === 0 ? (
<div className="ctv-collections-empty">No multi-collections yet.</div>
) : (
multiCollections.map((collection, index) => (
<div
className="ctv-settings-flush-row"
key={collection.id}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<Layers aria-hidden="true" color="var(--ctv-accent)" size={15} />
<button
className="ctv-settings-flush-row-main ctv-settings-flush-row-title ctv-collections-linkbtn"
onClick={() => setEditing({ item: collection, kind: 'edit' })}
type="button"
>
{collection.name}
</button>
<Badge tone="neutral">
{collection.items.length} item{collection.items.length === 1 ? '' : 's'}
</Badge>
<IconButton onClick={() => setDeleteTarget(collection)} size="sm" title="Delete" variant="ghost">
<Trash2 aria-hidden="true" size={14} />
</IconButton>
</div>
))
)}
</Card>
<ConfirmDialog
busy={deleteBusy}
confirmLabel="Delete"
message={
deleteTarget ? (
<>
<span>{`Delete "${deleteTarget.name ?? 'this multi-collection'}"? This cannot be undone.`}</span>
{deleteError && (
<span className="ctv-field-error" role="alert">
{deleteError}
</span>
)}
</>
) : (
''
)
}
onCancel={() => {
setDeleteTarget(null);
setDeleteError(null);
}}
onConfirm={() => void confirmDelete()}
open={deleteTarget !== null}
title="Delete multi-collection"
tone="danger"
/>
</div>
);
}