From 94942f5beed0762c965e6f3dc62d1136edbf5451 Mon Sep 17 00:00:00 2001 From: Tristan B Date: Thu, 29 Feb 2024 16:23:52 -0700 Subject: [PATCH] feat: Add mutation for climate settings (#561) * Add mutation for climate settings * ci: Format code * Format * Add `ClimateModeMenu` * ci: Format code * Hook up mode * Debounce teaser (not working) * ci: Format code * Add debounce function * ci: Format code * ci: Format code * Fix debounce * ci: Format code * ci: Format code * Add status message, hook up mutations * ci: Format code * Remove `console.log`s * Hide unsupported modes * Refactor `getSupportedModes` function * Lint fixes * Apply `delta` * Update `@seamapi/fake-seam-connect` * Update again * Update yet again * Try again... * ci: Format code * Return empty func * Add default `supportedModes` * Narrow default mode * ci: Format code * Format * Use `globalThis` --------- Co-authored-by: Seam Bot --- package-lock.json | 4 +- package.json | 4 +- src/lib/debounce.ts | 33 +++ .../DeviceDetails/ThermostatDeviceDetails.tsx | 208 +++++++++++++++++- src/lib/temperature-bounds.ts | 24 +- src/lib/ui/layout/AccordionRow.tsx | 7 +- .../ui/thermostat/ClimateModeMenu.stories.tsx | 1 + src/lib/ui/thermostat/ClimateModeMenu.tsx | 6 +- src/styles/_layout.scss | 15 +- src/styles/_thermostat.scss | 26 +++ 10 files changed, 309 insertions(+), 19 deletions(-) create mode 100644 src/lib/debounce.ts diff --git a/package-lock.json b/package-lock.json index 27de8492e..b961b2564 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "luxon": "^3.3.0", "queue": "^7.0.0", "react-hook-form": "^7.46.1", - "seamapi": "^8.19.0", + "seamapi": "^8.21.0", "uuid": "^9.0.0" }, "devDependencies": { @@ -23,7 +23,7 @@ "@mui/material": "^5.12.2", "@rxfork/r2wc-react-to-web-component": "^2.4.0", "@seamapi/fake-devicedb": "^1.6.0", - "@seamapi/fake-seam-connect": "^1.44.3", + "@seamapi/fake-seam-connect": "^1.60.2", "@seamapi/http": "^0.19.0", "@seamapi/types": "^1.122.0", "@storybook/addon-designs": "^7.0.1", diff --git a/package.json b/package.json index b36adb37e..2811f0ec7 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,7 @@ "luxon": "^3.3.0", "queue": "^7.0.0", "react-hook-form": "^7.46.1", - "seamapi": "^8.19.0", + "seamapi": "^8.21.0", "uuid": "^9.0.0" }, "devDependencies": { @@ -141,7 +141,7 @@ "@mui/material": "^5.12.2", "@rxfork/r2wc-react-to-web-component": "^2.4.0", "@seamapi/fake-devicedb": "^1.6.0", - "@seamapi/fake-seam-connect": "^1.44.3", + "@seamapi/fake-seam-connect": "^1.60.2", "@seamapi/http": "^0.19.0", "@seamapi/types": "^1.122.0", "@storybook/addon-designs": "^7.0.1", diff --git a/src/lib/debounce.ts b/src/lib/debounce.ts new file mode 100644 index 000000000..70f44cf0f --- /dev/null +++ b/src/lib/debounce.ts @@ -0,0 +1,33 @@ +type Procedure = (...args: any[]) => void + +export function debounce( + func: F, + waitMilliseconds: number +): { + (this: ThisParameterType, ...args: Parameters): void + cancel: () => void +} { + let timeoutId: ReturnType | null = null + + const debouncedFunction = function ( + this: ThisParameterType, + ...args: Parameters + ): void { + if (timeoutId !== null) { + globalThis.clearTimeout(timeoutId) + } + timeoutId = globalThis.setTimeout(() => { + timeoutId = null + func.apply(this, args) + }, waitMilliseconds) + } + + debouncedFunction.cancel = (): void => { + if (timeoutId !== null) { + globalThis.clearTimeout(timeoutId) + timeoutId = null + } + } + + return debouncedFunction +} diff --git a/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx index a511a7170..23b5060b4 100644 --- a/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx @@ -1,23 +1,33 @@ import classNames from 'classnames' -import { useState } from 'react' -import type { ThermostatDevice } from 'seamapi' +import { useEffect, useState } from 'react' +import type { HvacModeSetting, ThermostatDevice } from 'seamapi' +import { debounce } from 'lib/debounce.js' import { BeeIcon } from 'lib/icons/Bee.js' +import { CheckBlackIcon } from 'lib/icons/CheckBlack.js' import { ChevronWideIcon } from 'lib/icons/ChevronWide.js' import { NestedClimateSettingScheduleTable } from 'lib/seam/components/ClimateSettingScheduleTable/ClimateSettingScheduleTable.js' import type { CommonProps } from 'lib/seam/components/common-props.js' import { useConnectedAccount } from 'lib/seam/connected-accounts/use-connected-account.js' import { useClimateSettingSchedules } from 'lib/seam/thermostats/climate-setting-schedules/use-climate-setting-schedules.js' +import { useCoolThermostat } from 'lib/seam/thermostats/use-cool-thermostat.js' +import { useHeatCoolThermostat } from 'lib/seam/thermostats/use-heat-cool-thermostat.js' +import { useHeatThermostat } from 'lib/seam/thermostats/use-heat-thermostat.js' +import { useSetThermostatOff } from 'lib/seam/thermostats/use-set-thermostat-off.js' import { useUpdateFanMode } from 'lib/seam/thermostats/use-update-fan-mode.js' import { useUpdateThermostat } from 'lib/seam/thermostats/use-update-thermostat.js' +import { getSupportedThermostatModes } from 'lib/temperature-bounds.js' +import { AccordionRow } from 'lib/ui/layout/AccordionRow.js' import { ContentHeader } from 'lib/ui/layout/ContentHeader.js' import { DetailRow } from 'lib/ui/layout/DetailRow.js' import { DetailSection } from 'lib/ui/layout/DetailSection.js' import { DetailSectionGroup } from 'lib/ui/layout/DetailSectionGroup.js' import { Snackbar } from 'lib/ui/Snackbar/Snackbar.js' import { Switch } from 'lib/ui/Switch/Switch.js' +import { ClimateModeMenu } from 'lib/ui/thermostat/ClimateModeMenu.js' import { ClimateSettingStatus } from 'lib/ui/thermostat/ClimateSettingStatus.js' import { FanModeMenu } from 'lib/ui/thermostat/FanModeMenu.js' +import { TemperatureControlGroup } from 'lib/ui/thermostat/TemperatureControlGroup.js' import { ThermostatCard } from 'lib/ui/thermostat/ThermostatCard.js' interface ThermostatDeviceDetailsProps extends CommonProps { @@ -105,12 +115,7 @@ export function ThermostatDeviceDetails({ label={t.currentSettings} tooltipContent={t.currentSettingsTooltip} > - - - + @@ -239,6 +244,191 @@ function FanModeRow({ device }: { device: ThermostatDevice }): JSX.Element { ) } +function ClimateSettingRow({ + device, +}: { + device: ThermostatDevice +}): JSX.Element { + const deviceHeatValue = + device.properties.current_climate_setting.heating_set_point_fahrenheit + const deviceCoolValue = + device.properties.current_climate_setting.cooling_set_point_fahrenheit + + const supportedModes = getSupportedThermostatModes(device) + + const [showSuccess, setShowSuccess] = useState(false) + const [mode, setMode] = useState( + (supportedModes.includes('heat_cool') ? 'heat_cool' : supportedModes[0]) ?? + 'off' + ) + + const [heatValue, setHeatValue] = useState( + device.properties.current_climate_setting.heating_set_point_fahrenheit ?? 0 + ) + + const [coolValue, setCoolValue] = useState( + device.properties.current_climate_setting.cooling_set_point_fahrenheit ?? 0 + ) + + const { + mutate: heatCoolThermostat, + isSuccess: isHeatCoolSuccess, + isError: isHeatCoolError, + } = useHeatCoolThermostat() + + const { + mutate: heatThermostat, + isSuccess: isHeatSuccess, + isError: isHeatError, + } = useHeatThermostat() + + const { + mutate: coolThermostat, + isSuccess: isCoolSuccess, + isError: isCoolError, + } = useCoolThermostat() + + const { + mutate: setThermostatOff, + isSuccess: isSetOffSuccess, + isError: isSetOffError, + } = useSetThermostatOff() + + useEffect(() => { + const handler = debounce(() => { + switch (mode) { + case 'heat_cool': + heatCoolThermostat({ + device_id: device.device_id, + heating_set_point_fahrenheit: heatValue, + cooling_set_point_fahrenheit: coolValue, + }) + break + case 'heat': + heatThermostat({ + device_id: device.device_id, + heating_set_point_fahrenheit: heatValue, + }) + break + case 'cool': + coolThermostat({ + device_id: device.device_id, + cooling_set_point_fahrenheit: coolValue, + }) + break + case 'off': + setThermostatOff({ + device_id: device.device_id, + }) + break + } + }, 2000) + + if ( + heatValue !== deviceHeatValue || + coolValue !== deviceCoolValue || + mode === 'off' + ) { + handler() + } + + return () => { + handler.cancel() + } + }, [ + heatValue, + coolValue, + mode, + deviceHeatValue, + deviceCoolValue, + device, + heatThermostat, + coolThermostat, + heatCoolThermostat, + setThermostatOff, + ]) + + useEffect(() => { + if ( + isHeatCoolSuccess || + isHeatSuccess || + isCoolSuccess || + isSetOffSuccess + ) { + setShowSuccess(true) + + const timeout = globalThis.setTimeout(() => { + setShowSuccess(false) + }, 3000) + + return () => { + globalThis.clearTimeout(timeout) + } + } + + return () => {} + }, [isHeatCoolSuccess, isHeatSuccess, isCoolSuccess, isSetOffSuccess]) + + return ( + <> + +
+ +
+
+ {t.saved} +
+ + } + rightCollapsedContent={ + + } + > +
+ {mode !== 'off' && ( + + )} + + +
+
+ + + + ) +} + const t = { thermostat: 'Thermostat', climateSchedule: 'scheduled climate', @@ -266,4 +456,6 @@ const t = { fanModeError: 'Error updating fan mode. Please try again.', manualOverrideSuccess: 'Successfully updated manual override!', manualOverrideError: 'Error updating manual override. Please try again.', + climateSettingError: 'Error updating climate setting. Please try again.', + saved: 'Saved', } diff --git a/src/lib/temperature-bounds.ts b/src/lib/temperature-bounds.ts index 825003b2b..339511bac 100644 --- a/src/lib/temperature-bounds.ts +++ b/src/lib/temperature-bounds.ts @@ -1,4 +1,4 @@ -import type { HvacModeSetting } from 'seamapi' +import type { HvacModeSetting, ThermostatDevice } from 'seamapi' export interface ControlBounds { mode: Exclude @@ -51,3 +51,25 @@ export const getTemperatureBounds = ( heat: getHeatBounds(controlBounds), cool: getCoolBounds(controlBounds), }) + +export const getSupportedThermostatModes = ( + device: ThermostatDevice +): HvacModeSetting[] => { + const allModes: HvacModeSetting[] = ['heat', 'cool', 'heat_cool', 'off'] + + return allModes.filter((mode) => { + switch (mode) { + case 'cool': + return device.properties.is_cooling_available + case 'heat': + return device.properties.is_heating_available + case 'heat_cool': + return ( + device.properties.is_heating_available && + device.properties.is_cooling_available + ) + default: + return true + } + }) +} diff --git a/src/lib/ui/layout/AccordionRow.tsx b/src/lib/ui/layout/AccordionRow.tsx index 97390c336..003e55b52 100644 --- a/src/lib/ui/layout/AccordionRow.tsx +++ b/src/lib/ui/layout/AccordionRow.tsx @@ -5,11 +5,13 @@ import { useToggle } from 'lib/ui/use-toggle.js' interface AccordionRowProps extends PropsWithChildren { label: string + leftContent?: JSX.Element rightCollapsedContent?: JSX.Element } export function AccordionRow({ label, + leftContent, rightCollapsedContent, children, }: AccordionRowProps): JSX.Element { @@ -18,7 +20,10 @@ export function AccordionRow({ return (