preact-homeassistant 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -19
- package/dist/index.d.ts +20 -7
- package/dist/index.js +118 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/HAContext.tsx +26 -8
- package/src/__tests__/persistentCache.test.ts +118 -0
- package/src/__tests__/setup.ts +9 -0
- package/src/__tests__/useCalendarEvents.test.tsx +64 -0
- package/src/cacheUtils.ts +5 -4
- package/src/calendars.ts +37 -3
- package/src/persistentCache.ts +148 -0
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ pnpm add preact preact-homeassistant
|
|
|
14
14
|
|
|
15
15
|
## Quick start
|
|
16
16
|
|
|
17
|
+
The easiest way is to [use the template](https://github.com/stutrek/preact-homeassistant-template)
|
|
18
|
+
|
|
17
19
|
```tsx
|
|
18
20
|
import { registerPreactCard, HACard, useEntity, css } from 'preact-homeassistant';
|
|
19
21
|
|
|
@@ -167,36 +169,86 @@ const { getHass } = useHass();
|
|
|
167
169
|
await getHass()?.callService('script', 'morning_routine');
|
|
168
170
|
```
|
|
169
171
|
|
|
170
|
-
### `
|
|
172
|
+
### `useHassValue(selector, isEqual?)`
|
|
173
|
+
|
|
174
|
+
Subscribe to a derived slice of the `hass` object (config, themes, anything
|
|
175
|
+
that isn't entity state — entity state goes through `useEntity`). The selector
|
|
176
|
+
runs on every hass update, but the consumer only re-renders when `isEqual`
|
|
177
|
+
reports a change, so it's cheap for rarely-changing values. `isEqual` defaults
|
|
178
|
+
to `Object.is`.
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
const unitSystem = useHassValue((hass) => hass?.config?.unit_system?.temperature);
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### `useHassConfig()`
|
|
185
|
+
|
|
186
|
+
Shorthand for `useHassValue((hass) => hass?.config)`. Re-renders when
|
|
187
|
+
`hass.config` changes (units, latitude/longitude, etc.).
|
|
188
|
+
|
|
189
|
+
### `useDarkMode()`
|
|
171
190
|
|
|
172
|
-
|
|
191
|
+
Shorthand for the active theme's dark-mode flag. Returns `boolean`.
|
|
173
192
|
|
|
174
|
-
### `
|
|
193
|
+
### `useCalendarEvents(entityIds, { start, end })`
|
|
175
194
|
|
|
176
|
-
Fetch events from
|
|
177
|
-
|
|
195
|
+
Fetch events from one or more calendars for a date range. `entityIds` is an
|
|
196
|
+
array of `` `calendar.${string}` `` IDs, and every returned event carries the
|
|
197
|
+
`calendarId` it came from. Caches per-card in memory with
|
|
198
|
+
stale-while-revalidate, persists each calendar's events to localStorage (see
|
|
199
|
+
[Caching](#caching)), and debounce-refetches when any of the entities change.
|
|
178
200
|
|
|
179
201
|
```tsx
|
|
180
|
-
const { events, status, error, refetch } =
|
|
202
|
+
const { events, status, error, refetch, prefetch } = useCalendarEvents(
|
|
181
203
|
['calendar.family', 'calendar.work'],
|
|
182
204
|
{ start, end },
|
|
183
205
|
);
|
|
184
206
|
// status: 'loading' | 'cached' | 'ready' | 'refreshing'
|
|
207
|
+
|
|
208
|
+
// Warm adjacent months without touching component state:
|
|
209
|
+
prefetch({ start: prevMonthStart, end: prevMonthEnd });
|
|
185
210
|
```
|
|
186
211
|
|
|
212
|
+
`prefetch(range)` is best-effort — it skips ranges already cached and swallows
|
|
213
|
+
failures.
|
|
214
|
+
|
|
215
|
+
Events are read from the REST view `GET /api/calendars/{entity_id}`, which
|
|
216
|
+
(unlike the `calendar.get_events` service response) includes `uid`,
|
|
217
|
+
`recurrence_id`, and `rrule`. The service call is used as a fallback when
|
|
218
|
+
`hass.callApi` isn't available (test mocks, Storybook).
|
|
219
|
+
|
|
187
220
|
### `useWeatherForecast(entityId, type)`
|
|
188
221
|
|
|
189
|
-
Fetch weather forecast data. Caches
|
|
190
|
-
entity changes, and auto-refetches at the top of each hour.
|
|
222
|
+
Fetch weather forecast data. Caches per-card in memory and in localStorage,
|
|
223
|
+
debounce-refetches on entity changes, and auto-refetches at the top of each hour.
|
|
191
224
|
|
|
192
225
|
```tsx
|
|
193
226
|
const { forecast, status, error, refetch } = useWeatherForecast('weather.home', 'hourly');
|
|
194
227
|
```
|
|
195
228
|
|
|
196
|
-
### `useCachedFetch(cacheKey, fetcher, deps)`
|
|
229
|
+
### `useCachedFetch(cacheKey, fetcher, deps, options?)`
|
|
230
|
+
|
|
231
|
+
Generic hook for fetching data with per-card caching. The domain-specific hooks
|
|
232
|
+
above are built on this. `fetcher` is re-run whenever `deps` change; the result
|
|
233
|
+
is stored under `cacheKey` in the provider's cache.
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
const { data, status, error, refetch } = useCachedFetch(
|
|
237
|
+
`my-thing:${id}`,
|
|
238
|
+
() => fetchMyThing(id),
|
|
239
|
+
[id],
|
|
240
|
+
);
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Behavior is stale-while-revalidate and key-change aware: when `cacheKey`
|
|
244
|
+
changes it swaps to that key's cached value synchronously, or keeps the
|
|
245
|
+
previously rendered data on a cold key — it never blanks to a loading state.
|
|
246
|
+
`'loading'` only appears on a true cold start (nothing cached, nothing
|
|
247
|
+
fetched). In-flight fetches are ignored if a newer one has started.
|
|
197
248
|
|
|
198
|
-
|
|
199
|
-
hooks
|
|
249
|
+
`options.seed` is a fallback read used when the in-memory cache misses (the
|
|
250
|
+
built-in hooks use it to read localStorage). Seeded data reports `'cached'` and
|
|
251
|
+
is revalidated like any other hit.
|
|
200
252
|
|
|
201
253
|
### `useResizeObserver(ref, callback, deps?)`
|
|
202
254
|
|
|
@@ -272,12 +324,72 @@ import './MyCard.styles'; // registers styles on import
|
|
|
272
324
|
|
|
273
325
|
### `registerRawStyles(cssString)`
|
|
274
326
|
|
|
275
|
-
Register a raw CSS string, e.g. from a Vite `?inline` import.
|
|
327
|
+
Register a raw CSS string, e.g. from a Vite `?inline` import. No-ops if the
|
|
328
|
+
exact string is already registered.
|
|
329
|
+
|
|
330
|
+
### `getAllStyles()`
|
|
331
|
+
|
|
332
|
+
Returns every registered style as one concatenated string. `registerPreactCard`
|
|
333
|
+
calls this to inject styles into the card's Shadow DOM; you only need it if
|
|
334
|
+
you're rendering a card tree yourself.
|
|
335
|
+
|
|
336
|
+
## Calendar mutations
|
|
276
337
|
|
|
277
|
-
|
|
338
|
+
Plain async functions (not hooks) for calendars that support writes, e.g. Local
|
|
339
|
+
Calendar. Each takes the `hass` object — get it from `useHass()`. They require
|
|
340
|
+
entity control permission, not admin, and WebSocket errors reject unchanged so
|
|
341
|
+
you can inspect `err.code` (e.g. `'unauthorized'`).
|
|
278
342
|
|
|
279
|
-
|
|
280
|
-
|
|
343
|
+
```tsx
|
|
344
|
+
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from 'preact-homeassistant';
|
|
345
|
+
|
|
346
|
+
const { getHass } = useHass();
|
|
347
|
+
|
|
348
|
+
await createCalendarEvent(getHass(), 'calendar.family', {
|
|
349
|
+
dtstart: '2026-07-17T09:00:00',
|
|
350
|
+
dtend: '2026-07-17T10:00:00',
|
|
351
|
+
summary: 'Dentist',
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await updateCalendarEvent(getHass(), 'calendar.family', uid, { ...event, summary: 'Dentist ✅' });
|
|
355
|
+
|
|
356
|
+
await deleteCalendarEvent(getHass(), 'calendar.family', uid, {
|
|
357
|
+
recurrenceId, // optional, for one instance of a recurring event
|
|
358
|
+
recurrenceRange, // optional, e.g. 'THISANDFUTURE'
|
|
359
|
+
});
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
`CalendarMutationEvent` is the payload type: `dtstart` / `dtend` are either
|
|
363
|
+
date-only strings (`'2026-07-17'`, all-day) or ISO datetimes, plus `summary`
|
|
364
|
+
and optional `description`, `location`, `rrule`.
|
|
365
|
+
|
|
366
|
+
## Caching
|
|
367
|
+
|
|
368
|
+
The fetch hooks cache into an in-memory `Map` owned by the provider, so its
|
|
369
|
+
lifetime matches the card and it is garbage-collected on teardown.
|
|
370
|
+
|
|
371
|
+
Beneath that, calendar events and forecasts persist to localStorage so a
|
|
372
|
+
reload renders immediately. Persisted data only seeds a cold start: it's always
|
|
373
|
+
reported as `'cached'` and revalidated, so freshness still comes from entity
|
|
374
|
+
subscriptions and periodic refetch. Expiry exists to bound storage:
|
|
375
|
+
|
|
376
|
+
- Calendar events are stored **per entity** and range, so any set of calendars
|
|
377
|
+
(across cards, or a card's regular and highlight calendars) shares entries.
|
|
378
|
+
- Entries older than 3 days are dropped and never shown.
|
|
379
|
+
- At most 60 entries are kept; the least recently used are evicted.
|
|
380
|
+
- Expiry and the cap cover the whole `preact-ha:` namespace, not just this
|
|
381
|
+
version's keys: every card built on this library shares one localStorage, so
|
|
382
|
+
data left behind by a deleted card (or one on another library version)
|
|
383
|
+
expires whenever any preact-ha card loads.
|
|
384
|
+
- Keys are prefixed `preact-ha:v2:`; bumping the version makes old entries
|
|
385
|
+
unreadable, and they then age out.
|
|
386
|
+
- Cross-version contract: every value under `preact-ha:` is JSON with numeric
|
|
387
|
+
`s` (saved at) and `u` (last used) timestamps in ms. Future versions must keep
|
|
388
|
+
this so they can expire each other's data.
|
|
389
|
+
- Failed fetches are never persisted, and storage errors fall back to
|
|
390
|
+
memory-only.
|
|
391
|
+
|
|
392
|
+
The read/write helpers are internal — reach the cache through `useCachedFetch`.
|
|
281
393
|
|
|
282
394
|
## Other utilities
|
|
283
395
|
|
|
@@ -286,6 +398,25 @@ Register a raw CSS string, e.g. from a Vite `?inline` import.
|
|
|
286
398
|
Returns a stable callback ref that always calls the latest `fn`. Avoids effect
|
|
287
399
|
re-runs while keeping the closure current.
|
|
288
400
|
|
|
401
|
+
### `HAProvider`
|
|
402
|
+
|
|
403
|
+
The context provider `registerPreactCard` wraps your card in. Export exists so
|
|
404
|
+
you can render a card outside Home Assistant (Storybook, tests) with a mock
|
|
405
|
+
`hass`.
|
|
406
|
+
|
|
407
|
+
```tsx
|
|
408
|
+
<HAProvider hass={mockHass} subscribeToEntity={() => () => {}}>
|
|
409
|
+
<MyCardContent config={config} />
|
|
410
|
+
</HAProvider>
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
| Prop | Type | Required | Description |
|
|
414
|
+
|---|---|---|---|
|
|
415
|
+
| `hass` | `HomeAssistant \| undefined` | Yes | The hass object handed to hooks. |
|
|
416
|
+
| `subscribeToEntity` | `(entityId, cb) => () => void` | Yes | Entity subscription plumbing. Return a no-op unsubscribe for static mocks. |
|
|
417
|
+
| `subscribeToHass` | `(cb) => () => void` | No | Notifies `useHassValue` consumers. Defaults to a no-op, so those hooks return their initial value and never update. |
|
|
418
|
+
| `cache` | `Cache` | No | Inject a cache Map to seed or inspect it. Defaults to a fresh per-provider Map. |
|
|
419
|
+
|
|
289
420
|
## Types
|
|
290
421
|
|
|
291
422
|
All HA domain types live in [`src/types/`](src/types/):
|
|
@@ -302,20 +433,27 @@ Re-exported from the package root:
|
|
|
302
433
|
```ts
|
|
303
434
|
import type {
|
|
304
435
|
HomeAssistant,
|
|
436
|
+
FetchStatus,
|
|
305
437
|
CalendarEntity,
|
|
438
|
+
CalendarEvent,
|
|
439
|
+
CalendarEventWithSource,
|
|
440
|
+
CalendarMutationEvent,
|
|
306
441
|
WeatherEntity,
|
|
442
|
+
WeatherForecast,
|
|
443
|
+
ForecastType,
|
|
307
444
|
SunEntity,
|
|
308
445
|
FanEntity,
|
|
309
446
|
FanServices,
|
|
310
|
-
WeatherForecast,
|
|
311
447
|
EntityForId,
|
|
312
448
|
DomainEntityMap,
|
|
313
449
|
DomainServiceMap,
|
|
314
450
|
ServicesForId,
|
|
315
|
-
/* ... */
|
|
316
451
|
} from 'preact-homeassistant';
|
|
317
452
|
```
|
|
318
453
|
|
|
454
|
+
The non-domain types `HACardAlign`, `ElementSize`, and `ResizeCallback` are
|
|
455
|
+
exported from the root as well, alongside their components/hooks.
|
|
456
|
+
|
|
319
457
|
## Contributing types
|
|
320
458
|
|
|
321
459
|
The HA domain types in this package are intentionally minimal — only the
|
|
@@ -342,8 +480,10 @@ attributes can land later.
|
|
|
342
480
|
```bash
|
|
343
481
|
pnpm install
|
|
344
482
|
pnpm test # vitest run
|
|
483
|
+
pnpm typecheck # tsc --noEmit
|
|
345
484
|
pnpm build # tsc --noEmit && vite build
|
|
346
|
-
pnpm lint # biome check
|
|
485
|
+
pnpm lint # biome check src
|
|
486
|
+
pnpm lint:fix # biome check --write src
|
|
347
487
|
```
|
|
348
488
|
|
|
349
489
|
## Publishing
|
|
@@ -351,7 +491,7 @@ pnpm lint # biome check
|
|
|
351
491
|
Releases are published to npm manually from a local machine (no CI publish):
|
|
352
492
|
|
|
353
493
|
```bash
|
|
354
|
-
pnpm test && pnpm build
|
|
494
|
+
pnpm test && pnpm build
|
|
355
495
|
git tag v0.X.Y && git push origin v0.X.Y
|
|
356
496
|
pnpm publish --access public --provenance
|
|
357
497
|
```
|
package/dist/index.d.ts
CHANGED
|
@@ -288,10 +288,19 @@ export declare interface SunEntity extends HassEntityBase {
|
|
|
288
288
|
export declare function updateCalendarEvent(hass: HomeAssistant | undefined, entityId: `calendar.${string}`, uid: string, event: CalendarMutationEvent): Promise<void>;
|
|
289
289
|
|
|
290
290
|
/**
|
|
291
|
-
* Generic hook for fetching data with
|
|
292
|
-
* status string to distinguish cached
|
|
291
|
+
* Generic hook for fetching data with in-memory caching (optionally seeded from
|
|
292
|
+
* a persistent store). Returns a cache-aware status string to distinguish cached
|
|
293
|
+
* vs fresh data.
|
|
293
294
|
*/
|
|
294
|
-
export declare function useCachedFetch<T>(cacheKey: string, fetcher: () => Promise<T>, deps: unknown[]): UseCachedFetchResult<T>;
|
|
295
|
+
export declare function useCachedFetch<T>(cacheKey: string, fetcher: () => Promise<T>, deps: unknown[], options?: UseCachedFetchOptions<T>): UseCachedFetchResult<T>;
|
|
296
|
+
|
|
297
|
+
declare interface UseCachedFetchOptions<T> {
|
|
298
|
+
/**
|
|
299
|
+
* Fallback read when the in-memory cache misses (e.g. from localStorage).
|
|
300
|
+
* Seeded data is shown as stale (`'cached'`) and revalidated like any hit.
|
|
301
|
+
*/
|
|
302
|
+
seed?: () => T | undefined;
|
|
303
|
+
}
|
|
295
304
|
|
|
296
305
|
declare interface UseCachedFetchResult<T> {
|
|
297
306
|
data: T | undefined;
|
|
@@ -302,8 +311,10 @@ declare interface UseCachedFetchResult<T> {
|
|
|
302
311
|
|
|
303
312
|
/**
|
|
304
313
|
* Fetch events from one or more calendars for a date range, with in-memory
|
|
305
|
-
* (per-card) caching and stale-while-revalidate behavior.
|
|
306
|
-
*
|
|
314
|
+
* (per-card) caching and stale-while-revalidate behavior. Each calendar's
|
|
315
|
+
* events are also persisted to localStorage per entity, so a reload renders
|
|
316
|
+
* immediately. Events are tagged with their source calendar ID. Returns
|
|
317
|
+
* `prefetch` to warm adjacent ranges.
|
|
307
318
|
*/
|
|
308
319
|
export declare function useCalendarEvents(entityIds: `calendar.${string}`[], options: {
|
|
309
320
|
start: Date;
|
|
@@ -345,6 +356,7 @@ export declare function useDarkMode(): boolean;
|
|
|
345
356
|
* - 'calendar.xyz' -> CalendarEntity
|
|
346
357
|
* - 'weather.xyz' -> WeatherEntity
|
|
347
358
|
* - 'sun.sun' -> SunEntity
|
|
359
|
+
* - 'fan.xyz' -> FanEntity
|
|
348
360
|
* - other domains -> HassEntity (fallback)
|
|
349
361
|
*/
|
|
350
362
|
export declare function useEntity<T extends string>(entityId: T): EntityForId<T> | undefined;
|
|
@@ -416,8 +428,9 @@ export declare function useResizeObserver<T extends HTMLElement>(ref: RefObject<
|
|
|
416
428
|
export declare function useService<T extends string>(entityId: T): ServiceCaller<T>;
|
|
417
429
|
|
|
418
430
|
/**
|
|
419
|
-
* Fetch weather forecast data
|
|
420
|
-
*
|
|
431
|
+
* Fetch weather forecast data, cached in memory and persisted to localStorage
|
|
432
|
+
* so a reload renders immediately. Auto-refetches at the top of each hour and
|
|
433
|
+
* when the underlying entity changes (debounced).
|
|
421
434
|
*/
|
|
422
435
|
export declare function useWeatherForecast(entityId: `weather.${string}`, type: ForecastType): UseWeatherForecastResult;
|
|
423
436
|
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,97 @@ function readCache(cache, key) {
|
|
|
7
7
|
function writeCache(cache, key, data) {
|
|
8
8
|
cache.set(key, { data });
|
|
9
9
|
}
|
|
10
|
+
const NAMESPACE = "preact-ha:";
|
|
11
|
+
const PREFIX = "preact-ha:v2:";
|
|
12
|
+
const MAX_AGE_MS = 3 * 24 * 60 * 60 * 1e3;
|
|
13
|
+
const MAX_ENTRIES = 60;
|
|
14
|
+
let swept = false;
|
|
15
|
+
function storage() {
|
|
16
|
+
try {
|
|
17
|
+
return globalThis.localStorage ?? void 0;
|
|
18
|
+
} catch {
|
|
19
|
+
return void 0;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function timesOf(raw) {
|
|
23
|
+
if (!raw) return void 0;
|
|
24
|
+
try {
|
|
25
|
+
const value = JSON.parse(raw);
|
|
26
|
+
const saved = typeof value?.s === "number" ? value.s : value?.timestamp;
|
|
27
|
+
if (typeof saved !== "number") return void 0;
|
|
28
|
+
return { saved, used: typeof value.u === "number" ? value.u : saved };
|
|
29
|
+
} catch {
|
|
30
|
+
return void 0;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function namespaceKeys(ls) {
|
|
34
|
+
const keys = [];
|
|
35
|
+
for (let i = 0; i < ls.length; i++) {
|
|
36
|
+
const key = ls.key(i);
|
|
37
|
+
if (key?.startsWith(NAMESPACE)) keys.push(key);
|
|
38
|
+
}
|
|
39
|
+
return keys;
|
|
40
|
+
}
|
|
41
|
+
function entriesByUse(ls) {
|
|
42
|
+
return namespaceKeys(ls).map((key) => ({ key, used: timesOf(ls.getItem(key))?.used ?? 0 })).sort((a, b) => a.used - b.used);
|
|
43
|
+
}
|
|
44
|
+
function evictOverCap(ls) {
|
|
45
|
+
const count = namespaceKeys(ls).length;
|
|
46
|
+
if (count <= MAX_ENTRIES) return;
|
|
47
|
+
const entries = entriesByUse(ls);
|
|
48
|
+
for (let i = 0; i < count - MAX_ENTRIES; i++) {
|
|
49
|
+
ls.removeItem(entries[i].key);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function sweep(ls) {
|
|
53
|
+
if (swept) return;
|
|
54
|
+
swept = true;
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
for (const key of namespaceKeys(ls)) {
|
|
57
|
+
const times = timesOf(ls.getItem(key));
|
|
58
|
+
if (!times || now - times.saved > MAX_AGE_MS) ls.removeItem(key);
|
|
59
|
+
}
|
|
60
|
+
evictOverCap(ls);
|
|
61
|
+
}
|
|
62
|
+
function readPersisted(key) {
|
|
63
|
+
const ls = storage();
|
|
64
|
+
if (!ls) return void 0;
|
|
65
|
+
try {
|
|
66
|
+
sweep(ls);
|
|
67
|
+
const fullKey = PREFIX + key;
|
|
68
|
+
const raw = ls.getItem(fullKey);
|
|
69
|
+
if (!raw) return void 0;
|
|
70
|
+
const entry = JSON.parse(raw);
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
if (typeof entry?.s !== "number" || now - entry.s > MAX_AGE_MS) {
|
|
73
|
+
ls.removeItem(fullKey);
|
|
74
|
+
return void 0;
|
|
75
|
+
}
|
|
76
|
+
entry.u = now;
|
|
77
|
+
ls.setItem(fullKey, JSON.stringify(entry));
|
|
78
|
+
return entry.d;
|
|
79
|
+
} catch {
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function writePersisted(key, data) {
|
|
84
|
+
const ls = storage();
|
|
85
|
+
if (!ls) return;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const value = JSON.stringify({ d: data, s: now, u: now });
|
|
88
|
+
try {
|
|
89
|
+
sweep(ls);
|
|
90
|
+
ls.setItem(PREFIX + key, value);
|
|
91
|
+
evictOverCap(ls);
|
|
92
|
+
} catch {
|
|
93
|
+
try {
|
|
94
|
+
const oldest = entriesByUse(ls)[0];
|
|
95
|
+
if (oldest) ls.removeItem(oldest.key);
|
|
96
|
+
ls.setItem(PREFIX + key, value);
|
|
97
|
+
} catch {
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
10
101
|
function useCallbackStable(callback) {
|
|
11
102
|
const callbackRef = useRef(callback);
|
|
12
103
|
callbackRef.current = callback;
|
|
@@ -104,9 +195,10 @@ function useService(entityId) {
|
|
|
104
195
|
return hass.callService(domain, service, { entity_id: entityId, ...data });
|
|
105
196
|
}));
|
|
106
197
|
}
|
|
107
|
-
function useCachedFetch(cacheKey, fetcher, deps) {
|
|
198
|
+
function useCachedFetch(cacheKey, fetcher, deps, options = {}) {
|
|
108
199
|
const store = useHAStore();
|
|
109
|
-
const
|
|
200
|
+
const readCached = () => readCache(store.cache, cacheKey) ?? options.seed?.();
|
|
201
|
+
const [data, setData] = useState(readCached);
|
|
110
202
|
const [isFresh, setIsFresh] = useState(false);
|
|
111
203
|
const [isFetching, setIsFetching] = useState(false);
|
|
112
204
|
const [error, setError] = useState(void 0);
|
|
@@ -115,7 +207,7 @@ function useCachedFetch(cacheKey, fetcher, deps) {
|
|
|
115
207
|
dataKeyRef.current = cacheKey;
|
|
116
208
|
setIsFresh(false);
|
|
117
209
|
setError(void 0);
|
|
118
|
-
const cached =
|
|
210
|
+
const cached = readCached();
|
|
119
211
|
if (cached !== void 0) setData(cached);
|
|
120
212
|
}
|
|
121
213
|
const fetchIdRef = useRef(0);
|
|
@@ -170,14 +262,18 @@ function useWeatherForecast(entityId, type) {
|
|
|
170
262
|
target: { entity_id: entityId },
|
|
171
263
|
return_response: true
|
|
172
264
|
});
|
|
173
|
-
|
|
265
|
+
const forecast2 = result.response?.[entityId]?.forecast ?? [];
|
|
266
|
+
writePersisted(cacheKey, forecast2);
|
|
267
|
+
return forecast2;
|
|
174
268
|
});
|
|
175
269
|
const {
|
|
176
270
|
data: forecast,
|
|
177
271
|
status,
|
|
178
272
|
error,
|
|
179
273
|
refetch
|
|
180
|
-
} = useCachedFetch(cacheKey, fetcher, [entityId, type]
|
|
274
|
+
} = useCachedFetch(cacheKey, fetcher, [entityId, type], {
|
|
275
|
+
seed: () => readPersisted(cacheKey)
|
|
276
|
+
});
|
|
181
277
|
const debouncedRefetch = useCallbackStable(() => {
|
|
182
278
|
if (debounceTimerRef.current) {
|
|
183
279
|
clearTimeout(debounceTimerRef.current);
|
|
@@ -435,6 +531,19 @@ function HACard({ align = "top", class: className, children }) {
|
|
|
435
531
|
function calendarEventsCacheKey(entityIds, range) {
|
|
436
532
|
return `events:${entityIds.join(",")}:${range.start.getTime()}-${range.end.getTime()}`;
|
|
437
533
|
}
|
|
534
|
+
function entityEventsPersistKey(entityId, range) {
|
|
535
|
+
return `events:${entityId}:${range.start.getTime()}-${range.end.getTime()}`;
|
|
536
|
+
}
|
|
537
|
+
function readPersistedRange(entityIds, range) {
|
|
538
|
+
if (entityIds.length === 0) return void 0;
|
|
539
|
+
const result = [];
|
|
540
|
+
for (const entityId of entityIds) {
|
|
541
|
+
const events = readPersisted(entityEventsPersistKey(entityId, range));
|
|
542
|
+
if (!events) return void 0;
|
|
543
|
+
for (const event of events) result.push({ ...event, calendarId: entityId });
|
|
544
|
+
}
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
438
547
|
function apiDateString(value) {
|
|
439
548
|
return value.dateTime ?? value.date ?? "";
|
|
440
549
|
}
|
|
@@ -480,6 +589,7 @@ async function fetchCalendarRange(hass, entityIds, range) {
|
|
|
480
589
|
entityIds.map(async (entityId) => {
|
|
481
590
|
try {
|
|
482
591
|
const calendarEvents = hass.callApi ? await fetchEntityEventsRest(hass, entityId, range) : await fetchEntityEventsWs(hass, entityId, range);
|
|
592
|
+
writePersisted(entityEventsPersistKey(entityId, range), calendarEvents);
|
|
483
593
|
return calendarEvents.map(
|
|
484
594
|
(event) => ({ ...event, calendarId: entityId })
|
|
485
595
|
);
|
|
@@ -504,7 +614,9 @@ function useCalendarEvents(entityIds, options) {
|
|
|
504
614
|
status,
|
|
505
615
|
error,
|
|
506
616
|
refetch
|
|
507
|
-
} = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey]
|
|
617
|
+
} = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey], {
|
|
618
|
+
seed: () => readPersistedRange(entityIds, options)
|
|
619
|
+
});
|
|
508
620
|
const prefetch = useCallbackStable((range) => {
|
|
509
621
|
const key = calendarEventsCacheKey(entityIds, range);
|
|
510
622
|
if (store.cache.has(key)) return;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../src/cacheUtils.ts","../src/useCallbackStable.ts","../src/HAContext.tsx","../src/styleRegistry.ts","../src/registerPreactCard.tsx","../src/HACard.tsx","../src/calendars.ts","../src/useResizeObserver.ts","../src/useWidth.ts"],"sourcesContent":["// In-memory cache helpers. The cache Map is owned per-card by the HAProvider\n// store (see HAContext), so its lifetime matches the card and it is\n// garbage-collected when the card is torn down. There is intentionally no\n// persistence, TTL, or size cap: freshness comes from entity subscriptions and\n// periodic refetch, not from cache expiry, and growth is bounded by the ranges\n// a single card visits in a session.\n\nexport interface CacheEntry<T> {\n data: T;\n}\n\nexport type Cache = Map<string, CacheEntry<unknown>>;\n\nexport function readCache<T>(cache: Cache, key: string): T | undefined {\n return (cache.get(key) as CacheEntry<T> | undefined)?.data;\n}\n\nexport function writeCache<T>(cache: Cache, key: string, data: T): void {\n cache.set(key, { data });\n}\n","import { useRef } from 'preact/hooks';\n\n/**\n * Creates a stable callback reference that always calls the latest version of the callback.\n * Unlike useCallback, this never changes identity, so it won't cause re-renders in children.\n *\n * @param callback The callback function to stabilize\n * @returns A stable function reference that always calls the latest callback\n */\nexport function useCallbackStable<T extends (...args: never[]) => unknown>(callback: T): T {\n const callbackRef = useRef<T>(callback);\n\n callbackRef.current = callback;\n\n // Create a stable function reference once\n const stableRef = useRef<T | null>(null);\n if (stableRef.current === null) {\n stableRef.current = ((...args: Parameters<T>) => {\n return callbackRef.current(...args);\n }) as T;\n }\n\n return stableRef.current;\n}\n","import { createContext } from 'preact';\nimport type { ComponentChildren } from 'preact';\nimport { useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';\n\nimport { type Cache, readCache, writeCache } from './cacheUtils';\nimport type {\n EntityForId,\n FetchStatus,\n ForecastType,\n HomeAssistant,\n ServicesForId,\n WeatherForecast,\n} from './types';\nimport { useCallbackStable } from './useCallbackStable';\n\ntype SubscribeToHass = (callback: () => void) => () => void;\n\n// Default for providers that don't wire up hass-value notifications (Storybook,\n// tests). useHassValue then simply returns its initial value and never updates.\nconst noopSubscribeToHass: SubscribeToHass = () => () => {};\n\ninterface HAStore {\n getHass: () => HomeAssistant | undefined;\n subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;\n subscribeToHass: SubscribeToHass;\n // Per-card cache (events, forecasts, entities). Owned by the provider so its\n // lifetime is the card's — GC'd with the store when the card is torn down.\n cache: Cache;\n}\n\nconst HAContext = createContext<HAStore | null>(null);\n\ninterface HAProviderProps {\n hass: HomeAssistant | undefined;\n subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;\n subscribeToHass?: SubscribeToHass;\n // Optional injected cache (tests seed/inspect it); defaults to a fresh\n // per-provider Map held stable across re-renders.\n cache?: Cache;\n children: ComponentChildren;\n}\n\nexport function HAProvider({\n hass,\n subscribeToEntity,\n subscribeToHass,\n cache,\n children,\n}: HAProviderProps) {\n const hassRef = useRef(hass);\n hassRef.current = hass;\n\n const getHass = useCallbackStable(() => hassRef.current);\n\n const cacheRef = useRef<Cache>();\n if (!cacheRef.current) cacheRef.current = cache ?? new Map();\n\n const resolvedSubscribeToHass = subscribeToHass ?? noopSubscribeToHass;\n\n const store = useMemo<HAStore>(\n () => ({\n getHass,\n subscribeToEntity,\n subscribeToHass: resolvedSubscribeToHass,\n cache: cacheRef.current!,\n }),\n [getHass, subscribeToEntity, resolvedSubscribeToHass],\n );\n\n return <HAContext.Provider value={store}>{children}</HAContext.Provider>;\n}\n\n/** Internal accessor for the provider store (cache, subscriptions). Not part of the public API. */\nexport function useHAStore(): HAStore {\n const store = useContext(HAContext);\n if (!store) {\n throw new Error('useEntity/useHass must be used within an HAProvider');\n }\n return store;\n}\n\n/**\n * Subscribe to a specific entity by ID. Re-renders only when that entity changes.\n *\n * Returns a typed entity based on the domain prefix:\n * - 'calendar.xyz' -> CalendarEntity\n * - 'weather.xyz' -> WeatherEntity\n * - 'sun.sun' -> SunEntity\n * - other domains -> HassEntity (fallback)\n */\nexport function useEntity<T extends string>(entityId: T): EntityForId<T> | undefined {\n const store = useHAStore();\n const cacheKey = `entity:${entityId}`;\n\n const [entity, setEntity] = useState<EntityForId<T> | undefined>(() => {\n const current = store.getHass()?.states[entityId] as EntityForId<T> | undefined;\n if (current) return current;\n return readCache<EntityForId<T>>(store.cache, cacheKey);\n });\n\n useEffect(() => {\n const unsubscribe = store.subscribeToEntity(entityId, (newEntity) => {\n setEntity(newEntity as EntityForId<T>);\n writeCache(store.cache, cacheKey, newEntity);\n });\n return unsubscribe;\n }, [entityId, store.subscribeToEntity, store.cache, cacheKey]);\n\n return entity;\n}\n\n/**\n * Get access to the full hass object for calling services / accessing config.\n * Does NOT re-render on entity changes. Use useEntity for that.\n */\nexport function useHass(): { getHass: () => HomeAssistant | undefined } {\n const store = useHAStore();\n return { getHass: store.getHass };\n}\n\n/**\n * Subscribe to a derived slice of the `hass` object (e.g. config, themes) and\n * re-render only when that slice changes. Use this for non-entity values —\n * entity state goes through `useEntity`. The selector runs on every hass update\n * but only re-renders the consumer when `isEqual` reports a change, so it's\n * cheap for rarely-changing values like config/themes.\n */\nexport function useHassValue<T>(\n selector: (hass: HomeAssistant | undefined) => T,\n isEqual: (a: T, b: T) => boolean = Object.is,\n): T {\n const store = useHAStore();\n\n const selectorRef = useRef(selector);\n selectorRef.current = selector;\n const isEqualRef = useRef(isEqual);\n isEqualRef.current = isEqual;\n\n const [value, setValue] = useState<T>(() => selectorRef.current(store.getHass()));\n\n useEffect(() => {\n const unsubscribe = store.subscribeToHass(() => {\n const next = selectorRef.current(store.getHass());\n setValue((prev) => (isEqualRef.current(prev, next) ? prev : next));\n });\n return unsubscribe;\n }, [store.subscribeToHass, store.getHass]);\n\n return value;\n}\n\n/** Re-renders when `hass.config` changes (units, latitude/longitude, etc.). */\nexport function useHassConfig(): HomeAssistant['config'] | undefined {\n return useHassValue((hass) => hass?.config);\n}\n\n/** Re-renders when the active theme's dark mode flips. */\nexport function useDarkMode(): boolean {\n return useHassValue((hass) => hass?.themes?.darkMode ?? false);\n}\n\ntype ServiceCaller<T extends string> = <S extends keyof ServicesForId<T> & string>(\n service: S,\n ...args: ServicesForId<T>[S] extends undefined\n ? []\n : Record<string, never> extends Exclude<ServicesForId<T>[S], undefined>\n ? [data?: ServicesForId<T>[S]]\n : [data: ServicesForId<T>[S]]\n) => Promise<void>;\n\n/**\n * Returns a stable function that calls services on a specific HA entity.\n * The service domain is parsed from the entity ID prefix and `entity_id` is\n * auto-injected into every call. Service names and data shapes are strongly\n * typed via DomainServiceMap when the domain is registered. No-ops if hass\n * is not yet available or the entity ID is empty.\n *\n * const fanService = useService(config.entity); // `fan.${string}`\n * await fanService('turn_off');\n * await fanService('set_percentage', { percentage: 67 });\n */\nexport function useService<T extends string>(entityId: T): ServiceCaller<T> {\n const { getHass } = useHass();\n return useCallbackStable(((service: string, data?: object) => {\n const hass = getHass();\n if (!hass || !entityId.includes('.')) return Promise.resolve();\n const domain = entityId.split('.', 1)[0];\n return hass.callService(domain, service, { entity_id: entityId, ...data });\n }) as ServiceCaller<T>);\n}\n\ninterface UseCachedFetchResult<T> {\n data: T | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n}\n\n/**\n * Generic hook for fetching data with localStorage caching. Returns a cache-aware\n * status string to distinguish cached vs fresh data.\n */\nexport function useCachedFetch<T>(\n cacheKey: string,\n fetcher: () => Promise<T>,\n deps: unknown[],\n): UseCachedFetchResult<T> {\n const store = useHAStore();\n const [data, setData] = useState<T | undefined>(() => readCache<T>(store.cache, cacheKey));\n const [isFresh, setIsFresh] = useState(false);\n const [isFetching, setIsFetching] = useState(false);\n const [error, setError] = useState<Error | undefined>(undefined);\n\n // Stale-while-revalidate, key-change aware. When `cacheKey` changes we swap to\n // the new key's cached value synchronously (SWR hit) or keep the previously\n // rendered data (keep-previous-data on a cold key) — never blanking to a\n // loading state. The `deps` effect below issues the background refetch; the\n // only `'loading'` state is a true cold start (nothing cached, nothing fetched).\n const dataKeyRef = useRef(cacheKey);\n if (cacheKey !== dataKeyRef.current) {\n dataKeyRef.current = cacheKey;\n setIsFresh(false);\n setError(undefined);\n const cached = readCache<T>(store.cache, cacheKey);\n if (cached !== undefined) setData(cached);\n // cache miss: leave `data` as-is (keep-previous-data)\n }\n\n const fetchIdRef = useRef(0);\n\n const doFetch = useCallbackStable(async () => {\n const fetchId = ++fetchIdRef.current;\n setIsFetching(true);\n setError(undefined);\n\n try {\n const result = await fetcher();\n if (fetchId === fetchIdRef.current) {\n setData(result);\n setIsFresh(true);\n writeCache(store.cache, cacheKey, result);\n }\n } catch (err) {\n if (fetchId === fetchIdRef.current) {\n setError(err instanceof Error ? err : new Error(String(err)));\n }\n } finally {\n if (fetchId === fetchIdRef.current) {\n setIsFetching(false);\n }\n }\n });\n\n useEffect(() => {\n doFetch();\n }, deps);\n\n const status: FetchStatus = useMemo(() => {\n if (!data && isFetching) return 'loading';\n if (data && !isFresh && isFetching) return 'cached';\n if (data && isFresh && isFetching) return 'refreshing';\n return 'ready';\n }, [data, isFresh, isFetching]);\n\n return { data, status, error, refetch: doFetch };\n}\n\ninterface UseWeatherForecastResult {\n forecast: WeatherForecast[] | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n}\n\n/**\n * Fetch weather forecast data with localStorage caching. Auto-refetches at the\n * top of each hour and when the underlying entity changes (debounced).\n */\nexport function useWeatherForecast(\n entityId: `weather.${string}`,\n type: ForecastType,\n): UseWeatherForecastResult {\n const store = useHAStore();\n const { getHass } = useHass();\n const cacheKey = `forecast:${entityId}:${type}`;\n\n const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const hourlyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const fetcher = useCallbackStable(async () => {\n const hass = getHass();\n if (!hass?.connection) {\n throw new Error('Home Assistant connection not available');\n }\n\n const result = await hass.connection.sendMessagePromise<{\n response: { [entityId: string]: { forecast: WeatherForecast[] } };\n }>({\n type: 'call_service',\n domain: 'weather',\n service: 'get_forecasts',\n service_data: { type },\n target: { entity_id: entityId },\n return_response: true,\n });\n\n return result.response?.[entityId]?.forecast ?? [];\n });\n\n const {\n data: forecast,\n status,\n error,\n refetch,\n } = useCachedFetch(cacheKey, fetcher, [entityId, type]);\n\n const debouncedRefetch = useCallbackStable(() => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n debounceTimerRef.current = setTimeout(() => refetch(), 500);\n });\n\n const scheduleHourlyRefetch = useCallbackStable(() => {\n if (hourlyTimerRef.current) {\n clearTimeout(hourlyTimerRef.current);\n }\n const now = new Date();\n const nextHour = new Date(now);\n nextHour.setHours(now.getHours() + 1, 0, 0, 0);\n const msUntilNextHour = nextHour.getTime() - now.getTime();\n\n hourlyTimerRef.current = setTimeout(() => {\n refetch();\n scheduleHourlyRefetch();\n }, msUntilNextHour);\n });\n\n useEffect(() => {\n scheduleHourlyRefetch();\n }, [entityId, type, scheduleHourlyRefetch]);\n\n useEffect(() => {\n const unsubscribe = store.subscribeToEntity(entityId, debouncedRefetch);\n return () => {\n unsubscribe();\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n if (hourlyTimerRef.current) {\n clearTimeout(hourlyTimerRef.current);\n }\n };\n }, [entityId, store.subscribeToEntity, debouncedRefetch]);\n\n return { forecast, status, error, refetch };\n}\n","// Style registry for Shadow DOM injection\n// Each .styles.ts file uses css`` which auto-registers\n\nconst styleRegistry: string[] = [];\n\n/**\n * CSS tagged template literal for syntax highlighting.\n * Automatically registers the styles with the global registry.\n */\nexport const css = (strings: TemplateStringsArray, ...values: unknown[]): string => {\n const result = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');\n styleRegistry.push(result);\n return result;\n};\n\n/**\n * Register raw CSS string (e.g., from ?inline imports).\n * Only registers if not already present.\n */\nexport function registerRawStyles(styles: string): void {\n if (!styleRegistry.includes(styles)) {\n styleRegistry.push(styles);\n }\n}\n\n/**\n * Get all registered styles for Shadow DOM injection.\n */\nexport function getAllStyles(): string {\n return styleRegistry.join('\\n');\n}\n","import { type ComponentType, render } from 'preact';\nimport { HAProvider } from './HAContext';\nimport { getAllStyles } from './styleRegistry';\nimport type { HomeAssistant } from './types';\n\ninterface RegisterPreactCardOptions<TConfig> {\n type: string;\n name: string;\n description: string;\n Component: ComponentType<{ config: TConfig }>;\n ConfigComponent?: ComponentType<{\n hass: HomeAssistant;\n config: TConfig;\n onConfigChanged: (config: TConfig) => void;\n }>;\n UnconfiguredComponent?: ComponentType<{}>;\n getStubConfig?: () => Partial<TConfig>;\n}\n\ndeclare global {\n interface Window {\n customCards?: Array<{ type: string; name: string; description: string }>;\n }\n}\n\n// Grace period before a disconnected card is torn down. HA detaches and\n// re-attaches cards transiently (scroll virtualization, edit mode); only a card\n// that stays gone past this window is genuinely removed, so we defer the\n// Preact unmount — and the effect cleanups it triggers (timers, subscriptions)\n// — until then. A reconnect within the window cancels the teardown.\nconst TEARDOWN_GRACE_MS = 5000;\n\nexport function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<TConfig>) {\n const {\n type,\n name,\n description,\n Component,\n ConfigComponent,\n UnconfiguredComponent,\n getStubConfig,\n } = options;\n\n // Shared host-element plumbing: config/hass storage, connect/disconnect\n // lifecycle, deferred teardown, and the render skeleton. Subclasses supply\n // the render root and the tree to render, and may override hass behavior.\n abstract class BaseHACard extends HTMLElement {\n protected _hass?: HomeAssistant;\n protected _config?: TConfig;\n private _teardownTimer?: ReturnType<typeof setTimeout>;\n\n protected abstract _getRenderRoot(): Element | ShadowRoot;\n protected abstract _renderTree(): void;\n protected _renderUnconfigured(): void {}\n\n connectedCallback() {\n if (this._teardownTimer !== undefined) {\n clearTimeout(this._teardownTimer);\n this._teardownTimer = undefined;\n }\n this._maybeRenderOnConnect();\n }\n\n protected _maybeRenderOnConnect() {\n if (this._hass && this._config) {\n this._render();\n }\n }\n\n disconnectedCallback() {\n if (this._teardownTimer !== undefined) clearTimeout(this._teardownTimer);\n this._teardownTimer = setTimeout(() => {\n // Unmount the Preact tree, running effect cleanups (clears timers and\n // entity subscriptions). Scheduled, never synchronous, so a transient\n // disconnect+reconnect leaves the tree intact.\n render(null, this._getRenderRoot());\n this._teardownTimer = undefined;\n }, TEARDOWN_GRACE_MS);\n }\n\n setConfig(config: TConfig) {\n this._config = config;\n if (this._hass && this.isConnected) {\n this._render();\n }\n }\n\n protected _render() {\n if (!this._config || !this._hass) {\n this._renderUnconfigured();\n return;\n }\n this._renderTree();\n }\n }\n\n class HACard extends BaseHACard {\n private _shadowRoot: ShadowRoot;\n private _entityChangeListeners = new Map<string, Set<(entity: any) => void>>();\n private _hassChangeListeners = new Set<() => void>();\n\n constructor() {\n super();\n this._shadowRoot = this.attachShadow({ mode: 'open' });\n }\n\n protected _getRenderRoot() {\n return this._shadowRoot;\n }\n\n set hass(hass: HomeAssistant) {\n const prevStates = this._hass?.states;\n this._hass = hass;\n\n for (const [entityId, listeners] of this._entityChangeListeners) {\n const newState = hass.states[entityId];\n const oldState = prevStates?.[entityId];\n if (newState !== oldState) {\n listeners.forEach((listener) => listener(newState));\n }\n }\n\n // Notify non-entity (config/themes) subscribers on every update. This does\n // not re-render the whole card — useHassValue re-renders only its own\n // consumer, and only when its selected slice actually changes.\n this._hassChangeListeners.forEach((listener) => listener());\n\n if (!prevStates && this._config && this.isConnected) {\n this._render();\n }\n }\n\n private _subscribeToEntity = (entityId: string, callback: (entity: any) => void) => {\n if (!this._entityChangeListeners.has(entityId)) {\n this._entityChangeListeners.set(entityId, new Set());\n }\n this._entityChangeListeners.get(entityId)!.add(callback);\n\n return () => {\n const listeners = this._entityChangeListeners.get(entityId);\n if (listeners) {\n listeners.delete(callback);\n if (listeners.size === 0) {\n this._entityChangeListeners.delete(entityId);\n }\n }\n };\n };\n\n private _subscribeToHass = (callback: () => void) => {\n this._hassChangeListeners.add(callback);\n return () => {\n this._hassChangeListeners.delete(callback);\n };\n };\n\n protected _renderTree() {\n render(\n <HAProvider\n hass={this._hass}\n subscribeToEntity={this._subscribeToEntity}\n subscribeToHass={this._subscribeToHass}\n >\n <style>{getAllStyles()}</style>\n <Component config={this._config!} />\n </HAProvider>,\n this._shadowRoot,\n );\n }\n\n protected _renderUnconfigured() {\n if (UnconfiguredComponent) {\n render(<UnconfiguredComponent />, this._shadowRoot);\n }\n }\n\n static getConfigElement() {\n if (ConfigComponent) {\n return document.createElement(`${type}-editor`);\n }\n return undefined;\n }\n\n static getStubConfig() {\n return getStubConfig?.() ?? {};\n }\n }\n\n customElements.define(type, HACard);\n\n if (ConfigComponent) {\n const EditorComponent = ConfigComponent;\n\n class HACardEditor extends BaseHACard {\n protected _getRenderRoot() {\n return this;\n }\n\n // The editor re-renders on every hass update: it passes `hass` straight to\n // HA's <ha-form>/<ha-selector>, whose entity pickers need a fresh hass to\n // stay current. (Unlike the card, which renders once then subscribes.)\n set hass(hass: HomeAssistant) {\n this._hass = hass;\n this._render();\n }\n\n // Render whenever config arrives, regardless of connection — HA may set\n // config/hass before connecting the editor element.\n setConfig(config: TConfig) {\n this._config = config;\n this._render();\n }\n\n private _fireConfigChanged = (config: TConfig) => {\n this.dispatchEvent(\n new CustomEvent('config-changed', {\n detail: { config },\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n // Render to light DOM so HA's custom elements (ha-form etc.) work.\n protected _renderTree() {\n render(\n <EditorComponent\n hass={this._hass!}\n config={this._config!}\n onConfigChanged={this._fireConfigChanged}\n />,\n this,\n );\n }\n }\n\n customElements.define(`${type}-editor`, HACardEditor);\n }\n\n window.customCards = window.customCards || [];\n window.customCards.push({ type, name, description });\n\n console.info(\n `%c ${name.toUpperCase()} %c loaded `,\n 'background: #3b82f6; color: white; font-weight: bold',\n '',\n );\n}\n","import { type ComponentChildren, createElement } from 'preact';\n\n// `align` controls how content sits within the height HA assigns. Friendly\n// aliases map to flex justify-content; any raw flex value also passes through.\n// Default 'top' keeps content compact at the top (today's look, but the card\n// box now fills the slot). Use 'center', 'bottom', or 'space-between' to spread.\nexport type HACardAlign =\n | 'top'\n | 'center'\n | 'bottom'\n | 'flex-start'\n | 'flex-end'\n | 'space-between'\n | 'space-around'\n | 'space-evenly';\n\nconst ALIGN_ALIASES: Record<string, string> = { top: 'flex-start', bottom: 'flex-end' };\n\ninterface HACardProps {\n align?: HACardAlign;\n class?: string;\n children?: ComponentChildren;\n}\n\n// Drop-in replacement for a raw <ha-card> that fills the height Home Assistant\n// assigns. In the sections (grid) layout HA gives the card's host element a\n// definite height; a plain ha-card collapses to natural content height and\n// renders slightly short. HACard makes the host and ha-card fill that height\n// and flex-distributes content per `align`.\n//\n// `ha-card` is created via createElement (rather than as a JSX intrinsic) so\n// the component carries no dependency on consumers declaring it in their JSX\n// IntrinsicElements — it just works when imported.\nexport function HACard({ align = 'top', class: className, children }: HACardProps) {\n const justify = ALIGN_ALIASES[align] ?? align;\n return (\n <>\n {/* Host must have a definite height for ha-card's 100% to resolve. In\n masonry/auto-row layouts the parent height is auto, so this safely\n collapses back to natural height. */}\n <style>{':host{display:block;height:100%;box-sizing:border-box;}'}</style>\n {createElement(\n 'ha-card',\n {\n class: className,\n style: {\n height: '100%',\n boxSizing: 'border-box',\n display: 'flex',\n flexDirection: 'column',\n justifyContent: justify,\n },\n },\n children,\n )}\n </>\n );\n}\n","import { useEffect, useRef } from 'preact/hooks';\n\nimport { useCachedFetch, useHAStore, useHass } from './HAContext';\nimport { writeCache } from './cacheUtils';\nimport type { CalendarEvent, CalendarEventWithSource, FetchStatus, HomeAssistant } from './types';\nimport { useCallbackStable } from './useCallbackStable';\n\ninterface UseCalendarEventsResult {\n events: CalendarEventWithSource[] | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n /**\n * Warm the cache for an arbitrary range (e.g. adjacent months) without\n * touching component state. Best-effort: skips ranges already cached and\n * swallows failures.\n */\n prefetch: (range: { start: Date; end: Date }) => void;\n}\n\nfunction calendarEventsCacheKey(\n entityIds: `calendar.${string}`[],\n range: { start: Date; end: Date },\n): string {\n return `events:${entityIds.join(',')}:${range.start.getTime()}-${range.end.getTime()}`;\n}\n\n/** Event shape returned by the REST view GET /api/calendars/{entity_id}. */\ninterface ApiCalendarEvent {\n summary: string;\n description?: string | null;\n location?: string | null;\n uid?: string | null;\n recurrence_id?: string | null;\n rrule?: string | null;\n start: { dateTime?: string; date?: string };\n end: { dateTime?: string; date?: string };\n}\n\n/** Flatten the REST API's {dateTime}/{date} start/end into our string form. */\nfunction apiDateString(value: { dateTime?: string; date?: string }): string {\n return value.dateTime ?? value.date ?? '';\n}\n\nasync function fetchEntityEventsRest(\n hass: HomeAssistant,\n entityId: `calendar.${string}`,\n range: { start: Date; end: Date },\n): Promise<CalendarEvent[]> {\n const query =\n `start=${encodeURIComponent(range.start.toISOString())}` +\n `&end=${encodeURIComponent(range.end.toISOString())}`;\n const apiEvents = await hass.callApi!<ApiCalendarEvent[]>(\n 'GET',\n `calendars/${entityId}?${query}`,\n );\n return apiEvents.map((event) => ({\n start: apiDateString(event.start),\n end: apiDateString(event.end),\n summary: event.summary,\n ...(event.description != null && { description: event.description }),\n ...(event.location != null && { location: event.location }),\n ...(event.uid != null && { uid: event.uid }),\n ...(event.recurrence_id != null && { recurrence_id: event.recurrence_id }),\n ...(event.rrule != null && { rrule: event.rrule }),\n }));\n}\n\nasync function fetchEntityEventsWs(\n hass: HomeAssistant,\n entityId: `calendar.${string}`,\n range: { start: Date; end: Date },\n): Promise<CalendarEvent[]> {\n const result = await hass.connection.sendMessagePromise<{\n response: { [key: string]: { events: CalendarEvent[] } };\n }>({\n type: 'call_service',\n domain: 'calendar',\n service: 'get_events',\n service_data: {\n start_date_time: range.start.toISOString(),\n end_date_time: range.end.toISOString(),\n },\n target: { entity_id: entityId },\n return_response: true,\n });\n\n return result.response?.[entityId]?.events ?? [];\n}\n\nasync function fetchCalendarRange(\n hass: HomeAssistant | undefined,\n entityIds: `calendar.${string}`[],\n range: { start: Date; end: Date },\n): Promise<CalendarEventWithSource[]> {\n if (!hass?.connection) {\n throw new Error('Home Assistant connection not available');\n }\n if (entityIds.length === 0) {\n return [];\n }\n\n const results = await Promise.all(\n entityIds.map(async (entityId) => {\n try {\n // Prefer the REST view: unlike the calendar.get_events service\n // response (filtered to LIST_EVENT_FIELDS in HA core), it includes\n // uid/recurrence_id/rrule. The WS service call remains as a fallback\n // for environments without callApi (test mocks, Storybook).\n const calendarEvents = hass.callApi\n ? await fetchEntityEventsRest(hass, entityId, range)\n : await fetchEntityEventsWs(hass, entityId, range);\n return calendarEvents.map(\n (event): CalendarEventWithSource => ({ ...event, calendarId: entityId }),\n );\n } catch (err) {\n console.error(`Failed to fetch events for ${entityId}:`, err);\n return [];\n }\n }),\n );\n\n return results.flat();\n}\n\n/**\n * Fetch events from one or more calendars for a date range, with in-memory\n * (per-card) caching and stale-while-revalidate behavior. Events are tagged\n * with their source calendar ID. Returns `prefetch` to warm adjacent ranges.\n */\nexport function useCalendarEvents(\n entityIds: `calendar.${string}`[],\n options: { start: Date; end: Date },\n): UseCalendarEventsResult {\n const store = useHAStore();\n const { getHass } = useHass();\n\n const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const entityIdsKey = entityIds.join(',');\n const dateRangeKey = `${options.start.getTime()}-${options.end.getTime()}`;\n const cacheKey = `events:${entityIdsKey}:${dateRangeKey}`;\n\n const fetcher = useCallbackStable(() => fetchCalendarRange(getHass(), entityIds, options));\n\n const {\n data: events,\n status,\n error,\n refetch,\n } = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey]);\n\n const prefetch = useCallbackStable((range: { start: Date; end: Date }) => {\n const key = calendarEventsCacheKey(entityIds, range);\n if (store.cache.has(key)) return; // already warm\n fetchCalendarRange(getHass(), entityIds, range)\n .then((result) => writeCache(store.cache, key, result))\n .catch(() => {\n // best-effort prefetch; ignore failures\n });\n });\n\n const debouncedRefetch = useCallbackStable(() => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n debounceTimerRef.current = setTimeout(() => refetch(), 500);\n });\n\n useEffect(() => {\n const unsubscribes = entityIds.map((entityId) =>\n store.subscribeToEntity(entityId, debouncedRefetch),\n );\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n };\n }, [entityIdsKey, store.subscribeToEntity, debouncedRefetch]);\n\n return { events, status, error, refetch, prefetch };\n}\n\n/**\n * Event payload for the calendar mutation WebSocket commands. Dates are either\n * date-only strings (\"2026-07-17\", all-day) or ISO datetimes.\n */\nexport interface CalendarMutationEvent {\n dtstart: string;\n dtend: string;\n summary: string;\n description?: string;\n location?: string;\n rrule?: string;\n}\n\nfunction requireConnection(hass: HomeAssistant | undefined): HomeAssistant {\n if (!hass?.connection) {\n throw new Error('Home Assistant connection not available');\n }\n return hass;\n}\n\n/**\n * Create an event on a calendar that supports mutation (e.g. Local Calendar).\n * Requires entity control permission, not admin. WS errors reject unchanged so\n * callers can inspect `err.code` (e.g. 'unauthorized').\n */\nexport async function createCalendarEvent(\n hass: HomeAssistant | undefined,\n entityId: `calendar.${string}`,\n event: CalendarMutationEvent,\n): Promise<void> {\n await requireConnection(hass).connection.sendMessagePromise({\n type: 'calendar/event/create',\n entity_id: entityId,\n event,\n });\n}\n\n/** Delete a calendar event by its uid. */\nexport async function deleteCalendarEvent(\n hass: HomeAssistant | undefined,\n entityId: `calendar.${string}`,\n uid: string,\n opts?: { recurrenceId?: string; recurrenceRange?: string },\n): Promise<void> {\n await requireConnection(hass).connection.sendMessagePromise({\n type: 'calendar/event/delete',\n entity_id: entityId,\n uid,\n ...(opts?.recurrenceId !== undefined && { recurrence_id: opts.recurrenceId }),\n ...(opts?.recurrenceRange !== undefined && { recurrence_range: opts.recurrenceRange }),\n });\n}\n\n/** Replace a calendar event's content by its uid. */\nexport async function updateCalendarEvent(\n hass: HomeAssistant | undefined,\n entityId: `calendar.${string}`,\n uid: string,\n event: CalendarMutationEvent,\n): Promise<void> {\n await requireConnection(hass).connection.sendMessagePromise({\n type: 'calendar/event/update',\n entity_id: entityId,\n uid,\n event,\n });\n}\n","import type { RefObject } from 'preact';\nimport { useEffect, useRef } from 'preact/hooks';\n\nexport interface ElementSize {\n width: number;\n height: number;\n}\n\nexport type ResizeCallback = (size: ElementSize) => void;\n\n/**\n * Observe an element's size via ResizeObserver. The callback fires:\n *\n * 1. Once after mount, with the element's current size.\n * 2. Whenever the element's size changes.\n * 3. Whenever `deps` change, re-firing with the current size — so callers\n * can re-run draws when their inputs change without re-creating the\n * observer.\n *\n * The callback is suppressed only while the element is detached from the\n * document. Zero width/height is delivered to the callback as-is — consumers\n * that need to skip degenerate sizes (e.g. canvas painters where a 0-sized\n * drawImage throws InvalidStateError) should add their own early return.\n *\n * Sizes are read from `offsetWidth` / `offsetHeight` (CSS pixels, includes\n * padding + border). The callback is held in a ref, so passing a fresh\n * function each render is safe — it never re-creates the observer.\n *\n * @example\n * const containerRef = useRef<HTMLDivElement>(null);\n * const canvasRef = useRef<HTMLCanvasElement>(null);\n *\n * useResizeObserver(\n * containerRef,\n * ({ width, height }) => {\n * if (width === 0 || height === 0) return; // optional, consumer's call\n * drawChart(canvasRef.current, forecast, width, height);\n * },\n * [forecast],\n * );\n */\nexport function useResizeObserver<T extends HTMLElement>(\n ref: RefObject<T>,\n callback: ResizeCallback,\n deps: unknown[] = [],\n): void {\n const callbackRef = useRef(callback);\n callbackRef.current = callback;\n\n // Set up the observer once per element. ResizeObserver fires once\n // synchronously-ish after `.observe()` with the current size, which\n // covers the initial draw.\n useEffect(() => {\n const element = ref.current;\n if (!element) return;\n\n const fire = () => {\n if (!element.isConnected) return;\n callbackRef.current({\n width: element.offsetWidth,\n height: element.offsetHeight,\n });\n };\n\n const observer = new ResizeObserver(fire);\n observer.observe(element);\n return () => observer.disconnect();\n // ref identity is stable across renders; observer setup runs once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Re-fire on dependency change. Skip the first render — the observer's\n // initial `.observe()` fire already delivers the mount-time size.\n const isFirstRun = useRef(true);\n useEffect(() => {\n if (isFirstRun.current) {\n isFirstRun.current = false;\n return;\n }\n const element = ref.current;\n if (!element || !element.isConnected) return;\n callbackRef.current({\n width: element.offsetWidth,\n height: element.offsetHeight,\n });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps);\n}\n","import type { RefObject } from 'preact';\nimport { useState } from 'preact/hooks';\nimport { useResizeObserver } from './useResizeObserver';\n\n/**\n * Track the current width of a referenced element in CSS pixels.\n *\n * Returns `undefined` until the first non-zero measurement is observed, then\n * a positive number that updates as the element resizes. Once a real width\n * is captured the hook will never report `undefined` or `0` again, even\n * during HA layout transitions (dashboard switch, edit-mode toggle) — the\n * underlying ResizeObserver firings are silently dropped while the element\n * is detached or transiently zero-width, so the component renders with the\n * last good value instead of flashing through a degenerate state.\n *\n * Use this when you need a width value in JSX (responsive layout, prop to a\n * sized child). If you only need the value imperatively inside a draw\n * callback, prefer `useResizeObserver` directly — it doesn't allocate\n * component state or cause re-renders.\n *\n * @example\n * const ref = useRef<HTMLDivElement>(null);\n * const width = useWidth(ref);\n * return (\n * <div ref={ref}>\n * {width !== undefined && <Chart width={width} />}\n * </div>\n * );\n */\nexport function useWidth<T extends HTMLElement>(ref: RefObject<T>): number | undefined {\n const [width, setWidth] = useState<number | undefined>(undefined);\n useResizeObserver(ref, (size) => {\n if (size.width === 0) return;\n setWidth((prev) => (prev === size.width ? prev : size.width));\n });\n return width;\n}\n"],"names":["HACard"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../src/cacheUtils.ts","../src/persistentCache.ts","../src/useCallbackStable.ts","../src/HAContext.tsx","../src/styleRegistry.ts","../src/registerPreactCard.tsx","../src/HACard.tsx","../src/calendars.ts","../src/useResizeObserver.ts","../src/useWidth.ts"],"sourcesContent":["// In-memory cache helpers. The cache Map is owned per-card by the HAProvider\n// store (see HAContext), so its lifetime matches the card and it is\n// garbage-collected when the card is torn down. There is intentionally no TTL\n// or size cap here: freshness comes from entity subscriptions and periodic\n// refetch, not from cache expiry, and growth is bounded by the ranges a single\n// card visits in a session. Cross-reload persistence (with expiry) lives in\n// persistentCache.ts and only seeds this cache on a miss.\n\nexport interface CacheEntry<T> {\n data: T;\n}\n\nexport type Cache = Map<string, CacheEntry<unknown>>;\n\nexport function readCache<T>(cache: Cache, key: string): T | undefined {\n return (cache.get(key) as CacheEntry<T> | undefined)?.data;\n}\n\nexport function writeCache<T>(cache: Cache, key: string, data: T): void {\n cache.set(key, { data });\n}\n","// localStorage persistence beneath the per-card in-memory cache. It only seeds\n// a cold start: persisted data is always shown as stale ('cached') and then\n// revalidated, so expiry here bounds storage rather than guaranteeing freshness.\n//\n// Every card built on this library bundles its own copy, and they all share one\n// localStorage. Housekeeping therefore covers the whole `preact-ha:` NAMESPACE,\n// not just this version's PREFIX, so data left by a deleted card (or a card on\n// another library version) still expires as long as any preact-ha card loads.\n//\n// - Entries older than MAX_AGE_MS are dropped (never shown).\n// - At most MAX_ENTRIES are kept namespace-wide; least-recently-used go first.\n// - The first access per page sweeps expired and unrecognizable entries.\n// - The versioned PREFIX scopes what this version *reads*; bumping it makes old\n// entries unreadable to new code, and they then age out via the sweep.\n// - Quota errors evict the oldest entry and retry once, then give up silently.\n//\n// Cross-version contract — keep stable in all future versions: every value under\n// NAMESPACE is JSON with numeric `s` (saved at, ms) and `u` (last used, ms).\n// Pre-0.3 entries ({ data, timestamp }) are understood via `timestamp`.\n\nconst NAMESPACE = 'preact-ha:';\nconst PREFIX = 'preact-ha:v2:';\nexport const MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;\nexport const MAX_ENTRIES = 60;\n\ninterface PersistedEntry<T> {\n /** data */\n d: T;\n /** saved at (ms) */\n s: number;\n /** last used (ms) */\n u: number;\n}\n\ninterface EntryTimes {\n saved: number;\n used: number;\n}\n\nlet swept = false;\n\nfunction storage(): Storage | undefined {\n try {\n return globalThis.localStorage ?? undefined;\n } catch {\n // Access can throw (sandboxed iframes, disabled storage)\n return undefined;\n }\n}\n\n/** Timestamps from any version's entry; undefined if unrecognizable. */\nfunction timesOf(raw: string | null): EntryTimes | undefined {\n if (!raw) return undefined;\n try {\n const value = JSON.parse(raw);\n const saved = typeof value?.s === 'number' ? value.s : value?.timestamp;\n if (typeof saved !== 'number') return undefined;\n return { saved, used: typeof value.u === 'number' ? value.u : saved };\n } catch {\n return undefined;\n }\n}\n\nfunction namespaceKeys(ls: Storage): string[] {\n const keys: string[] = [];\n for (let i = 0; i < ls.length; i++) {\n const key = ls.key(i);\n if (key?.startsWith(NAMESPACE)) keys.push(key);\n }\n return keys;\n}\n\n/** Namespace entries, least recently used first. */\nfunction entriesByUse(ls: Storage): { key: string; used: number }[] {\n return namespaceKeys(ls)\n .map((key) => ({ key, used: timesOf(ls.getItem(key))?.used ?? 0 }))\n .sort((a, b) => a.used - b.used);\n}\n\nfunction evictOverCap(ls: Storage): void {\n // Cheap key count first; only parse entries when we actually need to evict.\n const count = namespaceKeys(ls).length;\n if (count <= MAX_ENTRIES) return;\n const entries = entriesByUse(ls);\n for (let i = 0; i < count - MAX_ENTRIES; i++) {\n ls.removeItem(entries[i].key);\n }\n}\n\n/** Drop expired and unrecognizable entries namespace-wide, then enforce the cap. Once per page. */\nfunction sweep(ls: Storage): void {\n if (swept) return;\n swept = true;\n const now = Date.now();\n for (const key of namespaceKeys(ls)) {\n const times = timesOf(ls.getItem(key));\n if (!times || now - times.saved > MAX_AGE_MS) ls.removeItem(key);\n }\n evictOverCap(ls);\n}\n\nexport function readPersisted<T>(key: string): T | undefined {\n const ls = storage();\n if (!ls) return undefined;\n try {\n sweep(ls);\n const fullKey = PREFIX + key;\n const raw = ls.getItem(fullKey);\n if (!raw) return undefined;\n const entry = JSON.parse(raw) as PersistedEntry<T>;\n const now = Date.now();\n if (typeof entry?.s !== 'number' || now - entry.s > MAX_AGE_MS) {\n ls.removeItem(fullKey);\n return undefined;\n }\n entry.u = now;\n ls.setItem(fullKey, JSON.stringify(entry));\n return entry.d;\n } catch {\n return undefined;\n }\n}\n\nexport function writePersisted<T>(key: string, data: T): void {\n const ls = storage();\n if (!ls) return;\n const now = Date.now();\n const value = JSON.stringify({ d: data, s: now, u: now } satisfies PersistedEntry<T>);\n try {\n sweep(ls);\n ls.setItem(PREFIX + key, value);\n evictOverCap(ls);\n } catch {\n // Most likely quota exceeded: free the oldest entry and retry once.\n try {\n const oldest = entriesByUse(ls)[0];\n if (oldest) ls.removeItem(oldest.key);\n ls.setItem(PREFIX + key, value);\n } catch {\n // Give up; the in-memory cache still works.\n }\n }\n}\n\n/** Test hook: forget that the startup sweep ran. */\nexport function resetPersistedSweep(): void {\n swept = false;\n}\n","import { useRef } from 'preact/hooks';\n\n/**\n * Creates a stable callback reference that always calls the latest version of the callback.\n * Unlike useCallback, this never changes identity, so it won't cause re-renders in children.\n *\n * @param callback The callback function to stabilize\n * @returns A stable function reference that always calls the latest callback\n */\nexport function useCallbackStable<T extends (...args: never[]) => unknown>(callback: T): T {\n const callbackRef = useRef<T>(callback);\n\n callbackRef.current = callback;\n\n // Create a stable function reference once\n const stableRef = useRef<T | null>(null);\n if (stableRef.current === null) {\n stableRef.current = ((...args: Parameters<T>) => {\n return callbackRef.current(...args);\n }) as T;\n }\n\n return stableRef.current;\n}\n","import { createContext } from 'preact';\nimport type { ComponentChildren } from 'preact';\nimport { useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';\n\nimport { type Cache, readCache, writeCache } from './cacheUtils';\nimport { readPersisted, writePersisted } from './persistentCache';\nimport type {\n EntityForId,\n FetchStatus,\n ForecastType,\n HomeAssistant,\n ServicesForId,\n WeatherForecast,\n} from './types';\nimport { useCallbackStable } from './useCallbackStable';\n\ntype SubscribeToHass = (callback: () => void) => () => void;\n\n// Default for providers that don't wire up hass-value notifications (Storybook,\n// tests). useHassValue then simply returns its initial value and never updates.\nconst noopSubscribeToHass: SubscribeToHass = () => () => {};\n\ninterface HAStore {\n getHass: () => HomeAssistant | undefined;\n subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;\n subscribeToHass: SubscribeToHass;\n // Per-card cache (events, forecasts, entities). Owned by the provider so its\n // lifetime is the card's — GC'd with the store when the card is torn down.\n cache: Cache;\n}\n\nconst HAContext = createContext<HAStore | null>(null);\n\ninterface HAProviderProps {\n hass: HomeAssistant | undefined;\n subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;\n subscribeToHass?: SubscribeToHass;\n // Optional injected cache (tests seed/inspect it); defaults to a fresh\n // per-provider Map held stable across re-renders.\n cache?: Cache;\n children: ComponentChildren;\n}\n\nexport function HAProvider({\n hass,\n subscribeToEntity,\n subscribeToHass,\n cache,\n children,\n}: HAProviderProps) {\n const hassRef = useRef(hass);\n hassRef.current = hass;\n\n const getHass = useCallbackStable(() => hassRef.current);\n\n const cacheRef = useRef<Cache>();\n if (!cacheRef.current) cacheRef.current = cache ?? new Map();\n\n const resolvedSubscribeToHass = subscribeToHass ?? noopSubscribeToHass;\n\n const store = useMemo<HAStore>(\n () => ({\n getHass,\n subscribeToEntity,\n subscribeToHass: resolvedSubscribeToHass,\n cache: cacheRef.current!,\n }),\n [getHass, subscribeToEntity, resolvedSubscribeToHass],\n );\n\n return <HAContext.Provider value={store}>{children}</HAContext.Provider>;\n}\n\n/** Internal accessor for the provider store (cache, subscriptions). Not part of the public API. */\nexport function useHAStore(): HAStore {\n const store = useContext(HAContext);\n if (!store) {\n throw new Error('useEntity/useHass must be used within an HAProvider');\n }\n return store;\n}\n\n/**\n * Subscribe to a specific entity by ID. Re-renders only when that entity changes.\n *\n * Returns a typed entity based on the domain prefix:\n * - 'calendar.xyz' -> CalendarEntity\n * - 'weather.xyz' -> WeatherEntity\n * - 'sun.sun' -> SunEntity\n * - 'fan.xyz' -> FanEntity\n * - other domains -> HassEntity (fallback)\n */\nexport function useEntity<T extends string>(entityId: T): EntityForId<T> | undefined {\n const store = useHAStore();\n const cacheKey = `entity:${entityId}`;\n\n const [entity, setEntity] = useState<EntityForId<T> | undefined>(() => {\n const current = store.getHass()?.states[entityId] as EntityForId<T> | undefined;\n if (current) return current;\n return readCache<EntityForId<T>>(store.cache, cacheKey);\n });\n\n useEffect(() => {\n const unsubscribe = store.subscribeToEntity(entityId, (newEntity) => {\n setEntity(newEntity as EntityForId<T>);\n writeCache(store.cache, cacheKey, newEntity);\n });\n return unsubscribe;\n }, [entityId, store.subscribeToEntity, store.cache, cacheKey]);\n\n return entity;\n}\n\n/**\n * Get access to the full hass object for calling services / accessing config.\n * Does NOT re-render on entity changes. Use useEntity for that.\n */\nexport function useHass(): { getHass: () => HomeAssistant | undefined } {\n const store = useHAStore();\n return { getHass: store.getHass };\n}\n\n/**\n * Subscribe to a derived slice of the `hass` object (e.g. config, themes) and\n * re-render only when that slice changes. Use this for non-entity values —\n * entity state goes through `useEntity`. The selector runs on every hass update\n * but only re-renders the consumer when `isEqual` reports a change, so it's\n * cheap for rarely-changing values like config/themes.\n */\nexport function useHassValue<T>(\n selector: (hass: HomeAssistant | undefined) => T,\n isEqual: (a: T, b: T) => boolean = Object.is,\n): T {\n const store = useHAStore();\n\n const selectorRef = useRef(selector);\n selectorRef.current = selector;\n const isEqualRef = useRef(isEqual);\n isEqualRef.current = isEqual;\n\n const [value, setValue] = useState<T>(() => selectorRef.current(store.getHass()));\n\n useEffect(() => {\n const unsubscribe = store.subscribeToHass(() => {\n const next = selectorRef.current(store.getHass());\n setValue((prev) => (isEqualRef.current(prev, next) ? prev : next));\n });\n return unsubscribe;\n }, [store.subscribeToHass, store.getHass]);\n\n return value;\n}\n\n/** Re-renders when `hass.config` changes (units, latitude/longitude, etc.). */\nexport function useHassConfig(): HomeAssistant['config'] | undefined {\n return useHassValue((hass) => hass?.config);\n}\n\n/** Re-renders when the active theme's dark mode flips. */\nexport function useDarkMode(): boolean {\n return useHassValue((hass) => hass?.themes?.darkMode ?? false);\n}\n\ntype ServiceCaller<T extends string> = <S extends keyof ServicesForId<T> & string>(\n service: S,\n ...args: ServicesForId<T>[S] extends undefined\n ? []\n : Record<string, never> extends Exclude<ServicesForId<T>[S], undefined>\n ? [data?: ServicesForId<T>[S]]\n : [data: ServicesForId<T>[S]]\n) => Promise<void>;\n\n/**\n * Returns a stable function that calls services on a specific HA entity.\n * The service domain is parsed from the entity ID prefix and `entity_id` is\n * auto-injected into every call. Service names and data shapes are strongly\n * typed via DomainServiceMap when the domain is registered. No-ops if hass\n * is not yet available or the entity ID is empty.\n *\n * const fanService = useService(config.entity); // `fan.${string}`\n * await fanService('turn_off');\n * await fanService('set_percentage', { percentage: 67 });\n */\nexport function useService<T extends string>(entityId: T): ServiceCaller<T> {\n const { getHass } = useHass();\n return useCallbackStable(((service: string, data?: object) => {\n const hass = getHass();\n if (!hass || !entityId.includes('.')) return Promise.resolve();\n const domain = entityId.split('.', 1)[0];\n return hass.callService(domain, service, { entity_id: entityId, ...data });\n }) as ServiceCaller<T>);\n}\n\ninterface UseCachedFetchResult<T> {\n data: T | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n}\n\ninterface UseCachedFetchOptions<T> {\n /**\n * Fallback read when the in-memory cache misses (e.g. from localStorage).\n * Seeded data is shown as stale (`'cached'`) and revalidated like any hit.\n */\n seed?: () => T | undefined;\n}\n\n/**\n * Generic hook for fetching data with in-memory caching (optionally seeded from\n * a persistent store). Returns a cache-aware status string to distinguish cached\n * vs fresh data.\n */\nexport function useCachedFetch<T>(\n cacheKey: string,\n fetcher: () => Promise<T>,\n deps: unknown[],\n options: UseCachedFetchOptions<T> = {},\n): UseCachedFetchResult<T> {\n const store = useHAStore();\n const readCached = () => readCache<T>(store.cache, cacheKey) ?? options.seed?.();\n const [data, setData] = useState<T | undefined>(readCached);\n const [isFresh, setIsFresh] = useState(false);\n const [isFetching, setIsFetching] = useState(false);\n const [error, setError] = useState<Error | undefined>(undefined);\n\n // Stale-while-revalidate, key-change aware. When `cacheKey` changes we swap to\n // the new key's cached value synchronously (SWR hit) or keep the previously\n // rendered data (keep-previous-data on a cold key) — never blanking to a\n // loading state. The `deps` effect below issues the background refetch; the\n // only `'loading'` state is a true cold start (nothing cached, nothing fetched).\n const dataKeyRef = useRef(cacheKey);\n if (cacheKey !== dataKeyRef.current) {\n dataKeyRef.current = cacheKey;\n setIsFresh(false);\n setError(undefined);\n const cached = readCached();\n if (cached !== undefined) setData(cached);\n // cache miss: leave `data` as-is (keep-previous-data)\n }\n\n const fetchIdRef = useRef(0);\n\n const doFetch = useCallbackStable(async () => {\n const fetchId = ++fetchIdRef.current;\n setIsFetching(true);\n setError(undefined);\n\n try {\n const result = await fetcher();\n if (fetchId === fetchIdRef.current) {\n setData(result);\n setIsFresh(true);\n writeCache(store.cache, cacheKey, result);\n }\n } catch (err) {\n if (fetchId === fetchIdRef.current) {\n setError(err instanceof Error ? err : new Error(String(err)));\n }\n } finally {\n if (fetchId === fetchIdRef.current) {\n setIsFetching(false);\n }\n }\n });\n\n useEffect(() => {\n doFetch();\n }, deps);\n\n const status: FetchStatus = useMemo(() => {\n if (!data && isFetching) return 'loading';\n if (data && !isFresh && isFetching) return 'cached';\n if (data && isFresh && isFetching) return 'refreshing';\n return 'ready';\n }, [data, isFresh, isFetching]);\n\n return { data, status, error, refetch: doFetch };\n}\n\ninterface UseWeatherForecastResult {\n forecast: WeatherForecast[] | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n}\n\n/**\n * Fetch weather forecast data, cached in memory and persisted to localStorage\n * so a reload renders immediately. Auto-refetches at the top of each hour and\n * when the underlying entity changes (debounced).\n */\nexport function useWeatherForecast(\n entityId: `weather.${string}`,\n type: ForecastType,\n): UseWeatherForecastResult {\n const store = useHAStore();\n const { getHass } = useHass();\n const cacheKey = `forecast:${entityId}:${type}`;\n\n const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const hourlyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const fetcher = useCallbackStable(async () => {\n const hass = getHass();\n if (!hass?.connection) {\n throw new Error('Home Assistant connection not available');\n }\n\n const result = await hass.connection.sendMessagePromise<{\n response: { [entityId: string]: { forecast: WeatherForecast[] } };\n }>({\n type: 'call_service',\n domain: 'weather',\n service: 'get_forecasts',\n service_data: { type },\n target: { entity_id: entityId },\n return_response: true,\n });\n\n const forecast = result.response?.[entityId]?.forecast ?? [];\n writePersisted(cacheKey, forecast);\n return forecast;\n });\n\n const {\n data: forecast,\n status,\n error,\n refetch,\n } = useCachedFetch(cacheKey, fetcher, [entityId, type], {\n seed: () => readPersisted<WeatherForecast[]>(cacheKey),\n });\n\n const debouncedRefetch = useCallbackStable(() => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n debounceTimerRef.current = setTimeout(() => refetch(), 500);\n });\n\n const scheduleHourlyRefetch = useCallbackStable(() => {\n if (hourlyTimerRef.current) {\n clearTimeout(hourlyTimerRef.current);\n }\n const now = new Date();\n const nextHour = new Date(now);\n nextHour.setHours(now.getHours() + 1, 0, 0, 0);\n const msUntilNextHour = nextHour.getTime() - now.getTime();\n\n hourlyTimerRef.current = setTimeout(() => {\n refetch();\n scheduleHourlyRefetch();\n }, msUntilNextHour);\n });\n\n useEffect(() => {\n scheduleHourlyRefetch();\n }, [entityId, type, scheduleHourlyRefetch]);\n\n useEffect(() => {\n const unsubscribe = store.subscribeToEntity(entityId, debouncedRefetch);\n return () => {\n unsubscribe();\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n if (hourlyTimerRef.current) {\n clearTimeout(hourlyTimerRef.current);\n }\n };\n }, [entityId, store.subscribeToEntity, debouncedRefetch]);\n\n return { forecast, status, error, refetch };\n}\n","// Style registry for Shadow DOM injection\n// Each .styles.ts file uses css`` which auto-registers\n\nconst styleRegistry: string[] = [];\n\n/**\n * CSS tagged template literal for syntax highlighting.\n * Automatically registers the styles with the global registry.\n */\nexport const css = (strings: TemplateStringsArray, ...values: unknown[]): string => {\n const result = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');\n styleRegistry.push(result);\n return result;\n};\n\n/**\n * Register raw CSS string (e.g., from ?inline imports).\n * Only registers if not already present.\n */\nexport function registerRawStyles(styles: string): void {\n if (!styleRegistry.includes(styles)) {\n styleRegistry.push(styles);\n }\n}\n\n/**\n * Get all registered styles for Shadow DOM injection.\n */\nexport function getAllStyles(): string {\n return styleRegistry.join('\\n');\n}\n","import { type ComponentType, render } from 'preact';\nimport { HAProvider } from './HAContext';\nimport { getAllStyles } from './styleRegistry';\nimport type { HomeAssistant } from './types';\n\ninterface RegisterPreactCardOptions<TConfig> {\n type: string;\n name: string;\n description: string;\n Component: ComponentType<{ config: TConfig }>;\n ConfigComponent?: ComponentType<{\n hass: HomeAssistant;\n config: TConfig;\n onConfigChanged: (config: TConfig) => void;\n }>;\n UnconfiguredComponent?: ComponentType<{}>;\n getStubConfig?: () => Partial<TConfig>;\n}\n\ndeclare global {\n interface Window {\n customCards?: Array<{ type: string; name: string; description: string }>;\n }\n}\n\n// Grace period before a disconnected card is torn down. HA detaches and\n// re-attaches cards transiently (scroll virtualization, edit mode); only a card\n// that stays gone past this window is genuinely removed, so we defer the\n// Preact unmount — and the effect cleanups it triggers (timers, subscriptions)\n// — until then. A reconnect within the window cancels the teardown.\nconst TEARDOWN_GRACE_MS = 5000;\n\nexport function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<TConfig>) {\n const {\n type,\n name,\n description,\n Component,\n ConfigComponent,\n UnconfiguredComponent,\n getStubConfig,\n } = options;\n\n // Shared host-element plumbing: config/hass storage, connect/disconnect\n // lifecycle, deferred teardown, and the render skeleton. Subclasses supply\n // the render root and the tree to render, and may override hass behavior.\n abstract class BaseHACard extends HTMLElement {\n protected _hass?: HomeAssistant;\n protected _config?: TConfig;\n private _teardownTimer?: ReturnType<typeof setTimeout>;\n\n protected abstract _getRenderRoot(): Element | ShadowRoot;\n protected abstract _renderTree(): void;\n protected _renderUnconfigured(): void {}\n\n connectedCallback() {\n if (this._teardownTimer !== undefined) {\n clearTimeout(this._teardownTimer);\n this._teardownTimer = undefined;\n }\n this._maybeRenderOnConnect();\n }\n\n protected _maybeRenderOnConnect() {\n if (this._hass && this._config) {\n this._render();\n }\n }\n\n disconnectedCallback() {\n if (this._teardownTimer !== undefined) clearTimeout(this._teardownTimer);\n this._teardownTimer = setTimeout(() => {\n // Unmount the Preact tree, running effect cleanups (clears timers and\n // entity subscriptions). Scheduled, never synchronous, so a transient\n // disconnect+reconnect leaves the tree intact.\n render(null, this._getRenderRoot());\n this._teardownTimer = undefined;\n }, TEARDOWN_GRACE_MS);\n }\n\n setConfig(config: TConfig) {\n this._config = config;\n if (this._hass && this.isConnected) {\n this._render();\n }\n }\n\n protected _render() {\n if (!this._config || !this._hass) {\n this._renderUnconfigured();\n return;\n }\n this._renderTree();\n }\n }\n\n class HACard extends BaseHACard {\n private _shadowRoot: ShadowRoot;\n private _entityChangeListeners = new Map<string, Set<(entity: any) => void>>();\n private _hassChangeListeners = new Set<() => void>();\n\n constructor() {\n super();\n this._shadowRoot = this.attachShadow({ mode: 'open' });\n }\n\n protected _getRenderRoot() {\n return this._shadowRoot;\n }\n\n set hass(hass: HomeAssistant) {\n const prevStates = this._hass?.states;\n this._hass = hass;\n\n for (const [entityId, listeners] of this._entityChangeListeners) {\n const newState = hass.states[entityId];\n const oldState = prevStates?.[entityId];\n if (newState !== oldState) {\n listeners.forEach((listener) => listener(newState));\n }\n }\n\n // Notify non-entity (config/themes) subscribers on every update. This does\n // not re-render the whole card — useHassValue re-renders only its own\n // consumer, and only when its selected slice actually changes.\n this._hassChangeListeners.forEach((listener) => listener());\n\n if (!prevStates && this._config && this.isConnected) {\n this._render();\n }\n }\n\n private _subscribeToEntity = (entityId: string, callback: (entity: any) => void) => {\n if (!this._entityChangeListeners.has(entityId)) {\n this._entityChangeListeners.set(entityId, new Set());\n }\n this._entityChangeListeners.get(entityId)!.add(callback);\n\n return () => {\n const listeners = this._entityChangeListeners.get(entityId);\n if (listeners) {\n listeners.delete(callback);\n if (listeners.size === 0) {\n this._entityChangeListeners.delete(entityId);\n }\n }\n };\n };\n\n private _subscribeToHass = (callback: () => void) => {\n this._hassChangeListeners.add(callback);\n return () => {\n this._hassChangeListeners.delete(callback);\n };\n };\n\n protected _renderTree() {\n render(\n <HAProvider\n hass={this._hass}\n subscribeToEntity={this._subscribeToEntity}\n subscribeToHass={this._subscribeToHass}\n >\n <style>{getAllStyles()}</style>\n <Component config={this._config!} />\n </HAProvider>,\n this._shadowRoot,\n );\n }\n\n protected _renderUnconfigured() {\n if (UnconfiguredComponent) {\n render(<UnconfiguredComponent />, this._shadowRoot);\n }\n }\n\n static getConfigElement() {\n if (ConfigComponent) {\n return document.createElement(`${type}-editor`);\n }\n return undefined;\n }\n\n static getStubConfig() {\n return getStubConfig?.() ?? {};\n }\n }\n\n customElements.define(type, HACard);\n\n if (ConfigComponent) {\n const EditorComponent = ConfigComponent;\n\n class HACardEditor extends BaseHACard {\n protected _getRenderRoot() {\n return this;\n }\n\n // The editor re-renders on every hass update: it passes `hass` straight to\n // HA's <ha-form>/<ha-selector>, whose entity pickers need a fresh hass to\n // stay current. (Unlike the card, which renders once then subscribes.)\n set hass(hass: HomeAssistant) {\n this._hass = hass;\n this._render();\n }\n\n // Render whenever config arrives, regardless of connection — HA may set\n // config/hass before connecting the editor element.\n setConfig(config: TConfig) {\n this._config = config;\n this._render();\n }\n\n private _fireConfigChanged = (config: TConfig) => {\n this.dispatchEvent(\n new CustomEvent('config-changed', {\n detail: { config },\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n // Render to light DOM so HA's custom elements (ha-form etc.) work.\n protected _renderTree() {\n render(\n <EditorComponent\n hass={this._hass!}\n config={this._config!}\n onConfigChanged={this._fireConfigChanged}\n />,\n this,\n );\n }\n }\n\n customElements.define(`${type}-editor`, HACardEditor);\n }\n\n window.customCards = window.customCards || [];\n window.customCards.push({ type, name, description });\n\n console.info(\n `%c ${name.toUpperCase()} %c loaded `,\n 'background: #3b82f6; color: white; font-weight: bold',\n '',\n );\n}\n","import { type ComponentChildren, createElement } from 'preact';\n\n// `align` controls how content sits within the height HA assigns. Friendly\n// aliases map to flex justify-content; any raw flex value also passes through.\n// Default 'top' keeps content compact at the top (today's look, but the card\n// box now fills the slot). Use 'center', 'bottom', or 'space-between' to spread.\nexport type HACardAlign =\n | 'top'\n | 'center'\n | 'bottom'\n | 'flex-start'\n | 'flex-end'\n | 'space-between'\n | 'space-around'\n | 'space-evenly';\n\nconst ALIGN_ALIASES: Record<string, string> = { top: 'flex-start', bottom: 'flex-end' };\n\ninterface HACardProps {\n align?: HACardAlign;\n class?: string;\n children?: ComponentChildren;\n}\n\n// Drop-in replacement for a raw <ha-card> that fills the height Home Assistant\n// assigns. In the sections (grid) layout HA gives the card's host element a\n// definite height; a plain ha-card collapses to natural content height and\n// renders slightly short. HACard makes the host and ha-card fill that height\n// and flex-distributes content per `align`.\n//\n// `ha-card` is created via createElement (rather than as a JSX intrinsic) so\n// the component carries no dependency on consumers declaring it in their JSX\n// IntrinsicElements — it just works when imported.\nexport function HACard({ align = 'top', class: className, children }: HACardProps) {\n const justify = ALIGN_ALIASES[align] ?? align;\n return (\n <>\n {/* Host must have a definite height for ha-card's 100% to resolve. In\n masonry/auto-row layouts the parent height is auto, so this safely\n collapses back to natural height. */}\n <style>{':host{display:block;height:100%;box-sizing:border-box;}'}</style>\n {createElement(\n 'ha-card',\n {\n class: className,\n style: {\n height: '100%',\n boxSizing: 'border-box',\n display: 'flex',\n flexDirection: 'column',\n justifyContent: justify,\n },\n },\n children,\n )}\n </>\n );\n}\n","import { useEffect, useRef } from 'preact/hooks';\n\nimport { useCachedFetch, useHAStore, useHass } from './HAContext';\nimport { writeCache } from './cacheUtils';\nimport { readPersisted, writePersisted } from './persistentCache';\nimport type { CalendarEvent, CalendarEventWithSource, FetchStatus, HomeAssistant } from './types';\nimport { useCallbackStable } from './useCallbackStable';\n\ninterface UseCalendarEventsResult {\n events: CalendarEventWithSource[] | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n /**\n * Warm the cache for an arbitrary range (e.g. adjacent months) without\n * touching component state. Best-effort: skips ranges already cached and\n * swallows failures.\n */\n prefetch: (range: { start: Date; end: Date }) => void;\n}\n\nfunction calendarEventsCacheKey(\n entityIds: `calendar.${string}`[],\n range: { start: Date; end: Date },\n): string {\n return `events:${entityIds.join(',')}:${range.start.getTime()}-${range.end.getTime()}`;\n}\n\n/**\n * Persistent (localStorage) key for one calendar's events over a range. Stored\n * per entity rather than per entity set, so any combination of calendars —\n * across cards, or the same card's regular vs highlight calendars — shares it.\n */\nfunction entityEventsPersistKey(\n entityId: `calendar.${string}`,\n range: { start: Date; end: Date },\n): string {\n return `events:${entityId}:${range.start.getTime()}-${range.end.getTime()}`;\n}\n\n/** Assemble a range from persisted per-entity entries; undefined unless all are present. */\nfunction readPersistedRange(\n entityIds: `calendar.${string}`[],\n range: { start: Date; end: Date },\n): CalendarEventWithSource[] | undefined {\n if (entityIds.length === 0) return undefined;\n const result: CalendarEventWithSource[] = [];\n for (const entityId of entityIds) {\n const events = readPersisted<CalendarEvent[]>(entityEventsPersistKey(entityId, range));\n if (!events) return undefined;\n for (const event of events) result.push({ ...event, calendarId: entityId });\n }\n return result;\n}\n\n/** Event shape returned by the REST view GET /api/calendars/{entity_id}. */\ninterface ApiCalendarEvent {\n summary: string;\n description?: string | null;\n location?: string | null;\n uid?: string | null;\n recurrence_id?: string | null;\n rrule?: string | null;\n start: { dateTime?: string; date?: string };\n end: { dateTime?: string; date?: string };\n}\n\n/** Flatten the REST API's {dateTime}/{date} start/end into our string form. */\nfunction apiDateString(value: { dateTime?: string; date?: string }): string {\n return value.dateTime ?? value.date ?? '';\n}\n\nasync function fetchEntityEventsRest(\n hass: HomeAssistant,\n entityId: `calendar.${string}`,\n range: { start: Date; end: Date },\n): Promise<CalendarEvent[]> {\n const query =\n `start=${encodeURIComponent(range.start.toISOString())}` +\n `&end=${encodeURIComponent(range.end.toISOString())}`;\n const apiEvents = await hass.callApi!<ApiCalendarEvent[]>(\n 'GET',\n `calendars/${entityId}?${query}`,\n );\n return apiEvents.map((event) => ({\n start: apiDateString(event.start),\n end: apiDateString(event.end),\n summary: event.summary,\n ...(event.description != null && { description: event.description }),\n ...(event.location != null && { location: event.location }),\n ...(event.uid != null && { uid: event.uid }),\n ...(event.recurrence_id != null && { recurrence_id: event.recurrence_id }),\n ...(event.rrule != null && { rrule: event.rrule }),\n }));\n}\n\nasync function fetchEntityEventsWs(\n hass: HomeAssistant,\n entityId: `calendar.${string}`,\n range: { start: Date; end: Date },\n): Promise<CalendarEvent[]> {\n const result = await hass.connection.sendMessagePromise<{\n response: { [key: string]: { events: CalendarEvent[] } };\n }>({\n type: 'call_service',\n domain: 'calendar',\n service: 'get_events',\n service_data: {\n start_date_time: range.start.toISOString(),\n end_date_time: range.end.toISOString(),\n },\n target: { entity_id: entityId },\n return_response: true,\n });\n\n return result.response?.[entityId]?.events ?? [];\n}\n\nasync function fetchCalendarRange(\n hass: HomeAssistant | undefined,\n entityIds: `calendar.${string}`[],\n range: { start: Date; end: Date },\n): Promise<CalendarEventWithSource[]> {\n if (!hass?.connection) {\n throw new Error('Home Assistant connection not available');\n }\n if (entityIds.length === 0) {\n return [];\n }\n\n const results = await Promise.all(\n entityIds.map(async (entityId) => {\n try {\n // Prefer the REST view: unlike the calendar.get_events service\n // response (filtered to LIST_EVENT_FIELDS in HA core), it includes\n // uid/recurrence_id/rrule. The WS service call remains as a fallback\n // for environments without callApi (test mocks, Storybook).\n const calendarEvents = hass.callApi\n ? await fetchEntityEventsRest(hass, entityId, range)\n : await fetchEntityEventsWs(hass, entityId, range);\n // Persist only successful fetches; a failure must not cache as \"no events\".\n writePersisted(entityEventsPersistKey(entityId, range), calendarEvents);\n return calendarEvents.map(\n (event): CalendarEventWithSource => ({ ...event, calendarId: entityId }),\n );\n } catch (err) {\n console.error(`Failed to fetch events for ${entityId}:`, err);\n return [];\n }\n }),\n );\n\n return results.flat();\n}\n\n/**\n * Fetch events from one or more calendars for a date range, with in-memory\n * (per-card) caching and stale-while-revalidate behavior. Each calendar's\n * events are also persisted to localStorage per entity, so a reload renders\n * immediately. Events are tagged with their source calendar ID. Returns\n * `prefetch` to warm adjacent ranges.\n */\nexport function useCalendarEvents(\n entityIds: `calendar.${string}`[],\n options: { start: Date; end: Date },\n): UseCalendarEventsResult {\n const store = useHAStore();\n const { getHass } = useHass();\n\n const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const entityIdsKey = entityIds.join(',');\n const dateRangeKey = `${options.start.getTime()}-${options.end.getTime()}`;\n const cacheKey = `events:${entityIdsKey}:${dateRangeKey}`;\n\n const fetcher = useCallbackStable(() => fetchCalendarRange(getHass(), entityIds, options));\n\n const {\n data: events,\n status,\n error,\n refetch,\n } = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey], {\n seed: () => readPersistedRange(entityIds, options),\n });\n\n const prefetch = useCallbackStable((range: { start: Date; end: Date }) => {\n const key = calendarEventsCacheKey(entityIds, range);\n if (store.cache.has(key)) return; // already warm\n fetchCalendarRange(getHass(), entityIds, range)\n .then((result) => writeCache(store.cache, key, result))\n .catch(() => {\n // best-effort prefetch; ignore failures\n });\n });\n\n const debouncedRefetch = useCallbackStable(() => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n debounceTimerRef.current = setTimeout(() => refetch(), 500);\n });\n\n useEffect(() => {\n const unsubscribes = entityIds.map((entityId) =>\n store.subscribeToEntity(entityId, debouncedRefetch),\n );\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n };\n }, [entityIdsKey, store.subscribeToEntity, debouncedRefetch]);\n\n return { events, status, error, refetch, prefetch };\n}\n\n/**\n * Event payload for the calendar mutation WebSocket commands. Dates are either\n * date-only strings (\"2026-07-17\", all-day) or ISO datetimes.\n */\nexport interface CalendarMutationEvent {\n dtstart: string;\n dtend: string;\n summary: string;\n description?: string;\n location?: string;\n rrule?: string;\n}\n\nfunction requireConnection(hass: HomeAssistant | undefined): HomeAssistant {\n if (!hass?.connection) {\n throw new Error('Home Assistant connection not available');\n }\n return hass;\n}\n\n/**\n * Create an event on a calendar that supports mutation (e.g. Local Calendar).\n * Requires entity control permission, not admin. WS errors reject unchanged so\n * callers can inspect `err.code` (e.g. 'unauthorized').\n */\nexport async function createCalendarEvent(\n hass: HomeAssistant | undefined,\n entityId: `calendar.${string}`,\n event: CalendarMutationEvent,\n): Promise<void> {\n await requireConnection(hass).connection.sendMessagePromise({\n type: 'calendar/event/create',\n entity_id: entityId,\n event,\n });\n}\n\n/** Delete a calendar event by its uid. */\nexport async function deleteCalendarEvent(\n hass: HomeAssistant | undefined,\n entityId: `calendar.${string}`,\n uid: string,\n opts?: { recurrenceId?: string; recurrenceRange?: string },\n): Promise<void> {\n await requireConnection(hass).connection.sendMessagePromise({\n type: 'calendar/event/delete',\n entity_id: entityId,\n uid,\n ...(opts?.recurrenceId !== undefined && { recurrence_id: opts.recurrenceId }),\n ...(opts?.recurrenceRange !== undefined && { recurrence_range: opts.recurrenceRange }),\n });\n}\n\n/** Replace a calendar event's content by its uid. */\nexport async function updateCalendarEvent(\n hass: HomeAssistant | undefined,\n entityId: `calendar.${string}`,\n uid: string,\n event: CalendarMutationEvent,\n): Promise<void> {\n await requireConnection(hass).connection.sendMessagePromise({\n type: 'calendar/event/update',\n entity_id: entityId,\n uid,\n event,\n });\n}\n","import type { RefObject } from 'preact';\nimport { useEffect, useRef } from 'preact/hooks';\n\nexport interface ElementSize {\n width: number;\n height: number;\n}\n\nexport type ResizeCallback = (size: ElementSize) => void;\n\n/**\n * Observe an element's size via ResizeObserver. The callback fires:\n *\n * 1. Once after mount, with the element's current size.\n * 2. Whenever the element's size changes.\n * 3. Whenever `deps` change, re-firing with the current size — so callers\n * can re-run draws when their inputs change without re-creating the\n * observer.\n *\n * The callback is suppressed only while the element is detached from the\n * document. Zero width/height is delivered to the callback as-is — consumers\n * that need to skip degenerate sizes (e.g. canvas painters where a 0-sized\n * drawImage throws InvalidStateError) should add their own early return.\n *\n * Sizes are read from `offsetWidth` / `offsetHeight` (CSS pixels, includes\n * padding + border). The callback is held in a ref, so passing a fresh\n * function each render is safe — it never re-creates the observer.\n *\n * @example\n * const containerRef = useRef<HTMLDivElement>(null);\n * const canvasRef = useRef<HTMLCanvasElement>(null);\n *\n * useResizeObserver(\n * containerRef,\n * ({ width, height }) => {\n * if (width === 0 || height === 0) return; // optional, consumer's call\n * drawChart(canvasRef.current, forecast, width, height);\n * },\n * [forecast],\n * );\n */\nexport function useResizeObserver<T extends HTMLElement>(\n ref: RefObject<T>,\n callback: ResizeCallback,\n deps: unknown[] = [],\n): void {\n const callbackRef = useRef(callback);\n callbackRef.current = callback;\n\n // Set up the observer once per element. ResizeObserver fires once\n // synchronously-ish after `.observe()` with the current size, which\n // covers the initial draw.\n useEffect(() => {\n const element = ref.current;\n if (!element) return;\n\n const fire = () => {\n if (!element.isConnected) return;\n callbackRef.current({\n width: element.offsetWidth,\n height: element.offsetHeight,\n });\n };\n\n const observer = new ResizeObserver(fire);\n observer.observe(element);\n return () => observer.disconnect();\n // ref identity is stable across renders; observer setup runs once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Re-fire on dependency change. Skip the first render — the observer's\n // initial `.observe()` fire already delivers the mount-time size.\n const isFirstRun = useRef(true);\n useEffect(() => {\n if (isFirstRun.current) {\n isFirstRun.current = false;\n return;\n }\n const element = ref.current;\n if (!element || !element.isConnected) return;\n callbackRef.current({\n width: element.offsetWidth,\n height: element.offsetHeight,\n });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps);\n}\n","import type { RefObject } from 'preact';\nimport { useState } from 'preact/hooks';\nimport { useResizeObserver } from './useResizeObserver';\n\n/**\n * Track the current width of a referenced element in CSS pixels.\n *\n * Returns `undefined` until the first non-zero measurement is observed, then\n * a positive number that updates as the element resizes. Once a real width\n * is captured the hook will never report `undefined` or `0` again, even\n * during HA layout transitions (dashboard switch, edit-mode toggle) — the\n * underlying ResizeObserver firings are silently dropped while the element\n * is detached or transiently zero-width, so the component renders with the\n * last good value instead of flashing through a degenerate state.\n *\n * Use this when you need a width value in JSX (responsive layout, prop to a\n * sized child). If you only need the value imperatively inside a draw\n * callback, prefer `useResizeObserver` directly — it doesn't allocate\n * component state or cause re-renders.\n *\n * @example\n * const ref = useRef<HTMLDivElement>(null);\n * const width = useWidth(ref);\n * return (\n * <div ref={ref}>\n * {width !== undefined && <Chart width={width} />}\n * </div>\n * );\n */\nexport function useWidth<T extends HTMLElement>(ref: RefObject<T>): number | undefined {\n const [width, setWidth] = useState<number | undefined>(undefined);\n useResizeObserver(ref, (size) => {\n if (size.width === 0) return;\n setWidth((prev) => (prev === size.width ? prev : size.width));\n });\n return width;\n}\n"],"names":["forecast","HACard"],"mappings":"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"}
|
package/package.json
CHANGED
package/src/HAContext.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import type { ComponentChildren } from 'preact';
|
|
|
3
3
|
import { useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
4
4
|
|
|
5
5
|
import { type Cache, readCache, writeCache } from './cacheUtils';
|
|
6
|
+
import { readPersisted, writePersisted } from './persistentCache';
|
|
6
7
|
import type {
|
|
7
8
|
EntityForId,
|
|
8
9
|
FetchStatus,
|
|
@@ -86,6 +87,7 @@ export function useHAStore(): HAStore {
|
|
|
86
87
|
* - 'calendar.xyz' -> CalendarEntity
|
|
87
88
|
* - 'weather.xyz' -> WeatherEntity
|
|
88
89
|
* - 'sun.sun' -> SunEntity
|
|
90
|
+
* - 'fan.xyz' -> FanEntity
|
|
89
91
|
* - other domains -> HassEntity (fallback)
|
|
90
92
|
*/
|
|
91
93
|
export function useEntity<T extends string>(entityId: T): EntityForId<T> | undefined {
|
|
@@ -196,17 +198,28 @@ interface UseCachedFetchResult<T> {
|
|
|
196
198
|
refetch: () => void;
|
|
197
199
|
}
|
|
198
200
|
|
|
201
|
+
interface UseCachedFetchOptions<T> {
|
|
202
|
+
/**
|
|
203
|
+
* Fallback read when the in-memory cache misses (e.g. from localStorage).
|
|
204
|
+
* Seeded data is shown as stale (`'cached'`) and revalidated like any hit.
|
|
205
|
+
*/
|
|
206
|
+
seed?: () => T | undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
199
209
|
/**
|
|
200
|
-
* Generic hook for fetching data with
|
|
201
|
-
* status string to distinguish cached
|
|
210
|
+
* Generic hook for fetching data with in-memory caching (optionally seeded from
|
|
211
|
+
* a persistent store). Returns a cache-aware status string to distinguish cached
|
|
212
|
+
* vs fresh data.
|
|
202
213
|
*/
|
|
203
214
|
export function useCachedFetch<T>(
|
|
204
215
|
cacheKey: string,
|
|
205
216
|
fetcher: () => Promise<T>,
|
|
206
217
|
deps: unknown[],
|
|
218
|
+
options: UseCachedFetchOptions<T> = {},
|
|
207
219
|
): UseCachedFetchResult<T> {
|
|
208
220
|
const store = useHAStore();
|
|
209
|
-
const
|
|
221
|
+
const readCached = () => readCache<T>(store.cache, cacheKey) ?? options.seed?.();
|
|
222
|
+
const [data, setData] = useState<T | undefined>(readCached);
|
|
210
223
|
const [isFresh, setIsFresh] = useState(false);
|
|
211
224
|
const [isFetching, setIsFetching] = useState(false);
|
|
212
225
|
const [error, setError] = useState<Error | undefined>(undefined);
|
|
@@ -221,7 +234,7 @@ export function useCachedFetch<T>(
|
|
|
221
234
|
dataKeyRef.current = cacheKey;
|
|
222
235
|
setIsFresh(false);
|
|
223
236
|
setError(undefined);
|
|
224
|
-
const cached =
|
|
237
|
+
const cached = readCached();
|
|
225
238
|
if (cached !== undefined) setData(cached);
|
|
226
239
|
// cache miss: leave `data` as-is (keep-previous-data)
|
|
227
240
|
}
|
|
@@ -273,8 +286,9 @@ interface UseWeatherForecastResult {
|
|
|
273
286
|
}
|
|
274
287
|
|
|
275
288
|
/**
|
|
276
|
-
* Fetch weather forecast data
|
|
277
|
-
*
|
|
289
|
+
* Fetch weather forecast data, cached in memory and persisted to localStorage
|
|
290
|
+
* so a reload renders immediately. Auto-refetches at the top of each hour and
|
|
291
|
+
* when the underlying entity changes (debounced).
|
|
278
292
|
*/
|
|
279
293
|
export function useWeatherForecast(
|
|
280
294
|
entityId: `weather.${string}`,
|
|
@@ -304,7 +318,9 @@ export function useWeatherForecast(
|
|
|
304
318
|
return_response: true,
|
|
305
319
|
});
|
|
306
320
|
|
|
307
|
-
|
|
321
|
+
const forecast = result.response?.[entityId]?.forecast ?? [];
|
|
322
|
+
writePersisted(cacheKey, forecast);
|
|
323
|
+
return forecast;
|
|
308
324
|
});
|
|
309
325
|
|
|
310
326
|
const {
|
|
@@ -312,7 +328,9 @@ export function useWeatherForecast(
|
|
|
312
328
|
status,
|
|
313
329
|
error,
|
|
314
330
|
refetch,
|
|
315
|
-
} = useCachedFetch(cacheKey, fetcher, [entityId, type]
|
|
331
|
+
} = useCachedFetch(cacheKey, fetcher, [entityId, type], {
|
|
332
|
+
seed: () => readPersisted<WeatherForecast[]>(cacheKey),
|
|
333
|
+
});
|
|
316
334
|
|
|
317
335
|
const debouncedRefetch = useCallbackStable(() => {
|
|
318
336
|
if (debounceTimerRef.current) {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
MAX_AGE_MS,
|
|
4
|
+
MAX_ENTRIES,
|
|
5
|
+
readPersisted,
|
|
6
|
+
resetPersistedSweep,
|
|
7
|
+
writePersisted,
|
|
8
|
+
} from '../persistentCache';
|
|
9
|
+
|
|
10
|
+
describe('persistentCache', () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('round-trips data through localStorage', () => {
|
|
17
|
+
writePersisted('k', { foo: 'bar' });
|
|
18
|
+
expect(readPersisted('k')).toEqual({ foo: 'bar' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns undefined for missing or corrupt entries', () => {
|
|
22
|
+
expect(readPersisted('missing')).toBeUndefined();
|
|
23
|
+
localStorage.setItem('preact-ha:v2:bad', '{not json');
|
|
24
|
+
expect(readPersisted('bad')).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('drops entries older than the max age on read', () => {
|
|
28
|
+
vi.useFakeTimers();
|
|
29
|
+
writePersisted('k', 1);
|
|
30
|
+
vi.advanceTimersByTime(MAX_AGE_MS + 1);
|
|
31
|
+
expect(readPersisted('k')).toBeUndefined();
|
|
32
|
+
expect(localStorage.getItem('preact-ha:v2:k')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('expires entries written by other library versions, keeping fresh ones', () => {
|
|
36
|
+
vi.useFakeTimers();
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const old = now - MAX_AGE_MS - 1;
|
|
39
|
+
localStorage.setItem('preact-ha:v3:stale', JSON.stringify({ d: 1, s: old, u: old }));
|
|
40
|
+
localStorage.setItem('preact-ha:v3:fresh', JSON.stringify({ d: 1, s: now, u: now }));
|
|
41
|
+
localStorage.setItem('preact-ha:events:stale', JSON.stringify({ data: 1, timestamp: old }));
|
|
42
|
+
localStorage.setItem('preact-ha:events:fresh', JSON.stringify({ data: 1, timestamp: now }));
|
|
43
|
+
|
|
44
|
+
readPersisted('anything');
|
|
45
|
+
|
|
46
|
+
expect(localStorage.getItem('preact-ha:v3:stale')).toBeNull();
|
|
47
|
+
expect(localStorage.getItem('preact-ha:events:stale')).toBeNull();
|
|
48
|
+
expect(localStorage.getItem('preact-ha:v3:fresh')).not.toBeNull();
|
|
49
|
+
expect(localStorage.getItem('preact-ha:events:fresh')).not.toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('applies the cap across all library versions', () => {
|
|
53
|
+
vi.useFakeTimers();
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
localStorage.setItem('preact-ha:v3:other', JSON.stringify({ d: 1, s: now, u: now - 1 }));
|
|
56
|
+
for (let i = 0; i < MAX_ENTRIES; i++) {
|
|
57
|
+
writePersisted(`k${i}`, i);
|
|
58
|
+
vi.advanceTimersByTime(1);
|
|
59
|
+
}
|
|
60
|
+
expect(localStorage.getItem('preact-ha:v3:other')).toBeNull();
|
|
61
|
+
expect(readPersisted('k0')).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('sweeps expired and unrecognizable entries on first access', () => {
|
|
65
|
+
vi.useFakeTimers();
|
|
66
|
+
writePersisted('old', 1);
|
|
67
|
+
vi.advanceTimersByTime(MAX_AGE_MS + 1);
|
|
68
|
+
writePersisted('fresh', 2);
|
|
69
|
+
localStorage.setItem('preact-ha:events:legacy', '{}');
|
|
70
|
+
localStorage.setItem('unrelated', 'keep');
|
|
71
|
+
|
|
72
|
+
resetPersistedSweep();
|
|
73
|
+
readPersisted('fresh');
|
|
74
|
+
|
|
75
|
+
expect(localStorage.getItem('preact-ha:v2:old')).toBeNull();
|
|
76
|
+
expect(localStorage.getItem('preact-ha:events:legacy')).toBeNull();
|
|
77
|
+
expect(localStorage.getItem('preact-ha:v2:fresh')).not.toBeNull();
|
|
78
|
+
expect(localStorage.getItem('unrelated')).toBe('keep');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('evicts the least recently used entries beyond the cap', () => {
|
|
82
|
+
vi.useFakeTimers();
|
|
83
|
+
for (let i = 0; i < MAX_ENTRIES; i++) {
|
|
84
|
+
writePersisted(`k${i}`, i);
|
|
85
|
+
vi.advanceTimersByTime(1);
|
|
86
|
+
}
|
|
87
|
+
// Touch the oldest so it becomes most recently used
|
|
88
|
+
readPersisted('k0');
|
|
89
|
+
vi.advanceTimersByTime(1);
|
|
90
|
+
writePersisted('new', 'x');
|
|
91
|
+
|
|
92
|
+
expect(readPersisted('k0')).toBe(0);
|
|
93
|
+
expect(readPersisted('k1')).toBeUndefined();
|
|
94
|
+
expect(readPersisted('new')).toBe('x');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('evicts the oldest entry and retries when storage is full', () => {
|
|
98
|
+
vi.useFakeTimers();
|
|
99
|
+
writePersisted('a', 1);
|
|
100
|
+
vi.advanceTimersByTime(1);
|
|
101
|
+
writePersisted('b', 2);
|
|
102
|
+
|
|
103
|
+
const realSetItem = localStorage.setItem;
|
|
104
|
+
let failed = false;
|
|
105
|
+
vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => {
|
|
106
|
+
if (!failed && key === 'preact-ha:v2:c') {
|
|
107
|
+
failed = true;
|
|
108
|
+
throw new DOMException('full', 'QuotaExceededError');
|
|
109
|
+
}
|
|
110
|
+
realSetItem(key, value);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
writePersisted('c', 3);
|
|
114
|
+
expect(localStorage.getItem('preact-ha:v2:a')).toBeNull();
|
|
115
|
+
expect(readPersisted('b')).toBe(2);
|
|
116
|
+
expect(readPersisted('c')).toBe(3);
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/__tests__/setup.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { beforeEach } from 'vitest';
|
|
2
|
+
import { resetPersistedSweep } from '../persistentCache';
|
|
3
|
+
|
|
1
4
|
// Node 22+ has a built-in localStorage that conflicts with jsdom's.
|
|
2
5
|
// This setup ensures we have a proper Web Storage API implementation.
|
|
3
6
|
|
|
@@ -25,3 +28,9 @@ Object.defineProperty(globalThis, 'localStorage', {
|
|
|
25
28
|
writable: true,
|
|
26
29
|
configurable: true,
|
|
27
30
|
});
|
|
31
|
+
|
|
32
|
+
// Persisted cache entries must not leak between tests.
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
store.clear();
|
|
35
|
+
resetPersistedSweep();
|
|
36
|
+
});
|
|
@@ -298,3 +298,67 @@ describe('useCalendarEvents REST path', () => {
|
|
|
298
298
|
});
|
|
299
299
|
});
|
|
300
300
|
});
|
|
301
|
+
|
|
302
|
+
describe('useCalendarEvents persistence', () => {
|
|
303
|
+
const start = new Date('2025-01-01');
|
|
304
|
+
const end = new Date('2025-01-31');
|
|
305
|
+
|
|
306
|
+
function renderWith(
|
|
307
|
+
entityIds: `calendar.${string}`[],
|
|
308
|
+
sendMessagePromise: ReturnType<typeof vi.fn>,
|
|
309
|
+
) {
|
|
310
|
+
const hass = makeHass({}, { connection: { sendMessagePromise } as any });
|
|
311
|
+
const { subscribe } = createMockSubscribe();
|
|
312
|
+
return render(
|
|
313
|
+
<HAProvider hass={hass} subscribeToEntity={subscribe}>
|
|
314
|
+
<CalendarDisplay entityIds={entityIds} start={start} end={end} />
|
|
315
|
+
</HAProvider>,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const respond = (msg: { target: { entity_id: string } }) => ({
|
|
320
|
+
response: {
|
|
321
|
+
[msg.target.entity_id]: {
|
|
322
|
+
events: [{ start: '2025-01-05', end: '2025-01-05', summary: msg.target.entity_id }],
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('seeds a fresh provider from persisted per-entity events', async () => {
|
|
328
|
+
const first = renderWith(['calendar.family'], vi.fn().mockImplementation(respond));
|
|
329
|
+
await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('ready'));
|
|
330
|
+
first.unmount();
|
|
331
|
+
|
|
332
|
+
// New provider = empty in-memory cache; a never-resolving fetch shows the seed.
|
|
333
|
+
renderWith(['calendar.family'], vi.fn().mockReturnValue(new Promise(() => {})));
|
|
334
|
+
expect(screen.getByTestId('status').textContent).toBe('cached');
|
|
335
|
+
expect(screen.getByTestId('events').textContent).toBe('calendar.family:calendar.family');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('composes a different calendar set from per-entity entries', async () => {
|
|
339
|
+
const a = renderWith(['calendar.family'], vi.fn().mockImplementation(respond));
|
|
340
|
+
await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('ready'));
|
|
341
|
+
a.unmount();
|
|
342
|
+
const b = renderWith(['calendar.work'], vi.fn().mockImplementation(respond));
|
|
343
|
+
await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('ready'));
|
|
344
|
+
b.unmount();
|
|
345
|
+
|
|
346
|
+
renderWith(
|
|
347
|
+
['calendar.family', 'calendar.work'],
|
|
348
|
+
vi.fn().mockReturnValue(new Promise(() => {})),
|
|
349
|
+
);
|
|
350
|
+
expect(screen.getByTestId('status').textContent).toBe('cached');
|
|
351
|
+
expect(screen.getByTestId('count').textContent).toBe('2');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('does not seed when any calendar is missing, and never persists failures', async () => {
|
|
355
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
356
|
+
const failing = vi.fn().mockRejectedValue(new Error('offline'));
|
|
357
|
+
const first = renderWith(['calendar.family'], failing);
|
|
358
|
+
await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('ready'));
|
|
359
|
+
first.unmount();
|
|
360
|
+
|
|
361
|
+
renderWith(['calendar.family'], vi.fn().mockReturnValue(new Promise(() => {})));
|
|
362
|
+
expect(screen.getByTestId('status').textContent).toBe('loading');
|
|
363
|
+
});
|
|
364
|
+
});
|
package/src/cacheUtils.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// In-memory cache helpers. The cache Map is owned per-card by the HAProvider
|
|
2
2
|
// store (see HAContext), so its lifetime matches the card and it is
|
|
3
|
-
// garbage-collected when the card is torn down. There is intentionally no
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// garbage-collected when the card is torn down. There is intentionally no TTL
|
|
4
|
+
// or size cap here: freshness comes from entity subscriptions and periodic
|
|
5
|
+
// refetch, not from cache expiry, and growth is bounded by the ranges a single
|
|
6
|
+
// card visits in a session. Cross-reload persistence (with expiry) lives in
|
|
7
|
+
// persistentCache.ts and only seeds this cache on a miss.
|
|
7
8
|
|
|
8
9
|
export interface CacheEntry<T> {
|
|
9
10
|
data: T;
|
package/src/calendars.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'preact/hooks';
|
|
|
2
2
|
|
|
3
3
|
import { useCachedFetch, useHAStore, useHass } from './HAContext';
|
|
4
4
|
import { writeCache } from './cacheUtils';
|
|
5
|
+
import { readPersisted, writePersisted } from './persistentCache';
|
|
5
6
|
import type { CalendarEvent, CalendarEventWithSource, FetchStatus, HomeAssistant } from './types';
|
|
6
7
|
import { useCallbackStable } from './useCallbackStable';
|
|
7
8
|
|
|
@@ -25,6 +26,33 @@ function calendarEventsCacheKey(
|
|
|
25
26
|
return `events:${entityIds.join(',')}:${range.start.getTime()}-${range.end.getTime()}`;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Persistent (localStorage) key for one calendar's events over a range. Stored
|
|
31
|
+
* per entity rather than per entity set, so any combination of calendars —
|
|
32
|
+
* across cards, or the same card's regular vs highlight calendars — shares it.
|
|
33
|
+
*/
|
|
34
|
+
function entityEventsPersistKey(
|
|
35
|
+
entityId: `calendar.${string}`,
|
|
36
|
+
range: { start: Date; end: Date },
|
|
37
|
+
): string {
|
|
38
|
+
return `events:${entityId}:${range.start.getTime()}-${range.end.getTime()}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Assemble a range from persisted per-entity entries; undefined unless all are present. */
|
|
42
|
+
function readPersistedRange(
|
|
43
|
+
entityIds: `calendar.${string}`[],
|
|
44
|
+
range: { start: Date; end: Date },
|
|
45
|
+
): CalendarEventWithSource[] | undefined {
|
|
46
|
+
if (entityIds.length === 0) return undefined;
|
|
47
|
+
const result: CalendarEventWithSource[] = [];
|
|
48
|
+
for (const entityId of entityIds) {
|
|
49
|
+
const events = readPersisted<CalendarEvent[]>(entityEventsPersistKey(entityId, range));
|
|
50
|
+
if (!events) return undefined;
|
|
51
|
+
for (const event of events) result.push({ ...event, calendarId: entityId });
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
28
56
|
/** Event shape returned by the REST view GET /api/calendars/{entity_id}. */
|
|
29
57
|
interface ApiCalendarEvent {
|
|
30
58
|
summary: string;
|
|
@@ -110,6 +138,8 @@ async function fetchCalendarRange(
|
|
|
110
138
|
const calendarEvents = hass.callApi
|
|
111
139
|
? await fetchEntityEventsRest(hass, entityId, range)
|
|
112
140
|
: await fetchEntityEventsWs(hass, entityId, range);
|
|
141
|
+
// Persist only successful fetches; a failure must not cache as "no events".
|
|
142
|
+
writePersisted(entityEventsPersistKey(entityId, range), calendarEvents);
|
|
113
143
|
return calendarEvents.map(
|
|
114
144
|
(event): CalendarEventWithSource => ({ ...event, calendarId: entityId }),
|
|
115
145
|
);
|
|
@@ -125,8 +155,10 @@ async function fetchCalendarRange(
|
|
|
125
155
|
|
|
126
156
|
/**
|
|
127
157
|
* Fetch events from one or more calendars for a date range, with in-memory
|
|
128
|
-
* (per-card) caching and stale-while-revalidate behavior.
|
|
129
|
-
*
|
|
158
|
+
* (per-card) caching and stale-while-revalidate behavior. Each calendar's
|
|
159
|
+
* events are also persisted to localStorage per entity, so a reload renders
|
|
160
|
+
* immediately. Events are tagged with their source calendar ID. Returns
|
|
161
|
+
* `prefetch` to warm adjacent ranges.
|
|
130
162
|
*/
|
|
131
163
|
export function useCalendarEvents(
|
|
132
164
|
entityIds: `calendar.${string}`[],
|
|
@@ -148,7 +180,9 @@ export function useCalendarEvents(
|
|
|
148
180
|
status,
|
|
149
181
|
error,
|
|
150
182
|
refetch,
|
|
151
|
-
} = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey]
|
|
183
|
+
} = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey], {
|
|
184
|
+
seed: () => readPersistedRange(entityIds, options),
|
|
185
|
+
});
|
|
152
186
|
|
|
153
187
|
const prefetch = useCallbackStable((range: { start: Date; end: Date }) => {
|
|
154
188
|
const key = calendarEventsCacheKey(entityIds, range);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// localStorage persistence beneath the per-card in-memory cache. It only seeds
|
|
2
|
+
// a cold start: persisted data is always shown as stale ('cached') and then
|
|
3
|
+
// revalidated, so expiry here bounds storage rather than guaranteeing freshness.
|
|
4
|
+
//
|
|
5
|
+
// Every card built on this library bundles its own copy, and they all share one
|
|
6
|
+
// localStorage. Housekeeping therefore covers the whole `preact-ha:` NAMESPACE,
|
|
7
|
+
// not just this version's PREFIX, so data left by a deleted card (or a card on
|
|
8
|
+
// another library version) still expires as long as any preact-ha card loads.
|
|
9
|
+
//
|
|
10
|
+
// - Entries older than MAX_AGE_MS are dropped (never shown).
|
|
11
|
+
// - At most MAX_ENTRIES are kept namespace-wide; least-recently-used go first.
|
|
12
|
+
// - The first access per page sweeps expired and unrecognizable entries.
|
|
13
|
+
// - The versioned PREFIX scopes what this version *reads*; bumping it makes old
|
|
14
|
+
// entries unreadable to new code, and they then age out via the sweep.
|
|
15
|
+
// - Quota errors evict the oldest entry and retry once, then give up silently.
|
|
16
|
+
//
|
|
17
|
+
// Cross-version contract — keep stable in all future versions: every value under
|
|
18
|
+
// NAMESPACE is JSON with numeric `s` (saved at, ms) and `u` (last used, ms).
|
|
19
|
+
// Pre-0.3 entries ({ data, timestamp }) are understood via `timestamp`.
|
|
20
|
+
|
|
21
|
+
const NAMESPACE = 'preact-ha:';
|
|
22
|
+
const PREFIX = 'preact-ha:v2:';
|
|
23
|
+
export const MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
|
|
24
|
+
export const MAX_ENTRIES = 60;
|
|
25
|
+
|
|
26
|
+
interface PersistedEntry<T> {
|
|
27
|
+
/** data */
|
|
28
|
+
d: T;
|
|
29
|
+
/** saved at (ms) */
|
|
30
|
+
s: number;
|
|
31
|
+
/** last used (ms) */
|
|
32
|
+
u: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface EntryTimes {
|
|
36
|
+
saved: number;
|
|
37
|
+
used: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let swept = false;
|
|
41
|
+
|
|
42
|
+
function storage(): Storage | undefined {
|
|
43
|
+
try {
|
|
44
|
+
return globalThis.localStorage ?? undefined;
|
|
45
|
+
} catch {
|
|
46
|
+
// Access can throw (sandboxed iframes, disabled storage)
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Timestamps from any version's entry; undefined if unrecognizable. */
|
|
52
|
+
function timesOf(raw: string | null): EntryTimes | undefined {
|
|
53
|
+
if (!raw) return undefined;
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(raw);
|
|
56
|
+
const saved = typeof value?.s === 'number' ? value.s : value?.timestamp;
|
|
57
|
+
if (typeof saved !== 'number') return undefined;
|
|
58
|
+
return { saved, used: typeof value.u === 'number' ? value.u : saved };
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function namespaceKeys(ls: Storage): string[] {
|
|
65
|
+
const keys: string[] = [];
|
|
66
|
+
for (let i = 0; i < ls.length; i++) {
|
|
67
|
+
const key = ls.key(i);
|
|
68
|
+
if (key?.startsWith(NAMESPACE)) keys.push(key);
|
|
69
|
+
}
|
|
70
|
+
return keys;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Namespace entries, least recently used first. */
|
|
74
|
+
function entriesByUse(ls: Storage): { key: string; used: number }[] {
|
|
75
|
+
return namespaceKeys(ls)
|
|
76
|
+
.map((key) => ({ key, used: timesOf(ls.getItem(key))?.used ?? 0 }))
|
|
77
|
+
.sort((a, b) => a.used - b.used);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function evictOverCap(ls: Storage): void {
|
|
81
|
+
// Cheap key count first; only parse entries when we actually need to evict.
|
|
82
|
+
const count = namespaceKeys(ls).length;
|
|
83
|
+
if (count <= MAX_ENTRIES) return;
|
|
84
|
+
const entries = entriesByUse(ls);
|
|
85
|
+
for (let i = 0; i < count - MAX_ENTRIES; i++) {
|
|
86
|
+
ls.removeItem(entries[i].key);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Drop expired and unrecognizable entries namespace-wide, then enforce the cap. Once per page. */
|
|
91
|
+
function sweep(ls: Storage): void {
|
|
92
|
+
if (swept) return;
|
|
93
|
+
swept = true;
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
for (const key of namespaceKeys(ls)) {
|
|
96
|
+
const times = timesOf(ls.getItem(key));
|
|
97
|
+
if (!times || now - times.saved > MAX_AGE_MS) ls.removeItem(key);
|
|
98
|
+
}
|
|
99
|
+
evictOverCap(ls);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readPersisted<T>(key: string): T | undefined {
|
|
103
|
+
const ls = storage();
|
|
104
|
+
if (!ls) return undefined;
|
|
105
|
+
try {
|
|
106
|
+
sweep(ls);
|
|
107
|
+
const fullKey = PREFIX + key;
|
|
108
|
+
const raw = ls.getItem(fullKey);
|
|
109
|
+
if (!raw) return undefined;
|
|
110
|
+
const entry = JSON.parse(raw) as PersistedEntry<T>;
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
if (typeof entry?.s !== 'number' || now - entry.s > MAX_AGE_MS) {
|
|
113
|
+
ls.removeItem(fullKey);
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
entry.u = now;
|
|
117
|
+
ls.setItem(fullKey, JSON.stringify(entry));
|
|
118
|
+
return entry.d;
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function writePersisted<T>(key: string, data: T): void {
|
|
125
|
+
const ls = storage();
|
|
126
|
+
if (!ls) return;
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const value = JSON.stringify({ d: data, s: now, u: now } satisfies PersistedEntry<T>);
|
|
129
|
+
try {
|
|
130
|
+
sweep(ls);
|
|
131
|
+
ls.setItem(PREFIX + key, value);
|
|
132
|
+
evictOverCap(ls);
|
|
133
|
+
} catch {
|
|
134
|
+
// Most likely quota exceeded: free the oldest entry and retry once.
|
|
135
|
+
try {
|
|
136
|
+
const oldest = entriesByUse(ls)[0];
|
|
137
|
+
if (oldest) ls.removeItem(oldest.key);
|
|
138
|
+
ls.setItem(PREFIX + key, value);
|
|
139
|
+
} catch {
|
|
140
|
+
// Give up; the in-memory cache still works.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Test hook: forget that the startup sweep ran. */
|
|
146
|
+
export function resetPersistedSweep(): void {
|
|
147
|
+
swept = false;
|
|
148
|
+
}
|