Compare commits

..

16 Commits

Author SHA1 Message Date
Petar Petrov
882682d0ae fix import 2025-11-12 16:48:25 +02:00
Petar Petrov
ec22937e71 fix import 2025-11-12 11:50:40 +02:00
Petar Petrov
53a87278dd PR comments 2025-11-12 11:41:06 +02:00
Petar Petrov
31383c114b Unify CalendarEventRestData and CalendarEventSubscriptionData
Both interfaces had identical structures, so unified them into a single
CalendarEventSubscriptionData interface that is used for both REST API
responses and WebSocket subscription data.

Changes:
- Removed CalendarEventRestData interface
- Updated fetchCalendarEvents to use CalendarEventSubscriptionData
- Added documentation clarifying the interface is used for both APIs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 16:29:50 +02:00
Petar Petrov
7e01777eaa Replace any types with proper TypeScript types
Added proper types for calendar event data:
- CalendarDateValue: Union type for date values (string | {dateTime} | {date})
- CalendarEventRestData: Interface for REST API event responses
- Updated fetchCalendarEvents to use CalendarEventRestData[]
- Updated CalendarEventSubscriptionData to use CalendarDateValue
- Updated getCalendarDate to use proper type guards with 'in' operator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 15:34:10 +02:00
Petar Petrov
9c64f5ac8b Move date normalization into normalizeSubscriptionEventData
The getCalendarDate helper is part of the normalization process and should be inside the normalization function. This makes normalizeSubscriptionEventData handle both REST API format (with dateTime/date objects) and subscription format (plain strings).

Changes:
- Moved getCalendarDate into normalizeSubscriptionEventData
- Updated CalendarEventSubscriptionData to accept string | any for start/end
- Made normalizeSubscriptionEventData return CalendarEvent | null for invalid dates
- Simplified fetchCalendarEvents to use the shared normalization
- Added null filtering in calendar card and panel event handlers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 15:32:31 +02:00
Petar Petrov
912e636207 Refactor fetchCalendarEvents to use shared normalization utility
Eliminated code duplication by reusing normalizeSubscriptionEventData() in
fetchCalendarEvents(). After extracting date strings from the REST API
response format, we now convert to a subscription-like format and pass
it to the shared utility.

This ensures consistent event normalization across both REST API and
WebSocket subscription code paths, reducing maintenance burden and
potential for divergence.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 15:25:31 +02:00
Petar Petrov
43367350b7 Update src/panels/calendar/ha-panel-calendar.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 13:24:02 +02:00
Petar Petrov
64a25cf7f9 Update src/panels/lovelace/cards/hui-calendar-card.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 13:23:51 +02:00
Petar Petrov
6de8f47e24 Update src/panels/lovelace/cards/hui-calendar-card.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 13:23:42 +02:00
Petar Petrov
0427c17a76 Address additional review comments
Fixed remaining issues from code review:

1. **Added @state decorator to _errorCalendars**: Ensures proper reactivity
   in calendar card when errors occur or are cleared, triggering UI updates.

2. **Fixed error accumulation in panel calendar**: Panel now properly
   accumulates errors from multiple calendars similar to the card
   implementation, preventing previously failed calendars from being
   hidden when new errors occur.

3. **Removed duplicate subscription check**: Deleted redundant duplicate
   subscription prevention in _requestSelected() since
   _subscribeCalendarEvents() already handles this at lines 221-227.

Note: The [nitpick] comment about loading states during await is a
performance enhancement suggestion, not a required fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:14:30 +02:00
Petar Petrov
0052f14521 Extract event normalization to shared utility function
Reduced code duplication by extracting the calendar event normalization
logic from both hui-calendar-card.ts and ha-panel-calendar.ts into a
shared utility function in calendar.ts.

The normalizeSubscriptionEventData() function handles the conversion
from subscription format (start/end) to internal format (dtstart/dtend)
in a single, reusable location.

This improves maintainability by ensuring consistent event normalization
across all calendar components and reduces the risk of divergence.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:04:04 +02:00
Petar Petrov
792274a82a Address PR review comments
Fixes based on Copilot review feedback:

1. **Fixed race conditions**: Made _unsubscribeAll() async and await it
   before creating new subscriptions to prevent old subscription events
   from updating UI after new subscriptions are created.

2. **Added error handling**: All unsubscribe operations now catch errors
   to handle cases where subscriptions may have already been closed.

3. **Fixed type safety**: Replaced 'any' type with proper
   CalendarEventSubscriptionData type and added interface definition
   for subscription response data structure.

4. **Improved error tracking**: Calendar card now accumulates errors from
   multiple calendars instead of only showing the last error.

5. **Prevented duplicate subscriptions**: Added checks to unsubscribe
   existing subscriptions before creating new ones in both
   _subscribeCalendarEvents and _requestSelected.

6. **Fixed null handling**: Properly convert null values to undefined
   for CalendarEventData fields to match expected types.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 12:58:32 +02:00
Petar Petrov
7b42b16de8 Fix calendar subscription data format mismatch
The websocket subscription returns event data with fields named start and end, but the frontend was expecting dtstart and dtend. This caused events to not display because the data wasn't being properly mapped.

Now properly transform the subscription response format:
- Subscription format: start/end/summary/description
- Internal format: dtstart/dtend/summary/description

This ensures both initial event loading and real-time updates work correctly.
2025-11-11 12:14:42 +02:00
Petar Petrov
fe98c0bdc0 Fix calendar events not loading on initial render
Remove premature subscription attempt in setConfig() that was failing because the date range wasn't available yet. The subscription now properly happens when the view-changed event fires from ha-full-calendar after initial render, which includes the necessary start/end dates.

This ensures calendar events load immediately when the component is first displayed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 12:05:38 +02:00
Petar Petrov
658955a1b9 Use websocket subscription for calendar events
Replace polling-based calendar event fetching with real-time websocket subscriptions. This leverages the new subscription API added in core to provide automatic updates when calendar events change, eliminating the need for periodic polling.

The subscription pattern follows the same approach used for todo items, with proper lifecycle management and cleanup.

Related: home-assistant/core#156340
Related: #27565

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:44:00 +02:00
71 changed files with 805 additions and 4555 deletions

View File

@@ -260,6 +260,7 @@ const createRspackConfig = ({
),
},
experiments: {
layers: true,
outputModule: true,
},
};

View File

@@ -1,55 +0,0 @@
---
title: Dropdown
---
# Dropdown `<ha-dropdown>`
## Implementation
A compact, accessible dropdown menu for choosing actions or settings. `ha-dropdown` supports composed menu items (`<ha-dropdown-item>`) for icons, submenus, checkboxes, disabled entries, and destructive variants. Use composition with `slot="trigger"` to control the trigger button and use `<ha-dropdown-item>` for rich item content.
### Example usage (composition)
```html
<ha-dropdown open>
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
<ha-dropdown-item>
<ha-svg-icon .path="mdiContentCut" slot="icon"></ha-svg-icon>
Cut
</ha-dropdown-item>
<ha-dropdown-item>
<ha-svg-icon .path="mdiContentCopy" slot="icon"></ha-svg-icon>
Copy
</ha-dropdown-item>
<ha-dropdown-item disabled>
<ha-svg-icon .path="mdiContentPaste" slot="icon"></ha-svg-icon>
Paste
</ha-dropdown-item>
<ha-dropdown-item>
Show images
<ha-dropdown-item slot="submenu" value="show-all-images"
>Show all images</ha-dropdown-item
>
<ha-dropdown-item slot="submenu" value="show-thumbnails"
>Show thumbnails</ha-dropdown-item
>
</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked>Emoji shortcuts</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked>Word wrap</ha-dropdown-item>
<ha-dropdown-item variant="danger">
<ha-svg-icon .path="mdiDelete" slot="icon"></ha-svg-icon>
Delete
</ha-dropdown-item>
</ha-dropdown>
```
### API
This component is based on the webawesome dropdown component.
Check the [webawesome documentation](https://webawesome.com/docs/components/dropdown/) for more details.

View File

@@ -1,133 +0,0 @@
import "@home-assistant/webawesome/dist/components/button/button";
import "@home-assistant/webawesome/dist/components/dropdown/dropdown";
import "@home-assistant/webawesome/dist/components/icon/icon";
import "@home-assistant/webawesome/dist/components/popup/popup";
import {
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
mdiDelete,
} from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-dropdown";
import "../../../../src/components/ha-dropdown-item";
import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-svg-icon";
@customElement("demo-components-ha-dropdown")
export class DemoHaDropdown extends LitElement {
protected render(): TemplateResult {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-button in ${mode}">
<div class="card-content">
<ha-dropdown open>
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
<ha-dropdown-item>
<ha-svg-icon
.path=${mdiContentCut}
slot="icon"
></ha-svg-icon>
Cut
</ha-dropdown-item>
<ha-dropdown-item>
<ha-svg-icon
.path=${mdiContentCopy}
slot="icon"
></ha-svg-icon>
Copy
</ha-dropdown-item>
<ha-dropdown-item disabled>
<ha-svg-icon
.path=${mdiContentPaste}
slot="icon"
></ha-svg-icon>
Paste
</ha-dropdown-item>
<ha-dropdown-item>
Show images
<ha-dropdown-item slot="submenu" value="show-all-images"
>Show All Images</ha-dropdown-item
>
<ha-dropdown-item slot="submenu" value="show-thumbnails"
>Show Thumbnails</ha-dropdown-item
>
</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked
>Emoji Shortcuts</ha-dropdown-item
>
<ha-dropdown-item type="checkbox" checked
>Word Wrap</ha-dropdown-item
>
<ha-dropdown-item variant="danger">
<ha-svg-icon .path=${mdiDelete} slot="icon"></ha-svg-icon>
Delete
</ha-dropdown-item>
</ha-dropdown>
</div>
</ha-card>
</div>
`
)}
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
.button {
padding: unset;
}
ha-card {
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.card-content div {
display: flex;
gap: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-dropdown": DemoHaDropdown;
}
}

View File

@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.7",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6",
@@ -122,7 +122,7 @@
"lit": "3.3.1",
"lit-html": "3.3.1",
"luxon": "3.7.2",
"marked": "17.0.0",
"marked": "16.4.2",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
@@ -217,7 +217,7 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.46.4",
"typescript-eslint": "8.46.3",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.8",
"webpack-stats-plugin": "1.1.3",

View File

@@ -1,36 +0,0 @@
import type {
Condition,
TimeCondition,
} from "../../panels/lovelace/common/validate-condition";
/**
* Extract media queries from conditions recursively
*/
export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(c.conditions));
}
if (c.condition === "screen" && c.media_query) {
array.push(c.media_query);
}
return array;
}, []);
}
/**
* Extract time conditions from conditions recursively
*/
export function extractTimeConditions(
conditions: Condition[]
): TimeCondition[] {
return conditions.reduce<TimeCondition[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractTimeConditions(c.conditions));
}
if (c.condition === "time") {
array.push(c);
}
return array;
}, []);
}

View File

@@ -1,89 +0,0 @@
import { listenMediaQuery } from "../dom/media_query";
import type { HomeAssistant } from "../../types";
import type { Condition } from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import { extractMediaQueries, extractTimeConditions } from "./extract";
import { calculateNextTimeUpdate } from "./time-calculator";
/** Maximum delay for setTimeout (2^31 - 1 milliseconds, ~24.8 days)
* Values exceeding this will overflow and execute immediately
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value
*/
const MAX_TIMEOUT_DELAY = 2147483647;
/**
* Helper to setup media query listeners for conditional visibility
*/
export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
): void {
const mediaQueries = extractMediaQueries(conditions);
if (mediaQueries.length === 0) return;
// Optimization for single media query
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
mediaQueries.forEach((mediaQuery) => {
const unsub = listenMediaQuery(mediaQuery, (matches) => {
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
});
addListener(unsub);
});
}
/**
* Helper to setup time-based listeners for conditional visibility
*/
export function setupTimeListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
): void {
const timeConditions = extractTimeConditions(conditions);
if (timeConditions.length === 0) return;
timeConditions.forEach((timeCondition) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const scheduleUpdate = () => {
const delay = calculateNextTimeUpdate(hass, timeCondition);
if (delay === undefined) return;
// Cap delay to prevent setTimeout overflow
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
scheduleUpdate();
}, cappedDelay);
};
// Register cleanup function once, outside of scheduleUpdate
addListener(() => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
});
scheduleUpdate();
});
}

View File

@@ -1,73 +0,0 @@
import { TZDate } from "@date-fns/tz";
import {
startOfDay,
addDays,
addMinutes,
differenceInMilliseconds,
} from "date-fns";
import type { HomeAssistant } from "../../types";
import { TimeZone } from "../../data/translation";
import { parseTimeString } from "../datetime/check_time";
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
/**
* Calculate milliseconds until next time boundary for a time condition
* @param hass Home Assistant object
* @param timeCondition Time condition to calculate next update for
* @returns Milliseconds until next boundary, or undefined if no boundaries
*/
export function calculateNextTimeUpdate(
hass: HomeAssistant,
{ after, before, weekdays }: Omit<TimeCondition, "condition">
): number | undefined {
const timezone =
hass.locale.time_zone === TimeZone.server
? hass.config.time_zone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new TZDate(new Date(), timezone);
const updates: Date[] = [];
// Calculate next occurrence of after time
if (after) {
let afterDate = parseTimeString(after, timezone);
if (afterDate <= now) {
// If time has passed today, schedule for tomorrow
afterDate = addDays(afterDate, 1);
}
updates.push(afterDate);
}
// Calculate next occurrence of before time
if (before) {
let beforeDate = parseTimeString(before, timezone);
if (beforeDate <= now) {
// If time has passed today, schedule for tomorrow
beforeDate = addDays(beforeDate, 1);
}
updates.push(beforeDate);
}
// If weekdays are specified, check for midnight (weekday transition)
if (weekdays && weekdays.length > 0 && weekdays.length < 7) {
// Calculate next midnight using startOfDay + addDays
const tomorrow = addDays(now, 1);
const midnight = startOfDay(tomorrow);
updates.push(midnight);
}
if (updates.length === 0) {
return undefined;
}
// Find the soonest update time
const nextUpdate = updates.reduce((soonest, current) =>
current < soonest ? current : soonest
);
// Add 1 minute buffer to ensure we're past the boundary
const updateWithBuffer = addMinutes(nextUpdate, 1);
// Calculate difference in milliseconds
return differenceInMilliseconds(updateWithBuffer, now);
}

View File

@@ -1,131 +0,0 @@
import { TZDate } from "@date-fns/tz";
import { isBefore, isAfter, isWithinInterval } from "date-fns";
import type { HomeAssistant } from "../../types";
import { TimeZone } from "../../data/translation";
import { WEEKDAY_MAP } from "./weekday";
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
/**
* Validate a time string format and value ranges without creating Date objects
* @param timeString Time string to validate (HH:MM or HH:MM:SS)
* @returns true if valid, false otherwise
*/
export function isValidTimeString(timeString: string): boolean {
// Reject empty strings
if (!timeString || timeString.trim() === "") {
return false;
}
const parts = timeString.split(":");
if (parts.length < 2 || parts.length > 3) {
return false;
}
// Ensure each part contains only digits (and optional leading zeros)
// This prevents "8:00 AM" from passing validation
if (!parts.every((part) => /^\d+$/.test(part))) {
return false;
}
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
return false;
}
return (
hours >= 0 &&
hours <= 23 &&
minutes >= 0 &&
minutes <= 59 &&
seconds >= 0 &&
seconds <= 59
);
}
/**
* Parse a time string (HH:MM or HH:MM:SS) and set it on today's date in the given timezone
*
* Note: This function assumes the time string has already been validated by
* isValidTimeString() at configuration time. It does not re-validate at runtime
* for consistency with other condition types (screen, user, location, etc.)
*
* @param timeString The time string to parse (must be pre-validated)
* @param timezone The timezone to use
* @returns The Date object
*/
export const parseTimeString = (timeString: string, timezone: string): Date => {
const parts = timeString.split(":");
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
const now = new TZDate(new Date(), timezone);
const dateWithTime = new TZDate(
now.getFullYear(),
now.getMonth(),
now.getDate(),
hours,
minutes,
seconds,
0,
timezone
);
return new Date(dateWithTime.getTime());
};
/**
* Check if the current time matches the time condition (after/before/weekday)
* @param hass Home Assistant object
* @param timeCondition Time condition to check
* @returns true if current time matches the condition
*/
export const checkTimeInRange = (
hass: HomeAssistant,
{ after, before, weekdays }: Omit<TimeCondition, "condition">
): boolean => {
const timezone =
hass.locale.time_zone === TimeZone.server
? hass.config.time_zone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new TZDate(new Date(), timezone);
// Check weekday condition
if (weekdays && weekdays.length > 0) {
const currentWeekday = WEEKDAY_MAP[now.getDay()];
if (!weekdays.includes(currentWeekday)) {
return false;
}
}
// Check time conditions
if (!after && !before) {
return true;
}
const afterDate = after ? parseTimeString(after, timezone) : undefined;
const beforeDate = before ? parseTimeString(before, timezone) : undefined;
if (afterDate && beforeDate) {
if (isBefore(beforeDate, afterDate)) {
// Crosses midnight (e.g., 22:00 to 06:00)
return !isBefore(now, afterDate) || !isAfter(now, beforeDate);
}
return isWithinInterval(now, { start: afterDate, end: beforeDate });
}
if (afterDate) {
return !isBefore(now, afterDate);
}
if (beforeDate) {
return !isAfter(now, beforeDate);
}
return true;
};

View File

@@ -1,7 +1,18 @@
import { getWeekStartByLocale } from "weekstart";
import type { FrontendLocaleData } from "../../data/translation";
import { FirstWeekday } from "../../data/translation";
import { WEEKDAYS_LONG, type WeekdayIndex } from "./weekday";
export const weekdays = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const;
type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
if (locale.first_weekday === FirstWeekday.language) {
@@ -12,12 +23,12 @@ export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
}
return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex;
}
return WEEKDAYS_LONG.includes(locale.first_weekday)
? (WEEKDAYS_LONG.indexOf(locale.first_weekday) as WeekdayIndex)
return weekdays.includes(locale.first_weekday)
? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex)
: 1;
};
export const firstWeekday = (locale: FrontendLocaleData) => {
const index = firstWeekdayIndex(locale);
return WEEKDAYS_LONG[index];
return weekdays[index];
};

View File

@@ -1,59 +0,0 @@
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export type WeekdayShort =
| "sun"
| "mon"
| "tue"
| "wed"
| "thu"
| "fri"
| "sat";
export type WeekdayLong =
| "sunday"
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday";
export const WEEKDAYS_SHORT = [
"sun",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
] as const satisfies readonly WeekdayShort[];
export const WEEKDAYS_LONG = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const satisfies readonly WeekdayLong[];
export const WEEKDAY_MAP = {
0: "sun",
1: "mon",
2: "tue",
3: "wed",
4: "thu",
5: "fri",
6: "sat",
} as const satisfies Record<WeekdayIndex, WeekdayShort>;
export const WEEKDAY_SHORT_TO_LONG = {
sun: "sunday",
mon: "monday",
tue: "tuesday",
wed: "wednesday",
thu: "thursday",
fri: "friday",
sat: "saturday",
} as const satisfies Record<WeekdayShort, WeekdayLong>;

View File

@@ -1,6 +1,5 @@
import type { ThemeVars } from "../../data/ws-themes";
import { darkColorVariables } from "../../resources/theme/color";
import { darkSemanticVariables } from "../../resources/theme/semantic.globals";
import { derivedStyles } from "../../resources/theme/theme";
import type { HomeAssistant } from "../../types";
import {
@@ -53,7 +52,7 @@ export const applyThemesOnElement = (
if (themeToApply && darkMode) {
cacheKey = `${cacheKey}__dark`;
themeRules = { ...darkSemanticVariables, ...darkColorVariables };
themeRules = { ...darkColorVariables };
}
if (themeToApply === "default") {

View File

@@ -1,36 +0,0 @@
/**
* Parses a CSS duration string (e.g., "300ms", "3s") and returns the duration in milliseconds.
*
* @param duration - A CSS duration string (e.g., "300ms", "3s", "0.5s")
* @returns The duration in milliseconds, or 0 if the input is invalid
*
* @example
* parseAnimationDuration("300ms") // Returns 300
* parseAnimationDuration("3s") // Returns 3000
* parseAnimationDuration("0.5s") // Returns 500
* parseAnimationDuration("invalid") // Returns 0
*/
export const parseAnimationDuration = (duration: string): number => {
const trimmed = duration.trim();
let value: number;
let multiplier: number;
if (trimmed.endsWith("ms")) {
value = parseFloat(trimmed.slice(0, -2));
multiplier = 1;
} else if (trimmed.endsWith("s")) {
value = parseFloat(trimmed.slice(0, -1));
multiplier = 1000;
} else {
// No recognized unit, try parsing as number (assume ms)
value = parseFloat(trimmed);
multiplier = 1;
}
if (!isFinite(value) || value < 0) {
return 0;
}
return value * multiplier;
};

View File

@@ -1,30 +0,0 @@
/**
* Executes a callback within a View Transition if supported, otherwise runs it directly.
*
* @param callback - Function to execute. Can be synchronous or return a Promise. The callback will be passed a boolean indicating whether the view transition is available.
* @returns Promise that resolves when the transition completes (or immediately if not supported)
*
* @example
* ```typescript
* // Synchronous callback
* withViewTransition(() => {
* this.large = !this.large;
* });
*
* // Async callback
* await withViewTransition(async () => {
* await this.updateData();
* });
* ```
*/
export const withViewTransition = (
callback: (viewTransitionAvailable: boolean) => void | Promise<void>
): Promise<void> => {
if (document.startViewTransition) {
return document.startViewTransition(() => callback(true)).finished;
}
// Fallback: Execute callback directly without transition
const result = callback(false);
return result instanceof Promise ? result : Promise.resolve();
};

View File

@@ -21,6 +21,7 @@ import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-icon-button";
import "../ha-input-helper-text";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
@@ -474,7 +475,6 @@ export class HaStatisticPicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
.helper=${this.helper}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -1,41 +0,0 @@
import DropdownItem from "@home-assistant/webawesome/dist/components/dropdown-item/dropdown-item";
import { css, type CSSResultGroup } from "lit";
import { customElement } from "lit/decorators";
/**
* Home Assistant dropdown item component
*
* @element ha-dropdown-item
* @extends {DropdownItem}
*
* @summary
* A stylable dropdown item component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown item.
*
*/
@customElement("ha-dropdown-item")
export class HaDropdownItem extends DropdownItem {
static get styles(): CSSResultGroup {
return [
DropdownItem.styles,
css`
:host {
min-height: var(--ha-space-10);
}
#icon ::slotted(*) {
color: var(--ha-color-on-neutral-normal);
}
:host([variant="danger"]) #icon ::slotted(*) {
color: var(--ha-color-on-danger-quiet);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dropdown-item": HaDropdownItem;
}
}

View File

@@ -1,45 +0,0 @@
import Dropdown from "@home-assistant/webawesome/dist/components/dropdown/dropdown";
import { css, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
/**
* Home Assistant dropdown component
*
* @element ha-dropdown
* @extends {Dropdown}
*
* @summary
* A stylable dropdown component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown.
*
*/
@customElement("ha-dropdown")
export class HaDropdown extends Dropdown {
@property({ attribute: false }) dropdownTag = "ha-dropdown";
@property({ attribute: false }) dropdownItemTag = "ha-dropdown-item";
static get styles(): CSSResultGroup {
return [
Dropdown.styles,
css`
:host {
font-size: var(--ha-dropdown-font-size, var(--ha-font-size-m));
--wa-color-surface-raised: var(
--card-background-color,
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
);
}
#menu {
padding: var(--ha-space-1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dropdown": HaDropdown;
}
}

View File

@@ -712,7 +712,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._selectedSection = section as TargetTypeFloorless | undefined;
return this._getItemsMemoized(
this.hass.localize,
this.entityFilter,
this.deviceFilter,
this.includeDomains,
@@ -726,7 +725,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _getItemsMemoized = memoizeOne(
(
localize: HomeAssistant["localize"],
entityFilter: this["entityFilter"],
deviceFilter: this["deviceFilter"],
includeDomains: this["includeDomains"],
@@ -744,7 +742,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
)[] = [];
if (!filterType || filterType === "entity") {
let entityItems = this._getEntitiesMemoized(
let entities = this._getEntitiesMemoized(
this.hass,
includeDomains,
undefined,
@@ -760,25 +758,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
if (searchTerm) {
entityItems = this._filterGroup(
entities = this._filterGroup(
"entity",
entityItems,
entities,
searchTerm,
(item: EntityComboBoxItem) =>
item.stateObj?.entity_id === searchTerm
) as EntityComboBoxItem[];
}
if (!filterType && entityItems.length) {
if (!filterType && entities.length) {
// show group title
items.push(localize("ui.components.target-picker.type.entities"));
items.push(
this.hass.localize("ui.components.target-picker.type.entities")
);
}
items.push(...entityItems);
items.push(...entities);
}
if (!filterType || filterType === "device") {
let deviceItems = this._getDevicesMemoized(
let devices = this._getDevicesMemoized(
this.hass,
configEntryLookup,
includeDomains,
@@ -794,15 +794,17 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
if (searchTerm) {
deviceItems = this._filterGroup("device", deviceItems, searchTerm);
devices = this._filterGroup("device", devices, searchTerm);
}
if (!filterType && deviceItems.length) {
if (!filterType && devices.length) {
// show group title
items.push(localize("ui.components.target-picker.type.devices"));
items.push(
this.hass.localize("ui.components.target-picker.type.devices")
);
}
items.push(...deviceItems);
items.push(...devices);
}
if (!filterType || filterType === "area") {
@@ -834,7 +836,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
if (!filterType && areasAndFloors.length) {
// show group title
items.push(localize("ui.components.target-picker.type.areas"));
items.push(
this.hass.localize("ui.components.target-picker.type.areas")
);
}
items.push(
@@ -875,7 +879,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
if (!filterType && labels.length) {
// show group title
items.push(localize("ui.components.target-picker.type.labels"));
items.push(
this.hass.localize("ui.components.target-picker.type.labels")
);
}
items.push(...labels);

View File

@@ -12,7 +12,6 @@ import { CONDITION_BUILDING_BLOCKS } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import type { Action, Field, MODES } from "./script";
import { migrateAutomationAction } from "./script";
import type { WeekdayShort } from "../common/datetime/weekday";
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
export const AUTOMATION_DEFAULT_MAX = 10;
@@ -258,11 +257,13 @@ export interface ZoneCondition extends BaseCondition {
zone: string;
}
type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
export interface TimeCondition extends BaseCondition {
condition: "time";
after?: string;
before?: string;
weekday?: WeekdayShort | WeekdayShort[];
weekday?: Weekday | Weekday[];
}
export interface TemplateCondition extends BaseCondition {

View File

@@ -53,6 +53,9 @@ export const enum CalendarEntityFeature {
UPDATE_EVENT = 4,
}
/** Type for date values that can come from REST API or subscription */
type CalendarDateValue = string | { dateTime: string } | { date: string };
export const fetchCalendarEvents = async (
hass: HomeAssistant,
start: Date,
@@ -65,11 +68,11 @@ export const fetchCalendarEvents = async (
const calEvents: CalendarEvent[] = [];
const errors: string[] = [];
const promises: Promise<CalendarEvent[]>[] = [];
const promises: Promise<CalendarEventApiData[]>[] = [];
calendars.forEach((cal) => {
promises.push(
hass.callApi<CalendarEvent[]>(
hass.callApi<CalendarEventApiData[]>(
"GET",
`calendars/${cal.entity_id}${params}`
)
@@ -77,7 +80,7 @@ export const fetchCalendarEvents = async (
});
for (const [idx, promise] of promises.entries()) {
let result: CalendarEvent[];
let result: CalendarEventApiData[];
try {
// eslint-disable-next-line no-await-in-loop
result = await promise;
@@ -87,53 +90,16 @@ export const fetchCalendarEvents = async (
}
const cal = calendars[idx];
result.forEach((ev) => {
const eventStart = getCalendarDate(ev.start);
const eventEnd = getCalendarDate(ev.end);
if (!eventStart || !eventEnd) {
return;
const normalized = normalizeSubscriptionEventData(ev, cal);
if (normalized) {
calEvents.push(normalized);
}
const eventData: CalendarEventData = {
uid: ev.uid,
summary: ev.summary,
description: ev.description,
dtstart: eventStart,
dtend: eventEnd,
recurrence_id: ev.recurrence_id,
rrule: ev.rrule,
};
const event: CalendarEvent = {
start: eventStart,
end: eventEnd,
title: ev.summary,
backgroundColor: cal.backgroundColor,
borderColor: cal.backgroundColor,
calendar: cal.entity_id,
eventData: eventData,
};
calEvents.push(event);
});
}
return { events: calEvents, errors };
};
const getCalendarDate = (dateObj: any): string | undefined => {
if (typeof dateObj === "string") {
return dateObj;
}
if (dateObj.dateTime) {
return dateObj.dateTime;
}
if (dateObj.date) {
return dateObj.date;
}
return undefined;
};
export const getCalendars = (hass: HomeAssistant): Calendar[] =>
Object.keys(hass.states)
.filter(
@@ -191,3 +157,89 @@ export const deleteCalendarEvent = (
recurrence_id,
recurrence_range,
});
/**
* Calendar event data from both REST API and WebSocket subscription.
* Both APIs use the same data format.
*/
export interface CalendarEventApiData {
summary: string;
start: CalendarDateValue;
end: CalendarDateValue;
description?: string | null;
location?: string | null;
uid?: string | null;
recurrence_id?: string | null;
rrule?: string | null;
}
export interface CalendarEventSubscription {
events: CalendarEventApiData[] | null;
}
export const subscribeCalendarEvents = (
hass: HomeAssistant,
entity_id: string,
start: Date,
end: Date,
callback: (update: CalendarEventSubscription) => void
) =>
hass.connection.subscribeMessage<CalendarEventSubscription>(callback, {
type: "calendar/event/subscribe",
entity_id,
start: start.toISOString(),
end: end.toISOString(),
});
const getCalendarDate = (dateObj: CalendarDateValue): string | undefined => {
if (typeof dateObj === "string") {
return dateObj;
}
if ("dateTime" in dateObj) {
return dateObj.dateTime;
}
if ("date" in dateObj) {
return dateObj.date;
}
return undefined;
};
/**
* Normalize calendar event data from API format to internal format.
* Handles both REST API format (with dateTime/date objects) and subscription format (strings).
* Converts to internal format with { dtstart, dtend, ... }
*/
export const normalizeSubscriptionEventData = (
eventData: CalendarEventApiData,
calendar: Calendar
): CalendarEvent | null => {
const eventStart = getCalendarDate(eventData.start);
const eventEnd = getCalendarDate(eventData.end);
if (!eventStart || !eventEnd) {
return null;
}
const normalizedEventData: CalendarEventData = {
summary: eventData.summary,
dtstart: eventStart,
dtend: eventEnd,
description: eventData.description ?? undefined,
uid: eventData.uid ?? undefined,
recurrence_id: eventData.recurrence_id ?? undefined,
rrule: eventData.rrule ?? undefined,
};
return {
start: eventStart,
end: eventEnd,
title: eventData.summary,
backgroundColor: calendar.backgroundColor,
borderColor: calendar.backgroundColor,
calendar: calendar.entity_id,
eventData: normalizedEventData,
};
};

View File

@@ -102,7 +102,6 @@ export type EnergySolarForecasts = Record<string, EnergySolarForecast>;
export interface DeviceConsumptionEnergyPreference {
// This is an ever increasing value
stat_consumption: string;
stat_rate?: string;
name?: string;
included_in_stat?: string;
}
@@ -131,17 +130,11 @@ export interface FlowToGridSourceEnergyPreference {
number_energy_price: number | null;
}
export interface GridPowerSourceEnergyPreference {
// W meter
stat_rate: string;
}
export interface GridSourceTypeEnergyPreference {
type: "grid";
flow_from: FlowFromGridSourceEnergyPreference[];
flow_to: FlowToGridSourceEnergyPreference[];
power?: GridPowerSourceEnergyPreference[];
cost_adjustment_day: number;
}
@@ -150,7 +143,6 @@ export interface SolarSourceTypeEnergyPreference {
type: "solar";
stat_energy_from: string;
stat_rate?: string;
config_entry_solar_forecast: string[] | null;
}
@@ -158,7 +150,6 @@ export interface BatterySourceTypeEnergyPreference {
type: "battery";
stat_energy_from: string;
stat_energy_to: string;
stat_rate?: string;
}
export interface GasSourceTypeEnergyPreference {
type: "gas";
@@ -360,35 +351,6 @@ export const getReferencedStatisticIds = (
return statIDs;
};
export const getReferencedStatisticIdsPower = (
prefs: EnergyPreferences
): string[] => {
const statIDs: (string | undefined)[] = [];
for (const source of prefs.energy_sources) {
if (source.type === "gas" || source.type === "water") {
continue;
}
if (source.type === "solar") {
statIDs.push(source.stat_rate);
continue;
}
if (source.type === "battery") {
statIDs.push(source.stat_rate);
continue;
}
if (source.power) {
statIDs.push(...source.power.map((p) => p.stat_rate));
}
}
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
return statIDs.filter(Boolean) as string[];
};
export const enum CompareMode {
NONE = "",
PREVIOUS = "previous",
@@ -436,10 +398,9 @@ const getEnergyData = async (
"gas",
"device",
]);
const powerStatIds = getReferencedStatisticIdsPower(prefs);
const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]);
const allStatIDs = [...energyStatIds, ...waterStatIds, ...powerStatIds];
const allStatIDs = [...energyStatIds, ...waterStatIds];
const dayDifference = differenceInDays(end || new Date(), start);
const period =
@@ -450,8 +411,6 @@ const getEnergyData = async (
: dayDifference > 2
? "day"
: "hour";
const finePeriod =
dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute";
const statsMetadata: Record<string, StatisticsMetaData> = {};
const statsMetadataArray = allStatIDs.length
@@ -473,9 +432,6 @@ const getEnergyData = async (
? (gasUnit as (typeof VOLUME_UNITS)[number])
: undefined,
};
const powerUnits: StatisticsUnitConfiguration = {
power: "kW",
};
const waterUnit = getEnergyWaterUnit(hass, prefs, statsMetadata);
const waterUnits: StatisticsUnitConfiguration = {
volume: waterUnit,
@@ -486,12 +442,6 @@ const getEnergyData = async (
"change",
])
: {};
const _powerStats: Statistics | Promise<Statistics> = powerStatIds.length
? fetchStatistics(hass!, start, end, powerStatIds, finePeriod, powerUnits, [
"mean",
])
: {};
const _waterStats: Statistics | Promise<Statistics> = waterStatIds.length
? fetchStatistics(hass!, start, end, waterStatIds, period, waterUnits, [
"change",
@@ -598,7 +548,6 @@ const getEnergyData = async (
const [
energyStats,
powerStats,
waterStats,
energyStatsCompare,
waterStatsCompare,
@@ -606,14 +555,13 @@ const getEnergyData = async (
fossilEnergyConsumptionCompare,
] = await Promise.all([
_energyStats,
_powerStats,
_waterStats,
_energyStatsCompare,
_waterStatsCompare,
_fossilEnergyConsumption,
_fossilEnergyConsumptionCompare,
]);
const stats = { ...energyStats, ...waterStats, ...powerStats };
const stats = { ...energyStats, ...waterStats };
if (compare) {
statsCompare = { ...energyStatsCompare, ...waterStatsCompare };
}

View File

@@ -20,21 +20,6 @@
<meta name="color-scheme" content="dark light" />
<%= renderTemplate("_style_base.html.template") %>
<style>
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
::view-transition-group(launch-screen) {
animation-duration: var(--ha-animation-base-duration, 350ms);
animation-timing-function: ease-out;
}
::view-transition-old(launch-screen) {
animation: fade-out var(--ha-animation-base-duration, 350ms) ease-out;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
@@ -47,29 +32,11 @@
}
}
#ha-launch-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
view-transition-name: launch-screen;
background-color: var(--primary-background-color, #fafafa);
z-index: 100;
}
@media (prefers-color-scheme: dark) {
/* body selector to avoid minification causing bad jinja2 */
body #ha-launch-screen {
background-color: var(--primary-background-color, #111111);
}
}
#ha-launch-screen.removing {
opacity: 0;
}
#ha-launch-screen svg {
width: 112px;

View File

@@ -35,7 +35,6 @@ const COMPONENTS = {
light: () => import("../panels/light/ha-panel-light"),
security: () => import("../panels/security/ha-panel-security"),
climate: () => import("../panels/climate/ha-panel-climate"),
home: () => import("../panels/home/ha-panel-home"),
};
@customElement("partial-panel-resolver")

View File

@@ -1,56 +1,82 @@
import type { ReactiveElement } from "lit";
import { listenMediaQuery } from "../common/dom/media_query";
import type { HomeAssistant } from "../types";
import {
setupMediaQueryListeners,
setupTimeListeners,
} from "../common/condition/listeners";
import type { Condition } from "../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../panels/lovelace/common/validate-condition";
type Constructor<T> = abstract new (...args: any[]) => T;
/**
* Base config type that can be used with conditional listeners
* Extract media queries from conditions recursively
*/
export interface ConditionalConfig {
visibility?: Condition[];
[key: string]: any;
export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(c.conditions));
}
if (c.condition === "screen" && c.media_query) {
array.push(c.media_query);
}
return array;
}, []);
}
/**
* Helper to setup media query listeners for conditional visibility
*/
export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
): void {
const mediaQueries = extractMediaQueries(conditions);
if (mediaQueries.length === 0) return;
// Optimization for single media query
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
mediaQueries.forEach((mediaQuery) => {
const unsub = listenMediaQuery(mediaQuery, (matches) => {
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
});
addListener(unsub);
});
}
/**
* Mixin to handle conditional listeners for visibility control
*
* Provides lifecycle management for listeners that control conditional
* visibility of components.
* Provides lifecycle management for listeners (media queries, time-based, state changes, etc.)
* that control conditional visibility of components.
*
* Usage:
* 1. Extend your component with ConditionalListenerMixin<YourConfigType>(ReactiveElement)
* 2. Ensure component has config.visibility or _config.visibility property with conditions
* 3. Ensure component has _updateVisibility() or _updateElement() method
* 4. Override setupConditionalListeners() if custom behavior needed (e.g., filter conditions)
* 1. Extend your component with ConditionalListenerMixin(ReactiveElement)
* 2. Override setupConditionalListeners() to setup your listeners
* 3. Use addConditionalListener() to register unsubscribe functions
* 4. Call clearConditionalListeners() and setupConditionalListeners() when config changes
*
* The mixin automatically:
* - Sets up listeners when component connects to DOM
* - Cleans up listeners when component disconnects from DOM
* - Handles conditional visibility based on defined conditions
*/
export const ConditionalListenerMixin = <
TConfig extends ConditionalConfig = ConditionalConfig,
T extends Constructor<ReactiveElement>,
>(
superClass: Constructor<ReactiveElement>
superClass: T
) => {
abstract class ConditionalListenerClass extends superClass {
private __listeners: (() => void)[] = [];
protected _config?: TConfig;
public config?: TConfig;
public hass?: HomeAssistant;
protected _updateElement?(config: TConfig): void;
protected _updateVisibility?(conditionsMet?: boolean): void;
public connectedCallback() {
super.connectedCallback();
this.setupConditionalListeners();
@@ -61,72 +87,17 @@ export const ConditionalListenerMixin = <
this.clearConditionalListeners();
}
/**
* Clear conditional listeners
*
* This method is called when the component is disconnected from the DOM.
* It clears all the listeners that were set up by the setupConditionalListeners() method.
*/
protected clearConditionalListeners(): void {
this.__listeners.forEach((unsub) => unsub());
this.__listeners = [];
}
/**
* Add a conditional listener to the list of listeners
*
* This method is called when a new listener is added.
* It adds the listener to the list of listeners.
*
* @param unsubscribe - The unsubscribe function to call when the listener is no longer needed
* @returns void
*/
protected addConditionalListener(unsubscribe: () => void): void {
this.__listeners.push(unsubscribe);
}
/**
* Setup conditional listeners for visibility control
*
* Default implementation:
* - Checks config.visibility or _config.visibility for conditions (if not provided)
* - Sets up appropriate listeners based on condition types
* - Calls _updateVisibility() or _updateElement() when conditions change
*
* Override this method to customize behavior (e.g., filter conditions first)
* and call super.setupConditionalListeners(customConditions) to reuse the base implementation
*
* @param conditions - Optional conditions array. If not provided, will check config.visibility or _config.visibility
*/
protected setupConditionalListeners(conditions?: Condition[]): void {
const config = this.config || this._config;
const finalConditions = conditions || config?.visibility;
if (!finalConditions || !this.hass) {
return;
}
const onUpdate = (conditionsMet: boolean) => {
if (this._updateVisibility) {
this._updateVisibility(conditionsMet);
} else if (this._updateElement && config) {
this._updateElement(config);
}
};
setupMediaQueryListeners(
finalConditions,
this.hass,
(unsub) => this.addConditionalListener(unsub),
onUpdate
);
setupTimeListeners(
finalConditions,
this.hass,
(unsub) => this.addConditionalListener(unsub),
onUpdate
);
protected setupConditionalListeners(): void {
// Override in subclass
}
}
return ConditionalListenerClass;

View File

@@ -1,5 +1,6 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { mdiChevronDown, mdiPlus, mdiRefresh } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -20,8 +21,17 @@ import "../../components/ha-menu-button";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-two-pane-top-app-bar-fixed";
import type { Calendar, CalendarEvent } from "../../data/calendar";
import { fetchCalendarEvents, getCalendars } from "../../data/calendar";
import type {
Calendar,
CalendarEvent,
CalendarEventSubscription,
CalendarEventApiData,
} from "../../data/calendar";
import {
getCalendars,
normalizeSubscriptionEventData,
subscribeCalendarEvents,
} from "../../data/calendar";
import { fetchIntegrationManifest } from "../../data/integration";
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
import { haStyle } from "../../resources/styles";
@@ -42,6 +52,8 @@ class PanelCalendar extends LitElement {
@state() private _error?: string = undefined;
@state() private _errorCalendars: string[] = [];
@state()
@storage({
key: "deSelectedCalendars",
@@ -53,6 +65,8 @@ class PanelCalendar extends LitElement {
private _end?: Date;
private _unsubs: Record<string, Promise<UnsubscribeFunc>> = {};
private _showPaneController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width > 750,
});
@@ -78,6 +92,7 @@ class PanelCalendar extends LitElement {
super.disconnectedCallback();
this._mql?.removeListener(this._setIsMobile!);
this._mql = undefined;
this._unsubscribeAll();
}
private _setIsMobile = (ev: MediaQueryListEvent) => {
@@ -194,19 +209,95 @@ class PanelCalendar extends LitElement {
.map((cal) => cal);
}
private async _fetchEvents(
start: Date | undefined,
end: Date | undefined,
calendars: Calendar[]
): Promise<{ events: CalendarEvent[]; errors: string[] }> {
if (!calendars.length || !start || !end) {
return { events: [], errors: [] };
private _subscribeCalendarEvents(calendars: Calendar[]): void {
if (!this._start || !this._end || calendars.length === 0) {
return;
}
return fetchCalendarEvents(this.hass, start, end, calendars);
this._error = undefined;
calendars.forEach((calendar) => {
// Unsubscribe existing subscription if any
if (calendar.entity_id in this._unsubs) {
this._unsubs[calendar.entity_id]
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
});
}
const unsub = subscribeCalendarEvents(
this.hass,
calendar.entity_id,
this._start!,
this._end!,
(update: CalendarEventSubscription) => {
this._handleCalendarUpdate(calendar, update);
}
);
this._unsubs[calendar.entity_id] = unsub;
});
}
private async _requestSelected(ev: CustomEvent<RequestSelectedDetail>) {
private _handleCalendarUpdate(
calendar: Calendar,
update: CalendarEventSubscription
): void {
// Remove events from this calendar
this._events = this._events.filter(
(event) => event.calendar !== calendar.entity_id
);
if (update.events === null) {
// Error fetching events
if (!this._errorCalendars.includes(calendar.entity_id)) {
this._errorCalendars = [...this._errorCalendars, calendar.entity_id];
}
this._handleErrors(this._errorCalendars);
return;
}
// Remove from error list if successfully loaded
this._errorCalendars = this._errorCalendars.filter(
(id) => id !== calendar.entity_id
);
this._handleErrors(this._errorCalendars);
// Add new events from this calendar
const newEvents: CalendarEvent[] = update.events
.map((eventData: CalendarEventApiData) =>
normalizeSubscriptionEventData(eventData, calendar)
)
.filter((event): event is CalendarEvent => event !== null);
this._events = [...this._events, ...newEvents];
}
private async _unsubscribeAll(): Promise<void> {
await Promise.all(
Object.values(this._unsubs).map((unsub) =>
unsub
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
})
)
);
this._unsubs = {};
}
private _unsubscribeCalendar(entityId: string): void {
if (entityId in this._unsubs) {
this._unsubs[entityId]
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
});
delete this._unsubs[entityId];
}
}
private _requestSelected(ev: CustomEvent<RequestSelectedDetail>) {
ev.stopPropagation();
const entityId = (ev.target as HaListItem).value;
if (ev.detail.selected) {
@@ -223,13 +314,10 @@ class PanelCalendar extends LitElement {
if (!calendar) {
return;
}
const result = await this._fetchEvents(this._start, this._end, [
calendar,
]);
this._events = [...this._events, ...result.events];
this._handleErrors(result.errors);
this._subscribeCalendarEvents([calendar]);
} else {
this._deSelectedCalendars = [...this._deSelectedCalendars, entityId];
this._unsubscribeCalendar(entityId);
this._events = this._events.filter(
(event) => event.calendar !== entityId
);
@@ -254,23 +342,15 @@ class PanelCalendar extends LitElement {
): Promise<void> {
this._start = ev.detail.start;
this._end = ev.detail.end;
const result = await this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
);
this._events = result.events;
this._handleErrors(result.errors);
await this._unsubscribeAll();
this._events = [];
this._subscribeCalendarEvents(this._selectedCalendars);
}
private async _handleRefresh(): Promise<void> {
const result = await this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
);
this._events = result.events;
this._handleErrors(result.errors);
await this._unsubscribeAll();
this._events = [];
this._subscribeCalendarEvents(this._selectedCalendars);
}
private _handleErrors(error_entity_ids: string[]) {

View File

@@ -63,7 +63,8 @@ export default class HaAutomationSidebarTrigger extends LitElement {
protected render() {
const rowDisabled =
"enabled" in this.config.config && this.config.config.enabled === false;
this.disabled ||
("enabled" in this.config.config && this.config.config.enabled === false);
const type = isTriggerList(this.config.config)
? "list"
: this.config.config.trigger;

View File

@@ -26,7 +26,6 @@ import type {
EnergySource,
FlowFromGridSourceEnergyPreference,
FlowToGridSourceEnergyPreference,
GridPowerSourceEnergyPreference,
GridSourceTypeEnergyPreference,
} from "../../../../data/energy";
import {
@@ -48,7 +47,6 @@ import { documentationUrl } from "../../../../util/documentation-url";
import {
showEnergySettingsGridFlowFromDialog,
showEnergySettingsGridFlowToDialog,
showEnergySettingsGridPowerDialog,
} from "../dialogs/show-dialogs-energy";
import "./ha-energy-validation-result";
import { energyCardStyles } from "./styles";
@@ -228,58 +226,6 @@ export class EnergyGridSettings extends LitElement {
>
</div>
<h3>
${this.hass.localize("ui.panel.config.energy.grid.grid_power")}
</h3>
${gridSource.power?.map((power) => {
const entityState = this.hass.states[power.stat_rate];
return html`
<div class="row" .source=${power}>
${entityState?.attributes.icon
? html`<ha-icon
.icon=${entityState.attributes.icon}
></ha-icon>`
: html`<ha-svg-icon
.path=${mdiTransmissionTower}
></ha-svg-icon>`}
<span class="content"
>${getStatisticLabel(
this.hass,
power.stat_rate,
this.statsMetadata?.[power.stat_rate]
)}</span
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.edit_power"
)}
@click=${this._editPowerSource}
.path=${mdiPencil}
></ha-icon-button>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.delete_power"
)}
@click=${this._deletePowerSource}
.path=${mdiDelete}
></ha-icon-button>
</div>
`;
})}
<div class="row border-bottom">
<ha-svg-icon .path=${mdiTransmissionTower}></ha-svg-icon>
<ha-button
@click=${this._addPowerSource}
appearance="filled"
size="small"
>
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.energy.grid.add_power"
)}</ha-button
>
</div>
<h3>
${this.hass.localize(
"ui.panel.config.energy.grid.grid_carbon_footprint"
@@ -553,97 +499,6 @@ export class EnergyGridSettings extends LitElement {
await this._savePreferences(cleanedPreferences);
}
private _addPowerSource() {
const gridSource = this.preferences.energy_sources.find(
(src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
showEnergySettingsGridPowerDialog(this, {
grid_source: gridSource,
saveCallback: async (power) => {
let preferences: EnergyPreferences;
if (!gridSource) {
preferences = {
...this.preferences,
energy_sources: [
...this.preferences.energy_sources,
{
...emptyGridSourceEnergyPreference(),
power: [power],
},
],
};
} else {
preferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? { ...src, power: [...(gridSource.power || []), power] }
: src
),
};
}
await this._savePreferences(preferences);
},
});
}
private _editPowerSource(ev) {
const origSource: GridPowerSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
const gridSource = this.preferences.energy_sources.find(
(src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
showEnergySettingsGridPowerDialog(this, {
source: { ...origSource },
grid_source: gridSource,
saveCallback: async (source) => {
const power =
energySourcesByType(this.preferences).grid![0].power || [];
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? {
...src,
power: power.map((p) => (p === origSource ? source : p)),
}
: src
),
};
await this._savePreferences(preferences);
},
});
}
private async _deletePowerSource(ev) {
const sourceToDelete: GridPowerSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
if (
!(await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
return;
}
const power =
energySourcesByType(this.preferences).grid![0].power?.filter(
(p) => p !== sourceToDelete
) || [];
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((source) =>
source.type === "grid" ? { ...source, power } : source
),
};
const cleanedPreferences = this._removeEmptySources(preferences);
await this._savePreferences(cleanedPreferences);
}
private _removeEmptySources(preferences: EnergyPreferences) {
// Check if grid sources became an empty type and remove if so
preferences.energy_sources = preferences.energy_sources.reduce<
@@ -652,8 +507,7 @@ export class EnergyGridSettings extends LitElement {
if (
source.type !== "grid" ||
source.flow_from.length > 0 ||
source.flow_to.length > 0 ||
(source.power && source.power.length > 0)
source.flow_to.length > 0
) {
acc.push(source);
}

View File

@@ -18,7 +18,6 @@ import type { HomeAssistant } from "../../../../types";
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
const energyUnitClasses = ["energy"];
const powerUnitClasses = ["power"];
@customElement("dialog-energy-battery-settings")
export class DialogEnergyBatterySettings
@@ -33,14 +32,10 @@ export class DialogEnergyBatterySettings
@state() private _energy_units?: string[];
@state() private _power_units?: string[];
@state() private _error?: string;
private _excludeList?: string[];
private _excludeListPower?: string[];
public async showDialog(
params: EnergySettingsBatteryDialogParams
): Promise<void> {
@@ -51,9 +46,6 @@ export class DialogEnergyBatterySettings
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units;
this._power_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
).units;
const allSources: string[] = [];
this._params.battery_sources.forEach((entry) => {
allSources.push(entry.stat_energy_from);
@@ -64,9 +56,6 @@ export class DialogEnergyBatterySettings
id !== this._source?.stat_energy_from &&
id !== this._source?.stat_energy_to
);
this._excludeListPower = this._params.battery_sources
.map((entry) => entry.stat_rate)
.filter((id) => id && id !== this._source?.stat_rate) as string[];
}
public closeDialog() {
@@ -83,6 +72,8 @@ export class DialogEnergyBatterySettings
return nothing;
}
const pickableUnit = this._energy_units?.join(", ") || "";
return html`
<ha-dialog
open
@@ -94,6 +85,12 @@ export class DialogEnergyBatterySettings
@closed=${this.closeDialog}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<div>
${this.hass.localize(
"ui.panel.config.energy.battery.dialog.entity_para",
{ unit: pickableUnit }
)}
</div>
<ha-statistic-picker
.hass=${this.hass}
@@ -108,10 +105,6 @@ export class DialogEnergyBatterySettings
this._source.stat_energy_from,
]}
@value-changed=${this._statisticToChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.energy_helper_into",
{ unit: this._energy_units?.join(", ") || "" }
)}
dialogInitialFocus
></ha-statistic-picker>
@@ -128,25 +121,6 @@ export class DialogEnergyBatterySettings
this._source.stat_energy_to,
]}
@value-changed=${this._statisticFromChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.energy_helper_out",
{ unit: this._energy_units?.join(", ") || "" }
)}
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${powerUnitClasses}
.value=${this._source.stat_rate}
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.power"
)}
.excludeStatistics=${this._excludeListPower}
@value-changed=${this._powerChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.power_helper",
{ unit: this._power_units?.join(", ") || "" }
)}
></ha-statistic-picker>
<ha-button
@@ -176,10 +150,6 @@ export class DialogEnergyBatterySettings
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
}
private _powerChanged(ev: CustomEvent<{ value: string }>) {
this._source = { ...this._source!, stat_rate: ev.detail.value };
}
private async _save() {
try {
await this._params!.saveCallback(this._source!);
@@ -198,11 +168,7 @@ export class DialogEnergyBatterySettings
--mdc-dialog-max-width: 430px;
}
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-statistic-picker:last-of-type {
margin-bottom: 0;
width: 100%;
}
`,
];

View File

@@ -21,7 +21,6 @@ import type { HomeAssistant } from "../../../../types";
import type { EnergySettingsDeviceDialogParams } from "./show-dialogs-energy";
const energyUnitClasses = ["energy"];
const powerUnitClasses = ["power"];
@customElement("dialog-energy-device-settings")
export class DialogEnergyDeviceSettings
@@ -36,14 +35,10 @@ export class DialogEnergyDeviceSettings
@state() private _energy_units?: string[];
@state() private _power_units?: string[];
@state() private _error?: string;
private _excludeList?: string[];
private _excludeListPower?: string[];
private _possibleParents: DeviceConsumptionEnergyPreference[] = [];
public async showDialog(
@@ -55,15 +50,9 @@ export class DialogEnergyDeviceSettings
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units;
this._power_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
).units;
this._excludeList = this._params.device_consumptions
.map((entry) => entry.stat_consumption)
.filter((id) => id !== this._device?.stat_consumption);
this._excludeListPower = this._params.device_consumptions
.map((entry) => entry.stat_rate)
.filter((id) => id && id !== this._device?.stat_rate) as string[];
}
private _computePossibleParents() {
@@ -104,6 +93,8 @@ export class DialogEnergyDeviceSettings
return nothing;
}
const pickableUnit = this._energy_units?.join(", ") || "";
return html`
<ha-dialog
open
@@ -117,6 +108,12 @@ export class DialogEnergyDeviceSettings
@closed=${this.closeDialog}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<div>
${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.selected_stat_intro",
{ unit: pickableUnit }
)}
</div>
<ha-statistic-picker
.hass=${this.hass}
@@ -128,28 +125,9 @@ export class DialogEnergyDeviceSettings
)}
.excludeStatistics=${this._excludeList}
@value-changed=${this._statisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.selected_stat_intro",
{ unit: this._energy_units?.join(", ") || "" }
)}
dialogInitialFocus
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${powerUnitClasses}
.value=${this._device?.stat_rate}
.label=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.device_consumption_power"
)}
.excludeStatistics=${this._excludeListPower}
@value-changed=${this._powerStatisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.selected_stat_intro",
{ unit: this._power_units?.join(", ") || "" }
)}
></ha-statistic-picker>
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.display_name"
@@ -232,20 +210,6 @@ export class DialogEnergyDeviceSettings
this._computePossibleParents();
}
private _powerStatisticChanged(ev: CustomEvent<{ value: string }>) {
if (!this._device) {
return;
}
const newDevice = {
...this._device,
stat_rate: ev.detail.value,
} as DeviceConsumptionEnergyPreference;
if (!newDevice.stat_rate) {
delete newDevice.stat_rate;
}
this._device = newDevice;
}
private _nameChanged(ev) {
const newDevice = {
...this._device!,
@@ -281,19 +245,15 @@ export class DialogEnergyDeviceSettings
return [
haStyleDialog,
css`
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-2);
}
ha-statistic-picker {
width: 100%;
}
ha-select {
margin-top: var(--ha-space-4);
margin-top: 16px;
width: 100%;
}
ha-textfield {
margin-top: var(--ha-space-4);
margin-top: 16px;
width: 100%;
}
`,

View File

@@ -104,6 +104,8 @@ export class DialogEnergyGridFlowSettings
return nothing;
}
const pickableUnit = this._energy_units?.join(", ") || "";
const unitPriceFixed = `${this.hass.config.currency}/kWh`;
const externalSource =
@@ -133,11 +135,19 @@ export class DialogEnergyGridFlowSettings
@closed=${this.closeDialog}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<p>
${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph`
)}
</p>
<div>
<p>
${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph`
)}
</p>
<p>
${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.entity_para`,
{ unit: pickableUnit }
)}
</p>
</div>
<ha-statistic-picker
.hass=${this.hass}
@@ -153,10 +163,6 @@ export class DialogEnergyGridFlowSettings
)}
.excludeStatistics=${this._excludeList}
@value-changed=${this._statisticChanged}
.helper=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.entity_para`,
{ unit: this._energy_units?.join(", ") || "" }
)}
dialogInitialFocus
></ha-statistic-picker>
@@ -355,10 +361,6 @@ export class DialogEnergyGridFlowSettings
ha-dialog {
--mdc-dialog-max-width: 430px;
}
ha-statistic-picker {
display: block;
margin: var(--ha-space-4) 0;
}
ha-formfield {
display: block;
}

View File

@@ -1,153 +0,0 @@
import { mdiTransmissionTower } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-dialog";
import "../../../../components/ha-button";
import type { GridPowerSourceEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { EnergySettingsGridPowerDialogParams } from "./show-dialogs-energy";
const powerUnitClasses = ["power"];
@customElement("dialog-energy-grid-power-settings")
export class DialogEnergyGridPowerSettings
extends LitElement
implements HassDialog<EnergySettingsGridPowerDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EnergySettingsGridPowerDialogParams;
@state() private _source?: GridPowerSourceEnergyPreference;
@state() private _power_units?: string[];
@state() private _error?: string;
private _excludeListPower?: string[];
public async showDialog(
params: EnergySettingsGridPowerDialogParams
): Promise<void> {
this._params = params;
this._source = params.source ? { ...params.source } : { stat_rate: "" };
const initialSourceIdPower = this._source.stat_rate;
this._power_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
).units;
this._excludeListPower = [
...(this._params.grid_source?.power?.map((entry) => entry.stat_rate) ||
[]),
].filter((id) => id && id !== initialSourceIdPower) as string[];
}
public closeDialog() {
this._params = undefined;
this._source = undefined;
this._error = undefined;
this._excludeListPower = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
}
protected render() {
if (!this._params || !this._source) {
return nothing;
}
return html`
<ha-dialog
open
.heading=${html`<ha-svg-icon
.path=${mdiTransmissionTower}
style="--mdc-icon-size: 32px;"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.header"
)}`}
@closed=${this.closeDialog}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<ha-statistic-picker
.hass=${this.hass}
.helpMissingEntityUrl=${energyStatisticHelpUrl}
.includeUnitClass=${powerUnitClasses}
.value=${this._source.stat_rate}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.power_stat"
)}
.excludeStatistics=${this._excludeListPower}
@value-changed=${this._powerStatisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.power_helper",
{ unit: this._power_units?.join(", ") || "" }
)}
dialogInitialFocus
></ha-statistic-picker>
<ha-button
appearance="plain"
@click=${this.closeDialog}
slot="primaryAction"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._source.stat_rate}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog>
`;
}
private _powerStatisticChanged(ev: CustomEvent<{ value: string }>) {
this._source = {
...this._source!,
stat_rate: ev.detail.value,
};
}
private async _save() {
try {
await this._params!.saveCallback(this._source!);
this.closeDialog();
} catch (err: any) {
this._error = err.message;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-max-width: 430px;
}
ha-statistic-picker {
display: block;
margin: var(--ha-space-4) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-energy-grid-power-settings": DialogEnergyGridPowerSettings;
}
}

View File

@@ -28,7 +28,6 @@ import { brandsUrl } from "../../../../util/brands-url";
import type { EnergySettingsSolarDialogParams } from "./show-dialogs-energy";
const energyUnitClasses = ["energy"];
const powerUnitClasses = ["power"];
@customElement("dialog-energy-solar-settings")
export class DialogEnergySolarSettings
@@ -47,14 +46,10 @@ export class DialogEnergySolarSettings
@state() private _energy_units?: string[];
@state() private _power_units?: string[];
@state() private _error?: string;
private _excludeList?: string[];
private _excludeListPower?: string[];
public async showDialog(
params: EnergySettingsSolarDialogParams
): Promise<void> {
@@ -67,15 +62,9 @@ export class DialogEnergySolarSettings
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units;
this._power_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
).units;
this._excludeList = this._params.solar_sources
.map((entry) => entry.stat_energy_from)
.filter((id) => id !== this._source?.stat_energy_from);
this._excludeListPower = this._params.solar_sources
.map((entry) => entry.stat_rate)
.filter((id) => id && id !== this._source?.stat_rate) as string[];
}
public closeDialog() {
@@ -92,6 +81,8 @@ export class DialogEnergySolarSettings
return nothing;
}
const pickableUnit = this._energy_units?.join(", ") || "";
return html`
<ha-dialog
open
@@ -103,6 +94,12 @@ export class DialogEnergySolarSettings
@closed=${this.closeDialog}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<div>
${this.hass.localize(
"ui.panel.config.energy.solar.dialog.entity_para",
{ unit: pickableUnit }
)}
</div>
<ha-statistic-picker
.hass=${this.hass}
@@ -114,28 +111,9 @@ export class DialogEnergySolarSettings
)}
.excludeStatistics=${this._excludeList}
@value-changed=${this._statisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.solar.dialog.entity_para",
{ unit: this._energy_units?.join(", ") || "" }
)}
dialogInitialFocus
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${powerUnitClasses}
.value=${this._source.stat_rate}
.label=${this.hass.localize(
"ui.panel.config.energy.solar.dialog.solar_production_power"
)}
.excludeStatistics=${this._excludeListPower}
@value-changed=${this._powerStatisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.solar.dialog.entity_para",
{ unit: this._power_units?.join(", ") || "" }
)}
></ha-statistic-picker>
<h3>
${this.hass.localize(
"ui.panel.config.energy.solar.dialog.solar_production_forecast"
@@ -289,10 +267,6 @@ export class DialogEnergySolarSettings
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
}
private _powerStatisticChanged(ev: CustomEvent<{ value: string }>) {
this._source = { ...this._source!, stat_rate: ev.detail.value };
}
private async _save() {
try {
if (!this._forecast) {
@@ -313,10 +287,6 @@ export class DialogEnergySolarSettings
ha-dialog {
--mdc-dialog-max-width: 430px;
}
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
img {
height: 24px;
margin-right: 16px;

View File

@@ -7,7 +7,6 @@ import type {
FlowFromGridSourceEnergyPreference,
FlowToGridSourceEnergyPreference,
GasSourceTypeEnergyPreference,
GridPowerSourceEnergyPreference,
GridSourceTypeEnergyPreference,
SolarSourceTypeEnergyPreference,
WaterSourceTypeEnergyPreference,
@@ -42,12 +41,6 @@ export interface EnergySettingsGridFlowToDialogParams {
saveCallback: (source: FlowToGridSourceEnergyPreference) => Promise<void>;
}
export interface EnergySettingsGridPowerDialogParams {
source?: GridPowerSourceEnergyPreference;
grid_source?: GridSourceTypeEnergyPreference;
saveCallback: (source: GridPowerSourceEnergyPreference) => Promise<void>;
}
export interface EnergySettingsSolarDialogParams {
info: EnergyInfo;
source?: SolarSourceTypeEnergyPreference;
@@ -159,14 +152,3 @@ export const showEnergySettingsGridFlowToDialog = (
dialogParams: { ...dialogParams, direction: "to" },
});
};
export const showEnergySettingsGridPowerDialog = (
element: HTMLElement,
dialogParams: EnergySettingsGridPowerDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-energy-grid-power-settings",
dialogImport: () => import("./dialog-energy-grid-power-settings"),
dialogParams: dialogParams,
});
};

View File

@@ -426,10 +426,6 @@ class DialogZHAReconfigureDevice extends LitElement {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-max-width: 800px;
}
.wrapper {
display: grid;
grid-template-columns: 3fr 1fr 2fr;

View File

@@ -360,20 +360,6 @@ export class HaConfigLovelaceDashboards extends LitElement {
});
}
if (this.hass.panels.home) {
result.push({
icon: this.hass.panels.home.icon || "mdi:home",
title: this.hass.localize("panel.home"),
show_in_sidebar: true,
mode: "storage",
url_path: "home",
filename: "",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
result.push(
...dashboards
.sort((a, b) =>
@@ -484,18 +470,13 @@ export class HaConfigLovelaceDashboards extends LitElement {
}
private _canDelete(urlPath: string) {
return ![
"lovelace",
"energy",
"light",
"security",
"climate",
"home",
].includes(urlPath);
return !["lovelace", "energy", "light", "security", "climate"].includes(
urlPath
);
}
private _canEdit(urlPath: string) {
return !["light", "security", "climate", "home"].includes(urlPath);
return !["light", "security", "climate"].includes(urlPath);
}
private _handleDelete = async (item: DataTableItem) => {

View File

@@ -65,15 +65,13 @@ export default class HaScriptFieldRow extends LitElement {
private _selectorRowElement?: HaAutomationRow;
protected render() {
const hasSelector =
this.field.selector && typeof this.field.selector === "object";
return html`
<ha-card outlined>
<ha-automation-row
.disabled=${this.disabled}
@click=${this._toggleSidebar}
.selected=${this._selected}
.leftChevron=${hasSelector}
left-chevron
@toggle-collapsed=${this._toggleCollapse}
.collapsed=${this._collapsed}
.highlight=${this.highlight}
@@ -142,124 +140,117 @@ export default class HaScriptFieldRow extends LitElement {
<slot name="icons" slot="icons"></slot>
</ha-automation-row>
</ha-card>
${hasSelector
? html`
<div
class=${classMap({
"selector-row": true,
"parent-selected": this._selected,
hidden: this._collapsed,
})}
<div
class=${classMap({
"selector-row": true,
"parent-selected": this._selected,
hidden: this._collapsed,
})}
>
<ha-card>
<ha-automation-row
.selected=${this._selectorRowSelected}
@click=${this._toggleSelectorSidebar}
.collapsed=${this._selectorRowCollapsed}
@toggle-collapsed=${this._toggleSelectorRowCollapse}
.leftChevron=${SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
Object.keys(this.field.selector)[0]
)}
.highlight=${this.highlight}
>
<h3 slot="header">
${this.hass.localize(
`ui.components.selectors.selector.types.${Object.keys(this.field.selector)[0]}` as LocalizeKeys
)}
${this.hass.localize(
"ui.panel.config.script.editor.field.selector"
)}
</h3>
<ha-md-button-menu
quick
slot="icons"
@click=${preventDefaultStopPropagation}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
anchor-corner="end-end"
menu-corner="start-end"
>
<ha-card>
<ha-automation-row
.selected=${this._selectorRowSelected}
@click=${this._toggleSelectorSidebar}
.collapsed=${this._selectorRowCollapsed}
@toggle-collapsed=${this._toggleSelectorRowCollapse}
.leftChevron=${SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
Object.keys(this.field.selector)[0]
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
selector-row
>
<ha-svg-icon
slot="start"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
.highlight=${this.highlight}
>
<h3 slot="header">
${this.hass.localize(
`ui.components.selectors.selector.types.${Object.keys(this.field.selector)[0]}` as LocalizeKeys
)}
${this.hass.localize(
"ui.panel.config.script.editor.field.selector"
)}
</h3>
<ha-md-button-menu
quick
slot="icons"
@click=${preventDefaultStopPropagation}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
anchor-corner="end-end"
menu-corner="start-end"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
selector-row
>
<ha-svg-icon
slot="start"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<span
class="shortcut-placeholder ${isMac ? "mac" : ""}"
></span>
</div>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._onDelete}
.disabled=${this.disabled}
class="warning"
>
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
${!this.narrow
? html`<span class="shortcut">
<span
class="shortcut-placeholder ${isMac ? "mac" : ""}"
></span>
</div>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._onDelete}
.disabled=${this.disabled}
class="warning"
>
<ha-svg-icon
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
${!this.narrow
? html`<span class="shortcut">
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
"ui.panel.config.automation.editor.ctrl"
)}</span
>
<span>+</span>
<span
>${this.hass.localize(
"ui.panel.config.automation.editor.del"
)}</span
>
</span>`
: nothing}
</div>
</ha-md-menu-item>
</ha-md-button-menu>
</ha-automation-row>
</ha-card>
${typeof this.field.selector === "object" &&
SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
Object.keys(this.field.selector)[0]
)
? html`
<ha-script-field-selector-editor
class=${this._selectorRowCollapsed ? "hidden" : ""}
.selected=${this._selectorRowSelected}
.hass=${this.hass}
.field=${this.field}
.disabled=${this.disabled}
indent
@value-changed=${this._selectorValueChanged}
.narrow=${this.narrow}
></ha-script-field-selector-editor>
`
: nothing}
</div>
`
: nothing}
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
"ui.panel.config.automation.editor.ctrl"
)}</span
>
<span>+</span>
<span
>${this.hass.localize(
"ui.panel.config.automation.editor.del"
)}</span
>
</span>`
: nothing}
</div>
</ha-md-menu-item>
</ha-md-button-menu>
</ha-automation-row>
</ha-card>
${typeof this.field.selector === "object" &&
SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
Object.keys(this.field.selector)[0]
)
? html`
<ha-script-field-selector-editor
class=${this._selectorRowCollapsed ? "hidden" : ""}
.selected=${this._selectorRowSelected}
.hass=${this.hass}
.field=${this.field}
.disabled=${this.disabled}
indent
@value-changed=${this._selectorValueChanged}
.narrow=${this.narrow}
></ha-script-field-selector-editor>
`
: nothing}
</div>
`;
}

View File

@@ -1,144 +0,0 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
import type { HomeAssistant, PanelInfo, Route } from "../../types";
import "../lovelace/hui-root";
import { generateLovelaceDashboardStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import { showAlertDialog } from "../lovelace/custom-card-helpers";
const HOME_LOVELACE_CONFIG: LovelaceDashboardStrategyConfig = {
strategy: {
type: "home",
},
};
@customElement("ha-panel-home")
class PanelHome extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public route?: Route;
@property({ attribute: false }) public panel?: PanelInfo;
@state() private _lovelace?: Lovelace;
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
if (!this._lovelace) {
return nothing;
}
return html`
<hui-root
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.route=${this.route}
.panel=${this.panel}
></hui-root>
`;
}
private async _setLovelace() {
const config = await generateLovelaceDashboardStrategy(
HOME_LOVELACE_CONFIG,
this.hass
);
if (deepEqual(config, this._lovelace?.config)) {
return;
}
this._lovelace = {
config: config,
rawConfig: config,
editMode: false,
urlPath: "home",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: this._setEditMode,
showToast: () => undefined,
};
}
private _setEditMode = () => {
// For now, we just show an alert that edit mode is not supported.
// This will be expanded in the future.
showAlertDialog(this, {
title: "Edit mode not available",
text: "The Home panel does not support edit mode.",
confirmText: this.hass.localize("ui.common.ok"),
});
};
static readonly styles: CSSResultGroup = css`
:host {
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-panel-home": PanelHome;
}
}

View File

@@ -5,7 +5,10 @@ import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-svg-icon";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import {
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import { checkConditionsMet } from "../common/validate-condition";
import { createBadgeElement } from "../create-element/create-badge-element";
import { createErrorBadgeConfig } from "../create-element/create-element-base";
@@ -19,9 +22,7 @@ declare global {
}
@customElement("hui-badge")
export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
ReactiveElement
) {
export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
@property({ type: Boolean }) public preview = false;
@property({ attribute: false }) public config?: LovelaceBadgeConfig;
@@ -52,7 +53,7 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
this._updateVisibility();
}
protected _updateElement(config: LovelaceBadgeConfig) {
private _updateElement(config: LovelaceBadgeConfig) {
if (!this._element) {
return;
}
@@ -132,7 +133,22 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
}
}
protected _updateVisibility(conditionsMet?: boolean) {
protected setupConditionalListeners() {
if (!this.config?.visibility || !this.hass) {
return;
}
setupMediaQueryListeners(
this.config.visibility,
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateVisibility(conditionsMet);
}
);
}
private _updateVisibility(ignoreConditions?: boolean) {
if (!this._element || !this.hass) {
return;
}
@@ -153,9 +169,9 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
}
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(this.config.visibility, this.hass));
ignoreConditions ||
!this.config?.visibility ||
checkConditionsMet(this.config.visibility, this.hass);
this._setElementVisibility(visible);
}

View File

@@ -16,10 +16,8 @@ import {
import type {
BarSeriesOption,
CallbackDataParams,
LineSeriesOption,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { LineDataItemOption } from "echarts/types/src/chart/line/LineSeries";
import type { FrontendLocaleData } from "../../../../../data/translation";
import { formatNumber } from "../../../../../common/number/format_number";
import {
@@ -172,10 +170,11 @@ function formatTooltip(
compare
? `${(showCompareYear ? formatDateShort : formatDateVeryShort)(date, locale, config)}: `
: ""
}${formatTime(date, locale, config)}`;
if (params[0].componentSubType === "bar") {
period += ` ${formatTime(addHours(date, 1), locale, config)}`;
}
}${formatTime(date, locale, config)} ${formatTime(
addHours(date, 1),
locale,
config
)}`;
}
const title = `<h4 style="text-align: center; margin: 0;">${period}</h4>`;
@@ -282,35 +281,6 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
});
}
export function fillLineGaps(datasets: LineSeriesOption[]) {
const buckets = Array.from(
new Set(
datasets
.map((dataset) =>
dataset.data!.map((datapoint) => Number(datapoint![0]))
)
.flat()
)
).sort((a, b) => a - b);
buckets.forEach((bucket, index) => {
for (let i = datasets.length - 1; i >= 0; i--) {
const dataPoint = datasets[i].data![index];
const item: LineDataItemOption =
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
? dataPoint
: ({ value: dataPoint } as LineDataItemOption);
const x = item.value?.[0];
if (x === undefined) {
continue;
}
if (Number(x) !== bucket) {
datasets[i].data?.splice(index, 0, [bucket, 0]);
}
}
});
return datasets;
}
export function getCompareTransform(start: Date, compareStart?: Date) {
if (!compareStart) {
return (ts: Date) => ts;

View File

@@ -1,335 +0,0 @@
import { endOfToday, isToday, startOfToday } from "date-fns";
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { LineSeriesOption } from "echarts/charts";
import { graphic } from "echarts";
import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy";
import { getEnergyDataCollection } from "../../../../data/energy";
import type { StatisticValue } from "../../../../data/recorder";
import type { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { PowerSourcesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions, fillLineGaps } from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import { hex2rgb } from "../../../../common/color/convert-color";
@customElement("hui-power-sources-graph-card")
export class HuiPowerSourcesGraphCard
extends SubscribeMixin(LitElement)
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: PowerSourcesGraphCardConfig;
@state() private _chartData: LineSeriesOption[] = [];
@state() private _start = startOfToday();
@state() private _end = endOfToday();
@state() private _compareStart?: Date;
@state() private _compareEnd?: Date;
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => this._getStatistics(data)),
];
}
public getCardSize(): Promise<number> | number {
return 3;
}
public setConfig(config: PowerSourcesGraphCardConfig): void {
this._config = config;
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
return (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass")
);
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
return html`
<ha-card>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: ""}
<div
class="content ${classMap({
"has-header": !!this._config.title,
})}"
>
<ha-chart-base
.hass=${this.hass}
.data=${this._chartData}
.options=${this._createOptions(
this._start,
this._end,
this.hass.locale,
this.hass.config,
this._compareStart,
this._compareEnd
)}
></ha-chart-base>
${!this._chartData.some((dataset) => dataset.data!.length)
? html`<div class="no-data">
${isToday(this._start)
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
: this.hass.localize(
"ui.panel.lovelace.cards.energy.no_data_period"
)}
</div>`
: nothing}
</div>
</ha-card>
`;
}
private _createOptions = memoizeOne(
(
start: Date,
end: Date,
locale: FrontendLocaleData,
config: HassConfig,
compareStart?: Date,
compareEnd?: Date
): ECOption =>
getCommonOptions(
start,
end,
locale,
config,
"kW",
compareStart,
compareEnd
)
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
const datasets: LineSeriesOption[] = [];
const statIds = {
solar: {
stats: [] as string[],
color: "--energy-solar-color",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.power_graph.solar"
),
},
grid: {
stats: [] as string[],
color: "--energy-grid-consumption-color",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.power_graph.grid"
),
},
battery: {
stats: [] as string[],
color: "--energy-battery-out-color",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.power_graph.battery"
),
},
};
const computedStyles = getComputedStyle(this);
for (const source of energyData.prefs.energy_sources) {
if (source.type === "solar") {
if (source.stat_rate) {
statIds.solar.stats.push(source.stat_rate);
}
continue;
}
if (source.type === "battery") {
if (source.stat_rate) {
statIds.battery.stats.push(source.stat_rate);
}
continue;
}
if (source.type === "grid" && source.power) {
statIds.grid.stats.push(...source.power.map((p) => p.stat_rate));
}
}
const commonSeriesOptions: LineSeriesOption = {
type: "line",
smooth: 0.4,
smoothMonotone: "x",
lineStyle: {
width: 1,
},
};
Object.keys(statIds).forEach((key, keyIndex) => {
if (statIds[key].stats.length) {
const colorHex = computedStyles.getPropertyValue(statIds[key].color);
const rgb = hex2rgb(colorHex);
// Echarts is supposed to handle that but it is bugged when you use it together with stacking.
// The interpolation breaks the stacking, so this positive/negative is a workaround
const { positive, negative } = this._processData(
statIds[key].stats.map((id: string) => energyData.stats[id] ?? [])
);
datasets.push({
...commonSeriesOptions,
id: key,
name: statIds[key].name,
color: colorHex,
stack: "positive",
areaStyle: {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
},
{
offset: 1,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
},
]),
},
data: positive,
z: 3 - keyIndex, // draw in reverse order so 0 value lines are overwritten
});
if (key !== "solar") {
datasets.push({
...commonSeriesOptions,
id: `${key}-negative`,
name: statIds[key].name,
color: colorHex,
stack: "negative",
areaStyle: {
color: new graphic.LinearGradient(0, 1, 0, 0, [
{
offset: 0,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
},
{
offset: 1,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
},
]),
},
data: negative,
z: 4 - keyIndex, // draw in reverse order but above positive series
});
}
}
});
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._chartData = fillLineGaps(datasets);
const usageData: NonNullable<LineSeriesOption["data"]> = [];
this._chartData[0]?.data!.forEach((item, i) => {
// fillLineGaps ensures all datasets have the same x values
const x =
typeof item === "object" && "value" in item!
? item.value![0]
: item![0];
usageData[i] = [x, 0];
this._chartData.forEach((dataset) => {
const y =
typeof dataset.data![i] === "object" && "value" in dataset.data![i]!
? dataset.data![i].value![1]
: dataset.data![i]![1];
usageData[i]![1] += y as number;
});
});
this._chartData.push({
...commonSeriesOptions,
id: "usage",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.power_graph.usage"
),
color: computedStyles.getPropertyValue("--primary-color"),
lineStyle: { width: 2 },
data: usageData,
z: 5,
});
}
private _processData(stats: StatisticValue[][]) {
const data: Record<number, number[]> = {};
stats.forEach((statSet) => {
statSet.forEach((point) => {
if (point.mean == null) {
return;
}
const x = (point.start + point.end) / 2;
data[x] = [...(data[x] ?? []), point.mean];
});
});
const positive: [number, number][] = [];
const negative: [number, number][] = [];
Object.entries(data).forEach(([x, y]) => {
const ts = Number(x);
const meanY = y.reduce((a, b) => a + b, 0) / y.length;
positive.push([ts, Math.max(0, meanY)]);
negative.push([ts, Math.min(0, meanY)]);
});
return { positive, negative };
}
static styles = css`
ha-card {
height: 100%;
}
.card-header {
padding-bottom: 0;
}
.content {
padding: var(--ha-space-4);
}
.has-header {
padding-top: 0;
}
.no-data {
position: absolute;
height: 100%;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
padding: 20%;
margin-left: var(--ha-space-8);
margin-inline-start: var(--ha-space-8);
margin-inline-end: initial;
box-sizing: border-box;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-power-sources-graph-card": HuiPowerSourcesGraphCard;
}
}

View File

@@ -1,3 +1,4 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { classMap } from "lit/directives/class-map";
@@ -7,8 +8,16 @@ import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_elemen
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-card";
import type { Calendar, CalendarEvent } from "../../../data/calendar";
import { fetchCalendarEvents } from "../../../data/calendar";
import type {
Calendar,
CalendarEvent,
CalendarEventSubscription,
CalendarEventApiData,
} from "../../../data/calendar";
import {
normalizeSubscriptionEventData,
subscribeCalendarEvents,
} from "../../../data/calendar";
import type {
CalendarViewChanged,
FullCalendarView,
@@ -65,12 +74,16 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
@state() private _error?: string = undefined;
@state() private _errorCalendars: string[] = [];
private _startDate?: Date;
private _endDate?: Date;
private _resizeObserver?: ResizeObserver;
private _unsubs: Record<string, Promise<UnsubscribeFunc>> = {};
public setConfig(config: CalendarCardConfig): void {
if (!config.entities?.length) {
throw new Error("Entities must be specified");
@@ -86,7 +99,8 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
}));
if (this._config?.entities !== config.entities) {
this._fetchCalendarEvents();
this._unsubscribeAll();
// Subscription will happen when view-changed event fires
}
this._config = { initial_view: "dayGridMonth", ...config };
@@ -115,6 +129,7 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
this._unsubscribeAll();
}
protected render() {
@@ -170,31 +185,85 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
}
}
private _handleViewChanged(ev: HASSDomEvent<CalendarViewChanged>): void {
private async _handleViewChanged(
ev: HASSDomEvent<CalendarViewChanged>
): Promise<void> {
this._startDate = ev.detail.start;
this._endDate = ev.detail.end;
this._fetchCalendarEvents();
await this._unsubscribeAll();
this._subscribeCalendarEvents();
}
private async _fetchCalendarEvents(): Promise<void> {
if (!this._startDate || !this._endDate) {
private _subscribeCalendarEvents(): void {
if (!this.hass || !this._startDate || !this._endDate) {
return;
}
this._error = undefined;
const result = await fetchCalendarEvents(
this.hass!,
this._startDate,
this._endDate,
this._calendars
);
this._events = result.events;
if (result.errors.length > 0) {
this._calendars.forEach((calendar) => {
const unsub = subscribeCalendarEvents(
this.hass!,
calendar.entity_id,
this._startDate!,
this._endDate!,
(update: CalendarEventSubscription) => {
this._handleCalendarUpdate(calendar, update);
}
);
this._unsubs[calendar.entity_id] = unsub;
});
}
private _handleCalendarUpdate(
calendar: Calendar,
update: CalendarEventSubscription
): void {
// Remove events from this calendar
this._events = this._events.filter(
(event) => event.calendar !== calendar.entity_id
);
if (update.events === null) {
// Error fetching events
if (!this._errorCalendars.includes(calendar.entity_id)) {
this._errorCalendars = [...this._errorCalendars, calendar.entity_id];
}
this._error = `${this.hass!.localize(
"ui.components.calendar.event_retrieval_error"
)}`;
return;
}
// Remove from error list if successfully loaded
this._errorCalendars = this._errorCalendars.filter(
(id) => id !== calendar.entity_id
);
if (this._errorCalendars.length === 0) {
this._error = undefined;
}
// Add new events from this calendar
const newEvents: CalendarEvent[] = update.events
.map((eventData: CalendarEventApiData) =>
normalizeSubscriptionEventData(eventData, calendar)
)
.filter((event): event is CalendarEvent => event !== null);
this._events = [...this._events, ...newEvents];
}
private async _unsubscribeAll(): Promise<void> {
await Promise.all(
Object.values(this._unsubs).map((unsub) =>
unsub
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
})
)
);
this._unsubs = {};
}
private _measureCard() {

View File

@@ -5,7 +5,10 @@ import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-svg-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import {
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
import { computeCardSize } from "../common/compute-card-size";
import { checkConditionsMet } from "../common/validate-condition";
@@ -21,9 +24,7 @@ declare global {
}
@customElement("hui-card")
export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
ReactiveElement
) {
export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
@property({ type: Boolean }) public preview = false;
@property({ attribute: false }) public config?: LovelaceCardConfig;
@@ -120,7 +121,7 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
return {};
}
protected _updateElement(config: LovelaceCardConfig) {
private _updateElement(config: LovelaceCardConfig) {
if (!this._element) {
return;
}
@@ -246,7 +247,22 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
}
}
protected _updateVisibility(conditionsMet?: boolean) {
protected setupConditionalListeners() {
if (!this.config?.visibility || !this.hass) {
return;
}
setupMediaQueryListeners(
this.config.visibility,
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateVisibility(conditionsMet);
}
);
}
private _updateVisibility(ignoreConditions?: boolean) {
if (!this._element || !this.hass) {
return;
}
@@ -267,9 +283,9 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
}
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(this.config.visibility, this.hass));
ignoreConditions ||
!this.config?.visibility ||
checkConditionsMet(this.config.visibility, this.hass);
this._setElementVisibility(visible);
}

View File

@@ -230,11 +230,6 @@ export interface EnergySankeyCardConfig extends EnergyCardBaseConfig {
group_by_area?: boolean;
}
export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
type: "power-sources-graph";
title?: string;
}
export interface EntityFilterCardConfig extends LovelaceCardConfig {
type: "entity-filter";
entities: (EntityFilterEntityConfig | string)[];

View File

@@ -1,7 +1,6 @@
import {
mdiAccount,
mdiAmpersand,
mdiCalendarClock,
mdiGateOr,
mdiMapMarker,
mdiNotEqualVariant,
@@ -16,7 +15,6 @@ export const ICON_CONDITION: Record<Condition["condition"], string> = {
numeric_state: mdiNumeric,
state: mdiStateMachine,
screen: mdiResponsive,
time: mdiCalendarClock,
user: mdiAccount,
and: mdiAmpersand,
not: mdiNotEqualVariant,

View File

@@ -1,23 +1,15 @@
import type { HomeAssistant } from "../../../types";
import { ensureArray } from "../../../common/array/ensure-array";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { UNKNOWN } from "../../../data/entity";
import { getUserPerson } from "../../../data/person";
import { ensureArray } from "../../../common/array/ensure-array";
import {
checkTimeInRange,
isValidTimeString,
} from "../../../common/datetime/check_time";
import {
WEEKDAYS_SHORT,
type WeekdayShort,
} from "../../../common/datetime/weekday";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import type { HomeAssistant } from "../../../types";
export type Condition =
| LocationCondition
| NumericStateCondition
| StateCondition
| ScreenCondition
| TimeCondition
| UserCondition
| OrCondition
| AndCondition
@@ -58,13 +50,6 @@ export interface ScreenCondition extends BaseCondition {
media_query?: string;
}
export interface TimeCondition extends BaseCondition {
condition: "time";
after?: string;
before?: string;
weekdays?: WeekdayShort[];
}
export interface UserCondition extends BaseCondition {
condition: "user";
users?: string[];
@@ -165,13 +150,6 @@ function checkScreenCondition(condition: ScreenCondition, _: HomeAssistant) {
: false;
}
function checkTimeCondition(
condition: Omit<TimeCondition, "condition">,
hass: HomeAssistant
) {
return checkTimeInRange(hass, condition);
}
function checkLocationCondition(
condition: LocationCondition,
hass: HomeAssistant
@@ -217,8 +195,6 @@ export function checkConditionsMet(
return conditions.every((c) => {
if ("condition" in c) {
switch (c.condition) {
case "time":
return checkTimeCondition(c, hass);
case "screen":
return checkScreenCondition(c, hass);
case "user":
@@ -295,35 +271,6 @@ function validateScreenCondition(condition: ScreenCondition) {
return condition.media_query != null;
}
function validateTimeCondition(condition: TimeCondition) {
// Check if time strings are present and non-empty
const hasAfter = condition.after != null && condition.after !== "";
const hasBefore = condition.before != null && condition.before !== "";
const hasTime = hasAfter || hasBefore;
const hasWeekdays =
condition.weekdays != null && condition.weekdays.length > 0;
const weekdaysValid =
!hasWeekdays ||
condition.weekdays!.every((w: WeekdayShort) => WEEKDAYS_SHORT.includes(w));
// Validate time string formats if present
const timeStringsValid =
(!hasAfter || isValidTimeString(condition.after!)) &&
(!hasBefore || isValidTimeString(condition.before!));
// Prevent after and before being identical (creates zero-length interval)
const timeRangeValid =
!hasAfter || !hasBefore || condition.after !== condition.before;
return (
(hasTime || hasWeekdays) &&
weekdaysValid &&
timeStringsValid &&
timeRangeValid
);
}
function validateUserCondition(condition: UserCondition) {
return condition.users != null;
}
@@ -363,8 +310,6 @@ export function validateConditionalConfig(
switch (c.condition) {
case "screen":
return validateScreenCondition(c);
case "time":
return validateTimeCondition(c);
case "user":
return validateUserCondition(c);
case "location":

View File

@@ -2,7 +2,10 @@ import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import {
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import type { HuiCard } from "../cards/hui-card";
import type { ConditionalCardConfig } from "../cards/types";
import type { Condition } from "../common/validate-condition";
@@ -19,9 +22,9 @@ declare global {
}
@customElement("hui-conditional-base")
export class HuiConditionalBase extends ConditionalListenerMixin<
ConditionalCardConfig | ConditionalRowConfig
>(ReactiveElement) {
export class HuiConditionalBase extends ConditionalListenerMixin(
ReactiveElement
) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public preview = false;
@@ -70,13 +73,18 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
return;
}
// Filter to supported conditions (those with 'condition' property)
const supportedConditions = this._config.conditions.filter(
(c) => "condition" in c
) as Condition[];
// Pass filtered conditions to parent implementation
super.setupConditionalListeners(supportedConditions);
setupMediaQueryListeners(
supportedConditions,
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this.setVisibility(conditionsMet);
}
);
}
protected update(changed: PropertyValues): void {
@@ -94,15 +102,17 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
}
}
protected _updateVisibility(conditionsMet?: boolean) {
private _updateVisibility() {
if (!this._element || !this.hass || !this._config) {
return;
}
this._element.preview = this.preview;
const conditionMet =
conditionsMet ?? checkConditionsMet(this._config.conditions, this.hass);
const conditionMet = checkConditionsMet(
this._config!.conditions,
this.hass!
);
this.setVisibility(conditionMet);
}

View File

@@ -66,8 +66,6 @@ const LAZY_LOAD_TYPES = {
"energy-usage-graph": () =>
import("../cards/energy/hui-energy-usage-graph-card"),
"energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"),
"power-sources-graph": () =>
import("../cards/energy/hui-power-sources-graph-card"),
"entity-filter": () => import("../cards/hui-entity-filter-card"),
error: () => import("../cards/hui-error-card"),
"home-summary": () => import("../cards/hui-home-summary-card"),

View File

@@ -25,7 +25,6 @@ import "./types/ha-card-condition-numeric_state";
import "./types/ha-card-condition-or";
import "./types/ha-card-condition-screen";
import "./types/ha-card-condition-state";
import "./types/ha-card-condition-time";
import "./types/ha-card-condition-user";
import { storage } from "../../../../common/decorators/storage";
@@ -34,7 +33,6 @@ const UI_CONDITION = [
"numeric_state",
"state",
"screen",
"time",
"user",
"and",
"not",

View File

@@ -1,102 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import {
literal,
array,
object,
optional,
string,
assert,
enums,
} from "superstruct";
import memoizeOne from "memoize-one";
import type { HomeAssistant } from "../../../../../types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import {
WEEKDAY_SHORT_TO_LONG,
WEEKDAYS_SHORT,
} from "../../../../../common/datetime/weekday";
import type { TimeCondition } from "../../../common/validate-condition";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../../components/ha-form/types";
import "../../../../../components/ha-form/ha-form";
const timeConditionStruct = object({
condition: literal("time"),
after: optional(string()),
before: optional(string()),
weekdays: optional(array(enums(WEEKDAYS_SHORT))),
});
@customElement("ha-card-condition-time")
export class HaCardConditionTime extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: TimeCondition;
@property({ type: Boolean }) public disabled = false;
public static get defaultConfig(): TimeCondition {
return { condition: "time", after: "08:00", before: "17:00" };
}
protected static validateUIConfig(condition: TimeCondition) {
return assert(condition, timeConditionStruct);
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{ name: "after", selector: { time: { no_second: true } } },
{ name: "before", selector: { time: { no_second: true } } },
{
name: "weekdays",
selector: {
select: {
mode: "list",
multiple: true,
options: WEEKDAYS_SHORT.map((day) => ({
value: day,
label: localize(`ui.weekdays.${WEEKDAY_SHORT_TO_LONG[day]}`),
})),
},
},
},
] as const satisfies HaFormSchema[]
);
protected render() {
return html`
<ha-form
.hass=${this.hass}
.data=${this.condition}
.computeLabel=${this._computeLabelCallback}
.schema=${this._schema(this.hass.localize)}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const data = ev.detail.value as TimeCondition;
fireEvent(this, "value-changed", { value: data });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.condition.time.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"ha-card-condition-time": HaCardConditionTime;
}
}

View File

@@ -22,7 +22,6 @@ const NON_STANDARD_URLS = {
"energy-devices-graph": "energy/#devices-energy-graph",
"energy-devices-detail-graph": "energy/#detail-devices-energy-graph",
"energy-sankey": "energy/#sankey-energy-graph",
"power-sources-graph": "energy/#power-sources-graph",
};
export const getCardDocumentationURL = (

View File

@@ -4,7 +4,10 @@ import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import {
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import { checkConditionsMet } from "../common/validate-condition";
import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element";
import type { LovelaceHeadingBadge } from "../types";
@@ -18,9 +21,7 @@ declare global {
}
@customElement("hui-heading-badge")
export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBadgeConfig>(
ReactiveElement
) {
export class HuiHeadingBadge extends ConditionalListenerMixin(ReactiveElement) {
@property({ type: Boolean }) public preview = false;
@property({ attribute: false }) public config?: LovelaceHeadingBadgeConfig;
@@ -51,7 +52,7 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
this._updateVisibility();
}
protected _updateElement(config: LovelaceHeadingBadgeConfig) {
private _updateElement(config: LovelaceHeadingBadgeConfig) {
if (!this._element) {
return;
}
@@ -132,7 +133,22 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
}
}
protected _updateVisibility(conditionsMet?: boolean) {
protected setupConditionalListeners() {
if (!this.config?.visibility || !this.hass) {
return;
}
setupMediaQueryListeners(
this.config.visibility,
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateVisibility(conditionsMet);
}
);
}
private _updateVisibility(forceVisible?: boolean) {
if (!this._element || !this.hass) {
return;
}
@@ -142,20 +158,11 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
return;
}
if (this.preview) {
this._setElementVisibility(true);
return;
}
if (this.config?.disabled) {
this._setElementVisibility(false);
return;
}
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(this.config.visibility, this.hass));
forceVisible ||
this.preview ||
!this.config?.visibility ||
checkConditionsMet(this.config.visibility, this.hass);
this._setElementVisibility(visible);
}

View File

@@ -13,7 +13,10 @@ import type {
} from "../../../data/lovelace/config/section";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import {
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import "../cards/hui-card";
import type { HuiCard } from "../cards/hui-card";
import { checkConditionsMet } from "../common/validate-condition";
@@ -34,9 +37,7 @@ declare global {
}
@customElement("hui-section")
export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
ReactiveElement
) {
export class HuiSection extends ConditionalListenerMixin(ReactiveElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: LovelaceSectionRawConfig;
@@ -58,6 +59,8 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
private _layoutElement?: LovelaceSectionElement;
private _config: LovelaceSectionConfig | undefined;
@storage({
key: "dashboardCardClipboard",
state: false,
@@ -113,7 +116,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
public connectedCallback() {
super.connectedCallback();
this._updateVisibility();
this._updateElement();
}
protected update(changedProperties) {
@@ -144,11 +147,26 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
this._layoutElement.cards = this._cards;
}
if (changedProperties.has("hass") || changedProperties.has("preview")) {
this._updateVisibility();
this._updateElement();
}
}
}
protected setupConditionalListeners() {
if (!this._config?.visibility || !this.hass) {
return;
}
setupMediaQueryListeners(
this._config.visibility,
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateElement(conditionsMet);
}
);
}
private async _initializeConfig() {
let sectionConfig = { ...this.config };
let isStrategy = false;
@@ -190,11 +208,11 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
while (this.lastChild) {
this.removeChild(this.lastChild);
}
this._updateVisibility();
this._updateElement();
}
}
protected _updateVisibility(conditionsMet?: boolean) {
private _updateElement(ignoreConditions?: boolean) {
if (!this._layoutElement || !this._config) {
return;
}
@@ -210,9 +228,9 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
}
const visible =
conditionsMet ??
(!this._config.visibility ||
checkConditionsMet(this._config.visibility, this.hass));
ignoreConditions ||
!this._config.visibility ||
checkConditionsMet(this._config.visibility, this.hass);
this._setElementVisibility(visible);
}

View File

@@ -168,27 +168,6 @@ export class HomeAreaViewStrategy extends ReactiveElement {
const summaryEntities = Object.values(entitiesBySummary).flat();
// Scenes section
const sceneFilter = generateEntityFilter(hass, {
domain: "scene",
entity_category: "none",
});
const scenes = areaEntities.filter(sceneFilter);
if (scenes.length > 0) {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
hass.localize("ui.panel.lovelace.strategy.home.scenes"),
"mdi:palette",
"/config/scene/dashboard"
),
...scenes.map(computeTileCard),
],
});
}
// Automations section
const automationFilter = generateEntityFilter(hass, {
domain: "automation",
@@ -199,9 +178,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
// Rest of entities grouped by device
const otherEntities = areaEntities.filter(
(entityId) =>
!summaryEntities.includes(entityId) &&
!scenes.includes(entityId) &&
!automations.includes(entityId)
!summaryEntities.includes(entityId) && !automations.includes(entityId)
);
const entitiesByDevice: Record<string, string[]> = {};

View File

@@ -109,7 +109,6 @@ export class HUIViewBackground extends LitElement {
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
let applyTheme = false;
if (changedProperties.has("hass") && this.hass) {
const oldHass = changedProperties.get("hass");
if (
@@ -117,18 +116,16 @@ export class HUIViewBackground extends LitElement {
this.hass.themes !== oldHass.themes ||
this.hass.selectedTheme !== oldHass.selectedTheme
) {
applyTheme = true;
this._applyTheme();
return;
}
}
if (changedProperties.has("background")) {
applyTheme = true;
this._applyTheme();
this._fetchMedia();
}
if (changedProperties.has("resolvedImage")) {
applyTheme = true;
}
if (applyTheme) {
this._applyTheme();
}
}

View File

@@ -14,14 +14,12 @@ import memoizeOne from "memoize-one";
import { relativeTime } from "../../common/datetime/relative_time";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-md-button-menu";
import "../../components/ha-md-menu-item";
import "../../components/ha-card";
import "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-label";
import "../../components/ha-list-item";
import "../../components/ha-md-button-menu";
import "../../components/ha-md-menu-item";
import "../../components/ha-settings-row";
import { deleteAllRefreshTokens } from "../../data/auth";
import type { RefreshToken } from "../../data/refresh_token";
@@ -148,18 +146,19 @@ class HaRefreshTokens extends LitElement {
)}
</div>
<div>
<ha-dropdown @wa-select=${this._handleDropdownSelect}>
<ha-md-button-menu positioning="popover">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item
<ha-md-menu-item
graphic="icon"
@click=${this._toggleTokenExpiration}
.token=${token}
.action=${"toggle_expiration"}
>
<ha-svg-icon
slot="icon"
slot="start"
.path=${token.expire_at
? mdiClockRemoveOutline
: mdiClockCheckOutline}
@@ -171,20 +170,24 @@ class HaRefreshTokens extends LitElement {
: this.hass.localize(
"ui.panel.profile.refresh_tokens.enable_token_expiration"
)}
</ha-dropdown-item>
<ha-dropdown-item
.token=${token}
.action=${"delete_token"}
variant="danger"
</ha-md-menu-item>
<ha-md-menu-item
graphic="icon"
class="warning"
.disabled=${token.is_current}
@click=${this._deleteToken}
.token=${token}
>
<ha-svg-icon
slot="icon"
class="warning"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize("ui.common.delete")}
</ha-dropdown-item>
</ha-dropdown>
<div slot="headline">
${this.hass.localize("ui.common.delete")}
</div>
</ha-md-menu-item>
</ha-md-button-menu>
</div>
</ha-settings-row>
`
@@ -207,17 +210,8 @@ class HaRefreshTokens extends LitElement {
`;
}
private _handleDropdownSelect(
ev: CustomEvent<{ item: { action: string; token: RefreshToken } }>
) {
if (ev.detail.item.action === "toggle_expiration") {
this._toggleTokenExpiration(ev.detail.item.token);
} else if (ev.detail.item.action === "delete_token") {
this._deleteToken(ev.detail.item.token);
}
}
private async _toggleTokenExpiration(token: RefreshToken): Promise<void> {
private async _toggleTokenExpiration(ev): Promise<void> {
const token = (ev.currentTarget as any).token as RefreshToken;
const enable = !token.expire_at;
if (!enable) {
if (
@@ -258,7 +252,8 @@ class HaRefreshTokens extends LitElement {
}
}
private async _deleteToken(token: RefreshToken): Promise<void> {
private async _deleteToken(ev): Promise<void> {
const token = (ev.currentTarget as any).token as RefreshToken;
if (
!(await showConfirmationDialog(this, {
title: this.hass.localize(

View File

@@ -1,30 +0,0 @@
import { css } from "lit";
export const animationStyles = css`
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes scale {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
`;

View File

@@ -74,10 +74,6 @@ export const coreColorStyles = css`
--ha-color-green-80: #93da98;
--ha-color-green-90: #c2f2c1;
--ha-color-green-95: #e3f9e3;
/* shadow */
--ha-color-shadow-light: #00000014;
--ha-color-shadow-dark: #00000046;
}
`;

View File

@@ -155,7 +155,6 @@ export const semanticColorStyles = css`
/* Surfaces */
--ha-color-surface-default: var(--ha-color-neutral-95);
--ha-color-on-surface-default: var(--ha-color-neutral-05);
}
`;
@@ -287,6 +286,5 @@ export const darkSemanticColorStyles = css`
/* Surfaces */
--ha-color-surface-default: var(--ha-color-neutral-10);
--ha-color-on-surface-default: var(--ha-color-neutral-95);
}
`;

View File

@@ -52,9 +52,7 @@ export const waColorStyles = css`
--wa-color-danger-on-normal: var(--ha-color-on-danger-normal);
--wa-color-danger-on-quiet: var(--ha-color-on-danger-quiet);
--wa-color-text-normal: var(--ha-color-text-primary);
--wa-color-surface-default: var(--card-background-color);
--wa-color-surface-raised: var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff));
--wa-panel-border-radius: var(--ha-border-radius-3xl);
--wa-panel-border-style: solid;
--wa-panel-border-width: 1px;

View File

@@ -42,27 +42,6 @@ export const coreStyles = css`
--ha-space-18: 72px;
--ha-space-19: 76px;
--ha-space-20: 80px;
--ha-shadow-offset-x-sm: 0;
--ha-shadow-offset-x-md: 0;
--ha-shadow-offset-x-lg: 0;
--ha-shadow-offset-y-sm: 2px;
--ha-shadow-offset-y-md: 4px;
--ha-shadow-offset-y-lg: 8px;
--ha-shadow-blur-sm: 4px;
--ha-shadow-blur-md: 8px;
--ha-shadow-blur-lg: 12px;
--ha-shadow-spread-sm: 0;
--ha-shadow-spread-md: 0;
--ha-shadow-spread-lg: 0;
--ha-animation-base-duration: 350ms;
}
@media (prefers-reduced-motion: reduce) {
html {
--ha-animation-base-duration: 0ms;
}
}
`;

View File

@@ -1,24 +0,0 @@
import { css } from "lit";
import { extractVars } from "../../common/style/derived-css-vars";
/**
* Semantic styles use core styles to define higher level variables like box shadows.
* Here we define all styles except colors
*/
export const semanticStyles = css`
html {
--ha-box-shadow-s: var(--ha-shadow-offset-x-sm) var(--ha-shadow-offset-y-sm) var(--ha-shadow-blur-sm) var(--ha-shadow-spread-sm) var(--ha-color-shadow-light);
--ha-box-shadow-m: var(--ha-shadow-offset-x-md) var(--ha-shadow-offset-y-md) var(--ha-shadow-blur-md) var(--ha-shadow-spread-md) var(--ha-color-shadow-light);
--ha-box-shadow-l: var(--ha-shadow-offset-x-lg) var(--ha-shadow-offset-y-lg) var(--ha-shadow-blur-lg) var(--ha-shadow-spread-lg) var(--ha-color-shadow-light);
}
`;
export const darkSemanticStyles = css`
html {
--ha-box-shadow-s: var(--ha-shadow-offset-x-sm) var(--ha-shadow-offset-y-sm) var(--ha-shadow-blur-sm) var(--ha-shadow-spread-sm) var(--ha-color-shadow-dark);
--ha-box-shadow-m: var(--ha-shadow-offset-x-md) var(--ha-shadow-offset-y-md) var(--ha-shadow-blur-md) var(--ha-shadow-spread-md) var(--ha-color-shadow-dark);
--ha-box-shadow-l: var(--ha-shadow-offset-x-lg) var(--ha-shadow-offset-y-lg) var(--ha-shadow-blur-lg) var(--ha-shadow-spread-lg) var(--ha-color-shadow-dark);
}
`;
export const darkSemanticVariables = extractVars(darkSemanticStyles);

View File

@@ -1,9 +1,7 @@
import { fontStyles } from "../roboto";
import { animationStyles } from "./animations.globals";
import { colorDerivedVariables, colorStylesCollection } from "./color";
import { coreDerivedVariables, coreStyles } from "./core.globals";
import { mainDerivedVariables, mainStyles } from "./main.globals";
import { semanticStyles } from "./semantic.globals";
import {
typographyDerivedVariables,
typographyStyles,
@@ -14,11 +12,9 @@ export const themeStyles = [
coreStyles.toString(),
mainStyles.toString(),
typographyStyles.toString(),
semanticStyles.toString(),
...colorStylesCollection,
fontStyles.toString(),
waMainStyles.toString(),
animationStyles.toString(),
].join("");
export const derivedStyles = {

View File

@@ -9,28 +9,15 @@ export const waMainStyles = css`
--wa-focus-ring-offset: 2px;
--wa-focus-ring: var(--wa-focus-ring-style) var(--wa-focus-ring-width) var(--wa-focus-ring-color);
--wa-space-l: var(--ha-space-6);
--wa-space-xl: var(--ha-space-8);
--wa-space-l: 24px;
--wa-shadow-l: 0 8px 8px -4px rgba(0, 0, 0, 0.2);
--wa-form-control-padding-block: 0.75em;
--wa-font-weight-action: var(--ha-font-weight-medium);
--wa-transition-fast: 75ms;
--wa-transition-easing: ease;
--wa-border-style: solid;
--wa-border-width-s: var(--ha-border-width-sm);
--wa-border-width-m: var(--ha-border-width-md);
--wa-border-width-l: var(--ha-border-width-lg);
--wa-border-radius-s: var(--ha-border-radius-sm);
--wa-border-radius-m: var(--ha-border-radius-md);
--wa-border-radius-l: var(--ha-border-radius-lg);
--wa-line-height-condensed: var(--ha-line-height-condensed);
--wa-shadow-s: var(--ha-box-shadow-s);
--wa-shadow-m: var(--ha-box-shadow-m);
--wa-shadow-l: var(--ha-box-shadow-l);
--wa-border-width-l: var(--ha-border-radius-lg);
--wa-space-xl: 32px;
}
${scrollLockStyles}

View File

@@ -13,8 +13,7 @@
"profile": "Profile",
"light": "Lights",
"security": "Security",
"climate": "Climate",
"home": "Home"
"climate": "Climate"
},
"state": {
"default": {
@@ -3077,15 +3076,6 @@
"grid_carbon_footprint": "Grid carbon footprint",
"remove_co2_signal": "Remove Electricity Maps integration",
"add_co2_signal": "Add Electricity Maps integration",
"grid_power": "Grid power",
"add_power": "Add power sensor",
"edit_power": "Edit power sensor",
"delete_power": "Delete power sensor",
"power_dialog": {
"header": "Configure grid power",
"power_stat": "Power sensor",
"power_helper": "Pick a sensor which measures grid power in either of {unit}. Positive values indicate importing electricity from the grid, negative values indicate exporting electricity to the grid."
},
"flow_dialog": {
"cost_entity_helper": "Any sensor with a unit of `{currency}/(valid energy unit)` (e.g. `{currency}/Wh` or `{currency}/kWh`) may be used and will be automatically converted.",
"from": {
@@ -3133,7 +3123,6 @@
"header": "Configure solar panels",
"entity_para": "Pick a sensor which measures solar energy production in either of {unit}.",
"solar_production_energy": "Solar production energy",
"solar_production_power": "Solar production power",
"solar_production_forecast": "Solar production forecast",
"solar_production_forecast_description": "Adding solar production forecast information will allow you to quickly see your expected production for today.",
"dont_forecast_production": "Don't forecast production",
@@ -3151,12 +3140,9 @@
"add_battery_system": "Add battery system",
"dialog": {
"header": "Configure battery system",
"energy_helper_into": "Pick a sensor that measures the electricity flowing into the battery in either of {unit}.",
"energy_helper_out": "Pick a sensor that measures the electricity flowing out of the battery in either of {unit}.",
"energy_into_battery": "Energy charged into the battery",
"energy_out_of_battery": "Energy discharged from the battery",
"power": "Battery power",
"power_helper": "Pick a sensor which measures the electricity flowing into and out of the battery in either of {unit}. Positive values indicate discharging the battery, negative values indicate charging the battery."
"entity_para": "Pick sensors which measure energy going into and coming out of the battery in either of {unit}.",
"energy_into_battery": "Energy going into the battery",
"energy_out_of_battery": "Energy coming out of the battery"
}
},
"gas": {
@@ -3218,8 +3204,7 @@
"header": "Add a device",
"display_name": "Display name",
"device_consumption_energy": "Device energy consumption",
"device_consumption_power": "Device power consumption",
"selected_stat_intro": "Select the sensor that measures the device's electricity usage in either of {unit}.",
"selected_stat_intro": "Select the energy sensor that measures the device's energy usage in either of {unit}.",
"included_in_device": "Upstream device",
"included_in_device_helper": "If this device is already counted by another device (such as a smart switch measured by a smart breaker), selecting the upstream device prevents duplicate energy tracking.",
"no_upstream_devices": "No eligible upstream devices"
@@ -6998,7 +6983,6 @@
"other_areas": "Other areas",
"unamed_device": "Unnamed device",
"others": "Others",
"scenes": "Scenes",
"automations": "Automations"
},
"common_controls": {
@@ -7161,12 +7145,6 @@
"low_carbon_energy_consumed": "Low-carbon electricity consumed",
"low_carbon_energy_not_calculated": "Consumed low-carbon electricity couldn't be calculated"
},
"power_graph": {
"grid": "Grid",
"solar": "Solar",
"battery": "Battery",
"usage": "Used"
},
"energy_compare": {
"info": "You are comparing the period {start} with the period {end}",
"compare_previous_year": "Compare previous year",
@@ -7584,12 +7562,6 @@
"state_equal": "State is equal to",
"state_not_equal": "State is not equal to"
},
"time": {
"label": "Time",
"after": "After",
"before": "Before",
"weekdays": "Weekdays"
},
"location": {
"label": "Location",
"locations": "Locations",

View File

@@ -1,30 +1,11 @@
import type { TemplateResult } from "lit";
import { render } from "lit";
import { parseAnimationDuration } from "../common/util/parse-animation-duration";
import { withViewTransition } from "../common/util/view-transition";
export const removeLaunchScreen = () => {
const launchScreenElement = document.getElementById("ha-launch-screen");
if (!launchScreenElement?.parentElement) {
return;
if (launchScreenElement) {
launchScreenElement.parentElement!.removeChild(launchScreenElement);
}
withViewTransition((viewTransitionAvailable: boolean) => {
if (!viewTransitionAvailable) {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
return;
}
launchScreenElement.classList.add("removing");
const durationFromCss = getComputedStyle(document.documentElement)
.getPropertyValue("--ha-animation-base-duration")
.trim();
setTimeout(() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
}, parseAnimationDuration(durationFromCss));
});
};
export const renderLaunchScreenInfoBox = (content: TemplateResult) => {

View File

@@ -1,437 +0,0 @@
import { describe, it, expect } from "vitest";
import {
extractMediaQueries,
extractTimeConditions,
} from "../../../src/common/condition/extract";
import type {
Condition,
TimeCondition,
ScreenCondition,
OrCondition,
AndCondition,
NotCondition,
} from "../../../src/panels/lovelace/common/validate-condition";
describe("extractMediaQueries", () => {
it("should extract single media query", () => {
const conditions: Condition[] = [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
];
const result = extractMediaQueries(conditions);
expect(result).toEqual(["(max-width: 600px)"]);
});
it("should extract multiple media queries", () => {
const conditions: Condition[] = [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
{
condition: "screen",
media_query: "(min-width: 1200px)",
} as ScreenCondition,
];
const result = extractMediaQueries(conditions);
expect(result).toEqual(["(max-width: 600px)", "(min-width: 1200px)"]);
});
it("should return empty array when no screen conditions", () => {
const conditions: Condition[] = [
{
condition: "time",
after: "08:00",
} as TimeCondition,
{
condition: "state",
entity: "light.living_room",
state: "on",
},
];
const result = extractMediaQueries(conditions);
expect(result).toEqual([]);
});
it("should ignore screen conditions without media_query", () => {
const conditions: Condition[] = [
{
condition: "screen",
} as ScreenCondition,
];
const result = extractMediaQueries(conditions);
expect(result).toEqual([]);
});
it("should extract from nested or conditions", () => {
const conditions: Condition[] = [
{
condition: "or",
conditions: [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
{
condition: "state",
entity: "light.living_room",
state: "on",
},
],
} as OrCondition,
];
const result = extractMediaQueries(conditions);
expect(result).toEqual(["(max-width: 600px)"]);
});
it("should extract from nested and conditions", () => {
const conditions: Condition[] = [
{
condition: "and",
conditions: [
{
condition: "screen",
media_query: "(orientation: portrait)",
} as ScreenCondition,
{
condition: "time",
after: "08:00",
} as TimeCondition,
],
} as AndCondition,
];
const result = extractMediaQueries(conditions);
expect(result).toEqual(["(orientation: portrait)"]);
});
it("should extract from nested not conditions", () => {
const conditions: Condition[] = [
{
condition: "not",
conditions: [
{
condition: "screen",
media_query: "(prefers-color-scheme: dark)",
} as ScreenCondition,
],
} as NotCondition,
];
const result = extractMediaQueries(conditions);
expect(result).toEqual(["(prefers-color-scheme: dark)"]);
});
it("should extract from deeply nested conditions", () => {
const conditions: Condition[] = [
{
condition: "or",
conditions: [
{
condition: "and",
conditions: [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
{
condition: "not",
conditions: [
{
condition: "screen",
media_query: "(orientation: landscape)",
} as ScreenCondition,
],
} as NotCondition,
],
} as AndCondition,
],
} as OrCondition,
];
const result = extractMediaQueries(conditions);
expect(result).toEqual(["(max-width: 600px)", "(orientation: landscape)"]);
});
it("should handle empty conditions array", () => {
const result = extractMediaQueries([]);
expect(result).toEqual([]);
});
it("should handle mixed conditions with nesting", () => {
const conditions: Condition[] = [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
{
condition: "time",
after: "08:00",
} as TimeCondition,
{
condition: "or",
conditions: [
{
condition: "screen",
media_query: "(min-width: 1200px)",
} as ScreenCondition,
],
} as OrCondition,
];
const result = extractMediaQueries(conditions);
expect(result).toEqual(["(max-width: 600px)", "(min-width: 1200px)"]);
});
});
describe("extractTimeConditions", () => {
it("should extract single time condition", () => {
const conditions: Condition[] = [
{
condition: "time",
after: "08:00",
before: "17:00",
} as TimeCondition,
];
const result = extractTimeConditions(conditions);
expect(result).toEqual([
{
condition: "time",
after: "08:00",
before: "17:00",
},
]);
});
it("should extract multiple time conditions", () => {
const conditions: Condition[] = [
{
condition: "time",
after: "08:00",
} as TimeCondition,
{
condition: "time",
before: "17:00",
weekdays: ["mon", "tue", "wed", "thu", "fri"],
} as TimeCondition,
];
const result = extractTimeConditions(conditions);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ condition: "time", after: "08:00" });
expect(result[1]).toMatchObject({
condition: "time",
before: "17:00",
weekdays: ["mon", "tue", "wed", "thu", "fri"],
});
});
it("should return empty array when no time conditions", () => {
const conditions: Condition[] = [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
{
condition: "state",
entity: "light.living_room",
state: "on",
},
];
const result = extractTimeConditions(conditions);
expect(result).toEqual([]);
});
it("should extract from nested or conditions", () => {
const conditions: Condition[] = [
{
condition: "or",
conditions: [
{
condition: "time",
after: "08:00",
} as TimeCondition,
{
condition: "state",
entity: "light.living_room",
state: "on",
},
],
} as OrCondition,
];
const result = extractTimeConditions(conditions);
expect(result).toEqual([
{
condition: "time",
after: "08:00",
},
]);
});
it("should extract from nested and conditions", () => {
const conditions: Condition[] = [
{
condition: "and",
conditions: [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
{
condition: "time",
weekdays: ["sat", "sun"],
} as TimeCondition,
],
} as AndCondition,
];
const result = extractTimeConditions(conditions);
expect(result).toEqual([
{
condition: "time",
weekdays: ["sat", "sun"],
},
]);
});
it("should extract from nested not conditions", () => {
const conditions: Condition[] = [
{
condition: "not",
conditions: [
{
condition: "time",
after: "22:00",
before: "06:00",
} as TimeCondition,
],
} as NotCondition,
];
const result = extractTimeConditions(conditions);
expect(result).toEqual([
{
condition: "time",
after: "22:00",
before: "06:00",
},
]);
});
it("should extract from deeply nested conditions", () => {
const conditions: Condition[] = [
{
condition: "or",
conditions: [
{
condition: "and",
conditions: [
{
condition: "time",
after: "08:00",
} as TimeCondition,
{
condition: "not",
conditions: [
{
condition: "time",
weekdays: ["sat", "sun"],
} as TimeCondition,
],
} as NotCondition,
],
} as AndCondition,
],
} as OrCondition,
];
const result = extractTimeConditions(conditions);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ condition: "time", after: "08:00" });
expect(result[1]).toMatchObject({
condition: "time",
weekdays: ["sat", "sun"],
});
});
it("should handle empty conditions array", () => {
const result = extractTimeConditions([]);
expect(result).toEqual([]);
});
it("should handle mixed conditions with nesting", () => {
const conditions: Condition[] = [
{
condition: "time",
after: "08:00",
} as TimeCondition,
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
{
condition: "or",
conditions: [
{
condition: "time",
before: "22:00",
} as TimeCondition,
],
} as OrCondition,
];
const result = extractTimeConditions(conditions);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ condition: "time", after: "08:00" });
expect(result[1]).toMatchObject({ condition: "time", before: "22:00" });
});
it("should preserve all time condition properties", () => {
const conditions: Condition[] = [
{
condition: "time",
after: "08:00",
before: "17:00",
weekdays: ["mon", "tue", "wed", "thu", "fri"],
} as TimeCondition,
];
const result = extractTimeConditions(conditions);
expect(result[0]).toEqual({
condition: "time",
after: "08:00",
before: "17:00",
weekdays: ["mon", "tue", "wed", "thu", "fri"],
});
});
});

View File

@@ -1,519 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
setupTimeListeners,
setupMediaQueryListeners,
} from "../../../src/common/condition/listeners";
import * as timeCalculator from "../../../src/common/condition/time-calculator";
import type {
TimeCondition,
ScreenCondition,
Condition,
} from "../../../src/panels/lovelace/common/validate-condition";
import type { HomeAssistant } from "../../../src/types";
import * as mediaQuery from "../../../src/common/dom/media_query";
// Maximum delay for setTimeout (2^31 - 1 milliseconds, ~24.8 days)
const MAX_TIMEOUT_DELAY = 2147483647;
describe("setupTimeListeners", () => {
let hass: HomeAssistant;
let listeners: (() => void)[];
let onUpdateCallback: (conditionsMet: boolean) => void;
beforeEach(() => {
vi.useFakeTimers();
listeners = [];
onUpdateCallback = vi.fn();
hass = {
locale: {
time_zone: "local",
},
config: {
time_zone: "America/New_York",
},
} as HomeAssistant;
});
afterEach(() => {
listeners.forEach((unsub) => unsub());
vi.restoreAllMocks();
});
describe("setTimeout overflow protection", () => {
it("should cap delay at MAX_TIMEOUT_DELAY", () => {
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
// Mock calculateNextTimeUpdate to return a delay exceeding the max
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
MAX_TIMEOUT_DELAY + 1000000
);
const conditions: TimeCondition[] = [
{
condition: "time",
after: "08:00",
},
];
setupTimeListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
// Verify setTimeout was called with the capped delay
expect(setTimeoutSpy).toHaveBeenCalledWith(
expect.any(Function),
MAX_TIMEOUT_DELAY
);
});
it("should not call onUpdate when hitting the cap", () => {
// Mock calculateNextTimeUpdate to return delays that decrease over time
// Both first and second delays exceed the cap
const delays = [
MAX_TIMEOUT_DELAY + 1000000,
MAX_TIMEOUT_DELAY + 500000,
1000,
];
let callCount = 0;
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockImplementation(
() => delays[callCount++]
);
const conditions: TimeCondition[] = [
{
condition: "time",
after: "08:00",
},
];
setupTimeListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
// Fast-forward to when the first timeout fires (at the cap)
vi.advanceTimersByTime(MAX_TIMEOUT_DELAY);
// onUpdate should NOT have been called because we hit the cap
expect(onUpdateCallback).not.toHaveBeenCalled();
// Fast-forward to the second timeout (still exceeds cap)
vi.advanceTimersByTime(MAX_TIMEOUT_DELAY);
// Still should not have been called
expect(onUpdateCallback).not.toHaveBeenCalled();
// Fast-forward to the third timeout (within cap)
vi.advanceTimersByTime(1000);
// NOW onUpdate should have been called
expect(onUpdateCallback).toHaveBeenCalledTimes(1);
});
it("should call onUpdate normally when delay is within cap", () => {
const normalDelay = 5000; // 5 seconds
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
normalDelay
);
const conditions: TimeCondition[] = [
{
condition: "time",
after: "08:00",
},
];
setupTimeListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
// Fast-forward by the normal delay
vi.advanceTimersByTime(normalDelay);
// onUpdate should have been called
expect(onUpdateCallback).toHaveBeenCalledTimes(1);
});
it("should reschedule after hitting the cap", () => {
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
// First delay exceeds cap, second delay is normal
const delays = [MAX_TIMEOUT_DELAY + 1000000, 5000];
let callCount = 0;
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockImplementation(
() => delays[callCount++]
);
const conditions: TimeCondition[] = [
{
condition: "time",
after: "08:00",
},
];
setupTimeListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
// First setTimeout call should use the capped delay
expect(setTimeoutSpy).toHaveBeenNthCalledWith(
1,
expect.any(Function),
MAX_TIMEOUT_DELAY
);
// Fast-forward to when the first timeout fires
vi.advanceTimersByTime(MAX_TIMEOUT_DELAY);
// Second setTimeout call should use the normal delay
expect(setTimeoutSpy).toHaveBeenNthCalledWith(
2,
expect.any(Function),
5000
);
});
});
describe("listener cleanup", () => {
it("should register cleanup function for each time condition", () => {
const normalDelay = 5000;
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
normalDelay
);
const conditions: TimeCondition[] = [
{
condition: "time",
after: "08:00",
},
{
condition: "time",
before: "17:00",
},
];
setupTimeListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
// Should have registered 2 cleanup functions (one per time condition)
expect(listeners).toHaveLength(2);
});
it("should clear timeout when cleanup is called", () => {
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
const normalDelay = 5000;
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
normalDelay
);
const conditions: TimeCondition[] = [
{
condition: "time",
after: "08:00",
},
];
setupTimeListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
// Call cleanup
listeners[0]();
// Should have cleared the timeout
expect(clearTimeoutSpy).toHaveBeenCalled();
});
});
describe("no time conditions", () => {
it("should not setup listeners when no time conditions exist", () => {
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
setupTimeListeners(
[],
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
// Should not have called setTimeout
expect(setTimeoutSpy).not.toHaveBeenCalled();
expect(listeners).toHaveLength(0);
});
});
describe("undefined delay handling", () => {
it("should not setup timeout when calculateNextTimeUpdate returns undefined", () => {
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
undefined
);
const conditions: TimeCondition[] = [
{
condition: "time",
weekdays: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
},
];
setupTimeListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
// Should not have called setTimeout
expect(setTimeoutSpy).not.toHaveBeenCalled();
});
});
});
describe("setupMediaQueryListeners", () => {
let hass: HomeAssistant;
let listeners: (() => void)[];
let onUpdateCallback: (conditionsMet: boolean) => void;
let listenMediaQuerySpy: any;
beforeEach(() => {
listeners = [];
onUpdateCallback = vi.fn();
hass = {
locale: {
time_zone: "local",
},
config: {
time_zone: "America/New_York",
},
} as HomeAssistant;
// Mock matchMedia for screen condition checks
global.matchMedia = vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
// Mock listenMediaQuery to capture the callback
listenMediaQuerySpy = vi
.spyOn(mediaQuery, "listenMediaQuery")
.mockImplementation((_query, _callback) => vi.fn());
});
afterEach(() => {
listeners.forEach((unsub) => unsub());
vi.restoreAllMocks();
});
describe("single media query", () => {
it("should setup listener for single screen condition", () => {
const conditions: Condition[] = [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
];
setupMediaQueryListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
expect(listenMediaQuerySpy).toHaveBeenCalledWith(
"(max-width: 600px)",
expect.any(Function)
);
expect(listeners).toHaveLength(1);
});
it("should call onUpdate with matches value for single screen condition", () => {
const conditions: Condition[] = [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
];
let capturedCallback: ((matches: boolean) => void) | undefined;
listenMediaQuerySpy.mockImplementation((_query, callback) => {
capturedCallback = callback;
return vi.fn();
});
setupMediaQueryListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
// Simulate media query match
capturedCallback?.(true);
// Should call onUpdate directly with the matches value
expect(onUpdateCallback).toHaveBeenCalledWith(true);
});
});
describe("multiple media queries", () => {
it("should setup listeners for multiple screen conditions", () => {
const conditions: Condition[] = [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
{
condition: "screen",
media_query: "(orientation: portrait)",
} as ScreenCondition,
];
setupMediaQueryListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
expect(listenMediaQuerySpy).toHaveBeenCalledWith(
"(max-width: 600px)",
expect.any(Function)
);
expect(listenMediaQuerySpy).toHaveBeenCalledWith(
"(orientation: portrait)",
expect.any(Function)
);
expect(listeners).toHaveLength(2);
});
it("should call onUpdate when media query changes with mixed conditions", () => {
const conditions: Condition[] = [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
{
condition: "time",
after: "08:00",
} as TimeCondition,
];
let capturedCallback: ((matches: boolean) => void) | undefined;
listenMediaQuerySpy.mockImplementation((_query, callback) => {
capturedCallback = callback;
return vi.fn();
});
setupMediaQueryListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
// Simulate media query change
capturedCallback?.(true);
// Should call onUpdate (would check all conditions)
expect(onUpdateCallback).toHaveBeenCalled();
});
});
describe("no screen conditions", () => {
it("should not setup listeners when no screen conditions exist", () => {
const conditions: Condition[] = [
{
condition: "time",
after: "08:00",
} as TimeCondition,
];
setupMediaQueryListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
expect(listenMediaQuerySpy).not.toHaveBeenCalled();
expect(listeners).toHaveLength(0);
});
it("should handle empty conditions array", () => {
setupMediaQueryListeners(
[],
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
expect(listenMediaQuerySpy).not.toHaveBeenCalled();
expect(listeners).toHaveLength(0);
});
});
describe("listener cleanup", () => {
it("should register cleanup functions", () => {
const unsubFn = vi.fn();
listenMediaQuerySpy.mockReturnValue(unsubFn);
const conditions: Condition[] = [
{
condition: "screen",
media_query: "(max-width: 600px)",
} as ScreenCondition,
];
setupMediaQueryListeners(
conditions,
hass,
(unsub) => listeners.push(unsub),
onUpdateCallback
);
expect(listeners).toHaveLength(1);
// Call cleanup
listeners[0]();
// Should have called the unsubscribe function
expect(unsubFn).toHaveBeenCalled();
});
});
});

View File

@@ -1,320 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { calculateNextTimeUpdate } from "../../../src/common/condition/time-calculator";
import type { HomeAssistant } from "../../../src/types";
import {
NumberFormat,
TimeFormat,
FirstWeekday,
DateFormat,
TimeZone,
} from "../../../src/data/translation";
describe("calculateNextTimeUpdate", () => {
let mockHass: HomeAssistant;
beforeEach(() => {
mockHass = {
locale: {
language: "en-US",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
},
config: {
time_zone: "America/Los_Angeles",
},
} as HomeAssistant;
});
afterEach(() => {
vi.useRealTimers();
});
describe("after time calculation", () => {
it("should calculate time until after time today when it hasn't passed", () => {
// Set time to 7:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
// Should be ~1 hour + 1 minute buffer = 61 minutes
expect(result).toBeGreaterThan(60 * 60 * 1000); // > 60 minutes
expect(result).toBeLessThan(62 * 60 * 1000); // < 62 minutes
});
it("should calculate time until after time tomorrow when it has passed", () => {
// Set time to 9:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 9, 0, 0));
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
// Should be ~23 hours + 1 minute buffer
expect(result).toBeGreaterThan(23 * 60 * 60 * 1000);
expect(result).toBeLessThan(24 * 60 * 60 * 1000);
});
it("should handle after time exactly at current time", () => {
// Set time to 8:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 8, 0, 0));
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
// Should be scheduled for tomorrow
expect(result).toBeGreaterThan(23 * 60 * 60 * 1000);
});
it("should handle after time with seconds", () => {
// Set time to 7:59:30 AM
vi.setSystemTime(new Date(2024, 0, 15, 7, 59, 30));
const result = calculateNextTimeUpdate(mockHass, { after: "08:00:00" });
// Should be ~30 seconds + 1 minute buffer
expect(result).toBeGreaterThan(30 * 1000);
expect(result).toBeLessThan(2 * 60 * 1000);
});
});
describe("before time calculation", () => {
it("should calculate time until before time today when it hasn't passed", () => {
// Set time to 4:00 PM
vi.setSystemTime(new Date(2024, 0, 15, 16, 0, 0));
const result = calculateNextTimeUpdate(mockHass, { before: "17:00" });
// Should be ~1 hour + 1 minute buffer
expect(result).toBeGreaterThan(60 * 60 * 1000);
expect(result).toBeLessThan(62 * 60 * 1000);
});
it("should calculate time until before time tomorrow when it has passed", () => {
// Set time to 6:00 PM
vi.setSystemTime(new Date(2024, 0, 15, 18, 0, 0));
const result = calculateNextTimeUpdate(mockHass, { before: "17:00" });
// Should be ~23 hours + 1 minute buffer
expect(result).toBeGreaterThan(23 * 60 * 60 * 1000);
expect(result).toBeLessThan(24 * 60 * 60 * 1000);
});
});
describe("combined after and before", () => {
it("should return the soonest boundary when both are in the future", () => {
// Set time to 7:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
const result = calculateNextTimeUpdate(mockHass, {
after: "08:00",
before: "17:00",
});
// Should return time until 8:00 AM (soonest)
expect(result).toBeGreaterThan(60 * 60 * 1000); // > 60 minutes
expect(result).toBeLessThan(62 * 60 * 1000); // < 62 minutes
});
it("should return the soonest boundary when within the range", () => {
// Set time to 10:00 AM (within 08:00-17:00 range)
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
const result = calculateNextTimeUpdate(mockHass, {
after: "08:00",
before: "17:00",
});
// Should return time until 5:00 PM (next boundary)
expect(result).toBeGreaterThan(7 * 60 * 60 * 1000); // > 7 hours
expect(result).toBeLessThan(8 * 60 * 60 * 1000); // < 8 hours
});
it("should handle midnight crossing range", () => {
// Set time to 11:00 PM
vi.setSystemTime(new Date(2024, 0, 15, 23, 0, 0));
const result = calculateNextTimeUpdate(mockHass, {
after: "22:00",
before: "06:00",
});
// Should return time until 6:00 AM (next boundary)
expect(result).toBeGreaterThan(7 * 60 * 60 * 1000); // > 7 hours
expect(result).toBeLessThan(8 * 60 * 60 * 1000); // < 8 hours
});
});
describe("weekday boundaries", () => {
it("should schedule for midnight when weekdays are specified (not all 7)", () => {
// Set time to Monday 10:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
const result = calculateNextTimeUpdate(mockHass, {
weekdays: ["mon", "wed", "fri"],
});
// Should be scheduled for midnight (Tuesday)
expect(result).toBeGreaterThan(14 * 60 * 60 * 1000); // > 14 hours
expect(result).toBeLessThan(15 * 60 * 60 * 1000); // < 15 hours
});
it("should not schedule midnight when all 7 weekdays specified", () => {
// Set time to Monday 10:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
const result = calculateNextTimeUpdate(mockHass, {
weekdays: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
});
// Should return undefined (no boundaries)
expect(result).toBeUndefined();
});
it("should combine weekday midnight with after time", () => {
// Set time to Monday 7:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
const result = calculateNextTimeUpdate(mockHass, {
after: "08:00",
weekdays: ["mon", "wed", "fri"],
});
// Should return the soonest (8:00 AM is sooner than midnight)
expect(result).toBeGreaterThan(60 * 60 * 1000); // > 60 minutes
expect(result).toBeLessThan(62 * 60 * 1000); // < 62 minutes
});
it("should prefer midnight over later time boundary", () => {
// Set time to Monday 11:00 PM
vi.setSystemTime(new Date(2024, 0, 15, 23, 0, 0));
const result = calculateNextTimeUpdate(mockHass, {
after: "08:00",
weekdays: ["mon", "wed", "fri"],
});
// Should return midnight (sooner than 8:00 AM)
expect(result).toBeGreaterThan(60 * 60 * 1000); // > 1 hour
expect(result).toBeLessThan(2 * 60 * 60 * 1000); // < 2 hours
});
});
describe("no boundaries", () => {
it("should return undefined when no conditions specified", () => {
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
const result = calculateNextTimeUpdate(mockHass, {});
expect(result).toBeUndefined();
});
it("should return undefined when only all weekdays specified", () => {
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
const result = calculateNextTimeUpdate(mockHass, {
weekdays: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
});
expect(result).toBeUndefined();
});
it("should return undefined when empty weekdays array", () => {
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
const result = calculateNextTimeUpdate(mockHass, {
weekdays: [],
});
expect(result).toBeUndefined();
});
});
describe("buffer addition", () => {
it("should add 1 minute buffer to next update time", () => {
// Set time to 7:59 AM
vi.setSystemTime(new Date(2024, 0, 15, 7, 59, 0));
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
// Should be ~1 minute for the boundary + 1 minute buffer = ~2 minutes
expect(result).toBeGreaterThan(60 * 1000); // > 1 minute
expect(result).toBeLessThan(3 * 60 * 1000); // < 3 minutes
});
});
describe("timezone handling", () => {
it("should use server timezone when configured", () => {
mockHass.locale.time_zone = TimeZone.server;
mockHass.config.time_zone = "America/New_York";
// Set time to 7:00 AM local time
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
// Should calculate based on server timezone
expect(result).toBeDefined();
expect(result).toBeGreaterThan(0);
});
it("should use local timezone when configured", () => {
mockHass.locale.time_zone = TimeZone.local;
// Set time to 7:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
// Should calculate based on local timezone
expect(result).toBeDefined();
expect(result).toBeGreaterThan(0);
});
});
describe("edge cases", () => {
it("should handle midnight (00:00) as after time", () => {
// Set time to 11:00 PM
vi.setSystemTime(new Date(2024, 0, 15, 23, 0, 0));
const result = calculateNextTimeUpdate(mockHass, { after: "00:00" });
// Should be ~1 hour + 1 minute buffer until midnight
expect(result).toBeGreaterThan(60 * 60 * 1000);
expect(result).toBeLessThan(62 * 60 * 1000);
});
it("should handle 23:59 as before time", () => {
// Set time to 11:00 PM
vi.setSystemTime(new Date(2024, 0, 15, 23, 0, 0));
const result = calculateNextTimeUpdate(mockHass, { before: "23:59" });
// Should be ~59 minutes + 1 minute buffer
expect(result).toBeGreaterThan(59 * 60 * 1000);
expect(result).toBeLessThan(61 * 60 * 1000);
});
it("should handle very close boundary (seconds away)", () => {
// Set time to 7:59:50 AM
vi.setSystemTime(new Date(2024, 0, 15, 7, 59, 50));
const result = calculateNextTimeUpdate(mockHass, { after: "08:00:00" });
// Should be ~10 seconds + 1 minute buffer
expect(result).toBeGreaterThan(10 * 1000);
expect(result).toBeLessThan(2 * 60 * 1000);
});
it("should handle DST transition correctly", () => {
// March 10, 2024 at 1:00 AM PST - before spring forward
vi.setSystemTime(new Date(2024, 2, 10, 1, 0, 0));
const result = calculateNextTimeUpdate(mockHass, { after: "03:00" });
// Should handle the transition where 2:00 AM doesn't exist
expect(result).toBeDefined();
expect(result).toBeGreaterThan(0);
});
});
});

View File

@@ -1,307 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
checkTimeInRange,
isValidTimeString,
} from "../../../src/common/datetime/check_time";
import type { HomeAssistant } from "../../../src/types";
import {
NumberFormat,
TimeFormat,
FirstWeekday,
DateFormat,
TimeZone,
} from "../../../src/data/translation";
describe("isValidTimeString", () => {
it("should accept valid HH:MM format", () => {
expect(isValidTimeString("08:00")).toBe(true);
expect(isValidTimeString("23:59")).toBe(true);
expect(isValidTimeString("00:00")).toBe(true);
});
it("should accept valid HH:MM:SS format", () => {
expect(isValidTimeString("08:00:30")).toBe(true);
expect(isValidTimeString("23:59:59")).toBe(true);
expect(isValidTimeString("00:00:00")).toBe(true);
});
it("should reject invalid formats", () => {
expect(isValidTimeString("")).toBe(false);
expect(isValidTimeString("8")).toBe(false);
expect(isValidTimeString("8:00 AM")).toBe(false);
expect(isValidTimeString("08:00:00:00")).toBe(false);
});
it("should reject invalid hour values", () => {
expect(isValidTimeString("24:00")).toBe(false);
expect(isValidTimeString("-01:00")).toBe(false);
expect(isValidTimeString("25:00")).toBe(false);
});
it("should reject invalid minute values", () => {
expect(isValidTimeString("08:60")).toBe(false);
expect(isValidTimeString("08:-01")).toBe(false);
});
it("should reject invalid second values", () => {
expect(isValidTimeString("08:00:60")).toBe(false);
expect(isValidTimeString("08:00:-01")).toBe(false);
});
});
describe("checkTimeInRange", () => {
let mockHass: HomeAssistant;
beforeEach(() => {
mockHass = {
locale: {
language: "en-US",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
},
config: {
time_zone: "America/Los_Angeles",
},
} as HomeAssistant;
});
afterEach(() => {
vi.useRealTimers();
});
describe("time ranges within same day", () => {
it("should return true when current time is within range", () => {
// Set time to 10:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
expect(
checkTimeInRange(mockHass, { after: "08:00", before: "17:00" })
).toBe(true);
});
it("should return false when current time is before range", () => {
// Set time to 7:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
expect(
checkTimeInRange(mockHass, { after: "08:00", before: "17:00" })
).toBe(false);
});
it("should return false when current time is after range", () => {
// Set time to 6:00 PM
vi.setSystemTime(new Date(2024, 0, 15, 18, 0, 0));
expect(
checkTimeInRange(mockHass, { after: "08:00", before: "17:00" })
).toBe(false);
});
});
describe("time ranges crossing midnight", () => {
it("should return true when current time is before midnight", () => {
// Set time to 11:00 PM
vi.setSystemTime(new Date(2024, 0, 15, 23, 0, 0));
expect(
checkTimeInRange(mockHass, { after: "22:00", before: "06:00" })
).toBe(true);
});
it("should return true exactly at the after boundary", () => {
// Set time to 10:00 PM
vi.setSystemTime(new Date(2024, 0, 15, 22, 0, 0));
expect(
checkTimeInRange(mockHass, { after: "22:00", before: "06:00" })
).toBe(true);
});
it("should return true when current time is after midnight", () => {
// Set time to 3:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 3, 0, 0));
expect(
checkTimeInRange(mockHass, { after: "22:00", before: "06:00" })
).toBe(true);
});
it("should return true exactly at the before boundary", () => {
// Set time to 6:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 6, 0, 0));
expect(
checkTimeInRange(mockHass, { after: "22:00", before: "06:00" })
).toBe(true);
});
it("should return false when outside the range", () => {
// Set time to 10:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
expect(
checkTimeInRange(mockHass, { after: "22:00", before: "06:00" })
).toBe(false);
});
});
describe("only 'after' condition", () => {
it("should return true when after specified time", () => {
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
expect(checkTimeInRange(mockHass, { after: "08:00" })).toBe(true);
});
it("should return false when before specified time", () => {
vi.setSystemTime(new Date(2024, 0, 15, 6, 0, 0));
expect(checkTimeInRange(mockHass, { after: "08:00" })).toBe(false);
});
});
describe("only 'before' condition", () => {
it("should return true when before specified time", () => {
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
expect(checkTimeInRange(mockHass, { before: "17:00" })).toBe(true);
});
it("should return false when after specified time", () => {
vi.setSystemTime(new Date(2024, 0, 15, 18, 0, 0));
expect(checkTimeInRange(mockHass, { before: "17:00" })).toBe(false);
});
});
describe("weekday filtering", () => {
it("should return true on matching weekday", () => {
// January 15, 2024 is a Monday
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
expect(checkTimeInRange(mockHass, { weekdays: ["mon"] })).toBe(true);
});
it("should return false on non-matching weekday", () => {
// January 15, 2024 is a Monday
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
expect(checkTimeInRange(mockHass, { weekdays: ["tue"] })).toBe(false);
});
it("should work with multiple weekdays", () => {
// January 15, 2024 is a Monday
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
expect(
checkTimeInRange(mockHass, { weekdays: ["mon", "wed", "fri"] })
).toBe(true);
});
});
describe("combined time and weekday conditions", () => {
it("should return true when both match", () => {
// January 15, 2024 is a Monday at 10:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
expect(
checkTimeInRange(mockHass, {
after: "08:00",
before: "17:00",
weekdays: ["mon"],
})
).toBe(true);
});
it("should return false when time matches but weekday doesn't", () => {
// January 15, 2024 is a Monday at 10:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
expect(
checkTimeInRange(mockHass, {
after: "08:00",
before: "17:00",
weekdays: ["tue"],
})
).toBe(false);
});
it("should return false when weekday matches but time doesn't", () => {
// January 15, 2024 is a Monday at 6:00 AM
vi.setSystemTime(new Date(2024, 0, 15, 6, 0, 0));
expect(
checkTimeInRange(mockHass, {
after: "08:00",
before: "17:00",
weekdays: ["mon"],
})
).toBe(false);
});
});
describe("no conditions", () => {
it("should return true when no conditions specified", () => {
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
expect(
checkTimeInRange(mockHass, { after: "08:00", before: "17:00" })
).toBe(true);
});
});
describe("DST transitions", () => {
it("should handle spring forward transition (losing an hour)", () => {
// March 10, 2024 at 1:30 AM PST - before spring forward
// At 2:00 AM, clocks jump to 3:00 AM PDT
vi.setSystemTime(new Date(2024, 2, 10, 1, 30, 0));
// Should be within range that crosses the transition
expect(
checkTimeInRange(mockHass, { after: "01:00", before: "04:00" })
).toBe(true);
});
it("should handle spring forward transition after the jump", () => {
// March 10, 2024 at 3:30 AM PDT - after spring forward
vi.setSystemTime(new Date(2024, 2, 10, 3, 30, 0));
// Should still be within range
expect(
checkTimeInRange(mockHass, { after: "01:00", before: "04:00" })
).toBe(true);
});
it("should handle fall back transition (gaining an hour)", () => {
// November 3, 2024 at 1:30 AM PDT - before fall back
// At 2:00 AM PDT, clocks fall back to 1:00 AM PST
vi.setSystemTime(new Date(2024, 10, 3, 1, 30, 0));
// Should be within range that crosses the transition
expect(
checkTimeInRange(mockHass, { after: "01:00", before: "03:00" })
).toBe(true);
});
it("should handle midnight crossing during DST transition", () => {
// March 10, 2024 at 1:00 AM - during spring forward night
vi.setSystemTime(new Date(2024, 2, 10, 1, 0, 0));
// Range that crosses midnight and DST transition
expect(
checkTimeInRange(mockHass, { after: "22:00", before: "04:00" })
).toBe(true);
});
it("should correctly compare times on DST transition day", () => {
// November 3, 2024 at 10:00 AM - after fall back completed
vi.setSystemTime(new Date(2024, 10, 3, 10, 0, 0));
// Normal business hours should work correctly
expect(
checkTimeInRange(mockHass, { after: "08:00", before: "17:00" })
).toBe(true);
expect(
checkTimeInRange(mockHass, { after: "12:00", before: "17:00" })
).toBe(false);
});
});
});

View File

@@ -1,56 +0,0 @@
import { assert, describe, it } from "vitest";
import { parseAnimationDuration } from "../../../src/common/util/parse-animation-duration";
describe("parseAnimationDuration", () => {
it("Parses milliseconds with unit", () => {
assert.equal(parseAnimationDuration("300ms"), 300);
});
it("Parses seconds with unit", () => {
assert.equal(parseAnimationDuration("3s"), 3000);
});
it("Parses decimal seconds", () => {
assert.equal(parseAnimationDuration("0.5s"), 500);
});
it("Parses decimal milliseconds", () => {
assert.equal(parseAnimationDuration("250.5ms"), 250.5);
});
it("Handles whitespace", () => {
assert.equal(parseAnimationDuration(" 300ms "), 300);
assert.equal(parseAnimationDuration(" 3s "), 3000);
});
it("Handles number without unit as milliseconds", () => {
assert.equal(parseAnimationDuration("300"), 300);
});
it("Returns 0 for invalid input", () => {
assert.equal(parseAnimationDuration("invalid"), 0);
});
it("Returns 0 for empty string", () => {
assert.equal(parseAnimationDuration(""), 0);
});
it("Returns 0 for negative values", () => {
assert.equal(parseAnimationDuration("-300ms"), 0);
assert.equal(parseAnimationDuration("-3s"), 0);
});
it("Returns 0 for NaN", () => {
assert.equal(parseAnimationDuration("NaN"), 0);
});
it("Returns 0 for Infinity", () => {
assert.equal(parseAnimationDuration("Infinity"), 0);
});
it("Handles zero values", () => {
assert.equal(parseAnimationDuration("0ms"), 0);
assert.equal(parseAnimationDuration("0s"), 0);
});
});

View File

@@ -1,248 +0,0 @@
import { describe, it, expect } from "vitest";
import { validateConditionalConfig } from "../../../../src/panels/lovelace/common/validate-condition";
import type { TimeCondition } from "../../../../src/panels/lovelace/common/validate-condition";
describe("validateConditionalConfig - TimeCondition", () => {
describe("valid configurations", () => {
it("should accept valid after time", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:00",
};
expect(validateConditionalConfig([condition])).toBe(true);
});
it("should accept valid before time", () => {
const condition: TimeCondition = {
condition: "time",
before: "17:00",
};
expect(validateConditionalConfig([condition])).toBe(true);
});
it("should accept valid after and before times", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:00",
before: "17:00",
};
expect(validateConditionalConfig([condition])).toBe(true);
});
it("should accept time with seconds", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:00:30",
before: "17:30:45",
};
expect(validateConditionalConfig([condition])).toBe(true);
});
it("should accept only weekdays", () => {
const condition: TimeCondition = {
condition: "time",
weekdays: ["mon", "wed", "fri"],
};
expect(validateConditionalConfig([condition])).toBe(true);
});
it("should accept time and weekdays combined", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:00",
before: "17:00",
weekdays: ["mon", "tue", "wed", "thu", "fri"],
};
expect(validateConditionalConfig([condition])).toBe(true);
});
it("should accept midnight times", () => {
const condition: TimeCondition = {
condition: "time",
after: "00:00",
before: "23:59",
};
expect(validateConditionalConfig([condition])).toBe(true);
});
it("should accept midnight crossing ranges", () => {
const condition: TimeCondition = {
condition: "time",
after: "22:00",
before: "06:00",
};
expect(validateConditionalConfig([condition])).toBe(true);
});
});
describe("invalid time formats", () => {
it("should reject invalid hour (> 23)", () => {
const condition: TimeCondition = {
condition: "time",
after: "25:00",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject invalid hour (< 0)", () => {
const condition: TimeCondition = {
condition: "time",
after: "-01:00",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject invalid minute (> 59)", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:60",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject invalid minute (< 0)", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:-01",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject invalid second (> 59)", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:00:60",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject invalid second (< 0)", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:00:-01",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject non-numeric values", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:XX",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject 12-hour format", () => {
const condition: TimeCondition = {
condition: "time",
after: "8:00 AM",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject single number", () => {
const condition: TimeCondition = {
condition: "time",
after: "8",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject too many parts", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:00:00:00",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject empty string", () => {
const condition: TimeCondition = {
condition: "time",
after: "",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
});
describe("invalid configurations", () => {
it("should reject when after and before are identical", () => {
const condition: TimeCondition = {
condition: "time",
after: "09:00",
before: "09:00",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject when no conditions specified", () => {
const condition: TimeCondition = {
condition: "time",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject invalid weekday", () => {
const condition = {
condition: "time",
weekdays: ["monday"], // Should be "mon" not "monday"
} as any;
expect(validateConditionalConfig([condition])).toBe(false);
});
it("should reject empty weekdays array", () => {
const condition: TimeCondition = {
condition: "time",
weekdays: [],
};
expect(validateConditionalConfig([condition])).toBe(false);
});
});
describe("edge cases", () => {
it("should accept single-digit hours with leading zero", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:00",
before: "09:00",
};
expect(validateConditionalConfig([condition])).toBe(true);
});
it("should reject single-digit hours without leading zero", () => {
const condition: TimeCondition = {
condition: "time",
after: "8:00",
};
// This should be rejected as hours should have 2 digits in HH:MM format
// However, parseInt will parse it successfully, so this will pass
// This is acceptable for flexibility
expect(validateConditionalConfig([condition])).toBe(true);
});
it("should accept 00:00:00", () => {
const condition: TimeCondition = {
condition: "time",
after: "00:00:00",
};
expect(validateConditionalConfig([condition])).toBe(true);
});
it("should accept 23:59:59", () => {
const condition: TimeCondition = {
condition: "time",
before: "23:59:59",
};
expect(validateConditionalConfig([condition])).toBe(true);
});
it("should reject both invalid times even if one is valid", () => {
const condition: TimeCondition = {
condition: "time",
after: "08:00",
before: "25:00",
};
expect(validateConditionalConfig([condition])).toBe(false);
});
});
});

166
yarn.lock
View File

@@ -1940,9 +1940,9 @@ __metadata:
languageName: node
linkType: hard
"@home-assistant/webawesome@npm:3.0.0":
version: 3.0.0
resolution: "@home-assistant/webawesome@npm:3.0.0"
"@home-assistant/webawesome@npm:3.0.0-beta.6.ha.7":
version: 3.0.0-beta.6.ha.7
resolution: "@home-assistant/webawesome@npm:3.0.0-beta.6.ha.7"
dependencies:
"@ctrl/tinycolor": "npm:4.1.0"
"@floating-ui/dom": "npm:^1.6.13"
@@ -1953,7 +1953,7 @@ __metadata:
lit: "npm:^3.2.1"
nanoid: "npm:^5.1.5"
qr-creator: "npm:^1.0.0"
checksum: 10/03400894cfee8548fd5b1f5c56d31d253830e704b18ba69d36ce6b761d8b1bef2fb52cffba8d9b033033bb582f2f51a2d6444d82622f66d70150e2104fcb49e2
checksum: 10/c20e5b60920a3cd5bbabb38e73d0a446c54074dcbb843272404b15b6a7e584b8a328393c1e845a2a400588fe15bdcd28d2c18aa2ce44b806f72a3b9343a3310f
languageName: node
linkType: hard
@@ -4945,106 +4945,106 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.46.4":
version: 8.46.4
resolution: "@typescript-eslint/eslint-plugin@npm:8.46.4"
"@typescript-eslint/eslint-plugin@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/eslint-plugin@npm:8.46.3"
dependencies:
"@eslint-community/regexpp": "npm:^4.10.0"
"@typescript-eslint/scope-manager": "npm:8.46.4"
"@typescript-eslint/type-utils": "npm:8.46.4"
"@typescript-eslint/utils": "npm:8.46.4"
"@typescript-eslint/visitor-keys": "npm:8.46.4"
"@typescript-eslint/scope-manager": "npm:8.46.3"
"@typescript-eslint/type-utils": "npm:8.46.3"
"@typescript-eslint/utils": "npm:8.46.3"
"@typescript-eslint/visitor-keys": "npm:8.46.3"
graphemer: "npm:^1.4.0"
ignore: "npm:^7.0.0"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.1.0"
peerDependencies:
"@typescript-eslint/parser": ^8.46.4
"@typescript-eslint/parser": ^8.46.3
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/5ae705d9dbf8cdeaf8cc2198cbfa1c3b70d5bf2fd20b5870448b53e9fe2f5a0d106162850aabd97897d250ec6fe7cebbb3f7ea2b6aa7ca9582b9b1b9e3be459f
checksum: 10/0c1eb81a43f1d04fdd79c4e59f9f0687b86735ae6c98d94fe5eb021da2f83e0e2426a2922fe94296fb0a9ab131d53fe4cde8b54d0948d7b23e01e648a318bd1c
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.46.4":
version: 8.46.4
resolution: "@typescript-eslint/parser@npm:8.46.4"
"@typescript-eslint/parser@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/parser@npm:8.46.3"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.46.4"
"@typescript-eslint/types": "npm:8.46.4"
"@typescript-eslint/typescript-estree": "npm:8.46.4"
"@typescript-eslint/visitor-keys": "npm:8.46.4"
"@typescript-eslint/scope-manager": "npm:8.46.3"
"@typescript-eslint/types": "npm:8.46.3"
"@typescript-eslint/typescript-estree": "npm:8.46.3"
"@typescript-eslint/visitor-keys": "npm:8.46.3"
debug: "npm:^4.3.4"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/560635f5567dba6342cea2146051e5647dbc48f5fb7b0a7a6d577cada06d43e07030bb3999f90f6cd01d5b0fdb25d829a25252c84cf7a685c5c9373e6e1e4a73
checksum: 10/d36edeba9ce37d219115fb101a4496bca2685969b217d0f64c0c255867a8793a8b41a95b86e26775a09b3abbb7c5b93ef712ea9a0fba3d055dcf385b17825075
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.46.4":
version: 8.46.4
resolution: "@typescript-eslint/project-service@npm:8.46.4"
"@typescript-eslint/project-service@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/project-service@npm:8.46.3"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.46.4"
"@typescript-eslint/types": "npm:^8.46.4"
"@typescript-eslint/tsconfig-utils": "npm:^8.46.3"
"@typescript-eslint/types": "npm:^8.46.3"
debug: "npm:^4.3.4"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10/f145da5f0c063833f48d36f2c3a19a37e2fb77156f0cc7046ee15f2e59418309b95628c8e7216e4429fac9f1257fab945c5d3f5abfd8f924223d36125c633d32
checksum: 10/2f041dfc664209b6a213cf585df28d0913ddf81916b83119c897a10dd9ad20dcd0ee3c523ee95440f498da6ba9d6e50cf08852418c0a2ebddd92c7a7cd295736
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.46.4":
version: 8.46.4
resolution: "@typescript-eslint/scope-manager@npm:8.46.4"
"@typescript-eslint/scope-manager@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/scope-manager@npm:8.46.3"
dependencies:
"@typescript-eslint/types": "npm:8.46.4"
"@typescript-eslint/visitor-keys": "npm:8.46.4"
checksum: 10/1439ffc1458281282c1ae3aabbe89140ce15c796d4f1c59f0de38e8536803e10143fe322a7e1cb56fe41da9e4617898d70923b71621b47cff4472aa5dae88d7e
"@typescript-eslint/types": "npm:8.46.3"
"@typescript-eslint/visitor-keys": "npm:8.46.3"
checksum: 10/6bb6c3210bfcca59cf60860b51bfae8d28b01d074a8608b6f24b3290952ff74103e08d390d11cbf613812fca04aa55ad14ad9da04c3041e23acdca235ab1ff78
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.46.4, @typescript-eslint/tsconfig-utils@npm:^8.46.4":
version: 8.46.4
resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.4"
"@typescript-eslint/tsconfig-utils@npm:8.46.3, @typescript-eslint/tsconfig-utils@npm:^8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.3"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10/eda25b1daee6abf51ee2dd5fc1dc1a5160a14301c0e7bed301ec5eb0f7b45418d509c035361f88a37f4af9771d7334f1dcb9bc7f7a38f07b09e85d4d9d92767f
checksum: 10/e7a16eadf79483d4b61dee56a08d032bafe26d44d634e7863a5875dbb44393570896641272a4e9810f4eac76a4109f59ad667b036d7627ef1647dc672ea19c5e
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.46.4":
version: 8.46.4
resolution: "@typescript-eslint/type-utils@npm:8.46.4"
"@typescript-eslint/type-utils@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/type-utils@npm:8.46.3"
dependencies:
"@typescript-eslint/types": "npm:8.46.4"
"@typescript-eslint/typescript-estree": "npm:8.46.4"
"@typescript-eslint/utils": "npm:8.46.4"
"@typescript-eslint/types": "npm:8.46.3"
"@typescript-eslint/typescript-estree": "npm:8.46.3"
"@typescript-eslint/utils": "npm:8.46.3"
debug: "npm:^4.3.4"
ts-api-utils: "npm:^2.1.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/438188d4db8889b1299df60e03be76bbbcfad6500cbdbaad83250bc3671d6d798d3eef01417dd2b4236334ed11e466b90a75d17c0d5b94b667b362ce746dd3e6
checksum: 10/b29cd001c715033ec9cd5fdf2723915f1b4c6c9342283ed00d20e4b942117625facba9a2cf3914b06633c2af9a167430f8f134323627adb0be85f73da4e89d72
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.46.4, @typescript-eslint/types@npm:^8.46.4":
version: 8.46.4
resolution: "@typescript-eslint/types@npm:8.46.4"
checksum: 10/dd71692722254308f7954ade97800c141ec4a2bbdeef334df4ef9a5ee00db4597db4c3d0783607fc61c22238c9c534803a5421fe0856033a635e13fbe99b3cf0
"@typescript-eslint/types@npm:8.46.3, @typescript-eslint/types@npm:^8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/types@npm:8.46.3"
checksum: 10/3de35df2ec2f2937c8f6eb262cd49f34500a18d01e0d8da6f348afd621f6c222c41d4ea15203ebbf0bd59814aa2b4c83fde7eb6d4aad1fa1514ee7a742887c6a
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.46.4":
version: 8.46.4
resolution: "@typescript-eslint/typescript-estree@npm:8.46.4"
"@typescript-eslint/typescript-estree@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/typescript-estree@npm:8.46.3"
dependencies:
"@typescript-eslint/project-service": "npm:8.46.4"
"@typescript-eslint/tsconfig-utils": "npm:8.46.4"
"@typescript-eslint/types": "npm:8.46.4"
"@typescript-eslint/visitor-keys": "npm:8.46.4"
"@typescript-eslint/project-service": "npm:8.46.3"
"@typescript-eslint/tsconfig-utils": "npm:8.46.3"
"@typescript-eslint/types": "npm:8.46.3"
"@typescript-eslint/visitor-keys": "npm:8.46.3"
debug: "npm:^4.3.4"
fast-glob: "npm:^3.3.2"
is-glob: "npm:^4.0.3"
@@ -5053,32 +5053,32 @@ __metadata:
ts-api-utils: "npm:^2.1.0"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10/2a932bdd7ac260e2b7290c952241bf06b2ddbeb3cf636bc624a64a9cfb046619620172a1967f30dbde6ac5f4fbdcfec66e1349af46313da86e01b5575dfebe2e
checksum: 10/b55cf72fe3dff0b9bdf9b1793e43fdb2789fa6d706ba7d69fb94801bea82041056a95659bd8fe1e6f026787b2e8d0f8d060149841095a0a82044e3469b8d82cd
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.46.4":
version: 8.46.4
resolution: "@typescript-eslint/utils@npm:8.46.4"
"@typescript-eslint/utils@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/utils@npm:8.46.3"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.7.0"
"@typescript-eslint/scope-manager": "npm:8.46.4"
"@typescript-eslint/types": "npm:8.46.4"
"@typescript-eslint/typescript-estree": "npm:8.46.4"
"@typescript-eslint/scope-manager": "npm:8.46.3"
"@typescript-eslint/types": "npm:8.46.3"
"@typescript-eslint/typescript-estree": "npm:8.46.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/8e11abb2e44b6e62ccf8fd9b96808cb58e68788564fa999f15b61c0ec929209ced7f92a57ffbfcaec80f926aa14dafcee756755b724ae543b4cbd84b0ffb890d
checksum: 10/369c962bc20a2a6022ef4533ad55ab4e3d2403e7e200505b29fae6f0b8fc99be8fe149d929781f5ead0d3f88f2c74904f60aaa3771e6773e2b7dd8f61f07a534
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.46.4":
version: 8.46.4
resolution: "@typescript-eslint/visitor-keys@npm:8.46.4"
"@typescript-eslint/visitor-keys@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/visitor-keys@npm:8.46.3"
dependencies:
"@typescript-eslint/types": "npm:8.46.4"
"@typescript-eslint/types": "npm:8.46.3"
eslint-visitor-keys: "npm:^4.2.1"
checksum: 10/bcf479fa5c59857cf7aa7b90d9c00e23f7303473b94a401cc3b64776ebb66978b5342459a1672581dcf1861fa5961bb59c901fe766c28b6bc3f93e60bfc34dae
checksum: 10/02659a4cc4780d677907ed7e356e18b941e0ed18883acfda0d74d3e388144f90aa098b8fcdc2f4c01e9e6b60ac6154d1afb009feb6169c483260a5c8b4891171
languageName: node
linkType: hard
@@ -9226,7 +9226,7 @@ __metadata:
"@fullcalendar/list": "npm:6.1.19"
"@fullcalendar/luxon3": "npm:6.1.19"
"@fullcalendar/timegrid": "npm:6.1.19"
"@home-assistant/webawesome": "npm:3.0.0"
"@home-assistant/webawesome": "npm:3.0.0-beta.6.ha.7"
"@lezer/highlight": "npm:1.2.3"
"@lit-labs/motion": "npm:1.0.9"
"@lit-labs/observers": "npm:2.0.6"
@@ -9351,7 +9351,7 @@ __metadata:
lodash.template: "npm:4.5.0"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
marked: "npm:17.0.0"
marked: "npm:16.4.2"
memoize-one: "npm:6.0.0"
node-vibrant: "npm:4.0.3"
object-hash: "npm:3.0.0"
@@ -9373,7 +9373,7 @@ __metadata:
tinykeys: "npm:3.0.0"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:5.9.3"
typescript-eslint: "npm:8.46.4"
typescript-eslint: "npm:8.46.3"
ua-parser-js: "npm:2.0.6"
vite-tsconfig-paths: "npm:5.1.4"
vitest: "npm:4.0.8"
@@ -10984,12 +10984,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:17.0.0":
version: 17.0.0
resolution: "marked@npm:17.0.0"
"marked@npm:16.4.2":
version: 16.4.2
resolution: "marked@npm:16.4.2"
bin:
marked: bin/marked.js
checksum: 10/5544d27547851986c4e994a3f5739ea30bfe2616a9e1d5d5c8ced0fd561b5e971b3c7ee62b4fea1ea530e9886b89102d5c3b3bf962756494ced021f1accd6854
checksum: 10/6e40e40661dce97e271198daa2054fc31e6445892a735e416c248fba046bdfa4573cafa08dc254529f105e7178a34485eb7f82573979cfb377a4530f66e79187
languageName: node
linkType: hard
@@ -14295,18 +14295,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.46.4":
version: 8.46.4
resolution: "typescript-eslint@npm:8.46.4"
"typescript-eslint@npm:8.46.3":
version: 8.46.3
resolution: "typescript-eslint@npm:8.46.3"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.46.4"
"@typescript-eslint/parser": "npm:8.46.4"
"@typescript-eslint/typescript-estree": "npm:8.46.4"
"@typescript-eslint/utils": "npm:8.46.4"
"@typescript-eslint/eslint-plugin": "npm:8.46.3"
"@typescript-eslint/parser": "npm:8.46.3"
"@typescript-eslint/typescript-estree": "npm:8.46.3"
"@typescript-eslint/utils": "npm:8.46.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/6d28371033653395f1108d880f32ed5b03c15d94a4ca7564b81cdb5c563fa618b48cbcb6c00f3341e3399b27711feb1073305b425a22de23786a87c6a3a19ccd
checksum: 10/2f77eb70c8fd6ec4920d5abf828ef28007df8ff94605246a4ca918fadb996a83f7fb82510a1de69fad7f0159ee8f15246d467ebc42df20a4585919cb6b401715
languageName: node
linkType: hard