import type { Torrent } from './shared'; import { useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router'; import { STATUS } from './shared'; // Filtering and sorting for the torrent list, with every knob in the query string. // // The nav panel writes these params and the table panel reads them; they never talk to each other. That is // the whole reason the state lives in the URL rather than in a channel — two sibling panels, one address, // and a filtered view you can bookmark or send to yourself. export const STATUS_FILTERS = [ { id: 'all', label: 'All' }, { id: 'active', label: 'Active' }, { id: 'downloading', label: 'Downloading' }, { id: 'seeding', label: 'Seeding' }, { id: 'paused', label: 'Paused' }, { id: 'finished', label: 'Finished' }, { id: 'checking', label: 'Verifying' }, { id: 'error', label: 'Error' }, ] as const; export type StatusFilterId = (typeof STATUS_FILTERS)[number]['id']; const isStatusFilter = (v: string | null): v is StatusFilterId => !!v && STATUS_FILTERS.some((f) => f.id === v); export type SortKey = | 'name' | 'queuePosition' | 'addedDate' | 'doneDate' | 'activityDate' | 'percentDone' | 'totalSize' | 'rateDownload' | 'rateUpload' | 'uploadRatio' | 'status' | 'eta' | 'downloadedEver' | 'uploadedEver' | 'peersSendingToUs' | 'peersGettingFromUs' | 'secondsSeeding' | 'downloadDir' | 'officerTrackerHost' | 'id'; const DEFAULT_SORT: SortKey = 'queuePosition'; /** Param names, in one place so the nav, the table header and the screen guard cannot drift. */ const P = { q: 'q', status: 'status', label: 'label', tracker: 'tracker', dir: 'dir', error: 'err', sort: 'sort', desc: 'desc', selected: 'selected', } as const; export type TorrentFilters = { q: string; status: StatusFilterId; label: string | null; tracker: string | null; downloadDir: string | null; errorOnly: boolean; sort: SortKey; desc: boolean; /** Any of the narrowing filters (not sort, not selection) is engaged. */ isFiltered: boolean; }; export function useTorrentFilters() { const [params, setParams] = useSearchParams(); const rawStatus = params.get(P.status); const filters = useMemo(() => { const q = params.get(P.q) ?? ''; const status = isStatusFilter(rawStatus) ? rawStatus : 'all'; const label = params.get(P.label); const tracker = params.get(P.tracker); const downloadDir = params.get(P.dir); const errorOnly = params.get(P.error) === '1'; return { q, status, label, tracker, downloadDir, errorOnly, sort: (params.get(P.sort) as SortKey) || DEFAULT_SORT, desc: params.get(P.desc) === '1', isFiltered: !!q || status !== 'all' || !!label || !!tracker || !!downloadDir || errorOnly, }; // params is a fresh URLSearchParams object on every render, so depend on the values instead of the // object — otherwise every render produces a new filters object and re-sorts the whole list. }, [ params.get(P.q), rawStatus, params.get(P.label), params.get(P.tracker), params.get(P.dir), params.get(P.error), params.get(P.sort), params.get(P.desc), ]); /** * Writes go through the functional form of setSearchParams so concurrent updates (typing in the search * box while a filter menu closes) compose instead of clobbering each other. `replace` keeps the back * button useful: forty keystrokes should not be forty history entries. */ const patch = useCallback( (next: Partial>, replace = true) => { setParams( (prev) => { const out = new URLSearchParams(prev); for (const [key, value] of Object.entries(next)) { const param = P[key as keyof typeof P]; if (value === null || value === '') out.delete(param); else out.set(param, value); } return out; }, { replace }, ); }, [setParams], ); const setSearch = useCallback((q: string) => patch({ q: q || null }), [patch]); const setStatus = useCallback((s: StatusFilterId) => patch({ status: s === 'all' ? null : s }), [patch]); /** Menu entries toggle: clicking the one already applied clears it. Saves a separate "clear" affordance. */ const toggle = useCallback( (key: 'label' | 'tracker' | 'dir' | 'error', value: string, current: string | null) => patch({ [key]: current === value ? null : value }), [patch], ); const clearFilters = useCallback( () => patch({ q: null, status: null, label: null, tracker: null, dir: null, error: null }, false), [patch], ); /** * Sorting toggles direction when you pick the column that is already sorted. New columns start ascending, * except the ones where "most" is the interesting end — nobody opens a torrent list to see the slowest. */ const setSort = useCallback( (key: SortKey, currentSort: SortKey, currentDesc: boolean) => { if (key === currentSort) return patch({ sort: key, desc: currentDesc ? null : '1' }); return patch({ sort: key, desc: DESC_BY_DEFAULT.has(key) ? '1' : null }); }, [patch], ); const selectedId = Number(params.get(P.selected)); const selected = Number.isFinite(selectedId) && selectedId > 0 ? selectedId : null; const setSelected = useCallback((id: number | null) => patch({ selected: id ? String(id) : null }), [patch]); return { filters, setSearch, setStatus, toggle, clearFilters, setSort, selected, setSelected }; } const DESC_BY_DEFAULT = new Set([ 'addedDate', 'doneDate', 'activityDate', 'rateDownload', 'rateUpload', 'uploadRatio', 'totalSize', 'downloadedEver', 'uploadedEver', 'peersSendingToUs', 'peersGettingFromUs', 'secondsSeeding', 'percentDone', ]); // ── Predicates ──────────────────────────────────────────────────────────────────────────────────── function matchesStatus(t: Torrent, filter: StatusFilterId): boolean { switch (filter) { case 'all': return true; // "Active" means moving bytes right now, which is not the same as "not stopped" — a seeding torrent // with no peers is running but idle, and lumping it in here makes the filter useless. case 'active': return t.rateDownload > 0 || t.rateUpload > 0; case 'downloading': return t.status === STATUS.Download || t.status === STATUS.DownloadWait; case 'seeding': return t.status === STATUS.Seed || t.status === STATUS.SeedWait; case 'paused': return t.status === STATUS.Stopped; case 'finished': return t.percentDone >= 1; case 'checking': return t.status === STATUS.Check || t.status === STATUS.CheckWait; case 'error': return t.error !== 0; } } const compare = (a: Torrent, b: Torrent, key: SortKey): number => { const av = a[key as keyof Torrent]; const bv = b[key as keyof Torrent]; if (typeof av === 'string' || typeof bv === 'string') { return String(av ?? '').localeCompare(String(bv ?? ''), undefined, { numeric: true, sensitivity: 'base' }); } return Number(av ?? 0) - Number(bv ?? 0); }; /** * Applies the filters and the sort. Pure, and memoised by the caller — 43 torrents is nothing but the list * re-renders every 5 seconds forever, and this also runs on every keystroke in the search box. */ export function selectTorrents(torrents: Torrent[], filters: TorrentFilters): Torrent[] { const needle = filters.q.trim().toLowerCase(); const out = torrents.filter((t) => { if (!matchesStatus(t, filters.status)) return false; if (filters.errorOnly && t.error === 0) return false; if (filters.label && !t.labels.includes(filters.label)) return false; if (filters.tracker && t.officerTrackerHost !== filters.tracker) return false; if (filters.downloadDir && t.downloadDir !== filters.downloadDir) return false; if (needle && !t.name.toLowerCase().includes(needle) && !t.labels.some((l) => l.toLowerCase().includes(needle))) return false; return true; }); out.sort((a, b) => { const primary = compare(a, b, filters.sort); // Ties on a coarse key (status, priority, a rate of zero) would otherwise reshuffle on every poll, // because Array.sort is only stable with respect to the input order and the input is a fresh fetch. return (filters.desc ? -primary : primary) || a.id - b.id; }); return out; } /** The distinct values the nav offers as filters, each with how many torrents carry it. */ export type FacetEntry = { value: string; count: number }; export function buildFacets(torrents: Torrent[]) { const labels = new Map(); const trackers = new Map(); const dirs = new Map(); let errors = 0; for (const t of torrents) { for (const label of t.labels) labels.set(label, (labels.get(label) ?? 0) + 1); if (t.officerTrackerHost) trackers.set(t.officerTrackerHost, (trackers.get(t.officerTrackerHost) ?? 0) + 1); if (t.downloadDir) dirs.set(t.downloadDir, (dirs.get(t.downloadDir) ?? 0) + 1); if (t.error !== 0) errors += 1; } const toSorted = (m: Map): FacetEntry[] => [...m.entries()] .map(([value, count]) => ({ value, count })) .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)); return { labels: toSorted(labels), trackers: toSorted(trackers), dirs: toSorted(dirs), errors }; } /** Per-status counts for the nav badges, computed in one pass rather than eight filters. */ export function countByStatus(torrents: Torrent[]): Record { const counts = Object.fromEntries(STATUS_FILTERS.map((f) => [f.id, 0])) as Record; for (const t of torrents) { for (const f of STATUS_FILTERS) if (matchesStatus(t, f.id)) counts[f.id] += 1; } return counts; }