diff --git a/src/renderer/src/components/EnvironmentStatusBar.tsx b/src/renderer/src/components/EnvironmentStatusBar.tsx index 2386030..1bb40f7 100644 --- a/src/renderer/src/components/EnvironmentStatusBar.tsx +++ b/src/renderer/src/components/EnvironmentStatusBar.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useSyncExternalStore, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import Box from "@mui/material/Box"; import ButtonBase from "@mui/material/ButtonBase"; @@ -11,41 +11,70 @@ import Divider from "@mui/material/Divider"; import ThermostatIcon from "@mui/icons-material/Thermostat"; import WaterDropIcon from "@mui/icons-material/WaterDrop"; import Co2Icon from "@mui/icons-material/Co2"; +import BoltIcon from "@mui/icons-material/Bolt"; import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import CheckIcon from "@mui/icons-material/Check"; import CloseIcon from "@mui/icons-material/Close"; +import { useTheme } from "@mui/material/styles"; import { AppDispatch } from "../store/store"; import { selectAllDevices, selectDeviceStatusMap } from "../store/slices/deviceSlice"; import { - selectPinnedEnvironmentDeviceId, - setPinnedEnvironmentDeviceId, + selectPinnedEnvironmentDeviceIds, + setPinnedEnvironmentDeviceIds, } from "../store/slices/settingsSlice"; import { findDeviceDefinition, getStatusFieldsForDevice } from "../deviceDefinitions"; import { useTranslation } from "../useTranslation"; -/** Device definition keys that provide temperature/humidity readings. */ -const ENVIRONMENT_DEVICE_KEYS = new Set(["meter", "hub2", "hub3"]); +/** + * Device definition keys eligible for pinning to the top status bar. + * Plug is filtered to Plug Mini below because legacy Plug does not report power. + */ +const STATUS_BAR_DEVICE_KEYS = new Set(["meter", "hub2", "hub3", "plug"]); +const isPlugMiniDeviceType = (deviceType?: string) => (deviceType ?? "").toLowerCase().includes("plug mini"); + +const getSnapshot = () => window.innerWidth; +const getServerSnapshot = () => 1024; + +const subscribeToResize = (onStoreChange: () => void) => { + window.addEventListener("resize", onStoreChange); + return () => window.removeEventListener("resize", onStoreChange); +}; export const EnvironmentStatusBar: React.FC = () => { const dispatch: AppDispatch = useDispatch(); const { t } = useTranslation(); const devices = useSelector(selectAllDevices); const statusMap = useSelector(selectDeviceStatusMap); - const pinnedId = useSelector(selectPinnedEnvironmentDeviceId); + const pinnedIds = useSelector(selectPinnedEnvironmentDeviceIds); const [anchorEl, setAnchorEl] = useState(null); - const envDevices = devices.filter((device) => { + const theme = useTheme(); + const width = useSyncExternalStore(subscribeToResize, getSnapshot, getServerSnapshot); + const sm = theme.breakpoints.values.sm; + const md = theme.breakpoints.values.md; + const lg = theme.breakpoints.values.lg; + const maxVisible = width >= lg ? 4 : width >= md ? 3 : width >= sm ? 2 : 1; + + const candidateDevices = devices.filter((device) => { const def = findDeviceDefinition(device.deviceType); - return def && ENVIRONMENT_DEVICE_KEYS.has(def.key); + if (!def || !STATUS_BAR_DEVICE_KEYS.has(def.key)) return false; + if (def.key === "plug") return isPlugMiniDeviceType(device.deviceType); + return true; }); - // Nothing to show if no environment-capable devices exist - if (envDevices.length === 0) return null; + // Nothing to show if no pin-eligible devices exist + if (candidateDevices.length === 0) return null; - const pinnedDevice = pinnedId ? envDevices.find((d) => d.deviceId === pinnedId) : undefined; + const pinnedDevices = pinnedIds + .map((id) => candidateDevices.find((d) => d.deviceId === id)) + .filter((d): d is (typeof candidateDevices)[number] => Boolean(d)); - const getFormattedValue = (deviceId: string, fieldKey: string): string | undefined => { + const getFormattedValue = ( + deviceId: string, + fieldKey: string, + options: { allowDerived?: boolean } = {} + ): string | undefined => { const device = devices.find((d) => d.deviceId === deviceId); if (!device) return undefined; const statusFields = getStatusFieldsForDevice(device.deviceType); @@ -53,74 +82,180 @@ export const EnvironmentStatusBar: React.FC = () => { if (!field) return undefined; const status = statusMap[deviceId]; const raw = status ? (status as any)[field.key] : undefined; - if (raw === undefined || raw === null) return undefined; + if (raw === undefined || raw === null) { + if (options.allowDerived && field.formatter) { + const derived = field.formatter(raw, status); + return derived === undefined || derived === null ? undefined : String(derived); + } + return undefined; + } return field.formatter ? String(field.formatter(raw, status)) : String(raw); }; - const temp = pinnedDevice ? getFormattedValue(pinnedDevice.deviceId, "temperature") : undefined; - const hum = pinnedDevice ? getFormattedValue(pinnedDevice.deviceId, "humidity") : undefined; - const co2 = pinnedDevice ? getFormattedValue(pinnedDevice.deviceId, "CO2") : undefined; + const getDeviceKey = (deviceType?: string) => findDeviceDefinition(deviceType)?.key; + + const getDeviceSummary = (deviceId: string, deviceType: string | undefined): string => { + const key = getDeviceKey(deviceType); + if (key === "plug") { + return getFormattedValue(deviceId, "power", { allowDerived: true }) ?? "—"; + } + const temp = getFormattedValue(deviceId, "temperature"); + const hum = getFormattedValue(deviceId, "humidity"); + const co2 = getFormattedValue(deviceId, "CO2"); + return [temp ?? "—", hum ?? "—", ...(co2 ? [co2] : [])].join(" / "); + }; const handleOpen = (e: React.MouseEvent) => setAnchorEl(e.currentTarget); const handleClose = () => setAnchorEl(null); - const handleSelect = (deviceId: string) => { - dispatch(setPinnedEnvironmentDeviceId(deviceId)); - handleClose(); + const handleToggle = (deviceId: string) => { + const next = pinnedIds.includes(deviceId) + ? pinnedIds.filter((id) => id !== deviceId) + : [...pinnedIds, deviceId]; + dispatch(setPinnedEnvironmentDeviceIds(next)); }; const handleClear = () => { - dispatch(setPinnedEnvironmentDeviceId(null)); + dispatch(setPinnedEnvironmentDeviceIds([])); handleClose(); }; + const renderDeviceReadings = (deviceId: string, deviceType: string | undefined) => { + const key = getDeviceKey(deviceType); + if (key === "plug") { + const power = getFormattedValue(deviceId, "power", { allowDerived: true }); + if (!power) return null; + return ( + + + + {power} + + + ); + } + const temp = getFormattedValue(deviceId, "temperature"); + const hum = getFormattedValue(deviceId, "humidity"); + const co2 = getFormattedValue(deviceId, "CO2"); + if (!temp && !hum && !co2) return null; + return ( + <> + {temp && ( + + + + {temp} + + + )} + {hum && ( + + + + {hum} + + + )} + {co2 && ( + + + + {co2} + + + )} + + ); + }; + + const pinnedDeviceReadings = pinnedDevices.reduce< + Array<{ device: (typeof pinnedDevices)[number]; readings: React.ReactNode }> + >((items, device) => { + const readings = renderDeviceReadings(device.deviceId, device.deviceType); + if (readings !== null) { + items.push({ device, readings }); + } + return items; + }, []); + const visibleDeviceReadings = pinnedDeviceReadings.slice(0, maxVisible); + const visibleDevices = pinnedDevices.slice(0, maxVisible); + const hiddenReadableCount = Math.max(0, pinnedDeviceReadings.length - visibleDeviceReadings.length); + + const ariaDevices = + visibleDeviceReadings.length > 0 + ? visibleDeviceReadings.map(({ device }) => device) + : visibleDevices; + + const ariaLabel = + ariaDevices.length > 0 + ? [ + ...ariaDevices.map((device) => { + const deviceName = device.deviceName || t("Unnamed Device"); + return `${deviceName}: ${getDeviceSummary(device.deviceId, device.deviceType)}`; + }), + ...(hiddenReadableCount > 0 ? [`+${hiddenReadableCount}`] : []), + ].join(", ") + : t("Select sensor"); + return ( <> - {pinnedDevice && (temp || hum || co2) ? ( - <> - {temp && ( - - - - {temp} - - - )} - {hum && ( - - - - {hum} - - + {visibleDeviceReadings.length > 0 ? ( + + {visibleDeviceReadings.map(({ device, readings }, idx) => ( + + {idx > 0 && ( + + )} + + {readings} + + + ))} + {hiddenReadableCount > 0 && ( + + +{hiddenReadableCount} + )} - {co2 && ( - - - - {co2} - - + + ) : ( + <> + + {pinnedDevices.length > 0 ? "—" : t("Select sensor")} + + {hiddenReadableCount > 0 && ( + + +{hiddenReadableCount} + )} - ) : ( - - {pinnedDevice ? "—" : t("Select sensor")} - )} @@ -131,35 +266,24 @@ export const EnvironmentStatusBar: React.FC = () => { onClose={handleClose} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} - slotProps={{ paper: { sx: { minWidth: 220 } } }} + slotProps={{ paper: { sx: { minWidth: 240 } } }} > - {envDevices.map((device) => { - const isSelected = device.deviceId === pinnedId; - const dTemp = getFormattedValue(device.deviceId, "temperature"); - const dHum = getFormattedValue(device.deviceId, "humidity"); - const dCo2 = getFormattedValue(device.deviceId, "CO2"); - const preview = [dTemp ?? "—", dHum ?? "—", ...(dCo2 ? [dCo2] : [])].join(" / "); + {candidateDevices.map((device) => { + const isSelected = pinnedIds.includes(device.deviceId); + const preview = getDeviceSummary(device.deviceId, device.deviceType); return ( - handleSelect(device.deviceId)} - > - {isSelected && ( - - - - )} - - {device.deviceName || t("Unnamed Device")} - + handleToggle(device.deviceId)}> + + {isSelected ? : null} + + {device.deviceName || t("Unnamed Device")} {preview} ); })} - {pinnedId && ( + {pinnedIds.length > 0 && ( <> diff --git a/src/renderer/src/store/slices/__tests__/settingsSlice.test.ts b/src/renderer/src/store/slices/__tests__/settingsSlice.test.ts index 98eb89b..31773d5 100644 --- a/src/renderer/src/store/slices/__tests__/settingsSlice.test.ts +++ b/src/renderer/src/store/slices/__tests__/settingsSlice.test.ts @@ -16,7 +16,7 @@ describe('settingsSlice reducers', () => { theme: 'system', logRetentionDays: 7, language: 'en', - pinnedEnvironmentDeviceId: null, + pinnedEnvironmentDeviceIds: [], }; it('should handle initial state', () => { diff --git a/src/renderer/src/store/slices/settingsSlice.ts b/src/renderer/src/store/slices/settingsSlice.ts index b5d8b5c..541c9d6 100644 --- a/src/renderer/src/store/slices/settingsSlice.ts +++ b/src/renderer/src/store/slices/settingsSlice.ts @@ -13,7 +13,7 @@ export interface SettingsState { theme: "light" | "dark" | "system"; logRetentionDays: number; language: "en" | "ja"; - pinnedEnvironmentDeviceId: string | null; + pinnedEnvironmentDeviceIds: string[]; } const mockDefaults = { @@ -31,7 +31,7 @@ const initialState: SettingsState = { theme: "system", logRetentionDays: 7, language: "en", - pinnedEnvironmentDeviceId: null, + pinnedEnvironmentDeviceIds: [], }; const persistSetting = (key: string, value: unknown, label: string) => { @@ -93,9 +93,23 @@ export const loadApiCredentials = createAsyncThunk( if (storedLanguage === "en" || storedLanguage === "ja") { dispatch(setLanguage(storedLanguage)); } - const storedPinnedDevice = await window.electronStore.get("pinnedEnvironmentDeviceId"); - if (typeof storedPinnedDevice === "string") { - dispatch(setPinnedEnvironmentDeviceId(storedPinnedDevice)); + const storedPinnedDevices = await window.electronStore.get("pinnedEnvironmentDeviceIds"); + if (Array.isArray(storedPinnedDevices)) { + const ids = storedPinnedDevices.filter((v): v is string => typeof v === "string"); + if (ids.length > 0) { + dispatch(setPinnedEnvironmentDeviceIds(ids)); + } + const legacyPinned = await window.electronStore.get("pinnedEnvironmentDeviceId"); + if (typeof legacyPinned === "string") { + deleteSetting("pinnedEnvironmentDeviceId", "legacy pinned environment device"); + } + } else { + // Migrate legacy single-device setting. + const legacyPinned = await window.electronStore.get("pinnedEnvironmentDeviceId"); + if (typeof legacyPinned === "string") { + dispatch(setPinnedEnvironmentDeviceIds([legacyPinned])); + deleteSetting("pinnedEnvironmentDeviceId", "legacy pinned environment device"); + } } if (token && secret) { @@ -282,12 +296,12 @@ export const settingsSlice = createSlice({ state.language = action.payload; persistSetting("language", action.payload, "language"); }, - setPinnedEnvironmentDeviceId: (state, action: PayloadAction) => { - state.pinnedEnvironmentDeviceId = action.payload; - if (action.payload) { - persistSetting("pinnedEnvironmentDeviceId", action.payload, "pinned environment device"); + setPinnedEnvironmentDeviceIds: (state, action: PayloadAction) => { + state.pinnedEnvironmentDeviceIds = action.payload; + if (action.payload.length > 0) { + persistSetting("pinnedEnvironmentDeviceIds", action.payload, "pinned status bar devices"); } else { - deleteSetting("pinnedEnvironmentDeviceId", "pinned environment device"); + deleteSetting("pinnedEnvironmentDeviceIds", "pinned status bar devices"); } }, }, @@ -320,7 +334,7 @@ export const { setPollingInterval, setTheme, setLanguage, - setPinnedEnvironmentDeviceId, + setPinnedEnvironmentDeviceIds, } = settingsSlice.actions; export const selectApiToken = (state: RootState) => state.settings.apiToken; @@ -330,6 +344,6 @@ export const selectValidationMessage = (state: RootState) => state.settings.vali export const selectPollingInterval = (state: RootState) => state.settings.pollingIntervalSeconds; export const selectTheme = (state: RootState) => state.settings.theme; export const selectLanguage = (state: RootState) => state.settings.language; -export const selectPinnedEnvironmentDeviceId = (state: RootState) => state.settings.pinnedEnvironmentDeviceId; +export const selectPinnedEnvironmentDeviceIds = (state: RootState) => state.settings.pinnedEnvironmentDeviceIds; export default settingsSlice.reducer;