Skip to content

Commit

Permalink
feat: Add mutation for climate settings (#561)
Browse files Browse the repository at this point in the history
* 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 <devops@getseam.com>
  • Loading branch information
xplato and seambot authored Feb 29, 2024
1 parent 6b7d25d commit 94942f5
Show file tree
Hide file tree
Showing 10 changed files with 309 additions and 19 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions src/lib/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
type Procedure = (...args: any[]) => void

export function debounce<F extends Procedure>(
func: F,
waitMilliseconds: number
): {
(this: ThisParameterType<F>, ...args: Parameters<F>): void
cancel: () => void
} {
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null

const debouncedFunction = function (
this: ThisParameterType<F>,
...args: Parameters<F>
): 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
}
208 changes: 200 additions & 8 deletions src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -105,12 +115,7 @@ export function ThermostatDeviceDetails({
label={t.currentSettings}
tooltipContent={t.currentSettingsTooltip}
>
<DetailRow label={t.climate}>
<ClimateSettingStatus
climateSetting={device.properties.current_climate_setting}
temperatureUnit='fahrenheit'
/>
</DetailRow>
<ClimateSettingRow device={device} />
<FanModeRow device={device} />
</DetailSection>

Expand Down Expand Up @@ -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<HvacModeSetting>(
(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 (
<>
<AccordionRow
label={t.climate}
leftContent={
<div
className={classNames('seam-thermostat-mutation-status', {
'is-visible': showSuccess,
})}
>
<div className='seam-thermostat-mutation-status-icon'>
<CheckBlackIcon />
</div>
<div className='seam-thermostat-mutation-status-label'>
{t.saved}
</div>
</div>
}
rightCollapsedContent={
<ClimateSettingStatus
climateSetting={device.properties.current_climate_setting}
temperatureUnit='fahrenheit'
/>
}
>
<div className='seam-detail-row-end-alignment'>
{mode !== 'off' && (
<TemperatureControlGroup
mode={mode}
heatValue={heatValue}
coolValue={coolValue}
onHeatValueChange={setHeatValue}
onCoolValueChange={setCoolValue}
delta={
Number(
'min_heating_cooling_delta_fahrenheit' in device.properties &&
device.properties.min_heating_cooling_delta_fahrenheit
) ?? 0
}
/>
)}

<ClimateModeMenu
mode={mode}
onChange={setMode}
supportedModes={supportedModes}
/>
</div>
</AccordionRow>

<Snackbar
message={t.climateSettingError}
variant='error'
visible={isHeatCoolError || isHeatError || isCoolError || isSetOffError}
automaticVisibility
/>
</>
)
}

const t = {
thermostat: 'Thermostat',
climateSchedule: 'scheduled climate',
Expand Down Expand Up @@ -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',
}
24 changes: 23 additions & 1 deletion src/lib/temperature-bounds.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { HvacModeSetting } from 'seamapi'
import type { HvacModeSetting, ThermostatDevice } from 'seamapi'

export interface ControlBounds {
mode: Exclude<HvacModeSetting, 'off'>
Expand Down Expand Up @@ -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
}
})
}
7 changes: 6 additions & 1 deletion src/lib/ui/layout/AccordionRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,7 +20,10 @@ export function AccordionRow({
return (
<div className='seam-accordion-row' aria-expanded={isExpanded}>
<button className='seam-accordion-row-trigger' onClick={toggle}>
<p className='seam-row-label'>{label}</p>
<div className='seam-row-inner-wrap'>
<p className='seam-row-label'>{label}</p>
<div className='seam-row-trigger-left-content'>{leftContent}</div>
</div>
<div className='seam-row-inner-wrap'>
<div className='seam-row-trigger-right-content'>
{rightCollapsedContent}
Expand Down
1 change: 1 addition & 0 deletions src/lib/ui/thermostat/ClimateModeMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const Content: Story = {
onChange={(mode) => {
setArgs({ mode })
}}
supportedModes={['heat', 'cool', 'heat_cool', 'off']}
/>
</Box>
)
Expand Down
Loading

0 comments on commit 94942f5

Please sign in to comment.