import { useState, useMemo } from 'react' import { ChevronUp, ChevronDown, Search, Filter } from 'lucide-react' import type { NodeInfo } from '@/lib/api' interface NodeTableProps { nodes: NodeInfo[] selectedNodeId: number | null onSelectNode: (nodeId: number) => void } type SortField = 'short_name' | 'role' | 'battery_level' | 'last_heard' | 'hardware' type SortDir = 'asc' | 'desc' type QuickFilter = 'all' | 'infra' | 'online' const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER'] function getStatusColor(lastHeard: string | null): string { if (!lastHeard) return 'bg-slate-500' const date = new Date(lastHeard) const now = new Date() const diffHours = (now.getTime() - date.getTime()) / 3600000 if (diffHours < 1) return 'bg-green-500' if (diffHours < 24) return 'bg-amber-500' return 'bg-slate-500' } function formatLastHeard(lastHeard: string | null): string { if (!lastHeard) return '—' const date = new Date(lastHeard) const now = new Date() const diffMs = now.getTime() - date.getTime() const diffMins = Math.floor(diffMs / 60000) const diffHours = Math.floor(diffMs / 3600000) const diffDays = Math.floor(diffMs / 86400000) if (diffMins < 1) return 'Just now' if (diffMins < 60) return `${diffMins}m ago` if (diffHours < 24) return `${diffHours}h ago` return `${diffDays}d ago` } function formatBattery(node: NodeInfo): string { if (node.battery_level === null) return '—' if (node.battery_level > 100 || (node.voltage && node.voltage > 4.1)) { return 'USB ⚡' } return `${node.battery_level.toFixed(0)}%` } function getRegionName(lat: number | null): string { if (lat === null) return '—' if (lat > 46) return 'Northern' if (lat > 44.5) return 'Central' if (lat > 43) return 'SW Idaho' return 'SC Idaho' } export default function NodeTable({ nodes, selectedNodeId, onSelectNode, }: NodeTableProps) { const [searchTerm, setSearchTerm] = useState('') const [sortField, setSortField] = useState('short_name') const [sortDir, setSortDir] = useState('asc') const [quickFilter, setQuickFilter] = useState('all') // Filter and sort nodes const filteredNodes = useMemo(() => { let result = [...nodes] // Quick filter if (quickFilter === 'infra') { result = result.filter((n) => INFRA_ROLES.includes(n.role)) } else if (quickFilter === 'online') { result = result.filter((n) => { if (!n.last_heard) return false const date = new Date(n.last_heard) const now = new Date() const diffHours = (now.getTime() - date.getTime()) / 3600000 return diffHours < 1 }) } // Search filter if (searchTerm) { const term = searchTerm.toLowerCase() result = result.filter((n) => n.short_name.toLowerCase().includes(term) || n.long_name.toLowerCase().includes(term) || n.role.toLowerCase().includes(term) || getRegionName(n.latitude).toLowerCase().includes(term) ) } // Sort result.sort((a, b) => { let aVal: string | number = '' let bVal: string | number = '' switch (sortField) { case 'short_name': aVal = a.short_name.toLowerCase() bVal = b.short_name.toLowerCase() break case 'role': aVal = a.role bVal = b.role break case 'battery_level': aVal = a.battery_level ?? -1 bVal = b.battery_level ?? -1 break case 'last_heard': aVal = a.last_heard ? new Date(a.last_heard).getTime() : 0 bVal = b.last_heard ? new Date(b.last_heard).getTime() : 0 break case 'hardware': aVal = a.hardware.toLowerCase() bVal = b.hardware.toLowerCase() break } if (aVal < bVal) return sortDir === 'asc' ? -1 : 1 if (aVal > bVal) return sortDir === 'asc' ? 1 : -1 return 0 }) return result }, [nodes, searchTerm, sortField, sortDir, quickFilter]) const handleSort = (field: SortField) => { if (sortField === field) { setSortDir(sortDir === 'asc' ? 'desc' : 'asc') } else { setSortField(field) setSortDir('asc') } } const SortIcon = ({ field }: { field: SortField }) => { if (sortField !== field) return null return sortDir === 'asc' ? ( ) : ( ) } return (
{/* Filter bar */}
{/* Search */}
setSearchTerm(e.target.value)} className="w-full pl-9 pr-3 py-1.5 bg-bg-hover border border-border rounded text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-accent" />
{/* Quick filters */}
{(['all', 'infra', 'online'] as QuickFilter[]).map((filter) => ( ))}
{/* Count */}
{filteredNodes.length} of {nodes.length} nodes
{/* Table */}
{filteredNodes.slice(0, 100).map((node) => { const isInfra = INFRA_ROLES.includes(node.role) const isSelected = node.node_num === selectedNodeId return ( onSelectNode(node.node_num)} className={`cursor-pointer transition-colors ${ isSelected ? 'bg-accent/10' : 'hover:bg-bg-hover' }`} > ) })}
handleSort('short_name')} > Name handleSort('role')} > Role Region handleSort('battery_level')} > Battery handleSort('last_heard')} > Last Heard handleSort('hardware')} > Hardware
{node.short_name}
{node.long_name}
{node.role} {getRegionName(node.latitude)} {formatBattery(node)} {formatLastHeard(node.last_heard)} {node.hardware || '—'}
{filteredNodes.length > 100 && (
Showing first 100 of {filteredNodes.length} nodes
)} {filteredNodes.length === 0 && (
No nodes match your filters
)}
) }