diff --git a/BRAIN.md b/BRAIN.md index f55b90c..9cfe421 100644 --- a/BRAIN.md +++ b/BRAIN.md @@ -1,190 +1,142 @@ -# 🧠 BRAIN.md — MindSift Internal Dev Cheat Sheet -> **Zweck:** Rein internes, ungeschöntes Nachschlagewerk für uns Entwickler. Keine Marketing-Texte, nur knallharte Fakten, Farbwerte, State-Machines, Namenskonventionen und Quick-Commands. +# 🧠 BRAIN.md — MindSift Internal Dev Cheat Sheet & Architecture Log +> **Zweck:** Zentrales, technisch präzises Nachschlagewerk für Entwicklung, Design-System, Datenbank-Architektur, State-Machines und aktuellen Umsetzungsstand. Wird bei jeder Änderung aktuell gehalten. --- -## 🎨 1. Design-Tokens & Template-Aesthetic (Copy & Paste) +## 📋 0. Aktueller Projektplan & Umsetzungs-Status + +### Erledigte Meilensteine +- [x] **CI/CD & Multi-Stage Docker Build (`Dockerfile`, `.gitea/workflows/build-and-publish.yaml`):** + - Alle Frontend-Checks (`tsc --noEmit`, `vite build`) und Backend-Tests (`go test -v ./...`) laufen direkt im Multi-Stage Container-Build ohne doppelte Vorab-Steps. + - Push auf `dev` baut und überschreibt `git.dsit.me/sandrodach/mindsift:dev`. +- [x] **Monochromes, eckiges Desktop-Design & Zentrale CSS-Steuerung (`frontend/src/index.css`, `frontend/tailwind.config.js`):** + - Zentrale CSS-Variablen (`--bg-app`, `--bg-panel`, `--accent-primary`, `--border-color`, `--radius-card: 0px`) in `frontend/src/index.css`. + - Eckige Tabs, eckige Panels/Karten (`0px` Radius), nur die Schnell-Eingabe-Blase oben im Header und Profil-Avatare sind rund (`rounded-full`). + - Untere doppelte Navigationsleiste entfernt; Schnell-Notiz-Blase (`InstantCaptureBar`) sitzt ganz oben im Header über den Tabs. + - Workspace links als eckiger, monochromer Datei-/Ordner-Baum (File Explorer Tree mit Unterordnern). +- [x] **Quick Dump Notes + Triage Swipen + Direkt-Zuordnung (`InstantCaptureBar.tsx`, `TriageDeck.tsx`, `server.go`):** + - **Quick Dump (Standard):** Text oben eingeben + `Enter` wirft den Gedanken sofort als Quick Dump (`status: 'inbox'`) ins Triage-Deck. + - **Direkt-Zuordnung bei der Eingabe:** Über das Auswahl-Menü (`Quick Dump ▾`) in der Eingabeleiste kann ein Eintrag alternativ direkt als **Aufgabe** (`target: 'task'`), direkt auf eine **Geteilte Liste** (`target: 'list_item'`) oder direkt als **Workspace-Notiz** (`target: 'document'`) gespeichert werden. + - **Triage-Entscheidung & Warteschlange:** Im Triage-Deck per Swipe/Klick **Wegwerfen** (`←` / `H`), **Später** (`Leertaste` – ans Ende schieben), **Auf Liste**, **Als Notiz** (`D`), **Mit Termin** (`T`) oder **Als Aufgabe** (`→` / `L`). Inklusive Inline-Textbearbeitung und Warteschlangen-Liste unter der aktiven Karte. +- [x] **Root-Cause Fixes für Browser-Hänger & Cross-Browser-Kompatibilität (Chrome & Firefox):** + - **SQLite Connection-Pool Deadlock behoben (`internal/storage/sqlite.go`):** Vorher war `db.SetMaxOpenConns(1)` aktiv, während `GetAllListsForUser`, `GetListsForSpace` und `GetSpacesForUser` innerhalb von `for rows.Next()` (bei offenem Cursor!) Unterabfragen (`s.GetListItems`, `s.GetSpaceMembers`) aufriefen. Sobald eine Liste existierte, blockierte die einzige DB-Verbindung dauerhaft und alle nachfolgenden Requests (`POST /api/v1/inbox` etc.) hingen bis zum 15-Minuten-TCP-Timeout. Jetzt werden alle `rows` strikt vor jeder Unterabfrage geschlossen (`rows.Close()`), WAL-Pragmas + `busy_timeout(10000)` per DSN übergeben und `MaxOpenConns(8)` gesetzt. + - **Stale JWT User-Auflösung (`internal/api/server.go`):** Wenn nach einem Container-Neustart eine neue SQLite-DB initialisiert wird, löst `authMiddleware` alte Browser-Tokens automatisch auf `EnsureDefaultUser()` (mit `sync.Mutex` gegen Race-Conditions) auf. + - **Service-Worker & Cache-Busting (`frontend/public/sw.js`, `frontend/index.html`, `internal/api/server.go`):** Kein Caching veralteter Bundles in Firefox/Chrome; `Cache-Control: no-store` auf allen `/api/*`-Routen und `index.html`. + - **Optimistisches Input-Clearing & Request-Timeout (`frontend/src/lib/api.ts`, `InstantCaptureBar.tsx`):** Eingabefeld leert sich sofort (<10ms) bei `Enter`, alle Fetch-Calls haben einen 8-Sekunden-`AbortController`-Timeout. + +--- + +## 🎨 1. Design-System & CSS-Architektur (`frontend/src/index.css`) + +Alle Farben, Abstände und Radien werden zentral in [`frontend/src/index.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/index.css) über CSS-Variablen gesteuert: -### Farbpalette (Deep Dark Theme & Squircles) ```css -/* Backgrounds (Canvas & Surfaces) */ ---bg-canvas: #16171a; /* Deep Charcoal Canvas */ ---bg-surface: #222428; /* Card & Capsule Surface */ ---bg-elevated: #282a2f; /* Elevated Container / Hover */ ---bg-field: #1a1b1e; /* Input Field Fill */ +:root { + /* Monochromer Hintergrund (Dunkles Schiefer/Anthrazit) */ + --bg-app: #121316; + --bg-panel: #1a1c20; + --bg-panel-elevated: #22252a; + --bg-panel-hover: #282c33; -/* Borders & Dividers */ ---border-subtle: rgba(255, 255, 255, 0.08); /* 1px Hairline Kante */ ---border-focus: rgba(29, 140, 248, 0.50); /* Electric Blue Hairline */ + /* Einzige Akzentfarbe + hellere/gedimmte Variante */ + --accent-primary: #2563eb; + --accent-hover: #3b82f6; + --accent-dim: rgba(37, 99, 235, 0.14); + --accent-border: rgba(59, 130, 246, 0.45); -/* Akzente & Status */ ---color-primary: #1d8cf8; /* Electric Blue Primary */ ---color-on-pri: #ffffff; /* Text on Primary */ ---color-success: #22c55e; /* Vivid Green (Kept / Check / Done) */ ---color-danger: #ef4444; /* Vibrant Red (Discard / Delete) */ ---color-warning: #f97316; /* Vivid Orange (High Priority) */ ---color-purple: #a855f7; /* Accent Purple */ + /* Kanten & Linien */ + --border-color: rgba(255, 255, 255, 0.09); + --border-subtle: rgba(255, 255, 255, 0.05); -/* Typografie */ ---text-primary: #ffffff; /* High Emphasis */ ---text-secondary: #d1d5db; /* Medium Emphasis */ ---text-muted: #9ca3af; /* Low Emphasis / Subtle */ -``` - -### UI-Regeln & Radien -* **Keine grellen Farbverläufe:** Niemals Blau-zu-Lila-Gradienten wie billige AI-Templates. Strikte neutrale Anthrazit-Flächen mit satten Einzelfarben (`#1d8cf8`, `#22c55e`, `#f97316`). -* **Squircles für Icons:** `w-10 h-10` oder `w-9 h-9` mit `rounded-xl` in soliden Farben. -* **Pill-Buttons:** `rounded-full` (`border-radius: 9999px`) mit reinweißem Text auf `#1d8cf8`. -* **Karten:** `rounded-2xl` (`16px`) oder `rounded-3xl` (`24px`), `bg-[#222428]`, feine Hairline-Border `1px solid rgba(255, 255, 255, 0.08)`. -* **Fokus/Active Outline:** `border border-[#22c55e]/70` mit dezentem Glow für aktive Elemente. -* **Floating Bottom Dock:** Feste Daumenleiste mit zentriertem runden `+` Button in `#1d8cf8`. -* **Gesten-Physik (Framer Motion):** - * Swipe-Schwellenwert: `dragConstraints={{ left: -150, right: 150 }}` - * Spring-Config: `{ type: "spring", stiffness: 350, damping: 25 }` - * Swipe-Winkel-Rotation: `rotate = x * 0.1` - ---- - -## ⚙️ 2. State-Machine: Gedanken-Lifecycle - -``` - [ INSTANT CAPTURE ] - │ - ▼ - status: 'inbox' - │ - ┌───────────┴───────────┐ - │ (Swipe Left) │ (Swipe Right) - ▼ ▼ - status: 'discarded' status: 'kept' - (Soft Delete) │ - ┌───────┴───────┐ - │ (Convert) │ (Direct Task) - ▼ ▼ - status: 'converted' due_date != null - ├── target: 'task' - ├── target: 'document' - └── target: 'list_item' -``` - -### Gültige Status-Enums -* `inbox` – Frisch eingekippt, wartet auf Triage. -* `discarded` – Nach links gewischt (bleibt für 30 Tage im Papierkorb, dann Hard Delete). -* `kept` – Nach rechts gewischt, bleibt als To-Do oder Gedanke archiviert. -* `converted` – In vollwertiges Dokument, Aufgabe oder Listen-Item transformiert. - ---- - -## 📡 3. WebSocket Realtime Protocol (Family Lists) - -Verbindungs-URL: `wss:///api/v1/ws/spaces/:spaceId` - -### Nachrichten-Format (JSON) -```typescript -interface WSMessage { - event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'USER_PRESENCE'; - space_id: string; - list_id: string; - payload: T; - timestamp: string; // ISO 8601 + /* Eckiges Layout (0px für Karten/Panels, minimal für Inputs) */ + --radius-card: 0px; + --radius-btn: 2px; + --radius-input: 2px; } ``` -### Schnelle Event-Referenz -* `ITEM_TOGGLED`: Wenn jemand die Hafermilch abhakt: - `{ "item_id": "itm_123", "is_completed": true, "completed_by": "usr_sandro" }` -* `Optimistic UI`: Frontend schlägt das Item sofort lokal durch und rollt nur bei WebSocket-Fehler zurück. +### Zentrale Komponenten-Klassen +- `.panel-surface` / `.panel-elevated`: Eckige Container mit `1px solid var(--border-color)`. +- `.btn-action`: Monochromer, kompakter Button (`border-radius: var(--radius-btn)`). +- `.btn-primary`: Akzent-Button auf `--accent-primary`. +- `.input-field`: Minimal gerundetes (`2px`) Eingabefeld für Eigenschaften/Optionen. +- `.tab-btn` / `.tab-btn-active`: Eckige, monochrome Header-Tabs. +- `.badge-accent`: Kompaktes Akzent-Badge für Zähler und Tags. +- **Ausnahme für Rundungen (`rounded-full`):** Nur die Schnell-Eingabe-Blase (`InstantCaptureBar`) im Header, der Profil-Avatar und Toggle-Switches sind rund. --- -## 🗂️ 4. Ordnerstruktur & Code-Konventionen +## ⚙️ 2. State-Machine: Quick Dump, Direkt-Zuordnung & Triage ``` -mindsift/ -├── cmd/ -│ └── server/ # Go Main Entrypoint (bindet embed.FS ein) -├── internal/ -│ ├── api/ # HTTP Chi Handler, Routing & Middleware -│ ├── domain/ # Entities, Interfaces & Domain Logic -│ ├── storage/ # SQLite / Postgres Adapter (sqlc generiert) -│ ├── ws/ # WebSocket Hub & Raumverwaltung -│ └── webhook/ # Smarthome Ingestion Logic -├── migrations/ # Reine .sql Migrationsdateien -├── frontend/ -│ ├── src/ -│ │ ├── components/ # UI Primitives (Button, Pill, Input, Modal) -│ │ ├── features/ -│ │ │ ├── capture/ # Instant-Input & Voice Recording -│ │ │ ├── triage/ # Framer Motion Swipe Deck -│ │ │ ├── workspace/ # Tiptap Block Editor -│ │ │ └── lists/ # Realtime Shopping / Todo Lists -│ │ ├── lib/ # WS Client, API Client, Sound/Haptics -│ │ └── index.css # Tailwind v4 & Design-Tokens / CSS Variablen -├── .gitea/workflows/ # Gitea Actions CI/CD -├── Dockerfile # Multi-Stage Build (< 35 MB) -├── docker-compose.yml -├── README.md # Öffentliche Doku & Vision -└── BRAIN.md # Dieses Dokument (Dev Cheat Sheet) + [ HEADER QUICK-CAPTURE BAR ] + │ + ┌───────────────────────┼───────────────────────┐ + │ Standard (Enter) │ Direkt-Option gewählt │ + ▼ ▼ ▼ + status: 'inbox' status: 'converted' status: 'converted' + (Quick Dump Deck) ├── target: 'task' ├── target: 'list_item' + │ └── target: 'document' └── Broadcast via WS + │ + ├──► Swipe Links / "Wegwerfen" (H / ←) ──► status: 'discarded' + ├──► "Später" (Leertaste) ──► ans Ende der lokalen Queue + ├──► Swipe Rechts / "Als Aufgabe" (L/→)──► status: 'converted' (type: 'task') + ├──► "Mit Termin" (T) ──► status: 'converted' (type: 'task', dueDate) + ├──► "Als Notiz" (D) ──► status: 'converted' (type: 'document' + Doc angelegt) + └──► "Auf Liste" ──► status: 'converted' (type: 'list_item' + Item angelegt) ``` -### Namens- & ID-Konventionen -* **Entity IDs:** Präfix-basierte Strings für Lesbarkeit beim Debuggen: - * `usr_` = User (`usr_9k2m1a`) - * `th_` = Thought (`th_4x8z19`) - * `doc_` = Document (`doc_81na72`) - * `lst_` = List (`lst_33fa09`) - * `itm_` = List Item (`itm_77bb41`) -* **Datenbank:** `snake_case` (z. B. `is_completed`, `due_date`) -* **JSON API:** `camelCase` (z. B. `isCompleted`, `dueDate`) -* **Timestamps:** Immer UTC ISO-8601 (`2026-10-24T14:20:00Z`) +### API-Endpunkt für Capture (`POST /api/v1/inbox`) +Akzeptiert neben `content`, `priority`, `source`, `dueDate` auch direkt: +- `target`: `'inbox'` (Standard Quick Dump) | `'task'` | `'list_item'` | `'document'` +- `targetListId`: ID der Zielliste (wenn `target === 'list_item'`) --- -## ⌨️ 5. Power-User Tastatur-Shortcuts (Desktop) +## 🛡️ 3. Kritische Backend-Regeln (SQLite & HTTP) + +1. **NIEMALS verschachtelte Queries bei offenem `sql.Rows`-Cursor ausführen:** + - In [`internal/storage/sqlite.go`](file:///home/sandro/Dokumente/git/MindSift/internal/storage/sqlite.go) muss jede Schleife `for rows.Next()` zuerst alle Zeilen in ein Slice einlesen und sofort `_ = rows.Close()` aufrufen, **bevor** Folgeabfragen (`GetListItems`, `GetSpaceMembers`, `QueryRow` etc.) ausgeführt werden. +2. **SQLite DSN-Pragmas:** + - Jede Verbindung im `sql.DB`-Pool erhält über den DSN automatisch: + `?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)` +3. **Thread-Safe Default User (`EnsureDefaultUser`):** + - Durch `s.initMu.Lock()` abgesichert, damit parallele Browser-Requests beim ersten Laden niemals in einen `UNIQUE constraint`-Konflikt laufen. +4. **Kein Browser-Caching auf API & `index.html`:** + - `writeJSON` und der SPA-Fallback setzen `Cache-Control: no-store, no-cache, must-revalidate`, damit Chrome und Firefox nach jedem Container-Update sofort den aktuellen Stand zeigen. + +--- + +## 📡 4. WebSocket Realtime Protocol (Geteilte Listen) + +Verbindungs-URL: `ws(s):///api/v1/ws/spaces/:spaceId?token=` + +### Events +- `ITEM_CREATED` (auch ausgelöst, wenn ein Gedanke per Direkt-Zuordnung oder Triage auf eine Liste geschoben wird) +- `ITEM_TOGGLED` +- `ITEM_DELETED` +- `LIST_CREATED` / `LIST_DELETED` / `LIST_CLEARED` +- `USER_PRESENCE` + +--- + +## ⌨️ 5. Tastatur-Shortcuts (Desktop) | Taste | Kontext | Aktion | | :--- | :--- | :--- | -| `C` oder `/` | Überall | Fokus sofort auf das Capture-Eingabefeld | -| `H` oder `←` | Triage | Karte nach links wischen (Verwerfen) | -| `L` oder `→` | Triage | Karte nach rechts wischen (Behalten) | -| `T` | Triage | Sofort in Aufgabe mit Fälligkeitsdatum verwandeln | -| `D` | Triage | In Workspace-Dokument umwandeln | -| `Esc` | Modal/Capture | Abbrechen / Eingabe leeren | +| `C` oder `/` | Überall | Fokus sofort oben in die Quick-Dump-Eingabeleiste | +| `Enter` | Capture-Leiste | Sofort speichern (Quick Dump oder gewähltes Direkt-Ziel) | +| `Esc` | Capture-Leiste | Eingabe leeren & Dropdown schließen | +| `H` oder `←` | Triage-Deck | Aktiven Quick Dump wegwerfen (`discard`) | +| `L` oder `→` | Triage-Deck | Aktiven Quick Dump sofort zur Aufgabe machen (`task`) | +| `T` | Triage-Deck | Datums-Auswahl für Termin-Aufgabe öffnen | +| `D` | Triage-Deck | Aktiven Quick Dump sofort in Workspace-Notiz umwandeln | +| `Leertaste` | Triage-Deck | Karte überspringen („Später" – ans Ende des Stapels schieben) | --- -## ⚡ 6. Cheat Sheet: Lokale Entwickler-Befehle - -```bash -# 1. Frontend Dev Server (Hot Reload) -cd frontend && npm run dev - -# 2. Go Backend starten (Hot Reload via air oder go run) -go run ./cmd/server - -# 3. Datenbank-Migrationen lokal ausführen -go run ./cmd/migrate -direction=up - -# 4. Lokalen Multi-Stage Docker-Build testen -docker build -t mindsift:dev . -docker run -p 8080:8080 -v $(pwd)/dev-data:/data -e JWT_SECRET="dev-secret-12345" mindsift:dev - -# 5. SQLite DB direkt per CLI inspizieren -sqlite3 ./dev-data/mindsift.db ".tables" "SELECT * FROM thoughts LIMIT 5;" -``` - ---- - -## 🚀 7. Gitea CI/CD & Docker Tagging-Strategie - -* **Aktiver Arbeits-Branch:** `dev` (hier entwickeln und pushen wir immer) -* **Registry:** `git.dsit.me/sandrodach/mindsift` -* **Entwickler-Image:** `git.dsit.me/sandrodach/mindsift:dev` (wird bei jedem Push auf `dev` überschrieben – kein Zumüllen der Registry!) -* **Gitea Secret für Registry-Login:** `secrets.PUSH_TOKEN` -* **API-Endpunkt zum Auslesen der Pipeline:** `https://git.dsit.me/api/v1/repos/sandrodach/MindSift/actions/tasks` -* **Branch- & Release-Regeln ([`.gitea/workflows/build-and-publish.yaml`](file:///home/sandro/Dokumente/git/MindSift/.gitea/workflows/build-and-publish.yaml)):** - * **Push auf `dev`:** Führt Linter, TypeScript-Checks (`tsc --noEmit`), Unit-Tests aus **und** überschreibt das Image `:dev`. - * **Push auf `main`:** Ignoriert (löst keinen Runner aus). - * **Git-Tag / Release (z. B. `v1.0`, `1.1`, `latest`):** Wird manuell bei echten Releases erstellt und baut das entsprechende Release-Image. -* **Cross-Compile Speedup:** `Dockerfile` nutzt `FROM --platform=$BUILDPLATFORM` für Node & Go und kompiliert `amd64` + `arm64` nativ über `GOOS=${TARGETOS} GOARCH=${TARGETARCH}` ohne langsames QEMU. +## 🚀 6. Gitea CI/CD & Docker Workflow +- **Arbeits-Branch:** `dev` (`origin/dev`) +- **Ziel-Image:** `git.dsit.me/sandrodach/mindsift:dev` +- **Build-Prinzip:** Ein einziger Multi-Stage-Build ([`Dockerfile`](file:///home/sandro/Dokumente/git/MindSift/Dockerfile)), der Frontend-Typecheck, Vite-Build und Go-Unit-Tests (`go test -v ./...` inkl. [`internal/api/server_test.go`](file:///home/sandro/Dokumente/git/MindSift/internal/api/server_test.go)) direkt beim Image-Bau ausführt und bei Erfolg sofort pusht. diff --git a/frontend/index.html b/frontend/index.html index 4661231..b020e97 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,10 +15,13 @@
diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 99f1384..86a830a 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,33 +1,18 @@ -const CACHE_NAME = 'mindsift-pwa-v1'; -const ASSETS = ['/', '/index.html', '/manifest.json']; - -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)) - ); +// Network-first / cache-clearing service worker to ensure fresh builds across all browsers +self.addEventListener('install', () => { self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((keys) => - Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) - ) + caches + .keys() + .then((keys) => Promise.all(keys.map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) ); - self.clients.claim(); }); -self.addEventListener('fetch', (event) => { - if (event.request.method !== 'GET' || event.request.url.includes('/api/')) { - return; - } - event.respondWith( - fetch(event.request) - .then((res) => { - const clone = res.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); - return res; - }) - .catch(() => caches.match(event.request).then((cached) => cached || caches.match('/'))) - ); +self.addEventListener('fetch', () => { + // Always use native browser network fetch so API calls and fresh Vite bundles are never intercepted by stale caches + return; }); diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index afc89cd..c0098bc 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -148,40 +148,46 @@ export const InstantCaptureBar: React.FC = ({ const effectiveTarget = overrideTarget || target; const effectiveListId = overrideListId || targetListId || (lists.length > 0 ? lists[0].id : ''); + const capturedPriority = priority; + const capturedDueDate = dueDate; + const capturedSource = isListening ? 'voice' : 'manual'; + // Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers + setContent(''); + setPriority('normal'); + setDueDate(''); + setTarget('inbox'); + setShowOptions(false); setIsSubmitting(true); triggerHaptic(15); + const savedMsg = + effectiveTarget === 'task' + ? 'Als Aufgabe!' + : effectiveTarget === 'list_item' + ? 'Auf Liste!' + : effectiveTarget === 'document' + ? 'Als Notiz!' + : 'Im Dump!'; + setJustSavedLabel(savedMsg); + setTimeout(() => setJustSavedLabel(null), 1600); + inputRef.current?.focus(); + try { const created = await api.captureThought( trimmed, - priority, - isListening ? 'voice' : 'manual', - dueDate, + capturedPriority, + capturedSource, + capturedDueDate, { target: effectiveTarget, targetListId: effectiveTarget === 'list_item' ? effectiveListId : '', } ); - setContent(''); - setPriority('normal'); - setDueDate(''); - setTarget('inbox'); - setShowOptions(false); - - const savedMsg = - effectiveTarget === 'task' - ? 'Als Aufgabe!' - : effectiveTarget === 'list_item' - ? 'Auf Liste!' - : effectiveTarget === 'document' - ? 'Als Notiz!' - : 'Im Dump!'; - setJustSavedLabel(savedMsg); - setTimeout(() => setJustSavedLabel(null), 1600); - onCaptured(created, effectiveTarget); - inputRef.current?.focus(); + } catch { + // Restore text if capture unexpectedly failed completely + setContent(trimmed); } finally { setIsSubmitting(false); } @@ -221,6 +227,12 @@ export const InstantCaptureBar: React.FC = ({ type="text" value={content} onChange={(e) => setContent(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + executeCapture(); + } + }} placeholder={ isListening ? 'Ich höre zu…' diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0945e0b..dcc0644 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -40,6 +40,7 @@ async function request(path: string, options: RequestInit = {}, isRetry = fal const token = getToken(); const headers: Record = { 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', ...(options.headers as Record), }; @@ -47,16 +48,27 @@ async function request(path: string, options: RequestInit = {}, isRetry = fal headers['Authorization'] = `Bearer ${token}`; } - const res = await fetch(path, { - ...options, - headers, - }); + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), 8000); + + let res: Response; + try { + res = await fetch(path, { + ...options, + headers, + cache: 'no-store', + signal: controller.signal, + }); + } finally { + window.clearTimeout(timeoutId); + } if (res.status === 401 && !isRetry && !path.includes('/auth/login') && !path.includes('/auth/quickstart')) { try { const qsRes = await fetch('/api/v1/auth/quickstart', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', }); if (qsRes.ok) { const qsData = await qsRes.json(); diff --git a/internal/api/server.go b/internal/api/server.go index 1810825..44302bb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -112,8 +112,19 @@ func (s *Server) Router() http.Handler { path = "index.html" } if _, err := fs.Stat(s.staticFS, path); err != nil { + // Never return index.html for missing JS/CSS assets + if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") { + http.NotFound(w, req) + return + } // SPA client-side routing fallback to index.html req.URL.Path = "/" + path = "index.html" + } + if path == "index.html" || path == "sw.js" || path == "manifest.json" { + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") } fileServer.ServeHTTP(w, req) }) @@ -126,6 +137,7 @@ func (s *Server) Router() http.Handler { func writeJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(data) } diff --git a/internal/api/server_test.go b/internal/api/server_test.go new file mode 100644 index 0000000..951e2e1 --- /dev/null +++ b/internal/api/server_test.go @@ -0,0 +1,186 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "sync" + "testing" + + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/config" + "github.com/sandrodach/mindsift/internal/domain" + "github.com/sandrodach/mindsift/internal/storage" + "github.com/sandrodach/mindsift/internal/ws" +) + +func setupTestServer(t *testing.T) (*Server, *storage.Store, string) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test_mindsift.db") + store, err := storage.NewStore(dbPath) + if err != nil { + t.Fatalf("failed to create test store: %v", err) + } + t.Cleanup(func() { + _ = store.Close() + }) + + user, err := store.EnsureDefaultUser() + if err != nil { + t.Fatalf("failed to ensure default user: %v", err) + } + + cfg := &config.Config{ + Port: "8080", + DBPath: dbPath, + JWTSecret: "test-jwt-secret-key", + AllowRegistration: true, + } + hub := ws.NewHub() + srv := NewServer(cfg, store, hub, nil) + + token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName) + if err != nil { + t.Fatalf("failed to generate token: %v", err) + } + + return srv, store, token +} + +func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) { + srv, _, token := setupTestServer(t) + router := srv.Router() + + endpoints := []string{ + "/api/v1/auth/me", + "/api/v1/triage/deck", + "/api/v1/lists", + "/api/v1/spaces", + "/api/v1/tasks", + "/api/v1/documents", + } + + var wg sync.WaitGroup + for i := 0; i < 3; i++ { + for _, ep := range endpoints { + wg.Add(1) + go func(path string) { + defer wg.Done() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("GET %s returned %d: %s", path, rec.Code, rec.Body.String()) + } + }(ep) + } + } + wg.Wait() +} + +func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) { + srv, store, _ := setupTestServer(t) + router := srv.Router() + + // Generate a stale token from a previous container run (non-existent user ID) + staleToken, err := auth.GenerateToken("test-jwt-secret-key", "usr_stale_from_old_container", "admin@mindsift.local", "Sandro") + if err != nil { + t.Fatalf("failed to generate stale token: %v", err) + } + + // 1. Capture Quick Dump (default target: inbox) using stale token + dumpPayload := map[string]string{ + "content": "Schneller Quick Dump Testgedanke #test", + "priority": "high", + "target": "inbox", + } + body, _ := json.Marshal(dumpPayload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+staleToken) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created for Quick Dump, got %d: %s", rec.Code, rec.Body.String()) + } + + var createdDump domain.Thought + if err := json.Unmarshal(rec.Body.Bytes(), &createdDump); err != nil { + t.Fatalf("failed to decode created dump: %v", err) + } + if createdDump.Status != domain.StatusInbox { + t.Fatalf("expected status 'inbox', got %q", createdDump.Status) + } + + // 2. Capture Direct Task (target: task) + taskPayload := map[string]string{ + "content": "Direkte Aufgabe ohne Triage", + "priority": "urgent", + "target": "task", + "dueDate": "2026-11-01", + } + body, _ = json.Marshal(taskPayload) + req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+staleToken) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created for Direct Task, got %d: %s", rec.Code, rec.Body.String()) + } + var createdTask domain.Thought + _ = json.Unmarshal(rec.Body.Bytes(), &createdTask) + if createdTask.Status != domain.StatusConverted || createdTask.ConvertedType != "task" { + t.Fatalf("expected converted task, got status=%q type=%q", createdTask.Status, createdTask.ConvertedType) + } + + // 3. Capture Direct List Item (target: list_item) + defUser, _ := store.EnsureDefaultUser() + lists, err := store.GetAllListsForUser(defUser.ID) + if err != nil || len(lists) == 0 { + t.Fatalf("expected starter lists, got len=%d err=%v", len(lists), err) + } + targetListID := lists[0].ID + + listPayload := map[string]string{ + "content": "Frische Haferflocken", + "target": "list_item", + "targetListId": targetListID, + } + body, _ = json.Marshal(listPayload) + req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+staleToken) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created for Direct List Item, got %d: %s", rec.Code, rec.Body.String()) + } + var createdListItemThought domain.Thought + _ = json.Unmarshal(rec.Body.Bytes(), &createdListItemThought) + if createdListItemThought.ConvertedType != "list_item" || createdListItemThought.ConvertedRefID == "" { + t.Fatalf("expected converted list_item, got %+v", createdListItemThought) + } + + // 4. Swipe Triage card to Task + triagePayload := map[string]string{ + "action": "keep", + "convertTo": "task", + } + body, _ = json.Marshal(triagePayload) + req = httptest.NewRequest(http.MethodPatch, "/api/v1/triage/"+createdDump.ID, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+staleToken) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK for triage action, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 6830c38..d13756a 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" @@ -15,22 +16,30 @@ import ( ) type Store struct { - db *sql.DB + db *sql.DB + initMu sync.Mutex } func NewStore(dbPath string) (*Store, error) { - dir := filepath.Dir(dbPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return nil, fmt.Errorf("failed to create data directory %s: %w", dir, err) + if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") { + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create data directory %s: %w", dir, err) + } } - db, err := sql.Open("sqlite", dbPath) + dsn := dbPath + if !strings.Contains(dsn, "?") { + dsn += "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)" + } + + db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("failed to open sqlite db: %w", err) } - db.SetMaxOpenConns(1) // SQLite WAL optimal writer serialization in single process - db.SetMaxIdleConns(1) + db.SetMaxOpenConns(8) + db.SetMaxIdleConns(4) store := &Store{db: db} if err := store.migrate(); err != nil { @@ -266,6 +275,9 @@ func (s *Store) SeedStarterContent(user *domain.User) error { // EnsureDefaultUser creates a default user if the database has 0 users so self-hosted users can jump right in func (s *Store) EnsureDefaultUser() (*domain.User, error) { + s.initMu.Lock() + defer s.initMu.Unlock() + count, err := s.CountUsers() if err != nil { return nil, err @@ -280,7 +292,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) { } user, err := s.CreateUser("admin@mindsift.local", hash, "Sandro") if err != nil { - return nil, err + return s.GetFirstUser() } _ = s.SeedStarterContent(user) return user, nil @@ -872,7 +884,6 @@ func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) { if err != nil { return nil, err } - defer rows.Close() spaces := make([]domain.Space, 0) for rows.Next() { @@ -880,11 +891,15 @@ func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) { var personalInt int if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil { sp.IsPersonal = personalInt != 0 - sp.Members, _ = s.GetSpaceMembers(sp.ID) - _ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, sp.ID).Scan(&sp.ListsCount) spaces = append(spaces, sp) } } + _ = rows.Close() + + for i := range spaces { + spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID) + _ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount) + } return spaces, nil } @@ -929,16 +944,19 @@ func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) { if err != nil { return nil, err } - defer rows.Close() lists := make([]domain.SharedList, 0) for rows.Next() { var l domain.SharedList if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil { - l.Items, _ = s.GetListItems(l.ID) lists = append(lists, l) } } + _ = rows.Close() + + for i := range lists { + lists[i].Items, _ = s.GetListItems(lists[i].ID) + } return lists, nil } @@ -954,16 +972,19 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { if err != nil { return nil, err } - defer rows.Close() lists := make([]domain.SharedList, 0) for rows.Next() { var l domain.SharedList if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil { - l.Items, _ = s.GetListItems(l.ID) lists = append(lists, l) } } + _ = rows.Close() + + for i := range lists { + lists[i].Items, _ = s.GetListItems(lists[i].ID) + } return lists, nil } @@ -1183,12 +1204,12 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu ) thoughts := make([]domain.Thought, 0) if err == nil { - defer tRows.Close() for tRows.Next() { if t, err := scanThought(tRows); err == nil { thoughts = append(thoughts, *t) } } + _ = tRows.Close() } dRows, err := s.db.Query( @@ -1198,12 +1219,12 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu ) docs := make([]domain.Document, 0) if err == nil { - defer dRows.Close() for dRows.Next() { if d, err := scanDocument(dRows); err == nil { docs = append(docs, *d) } } + _ = dRows.Close() } return thoughts, docs, nil