Skip to content

Commit dceab44

Browse files
committed
Add time-based conditional visibility for cards
1 parent b2ec4b7 commit dceab44

File tree

19 files changed

+821
-172
lines changed

19 files changed

+821
-172
lines changed

src/common/condition/extract.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type {
2+
Condition,
3+
TimeCondition,
4+
} from "../../panels/lovelace/common/validate-condition";
5+
6+
/**
7+
* Extract media queries from conditions recursively
8+
*/
9+
export function extractMediaQueries(conditions: Condition[]): string[] {
10+
return conditions.reduce<string[]>((array, c) => {
11+
if ("conditions" in c && c.conditions) {
12+
array.push(...extractMediaQueries(c.conditions));
13+
}
14+
if (c.condition === "screen" && c.media_query) {
15+
array.push(c.media_query);
16+
}
17+
return array;
18+
}, []);
19+
}
20+
21+
/**
22+
* Extract time conditions from conditions recursively
23+
*/
24+
export function extractTimeConditions(
25+
conditions: Condition[]
26+
): TimeCondition[] {
27+
return conditions.reduce<TimeCondition[]>((array, c) => {
28+
if ("conditions" in c && c.conditions) {
29+
array.push(...extractTimeConditions(c.conditions));
30+
}
31+
if (c.condition === "time") {
32+
array.push(c);
33+
}
34+
return array;
35+
}, []);
36+
}

src/common/condition/listeners.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { listenMediaQuery } from "../dom/media_query";
2+
import type { HomeAssistant } from "../../types";
3+
import type { Condition } from "../../panels/lovelace/common/validate-condition";
4+
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
5+
import { extractMediaQueries, extractTimeConditions } from "./extract";
6+
import { calculateNextTimeUpdate } from "./time-calculator";
7+
8+
/**
9+
* Helper to setup media query listeners for conditional visibility
10+
*/
11+
export function setupMediaQueryListeners(
12+
conditions: Condition[],
13+
hass: HomeAssistant,
14+
addListener: (unsub: () => void) => void,
15+
onUpdate: (conditionsMet: boolean) => void
16+
): void {
17+
const mediaQueries = extractMediaQueries(conditions);
18+
19+
if (mediaQueries.length === 0) return;
20+
21+
// Optimization for single media query
22+
const hasOnlyMediaQuery =
23+
conditions.length === 1 &&
24+
conditions[0].condition === "screen" &&
25+
!!conditions[0].media_query;
26+
27+
mediaQueries.forEach((mediaQuery) => {
28+
const unsub = listenMediaQuery(mediaQuery, (matches) => {
29+
if (hasOnlyMediaQuery) {
30+
onUpdate(matches);
31+
} else {
32+
const conditionsMet = checkConditionsMet(conditions, hass);
33+
onUpdate(conditionsMet);
34+
}
35+
});
36+
addListener(unsub);
37+
});
38+
}
39+
40+
/**
41+
* Helper to setup time-based listeners for conditional visibility
42+
*/
43+
export function setupTimeListeners(
44+
conditions: Condition[],
45+
hass: HomeAssistant,
46+
addListener: (unsub: () => void) => void,
47+
onUpdate: (conditionsMet: boolean) => void
48+
): void {
49+
const timeConditions = extractTimeConditions(conditions);
50+
51+
if (timeConditions.length === 0) return;
52+
53+
timeConditions.forEach((timeCondition) => {
54+
const scheduleUpdate = () => {
55+
const delay = calculateNextTimeUpdate(hass, timeCondition);
56+
57+
if (delay === undefined) return;
58+
59+
const timeoutId = setTimeout(() => {
60+
const conditionsMet = checkConditionsMet(conditions, hass);
61+
onUpdate(conditionsMet);
62+
// Reschedule for next boundary
63+
scheduleUpdate();
64+
}, delay);
65+
66+
// Store cleanup function
67+
addListener(() => clearTimeout(timeoutId));
68+
};
69+
70+
scheduleUpdate();
71+
});
72+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { TZDate } from "@date-fns/tz";
2+
import {
3+
startOfDay,
4+
addDays,
5+
addMinutes,
6+
differenceInMilliseconds,
7+
} from "date-fns";
8+
import type { HomeAssistant } from "../../types";
9+
import { TimeZone } from "../../data/translation";
10+
import { parseTimeString } from "../datetime/check_time";
11+
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
12+
13+
/**
14+
* Calculate milliseconds until next time boundary for a time condition
15+
* @param hass Home Assistant object
16+
* @param timeCondition Time condition to calculate next update for
17+
* @returns Milliseconds until next boundary, or undefined if no boundaries
18+
*/
19+
export function calculateNextTimeUpdate(
20+
hass: HomeAssistant,
21+
{ after, before, weekdays }: Omit<TimeCondition, "condition">
22+
): number | undefined {
23+
const timezone =
24+
hass.locale.time_zone === TimeZone.server
25+
? hass.config.time_zone
26+
: Intl.DateTimeFormat().resolvedOptions().timeZone;
27+
28+
const now = new TZDate(new Date(), timezone);
29+
const updates: Date[] = [];
30+
31+
// Calculate next occurrence of after time
32+
if (after) {
33+
let afterDate = parseTimeString(after, timezone);
34+
if (afterDate <= now) {
35+
// If time has passed today, schedule for tomorrow
36+
afterDate = addDays(afterDate, 1);
37+
}
38+
updates.push(afterDate);
39+
}
40+
41+
// Calculate next occurrence of before time
42+
if (before) {
43+
let beforeDate = parseTimeString(before, timezone);
44+
if (beforeDate <= now) {
45+
// If time has passed today, schedule for tomorrow
46+
beforeDate = addDays(beforeDate, 1);
47+
}
48+
updates.push(beforeDate);
49+
}
50+
51+
// If weekdays are specified, check for midnight (weekday transition)
52+
if (weekdays && weekdays.length > 0 && weekdays.length < 7) {
53+
// Calculate next midnight using startOfDay + addDays
54+
const tomorrow = addDays(now, 1);
55+
const midnight = startOfDay(tomorrow);
56+
updates.push(midnight);
57+
}
58+
59+
if (updates.length === 0) {
60+
return undefined;
61+
}
62+
63+
// Find the soonest update time
64+
const nextUpdate = updates.reduce((soonest, current) =>
65+
current < soonest ? current : soonest
66+
);
67+
68+
// Add 1 minute buffer to ensure we're past the boundary
69+
const updateWithBuffer = addMinutes(nextUpdate, 1);
70+
71+
// Calculate difference in milliseconds
72+
return differenceInMilliseconds(updateWithBuffer, now);
73+
}

src/common/datetime/check_time.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { TZDate } from "@date-fns/tz";
2+
import { isBefore, isAfter, isWithinInterval } from "date-fns";
3+
import type { HomeAssistant } from "../../types";
4+
import { TimeZone } from "../../data/translation";
5+
import { WEEKDAY_MAP } from "./weekday";
6+
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
7+
8+
/**
9+
* Parse a time string (HH:MM or HH:MM:SS) and set it on today's date in the given timezone
10+
* @param timeString The time string to parse
11+
* @param timezone The timezone to use
12+
* @returns The Date object
13+
*/
14+
export const parseTimeString = (timeString: string, timezone: string): Date => {
15+
const parts = timeString.split(":");
16+
17+
if (parts.length < 2 || parts.length > 3) {
18+
throw new Error(
19+
`Invalid time format: ${timeString}. Expected HH:MM or HH:MM:SS`
20+
);
21+
}
22+
23+
const hours = parseInt(parts[0], 10);
24+
const minutes = parseInt(parts[1], 10);
25+
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
26+
27+
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
28+
throw new Error(`Invalid time values in: ${timeString}`);
29+
}
30+
31+
// Add range validation
32+
if (hours < 0 || hours > 23) {
33+
throw new Error(`Invalid hours in: ${timeString}. Must be 0-23`);
34+
}
35+
if (minutes < 0 || minutes > 59) {
36+
throw new Error(`Invalid minutes in: ${timeString}. Must be 0-59`);
37+
}
38+
if (seconds < 0 || seconds > 59) {
39+
throw new Error(`Invalid seconds in: ${timeString}. Must be 0-59`);
40+
}
41+
42+
const now = new TZDate(new Date(), timezone);
43+
const dateWithTime = new TZDate(
44+
now.getFullYear(),
45+
now.getMonth(),
46+
now.getDate(),
47+
hours,
48+
minutes,
49+
seconds,
50+
0,
51+
timezone
52+
);
53+
54+
return new Date(dateWithTime.getTime());
55+
};
56+
57+
/**
58+
* Check if the current time matches the time condition (after/before/weekday)
59+
* @param hass Home Assistant object
60+
* @param timeCondition Time condition to check
61+
* @returns true if current time matches the condition
62+
*/
63+
export const checkTimeInRange = (
64+
hass: HomeAssistant,
65+
{ after, before, weekdays }: Omit<TimeCondition, "condition">
66+
): boolean => {
67+
const timezone =
68+
hass.locale.time_zone === TimeZone.server
69+
? hass.config.time_zone
70+
: Intl.DateTimeFormat().resolvedOptions().timeZone;
71+
72+
const now = new TZDate(new Date(), timezone);
73+
74+
// Check weekday condition
75+
if (weekdays && weekdays.length > 0) {
76+
const currentWeekday = WEEKDAY_MAP[now.getDay()];
77+
if (!weekdays.includes(currentWeekday)) {
78+
return false;
79+
}
80+
}
81+
82+
// Check time conditions
83+
if (!after && !before) {
84+
return true;
85+
}
86+
87+
const afterDate = after ? parseTimeString(after, timezone) : undefined;
88+
const beforeDate = before ? parseTimeString(before, timezone) : undefined;
89+
90+
if (afterDate && beforeDate) {
91+
if (isBefore(beforeDate, afterDate)) {
92+
// Crosses midnight (e.g., 22:00 to 06:00)
93+
return isAfter(now, afterDate) || isBefore(now, beforeDate);
94+
}
95+
return isWithinInterval(now, { start: afterDate, end: beforeDate });
96+
}
97+
98+
if (afterDate) {
99+
return !isBefore(now, afterDate);
100+
}
101+
102+
if (beforeDate) {
103+
return !isAfter(now, beforeDate);
104+
}
105+
106+
return true;
107+
};
Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,7 @@
11
import { getWeekStartByLocale } from "weekstart";
22
import type { FrontendLocaleData } from "../../data/translation";
33
import { FirstWeekday } from "../../data/translation";
4-
5-
export const weekdays = [
6-
"sunday",
7-
"monday",
8-
"tuesday",
9-
"wednesday",
10-
"thursday",
11-
"friday",
12-
"saturday",
13-
] as const;
14-
15-
type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
4+
import { WEEKDAYS_LONG, type WeekdayIndex } from "./weekday";
165

176
export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
187
if (locale.first_weekday === FirstWeekday.language) {
@@ -23,12 +12,12 @@ export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
2312
}
2413
return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex;
2514
}
26-
return weekdays.includes(locale.first_weekday)
27-
? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex)
15+
return WEEKDAYS_LONG.includes(locale.first_weekday)
16+
? (WEEKDAYS_LONG.indexOf(locale.first_weekday) as WeekdayIndex)
2817
: 1;
2918
};
3019

3120
export const firstWeekday = (locale: FrontendLocaleData) => {
3221
const index = firstWeekdayIndex(locale);
33-
return weekdays[index];
22+
return WEEKDAYS_LONG[index];
3423
};

src/common/datetime/weekday.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
2+
3+
export type WeekdayShort =
4+
| "sun"
5+
| "mon"
6+
| "tue"
7+
| "wed"
8+
| "thu"
9+
| "fri"
10+
| "sat";
11+
12+
export type WeekdayLong =
13+
| "sunday"
14+
| "monday"
15+
| "tuesday"
16+
| "wednesday"
17+
| "thursday"
18+
| "friday"
19+
| "saturday";
20+
21+
export const WEEKDAYS_SHORT = [
22+
"sun",
23+
"mon",
24+
"tue",
25+
"wed",
26+
"thu",
27+
"fri",
28+
"sat",
29+
] as const satisfies readonly WeekdayShort[];
30+
31+
export const WEEKDAYS_LONG = [
32+
"sunday",
33+
"monday",
34+
"tuesday",
35+
"wednesday",
36+
"thursday",
37+
"friday",
38+
"saturday",
39+
] as const satisfies readonly WeekdayLong[];
40+
41+
export const WEEKDAY_MAP = {
42+
0: "sun",
43+
1: "mon",
44+
2: "tue",
45+
3: "wed",
46+
4: "thu",
47+
5: "fri",
48+
6: "sat",
49+
} as const satisfies Record<WeekdayIndex, WeekdayShort>;
50+
51+
export const WEEKDAY_SHORT_TO_LONG = {
52+
sun: "sunday",
53+
mon: "monday",
54+
tue: "tuesday",
55+
wed: "wednesday",
56+
thu: "thursday",
57+
fri: "friday",
58+
sat: "saturday",
59+
} as const satisfies Record<WeekdayShort, WeekdayLong>;

0 commit comments

Comments
 (0)