/**
* reveil-card — carte Lovelace pour l'intégration « Réveil ».
*
* Configuration minimale :
* type: custom:reveil-card
* device_id: 0123456789abcdef # l'appareil « Réveil » (sélecteur dans l'éditeur)
* name: Réveil semaine # facultatif
*
* La carte retrouve toutes les entités de l'appareil via leur clé de traduction
* (donc quelle que soit la langue). L'ancienne option `entity` (n'importe quelle
* entité du réveil) reste acceptée.
* Aucune dépendance : composant web natif.
*/
const CARD_VERSION = "0.1.0";
const DAYS = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
const TEXTS = {
fr: {
today: "Aujourd'hui",
tomorrow: "Demain",
none: "Aucun réveil programmé",
off: "Réveil désactivé",
now: "Maintenant",
lessThanMinute: "dans moins d'une minute",
rel: (d, h, m) =>
d
? `dans ${d} j ${h} h`
: h
? `dans ${h} h ${String(m).padStart(2, "0")}`
: `dans ${m} min`,
lightFrom: (t) => `Lumière dès ${t}`,
lightRising: "La lumière monte",
defaultTime: "Heure par défaut",
dayTime: (d) => `Heure du ${d}`,
save: "Enregistrer",
useDefault: "Utiliser l'heure par défaut",
cancel: "Annuler",
awake: "Je suis réveillé",
toggleAlarm: "Activer ou désactiver le réveil",
toggleDay: (d) => `Activer ou désactiver le ${d}`,
editTime: (d) => `Modifier l'heure du ${d}`,
missing: (e) => `Entité introuvable : ${e}`,
notReveil: "Cet appareil ou cette entité n'appartient pas à l'intégration Réveil.",
unsupported:
"Cette version de Home Assistant n'expose pas les clés de traduction des entités. Mettez Home Assistant à jour.",
},
en: {
today: "Today",
tomorrow: "Tomorrow",
none: "No alarm scheduled",
off: "Alarm disabled",
now: "Now",
lessThanMinute: "in less than a minute",
rel: (d, h, m) =>
d
? `in ${d} d ${h} h`
: h
? `in ${h} h ${String(m).padStart(2, "0")} min`
: `in ${m} min`,
lightFrom: (t) => `Light from ${t}`,
lightRising: "Light is rising",
defaultTime: "Default time",
dayTime: (d) => `${d} time`,
save: "Save",
useDefault: "Use default time",
cancel: "Cancel",
awake: "I'm awake",
toggleAlarm: "Turn the alarm on or off",
toggleDay: (d) => `Turn ${d} on or off`,
editTime: (d) => `Edit ${d} time`,
missing: (e) => `Entity not found: ${e}`,
notReveil: "This device or entity does not belong to the Réveil integration.",
unsupported:
"This Home Assistant version does not expose entity translation keys. Please update Home Assistant.",
},
};
const esc = (s) =>
String(s).replace(
/[&<>"']/g,
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]
);
const capitalize = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
/** "07:00:00" -> "07:00:00" ; "unknown" / undefined -> null */
const validTime = (state) => (/^\d{2}:\d{2}/.test(state || "") ? state : null);
const STYLE = `
:host { display: block; }
ha-card { padding: 16px; }
button { font: inherit; }
button:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.msg { color: var(--error-color, #db4437); font-size: 0.9rem; }
.top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.name {
font-size: 1rem; font-weight: 500; color: var(--primary-text-color);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sw {
position: relative; flex: none; width: 44px; height: 26px; padding: 0;
border: 0; border-radius: 13px; cursor: pointer;
background: var(--switch-unchecked-track-color, var(--divider-color));
transition: background 0.15s;
}
.sw::after {
content: ""; position: absolute; top: 3px; left: 3px;
width: 20px; height: 20px; border-radius: 50%; background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); transition: transform 0.15s;
}
.sw[aria-checked="true"] { background: var(--primary-color); }
.sw[aria-checked="true"]::after { transform: translateX(18px); }
.body { transition: opacity 0.2s; }
.off .body { opacity: 0.5; }
.next { margin-top: 14px; }
.when { font-size: 0.875rem; color: var(--secondary-text-color); }
.nextrow { display: flex; align-items: baseline; flex-wrap: wrap; column-gap: 12px; }
.time {
font-size: 3.25rem; font-weight: 300; line-height: 1.05; letter-spacing: -0.02em;
font-variant-numeric: tabular-nums; color: var(--primary-text-color);
}
.rel { font-size: 0.95rem; color: var(--secondary-text-color); }
.dawn { margin-top: 14px; }
.track { height: 6px; border-radius: 3px; background: var(--divider-color); overflow: hidden; }
.fill {
height: 100%; width: calc(var(--p, 0) * 1%);
background: linear-gradient(90deg, #35407a, #b86f6a 45%, #e9a95b 75%, #f6d58f);
background-size: calc(10000% / max(var(--p, 1), 1)) 100%;
}
.cap { margin-top: 6px; font-size: 0.8rem; color: var(--secondary-text-color); }
.days {
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px; margin-top: 18px;
}
.day { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.chip {
width: 100%; max-width: 48px; height: 40px; border-radius: 20px;
border: 1px solid var(--divider-color); background: transparent;
color: var(--secondary-text-color); font-size: 0.85rem; cursor: pointer;
}
.day.on .chip {
background: var(--primary-color); border-color: var(--primary-color);
color: var(--text-primary-color, #fff);
}
.hhmm {
min-height: 32px; padding: 6px 4px; border: 0; border-radius: 6px; background: none;
font-size: 0.75rem; color: var(--secondary-text-color); cursor: pointer;
font-variant-numeric: tabular-nums;
}
.day.ovr .hhmm { color: var(--primary-text-color); font-weight: 600; }
.day:not(.on) .hhmm { opacity: 0.55; }
.hhmm[aria-expanded="true"] { background: var(--secondary-background-color); }
.editor {
margin-top: 10px; padding: 12px; border-radius: 10px;
background: var(--secondary-background-color); animation: open 0.18s ease-out;
}
.editor label { display: block; margin-bottom: 6px; font-size: 0.85rem; color: var(--secondary-text-color); }
.editor input {
font: inherit; font-size: 1.25rem; padding: 6px 8px; border-radius: 8px;
border: 1px solid var(--divider-color); background: var(--card-background-color);
color: var(--primary-text-color); font-variant-numeric: tabular-nums;
}
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.btn {
padding: 8px 12px; border-radius: 8px; font-size: 0.85rem; cursor: pointer;
border: 1px solid var(--divider-color); background: transparent;
color: var(--primary-text-color);
}
.btn.primary {
background: var(--primary-color); border-color: var(--primary-color);
color: var(--text-primary-color, #fff);
}
.foot {
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 8px; margin-top: 10px;
}
.linkbtn {
padding: 6px 0; border: 0; background: none; cursor: pointer; font-size: 0.85rem;
color: var(--secondary-text-color); text-decoration: underline dotted;
text-underline-offset: 3px; font-variant-numeric: tabular-nums;
}
@keyframes open { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
.editor { animation: none; }
.sw, .sw::after, .body { transition: none; }
}
`;
class ReveilCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._editing = null; // "default" | "monday" | ... | null
this._draft = "";
this._sig = null;
this._timer = null;
this._live = null;
this._focusInput = false;
this.shadowRoot.addEventListener("click", (ev) => this._onClick(ev));
this.shadowRoot.addEventListener("input", (ev) => {
if (ev.target.matches && ev.target.matches("input[type=time]")) {
this._draft = ev.target.value;
}
});
}
// ---- API Lovelace -------------------------------------------------------
setConfig(config) {
if (!config || !(config.device_id || config.entity)) {
throw new Error("Choisissez l'appareil Réveil (« device_id »).");
}
this._config = { ...config };
this._sig = null;
this._resolvedFor = null;
this._resolved = null;
if (this._hass) this._update();
}
set hass(hass) {
this._hass = hass;
this._update();
}
getCardSize() {
return 4;
}
getGridOptions() {
return { columns: 12, min_columns: 6 };
}
static getConfigElement() {
return document.createElement("reveil-card-editor");
}
static getStubConfig(hass) {
const found = Object.values((hass && hass.entities) || {}).find(
(e) => e.platform === "reveil" && e.device_id
);
return { device_id: found ? found.device_id : "" };
}
connectedCallback() {
this._timer = setInterval(() => this._paintLive(), 30000);
this._paintLive();
}
disconnectedCallback() {
clearInterval(this._timer);
this._timer = null;
}
// ---- Utilitaires --------------------------------------------------------
_lang() {
return (this._hass && this._hass.language) || "en";
}
_t() {
return this._lang().toLowerCase().startsWith("fr") ? TEXTS.fr : TEXTS.en;
}
_fmt(value) {
let date = value;
if (!(value instanceof Date)) {
const [h, m] = String(value).split(":").map(Number);
date = new Date(2000, 0, 1, h, m);
}
const opts = { hour: "2-digit", minute: "2-digit" };
const tf = this._hass.locale && this._hass.locale.time_format;
if (tf === "12") opts.hour12 = true;
else if (tf === "24") opts.hourCycle = "h23";
return date.toLocaleTimeString(this._lang(), opts);
}
_dayName(index, style) {
// 2024-01-01 est un lundi
const name = new Date(2024, 0, 1 + index).toLocaleDateString(this._lang(), {
weekday: style,
});
return capitalize(name.replace(/\.$/, ""));
}
_dayLabel(next) {
const L = this._t();
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
if (next.toDateString() === now.toDateString()) return L.today;
if (next.toDateString() === tomorrow.toDateString()) return L.tomorrow;
return capitalize(
next.toLocaleDateString(this._lang(), { weekday: "long", day: "numeric", month: "long" })
);
}
_callService(domain, service, data) {
Promise.resolve(this._hass.callService(domain, service, data)).catch((err) =>
console.error(`reveil-card: ${domain}.${service} a échoué`, err)
);
}
// ---- Résolution des entités --------------------------------------------
_resolve() {
const hass = this._hass;
if (this._resolved && this._resolvedFor === hass.entities) return this._resolved;
const registry = hass.entities || {};
let deviceId = this._config.device_id;
let result;
if (!deviceId) {
// Compatibilité : n'importe quelle entité du réveil
const ref = registry[this._config.entity];
if (!ref) result = { error: "missing" };
else if ((ref.platform && ref.platform !== "reveil") || !ref.device_id) {
result = { error: "notReveil" };
} else deviceId = ref.device_id;
}
if (deviceId) {
const map = {};
let found = false;
for (const e of Object.values(registry)) {
if (e.device_id !== deviceId) continue;
if (e.platform && e.platform !== "reveil") continue;
found = true;
if (e.translation_key) map[e.translation_key] = e.entity_id;
}
if (!found) {
result = { error: "notReveil" };
} else if (!map.enabled || !map.default_time) {
result = { error: Object.keys(map).length ? "notReveil" : "unsupported" };
} else {
const device = hass.devices && hass.devices[deviceId];
result = {
map,
name:
this._config.name ||
(device && (device.name_by_user || device.name)) ||
"Réveil",
};
}
}
this._resolvedFor = hass.entities;
this._resolved = result;
return result;
}
// ---- Rendu --------------------------------------------------------------
_update() {
if (!this._hass || !this._config) return;
const r = this._resolve();
const sig = r.error
? `err:${r.error}`
: Object.keys(r.map)
.sort()
.map((k) => `${k}=${this._hass.states[r.map[k]]?.state}`)
.join("|") + `|${this._editing}|${this._lang()}`;
if (sig === this._sig) return;
this._sig = sig;
this._render(r);
}
_render(r) {
const root = this.shadowRoot;
const L = this._t();
if (r.error) {
const text =
r.error === "missing"
? L.missing(this._config.entity)
: r.error === "notReveil"
? L.notReveil
: L.unsupported;
root.innerHTML = `