Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { DeviceDetailScreen } from "./components/DeviceDetailScreen";
import { SettingsScreen } from "./components/SettingsScreen";
import { ScenesScreen } from "./components/ScenesScreen";
import { HideOnScroll } from "./components/HideOnScroll";
import { EnvironmentStatusBar } from "./components/EnvironmentStatusBar";
import { AppDispatch } from "./store/store";
import { createAppTheme } from "./theme";
import {
Expand All @@ -29,7 +30,14 @@ import {
testApiCredentials,
selectTheme,
} from "./store/slices/settingsSlice";
import { loadDeviceOrder, pollAllDeviceStatuses, selectAllDevices } from "./store/slices/deviceSlice"; // Added pollAllDeviceStatuses
import {
clearDevices,
clearSelectedDeviceStatus,
fetchDevices,
loadDeviceOrder,
pollAllDeviceStatuses,
selectAllDevices,
} from "./store/slices/deviceSlice";
import { loadNightLightSceneAssignments, loadSceneOrder } from "./store/slices/sceneSlice";
import { useTranslation } from "./useTranslation";

Expand Down Expand Up @@ -106,6 +114,17 @@ function App() {
dispatch(testApiCredentials());
}, [dispatch, storedToken, storedSecret]);

// Fetch device list at the App level so the environment status bar (and
// polling) work regardless of which view is active on startup.
useEffect(() => {
if (isTokenValid && storedToken) {
dispatch(fetchDevices());
return;
}
Comment on lines +120 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear cached devices when auth is no longer valid

This effect fetches devices after validation succeeds but has no corresponding branch to clear previously loaded devices when credentials are removed or become invalid. In that scenario, devices/deviceStatuses stay populated and the new global EnvironmentStatusBar can continue showing stale sensor data from the prior authenticated session until another successful fetch occurs. Add a cleanup path (for example dispatching clearDevices() when !isTokenValid or token is missing) so UI state matches auth state.

Useful? React with 👍 / 👎.

dispatch(clearDevices());
dispatch(clearSelectedDeviceStatus());
}, [dispatch, isTokenValid, storedToken]);

// Effect for polling logic
useEffect(() => {
// Clear any existing timer
Expand Down Expand Up @@ -204,9 +223,12 @@ function App() {
<HideOnScroll>
<AppBar position="fixed" color="default" elevation={0}>
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: "bold", letterSpacing: -0.5 }}>
<Typography variant="h6" component="div" sx={{ fontWeight: "bold", letterSpacing: -0.5 }}>
{t("App Title")}
</Typography>
<Box sx={{ flexGrow: 1, display: "flex", justifyContent: "center" }}>
<EnvironmentStatusBar />
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
color={currentView === "list" ? "primary" : "inherit"}
Expand Down
11 changes: 4 additions & 7 deletions src/renderer/src/components/DeviceListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
selectAllDevices,
selectDevicesLoading,
selectDevicesError,
clearDevices,
setError,
selectDeviceStatusMap,
setDeviceOrder,
Expand Down Expand Up @@ -120,14 +119,12 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) =>
[]
);

// Device fetching is handled at the App level. This effect only sets
// contextual error messages for the list view.
useEffect(() => {
if (isTokenValid && apiTokenSet) {
dispatch(fetchDevices());
} else if (apiTokenSet && !isTokenValid) {
dispatch(clearDevices());
if (apiTokenSet && !isTokenValid) {
dispatch(setError(t("API credentials are set but not validated. Please test them in Settings.")));
} else {
dispatch(clearDevices());
} else if (!apiTokenSet) {
dispatch(setError(t("API credentials not configured. Please go to Settings.")));
}
}, [dispatch, isTokenValid, apiTokenSet, t]);
Expand Down
176 changes: 176 additions & 0 deletions src/renderer/src/components/EnvironmentStatusBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import React, { useState } from "react";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

パフォーマンス最適化のために useMemouseCallback を使用するため、これらをインポートに追加してください。

Suggested change
import React, { useState } from "react";
import React, { useState, useMemo, useCallback } from "react";

import { useDispatch, useSelector } from "react-redux";
import Box from "@mui/material/Box";
import ButtonBase from "@mui/material/ButtonBase";
import Typography from "@mui/material/Typography";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import ListItemText from "@mui/material/ListItemText";
import ListItemIcon from "@mui/material/ListItemIcon";
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 ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";

import { AppDispatch } from "../store/store";
import { selectAllDevices, selectDeviceStatusMap } from "../store/slices/deviceSlice";
import {
selectPinnedEnvironmentDeviceId,
setPinnedEnvironmentDeviceId,
} 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"]);

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 [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);

const envDevices = devices.filter((device) => {
const def = findDeviceDefinition(device.deviceType);
return def && ENVIRONMENT_DEVICE_KEYS.has(def.key);
});
Comment on lines +38 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

devices 配列のフィルタリング処理は計算コストがかかる可能性があり、また devices は Redux の状態更新(ポーリング等)により頻繁に更新されるため、useMemo を使用して最適化することをお勧めします。

Suggested change
const envDevices = devices.filter((device) => {
const def = findDeviceDefinition(device.deviceType);
return def && ENVIRONMENT_DEVICE_KEYS.has(def.key);
});
const envDevices = useMemo(() => {
return devices.filter((device) => {
const def = findDeviceDefinition(device.deviceType);
return def && ENVIRONMENT_DEVICE_KEYS.has(def.key);
});
}, [devices]);


// Nothing to show if no environment-capable devices exist
if (envDevices.length === 0) return null;

const pinnedDevice = pinnedId ? envDevices.find((d) => d.deviceId === pinnedId) : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

pinnedDevice の検索結果を useMemo でメモ化することで、レンダリングごとの不要な再計算を抑制できます。

  const pinnedDevice = useMemo(() => 
    pinnedId ? envDevices.find((d) => d.deviceId === pinnedId) : undefined
  , [envDevices, pinnedId]);


const getFormattedValue = (deviceId: string, fieldKey: string): string | undefined => {
const device = devices.find((d) => d.deviceId === deviceId);
if (!device) return undefined;
const statusFields = getStatusFieldsForDevice(device.deviceType);
const field = statusFields.find((f) => f.key === fieldKey);
if (!field) return undefined;
const status = statusMap[deviceId];
const raw = status ? (status as any)[field.key] : undefined;
if (raw === undefined || raw === null) return undefined;
return field.formatter ? String(field.formatter(raw, status)) : String(raw);
};
Comment on lines +48 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

getFormattedValue 関数はレンダリングのたびに再生成されています。また、この関数は Menu 内のループでも呼び出されるため、useCallback でラップしてメモ化することをお勧めします。併せて、statusFields.find の引数における不要な : any 型指定も削除しています。

Suggested change
const getFormattedValue = (deviceId: string, fieldKey: string): string | undefined => {
const device = devices.find((d) => d.deviceId === deviceId);
if (!device) return undefined;
const statusFields = getStatusFieldsForDevice(device.deviceType);
const field = statusFields.find((f: any) => f.key === fieldKey);
if (!field) return undefined;
const status = statusMap[deviceId];
const raw = status ? (status as any)[field.key] : undefined;
if (raw === undefined || raw === null) return undefined;
return field.formatter ? String(field.formatter(raw, status)) : String(raw);
};
const getFormattedValue = useCallback((deviceId: string, fieldKey: string): string | undefined => {
const device = devices.find((d) => d.deviceId === deviceId);
if (!device) return undefined;
const statusFields = getStatusFieldsForDevice(device.deviceType);
const field = statusFields.find((f) => f.key === fieldKey);
if (!field) return undefined;
const status = statusMap[deviceId];
const raw = status ? (status as any)[field.key] : undefined;
if (raw === undefined || raw === null) return undefined;
return field.formatter ? String(field.formatter(raw, status)) : String(raw);
}, [devices, statusMap]);


const temp = pinnedDevice ? getFormattedValue(pinnedDevice.deviceId, "temperature") : undefined;
const hum = pinnedDevice ? getFormattedValue(pinnedDevice.deviceId, "humidity") : undefined;
const co2 = pinnedDevice ? getFormattedValue(pinnedDevice.deviceId, "CO2") : undefined;
Comment on lines +60 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

ステータスバーに表示する主要な環境数値(温度・湿度・CO2)は、依存関係が変更されたときのみ再計算されるように useMemo でメモ化することをお勧めします。

Suggested change
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 { temp, hum, co2 } = useMemo(() => ({
temp: pinnedDevice ? getFormattedValue(pinnedDevice.deviceId, "temperature") : undefined,
hum: pinnedDevice ? getFormattedValue(pinnedDevice.deviceId, "humidity") : undefined,
co2: pinnedDevice ? getFormattedValue(pinnedDevice.deviceId, "CO2") : undefined,
}), [pinnedDevice, getFormattedValue]);


const handleOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
const handleClose = () => setAnchorEl(null);

const handleSelect = (deviceId: string) => {
dispatch(setPinnedEnvironmentDeviceId(deviceId));
handleClose();
};

const handleClear = () => {
dispatch(setPinnedEnvironmentDeviceId(null));
handleClose();
};

return (
<>
<ButtonBase
onClick={handleOpen}
aria-label={t("Select sensor")}
aria-haspopup="listbox"
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 1.5,
py: 0.5,
borderRadius: 2,
"&:hover": { bgcolor: "action.hover" },
}}
>
{pinnedDevice && (temp || hum || co2) ? (
<>
{temp && (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.3 }}>
<ThermostatIcon sx={{ fontSize: 18, color: "warning.main" }} />
<Typography variant="body2" fontWeight="bold">
{temp}
</Typography>
</Box>
)}
{hum && (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.3, ml: 1 }}>
<WaterDropIcon sx={{ fontSize: 18, color: "info.main" }} />
<Typography variant="body2" fontWeight="bold">
{hum}
</Typography>
</Box>
)}
{co2 && (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.3, ml: 1 }}>
<Co2Icon sx={{ fontSize: 20, color: "success.main" }} />
<Typography variant="body2" fontWeight="bold">
{co2}
</Typography>
</Box>
)}
</>
) : (
<Typography variant="body2" color="text.secondary">
{pinnedDevice ? "—" : t("Select sensor")}
</Typography>
)}
<ArrowDropDownIcon sx={{ fontSize: 18, color: "text.secondary" }} />
</ButtonBase>

<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{ paper: { sx: { minWidth: 220 } } }}
>
{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(" / ");
return (
<MenuItem
key={device.deviceId}
selected={isSelected}
onClick={() => handleSelect(device.deviceId)}
>
{isSelected && (
<ListItemIcon>
<CheckIcon fontSize="small" />
</ListItemIcon>
)}
<ListItemText inset={!isSelected}>
{device.deviceName || t("Unnamed Device")}
</ListItemText>
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
{preview}
</Typography>
</MenuItem>
);
})}
{pinnedId && (
<>
<Divider />
<MenuItem onClick={handleClear}>
<ListItemIcon>
<CloseIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("Clear selection")}</ListItemText>
</MenuItem>
</>
)}
</Menu>
</>
);
};
4 changes: 4 additions & 0 deletions src/renderer/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const en: Dictionary = {
"Cannot refresh. API credentials are not valid or not set.":
"Cannot refresh. API credentials are not valid or not set.",
"No devices found, or check API permissions.": "No devices found, or check API permissions.",
"Select sensor": "Select sensor",
"Clear selection": "Clear selection",
"Unnamed Device": "Unnamed Device",
Unknown: "Unknown",
Details: "Details",
Expand Down Expand Up @@ -206,6 +208,8 @@ const ja: Dictionary = {
"再読み込みできません。API 認証情報が未設定または無効です。",
"No devices found, or check API permissions.":
"デバイスが見つかりません。API 権限を確認してください。",
"Select sensor": "センサーを選択",
"Clear selection": "選択を解除",
"Unnamed Device": "名称未設定デバイス",
Unknown: "不明",
Details: "詳細",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('settingsSlice reducers', () => {
theme: 'system',
logRetentionDays: 7,
language: 'en',
pinnedEnvironmentDeviceId: null,
};

it('should handle initial state', () => {
Expand Down
17 changes: 16 additions & 1 deletion src/renderer/src/store/slices/settingsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface SettingsState {
theme: "light" | "dark" | "system";
logRetentionDays: number;
language: "en" | "ja";
pinnedEnvironmentDeviceId: string | null;
}

const mockDefaults = {
Expand All @@ -30,6 +31,7 @@ const initialState: SettingsState = {
theme: "system",
logRetentionDays: 7,
language: "en",
pinnedEnvironmentDeviceId: null,
};

const persistSetting = (key: string, value: unknown, label: string) => {
Expand Down Expand Up @@ -91,6 +93,10 @@ 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));
}

if (token && secret) {
dispatch(setApiCredentials({ token, secret }));
Expand Down Expand Up @@ -276,7 +282,14 @@ export const settingsSlice = createSlice({
state.language = action.payload;
persistSetting("language", action.payload, "language");
},
// ... other reducers
setPinnedEnvironmentDeviceId: (state, action: PayloadAction<string | null>) => {
state.pinnedEnvironmentDeviceId = action.payload;
if (action.payload) {
persistSetting("pinnedEnvironmentDeviceId", action.payload, "pinned environment device");
} else {
deleteSetting("pinnedEnvironmentDeviceId", "pinned environment device");
}
},
},
extraReducers: (builder) => {
builder
Expand Down Expand Up @@ -307,6 +320,7 @@ export const {
setPollingInterval,
setTheme,
setLanguage,
setPinnedEnvironmentDeviceId,
} = settingsSlice.actions;

export const selectApiToken = (state: RootState) => state.settings.apiToken;
Expand All @@ -316,5 +330,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 default settingsSlice.reducer;
Loading