Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/common/condition/extract.ts
Original file line number Diff line number Diff line change
@@ -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<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;
}, []);
}
72 changes: 72 additions & 0 deletions src/common/condition/listeners.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
73 changes: 73 additions & 0 deletions src/common/condition/time-calculator.ts
Original file line number Diff line number Diff line change
@@ -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<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);
}
107 changes: 107 additions & 0 deletions src/common/datetime/check_time.ts
Original file line number Diff line number Diff line change
@@ -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<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 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;
};
19 changes: 4 additions & 15 deletions src/common/datetime/first_weekday.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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];
};
59 changes: 59 additions & 0 deletions src/common/datetime/weekday.ts
Original file line number Diff line number Diff line change
@@ -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<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>;
Loading
Loading