HA Widgets — Eigene Home-Assistant-Apps mit Vue 3 (Teil 4: Architektur-Checks und Duplikate)

- Veröffentlicht unter Makerspace von

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.

ESLint-Ausgabe im Terminal mit dem cameraAvailable-Fehler in TokenDialog.vue


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:

  1. <script setup>-Block aus der .vue-Datei extrahieren (Regex auf <script\s[^>]*\bsetup\b[^>]*>)
  2. Alle import { ... } from 'vue'-Statements parsen — type-Imports überspringen
  3. Alle Aufrufe bekannter Vue-Funktionen finden (ref(, watch(, computed< etc.)
  4. 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.

jscpd-Ausgabe im Terminal mit den 17 gefundenen Klonen


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.

jscpd nach dem Refactoring — 5 verbleibende Klone, alle akzeptiert


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 --noEmit findet 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ändige import { ... } from 'vue'-Statements prüfen
  • jscpd findet 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