Dashboard Studio 1.14.0: new cards, kiosk announcements, guest mode, templates, sharing and phone app
- Seven new cards: parcels, air quality, water and gas usage, solar forecast, garage and gate, sports, Formula One - Kiosk: announcements from automations, tab rotation, guest mode, click sound - Editor: templates, widget presets, undo history, alignment guides, Lovelace import, share link and QR code, theme variants, suggestions, touch and hold actions - Phones: installable app with shortcuts, offline states, tab swipe, pull to refresh, bottom sheets, haptics, phone/large visibility, landscape layout, editing on the phone, away tab - Fixed: the weather forecast and to-do list could stay on Loading… Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#24304f"/><stop offset="1" stop-color="#0f1526"/></linearGradient>
|
||||
<linearGradient id="lamp" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffe3a3"/><stop offset="1" stop-color="#f6b955"/></linearGradient>
|
||||
<radialGradient id="glow" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="#ffcf70" stop-opacity=".45"/><stop offset="1" stop-color="#ffcf70" stop-opacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
<ellipse cx="160" cy="260" rx="150" ry="210" fill="url(#glow)"/>
|
||||
<rect x="76" y="76" width="172" height="360" rx="36" fill="url(#lamp)"/>
|
||||
<circle cx="124" cy="124" r="22" fill="#fff6e4"/><circle cx="124" cy="124" r="10" fill="#7a4b14"/>
|
||||
<rect x="100" y="364" width="124" height="36" rx="18" fill="#c48a3a" opacity=".55"/>
|
||||
<rect x="100" y="364" width="72" height="36" rx="18" fill="#7a4b14"/>
|
||||
<rect x="276" y="76" width="160" height="172" rx="36" fill="#2c3754" stroke="#3d4a6b" stroke-width="3"/>
|
||||
<path d="M300 196l44-36 30 22 40-62" fill="none" stroke="#5aa2ff" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="276" y="264" width="160" height="172" rx="36" fill="#2c3754" stroke="#3d4a6b" stroke-width="3"/>
|
||||
<rect x="300" y="326" width="112" height="52" rx="26" fill="#34c759"/>
|
||||
<circle cx="386" cy="352" r="21" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
+951
-43
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -398,6 +398,222 @@
|
||||
"Unlocked, tap to lock": "Open, tik om op slot te doen",
|
||||
"Location: {0}": "Locatie: {0}",
|
||||
"Inside {0}°": "Binnen {0}°",
|
||||
"Parcels": "Pakketten",
|
||||
"Parcel sensors (PostNL, DHL, …)": "Pakketsensoren (PostNL, DHL, …)",
|
||||
"Show parcels delivered in the last two days": "Pakketten tonen die de afgelopen twee dagen zijn bezorgd",
|
||||
"a parcel sensor": "een pakketsensor",
|
||||
"Parcel": "Pakket",
|
||||
"Delivered": "Bezorgd",
|
||||
"On the way": "Onderweg",
|
||||
"parcel on the way": "pakket onderweg",
|
||||
"parcels on the way": "pakketten onderweg",
|
||||
"{0} today": "{0} vandaag",
|
||||
"No parcels on the way.": "Geen pakketten onderweg.",
|
||||
"Yesterday": "Gisteren",
|
||||
"Air quality": "Luchtkwaliteit",
|
||||
"Sensors (leave empty to find CO₂, fine dust and humidity per room)": "Sensoren (laat leeg om CO₂, fijnstof en luchtvochtigheid per ruimte te zoeken)",
|
||||
"No air quality sensors found. Add CO₂, fine dust or humidity sensors in the inspector.": "Geen luchtkwaliteitssensoren gevonden. Voeg CO₂-, fijnstof- of vochtigheidssensoren toe in de inspector.",
|
||||
"Good": "Goed",
|
||||
"Fair": "Matig",
|
||||
"Poor": "Slecht",
|
||||
"Open a window": "Zet een raam open",
|
||||
"Air the room or turn on the purifier": "Lucht de ruimte of zet de luchtreiniger aan",
|
||||
"Too humid, air the room": "Te vochtig, lucht de ruimte",
|
||||
"The air is dry": "De lucht is droog",
|
||||
"Water and gas": "Water en gas",
|
||||
"Meter (water, gas or energy)": "Meter (water, gas of energie)",
|
||||
"Type": "Soort",
|
||||
"water": "Water",
|
||||
"gas": "Gas",
|
||||
"electricity": "Stroom",
|
||||
"Days to show": "Aantal dagen",
|
||||
"a meter": "een meter",
|
||||
"No statistics for this meter. It needs a state class (total or total_increasing).": "Geen statistieken voor deze meter. Hij heeft een state class nodig (total of total_increasing).",
|
||||
"Today so far": "Vandaag tot nu",
|
||||
"vs yesterday": "t.o.v. gisteren",
|
||||
"Average": "Gemiddeld",
|
||||
"{0} days": "{0} dagen",
|
||||
"Average {0}": "Gemiddeld {0}",
|
||||
"Solar forecast": "Zonneverwachting",
|
||||
"Production today (kWh)": "Opbrengst vandaag (kWh)",
|
||||
"Production tomorrow (kWh)": "Opbrengst morgen (kWh)",
|
||||
"Power now (W)": "Vermogen nu (W)",
|
||||
"Hours for the washer or dishwasher": "Uren voor de wasmachine of vaatwasser",
|
||||
"the production today sensor": "de sensor voor de opbrengst vandaag",
|
||||
"The hour by hour forecast isn’t available.": "De verwachting per uur is niet beschikbaar.",
|
||||
"Best time for the washer: {0}": "Beste moment voor de was: {0}",
|
||||
"Garage and gate": "Garage en poort",
|
||||
"Garage door, gate or lock": "Garagedeur, poort of slot",
|
||||
"garage": "Garage",
|
||||
"gate": "Poort",
|
||||
"door": "Deur",
|
||||
"Ask before opening or closing": "Vragen voor openen of sluiten",
|
||||
"a garage door or gate": "een garagedeur of poort",
|
||||
"Press": "Indrukken",
|
||||
"by {0}": "door {0}",
|
||||
"Open {0}?": "{0} openen?",
|
||||
"Close {0}?": "{0} sluiten?",
|
||||
"Lock {0}?": "{0} op slot doen?",
|
||||
"Unlock {0}?": "{0} ontgrendelen?",
|
||||
"Stop {0}?": "{0} stoppen?",
|
||||
"Press {0}?": "{0} indrukken?",
|
||||
"Sports": "Sport",
|
||||
"Team (TeamTracker sensor)": "Team (TeamTracker-sensor)",
|
||||
"Only show on match days": "Alleen tonen op wedstrijddagen",
|
||||
"Show the venue and TV channel": "Stadion en tv-zender tonen",
|
||||
"a TeamTracker sensor": "een TeamTracker-sensor",
|
||||
"No match this round.": "Geen wedstrijd deze speelronde.",
|
||||
"No match planned.": "Geen wedstrijd gepland.",
|
||||
"Live": "Live",
|
||||
"Full time": "Afgelopen",
|
||||
"Formula One": "Formule 1",
|
||||
"Next race": "Volgende race",
|
||||
"Driver standings": "Coureursklassement",
|
||||
"Constructor standings": "Constructeursklassement",
|
||||
"Last race results": "Uitslag laatste race",
|
||||
"Show first": "Eerst tonen",
|
||||
"drivers": "Coureurs",
|
||||
"teams": "Teams",
|
||||
"results": "Uitslag",
|
||||
"Positions to show": "Aantal posities",
|
||||
"the next race sensor": "de sensor voor de volgende race",
|
||||
"in {0} d {1} h": "over {0} d {1} u",
|
||||
"in {0} h {1} min": "over {0} u {1} min",
|
||||
"Drivers": "Coureurs",
|
||||
"Teams": "Teams",
|
||||
"Last race": "Laatste race",
|
||||
"{0} wins": "{0} zeges",
|
||||
"pts": "pnt",
|
||||
"Round {0}": "Ronde {0}",
|
||||
"No standings yet.": "Nog geen klassement.",
|
||||
"Practice 1": "Training 1",
|
||||
"Practice 2": "Training 2",
|
||||
"Practice 3": "Training 3",
|
||||
"Sprint qualifying": "Sprintkwalificatie",
|
||||
"Sprint": "Sprint",
|
||||
"Qualifying": "Kwalificatie",
|
||||
"Race": "Race",
|
||||
"yesterday {0}": "gisteren {0}",
|
||||
"Go to the next tab every (seconds)": "Naar het volgende tabblad elke (seconden)",
|
||||
"Show announcements from Home Assistant": "Mededelingen uit Home Assistant tonen",
|
||||
"Guest mode": "Gastmodus",
|
||||
"A simple screen with only lights and thermostats, for guests or a babysitter.": "Een eenvoudig scherm met alleen lampen en thermostaten, voor gasten of de oppas.",
|
||||
"Turns on": "Gaat aan",
|
||||
"With the button": "Met de knop",
|
||||
"With an entity": "Met een entiteit",
|
||||
"On while this is on": "Aan zolang dit aan staat",
|
||||
"Welcome text": "Welkomsttekst",
|
||||
"Welcome": "Welkom",
|
||||
"Lights and thermostats (leave empty for all)": "Lampen en thermostaten (laat leeg voor alle)",
|
||||
"End guest mode on this device": "Gastmodus op dit apparaat beëindigen",
|
||||
"Start guest mode on this device": "Gastmodus op dit apparaat starten",
|
||||
"Announcement": "Mededeling",
|
||||
"Guest mode is on.": "Gastmodus staat aan.",
|
||||
"Guest mode is off.": "Gastmodus staat uit.",
|
||||
"Lights and temperature": "Lampen en temperatuur",
|
||||
"End guest mode?": "Gastmodus beëindigen?",
|
||||
"Guest mode was turned on by a schedule or an entity, and comes back when that says so.": "Gastmodus is aangezet door een tijdschema of entiteit, en komt terug zodra die dat aangeeft.",
|
||||
"End guest mode": "Gastmodus beëindigen",
|
||||
"Moved widgets around": "Widgets verplaatst",
|
||||
"Added {0}": "{0} toegevoegd",
|
||||
"Added {0} widgets": "{0} widgets toegevoegd",
|
||||
"Deleted {0}": "{0} verwijderd",
|
||||
"Deleted {0} widgets": "{0} widgets verwijderd",
|
||||
"Moved {0}": "{0} verplaatst",
|
||||
"Moved {0} widgets": "{0} widgets verplaatst",
|
||||
"Resized {0}": "Formaat van {0} gewijzigd",
|
||||
"Resized {0} widgets": "Formaat van {0} widgets gewijzigd",
|
||||
"Edited {0}": "{0} bewerkt",
|
||||
"Edited {0} widgets": "{0} widgets bewerkt",
|
||||
"Changed the tabs": "Tabbladen gewijzigd",
|
||||
"Changed the look": "Uiterlijk gewijzigd",
|
||||
"Changed the grid": "Raster gewijzigd",
|
||||
"Changed the kiosk settings": "Kioskinstellingen gewijzigd",
|
||||
"Changed guest mode": "Gastmodus gewijzigd",
|
||||
"Renamed the dashboard": "Dashboard hernoemd",
|
||||
"Changed the dashboard": "Dashboard gewijzigd",
|
||||
"No changes yet in this session.": "Nog geen wijzigingen in deze sessie.",
|
||||
"Undo history": "Geschiedenis van wijzigingen",
|
||||
"Undo history…": "Geschiedenis van wijzigingen…",
|
||||
"Pick a change to go back to just after it. Redo brings the later ones back.": "Kies een wijziging om terug te gaan naar net daarna. Opnieuw brengt de latere wijzigingen terug.",
|
||||
"Before your changes": "Voor je wijzigingen",
|
||||
"Save as preset": "Bewaren als voorinstelling",
|
||||
"My presets": "Mijn voorinstellingen",
|
||||
"Delete preset “{0}”": "Voorinstelling “{0}” verwijderen",
|
||||
"Save": "Bewaren",
|
||||
"Saved “{0}”. You’ll find it at the top of the widget list.": "“{0}” bewaard. Je vindt hem bovenaan de lijst met widgets.",
|
||||
"Widgets you already added from it stay.": "Widgets die je er al mee hebt toegevoegd blijven staan.",
|
||||
"Wall tablet": "Wandtablet",
|
||||
"Clock, weather, calendar, rooms and cameras for a screen on the wall.": "Klok, weer, agenda, ruimtes en camera’s voor een scherm aan de muur.",
|
||||
"Phone": "Telefoon",
|
||||
"A 4-column layout for your phone: people, lights, climate and media.": "Een indeling met 4 kolommen voor je telefoon: personen, lampen, klimaat en media.",
|
||||
"Power flow, solar forecast, water and gas use and history graphs.": "Energiestromen, zonneverwachting, water- en gasverbruik en grafieken.",
|
||||
"Power, last 24 hours": "Vermogen, afgelopen 24 uur",
|
||||
"Family": "Gezin",
|
||||
"Who’s home, the family calendar, shopping list, parcels and the weather.": "Wie er thuis is, de gezinsagenda, boodschappenlijst, pakketten en het weer.",
|
||||
"Empty": "Leeg",
|
||||
"Start with a blank grid.": "Begin met een leeg raster.",
|
||||
"From my devices": "Vanuit mijn apparaten",
|
||||
"A mix of your lights, sensors and devices.": "Een mix van je lampen, sensoren en apparaten.",
|
||||
"Start from": "Beginnen met",
|
||||
"Switch to a dark or light version with the device": "Wisselen naar een donkere of lichte versie met het apparaat",
|
||||
"QR code for {0}": "QR-code voor {0}",
|
||||
"Share “{0}”": "“{0}” delen",
|
||||
"Open this link or scan the code on another device that is signed in to Home Assistant.": "Open deze link of scan de code op een ander apparaat dat is ingelogd bij Home Assistant.",
|
||||
"Link": "Link",
|
||||
"Open in kiosk mode": "Openen in kioskmodus",
|
||||
"Link copied.": "Link gekopieerd.",
|
||||
"Share…": "Delen…",
|
||||
"Import from Home Assistant…": "Importeren uit Home Assistant…",
|
||||
"Import from Home Assistant": "Importeren uit Home Assistant",
|
||||
"Choose one of your Home Assistant dashboards. Its cards become widgets in a new dashboard here; the original stays as it is.": "Kies een van je Home Assistant-dashboards. De kaarten worden widgets in een nieuw dashboard hier; het origineel blijft zoals het is.",
|
||||
"Import": "Importeren",
|
||||
"Couldn’t load that dashboard: {0}": "Dat dashboard kon niet worden geladen: {0}",
|
||||
"That dashboard is generated by Home Assistant and has no cards of its own to import.": "Dat dashboard wordt door Home Assistant zelf gemaakt en heeft geen eigen kaarten om te importeren.",
|
||||
"Imported {0} widgets. {1} cards couldn’t be converted.": "{0} widgets geïmporteerd. {1} kaarten konden niet worden omgezet.",
|
||||
"Imported {0} widgets.": "{0} widgets geïmporteerd.",
|
||||
"That shortcut’s widget no longer exists.": "De widget van die snelkoppeling bestaat niet meer.",
|
||||
"Done: {0}": "Gedaan: {0}",
|
||||
"Home screen": "Beginscherm",
|
||||
"Shortcut on the app icon": "Snelkoppeling op het app-pictogram",
|
||||
"When Dashboard Studio is installed as an app on Android, long-press its icon to run this widget straight away. Up to four shortcuts.": "Als Dashboard Studio als app op Android is geïnstalleerd, houd je het pictogram ingedrukt om deze widget meteen te gebruiken. Maximaal vier snelkoppelingen.",
|
||||
"Install as an app…": "Installeren als app…",
|
||||
"Install as an app": "Installeren als app",
|
||||
"Home Assistant’s page can’t be installed itself. Open Dashboard Studio in its own tab first, then choose Install as an app again there.": "De pagina van Home Assistant zelf kan niet worden geïnstalleerd. Open Dashboard Studio eerst in een eigen tabblad en kies daar opnieuw Installeren als app.",
|
||||
"In Safari, tap the Share button, then Add to Home Screen.": "Tik in Safari op de deelknop en daarna op Zet op beginscherm.",
|
||||
"Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.": "Je browser kan Dashboard Studio installeren als app met een eigen pictogram. Hij opent schermvullend, zonder de balken van de browser.",
|
||||
"In your browser’s menu, choose Install app or Add to Home screen.": "Kies in het menu van je browser App installeren of Toevoegen aan startscherm.",
|
||||
"It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon.": "Hij opent het dashboard dat voor dat apparaat is gekozen. Markeer knoppen als snelkoppeling om ze vanaf het app-pictogram te gebruiken.",
|
||||
"Open in its own tab": "Openen in een eigen tabblad",
|
||||
"Offline · {0}": "Offline · {0}",
|
||||
"Offline. Showing the states from {0}.": "Offline. De standen van {0} worden getoond.",
|
||||
"Offline. Reconnecting…": "Offline. Opnieuw verbinden…",
|
||||
"Show on": "Tonen op",
|
||||
"All screens": "Alle schermen",
|
||||
"Phones": "Telefoons",
|
||||
"Larger screens": "Grotere schermen",
|
||||
"Vibrate briefly when you press something (phones that support it)": "Kort trillen als je iets indrukt (telefoons die dat kunnen)",
|
||||
"Edit widget": "Widget bewerken",
|
||||
"Dashboard settings": "Dashboardinstellingen",
|
||||
"When you’re away from home, open": "Als je niet thuis bent, openen",
|
||||
"Nothing special": "Niets bijzonders",
|
||||
"Away means this person is away": "Weg betekent: deze persoon is weg",
|
||||
"Leave the person empty to follow whoever is signed in on the device. Not used in kiosk mode.": "Laat de persoon leeg om te volgen wie er op het apparaat is ingelogd. Niet in kioskmodus.",
|
||||
"You’re away: showing “{0}”.": "Je bent weg: “{0}” wordt getoond.",
|
||||
"Settings": "Instellingen",
|
||||
"Touch and hold": "Ingedrukt houden",
|
||||
"Touch and hold to": "Ingedrukt houden om",
|
||||
"Show the details": "De details te tonen",
|
||||
"Switch on or off": "Aan of uit te zetten",
|
||||
"Run an action": "Een actie uit te voeren",
|
||||
"Do nothing": "Niets te doen",
|
||||
"A soft click when something is pressed": "Een zachte klik als er iets wordt ingedrukt",
|
||||
"Every device is on a dashboard already.": "Elk apparaat staat al op een dashboard.",
|
||||
"Not on a dashboard yet": "Nog niet op een dashboard",
|
||||
"These devices aren’t on any of your dashboards. Add one to this tab with the widget that suits it.": "Deze apparaten staan op geen van je dashboards. Voeg er een toe aan dit tabblad met de widget die erbij past.",
|
||||
"1 device isn’t on a dashboard": "1 apparaat staat niet op een dashboard",
|
||||
"{0} devices aren’t on a dashboard": "{0} apparaten staan niet op een dashboard",
|
||||
"Devices not on a dashboard…": "Apparaten die niet op een dashboard staan…",
|
||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
@@ -151,6 +151,18 @@
|
||||
"arrow-up": "M12 19V5M5 12l7-7 7 7",
|
||||
"arrow-down": "M12 5v14M19 12l-7 7-7-7",
|
||||
"copy-to": "M9 9h11v11H9zM5 15H4V4h11v1M13 14.5h4M15 12.5l2 2-2 2",
|
||||
package: "M21 8l-9-5-9 5v8l9 5 9-5zM3 8l9 5 9-5M12 13v8M7.5 5.5l9 5",
|
||||
trophy: "M8 4h8v5a4 4 0 0 1-8 0zM8 6H5a3 3 0 0 0 3 4M16 6h3a3 3 0 0 1-3 4M12 13v4M8 21h8M10 17h4v4h-4z",
|
||||
flag: "M5 21V4M5 4h11l-2 4 2 4H5",
|
||||
share: "M12 3v12M8 7l4-4 4 4M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7",
|
||||
qr: "M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h2v2h-2zM18 14h2M14 18h2M18 18h2v2M17 17h1",
|
||||
layout: "M4 4h16v6H4zM4 14h7v6H4zM15 14h5v6h-5z",
|
||||
bookmark: "M6 3h12v18l-6-4-6 4z",
|
||||
megaphone: "M3 10v4h4l7 5V5L7 10zM17 9a4 4 0 0 1 0 6M19.5 6.5a8 8 0 0 1 0 11",
|
||||
import: "M12 3v12M8 11l4 4 4-4M5 19h14",
|
||||
refresh: "M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7",
|
||||
air: "M3 8h10a3 3 0 1 0-3-3M3 12h15a3 3 0 1 1-3 3M3 16h7",
|
||||
gate: "M3 21V5M21 21V5M3 9h18M3 15h18M8 9v6M13 9v6M18 9v6",
|
||||
};
|
||||
const FILLED = new Set(["play", "pause", "next", "prev"]);
|
||||
// Dutch (and some English) words for the icon search, so "keuken" or "slaapkamer" find something.
|
||||
@@ -167,6 +179,8 @@
|
||||
paw: "huisdier hond kat", bike: "fiets", bulb: "lamp licht", plug: "stopcontact stekker", fan: "ventilator", thermometer: "temperatuur thermostaat",
|
||||
droplet: "water vocht", washer: "wasmachine", dryer: "droger", dishwasher: "vaatwasser", vacuum: "stofzuiger robot", car: "auto", lock: "slot",
|
||||
camera: "camera", speaker: "speaker luidspreker", music: "muziek", door: "deur", window: "raam", timer: "timer kookwekker", people: "personen familie",
|
||||
package: "pakket pakje post bezorging doos", trophy: "sport wedstrijd beker voetbal", flag: "vlag formule 1 race", air: "lucht luchtkwaliteit co2",
|
||||
gate: "hek poort oprit", megaphone: "omroep mededeling bericht", share: "delen", qr: "qr code", bookmark: "favoriet bewaren",
|
||||
};
|
||||
|
||||
/** Icon names matching a search, by name or by the words above. */
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<title>Dashboard Studio</title>
|
||||
<link rel="icon" type="image/png" href="static/favicon.png">
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="static/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#0f1526">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Dashboards">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@@ -29,6 +36,7 @@
|
||||
<button id="btn-redo" class="btn icon" aria-label="Redo" title="Redo (⇧⌘Z)"></button>
|
||||
<button id="btn-menu" class="btn icon" aria-label="More actions" aria-haspopup="menu" aria-expanded="false" title="More actions"></button>
|
||||
</span>
|
||||
<button id="btn-guest" class="btn view-only" hidden><span data-i18n>Guest mode</span></button>
|
||||
<button id="btn-kiosk" class="btn view-only label-wide" aria-label="Kiosk" title="Kiosk mode"><span data-i18n>Kiosk</span></button>
|
||||
<button id="btn-edit" class="btn primary" data-i18n>Edit</button>
|
||||
</div>
|
||||
@@ -36,11 +44,16 @@
|
||||
<button role="menuitem" data-menu="rename" data-icon="edit" data-i18n>Rename…</button>
|
||||
<button role="menuitem" data-menu="duplicate" data-icon="copy" data-i18n>Duplicate</button>
|
||||
<button role="menuitem" data-menu="starter" data-icon="sparkles" data-i18n>New dashboard from my devices</button>
|
||||
<button role="menuitem" data-menu="suggest" data-icon="search" data-i18n>Devices not on a dashboard…</button>
|
||||
<button role="menuitem" data-menu="assign" data-icon="person" data-i18n>Who sees which dashboard…</button>
|
||||
<button role="menuitem" data-menu="history" data-icon="history" data-i18n>Version history…</button>
|
||||
<button role="menuitem" data-menu="undo-history" data-icon="undo" data-i18n>Undo history…</button>
|
||||
<button role="menuitem" data-menu="share" data-icon="share" data-i18n>Share…</button>
|
||||
<button role="menuitem" data-menu="install" data-icon="phone" data-i18n>Install as an app…</button>
|
||||
<hr>
|
||||
<button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button>
|
||||
<button role="menuitem" data-menu="import" data-icon="plus" data-i18n>Import JSON…</button>
|
||||
<button role="menuitem" data-menu="lovelace" data-icon="import" data-i18n>Import from Home Assistant…</button>
|
||||
<hr>
|
||||
<button role="menuitem" data-menu="delete" data-icon="trash" class="danger" data-i18n>Delete dashboard…</button>
|
||||
</div>
|
||||
@@ -48,6 +61,7 @@
|
||||
|
||||
<div id="workspace">
|
||||
<aside id="palette" class="edit-only" aria-label="Widgets">
|
||||
<button class="sheet-close btn icon" aria-label="Close" title="Close">×</button>
|
||||
<div class="panel-title" data-i18n>Add widget</div>
|
||||
<input id="palette-search" class="input" type="search" aria-label="Search widgets" autocomplete="off" spellcheck="false">
|
||||
<div id="palette-list"></div>
|
||||
@@ -63,9 +77,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-bar" class="edit-only"><button class="btn" id="eb-add"><span data-i18n>Add</span></button><button class="btn primary" id="eb-insp"></button></div>
|
||||
<div id="sheet-backdrop" hidden></div>
|
||||
<div id="saver" hidden aria-hidden="true"><div class="photos"><img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-msg"></div></div><div class="saver-clock"><div class="time num"></div><div class="date"></div></div></div>
|
||||
<div id="doorbell" hidden role="alertdialog" aria-modal="true" aria-labelledby="db-title"><div class="db-cam"></div><div class="db-bar"><div class="db-title" id="db-title"></div><button class="btn db-close">Close</button></div></div>
|
||||
<div id="night-dim" aria-hidden="true"></div>
|
||||
<div id="offline-note" hidden role="status"></div>
|
||||
<div id="ptr" hidden aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg></div>
|
||||
<div id="announce" hidden role="alertdialog" aria-modal="true" aria-labelledby="an-title"><div class="an-card"><div class="an-ico"></div><div class="an-title" id="an-title"></div><div class="an-msg"></div><button class="btn primary an-ok">OK</button></div></div>
|
||||
|
||||
<div id="modal" class="modal" hidden><div class="modal-card" role="dialog" aria-modal="true"></div></div>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
@@ -73,6 +92,7 @@
|
||||
|
||||
<script src="static/i18n.js"></script>
|
||||
<script src="static/icons.js"></script>
|
||||
<script src="static/qr.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/* A small QR code encoder for share links: byte mode, error correction level M.
|
||||
* Follows the structure of Project Nayuki's QR Code generator (MIT licence).
|
||||
* QR.svg(text) returns an SVG string; QR.matrix(text, mask) the modules (true = dark). */
|
||||
(function () {
|
||||
// Per version (index 1 to 40), for level M: error correction codewords per block, and the number of blocks.
|
||||
const ECC = [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28];
|
||||
const BLOCKS = [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49];
|
||||
|
||||
const rawModules = (v) => {
|
||||
let r = (16 * v + 128) * v + 64;
|
||||
if (v >= 2) { const n = Math.floor(v / 7) + 2; r -= (25 * n - 10) * n - 55; if (v >= 7) r -= 36; }
|
||||
return r;
|
||||
};
|
||||
const dataCodewords = (v) => Math.floor(rawModules(v) / 8) - ECC[v] * BLOCKS[v];
|
||||
const bit = (x, i) => ((x >>> i) & 1) !== 0;
|
||||
|
||||
// Reed-Solomon over GF(2^8) with the polynomial 0x11D.
|
||||
function gfMul(x, y) {
|
||||
let z = 0;
|
||||
for (let i = 7; i >= 0; i--) { z = (z << 1) ^ ((z >>> 7) * 0x11d); z ^= ((y >>> i) & 1) * x; }
|
||||
return z;
|
||||
}
|
||||
function rsDivisor(degree) {
|
||||
const r = new Array(degree).fill(0);
|
||||
r[degree - 1] = 1;
|
||||
let root = 1;
|
||||
for (let i = 0; i < degree; i++) {
|
||||
for (let j = 0; j < r.length; j++) { r[j] = gfMul(r[j], root); if (j + 1 < r.length) r[j] ^= r[j + 1]; }
|
||||
root = gfMul(root, 0x02);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
function rsRemainder(data, divisor) {
|
||||
const r = divisor.map(() => 0);
|
||||
for (const b of data) {
|
||||
const f = b ^ r.shift();
|
||||
r.push(0);
|
||||
divisor.forEach((c, i) => (r[i] ^= gfMul(c, f)));
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function encodeData(bytes) {
|
||||
let ver = 1;
|
||||
for (; ver <= 40; ver++) if (4 + (ver < 10 ? 8 : 16) + bytes.length * 8 <= dataCodewords(ver) * 8) break;
|
||||
if (ver > 40) throw new Error("Text too long for a QR code");
|
||||
const bits = [];
|
||||
const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
|
||||
put(4, 4); // byte mode
|
||||
put(bytes.length, ver < 10 ? 8 : 16);
|
||||
for (const b of bytes) put(b, 8);
|
||||
const cap = dataCodewords(ver) * 8;
|
||||
put(0, Math.min(4, cap - bits.length));
|
||||
put(0, (8 - (bits.length % 8)) % 8);
|
||||
for (let pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) put(pad, 8);
|
||||
const data = [];
|
||||
for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((a, b2) => (a << 1) | b2, 0));
|
||||
// split into blocks, add error correction, interleave
|
||||
const nBlocks = BLOCKS[ver], eccLen = ECC[ver], raw = Math.floor(rawModules(ver) / 8);
|
||||
const nShort = nBlocks - (raw % nBlocks), shortLen = Math.floor(raw / nBlocks), div = rsDivisor(eccLen);
|
||||
const blocks = [];
|
||||
for (let i = 0, k = 0; i < nBlocks; i++) {
|
||||
const dat = data.slice(k, k + shortLen - eccLen + (i < nShort ? 0 : 1));
|
||||
k += dat.length;
|
||||
const ecc = rsRemainder(dat, div);
|
||||
if (i < nShort) dat.push(0);
|
||||
blocks.push(dat.concat(ecc));
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < blocks[0].length; i++) blocks.forEach((b, j) => { if (i !== shortLen - eccLen || j >= nShort) out.push(b[i]); });
|
||||
return { ver, codewords: out };
|
||||
}
|
||||
|
||||
function build(ver, codewords, mask) {
|
||||
const size = ver * 4 + 17;
|
||||
const mod = Array.from({ length: size }, () => new Array(size).fill(false));
|
||||
const fn = Array.from({ length: size }, () => new Array(size).fill(false));
|
||||
const set = (x, y, dark) => { mod[y][x] = dark; fn[y][x] = true; };
|
||||
for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
||||
const finder = (x, y) => {
|
||||
for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
|
||||
const d = Math.max(Math.abs(dx), Math.abs(dy)), xx = x + dx, yy = y + dy;
|
||||
if (xx >= 0 && xx < size && yy >= 0 && yy < size) set(xx, yy, d !== 2 && d !== 4);
|
||||
}
|
||||
};
|
||||
finder(3, 3); finder(size - 4, 3); finder(3, size - 4);
|
||||
const align = [];
|
||||
if (ver > 1) {
|
||||
const n = Math.floor(ver / 7) + 2, step = ver === 32 ? 26 : Math.ceil((ver * 4 + 4) / (n * 2 - 2)) * 2;
|
||||
align.push(6);
|
||||
for (let pos = size - 7; align.length < n; pos -= step) align.splice(1, 0, pos);
|
||||
}
|
||||
align.forEach((ax, i) => align.forEach((ay, j) => {
|
||||
if ((i === 0 && j === 0) || (i === 0 && j === align.length - 1) || (i === align.length - 1 && j === 0)) return;
|
||||
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(ax + dx, ay + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
||||
}));
|
||||
const format = (m) => {
|
||||
const d = (0 << 3) | m; // level M has format bits 00
|
||||
let rem = d;
|
||||
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
||||
const b = ((d << 10) | rem) ^ 0x5412;
|
||||
for (let i = 0; i <= 5; i++) set(8, i, bit(b, i));
|
||||
set(8, 7, bit(b, 6)); set(8, 8, bit(b, 7)); set(7, 8, bit(b, 8));
|
||||
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(b, i));
|
||||
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(b, i));
|
||||
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(b, i));
|
||||
set(8, size - 8, true);
|
||||
};
|
||||
format(0);
|
||||
if (ver >= 7) {
|
||||
let rem = ver;
|
||||
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
|
||||
const b = (ver << 12) | rem;
|
||||
for (let i = 0; i < 18; i++) { const a = size - 11 + (i % 3), c = Math.floor(i / 3); set(a, c, bit(b, i)); set(c, a, bit(b, i)); }
|
||||
}
|
||||
// the data, in the zigzag order
|
||||
let i = 0;
|
||||
for (let right = size - 1; right >= 1; right -= 2) {
|
||||
if (right === 6) right = 5;
|
||||
for (let vert = 0; vert < size; vert++) for (let j = 0; j < 2; j++) {
|
||||
const x = right - j, up = ((right + 1) & 2) === 0, y = up ? size - 1 - vert : vert;
|
||||
if (!fn[y][x] && i < codewords.length * 8) { mod[y][x] = bit(codewords[i >>> 3], 7 - (i & 7)); i++; }
|
||||
}
|
||||
}
|
||||
const masks = [(x, y) => (x + y) % 2 === 0, (x, y) => y % 2 === 0, (x) => x % 3 === 0, (x, y) => (x + y) % 3 === 0,
|
||||
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
|
||||
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0];
|
||||
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fn[y][x] && masks[mask](x, y)) mod[y][x] = !mod[y][x];
|
||||
format(mask);
|
||||
return mod;
|
||||
}
|
||||
|
||||
/** How hard a pattern is to scan (lower is better): runs, blocks, finder look-alikes and balance. */
|
||||
function penalty(m) {
|
||||
const n = m.length;
|
||||
let p = 0, dark = 0;
|
||||
const lines = [...m, ...m[0].map((_, x) => m.map((row) => row[x]))];
|
||||
for (const line of lines) {
|
||||
let run = 1;
|
||||
for (let i = 1; i <= n; i++) {
|
||||
if (i < n && line[i] === line[i - 1]) run++;
|
||||
else { if (run >= 5) p += run - 2; run = 1; }
|
||||
}
|
||||
const s = line.map((v) => (v ? 1 : 0)).join("");
|
||||
for (const pat of ["10111010000", "00001011101"]) for (let k = s.indexOf(pat); k !== -1; k = s.indexOf(pat, k + 1)) p += 40;
|
||||
}
|
||||
for (let y = 0; y < n - 1; y++) for (let x = 0; x < n - 1; x++) {
|
||||
const c = m[y][x];
|
||||
if (c === m[y][x + 1] && c === m[y + 1][x] && c === m[y + 1][x + 1]) p += 3;
|
||||
}
|
||||
for (const row of m) for (const v of row) if (v) dark++;
|
||||
p += Math.floor(Math.abs(dark * 20 - n * n * 10) / (n * n)) * 10;
|
||||
return p;
|
||||
}
|
||||
|
||||
function matrix(text, mask) {
|
||||
const { ver, codewords } = encodeData([...new TextEncoder().encode(text)]);
|
||||
if (mask != null) return build(ver, codewords, mask);
|
||||
let best = null, bestScore = Infinity;
|
||||
for (let m = 0; m < 8; m++) { const mm = build(ver, codewords, m), sc = penalty(mm); if (sc < bestScore) { best = mm; bestScore = sc; } }
|
||||
return best;
|
||||
}
|
||||
|
||||
function svg(text, { border = 3, label = "QR code" } = {}) {
|
||||
const m = matrix(text), n = m.length + border * 2;
|
||||
let d = "";
|
||||
m.forEach((row, y) => row.forEach((v, x) => { if (v) d += `M${x + border} ${y + border}h1v1h-1z`; }));
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" shape-rendering="crispEdges" role="img" aria-label="${label}"><rect width="${n}" height="${n}" fill="#fff"/><path d="${d}" fill="#000"/></svg>`;
|
||||
}
|
||||
|
||||
const api = { matrix, svg };
|
||||
if (typeof window !== "undefined") window.QR = api;
|
||||
if (typeof module !== "undefined") module.exports = api;
|
||||
})();
|
||||
@@ -1093,6 +1093,238 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
}
|
||||
@container (max-height: 120px) { .tz.tz .tz-ctrls { display: none; } }
|
||||
|
||||
/* parcels */
|
||||
.pc { display: flex; flex-direction: column; gap: 10px; min-height: 0; flex: 1; }
|
||||
.pc-sum { font-size: 14px; color: var(--d-text-2); }
|
||||
.pc-sum b { font-size: 22px; color: var(--d-text); margin-right: 2px; }
|
||||
.pc-today { color: var(--d-accent); font-weight: 600; }
|
||||
.pc-list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; min-height: 0; }
|
||||
.pc-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); color: inherit; text-decoration: none; min-width: 0; }
|
||||
.pc-row.today { background: color-mix(in srgb, var(--d-accent) 14%, transparent); }
|
||||
.pc-row.done { opacity: .6; }
|
||||
.pc-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: var(--d-tile); font-size: 17px; flex: none; }
|
||||
.pc-row.today .pc-ico { color: var(--d-accent); }
|
||||
.pc-row.done .pc-ico { color: var(--ok); }
|
||||
.pc-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.pc-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pc-status { font-size: 12px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pc-when { font-size: 12px; font-weight: 600; color: var(--d-text-2); white-space: nowrap; text-align: right; }
|
||||
.pc-row.today .pc-when { color: var(--d-accent); }
|
||||
|
||||
/* air quality */
|
||||
.aq { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; min-height: 0; }
|
||||
.aq-row { --aq: var(--ok); display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); }
|
||||
.aq-row.lvl1 { --aq: var(--warn); } .aq-row.lvl2 { --aq: var(--crit); }
|
||||
.aq-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--aq); box-shadow: 0 0 0 4px color-mix(in srgb, var(--aq) 22%, transparent); }
|
||||
.aq-room { display: flex; flex-direction: column; min-width: 0; }
|
||||
.aq-room b { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.aq-room span { font-size: 12px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.aq-row.lvl2 .aq-room span { color: var(--crit); font-weight: 600; }
|
||||
.aq-vals { display: flex; gap: 4px; }
|
||||
.aq-val { display: flex; flex-direction: column; align-items: center; min-width: 44px; padding: 3px 6px; border-radius: 9px; background: var(--d-tile); }
|
||||
.aq-val small { font-size: 10px; color: var(--d-text-2); }
|
||||
.aq-val span { font-size: 13px; font-weight: 600; }
|
||||
.aq-val.lvl1 span { color: var(--warn); } .aq-val.lvl2 span { color: var(--crit); }
|
||||
@container (max-width: 300px) { .aq-val small { display: none; } .aq-val { min-width: 36px; } }
|
||||
|
||||
/* water and gas */
|
||||
.us-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; min-width: 0; }
|
||||
.us-head .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.us-ico { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 16px; color: #0a84ff; background: color-mix(in srgb, #0a84ff 16%, transparent); flex: none; }
|
||||
.us-ico.gas { color: #ff9f0a; background: color-mix(in srgb, #ff9f0a 16%, transparent); }
|
||||
.us-ico.electricity { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); }
|
||||
.us { display: flex; flex-direction: column; gap: 10px; flex: 1; min-height: 0; }
|
||||
.us-now { display: flex; flex-direction: column; gap: 2px; }
|
||||
.us-big { font-size: clamp(26px, 9cqw, 38px); font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
|
||||
.us-big small { font-size: .45em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; }
|
||||
.us-cmp { font-size: 12px; color: var(--d-text-2); }
|
||||
.us-cmp .up { color: var(--warn); } .us-cmp .down { color: var(--ok); }
|
||||
.us-bars { position: relative; flex: 1; min-height: 40px; display: flex; align-items: stretch; gap: 4px; }
|
||||
.us-day { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 4px; min-width: 0; }
|
||||
.us-bar { width: 100%; max-width: 26px; border-radius: 5px 5px 2px 2px; background: color-mix(in srgb, var(--d-text) 18%, transparent); }
|
||||
.us-day.today .us-bar { background: #0a84ff; }
|
||||
.us.gas .us-day.today .us-bar { background: #ff9f0a; }
|
||||
.us.electricity .us-day.today .us-bar { background: var(--ok); }
|
||||
.us-lbl { font-size: 10px; color: var(--d-text-2); }
|
||||
.us-avg { position: absolute; left: 0; right: 0; height: 0; border-top: 1px dashed var(--d-text-2); opacity: .6; margin-bottom: 18px; pointer-events: none; }
|
||||
.us-foot { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--d-text-2); }
|
||||
@container (max-height: 200px) { .us-bars, .us-foot { display: none; } }
|
||||
|
||||
/* solar forecast */
|
||||
.sf { display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; }
|
||||
.sf-top { display: flex; align-items: flex-end; gap: 18px; flex-wrap: wrap; }
|
||||
.sf-top > div { display: flex; flex-direction: column; }
|
||||
.sf-lbl { font-size: 12px; color: var(--d-text-2); }
|
||||
.sf-big { font-size: clamp(28px, 10cqw, 42px); font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
|
||||
.sf-mid { font-size: 18px; font-weight: 600; }
|
||||
.sf-big small, .sf-mid small { font-size: .5em; color: var(--d-text-2); margin-left: 3px; font-weight: 600; }
|
||||
.sf-bars { flex: 1; min-height: 50px; display: flex; align-items: stretch; gap: 3px; padding-bottom: 14px; }
|
||||
.sf-h { position: relative; flex: 1; display: flex; align-items: flex-end; }
|
||||
.sf-h i { width: 100%; border-radius: 4px 4px 1px 1px; background: color-mix(in srgb, #ffcc00 55%, transparent); }
|
||||
.sf-h.past i { opacity: .45; }
|
||||
.sf-h.best i { background: #ff9f0a; }
|
||||
.sf-h.now i { background: #ffcc00; box-shadow: 0 0 0 2px var(--d-tile), 0 0 0 3px #ffcc00; }
|
||||
.sf-h span { position: absolute; bottom: -15px; left: 50%; transform: translateX(-50%); font-size: 10px; color: var(--d-text-2); }
|
||||
.sf-best { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 500; padding: 8px 10px; border-radius: 12px; background: color-mix(in srgb, #ff9f0a 14%, transparent); }
|
||||
.sf-best .i { color: #ff9f0a; font-size: 18px; flex: none; }
|
||||
@container (max-height: 220px) { .sf-bars { display: none; } }
|
||||
|
||||
/* garage and gate */
|
||||
.gt { display: grid; grid-template-rows: auto 1fr auto; gap: 8px; height: 100%; min-height: 0; }
|
||||
.gt-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.gt-head .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gt-state { font-size: 12px; font-weight: 600; color: var(--d-text-2); }
|
||||
.gt.open .gt-state { color: var(--warn); }
|
||||
.gt-btn { justify-self: center; align-self: center; display: flex; flex-direction: column; align-items: center; gap: 6px; min-height: 0; }
|
||||
.gt-ring { width: clamp(64px, 34cqmin, 116px); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; font-size: clamp(28px, 14cqmin, 50px);
|
||||
background: var(--d-fill); box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--d-text) 10%, transparent); transition: background-color .25s, color .25s, transform .15s; }
|
||||
.gt-btn:active .gt-ring { transform: scale(.96); }
|
||||
.gt.open .gt-ring { background: color-mix(in srgb, var(--warn) 20%, transparent); color: var(--warn); box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--warn) 55%, transparent); }
|
||||
.gt.moving .gt-ring { animation: gt-pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes gt-pulse { 50% { opacity: .55; } }
|
||||
.gt-lbl { font-size: 14px; font-weight: 600; }
|
||||
.gt-since { font-size: 12px; color: var(--d-text-2); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@media (prefers-reduced-motion: reduce) { .gt.moving .gt-ring { animation: none; } }
|
||||
|
||||
/* sports */
|
||||
.sp { display: flex; flex-direction: column; gap: 8px; height: 100%; justify-content: center; min-height: 0; }
|
||||
.sp-league { font-size: 12px; font-weight: 600; color: var(--d-text-2); text-align: center; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.sp-match { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px; }
|
||||
.sp-team { display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; text-align: center; }
|
||||
.sp-team img { width: clamp(36px, 16cqmin, 64px); aspect-ratio: 1; object-fit: contain; }
|
||||
.sp-badge { width: clamp(36px, 16cqmin, 64px); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; background: var(--d-fill); font-weight: 700; font-size: 13px; }
|
||||
.sp-team > span:last-child { font-size: 13px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sp-team.win > span:last-child { color: var(--ok); }
|
||||
.sp-mid { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.sp-score { font-size: clamp(26px, 11cqmin, 40px); font-weight: 700; letter-spacing: -0.02em; }
|
||||
.sp-score i { font-style: normal; color: var(--d-text-2); margin: 0 6px; }
|
||||
.sp-time { font-size: clamp(22px, 9cqmin, 32px); font-weight: 700; }
|
||||
.sp-sub { font-size: 12px; color: var(--d-text-2); white-space: nowrap; }
|
||||
.sp-live { color: var(--crit); }
|
||||
.sp-live::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--crit); margin-right: 4px; vertical-align: 1px; animation: tz-pulse 1.4s infinite; }
|
||||
.sp-extra { font-size: 12px; color: var(--d-text-2); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Formula One */
|
||||
.f1 { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; }
|
||||
.f1-next { padding: 12px; border-radius: 14px; background: linear-gradient(135deg, color-mix(in srgb, #e10600 22%, var(--d-fill)), var(--d-fill)); }
|
||||
.f1-top { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--d-text-2); }
|
||||
.f1-top .i { color: #e10600; font-size: 15px; }
|
||||
.f1-top b { margin-left: auto; color: var(--d-text); font-weight: 600; }
|
||||
.f1-race { font-size: 18px; font-weight: 700; margin-top: 4px; letter-spacing: -0.01em; }
|
||||
.f1-place { font-size: 12px; color: var(--d-text-2); }
|
||||
.f1-sessions { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; font-size: 12px; }
|
||||
.f1-sessions > div { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.f1-sessions .past { opacity: .5; }
|
||||
.f1-sessions .next { font-weight: 700; color: var(--d-text); }
|
||||
.f1-tabs { display: flex; gap: 4px; padding: 3px; border-radius: 10px; background: var(--d-fill); }
|
||||
.f1-tabs button { flex: 1; padding: 5px 4px; border-radius: 8px; font-size: 12px; font-weight: 600; color: var(--d-text-2); }
|
||||
.f1-tabs button[aria-selected="true"] { background: var(--d-tile); color: var(--d-text); box-shadow: 0 1px 3px rgba(0,0,0,.12); }
|
||||
.f1-list { display: flex; flex-direction: column; overflow-y: auto; min-height: 0; }
|
||||
.f1-row { display: grid; grid-template-columns: 26px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--d-separator); }
|
||||
.f1-row:last-child { border-bottom: 0; }
|
||||
.f1-pos { font-weight: 700; color: var(--d-text-2); text-align: center; }
|
||||
.f1-row:first-child .f1-pos { color: #e10600; }
|
||||
.f1-name { display: flex; flex-direction: column; min-width: 0; }
|
||||
.f1-name b { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.f1-name span { font-size: 11px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.f1-val { font-size: 13px; font-weight: 600; }
|
||||
@container (max-height: 380px) { .f1-sessions { display: none; } }
|
||||
|
||||
/* announcements */
|
||||
#announce { position: fixed; inset: 0; z-index: 95; display: grid; place-items: center; padding: 24px; background: rgba(0, 0, 0, .72); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); }
|
||||
#announce[hidden] { display: none; }
|
||||
.an-card { width: min(640px, 100%); display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; color: #fff; }
|
||||
.an-ico { width: 84px; height: 84px; border-radius: 50%; display: grid; place-items: center; font-size: 40px; background: var(--accent); color: #fff; animation: an-pop .5s cubic-bezier(.2, 1.4, .4, 1); }
|
||||
@keyframes an-pop { from { transform: scale(.4); opacity: 0; } }
|
||||
.an-title { font-size: 18px; font-weight: 600; opacity: .8; }
|
||||
.an-msg { font-size: clamp(30px, 6vw, 56px); font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; white-space: pre-line; }
|
||||
.an-ok { min-height: 52px; min-width: 160px; font-size: 18px; margin-top: 10px; }
|
||||
@media (prefers-reduced-motion: reduce) { .an-ico { animation: none; } }
|
||||
#btn-guest[hidden] { display: none; }
|
||||
#btn-guest { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
|
||||
|
||||
/* editor: alignment guides, presets, templates, undo history */
|
||||
.guide { position: absolute; z-index: 6; pointer-events: none; background: #ff2d92; }
|
||||
.guide.v { top: 0; bottom: 0; width: 1px; }
|
||||
.guide.h { left: 0; right: 0; height: 1px; }
|
||||
.pal-sub { grid-column: 1 / -1; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-2); margin: 6px 2px 0; }
|
||||
.pal-preset { position: relative; display: grid; }
|
||||
.pal-del { position: absolute; top: 4px; right: 4px; width: 22px; height: 22px; border-radius: 50%; display: none; place-items: center; font-size: 12px; background: var(--surface); color: var(--text-2); }
|
||||
.pal-preset:hover .pal-del, .pal-del:focus-visible { display: grid; }
|
||||
.modal-card:has(.tpl-grid) { width: 640px; }
|
||||
.tpl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; margin-top: 14px; max-height: 52vh; overflow-y: auto; }
|
||||
.tpl { display: flex; gap: 12px; align-items: flex-start; padding: 12px; border-radius: 14px; border: 1px solid var(--separator); cursor: pointer; }
|
||||
.tpl input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.tpl:has(input:checked) { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
||||
.tpl:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.tpl-ico { width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; font-size: 20px; background: var(--fill); flex: none; }
|
||||
.tpl > span:last-child { display: flex; flex-direction: column; gap: 2px; font-size: 13px; color: var(--text-2); }
|
||||
.tpl b { color: var(--text); font-size: 14px; }
|
||||
.undo-row { width: 100%; text-align: left; border-radius: 10px; }
|
||||
.undo-row:hover { background: var(--fill); }
|
||||
|
||||
/* share */
|
||||
.share-qr { width: 220px; max-width: 70%; margin: 14px auto 12px; border-radius: 12px; overflow: hidden; box-shadow: 0 0 0 1px var(--separator); }
|
||||
.share-qr svg { display: block; width: 100%; height: auto; }
|
||||
.share-row { display: flex; gap: 8px; }
|
||||
.share-row .input { flex: 1; min-width: 0; font-size: 13px; }
|
||||
.share-row .btn { flex: none; }
|
||||
|
||||
/* offline note: visible without the top bar too (phones, kiosk) */
|
||||
#offline-note { position: fixed; z-index: 85; left: 50%; top: calc(10px + env(safe-area-inset-top)); transform: translateX(-50%); padding: 6px 14px; border-radius: 99px;
|
||||
font-size: 13px; font-weight: 600; color: #fff; background: rgba(28, 28, 30, .86); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 4px 16px rgba(0, 0, 0, .25); max-width: calc(100% - 32px); text-align: center; }
|
||||
#offline-note[hidden] { display: none; }
|
||||
body.editing #offline-note { display: none; }
|
||||
|
||||
/* pull to refresh */
|
||||
#ptr { position: fixed; z-index: 86; left: 50%; top: calc(8px + env(safe-area-inset-top)); width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center;
|
||||
background: var(--surface); color: var(--text-2); box-shadow: 0 4px 16px rgba(0, 0, 0, .2); transform: translate(-50%, 0); pointer-events: none; }
|
||||
#ptr[hidden] { display: none; }
|
||||
#ptr svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
#ptr.ready { color: var(--accent); }
|
||||
#ptr.spin svg { animation: ptr-spin .8s linear infinite; }
|
||||
@keyframes ptr-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* popups as bottom sheets on phones */
|
||||
@media (max-width: 600px) {
|
||||
.modal { align-items: flex-end; padding: 0; }
|
||||
.modal-card, .modal-card:has(.tpl-grid) { width: 100%; max-height: 90vh; border-radius: 20px 20px 0 0; padding: 26px 18px calc(18px + env(safe-area-inset-bottom)); transition: transform .25s ease; animation: sheet-up .3s cubic-bezier(.2, .9, .3, 1); }
|
||||
.modal-card::before { content: ""; position: sticky; top: -18px; display: block; width: 38px; height: 5px; border-radius: 3px; background: var(--separator); margin: -14px auto 12px; }
|
||||
.modal-actions { flex-wrap: wrap; }
|
||||
.modal-actions .btn { flex: 1 1 auto; min-height: 46px; }
|
||||
}
|
||||
@keyframes sheet-up { from { transform: translateY(100%); } }
|
||||
@media (prefers-reduced-motion: reduce) { .modal-card { animation: none !important; } }
|
||||
|
||||
/* phone editing: the bar with "Add widget" and the settings */
|
||||
#edit-bar { display: none; position: fixed; z-index: 63; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); gap: 8px; padding: 6px;
|
||||
border-radius: 99px; background: var(--glass); border: 1px solid var(--glass-edge); box-shadow: 0 8px 30px rgba(0, 0, 0, .25); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); }
|
||||
#edit-bar { max-width: calc(100% - 24px); }
|
||||
#edit-bar .btn { min-height: 44px; border-radius: 99px; padding: 0 18px; white-space: nowrap; }
|
||||
#sheet-backdrop { position: fixed; inset: 0; z-index: 61; background: rgba(0, 0, 0, .35); }
|
||||
#sheet-backdrop[hidden] { display: none; }
|
||||
.sheet-close { display: none; position: sticky; top: 0; float: right; width: 34px; height: 34px; border-radius: 50%; place-items: center; font-size: 20px; z-index: 2; }
|
||||
body:not(.editing) #edit-bar { display: none !important; }
|
||||
@media (pointer: coarse) {
|
||||
body.editing .w.selected { touch-action: none; }
|
||||
body.editing .w.selected .w-resize { width: 34px; height: 34px; }
|
||||
body.editing .w-tools button { width: 38px; height: 38px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { body.editing #palette, body.editing #inspector { transition: none; } }
|
||||
|
||||
/* suggestions */
|
||||
.pal-sug { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; padding: 9px 10px; border-radius: 12px; text-align: left; font-size: 13px; font-weight: 500;
|
||||
color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||
.pal-sug .i { font-size: 17px; flex: none; }
|
||||
.sg-row { display: flex; align-items: center; gap: 10px; padding: 8px 2px; border-bottom: 1px solid var(--separator); }
|
||||
.sg-row:last-child { border-bottom: 0; }
|
||||
.sg-row .grow { display: flex; flex-direction: column; min-width: 0; }
|
||||
.sg-row b { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sg-row .muted { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sg-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: var(--fill); font-size: 17px; flex: none; }
|
||||
.sg-row .btn { flex: none; }
|
||||
body:not(.editing) .w { -webkit-touch-callout: none; }
|
||||
|
||||
/* ============================================================ phone dashboards */
|
||||
body.phone-ui:not(.peek) #topbar { display: none; }
|
||||
body.phone-ui #stage { padding-top: calc(env(safe-area-inset-top) + 6px); }
|
||||
@@ -1160,10 +1392,16 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; }
|
||||
body.editing #stage { flex-direction: column; align-items: stretch; }
|
||||
#workspace { flex-direction: column; }
|
||||
body.editing #stage { order: 1; flex: 1; }
|
||||
body.editing #palette { order: 2; width: auto; max-height: 120px; padding: 10px; border: 0; border-top: 1px solid var(--separator); }
|
||||
body.editing #palette .panel-title, body.editing #palette .hint { display: none; }
|
||||
body.editing #palette-list { grid-template-columns: repeat(4, 1fr); }
|
||||
body.editing #inspector { order: 3; width: auto; max-height: 40vh; padding: 14px; border: 0; border-top: 1px solid var(--separator); }
|
||||
/* editing on a phone: the widget list and the settings slide up as sheets */
|
||||
body.editing #palette, body.editing #inspector { position: fixed; left: 0; right: 0; bottom: 0; z-index: 62; width: auto; height: 72vh; max-height: none; overflow-y: auto;
|
||||
padding: 16px 16px calc(84px + env(safe-area-inset-bottom)); border: 0; border-radius: 20px 20px 0 0; background: var(--surface); box-shadow: 0 -10px 40px rgba(0, 0, 0, .3);
|
||||
transform: translateY(105%); transition: transform .3s cubic-bezier(.2, .9, .3, 1); }
|
||||
body.editing.sheet-palette #palette, body.editing.sheet-inspector #inspector { transform: none; }
|
||||
body.editing #palette .hint { display: none; }
|
||||
body.editing #palette-list { grid-template-columns: repeat(3, 1fr); }
|
||||
body.editing #edit-bar { display: flex; }
|
||||
body.editing .sheet-close { display: grid; }
|
||||
body.editing #canvas { padding-bottom: 90px; }
|
||||
.btn.label-wide span { display: none; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* Dashboard Studio service worker: always the network first, so updates show straight away.
|
||||
* When the network fails (or Home Assistant's ingress session has run out), it answers with
|
||||
* the last good copy, so the app can start, show the last known states and reconnect. */
|
||||
const CACHE = "ds-__VERSION__";
|
||||
const SKIP = /\/(ws|api\/camera|api\/immich|api\/history)/;
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k.startsWith("ds-") && k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
|
||||
});
|
||||
self.addEventListener("fetch", (e) => {
|
||||
const req = e.request, url = new URL(req.url);
|
||||
if (req.method !== "GET" || url.origin !== location.origin || SKIP.test(url.pathname)) return;
|
||||
const page = req.mode === "navigate";
|
||||
e.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
try {
|
||||
const res = await fetch(req);
|
||||
if (res.ok) {
|
||||
cache.put(page ? new Request(new URL("./", self.registration.scope)) : req, res.clone());
|
||||
return res;
|
||||
}
|
||||
if (!page) return res;
|
||||
throw new Error(String(res.status));
|
||||
} catch (err) {
|
||||
const hit = page ? await cache.match(new URL("./", self.registration.scope).href) : await cache.match(req);
|
||||
if (hit) return hit;
|
||||
throw err;
|
||||
}
|
||||
})());
|
||||
});
|
||||
@@ -549,7 +549,7 @@
|
||||
if (ctx.cache.key !== key) {
|
||||
ctx.cache.key = key;
|
||||
try { ctx.cache.items = (await ctx.ha.send({ type: "todo/item/list", entity_id: c.entity }))?.items || []; } catch { ctx.cache.items = ctx.cache.items || []; }
|
||||
if (el.isConnected) ctx.rerender();
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
// Swipe a row to the left to delete it (touch); the ✕ does the same with a mouse.
|
||||
@@ -630,7 +630,7 @@
|
||||
cache.key = c.entity; cache.at = Date.now();
|
||||
const [daily, hourly] = await Promise.all([ctx.ha.forecast(c.entity, "daily").catch(() => null), ctx.ha.forecast(c.entity, "hourly").catch(() => null)]);
|
||||
cache.daily = daily; cache.hourly = hourly;
|
||||
if (el.isConnected) ctx.rerender();
|
||||
ctx.rerender();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1266,6 +1266,356 @@
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- parcels
|
||||
// Parcel integrations (PostNL, DHL, …) keep their parcels as lists in a sensor's attributes;
|
||||
// field names differ, so each parcel is read from whichever names it has.
|
||||
const pick = (o, ...keys) => { for (const k of keys) if (o?.[k] != null && o[k] !== "") return o[k]; return null; };
|
||||
const dayLabel = (d) => {
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const diff = Math.round((new Date(d).setHours(0, 0, 0, 0) - today) / 86400000);
|
||||
return diff === 0 ? t("Today") : diff === 1 ? t("Tomorrow") : diff === -1 ? t("Yesterday") : new Date(d).toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" });
|
||||
};
|
||||
function parcelsOf(s) {
|
||||
const out = [];
|
||||
for (const [k, v] of Object.entries(s.attributes)) {
|
||||
if (!Array.isArray(v) || !v.length || typeof v[0] !== "object") continue;
|
||||
const listDelivered = /deliver|bezorgd|afgeleverd/i.test(k) && !/route|onderweg|transit/i.test(k);
|
||||
for (const p of v) {
|
||||
const status = String(pick(p, "status_message", "status", "state", "statusText") || "");
|
||||
const when = pick(p, "expected_datetime", "planned_date", "delivery_date", "expected_delivery", "eta", "date", "planned_from");
|
||||
const date = when && !isNaN(new Date(when)) ? new Date(when) : null;
|
||||
out.push({
|
||||
name: String(pick(p, "name", "title", "description", "sender", "shop", "product", "key", "tracking_number", "barcode") || t("Parcel")),
|
||||
status, date, until: pick(p, "planned_to", "expected_to"),
|
||||
done: listDelivered || p.delivered === true || /delivered|bezorgd|afgeleverd/i.test(status),
|
||||
url: pick(p, "url", "track_url", "tracking_url"),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
W.parcels = {
|
||||
label: "Parcels", icon: "package", size: [4, 4],
|
||||
fields: [
|
||||
{ k: "title", t: "text", l: "Title", d: "Parcels" },
|
||||
{ k: "entities", t: "entities", l: "Parcel sensors (PostNL, DHL, …)", domains: ["sensor"] },
|
||||
{ k: "showDelivered", t: "check", l: "Show parcels delivered in the last two days", d: true },
|
||||
],
|
||||
entities: (c) => c.entities || [],
|
||||
render(c, { ha }) {
|
||||
const list = (c.entities || []).map((e) => ha.states[e]).filter(Boolean);
|
||||
if (!list.length) return `${title(c.title)}${missing((c.entities || [])[0], "a parcel sensor")}`;
|
||||
let items = list.flatMap((s) => {
|
||||
const ps = parcelsOf(s);
|
||||
return ps.length || !isFinite(Number(s.state)) ? ps : []; // a count with no list means nothing on the way
|
||||
});
|
||||
const recent = Date.now() - 2 * 86400000;
|
||||
if (c.showDelivered === false) items = items.filter((p) => !p.done);
|
||||
else items = items.filter((p) => !p.done || !p.date || p.date.getTime() > recent);
|
||||
items.sort((a, b) => a.done - b.done || (a.date?.getTime() ?? 9e15) - (b.date?.getTime() ?? 9e15));
|
||||
const coming = items.filter((p) => !p.done), today = coming.filter((p) => p.date && dayLabel(p.date) === t("Today")).length;
|
||||
const hm2 = (d) => new Date(d).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
|
||||
const when = (p) => {
|
||||
if (!p.date) return "";
|
||||
const hasTime = p.date.getHours() || p.date.getMinutes();
|
||||
return `${dayLabel(p.date)}${hasTime ? ` ${hm2(p.date)}${p.until && !isNaN(new Date(p.until)) ? `–${hm2(p.until)}` : ""}` : ""}`;
|
||||
};
|
||||
const rows = items.map((p) => `<${p.url ? `a href="${esc(p.url)}" target="_blank" rel="noopener"` : "div"} class="pc-row ${p.done ? "done" : ""} ${p.date && dayLabel(p.date) === t("Today") && !p.done ? "today" : ""}">
|
||||
<span class="pc-ico">${Icons.svg(p.done ? "check" : "package")}</span>
|
||||
<span class="pc-text"><span class="pc-name">${esc(p.name)}</span><span class="pc-status">${esc(p.done ? t("Delivered") : p.status || t("On the way"))}</span></span>
|
||||
<span class="pc-when">${esc(when(p))}</span></${p.url ? "a" : "div"}>`).join("");
|
||||
return `${title(c.title)}<div class="pc">
|
||||
<div class="pc-sum"><b class="num">${coming.length}</b> ${esc(coming.length === 1 ? t("parcel on the way") : t("parcels on the way"))}${today ? ` · <span class="pc-today">${esc(t("{0} today", today))}</span>` : ""}</div>
|
||||
${rows ? `<div class="pc-list">${rows}</div>` : `<div class="empty">${t("No parcels on the way.")}</div>`}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- air quality
|
||||
const AIR = {
|
||||
carbon_dioxide: { k: "co2", l: "CO₂", lv: (v) => (v <= 800 ? 0 : v <= 1200 ? 1 : 2) },
|
||||
pm25: { k: "pm25", l: "PM2.5", lv: (v) => (v <= 12 ? 0 : v <= 35 ? 1 : 2) },
|
||||
pm10: { k: "pm10", l: "PM10", lv: (v) => (v <= 20 ? 0 : v <= 50 ? 1 : 2) },
|
||||
volatile_organic_compounds: { k: "voc", l: "VOC", lv: (v) => (v <= 300 ? 0 : v <= 1000 ? 1 : 2) },
|
||||
volatile_organic_compounds_parts: { k: "voc", l: "VOC", lv: (v) => (v <= 300 ? 0 : v <= 1000 ? 1 : 2) },
|
||||
humidity: { k: "hum", l: "Humidity", lv: (v) => (v >= 40 && v <= 60 ? 0 : v >= 30 && v <= 70 ? 1 : 2) },
|
||||
};
|
||||
const AIR_WORDS = /\b(co2|carbon dioxide|pm2[.,]?5|pm10|voc|humidity|luchtvochtigheid|vochtigheid|air quality|luchtkwaliteit)\b/gi;
|
||||
|
||||
W.air = {
|
||||
label: "Air quality", icon: "air", size: [4, 4],
|
||||
fields: [
|
||||
{ k: "title", t: "text", l: "Title", d: "Air quality" },
|
||||
{ k: "entities", t: "entities", l: "Sensors (leave empty to find CO₂, fine dust and humidity per room)", domains: ["sensor"] },
|
||||
],
|
||||
entities: (c, ha) => (c.entities?.length ? c.entities : Object.values(ha?.states || {}).filter((s) => AIR[s.attributes.device_class]).map((s) => s.entity_id)),
|
||||
render(c, { ha }) {
|
||||
const reg = ha.reg;
|
||||
const areaOf = {};
|
||||
if (reg) for (const [a, ids] of Object.entries(reg.byArea)) for (const id of ids) areaOf[id] = reg.areas.find((x) => x.area_id === a)?.name;
|
||||
const sensors = (c.entities?.length ? c.entities : W.air.entities(c, ha)).map((e) => ha.states[e]).filter((s) => s && AIR[s.attributes.device_class] && isFinite(Number(s.state)));
|
||||
// Without chosen sensors, only rooms count; with chosen ones, a sensor's name is its room when it has none.
|
||||
const rooms = new Map();
|
||||
for (const s of sensors) {
|
||||
const room = areaOf[s.entity_id] || (c.entities?.length ? friendly(s).replace(AIR_WORDS, "").replace(/\s+/g, " ").trim() || friendly(s) : null);
|
||||
if (!room) continue;
|
||||
(rooms.get(room) || rooms.set(room, []).get(room)).push(s);
|
||||
}
|
||||
if (!rooms.size) return `${title(c.title)}<div class="empty">${t("No air quality sensors found. Add CO₂, fine dust or humidity sensors in the inspector.")}</div>`;
|
||||
const names = ["Good", "Fair", "Poor"];
|
||||
const rows = [...rooms.entries()].map(([room, ss]) => {
|
||||
const m = ss.map((s) => { const d = AIR[s.attributes.device_class], v = Number(s.state); return { ...d, v, lvl: d.lv(v), s }; });
|
||||
const worst = Math.max(...m.map((x) => x.lvl));
|
||||
const co2 = m.find((x) => x.k === "co2")?.v, hum = m.find((x) => x.k === "hum")?.v, pm = m.find((x) => (x.k === "pm25" || x.k === "pm10") && x.lvl === 2);
|
||||
const hint = co2 >= 1000 ? t("Open a window") : pm ? t("Air the room or turn on the purifier") : hum > 70 ? t("Too humid, air the room") : hum < 30 ? t("The air is dry") : "";
|
||||
return `<div class="aq-row lvl${worst}"><span class="aq-dot" aria-hidden="true"></span>
|
||||
<span class="aq-room"><b>${esc(room)}</b><span>${esc(hint || t(names[worst]))}</span></span>
|
||||
<span class="aq-vals">${m.map((x) => `<button class="aq-val lvl${x.lvl} wbtn" data-act="more" data-e="${esc(x.s.entity_id)}" title="${esc(t(x.l))}: ${esc(t(names[x.lvl]))}"><small>${esc(t(x.l))}</small><span class="num">${esc(fmtNum(x.v))}${x.k === "hum" ? "%" : ""}</span></button>`).join("")}</span></div>`;
|
||||
}).join("");
|
||||
return `${title(c.title)}<div class="aq">${rows}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- water and gas
|
||||
// Daily use from Home Assistant's long-term statistics of a meter (the same numbers the energy dashboard uses).
|
||||
W.usage = {
|
||||
label: "Water and gas", icon: "droplet", size: [4, 3],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Meter (water, gas or energy)", domains: ["sensor"] },
|
||||
{ k: "title", t: "text", l: "Title", ph: "Entity name" },
|
||||
{ k: "kind", t: "seg", l: "Type", o: ["water", "gas", "electricity"], d: "water" },
|
||||
{ k: "days", t: "number", l: "Days to show", d: 7 },
|
||||
],
|
||||
render(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a meter");
|
||||
let unit = s.attributes.unit_of_measurement || "";
|
||||
// Water reads better in litres than in thousandths of a cubic metre.
|
||||
const liters = (c.kind || "water") === "water" && unit === "m³";
|
||||
if (liters) unit = "L";
|
||||
const ico = { water: "droplet", gas: "flame", electricity: "bolt" }[c.kind] || "droplet";
|
||||
const head = `<div class="us-head"><span class="us-ico ${esc(c.kind || "water")}">${Icons.svg(ico)}</span><span class="name">${esc(c.title || friendly(s))}</span></div>`;
|
||||
if (cache.error) return `${head}<div class="empty">${t("No statistics for this meter. It needs a state class (total or total_increasing).")}</div>`;
|
||||
if (!cache.days) return `${head}<div class="empty">${t("Loading…")}</div>`;
|
||||
const days = liters ? cache.days.map((d) => ({ ...d, v: Math.round(d.v * 1000) })) : cache.days;
|
||||
const today = days[days.length - 1]?.v ?? 0, yest = days[days.length - 2]?.v;
|
||||
const max = Math.max(...days.map((d) => d.v), 0.0001), avg = days.slice(0, -1).reduce((a, d) => a + d.v, 0) / Math.max(1, days.length - 1);
|
||||
return `${head}<div class="us ${esc(c.kind || "water")}">
|
||||
<div class="us-now"><span class="us-big num">${esc(fmtNum(today))}<small>${esc(unit)}</small></span>
|
||||
<span class="us-cmp">${t("Today so far")}${yest != null ? ` · ${t("yesterday {0}", `${fmtNum(yest)} ${unit}`)}` : ""}</span></div>
|
||||
<div class="us-bars" role="img" aria-label="${esc(days.map((d) => `${d.label}: ${fmtNum(d.v)} ${unit}`).join(", "))}">
|
||||
${avg ? `<i class="us-avg" style="bottom:${(avg / max) * 100}%" title="${esc(t("Average"))}"></i>` : ""}
|
||||
${days.map((d, i) => `<div class="us-day ${i === days.length - 1 ? "today" : ""}"><span class="us-bar" style="height:${Math.max(2, (d.v / max) * 100)}%"></span><span class="us-lbl">${esc(d.label)}</span></div>`).join("")}</div>
|
||||
<div class="us-foot"><span>${t("{0} days", days.length)}: <b class="num">${esc(fmtNum(days.reduce((a, d) => a + d.v, 0)))} ${esc(unit)}</b></span><span>${t("Average {0}", `${fmtNum(avg)} ${unit}`)}</span></div>
|
||||
</div>`;
|
||||
},
|
||||
async after(el, c, ctx) {
|
||||
if (!ctx.ha.states[c.entity]) return;
|
||||
const cache = ctx.cache, n = Math.max(3, Math.min(31, Number(c.days) || 7));
|
||||
const key = `${c.entity}|${n}`;
|
||||
if (cache.key === key && Date.now() - cache.at < 10 * 60 * 1000) return;
|
||||
cache.key = key; cache.at = Date.now();
|
||||
try {
|
||||
const rows = await ctx.ha.statistics(c.entity, n);
|
||||
cache.days = rows.map((r) => ({ v: Math.max(0, Number(r.change) || 0), label: new Date(r.start).toLocaleDateString(LOCALE, { weekday: "short" }) }));
|
||||
cache.error = !rows.length;
|
||||
} catch { cache.error = true; }
|
||||
ctx.rerender(); // the card may have been redrawn while loading: draw whichever one is on screen now
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- solar forecast
|
||||
// Forecast.Solar: the day totals come from its sensors, the hour by hour forecast from the energy API.
|
||||
W.solar = {
|
||||
label: "Solar forecast", icon: "solar", size: [4, 4],
|
||||
fields: [
|
||||
{ k: "title", t: "text", l: "Title", d: "Solar forecast" },
|
||||
{ k: "today", t: "entity", l: "Production today (kWh)", domains: ["sensor"] },
|
||||
{ k: "tomorrow", t: "entity", l: "Production tomorrow (kWh)", domains: ["sensor"] },
|
||||
{ k: "now", t: "entity", l: "Power now (W)", domains: ["sensor"] },
|
||||
{ k: "hours", t: "number", l: "Hours for the washer or dishwasher", d: 3 },
|
||||
],
|
||||
autofill: {
|
||||
from: "today",
|
||||
fill: (eid, ha) => {
|
||||
const has = (e) => (ha.states[e] ? e : "");
|
||||
return { tomorrow: has(eid.replace("today", "tomorrow")), now: has(eid.replace("energy_production_today", "power_production_now")) };
|
||||
},
|
||||
},
|
||||
entities: (c) => [c.today, c.tomorrow, c.now].filter(Boolean),
|
||||
render(c, { ha, cache }) {
|
||||
const today = ha.states[c.today], tomorrow = ha.states[c.tomorrow], now = ha.states[c.now];
|
||||
if (!today && !tomorrow) return `${title(c.title)}${missing(c.today, "the production today sensor")}`;
|
||||
const kwh = (s) => (s ? `${fmtNum(s.state)}<small>${esc(s.attributes.unit_of_measurement || "kWh")}</small>` : "–");
|
||||
const hours = cache.hours || [];
|
||||
const day0 = new Date(); day0.setHours(0, 0, 0, 0);
|
||||
const todays = hours.filter((h) => h.t >= day0.getTime() && h.t < day0.getTime() + 86400000);
|
||||
const shown = todays.filter((h) => h.wh > 0 || (new Date(h.t).getHours() >= 7 && new Date(h.t).getHours() <= 20));
|
||||
const max = Math.max(...shown.map((h) => h.wh), 1), nowH = new Date().getHours();
|
||||
// the best window to run an appliance: the most sun in N hours in a row, from now on
|
||||
const n = Math.max(1, Math.min(6, Number(c.hours) || 3));
|
||||
const ahead = hours.filter((h) => h.t >= Date.now() - 3600000);
|
||||
let best = null;
|
||||
for (let i = 0; i + n <= ahead.length; i++) {
|
||||
const sum = ahead.slice(i, i + n).reduce((a, h) => a + h.wh, 0);
|
||||
if (ahead[i + n - 1].t - ahead[i].t === (n - 1) * 3600000 && (!best || sum > best.sum)) best = { sum, from: ahead[i].t, to: ahead[i].t + n * 3600000 };
|
||||
}
|
||||
const hmT = (ms) => new Date(ms).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
|
||||
return `${title(c.title)}<div class="sf">
|
||||
<div class="sf-top"><div class="sf-main"><span class="sf-lbl">${t("Today")}</span><span class="sf-big num">${kwh(today)}</span></div>
|
||||
${tomorrow ? `<div><span class="sf-lbl">${t("Tomorrow")}</span><span class="sf-mid num">${kwh(tomorrow)}</span></div>` : ""}
|
||||
${now ? `<div><span class="sf-lbl">${t("Now")}</span><span class="sf-mid num">${esc(fmtState(now))}</span></div>` : ""}</div>
|
||||
${shown.length ? `<div class="sf-bars">${shown.map((h) => { const hr = new Date(h.t).getHours(); return `<div class="sf-h ${hr === nowH ? "now" : ""} ${hr < nowH ? "past" : ""} ${best && h.t >= best.from && h.t < best.to ? "best" : ""}" title="${esc(`${hmT(h.t)}: ${fmtNum(h.wh / 1000)} kWh`)}"><i style="height:${Math.max(2, (h.wh / max) * 100)}%"></i>${hr % 3 === 0 ? `<span>${hr}</span>` : ""}</div>`; }).join("")}</div>`
|
||||
: cache.loaded ? `<div class="muted small">${t("The hour by hour forecast isn’t available.")}</div>` : ""}
|
||||
${best ? `<div class="sf-best">${Icons.svg("washer")}<span>${esc(t("Best time for the washer: {0}", `${dayLabel(best.from) === t("Today") ? "" : dayLabel(best.from) + " "}${hmT(best.from)}–${hmT(best.to)}`))}</span></div>` : ""}
|
||||
</div>`;
|
||||
},
|
||||
async after(el, c, ctx) {
|
||||
const cache = ctx.cache;
|
||||
if (cache.at && Date.now() - cache.at < 30 * 60 * 1000) return;
|
||||
cache.at = Date.now();
|
||||
try {
|
||||
const res = await ctx.ha.solarForecast();
|
||||
const sum = {};
|
||||
for (const entry of Object.values(res || {})) for (const [iso, wh] of Object.entries(entry.wh_hours || {})) { const tm = new Date(iso).getTime(); sum[tm] = (sum[tm] || 0) + Number(wh || 0); }
|
||||
cache.hours = Object.entries(sum).map(([tm, wh]) => ({ t: Number(tm), wh })).sort((a, b) => a.t - b.t);
|
||||
} catch { cache.hours = []; }
|
||||
cache.loaded = true;
|
||||
ctx.rerender();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- garage and gate
|
||||
/** Who changed an entity last, from the user on its context (matched to a person). */
|
||||
function changedBy(s, ha) {
|
||||
const uid = s?.context?.user_id;
|
||||
if (!uid) return "";
|
||||
const p = Object.values(ha.states).find((x) => x.entity_id.startsWith("person.") && x.attributes.user_id === uid);
|
||||
return p ? friendly(p) : "";
|
||||
}
|
||||
|
||||
W.gate = {
|
||||
label: "Garage and gate", icon: "garage", size: [3, 3],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Garage door, gate or lock", domains: ["cover", "lock", "switch", "button", "input_boolean"] },
|
||||
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||
{ k: "kind", t: "seg", l: "Icon", o: ["garage", "gate", "door"], d: "garage" },
|
||||
{ k: "confirm", t: "check", l: "Ask before opening or closing", d: true },
|
||||
],
|
||||
render(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a garage door or gate");
|
||||
const d = domainOf(c.entity), st0 = s.state;
|
||||
const open = ["open", "opening", "unlocked", "unlocking", "on"].includes(st0), moving = ["opening", "closing", "locking", "unlocking"].includes(st0);
|
||||
const name = c.name || friendly(s);
|
||||
const label = d === "button" ? t("Press") : moving ? st(st0) : open ? (d === "lock" ? t("Lock") : t("Close")) : d === "lock" ? t("Unlock") : t("Open");
|
||||
const by = changedBy(s, ha);
|
||||
const since = `${open ? t("Open") : st(st0)} · ${relTime(s.last_changed)}${by ? ` · ${t("by {0}", by)}` : ""}`;
|
||||
const ico = { garage: "garage", gate: "gate", door: "door" }[c.kind] || "garage";
|
||||
return `<div class="gt ${open ? "open" : ""} ${moving ? "moving" : ""}">
|
||||
<div class="gt-head"><span class="name">${esc(name)}</span><span class="gt-state">${esc(open ? t("Open") : d === "button" ? "" : st(st0))}</span></div>
|
||||
<button class="gt-btn wbtn" data-act="gate" data-e="${esc(c.entity)}" data-v="${c.confirm === false ? "" : "1"}" aria-label="${esc(`${label}: ${name}`)}">
|
||||
<span class="gt-ring">${Icons.svg(open && c.kind === "door" ? "door-open" : ico)}</span><span class="gt-lbl">${esc(label)}</span></button>
|
||||
<div class="gt-since">${esc(since)}</div></div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- sports (TeamTracker)
|
||||
const isMatchDay = (s) => s && (s.state === "IN" || (s.attributes.date && dayLabel(s.attributes.date) === t("Today")));
|
||||
|
||||
W.sports = {
|
||||
label: "Sports", icon: "trophy", size: [4, 3],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Team (TeamTracker sensor)", domains: ["sensor"] },
|
||||
{ k: "matchDay", t: "check", l: "Only show on match days", d: false },
|
||||
{ k: "venue", t: "check", l: "Show the venue and TV channel", d: true },
|
||||
],
|
||||
autoHide: (c, { ha }) => !!c.matchDay && !isMatchDay(ha.states[c.entity]),
|
||||
render(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a TeamTracker sensor");
|
||||
const a = s.attributes, state = String(s.state).toUpperCase();
|
||||
if (state === "NOT_FOUND" || state === "BYE" || !a.opponent_name) return `<div class="sp"><div class="sp-league">${esc(a.league || friendly(s))}</div><div class="empty">${state === "BYE" ? t("No match this round.") : t("No match planned.")}</div></div>`;
|
||||
const team = { name: a.team_name, short: a.team_abbr, logo: a.team_logo, score: a.team_score, win: a.team_winner },
|
||||
opp = { name: a.opponent_name, short: a.opponent_abbr, logo: a.opponent_logo, score: a.opponent_score, win: a.opponent_winner };
|
||||
const [home, away] = a.team_homeaway === "away" ? [opp, team] : [team, opp];
|
||||
const side = (x) => `<div class="sp-team ${x.win ? "win" : ""}">${x.logo ? `<img src="${esc(x.logo)}" alt="">` : `<span class="sp-badge">${esc((x.short || x.name || "?").slice(0, 3))}</span>`}<span>${esc(x.name || x.short || "")}</span></div>`;
|
||||
const kick = a.date ? new Date(a.date) : null;
|
||||
let mid;
|
||||
if (state === "PRE") mid = `<span class="sp-time num">${kick ? esc(kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : "–"}</span><span class="sp-sub">${esc(kick ? dayLabel(kick) : "")}${a.kickoff_in ? ` · ${esc(a.kickoff_in)}` : ""}</span>`;
|
||||
else mid = `<span class="sp-score num">${esc(home.score ?? 0)}<i>–</i>${esc(away.score ?? 0)}</span><span class="sp-sub">${state === "IN" ? `<b class="sp-live">${t("Live")}</b> ${esc(a.clock || "")}` : t("Full time")}</span>`;
|
||||
const extra = c.venue !== false ? [a.venue, a.tv_network].filter(Boolean).join(" · ") : "";
|
||||
return `<div class="sp ${state === "IN" ? "live" : ""}"><div class="sp-league">${esc(a.league || a.sport || "")}${a.event_name && state !== "PRE" ? "" : ""}</div>
|
||||
<div class="sp-match">${side(home)}<div class="sp-mid">${mid}</div>${side(away)}</div>
|
||||
${extra ? `<div class="sp-extra">${esc(extra)}</div>` : ""}${state === "IN" && a.last_play ? `<div class="sp-extra">${esc(a.last_play)}</div>` : ""}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Formula One (F1 Sensor integration)
|
||||
// Its sensors carry the season data from the Jolpica (Ergast) API in their attributes.
|
||||
const F1_SESSIONS = [["first_practice", "Practice 1"], ["second_practice", "Practice 2"], ["third_practice", "Practice 3"], ["sprint_qualifying", "Sprint qualifying"], ["sprint", "Sprint"], ["qualifying", "Qualifying"], ["race", "Race"]];
|
||||
const f1List = (s, ...keys) => { const a = s?.attributes || {}; for (const k of keys) if (Array.isArray(a[k])) return a[k]; return []; };
|
||||
const f1Driver = (r) => { const d = r.Driver || r.driver || {}; return typeof d === "string" ? d : [d.givenName, d.familyName].filter(Boolean).join(" ") || d.code || ""; };
|
||||
const f1Team = (r) => { const c = r.Constructor || (r.Constructors || [])[0] || r.constructor_name || r.team; return typeof c === "string" ? c : c?.name || ""; };
|
||||
|
||||
W.f1 = {
|
||||
label: "Formula One", icon: "flag", size: [4, 5],
|
||||
fields: [
|
||||
{ k: "next", t: "entity", l: "Next race", domains: ["sensor"] },
|
||||
{ k: "drivers", t: "entity", l: "Driver standings", domains: ["sensor"] },
|
||||
{ k: "teams", t: "entity", l: "Constructor standings", domains: ["sensor"] },
|
||||
{ k: "results", t: "entity", l: "Last race results", domains: ["sensor"] },
|
||||
{ k: "show", t: "seg", l: "Show first", o: ["drivers", "teams", "results"], d: "drivers" },
|
||||
{ k: "top", t: "number", l: "Positions to show", d: 5 },
|
||||
],
|
||||
autofill: {
|
||||
from: "next",
|
||||
fill: (eid, ha) => {
|
||||
const pre = eid.replace(/next_race.*$/, ""), has = (e) => (ha.states[e] ? e : "");
|
||||
return { drivers: has(`${pre}driver_standings`), teams: has(`${pre}constructor_standings`), results: has(`${pre}last_race_results`) };
|
||||
},
|
||||
},
|
||||
entities: (c) => [c.next, c.drivers, c.teams, c.results].filter(Boolean),
|
||||
render(c, { ha, cache }) {
|
||||
const next = ha.states[c.next];
|
||||
if (!next && !c.drivers) return missing(c.next, "the next race sensor");
|
||||
const a = next?.attributes || {};
|
||||
const iso = (k) => a[`${k}_start`] || a[`${k}_date`] || a[`${k}_start_local`];
|
||||
const raceAt = iso("race") ? new Date(iso("race")) : null;
|
||||
let countdown = "";
|
||||
if (raceAt && raceAt > Date.now()) {
|
||||
const m = Math.round((raceAt - Date.now()) / 60000), dd = Math.floor(m / 1440), hh = Math.floor((m % 1440) / 60);
|
||||
countdown = dd ? t("in {0} d {1} h", dd, hh) : t("in {0} h {1} min", hh, m % 60);
|
||||
}
|
||||
const when = (d) => `${d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" })} ${d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })}`;
|
||||
const sessions = F1_SESSIONS.map(([k, l]) => [l, iso(k)]).filter(([, v]) => v && !isNaN(new Date(v)));
|
||||
const nextSession = sessions.find(([, v]) => new Date(v) > Date.now());
|
||||
const place = [a.circuit_name || a.Circuit?.circuitName, a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · ");
|
||||
const tab = cache.tab || c.show || "drivers", top = Math.max(1, Math.min(20, Number(c.top) || 5));
|
||||
const tabs = [["drivers", "Drivers", c.drivers], ["teams", "Teams", c.teams], ["results", "Last race", c.results]].filter(([, , e]) => e && ha.states[e]);
|
||||
const cur = tabs.find(([k]) => k === tab) || tabs[0];
|
||||
let rows = "";
|
||||
if (cur) {
|
||||
const s = ha.states[cur[2]];
|
||||
const list = cur[0] === "drivers" ? f1List(s, "driver_standings", "standings", "DriverStandings")
|
||||
: cur[0] === "teams" ? f1List(s, "constructor_standings", "standings", "ConstructorStandings") : f1List(s, "results", "Results", "race_results");
|
||||
rows = list.slice(0, top).map((r) => {
|
||||
const name = cur[0] === "teams" ? f1Team(r) : f1Driver(r);
|
||||
const sub = cur[0] === "teams" ? (r.wins != null ? t("{0} wins", r.wins) : "") : f1Team(r);
|
||||
const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""} ${t("pts")}`;
|
||||
return `<div class="f1-row"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><span class="f1-name"><b>${esc(name)}</b><span>${esc(sub)}</span></span><span class="f1-val num">${esc(val)}</span></div>`;
|
||||
}).join("");
|
||||
}
|
||||
const raceName = a.race_name || a.raceName || (next && !/^\d{4}-/.test(next.state) ? next.state : "");
|
||||
return `<div class="f1">
|
||||
${next ? `<div class="f1-next"><div class="f1-top">${Icons.svg("flag")}<span>${esc(a.season ? `${a.season} · ${t("Round {0}", a.round)}` : t("Next race"))}</span>${countdown ? `<b>${esc(countdown)}</b>` : ""}</div>
|
||||
<div class="f1-race">${esc(raceName || t("Next race"))}</div>${place ? `<div class="f1-place">${esc(place)}</div>` : ""}
|
||||
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => `<div class="${nextSession && nextSession[1] === v ? "next" : ""} ${new Date(v) < Date.now() ? "past" : ""}"><span>${esc(t(l))}</span><span class="num">${esc(when(new Date(v)))}</span></div>`).join("")}</div>` : ""}</div>` : ""}
|
||||
${tabs.length ? `<div class="f1-tabs" role="tablist">${tabs.map(([k, l]) => `<button class="wbtn" role="tab" data-act="card-tab" data-v="${k}" aria-selected="${cur[0] === k}">${esc(t(l))}</button>`).join("")}</div>
|
||||
<div class="f1-list">${rows || `<div class="empty">${t("No standings yet.")}</div>`}</div>` : ""}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
W.text = {
|
||||
label: "Text", icon: "text", size: [4, 2],
|
||||
fields: [
|
||||
|
||||
Reference in New Issue
Block a user