import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Battery, Droplets, RotateCcw, Save, Settings2, Thermometer, X } from 'lucide-react'; import { api } from '../api/client'; import type { AppSettingsUpdate } from '../api/client'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; import { useToast } from '../contexts/ToastContext'; import { LOCATION_SENSOR_ALERT_COLORS, loadLocationSensorAlertAboveColor, loadLocationSensorAlertBelowColor, loadLocationSensorAlertOptimalColor, loadLocationSensorColorizeValues, loadLocationSensorDefaults, saveLocationSensorAlertAboveColor, saveLocationSensorAlertBelowColor, saveLocationSensorAlertOptimalColor, saveLocationSensorColorizeValues, saveLocationSensorShowOnCardDefaults, serializeLocationSensorAlertDefaults, type LocationSensorAlertColor, type LocationSensorCategory, type LocationSensorCategoryDefaults, type LocationSensorDefaults, } from '../utils/locationSensorDefaults'; interface Props { onClose: () => void; } const MIN_POLL_INTERVAL = 60; const DEFAULT_POLL_INTERVAL = 120; const CATEGORY_ICONS: Record = { temperature: Thermometer, humidity: Droplets, battery: Battery, }; const CATEGORY_UNITS: Record = { temperature: '°C', humidity: '%', battery: '%', }; // Keep in step with categoryFor in LocationHASensorModal — "moisture" is // binary wet/dry, not a humidity percentage, and is not a location category. function categoryFor(deviceClass: string | null): LocationSensorCategory | null { if (deviceClass === 'temperature') return 'temperature'; if (deviceClass === 'humidity') return 'humidity'; if (deviceClass === 'battery') return 'battery'; return null; } function CategorySection({ category, state, onChange, }: { category: LocationSensorCategory; state: LocationSensorCategoryDefaults; onChange: (patch: Partial) => void; }) { const { t } = useTranslation(); const Icon = CATEGORY_ICONS[category]; const unit = CATEGORY_UNITS[category]; const hasAlertCondition = state.alertAbove !== '' || state.alertBelow !== ''; return (
{t(`inventory.${category}`)}
{category === 'battery' ? (
{t('haSensors.alertBelow')} {unit} onChange({ alertBelow: e.target.value })} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white" />
) : (
{t('haSensors.alertAbove')} {unit} onChange({ alertAbove: e.target.value })} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white" />
{t('haSensors.alertBelow')} {unit} onChange({ alertBelow: e.target.value })} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white" />
)}
); } export function LocationSensorOptionsModal({ onClose }: Props) { const { t } = useTranslation(); const { showToast } = useToast(); const queryClient = useQueryClient(); // Built-ins first, then seeded from the server once the settings query // lands. The alert fields come from `location_sensor_alert_defaults`; // show-on-card is still local. const [defaults, setDefaults] = useState(() => loadLocationSensorDefaults()); const [colorizeValues, setColorizeValues] = useState(() => loadLocationSensorColorizeValues()); const [aboveColor, setAboveColor] = useState(() => loadLocationSensorAlertAboveColor()); const [belowColor, setBelowColor] = useState(() => loadLocationSensorAlertBelowColor()); const [optimalColor, setOptimalColor] = useState(() => loadLocationSensorAlertOptimalColor()); const [showResetConfirm, setShowResetConfirm] = useState(false); const { data: appSettings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings }); const [pollInterval, setPollInterval] = useState(DEFAULT_POLL_INTERVAL); // Seed the server-backed fields once, and never over an edit in progress. // The dialog renders immediately with placeholder values, so a settings // response landing mid-keystroke would otherwise put the old value back in // front of what was just typed (a field cleared and retyped came out as // "3035"). In practice ['settings'] is warm — SettingsPage, which opens this // dialog, already holds it — so this only covers the cold path and a // background refetch. // // "Seeded" and "touched" are tracked apart on purpose. One flag for both // means a keystroke that beats the response cancels the seed outright, and // Save then writes built-in defaults over the server values for every field // the user never saw. Seeding therefore always happens; it just skips the // fields already edited, which are named here rather than counted. const seeded = useRef(false); const touched = useRef(new Set()); useEffect(() => { if (!appSettings || seeded.current) return; seeded.current = true; if (!touched.current.has('pollInterval')) setPollInterval(appSettings.location_sensor_poll_interval); const fromServer = loadLocationSensorDefaults(appSettings.location_sensor_alert_defaults); setDefaults((prev) => { const next = { ...fromServer }; (Object.keys(next) as LocationSensorCategory[]).forEach((category) => { if (touched.current.has(category)) next[category] = prev[category]; }); return next; }); }, [appSettings]); const updateCategory = (category: LocationSensorCategory, patch: Partial) => { touched.current.add(category); setDefaults((prev) => ({ ...prev, [category]: { ...prev[category], ...patch } })); }; const updatePollInterval = (value: number) => { touched.current.add('pollInterval'); setPollInterval(value); }; const persistDefaults = async () => { // Server call first, and only for fields that actually changed (these // need SETTINGS_UPDATE/admin), before writing anything to localStorage. // A failed PATCH must leave the local preferences below untouched, so the // error toast that follows is true — nothing was saved, not "half of it // was". const clampedInterval = Math.max(MIN_POLL_INTERVAL, pollInterval); const alertDefaults = serializeLocationSensorAlertDefaults({ ...defaults, battery: { ...defaults.battery, alertAbove: '' }, }); const patch: AppSettingsUpdate = {}; if (appSettings && clampedInterval !== appSettings.location_sensor_poll_interval) { patch.location_sensor_poll_interval = clampedInterval; } if (appSettings && alertDefaults !== appSettings.location_sensor_alert_defaults) { patch.location_sensor_alert_defaults = alertDefaults; } if (Object.keys(patch).length > 0) { await api.updateSettings(patch); queryClient.invalidateQueries({ queryKey: ['settings'] }); } saveLocationSensorShowOnCardDefaults(defaults); saveLocationSensorColorizeValues(colorizeValues); saveLocationSensorAlertAboveColor(aboveColor); saveLocationSensorAlertBelowColor(belowColor); saveLocationSensorAlertOptimalColor(optimalColor); }; const saveMutation = useMutation({ mutationFn: persistDefaults, onSuccess: () => { showToast(t('locationHaSensors.options.saved'), 'success'); onClose(); }, onError: (err: Error) => { showToast(err.message || t('locationHaSensors.options.saveFailed'), 'error'); }, }); const handleSave = (e: React.FormEvent) => { e.preventDefault(); saveMutation.mutate(); }; const resetMutation = useMutation({ mutationFn: async () => { // Sensors first, options after — the same write-order rule Save follows, // one level up. Reset is the risky half: it rewrites every bound sensor, // and if that fails the error toast has to mean "nothing was saved". The // per-sensor PATCHes below stay individually non-atomic (there is no bulk // route), so a failure part-way still leaves some rows reset — but it no // longer also leaves the options saved against a reset that half ran. const [sensors, entities] = await Promise.all([api.getLocationHASensors(), api.getBindableLocationHAEntities()]); const friendlyNameByEntityId = new Map(entities.map((entity) => [entity.entity_id, entity.friendly_name])); const targets = sensors.filter((sensor) => categoryFor(sensor.device_class) !== null); await Promise.all( targets.map((sensor) => { const category = categoryFor(sensor.device_class)!; const categoryDefaults = defaults[category]; const friendlyName = friendlyNameByEntityId.get(sensor.entity_id); return api.updateLocationHASensor(sensor.id, { ...(friendlyName ? { name: friendlyName.slice(0, 100) } : {}), alert_above: category !== 'battery' && categoryDefaults.alertAbove !== '' ? Number(categoryDefaults.alertAbove) : null, alert_below: categoryDefaults.alertBelow !== '' ? Number(categoryDefaults.alertBelow) : null, notify_on_alert: categoryDefaults.notifyOnAlert, show_on_card: categoryDefaults.showOnCard, }); }) ); await persistDefaults(); return targets.length; }, onSuccess: (count) => { queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] }); queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] }); showToast(t('locationHaSensors.options.resetDone', { count }), 'success'); setShowResetConfirm(false); onClose(); }, onError: (err: Error) => { showToast(err.message || t('locationHaSensors.options.resetFailed'), 'error'); setShowResetConfirm(false); }, }); return ( <>
e.stopPropagation()} >

{t('locationHaSensors.options.title')}

{t('locationHaSensors.options.description')}

updateCategory('temperature', patch)} /> updateCategory('humidity', patch)} /> updateCategory('battery', patch)} />
{/* Everything above is local display preference (localStorage); the poll interval below is the one field here that actually lives on the server, hence its own heading. */}

{t('locationHaSensors.options.generalSettings')}

updatePollInterval(Number(e.target.value))} onBlur={() => updatePollInterval(Math.max(MIN_POLL_INTERVAL, pollInterval || DEFAULT_POLL_INTERVAL))} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white" />

{t('locationHaSensors.options.pollIntervalHint')}

{showResetConfirm && ( resetMutation.mutate()} onCancel={() => setShowResetConfirm(false)} /> )} ); }