@camstack/ui-library 1.2.221 → 1.2.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -7670,6 +7670,12 @@ var useOsdManagerCopyDeviceConfiguration = trpc.osdManager.copyDeviceConfigurati
7670
7670
  var useOsdManagerPreviewSlot = trpc.osdManager.previewSlot.useMutation;
7671
7671
  /** Generated alias around `trpc.osdManager.renderDevice.useMutation`. */
7672
7672
  var useOsdManagerRenderDevice = trpc.osdManager.renderDevice.useMutation;
7673
+ /** Generated alias around `trpc.osdManager.listTemplates.useQuery`. */
7674
+ var useOsdManagerListTemplates = trpc.osdManager.listTemplates.useQuery;
7675
+ /** Generated alias around `trpc.osdManager.upsertTemplate.useMutation`. */
7676
+ var useOsdManagerUpsertTemplate = trpc.osdManager.upsertTemplate.useMutation;
7677
+ /** Generated alias around `trpc.osdManager.deleteTemplate.useMutation`. */
7678
+ var useOsdManagerDeleteTemplate = trpc.osdManager.deleteTemplate.useMutation;
7673
7679
  /** Generated alias around `trpc.petFeeder.feed.useMutation`. */
7674
7680
  var usePetFeederFeed = trpc.petFeeder.feed.useMutation;
7675
7681
  /** Generated alias around `trpc.petFeeder.cancelFeed.useMutation`. */
@@ -12355,8 +12361,8 @@ function selectedDeviceOptions(options, selected) {
12355
12361
  * renders the shared form field gets them without extra registration.
12356
12362
  */
12357
12363
  var INPUT_CLASS$1 = "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none disabled:opacity-50 disabled:cursor-not-allowed";
12358
- var INPUT_SHELL = "flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1.5 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30";
12359
- var LABEL_CLASS$1 = "block text-[11px] font-medium text-foreground mb-1 break-words";
12364
+ var INPUT_SHELL$1 = "flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1.5 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30";
12365
+ var LABEL_CLASS$2 = "block text-[11px] font-medium text-foreground mb-1 break-words";
12360
12366
  var DESC_CLASS$1 = "text-[10px] text-foreground-subtle mt-0.5 break-words";
12361
12367
  /** The compact projection of the listAll rows the device fields consume. */
12362
12368
  function toCandidates(rows) {
@@ -12393,7 +12399,7 @@ var FACET_ITEM = "flex h-7 w-full items-center gap-2 px-2 text-left text-xs text
12393
12399
  * The operator asked for dropdowns over rows of chips (2026-09-05): with
12394
12400
  * sixteen rooms and a dozen types the chips ate the panel.
12395
12401
  */
12396
- function FacetDropdown({ label, values, chosen, onChange }) {
12402
+ function FacetDropdown$1({ label, values, chosen, onChange }) {
12397
12403
  if (values.length < 2) return null;
12398
12404
  const summary = chosen.length === 0 ? "All" : chosen.join(", ");
12399
12405
  const toggle = (value) => {
@@ -12451,7 +12457,7 @@ function FieldShell({ label, description, required, translationFn, children }) {
12451
12457
  className: "min-w-0",
12452
12458
  children: [
12453
12459
  resolvedLabel !== void 0 && resolvedLabel !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
12454
- className: LABEL_CLASS$1,
12460
+ className: LABEL_CLASS$2,
12455
12461
  children: [resolvedLabel, required && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
12456
12462
  className: "text-danger ml-0.5",
12457
12463
  children: "*"
@@ -12547,7 +12553,7 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
12547
12553
  className: "min-w-0",
12548
12554
  children: [
12549
12555
  label !== void 0 && label !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
12550
- className: LABEL_CLASS$1,
12556
+ className: LABEL_CLASS$2,
12551
12557
  children: label
12552
12558
  }),
12553
12559
  selectedOptions.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -12568,7 +12574,7 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
12568
12574
  }),
12569
12575
  smart && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
12570
12576
  className: "mb-1.5 flex flex-wrap gap-1.5",
12571
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown, {
12577
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown$1, {
12572
12578
  label: "Location",
12573
12579
  values: facetValues.locations,
12574
12580
  chosen: facets.locations,
@@ -12576,7 +12582,7 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
12576
12582
  ...f,
12577
12583
  locations
12578
12584
  }))
12579
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown, {
12585
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown$1, {
12580
12586
  label: "Type",
12581
12587
  values: facetValues.types,
12582
12588
  chosen: facets.types,
@@ -12587,7 +12593,7 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
12587
12593
  })]
12588
12594
  }),
12589
12595
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
12590
- className: INPUT_SHELL + (isDisabled ? " opacity-50" : ""),
12596
+ className: INPUT_SHELL$1 + (isDisabled ? " opacity-50" : ""),
12591
12597
  children: [
12592
12598
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Search, { className: "h-3.5 w-3.5 shrink-0 text-foreground-subtle" }),
12593
12599
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -25810,7 +25816,7 @@ function TimezoneSelector({ value, onChange, disabled }) {
25810
25816
  //#endregion
25811
25817
  //#region src/composites/config-form-field.tsx
25812
25818
  var INPUT_CLASS = "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none disabled:opacity-50 disabled:cursor-not-allowed";
25813
- var LABEL_CLASS = "block text-[11px] font-medium text-foreground mb-1 break-words";
25819
+ var LABEL_CLASS$1 = "block text-[11px] font-medium text-foreground mb-1 break-words";
25814
25820
  var DESC_CLASS = "text-[10px] text-foreground-subtle mt-0.5 break-words";
25815
25821
  function FieldWrapper({ label, description, required, span, children, translationFn }) {
25816
25822
  const colSpanClass = span === 2 ? "col-span-1 @[480px]:col-span-2" : span === 3 ? "col-span-1 @[480px]:col-span-2 @[880px]:col-span-3" : span === 4 ? "col-span-1 @[480px]:col-span-2 @[1140px]:col-span-4" : "col-span-1";
@@ -25821,7 +25827,7 @@ function FieldWrapper({ label, description, required, span, children, translatio
25821
25827
  className: `${colSpanClass} min-w-0`,
25822
25828
  children: [
25823
25829
  hasLabel && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
25824
- className: LABEL_CLASS,
25830
+ className: LABEL_CLASS$1,
25825
25831
  children: [resolvedLabel, required && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25826
25832
  className: "text-danger ml-0.5",
25827
25833
  children: "*"
@@ -26511,7 +26517,7 @@ function SubTabsField({ field, values, onChange, disabled, translationFn, onTest
26511
26517
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26512
26518
  className: `${colSpanClass} min-w-0`,
26513
26519
  children: [field.label && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26514
- className: LABEL_CLASS,
26520
+ className: LABEL_CLASS$1,
26515
26521
  children: resolveLabel(field.label, translationFn)
26516
26522
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26517
26523
  className: "@container rounded-lg border border-border-subtle bg-surface-hover/40 overflow-hidden",
@@ -27309,7 +27315,7 @@ function MultipleFieldWrapper({ field, values, onChange, disabled, translationFn
27309
27315
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
27310
27316
  className: "space-y-1",
27311
27317
  children: [itemLabel && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
27312
- className: LABEL_CLASS,
27318
+ className: LABEL_CLASS$1,
27313
27319
  children: itemLabel
27314
27320
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
27315
27321
  className: "flex items-center gap-2",
@@ -39438,12 +39444,17 @@ function PopoverRowAction({ icon: Icon, title, children }) {
39438
39444
  }
39439
39445
  //#endregion
39440
39446
  //#region src/lib/resolve-sensor-display.ts
39447
+ /** Table keys are canonical spellings; providers often send `C` / `F` / `celsius`. */
39448
+ function canonUnit(raw) {
39449
+ if (raw === void 0) return void 0;
39450
+ return (0, _camstack_types.normalizeUnit)(raw) ?? raw;
39451
+ }
39441
39452
  var identity = (raw) => raw;
39442
39453
  function resolveSensorDisplay(input) {
39443
39454
  const perCap = input.capName !== void 0 ? input.display?.perCap?.[input.capName] : void 0;
39444
39455
  const precision = perCap?.precision ?? input.display?.precision ?? input.roleDefault?.precision ?? input.slicePrecision;
39445
- const sourceUnit = input.sliceUnit ?? input.canonicalUnit;
39446
- const overrideUnit = perCap?.unit ?? input.display?.unit ?? input.roleDefault?.unit;
39456
+ const sourceUnit = canonUnit(input.sliceUnit ?? input.canonicalUnit);
39457
+ const overrideUnit = canonUnit(perCap?.unit ?? input.display?.unit ?? input.roleDefault?.unit);
39447
39458
  if (overrideUnit !== void 0 && sourceUnit !== void 0 && overrideUnit !== sourceUnit && (0, _camstack_types.canConvertUnit)(sourceUnit, overrideUnit)) return {
39448
39459
  unit: overrideUnit,
39449
39460
  precision,
@@ -50574,7 +50585,7 @@ function SectionRows({ title, count, open, colSpan, onToggle, rows, trpc, parent
50574
50585
  //#region src/composites/device-list/sort.ts
50575
50586
  /** Human-facing type label used for the `type` comparator — matches the label
50576
50587
  * the row renders, so the sort order reads as the user sees it. */
50577
- function typeLabel(device) {
50588
+ function typeLabel$1(device) {
50578
50589
  return deviceTypeMetaOf(device.type).label.toLowerCase();
50579
50590
  }
50580
50591
  function nameKey(device) {
@@ -50583,7 +50594,7 @@ function nameKey(device) {
50583
50594
  /** Ascending comparator for a given sortable column. */
50584
50595
  function ascComparator(columnId) {
50585
50596
  if (columnId === "name") return (a, b) => nameKey(a).localeCompare(nameKey(b));
50586
- if (columnId === "type") return (a, b) => typeLabel(a).localeCompare(typeLabel(b));
50597
+ if (columnId === "type") return (a, b) => typeLabel$1(a).localeCompare(typeLabel$1(b));
50587
50598
  return compareHardware;
50588
50599
  }
50589
50600
  /**
@@ -55739,6 +55750,1362 @@ function VersionBadge({ version, preRelease, className }) {
55739
55750
  });
55740
55751
  }
55741
55752
  //#endregion
55753
+ //#region src/composites/device-picker/SelectedChip.tsx
55754
+ /**
55755
+ * Removable selection chip + the search-box shell shared by DevicePicker and
55756
+ * SearchableMultiSelectField (admin-ui). Lives here so addon MFEs can render
55757
+ * the same picker without importing admin-ui.
55758
+ */
55759
+ /** The search box's shell — shared with multiselect fields that mirror this picker. */
55760
+ var INPUT_SHELL = "flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1.5 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30";
55761
+ /** One selected entry as a removable chip. */
55762
+ function SelectedChip({ label, onRemove, warning = false, iconId, testId }) {
55763
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55764
+ "data-testid": testId,
55765
+ className: warning ? "inline-flex items-center gap-1 rounded-md bg-warning/10 px-1.5 py-0.5 text-[11px] text-warning max-w-full" : "inline-flex items-center gap-1 rounded-md bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary max-w-full",
55766
+ children: [
55767
+ iconId !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EventKindGlyph, {
55768
+ iconId,
55769
+ size: 12
55770
+ }),
55771
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55772
+ className: "truncate",
55773
+ children: label
55774
+ }),
55775
+ onRemove !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
55776
+ type: "button",
55777
+ className: "shrink-0 hover:text-danger",
55778
+ "aria-label": `Remove ${label}`,
55779
+ onClick: onRemove,
55780
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(X, { className: "h-2.5 w-2.5" })
55781
+ })
55782
+ ]
55783
+ });
55784
+ }
55785
+ //#endregion
55786
+ //#region src/composites/device-picker/device-picker-model.ts
55787
+ /** Numbers numerically, anything else by its text — the name tie-break. */
55788
+ function compareIds(a, b) {
55789
+ if (typeof a === "number" && typeof b === "number") return a - b;
55790
+ return String(a).localeCompare(String(b));
55791
+ }
55792
+ /** How long the search waits after the last keystroke. */
55793
+ var PICKER_SEARCH_DEBOUNCE_MS = 200;
55794
+ /** A group with more devices than this starts collapsed (unless searching). */
55795
+ var PICKER_GROUP_COLLAPSE_AT = 8;
55796
+ /** How many selected chips are drawn before "+N more". */
55797
+ var PICKER_CHIP_LIMIT = 6;
55798
+ /** Rows a capped render draws per "show more". */
55799
+ var PICKER_PAGE_SIZE = 60;
55800
+ /** Rows a virtualised list renders per batch and initially. */
55801
+ var PICKER_WINDOW = 30;
55802
+ /** Everything a query can match on, lower-cased. */
55803
+ function pickerSearchText(device) {
55804
+ return `${[
55805
+ device.name,
55806
+ device.location ?? "",
55807
+ device.type ?? "",
55808
+ device.detail ?? ""
55809
+ ].join(" ")} ${String(device.id)}`.toLowerCase();
55810
+ }
55811
+ /** The query's tokens; empty for a blank query. */
55812
+ function pickerQueryTokens(query) {
55813
+ return query.trim().toLowerCase().split(/\s+/).filter((token) => token.length > 0);
55814
+ }
55815
+ /** Devices matching EVERY token. A blank query keeps the list as it is. */
55816
+ function filterPickerDevices(devices, query) {
55817
+ const tokens = pickerQueryTokens(query);
55818
+ if (tokens.length === 0) return devices;
55819
+ return devices.filter((device) => {
55820
+ const hay = pickerSearchText(device);
55821
+ return tokens.every((token) => hay.includes(token));
55822
+ });
55823
+ }
55824
+ function compareNullableLast(a, b) {
55825
+ if (a === b) return 0;
55826
+ if (a === null) return 1;
55827
+ if (b === null) return -1;
55828
+ return a.localeCompare(b);
55829
+ }
55830
+ function blankToNull(value) {
55831
+ return value === void 0 || value === null || value.trim() === "" ? null : value;
55832
+ }
55833
+ /** The key a group is remembered by (collapsed / expanded). */
55834
+ function pickerGroupKey(type, location) {
55835
+ return `${type ?? ""}|${location ?? ""}`;
55836
+ }
55837
+ /** Group by type, then room; both ascending, "none" last; devices by name. */
55838
+ function groupPickerDevices(devices, selected) {
55839
+ const picked = new Set(selected);
55840
+ const buckets = /* @__PURE__ */ new Map();
55841
+ for (const device of devices) {
55842
+ const type = blankToNull(device.type);
55843
+ const location = blankToNull(device.location);
55844
+ const key = pickerGroupKey(type, location);
55845
+ const bucket = buckets.get(key);
55846
+ if (bucket === void 0) buckets.set(key, {
55847
+ type,
55848
+ location,
55849
+ devices: [device]
55850
+ });
55851
+ else bucket.devices.push(device);
55852
+ }
55853
+ const groups = [];
55854
+ for (const [key, bucket] of buckets) {
55855
+ const sorted = [...bucket.devices].sort((a, b) => a.name.localeCompare(b.name) || compareIds(a.id, b.id));
55856
+ groups.push({
55857
+ key,
55858
+ type: bucket.type,
55859
+ location: bucket.location,
55860
+ devices: sorted,
55861
+ selectedCount: sorted.filter((device) => picked.has(device.id)).length
55862
+ });
55863
+ }
55864
+ return groups.sort((a, b) => compareNullableLast(a.type, b.type) || compareNullableLast(a.location, b.location));
55865
+ }
55866
+ /**
55867
+ * Whether a group is open. The operator's own toggle wins; otherwise a small
55868
+ * group is open, a big one is closed — unless a search is running (the result
55869
+ * IS what they asked to see) or it holds a selected device.
55870
+ */
55871
+ function isGroupExpanded(group, toggles, searching) {
55872
+ const own = toggles.get(group.key);
55873
+ if (own !== void 0) return own;
55874
+ if (searching || group.selectedCount > 0) return true;
55875
+ return group.devices.length <= 8;
55876
+ }
55877
+ /** The groups as ONE list of rows — what a virtualised list renders. */
55878
+ function flattenPickerRows(groups, selected, toggles, searching) {
55879
+ const picked = new Set(selected);
55880
+ const rows = [];
55881
+ for (const group of groups) {
55882
+ const expanded = isGroupExpanded(group, toggles, searching);
55883
+ rows.push({
55884
+ kind: "header",
55885
+ key: `h:${group.key}`,
55886
+ group,
55887
+ expanded
55888
+ });
55889
+ if (!expanded) continue;
55890
+ for (const device of group.devices) rows.push({
55891
+ kind: "device",
55892
+ key: `d:${String(device.id)}`,
55893
+ device,
55894
+ groupKey: group.key,
55895
+ selected: picked.has(device.id)
55896
+ });
55897
+ }
55898
+ return rows;
55899
+ }
55900
+ /** Tick or untick one id. The order of the rest is kept. */
55901
+ function togglePickerDevice(value, id) {
55902
+ return value.includes(id) ? value.filter((v) => v !== id) : [...value, id];
55903
+ }
55904
+ /** Add every device of the group (only the ones the current search shows). */
55905
+ function selectPickerGroup(value, group) {
55906
+ const next = [...value];
55907
+ const have = new Set(value);
55908
+ for (const device of group.devices) if (!have.has(device.id)) next.push(device.id);
55909
+ return next;
55910
+ }
55911
+ /** Remove every device of the group; ids outside it are untouched. */
55912
+ function clearPickerGroup(value, group) {
55913
+ const drop = new Set(group.devices.map((device) => device.id));
55914
+ return value.filter((id) => !drop.has(id));
55915
+ }
55916
+ function chipOverflow(items, expanded) {
55917
+ if (expanded || items.length <= 6) return {
55918
+ shown: items,
55919
+ hidden: 0
55920
+ };
55921
+ return {
55922
+ shown: items.slice(0, 6),
55923
+ hidden: items.length - 6
55924
+ };
55925
+ }
55926
+ function pageRows(rows, shown) {
55927
+ const count = Math.max(0, Math.min(rows.length, shown));
55928
+ return {
55929
+ rows: rows.slice(0, count),
55930
+ remaining: rows.length - count
55931
+ };
55932
+ }
55933
+ /** The modes, in the order the control draws them. */
55934
+ var PICKER_GROUP_BY_MODES = [
55935
+ "zone",
55936
+ "location",
55937
+ "none"
55938
+ ];
55939
+ /** What an operator who never chose gets. */
55940
+ var PICKER_DEFAULT_GROUP_BY = "zone";
55941
+ /** A stored choice; anything unreadable is the default. */
55942
+ function parsePickerGroupBy(raw) {
55943
+ return raw === "zone" || raw === "location" || raw === "none" ? raw : PICKER_DEFAULT_GROUP_BY;
55944
+ }
55945
+ /** The folded key a location label joins a zone by — `siteZoneKey`'s fold. */
55946
+ function pickerLocationKey(label) {
55947
+ return label.trim().toLowerCase();
55948
+ }
55949
+ var NO_ZONE_RANK = 1;
55950
+ var NO_LOCATION_RANK = 2;
55951
+ function byName(a, b) {
55952
+ return a.name.localeCompare(b.name) || compareIds(a.id, b.id);
55953
+ }
55954
+ function headingLabel(heading) {
55955
+ return heading.kind === "zone" || heading.kind === "location" ? heading.label : "";
55956
+ }
55957
+ var NO_LOCATION = {
55958
+ key: "-location",
55959
+ heading: { kind: "no-location" },
55960
+ rank: NO_LOCATION_RANK
55961
+ };
55962
+ var NO_ZONE = {
55963
+ key: "-zone",
55964
+ heading: { kind: "no-zone" },
55965
+ rank: NO_ZONE_RANK
55966
+ };
55967
+ /** Every zone by the folded location keys it holds; the first zone wins a key. */
55968
+ function zoneByLocation(zones) {
55969
+ const out = /* @__PURE__ */ new Map();
55970
+ for (const zone of zones) for (const key of zone.locationKeys) {
55971
+ const folded = pickerLocationKey(key);
55972
+ if (!out.has(folded)) out.set(folded, zone);
55973
+ }
55974
+ return out;
55975
+ }
55976
+ function placeByLocation(location) {
55977
+ if (location === null) return NO_LOCATION;
55978
+ return {
55979
+ key: `l:${pickerLocationKey(location)}`,
55980
+ heading: {
55981
+ kind: "location",
55982
+ label: location.trim()
55983
+ },
55984
+ rank: 0
55985
+ };
55986
+ }
55987
+ function placeByZone(location, zones) {
55988
+ if (location === null) return NO_LOCATION;
55989
+ const zone = zones.get(pickerLocationKey(location));
55990
+ if (zone === void 0) return NO_ZONE;
55991
+ return {
55992
+ key: `z:${zone.zoneKey}`,
55993
+ heading: {
55994
+ kind: "zone",
55995
+ label: zone.label
55996
+ },
55997
+ rank: 0
55998
+ };
55999
+ }
56000
+ /**
56001
+ * Group the list by zone, location or nothing.
56002
+ *
56003
+ * Zone: a group per site zone that holds a listed device (by label), then
56004
+ * "No zone" (a location no zone holds), then "No location". Location: a group
56005
+ * per location (folded; named after the first device's trimmed spelling), then "No
56006
+ * location". None: one flat list by name, then id.
56007
+ *
56008
+ * A zone read that is pending or failed can NOT say a location is in no zone
56009
+ * (D315), so Zone mode then groups by location and returns the reason as
56010
+ * {@link PickerGrouping.fallback} for the UI's one-line note.
56011
+ */
56012
+ function groupPickerDevicesBy(devices, selected, groupBy, zones) {
56013
+ const picked = new Set(selected);
56014
+ const sorted = [...devices].sort(byName);
56015
+ const count = (list) => list.filter((device) => picked.has(device.id)).length;
56016
+ if (groupBy === "none") return {
56017
+ groupBy,
56018
+ fallback: null,
56019
+ flat: true,
56020
+ groups: [{
56021
+ key: "all",
56022
+ type: null,
56023
+ location: null,
56024
+ devices: sorted,
56025
+ selectedCount: count(sorted)
56026
+ }]
56027
+ };
56028
+ const fallback = groupBy === "zone" && zones.state !== "answered" ? zones.state : null;
56029
+ const effective = fallback === null ? groupBy : "location";
56030
+ const zoneMap = zones.state === "answered" ? zoneByLocation(zones.zones) : /* @__PURE__ */ new Map();
56031
+ const buckets = /* @__PURE__ */ new Map();
56032
+ for (const device of sorted) {
56033
+ const location = blankToNull(device.location);
56034
+ const place = effective === "zone" ? placeByZone(location, zoneMap) : placeByLocation(location);
56035
+ const bucket = buckets.get(place.key);
56036
+ if (bucket === void 0) buckets.set(place.key, {
56037
+ heading: place.heading,
56038
+ rank: place.rank,
56039
+ devices: [device]
56040
+ });
56041
+ else bucket.devices.push(device);
56042
+ }
56043
+ return {
56044
+ groupBy: effective,
56045
+ fallback,
56046
+ flat: false,
56047
+ groups: [...buckets].sort(([, a], [, b]) => a.rank - b.rank || headingLabel(a.heading).localeCompare(headingLabel(b.heading))).map(([key, bucket]) => ({
56048
+ key,
56049
+ type: null,
56050
+ location: bucket.heading.kind === "location" ? bucket.heading.label : null,
56051
+ devices: bucket.devices,
56052
+ selectedCount: count(bucket.devices),
56053
+ heading: bucket.heading
56054
+ }))
56055
+ };
56056
+ }
56057
+ /** The grouping as rows: a flat grouping draws no header and is always open. */
56058
+ function flattenPickerGrouping(grouping, selected, toggles, searching) {
56059
+ if (!grouping.flat) return flattenPickerRows(grouping.groups, selected, toggles, searching);
56060
+ const picked = new Set(selected);
56061
+ const rows = [];
56062
+ for (const group of grouping.groups) for (const device of group.devices) rows.push({
56063
+ kind: "device",
56064
+ key: `d:${String(device.id)}`,
56065
+ device,
56066
+ groupKey: group.key,
56067
+ selected: picked.has(device.id)
56068
+ });
56069
+ return rows;
56070
+ }
56071
+ function pickerRowFacts(device, groupBy) {
56072
+ return {
56073
+ location: groupBy === "location" ? null : blankToNull(device.location),
56074
+ type: blankToNull(device.type)
56075
+ };
56076
+ }
56077
+ /** The filter key of an item with no type / no location / no zone. */
56078
+ var PICKER_NONE_KEY = "(none)";
56079
+ var PICKER_NO_FILTERS = {
56080
+ query: "",
56081
+ types: [],
56082
+ zones: [],
56083
+ locations: []
56084
+ };
56085
+ var NO_FACET_VALUE = {
56086
+ key: PICKER_NONE_KEY,
56087
+ label: null
56088
+ };
56089
+ function facetValue(item, facet, zones) {
56090
+ if (facet === "type") {
56091
+ const type = blankToNull(item.type);
56092
+ return type === null ? NO_FACET_VALUE : {
56093
+ key: type,
56094
+ label: type
56095
+ };
56096
+ }
56097
+ const location = blankToNull(item.location);
56098
+ if (location === null) return NO_FACET_VALUE;
56099
+ if (facet === "location") return {
56100
+ key: pickerLocationKey(location),
56101
+ label: location.trim()
56102
+ };
56103
+ if (zones.state !== "answered") return NO_FACET_VALUE;
56104
+ const zone = zoneByLocation(zones.zones).get(pickerLocationKey(location));
56105
+ return zone === void 0 ? NO_FACET_VALUE : {
56106
+ key: zone.zoneKey,
56107
+ label: zone.label
56108
+ };
56109
+ }
56110
+ /** The key an item filters by under one facet; `(none)` when it has no such value. */
56111
+ function pickerFacetKey(item, facet, zones) {
56112
+ return facetValue(item, facet, zones).key;
56113
+ }
56114
+ function compareFacetOptions(a, b) {
56115
+ if (a.key === "(none)" || b.key === "(none)") return a.key === b.key ? 0 : a.key === "(none)" ? 1 : -1;
56116
+ return (a.label ?? "").localeCompare(b.label ?? "");
56117
+ }
56118
+ /** Every value of a facet the list holds (plus chosen ones it no longer holds, at 0). */
56119
+ function pickerFacetOptions(items, facet, chosen, zones) {
56120
+ if (facet === "zone" && zones.state !== "answered") return [];
56121
+ const byKey = /* @__PURE__ */ new Map();
56122
+ for (const item of items) {
56123
+ const value = facetValue(item, facet, zones);
56124
+ const have = byKey.get(value.key);
56125
+ if (have === void 0) byKey.set(value.key, {
56126
+ label: value.label,
56127
+ count: 1
56128
+ });
56129
+ else have.count++;
56130
+ }
56131
+ for (const key of chosen) if (!byKey.has(key)) byKey.set(key, {
56132
+ label: key === "(none)" ? null : key,
56133
+ count: 0
56134
+ });
56135
+ const options = [];
56136
+ for (const [key, entry] of byKey) options.push({
56137
+ key,
56138
+ label: entry.label,
56139
+ count: entry.count
56140
+ });
56141
+ return options.sort(compareFacetOptions);
56142
+ }
56143
+ /** A zone filter is chosen but the zones read has not answered: it is paused, not applied. */
56144
+ function pickerZoneFilterSuspended(filters, zones) {
56145
+ return filters.zones.length > 0 && zones.state !== "answered";
56146
+ }
56147
+ function keepFacet(items, facet, chosen, zones) {
56148
+ if (chosen.length === 0) return items;
56149
+ return items.filter((item) => chosen.includes(pickerFacetKey(item, facet, zones)));
56150
+ }
56151
+ /** Search, then Type, Zone (only from an answered read), Location. No filter: the list itself. */
56152
+ function applyPickerFilters(items, filters, zones) {
56153
+ const typed = keepFacet(filterPickerDevices(items, filters.query), "type", filters.types, zones);
56154
+ return keepFacet(zones.state === "answered" ? keepFacet(typed, "zone", filters.zones, zones) : typed, "location", filters.locations, zones);
56155
+ }
56156
+ /** Tick or untick one filter key. */
56157
+ function togglePickerKey(chosen, key) {
56158
+ return chosen.includes(key) ? chosen.filter((k) => k !== key) : [...chosen, key];
56159
+ }
56160
+ function pickerFiltersActive(filters) {
56161
+ return filters.query.trim() !== "" || filters.types.length > 0 || filters.zones.length > 0 || filters.locations.length > 0;
56162
+ }
56163
+ /** Reset clears search, Type, Zone and Location — never "Group by". */
56164
+ function resetPickerFilters() {
56165
+ return PICKER_NO_FILTERS;
56166
+ }
56167
+ /**
56168
+ * Selected ids the current filters hide — they stay selected, they are only not listed. Only a
56169
+ * LISTED device can be hidden: an id the list does not hold at all (removed, unknown, off-offer)
56170
+ * is not "hidden", so it is not counted.
56171
+ */
56172
+ function pickerHiddenSelectedCount(selected, list, matches) {
56173
+ const listed = new Set(list.map((item) => item.id));
56174
+ const shown = new Set(matches.map((item) => item.id));
56175
+ return selected.filter((id) => listed.has(id) && !shown.has(id)).length;
56176
+ }
56177
+ /** A dropdown is drawn when it can narrow (two values or more) or a key is chosen. */
56178
+ function pickerFacetFilterShown(options, chosen) {
56179
+ return options.length >= 2 || chosen.length > 0;
56180
+ }
56181
+ //#endregion
56182
+ //#region src/composites/device-picker/device-filter-bar.tsx
56183
+ /**
56184
+ * The filter bar above a picker's list (D697): search, "Group by" (when the
56185
+ * list offers it), the Type / Zone / Location dropdowns and "Reset filters".
56186
+ *
56187
+ * It owns no filter state — the caller holds `PickerFilters` and applies them
56188
+ * (`applyPickerFilters`); this only draws the controls and reports changes.
56189
+ * The search box keeps its own text so typing is instant, and tells the
56190
+ * caller `PICKER_SEARCH_DEBOUNCE_MS` after the last keystroke.
56191
+ *
56192
+ * The Zone and Location dropdowns are provisional (operator answer Q1): to
56193
+ * drop them, delete the two `FacetDropdown` uses in {@link DeviceFilterBar}
56194
+ * and their option props — the model side is described in
56195
+ * `device-picker-model.ts`.
56196
+ *
56197
+ * - D315: a zones read that has not answered draws the Zone dropdown
56198
+ * DISABLED ("loading zones…" / "zones unavailable"), never empty, and a
56199
+ * chosen zone is reported as paused, not as matching nothing.
56200
+ * - "Reset filters" clears search, Type, Zone and Location and leaves the
56201
+ * Group-by choice alone.
56202
+ */
56203
+ /** "media-player" → "Media player"; null → "No type". */
56204
+ function typeLabel(type) {
56205
+ if (type === null) return "No type";
56206
+ const spaced = type.replaceAll("-", " ");
56207
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
56208
+ }
56209
+ var GROUP_BY_LABEL = {
56210
+ zone: "Zone",
56211
+ location: "Location",
56212
+ none: "None"
56213
+ };
56214
+ var FACET_TITLE = {
56215
+ type: "Type",
56216
+ zone: "Zone",
56217
+ location: "Location"
56218
+ };
56219
+ var NO_VALUE_LABEL = {
56220
+ type: "No type",
56221
+ zone: "No zone",
56222
+ location: "No location"
56223
+ };
56224
+ function optionLabel(facet, option) {
56225
+ if (option.key === "(none)" || option.label === null) return NO_VALUE_LABEL[facet];
56226
+ return facet === "type" ? typeLabel(option.label) : option.label;
56227
+ }
56228
+ /** "Group by: Zone | Location | None", and why Zone is showing locations. */
56229
+ function GroupByControl({ grouping }) {
56230
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56231
+ className: "mb-1.5",
56232
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56233
+ role: "radiogroup",
56234
+ "aria-label": "Group by",
56235
+ className: "flex flex-wrap items-center gap-1 text-[10px] text-foreground-subtle",
56236
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56237
+ className: "mr-1 font-semibold uppercase tracking-wide",
56238
+ children: "Group by"
56239
+ }), PICKER_GROUP_BY_MODES.map((mode) => {
56240
+ const active = mode === grouping.groupBy;
56241
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
56242
+ type: "button",
56243
+ role: "radio",
56244
+ "aria-checked": active,
56245
+ onClick: () => grouping.onGroupByChange(mode),
56246
+ className: active ? "rounded-md border border-primary bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary" : "rounded-md border border-border px-1.5 py-0.5 text-[11px] text-foreground-subtle hover:bg-surface-hover",
56247
+ children: GROUP_BY_LABEL[mode]
56248
+ }, mode);
56249
+ })]
56250
+ }), grouping.fallback !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
56251
+ className: "mt-0.5 text-[10px] text-foreground-subtle",
56252
+ "data-testid": "picker-zones-note",
56253
+ children: grouping.fallback === "pending" ? "Loading zones — grouped by location for now" : "Zones unavailable — grouped by location"
56254
+ })]
56255
+ });
56256
+ }
56257
+ function FacetDropdown({ facet, options, chosen, onChange, unavailable, disabled }) {
56258
+ const title = FACET_TITLE[facet];
56259
+ const summary = unavailable !== void 0 ? unavailable : chosen.length === 0 ? "All" : `${String(chosen.length)} selected`;
56260
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
56261
+ "data-testid": `picker-filter-${facet}`,
56262
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Popover, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(PopoverTrigger, {
56263
+ disabled: disabled || unavailable !== void 0,
56264
+ className: "rounded-md border border-border px-1.5 py-0.5 text-[11px] text-foreground hover:bg-surface-hover disabled:opacity-50",
56265
+ children: [
56266
+ title,
56267
+ ": ",
56268
+ summary
56269
+ ]
56270
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PopoverContent, {
56271
+ className: "min-w-[200px] max-h-[40vh] overflow-y-auto p-1",
56272
+ children: options.map((option) => {
56273
+ const label = optionLabel(facet, option);
56274
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
56275
+ className: "flex cursor-pointer items-center gap-2 px-2 py-1 text-xs text-foreground hover:bg-surface-hover",
56276
+ children: [
56277
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
56278
+ checked: chosen.includes(option.key),
56279
+ onChange: () => onChange(togglePickerKey(chosen, option.key)),
56280
+ "aria-label": label
56281
+ }),
56282
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56283
+ className: "min-w-0 flex-1 truncate",
56284
+ children: label
56285
+ }),
56286
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
56287
+ className: "text-[10px] text-foreground-subtle",
56288
+ children: [
56289
+ "(",
56290
+ option.count,
56291
+ ")"
56292
+ ]
56293
+ })
56294
+ ]
56295
+ }, option.key);
56296
+ })
56297
+ })] })
56298
+ });
56299
+ }
56300
+ /** Why the Zone dropdown cannot be used, or undefined when the read answered. */
56301
+ function zoneUnavailable(zones) {
56302
+ if (zones.state === "pending") return "loading zones…";
56303
+ if (zones.state === "failed") return "zones unavailable";
56304
+ }
56305
+ function DeviceFilterBar({ filters, onFiltersChange, typeOptions, zoneOptions, locationOptions, zones, grouping, searchPlaceholder, searchLabel, disabled = false }) {
56306
+ const [text, setText] = (0, react$1.useState)(filters.query);
56307
+ const latest = (0, react$1.useRef)({
56308
+ filters,
56309
+ onFiltersChange
56310
+ });
56311
+ latest.current = {
56312
+ filters,
56313
+ onFiltersChange
56314
+ };
56315
+ (0, react$1.useEffect)(() => {
56316
+ if (text === latest.current.filters.query) return;
56317
+ const timer = setTimeout(() => latest.current.onFiltersChange({
56318
+ ...latest.current.filters,
56319
+ query: text
56320
+ }), 200);
56321
+ return () => clearTimeout(timer);
56322
+ }, [text]);
56323
+ const unavailable = zoneUnavailable(zones);
56324
+ const zoneShown = unavailable !== void 0 || pickerFacetFilterShown(zoneOptions, filters.zones);
56325
+ const active = pickerFiltersActive(filters) || text.trim() !== "";
56326
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56327
+ className: "space-y-1.5",
56328
+ "data-testid": "device-filter-bar",
56329
+ children: [
56330
+ grouping !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GroupByControl, { grouping }),
56331
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56332
+ className: INPUT_SHELL,
56333
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Search, { className: "h-3.5 w-3.5 shrink-0 text-foreground-subtle" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
56334
+ type: "text",
56335
+ value: text,
56336
+ placeholder: searchPlaceholder,
56337
+ "aria-label": searchLabel,
56338
+ className: "min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-foreground-disabled",
56339
+ onChange: (e) => setText(e.target.value)
56340
+ })]
56341
+ }),
56342
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56343
+ className: "flex flex-wrap items-center gap-1",
56344
+ children: [
56345
+ pickerFacetFilterShown(typeOptions, filters.types) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown, {
56346
+ facet: "type",
56347
+ options: typeOptions,
56348
+ chosen: filters.types,
56349
+ onChange: (types) => onFiltersChange({
56350
+ ...filters,
56351
+ types
56352
+ }),
56353
+ disabled
56354
+ }),
56355
+ zoneShown && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown, {
56356
+ facet: "zone",
56357
+ options: zoneOptions,
56358
+ chosen: filters.zones,
56359
+ onChange: (next) => onFiltersChange({
56360
+ ...filters,
56361
+ zones: next
56362
+ }),
56363
+ ...unavailable !== void 0 ? { unavailable } : {},
56364
+ disabled
56365
+ }),
56366
+ pickerFacetFilterShown(locationOptions, filters.locations) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown, {
56367
+ facet: "location",
56368
+ options: locationOptions,
56369
+ chosen: filters.locations,
56370
+ onChange: (locations) => onFiltersChange({
56371
+ ...filters,
56372
+ locations
56373
+ }),
56374
+ disabled
56375
+ }),
56376
+ active && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
56377
+ variant: "ghost",
56378
+ "data-testid": "picker-reset-filters",
56379
+ onClick: () => {
56380
+ setText("");
56381
+ onFiltersChange(resetPickerFilters());
56382
+ },
56383
+ children: "Reset filters"
56384
+ })
56385
+ ]
56386
+ }),
56387
+ pickerZoneFilterSuspended(filters, zones) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
56388
+ className: "text-[10px] text-foreground-subtle",
56389
+ "data-testid": "picker-zone-filter-paused",
56390
+ children: "Zone filter paused — zones not loaded"
56391
+ })
56392
+ ]
56393
+ });
56394
+ }
56395
+ //#endregion
56396
+ //#region src/composites/device-picker/device-picker-grouping.ts
56397
+ /**
56398
+ * The admin side of the device picker's "Group by" (Zone | Location | None):
56399
+ * the remembered choice, and the site-zone read. What each mode DRAWS is
56400
+ * `device-picker-model.ts` (shared with the viewer); this only supplies what
56401
+ * is side-specific.
56402
+ *
56403
+ * - The choice lives in `localStorage`, one value for every device picker in
56404
+ * this browser. Storage that throws or holds an unknown value is the
56405
+ * default (`zone`), never an error.
56406
+ * - The zones are read whenever the picker's panel is open (the Zone filter
56407
+ * needs them even in Location or None mode) — the caller mounts this with
56408
+ * the panel — and a failure stays a FAILURE: an
56409
+ * empty list would put every device under "No zone", a claim of absence
56410
+ * from a read that did not answer (D315). The model then groups by
56411
+ * location and the panel says why.
56412
+ */
56413
+ var DEVICE_PICKER_GROUP_STORAGE_KEY = "camstack.admin.device-picker.group-by";
56414
+ /**
56415
+ * Under `listSiteZones`'s own prefix, so the Locations page's invalidation of
56416
+ * `[['deviceManager', 'listSiteZones']]` refreshes it too.
56417
+ */
56418
+ var PICKER_ZONES_QUERY_KEY = [["deviceManager", "listSiteZones"], "device-picker"];
56419
+ var PICKER_ZONES_STALE_MS = 6e4;
56420
+ /** The stored choice; the default when storage is absent, throws or is unreadable. */
56421
+ function readStoredGroupBy() {
56422
+ try {
56423
+ const raw = globalThis.localStorage?.getItem("camstack.admin.device-picker.group-by") ?? null;
56424
+ return raw === null ? PICKER_DEFAULT_GROUP_BY : parsePickerGroupBy(raw);
56425
+ } catch {
56426
+ return PICKER_DEFAULT_GROUP_BY;
56427
+ }
56428
+ }
56429
+ /** Remember the choice; a storage that refuses keeps it for this page only. */
56430
+ function writeStoredGroupBy(next) {
56431
+ try {
56432
+ globalThis.localStorage?.setItem(DEVICE_PICKER_GROUP_STORAGE_KEY, next);
56433
+ } catch {}
56434
+ }
56435
+ function useStoredGroupBy() {
56436
+ const [groupBy, setState] = (0, react$1.useState)(readStoredGroupBy);
56437
+ const setGroupBy = (next) => {
56438
+ setState(next);
56439
+ writeStoredGroupBy(next);
56440
+ };
56441
+ return {
56442
+ groupBy,
56443
+ setGroupBy
56444
+ };
56445
+ }
56446
+ /** The site zones, with whether the read ANSWERED — read only while `enabled`. */
56447
+ function usePickerSiteZones(enabled) {
56448
+ const trpcClient = useSystem().trpcClient;
56449
+ const { data, isError } = (0, _tanstack_react_query.useQuery)({
56450
+ queryKey: PICKER_ZONES_QUERY_KEY,
56451
+ enabled,
56452
+ staleTime: PICKER_ZONES_STALE_MS,
56453
+ queryFn: () => trpcClient.deviceManager.listSiteZones.query()
56454
+ });
56455
+ return (0, react$1.useMemo)(() => {
56456
+ if (data !== void 0) return {
56457
+ state: "answered",
56458
+ zones: data
56459
+ };
56460
+ return isError ? { state: "failed" } : { state: "pending" };
56461
+ }, [data, isError]);
56462
+ }
56463
+ //#endregion
56464
+ //#region src/composites/device-picker/device-selection.ts
56465
+ function deviceListStateOf(query) {
56466
+ if (query.data !== void 0) return "answered";
56467
+ return query.isError ? "failed" : "pending";
56468
+ }
56469
+ function resolveSelectedDevices(selectedIds, cameras, state) {
56470
+ if (state !== "answered" || cameras === void 0) return selectedIds.map((id) => ({
56471
+ id,
56472
+ removed: false
56473
+ }));
56474
+ const byId = new Map(cameras.map((cam) => [cam.id, cam.name]));
56475
+ return selectedIds.map((id) => {
56476
+ const name = byId.get(id);
56477
+ return name === void 0 ? {
56478
+ id,
56479
+ removed: true
56480
+ } : {
56481
+ id,
56482
+ name,
56483
+ removed: false
56484
+ };
56485
+ });
56486
+ }
56487
+ /**
56488
+ * The selected ids a picker's DRAWN list does not contain, judged against the
56489
+ * WHOLE fleet — never against the drawn list itself. A camera picker draws
56490
+ * cameras, but a rule's devices can be a sensor picked on another surface: the
56491
+ * camera list lacking it says nothing about whether it still exists. Each row
56492
+ * is `removed` (the answered fleet lacks it), named (the fleet has it) or
56493
+ * unnamed (the fleet has not answered).
56494
+ */
56495
+ function offListSelections(selectedIds, listed, fleet, fleetState) {
56496
+ const listedIds = new Set((listed ?? []).map((device) => device.id));
56497
+ return resolveSelectedDevices(selectedIds.filter((id) => !listedIds.has(id)), fleet, fleetState);
56498
+ }
56499
+ /** The "no cameras" hint: only an ANSWERED empty list with nothing selected. */
56500
+ function showsNoCamerasHint(state, cameraCount, selectedCount) {
56501
+ return state === "answered" && cameraCount === 0 && selectedCount === 0;
56502
+ }
56503
+ /**
56504
+ * The widening warning: on a picker where an empty selection means ALL
56505
+ * devices, an empty selection that once held devices must say so.
56506
+ */
56507
+ function emptyDevicesWidens(emptyMeansAll, selectedCount, hadDevices) {
56508
+ return emptyMeansAll && selectedCount === 0 && hadDevices;
56509
+ }
56510
+ //#endregion
56511
+ //#region src/composites/device-picker/device-selection-rows.tsx
56512
+ /** Each noun's plural. */
56513
+ var PICKER_NOUN_PLURAL = {
56514
+ camera: "cameras",
56515
+ device: "devices",
56516
+ node: "nodes",
56517
+ package: "packages",
56518
+ class: "classes",
56519
+ zone: "zones",
56520
+ face: "faces"
56521
+ };
56522
+ /** The label a picker gives a selected id its drawn list does not contain. */
56523
+ function orphanDeviceLabel(row, noun = "device") {
56524
+ const id = String(row.id);
56525
+ switch (noun) {
56526
+ case "node":
56527
+ if (row.removed) return `Node ${id} — unknown to the hub`;
56528
+ return row.name ?? `Node ${id}`;
56529
+ case "package":
56530
+ if (row.removed) return `${id} — not installed`;
56531
+ return row.name ?? id;
56532
+ case "class":
56533
+ if (row.removed) return `${id} — not in the class list`;
56534
+ return row.name ?? id;
56535
+ case "zone":
56536
+ if (row.removed) return `${id} — zone not found`;
56537
+ return row.name ?? `${id} — zone unavailable`;
56538
+ case "face":
56539
+ if (row.removed) return `${id} — not in the face gallery`;
56540
+ return row.name ?? id;
56541
+ case "camera":
56542
+ case "device":
56543
+ if (row.removed) return `Device #${id} — removed`;
56544
+ return row.name ?? `Device #${id}`;
56545
+ }
56546
+ }
56547
+ /** Said under a picker whose empty selection means EVERY device. */
56548
+ function DevicesWidenWarning({ noun = "camera" }) {
56549
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
56550
+ className: "mt-1 text-[11px] text-warning",
56551
+ "data-testid": "devices-widen-warning",
56552
+ children: [
56553
+ "No ",
56554
+ noun,
56555
+ " selected: the rule will apply to ALL ",
56556
+ PICKER_NOUN_PLURAL[noun],
56557
+ "."
56558
+ ]
56559
+ });
56560
+ }
56561
+ /** A list that has not answered: never an absence claim (D315). */
56562
+ function DevicesLoadingLine({ noun = "cameras" }) {
56563
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
56564
+ className: "text-[11px] text-foreground-subtle",
56565
+ children: [
56566
+ "Loading ",
56567
+ noun,
56568
+ "…"
56569
+ ]
56570
+ });
56571
+ }
56572
+ /** A list that failed: said, while the selected ids stay removable. */
56573
+ function DevicesFailedLine({ noun = "devices" }) {
56574
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
56575
+ className: "text-[11px] text-warning",
56576
+ children: [
56577
+ "The hub did not return its ",
56578
+ noun,
56579
+ " — selected ones are listed by id so they can still be removed."
56580
+ ]
56581
+ });
56582
+ }
56583
+ //#endregion
56584
+ //#region src/composites/device-picker/DevicePicker.tsx
56585
+ /**
56586
+ * The smart device picker every notification editor uses: the selection as
56587
+ * compact chips, and a searchable, grouped list opened only to change it.
56588
+ *
56589
+ * It replaces the long checklists (a rule's devices, a system rule's
56590
+ * include/exclude, the timelapse and digest cameras, the alarm's non-blocking
56591
+ * openings) with the same UX the viewer's picker has (D636). The decisions are
56592
+ * `device-picker-model.ts` (shared with the viewer, byte-identical); what a
56593
+ * selected id the list does not draw IS — removed, named, or unknown — is
56594
+ * `device-selection.ts`, and so are the two rules that shaped it:
56595
+ *
56596
+ * - D315: only a list that ANSWERED may call an id removed or say "no
56597
+ * cameras"; a pending list is a loading line and names nothing absent.
56598
+ * - D49: a read never drops a selected id; every one is a chip the operator
56599
+ * can remove, and only the operator removes one.
56600
+ *
56601
+ * It reads nothing. It filters the list its caller already holds, and judges
56602
+ * an off-list id against the caller's fleet (a camera picker draws cameras,
56603
+ * and a selected sensor missing from it still exists).
56604
+ *
56605
+ * Generic over the id since D637: the system family's node and package pickers
56606
+ * (`scope-pickers.tsx`) are this picker over string ids, with their own noun
56607
+ * and group titles.
56608
+ *
56609
+ * Bounded at hundreds of devices of one type: big groups start collapsed, the
56610
+ * rows are rendered a page at a time with "Show N more", and the chips stop at
56611
+ * `PICKER_CHIP_LIMIT` with a "+N more". `showAll` turns all three off, and the
56612
+ * list is always open: the rule editor's Devices tab exists to show every
56613
+ * device (R15, D690).
56614
+ *
56615
+ * "Group by" (Zone | Location | None) sits at the top of a DEVICE picker's
56616
+ * list only — the node and package pickers keep their own groups, and so does
56617
+ * the CLASS picker (D641), whose groups are the taxonomy's categories. The choice
56618
+ * is remembered in `localStorage`, and the site zones are read whenever the
56619
+ * panel is open (the Zone filter needs them; `device-picker-grouping.ts`); a zone read that has
56620
+ * not answered groups by location with a one-line note, never "No zone"
56621
+ * (D315).
56622
+ */
56623
+ /** A "Group by" group's title: the zone or room, or which one it lacks. */
56624
+ function headingTitle(heading) {
56625
+ switch (heading.kind) {
56626
+ case "zone":
56627
+ case "location": return heading.label;
56628
+ case "no-zone": return "No zone";
56629
+ case "no-location": return "No location";
56630
+ }
56631
+ }
56632
+ function deviceGroupTitle(group) {
56633
+ if (group.heading !== void 0) return headingTitle(group.heading);
56634
+ return `${typeLabel(group.type)} · ${group.location ?? "No room"}`;
56635
+ }
56636
+ /** Whether a picker lists devices — the only kind with "Group by". */
56637
+ function isDeviceNoun(noun) {
56638
+ return noun === "camera" || noun === "device";
56639
+ }
56640
+ function chipsOf(value, devices, fleet, noun) {
56641
+ const byId = new Map(devices.map((device) => [device.id, device.name]));
56642
+ const orphans = new Map(offListSelections(value, devices, fleet.devices, fleet.state).map((row) => [row.id, row]));
56643
+ return value.map((id) => {
56644
+ const name = byId.get(id);
56645
+ if (name !== void 0) return {
56646
+ id,
56647
+ label: name,
56648
+ removed: false
56649
+ };
56650
+ const row = orphans.get(id) ?? {
56651
+ id,
56652
+ removed: false
56653
+ };
56654
+ return {
56655
+ id,
56656
+ label: orphanDeviceLabel(row, noun),
56657
+ removed: row.removed
56658
+ };
56659
+ });
56660
+ }
56661
+ /** The empty-list sentence: the device pickers keep the words they shipped with. */
56662
+ function noItemsSentence(noun, emptyMeansAll) {
56663
+ const plural = PICKER_NOUN_PLURAL[noun];
56664
+ if (!emptyMeansAll) return `No ${plural} available.`;
56665
+ if (noun === "camera" || noun === "device") return "No cameras available — the rule applies to all devices.";
56666
+ return `No ${plural} available — the rule applies to every ${noun}.`;
56667
+ }
56668
+ function DevicePicker({ devices, listState, fleet, value, onChange, emptyMeansAll = false, savedHadDevices = false, noun = "camera", groupTitle = deviceGroupTitle, emptyLabel, annotate, disabled = false, testId, showAll = false }) {
56669
+ const judge = fleet ?? {
56670
+ state: listState,
56671
+ devices: listState === "answered" ? devices : void 0
56672
+ };
56673
+ const [open, setOpen] = (0, react$1.useState)(false);
56674
+ const [chipsExpanded, setChipsExpanded] = (0, react$1.useState)(false);
56675
+ const [hadDevices, setHadDevices] = (0, react$1.useState)(savedHadDevices || value.length > 0);
56676
+ if (value.length > 0 && !hadDevices) setHadDevices(true);
56677
+ const chips = chipsOf(value, devices, judge, noun);
56678
+ const overflow = chipOverflow(chips, chipsExpanded || showAll);
56679
+ const listOpen = open || showAll;
56680
+ const widening = emptyDevicesWidens(emptyMeansAll, value.length, hadDevices);
56681
+ const plural = PICKER_NOUN_PLURAL[noun];
56682
+ if (listState === "answered" && showsNoCamerasHint(listState, devices.length, value.length)) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56683
+ "data-testid": testId,
56684
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
56685
+ className: "text-[10px] text-foreground-subtle mt-0.5",
56686
+ children: noItemsSentence(noun, emptyMeansAll)
56687
+ }), widening && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DevicesWidenWarning, { noun })]
56688
+ });
56689
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56690
+ className: "mt-1 space-y-1.5",
56691
+ "data-testid": testId,
56692
+ children: [
56693
+ listState === "pending" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DevicesLoadingLine, { noun: plural }),
56694
+ listState === "failed" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DevicesFailedLine, { noun: noun === "camera" ? "devices" : plural }),
56695
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56696
+ className: "flex flex-wrap items-center gap-1",
56697
+ children: [
56698
+ chips.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56699
+ className: "text-[11px] text-foreground-subtle",
56700
+ children: emptyLabel ?? (emptyMeansAll ? `Every ${noun} — no restriction` : "None selected")
56701
+ }) : overflow.shown.map((chip) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SelectedChip, {
56702
+ testId: `picker-chip-${String(chip.id)}`,
56703
+ label: chip.label,
56704
+ warning: chip.removed,
56705
+ ...!disabled ? { onRemove: () => onChange(value.filter((id) => id !== chip.id)) } : {}
56706
+ }, chip.id)),
56707
+ overflow.hidden > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
56708
+ type: "button",
56709
+ className: "rounded-md border border-border px-1.5 py-0.5 text-[11px] text-foreground-subtle hover:bg-surface-hover",
56710
+ onClick: () => setChipsExpanded(true),
56711
+ children: [
56712
+ "+",
56713
+ overflow.hidden,
56714
+ " more"
56715
+ ]
56716
+ }),
56717
+ chipsExpanded && chips.length > 6 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
56718
+ type: "button",
56719
+ className: "text-[11px] text-foreground-subtle underline",
56720
+ onClick: () => setChipsExpanded(false),
56721
+ children: "Show fewer"
56722
+ }),
56723
+ listState !== "pending" && !showAll && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
56724
+ type: "button",
56725
+ disabled,
56726
+ "aria-expanded": open,
56727
+ onClick: () => setOpen((o) => !o),
56728
+ className: "rounded-md border border-dashed border-border px-1.5 py-0.5 text-[11px] text-foreground hover:bg-surface-hover disabled:opacity-50",
56729
+ children: open ? "Done" : value.length === 0 ? `Add ${plural}…` : "Edit…"
56730
+ })
56731
+ ]
56732
+ }),
56733
+ listOpen && listState === "answered" && (isDeviceNoun(noun) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GroupedPickerPanel, {
56734
+ devices,
56735
+ value,
56736
+ onChange,
56737
+ annotate,
56738
+ groupTitle,
56739
+ noun,
56740
+ disabled,
56741
+ showAll
56742
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PickerPanel, {
56743
+ devices,
56744
+ value,
56745
+ onChange,
56746
+ annotate,
56747
+ groupTitle,
56748
+ noun,
56749
+ disabled,
56750
+ showAll
56751
+ })),
56752
+ widening && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DevicesWidenWarning, { noun })
56753
+ ]
56754
+ });
56755
+ }
56756
+ /**
56757
+ * The device picker's panel: mounted only while the list is open, so the zone
56758
+ * read below happens only then (for Zone grouping and the Zone filter).
56759
+ */
56760
+ function GroupedPickerPanel(props) {
56761
+ const { groupBy, setGroupBy } = useStoredGroupBy();
56762
+ const zones = usePickerSiteZones(true);
56763
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PickerPanel, {
56764
+ ...props,
56765
+ grouping: {
56766
+ groupBy,
56767
+ onGroupByChange: setGroupBy,
56768
+ zones
56769
+ }
56770
+ });
56771
+ }
56772
+ /** The search box's hint: a device has a room and a type, a class a category, a node or package an id. */
56773
+ function searchPlaceholder(noun) {
56774
+ if (noun === "camera" || noun === "device") return "Search by name, room or type…";
56775
+ if (noun === "class") return "Search by class or category…";
56776
+ if (noun === "zone") return "Search by camera or zone…";
56777
+ return "Search by name or id…";
56778
+ }
56779
+ /** An answered read with no zones: what the pickers without "Group by" are judged against. */
56780
+ var NO_ZONES = {
56781
+ state: "answered",
56782
+ zones: []
56783
+ };
56784
+ /** The searchable, grouped list — rendered one page at a time. */
56785
+ function PickerPanel({ devices, value, onChange, annotate, groupTitle, noun, disabled, showAll, grouping }) {
56786
+ const [filters, setFilters] = (0, react$1.useState)(PICKER_NO_FILTERS);
56787
+ const [toggles, setToggles] = (0, react$1.useState)(/* @__PURE__ */ new Map());
56788
+ const [shown, setShown] = (0, react$1.useState)(60);
56789
+ const searching = showAll || pickerQueryTokens(filters.query).length > 0;
56790
+ const groupBy = grouping?.groupBy;
56791
+ const zones = grouping?.zones ?? NO_ZONES;
56792
+ const matches = (0, react$1.useMemo)(() => applyPickerFilters(devices, filters, zones), [
56793
+ devices,
56794
+ filters,
56795
+ zones
56796
+ ]);
56797
+ const grouped = (0, react$1.useMemo)(() => groupBy !== void 0 ? groupPickerDevicesBy(matches, value, groupBy, zones) : null, [
56798
+ matches,
56799
+ value,
56800
+ groupBy,
56801
+ zones
56802
+ ]);
56803
+ const rows = (0, react$1.useMemo)(() => grouped !== null ? flattenPickerGrouping(grouped, value, toggles, searching) : flattenPickerRows(groupPickerDevices(matches, value), value, toggles, searching), [
56804
+ grouped,
56805
+ matches,
56806
+ value,
56807
+ toggles,
56808
+ searching
56809
+ ]);
56810
+ const shownBy = grouped?.groupBy ?? null;
56811
+ const typeOptions = (0, react$1.useMemo)(() => pickerFacetOptions(devices, "type", filters.types, zones), [
56812
+ devices,
56813
+ filters.types,
56814
+ zones
56815
+ ]);
56816
+ const zoneOptions = (0, react$1.useMemo)(() => pickerFacetOptions(devices, "zone", filters.zones, zones), [
56817
+ devices,
56818
+ filters.zones,
56819
+ zones
56820
+ ]);
56821
+ const locationOptions = (0, react$1.useMemo)(() => grouping === void 0 ? [] : pickerFacetOptions(devices, "location", filters.locations, zones), [
56822
+ devices,
56823
+ filters.locations,
56824
+ zones,
56825
+ grouping
56826
+ ]);
56827
+ const hiddenSelected = (0, react$1.useMemo)(() => pickerHiddenSelectedCount(value, devices, matches), [
56828
+ devices,
56829
+ value,
56830
+ matches
56831
+ ]);
56832
+ const page = pageRows(rows, showAll ? rows.length : shown);
56833
+ const toggleGroup = (group, expanded) => setToggles((current) => new Map([...current, [group.key, !expanded]]));
56834
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56835
+ className: "rounded-md border border-border bg-surface p-2",
56836
+ "data-testid": "device-picker-panel",
56837
+ children: [
56838
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceFilterBar, {
56839
+ filters,
56840
+ onFiltersChange: (next) => {
56841
+ setFilters(next);
56842
+ setShown(60);
56843
+ },
56844
+ typeOptions,
56845
+ zoneOptions,
56846
+ locationOptions,
56847
+ zones,
56848
+ ...grouping !== void 0 ? { grouping: {
56849
+ groupBy: grouping.groupBy,
56850
+ onGroupByChange: grouping.onGroupByChange,
56851
+ fallback: grouped?.fallback ?? null
56852
+ } } : {},
56853
+ searchPlaceholder: searchPlaceholder(noun),
56854
+ searchLabel: noun === "camera" || noun === "device" ? "Search devices" : `Search ${PICKER_NOUN_PLURAL[noun]}`,
56855
+ disabled
56856
+ }),
56857
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
56858
+ className: "mt-1 text-[10px] text-foreground-subtle",
56859
+ children: [
56860
+ matches.length,
56861
+ " of ",
56862
+ devices.length,
56863
+ " shown · ",
56864
+ value.length,
56865
+ " selected"
56866
+ ]
56867
+ }),
56868
+ rows.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
56869
+ className: "py-2 text-[11px] text-foreground-subtle italic",
56870
+ children: noun === "camera" || noun === "device" ? "No device matches." : `No ${noun} matches.`
56871
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56872
+ className: showAll ? "mt-1" : "mt-1 max-h-72 overflow-y-auto",
56873
+ "data-testid": "device-picker-rows",
56874
+ children: [page.rows.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PickerRowView, {
56875
+ row,
56876
+ value,
56877
+ onChange,
56878
+ onToggleGroup: toggleGroup,
56879
+ annotate,
56880
+ groupTitle,
56881
+ groupBy: shownBy,
56882
+ disabled
56883
+ }, row.key)), page.remaining > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
56884
+ type: "button",
56885
+ className: "mt-1 w-full rounded border border-border py-1 text-[11px] text-foreground-subtle hover:bg-surface-hover",
56886
+ onClick: () => setShown((n) => n + 60),
56887
+ children: [
56888
+ "Show ",
56889
+ Math.min(page.remaining, 60),
56890
+ " more (",
56891
+ page.remaining,
56892
+ " left)"
56893
+ ]
56894
+ })]
56895
+ }),
56896
+ hiddenSelected > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
56897
+ className: "mt-1 text-[10px] text-foreground-subtle",
56898
+ "data-testid": "picker-hidden-selected",
56899
+ children: [hiddenSelected, " selected hidden by filters"]
56900
+ })
56901
+ ]
56902
+ });
56903
+ }
56904
+ /** The second line: under "Group by" the room (where the header lacks it) and type, then the container. */
56905
+ function secondLine(device, groupBy) {
56906
+ if (groupBy === null) return device.detail;
56907
+ const facts = pickerRowFacts(device, groupBy);
56908
+ const parts = [
56909
+ facts.location,
56910
+ facts.type === null ? null : typeLabel(facts.type),
56911
+ device.detail ?? null
56912
+ ].filter((part) => part !== null);
56913
+ return parts.length > 0 ? parts.join(" · ") : void 0;
56914
+ }
56915
+ function PickerRowView({ row, value, onChange, onToggleGroup, annotate, groupTitle, groupBy, disabled }) {
56916
+ if (row.kind === "header") {
56917
+ const { group, expanded } = row;
56918
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56919
+ className: "flex items-center gap-2 border-b border-border px-1 py-1",
56920
+ "data-testid": "device-picker-group",
56921
+ children: [
56922
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
56923
+ type: "button",
56924
+ "aria-expanded": expanded,
56925
+ onClick: () => onToggleGroup(group, expanded),
56926
+ className: "flex min-w-0 flex-1 items-center gap-1 text-left text-[10px] font-semibold uppercase tracking-wide text-foreground-subtle",
56927
+ children: [
56928
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: expanded ? "▾" : "▸" }),
56929
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56930
+ className: "truncate",
56931
+ children: groupTitle(group)
56932
+ }),
56933
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
56934
+ className: "font-normal normal-case",
56935
+ children: [
56936
+ "(",
56937
+ group.selectedCount,
56938
+ "/",
56939
+ group.devices.length,
56940
+ ")"
56941
+ ]
56942
+ })
56943
+ ]
56944
+ }),
56945
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
56946
+ type: "button",
56947
+ disabled,
56948
+ className: "text-[10px] text-primary hover:underline disabled:opacity-50",
56949
+ onClick: () => onChange(selectPickerGroup(value, group)),
56950
+ children: "All"
56951
+ }),
56952
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
56953
+ type: "button",
56954
+ disabled,
56955
+ className: "text-[10px] text-foreground-subtle hover:underline disabled:opacity-50",
56956
+ onClick: () => onChange(clearPickerGroup(value, group)),
56957
+ children: "Clear"
56958
+ })
56959
+ ]
56960
+ });
56961
+ }
56962
+ const note = annotate?.(row.device.id);
56963
+ const second = secondLine(row.device, groupBy);
56964
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
56965
+ className: "flex cursor-pointer items-center gap-2.5 px-3 py-1 text-xs text-foreground hover:bg-surface-hover",
56966
+ "data-testid": "device-picker-row",
56967
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
56968
+ type: "checkbox",
56969
+ checked: row.selected,
56970
+ disabled,
56971
+ onChange: () => onChange(togglePickerDevice(value, row.device.id)),
56972
+ className: "accent-primary",
56973
+ "data-device-id": String(row.device.id)
56974
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
56975
+ className: "flex min-w-0 flex-1 flex-col",
56976
+ children: [
56977
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56978
+ className: "truncate",
56979
+ children: row.device.name
56980
+ }),
56981
+ second !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56982
+ className: "truncate text-[10px] text-foreground-subtle",
56983
+ children: second
56984
+ }),
56985
+ note !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56986
+ className: "text-[10px] text-warning",
56987
+ children: note
56988
+ })
56989
+ ]
56990
+ })]
56991
+ });
56992
+ }
56993
+ //#endregion
56994
+ //#region src/composites/unit-picker/UnitPicker.tsx
56995
+ /**
56996
+ * Same-dimension unit picker. Lists only `unitsForDimension(dimension)`; an
56997
+ * empty dimension disables the control and shows `emptyReason` so the operator
56998
+ * never sees a refused conversion as a pickable option.
56999
+ */
57000
+ var FIELD_CLASS = "w-full min-w-0 px-1.5 py-0.5 text-[11px] bg-background border border-border rounded focus:outline-none focus:border-primary disabled:opacity-50 disabled:cursor-not-allowed";
57001
+ function UnitPicker({ dimension, value, onChange, disabled = false, emptyReason, inheritLabel, className, id, "aria-label": ariaLabel }) {
57002
+ const choices = dimension !== void 0 ? (0, _camstack_types.unitsForDimension)(dimension) : [];
57003
+ const isEmpty = dimension === void 0;
57004
+ const selectDisabled = disabled || isEmpty;
57005
+ const handleChange = (event) => {
57006
+ const raw = event.target.value;
57007
+ if (raw === "") {
57008
+ onChange("");
57009
+ return;
57010
+ }
57011
+ onChange((0, _camstack_types.normalizeUnit)(raw) ?? raw);
57012
+ };
57013
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
57014
+ className: "flex flex-col gap-0.5 min-w-0 w-full",
57015
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
57016
+ id,
57017
+ className: className ?? FIELD_CLASS,
57018
+ "aria-label": ariaLabel,
57019
+ value,
57020
+ onChange: handleChange,
57021
+ disabled: selectDisabled,
57022
+ children: [inheritLabel !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
57023
+ value: "",
57024
+ children: inheritLabel
57025
+ }), choices.map((unit) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
57026
+ value: unit,
57027
+ children: unit
57028
+ }, unit))]
57029
+ }), isEmpty && emptyReason !== void 0 && emptyReason !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
57030
+ className: "text-[10px] text-foreground-subtle",
57031
+ children: emptyReason
57032
+ })]
57033
+ });
57034
+ }
57035
+ //#endregion
57036
+ //#region src/composites/placeholder-field/PlaceholderChipField.tsx
57037
+ /**
57038
+ * Text field with insertable placeholder chips — the same interaction model
57039
+ * as Notification Center's TemplateField, without the NC variable catalog.
57040
+ */
57041
+ var LABEL_CLASS = "block text-[11px] font-medium text-foreground mb-1";
57042
+ var TEXTAREA_CLASS = "w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-1 focus:ring-primary";
57043
+ function insertAtSelection(value, selection, token) {
57044
+ const before = value.slice(0, selection.start);
57045
+ return {
57046
+ value: `${before}${token}${value.slice(selection.end)}`,
57047
+ cursor: before.length + token.length
57048
+ };
57049
+ }
57050
+ function PlaceholderChipField({ label, value, onChange, chips, multiline = false, placeholder, disabled = false, testId, maxLength }) {
57051
+ const inputRef = (0, react$1.useRef)(null);
57052
+ const areaRef = (0, react$1.useRef)(null);
57053
+ const insert = (token) => {
57054
+ const element = multiline ? areaRef.current : inputRef.current;
57055
+ const result = insertAtSelection(value, {
57056
+ start: element?.selectionStart ?? value.length,
57057
+ end: element?.selectionEnd ?? value.length
57058
+ }, token);
57059
+ onChange(result.value);
57060
+ requestAnimationFrame(() => {
57061
+ element?.focus();
57062
+ element?.setSelectionRange(result.cursor, result.cursor);
57063
+ });
57064
+ };
57065
+ const handle = (e) => onChange(e.target.value);
57066
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
57067
+ className: "space-y-1",
57068
+ "data-testid": testId,
57069
+ children: [
57070
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
57071
+ className: LABEL_CLASS,
57072
+ children: label
57073
+ }),
57074
+ multiline ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
57075
+ ref: areaRef,
57076
+ value,
57077
+ rows: 2,
57078
+ disabled,
57079
+ placeholder,
57080
+ maxLength,
57081
+ onChange: handle,
57082
+ className: TEXTAREA_CLASS,
57083
+ "data-testid": `${testId}-input`
57084
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
57085
+ ref: inputRef,
57086
+ value,
57087
+ disabled,
57088
+ placeholder,
57089
+ maxLength,
57090
+ onChange: handle,
57091
+ "data-testid": `${testId}-input`
57092
+ }),
57093
+ chips !== void 0 && chips.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
57094
+ className: "flex flex-wrap gap-1 pt-0.5",
57095
+ "data-testid": `${testId}-chips`,
57096
+ children: chips.map((chip) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
57097
+ type: "button",
57098
+ disabled,
57099
+ className: "rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary hover:bg-primary/20 disabled:opacity-50",
57100
+ onClick: () => insert(chip.token),
57101
+ "data-testid": `${testId}-chip-${chip.id}`,
57102
+ children: chip.label
57103
+ }, chip.id))
57104
+ })
57105
+ ]
57106
+ });
57107
+ }
57108
+ //#endregion
55742
57109
  //#region src/contexts/zone-editing.tsx
55743
57110
  /**
55744
57111
  * Per-device zone-editing state shared between the player overlay
@@ -56756,6 +58123,7 @@ exports.DEFAULT_COLOR = DEFAULT_COLOR;
56756
58123
  exports.DEVICE_COLUMNS = DEVICE_COLUMNS;
56757
58124
  exports.DEVICE_LIST_PAGE_SIZE_KEY = DEVICE_LIST_PAGE_SIZE_KEY;
56758
58125
  exports.DEVICE_LIST_PAGE_SIZE_OPTIONS = DEVICE_LIST_PAGE_SIZE_OPTIONS;
58126
+ exports.DEVICE_PICKER_GROUP_STORAGE_KEY = DEVICE_PICKER_GROUP_STORAGE_KEY;
56759
58127
  exports.DEVICE_ROLE_META = DEVICE_ROLE_META;
56760
58128
  exports.DEVICE_TYPE_CONTROL = DEVICE_TYPE_CONTROL;
56761
58129
  exports.DEVICE_TYPE_META = DEVICE_TYPE_META;
@@ -56771,13 +58139,18 @@ exports.DeviceBatchToolbar = DeviceBatchToolbar;
56771
58139
  exports.DeviceCard = DeviceCard;
56772
58140
  exports.DeviceContextProvider = DeviceContextProvider;
56773
58141
  exports.DeviceExportPanel = DeviceExportPanel;
58142
+ exports.DeviceFilterBar = DeviceFilterBar;
56774
58143
  exports.DeviceGrid = DeviceGrid;
56775
58144
  exports.DeviceItem = DeviceItem;
56776
58145
  exports.DeviceList = DeviceList;
56777
58146
  exports.DeviceMultiSelectField = DeviceMultiSelectField;
58147
+ exports.DevicePicker = DevicePicker;
56778
58148
  exports.DeviceSelectField = DeviceSelectField;
56779
58149
  exports.DeviceSelectorPicker = DeviceSelectorPicker;
56780
58150
  exports.DeviceStepMatrix = DeviceStepMatrix;
58151
+ exports.DevicesFailedLine = DevicesFailedLine;
58152
+ exports.DevicesLoadingLine = DevicesLoadingLine;
58153
+ exports.DevicesWidenWarning = DevicesWidenWarning;
56781
58154
  exports.Dialog = Dialog;
56782
58155
  exports.DialogBody = DialogBody;
56783
58156
  exports.DialogContent = DialogContent;
@@ -56826,6 +58199,7 @@ exports.HoverZoomImage = HoverZoomImage;
56826
58199
  exports.HumidifierHeroCard = HumidifierHeroCard;
56827
58200
  exports.HumidifierInlineControl = HumidifierInlineControl;
56828
58201
  exports.INPUT_COMPACT = INPUT_COMPACT;
58202
+ exports.INPUT_SHELL = INPUT_SHELL;
56829
58203
  exports.IconAction = IconAction;
56830
58204
  exports.IconButton = IconButton;
56831
58205
  exports.IconPicker = IconPicker;
@@ -56866,6 +58240,16 @@ exports.NodePicker = NodePicker;
56866
58240
  exports.NodeSelectField = NodeSelectField;
56867
58241
  exports.OfflineBadge = OfflineBadge;
56868
58242
  exports.PHASE_CONFIG = PHASE_CONFIG;
58243
+ exports.PICKER_CHIP_LIMIT = PICKER_CHIP_LIMIT;
58244
+ exports.PICKER_DEFAULT_GROUP_BY = PICKER_DEFAULT_GROUP_BY;
58245
+ exports.PICKER_GROUP_BY_MODES = PICKER_GROUP_BY_MODES;
58246
+ exports.PICKER_GROUP_COLLAPSE_AT = PICKER_GROUP_COLLAPSE_AT;
58247
+ exports.PICKER_NONE_KEY = PICKER_NONE_KEY;
58248
+ exports.PICKER_NOUN_PLURAL = PICKER_NOUN_PLURAL;
58249
+ exports.PICKER_NO_FILTERS = PICKER_NO_FILTERS;
58250
+ exports.PICKER_PAGE_SIZE = PICKER_PAGE_SIZE;
58251
+ exports.PICKER_SEARCH_DEBOUNCE_MS = PICKER_SEARCH_DEBOUNCE_MS;
58252
+ exports.PICKER_WINDOW = PICKER_WINDOW;
56869
58253
  exports.PRIORITY = PRIORITY;
56870
58254
  exports.PTZOverlay = PTZOverlay;
56871
58255
  exports.PageHeader = PageHeader;
@@ -56874,6 +58258,7 @@ exports.PipelineBuilder = PipelineBuilder;
56874
58258
  exports.PipelineRuntimeSelector = PipelineRuntimeSelector;
56875
58259
  exports.PipelineStep = PipelineStep;
56876
58260
  exports.PipelineTreeMatrix = PipelineTreeMatrix;
58261
+ exports.PlaceholderChipField = PlaceholderChipField;
56877
58262
  exports.PlayerOverlaysProvider = PlayerOverlaysProvider;
56878
58263
  exports.Popover = Popover;
56879
58264
  exports.PopoverContent = PopoverContent;
@@ -56917,6 +58302,7 @@ exports.ScriptHeroCard = ScriptHeroCard;
56917
58302
  exports.ScriptInlineControl = ScriptInlineControl;
56918
58303
  exports.ScrollArea = ScrollArea;
56919
58304
  exports.Select = Select;
58305
+ exports.SelectedChip = SelectedChip;
56920
58306
  exports.SemanticBadge = SemanticBadge;
56921
58307
  exports.SensorHeroCard = SensorHeroCard;
56922
58308
  exports.SensorInlineControl = SensorInlineControl;
@@ -56965,6 +58351,7 @@ exports.Tooltip = Tooltip;
56965
58351
  exports.TooltipContent = TooltipContent;
56966
58352
  exports.TooltipTrigger = TooltipTrigger;
56967
58353
  exports.Trash2 = Trash2;
58354
+ exports.UnitPicker = UnitPicker;
56968
58355
  exports.VacuumHeroCard = VacuumHeroCard;
56969
58356
  exports.VacuumInlineControl = VacuumInlineControl;
56970
58357
  exports.ValueReadout = ValueReadout;
@@ -56984,13 +58371,16 @@ exports.ZoneEditingProvider = ZoneEditingProvider;
56984
58371
  exports.agentColumnKey = agentColumnKey;
56985
58372
  exports.allDeviceTypeFilterOptions = allDeviceTypeFilterOptions;
56986
58373
  exports.applyFleetQueryDefaults = applyFleetQueryDefaults;
58374
+ exports.applyPickerFilters = applyPickerFilters;
56987
58375
  exports.ariaSortForColumn = ariaSortForColumn;
56988
58376
  exports.badgeFor = badgeFor;
56989
58377
  exports.badgeFor$1 = badgeFor;
56990
58378
  exports.buildStepTreeFromSchema = buildStepTreeFromSchema;
56991
58379
  exports.childEntityId = childEntityId;
56992
58380
  exports.childListName = childListName;
58381
+ exports.chipOverflow = chipOverflow;
56993
58382
  exports.classifyCapability = classifyCapability;
58383
+ exports.clearPickerGroup = clearPickerGroup;
56994
58384
  exports.cn = cn;
56995
58385
  exports.columnsForContext = columnsForContext;
56996
58386
  exports.containerChildToRef = containerChildToRef;
@@ -57009,6 +58399,7 @@ exports.deriveDeviceKind = deriveDeviceKind;
57009
58399
  exports.deriveWebrtcDebugStats = deriveWebrtcDebugStats;
57010
58400
  exports.describeDeviceSelector = describeDeviceSelector;
57011
58401
  exports.deviceIntegrationResolver = deviceIntegrationResolver;
58402
+ exports.deviceListStateOf = deviceListStateOf;
57012
58403
  exports.deviceMatchesFilter = deviceMatchesFilter;
57013
58404
  exports.deviceOptionLabel = deviceOptionLabel;
57014
58405
  exports.deviceRoleMeta = deviceRoleMeta;
@@ -57017,11 +58408,15 @@ exports.deviceTypeLabel = deviceTypeLabel;
57017
58408
  exports.deviceTypeMeta = deviceTypeMeta;
57018
58409
  exports.deviceTypeMetaOf = deviceTypeMetaOf;
57019
58410
  exports.devicesToOptions = devicesToOptions;
58411
+ exports.emptyDevicesWidens = emptyDevicesWidens;
57020
58412
  exports.ensureMfHostInit = ensureMfHostInit;
57021
58413
  exports.eventKindLabel = eventKindLabel;
57022
58414
  exports.filterDataTableRows = filterDataTableRows;
57023
58415
  exports.filterDeviceOptions = filterDeviceOptions;
58416
+ exports.filterPickerDevices = filterPickerDevices;
57024
58417
  exports.findTimezone = _camstack_types.findTimezone;
58418
+ exports.flattenPickerGrouping = flattenPickerGrouping;
58419
+ exports.flattenPickerRows = flattenPickerRows;
57025
58420
  exports.formatControlDateTime = formatControlDateTime;
57026
58421
  exports.formatLastSeen = formatLastSeen;
57027
58422
  exports.formatNumeric = formatNumeric;
@@ -57031,6 +58426,8 @@ exports.getClassColor = getClassColor;
57031
58426
  exports.getPhaseVisual = getPhaseVisual;
57032
58427
  exports.groupAgentColumns = groupAgentColumns;
57033
58428
  exports.groupChildrenByLayout = groupChildrenByLayout;
58429
+ exports.groupPickerDevices = groupPickerDevices;
58430
+ exports.groupPickerDevicesBy = groupPickerDevicesBy;
57034
58431
  exports.hardwareLabel = hardwareLabel;
57035
58432
  exports.hubOrigin = hubOrigin;
57036
58433
  exports.hubPath = hubPath;
@@ -57040,10 +58437,12 @@ exports.hubWsUrl = hubWsUrl;
57040
58437
  exports.humanDuration = humanDuration;
57041
58438
  exports.humidifierTint = humidifierTint;
57042
58439
  exports.initialScrubState = initialScrubState;
58440
+ exports.insertAtSelection = insertAtSelection;
57043
58441
  exports.isAbsentProvider = isAbsentProvider;
57044
58442
  exports.isAbsentProvider$1 = isAbsentProvider;
57045
58443
  exports.isDataTableFilterActive = isDataTableFilterActive;
57046
58444
  exports.isFieldVisible = isFieldVisible;
58445
+ exports.isGroupExpanded = isGroupExpanded;
57047
58446
  exports.lawnMowerActivityMeta = lawnMowerActivityMeta;
57048
58447
  exports.lightColors = require_theme_index.lightColors$1;
57049
58448
  exports.loadRemoteBundle = loadRemoteBundle;
@@ -57058,11 +58457,28 @@ exports.nextSort = nextSort;
57058
58457
  exports.nextSortDirection = nextSortDirection;
57059
58458
  exports.nextTableSort = nextTableSort;
57060
58459
  exports.normalizeForSearch = normalizeForSearch;
58460
+ exports.offListSelections = offListSelections;
57061
58461
  exports.openTopLayer = openTopLayer;
58462
+ exports.orphanDeviceLabel = orphanDeviceLabel;
57062
58463
  exports.overrideEntityIdFromLink = overrideEntityIdFromLink;
58464
+ exports.pageRows = pageRows;
58465
+ exports.parsePickerGroupBy = parsePickerGroupBy;
57063
58466
  exports.parseRecordedServerMessage = parseRecordedServerMessage;
58467
+ exports.pickerFacetFilterShown = pickerFacetFilterShown;
58468
+ exports.pickerFacetKey = pickerFacetKey;
58469
+ exports.pickerFacetOptions = pickerFacetOptions;
58470
+ exports.pickerFiltersActive = pickerFiltersActive;
58471
+ exports.pickerGroupKey = pickerGroupKey;
58472
+ exports.pickerHiddenSelectedCount = pickerHiddenSelectedCount;
58473
+ exports.pickerLocationKey = pickerLocationKey;
58474
+ exports.pickerQueryTokens = pickerQueryTokens;
58475
+ exports.pickerRowFacts = pickerRowFacts;
58476
+ exports.pickerSearchText = pickerSearchText;
58477
+ exports.pickerZoneFilterSuspended = pickerZoneFilterSuspended;
57064
58478
  exports.providerIcons = providerIcons;
58479
+ exports.readStoredGroupBy = readStoredGroupBy;
57065
58480
  exports.resetHubMountForTests = resetHubMountForTests;
58481
+ exports.resetPickerFilters = resetPickerFilters;
57066
58482
  exports.resolveContainerPrimary = resolveContainerPrimary;
57067
58483
  exports.resolveControlAlign = resolveControlAlign;
57068
58484
  exports.resolveDeviceControl = resolveDeviceControl;
@@ -57072,6 +58488,7 @@ exports.resolveEffectiveDefaultModel = resolveEffectiveDefaultModel;
57072
58488
  exports.resolveEffectiveStepModel = resolveEffectiveStepModel;
57073
58489
  exports.resolveEventKindIcon = resolveEventKindIcon;
57074
58490
  exports.resolvePrimaryChild = resolvePrimaryChild;
58491
+ exports.resolveSelectedDevices = resolveSelectedDevices;
57075
58492
  exports.resolveSensorDisplay = resolveSensorDisplay;
57076
58493
  exports.resolveStepDefaultModel = resolveStepDefaultModel;
57077
58494
  exports.resolveTableLayout = resolveTableLayout;
@@ -57079,11 +58496,13 @@ exports.roiTooLarge = roiTooLarge;
57079
58496
  exports.roiTooLarge$1 = roiTooLarge;
57080
58497
  exports.routerBasename = routerBasename;
57081
58498
  exports.scrubReducer = scrubReducer;
58499
+ exports.selectPickerGroup = selectPickerGroup;
57082
58500
  exports.selectedDeviceOptions = selectedDeviceOptions;
57083
58501
  exports.serializeRecordedCommand = serializeRecordedCommand;
57084
58502
  exports.shouldCommit = shouldCommit;
57085
58503
  exports.shouldEmit = shouldEmit;
57086
58504
  exports.shouldUseSingleNode = shouldUseSingleNode;
58505
+ exports.showsNoCamerasHint = showsNoCamerasHint;
57087
58506
  exports.sortRows = sortRows;
57088
58507
  exports.sortRowsByColumn = sortRowsByColumn;
57089
58508
  exports.statusIcons = statusIcons;
@@ -57097,7 +58516,10 @@ exports.tankAlert = tankAlert;
57097
58516
  exports.themeToCss = require_theme_index.themeToCss$1;
57098
58517
  exports.toStationaryOverlayBox = toStationaryOverlayBox;
57099
58518
  exports.toggleDataTableFacet = toggleDataTableFacet;
58519
+ exports.togglePickerDevice = togglePickerDevice;
58520
+ exports.togglePickerKey = togglePickerKey;
57100
58521
  exports.trpc = trpc;
58522
+ exports.typeLabel = typeLabel;
57101
58523
  exports.useAccessoriesGetStatus = useAccessoriesGetStatus;
57102
58524
  exports.useAccessoriesSetChildHidden = useAccessoriesSetChildHidden;
57103
58525
  exports.useAddonPagesListPages = useAddonPagesListPages;
@@ -57710,12 +59132,15 @@ exports.useOptionalWidgetRegistry = useOptionalWidgetRegistry;
57710
59132
  exports.useOsdGetStatus = useOsdGetStatus;
57711
59133
  exports.useOsdManagerClearSlotBinding = useOsdManagerClearSlotBinding;
57712
59134
  exports.useOsdManagerCopyDeviceConfiguration = useOsdManagerCopyDeviceConfiguration;
59135
+ exports.useOsdManagerDeleteTemplate = useOsdManagerDeleteTemplate;
57713
59136
  exports.useOsdManagerGetConditionSupport = useOsdManagerGetConditionSupport;
57714
59137
  exports.useOsdManagerGetDeviceOsd = useOsdManagerGetDeviceOsd;
57715
59138
  exports.useOsdManagerGetSourceCatalog = useOsdManagerGetSourceCatalog;
59139
+ exports.useOsdManagerListTemplates = useOsdManagerListTemplates;
57716
59140
  exports.useOsdManagerPreviewSlot = useOsdManagerPreviewSlot;
57717
59141
  exports.useOsdManagerRenderDevice = useOsdManagerRenderDevice;
57718
59142
  exports.useOsdManagerSetSlotBinding = useOsdManagerSetSlotBinding;
59143
+ exports.useOsdManagerUpsertTemplate = useOsdManagerUpsertTemplate;
57719
59144
  exports.useOsdSetOverlay = useOsdSetOverlay;
57720
59145
  exports.usePTZ = usePTZ;
57721
59146
  exports.usePetFeederCallPet = usePetFeederCallPet;
@@ -57729,6 +59154,7 @@ exports.usePetFeederSetChildLock = usePetFeederSetChildLock;
57729
59154
  exports.usePetFeederSetFeedSound = usePetFeederSetFeedSound;
57730
59155
  exports.usePetFeederSetIndicatorLight = usePetFeederSetIndicatorLight;
57731
59156
  exports.usePetFeederSetVolume = usePetFeederSetVolume;
59157
+ exports.usePickerSiteZones = usePickerSiteZones;
57732
59158
  exports.usePipelineAnalyticsApplyDeviceSettingsPatch = usePipelineAnalyticsApplyDeviceSettingsPatch;
57733
59159
  exports.usePipelineAnalyticsCancelRelocateMedia = usePipelineAnalyticsCancelRelocateMedia;
57734
59160
  exports.usePipelineAnalyticsCancelStorageMigrationMove = usePipelineAnalyticsCancelStorageMigrationMove;
@@ -58088,6 +59514,7 @@ exports.useStorageTestLocation = useStorageTestLocation;
58088
59514
  exports.useStorageUpsertLocation = useStorageUpsertLocation;
58089
59515
  exports.useStorageWrite = useStorageWrite;
58090
59516
  exports.useStorageWriteChunk = useStorageWriteChunk;
59517
+ exports.useStoredGroupBy = useStoredGroupBy;
58091
59518
  exports.useStreamBrokerAcquireEgressTranscode = useStreamBrokerAcquireEgressTranscode;
58092
59519
  exports.useStreamBrokerApplyDeviceSettingsPatch = useStreamBrokerApplyDeviceSettingsPatch;
58093
59520
  exports.useStreamBrokerAssignProfile = useStreamBrokerAssignProfile;
@@ -58262,3 +59689,4 @@ exports.waterHeaterPhase = waterHeaterPhase;
58262
59689
  exports.waterHeaterTint = waterHeaterTint;
58263
59690
  exports.weatherConditionMeta = weatherConditionMeta;
58264
59691
  exports.weatherTint = weatherTint;
59692
+ exports.writeStoredGroupBy = writeStoredGroupBy;