From 091ba06ccf8903ff118fce82d3eb72ad2b316f42 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 22 Apr 2026 17:01:41 -0700 Subject: [PATCH] Make bulk delete sortable and filterable by last-heard. Closes #218. --- .../settings/BulkDeleteContactsModal.tsx | 232 +++++++++++++++--- 1 file changed, 192 insertions(+), 40 deletions(-) diff --git a/frontend/src/components/settings/BulkDeleteContactsModal.tsx b/frontend/src/components/settings/BulkDeleteContactsModal.tsx index 8fd7a31..3c6f67d 100644 --- a/frontend/src/components/settings/BulkDeleteContactsModal.tsx +++ b/frontend/src/components/settings/BulkDeleteContactsModal.tsx @@ -15,6 +15,9 @@ const CONTACT_TYPE_LABELS: Record = { 4: 'Sensor', }; +type SortField = 'name' | 'type' | 'key' | 'first_seen' | 'last_seen'; +type SortDir = 'asc' | 'desc'; + function formatDate(ts: number): string { return new Date(ts * 1000).toLocaleDateString([], { year: 'numeric', @@ -32,6 +35,32 @@ function datetimeToUnix(datetimeStr: string): number { return Math.floor(d.getTime() / 1000); } +function SortableHeader({ + label, + field, + sortField, + sortDir, + onSort, + className, +}: { + label: string; + field: SortField; + sortField: SortField; + sortDir: SortDir; + onSort: (field: SortField) => void; + className?: string; +}) { + const active = sortField === field; + return ( + onSort(field)} + > + {label} {active ? (sortDir === 'asc' ? '▲' : '▼') : ''} + + ); +} + interface BulkDeleteContactsModalProps { open: boolean; onClose: () => void; @@ -49,22 +78,42 @@ export function BulkDeleteContactsModal({ const [selectedKeys, setSelectedKeys] = useState>(new Set()); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); + const [lastHeardAfter, setLastHeardAfter] = useState(''); + const [lastHeardBefore, setLastHeardBefore] = useState(''); const [typeFilter, setTypeFilter] = useState('all'); + const [sortField, setSortField] = useState('first_seen'); + const [sortDir, setSortDir] = useState('desc'); const [deleting, setDeleting] = useState(false); const lastClickedKeyRef = useRef(null); + const handleSort = useCallback( + (field: SortField) => { + if (sortField === field) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDir(field === 'name' || field === 'key' ? 'asc' : 'desc'); + } + }, + [sortField] + ); + const resetAndClose = useCallback(() => { setStep('select'); setSelectedKeys(new Set()); setStartDate(''); setEndDate(''); + setLastHeardAfter(''); + setLastHeardBefore(''); setTypeFilter('all'); + setSortField('first_seen'); + setSortDir('desc'); lastClickedKeyRef.current = null; onClose(); }, [onClose]); const filteredContacts = useMemo(() => { - let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0)); + let list = [...contacts]; if (typeFilter !== 'all') { list = list.filter((c) => c.type === typeFilter); } @@ -76,8 +125,44 @@ export function BulkDeleteContactsModal({ const end = datetimeToUnix(endDate); list = list.filter((c) => (c.first_seen ?? 0) <= end); } + if (lastHeardAfter) { + const after = datetimeToUnix(lastHeardAfter); + list = list.filter((c) => (c.last_seen ?? 0) >= after); + } + if (lastHeardBefore) { + const before = datetimeToUnix(lastHeardBefore); + list = list.filter((c) => (c.last_seen ?? 0) <= before); + } + + const dir = sortDir === 'asc' ? 1 : -1; + list.sort((a, b) => { + switch (sortField) { + case 'name': { + const an = getContactDisplayName(a.name, a.public_key, a.last_advert).toLowerCase(); + const bn = getContactDisplayName(b.name, b.public_key, b.last_advert).toLowerCase(); + return an < bn ? -dir : an > bn ? dir : 0; + } + case 'type': + return (a.type - b.type) * dir; + case 'key': + return a.public_key < b.public_key ? -dir : a.public_key > b.public_key ? dir : 0; + case 'first_seen': + return ((a.first_seen ?? 0) - (b.first_seen ?? 0)) * dir; + case 'last_seen': + return ((a.last_seen ?? 0) - (b.last_seen ?? 0)) * dir; + } + }); return list; - }, [contacts, typeFilter, startDate, endDate]); + }, [ + contacts, + typeFilter, + startDate, + endDate, + lastHeardAfter, + lastHeardBefore, + sortField, + sortDir, + ]); const handleToggle = (key: string, shiftKey: boolean) => { if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) { @@ -148,6 +233,8 @@ export function BulkDeleteContactsModal({ } }; + const hasFilters = startDate || endDate || lastHeardAfter || lastHeardBefore; + return ( !isOpen && resetAndClose()}> @@ -164,40 +251,64 @@ export function BulkDeleteContactsModal({ {step === 'select' && ( <> -
-
- - +
+
+
+ + +
-
- - setStartDate(e.target.value)} - className="w-48 h-8 text-sm" - /> +
+
+ + setStartDate(e.target.value)} + className="w-48 h-8 text-sm" + /> +
+
+ + setEndDate(e.target.value)} + className="w-48 h-8 text-sm" + /> +
-
- - setEndDate(e.target.value)} - className="w-48 h-8 text-sm" - /> +
+
+ + setLastHeardAfter(e.target.value)} + className="w-48 h-8 text-sm" + /> +
+
+ + setLastHeardBefore(e.target.value)} + className="w-48 h-8 text-sm" + /> +