import { useMemo, useState } from "react"; import { ChevronRight, Search } from "lucide-react"; import type { UserDevice } from "@/api/types"; import { Input } from "@/components/ui/input"; import { classifyPlatform, PlatformIcon, platformKindLabel, } from "@/components/admin/deviceOverrides"; import { deviceRecencyGroup, type DeviceRecencyGroup } from "@/hooks/queries/devices"; import { cn } from "@/lib/utils"; const GROUP_TITLES: Record = { current: "Using now", week: "This week", earlier: "Earlier", }; const GROUP_ORDER: DeviceRecencyGroup[] = ["current", "week", "earlier"]; /** * A device is "dormant" when nobody has used it for three months and it carries * no settings of its own. * * Accounts accumulate these relentlessly — every browser profile, private * window, reinstall and test build registers an identity, and nothing prunes * them. A real account had 260 devices, over half of them one-off sessions that * had never changed a setting. Listing them alongside the living-room TV is * what turned this screen into sixteen screens of scrolling, so they collapse * behind a count until asked for. */ const DORMANT_AFTER_MS = 90 * 24 * 60 * 60 * 1000; export function isDormantDevice(device: UserDevice, now: number): boolean { if (device.is_current_device || device.changed_count > 0) return false; const seen = Date.parse(device.last_seen_at); if (Number.isNaN(seen)) return true; return now - seen > DORMANT_AFTER_MS; } export interface DeviceListProps { devices: UserDevice[]; selectedDeviceId: string | null; onSelect: (device: UserDevice) => void; search: string; onSearchChange: (value: string) => void; /** Group by person first. Only the household view sets this. */ groupByProfile?: boolean; /** * Show only this profile's devices. Null means everyone. Only meaningful * alongside groupByProfile — a viewer looking at their own devices has just * the one profile, so a filter with a single option would be noise. */ profileFilter?: string | null; onProfileFilterChange?: (profileId: string | null) => void; /** The viewer's own profile, so their chip leads the row. */ ownProfileId?: string; /** * "Now" for relative-time labels. Passed in rather than read during render so * the component stays pure — and so a test can pin the clock. */ now: number; } /** * The device list. * * Fixed-height rows carrying a name, when it was last used, and the one number * that matters — how many settings differ there. A device with nothing changed * shows a dash rather than a zero, so "which one did I change?" is answerable * by scanning rather than by opening each device in turn. */ export function DeviceList({ devices, selectedDeviceId, onSelect, search, onSearchChange, groupByProfile = false, profileFilter = null, onProfileFilterChange, ownProfileId, now, }: DeviceListProps) { const [showDormant, setShowDormant] = useState(false); // Counts come from the unfiltered list so a chip keeps showing how many // devices it would reveal, rather than collapsing to zero once another chip // is active. const profiles = useMemo(() => profileOptions(devices, ownProfileId), [devices, ownProfileId]); const matching = useMemo(() => { const query = search.trim().toLowerCase(); return devices.filter((device) => { if (profileFilter && device.profile_id !== profileFilter) return false; if (!query) return true; const platform = platformKindLabel(classifyPlatform(device.device_platform)); return ( device.device_name.toLowerCase().includes(query) || device.device_platform.toLowerCase().includes(query) || platform.toLowerCase().includes(query) || device.profile_name.toLowerCase().includes(query) ); }); }, [devices, search, profileFilter]); // Searching means you are looking for something specific, so a search spans // everything — hiding a dormant device from its own name would be a bug. const searching = search.trim().length > 0; const dormantCount = useMemo( () => (searching ? 0 : matching.filter((device) => isDormantDevice(device, now)).length), [matching, searching, now], ); const filtered = useMemo( () => searching || showDormant ? matching : matching.filter((device) => !isDormantDevice(device, now)), [matching, searching, showDormant, now], ); const sections = useMemo(() => { if (groupByProfile && !profileFilter) { const byProfile = new Map(); for (const device of filtered) { const existing = byProfile.get(device.profile_id); if (existing) { existing.devices.push(device); } else { byProfile.set(device.profile_id, { title: device.profile_name || "Other profile", devices: [device], }); } } return [...byProfile.values()]; } return GROUP_ORDER.map((group) => ({ title: GROUP_TITLES[group], devices: filtered.filter((device) => deviceRecencyGroup(device, now) === group), })).filter((section) => section.devices.length > 0); }, [filtered, groupByProfile, profileFilter, now]); return (
onSearchChange(event.target.value)} placeholder={`Search ${devices.length} ${devices.length === 1 ? "device" : "devices"}`} aria-label="Search devices" // text-base on mobile: iOS Safari zooms the viewport when a focused // input's font is under 16px, and the zoom does not undo itself. className="h-11 pl-8 text-base xl:h-9 xl:text-[13px]" />
{groupByProfile && profiles.length > 1 && onProfileFilterChange ? ( // A wrapping row cost three lines and pushed the list off a phone // screen at eight profiles. Scrolling horizontally keeps it to one // line at any household size — the same trade the settings shell's own // mobile tab bar makes — and unwraps into a normal row from xl up.
onProfileFilterChange(null)} /> {profiles.map((profile) => ( onProfileFilterChange(profileFilter === profile.id ? null : profile.id) } /> ))}
) : null} {sections.length === 0 ? (

{devices.length === 0 ? "No devices yet." : searching ? "No devices match that search." : "Nothing here. Try showing unused devices."}

) : null} {/* Capped and scrollable rather than growing without limit: an account with a few hundred devices produced a thirteen-thousand-pixel page, so the settings never came into view. The list keeps its own scroll and the page stays roughly one screen. */}
{sections.map((section) => (

{section.title}

    {section.devices.map((device) => (
  • ))}
))}
{/* The long tail is opt-in, and says how big it is so nobody wonders whether a device is missing. */} {dormantCount > 0 ? ( ) : null}
); } interface ProfileOption { id: string; name: string; count: number; } /** * One entry per profile that owns at least one device. * * The viewer's own profile comes first — at eight profiles the arrival order * put the person actually using the screen last — and the rest are alphabetical * so a chip stays where someone last saw it, rather than moving whenever a * device is used. */ function profileOptions(devices: UserDevice[], ownProfileId?: string): ProfileOption[] { const byId = new Map(); for (const device of devices) { const existing = byId.get(device.profile_id); if (existing) { existing.count += 1; } else { byId.set(device.profile_id, { id: device.profile_id, name: device.profile_name || "Other profile", count: 1, }); } } return [...byId.values()].sort((a, b) => { if (a.id === ownProfileId) return -1; if (b.id === ownProfileId) return 1; return a.name.localeCompare(b.name); }); } function ProfileChip({ label, count, active, onClick, }: { label: string; count: number; active: boolean; onClick: () => void; }) { return ( ); } function DeviceRow({ device, selected, onSelect, now, }: { device: UserDevice; selected: boolean; onSelect: (device: UserDevice) => void; now: number; }) { const kind = classifyPlatform(device.device_platform); return ( ); } function ChangedCount({ count }: { count: number }) { if (count <= 0) { return ( — ); } return ( {count} ); } /** Plain relative time; the exact timestamp is not what anyone is asking. */ export function lastSeenLabel(iso: string, now: number): string { const seen = Date.parse(iso); if (Number.isNaN(seen)) return "Never used"; const minutes = Math.max(0, Math.round((now - seen) / 60000)); if (minutes < 60) return "Less than an hour ago"; const hours = Math.round(minutes / 60); if (hours < 24) return `${hours} ${hours === 1 ? "hour" : "hours"} ago`; const days = Math.round(hours / 24); if (days === 1) return "Yesterday"; if (days < 30) return `${days} days ago`; const months = Math.round(days / 30); if (months < 12) return `${months} ${months === 1 ? "month" : "months"} ago`; const years = Math.round(months / 12); return `${years} ${years === 1 ? "year" : "years"} ago`; }