278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
+34 -1
View File
@@ -764,6 +764,15 @@
"panel.reward_cost_label": "Kosten ({points_name})",
"panel.reward_description_label": "Beschreibung (optional)",
"panel.reward_expires_hint": "Gesammelte Punkte werden um Mitternacht zurückerstattet.",
"panel.reward_timelock_section": "Erweitert — Zeitsperre",
"panel.reward_timelock_label": "Einlösen zeitlich begrenzen",
"panel.reward_timelock_hint": "Die Belohnung bleibt sichtbar, kann aber nur innerhalb des festgelegten Zeitfensters eingelöst werden. Punkte ansparen ist nie blockiert.",
"panel.reward_timelock_days_label": "Tage",
"panel.reward_timelock_days_hint": "Alle Tage aus lassen, wenn es keine Tagesbeschränkung geben soll.",
"panel.reward_timelock_from_label": "Von",
"panel.reward_timelock_until_label": "Bis",
"panel.reward_timelock_times_hint": "Beide Felder leer lassen für jede Uhrzeit. Die Endzeit ist exklusiv, 07:00–19:00 endet also um 19:00 Uhr. Ist der Start später als das Ende, läuft das Fenster über Mitternacht.",
"panel.reward_timelock_badge": "Zeitgesperrt",
"panel.reward_expires_label": "Läuft ab (leer = nie)",
"panel.reward_icon_label": "Symbol",
"panel.reward_is_jackpot_hint": "Beiträge aller Kinder für eine gemeinsame Belohnung zusammenlegen.",
@@ -799,6 +808,8 @@
"panel.settings_parents_title": "Eltern (ohne Admin-Rechte)",
"panel.settings_parents_hint": "Geben Sie einem Haushaltsmitglied ohne Admin-Rechte die tägliche Kontrolle — Aufgaben bestätigen, Punkte anpassen, Belohnungen freigeben — ohne Home-Assistant-Administratorrechte. Dieses Admin-Panel können sie weiterhin nicht öffnen und die Konfiguration nicht ändern.",
"panel.settings_parents_empty": "Keine Home-Assistant-Benutzer ohne Admin-Rechte gefunden. Erstellen Sie zuerst einen normalen HA-Benutzer und wählen Sie ihn dann hier aus.",
"panel.settings_require_linked_child_label": "Verknüpftes Konto pro Kind verlangen",
"panel.settings_require_linked_child_hint": "Nur das eigene Home-Assistant-Konto eines Kindes darf für dieses Kind handeln. Für ein gemeinsames Familien-Tablet, auf dem ein Konto für alle genutzt wird, ausgeschaltet lassen. Eltern und Admins können immer für jedes Kind handeln.",
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
"panel.settings_evening_label": "Abend",
"panel.settings_history_title": "Verlauf & Serien",
@@ -1217,6 +1228,8 @@
"rewards.reward_count_plural": "{count} Belohnungen",
"rewards.reward_count_singular": "{count} Belohnung",
"rewards.sold_out": "Ausverkauft",
"rewards.available_when": "Verfügbar {window}",
"rewards.locked_now": "Derzeit nicht verfügbar",
"rewards.spendable_balance": "Ausgebbar",
"rewards.unavailable_hint": "Diese Belohnung kann nicht mehr beansprucht werden",
"streak.achievement.fifty_desc": "Erledige 50 Aufgaben",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Punktzahl statt Sterne im Bildmodus anzeigen",
"common.design.accessible": "Barrierefrei",
"child.editor.include_anytime_chores": "Jederzeit-Aufgaben einbeziehen",
"child.editor.include_anytime_chores_helper": "Jederzeit-Aufgaben erscheinen normalerweise in jeder Zeitkategorie – deaktivieren, um sie auszublenden"
"child.editor.include_anytime_chores_helper": "Jederzeit-Aufgaben erscheinen normalerweise in jeder Zeitkategorie – deaktivieren, um sie auszublenden",
"panel.settings_sounds_title": "Abschlussgeräusche",
"panel.settings_sounds_hint": "Eigene Soundclips hochladen und pro Aufgabe auswählen.",
"panel.settings_sounds_empty": "Noch keine eigenen Geräusche.",
"panel.settings_sounds_formats": "MP3, OGG, WAV oder M4A. Max. 1 MB pro Datei.",
"panel.sound_upload": "Geräusch hochladen",
"panel.sound_uploading": "Wird hochgeladen…",
"panel.sound_uploaded": "Geräusch hinzugefügt",
"panel.sound_upload_failed": "Upload fehlgeschlagen",
"panel.sound_too_large": "Dieses Geräusch ist zu groß (max. 1 MB).",
"panel.sound_bad_type": "Wähle eine MP3-, OGG-, WAV- oder M4A-Datei.",
"panel.sound_storage_full": "Der Speicher für Geräusche ist voll. Lösche ein Geräusch und versuche es erneut.",
"panel.sound_default_name": "Mein Geräusch",
"panel.sound_preview": "Abspielen",
"panel.sound_rename": "Umbenennen",
"panel.sound_rename_prompt": "Name für dieses Geräusch:",
"panel.sound_name_required": "Gib dem Geräusch einen Namen.",
"panel.sound_delete": "Löschen",
"panel.sound_delete_confirm": "Geräusch „{name}“ löschen? Aufgaben, die es nutzen, erhalten wieder das Standardgeräusch.",
"panel.sound_deleted": "Geräusch gelöscht",
"panel.sound_deleted_reset": "Geräusch gelöscht. {count} Aufgabe(n) auf das Standardgeräusch zurückgesetzt."
}
@@ -802,6 +802,15 @@
"panel.reward_cost_label": "Cost ({points_name})",
"panel.reward_description_label": "Description (optional)",
"panel.reward_expires_hint": "Pooled points are refunded at midnight.",
"panel.reward_timelock_section": "Advanced — time lock",
"panel.reward_timelock_label": "Limit when this can be claimed",
"panel.reward_timelock_hint": "The reward stays visible, but children can only claim it inside the window you set. Saving points into a pool reward is never blocked.",
"panel.reward_timelock_days_label": "Days",
"panel.reward_timelock_days_hint": "Leave every day off for no day restriction.",
"panel.reward_timelock_from_label": "From",
"panel.reward_timelock_until_label": "Until",
"panel.reward_timelock_times_hint": "Leave both blank for any time of day. The end time is exclusive, so 07:00–19:00 stops at 19:00. A start later than the end spans midnight.",
"panel.reward_timelock_badge": "Time-locked",
"panel.reward_expires_label": "Expires (blank = never)",
"panel.reward_icon_label": "Icon",
"panel.reward_is_jackpot_hint": "Combine all children's contributions toward one shared reward.",
@@ -837,6 +846,8 @@
"panel.settings_parents_title": "Parents (no admin rights)",
"panel.settings_parents_hint": "Give a non-admin household member day-to-day control — approve chores, adjust points, confirm rewards — without Home Assistant admin rights. They still can't open this admin panel or change configuration.",
"panel.settings_parents_empty": "No non-admin Home Assistant users found. Create a standard HA user first, then tick them here.",
"panel.settings_require_linked_child_label": "Require a linked account per child",
"panel.settings_require_linked_child_hint": "Only let a child's own Home Assistant account act as that child. Leave off for a shared family tablet where one account is used for everyone. Parents and admins can always act for any child.",
"panel.settings_evening_hint": "Default 17:00 – 21:00",
"panel.settings_evening_label": "Evening",
"panel.settings_history_title": "History & streaks",
@@ -1270,6 +1281,8 @@
"rewards.reward_count_plural": "{count} rewards",
"rewards.reward_count_singular": "{count} reward",
"rewards.sold_out": "Sold Out",
"rewards.available_when": "Available {window}",
"rewards.locked_now": "Not available right now",
"rewards.spendable_balance": "Spendable",
"rewards.unavailable_hint": "This reward can no longer be claimed",
"streak.achievement.fifty_desc": "Complete 50 chores",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Show point values instead of stars in picture mode",
"common.design.accessible": "Accessible",
"child.editor.include_anytime_chores": "Include anytime chores",
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them"
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them",
"panel.settings_sounds_title": "Completion sounds",
"panel.settings_sounds_hint": "Upload your own sound clips and pick them per chore.",
"panel.settings_sounds_empty": "No custom sounds yet.",
"panel.settings_sounds_formats": "MP3, OGG, WAV or M4A. Max 1 MB each.",
"panel.sound_upload": "Upload sound",
"panel.sound_uploading": "Uploading…",
"panel.sound_uploaded": "Sound added",
"panel.sound_upload_failed": "Upload failed",
"panel.sound_too_large": "That sound is too large (max 1 MB).",
"panel.sound_bad_type": "Choose an MP3, OGG, WAV or M4A file.",
"panel.sound_storage_full": "Sound storage is full. Delete a sound and try again.",
"panel.sound_default_name": "My sound",
"panel.sound_preview": "Play",
"panel.sound_rename": "Rename",
"panel.sound_rename_prompt": "Name for this sound:",
"panel.sound_name_required": "Give the sound a name.",
"panel.sound_delete": "Delete",
"panel.sound_delete_confirm": "Delete the sound “{name}”? Any chore using it goes back to the default sound.",
"panel.sound_deleted": "Sound deleted",
"panel.sound_deleted_reset": "Sound deleted. {count} chore(s) reset to the default sound."
}
+34 -1
View File
@@ -802,6 +802,15 @@
"panel.reward_cost_label": "Cost ({points_name})",
"panel.reward_description_label": "Description (optional)",
"panel.reward_expires_hint": "Pooled points are refunded at midnight.",
"panel.reward_timelock_section": "Advanced — time lock",
"panel.reward_timelock_label": "Limit when this can be claimed",
"panel.reward_timelock_hint": "The reward stays visible, but children can only claim it inside the window you set. Saving points into a pool reward is never blocked.",
"panel.reward_timelock_days_label": "Days",
"panel.reward_timelock_days_hint": "Leave every day off for no day restriction.",
"panel.reward_timelock_from_label": "From",
"panel.reward_timelock_until_label": "Until",
"panel.reward_timelock_times_hint": "Leave both blank for any time of day. The end time is exclusive, so 07:00–19:00 stops at 19:00. A start later than the end spans midnight.",
"panel.reward_timelock_badge": "Time-locked",
"panel.reward_expires_label": "Expires (blank = never)",
"panel.reward_icon_label": "Icon",
"panel.reward_is_jackpot_hint": "Combine all children's contributions toward one shared reward.",
@@ -837,6 +846,8 @@
"panel.settings_parents_title": "Parents (no admin rights)",
"panel.settings_parents_hint": "Give a non-admin household member day-to-day control — approve chores, adjust points, confirm rewards — without Home Assistant admin rights. They still can't open this admin panel or change configuration.",
"panel.settings_parents_empty": "No non-admin Home Assistant users found. Create a standard HA user first, then tick them here.",
"panel.settings_require_linked_child_label": "Require a linked account per child",
"panel.settings_require_linked_child_hint": "Only let a child's own Home Assistant account act as that child. Leave off for a shared family tablet where one account is used for everyone. Parents and admins can always act for any child.",
"panel.settings_evening_hint": "Default 17:00 – 21:00",
"panel.settings_evening_label": "Evening",
"panel.settings_history_title": "History & streaks",
@@ -1270,6 +1281,8 @@
"rewards.reward_count_plural": "{count} rewards",
"rewards.reward_count_singular": "{count} reward",
"rewards.sold_out": "Sold Out",
"rewards.available_when": "Available {window}",
"rewards.locked_now": "Not available right now",
"rewards.spendable_balance": "Spendable",
"rewards.unavailable_hint": "This reward can no longer be claimed",
"streak.achievement.fifty_desc": "Complete 50 chores",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Show point values instead of stars in picture mode",
"common.design.accessible": "Accessible",
"child.editor.include_anytime_chores": "Include anytime chores",
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them"
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them",
"panel.settings_sounds_title": "Completion sounds",
"panel.settings_sounds_hint": "Upload your own sound clips and pick them per chore.",
"panel.settings_sounds_empty": "No custom sounds yet.",
"panel.settings_sounds_formats": "MP3, OGG, WAV or M4A. Max 1 MB each.",
"panel.sound_upload": "Upload sound",
"panel.sound_uploading": "Uploading…",
"panel.sound_uploaded": "Sound added",
"panel.sound_upload_failed": "Upload failed",
"panel.sound_too_large": "That sound is too large (max 1 MB).",
"panel.sound_bad_type": "Choose an MP3, OGG, WAV or M4A file.",
"panel.sound_storage_full": "Sound storage is full. Delete a sound and try again.",
"panel.sound_default_name": "My sound",
"panel.sound_preview": "Play",
"panel.sound_rename": "Rename",
"panel.sound_rename_prompt": "Name for this sound:",
"panel.sound_name_required": "Give the sound a name.",
"panel.sound_delete": "Delete",
"panel.sound_delete_confirm": "Delete the sound “{name}”? Any chore using it goes back to the default sound.",
"panel.sound_deleted": "Sound deleted",
"panel.sound_deleted_reset": "Sound deleted. {count} chore(s) reset to the default sound."
}
+34 -1
View File
@@ -764,6 +764,15 @@
"panel.reward_cost_label": "Coût ({points_name})",
"panel.reward_description_label": "Description (optionnel)",
"panel.reward_expires_hint": "Les points en cagnotte sont remboursés à minuit.",
"panel.reward_timelock_section": "Avancé — verrouillage horaire",
"panel.reward_timelock_label": "Limiter quand elle peut être réclamée",
"panel.reward_timelock_hint": "La récompense reste visible, mais ne peut être réclamée que pendant la plage définie. Mettre des points en cagnotte n'est jamais bloqué.",
"panel.reward_timelock_days_label": "Jours",
"panel.reward_timelock_days_hint": "Laissez tous les jours désactivés pour ne restreindre aucun jour.",
"panel.reward_timelock_from_label": "De",
"panel.reward_timelock_until_label": "À",
"panel.reward_timelock_times_hint": "Laissez les deux champs vides pour autoriser toute heure. L'heure de fin est exclue : 07:00–19:00 s'arrête à 19:00. Si le début est après la fin, la plage passe minuit.",
"panel.reward_timelock_badge": "Horaire limité",
"panel.reward_expires_label": "Expire (vide = jamais)",
"panel.reward_icon_label": "Icône",
"panel.reward_is_jackpot_hint": "Combiner les contributions de tous les enfants vers une récompense partagée.",
@@ -799,6 +808,8 @@
"panel.settings_parents_title": "Parents (sans droits admin)",
"panel.settings_parents_hint": "Donnez à un membre du foyer non-administrateur le contrôle quotidien — valider les tâches, ajuster les points, confirmer les récompenses — sans droits d’administrateur Home Assistant. Il ne pourra toujours pas ouvrir ce panneau d’administration ni modifier la configuration.",
"panel.settings_parents_empty": "Aucun utilisateur Home Assistant non-administrateur trouvé. Créez d’abord un utilisateur HA standard, puis cochez-le ici.",
"panel.settings_require_linked_child_label": "Exiger un compte lié par enfant",
"panel.settings_require_linked_child_hint": "Seul le compte Home Assistant d'un enfant peut agir en son nom. Laissez désactivé pour une tablette familiale partagée où un seul compte sert à tous. Les parents et les administrateurs peuvent toujours agir pour n'importe quel enfant.",
"panel.settings_evening_hint": "Par défaut 17:00 – 21:00",
"panel.settings_evening_label": "Soir",
"panel.settings_history_title": "Historique et séries",
@@ -1217,6 +1228,8 @@
"rewards.reward_count_plural": "{count} récompenses",
"rewards.reward_count_singular": "{count} récompense",
"rewards.sold_out": "Épuisée",
"rewards.available_when": "Disponible {window}",
"rewards.locked_now": "Indisponible pour le moment",
"rewards.spendable_balance": "Disponible",
"rewards.unavailable_hint": "Cette récompense ne peut plus être réclamée",
"streak.achievement.fifty_desc": "Accomplis 50 tâches",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Afficher les points au lieu des étoiles en mode images",
"common.design.accessible": "Accessible",
"child.editor.include_anytime_chores": "Inclure les tâches « n'importe quand »",
"child.editor.include_anytime_chores_helper": "Les tâches « n'importe quand » apparaissent normalement dans toutes les catégories de temps — désactivez pour les masquer"
"child.editor.include_anytime_chores_helper": "Les tâches « n'importe quand » apparaissent normalement dans toutes les catégories de temps — désactivez pour les masquer",
"panel.settings_sounds_title": "Sons de fin de tâche",
"panel.settings_sounds_hint": "Importez vos propres extraits sonores et choisissez-les par tâche.",
"panel.settings_sounds_empty": "Aucun son personnalisé pour le moment.",
"panel.settings_sounds_formats": "MP3, OGG, WAV ou M4A. 1 Mo maximum par fichier.",
"panel.sound_upload": "Importer un son",
"panel.sound_uploading": "Import en cours…",
"panel.sound_uploaded": "Son ajouté",
"panel.sound_upload_failed": "Échec de l’import",
"panel.sound_too_large": "Ce son est trop volumineux (1 Mo maximum).",
"panel.sound_bad_type": "Choisissez un fichier MP3, OGG, WAV ou M4A.",
"panel.sound_storage_full": "L’espace de stockage des sons est plein. Supprimez un son et réessayez.",
"panel.sound_default_name": "Mon son",
"panel.sound_preview": "Écouter",
"panel.sound_rename": "Renommer",
"panel.sound_rename_prompt": "Nom de ce son :",
"panel.sound_name_required": "Donnez un nom au son.",
"panel.sound_delete": "Supprimer",
"panel.sound_delete_confirm": "Supprimer le son « {name} » ? Les tâches qui l’utilisent reviendront au son par défaut.",
"panel.sound_deleted": "Son supprimé",
"panel.sound_deleted_reset": "Son supprimé. {count} tâche(s) remise(s) au son par défaut."
}
+34 -1
View File
@@ -764,6 +764,15 @@
"panel.reward_cost_label": "Kostnad ({points_name})",
"panel.reward_description_label": "Beskrivelse (valgfritt)",
"panel.reward_expires_hint": "Sparede poeng tilbakebetales ved midnatt.",
"panel.reward_timelock_section": "Avansert — tidslås",
"panel.reward_timelock_label": "Begrens når den kan kreves",
"panel.reward_timelock_hint": "Belønningen vises fortsatt, men kan bare kreves innenfor tidsrommet du angir. Å spare poeng blir aldri blokkert.",
"panel.reward_timelock_days_label": "Dager",
"panel.reward_timelock_days_hint": "La alle dagene være av for ingen dagsbegrensning.",
"panel.reward_timelock_from_label": "Fra",
"panel.reward_timelock_until_label": "Til",
"panel.reward_timelock_times_hint": "La begge feltene stå tomme for når som helst på døgnet. Sluttidspunktet er ekskludert, så 07:00–19:00 stopper 19:00. Er starten senere enn slutten, går tidsrommet over midnatt.",
"panel.reward_timelock_badge": "Tidslåst",
"panel.reward_expires_label": "Utløper (tomt = aldri)",
"panel.reward_icon_label": "Ikon",
"panel.reward_is_jackpot_hint": "Kombiner alle barnas bidrag mot én felles belønning.",
@@ -799,6 +808,8 @@
"panel.settings_parents_title": "Foreldre (uten adminrettigheter)",
"panel.settings_parents_hint": "Gi et husstandsmedlem uten adminrettigheter daglig kontroll — godkjenne oppgaver, justere poeng, bekrefte belønninger — uten Home Assistant-adminrettigheter. De kan fortsatt ikke åpne dette adminpanelet eller endre konfigurasjonen.",
"panel.settings_parents_empty": "Fant ingen Home Assistant-brukere uten adminrettigheter. Opprett en vanlig HA-bruker først, og huk deretter av her.",
"panel.settings_require_linked_child_label": "Krev en koblet konto per barn",
"panel.settings_require_linked_child_hint": "La bare barnets egen Home Assistant-konto handle som det barnet. La stå av for et delt familienettbrett der én konto brukes av alle. Foreldre og administratorer kan alltid handle for alle barn.",
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
"panel.settings_evening_label": "Kveld",
"panel.settings_history_title": "Historikk og serier",
@@ -1217,6 +1228,8 @@
"rewards.reward_count_plural": "{count} belønninger",
"rewards.reward_count_singular": "{count} belønning",
"rewards.sold_out": "Utsolgt",
"rewards.available_when": "Tilgjengelig {window}",
"rewards.locked_now": "Ikke tilgjengelig akkurat nå",
"rewards.spendable_balance": "Disponibelt",
"rewards.unavailable_hint": "Denne belønningen kan ikke lenger kreves",
"streak.achievement.fifty_desc": "Fullfør 50 oppgaver",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Vis poengverdier i stedet for stjerner i bildemodus",
"common.design.accessible": "Tilgjengelig",
"child.editor.include_anytime_chores": "Ta med «når som helst»-oppgaver",
"child.editor.include_anytime_chores_helper": "«Når som helst»-oppgaver vises normalt i alle tidskategorier — slå av for å skjule dem"
"child.editor.include_anytime_chores_helper": "«Når som helst»-oppgaver vises normalt i alle tidskategorier — slå av for å skjule dem",
"panel.settings_sounds_title": "Fullføringslyder",
"panel.settings_sounds_hint": "Last opp egne lydklipp og velg dem per oppgave.",
"panel.settings_sounds_empty": "Ingen egne lyder ennå.",
"panel.settings_sounds_formats": "MP3, OGG, WAV eller M4A. Maks 1 MB per fil.",
"panel.sound_upload": "Last opp lyd",
"panel.sound_uploading": "Laster opp…",
"panel.sound_uploaded": "Lyd lagt til",
"panel.sound_upload_failed": "Opplasting mislyktes",
"panel.sound_too_large": "Denne lyden er for stor (maks 1 MB).",
"panel.sound_bad_type": "Velg en MP3-, OGG-, WAV- eller M4A-fil.",
"panel.sound_storage_full": "Lagringsplassen for lyder er full. Slett en lyd og prøv igjen.",
"panel.sound_default_name": "Min lyd",
"panel.sound_preview": "Spill av",
"panel.sound_rename": "Gi nytt navn",
"panel.sound_rename_prompt": "Navn på denne lyden:",
"panel.sound_name_required": "Gi lyden et navn.",
"panel.sound_delete": "Slett",
"panel.sound_delete_confirm": "Slette lyden «{name}»? Oppgaver som bruker den, går tilbake til standardlyden.",
"panel.sound_deleted": "Lyd slettet",
"panel.sound_deleted_reset": "Lyd slettet. {count} oppgave(r) tilbakestilt til standardlyden."
}
+34 -1
View File
@@ -764,6 +764,15 @@
"panel.reward_cost_label": "Kostnad ({points_name})",
"panel.reward_description_label": "Skildring (valfri)",
"panel.reward_expires_hint": "Samla poeng vert refunderte ved midnatt.",
"panel.reward_timelock_section": "Avansert — tidslås",
"panel.reward_timelock_label": "Avgrens når ho kan krevjast",
"panel.reward_timelock_hint": "Løninga er framleis synleg, men kan berre krevjast innanfor tidsrommet du set. Å spare poeng vert aldri blokkert.",
"panel.reward_timelock_days_label": "Dagar",
"panel.reward_timelock_days_hint": "Lat alle dagane vere av for inga dagsavgrensing.",
"panel.reward_timelock_from_label": "Frå",
"panel.reward_timelock_until_label": "Til",
"panel.reward_timelock_times_hint": "Lat begge felta stå tomme for kva tid som helst på døgnet. Slutt-tidspunktet er ekskludert, så 07:00–19:00 stoppar 19:00. Er starten seinare enn slutten, går tidsrommet over midnatt.",
"panel.reward_timelock_badge": "Tidslåst",
"panel.reward_expires_label": "Går ut (tomt = aldri)",
"panel.reward_icon_label": "Ikon",
"panel.reward_is_jackpot_hint": "Slå saman bidrag frå alle born mot éin felles premie.",
@@ -799,6 +808,8 @@
"panel.settings_parents_title": "Foreldre (utan adminrettar)",
"panel.settings_parents_hint": "Gi eit husstandsmedlem utan adminrettar dagleg kontroll — godkjenne oppgåver, justere poeng, stadfeste løningar — utan Home Assistant-adminrettar. Dei kan framleis ikkje opne dette adminpanelet eller endre konfigurasjonen.",
"panel.settings_parents_empty": "Fann ingen Home Assistant-brukarar utan adminrettar. Opprett ein vanleg HA-brukar først, og hak deretter av her.",
"panel.settings_require_linked_child_label": "Krev ein kopla konto per barn",
"panel.settings_require_linked_child_hint": "Lat berre barnet sin eigen Home Assistant-konto handle som det barnet. Lat stå av for eit delt familienettbrett der éin konto vert brukt av alle. Foreldre og administratorar kan alltid handle for alle barn.",
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
"panel.settings_evening_label": "Kveld",
"panel.settings_history_title": "Historikk og rekkjer",
@@ -1217,6 +1228,8 @@
"rewards.reward_count_plural": "{count} løningar",
"rewards.reward_count_singular": "{count} løning",
"rewards.sold_out": "Utseld",
"rewards.available_when": "Tilgjengeleg {window}",
"rewards.locked_now": "Ikkje tilgjengeleg akkurat no",
"rewards.spendable_balance": "Tilgjengeleg",
"rewards.unavailable_hint": "Denne løninga kan ikkje lenger krevjast",
"streak.achievement.fifty_desc": "Fullfør 50 oppgåver",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Vis poengverdiar i staden for stjerner i biletemodus",
"common.design.accessible": "Tilgjengeleg",
"child.editor.include_anytime_chores": "Ta med «kva tid som helst»-oppgåver",
"child.editor.include_anytime_chores_helper": "«Kva tid som helst»-oppgåver vert normalt viste i alle tidskategoriar — slå av for å skjule dei"
"child.editor.include_anytime_chores_helper": "«Kva tid som helst»-oppgåver vert normalt viste i alle tidskategoriar — slå av for å skjule dei",
"panel.settings_sounds_title": "Fullføringslydar",
"panel.settings_sounds_hint": "Last opp eigne lydklipp og vel dei per oppgåve.",
"panel.settings_sounds_empty": "Ingen eigne lydar ennå.",
"panel.settings_sounds_formats": "MP3, OGG, WAV eller M4A. Maks 1 MB per fil.",
"panel.sound_upload": "Last opp lyd",
"panel.sound_uploading": "Lastar opp…",
"panel.sound_uploaded": "Lyd lagt til",
"panel.sound_upload_failed": "Opplasting mislukkast",
"panel.sound_too_large": "Denne lyden er for stor (maks 1 MB).",
"panel.sound_bad_type": "Vel ei MP3-, OGG-, WAV- eller M4A-fil.",
"panel.sound_storage_full": "Lagringsplassen for lydar er full. Slett ein lyd og prøv igjen.",
"panel.sound_default_name": "Lyden min",
"panel.sound_preview": "Spel av",
"panel.sound_rename": "Gi nytt namn",
"panel.sound_rename_prompt": "Namn på denne lyden:",
"panel.sound_name_required": "Gi lyden eit namn.",
"panel.sound_delete": "Slett",
"panel.sound_delete_confirm": "Slette lyden «{name}»? Oppgåver som brukar han, går tilbake til standardlyden.",
"panel.sound_deleted": "Lyd sletta",
"panel.sound_deleted_reset": "Lyd sletta. {count} oppgåve(r) tilbakestilt til standardlyden."
}
@@ -764,6 +764,15 @@
"panel.reward_cost_label": "Custo ({points_name})",
"panel.reward_description_label": "Descrição (opcional)",
"panel.reward_expires_hint": "Pontos acumulados são devolvidos à meia-noite.",
"panel.reward_timelock_section": "Avançado — bloqueio por horário",
"panel.reward_timelock_label": "Limitar quando pode ser resgatada",
"panel.reward_timelock_hint": "A recompensa continua visível, mas só pode ser resgatada dentro do período definido. Acumular pontos nunca é bloqueado.",
"panel.reward_timelock_days_label": "Dias",
"panel.reward_timelock_days_hint": "Deixe todos os dias desligados para não restringir dias.",
"panel.reward_timelock_from_label": "Das",
"panel.reward_timelock_until_label": "Até",
"panel.reward_timelock_times_hint": "Deixe ambos em branco para qualquer horário do dia. O horário final é exclusivo, então 07:00–19:00 termina às 19:00. Se o início for depois do fim, o período atravessa a meia-noite.",
"panel.reward_timelock_badge": "Com bloqueio de horário",
"panel.reward_expires_label": "Expira (vazio = nunca)",
"panel.reward_icon_label": "Ícone",
"panel.reward_is_jackpot_hint": "Combina contribuições de todas as crianças para uma recompensa compartilhada.",
@@ -799,6 +808,8 @@
"panel.settings_parents_title": "Pais (sem direitos de administrador)",
"panel.settings_parents_hint": "Dê a um membro da família sem permissões de administrador o controle do dia a dia — aprovar tarefas, ajustar pontos, confirmar recompensas — sem direitos de administrador do Home Assistant. Ele ainda não poderá abrir este painel de administração nem alterar a configuração.",
"panel.settings_parents_empty": "Nenhum usuário do Home Assistant sem permissões de administrador foi encontrado. Crie primeiro um usuário HA padrão e depois marque-o aqui.",
"panel.settings_require_linked_child_label": "Exigir uma conta vinculada por criança",
"panel.settings_require_linked_child_hint": "Só permite que a conta do Home Assistant da própria criança atue como essa criança. Deixe desligado em um tablet familiar compartilhado em que uma conta serve para todos. Pais e administradores sempre podem atuar por qualquer criança.",
"panel.settings_evening_hint": "Padrão 17:00 – 21:00",
"panel.settings_evening_label": "Noite",
"panel.settings_history_title": "Histórico e sequências",
@@ -1217,6 +1228,8 @@
"rewards.reward_count_plural": "{count} recompensas",
"rewards.reward_count_singular": "{count} recompensa",
"rewards.sold_out": "Esgotada",
"rewards.available_when": "Disponível {window}",
"rewards.locked_now": "Indisponível no momento",
"rewards.spendable_balance": "Disponível",
"rewards.unavailable_hint": "Essa recompensa não pode mais ser reclamada",
"streak.achievement.fifty_desc": "Conclui 50 tarefas",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Mostrar pontos em vez de estrelas no modo imagens",
"common.design.accessible": "Acessível",
"child.editor.include_anytime_chores": "Incluir tarefas de qualquer hora",
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para ocultá-las"
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para ocultá-las",
"panel.settings_sounds_title": "Sons de conclusão",
"panel.settings_sounds_hint": "Envie seus próprios clipes de som e escolha-os por tarefa.",
"panel.settings_sounds_empty": "Ainda não há sons personalizados.",
"panel.settings_sounds_formats": "MP3, OGG, WAV ou M4A. Máximo de 1 MB cada.",
"panel.sound_upload": "Enviar som",
"panel.sound_uploading": "Enviando…",
"panel.sound_uploaded": "Som adicionado",
"panel.sound_upload_failed": "Falha no envio",
"panel.sound_too_large": "Esse som é muito grande (máx. 1 MB).",
"panel.sound_bad_type": "Escolha um arquivo MP3, OGG, WAV ou M4A.",
"panel.sound_storage_full": "O armazenamento de sons está cheio. Exclua um som e tente novamente.",
"panel.sound_default_name": "Meu som",
"panel.sound_preview": "Reproduzir",
"panel.sound_rename": "Renomear",
"panel.sound_rename_prompt": "Nome deste som:",
"panel.sound_name_required": "Dê um nome ao som.",
"panel.sound_delete": "Excluir",
"panel.sound_delete_confirm": "Excluir o som “{name}”? As tarefas que o usam voltam ao som padrão.",
"panel.sound_deleted": "Som excluído",
"panel.sound_deleted_reset": "Som excluído. {count} tarefa(s) redefinida(s) para o som padrão."
}
+34 -1
View File
@@ -769,6 +769,15 @@
"panel.reward_cost_label": "Custo ({points_name})",
"panel.reward_description_label": "Descrição (opcional)",
"panel.reward_expires_hint": "Os pontos acumulados são devolvidos à meia-noite.",
"panel.reward_timelock_section": "Avançado — bloqueio horário",
"panel.reward_timelock_label": "Limitar quando pode ser reclamada",
"panel.reward_timelock_hint": "A recompensa continua visível, mas só pode ser reclamada dentro do período definido. Acumular pontos nunca é bloqueado.",
"panel.reward_timelock_days_label": "Dias",
"panel.reward_timelock_days_hint": "Deixe todos os dias desligados para não restringir dias.",
"panel.reward_timelock_from_label": "Das",
"panel.reward_timelock_until_label": "Até",
"panel.reward_timelock_times_hint": "Deixe ambos em branco para qualquer hora do dia. A hora final é exclusiva, por isso 07:00–19:00 termina às 19:00. Se o início for depois do fim, o período passa a meia-noite.",
"panel.reward_timelock_badge": "Com bloqueio horário",
"panel.reward_expires_label": "Expira (vazio = nunca)",
"panel.reward_icon_label": "Ícone",
"panel.reward_is_jackpot_hint": "Combinar as contribuições de todas as crianças para uma recompensa partilhada.",
@@ -804,6 +813,8 @@
"panel.settings_parents_title": "Pais (sem direitos de administrador)",
"panel.settings_parents_hint": "Dê a um membro do agregado sem permissões de administrador o controlo do dia a dia — aprovar tarefas, ajustar pontos, confirmar recompensas — sem direitos de administrador do Home Assistant. Continuará sem poder abrir este painel de administração nem alterar a configuração.",
"panel.settings_parents_empty": "Não foram encontrados utilizadores do Home Assistant sem permissões de administrador. Crie primeiro um utilizador HA normal e depois selecione-o aqui.",
"panel.settings_require_linked_child_label": "Exigir uma conta associada por criança",
"panel.settings_require_linked_child_hint": "Só permite que a conta do Home Assistant da própria criança atue como essa criança. Deixe desligado num tablet familiar partilhado em que uma conta serve todos. Os pais e administradores podem sempre atuar por qualquer criança.",
"panel.settings_evening_hint": "Predefinição 17:00 – 21:00",
"panel.settings_evening_label": "Noite",
"panel.settings_history_title": "Histórico e sequências",
@@ -1222,6 +1233,8 @@
"rewards.reward_count_plural": "{count} recompensas",
"rewards.reward_count_singular": "{count} recompensa",
"rewards.sold_out": "Esgotada",
"rewards.available_when": "Disponível {window}",
"rewards.locked_now": "Indisponível neste momento",
"rewards.spendable_balance": "Disponível",
"rewards.unavailable_hint": "Esta recompensa já não pode ser reclamada",
"streak.achievement.fifty_desc": "Conclui 50 tarefas",
@@ -1761,5 +1774,25 @@
"child.editor.pre_reader_points": "Mostrar pontos em vez de estrelas no modo imagens",
"common.design.accessible": "Acessível",
"child.editor.include_anytime_chores": "Incluir tarefas de qualquer hora",
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para as ocultar"
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para as ocultar",
"panel.settings_sounds_title": "Sons de conclusão",
"panel.settings_sounds_hint": "Carregue os seus próprios clipes de som e escolha-os por tarefa.",
"panel.settings_sounds_empty": "Ainda não há sons personalizados.",
"panel.settings_sounds_formats": "MP3, OGG, WAV ou M4A. Máximo de 1 MB cada.",
"panel.sound_upload": "Carregar som",
"panel.sound_uploading": "A carregar…",
"panel.sound_uploaded": "Som adicionado",
"panel.sound_upload_failed": "Falha ao carregar",
"panel.sound_too_large": "Esse som é demasiado grande (máx. 1 MB).",
"panel.sound_bad_type": "Escolha um ficheiro MP3, OGG, WAV ou M4A.",
"panel.sound_storage_full": "O armazenamento de sons está cheio. Elimine um som e tente novamente.",
"panel.sound_default_name": "O meu som",
"panel.sound_preview": "Reproduzir",
"panel.sound_rename": "Mudar o nome",
"panel.sound_rename_prompt": "Nome deste som:",
"panel.sound_name_required": "Dê um nome ao som.",
"panel.sound_delete": "Eliminar",
"panel.sound_delete_confirm": "Eliminar o som “{name}”? As tarefas que o usam voltam ao som predefinido.",
"panel.sound_deleted": "Som eliminado",
"panel.sound_deleted_reset": "Som eliminado. {count} tarefa(s) reposta(s) para o som predefinido."
}
@@ -216,353 +216,26 @@ class TaskMateChildCard extends LitElement {
}
/**
* Get or create the AudioContext (lazy initialization)
* Must be called after user interaction due to browser autoplay policies
*/
_getAudioContext() {
if (!this._audioContext) {
this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// Resume if suspended (required after user interaction)
if (this._audioContext.state === 'suspended') {
this._audioContext.resume();
}
return this._audioContext;
}
/**
* Play a completion sound using Web Audio API
* Generates fun synthesized sounds - no external files needed!
* @param {string} soundName - Name of the sound to play (coin, levelup, fanfare, chime, powerup)
* Play a completion sound.
*
* Delegates to the shared engine in taskmate-sounds.js — this card used to
* carry its own copy of every synthesised sound, which drifted out of sync
* with the other copy in taskmate-config-sounds.js.
*
* @param {string} soundName - built-in name, or "custom:<file>" (#856)
*/
_playSound(soundName) {
// Don't play if sound is "none" or not specified
if (!soundName || soundName === 'none') {
return;
}
try {
const ctx = this._getAudioContext();
const now = ctx.currentTime;
switch (soundName) {
case 'coin':
this._playCoinSound(ctx, now);
break;
case 'levelup':
this._playLevelUpSound(ctx, now);
break;
case 'fanfare':
this._playFanfareSound(ctx, now);
break;
case 'chime':
this._playChimeSound(ctx, now);
break;
case 'powerup':
this._playPowerUpSound(ctx, now);
break;
case 'undo':
this._playUndoSound(ctx, now);
break;
case 'fart1':
this._playAudioFile('fart1.mp3');
break;
case 'fart2':
this._playAudioFile('fart2.mp3');
break;
case 'fart3':
this._playAudioFile('fart3.mp3');
break;
case 'fart4':
this._playAudioFile('fart4.mp3');
break;
case 'fart5':
this._playAudioFile('fart5.mp3');
break;
case 'fart6':
this._playAudioFile('fart6.mp3');
break;
case 'fart7':
this._playAudioFile('fart7.mp3');
break;
case 'fart8':
this._playAudioFile('fart8.mp3');
break;
case 'fart9':
this._playAudioFile('fart9.mp3');
break;
case 'fart10':
this._playAudioFile('fart10.mp3');
break;
case 'fart_random': {
// Pick a random fart sound (1-10)
const randomFartNum = Math.floor(Math.random() * 10) + 1;
this._playAudioFile(`fart${randomFartNum}.mp3`);
break;
}
default:
this._playCoinSound(ctx, now);
}
} catch (e) {
}
const engine = window.__taskmate_sounds;
if (!engine) return;
engine.play(soundName, this._customSounds());
}
/**
* Coin collect sound - classic video game coin pickup
* Two quick ascending tones
*/
_playCoinSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// First tone (E6)
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.frequency.value = 1318.5; // E6
osc1.type = 'square';
gain1.gain.setValueAtTime(0.5, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
osc1.start(startTime);
osc1.stop(startTime + 0.1);
// Second tone (B6) - higher
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.frequency.value = 1975.5; // B6
osc2.type = 'square';
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
osc2.start(startTime + 0.08);
osc2.stop(startTime + 0.25);
}
/**
* Level up sound - triumphant ascending arpeggio
*/
_playLevelUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// C major arpeggio going up: C5, E5, G5, C6
const notes = [523.25, 659.25, 783.99, 1046.5];
const duration = 0.12;
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'square';
const noteStart = startTime + i * duration;
gain.gain.setValueAtTime(0.6, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.15);
});
// Final sustained chord
const chordNotes = [523.25, 659.25, 783.99]; // C major chord
const chordStart = startTime + notes.length * duration;
chordNotes.forEach((freq) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'triangle';
gain.gain.setValueAtTime(0.3, chordStart);
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
osc.start(chordStart);
osc.stop(chordStart + 0.55);
});
}
/**
* Fanfare sound - celebratory trumpet-like fanfare
*/
_playFanfareSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.2;
// Fanfare pattern: G4, G4, G4, E4, G4, C5 (classic celebration pattern)
const pattern = [
{ freq: 392.00, duration: 0.1, delay: 0 }, // G4
{ freq: 392.00, duration: 0.1, delay: 0.12 }, // G4
{ freq: 392.00, duration: 0.15, delay: 0.24 }, // G4
{ freq: 329.63, duration: 0.15, delay: 0.42 }, // E4
{ freq: 392.00, duration: 0.15, delay: 0.6 }, // G4
{ freq: 523.25, duration: 0.4, delay: 0.78 }, // C5 (long final note)
];
pattern.forEach(({ freq, duration, delay }) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sawtooth';
const noteStart = startTime + delay;
gain.gain.setValueAtTime(0.5, noteStart);
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.05);
});
}
/**
* Chime sound - simple pleasant bell chime
*/
_playChimeSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// Bell-like sound using multiple harmonics
const fundamental = 880; // A5
const harmonics = [1, 2, 3, 4.2]; // Slight inharmonicity for bell-like quality
harmonics.forEach((harmonic, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = fundamental * harmonic;
osc.type = 'sine';
// Higher harmonics decay faster
const amplitude = 0.5 / (i + 1);
const decayTime = 0.8 / (i + 1);
gain.gain.setValueAtTime(amplitude, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
osc.start(startTime);
osc.stop(startTime + decayTime + 0.1);
});
}
/**
* Power up sound - ascending sweep with sparkle
*/
_playPowerUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// Ascending sweep
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(200, startTime);
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
gain1.gain.setValueAtTime(0.4, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
osc1.start(startTime);
osc1.stop(startTime + 0.4);
// Sparkle notes at the end
const sparkleNotes = [1318.5, 1567.98, 1975.5]; // E6, G6, B6
sparkleNotes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sine';
const noteStart = startTime + 0.25 + i * 0.05;
gain.gain.setValueAtTime(0.3, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
osc.start(noteStart);
osc.stop(noteStart + 0.25);
});
}
/**
* Undo sound - sad descending "womp womp" style
* Two descending tones that sound disappointed/sad
*/
_playUndoSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// First "womp" - descending tone
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(311.13, startTime); // Eb4
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25); // Bb3
gain1.gain.setValueAtTime(0.6, startTime);
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
osc1.start(startTime);
osc1.stop(startTime + 0.35);
// Second "womp" - even lower descending tone (the sad part)
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(233.08, startTime + 0.3); // Bb3
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7); // Eb3
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
osc2.start(startTime + 0.3);
osc2.stop(startTime + 0.8);
// Optional: add a subtle low vibrato for extra sadness
const osc3 = ctx.createOscillator();
const gain3 = ctx.createGain();
osc3.connect(gain3);
gain3.connect(masterGain);
osc3.type = 'sine';
osc3.frequency.setValueAtTime(116.54, startTime + 0.5); // Bb2 (sub bass)
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
osc3.start(startTime + 0.5);
osc3.stop(startTime + 0.85);
}
/**
* Play an audio file from the www folder
* Used for fart sounds (real audio files, not synthesized)
* CC0 public domain from BigSoundBank.com
* @param {string} filename - The audio file name (e.g., 'fart1.mp3')
*/
_playAudioFile(filename) {
try {
// Build the URL to the audio file in the www folder
const audio = new Audio(`/local/taskmate/${filename}`);
audio.volume = 1.0;
audio.play().catch(e => {
console.warn(`[TaskMate] Failed to play ${filename}:`, e);
});
} catch (e) {
console.warn(`[TaskMate] Error preparing audio ${filename}:`, e);
}
/** Uploaded custom sounds (#856). Lives on the chores sensor; the attr
* resolver merges it in, so the card reads it off the primary entity. */
_customSounds() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
return attrs.custom_sounds || [];
}
static get styles() {
@@ -1,647 +1,35 @@
/**
* TaskMate Config Flow Sound Preview
* Plays sound preview when the completion_sound dropdown value changes
* in the config flow (add/edit chore screens).
* TaskMate sound preview bridge.
*
* Loaded on every HA frontend page (see GLOBAL_MODULES in frontend.py) so the
* `taskmate.preview_sound` service can make a browser actually play something.
*
* This file used to carry a second, hand-maintained copy of every synthesised
* sound plus a MutationObserver that scanned the document for anything looking
* like a sound dropdown. Both had rotted: the copy still only knew the
* long-removed "fart"/"fart_long" names while the real list had moved on to
* fart1..fart10, and the scan could never reach the admin panel's sound picker
* because that lives inside a shadow root. The panel now previews sounds
* directly through window.__taskmate_sounds, so all that is gone; playback
* lives in taskmate-sounds.js and this file is just the event bridge.
*/
(function() {
'use strict';
(function () {
"use strict";
// Debug logging gate. Set window.__taskmate_config_sounds_debug = true in the
// browser console to enable the chatty console.debug/warn output.
const debugLog = (...args) => {
if (window.__taskmate_config_sounds_debug) console.debug(...args);
};
const debugWarn = (...args) => {
if (window.__taskmate_config_sounds_debug) console.warn(...args);
};
// Audio context for sound preview (lazy initialized)
let audioContext = null;
/**
* Get or create AudioContext
*/
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioContext.state === 'suspended') {
audioContext.resume();
}
return audioContext;
function play(sound) {
const engine = window.__taskmate_sounds;
if (engine && sound) engine.play(sound, window.__taskmate_custom_sounds || []);
}
// ============== SOUND IMPLEMENTATIONS ==============
// These are copies from taskmate-child-card.js to keep this file standalone
function playCoinSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.frequency.value = 1318.5;
osc1.type = 'square';
gain1.gain.setValueAtTime(0.5, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
osc1.start(startTime);
osc1.stop(startTime + 0.1);
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.frequency.value = 1975.5;
osc2.type = 'square';
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
osc2.start(startTime + 0.08);
osc2.stop(startTime + 0.25);
function onPreview(e) {
const sound = (e.detail && (e.detail.sound || (e.detail.data && e.detail.data.sound))) || null;
if (sound) play(sound);
}
function playLevelUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
const notes = [523.25, 659.25, 783.99, 1046.5];
const duration = 0.12;
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'square';
const noteStart = startTime + i * duration;
gain.gain.setValueAtTime(0.6, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.15);
});
const chordNotes = [523.25, 659.25, 783.99];
const chordStart = startTime + notes.length * duration;
chordNotes.forEach((freq) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'triangle';
gain.gain.setValueAtTime(0.3, chordStart);
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
osc.start(chordStart);
osc.stop(chordStart + 0.55);
});
}
function playFanfareSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.2;
const pattern = [
{ freq: 392.00, duration: 0.1, delay: 0 },
{ freq: 392.00, duration: 0.1, delay: 0.12 },
{ freq: 392.00, duration: 0.15, delay: 0.24 },
{ freq: 329.63, duration: 0.15, delay: 0.42 },
{ freq: 392.00, duration: 0.15, delay: 0.6 },
{ freq: 523.25, duration: 0.4, delay: 0.78 },
];
pattern.forEach(({ freq, duration, delay }) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sawtooth';
const noteStart = startTime + delay;
gain.gain.setValueAtTime(0.5, noteStart);
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.05);
});
}
function playChimeSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
const fundamental = 880;
const harmonics = [1, 2, 3, 4.2];
harmonics.forEach((harmonic, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = fundamental * harmonic;
osc.type = 'sine';
const amplitude = 0.5 / (i + 1);
const decayTime = 0.8 / (i + 1);
gain.gain.setValueAtTime(amplitude, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
osc.start(startTime);
osc.stop(startTime + decayTime + 0.1);
});
}
function playPowerUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(200, startTime);
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
gain1.gain.setValueAtTime(0.4, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
osc1.start(startTime);
osc1.stop(startTime + 0.4);
const sparkleNotes = [1318.5, 1567.98, 1975.5];
sparkleNotes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sine';
const noteStart = startTime + 0.25 + i * 0.05;
gain.gain.setValueAtTime(0.3, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
osc.start(noteStart);
osc.stop(noteStart + 0.25);
});
}
function playUndoSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(311.13, startTime);
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25);
gain1.gain.setValueAtTime(0.6, startTime);
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
osc1.start(startTime);
osc1.stop(startTime + 0.35);
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(233.08, startTime + 0.3);
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7);
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
osc2.start(startTime + 0.3);
osc2.stop(startTime + 0.8);
const osc3 = ctx.createOscillator();
const gain3 = ctx.createGain();
osc3.connect(gain3);
gain3.connect(masterGain);
osc3.type = 'sine';
osc3.frequency.setValueAtTime(116.54, startTime + 0.5);
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
osc3.start(startTime + 0.5);
osc3.stop(startTime + 0.85);
}
function playFartSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.9;
const noiseLength = ctx.sampleRate * 0.5;
const noiseBuffer = ctx.createBuffer(1, noiseLength, ctx.sampleRate);
const noiseData = noiseBuffer.getChannelData(0);
let lastOut = 0;
for (let i = 0; i < noiseLength; i++) {
const white = Math.random() * 2 - 1;
noiseData[i] = (lastOut + (0.04 * white)) / 1.04;
lastOut = noiseData[i];
noiseData[i] *= 8;
}
const noiseSource = ctx.createBufferSource();
noiseSource.buffer = noiseBuffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(200, startTime);
filter.frequency.exponentialRampToValueAtTime(80, startTime + 0.4);
filter.Q.value = 2;
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0, startTime);
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.01);
noiseGain.gain.setValueAtTime(1.0, startTime + 0.15);
noiseGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.45);
noiseSource.connect(filter);
filter.connect(noiseGain);
noiseGain.connect(masterGain);
noiseSource.start(startTime);
noiseSource.stop(startTime + 0.5);
const bass = ctx.createOscillator();
const bassGain = ctx.createGain();
bass.type = 'sawtooth';
bass.frequency.setValueAtTime(80, startTime);
bass.frequency.exponentialRampToValueAtTime(40, startTime + 0.4);
bassGain.gain.setValueAtTime(0, startTime);
bassGain.gain.linearRampToValueAtTime(0.8, startTime + 0.02);
bassGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.45);
bass.connect(bassGain);
bassGain.connect(masterGain);
bass.start(startTime);
bass.stop(startTime + 0.5);
const flap = ctx.createOscillator();
const flapGain = ctx.createGain();
flap.type = 'square';
flap.frequency.setValueAtTime(25, startTime);
flap.frequency.exponentialRampToValueAtTime(12, startTime + 0.4);
flapGain.gain.setValueAtTime(0.6, startTime);
flapGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.4);
flap.connect(flapGain);
flapGain.connect(masterGain);
flap.start(startTime);
flap.stop(startTime + 0.45);
const sub = ctx.createOscillator();
const subGain = ctx.createGain();
sub.type = 'sine';
sub.frequency.setValueAtTime(50, startTime);
sub.frequency.exponentialRampToValueAtTime(25, startTime + 0.3);
subGain.gain.setValueAtTime(0.7, startTime);
subGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
sub.connect(subGain);
subGain.connect(masterGain);
sub.start(startTime);
sub.stop(startTime + 0.4);
}
function playFartLongSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.85;
const noiseLength = ctx.sampleRate * 2.0;
const noiseBuffer = ctx.createBuffer(1, noiseLength, ctx.sampleRate);
const noiseData = noiseBuffer.getChannelData(0);
let lastOut = 0;
for (let i = 0; i < noiseLength; i++) {
const white = Math.random() * 2 - 1;
noiseData[i] = (lastOut + (0.05 * white)) / 1.05;
lastOut = noiseData[i];
noiseData[i] *= 7;
}
const noiseSource = ctx.createBufferSource();
noiseSource.buffer = noiseBuffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(250, startTime);
filter.frequency.linearRampToValueAtTime(180, startTime + 0.3);
filter.frequency.linearRampToValueAtTime(280, startTime + 0.5);
filter.frequency.linearRampToValueAtTime(150, startTime + 0.9);
filter.frequency.linearRampToValueAtTime(220, startTime + 1.2);
filter.frequency.exponentialRampToValueAtTime(60, startTime + 1.8);
filter.Q.value = 3;
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0, startTime);
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.02);
noiseGain.gain.linearRampToValueAtTime(0.7, startTime + 0.4);
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.6);
noiseGain.gain.linearRampToValueAtTime(0.5, startTime + 1.0);
noiseGain.gain.linearRampToValueAtTime(0.9, startTime + 1.3);
noiseGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.9);
noiseSource.connect(filter);
filter.connect(noiseGain);
noiseGain.connect(masterGain);
noiseSource.start(startTime);
noiseSource.stop(startTime + 2.0);
const bass = ctx.createOscillator();
const bassGain = ctx.createGain();
bass.type = 'sawtooth';
bass.frequency.setValueAtTime(90, startTime);
bass.frequency.linearRampToValueAtTime(70, startTime + 0.5);
bass.frequency.linearRampToValueAtTime(100, startTime + 0.8);
bass.frequency.linearRampToValueAtTime(50, startTime + 1.3);
bass.frequency.exponentialRampToValueAtTime(30, startTime + 1.8);
bassGain.gain.setValueAtTime(0.8, startTime);
bassGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.85);
bass.connect(bassGain);
bassGain.connect(masterGain);
bass.start(startTime);
bass.stop(startTime + 1.9);
const flutter = ctx.createOscillator();
const flutterGain = ctx.createGain();
flutter.type = 'square';
flutter.frequency.setValueAtTime(35, startTime);
flutter.frequency.linearRampToValueAtTime(20, startTime + 0.8);
flutter.frequency.linearRampToValueAtTime(28, startTime + 1.0);
flutter.frequency.exponentialRampToValueAtTime(6, startTime + 1.8);
flutterGain.gain.setValueAtTime(0.7, startTime);
flutterGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.8);
flutter.connect(flutterGain);
flutterGain.connect(masterGain);
flutter.start(startTime);
flutter.stop(startTime + 1.85);
const sub = ctx.createOscillator();
const subGain = ctx.createGain();
sub.type = 'sine';
sub.frequency.setValueAtTime(45, startTime);
sub.frequency.exponentialRampToValueAtTime(20, startTime + 1.7);
subGain.gain.setValueAtTime(0.6, startTime);
subGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.75);
sub.connect(subGain);
subGain.connect(masterGain);
sub.start(startTime);
sub.stop(startTime + 1.8);
const squeakTimes = [0.5, 1.2];
squeakTimes.forEach((delay) => {
const squeak = ctx.createOscillator();
const squeakGain = ctx.createGain();
squeak.type = 'triangle';
squeak.frequency.setValueAtTime(180, startTime + delay);
squeak.frequency.exponentialRampToValueAtTime(120, startTime + delay + 0.15);
squeakGain.gain.setValueAtTime(0.4, startTime + delay);
squeakGain.gain.exponentialRampToValueAtTime(0.01, startTime + delay + 0.15);
squeak.connect(squeakGain);
squeakGain.connect(masterGain);
squeak.start(startTime + delay);
squeak.stop(startTime + delay + 0.2);
});
}
/**
* Play a sound by name
*/
function playSound(soundName) {
if (!soundName || soundName === 'none') {
debugLog('[TaskMate Config] No sound to play');
return;
}
try {
const ctx = getAudioContext();
const now = ctx.currentTime;
switch (soundName) {
case 'coin': playCoinSound(ctx, now); break;
case 'levelup': playLevelUpSound(ctx, now); break;
case 'fanfare': playFanfareSound(ctx, now); break;
case 'chime': playChimeSound(ctx, now); break;
case 'powerup': playPowerUpSound(ctx, now); break;
case 'undo': playUndoSound(ctx, now); break;
case 'fart': playFartSound(ctx, now); break;
case 'fart_long': playFartLongSound(ctx, now); break;
default:
debugWarn(`[TaskMate Config] Unknown sound: ${soundName}`);
playCoinSound(ctx, now);
}
} catch (e) {
debugWarn('[TaskMate Config] Error playing sound:', e);
}
}
// Valid sound names
const VALID_SOUNDS = ['coin', 'levelup', 'fanfare', 'chime', 'powerup', 'undo', 'fart', 'fart_long', 'none'];
/**
* Map display text to sound value
*/
function textToSoundValue(text) {
if (!text) return null;
const normalized = text.toLowerCase().trim().replace(/\s+/g, '_');
const map = {
'no_sound': 'none',
'no sound': 'none',
'coin': 'coin',
'levelup': 'levelup',
'level_up': 'levelup',
'fanfare': 'fanfare',
'chime': 'chime',
'powerup': 'powerup',
'power_up': 'powerup',
'undo': 'undo',
'fart': 'fart',
'fart_long': 'fart_long',
};
return map[normalized] || (VALID_SOUNDS.includes(normalized) ? normalized : null);
}
/**
* Attach change listener to a select element
*/
function attachSoundChangeListener(element) {
if (element.dataset.taskmateSoundListener) return;
element.dataset.taskmateSoundListener = 'true';
// Listen for various change events
const handleChange = (e) => {
const value = e.target?.value || e.detail?.value;
const soundValue = textToSoundValue(value) || value;
debugLog('[TaskMate Config] Sound changed to:', soundValue);
if (soundValue && soundValue !== 'none' && VALID_SOUNDS.includes(soundValue)) {
playSound(soundValue);
}
};
element.addEventListener('change', handleChange);
element.addEventListener('value-changed', handleChange);
element.addEventListener('selected', handleChange);
element.addEventListener('click', (e) => {
// For dropdown items being clicked
const item = e.target.closest('mwc-list-item, ha-list-item, [role="option"]');
if (item) {
const value = item.value || item.getAttribute('value') || item.textContent;
const soundValue = textToSoundValue(value);
if (soundValue && soundValue !== 'none') {
setTimeout(() => playSound(soundValue), 50);
}
}
});
debugLog('[TaskMate Config] Attached sound change listener to element');
}
/**
* Find sound selectors and attach listeners
*/
function findAndEnhanceSoundSelectors() {
// Look for any select/dropdown that might be for sounds
const selectors = document.querySelectorAll('ha-select, mwc-select, ha-combo-box, select');
selectors.forEach(selector => {
// Check if this selector has sound-related options
const options = selector.querySelectorAll('mwc-list-item, ha-list-item, option, [role="option"]');
let isSoundSelector = false;
options.forEach(opt => {
const text = (opt.value || opt.getAttribute('value') || opt.textContent || '').toLowerCase();
if (VALID_SOUNDS.some(s => text.includes(s))) {
isSoundSelector = true;
}
});
// Also check the selector's current value
const currentValue = (selector.value || '').toLowerCase();
if (VALID_SOUNDS.some(s => currentValue.includes(s))) {
isSoundSelector = true;
}
if (isSoundSelector) {
attachSoundChangeListener(selector);
// Also attach to inner elements if present
const innerSelect = selector.shadowRoot?.querySelector('select, mwc-menu, mwc-list');
if (innerSelect) {
attachSoundChangeListener(innerSelect);
}
}
});
// Also look for ha-selector elements (HA's custom form selectors)
document.querySelectorAll('ha-selector').forEach(haSelector => {
const innerSelect = haSelector.shadowRoot?.querySelector('ha-select, mwc-select, select') ||
haSelector.querySelector('ha-select, mwc-select, select');
if (innerSelect) {
const options = innerSelect.querySelectorAll('mwc-list-item, [role="option"]');
let isSoundSelector = false;
options.forEach(opt => {
const text = (opt.value || opt.textContent || '').toLowerCase();
if (VALID_SOUNDS.some(s => text.includes(s))) {
isSoundSelector = true;
}
});
if (isSoundSelector) {
attachSoundChangeListener(innerSelect);
}
}
});
}
/**
* Watch for DOM changes and enhance new sound selectors
*/
function startObserver() {
const observer = new MutationObserver((mutations) => {
let shouldScan = false;
mutations.forEach((mutation) => {
if (mutation.addedNodes.length > 0) {
shouldScan = true;
}
});
if (shouldScan) {
// Debounce scanning
clearTimeout(window._taskmateScanTimeout);
window._taskmateScanTimeout = setTimeout(findAndEnhanceSoundSelectors, 100);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
window._taskmateSoundObserver = observer;
debugLog('[TaskMate Config] Sound change observer started');
}
// Initialize
function init() {
if (window._taskmateConfigSoundsInit) return;
if (!window._taskmateConfigSoundsInit) {
window._taskmateConfigSoundsInit = true;
findAndEnhanceSoundSelectors();
startObserver();
// Slow periodic scan as a safety net for dynamically loaded content the
// MutationObserver doesn't catch (e.g. dialogs rendered outside body's light DOM)
window._taskmateConfigSoundsPoll = setInterval(findAndEnhanceSoundSelectors, 10000);
const cleanup = () => {
if (window._taskmateConfigSoundsPoll) {
clearInterval(window._taskmateConfigSoundsPoll);
window._taskmateConfigSoundsPoll = null;
}
if (window._taskmateScanTimeout) {
clearTimeout(window._taskmateScanTimeout);
window._taskmateScanTimeout = null;
}
if (window._taskmateSoundObserver) {
window._taskmateSoundObserver.disconnect();
window._taskmateSoundObserver = null;
}
};
// pagehide covers SPA navigation in HA; beforeunload covers full reloads.
window.addEventListener('pagehide', cleanup, { once: true });
window.addEventListener('beforeunload', cleanup, { once: true });
document.addEventListener("taskmate_preview_sound", onPreview);
window.addEventListener("taskmate_preview_sound", onPreview);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.addEventListener('load', () => setTimeout(init, 500));
// Listen for taskmate_preview_sound events fired by the preview_sound service
document.addEventListener('taskmate_preview_sound', (e) => {
const sound = e.detail?.sound || e.detail?.data?.sound;
if (sound) playSound(sound);
});
// Also listen via window for HA event bus forwarding
window.addEventListener('taskmate_preview_sound', (e) => {
const sound = e.detail?.sound || e.detail?.data?.sound;
if (sound) playSound(sound);
});
console.info('[TaskMate] Config sound preview module loaded - sounds will play on selection change');
})();
@@ -145,7 +145,32 @@ function _refreshTaskMateCards() {
}
}
const _DAY_KEYS = [
'weekly.day_mon', 'weekly.day_tue', 'weekly.day_wed', 'weekly.day_thu',
'weekly.day_fri', 'weekly.day_sat', 'weekly.day_sun',
];
/**
* Human summary of a reward's time lock (#857), e.g. "Fri, Sat · 18:00–21:00".
* Returns "" when nothing is restricted, so callers can skip the badge.
*
* @param {object} lock - { days: number[], from: string, until: string }
* @param {function} t - the caller's translate function (key, params) => string
*/
function timeLockLabel(lock, t) {
if (!lock) return '';
const days = (lock.days || []).map(Number).filter(n => Number.isInteger(n) && n >= 0 && n <= 6).sort((a, b) => a - b);
const parts = [];
// All seven days selected is the same as no day restriction — don't spell it out.
if (days.length && days.length < 7) parts.push(days.map(d => t(_DAY_KEYS[d])).join(', '));
const from = lock.from || '';
const until = lock.until || '';
if (from && until && from !== until) parts.push(`${from}\u2013${until}`);
return parts.join(' \u00b7 ');
}
// Expose globally so cards can access without ES module imports
window.__taskmate_time_lock_label = timeLockLabel;
window.__taskmate_localize = localize;
window.__taskmate_loadTranslations = loadTranslations;
+393 -49
View File
@@ -122,6 +122,8 @@ const WEATHER_CONDITIONS = [
{ v: "exceptional", icon: "mdi:alert-outline" },
];
// Built-in sounds, mirroring COMPLETION_SOUND_OPTIONS in const.py. User-uploaded
// sounds are appended per render by _soundOptions() (#856).
const COMPLETION_SOUNDS = [
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
@@ -506,6 +508,15 @@ class TaskMatePanel extends HTMLElement {
if (act === "save-gift") { this._doGiftPoints(); return; }
if (act === "adjust-points") { this._doAdjustPoints(t.dataset.id, Number(t.dataset.delta)); return; }
if (act === "adjust-points-custom") { this._openAdjustDialog(t.dataset.id); return; }
if (act === "sound-preview-field") {
const sel = t.closest(".tm-sound-row")?.querySelector('select[data-field="completion_sound"]');
if (sel) this._previewSound(sel.value);
return;
}
if (act === "sound-preview") { this._previewSound(t.dataset.value); return; }
if (act === "sound-upload") { this._pickCustomSound(); return; }
if (act === "sound-rename") { this._renameCustomSound(t.dataset.file, t.dataset.name); return; }
if (act === "sound-delete") { this._deleteCustomSound(t.dataset.file, t.dataset.name); return; }
if (act === "chore-image-pick") { this._pickChoreImage(); return; }
if (act === "chore-image-remove") {
// Clears the dialog value only — the file is deleted on save by the
@@ -605,6 +616,7 @@ class TaskMatePanel extends HTMLElement {
if (act === "delete-reward") { this._confirmDelete("reward", t.dataset.id); return; }
if (act === "save-reward") { this._doSaveReward(); return; }
if (act === "toggle-reward-assigned") { this._toggleArrayField("assigned_to", t.dataset.id); return; }
if (act === "toggle-reward-day") { this._toggleArrayField("available_days", Number(t.dataset.day)); return; }
// Quests (chore chains)
if (act === "add-quest") { this._openQuestDialog(null); return; }
@@ -1449,6 +1461,100 @@ class TaskMatePanel extends HTMLElement {
}
}
// --- Custom completion sounds (#856) ------------------------------------
_pickCustomSound() {
const input = document.createElement("input");
input.type = "file";
input.accept = "audio/mpeg,audio/ogg,audio/wav,audio/mp4,.mp3,.ogg,.wav,.m4a";
input.addEventListener("change", () => {
const file = input.files && input.files[0];
if (file) this._uploadCustomSound(file);
});
input.click();
}
/**
* Upload the file, then register it under a display name.
*
* Two steps because the bytes go over HTTP (multipart) while the registry
* entry is a WebSocket command — same split as chore images. The default
* name is the filename without its extension, which is nearly always what
* the user wants.
*/
async _uploadCustomSound(file) {
if (this._soundBusy) return;
this._soundBusy = true;
this._render();
try {
const post = () => {
const token = this._hass && this._hass.auth && this._hass.auth.data
&& this._hass.auth.data.access_token;
const fd = new FormData();
fd.append("file", file, file.name || "sound");
return fetch("/api/taskmate/sound", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
};
// The cached access token can go stale mid-session, so refresh and retry
// once on a 401 before giving up. Mirrors the chore-image upload.
if (this._hass && this._hass.auth && this._hass.auth.expired
&& this._hass.auth.refreshAccessToken) {
try { await this._hass.auth.refreshAccessToken(); } catch (e) { /* surfaced below */ }
}
let resp = await post();
if (resp.status === 401 && this._hass && this._hass.auth
&& this._hass.auth.refreshAccessToken) {
await this._hass.auth.refreshAccessToken();
resp = await post();
}
if (resp.status === 413) { this._showToast("err", this._t("panel.sound_too_large")); return; }
if (resp.status === 400) { this._showToast("err", this._t("panel.sound_bad_type")); return; }
if (resp.status === 507) { this._showToast("err", this._t("panel.sound_storage_full")); return; }
if (!resp.ok) { this._showToast("err", this._t("panel.sound_upload_failed")); return; }
const body = await resp.json();
const name = (file.name || "").replace(/\.[^.]+$/, "").slice(0, 40) || this._t("panel.sound_default_name");
const { ok, err } = await this._callWS({
type: "taskmate/add_custom_sound", file: body.file, name,
});
if (!ok) { this._showToast("err", this._t("panel.sound_upload_failed", { error: err })); return; }
this._showToast("ok", this._t("panel.sound_uploaded"));
await this._fetchState();
} catch (err) {
this._showToast("err", this._t("panel.sound_upload_failed"));
} finally {
this._soundBusy = false;
this._render();
}
}
async _renameCustomSound(file, current) {
const name = prompt(this._t("panel.sound_rename_prompt"), current || "");
if (name === null) return;
if (!name.trim()) { this._showToast("err", this._t("panel.sound_name_required")); return; }
const { ok, err } = await this._callWS({
type: "taskmate/rename_custom_sound", file, name: name.trim().slice(0, 40),
});
if (!ok) { this._showToast("err", err); return; }
await this._fetchState();
}
async _deleteCustomSound(file, name) {
if (!confirm(this._t("panel.sound_delete_confirm", { name: name || file }))) return;
const { ok, err, res } = await this._callWS({ type: "taskmate/remove_custom_sound", file });
if (!ok) { this._showToast("err", err); return; }
// Chores that used it were reset to the default sound server-side; say so
// rather than letting the change be discovered later.
const reset = (res && res.chores_reset) || 0;
this._showToast("ok", reset
? this._t("panel.sound_deleted_reset", { count: reset })
: this._t("panel.sound_deleted"));
await this._fetchState();
}
_pickChoreImage() {
const input = document.createElement("input");
input.type = "file";
@@ -1619,7 +1725,8 @@ class TaskMatePanel extends HTMLElement {
assigned_to: [], is_jackpot: false, pool_enabled: false,
quantity_str: "", expires_at: "",
restock_enabled: false, restock_amount: 0, restock_period: "weekly",
unlock_entity: "", unlock_minutes: 30 };
unlock_entity: "", unlock_minutes: 30,
time_lock_enabled: false, available_days: [], available_from: "", available_until: "" };
if (id) {
const r = (this._state.rewards || []).find(x => x.id === id);
if (!r) return;
@@ -1628,6 +1735,7 @@ class TaskMatePanel extends HTMLElement {
assigned_to: [...(r.assigned_to || [])],
quantity_str: r.quantity == null ? "" : String(r.quantity),
expires_at: r.expires_at || "",
available_days: (r.available_days || []).map(Number),
} });
} else {
this._openDialog({ kind: "reward", mode: "add", data: blank });
@@ -1655,6 +1763,10 @@ class TaskMatePanel extends HTMLElement {
unlock_minutes: Math.max(0, Number(d.unlock_minutes) || 0),
restock_amount: Math.max(0, Number(d.restock_amount) || 0),
restock_period: d.restock_period || "weekly",
time_lock_enabled: !!d.time_lock_enabled,
available_days: (d.available_days || []).map(Number).filter(n => Number.isInteger(n) && n >= 0 && n <= 6),
available_from: d.available_from || "",
available_until: d.available_until || "",
};
const payload = wasAdd ? { type: "taskmate/add_reward", ...base } : { type: "taskmate/update_reward", reward_id: d.id, ...base };
const { ok, err } = await this._callWS(payload);
@@ -2368,7 +2480,9 @@ class TaskMatePanel extends HTMLElement {
_timeAgo(iso) {
if (!iso) return "—";
const then = new Date(iso);
if (isNaN(then)) return iso;
// Echoing an unparseable value straight back would put stored text into
// markup, so escape it — the panel builds its HTML as strings.
if (isNaN(then)) return this._esc(iso);
const diffSec = Math.floor((Date.now() - then.getTime()) / 1000);
if (diffSec < 60) return this._t("panel.time_ago_just_now");
if (diffSec < 3600) return this._t("panel.time_ago_minutes", {count: Math.floor(diffSec / 60)});
@@ -2832,7 +2946,7 @@ class TaskMatePanel extends HTMLElement {
<td><strong>${this._t("panel.day_" + d.weekday.slice(0, 3))}</strong> <span class="tm-proj-date">${d.date.slice(5)}</span></td>
${kids.map(k => {
const cell = d.children.find(c => c.id === k.id) || { points: 0, chores: 0 };
return `<td>${cell.chores ? `${cell.points} <span class="tm-proj-count">(${cell.chores})</span>` : "—"}</td>`;
return `<td>${cell.chores ? `${this._num(cell.points)} <span class="tm-proj-count">(${this._num(cell.chores)})</span>` : "—"}</td>`;
}).join("")}
</tr>
`).join("")}
@@ -2976,7 +3090,7 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-fair-figs">
<strong>${c.completions}</strong> ${this._t(c.completions === 1 ? "panel.insights_chore" : "panel.insights_chores")}
· ${c.points} ${this._esc(this._state.settings?.points_name || this._t("common.points"))}
· ${this._num(c.points)} ${this._esc(this._state.settings?.points_name || this._t("common.points"))}
· ${c.share_completions}%
</div>
<div class="tm-fair-status tm-fair-${c.status}">${this._t("panel.insights_status_" + c.status)}</div>
@@ -3191,7 +3305,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-approval-icon"><ha-icon icon="mdi:gift-outline"></ha-icon></div>
<div class="tm-approval-body">
<div class="tm-approval-line">${this._t("panel.activity_claimed_text", {child: this._esc((child && child.name) || "?"), reward: this._esc((reward && reward.name) || this._t("panel.activity_deleted_reward"))})}</div>
<div class="tm-meta">${this._timeAgo(c.claimed_at)}${reward ? ` · ${reward.cost} ${this._t("panel.activity_points")}` : ""}</div>
<div class="tm-meta">${this._timeAgo(c.claimed_at)}${reward ? ` · ${this._num(reward.cost)} ${this._t("panel.activity_points")}` : ""}</div>
</div>
<div class="tm-approval-actions">
<button type="button" class="tm-btn tm-btn-sm" data-act="reject-reward" data-id="${this._esc(c.id)}">${this._t("panel.activity_reject")}</button>
@@ -3234,7 +3348,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-timeline-body">
<div><strong>${this._esc(ev.child)}</strong> · ${this._esc(ev.label)}</div>
</div>
<div class="tm-timeline-points ${ev.points >= 0 ? 'tm-pos' : 'tm-neg'} tm-numeric">${ev.points >= 0 ? '+' : ''}${ev.points || 0}</div>
<div class="tm-timeline-points ${ev.points >= 0 ? 'tm-pos' : 'tm-neg'} tm-numeric">${ev.points >= 0 ? '+' : ''}${this._num(ev.points)}</div>
</div>
`).join("")}
</div>
@@ -3258,7 +3372,7 @@ class TaskMatePanel extends HTMLElement {
<tr class="tm-row">
<td class="tm-meta">${this._esc(this._timeAgo(t.created_at))}</td>
<td>${this._esc((child && child.name) || "?")}</td>
<td><strong class="tm-numeric ${t.points >= 0 ? 'tm-pos' : 'tm-neg'}">${t.points >= 0 ? '+' : ''}${t.points}</strong></td>
<td><strong class="tm-numeric ${t.points >= 0 ? 'tm-pos' : 'tm-neg'}">${t.points >= 0 ? '+' : ''}${this._num(t.points)}</strong></td>
<td>${this._esc(this._translateReason(t.reason) || "—")}</td>
<td>${undoable ? `<button type="button" class="tm-icon-btn" data-act="undo-tx" data-id="${this._esc(t.id)}" title="${this._esc(this._t("panel.activity_undo"))}"><ha-icon icon="mdi:undo-variant"></ha-icon></button>` : ""}</td>
</tr>
@@ -3365,7 +3479,7 @@ class TaskMatePanel extends HTMLElement {
<button type="button" class="tm-icon-btn" data-act="toggle-row-menu" data-id="${this._esc(c.id)}" title="${this._t("panel.row_menu_more")}" aria-haspopup="menu" aria-label="${this._t("panel.row_menu_more")}">⋮</button>
</span>`}
</div></td>
<td><strong class="tm-numeric">${c.task_type === "timed" ? `${c.timed_rate_points || 0}/${c.timed_rate_minutes || 1} min` : c.points}</strong></td>
<td><strong class="tm-numeric">${c.task_type === "timed" ? `${this._num(c.timed_rate_points)}/${this._num(c.timed_rate_minutes, 1)} min` : this._num(c.points)}</strong></td>
<td><span class="tm-pill">${this._esc(this._timeCategoryLabel(c.time_category))}</span></td>
<td>${assignedNames} ${modeBadge}</td>
<td>${currentName}</td>
@@ -3396,6 +3510,18 @@ class TaskMatePanel extends HTMLElement {
`;
}
/** Reward-list pill summarising a time lock (#857), or "" when unrestricted. */
_rewardTimeLockPill(r) {
if (!r.time_lock_enabled) return "";
const summary = window.__taskmate_time_lock_label
? window.__taskmate_time_lock_label(
{ days: r.available_days, from: r.available_from, until: r.available_until },
(k, pr) => this._t(k, pr))
: "";
const label = summary || this._t("panel.reward_timelock_badge");
return `<span class="tm-pill tm-pill-timelock">${this._mdi("mdi:clock-outline")} ${this._esc(label)}</span>`;
}
_renderRewardCard(r, pointsName) {
const totalPooled = (this._state.pool_allocations || []).filter(a => a.reward_id === r.id).reduce((s, a) => s + (a.allocated_points || 0), 0);
const showProgress = (r.pool_enabled || r.is_jackpot) && r.cost > 0;
@@ -3405,14 +3531,14 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-child-head">
<div class="tm-avatar tm-avatar-reward">${this._mdi(r.icon || "mdi:gift")}</div>
<div class="tm-child-name">
<h3>${this._esc(r.name)} ${r.is_jackpot ? `<span class="tm-pill tm-pill-jackpot">🏆 ${this._t("panel.reward_badge_jackpot")}</span>` : ""} ${r.pool_enabled ? `<span class="tm-pill tm-pill-pool">${this._t("panel.reward_badge_pool")}</span>` : ""}</h3>
<h3>${this._esc(r.name)} ${r.is_jackpot ? `<span class="tm-pill tm-pill-jackpot">🏆 ${this._t("panel.reward_badge_jackpot")}</span>` : ""} ${r.pool_enabled ? `<span class="tm-pill tm-pill-pool">${this._t("panel.reward_badge_pool")}</span>` : ""} ${this._rewardTimeLockPill(r)}</h3>
${this._idBadge(r.id)}
<div class="tm-meta">${this._esc(r.description || "")}</div>
</div>
</div>
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr ${r.expires_at ? "1fr" : ""};">
<div class="tm-stat"><div class="tm-stat-value tm-numeric tm-cost">${this._fmtNum(r.cost)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_cost", {points_name: pointsName})}</div></div>
${r.quantity != null ? `<div class="tm-stat"><div class="tm-stat-value tm-numeric">${r.quantity}</div><div class="tm-stat-label">${this._t("panel.reward_stat_remaining")}</div></div>` : `<div class="tm-stat"><div class="tm-stat-value">∞</div><div class="tm-stat-label">${this._t("panel.reward_stat_unlimited")}</div></div>`}
${r.quantity != null ? `<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._num(r.quantity)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_remaining")}</div></div>` : `<div class="tm-stat"><div class="tm-stat-value">∞</div><div class="tm-stat-label">${this._t("panel.reward_stat_unlimited")}</div></div>`}
${r.expires_at ? `<div class="tm-stat"><div class="tm-stat-value" style="font-size: 14px;">${this._esc(r.expires_at)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_expires")}</div></div>` : ""}
</div>
${showProgress ? `
@@ -3533,7 +3659,7 @@ class TaskMatePanel extends HTMLElement {
</div>
</div>
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr;">
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${c.target}</div><div class="tm-stat-label">${this._esc(metricLabel)}</div></div>
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._num(c.target)}</div><div class="tm-stat-label">${this._esc(metricLabel)}</div></div>
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._fmtNum(c.bonus_points || 0)}</div><div class="tm-stat-label">${this._t("panel.quest_stat_bonus", {points_name: pointsName})}</div></div>
</div>
<div class="tm-meta">${this._t("panel.quest_assigned_to")}: ${this._esc(assigned.join(", "))}</div>
@@ -3577,7 +3703,7 @@ class TaskMatePanel extends HTMLElement {
_criteriaLabel(criteria, combinator = "AND") {
if (!criteria || !criteria.length) return this._t("badge.manual_award_only");
const joinKey = combinator === "OR" ? "badge.criteria_or" : "badge.criteria_and";
return criteria.map(c => `${this._t("badge.criteria_" + c.metric)} ${c.operator} ${c.value}`).join(` ${this._t(joinKey)} `);
return criteria.map(c => `${this._t("badge.criteria_" + c.metric)} ${this._esc(c.operator)} ${this._num(c.value)}`).join(` ${this._t(joinKey)} `);
}
_badgeName(b) {
@@ -3870,7 +3996,7 @@ class TaskMatePanel extends HTMLElement {
<select class="tm-select tm-badge-op-select" data-badge-criterion-field="operator" data-badge-criterion-idx="${idx}">
${BADGE_OPERATORS.map(sym => { const v = BADGE_OP_VALUES[sym]; return `<option value="${v}"${(c.operator || ">=") === v ? " selected" : ""}>${sym}</option>`; }).join("")}
</select>`}
${isBool ? `<span class="tm-field-hint" style="flex:1">true</span>` : `<input class="tm-input" type="number" min="0" value="${c.value || 1}" data-badge-criterion-field="value" data-badge-criterion-idx="${idx}">`}
${isBool ? `<span class="tm-field-hint" style="flex:1">true</span>` : `<input class="tm-input" type="number" min="0" value="${this._num(c.value, 1)}" data-badge-criterion-field="value" data-badge-criterion-idx="${idx}">`}
<button type="button" class="tm-icon-btn" data-act="badge-remove-criterion" data-idx="${idx}" title="${this._t("panel.badge_remove_criterion_btn")}">✕</button>
</div>
`;
@@ -3935,7 +4061,7 @@ class TaskMatePanel extends HTMLElement {
return `
<tr class="tm-row">
<td><span class="tm-row-icon">${this._mdi(item.icon)}</span><strong>${this._esc(item.name)}</strong>${this._idBadge(item.id)}${item.description ? `<div class="tm-meta">${this._esc(item.description)}</div>` : ""}</td>
<td><strong class="tm-numeric ${kind === "penalty" ? "tm-neg" : "tm-pos"}">${kind === "penalty" ? "−" : "+"}${item.points}</strong></td>
<td><strong class="tm-numeric ${kind === "penalty" ? "tm-neg" : "tm-pos"}">${kind === "penalty" ? "−" : "+"}${this._num(item.points)}</strong></td>
<td>${assignedNames}</td>
<td class="tm-row-actions"><div>
<button type="button" class="tm-btn tm-btn-sm" data-act="apply-${kind}" data-id="${this._esc(item.id)}">${this._t("panel.btn_apply")}</button>
@@ -4120,7 +4246,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-tpl-preview-body">
<div class="tm-field-row">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_name")}</span><input class="tm-input" type="text" data-tpl-field="name" data-tpl-idx="${idx}" value="${this._esc(chore.name)}"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-field="points" data-tpl-idx="${idx}" value="${chore.points || 0}" min="0"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-field="points" data-tpl-idx="${idx}" value="${this._num(chore.points)}" min="0"></div>
</div>
<div class="tm-field-row">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.chore_time_category_label")}</span>
@@ -4147,7 +4273,7 @@ class TaskMatePanel extends HTMLElement {
</select>
</div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.template_daily_limit_label")}</span>
<input class="tm-input" type="number" data-tpl-field="daily_limit" data-tpl-idx="${idx}" value="${chore.daily_limit || 1}" min="1">
<input class="tm-input" type="number" data-tpl-field="daily_limit" data-tpl-idx="${idx}" value="${this._num(chore.daily_limit, 1)}" min="1">
</div>
</div>
</div>
@@ -4336,6 +4462,50 @@ class TaskMatePanel extends HTMLElement {
}
// -- Settings tab ------------------------------------------------------
/**
* Custom completion sounds (#856).
*
* Uploaded clips are listed with preview / rename / delete. Deleting one
* resets any chore using it back to the default sound server-side, so the
* confirm copy warns about that rather than leaving it to be discovered.
*/
_renderSoundsSection() {
const list = this._customSounds();
const busy = this._soundBusy;
return `
<div class="tm-section">
<div class="tm-section-head"><div>
<h3>${this._t("panel.settings_sounds_title")}</h3>
<p class="tm-meta">${this._t("panel.settings_sounds_hint")}</p>
</div></div>
<div class="tm-section-body">
${list.length ? `
<div class="tm-sound-list">
${list.map(s => `
<div class="tm-sound-item">
<span class="tm-sound-name">${this._esc(s.name)}</span>
<div class="tm-sound-item-actions">
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-preview"
data-value="${this._esc(s.value)}"
aria-label="${this._esc(this._t("panel.sound_preview"))}"
title="${this._esc(this._t("panel.sound_preview"))}">▶</button>
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-rename"
data-file="${this._esc(s.file)}" data-name="${this._esc(s.name)}">${this._t("panel.sound_rename")}</button>
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="sound-delete"
data-file="${this._esc(s.file)}" data-name="${this._esc(s.name)}">${this._t("panel.sound_delete")}</button>
</div>
</div>`).join("")}
</div>` : `<p class="tm-meta">${this._t("panel.settings_sounds_empty")}</p>`}
<div class="tm-sound-upload">
<button type="button" class="tm-btn tm-btn-raised" data-act="sound-upload" ${busy ? "disabled" : ""}>
${busy ? this._t("panel.sound_uploading") : this._t("panel.sound_upload")}
</button>
<span class="tm-field-hint">${this._t("panel.settings_sounds_formats")}</span>
</div>
</div>
</div>`;
}
_renderSettingsTab() {
const s = this._state.settings || {};
const notifyServices = Object.keys((this._hass && this._hass.services && this._hass.services.notify) || {});
@@ -4377,6 +4547,8 @@ class TaskMatePanel extends HTMLElement {
</div>
</div>
${this._renderSoundsSection()}
<div class="tm-section">
<div class="tm-section-head"><div>
<h3>${this._t("panel.settings_parents_title")}</h3>
@@ -4389,6 +4561,10 @@ class TaskMatePanel extends HTMLElement {
<input type="checkbox" class="tm-role-check" data-parent-user="${this._esc(u.id)}" ${(s.parent_user_ids || []).includes(u.id) ? "checked" : ""}>
</label>`).join("")
|| `<p class="tm-meta">${this._t("panel.settings_parents_empty")}</p>`}
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_require_linked_child_label")}<small>${this._t("panel.settings_require_linked_child_hint")}</small></div>
<ha-switch data-setting="require_linked_child" ${s.require_linked_child ? "checked" : ""}></ha-switch>
</div>
</div>
</div>
@@ -4397,7 +4573,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-section-body">
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_retention_label")}<small>${this._t("panel.settings_retention_hint")}</small></div>
<input type="number" class="tm-input" min="30" max="365" data-setting="history_days" value="${s.history_days || 90}">
<input type="number" class="tm-input" min="30" max="365" data-setting="history_days" value="${this._num(s.history_days, 90)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_streak_reset_label")}<small>${this._t("panel.settings_streak_reset_hint")}</small></div>
@@ -4411,19 +4587,19 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_weekend_multiplier_label")}<small>${this._t("panel.settings_weekend_multiplier_hint")}</small></div>
<input type="number" class="tm-input" step="0.1" min="1" max="5" data-setting="weekend_multiplier" value="${s.weekend_multiplier || 1.0}">
<input type="number" class="tm-input" step="0.1" min="1" max="5" data-setting="weekend_multiplier" value="${this._num(s.weekend_multiplier, 1.0)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_difficulty_multipliers_label")}<small>${this._t("panel.settings_difficulty_multipliers_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.difficulty_easy")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_easy" value="${s.difficulty_multiplier_easy ?? 0.5}"></label>
<label>${this._t("panel.difficulty_medium")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_medium" value="${s.difficulty_multiplier_medium ?? 1.0}"></label>
<label>${this._t("panel.difficulty_hard")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_hard" value="${s.difficulty_multiplier_hard ?? 2.0}"></label>
<label>${this._t("panel.difficulty_easy")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_easy" value="${this._num(s.difficulty_multiplier_easy, 0.5)}"></label>
<label>${this._t("panel.difficulty_medium")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_medium" value="${this._num(s.difficulty_multiplier_medium, 1.0)}"></label>
<label>${this._t("panel.difficulty_hard")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_hard" value="${this._num(s.difficulty_multiplier_hard, 2.0)}"></label>
</div>
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_calendar_projection_label")}<small>${this._t("panel.settings_calendar_projection_hint")}</small></div>
<input type="number" class="tm-input" min="1" max="90" data-setting="calendar_projection_days" value="${s.calendar_projection_days || 14}">
<input type="number" class="tm-input" min="1" max="90" data-setting="calendar_projection_days" value="${this._num(s.calendar_projection_days, 14)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_surprise_enabled_label")}<small>${this._t("panel.settings_surprise_enabled_hint")}</small></div>
@@ -4432,9 +4608,9 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_surprise_params_label")}<small>${this._t("panel.settings_surprise_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_surprise_chance")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="surprise_bonus_chance" value="${s.surprise_bonus_chance ?? 15}"></label>
<label>${this._t("panel.settings_surprise_min")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_min" value="${s.surprise_bonus_min ?? 5}"></label>
<label>${this._t("panel.settings_surprise_max")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_max" value="${s.surprise_bonus_max ?? 20}"></label>
<label>${this._t("panel.settings_surprise_chance")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="surprise_bonus_chance" value="${this._num(s.surprise_bonus_chance, 15)}"></label>
<label>${this._t("panel.settings_surprise_min")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_min" value="${this._num(s.surprise_bonus_min, 5)}"></label>
<label>${this._t("panel.settings_surprise_max")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_max" value="${this._num(s.surprise_bonus_max, 20)}"></label>
</div>
</div>
<div class="tm-setting-row tm-setting-stack">
@@ -4460,8 +4636,8 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_roulette_section")}</div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_roulette_multiplier")}<input type="number" class="tm-input" step="0.5" min="1" max="5" data-setting="roulette_multiplier" value="${s.roulette_multiplier ?? 2}"></label>
<label>${this._t("panel.settings_roulette_spins")}<input type="number" class="tm-input" step="1" min="1" max="10" data-setting="roulette_daily_spins" value="${s.roulette_daily_spins ?? 1}"></label>
<label>${this._t("panel.settings_roulette_multiplier")}<input type="number" class="tm-input" step="0.5" min="1" max="5" data-setting="roulette_multiplier" value="${this._num(s.roulette_multiplier, 2)}"></label>
<label>${this._t("panel.settings_roulette_spins")}<input type="number" class="tm-input" step="1" min="1" max="10" data-setting="roulette_daily_spins" value="${this._num(s.roulette_daily_spins, 1)}"></label>
</div>
</div>
<div class="tm-setting-row">
@@ -4471,7 +4647,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_decay_params_label")}<small>${this._t("panel.settings_decay_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_decay_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="points_decay_percent" value="${s.points_decay_percent ?? 10}"></label>
<label>${this._t("panel.settings_decay_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="points_decay_percent" value="${this._num(s.points_decay_percent, 10)}"></label>
<label>${this._t("panel.settings_decay_period")}
<select class="tm-select" data-setting="points_decay_period">
<option value="weekly" ${s.points_decay_period === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
@@ -4486,7 +4662,7 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_level_step_label")}<small>${this._t("panel.settings_level_step_hint")}</small></div>
<input type="number" class="tm-input" min="1" data-setting="level_xp_step" value="${s.level_xp_step || 100}">
<input type="number" class="tm-input" min="1" data-setting="level_xp_step" value="${this._num(s.level_xp_step, 100)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_spendcap_enabled_label")}<small>${this._t("panel.settings_spendcap_enabled_hint")}</small></div>
@@ -4495,7 +4671,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_spendcap_params_label")}<small>${this._t("panel.settings_spendcap_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_spendcap_amount")}<input type="number" class="tm-input" step="1" min="0" data-setting="spend_cap_amount" value="${s.spend_cap_amount ?? 0}"></label>
<label>${this._t("panel.settings_spendcap_amount")}<input type="number" class="tm-input" step="1" min="0" data-setting="spend_cap_amount" value="${this._num(s.spend_cap_amount, 0)}"></label>
<label>${this._t("panel.settings_decay_period")}
<select class="tm-select" data-setting="spend_cap_period">
<option value="weekly" ${s.spend_cap_period === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
@@ -4511,7 +4687,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_interest_params_label")}<small>${this._t("panel.settings_interest_params_hint")}</small></div>
<div class="tm-difficulty-mults">
<label>${this._t("panel.settings_interest_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="interest_percent" value="${s.interest_percent ?? 5}"></label>
<label>${this._t("panel.settings_interest_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="interest_percent" value="${this._num(s.interest_percent, 5)}"></label>
<label>${this._t("panel.settings_decay_period")}
<select class="tm-select" data-setting="interest_period">
<option value="weekly" ${(s.interest_period || "weekly") === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
@@ -4544,7 +4720,7 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_perfect_week_bonus_label")}<small>${this._t("panel.settings_perfect_week_bonus_hint")}</small></div>
<input type="number" class="tm-input" min="0" data-setting="perfect_week_bonus" value="${s.perfect_week_bonus || 50}">
<input type="number" class="tm-input" min="0" data-setting="perfect_week_bonus" value="${this._num(s.perfect_week_bonus, 50)}">
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_celebration_notify_label")}<small>${this._t("panel.settings_celebration_notify_hint")}</small></div>
@@ -4658,7 +4834,7 @@ class TaskMatePanel extends HTMLElement {
<tr>
<td>${this._esc((p.date || "").slice(0, 10))}</td>
<td>${this._esc(p.child_name || "")}</td>
<td style="text-align:right">${p.points} → ${this._esc(p.currency || "")}${this._esc(String(p.amount))}</td>
<td style="text-align:right">${this._num(p.points)} → ${this._esc(p.currency || "")}${this._esc(String(p.amount))}</td>
</tr>
`).join("")}
</tbody></table></div>
@@ -5115,7 +5291,7 @@ class TaskMatePanel extends HTMLElement {
<div style="padding:0 14px 12px;border-top:1px solid var(--tm-border-soft)">
<div class="tm-field-row" style="margin-top:10px">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_name")}</span><input class="tm-input" type="text" data-tpl-dialog-field="name" data-tpl-dialog-idx="${i}" value="${this._esc(c.name)}"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-dialog-field="points" data-tpl-dialog-idx="${i}" value="${c.points || 0}" min="0"></div>
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-dialog-field="points" data-tpl-dialog-idx="${i}" value="${this._num(c.points)}" min="0"></div>
</div>
<div class="tm-field-row">
<div class="tm-field"><span class="tm-field-label">${this._t("panel.chore_time_category_label")}</span>
@@ -5266,7 +5442,7 @@ class TaskMatePanel extends HTMLElement {
${this._select(this._t("panel.chore_first_occurrence_label"), "first_occurrence_mode", d.first_occurrence_mode, FIRST_OCCURRENCE)}
</div>
` : "",
this._select(this._t("panel.chore_completion_sound_label"), "completion_sound", d.completion_sound, COMPLETION_SOUNDS.map(s => ({ v: s, l: s }))),
this._soundField(d.completion_sound),
this._select(this._t("panel.chore_difficulty_label"), "difficulty", d.difficulty || "medium", [
{ v: "easy", l: this._t("panel.difficulty_easy") },
{ v: "medium", l: this._t("panel.difficulty_medium") },
@@ -5299,7 +5475,7 @@ class TaskMatePanel extends HTMLElement {
${(d.bonus_subtasks || []).map((b, idx) => `
<div class="tm-field-row" style="align-items:flex-end;gap:6px;margin-bottom:6px">
<div class="tm-field" style="flex:2;margin:0"><input class="tm-input" placeholder="${this._t("panel.chore_subtask_name_placeholder")}" value="${this._esc(b.name)}" data-field="bonus_subtasks[${idx}].name"></div>
<div class="tm-field" style="flex:0 0 70px;margin:0"><input class="tm-input" type="number" min="0" placeholder="${this._t("panel.chore_subtask_points_placeholder")}" value="${b.points}" data-field="bonus_subtasks[${idx}].points"></div>
<div class="tm-field" style="flex:0 0 70px;margin:0"><input class="tm-input" type="number" min="0" placeholder="${this._t("panel.chore_subtask_points_placeholder")}" value="${this._num(b.points)}" data-field="bonus_subtasks[${idx}].points"></div>
<button type="button" class="tm-btn tm-btn-icon" data-act="remove-bonus-subtask" data-idx="${idx}" title="${this._t("panel.tooltip_remove")}" style="padding:6px 10px">✕</button>
</div>
`).join("")}
@@ -5595,6 +5771,7 @@ class TaskMatePanel extends HTMLElement {
{ v: "monthly", l: this._t("panel.reward_restock_monthly") },
])}
</div>`,
this._renderRewardTimeLock(d),
this._renderRewardUnlock(d),
].join(""),
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
@@ -5602,6 +5779,42 @@ class TaskMatePanel extends HTMLElement {
);
}
/**
* Time lock (#857). Restricts *claiming* to certain weekdays and/or a
* time-of-day window — the reward stays visible the rest of the time, greyed
* out with a "when it's available" badge, and saving points into a pool
* reward is never blocked.
*/
_renderRewardTimeLock(d) {
const open = this._dialog._openAdvanced?.has("timelock") || !!d.time_lock_enabled;
const days = (d.available_days || []).map(Number);
return `<details class="tm-advanced" data-section="timelock"${open ? " open" : ""}>
<summary>${this._t("panel.reward_timelock_section")}</summary>
<div>
${this._switch(this._t("panel.reward_timelock_label"), "time_lock_enabled", d.time_lock_enabled,
this._t("panel.reward_timelock_hint"), true)}
${d.time_lock_enabled ? `
<div class="tm-field">
<span class="tm-field-label">${this._t("panel.reward_timelock_days_label")}</span>
<div class="tm-chip-row">
${DAYS.map((day, i) => `
<button type="button" class="tm-chip-btn ${days.includes(i) ? "tm-chip-on" : ""}" data-act="toggle-reward-day" data-day="${i}">
${this._t(day.lk)}
</button>
`).join("")}
</div>
<span class="tm-field-hint">${this._t("panel.reward_timelock_days_hint")}</span>
</div>
<div class="tm-field-row">
${this._timeField(this._t("panel.reward_timelock_from_label"), "available_from", d.available_from)}
${this._timeField(this._t("panel.reward_timelock_until_label"), "available_until", d.available_until)}
</div>
<span class="tm-field-hint">${this._t("panel.reward_timelock_times_hint")}</span>
` : ""}
</div>
</details>`;
}
/**
* Timed unlock (#678). Only offers entities the parent has allowlisted in
* Settings — a reward must not be able to reach arbitrary devices.
@@ -5845,7 +6058,7 @@ class TaskMatePanel extends HTMLElement {
</div>
</div>
` : "",
this._select(this._t("panel.chore_completion_sound_label"), "completion_sound", d.completion_sound, COMPLETION_SOUNDS.map(s => ({ v: s, l: s }))),
this._soundField(d.completion_sound),
this._select(this._t("panel.chore_difficulty_label"), "difficulty", d.difficulty || "medium", [
{ v: "easy", l: this._t("panel.difficulty_easy") },
{ v: "medium", l: this._t("panel.difficulty_medium") },
@@ -5881,7 +6094,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-reorder-item" draggable="true" data-drag-id="${this._esc(id)}">
<div class="tm-reorder-handle">⠿</div>
<div class="tm-reorder-name">${this._esc(c.name)}</div>
<div class="tm-reorder-points tm-numeric">${c.points}</div>
<div class="tm-reorder-points tm-numeric">${this._num(c.points)}</div>
<div class="tm-reorder-moves">
<button type="button" class="tm-reorder-move" data-act="reorder-move" data-dir="-1" data-drag-id="${this._esc(id)}" ${idx === 0 ? "disabled" : ""} title="${this._t("reorder.move_up")}" aria-label="${this._t("reorder.move_up")}">▲</button>
<button type="button" class="tm-reorder-move" data-act="reorder-move" data-dir="1" data-drag-id="${this._esc(id)}" ${idx === arr.length - 1 ? "disabled" : ""} title="${this._t("reorder.move_down")}" aria-label="${this._t("reorder.move_down")}">▼</button>
@@ -5910,7 +6123,7 @@ class TaskMatePanel extends HTMLElement {
</div>`;
}
_field(label, name, value, type = "text", hint = "") {
_field(label, name, value, type = "text", hintHtml = "") {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
@@ -5920,11 +6133,11 @@ class TaskMatePanel extends HTMLElement {
value="${this._esc(value == null ? "" : value)}"
${type === "number" ? 'type="number"' : 'type="text"'}
>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_dateField(label, name, value, hint = "") {
_dateField(label, name, value, hintHtml = "") {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
@@ -5934,11 +6147,25 @@ class TaskMatePanel extends HTMLElement {
value="${this._esc(value || "")}"
type="date"
>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_select(label, name, value, options, hint = "", rerender = false) {
_timeField(label, name, value, hintHtml = "") {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
<input
class="tm-input"
data-field="${name}"
value="${this._esc(value || "")}"
type="time"
>
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_select(label, name, value, options, hintHtml = "", rerender = false) {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
@@ -5949,17 +6176,17 @@ class TaskMatePanel extends HTMLElement {
>
${options.map(o => `<option value="${this._esc(o.v)}" ${String(o.v) === String(value) ? "selected" : ""}>${this._esc(o.lk ? this._t(o.lk) : o.l)}</option>`).join("")}
</select>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
_switch(label, name, checked, hint = "", rerender = false) {
_switch(label, name, checked, hintHtml = "", rerender = false) {
return `
<div class="tm-check-row">
<ha-switch data-field="${name}" ${checked ? "checked" : ""} ${rerender ? 'data-rerender="true"' : ""}></ha-switch>
<div>
<div class="tm-check-title">${this._esc(label)}</div>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>
</div>`;
}
@@ -5985,11 +6212,63 @@ class TaskMatePanel extends HTMLElement {
</div>`;
}
/** Uploaded custom sounds from the last get_state (#856). */
_customSounds() {
return (this._state && this._state.custom_sounds) || [];
}
/** Built-in + uploaded sounds, as <select> options. */
_soundOptions() {
return [
...COMPLETION_SOUNDS.map(v => ({ v, l: v })),
...this._customSounds().map(s => ({ v: s.value, l: s.name })),
];
}
/**
* The chore editor's completion-sound picker, with a preview button.
*
* Picking a sound used to be silent here: the only preview code lived in
* taskmate-config-sounds.js, which scans the document for sound dropdowns
* and so could never see this one inside the panel's shadow root (#856).
*/
_soundField(value) {
const current = value || "coin";
return `
<div class="tm-field">
<span class="tm-field-label">${this._t("panel.chore_completion_sound_label")}</span>
<div class="tm-sound-row">
<select class="tm-select" data-field="completion_sound">
${this._soundOptions().map(o => `<option value="${this._esc(o.v)}" ${String(o.v) === String(current) ? "selected" : ""}>${this._esc(o.l)}</option>`).join("")}
</select>
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-preview-field"
title="${this._esc(this._t("panel.sound_preview"))}"
aria-label="${this._esc(this._t("panel.sound_preview"))}">▶</button>
</div>
</div>`;
}
/** Play a sound through the shared engine (taskmate-sounds.js). */
_previewSound(value) {
const engine = window.__taskmate_sounds;
if (engine) engine.play(value, this._customSounds());
}
/**
* Icon field. `ha-icon-picker` is HA's own component — we keep it for the
* searchable MDI list, but its Material shell (grey fill, underline, 56px
* box) reads as a foreign control beside the panel's own inputs. The
* `.tm-icon-well` wrapper supplies the tm-input frame and the picker inside
* it is made transparent; see the CSS for why the wrapper, not the picker,
* carries the border.
*/
_iconPickerField(label, name, value) {
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
<ha-icon-picker data-field="${name}" data-current="${this._esc(value || "")}"></ha-icon-picker>
<div class="tm-icon-well">
<ha-icon-picker data-field="${name}" data-current="${this._esc(value || "")}"></ha-icon-picker>
</div>
</div>`;
}
@@ -6014,7 +6293,7 @@ class TaskMatePanel extends HTMLElement {
else { (this._settingsEntityDraft = this._settingsEntityDraft || {})[field] = value; }
}
_entityPickerField(label, name, value, domains, hint = "") {
_entityPickerField(label, name, value, domains, hintHtml = "") {
const friendly = value && this._hass?.states?.[value]?.attributes?.friendly_name || "";
const icon = value ? (this._hass?.states?.[value]?.attributes?.icon || this._entityDomainIcon(value)) : "";
return `
@@ -6033,7 +6312,7 @@ class TaskMatePanel extends HTMLElement {
</div>`
}
</div>
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
</div>`;
}
@@ -6208,6 +6487,15 @@ class TaskMatePanel extends HTMLElement {
return new Intl.NumberFormat().format(n);
}
// A number bound for a markup or attribute slot. The panel assembles its
// HTML as strings, so anything stored in a field we *assume* is numeric has
// to be proven numeric before it is echoed back. Non-numbers, inf and NaN
// fall back rather than reaching the DOM.
_num(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
// Quick point-adjust amounts (#746). Stored as a comma-separated string like
// "5, 10, 20" — same shape as streak_milestones — so the generic settings
// collector can round-trip it as a plain text input. Junk degrades to the
@@ -6740,6 +7028,24 @@ class TaskMatePanel extends HTMLElement {
}
.tm-image-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* Custom completion sounds (#856) */
.tm-sound-row { display: flex; gap: 6px; align-items: center; }
.tm-sound-row .tm-select { flex: 1; min-width: 0; }
.tm-sound-list {
display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;
}
.tm-sound-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border: 1px solid var(--tm-border);
border-radius: var(--tm-radius-sm); background: var(--tm-surface-2);
}
.tm-sound-name {
flex: 1; min-width: 0; font-size: 14px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tm-sound-item-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.tm-sound-upload { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.tm-points-adjust {
display: flex; gap: 4px; flex-wrap: wrap;
align-items: center;
@@ -6911,6 +7217,8 @@ class TaskMatePanel extends HTMLElement {
.tm-pill-pool { background: var(--tm-pool-soft); color: var(--tm-pool); border-color: color-mix(in srgb, var(--tm-pool), transparent 75%); }
.tm-pill-sticky { background: var(--tm-sticky-soft); color: var(--tm-sticky); border-color: color-mix(in srgb, var(--tm-sticky), transparent 75%); }
.tm-pill-spread { background: var(--tm-positive-soft); color: var(--tm-positive); border-color: var(--tm-positive-border); }
.tm-pill-timelock { background: var(--tm-accent-soft); color: var(--tm-accent-text); border-color: var(--tm-accent-border); }
.tm-pill-timelock ha-icon { --mdc-icon-size: 13px; vertical-align: -2px; }
.tm-pill-jackpot { background: var(--tm-gold-soft); color: var(--tm-gold); border-color: color-mix(in srgb, var(--tm-gold), transparent 75%); }
.tm-pill-muted { background: var(--tm-surface-2); color: var(--tm-text-muted); border-color: var(--tm-border); }
.tm-quest-inactive { opacity: 0.6; }
@@ -7335,6 +7643,42 @@ class TaskMatePanel extends HTMLElement {
display: block; width: 100%;
}
/* Icon field well: make ha-icon-picker sit in the dialogs like a
tm-input. The picker's inner shell can't be reached from here (shadow
DOM), so the wrapper carries the frame and the picker is stripped back:
- the fill and the 1px "active indicator" are var() usages inside
ha-picker-field, so overriding the vars here does reach them;
- ha-combo-box-item hard-sets its own 56px row height and square
bottom corners, which no variable overrides — so the well is
tm-input height with overflow:hidden, and the picker is pulled up
by half the difference to keep its content optically centred.
The --mdc-* vars cover older HA, which still renders an mwc textfield. */
.tm-icon-well {
box-sizing: border-box;
height: 37px;
background: var(--tm-surface-0);
border: 1px solid var(--tm-border);
border-radius: var(--tm-radius-sm);
box-shadow: var(--tm-shadow-xs);
overflow: hidden;
transition: all 0.1s var(--tm-easing);
}
.tm-icon-well:hover { border-color: var(--tm-border-strong); }
.tm-icon-well:focus-within { border-color: var(--tm-accent); box-shadow: var(--tm-shadow-focus); }
.tm-icon-well ha-icon-picker {
display: block; width: 100%;
margin-top: calc((37px - 56px) / 2);
--ha-color-form-background: transparent;
--ha-color-border-neutral-loud: transparent;
--md-list-item-leading-space: 11px;
--md-list-item-trailing-space: 4px;
--md-list-item-label-text-size: 13px;
--mdc-text-field-fill-color: transparent;
--mdc-text-field-idle-line-color: transparent;
--mdc-text-field-hover-line-color: transparent;
--mdc-text-field-focused-line-color: transparent;
}
/* Chip multi-select */
/* Insight reports (#679) */
.tm-verdict {
@@ -325,6 +325,13 @@ class TaskMateRewardProgressCard extends LitElement {
background: color-mix(in srgb, var(--error-color, #db4437) 18%, transparent);
color: var(--error-color, #db4437);
}
.availability-badge.badge-time-locked {
background: color-mix(in srgb, var(--info-color, #3498db) 18%, transparent);
color: var(--info-color, #2471a3);
/* Longer than the other badges — uppercasing a whole window reads badly. */
text-transform: none;
letter-spacing: 0;
}
.availability-badge.badge-expiring-soon {
background: color-mix(in srgb, var(--warning-color, #ff9800) 20%, transparent);
color: var(--warning-color, #ff9800);
@@ -503,6 +510,9 @@ class TaskMateRewardProgressCard extends LitElement {
} else if (isSoldOut) {
availabilityLabel = this._t('rewards.sold_out');
availabilityClass = 'badge-sold-out';
} else if (reward.is_time_locked === true) {
availabilityLabel = this._timeLockLabel(reward);
availabilityClass = 'badge-time-locked';
} else if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
availabilityLabel = this._t('rewards.only_n_left', { count: reward.quantity });
availabilityClass = 'badge-low-stock';
@@ -559,6 +569,14 @@ class TaskMateRewardProgressCard extends LitElement {
`;
}
/** "Available Fri, Sat · 18:00–21:00" for a time-locked reward (#857). */
_timeLockLabel(reward) {
const summary = window.__taskmate_time_lock_label
? window.__taskmate_time_lock_label(reward.time_lock, (k, pr) => this._t(k, pr))
: '';
return summary ? this._t('rewards.available_when', { window: summary }) : this._t('rewards.locked_now');
}
_renderChildProgress(child, reward, pointsIcon, pointsName) {
const cost = reward.calculated_costs?.[child.id] ?? reward.cost;
const have = child.points || 0;
@@ -760,6 +778,9 @@ class TaskMateRewardProgressCard extends LitElement {
} else if (isSoldOut) {
availabilityLabel = this._t('rewards.sold_out');
availabilityTone = 'dim';
} else if (reward.is_time_locked === true) {
availabilityLabel = this._timeLockLabel(reward);
availabilityTone = 'dim';
} else if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
availabilityLabel = this._t('rewards.only_n_left', { count: reward.quantity });
availabilityTone = 'bad';
@@ -587,6 +587,12 @@ class TaskMateRewardsCard extends LitElement {
background: rgba(231,76,60,0.15);
color: #c0392b;
}
.availability-badge.badge-time-locked {
gap: 3px;
background: rgba(52,152,219,0.16);
color: #2471a3;
}
.availability-badge.badge-time-locked ha-icon { --mdc-icon-size: 13px; }
.availability-badge.badge-expiring-soon {
background: rgba(243,156,18,0.18);
color: #d35400;
@@ -1393,10 +1399,12 @@ class TaskMateRewardsCard extends LitElement {
const isLoading = this._loading[reward.id];
const isAllocLoading = this._loading[`${reward.id}_alloc`];
// Availability (quantity / expiration)
// Availability (quantity / expiration / time lock)
const isSoldOut = reward.is_sold_out === true;
const isExpired = reward.is_expired === true;
const isUnavailable = isSoldOut || isExpired;
const isTimeLocked = reward.is_time_locked === true;
const canClaimNow = !isUnavailable && !isTimeLocked;
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number')
? reward.days_until_expiry
: null;
@@ -1407,7 +1415,7 @@ class TaskMateRewardsCard extends LitElement {
const showPoolControls = enablePoolMode && childId && !hasPendingClaim && !isUnavailable;
return html`
<div class="reward-row ${isJackpot ? 'jackpot' : ''} ${hasPendingClaim ? 'pending-approval' : ''} ${enablePoolMode ? 'pool-mode' : ''} ${isUnavailable ? 'unavailable' : ''}">
<div class="reward-row ${isJackpot ? 'jackpot' : ''} ${hasPendingClaim ? 'pending-approval' : ''} ${enablePoolMode ? 'pool-mode' : ''} ${isUnavailable || isTimeLocked ? 'unavailable' : ''}">
<div class="cost-badge">
<ha-icon icon="${pointsIcon}"></ha-icon>
<span class="cost-value">${displayCost}</span>
@@ -1450,7 +1458,7 @@ class TaskMateRewardsCard extends LitElement {
: ""}
</div>
<div class="reward-right-col">
${!enablePoolMode && !hasPendingClaim && childId && !isUnavailable ? html`
${!enablePoolMode && !hasPendingClaim && childId && canClaimNow ? html`
<button
class="claim-btn ${!canAfford ? 'cant-afford' : ''}"
?disabled="${!canAfford || isLoading}"
@@ -1460,7 +1468,7 @@ class TaskMateRewardsCard extends LitElement {
<ha-icon icon="${isLoading ? 'mdi:loading' : rewardIcon}"></ha-icon>
</button>
` : html`
<div class="reward-icon-container" title="${isUnavailable ? this._t('rewards.unavailable_hint') : ''}">
<div class="reward-icon-container" title="${isTimeLocked ? this._timeLockLabel(reward) : (isUnavailable ? this._t('rewards.unavailable_hint') : '')}">
<ha-icon icon="${rewardIcon}"></ha-icon>
</div>
`}
@@ -1469,6 +1477,14 @@ class TaskMateRewardsCard extends LitElement {
`;
}
/** "Available Fri, Sat · 18:00–21:00" for a time-locked reward (#857). */
_timeLockLabel(reward) {
const summary = window.__taskmate_time_lock_label
? window.__taskmate_time_lock_label(reward.time_lock, (k, pr) => this._t(k, pr))
: '';
return summary ? this._t('rewards.available_when', { window: summary }) : this._t('rewards.locked_now');
}
_renderAvailabilityBadge(reward, isSoldOut, isExpired, daysUntilExpiry) {
if (isExpired) {
return html`<div class="availability-badge badge-expired">${this._t('rewards.expired')}</div>`;
@@ -1476,6 +1492,11 @@ class TaskMateRewardsCard extends LitElement {
if (isSoldOut) {
return html`<div class="availability-badge badge-sold-out">${this._t('rewards.sold_out')}</div>`;
}
if (reward.is_time_locked === true) {
return html`<div class="availability-badge badge-time-locked">
<ha-icon icon="mdi:clock-outline"></ha-icon>${this._timeLockLabel(reward)}
</div>`;
}
const badges = [];
if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
badges.push(html`<div class="availability-badge badge-low-stock">${this._t('rewards.only_n_left', { count: reward.quantity })}</div>`);
@@ -1872,14 +1893,17 @@ class TaskMateRewardsCard extends LitElement {
const isSoldOut = reward.is_sold_out === true;
const isExpired = reward.is_expired === true;
const isUnavailable = isSoldOut || isExpired;
// Time lock (#857) is temporary: it blocks claiming and greys the reward,
// but leaves pool allocation alone — saving up is not spending.
const isTimeLocked = reward.is_time_locked === true;
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number')
? reward.days_until_expiry : null;
return {
reward, isJackpot, enablePoolMode, displayCost, relevantChildren, relevantChild,
childId, currentStars, childContributions, percentage, hasPendingClaim,
spendable, poolFull, canAfford, isSoldOut, isExpired, isUnavailable, daysUntilExpiry,
showClaim: !enablePoolMode && !hasPendingClaim && childId && !isUnavailable,
spendable, poolFull, canAfford, isSoldOut, isExpired, isUnavailable, isTimeLocked, daysUntilExpiry,
showClaim: !enablePoolMode && !hasPendingClaim && childId && !isUnavailable && !isTimeLocked,
showPoolControls: enablePoolMode && childId && !hasPendingClaim && !isUnavailable,
};
}
@@ -1888,6 +1912,7 @@ class TaskMateRewardsCard extends LitElement {
_designAvail(d) {
if (d.isExpired) return { label: this._t('rewards.expired'), tone: 'muted' };
if (d.isSoldOut) return { label: this._t('rewards.sold_out'), tone: 'muted' };
if (d.isTimeLocked) return { label: this._timeLockLabel(d.reward), tone: 'muted' };
if (typeof d.reward.quantity === 'number' && d.reward.quantity > 0 && d.reward.quantity <= 3) {
return { label: this._t('rewards.only_n_left', { count: d.reward.quantity }), tone: 'bad' };
}
@@ -2049,7 +2074,7 @@ class TaskMateRewardsCard extends LitElement {
: html`<span class="muted rw-status">${d.currentStars} / ${d.displayCost}</span>`;
return html`
<div class="rw-card ${d.isJackpot ? 'rw-jackpot' : ''} ${d.isUnavailable ? 'rw-unavail' : ''}"
<div class="rw-card ${d.isJackpot ? 'rw-jackpot' : ''} ${d.isUnavailable || d.isTimeLocked ? 'rw-unavail' : ''}"
style="--ac:${tone}">
<div class="row rw-top">
<span class="rw-emoji">${emoji}</span>
@@ -0,0 +1,367 @@
/**
* TaskMate shared sound engine.
*
* Single home for every completion sound, so the child card, the admin panel
* and the config-flow preview module all behave identically. Before this
* existed the six synthesised sounds were copy-pasted into two files that had
* already drifted apart (one still knew only about a long-gone "fart"/"fart_long"
* pair while the real list had moved to fart1..fart10).
*
* Three kinds of sound:
* synthesised — generated on the fly via Web Audio API, no files needed
* built-in files — the CC0 fart mp3s shipped in this www folder
* custom — user-uploaded, referenced as "custom:<file>" (#856)
*
* A custom sound is resolved through a lookup table the caller passes in (the
* `custom_sounds` attribute of sensor.taskmate_chores), because its URL is
* signed server-side — a bare Audio() carries no bearer token and would 401
* against the auth-gated serve view.
*
* Mirrors window.__taskmate_design: exposed globally, no ES module imports.
*/
(function () {
"use strict";
// Base URL for files shipped inside the integration's www folder. Registered
// by frontend.py as a static path. NOT /local/, which maps to config/www and
// is where these files have never lived — that mismatch silently 404'd every
// fart sound until #856.
const FILE_BASE = "/taskmate";
const CUSTOM_PREFIX = "custom:";
const FART_COUNT = 10;
// Built-in names, mirroring COMPLETION_SOUND_OPTIONS in const.py.
const BUILTIN = [
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
"fart8", "fart9", "fart10", "fart_random",
];
// One shared AudioContext per page. Browsers cap how many a page may create,
// and each card minting its own used to leak one per card instance.
let audioContext = null;
function getAudioContext() {
if (!audioContext) {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return null;
audioContext = new Ctor();
}
// Autoplay policy: a context created before any user gesture starts
// suspended and stays silent until resumed.
if (audioContext.state === "suspended") audioContext.resume();
return audioContext;
}
function playCoinSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// First tone (E6)
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.frequency.value = 1318.5; // E6
osc1.type = 'square';
gain1.gain.setValueAtTime(0.5, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
osc1.start(startTime);
osc1.stop(startTime + 0.1);
// Second tone (B6) - higher
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.frequency.value = 1975.5; // B6
osc2.type = 'square';
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
osc2.start(startTime + 0.08);
osc2.stop(startTime + 0.25);
}
/**
* Level up sound - triumphant ascending arpeggio
*/
function playLevelUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// C major arpeggio going up: C5, E5, G5, C6
const notes = [523.25, 659.25, 783.99, 1046.5];
const duration = 0.12;
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'square';
const noteStart = startTime + i * duration;
gain.gain.setValueAtTime(0.6, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.15);
});
// Final sustained chord
const chordNotes = [523.25, 659.25, 783.99]; // C major chord
const chordStart = startTime + notes.length * duration;
chordNotes.forEach((freq) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'triangle';
gain.gain.setValueAtTime(0.3, chordStart);
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
osc.start(chordStart);
osc.stop(chordStart + 0.55);
});
}
/**
* Fanfare sound - celebratory trumpet-like fanfare
*/
function playFanfareSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.2;
// Fanfare pattern: G4, G4, G4, E4, G4, C5 (classic celebration pattern)
const pattern = [
{ freq: 392.00, duration: 0.1, delay: 0 }, // G4
{ freq: 392.00, duration: 0.1, delay: 0.12 }, // G4
{ freq: 392.00, duration: 0.15, delay: 0.24 }, // G4
{ freq: 329.63, duration: 0.15, delay: 0.42 }, // E4
{ freq: 392.00, duration: 0.15, delay: 0.6 }, // G4
{ freq: 523.25, duration: 0.4, delay: 0.78 }, // C5 (long final note)
];
pattern.forEach(({ freq, duration, delay }) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sawtooth';
const noteStart = startTime + delay;
gain.gain.setValueAtTime(0.5, noteStart);
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
osc.start(noteStart);
osc.stop(noteStart + duration + 0.05);
});
}
/**
* Chime sound - simple pleasant bell chime
*/
function playChimeSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.3;
// Bell-like sound using multiple harmonics
const fundamental = 880; // A5
const harmonics = [1, 2, 3, 4.2]; // Slight inharmonicity for bell-like quality
harmonics.forEach((harmonic, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = fundamental * harmonic;
osc.type = 'sine';
// Higher harmonics decay faster
const amplitude = 0.5 / (i + 1);
const decayTime = 0.8 / (i + 1);
gain.gain.setValueAtTime(amplitude, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
osc.start(startTime);
osc.stop(startTime + decayTime + 0.1);
});
}
/**
* Power up sound - ascending sweep with sparkle
*/
function playPowerUpSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// Ascending sweep
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(200, startTime);
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
gain1.gain.setValueAtTime(0.4, startTime);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
osc1.start(startTime);
osc1.stop(startTime + 0.4);
// Sparkle notes at the end
const sparkleNotes = [1318.5, 1567.98, 1975.5]; // E6, G6, B6
sparkleNotes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(masterGain);
osc.frequency.value = freq;
osc.type = 'sine';
const noteStart = startTime + 0.25 + i * 0.05;
gain.gain.setValueAtTime(0.3, noteStart);
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
osc.start(noteStart);
osc.stop(noteStart + 0.25);
});
}
/**
* Undo sound - sad descending "womp womp" style
* Two descending tones that sound disappointed/sad
*/
function playUndoSound(ctx, startTime) {
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.value = 0.25;
// First "womp" - descending tone
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.connect(gain1);
gain1.connect(masterGain);
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(311.13, startTime); // Eb4
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25); // Bb3
gain1.gain.setValueAtTime(0.6, startTime);
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
osc1.start(startTime);
osc1.stop(startTime + 0.35);
// Second "womp" - even lower descending tone (the sad part)
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(masterGain);
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(233.08, startTime + 0.3); // Bb3
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7); // Eb3
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
osc2.start(startTime + 0.3);
osc2.stop(startTime + 0.8);
// Optional: add a subtle low vibrato for extra sadness
const osc3 = ctx.createOscillator();
const gain3 = ctx.createGain();
osc3.connect(gain3);
gain3.connect(masterGain);
osc3.type = 'sine';
osc3.frequency.setValueAtTime(116.54, startTime + 0.5); // Bb2 (sub bass)
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
osc3.start(startTime + 0.5);
osc3.stop(startTime + 0.85);
}
/**
* Play an audio file by URL. Used for the shipped fart mp3s and for
* user-uploaded custom sounds.
*/
function playUrl(url) {
try {
const audio = new Audio(url);
audio.volume = 1.0;
audio.play().catch(() => { /* autoplay blocked until a user gesture */ });
} catch (e) { /* no Audio support */ }
}
function playFile(filename) {
playUrl(`${FILE_BASE}/${filename}`);
}
/** True for a well-formed "custom:<32hex>.<ext>" value. Mirrors sounds.is_custom_sound. */
function isCustom(name) {
return typeof name === "string"
&& name.startsWith(CUSTOM_PREFIX)
&& /^[0-9a-f]{32}\.(mp3|ogg|wav|m4a)$/.test(name.slice(CUSTOM_PREFIX.length));
}
/**
* Resolve a custom sound value to its signed URL via the lookup table.
* `customSounds` is the list delivered on sensor.taskmate_chores.
*/
function customUrl(name, customSounds) {
if (!isCustom(name) || !Array.isArray(customSounds)) return null;
const hit = customSounds.find(s => s && s.value === name);
return (hit && hit.url) || null;
}
/**
* Play a completion sound by name.
*
* @param {string} name - built-in name, or "custom:<file>"
* @param {Array} customSounds - optional lookup table for custom sounds
*/
function play(name, customSounds) {
if (!name || name === "none") return;
if (isCustom(name)) {
const url = customUrl(name, customSounds);
// A dangling reference (sound deleted, or a card whose sensor data
// predates the upload) stays silent rather than playing the wrong thing.
if (url) playUrl(url);
return;
}
if (name === "fart_random") {
playFile(`fart${Math.floor(Math.random() * FART_COUNT) + 1}.mp3`);
return;
}
if (/^fart([1-9]|10)$/.test(name)) {
playFile(`${name}.mp3`);
return;
}
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
try {
switch (name) {
case "coin": playCoinSound(ctx, now); break;
case "levelup": playLevelUpSound(ctx, now); break;
case "fanfare": playFanfareSound(ctx, now); break;
case "chime": playChimeSound(ctx, now); break;
case "powerup": playPowerUpSound(ctx, now); break;
case "undo": playUndoSound(ctx, now); break;
default: playCoinSound(ctx, now); break;
}
} catch (e) { /* a failed sound must never break a completion */ }
}
window.__taskmate_sounds = { BUILTIN, CUSTOM_PREFIX, play, isCustom, customUrl, getAudioContext };
})();