diff --git a/src/common/condition/extract.ts b/src/common/condition/extract.ts new file mode 100644 index 000000000000..54120a403d21 --- /dev/null +++ b/src/common/condition/extract.ts @@ -0,0 +1,36 @@ +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((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((array, c) => { + if ("conditions" in c && c.conditions) { + array.push(...extractTimeConditions(c.conditions)); + } + if (c.condition === "time") { + array.push(c); + } + return array; + }, []); +} diff --git a/src/common/condition/listeners.ts b/src/common/condition/listeners.ts new file mode 100644 index 000000000000..f1a819926ed7 --- /dev/null +++ b/src/common/condition/listeners.ts @@ -0,0 +1,72 @@ +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"; + +/** + * 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) => { + const scheduleUpdate = () => { + const delay = calculateNextTimeUpdate(hass, timeCondition); + + if (delay === undefined) return; + + const timeoutId = setTimeout(() => { + const conditionsMet = checkConditionsMet(conditions, hass); + onUpdate(conditionsMet); + // Reschedule for next boundary + scheduleUpdate(); + }, delay); + + // Store cleanup function + addListener(() => clearTimeout(timeoutId)); + }; + + scheduleUpdate(); + }); +} diff --git a/src/common/condition/time-calculator.ts b/src/common/condition/time-calculator.ts new file mode 100644 index 000000000000..fbffacfa8524 --- /dev/null +++ b/src/common/condition/time-calculator.ts @@ -0,0 +1,73 @@ +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 +): 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); +} diff --git a/src/common/datetime/check_time.ts b/src/common/datetime/check_time.ts new file mode 100644 index 000000000000..c273f7dddce8 --- /dev/null +++ b/src/common/datetime/check_time.ts @@ -0,0 +1,107 @@ +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"; + +/** + * Parse a time string (HH:MM or HH:MM:SS) and set it on today's date in the given timezone + * @param timeString The time string to parse + * @param timezone The timezone to use + * @returns The Date object + */ +export const parseTimeString = (timeString: string, timezone: string): Date => { + const parts = timeString.split(":"); + + if (parts.length < 2 || parts.length > 3) { + throw new Error( + `Invalid time format: ${timeString}. Expected HH:MM or HH:MM:SS` + ); + } + + 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)) { + throw new Error(`Invalid time values in: ${timeString}`); + } + + // Add range validation + if (hours < 0 || hours > 23) { + throw new Error(`Invalid hours in: ${timeString}. Must be 0-23`); + } + if (minutes < 0 || minutes > 59) { + throw new Error(`Invalid minutes in: ${timeString}. Must be 0-59`); + } + if (seconds < 0 || seconds > 59) { + throw new Error(`Invalid seconds in: ${timeString}. Must be 0-59`); + } + + 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 +): 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 isAfter(now, afterDate) || isBefore(now, beforeDate); + } + return isWithinInterval(now, { start: afterDate, end: beforeDate }); + } + + if (afterDate) { + return !isBefore(now, afterDate); + } + + if (beforeDate) { + return !isAfter(now, beforeDate); + } + + return true; +}; diff --git a/src/common/datetime/first_weekday.ts b/src/common/datetime/first_weekday.ts index c5099702c1dd..30a19af524b6 100644 --- a/src/common/datetime/first_weekday.ts +++ b/src/common/datetime/first_weekday.ts @@ -1,18 +1,7 @@ import { getWeekStartByLocale } from "weekstart"; import type { FrontendLocaleData } from "../../data/translation"; import { FirstWeekday } from "../../data/translation"; - -export const weekdays = [ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", -] as const; - -type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6; +import { WEEKDAYS_LONG, type WeekdayIndex } from "./weekday"; export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => { if (locale.first_weekday === FirstWeekday.language) { @@ -23,12 +12,12 @@ export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => { } return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex; } - return weekdays.includes(locale.first_weekday) - ? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex) + return WEEKDAYS_LONG.includes(locale.first_weekday) + ? (WEEKDAYS_LONG.indexOf(locale.first_weekday) as WeekdayIndex) : 1; }; export const firstWeekday = (locale: FrontendLocaleData) => { const index = firstWeekdayIndex(locale); - return weekdays[index]; + return WEEKDAYS_LONG[index]; }; diff --git a/src/common/datetime/weekday.ts b/src/common/datetime/weekday.ts new file mode 100644 index 000000000000..a57052a51ad5 --- /dev/null +++ b/src/common/datetime/weekday.ts @@ -0,0 +1,59 @@ +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; + +export const WEEKDAY_SHORT_TO_LONG = { + sun: "sunday", + mon: "monday", + tue: "tuesday", + wed: "wednesday", + thu: "thursday", + fri: "friday", + sat: "saturday", +} as const satisfies Record; diff --git a/src/data/automation.ts b/src/data/automation.ts index e0eca6cc581b..0b793c750e58 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -12,6 +12,7 @@ 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; @@ -257,13 +258,11 @@ 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?: Weekday | Weekday[]; + weekday?: WeekdayShort | WeekdayShort[]; } export interface TemplateCondition extends BaseCondition { diff --git a/src/mixins/conditional-listener-mixin.ts b/src/mixins/conditional-listener-mixin.ts index f598098254d1..afaaa9831a2a 100644 --- a/src/mixins/conditional-listener-mixin.ts +++ b/src/mixins/conditional-listener-mixin.ts @@ -1,73 +1,28 @@ import type { ReactiveElement } from "lit"; -import { listenMediaQuery } from "../common/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 { + setupMediaQueryListeners, + setupTimeListeners, +} from "../common/condition/listeners"; type Constructor = abstract new (...args: any[]) => T; -/** - * Extract media queries from conditions recursively - */ -export function extractMediaQueries(conditions: Condition[]): string[] { - return conditions.reduce((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 (media queries, time-based, state changes, etc.) - * that control conditional visibility of components. + * Provides lifecycle management for listeners that control conditional + * visibility of components. * * Usage: * 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 + * 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) * * 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 = < T extends Constructor, @@ -77,6 +32,9 @@ export const ConditionalListenerMixin = < abstract class ConditionalListenerClass extends superClass { private __listeners: (() => void)[] = []; + // Type hint for hass property (should be provided by subclass) + abstract hass?: HomeAssistant; + public connectedCallback() { super.connectedCallback(); this.setupConditionalListeners(); @@ -96,8 +54,51 @@ export const ConditionalListenerMixin = < this.__listeners.push(unsubscribe); } - protected setupConditionalListeners(): void { - // Override in subclass + /** + * 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?: any[]): void { + const component = this as any; + const finalConditions = + conditions || + component.config?.visibility || + component._config?.visibility; + + if (!finalConditions || !this.hass) { + return; + } + + const onUpdate = (conditionsMet: boolean) => { + if (component._updateVisibility) { + component._updateVisibility(conditionsMet); + } else if (component._updateElement) { + component._updateElement(conditionsMet); + } + }; + + setupMediaQueryListeners( + finalConditions, + this.hass, + (unsub) => this.addConditionalListener(unsub), + onUpdate + ); + + setupTimeListeners( + finalConditions, + this.hass, + (unsub) => this.addConditionalListener(unsub), + onUpdate + ); } } return ConditionalListenerClass; diff --git a/src/panels/lovelace/badges/hui-badge.ts b/src/panels/lovelace/badges/hui-badge.ts index 104fe34a177e..b70053c35f4d 100644 --- a/src/panels/lovelace/badges/hui-badge.ts +++ b/src/panels/lovelace/badges/hui-badge.ts @@ -5,10 +5,7 @@ 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, - setupMediaQueryListeners, -} from "../../../mixins/conditional-listener-mixin"; +import { ConditionalListenerMixin } 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"; @@ -133,21 +130,6 @@ export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) { } } - 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; diff --git a/src/panels/lovelace/cards/hui-card.ts b/src/panels/lovelace/cards/hui-card.ts index 22b56d5d872f..f22d84d6b957 100644 --- a/src/panels/lovelace/cards/hui-card.ts +++ b/src/panels/lovelace/cards/hui-card.ts @@ -5,10 +5,7 @@ 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, - setupMediaQueryListeners, -} from "../../../mixins/conditional-listener-mixin"; +import { ConditionalListenerMixin } 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"; @@ -247,21 +244,6 @@ export class HuiCard extends ConditionalListenerMixin(ReactiveElement) { } } - 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; diff --git a/src/panels/lovelace/common/icon-condition.ts b/src/panels/lovelace/common/icon-condition.ts index 9bb50b1b64b6..fe872f1e6d70 100644 --- a/src/panels/lovelace/common/icon-condition.ts +++ b/src/panels/lovelace/common/icon-condition.ts @@ -1,6 +1,7 @@ import { mdiAccount, mdiAmpersand, + mdiCalendarClock, mdiGateOr, mdiMapMarker, mdiNotEqualVariant, @@ -15,6 +16,7 @@ export const ICON_CONDITION: Record = { numeric_state: mdiNumeric, state: mdiStateMachine, screen: mdiResponsive, + time: mdiCalendarClock, user: mdiAccount, and: mdiAmpersand, not: mdiNotEqualVariant, diff --git a/src/panels/lovelace/common/validate-condition.ts b/src/panels/lovelace/common/validate-condition.ts index 758b621f3762..37a5c0d6ca9c 100644 --- a/src/panels/lovelace/common/validate-condition.ts +++ b/src/panels/lovelace/common/validate-condition.ts @@ -1,15 +1,20 @@ -import { ensureArray } from "../../../common/array/ensure-array"; - -import { isValidEntityId } from "../../../common/entity/valid_entity_id"; +import type { HomeAssistant } from "../../../types"; import { UNKNOWN } from "../../../data/entity"; import { getUserPerson } from "../../../data/person"; -import type { HomeAssistant } from "../../../types"; +import { ensureArray } from "../../../common/array/ensure-array"; +import { checkTimeInRange } from "../../../common/datetime/check_time"; +import { + WEEKDAYS_SHORT, + type WeekdayShort, +} from "../../../common/datetime/weekday"; +import { isValidEntityId } from "../../../common/entity/valid_entity_id"; export type Condition = | LocationCondition | NumericStateCondition | StateCondition | ScreenCondition + | TimeCondition | UserCondition | OrCondition | AndCondition @@ -50,6 +55,13 @@ 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[]; @@ -150,6 +162,13 @@ function checkScreenCondition(condition: ScreenCondition, _: HomeAssistant) { : false; } +function checkTimeCondition( + condition: Omit, + hass: HomeAssistant +) { + return checkTimeInRange(hass, condition); +} + function checkLocationCondition( condition: LocationCondition, hass: HomeAssistant @@ -195,6 +214,8 @@ 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": @@ -271,6 +292,17 @@ function validateScreenCondition(condition: ScreenCondition) { return condition.media_query != null; } +function validateTimeCondition(condition: TimeCondition) { + const hasTime = condition.after != null || condition.before != null; + const hasWeekdays = + condition.weekdays != null && condition.weekdays.length > 0; + const weekdaysValid = + !hasWeekdays || + condition.weekdays!.every((w: WeekdayShort) => WEEKDAYS_SHORT.includes(w)); + + return (hasTime || hasWeekdays) && weekdaysValid; +} + function validateUserCondition(condition: UserCondition) { return condition.users != null; } @@ -310,6 +342,8 @@ export function validateConditionalConfig( switch (c.condition) { case "screen": return validateScreenCondition(c); + case "time": + return validateTimeCondition(c); case "user": return validateUserCondition(c); case "location": diff --git a/src/panels/lovelace/components/hui-conditional-base.ts b/src/panels/lovelace/components/hui-conditional-base.ts index 8c53e9fb0d81..7045cbe7f0b8 100644 --- a/src/panels/lovelace/components/hui-conditional-base.ts +++ b/src/panels/lovelace/components/hui-conditional-base.ts @@ -2,10 +2,7 @@ import type { PropertyValues } from "lit"; import { ReactiveElement } from "lit"; import { customElement, property, state } from "lit/decorators"; import type { HomeAssistant } from "../../../types"; -import { - ConditionalListenerMixin, - setupMediaQueryListeners, -} from "../../../mixins/conditional-listener-mixin"; +import { ConditionalListenerMixin } 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"; @@ -73,18 +70,13 @@ 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[]; - setupMediaQueryListeners( - supportedConditions, - this.hass, - (unsub) => this.addConditionalListener(unsub), - (conditionsMet) => { - this.setVisibility(conditionsMet); - } - ); + // Pass filtered conditions to parent implementation + super.setupConditionalListeners(supportedConditions); } protected update(changed: PropertyValues): void { @@ -102,17 +94,15 @@ export class HuiConditionalBase extends ConditionalListenerMixin( } } - private _updateVisibility() { + private _updateVisibility(conditionsMet?: boolean) { if (!this._element || !this.hass || !this._config) { return; } this._element.preview = this.preview; - const conditionMet = checkConditionsMet( - this._config!.conditions, - this.hass! - ); + const conditionMet = + conditionsMet ?? checkConditionsMet(this._config.conditions, this.hass); this.setVisibility(conditionMet); } diff --git a/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts b/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts index 3f29901a6c20..9a14d230eab1 100644 --- a/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts +++ b/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts @@ -25,6 +25,7 @@ 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"; @@ -33,6 +34,7 @@ const UI_CONDITION = [ "numeric_state", "state", "screen", + "time", "user", "and", "not", diff --git a/src/panels/lovelace/editor/conditions/types/ha-card-condition-time.ts b/src/panels/lovelace/editor/conditions/types/ha-card-condition-time.ts new file mode 100644 index 000000000000..3076d3d8270d --- /dev/null +++ b/src/panels/lovelace/editor/conditions/types/ha-card-condition-time.ts @@ -0,0 +1,102 @@ +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` + + `; + } + + private _valueChanged(ev: CustomEvent) { + ev.stopPropagation(); + const data = ev.detail.value as TimeCondition; + fireEvent(this, "value-changed", { value: data }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ): string => + this.hass.localize( + `ui.panel.lovelace.editor.condition-editor.condition.time.${schema.name}` + ); +} + +declare global { + interface HTMLElementTagNameMap { + "ha-card-condition-time": HaCardConditionTime; + } +} diff --git a/src/panels/lovelace/heading-badges/hui-heading-badge.ts b/src/panels/lovelace/heading-badges/hui-heading-badge.ts index 44b363e06ee3..3fecf885f690 100644 --- a/src/panels/lovelace/heading-badges/hui-heading-badge.ts +++ b/src/panels/lovelace/heading-badges/hui-heading-badge.ts @@ -4,10 +4,7 @@ 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, - setupMediaQueryListeners, -} from "../../../mixins/conditional-listener-mixin"; +import { ConditionalListenerMixin } 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"; @@ -133,21 +130,6 @@ export class HuiHeadingBadge extends ConditionalListenerMixin(ReactiveElement) { } } - 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; diff --git a/src/panels/lovelace/sections/hui-section.ts b/src/panels/lovelace/sections/hui-section.ts index f1d7eb102448..25c9c91d8490 100644 --- a/src/panels/lovelace/sections/hui-section.ts +++ b/src/panels/lovelace/sections/hui-section.ts @@ -13,10 +13,7 @@ import type { } from "../../../data/lovelace/config/section"; import { isStrategySection } from "../../../data/lovelace/config/section"; import type { HomeAssistant } from "../../../types"; -import { - ConditionalListenerMixin, - setupMediaQueryListeners, -} from "../../../mixins/conditional-listener-mixin"; +import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin"; import "../cards/hui-card"; import type { HuiCard } from "../cards/hui-card"; import { checkConditionsMet } from "../common/validate-condition"; @@ -152,21 +149,6 @@ export class HuiSection extends ConditionalListenerMixin(ReactiveElement) { } } - 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; diff --git a/src/translations/en.json b/src/translations/en.json index 451d68e353ac..ad89f7a42d2c 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7561,6 +7561,12 @@ "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", diff --git a/test/common/datetime/check_time.test.ts b/test/common/datetime/check_time.test.ts new file mode 100644 index 000000000000..4bf03d3ef1ef --- /dev/null +++ b/test/common/datetime/check_time.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { checkTimeInRange } from "../../../src/common/datetime/check_time"; +import type { HomeAssistant } from "../../../src/types"; +import { + NumberFormat, + TimeFormat, + FirstWeekday, + DateFormat, + TimeZone, +} from "../../../src/data/translation"; + +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 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 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); + }); + }); +});