From 6b88771958fb4e8239f43b2f5cc340bd33a446b7 Mon Sep 17 00:00:00 2001 From: TheThomaas Date: Wed, 16 Sep 2026 13:50:55 +0200 Subject: [PATCH] Update cookcli-recipe-strategy.js --- dist/cookcli-recipe-strategy.js | 278 +++++++++++++++++++++----------- 1 file changed, 181 insertions(+), 97 deletions(-) diff --git a/dist/cookcli-recipe-strategy.js b/dist/cookcli-recipe-strategy.js index e629a4d..27c3564 100644 --- a/dist/cookcli-recipe-strategy.js +++ b/dist/cookcli-recipe-strategy.js @@ -11,21 +11,43 @@ * recette (un vrai `path` HA différent pour chacune), ce qui force un * remontage — donc une régénération — à chaque navigation. * + * La vue d'une recette est UNE SEULE carte tabdeck-card : un onglet "Résumé" + * (titre, image, ustensiles, tous les ingrédients à cocher, bouton + * "Commencer"), puis un onglet par étape (colonne gauche 30% : ingrédients + * de l'étape + minuteur ; colonne droite 70% : texte de l'étape ; boutons + * Précédent/Suivant en bas). + * + * Le ratio 30/70 n'a pas d'équivalent natif HA (grid/horizontal-stack ne + * font que des colonnes égales) : on s'appuie sur **card-mod** + * (https://github.com/thomasloven/lovelace-card-mod, HACS) pour forcer la + * largeur des deux colonnes d'un horizontal-stack. + * + * Pour Précédent/Suivant/Commencer, on n'utilise PAS le hash d'URL + * `#tab=...` de tabdeck (son comportement en navigation live après montage + * n'est pas documenté avec certitude) mais son mode `remember: entity` — + * documenté et fiable : l'onglet actif est piloté par la valeur d'un helper + * input_number, que nos boutons changent via `input_number.set_value`. + * tabdeck lit cette valeur pour choisir l'onglet actif et se met à jour + * comme n'importe quel autre entité HA quand elle change — un mécanisme + * standard, pas une supposition sur le comportement interne de la carte. + * * Cartes utilisées, toutes tierces ou natives — aucune carte custom pour le * rendu du contenu lui-même : - * - markdown (native) : titre, image, ustensiles, texte des étapes - * - todo-list (native) : checklist des ingrédients (todo.py backend) - * - button (native) : démarre le minuteur avec la bonne durée - * - custom:circular-timer-card : affichage/contrôle du minuteur partagé - * - custom:tabdeck-card : une tab par étape + * - markdown (native) : titre, image, ustensiles, texte des étapes + * - todo-list (native) : checklist des ingrédients (todo.py backend) + * - button (native) : Commencer / Précédent / Suivant / Démarrer minuteur + * - custom:circular-timer-card : affichage/contrôle du minuteur partagé + * - custom:tabdeck-card : les onglets (Résumé + une par étape) + * - custom:mod-card (card-mod) : force le ratio 30/70 des colonnes d'étape * - custom:cookcli-card : la liste (voir cookcli-card.js) * * Configuration du tableau de bord (remplace le contenu YAML du dashboard) : * strategy: * type: custom:cookcli * title: Recettes - * timer_entity: timer.recette_en_cours # créé manuellement, voir README - * entry_id: xxxx # optionnel, si plusieurs serveurs CookCLI + * timer_entity: timer.recette_en_cours # créé manuellement, voir README + * step_entity: input_number.recette_etape # créé manuellement, voir README + * entry_id: xxxx # optionnel, si plusieurs serveurs CookCLI */ class CookCliDashboardStrategy extends HTMLElement { @@ -44,8 +66,7 @@ class CookCliDashboardStrategy extends HTMLElement { const result = await hass.connection.sendMessagePromise(wsMsg); recipes = result.recipes || []; } catch (err) { - // Liste vide plutôt que planter tout le dashboard ; la carte affichera - // son propre message d'erreur au prochain essai de fetch côté carte. + // Liste vide plutôt que planter tout le dashboard. } const listView = { @@ -67,6 +88,7 @@ class CookCliDashboardStrategy extends HTMLElement { type: "custom:cookcli-recipe", path: recipe.path, timer_entity: config.timer_entity, + step_entity: config.step_entity, entry_id: config.entry_id, }, })); @@ -79,9 +101,8 @@ class CookCliDashboardStrategy extends HTMLElement { } /** - * View strategy : génère le contenu de la vue d'UNE recette. `config.path` - * est fourni par la dashboard strategy ci-dessus ; utilisable seule (une vue - * par recette écrite à la main) en le fixant directement dans le YAML. + * View strategy : génère le contenu de la vue d'UNE recette, sous forme + * d'une unique carte tabdeck-card (Résumé + une tab par étape). */ class CookCliRecipeViewStrategy extends HTMLElement { static async generate(config, hass) { @@ -111,111 +132,176 @@ class CookCliRecipeViewStrategy extends HTMLElement { }; } - const cards = []; - cards.push(this._headerCard(recipe)); - - if (config.timer_entity) { - cards.push({ - type: "custom:simple-timer-card", - style: "fill_horizontal", - show_active_header: false, - entities: [{ - entity: config.timer_entity, - keep_timer_visible_when_idle: true, - name: " " - }] - }); - cards.push({ - type: "horizontal-stack", - cards: [ - { - type: "markdown", - content: " ", - text_only: true - }, - { - type: "custom:circular-timer-card", - entity: config.timer_entity, - primary_info: "none" - } - ] - }); + // Repart de "Résumé" à chaque nouvelle recette ouverte, plutôt que de + // garder l'étape où on s'était arrêté sur la recette précédente (le + // helper input_number est partagé entre toutes les recettes). + if (config.step_entity) { + await this._setStepIndex(hass, config.step_entity, 0); } - if (recipe.todo_entity_id) { - cards.push({ - type: "todo-list", - entity: recipe.todo_entity_id, - title: "Ingrédients à rassembler", - card_mod: { - style: ` - ha-list .header { - display: none; - } - ` - } - }); + // Étapes à plat, avec leur index global (1-based ; 0 est réservé au + // "Résumé") pour piloter remember_entity. + const flatSteps = []; + for (const section of recipe.sections || []) { + for (const step of section.steps || []) { + flatSteps.push({ section, step }); + } } - const tabs = this._stepTabs(recipe, config.timer_entity); - if (tabs.length) { - cards.push({ type: "custom:tabdeck-card", tabs }); + const tabs = [this._summaryTab(recipe, config, flatSteps.length)]; + flatSteps.forEach(({ section, step }, i) => { + const tabIndex = i + 1; // 0 = Résumé + const isLast = tabIndex === flatSteps.length; + tabs.push( + this._stepTab(section, step, tabIndex, isLast, config) + ); + }); + + const tabdeckCard = { + type: "custom:tabdeck-card", + default_tab: 0, + tabs, + }; + if (config.step_entity) { + tabdeckCard.remember = "entity"; + tabdeckCard.remember_entity = config.step_entity; } return { title: recipe.title || "Recette", - cards, + cards: [tabdeckCard], }; } - static _headerCard(recipe) { + static async _setStepIndex(hass, entity, value) { + try { + await hass.connection.sendMessagePromise({ + type: "call_service", + domain: "input_number", + service: "set_value", + service_data: { entity_id: entity, value }, + }); + } catch (err) { + // Pas bloquant : au pire l'utilisateur atterrit sur le dernier onglet + // laissé actif plutôt que sur "Résumé". + } + } + + static _navButton(entity, targetIndex, label, icon) { + return { + type: "button", + name: label, + icon, + tap_action: { + action: "call-service", + service: "input_number.set_value", + target: { entity_id: entity }, + data: { value: targetIndex }, + }, + }; + } + + static _summaryTab(recipe, config, stepCount) { let content = ""; if (recipe.image_url) content += `![](${recipe.image_url})\n\n`; content += `## ${recipe.title || ""}\n`; if (recipe.cookware && recipe.cookware.length) { content += `\n**Ustensiles** : ${recipe.cookware.map((c) => c.name).join(", ")}\n`; } - return { type: "markdown", content }; + + const cards = [{ type: "markdown", content }]; + + if (recipe.todo_entity_id) { + cards.push({ + type: "todo-list", + entity: recipe.todo_entity_id, + title: "Ingrédients", + }); + } + + if (config.step_entity && stepCount > 0) { + cards.push(this._navButton(config.step_entity, 1, "Commencer", "mdi:play")); + } + + return { name: "Résumé", icon: "mdi:book-open-variant", card: { type: "vertical-stack", cards } }; } - static _stepTabs(recipe, timerEntity) { - const tabs = []; - let stepCounter = 0; + static _stepTab(section, step, tabIndex, isLast, config) { + const { markdown: stepMarkdown, timers } = this._renderStepMarkdown(step); - for (const section of recipe.sections || []) { - for (const step of section.steps || []) { - stepCounter += 1; - const { markdown, timers } = this._renderStepMarkdown(step); + const leftCards = []; + const stepIngredients = (step.items || []).filter((item) => item.type === "ingredient"); + if (stepIngredients.length) { + const lines = stepIngredients.map((item) => { + const qty = item.quantity + ? `${item.quantity.value ?? ""} ${item.quantity.unit ?? ""}`.trim() + : ""; + return `- ${qty ? `**${qty}** ` : ""}${item.name}`; + }); + leftCards.push({ type: "markdown", content: lines.join("\n") }); + } - const stepCards = [{ type: "markdown", content: markdown }]; - - if (timerEntity) { - for (const timer of timers) { - const seconds = this._parseDurationSeconds(timer.duration, timer.unit); - if (!seconds) continue; - const label = `${timer.duration ?? ""} ${timer.unit ?? ""}`.trim(); - stepCards.push({ - type: "button", - name: `Démarrer ${label}`, - icon: "mdi:timer-outline", - tap_action: { - action: "call-service", - service: "timer.start", - target: { entity_id: timerEntity }, - data: { duration: this._secondsToHms(seconds) }, - }, - }); - } - } - - tabs.push({ - name: section.name ? `${section.name} ${step.number ?? ""}`.trim() : `Étape ${stepCounter}`, - card: { type: "vertical-stack", cards: stepCards }, + if (config.timer_entity) { + for (const timer of timers) { + const seconds = this._parseDurationSeconds(timer.duration, timer.unit); + if (!seconds) continue; + const label = `${timer.duration ?? ""} ${timer.unit ?? ""}`.trim(); + leftCards.push({ + type: "button", + name: `Démarrer ${label}`, + icon: "mdi:timer-outline", + tap_action: { + action: "call-service", + service: "timer.start", + target: { entity_id: config.timer_entity }, + data: { duration: this._secondsToHms(seconds) }, + }, }); + leftCards.push({ type: "custom:circular-timer-card", entity: config.timer_entity }); } } - return tabs; + if (!leftCards.length) { + // horizontal-stack veut deux cartes ; une carte vide maintient le ratio. + leftCards.push({ type: "markdown", content: "" }); + } + + const columns = { + type: "custom:mod-card", + card_mod: { + style: + "hui-horizontal-stack-card $: | " + + "div#root > :first-child { width: 30%; flex: auto; } " + + "div#root > :last-child { width: 70%; flex: auto; }", + }, + card: { + type: "horizontal-stack", + cards: [ + { type: "vertical-stack", cards: leftCards }, + { type: "markdown", content: stepMarkdown }, + ], + }, + }; + + const cards = [columns]; + + if (config.step_entity) { + const navButtons = [ + this._navButton(config.step_entity, tabIndex - 1, "Précédent", "mdi:arrow-left"), + ]; + if (!isLast) { + navButtons.push( + this._navButton(config.step_entity, tabIndex + 1, "Suivant", "mdi:arrow-right") + ); + } + cards.push({ type: "horizontal-stack", cards: navButtons }); + } + + return { + name: section.name ? `${section.name} ${step.number ?? ""}`.trim() : `Étape ${tabIndex}`, + icon: "mdi:numeric-" + (tabIndex <= 9 ? tabIndex : "9-plus") + "-circle-outline", + card: { type: "vertical-stack", cards }, + }; } static _renderStepMarkdown(step) { @@ -254,9 +340,8 @@ class CookCliRecipeViewStrategy extends HTMLElement { * `duration` peut être un nombre (secondes/minutes selon `unit`) ou du * texte libre façon "2-3 minutes" (Cooklang autorise les plages en texte * libre) — dans ce cas on prend le premier nombre trouvé, ce qui donne une - * estimation basse ; ajustable ensuite via circular-timer-card (tap = - * toggle, double-tap = annuler, appui long = plus d'infos) ou en relançant - * timer.start avec une autre durée. + * estimation basse ; ajustable ensuite en relançant le bouton "Démarrer" + * de l'étape (pas d'édition de durée à la volée sur circular-timer-card). */ static _parseDurationSeconds(duration, unit) { let value = null; @@ -280,7 +365,6 @@ class CookCliRecipeViewStrategy extends HTMLElement { if (unitHint.includes("sec") || unitHint === "s") { return Math.round(value); } - // Par défaut (minutes, ou pas d'unité reconnue) : on suppose des minutes. return Math.round(value * 60); } @@ -302,4 +386,4 @@ window.customStrategies.push({ name: "CookCLI", description: "Dashboard de recettes CookCLI : liste + une vue par recette.", documentationURL: "https://git.thethomaas.net/TheThomaas/ha-cookcli", -}); +}); \ No newline at end of file