diff --git a/packages/web/src/components/generic/Filter/FilterControl.tsx b/packages/web/src/components/generic/Filter/FilterControl.tsx index 3eb282f7..c2eb6cf2 100644 --- a/packages/web/src/components/generic/Filter/FilterControl.tsx +++ b/packages/web/src/components/generic/Filter/FilterControl.tsx @@ -16,20 +16,19 @@ import { import { cn } from "@core/utils/cn.ts"; import { debounce } from "@core/utils/debounce.ts"; import { Protobuf } from "@meshtastic/core"; -import type { TFunction } from "i18next"; import { FunnelIcon } from "lucide-react"; import { type ComponentProps, type ReactNode, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; const DEBOUNCE_DELAY_MS = 250; -const BATTERY_STATUS_PLUGGED_IN_VALUE = 101; type PopoverContentProps = ComponentProps; @@ -47,88 +46,57 @@ interface FilterControlProps { children?: ReactNode; } -interface HopsLabelProps { - hopsAway: number[]; - t: TFunction<"ui", undefined>; +interface RangeLabelContentProps { + range: [number, number]; + defaultRange: [number, number]; + format?: (ts: number) => ReactNode; + initialLabel?: ReactNode; + customLabel?: { start?: string; end?: string }; } -function HopsLabelContent({ hopsAway, t }: HopsLabelProps) { - const startHops = hopsAway[0]; - const endHops = hopsAway[1]; - return ( - <> - {t("hops.text", { - value: startHops === 0 ? t("hops.direct") : startHops, - })} - {startHops !== endHops ? ` — ${endHops}` : ""} - - ); -} - -interface LastHeardLabelProps { - lastHeardRange: number[]; - defaultMaxLastHeard: number; - formatTS: (ts: number) => ReactNode; - t: TFunction<"ui", undefined>; -} -function LastHeardLabelContent({ - lastHeardRange, - defaultMaxLastHeard, - formatTS, - t, -}: LastHeardLabelProps) { - const [start, end] = lastHeardRange; - return ( - <> - {t("lastHeard.labelText", { value: "" })} -
- {start === 0 ? ( - t("lastHeard.nowLabel") - ) : ( - <> - {start === defaultMaxLastHeard && ">"} - {formatTS(start)} - - )} - {start !== end && ( - <> - {" — "} - {end === defaultMaxLastHeard && ">"} - {formatTS(end)} - - )} - - ); -} +function RangeLabelContent({ + range, + defaultRange, + format, + initialLabel, + customLabel, +}: RangeLabelContentProps) { + const [start, end] = range; + const [min, max] = defaultRange; + const unequal = start !== end; -interface BatteryLevelLabelProps { - batteryLevelRange: (number | undefined)[]; - t: TFunction<"ui", undefined>; -} -function BatteryLevelLabelContent({ - batteryLevelRange, - t, -}: BatteryLevelLabelProps) { - const [start, end] = batteryLevelRange; - - const formatBatteryValue = (value: number | undefined) => { - if (value === undefined) { - return ""; - } - return value === BATTERY_STATUS_PLUGGED_IN_VALUE - ? t("batteryStatus.pluggedIn") - : `${value}%`; - }; + const fmtStart = format ? format(start) : start; + const fmtEnd = format ? format(end) : end; return ( <> - {t("batteryLevel.labelText", { - value: formatBatteryValue(start), - })} - {start !== end && typeof end !== "undefined" && ( + {initialLabel} + {start === min + ? (customLabel?.start ?? ( + <> + {"<"} + {fmtStart} + + )) + : start === max + ? (customLabel?.end ?? ( + <> + {">"} + {fmtEnd} + + )) + : fmtStart} + {unequal && ( <> - {" – "} - {formatBatteryValue(end)} + {" — "} + {end === max + ? (customLabel?.end ?? ( + <> + {">"} + {fmtEnd} + + )) + : fmtEnd} )} @@ -190,18 +158,20 @@ export function FilterControl({ ); const handleBoolChange = useCallback( - (key: K, value: string | boolean) => { - const typedValue = - value === true || value === "true" - ? true - : value === false || value === "false" - ? false - : undefined; + ( + key: K, + value: string | boolean | undefined, + ) => { + let typedValue: boolean | undefined; + if (value === true || value === "true") { + typedValue = true; + } else if (value === false || value === "false") { + typedValue = false; + } else { + typedValue = undefined; + } - setFilterState((prev) => ({ - ...prev, - [key]: typedValue, - })); + setFilterState((prev) => ({ ...prev, [key]: typedValue })); }, [setFilterState], ); @@ -220,6 +190,21 @@ export function FilterControl({ [], ); + const roleOptions = useMemo( + () => + Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( + (v): v is number => typeof v === "number", + ), + [], + ); + const hwModelOptions = useMemo( + () => + Object.values(Protobuf.Mesh.HardwareModel).filter( + (v): v is number => typeof v === "number", + ), + [], + ); + return ( @@ -229,9 +214,8 @@ export function FilterControl({ "rounded", "text-slate-600 hover:text-slate-700 bg-slate-100 hover:bg-slate-200 active:bg-slate-300", "dark:text-slate-400 hover:dark:text-slate-400 dark:bg-slate-700 hover:dark:bg-slate-800 dark:active:bg-slate-950", - isDirty - ? "text-slate-100 dark:text-slate-300 bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800 hover:text-slate-200 dark:hover:text-slate-300 active:bg-green-800 dark:active:bg-green-900" - : "", + isDirty && + "text-slate-100 dark:text-slate-300 bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800 hover:text-slate-200 dark:hover:text-slate-300 active:bg-green-800 dark:active:bg-green-900", parameters?.popoverTriggerClassName, )} aria-label={t("filter.label")} @@ -269,30 +253,31 @@ export function FilterControl({ )} } /> } /> @@ -314,11 +299,17 @@ export function FilterControl({ + } /> } /> + } /> @@ -361,11 +364,11 @@ export function FilterControl({ filterKey="role" filterState={filterState} setFilterState={setFilterState} - options={Object.values( - Protobuf.Config.Config_DeviceConfig_Role, - ).filter((v): v is number => typeof v === "number")} + options={roleOptions} getLabel={(val) => - formatEnumLabel(Protobuf.Config.Config_DeviceConfig_Role[val]) + formatEnumLabel( + Protobuf.Config.Config_DeviceConfig_Role[val] ?? "UNSET", + ) } /> @@ -375,11 +378,9 @@ export function FilterControl({ filterKey="hwModel" filterState={filterState} setFilterState={setFilterState} - options={Object.values(Protobuf.Mesh.HardwareModel).filter( - (v): v is number => typeof v === "number", - )} + options={hwModelOptions} getLabel={(val) => - formatEnumLabel(Protobuf.Mesh.HardwareModel[val]) + formatEnumLabel(Protobuf.Mesh.HardwareModel[val] ?? "UNKNOWN") } /> diff --git a/packages/web/src/components/generic/Filter/useFilterNode.test.ts b/packages/web/src/components/generic/Filter/useFilterNode.test.ts index a7b48f04..439fd357 100644 --- a/packages/web/src/components/generic/Filter/useFilterNode.test.ts +++ b/packages/web/src/components/generic/Filter/useFilterNode.test.ts @@ -180,4 +180,56 @@ describe("useFilterNode", () => { expect(isFilterDirty(modified)).toBe(true); }); }); + + describe("default-boundary semantics", () => { + const node = createMockNode(); + + it("lastHeard: end at default max means 'any age'", () => { + const { + lastHeard: [lastHeardMin, lastHeardMax], + } = defaultFilterValues; + const veryOld = { ...node, lastHeard: lastHeardMax + 1 }; // older than slider max + expect( + nodeFilter(veryOld, { lastHeard: [lastHeardMin, lastHeardMax] }), + ).toBe(true); // open upper + expect( + nodeFilter(veryOld, { lastHeard: [lastHeardMin, lastHeardMax - 1] }), + ).toBe(false); // now bounded + }); + + it("snr: max at default means no upper bound", () => { + const { + snr: [snrMin, snrMax], + } = defaultFilterValues; + const hiSnr = { ...node, snr: snrMax + 1 }; // above slider max + expect(nodeFilter(hiSnr, { snr: [snrMin, snrMax] })).toBe(true); // open upper + expect(nodeFilter(hiSnr, { snr: [snrMin, snrMax - 1] })).toBe(false); // bounded + }); + + it("snr: min at default means no lower bound", () => { + const { + snr: [snrMin, snrMax], + } = defaultFilterValues; + const loSnr = { ...node, snr: snrMin - 1 }; // below slider min + expect(nodeFilter(loSnr, { snr: [snrMin, snrMax] })).toBe(true); // open lower + expect(nodeFilter(loSnr, { snr: [snrMin + 1, snrMax] })).toBe(false); // bounded + }); + + it("voltage: max at default means no upper bound", () => { + const { + voltage: [voltageMin, voltageMax], + } = defaultFilterValues; + const hiV = { + ...node, + deviceMetrics: { + ...node.deviceMetrics!, + voltage: voltageMax + 1, + }, + } satisfies Protobuf.Mesh.NodeInfo; + expect(nodeFilter(hiV, { voltage: [voltageMin, voltageMax] })).toBe(true); // open upper + expect( + nodeFilter(hiV, { voltage: [voltageMin, voltageMax - 0.01] }), + ).toBe(false); // bounded + }); + }); }); diff --git a/packages/web/src/components/generic/Filter/useFilterNode.ts b/packages/web/src/components/generic/Filter/useFilterNode.ts index eade279a..275c67b1 100644 --- a/packages/web/src/components/generic/Filter/useFilterNode.ts +++ b/packages/web/src/components/generic/Filter/useFilterNode.ts @@ -127,7 +127,12 @@ export function useFilterNode() { } const snr = node.snr ?? -20; - if (snr < filterState.snr[0] || snr > filterState.snr[1]) { + if ( + (snr < filterState.snr[0] && + filterState.snr[0] !== defaultFilterValues.snr[0]) || + (snr > filterState.snr[1] && + filterState.snr[1] !== defaultFilterValues.snr[1]) + ) { return false; } @@ -158,7 +163,8 @@ export function useFilterNode() { const voltage = node.deviceMetrics?.voltage ?? 0; if ( voltage < filterState.voltage[0] || - voltage > filterState.voltage[1] + (voltage > filterState.voltage[1] && + filterState.voltage[1] !== defaultFilterValues.voltage[1]) ) { return false; }