--- phase: 18-desktop-client-fertigstellen plan: 03 type: execute wave: 2 depends_on: ["18-01"] files_modified: - apps/web/src/lib/desktop.ts - apps/web/src/lib/desktop.test.ts - apps/web/src/components/desktop/desktop-download-links.tsx - apps/web/src/components/desktop/desktop-download-links.test.tsx - apps/web/src/app/(auth)/login/page.tsx - apps/web/src/app/(portal)/settings/general/desktop/page.tsx - apps/web/src/components/settings/desktop-app-settings.tsx - apps/web/src/components/settings/desktop-app-settings.test.tsx - apps/web/src/components/settings/settings-sidebar.tsx - apps/web/src/messages/de.json - apps/web/src/messages/en.json autonomous: true requirements: [DESK-03] user_setup: [] estimate: tokens: 80000 raw_tokens: 80000 tasks: 2 confidence: low must_haves: truths: - "Auf der Anmeldeseite steht unterhalb des Formulars ein unauffaelliger Link 'Desktop-App herunterladen (Windows)' mit kleinem Linux-Link und Versionsangabe — nur wenn /desktop/latest antwortet (D-12)." - "Unter Einstellungen -> Allgemein -> Desktop-App gibt es eine Seite mit Version, zwei Download-Knoepfen in Primaerfarbe mit Plattform-Symbol, Dateiname und Dateigroesse sowie vier Saetzen in Sie-Form; antwortet die API mit 404, erscheint statt der Knoepfe ein Hinweis (D-12)." - "Jeder Download laeuft ueber die Tessera-API (API_URL + url aus /desktop/latest); Anwender brauchen keinen Gitea-Zugang (D-01, D-10)." - "Alle neuen Texte liegen 1:1 in de.json und en.json vor, deutsche Texte mit echten Umlauten (Projektkonvention)." artifacts: - path: "apps/web/src/lib/desktop.ts" provides: "loadDesktopLatest (memoisiert, still bei Fehler), desktopDownloadUrl, formatFileSize" exports: ["loadDesktopLatest", "desktopDownloadUrl", "formatFileSize"] - path: "apps/web/src/components/desktop/desktop-download-links.tsx" provides: "Link-Block der Anmeldeseite, rendert nichts ohne Daten" exports: ["DesktopDownloadLinks"] - path: "apps/web/src/components/settings/desktop-app-settings.tsx" provides: "Inhalt der Einstellungsseite: Version, Knoepfe, Groesse, Saetze, Hinweis" exports: ["DesktopAppSettings"] - path: "apps/web/src/app/(portal)/settings/general/desktop/page.tsx" provides: "Route /settings/general/desktop" contains: "DesktopAppSettings" - path: "apps/web/src/messages/de.json" provides: "auth.desktopDownload.*, settings.categoryDesktopApp, settings.desktop.*" contains: "desktopDownload" key_links: - from: "apps/web/src/lib/desktop.ts" to: "apps/api/src/desktop/desktop.controller.ts" via: "fetch(`${API_URL}/desktop/latest`) — im Betrieb ueber den Rewrite /api-proxy" pattern: "desktop/latest" - from: "apps/web/src/components/desktop/desktop-download-links.tsx" to: "apps/web/src/lib/desktop.ts" via: "loadDesktopLatest() in useEffect; null blendet den Block aus" pattern: "loadDesktopLatest" - from: "apps/web/src/components/settings/settings-sidebar.tsx" to: "apps/web/src/app/(portal)/settings/general/desktop/page.tsx" via: "Link href=/settings/general/desktop unter 'Allgemein'" pattern: "settings/general/desktop" --- Anwender sehen die Desktop-App in Tessera selbst: ein Link auf der Anmeldeseite und eine eigene Einstellungsseite "Desktop-App" mit Version, Download-Knoepfen fuer Windows und Linux, Dateigroesse und einer kurzen Erklaerung. Beides liest `GET /desktop/latest` aus 18-01 und blendet sich aus, wenn der Server keine Pakete traegt. Purpose: D-12 aus 18-CONTEXT.md (Web-Oberflaeche) und Erfolgskriterium 2. Output: Fetch-Helfer, zwei Komponenten mit Tests, neue Einstellungsroute, Seitenleisteneintrag, Uebersetzungen de/en. Alle Adressen werden aus `API_URL` gebildet (`NEXT_PUBLIC_API_URL`, im Betrieb `/api-proxy`); es wird nirgends eine feste Server- oder Firmenadresse eingetragen. ## Artifacts this phase produces Dieser Plan: `apps/web/src/lib/desktop.ts` (`DesktopPlatform`, `DesktopFileInfo`, `DesktopLatestInfo`, `loadDesktopLatest`, `desktopDownloadUrl`, `formatFileSize`), `desktop.test.ts`, `components/desktop/desktop-download-links.tsx` (`DesktopDownloadLinks`), `desktop-download-links.test.tsx`, `app/(auth)/login/page.tsx` (Einbau), `app/(portal)/settings/general/desktop/page.tsx` (`DesktopSettingsPage`), `components/settings/desktop-app-settings.tsx` (`DesktopAppSettings`), `desktop-app-settings.test.tsx`, `components/settings/settings-sidebar.tsx` (Eintrag), `messages/de.json` und `messages/en.json` (`auth.desktopDownload.*`, `settings.categoryDesktopApp`, `settings.desktop.*`). Gesamtliste der Phase: siehe 18-01-PLAN.md. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md @.planning/phases/18-desktop-client-fertigstellen/18-PATTERNS.md @.planning/phases/18-desktop-client-fertigstellen/18-01-SUMMARY.md @apps/web/src/lib/app-version.ts @apps/web/src/lib/app-version.test.ts @apps/web/src/components/layout/app-version-badge.tsx @apps/web/src/app/(auth)/login/page.tsx @apps/web/src/app/(portal)/settings/general/account/page.tsx @apps/web/src/components/settings/settings-sidebar.tsx @apps/web/src/components/settings/widget-settings-panel.test.tsx Task 1: Fetch-Helfer und der Download-Link auf der Anmeldeseite apps/web/src/lib/desktop.ts, apps/web/src/lib/desktop.test.ts, apps/web/src/components/desktop/desktop-download-links.tsx, apps/web/src/components/desktop/desktop-download-links.test.tsx, apps/web/src/app/(auth)/login/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json apps/web/src/lib/app-version.ts (gesamt — Muster fuer API_URL und memoisiertes Laden), apps/web/src/lib/app-version.test.ts (gesamt — vi.resetModules + dynamischer Import), apps/web/src/components/layout/app-version-badge.tsx (useEffect/useState-Konsum), apps/web/src/app/(auth)/login/page.tsx (Einbaustelle nach dem Formular), apps/web/src/components/settings/widget-settings-panel.test.tsx (Zeilen 1-30, next-intl-Mock mit de.json), apps/web/src/messages/de.json (Namensraum `auth`), .planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Code Example 8) - `loadDesktopLatest()` ruft `${API_URL}/desktop/latest` genau einmal je Modulinstanz auf (zweiter Aufruf liefert dasselbe Promise); `ok=false` und Netzfehler liefern `null`, nichts wird geworfen. - `desktopDownloadUrl(file)` ergibt `${API_URL}${file.url}` (z. B. `http://localhost:3001/desktop/download/windows` in Tests). - `formatFileSize(6123456, 'de')` ergibt `5,8 MB`; `formatFileSize(6123456, 'en')` ergibt `5.8 MB`; `formatFileSize(106461688, 'de')` ergibt `101,5 MB`. - `DesktopDownloadLinks` rendert nichts, solange nichts geladen ist oder `null` kam; mit Daten fuer beide Plattformen erscheinen ein Link "Desktop-App herunterladen (Windows)" (href = Windows-URL, Attribut `download`) und ein Link "Linux-Version" sowie der Text "Version 1.1.0". - Fehlt `files.windows` (Stand nach 18-01, nur Linux gebaut), erscheint genau ein Link mit dem Text "Desktop-App herunterladen (Linux)" und der Versionstext. **`apps/web/src/lib/desktop.ts`** nach dem Vorbild `app-version.ts` (gleicher `API_URL`-Ausdruck mit woertlichem `process.env.NEXT_PUBLIC_API_URL`, deutscher Kopfkommentar mit Verweis auf D-10/D-12 und auf den Rewrite `/api-proxy`). Typen als Spiegel der API (kein Import aus `@tessera/shared`, gleiche Begruendung wie im Kommentar von `app-version.ts`): `DesktopPlatform = 'windows' | 'linux'`, `DesktopFileInfo { name; size; sha256; url }`, `DesktopLatestInfo { version; channel; commit; buildTime; files: Partial> }`. `loadDesktopLatest()` memoisiert wie `loadApiVersion()`, aber **ohne** `credentials: 'include'` (oeffentlicher Endpunkt, Anmeldeseite hat noch kein Cookie). `desktopDownloadUrl(file)` und `formatFileSize(bytes, locale)` (`Intl.NumberFormat(locale, { maximumFractionDigits: 1 })` auf `bytes / 1048576`, Suffix ` MB`). **`desktop.test.ts`** im Stil von `app-version.test.ts` (`importFresh` mit `vi.resetModules`, `vi.stubGlobal('fetch', …)`): Test 1 memoisiert (ein Fetch, zwei gleiche Ergebnisse, Aufruf-URL endet auf `/desktop/latest`, kein `credentials`-Feld in den Optionen); Test 2 still bei `ok=false`; Test 3 still bei Netzfehler; Test 4 `desktopDownloadUrl`; Test 5 die drei `formatFileSize`-Faelle aus `` — Erwartungen von Hand. **`components/desktop/desktop-download-links.tsx`** (`'use client'`, `useTranslations('auth')`, `useLocale()` aus `next-intl`): `useEffect` laedt `loadDesktopLatest()` mit `active`-Schutz wie `AppVersionBadge`; State `DesktopLatestInfo | null`. Rendert `null`, wenn keine Daten oder keine Plattform in `files`. Sonst ein `
` mit: Hauptlink (Windows, falls vorhanden, sonst Linux) als `` mit Text `t('desktopDownload.windows')` bzw. `t('desktopDownload.linux')`; ist Windows vorhanden **und** Linux vorhanden, dahinter ` · ` und ein zweiter Link `t('desktopDownload.linuxShort')`; darunter in `text-xs` `t('desktopDownload.version', { version })`. Keine Fehlermeldung, kein Spinner — der Block ist unauffaellig (D-12). **`desktop-download-links.test.tsx`**: next-intl-Mock nach dem Muster in `widget-settings-panel.test.tsx` (de.json-gestuetzt, zusaetzlich `useLocale: () => 'de'`), `vi.mock('@/lib/desktop', …)` mit steuerbarem `loadDesktopLatest` (echte `desktopDownloadUrl`/`formatFileSize` per `importOriginal` durchreichen). Faelle: (1) `null` -> Container leer (`container.firstChild` ist `null`); (2) beide Plattformen -> zwei Links mit den deutschen Texten aus de.json und hrefs `…/desktop/download/windows` bzw. `…/desktop/download/linux`, Text `Version 1.1.0`; (3) nur Linux -> genau ein Link mit dem Linux-Text. `findBy…` fuer die asynchrone Aufloesung. **Anmeldeseite (`(auth)/login/page.tsx`)**: Import der Komponente; direkt nach dem schliessenden `` innerhalb des `max-w-sm space-y-8`-Blocks `` einfuegen. Sonst nichts aendern. **Uebersetzungen** in `de.json` unter `auth` neuer Block `desktopDownload`: `windows` = "Desktop-App herunterladen (Windows)", `linux` = "Desktop-App herunterladen (Linux)", `linuxShort` = "Linux-Version", `version` = "Version {version}". In `en.json` 1:1: "Download desktop app (Windows)", "Download desktop app (Linux)", "Linux version", "Version {version}". - `pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop` meldet 8 Tests bestanden, 0 fehlgeschlagen. - `grep -v '^\s*//' apps/web/src/lib/desktop.ts | grep -c 'process.env.NEXT_PUBLIC_API_URL'` ergibt 1. - `grep -c 'DesktopDownloadLinks' "apps/web/src/app/(auth)/login/page.tsx"` ergibt 2 (Import und Einbau). - `node -e "const de=require('./apps/web/src/messages/de.json');if(de.auth.desktopDownload.windows!=='Desktop-App herunterladen (Windows)')process.exit(1)"` endet mit 0. - `pnpm --filter @tessera/web type-check` fehlerfrei. cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop && pnpm --filter @tessera/web type-check vitest meldet "failed" oder Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus. Acht Tests gruen, Typpruefung fehlerfrei, die Anmeldeseite baut den Link-Block ein, de/en tragen den Block `auth.desktopDownload`. Task 2: Einstellungsseite "Desktop-App" mit Knoepfen, Groesse und Erklaerung apps/web/src/app/(portal)/settings/general/desktop/page.tsx, apps/web/src/components/settings/desktop-app-settings.tsx, apps/web/src/components/settings/desktop-app-settings.test.tsx, apps/web/src/components/settings/settings-sidebar.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json apps/web/src/app/(portal)/settings/general/account/page.tsx (Seitenhuelle), apps/web/src/components/settings/settings-sidebar.tsx (Eintrag "Konto" unter "Allgemein"), apps/web/src/components/settings/widget-settings-panel.test.tsx (Zeilen 1-30), apps/web/src/app/(auth)/login/page.tsx (Klassen des Primaerknopfs: `rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90`), apps/web/src/lib/desktop.ts (aus Task 1), apps/web/src/messages/de.json (Namensraum `settings`, Block `account`) - Route `/settings/general/desktop` rendert die Ueberschrift "Desktop-App" und die Komponente `DesktopAppSettings`. - Mit Daten fuer beide Plattformen zeigt die Seite "Aktuelle Version: 1.1.0", zwei Knoepfe "Für Windows herunterladen" und "Für Linux herunterladen" (Primaerfarbe, jeweils mit Plattform-Symbol als inline-SVG, `href` aus `desktopDownloadUrl`, Attribut `download`) und darunter je Knopf die Zeile "{Dateiname} · {Groesse}", z. B. "Tessera-Setup-1.1.0.exe · 5,8 MB". - Auf dem Beta-Kanal steht zusaetzlich "Beta-Ausgabe, Stand {commit}". - Vier Saetze in Sie-Form erklaeren, was die App ist, den Erststart mit Server-Adresse, das Verhalten im Infobereich und den Update-Hinweis. - Antwortet die API mit null, erscheinen statt der Knoepfe der Satz "Auf diesem Server sind derzeit keine Desktop-Pakete hinterlegt." und die vier Saetze bleiben stehen. - Die Seitenleiste zeigt unter "Allgemein" den Eintrag "Desktop-App" mit `aria-current="page"` auf der Route. **Seite `app/(portal)/settings/general/desktop/page.tsx`**: exakt die Huelle von `account/page.tsx` (`'use client'`, `useTranslations('settings')`, `

` mit `t('desktop.title')`), Inhalt ``. Kein Anlegen weiterer Layout-Dateien — die Route liegt unter dem bestehenden `settings`-Layout mit Seitenleiste. **Komponente `components/settings/desktop-app-settings.tsx`** (`'use client'`, `useTranslations('settings')`, `useLocale()`): laedt `loadDesktopLatest()` wie in Task 1 (State `undefined` = laedt, `null` = nicht verfuegbar, Objekt = Daten). Aufbau: Absatz mit den vier Saetzen `t('desktop.intro')`, `t('desktop.firstStart')`, `t('desktop.tray')`, `t('desktop.update')` (ein `

` je Satz, `text-sm text-muted-foreground`); dann bei Daten: `

` mit `t('desktop.versionLabel', { version })` und, wenn `channel === 'beta'`, `t('desktop.channelBeta', { commit })`; dann ein `

` mit je Plattform (nur vorhandene, Reihenfolge Windows, Linux) einem Block aus `` im Primaerknopf-Stil der Anmeldeseite (`inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90`) mit inline-SVG-Symbol (Windows: vier abgerundete Felder im 2x2-Raster; Linux: Terminalfenster mit `>_`-Prompt — beide 16x16, `aria-hidden`) und Text `t('desktop.downloadWindows')` bzw. `t('desktop.downloadLinux')`, darunter `

` mit `t('desktop.fileInfo', { name, size: formatFileSize(size, locale) })`. Bei `null`: `

` mit `t('desktop.unavailable')` statt Knoepfen. Waehrend des Ladens nichts unterhalb der Saetze. `data-testid="desktop-download-windows"` und `desktop-download-linux` an den Links. **Seitenleiste `settings-sidebar.tsx`**: im `