Hinweis: Der Beitrag wurde mit KI-Unterstützung (Claude Code) verfasst und manuell nachbearbeitet.
HA Widgets — alle Teile: Teil 1: Überblick · Teil 2: Wie fängt man an? · Teil 3: Architektur · Teil 4: Architektur-Checks · Teil 5: HA-Infrastruktur · Teil 6: LCARS-Design
Teil 3 hat die Architektur-Regeln beschrieben: kein fetch() in Views, kein any, alles in shared/ was mehrere Apps brauchen. Und einen kurzen Ausblick auf automatische Checks gegeben. Dieser Teil macht das konkret — wie sehen die Checks aus, wo liegen ihre Grenzen, und wie findet man Probleme die kein Tool von Haus aus erkennt.
Die kurze Antwort: mehrere Tools, jedes mit seinem blinden Fleck. Zusammen decken sie das meiste ab.
tsc und ESLint: Grundlagen
tsc --noEmit prüft die Typen in allen drei Apps — kein Build, nur Typen-Check:
npx tsc -p tablet/app/tsconfig.json --noEmit
npx tsc -p phone/app/tsconfig.json --noEmit
npx tsc -p desktop/app/tsconfig.json --noEmit
Vite baut auch ohne saubere Typen durch. TypeScript-Fehler fallen sonst erst zur Laufzeit auf — dieser Check kostet zwei Sekunden und verhindert das.
ESLint übernimmt was TypeScript nicht sieht: Architektur-Regeln. Die Konfiguration nutzt ESLint Flat Config mit dem TypeScript-Plugin und dem Vue-Parser:
// Kein fetch() oder callAction() direkt in Views/Components:
'no-restricted-syntax': [
{ selector: 'CallExpression[callee.name="fetch"]',
message: 'fetch() in Views verboten — Composable verwenden.' },
{ selector: 'CallExpression[callee.name="callAction"]',
message: 'callAction() in Views verboten — in Composable kapseln.' }
]
// Kein direkter Import von entityConfig/apiConfig in Views:
'no-restricted-imports': [
{ group: ['**/entityConfig'], message: 'Entity-IDs nur über E.xxx' },
{ group: ['**/apiConfig'], message: 'URLs nur in shared/' }
]
// Kein any, keine ungenutzten Variablen/Imports:
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': 'error',
// Ungenutzte Vue-Komponenten (importiert aber nie im Template verwendet):
'vue/no-unused-vars': 'error',
npm run lint prüft alle drei Apps und shared/ auf einmal. In Teil 3 hat no-unused-vars dabei einen echten Bug aufgedeckt: cameraAvailable war in TokenDialog.vue definiert aber nicht im return {}-Statement — der QR-Code-Button war dadurch immer unsichtbar.
Das sind die Grundlagen. Aber es gibt Lücken.

Der blinde Fleck: Vue-Imports
TypeScript und ESLint prüfen .vue-Dateien — aber nicht alles darin. Konkret: welche Funktionen aus vue importiert werden müssen, prüft keines der beiden Tools zuverlässig.
Das klingt harmlos. Es ist es nicht.
In <script setup> muss jede verwendete Vue-Funktion explizit importiert werden:
import { ref, watch, computed } from 'vue'
Fehlt ein Import, gibt es keinen Compile-Fehler. TypeScript sieht watch als globale Variable — unbekannt, aber kein Error. Erst zur Laufzeit, wenn die Komponente mountet, kommt watch is not defined.
Genau das ist passiert. In phone/ViewEinkauf.vue wurde watch() aufgerufen — der Import fehlte. tsc: kein Fehler. ESLint: kein Fehler. Browser-Konsole: watch is not defined. Aufgefallen erst beim Test nach einer Änderung die gar nichts mit watch zu tun hatte.
Die Wurzel des Problems: Vue-Compiler-Macros wie defineProps, defineEmits und defineExpose brauchen keinen Import — sie sind vom Vue-Compiler automatisch verfügbar. ESLint kennt diesen Unterschied nicht zuverlässig, vor allem nicht mit den verschachtelten Parser-Konfigurationen die ein Vue-TypeScript-Projekt braucht.
Lösung: ein kleines Python-Script
Das Problem ist eigentlich einfach: in jeder .vue-Datei prüfen, ob alle verwendeten Vue-Funktionen auch importiert sind. Das lässt sich mit einem Python-Script lösen — ohne externe Abhängigkeit, ohne komplexe AST-Analyse.
tools/check_vue_imports.py:
VUE_FUNCTIONS = {
'ref', 'reactive', 'computed',
'watch', 'watchEffect',
'onMounted', 'onUnmounted', 'onBeforeMount', 'onBeforeUnmount',
'nextTick', 'inject', 'provide',
'toRef', 'toRefs', 'toValue', 'toRaw',
# ... weitere
}
COMPILER_MACROS = {
'defineProps', 'defineEmits', 'defineExpose',
'defineOptions', 'defineSlots', 'withDefaults',
}
def check_file(path):
script = extract_script_setup(content)
imported = get_vue_imports(script) # was importiert wird
used = get_used_vue_functions(script) # was verwendet wird
missing = used - imported - COMPILER_MACROS
# Fehler melden für alles in missing
Funktionsweise:
<script setup>-Block aus der.vue-Datei extrahieren (Regex auf<script\s[^>]*\bsetup\b[^>]*>)- Alle
import { ... } from 'vue'-Statements parsen —type-Imports überspringen - Alle Aufrufe bekannter Vue-Funktionen finden (
ref(,watch(,computed<etc.) - Differenz melden — alles was verwendet aber nicht importiert wird
Compiler-Macros werden explizit ausgenommen, weil die tatsächlich keinen Import brauchen.
Aufruf:
python tools/check_vue_imports.py
Ausgabe wenn alles stimmt:
✓ Vue-Imports OK — 35 Dateien geprüft
Das Script ist kein Ersatz für TypeScript oder ESLint — es prüft nur diesen einen spezifischen Sachverhalt. Aber genau für den ist es zuverlässiger als die Alternative.
Duplikate aufspüren: jscpd
Drei Apps, eine gemeinsame Library — das klingt gut in der Theorie. In der Praxis schleicht sich Copy-Paste trotzdem ein. Ein Artikel-Edit-Dialog wird einmal gebaut, dann für eine zweite View übernommen. Dann gibt es einen Fix — und man patcht nur eine der beiden Stellen.
jscpd (JavaScript Copy-Paste Detector) findet solche Klone:
npx jscpd --min-lines 6 --min-tokens 50 \
--ignore "**/node_modules/**,**/dist/**" \
desktop/app/src phone/app/src tablet/app/src shared
Das Ergebnis beim ersten Lauf: 17 Klone im Template-Code (markup), 0 Klone im TypeScript.
Die 0 Klone im TypeScript sind ein gutes Zeichen — die Logik ist ordentlich in shared/ ausgelagert. Die Template-Klone sind das eigentliche Problem.

Refactoring: was jscpd gefunden hat
Zwei Funde waren konkret behebbar.
VorratItemDialog.vue — 31 Zeilen identisches Template
Der Artikel-Edit-Dialog (Name, Kategorie, Einheit, kcal, Rezeptvorrat, Speichern/Löschen) war in desktop/ViewEinkauf.vue und desktop/ViewLager.vue nahezu identisch. Der einzige Unterschied: ViewLager hat ein zusätzliches Abteilungs-Feld.
Lösung: desktop/components/VorratItemDialog.vue, mit abteilungOptions als optionalem Prop:
<VorratItemDialog v-if="editDialog"
:edit-dialog="editDialog"
:is-duplicate="isDuplicate"
:display-kategorien="displayKategorien"
:kcal-loading="kcalLoading"
:kcal-hint="kcalHint"
:abteilung-options="gruppen.abteilungOptions" @close="editDialog = null"
@save="saveEdit"
@delete="confirmDeleteItem"
@fetch-kcal="fetchKcal"/>
Das onMounted-Hook im Dialog fokussiert automatisch das Name-Feld — das nextTick(() => ref.focus()) in beiden Views entfällt.
LagerItemRow.vue — 4 fast identische Blöcke
In ViewLager.vue gibt es zwei Anzeigemodi (Kategorie-Gruppierung und Abteilungs-Gruppierung) und in jedem Modus zwei Item-Typen (normale Items und „sonstige" Items mit rezeptvorrat: false). Das macht vier fast identische Template-Blöcke mit je ~12 Zeilen.
Der einzige Unterschied: sonstige Items bekommen die CSS-Klasse lager-item-sonstig.
Lösung: desktop/components/LagerItemRow.vue mit einem sonstig-Prop:
<LagerItemRow
v-for="item in kat.rezeptItems"
:item="item"
:sonstig="false"
:in-vorrat="inVorrat"
:in-liste="inListe"
@dragstart="onDragStart(item.name)"
@dragend="onDragEnd()"
@toggle-vorrat="toggleVorrat"
@toggle-liste="toggleListe"
@edit="n => openEdit(n, kat.name)"/>
Beim Refactoring fiel nebenbei ein Bug auf: im Abteilungs-Modus fehlte die v-if="item.einkaufsliste !== false"-Prüfung am Warenkorb-Button. Items die nicht auf der Einkaufsliste erscheinen sollen (Getränke, Haushalt) hätten trotzdem den Button gezeigt. Im Kategorie-Modus war die Prüfung korrekt — der Klon hatte sie nicht übernommen.
Das ist der Hauptvorteil von Duplikat-Erkennung: man findet nicht nur den Klon, sondern auch die Stellen wo die Kopie vom Original auseinandergedriftet ist.
Was akzeptabel bleibt
Nicht jeder Klon ist ein Problem. jscpd meldet auch:
ConfirmDialog.vue — desktop vs. phone
Beide haben identische Prop-Definitionen (title, body, confirmLabel, confirmClass) und identische Emit-Struktur. Aber die CSS-Klassen sind komplett verschieden: Desktop verwendet btn btn-secondary btn-danger, Phone md-btn md-btn-tonal md-btn-outlined. Das sind zwei verschiedene Design-Systeme — ein gemeinsamer Komponente in shared/ würde entweder hässliche Prop-Übergaben für jede CSS-Klasse brauchen oder das Design-System kaputt machen. Akzeptabler Klon.
LightButton.vue — desktop vs. phone
Der Klon liegt im <script setup>: gleiche Imports, gleiche Props, gleicher Composable-Aufruf (useLightControl), gleiche Click-Outside-Logik. Die Templates sind aber verschieden — Desktop nutzt ein <div> mit eigenen CSS-Klassen, Phone einen <button> im Material-3-Stil mit LED-Indikator. Gleiche Logik, verschiedene Darstellung — selbe Begründung wie ConfirmDialog. Akzeptabler Klon.
Alarm-Overlay in App.vue — desktop vs. phone
Sieben Zeilen identisches HTML für das Timer-Alarm-Modal — aber dazu noch ~20 Zeilen fast identisches CSS, das jscpd nicht anzeigt weil es CSS separat zählt. Die Logik (useTimerAlarm) ist bereits in shared/ ausgelagert, nur Template und Styling sind dupliziert. Technisch wäre ein shared/components/AlarmOverlay.vue möglich — scheitert aber daran, dass Desktop und Phone komplett verschiedene CSS-Variablennamen verwenden (--surface vs. --md-surface, --accent vs. --md-primary). Ein gemeinsamer Komponente bräuchte entweder Style-Props oder eine Angleichung der Variablennamen quer über beide Design-Systeme.
Das ist eigentlich ein Designfehler: ohne einheitliche CSS-Variablen-Konventionen bleibt jeder Klon zwischen den Apps aufwändig zu bereinigen, egal wie gut die TypeScript-Logik strukturiert ist. Für jetzt akzeptiert — aber mit dem Hinweis dass das der eigentliche Hebel wäre.
Einheit-<select> — phone/ViewEinkauf vs. desktop/VorratItemDialog vs. desktop/ViewKatalog
Drei <option>-Elemente (100g, 100ml, Stück) tauchen in drei Dateien auf. Die umgebenden Formularelemente sehen in jedem Design-System anders aus — eine Shared-Komponente nur für die Optionen wäre übertrieben. Akzeptabler Klon.
Interne ViewLager-Ähnlichkeiten nach dem Refactoring
Nach dem Extrahieren von LagerItemRow gibt es noch Ähnlichkeiten zwischen Kategorie- und Abteilungs-Header-Blöcken. Die sind aber strukturell unterschiedlich (Kategorie hat Emoji + Add-Button, Abteilung hat weder noch) — kein sinnvoller weiterer Merge.
Die vollständige Checkliste
Nach jeder größeren Änderung laufen vier Checks:
Typen:
npx tsc -p tablet/app/tsconfig.json --noEmit
npx tsc -p phone/app/tsconfig.json --noEmit
npx tsc -p desktop/app/tsconfig.json --noEmit
Architektur-Regeln:
npm run lint
Vue-Imports:
python tools/check_vue_imports.py
Duplikate (bei größeren Refactorings):
npx jscpd --min-lines 6 --min-tokens 50 \
--ignore "**/node_modules/**,**/dist/**,**/_junk/**" \
desktop/app/src phone/app/src tablet/app/src shared
Die ersten drei laufen nach jeder Änderung. jscpd läuft seltener — eher nach einem größeren Feature oder wenn man das Gefühl hat, ähnlichen Code mehrfach geschrieben zu haben.

Wie geht es weiter?
Die Checks sichern die Code-Qualität — aber sie setzen voraus dass die App überhaupt mit Home Assistant kommunizieren kann. Bisher war das nur subscribeEntities und callService direkt über den WebSocket.
Teil 5 zeigt die andere Hälfte der Infrastruktur: eine HA Custom Component die eine saubere bidirektionale API über denselben WebSocket bereitstellt, wie Rezepte, Katalog und Vorrat in JSON-Dateien auf dem HA-Server gespeichert werden — und wie der KI-Flow von der App über einen lokalen Proxy bis zu Claude läuft.
Fazit
tsc --noEmitfindet Typfehler — aber nicht fehlende Vue-Imports- ESLint prüft Architektur-Regeln und findet ungenutzte Variablen — aber auch nicht fehlende Vue-Imports
- Ein kleines Python-Script schließt genau diese Lücke: alle
.vue-Dateien auf vollständigeimport { ... } from 'vue'-Statements prüfen jscpdfindet Template-Klone die TypeScript und ESLint gar nicht sehen — im ersten Lauf 17 Klone, davon mehrere behebbar- Beim Refactoring der Klone tauchen oft echte Bugs auf — Stellen wo die Kopie vom Original auseinandergedriftet ist
- Kein Tool prüft alles — aber zusammen decken sie die wichtigsten Lücken ab
