From bfc5c4f574701fc5a566a0e3623115f78ce68664 Mon Sep 17 00:00:00 2001 From: suu Date: Sat, 18 Apr 2026 18:46:08 +0900 Subject: [PATCH 1/2] add --- .../src/components/EnvironmentStatusBar.tsx | 219 ++++++++++++------ .../slices/__tests__/settingsSlice.test.ts | 2 +- .../src/store/slices/settingsSlice.ts | 34 ++- 3 files changed, 173 insertions(+), 82 deletions(-) diff --git a/src/renderer/src/components/EnvironmentStatusBar.tsx b/src/renderer/src/components/EnvironmentStatusBar.tsx index 2386030..d81c2fe 100644 --- a/src/renderer/src/components/EnvironmentStatusBar.tsx +++ b/src/renderer/src/components/EnvironmentStatusBar.tsx @@ -11,39 +11,55 @@ 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 useMediaQuery from "@mui/material/useMediaQuery"; +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. */ +const STATUS_BAR_DEVICE_KEYS = new Set(["meter", "hub2", "hub3", "plug"]); +const isPlugMiniDeviceType = (deviceType?: string) => (deviceType ?? "").toLowerCase().includes("plug mini"); 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 isXs = useMediaQuery(theme.breakpoints.down("sm")); + const isSm = useMediaQuery(theme.breakpoints.down("md")); + const isMd = useMediaQuery(theme.breakpoints.down("lg")); + const maxVisible = isXs ? 1 : isSm ? 2 : isMd ? 3 : 4; + + 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 pinnedDevices = pinnedIds + .map((id) => candidateDevices.find((d) => d.deviceId === id)) + .filter((d): d is (typeof candidateDevices)[number] => Boolean(d)); - const pinnedDevice = pinnedId ? envDevices.find((d) => d.deviceId === pinnedId) : undefined; + const visibleDevices = pinnedDevices.slice(0, maxVisible); const getFormattedValue = (deviceId: string, fieldKey: string): string | undefined => { const device = devices.find((d) => d.deviceId === deviceId); @@ -53,27 +69,99 @@ 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) { + // Some formatters (e.g. plug power) can derive values from other fields + // even when the primary key is missing — let them try. + if (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") ?? "—"; + } + const t = getFormattedValue(deviceId, "temperature"); + const h = getFormattedValue(deviceId, "humidity"); + const c = getFormattedValue(deviceId, "CO2"); + return [t ?? "—", h ?? "—", ...(c ? [c] : [])].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"); + 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 hasVisibleReadings = visibleDevices.some((d) => { + const el = renderDeviceReadings(d.deviceId, d.deviceType); + return el !== null; + }); + return ( <> { sx={{ display: "flex", alignItems: "center", - gap: 0.5, + gap: 1, px: 1.5, py: 0.5, borderRadius: 2, + maxWidth: "100%", + overflow: "hidden", "&:hover": { bgcolor: "action.hover" }, }} > - {pinnedDevice && (temp || hum || co2) ? ( - <> - {temp && ( - - - - {temp} - - - )} - {hum && ( - - - - {hum} - - - )} - {co2 && ( - - - - {co2} - - - )} - + {visibleDevices.length > 0 && hasVisibleReadings ? ( + + {visibleDevices.map((device, idx) => { + const readings = renderDeviceReadings(device.deviceId, device.deviceType); + if (!readings) return null; + return ( + + {idx > 0 && ( + + )} + + {readings} + + + ); + })} + ) : ( - - {pinnedDevice ? "—" : t("Select sensor")} + + {pinnedDevices.length > 0 ? "—" : t("Select sensor")} )} @@ -131,35 +223,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..f3a56c9 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,18 @@ 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)); + } + } else { + // Migrate legacy single-device setting. + const legacyPinned = await window.electronStore.get("pinnedEnvironmentDeviceId"); + if (typeof legacyPinned === "string") { + dispatch(setPinnedEnvironmentDeviceIds([legacyPinned])); + } } if (token && secret) { @@ -282,13 +291,14 @@ 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"); } + deleteSetting("pinnedEnvironmentDeviceId", "legacy pinned environment device"); }, }, extraReducers: (builder) => { @@ -320,7 +330,7 @@ export const { setPollingInterval, setTheme, setLanguage, - setPinnedEnvironmentDeviceId, + setPinnedEnvironmentDeviceIds, } = settingsSlice.actions; export const selectApiToken = (state: RootState) => state.settings.apiToken; @@ -330,6 +340,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; From d878bd332a11fef3d3a0968dd799f46295038c8e Mon Sep 17 00:00:00 2001 From: suu <46421931+0suu@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:37:19 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit れびゅーたいおう --- .../src/components/EnvironmentStatusBar.tsx | 135 ++++++++++++------ .../src/store/slices/settingsSlice.ts | 6 +- 2 files changed, 94 insertions(+), 47 deletions(-) diff --git a/src/renderer/src/components/EnvironmentStatusBar.tsx b/src/renderer/src/components/EnvironmentStatusBar.tsx index d81c2fe..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"; @@ -15,7 +15,6 @@ 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 useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; import { AppDispatch } from "../store/store"; @@ -27,10 +26,21 @@ import { import { findDeviceDefinition, getStatusFieldsForDevice } from "../deviceDefinitions"; import { useTranslation } from "../useTranslation"; -/** Device definition keys eligible for pinning to the top status bar. */ +/** + * 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(); @@ -40,10 +50,11 @@ export const EnvironmentStatusBar: React.FC = () => { const [anchorEl, setAnchorEl] = useState(null); const theme = useTheme(); - const isXs = useMediaQuery(theme.breakpoints.down("sm")); - const isSm = useMediaQuery(theme.breakpoints.down("md")); - const isMd = useMediaQuery(theme.breakpoints.down("lg")); - const maxVisible = isXs ? 1 : isSm ? 2 : isMd ? 3 : 4; + 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); @@ -59,9 +70,11 @@ export const EnvironmentStatusBar: React.FC = () => { .map((id) => candidateDevices.find((d) => d.deviceId === id)) .filter((d): d is (typeof candidateDevices)[number] => Boolean(d)); - const visibleDevices = pinnedDevices.slice(0, maxVisible); - - 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); @@ -70,9 +83,7 @@ export const EnvironmentStatusBar: React.FC = () => { const status = statusMap[deviceId]; const raw = status ? (status as any)[field.key] : undefined; if (raw === undefined || raw === null) { - // Some formatters (e.g. plug power) can derive values from other fields - // even when the primary key is missing — let them try. - if (field.formatter) { + if (options.allowDerived && field.formatter) { const derived = field.formatter(raw, status); return derived === undefined || derived === null ? undefined : String(derived); } @@ -86,12 +97,12 @@ export const EnvironmentStatusBar: React.FC = () => { const getDeviceSummary = (deviceId: string, deviceType: string | undefined): string => { const key = getDeviceKey(deviceType); if (key === "plug") { - return getFormattedValue(deviceId, "power") ?? "—"; + return getFormattedValue(deviceId, "power", { allowDerived: true }) ?? "—"; } - const t = getFormattedValue(deviceId, "temperature"); - const h = getFormattedValue(deviceId, "humidity"); - const c = getFormattedValue(deviceId, "CO2"); - return [t ?? "—", h ?? "—", ...(c ? [c] : [])].join(" / "); + 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); @@ -112,7 +123,7 @@ export const EnvironmentStatusBar: React.FC = () => { const renderDeviceReadings = (deviceId: string, deviceType: string | undefined) => { const key = getDeviceKey(deviceType); if (key === "plug") { - const power = getFormattedValue(deviceId, "power"); + const power = getFormattedValue(deviceId, "power", { allowDerived: true }); if (!power) return null; return ( @@ -157,16 +168,40 @@ export const EnvironmentStatusBar: React.FC = () => { ); }; - const hasVisibleReadings = visibleDevices.some((d) => { - const el = renderDeviceReadings(d.deviceId, d.deviceType); - return el !== null; - }); + 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 ( <> { "&:hover": { bgcolor: "action.hover" }, }} > - {visibleDevices.length > 0 && hasVisibleReadings ? ( + {visibleDeviceReadings.length > 0 ? ( { overflow: "hidden", }} > - {visibleDevices.map((device, idx) => { - const readings = renderDeviceReadings(device.deviceId, device.deviceType); - if (!readings) return null; - return ( - - {idx > 0 && ( - - )} - - {readings} - - - ); - })} + {visibleDeviceReadings.map(({ device, readings }, idx) => ( + + {idx > 0 && ( + + )} + + {readings} + + + ))} + {hiddenReadableCount > 0 && ( + + +{hiddenReadableCount} + + )} ) : ( - - {pinnedDevices.length > 0 ? "—" : t("Select sensor")} - + <> + + {pinnedDevices.length > 0 ? "—" : t("Select sensor")} + + {hiddenReadableCount > 0 && ( + + +{hiddenReadableCount} + + )} + )} diff --git a/src/renderer/src/store/slices/settingsSlice.ts b/src/renderer/src/store/slices/settingsSlice.ts index f3a56c9..541c9d6 100644 --- a/src/renderer/src/store/slices/settingsSlice.ts +++ b/src/renderer/src/store/slices/settingsSlice.ts @@ -99,11 +99,16 @@ export const loadApiCredentials = createAsyncThunk( 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"); } } @@ -298,7 +303,6 @@ export const settingsSlice = createSlice({ } else { deleteSetting("pinnedEnvironmentDeviceIds", "pinned status bar devices"); } - deleteSetting("pinnedEnvironmentDeviceId", "legacy pinned environment device"); }, }, extraReducers: (builder) => {