Skip to content

Commit 64deac9

Browse files
committed
feat(climate): add hvac temperature presets
- add HVAC preset utilities and settings dialog coverage - refine dashboard card config and security tests - align commit-message docs and hook validation with Conventional Commits 1.0.0 - move Vite chunking helpers under scripts
1 parent b6fa350 commit 64deac9

22 files changed

Lines changed: 311 additions & 76 deletions

AGENTS.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,23 @@ Connects to Home Assistant over WebSocket.
77

88
## Commit Rules
99

10-
- Use [Conventional Commits](https://www.conventionalcommits.org/) format: `type(scope): summary`
11-
- Valid types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `build`, `ci`, `perf`, `style`
12-
- Summary must be lowercase, imperative mood, no period at the end
13-
- Do not use generic or free-form commit messages
14-
- Include a concise bullet-point body for meaningful changes so the project can track what changed between releases
15-
- Keep bullet lists contiguous in the commit body. Do not create each bullet as a separate `git commit -m` paragraph, because that inserts blank lines between items.
16-
- Keep commit bodies focused on user-visible behavior, architecture, tests, docs, or operational impact
17-
- Trivial commits such as formatting-only or metadata-only changes may omit the body
18-
- Do not use `git commit --no-verify` unless the user explicitly approves skipping hooks for that commit
10+
- Follow the [Conventional Commits 1.0.0 specification](https://www.conventionalcommits.org/en/v1.0.0/#specification).
11+
- Use this structure:
12+
```text
13+
<type>[optional scope][optional !]: <description>
14+
15+
[optional body]
16+
17+
[optional footer(s)]
18+
```
19+
- `feat` means the commit adds a new feature.
20+
- `fix` means the commit fixes a bug.
21+
- Other types are allowed when they describe the change clearly, such as `build`, `chore`, `ci`, `docs`, `perf`, `refactor`, `style`, or `test`.
22+
- A scope may be added after the type to identify the affected area, for example `feat(dashboard): add room filters`.
23+
- The description must immediately follow the colon and space, and should be a concise summary of the change.
24+
- A body may be added after one blank line when the change needs extra context. The body is free-form and may contain multiple paragraphs.
25+
- Footers may be added after one blank line following the body. Use git-trailer style tokens such as `Refs: #123` or `Reviewed-by: Name`.
26+
- Breaking changes must be marked either with `!` before the colon, such as `feat(api)!: remove legacy auth`, or with a footer that starts with `BREAKING CHANGE:`.
1927

2028
---
2129

CONTRIBUTING.md

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -181,16 +181,13 @@ source of truth for the production `pnpm build` step.
181181

182182
### Commit Messages
183183

184-
Follow [Conventional Commits](https://www.conventionalcommits.org/):
184+
Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/#specification):
185185

186-
- Format: `type(scope): summary`
187-
- `feat:` - New feature
188-
- `fix:` - Bug fix
189-
- `docs:` - Documentation changes
190-
- `style:` - Code style changes (formatting, etc.)
191-
- `refactor:` - Code refactoring
192-
- `test:` - Adding or updating tests
193-
- `chore:` - Maintenance tasks
186+
- Format: `<type>[optional scope][optional !]: <description>`
187+
- `feat` commits add a new feature.
188+
- `fix` commits fix a bug.
189+
- Other clear types are allowed, such as `build`, `chore`, `ci`, `docs`, `perf`, `refactor`, `style`, and `test`.
190+
- Breaking changes use `!` before the colon or a `BREAKING CHANGE:` footer.
194191

195192
Version bumps follow the beta semver policy in [docs/VERSIONING.md](docs/VERSIONING.md). While Navet is in beta, prefer `0.x.y` and `0.x.y-beta.n` over `1.0.0`.
196193

@@ -199,8 +196,7 @@ Examples:
199196
feat(calendar): add source selection to card settings
200197
fix(search): match Home Assistant entity-id queries
201198
docs(readme): update appearance and search behavior
202-
style(settings): tighten preview card spacing
203-
refactor(lighting): share light card surface tokens
199+
refactor(lighting)!: replace legacy light state mapping
204200
```
205201

206202
### Pre-commit Hooks
@@ -210,7 +206,7 @@ Navet uses Husky hooks in `.husky/`. `pnpm install` runs the `prepare` script an
210206
The current hook split is:
211207

212208
- `commit-msg`
213-
- enforces the Conventional Commit format: `type(scope): summary`
209+
- enforces the Conventional Commits header format: `<type>[optional scope][optional !]: <description>`
214210
- `pre-commit`
215211
- `pnpm check:lockfile` to keep `package.json` and `pnpm-lock.yaml` in sync
216212
- `pnpm check` for Biome lint/format issues

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ when to run those locally.
287287
Navet uses Conventional Commits:
288288

289289
```text
290-
type(scope): summary
290+
<type>[optional scope][optional !]: <description>
291291
```
292292

293293
When contributing:

scripts/check-commit-message.mjs

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import fs from 'node:fs';
22
import process from 'node:process';
33

4-
const COMMIT_TYPES = ['feat', 'fix', 'refactor', 'docs', 'test', 'chore', 'build', 'ci', 'perf', 'style'];
5-
const FIRST_LINE_LIMIT = 72;
64
const messageFile = process.argv[2];
75

86
if (!messageFile) {
@@ -11,36 +9,28 @@ if (!messageFile) {
119
}
1210

1311
const message = fs.readFileSync(messageFile, 'utf8');
14-
const firstLine = message.split(/\r?\n/, 1)[0]?.trim() ?? '';
12+
const lines = message.replace(/\r\n/g, '\n').split('\n');
13+
const firstLine = lines[0]?.trim() ?? '';
1514

1615
if (firstLine.length === 0) {
1716
console.error('Commit message is empty.');
1817
process.exit(1);
1918
}
2019

21-
if (firstLine.length > FIRST_LINE_LIMIT) {
22-
console.error(
23-
`Commit summary must be ${FIRST_LINE_LIMIT} characters or fewer. Got ${firstLine.length}.`
24-
);
25-
process.exit(1);
26-
}
27-
28-
const conventionalCommitPattern = new RegExp(
29-
`^(${COMMIT_TYPES.join('|')})(\\([a-z0-9][a-z0-9-]*\\))?: [a-z0-9][a-z0-9 /,&+()-]*[a-z0-9)]$`
30-
);
20+
const conventionalCommitPattern =
21+
/^(?<type>[a-zA-Z0-9-]+)(?<scope>\([^)()\r\n]+\))?(?<breaking>!)?: (?<description>.+)$/;
3122

3223
if (!conventionalCommitPattern.test(firstLine)) {
3324
console.error(
34-
'Commit message must match `type(scope): summary` using a supported type and lowercase summary.'
25+
'Commit message must match Conventional Commits: `<type>[optional scope][optional !]: <description>`.'
3526
);
36-
console.error(`Supported types: ${COMMIT_TYPES.join(', ')}`);
3727
console.error(`Received: ${firstLine}`);
3828
process.exit(1);
3929
}
4030

41-
if (firstLine.endsWith('.')) {
42-
console.error('Commit summary must not end with a period.');
31+
if (lines.length > 1 && lines[1] !== '') {
32+
console.error('Commit body or footers must be separated from the header by one blank line.');
4333
process.exit(1);
4434
}
4535

46-
console.log('Commit message format looks good.');
36+
console.log('Commit message follows Conventional Commits.');

src/app/features/climate/components/hvac-card/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ export const HVACCard = memo(function HVACCard({
280280
mode={controller.mode}
281281
targetTemp={controller.targetTemp}
282282
currentTemp={controller.currentTemp}
283+
sourceTemperatureUnit={temperatureUnit}
283284
minTemp={controller.minTemp}
284285
maxTemp={controller.maxTemp}
285286
step={controller.step}

src/app/features/climate/components/hvac-card/layouts/hvac-card-large-layout.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { CardActionRow, CardActionRowGroup } from '@/app/components/patterns/car
33
import { CardSettingsActionButton } from '@/app/components/shared/card-settings-action-button';
44
import { cn } from '@/app/components/ui/utils';
55
import type { ThemeType } from '@/app/hooks';
6+
import { formatTemperatureValueFromSourceUnit } from '@/app/utils/temperature';
7+
import { convertCelsiusPresetToSourceUnit } from '../../../utils/hvac-temperature-presets';
68
import { HVACModeControls } from '../hvac-mode-controls';
79
import { HVACTempControls } from '../hvac-temp-controls';
810
import type { HVACCardController } from '../use-hvac-card-controller';
@@ -53,15 +55,19 @@ export const HVACCardLargeLayout = memo(function HVACCardLargeLayout({
5355
<div className="mt-auto">
5456
<div className="mb-4 flex max-w-[72%] items-center gap-1.5">
5557
{TEMPERATURE_PRESETS.map((preset) => {
56-
const isSelected = Math.abs(controller.targetTemp - preset) < 0.05;
58+
const sourcePresetValue = convertCelsiusPresetToSourceUnit(
59+
preset,
60+
controller.sourceTemperatureUnit
61+
);
62+
const isSelected = Math.abs(controller.targetTemp - sourcePresetValue) < 0.05;
5763

5864
return (
5965
<button
6066
type="button"
6167
key={preset}
6268
onClick={(event) => {
6369
event.stopPropagation();
64-
controller.commitTargetTemp(preset);
70+
controller.commitTargetTemp(sourcePresetValue);
6571
}}
6672
className={cn(
6773
'relative z-[3] min-w-[4.5rem] rounded-2xl border px-3 py-2 text-sm font-semibold transition-all',
@@ -73,7 +79,12 @@ export const HVACCardLargeLayout = memo(function HVACCardLargeLayout({
7379
color: isSelected ? readableTokens.titleColor : readableTokens.subtitleColor,
7480
}}
7581
>
76-
{controller.formatTemperatureValue(preset)}°
82+
{formatTemperatureValueFromSourceUnit(
83+
sourcePresetValue,
84+
controller.sourceTemperatureUnit,
85+
controller.temperatureUnit
86+
)}
87+
°
7788
</button>
7889
);
7990
})}

src/app/features/climate/components/hvac-card/use-hvac-card-controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ export function useHVACCardController({
380380
step: temperatureRange.step,
381381
targetTemp,
382382
temperatureUnit,
383+
sourceTemperatureUnit,
383384
textColor,
384385
theme,
385386
};
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { fireEvent, screen } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { useSettingsStore } from '@/app/stores/settings-store';
4+
import { renderWithProviders } from '@/test/render';
5+
import { HVACSettingsDialog } from '../index';
6+
import type { HVACSettingsDialogProps } from '../types';
7+
8+
function renderDialog(overrides: Partial<HVACSettingsDialogProps> = {}) {
9+
const props: HVACSettingsDialogProps = {
10+
entityId: 'climate.hallway',
11+
isOpen: true,
12+
onOpenChange: vi.fn(),
13+
name: 'Hallway',
14+
isOn: true,
15+
mode: 'heat',
16+
targetTemp: 72,
17+
currentTemp: 70,
18+
sourceTemperatureUnit: 'fahrenheit',
19+
minTemp: 60,
20+
maxTemp: 86,
21+
step: 1,
22+
supportedHvacModes: ['heat', 'cool', 'off'],
23+
onModeChange: vi.fn(),
24+
onTargetTempChange: vi.fn(),
25+
onTargetTempCommit: vi.fn(),
26+
...overrides,
27+
};
28+
29+
renderWithProviders(<HVACSettingsDialog {...props} />);
30+
return props;
31+
}
32+
33+
describe('HVACSettingsDialog', () => {
34+
beforeEach(() => {
35+
useSettingsStore.setState({ temperatureUnit: 'fahrenheit' });
36+
});
37+
38+
it('keeps Fahrenheit source temperatures unchanged when Navet display is Fahrenheit', () => {
39+
const props = renderDialog();
40+
41+
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '72');
42+
expect(screen.getByText('70°F')).toBeInTheDocument();
43+
expect(screen.queryByText('162')).not.toBeInTheDocument();
44+
expect(screen.queryByText('Current 158°F')).not.toBeInTheDocument();
45+
46+
fireEvent.click(screen.getByLabelText('Increase temperature'));
47+
48+
expect(props.onTargetTempCommit).toHaveBeenCalledWith(73);
49+
});
50+
51+
it('renders Celsius comfort presets as Fahrenheit display values and commits Fahrenheit source values', () => {
52+
const props = renderDialog();
53+
54+
expect(screen.queryByRole('button', { name: '18°' })).not.toBeInTheDocument();
55+
expect(screen.queryByRole('button', { name: '21°' })).not.toBeInTheDocument();
56+
expect(screen.queryByRole('button', { name: '24°' })).not.toBeInTheDocument();
57+
expect(screen.getByRole('button', { name: '64°' })).toBeInTheDocument();
58+
expect(screen.getByRole('button', { name: '70°' })).toBeInTheDocument();
59+
expect(screen.getByRole('button', { name: '75°' })).toBeInTheDocument();
60+
61+
fireEvent.click(screen.getByRole('button', { name: '70°' }));
62+
63+
expect(props.onTargetTempCommit).toHaveBeenCalledWith(expect.closeTo(69.8));
64+
});
65+
66+
it('converts Fahrenheit source temperatures when Navet display is Celsius', () => {
67+
useSettingsStore.setState({ temperatureUnit: 'celsius' });
68+
const props = renderDialog();
69+
70+
expect(Number(screen.getByRole('slider').getAttribute('aria-valuenow'))).toBeCloseTo(22.222);
71+
expect(screen.getByText('21.1°C')).toBeInTheDocument();
72+
expect(screen.queryByText('70°F')).not.toBeInTheDocument();
73+
74+
fireEvent.click(screen.getByLabelText('Increase temperature'));
75+
76+
expect(props.onTargetTempCommit).toHaveBeenCalledWith(expect.closeTo(73));
77+
});
78+
79+
it('keeps Celsius source temperatures unchanged when Navet display is Celsius', () => {
80+
useSettingsStore.setState({ temperatureUnit: 'celsius' });
81+
const props = renderDialog({
82+
targetTemp: 22,
83+
currentTemp: 21,
84+
sourceTemperatureUnit: 'celsius',
85+
minTemp: 16,
86+
maxTemp: 30,
87+
step: 0.5,
88+
});
89+
90+
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '22');
91+
expect(screen.getByText('21°C')).toBeInTheDocument();
92+
expect(screen.queryByText('69.8°F')).not.toBeInTheDocument();
93+
94+
fireEvent.click(screen.getByLabelText('Increase temperature'));
95+
96+
expect(props.onTargetTempCommit).toHaveBeenCalledWith(22.5);
97+
});
98+
99+
it('converts Celsius source temperatures when Navet display is Fahrenheit', () => {
100+
const props = renderDialog({
101+
targetTemp: 22,
102+
currentTemp: 21,
103+
sourceTemperatureUnit: 'celsius',
104+
minTemp: 16,
105+
maxTemp: 30,
106+
step: 0.5,
107+
});
108+
109+
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '71.6');
110+
expect(screen.getByText('69.8°F')).toBeInTheDocument();
111+
expect(screen.queryByText('21°C')).not.toBeInTheDocument();
112+
113+
fireEvent.click(screen.getByLabelText('Increase temperature'));
114+
115+
expect(props.onTargetTempCommit).toHaveBeenCalledWith(expect.closeTo(22.5));
116+
});
117+
});

0 commit comments

Comments
 (0)