Skip to content

Commit 94942f5

Browse files
xplatoseambot
andauthored
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 <[email protected]>
1 parent 6b7d25d commit 94942f5

File tree

10 files changed

+309
-19
lines changed

10 files changed

+309
-19
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@
132132
"luxon": "^3.3.0",
133133
"queue": "^7.0.0",
134134
"react-hook-form": "^7.46.1",
135-
"seamapi": "^8.19.0",
135+
"seamapi": "^8.21.0",
136136
"uuid": "^9.0.0"
137137
},
138138
"devDependencies": {
@@ -141,7 +141,7 @@
141141
"@mui/material": "^5.12.2",
142142
"@rxfork/r2wc-react-to-web-component": "^2.4.0",
143143
"@seamapi/fake-devicedb": "^1.6.0",
144-
"@seamapi/fake-seam-connect": "^1.44.3",
144+
"@seamapi/fake-seam-connect": "^1.60.2",
145145
"@seamapi/http": "^0.19.0",
146146
"@seamapi/types": "^1.122.0",
147147
"@storybook/addon-designs": "^7.0.1",

src/lib/debounce.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
type Procedure = (...args: any[]) => void
2+
3+
export function debounce<F extends Procedure>(
4+
func: F,
5+
waitMilliseconds: number
6+
): {
7+
(this: ThisParameterType<F>, ...args: Parameters<F>): void
8+
cancel: () => void
9+
} {
10+
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null
11+
12+
const debouncedFunction = function (
13+
this: ThisParameterType<F>,
14+
...args: Parameters<F>
15+
): void {
16+
if (timeoutId !== null) {
17+
globalThis.clearTimeout(timeoutId)
18+
}
19+
timeoutId = globalThis.setTimeout(() => {
20+
timeoutId = null
21+
func.apply(this, args)
22+
}, waitMilliseconds)
23+
}
24+
25+
debouncedFunction.cancel = (): void => {
26+
if (timeoutId !== null) {
27+
globalThis.clearTimeout(timeoutId)
28+
timeoutId = null
29+
}
30+
}
31+
32+
return debouncedFunction
33+
}

src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx

Lines changed: 200 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,33 @@
11
import classNames from 'classnames'
2-
import { useState } from 'react'
3-
import type { ThermostatDevice } from 'seamapi'
2+
import { useEffect, useState } from 'react'
3+
import type { HvacModeSetting, ThermostatDevice } from 'seamapi'
44

5+
import { debounce } from 'lib/debounce.js'
56
import { BeeIcon } from 'lib/icons/Bee.js'
7+
import { CheckBlackIcon } from 'lib/icons/CheckBlack.js'
68
import { ChevronWideIcon } from 'lib/icons/ChevronWide.js'
79
import { NestedClimateSettingScheduleTable } from 'lib/seam/components/ClimateSettingScheduleTable/ClimateSettingScheduleTable.js'
810
import type { CommonProps } from 'lib/seam/components/common-props.js'
911
import { useConnectedAccount } from 'lib/seam/connected-accounts/use-connected-account.js'
1012
import { useClimateSettingSchedules } from 'lib/seam/thermostats/climate-setting-schedules/use-climate-setting-schedules.js'
13+
import { useCoolThermostat } from 'lib/seam/thermostats/use-cool-thermostat.js'
14+
import { useHeatCoolThermostat } from 'lib/seam/thermostats/use-heat-cool-thermostat.js'
15+
import { useHeatThermostat } from 'lib/seam/thermostats/use-heat-thermostat.js'
16+
import { useSetThermostatOff } from 'lib/seam/thermostats/use-set-thermostat-off.js'
1117
import { useUpdateFanMode } from 'lib/seam/thermostats/use-update-fan-mode.js'
1218
import { useUpdateThermostat } from 'lib/seam/thermostats/use-update-thermostat.js'
19+
import { getSupportedThermostatModes } from 'lib/temperature-bounds.js'
20+
import { AccordionRow } from 'lib/ui/layout/AccordionRow.js'
1321
import { ContentHeader } from 'lib/ui/layout/ContentHeader.js'
1422
import { DetailRow } from 'lib/ui/layout/DetailRow.js'
1523
import { DetailSection } from 'lib/ui/layout/DetailSection.js'
1624
import { DetailSectionGroup } from 'lib/ui/layout/DetailSectionGroup.js'
1725
import { Snackbar } from 'lib/ui/Snackbar/Snackbar.js'
1826
import { Switch } from 'lib/ui/Switch/Switch.js'
27+
import { ClimateModeMenu } from 'lib/ui/thermostat/ClimateModeMenu.js'
1928
import { ClimateSettingStatus } from 'lib/ui/thermostat/ClimateSettingStatus.js'
2029
import { FanModeMenu } from 'lib/ui/thermostat/FanModeMenu.js'
30+
import { TemperatureControlGroup } from 'lib/ui/thermostat/TemperatureControlGroup.js'
2131
import { ThermostatCard } from 'lib/ui/thermostat/ThermostatCard.js'
2232

2333
interface ThermostatDeviceDetailsProps extends CommonProps {
@@ -105,12 +115,7 @@ export function ThermostatDeviceDetails({
105115
label={t.currentSettings}
106116
tooltipContent={t.currentSettingsTooltip}
107117
>
108-
<DetailRow label={t.climate}>
109-
<ClimateSettingStatus
110-
climateSetting={device.properties.current_climate_setting}
111-
temperatureUnit='fahrenheit'
112-
/>
113-
</DetailRow>
118+
<ClimateSettingRow device={device} />
114119
<FanModeRow device={device} />
115120
</DetailSection>
116121

@@ -239,6 +244,191 @@ function FanModeRow({ device }: { device: ThermostatDevice }): JSX.Element {
239244
)
240245
}
241246

247+
function ClimateSettingRow({
248+
device,
249+
}: {
250+
device: ThermostatDevice
251+
}): JSX.Element {
252+
const deviceHeatValue =
253+
device.properties.current_climate_setting.heating_set_point_fahrenheit
254+
const deviceCoolValue =
255+
device.properties.current_climate_setting.cooling_set_point_fahrenheit
256+
257+
const supportedModes = getSupportedThermostatModes(device)
258+
259+
const [showSuccess, setShowSuccess] = useState(false)
260+
const [mode, setMode] = useState<HvacModeSetting>(
261+
(supportedModes.includes('heat_cool') ? 'heat_cool' : supportedModes[0]) ??
262+
'off'
263+
)
264+
265+
const [heatValue, setHeatValue] = useState(
266+
device.properties.current_climate_setting.heating_set_point_fahrenheit ?? 0
267+
)
268+
269+
const [coolValue, setCoolValue] = useState(
270+
device.properties.current_climate_setting.cooling_set_point_fahrenheit ?? 0
271+
)
272+
273+
const {
274+
mutate: heatCoolThermostat,
275+
isSuccess: isHeatCoolSuccess,
276+
isError: isHeatCoolError,
277+
} = useHeatCoolThermostat()
278+
279+
const {
280+
mutate: heatThermostat,
281+
isSuccess: isHeatSuccess,
282+
isError: isHeatError,
283+
} = useHeatThermostat()
284+
285+
const {
286+
mutate: coolThermostat,
287+
isSuccess: isCoolSuccess,
288+
isError: isCoolError,
289+
} = useCoolThermostat()
290+
291+
const {
292+
mutate: setThermostatOff,
293+
isSuccess: isSetOffSuccess,
294+
isError: isSetOffError,
295+
} = useSetThermostatOff()
296+
297+
useEffect(() => {
298+
const handler = debounce(() => {
299+
switch (mode) {
300+
case 'heat_cool':
301+
heatCoolThermostat({
302+
device_id: device.device_id,
303+
heating_set_point_fahrenheit: heatValue,
304+
cooling_set_point_fahrenheit: coolValue,
305+
})
306+
break
307+
case 'heat':
308+
heatThermostat({
309+
device_id: device.device_id,
310+
heating_set_point_fahrenheit: heatValue,
311+
})
312+
break
313+
case 'cool':
314+
coolThermostat({
315+
device_id: device.device_id,
316+
cooling_set_point_fahrenheit: coolValue,
317+
})
318+
break
319+
case 'off':
320+
setThermostatOff({
321+
device_id: device.device_id,
322+
})
323+
break
324+
}
325+
}, 2000)
326+
327+
if (
328+
heatValue !== deviceHeatValue ||
329+
coolValue !== deviceCoolValue ||
330+
mode === 'off'
331+
) {
332+
handler()
333+
}
334+
335+
return () => {
336+
handler.cancel()
337+
}
338+
}, [
339+
heatValue,
340+
coolValue,
341+
mode,
342+
deviceHeatValue,
343+
deviceCoolValue,
344+
device,
345+
heatThermostat,
346+
coolThermostat,
347+
heatCoolThermostat,
348+
setThermostatOff,
349+
])
350+
351+
useEffect(() => {
352+
if (
353+
isHeatCoolSuccess ||
354+
isHeatSuccess ||
355+
isCoolSuccess ||
356+
isSetOffSuccess
357+
) {
358+
setShowSuccess(true)
359+
360+
const timeout = globalThis.setTimeout(() => {
361+
setShowSuccess(false)
362+
}, 3000)
363+
364+
return () => {
365+
globalThis.clearTimeout(timeout)
366+
}
367+
}
368+
369+
return () => {}
370+
}, [isHeatCoolSuccess, isHeatSuccess, isCoolSuccess, isSetOffSuccess])
371+
372+
return (
373+
<>
374+
<AccordionRow
375+
label={t.climate}
376+
leftContent={
377+
<div
378+
className={classNames('seam-thermostat-mutation-status', {
379+
'is-visible': showSuccess,
380+
})}
381+
>
382+
<div className='seam-thermostat-mutation-status-icon'>
383+
<CheckBlackIcon />
384+
</div>
385+
<div className='seam-thermostat-mutation-status-label'>
386+
{t.saved}
387+
</div>
388+
</div>
389+
}
390+
rightCollapsedContent={
391+
<ClimateSettingStatus
392+
climateSetting={device.properties.current_climate_setting}
393+
temperatureUnit='fahrenheit'
394+
/>
395+
}
396+
>
397+
<div className='seam-detail-row-end-alignment'>
398+
{mode !== 'off' && (
399+
<TemperatureControlGroup
400+
mode={mode}
401+
heatValue={heatValue}
402+
coolValue={coolValue}
403+
onHeatValueChange={setHeatValue}
404+
onCoolValueChange={setCoolValue}
405+
delta={
406+
Number(
407+
'min_heating_cooling_delta_fahrenheit' in device.properties &&
408+
device.properties.min_heating_cooling_delta_fahrenheit
409+
) ?? 0
410+
}
411+
/>
412+
)}
413+
414+
<ClimateModeMenu
415+
mode={mode}
416+
onChange={setMode}
417+
supportedModes={supportedModes}
418+
/>
419+
</div>
420+
</AccordionRow>
421+
422+
<Snackbar
423+
message={t.climateSettingError}
424+
variant='error'
425+
visible={isHeatCoolError || isHeatError || isCoolError || isSetOffError}
426+
automaticVisibility
427+
/>
428+
</>
429+
)
430+
}
431+
242432
const t = {
243433
thermostat: 'Thermostat',
244434
climateSchedule: 'scheduled climate',
@@ -266,4 +456,6 @@ const t = {
266456
fanModeError: 'Error updating fan mode. Please try again.',
267457
manualOverrideSuccess: 'Successfully updated manual override!',
268458
manualOverrideError: 'Error updating manual override. Please try again.',
459+
climateSettingError: 'Error updating climate setting. Please try again.',
460+
saved: 'Saved',
269461
}

src/lib/temperature-bounds.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { HvacModeSetting } from 'seamapi'
1+
import type { HvacModeSetting, ThermostatDevice } from 'seamapi'
22

33
export interface ControlBounds {
44
mode: Exclude<HvacModeSetting, 'off'>
@@ -51,3 +51,25 @@ export const getTemperatureBounds = (
5151
heat: getHeatBounds(controlBounds),
5252
cool: getCoolBounds(controlBounds),
5353
})
54+
55+
export const getSupportedThermostatModes = (
56+
device: ThermostatDevice
57+
): HvacModeSetting[] => {
58+
const allModes: HvacModeSetting[] = ['heat', 'cool', 'heat_cool', 'off']
59+
60+
return allModes.filter((mode) => {
61+
switch (mode) {
62+
case 'cool':
63+
return device.properties.is_cooling_available
64+
case 'heat':
65+
return device.properties.is_heating_available
66+
case 'heat_cool':
67+
return (
68+
device.properties.is_heating_available &&
69+
device.properties.is_cooling_available
70+
)
71+
default:
72+
return true
73+
}
74+
})
75+
}

src/lib/ui/layout/AccordionRow.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { useToggle } from 'lib/ui/use-toggle.js'
55

66
interface AccordionRowProps extends PropsWithChildren {
77
label: string
8+
leftContent?: JSX.Element
89
rightCollapsedContent?: JSX.Element
910
}
1011

1112
export function AccordionRow({
1213
label,
14+
leftContent,
1315
rightCollapsedContent,
1416
children,
1517
}: AccordionRowProps): JSX.Element {
@@ -18,7 +20,10 @@ export function AccordionRow({
1820
return (
1921
<div className='seam-accordion-row' aria-expanded={isExpanded}>
2022
<button className='seam-accordion-row-trigger' onClick={toggle}>
21-
<p className='seam-row-label'>{label}</p>
23+
<div className='seam-row-inner-wrap'>
24+
<p className='seam-row-label'>{label}</p>
25+
<div className='seam-row-trigger-left-content'>{leftContent}</div>
26+
</div>
2227
<div className='seam-row-inner-wrap'>
2328
<div className='seam-row-trigger-right-content'>
2429
{rightCollapsedContent}

src/lib/ui/thermostat/ClimateModeMenu.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const Content: Story = {
2323
onChange={(mode) => {
2424
setArgs({ mode })
2525
}}
26+
supportedModes={['heat', 'cool', 'heat_cool', 'off']}
2627
/>
2728
</Box>
2829
)

0 commit comments

Comments
 (0)