Skip to content

Commit 3b11cb4

Browse files
tykealCopilot
andcommitted
fix: use compact datetime rows with edit affordance for date range
Replace the default datetime entity rows for date range start/end with a lightweight custom Lovelace row component that displays the datetime value as text with a pencil icon indicating editability. Tapping opens HA's native more-info dialog which correctly handles timezone conversion, 12/24h locale formatting, and date/time editing across all platforms including the companion app. The custom row delegates layout to hui-generic-entity-row for perfect alignment with standard entity rows. Parent-view (child locks following a parent) datetime rows use simple-entity with no edit affordance since values are controlled by the parent. Closes #591 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
1 parent 5aecf76 commit 3b11cb4

7 files changed

Lines changed: 495 additions & 8 deletions

File tree

custom_components/keymaster/lovelace.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -360,14 +360,15 @@ def _generate_entity_card_ll_config(
360360
name: str,
361361
parent: bool = False,
362362
type_: str | None = None,
363+
tap_action: MutableMapping[str, Any] | None = None,
363364
) -> MutableMapping[str, Any]:
364365
"""Generate entity configuration for use in Lovelace cards."""
365366
prefix = "parent." if parent else ""
366367
entity = f"{prefix}{domain}.code_slots:{code_slot_num}.{key}"
367368
data: MutableMapping[str, Any] = {
368369
"entity": entity,
369370
"name": name,
370-
"tap_action": {"action": "none"},
371+
"tap_action": tap_action or {"action": "none"},
371372
"hold_action": {"action": "none"},
372373
"double_tap_action": {"action": "none"},
373374
}
@@ -417,13 +418,14 @@ def _generate_conditional_card_ll_config(
417418
conditions: list[MutableMapping[str, Any]],
418419
parent: bool = False,
419420
type_: str | None = None,
421+
tap_action: MutableMapping[str, Any] | None = None,
420422
) -> MutableMapping[str, Any]:
421423
"""Generate Lovelace config for a `conditional` card."""
422424
return {
423425
"type": "conditional",
424426
"conditions": conditions,
425427
"row": _generate_entity_card_ll_config(
426-
code_slot_num, domain, key, name, parent=parent, type_=type_
428+
code_slot_num, domain, key, name, parent=parent, type_=type_, tap_action=tap_action
427429
),
428430
}
429431

@@ -634,6 +636,11 @@ def _generate_date_range_entities(
634636
) -> list[MutableMapping[str, Any]]:
635637
"""Build the date range entities for the code slot."""
636638
type_ = "simple-entity" if parent else None
639+
# Non-parent views use the custom datetime row with pencil icon and
640+
# more-info tap action for editing. Parent views use simple-entity
641+
# with no tap action since the values are read-only (controlled by parent).
642+
datetime_type = "simple-entity" if parent else "custom:keymaster-datetime-row"
643+
datetime_tap: MutableMapping[str, Any] | None = None if parent else {"action": "more-info"}
637644
return [
638645
*([] if parent else [DIVIDER_CARD]),
639646
_generate_entity_card_ll_config(
@@ -655,7 +662,8 @@ def _generate_date_range_entities(
655662
)
656663
],
657664
parent=parent,
658-
type_=type_,
665+
type_=datetime_type,
666+
tap_action=datetime_tap,
659667
),
660668
_generate_conditional_card_ll_config(
661669
code_slot_num,
@@ -668,7 +676,8 @@ def _generate_date_range_entities(
668676
)
669677
],
670678
parent=parent,
671-
type_=type_,
679+
type_=datetime_type,
680+
tap_action=datetime_tap,
672681
),
673682
]
674683

custom_components/keymaster/www/generated/keymaster.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { beforeAll, describe, expect, it, vi } from 'vitest';
2+
import { HomeAssistant } from './ha_type_stubs';
3+
import { KeymasterDatetimeRow } from './datetime-row';
4+
5+
// Mock hui-generic-entity-row since it only exists inside HA's Lovelace runtime.
6+
class MockGenericEntityRow extends HTMLElement {
7+
private _hass: unknown;
8+
private _config: unknown;
9+
10+
set hass(v: unknown) {
11+
this._hass = v;
12+
}
13+
get hass(): unknown {
14+
return this._hass;
15+
}
16+
17+
set config(v: unknown) {
18+
this._config = v;
19+
}
20+
get config(): unknown {
21+
return this._config;
22+
}
23+
24+
connectedCallback(): void {
25+
if (!this.shadowRoot) {
26+
this.attachShadow({ mode: 'open' });
27+
this.shadowRoot!.innerHTML = '<slot></slot>';
28+
}
29+
}
30+
}
31+
32+
beforeAll(() => {
33+
if (!customElements.get('hui-generic-entity-row')) {
34+
customElements.define('hui-generic-entity-row', MockGenericEntityRow);
35+
}
36+
if (!customElements.get('keymaster-datetime-row')) {
37+
customElements.define('keymaster-datetime-row', KeymasterDatetimeRow);
38+
}
39+
});
40+
41+
function createMockHass(
42+
states: Record<string, { state: string; attributes: Record<string, unknown> }> = {}
43+
): HomeAssistant {
44+
return {
45+
callWS: vi.fn(),
46+
config: { state: 'RUNNING' },
47+
states,
48+
} as unknown as HomeAssistant;
49+
}
50+
51+
function createElement(): KeymasterDatetimeRow {
52+
return document.createElement('keymaster-datetime-row') as KeymasterDatetimeRow;
53+
}
54+
55+
describe('KeymasterDatetimeRow', () => {
56+
describe('setConfig', () => {
57+
it('throws if no entity is provided', () => {
58+
const el = createElement();
59+
expect(() => el.setConfig({} as never)).toThrow('Entity is required');
60+
});
61+
62+
it('stores the config', () => {
63+
const el = createElement();
64+
const config = { entity: 'datetime.test' };
65+
el.setConfig(config);
66+
// Verify indirectly by ensuring render doesn't throw when hass is set
67+
el.hass = createMockHass({
68+
'datetime.test': {
69+
state: '2026-04-03T14:30:00+00:00',
70+
attributes: { friendly_name: 'Test' },
71+
},
72+
});
73+
});
74+
});
75+
76+
describe('render', () => {
77+
it('renders hui-generic-entity-row with correct hass and config', async () => {
78+
const el = createElement();
79+
const config = { entity: 'datetime.test' };
80+
el.setConfig(config);
81+
const hass = createMockHass({
82+
'datetime.test': {
83+
state: '2026-04-03T00:00:00+00:00',
84+
attributes: { friendly_name: 'Date Range Start' },
85+
},
86+
});
87+
el.hass = hass;
88+
89+
document.body.appendChild(el);
90+
await el.updateComplete;
91+
92+
const shadow = el.shadowRoot!;
93+
const genericRow = shadow.querySelector(
94+
'hui-generic-entity-row'
95+
) as MockGenericEntityRow;
96+
expect(genericRow).not.toBeNull();
97+
expect(genericRow.hass).toBe(hass);
98+
expect(genericRow.config).toEqual(config);
99+
100+
document.body.removeChild(el);
101+
});
102+
103+
it('renders state text and pencil icon in slot', async () => {
104+
const utcStr = '2026-04-03T00:00:00+00:00';
105+
const d = new Date(utcStr);
106+
const expected = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
107+
108+
const el = createElement();
109+
el.setConfig({ entity: 'datetime.test' });
110+
el.hass = createMockHass({
111+
'datetime.test': {
112+
state: utcStr,
113+
attributes: { friendly_name: 'Date Range Start' },
114+
},
115+
});
116+
117+
document.body.appendChild(el);
118+
await el.updateComplete;
119+
120+
const shadow = el.shadowRoot!;
121+
expect(shadow.querySelector('.state')?.textContent).toBe(expected);
122+
expect(shadow.querySelector('ha-icon')).not.toBeNull();
123+
expect(shadow.querySelector('ha-icon')?.getAttribute('icon')).toBe('mdi:pencil');
124+
125+
document.body.removeChild(el);
126+
});
127+
128+
it('shows hui-warning for missing entity', async () => {
129+
const el = createElement();
130+
el.setConfig({ entity: 'datetime.missing' });
131+
el.hass = createMockHass({});
132+
133+
document.body.appendChild(el);
134+
await el.updateComplete;
135+
136+
const shadow = el.shadowRoot!;
137+
const warning = shadow.querySelector('hui-warning');
138+
expect(warning).not.toBeNull();
139+
expect(warning?.textContent).toContain('datetime.missing');
140+
141+
document.body.removeChild(el);
142+
});
143+
});
144+
145+
describe('_formatState (via render)', () => {
146+
it('formats ISO datetime state correctly', async () => {
147+
const utcStr = '2026-04-03T00:00:00+00:00';
148+
const d = new Date(utcStr);
149+
const expected = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
150+
151+
const el = createElement();
152+
el.setConfig({ entity: 'datetime.test' });
153+
el.hass = createMockHass({
154+
'datetime.test': {
155+
state: utcStr,
156+
attributes: { friendly_name: 'Test' },
157+
},
158+
});
159+
160+
document.body.appendChild(el);
161+
await el.updateComplete;
162+
163+
expect(el.shadowRoot!.querySelector('.state')?.textContent).toBe(expected);
164+
165+
document.body.removeChild(el);
166+
});
167+
168+
it('converts UTC datetime to local timezone', async () => {
169+
const utcStr = '2026-04-03T23:00:00+00:00';
170+
const d = new Date(utcStr);
171+
const expected = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
172+
173+
const el = createElement();
174+
el.setConfig({ entity: 'datetime.test' });
175+
el.hass = createMockHass({
176+
'datetime.test': {
177+
state: utcStr,
178+
attributes: { friendly_name: 'Test' },
179+
},
180+
});
181+
182+
document.body.appendChild(el);
183+
await el.updateComplete;
184+
185+
const stateText = el.shadowRoot!.querySelector('.state')?.textContent;
186+
expect(stateText).toBe(expected);
187+
// In non-UTC timezones, verify it does NOT just regex-extract the UTC values
188+
if (d.getTimezoneOffset() !== 0) {
189+
expect(stateText).not.toBe('2026-04-03 23:00');
190+
}
191+
192+
document.body.removeChild(el);
193+
});
194+
195+
it('shows "unknown" for unknown state', async () => {
196+
const el = createElement();
197+
el.setConfig({ entity: 'datetime.test' });
198+
el.hass = createMockHass({
199+
'datetime.test': {
200+
state: 'unknown',
201+
attributes: { friendly_name: 'Test' },
202+
},
203+
});
204+
205+
document.body.appendChild(el);
206+
await el.updateComplete;
207+
208+
expect(el.shadowRoot!.querySelector('.state')?.textContent).toBe('unknown');
209+
210+
document.body.removeChild(el);
211+
});
212+
213+
it('shows "unavailable" for unavailable state', async () => {
214+
const el = createElement();
215+
el.setConfig({ entity: 'datetime.test' });
216+
el.hass = createMockHass({
217+
'datetime.test': {
218+
state: 'unavailable',
219+
attributes: { friendly_name: 'Test' },
220+
},
221+
});
222+
223+
document.body.appendChild(el);
224+
await el.updateComplete;
225+
226+
expect(el.shadowRoot!.querySelector('.state')?.textContent).toBe('unavailable');
227+
228+
document.body.removeChild(el);
229+
});
230+
});
231+
232+
describe('shouldUpdate', () => {
233+
it('returns false when entity state is unchanged', async () => {
234+
const el = createElement();
235+
el.setConfig({ entity: 'datetime.test' });
236+
237+
const stateObj = {
238+
state: '2026-04-03T14:30:00+00:00',
239+
attributes: { friendly_name: 'Test' },
240+
};
241+
242+
el.hass = createMockHass({ 'datetime.test': stateObj });
243+
document.body.appendChild(el);
244+
await el.updateComplete;
245+
246+
// Spy on render
247+
const renderSpy = vi.spyOn(el as never, 'render');
248+
249+
// Set same hass with same state object reference
250+
el.hass = createMockHass({ 'datetime.test': stateObj });
251+
await el.updateComplete;
252+
253+
// shouldUpdate should have prevented the render
254+
expect(renderSpy).not.toHaveBeenCalled();
255+
256+
renderSpy.mockRestore();
257+
document.body.removeChild(el);
258+
});
259+
});
260+
261+
describe('styles', () => {
262+
it('pencil icon uses mdi:pencil', async () => {
263+
const el = createElement();
264+
el.setConfig({ entity: 'datetime.test' });
265+
el.hass = createMockHass({
266+
'datetime.test': {
267+
state: '2026-04-03T14:30:00+00:00',
268+
attributes: { friendly_name: 'Test' },
269+
},
270+
});
271+
272+
document.body.appendChild(el);
273+
await el.updateComplete;
274+
275+
const editIcon = el.shadowRoot!.querySelector('.edit-icon');
276+
expect(editIcon).not.toBeNull();
277+
expect(editIcon?.getAttribute('icon')).toBe('mdi:pencil');
278+
279+
document.body.removeChild(el);
280+
});
281+
});
282+
});

0 commit comments

Comments
 (0)