mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 16:29:43 +02:00
feat(local-first): add local-first architecture with Dexie.js, Go sync server, and Todo pilot
Implement the foundational local-first data layer for ManaCore apps: - New @manacore/local-store package (Dexie.js IndexedDB, sync engine, Svelte 5 reactive queries) - New mana-sync Go service (sync protocol, WebSocket push, field-level LWW conflict resolution) - Todo app migrated as pilot: stores read/write IndexedDB, guest mode with onboarding seed data - PillNavigation: prominent login pill for unauthenticated users - SyncIndicator component showing local/syncing/offline status - GuestWelcomeModal on first visit for Todo app - Removed demo-mode auth_required checks from Todo components (all writes are now local) - CSP fix for local development (localhost:3001, localhost:3050) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ddff8485b
commit
2e4bb9bad7
41 changed files with 4388 additions and 340 deletions
|
|
@ -0,0 +1,297 @@
|
|||
---
|
||||
title: 'Local-First Architektur: Von Login-Wall zu Instant-App mit neuem Tech-Stack'
|
||||
description: 'Architekturplanung für den Umbau aller ManaCore-Apps auf Local-First mit Dexie.js, Go Sync-Server und Hono/Bun statt NestJS. Guest-Mode, Offline-CRUD und Instant UI als Kernziele.'
|
||||
date: 2026-03-26
|
||||
author: 'Till Schneider'
|
||||
category: 'infrastructure'
|
||||
tags:
|
||||
[
|
||||
'architecture',
|
||||
'local-first',
|
||||
'offline',
|
||||
'dexie',
|
||||
'indexeddb',
|
||||
'go',
|
||||
'hono',
|
||||
'bun',
|
||||
'sync',
|
||||
'guest-mode',
|
||||
'pwa',
|
||||
'nestjs-migration',
|
||||
'tech-stack',
|
||||
]
|
||||
featured: true
|
||||
readTime: 15
|
||||
contributors:
|
||||
- name: 'Till Schneider'
|
||||
handle: 'Till-JS'
|
||||
workingHours:
|
||||
start: '2026-03-26T10:00'
|
||||
end: '2026-03-26T16:00'
|
||||
---
|
||||
|
||||
Umfassende Architekturplanung mit dem Ziel, die gesamte ManaCore-Plattform fundamental zu modernisieren:
|
||||
|
||||
- **Local-First statt API-First** — Alle Daten leben primär in IndexedDB (Dexie.js), Server synchronisiert im Hintergrund
|
||||
- **Guest-Mode als Nebeneffekt** — Kein Login-Screen mehr beim ersten Besuch, Nutzer landen direkt in der App
|
||||
- **Backend-Modernisierung** — NestJS wird durch Hono auf Bun ersetzt, neuer Go Sync-Server
|
||||
- **Instant UI** — Reads < 1ms aus IndexedDB statt 200-500ms API-Roundtrip
|
||||
- **Echtes Offline** — Voller CRUD ohne Netz, nicht nur gecachte Reads
|
||||
|
||||
---
|
||||
|
||||
## Ausgangslage: Das Login-Wall-Problem
|
||||
|
||||
Wenn ein neuer Nutzer `todo.mana.how` besucht, passiert aktuell Folgendes:
|
||||
|
||||
1. SvelteKit App lädt
|
||||
2. `AuthGate` Komponente prüft Auth-Status
|
||||
3. Kein Token vorhanden → Redirect zu `/login`
|
||||
4. Nutzer sieht Login-Screen, ohne die App je gesehen zu haben
|
||||
|
||||
Das ist ein klassisches Conversion-Problem: Nutzer müssen sich committen (Account erstellen), bevor sie den Wert der App erleben. Die Lösung scheint einfach — `allowGuest=true` setzen und fertig. Aber die Frage "wo kommen dann die Daten her?" führt zu einer viel grundlegenderen Architekturentscheidung.
|
||||
|
||||
---
|
||||
|
||||
## Analyse: Was ist eigentlich "Offline"?
|
||||
|
||||
Aktuell haben alle 20 Web-Apps PWA-Support via `@manacore/shared-pwa` mit Workbox-Caching:
|
||||
|
||||
| Schicht | Status | Was es kann |
|
||||
|---------|--------|------------|
|
||||
| Service Worker + Precaching | Alle 20 Apps | App-Shell (HTML/CSS/JS) offline laden |
|
||||
| API Caching (NetworkFirst) | Alle Apps | Zuletzt geladene API-Responses aus Cache lesen |
|
||||
| Offline-Seite | Alle Apps | Fallback-UI wenn komplett offline |
|
||||
| Offline-Writes | Nur SkillTree | Daten in IndexedDB erstellen/bearbeiten |
|
||||
|
||||
Das bedeutet: **Read-Only Offline** ist bereits da. Aber kein einziger Write funktioniert offline (ausser SkillTree mit dediziertem IndexedDB-Store).
|
||||
|
||||
### Das Spektrum der Offline-Architekturen
|
||||
|
||||
```
|
||||
Level 0 Level 1 Level 2 Level 3 Level 4
|
||||
Online-Only → Cache-Read → Offline-Capable → Offline-First → Local-First
|
||||
(AKTUELL) (ZIEL)
|
||||
```
|
||||
|
||||
- **Level 1 (aktuell):** Gecachte API-Responses lesbar, kein Write
|
||||
- **Level 2:** Writes werden gequeued, sync bei Reconnect
|
||||
- **Level 3:** App arbeitet immer gegen lokale DB, Server synced im Hintergrund
|
||||
- **Level 4:** Volle CRDT-basierte Sync, Real-time Collab
|
||||
|
||||
**Entscheidung: Level 3 (Offline-First)** — Guest-Mode wird Nebeneffekt, Instant UI, voller Offline-CRUD. Level 4 (CRDT) ist Overengineering ohne Real-time-Collab-Requirement.
|
||||
|
||||
---
|
||||
|
||||
## Die neue Architektur
|
||||
|
||||
### Client: Dexie.js als lokale Datenbank
|
||||
|
||||
Jede App bekommt eine lokale IndexedDB-Datenbank via Dexie.js. Statt API-Calls liest und schreibt die App gegen lokale Daten:
|
||||
|
||||
```
|
||||
VORHER: Component → API Call → 200ms warten → State Update → Render
|
||||
NACHHER: Component → IndexedDB Read (< 1ms) → Render → Sync im Hintergrund
|
||||
```
|
||||
|
||||
**Warum Dexie.js:**
|
||||
- `liveQuery()` — reaktive Queries, die automatisch UI updaten (perfekt für Svelte 5 runes)
|
||||
- 15KB Bundle (vs. 500KB für SQLite WASM)
|
||||
- Kein OPFS nötig, breite Browser-Unterstützung
|
||||
- Bewährte Library mit grosser Community
|
||||
|
||||
**Neues Shared Package: `@manacore/local-store`** kapselt die gesamte Local-First-Logik:
|
||||
- `createLocalCollection<T>()` — Factory für typisierte, reaktive Collections
|
||||
- Sync Engine mit Field-Level Last-Write-Wins Conflict Resolution
|
||||
- WebSocket-Client für Push-Updates von anderen Geraeten
|
||||
- Offline-Queue für pending Writes
|
||||
|
||||
### Sync-Server: Go
|
||||
|
||||
Ein zentraler Sync-Server (`mana-sync`) in Go, der das Sync-Protokoll fuer alle Apps implementiert:
|
||||
|
||||
**Warum Go:**
|
||||
- 100.000+ gleichzeitige WebSocket-Verbindungen (Goroutines, ~4KB/Connection)
|
||||
- P99 Latency < 1ms fuer Sync-Operationen
|
||||
- Single Binary Deployment (~15MB)
|
||||
- Perfekt fuer genau diese Art von I/O-bound Service
|
||||
|
||||
**Was der Sync-Server macht:**
|
||||
1. Empfaengt Changesets von Clients (Batch von Aenderungen)
|
||||
2. Wendet Field-Level LWW an bei Konflikten
|
||||
3. Persistiert in PostgreSQL
|
||||
4. Gibt Server-Delta zurueck (was der Client noch nicht hat)
|
||||
5. Pushed via WebSocket an andere Geraete des Users
|
||||
|
||||
**Was er NICHT macht:** Business-Logik, Auth, File-Uploads, AI-Calls. Das bleibt in den App-Backends.
|
||||
|
||||
### App-Backends: Von NestJS zu Hono auf Bun
|
||||
|
||||
Die groesste Aenderung: NestJS wird durch Hono ersetzt, laeuft auf Bun statt Node.
|
||||
|
||||
**Warum weg von NestJS:**
|
||||
- Enterprise-Java-Philosophie (Angular-Style DI, Decorators, Module, Guards, DTOs...)
|
||||
- ~50MB node_modules pro Backend
|
||||
- 2-5 Sekunden Cold Start
|
||||
- Viel Boilerplate fuer einfache Aufgaben
|
||||
|
||||
**Warum Hono:**
|
||||
- 14KB Bundle
|
||||
- < 50ms Cold Start auf Bun (< 6ms fuer Bun selbst)
|
||||
- Web-Standard API (fetch, Request/Response)
|
||||
- RPC Type Safety: Client importiert Server-Typen ohne Codegen
|
||||
- Laeuft ueberall: Bun, Node, Deno, Cloudflare Workers
|
||||
|
||||
**Warum Bun:**
|
||||
- Nativer TypeScript-Support (kein Compiler noetig)
|
||||
- ~150K req/s (3x Node)
|
||||
- Built-in SQLite, Test Runner, Package Manager
|
||||
- Startup ~6ms statt ~300ms (Node)
|
||||
|
||||
### Was sich am Backend aendert
|
||||
|
||||
Durch Local-First fallen ~220 von ~260 Endpoints weg (alle CRUD). Was bleibt:
|
||||
|
||||
| Kategorie | Beispiele | Bleibt weil |
|
||||
|-----------|-----------|-------------|
|
||||
| **External APIs** | Replicate (Bild-Gen), OpenRouter (LLM), Google OAuth | API Keys duerfen nicht zum Client |
|
||||
| **Webhooks** | Stripe Payment, Replicate Completion | Server muss Callbacks empfangen |
|
||||
| **Server-Compute** | RRULE Expansion (DoS-Schutz), Spaced Repetition | Zu teuer/riskant fuer Client |
|
||||
| **File Uploads** | Bilder, vCards, CSVs → MinIO/S3 | Braucht Server-seitigen Storage-Zugang |
|
||||
| **Credits** | Balance pruefen, Consumption tracken | Authoritative Quelle, Betrugsschutz |
|
||||
| **Admin** | User-Uebersicht, Metriken | Zugriff auf alle Daten |
|
||||
|
||||
### Auth: Better Auth bleibt, NestJS geht
|
||||
|
||||
Better Auth hat einen nativen Hono-Adapter. Die Migration ist hauptsaechlich ein HTTP-Layer-Wechsel, die Auth-Logik (EdDSA JWT, SSO, Organizations, Credits) bleibt identisch.
|
||||
|
||||
### AI Services: Bleiben Python
|
||||
|
||||
`mana-llm`, `mana-stt`, `mana-tts`, `mana-image-gen` — Python ist das richtige Oekosystem fuer ML/AI. Keine Aenderung.
|
||||
|
||||
---
|
||||
|
||||
## Conflict Resolution: Field-Level Last-Write-Wins
|
||||
|
||||
Das Herzstück des Sync-Protokolls. Statt "ganzes Objekt gewinnt" wird pro Feld entschieden:
|
||||
|
||||
```
|
||||
Gerät A (offline): Task "Einkaufen" → priority: "high" (14:01:03)
|
||||
Gerät B (offline): Task "Einkaufen" → title: "Einkaufen Rewe" (14:01:05)
|
||||
|
||||
Sync-Ergebnis:
|
||||
title: "Einkaufen Rewe" (B war neuer für dieses Feld)
|
||||
priority: "high" (A war einziger Editor für dieses Feld)
|
||||
→ Kein Datenverlust
|
||||
```
|
||||
|
||||
Jede Tabelle bekommt ein `field_timestamps` JSONB-Feld das den letzten Aenderungszeitpunkt pro Feld speichert.
|
||||
|
||||
---
|
||||
|
||||
## Guest-Mode: Kein Sonderfall mehr
|
||||
|
||||
Mit Local-First ist Guest-Mode kein Feature sondern der Default-Zustand:
|
||||
|
||||
```
|
||||
Guest: IndexedDB ←→ UI (Sync Engine aus)
|
||||
Eingeloggt: IndexedDB ←→ UI ←→ Sync ←→ Server (Sync Engine an)
|
||||
```
|
||||
|
||||
Bei Login passiert:
|
||||
1. User meldet sich an
|
||||
2. Sync Engine startet
|
||||
3. Lokale Daten bekommen `userId`
|
||||
4. Alles wird zum Server gepusht
|
||||
5. Server-Daten (von anderen Geraeten) werden gepullt
|
||||
|
||||
Kein separater Migrations-Endpoint, kein Sonderfall im Store-Code.
|
||||
|
||||
### Onboarding-Seed pro App
|
||||
|
||||
Jede App definiert Seed-Daten die bei erstem Besuch in IndexedDB geladen werden:
|
||||
|
||||
- **Todo:** Beispiel-Projekt "Erste Schritte" mit erklaerenden Tasks
|
||||
- **Contacts:** Beispiel-Kontakt mit allen Feldern ausgefuellt
|
||||
- **Calendar:** Beispiel-Termine fuer diese Woche
|
||||
- **Chat:** Willkommensnachricht mit Erklaerung der Features
|
||||
|
||||
---
|
||||
|
||||
## Performance-Vergleich
|
||||
|
||||
| Metrik | Aktuell (NestJS/Node) | Ziel (Go + Hono/Bun) |
|
||||
|--------|----------------------|----------------------|
|
||||
| Task erstellen | 200-500ms (API) | < 5ms (lokal) |
|
||||
| Seitenwechsel | Loading-Spinner + API | Instant (IndexedDB) |
|
||||
| Backend Memory/Service | ~150MB | ~15MB (Go) / ~40MB (Bun) |
|
||||
| Cold Start | 2-5s | ~6ms (Bun) / ~50ms (Go) |
|
||||
| Concurrent WebSockets | ~5.000 | ~100.000 (Go) |
|
||||
| Total Docker Image Size | ~3GB (6 NestJS) | ~250MB (1 Go + 3-4 Hono) |
|
||||
| CRUD Endpoints | ~260 | ~40 + 1 Sync-Protokoll |
|
||||
|
||||
---
|
||||
|
||||
## Migrationsplan (5 Phasen)
|
||||
|
||||
### Phase 1: Foundation (2-3 Wochen)
|
||||
- `@manacore/local-store` Package bauen
|
||||
- `mana-sync` Go Service bauen
|
||||
- Todo als Pilot umbauen
|
||||
|
||||
### Phase 2: Todo komplett (2-3 Wochen)
|
||||
- Todo NestJS → Hono/Bun
|
||||
- Guest-Mode + Onboarding-Seed
|
||||
- PillNav Login-Button
|
||||
|
||||
### Phase 3: Alle Apps (4-6 Wochen)
|
||||
- Reihenfolge: Zitare → Calendar → Clock → ManaDeck → Contacts → Chat → Picture → Presi
|
||||
- Pro App: Collections definieren, Stores umbauen, NestJS → Hono
|
||||
|
||||
### Phase 4: Auth-Migration (2 Wochen)
|
||||
- mana-core-auth: NestJS → Hono/Bun mit Better Auth Hono-Adapter
|
||||
|
||||
### Phase 5: Cleanup (1-2 Wochen)
|
||||
- NestJS Dependencies entfernen
|
||||
- Shared Packages migrieren (shared-nestjs-auth → shared-hono-auth)
|
||||
- Docker-Images auf Bun Base umstellen
|
||||
- CI/CD anpassen
|
||||
|
||||
---
|
||||
|
||||
## Technologie-Entscheidungen
|
||||
|
||||
| Entscheidung | Gewählt | Alternativen betrachtet | Begründung |
|
||||
|---|---|---|---|
|
||||
| Lokale DB | Dexie.js | SQLite WASM, cr-sqlite | 15KB vs 500KB, liveQuery Reaktivität, breiter Support |
|
||||
| Sync-Server | Go | Rust, Elixir, Node | Performance + Einfachheit, perfekt für I/O-bound WebSocket Service |
|
||||
| App-Backend | Hono + Bun | Fastify, ElysiaJS, Express | RPC Type Safety, Web-Standard API, Multi-Runtime |
|
||||
| Conflict Strategy | Field-Level LWW | Volles CRDT (Automerge/Y.js) | Löst 99% der Konflikte, CRDT nur nötig bei Real-time Collab |
|
||||
| Runtime | Bun | Node, Deno | Nativer TS, 3x Performance, schnellster Startup |
|
||||
| Auth | Better Auth (bleibt) | Lucia, Auth.js | Bereits integriert, Hono-Adapter vorhanden |
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Aspekt | Änderung |
|
||||
|--------|----------|
|
||||
| **Datenmodell** | API-First → Local-First (IndexedDB + Sync) |
|
||||
| **Backend-Framework** | NestJS → Hono auf Bun |
|
||||
| **Sync-Server** | Neu: Go Service (mana-sync) |
|
||||
| **Runtime** | Node.js → Bun |
|
||||
| **Guest-Mode** | Separater Code → Nebeneffekt der Architektur |
|
||||
| **Offline** | Read-Only Cache → Voller CRUD |
|
||||
| **UI-Geschwindigkeit** | API-abhängig → Instant (lokal) |
|
||||
| **AI Services** | Python → Python (keine Änderung) |
|
||||
| **Auth** | Better Auth bleibt, HTTP-Layer wechselt |
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. `@manacore/local-store` Package initialisieren
|
||||
2. Go-Projekt `mana-sync` aufsetzen
|
||||
3. Todo-App als Pilot: Stores auf Dexie.js umbauen
|
||||
4. Sync-Protokoll zwischen Client und Go-Server implementieren
|
||||
5. Guest-Seed und PillNav Login-Button für Todo
|
||||
|
||||
Detaillierter Plan: `.claude/plans/local-first-architecture-migration.md`
|
||||
|
|
@ -39,6 +39,7 @@
|
|||
"vitest": "^4.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-api-client": "workspace:*",
|
||||
"@manacore/shared-app-onboarding": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
|
|||
});
|
||||
|
||||
setSecurityHeaders(response, {
|
||||
connectSrc: [PUBLIC_MANA_CORE_AUTH_URL_CLIENT, PUBLIC_BACKEND_URL_CLIENT],
|
||||
connectSrc: [
|
||||
PUBLIC_MANA_CORE_AUTH_URL_CLIENT || 'http://localhost:3001',
|
||||
PUBLIC_BACKEND_URL_CLIENT || 'http://localhost:3018',
|
||||
'http://localhost:3050', // mana-sync server
|
||||
],
|
||||
});
|
||||
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -60,19 +60,13 @@
|
|||
isLoading = true;
|
||||
|
||||
try {
|
||||
const result = await tasksStore.createTask({
|
||||
await tasksStore.createTask({
|
||||
title,
|
||||
projectId: selectedProjectId,
|
||||
dueDate: selectedDate.toISOString(),
|
||||
priority: selectedPriority,
|
||||
});
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset form
|
||||
inputValue = '';
|
||||
selectedDate = new Date();
|
||||
|
|
|
|||
164
apps/todo/apps/web/src/lib/components/SyncIndicator.svelte
Normal file
164
apps/todo/apps/web/src/lib/components/SyncIndicator.svelte
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<!--
|
||||
SyncIndicator — Shows sync status as a small pill in the layout.
|
||||
|
||||
- Guest (no sync): "Lokal" with info icon
|
||||
- Synced: green dot
|
||||
- Syncing: animated spinner
|
||||
- Offline: orange dot + "Offline"
|
||||
- Pending: count of pending changes
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { todoStore } from '$lib/data/local-store';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { SyncStatus } from '@manacore/local-store';
|
||||
|
||||
let status = $state<SyncStatus>('idle');
|
||||
let pendingCount = $state(0);
|
||||
let isGuest = $derived(!authStore.isAuthenticated);
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let pendingInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const engine = todoStore.syncEngine;
|
||||
if (engine) {
|
||||
status = engine.status;
|
||||
unsubscribe = engine.onStatusChange((s) => {
|
||||
status = s;
|
||||
});
|
||||
|
||||
const updatePending = async () => {
|
||||
pendingCount = await engine.getPendingCount();
|
||||
};
|
||||
updatePending();
|
||||
pendingInterval = setInterval(updatePending, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe?.();
|
||||
if (pendingInterval) clearInterval(pendingInterval);
|
||||
});
|
||||
|
||||
let label = $derived.by(() => {
|
||||
if (isGuest) return 'Lokal';
|
||||
switch (status) {
|
||||
case 'syncing':
|
||||
return 'Sync...';
|
||||
case 'synced':
|
||||
return pendingCount > 0 ? `${pendingCount} ausstehend` : '';
|
||||
case 'offline':
|
||||
return 'Offline';
|
||||
case 'error':
|
||||
return 'Sync-Fehler';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
let dotClass = $derived.by(() => {
|
||||
if (isGuest) return 'dot-local';
|
||||
switch (status) {
|
||||
case 'syncing':
|
||||
return 'dot-syncing';
|
||||
case 'synced':
|
||||
return pendingCount > 0 ? 'dot-pending' : 'dot-synced';
|
||||
case 'offline':
|
||||
return 'dot-offline';
|
||||
case 'error':
|
||||
return 'dot-error';
|
||||
default:
|
||||
return 'dot-idle';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if label || !isGuest}
|
||||
<div
|
||||
class="sync-indicator"
|
||||
title={isGuest ? 'Daten werden nur in diesem Browser gespeichert' : `Sync: ${status}`}
|
||||
>
|
||||
<span class="dot {dotClass}"></span>
|
||||
{#if label}
|
||||
<span class="label">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.sync-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:global(.light) .sync-indicator,
|
||||
:global(:root:not(.dark)) .sync-indicator {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-synced {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 4px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.dot-syncing {
|
||||
background: #3b82f6;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot-pending {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.dot-offline {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.dot-error {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.dot-local {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
.dot-idle {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -212,26 +212,15 @@
|
|||
}
|
||||
|
||||
async function handleToggleComplete(task: Task) {
|
||||
let result;
|
||||
if (task.isCompleted) {
|
||||
result = await tasksStore.uncompleteTask(task.id);
|
||||
await tasksStore.uncompleteTask(task.id);
|
||||
} else {
|
||||
result = await tasksStore.completeTask(task.id);
|
||||
}
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
await tasksStore.completeTask(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(taskId: string) {
|
||||
const result = await tasksStore.deleteTask(taskId);
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
}
|
||||
await tasksStore.deleteTask(taskId);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -79,12 +79,7 @@
|
|||
// Get projectId from current board if available
|
||||
const currentBoard = kanbanStore.currentBoard;
|
||||
const taskProjectId = currentBoard?.projectId ?? projectId;
|
||||
const result = await kanbanStore.createTaskInColumn(columnId, title, taskProjectId ?? undefined);
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
}
|
||||
await kanbanStore.createTaskInColumn(columnId, title, taskProjectId ?? undefined);
|
||||
}
|
||||
|
||||
async function handleTaskMove(taskId: string, toColumnId: string, order: number) {
|
||||
|
|
|
|||
|
|
@ -62,16 +62,10 @@
|
|||
}
|
||||
|
||||
async function handleToggleComplete(task: Task) {
|
||||
let result;
|
||||
if (task.isCompleted) {
|
||||
result = await tasksStore.uncompleteTask(task.id);
|
||||
await tasksStore.uncompleteTask(task.id);
|
||||
} else {
|
||||
result = await tasksStore.completeTask(task.id);
|
||||
}
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
await tasksStore.completeTask(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,12 +85,7 @@
|
|||
if (data.metadata !== undefined) updateData.metadata = data.metadata;
|
||||
if (data.labels !== undefined) updateData.labelIds = data.labels?.map((l) => l.id);
|
||||
|
||||
const result = await tasksStore.updateTask(task.id, updateData);
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
}
|
||||
await tasksStore.updateTask(task.id, updateData);
|
||||
}
|
||||
|
||||
async function handleDeleteTask(task: Task) {
|
||||
|
|
|
|||
130
apps/todo/apps/web/src/lib/data/guest-seed.ts
Normal file
130
apps/todo/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Guest seed data for the Todo app.
|
||||
*
|
||||
* These records are loaded into IndexedDB when a new guest visits the app.
|
||||
* They serve as onboarding content that teaches the user how the app works.
|
||||
*/
|
||||
|
||||
import type { LocalTask, LocalProject, LocalLabel } from './local-store';
|
||||
|
||||
const ONBOARDING_PROJECT_ID = 'onboarding-project';
|
||||
const PERSONAL_PROJECT_ID = 'personal-project';
|
||||
|
||||
export const guestProjects: LocalProject[] = [
|
||||
{
|
||||
id: ONBOARDING_PROJECT_ID,
|
||||
name: 'Erste Schritte',
|
||||
color: '#3b82f6',
|
||||
icon: 'sparkle',
|
||||
order: 0,
|
||||
isArchived: false,
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
id: PERSONAL_PROJECT_ID,
|
||||
name: 'Persönlich',
|
||||
color: '#10b981',
|
||||
icon: 'home',
|
||||
order: 1,
|
||||
isArchived: false,
|
||||
isDefault: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const guestLabels: LocalLabel[] = [
|
||||
{
|
||||
id: 'label-important',
|
||||
name: 'Wichtig',
|
||||
color: '#ef4444',
|
||||
},
|
||||
{
|
||||
id: 'label-idea',
|
||||
name: 'Idee',
|
||||
color: '#f59e0b',
|
||||
},
|
||||
];
|
||||
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const nextWeek = new Date(now);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
|
||||
export const guestTasks: LocalTask[] = [
|
||||
// ─── Onboarding Tasks ───────────────────────────────────
|
||||
{
|
||||
id: 'onboard-1',
|
||||
title: 'Willkommen bei Todo! Tippe hier, um diese Aufgabe zu bearbeiten ✏️',
|
||||
description:
|
||||
'Du kannst Titel, Beschreibung, Priorität und Fälligkeitsdatum ändern. Probiere es aus!',
|
||||
projectId: ONBOARDING_PROJECT_ID,
|
||||
priority: 'medium',
|
||||
isCompleted: false,
|
||||
order: 0,
|
||||
subtasks: [
|
||||
{ id: 'sub-1', title: 'Titel bearbeiten', isCompleted: false, order: 0 },
|
||||
{ id: 'sub-2', title: 'Beschreibung hinzufügen', isCompleted: false, order: 1 },
|
||||
{ id: 'sub-3', title: 'Priorität ändern', isCompleted: false, order: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'onboard-2',
|
||||
title: 'Klicke den Kreis links, um diese Aufgabe abzuschließen ✓',
|
||||
projectId: ONBOARDING_PROJECT_ID,
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'onboard-3',
|
||||
title: 'Erstelle eine neue Aufgabe mit dem + Button oben',
|
||||
projectId: ONBOARDING_PROJECT_ID,
|
||||
priority: 'medium',
|
||||
isCompleted: false,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'onboard-4',
|
||||
title: 'Wechsle zur Kanban-Ansicht über die Navigation',
|
||||
projectId: ONBOARDING_PROJECT_ID,
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: 'onboard-5',
|
||||
title: 'Melde dich an, um deine Aufgaben auf allen Geräten zu synchronisieren',
|
||||
description:
|
||||
'Ohne Anmeldung werden deine Daten nur in diesem Browser gespeichert. Mit einem Account synchronisieren wir sie automatisch.',
|
||||
projectId: ONBOARDING_PROJECT_ID,
|
||||
priority: 'high',
|
||||
isCompleted: false,
|
||||
order: 4,
|
||||
},
|
||||
|
||||
// ─── Sample Personal Tasks ──────────────────────────────
|
||||
{
|
||||
id: 'sample-1',
|
||||
title: 'Einkaufen gehen',
|
||||
description: 'Milch, Brot, Obst',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
priority: 'medium',
|
||||
isCompleted: false,
|
||||
dueDate: tomorrow.toISOString(),
|
||||
order: 0,
|
||||
subtasks: [
|
||||
{ id: 'shop-1', title: 'Milch', isCompleted: false, order: 0 },
|
||||
{ id: 'shop-2', title: 'Brot', isCompleted: false, order: 1 },
|
||||
{ id: 'shop-3', title: 'Obst', isCompleted: false, order: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sample-2',
|
||||
title: 'Wohnung aufräumen',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
dueDate: nextWeek.toISOString(),
|
||||
order: 1,
|
||||
},
|
||||
];
|
||||
112
apps/todo/apps/web/src/lib/data/local-store.ts
Normal file
112
apps/todo/apps/web/src/lib/data/local-store.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Todo App — Local-First Data Layer
|
||||
*
|
||||
* Defines the IndexedDB database, collections, and guest seed data.
|
||||
* This is the single source of truth for all Todo data.
|
||||
*/
|
||||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
import type { Subtask as SharedSubtask } from '@todo/shared';
|
||||
import { guestProjects, guestTasks, guestLabels } from './guest-seed.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
export interface LocalTask extends BaseRecord {
|
||||
title: string;
|
||||
description?: string;
|
||||
projectId?: string | null;
|
||||
userId?: string;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
isCompleted: boolean;
|
||||
completedAt?: string | null;
|
||||
dueDate?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
scheduledStartTime?: string | null;
|
||||
estimatedDuration?: number | null;
|
||||
order: number;
|
||||
recurrenceRule?: string | null;
|
||||
subtasks?: SharedSubtask[] | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type { SharedSubtask as Subtask };
|
||||
|
||||
export interface LocalProject extends BaseRecord {
|
||||
name: string;
|
||||
color: string;
|
||||
icon?: string | null;
|
||||
userId?: string;
|
||||
order: number;
|
||||
isArchived: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface LocalLabel extends BaseRecord {
|
||||
name: string;
|
||||
color: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface LocalTaskLabel extends BaseRecord {
|
||||
taskId: string;
|
||||
labelId: string;
|
||||
}
|
||||
|
||||
export interface LocalReminder extends BaseRecord {
|
||||
taskId: string;
|
||||
userId?: string;
|
||||
minutesBefore: number;
|
||||
type: 'push' | 'email' | 'both';
|
||||
status: 'pending' | 'sent' | 'failed';
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
||||
export const todoStore = createLocalStore({
|
||||
appId: 'todo',
|
||||
collections: [
|
||||
{
|
||||
name: 'tasks',
|
||||
indexes: [
|
||||
'projectId',
|
||||
'dueDate',
|
||||
'isCompleted',
|
||||
'priority',
|
||||
'order',
|
||||
'[isCompleted+order]',
|
||||
'[projectId+order]',
|
||||
],
|
||||
guestSeed: guestTasks,
|
||||
},
|
||||
{
|
||||
name: 'projects',
|
||||
indexes: ['order', 'isArchived'],
|
||||
guestSeed: guestProjects,
|
||||
},
|
||||
{
|
||||
name: 'labels',
|
||||
indexes: [],
|
||||
guestSeed: guestLabels,
|
||||
},
|
||||
{
|
||||
name: 'taskLabels',
|
||||
indexes: ['taskId', 'labelId'],
|
||||
},
|
||||
{
|
||||
name: 'reminders',
|
||||
indexes: ['taskId'],
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
serverUrl: SYNC_SERVER_URL,
|
||||
},
|
||||
});
|
||||
|
||||
// Typed collection accessors
|
||||
export const taskCollection = todoStore.collection<LocalTask>('tasks');
|
||||
export const projectCollection = todoStore.collection<LocalProject>('projects');
|
||||
export const labelCollection = todoStore.collection<LocalLabel>('labels');
|
||||
export const taskLabelCollection = todoStore.collection<LocalTaskLabel>('taskLabels');
|
||||
export const reminderCollection = todoStore.collection<LocalReminder>('reminders');
|
||||
|
|
@ -5,7 +5,6 @@
|
|||
import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared';
|
||||
import * as kanbanApi from '$lib/api/kanban';
|
||||
import * as tasksApi from '$lib/api/tasks';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
// Board state
|
||||
let boards = $state<KanbanBoard[]>([]);
|
||||
|
|
@ -419,16 +418,10 @@ export const kanbanStore = {
|
|||
|
||||
/**
|
||||
* Create a new task in a specific column
|
||||
* Requires authentication - demo mode shows auth gate
|
||||
*/
|
||||
async createTaskInColumn(columnId: string, title: string, projectId?: string) {
|
||||
error = null;
|
||||
|
||||
// Demo mode: require authentication
|
||||
if (!authStore.isAuthenticated) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the column to get its default status
|
||||
const column = columns.find((c) => c.id === columnId);
|
||||
|
|
|
|||
|
|
@ -1,33 +1,36 @@
|
|||
/**
|
||||
* Projects Store - Manages project state using Svelte 5 runes
|
||||
* Supports both authenticated (cloud) and guest (session) modes
|
||||
* Projects Store — Local-First with Dexie.js
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* Same public API as before so components don't need changes.
|
||||
*/
|
||||
|
||||
import type { Project } from '@todo/shared';
|
||||
import * as projectsApi from '$lib/api/projects';
|
||||
import { authStore } from './auth.svelte';
|
||||
import { projectCollection, type LocalProject } from '$lib/data/local-store';
|
||||
import { TodoEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
// Guest inbox project for unauthenticated users
|
||||
const GUEST_INBOX: Project = {
|
||||
id: 'session-inbox',
|
||||
userId: 'guest',
|
||||
name: 'Inbox',
|
||||
color: '#6b7280',
|
||||
order: 0,
|
||||
isArchived: false,
|
||||
isDefault: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// State
|
||||
let projects = $state<Project[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/** Convert a LocalProject (IndexedDB) to the shared Project type. */
|
||||
function toProject(local: LocalProject): Project {
|
||||
return {
|
||||
id: local.id,
|
||||
userId: local.userId ?? 'guest',
|
||||
name: local.name,
|
||||
color: local.color,
|
||||
icon: local.icon,
|
||||
order: local.order,
|
||||
isArchived: local.isArchived,
|
||||
isDefault: local.isDefault,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const projectsStore = {
|
||||
// Getters
|
||||
get projects() {
|
||||
return projects;
|
||||
},
|
||||
|
|
@ -38,45 +41,30 @@ export const projectsStore = {
|
|||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get inbox project (default project)
|
||||
*/
|
||||
get inboxProject() {
|
||||
return projects.find((p) => p.isDefault);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get non-archived projects sorted by order
|
||||
*/
|
||||
get activeProjects() {
|
||||
return projects.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get archived projects
|
||||
*/
|
||||
get archivedProjects() {
|
||||
return projects.filter((p) => p.isArchived);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all projects from API
|
||||
* In guest mode, returns a default inbox project
|
||||
* Load projects from IndexedDB.
|
||||
*/
|
||||
async fetchProjects() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Guest mode: return local inbox only
|
||||
if (!authStore.isAuthenticated) {
|
||||
projects = [GUEST_INBOX];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
try {
|
||||
projects = await projectsApi.getProjects();
|
||||
const localProjects = await projectCollection.getAll(undefined, {
|
||||
sortBy: 'order',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
projects = localProjects.map(toProject);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch projects';
|
||||
console.error('Failed to fetch projects:', e);
|
||||
|
|
@ -85,29 +73,31 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get project by ID
|
||||
*/
|
||||
getById(id: string): Project | undefined {
|
||||
return projects.find((p) => p.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get project color by ID
|
||||
*/
|
||||
getColor(projectId: string): string {
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
return project?.color || '#6b7280';
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
async createProject(data: { name: string; description?: string; color?: string; icon?: string }) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const newProject = await projectsApi.createProject(data);
|
||||
const newLocal: LocalProject = {
|
||||
id: crypto.randomUUID(),
|
||||
name: data.name,
|
||||
color: data.color ?? '#6b7280',
|
||||
icon: data.icon ?? null,
|
||||
order: projects.length,
|
||||
isArchived: false,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
const inserted = await projectCollection.insert(newLocal);
|
||||
const newProject = toProject(inserted);
|
||||
projects = [...projects, newProject];
|
||||
TodoEvents.projectCreated();
|
||||
return newProject;
|
||||
|
|
@ -120,18 +110,18 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
*/
|
||||
async updateProject(
|
||||
id: string,
|
||||
data: { name?: string; description?: string; color?: string; icon?: string }
|
||||
) {
|
||||
error = null;
|
||||
try {
|
||||
const updatedProject = await projectsApi.updateProject(id, data);
|
||||
projects = projects.map((p) => (p.id === id ? updatedProject : p));
|
||||
return updatedProject;
|
||||
const updated = await projectCollection.update(id, data as Partial<LocalProject>);
|
||||
if (updated) {
|
||||
const updatedProject = toProject(updated);
|
||||
projects = projects.map((p) => (p.id === id ? updatedProject : p));
|
||||
return updatedProject;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update project';
|
||||
console.error('Failed to update project:', e);
|
||||
|
|
@ -139,13 +129,10 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a project
|
||||
*/
|
||||
async deleteProject(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await projectsApi.deleteProject(id);
|
||||
await projectCollection.delete(id);
|
||||
projects = projects.filter((p) => p.id !== id);
|
||||
TodoEvents.projectDeleted();
|
||||
} catch (e) {
|
||||
|
|
@ -155,15 +142,17 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Archive a project
|
||||
*/
|
||||
async archiveProject(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const archivedProject = await projectsApi.archiveProject(id);
|
||||
projects = projects.map((p) => (p.id === id ? archivedProject : p));
|
||||
return archivedProject;
|
||||
const updated = await projectCollection.update(id, {
|
||||
isArchived: true,
|
||||
} as Partial<LocalProject>);
|
||||
if (updated) {
|
||||
const archivedProject = toProject(updated);
|
||||
projects = projects.map((p) => (p.id === id ? archivedProject : p));
|
||||
return archivedProject;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to archive project';
|
||||
console.error('Failed to archive project:', e);
|
||||
|
|
@ -171,18 +160,17 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder projects
|
||||
*/
|
||||
async reorderProjects(projectIds: string[]) {
|
||||
error = null;
|
||||
try {
|
||||
await projectsApi.reorderProjects(projectIds);
|
||||
// Update local order
|
||||
projects = projects.map((p) => {
|
||||
const newOrder = projectIds.indexOf(p.id);
|
||||
return newOrder !== -1 ? { ...p, order: newOrder } : p;
|
||||
});
|
||||
|
||||
for (let i = 0; i < projectIds.length; i++) {
|
||||
await projectCollection.update(projectIds[i], { order: i } as Partial<LocalProject>);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder projects';
|
||||
console.error('Failed to reorder projects:', e);
|
||||
|
|
@ -190,26 +178,17 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state (for logout)
|
||||
*/
|
||||
clear() {
|
||||
projects = [];
|
||||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a project ID is the guest inbox
|
||||
*/
|
||||
isGuestInbox(id: string) {
|
||||
return id === GUEST_INBOX.id;
|
||||
isGuestInbox(_id: string) {
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the guest inbox ID
|
||||
*/
|
||||
get guestInboxId() {
|
||||
return GUEST_INBOX.id;
|
||||
return 'personal-project';
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,52 @@
|
|||
/**
|
||||
* Tasks Store - Manages task state using Svelte 5 runes
|
||||
* Authenticated users: tasks from API
|
||||
* Demo mode: static sample tasks to showcase the app
|
||||
* Tasks Store — Local-First with Dexie.js
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* When authenticated, changes sync to the server in the background.
|
||||
* Same public API as before so components don't need changes.
|
||||
*/
|
||||
|
||||
import type { Task, TaskPriority, TaskStatus, Subtask } from '@todo/shared';
|
||||
import * as tasksApi from '$lib/api/tasks';
|
||||
import { taskCollection, type LocalTask } from '$lib/data/local-store';
|
||||
import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
|
||||
import { authStore } from './auth.svelte';
|
||||
import { generateDemoTasks, isDemoTask } from '$lib/data/demo-tasks';
|
||||
import { TodoEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
// State
|
||||
// State — populated from IndexedDB
|
||||
let tasks = $state<Task[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/** Convert a LocalTask (IndexedDB record) to the shared Task type. */
|
||||
function toTask(local: LocalTask): Task {
|
||||
return {
|
||||
id: local.id,
|
||||
projectId: local.projectId,
|
||||
userId: local.userId ?? 'guest',
|
||||
title: local.title,
|
||||
description: local.description,
|
||||
dueDate: local.dueDate,
|
||||
scheduledDate: local.scheduledDate,
|
||||
scheduledStartTime: local.scheduledStartTime,
|
||||
estimatedDuration: local.estimatedDuration,
|
||||
priority: local.priority,
|
||||
status: local.isCompleted ? 'completed' : 'pending',
|
||||
isCompleted: local.isCompleted,
|
||||
completedAt: local.completedAt,
|
||||
order: local.order,
|
||||
recurrenceRule: local.recurrenceRule,
|
||||
subtasks: local.subtasks ?? null,
|
||||
metadata: local.metadata as Task['metadata'],
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Load tasks from IndexedDB into the reactive state. */
|
||||
async function refreshTasks(filter?: Partial<LocalTask>) {
|
||||
const localTasks = await taskCollection.getAll(filter, { sortBy: 'order', sortDirection: 'asc' });
|
||||
tasks = localTasks.map(toTask);
|
||||
}
|
||||
|
||||
export const tasksStore = {
|
||||
// Getters
|
||||
get tasks() {
|
||||
|
|
@ -28,22 +59,16 @@ export const tasksStore = {
|
|||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get incomplete tasks
|
||||
*/
|
||||
get incompleteTasks() {
|
||||
return tasks.filter((t) => !t.isCompleted);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get completed tasks
|
||||
*/
|
||||
get completedTasks() {
|
||||
return tasks.filter((t) => t.isCompleted);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch tasks with optional filters
|
||||
* Fetch tasks with optional filters — reads from IndexedDB.
|
||||
*/
|
||||
async fetchTasks(
|
||||
query: {
|
||||
|
|
@ -60,7 +85,26 @@ export const tasksStore = {
|
|||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getTasks(query);
|
||||
const filter: Partial<LocalTask> = {};
|
||||
if (query.projectId) filter.projectId = query.projectId;
|
||||
if (query.priority) filter.priority = query.priority;
|
||||
if (query.isCompleted !== undefined) filter.isCompleted = query.isCompleted;
|
||||
|
||||
let localTasks = await taskCollection.getAll(
|
||||
Object.keys(filter).length > 0 ? filter : undefined,
|
||||
{ sortBy: 'order', sortDirection: 'asc' }
|
||||
);
|
||||
|
||||
// Client-side search filter
|
||||
if (query.search) {
|
||||
const search = query.search.toLowerCase();
|
||||
localTasks = localTasks.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(search) || t.description?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
tasks = localTasks.map(toTask);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch tasks';
|
||||
console.error('Failed to fetch tasks:', e);
|
||||
|
|
@ -69,85 +113,82 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch inbox tasks (tasks without project)
|
||||
*/
|
||||
async fetchInboxTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getInboxTasks();
|
||||
const localTasks = await taskCollection.getAll(undefined, {
|
||||
sortBy: 'order',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
// Inbox = tasks without projectId or with null projectId
|
||||
tasks = localTasks.filter((t) => !t.projectId).map(toTask);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch inbox tasks';
|
||||
console.error('Failed to fetch inbox tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch today's tasks
|
||||
*/
|
||||
async fetchTodayTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getTodayTasks();
|
||||
const localTasks = await taskCollection.getAll(
|
||||
{ isCompleted: false },
|
||||
{ sortBy: 'order', sortDirection: 'asc' }
|
||||
);
|
||||
const today = startOfDay(new Date());
|
||||
tasks = localTasks
|
||||
.filter((t) => {
|
||||
if (!t.dueDate) return false;
|
||||
const d = new Date(t.dueDate);
|
||||
return isToday(d) || (isPast(startOfDay(d)) && !isToday(d));
|
||||
})
|
||||
.map(toTask);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch today tasks';
|
||||
console.error('Failed to fetch today tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch upcoming tasks
|
||||
*/
|
||||
async fetchUpcomingTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getUpcomingTasks();
|
||||
const localTasks = await taskCollection.getAll(
|
||||
{ isCompleted: false },
|
||||
{ sortBy: 'dueDate', sortDirection: 'asc' }
|
||||
);
|
||||
const today = startOfDay(new Date());
|
||||
const weekFromNow = addDays(today, 7);
|
||||
tasks = localTasks
|
||||
.filter((t) => {
|
||||
if (!t.dueDate) return false;
|
||||
const d = new Date(t.dueDate);
|
||||
return isFuture(d) && d <= weekFromNow;
|
||||
})
|
||||
.map(toTask);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch upcoming tasks';
|
||||
console.error('Failed to fetch upcoming tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all tasks (incomplete + completed) for unified view
|
||||
* In demo mode, shows static sample tasks
|
||||
*/
|
||||
async fetchAllTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Demo mode: load static demo tasks
|
||||
if (!authStore.isAuthenticated) {
|
||||
tasks = generateDemoTasks();
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
try {
|
||||
// Fetch all tasks without filter - let frontend handle filtering
|
||||
const allTasks = await tasksApi.getTasks({});
|
||||
tasks = allTasks;
|
||||
await refreshTasks();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch all tasks';
|
||||
console.error('Failed to fetch all tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks for a specific project
|
||||
*/
|
||||
getTasksByProject(projectId: string | null): Task[] {
|
||||
if (projectId === null) {
|
||||
return tasks.filter((t) => !t.projectId);
|
||||
|
|
@ -155,16 +196,10 @@ export const tasksStore = {
|
|||
return tasks.filter((t) => t.projectId === projectId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks with a specific label
|
||||
*/
|
||||
getTasksByLabel(labelId: string): Task[] {
|
||||
return tasks.filter((t) => t.labels?.some((l) => l.id === labelId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get overdue tasks
|
||||
*/
|
||||
get overdueTasks(): Task[] {
|
||||
return tasks.filter((t) => {
|
||||
if (!t.dueDate || t.isCompleted) return false;
|
||||
|
|
@ -173,23 +208,16 @@ export const tasksStore = {
|
|||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks due today
|
||||
*/
|
||||
get todayTasks(): Task[] {
|
||||
const today = startOfDay(new Date());
|
||||
return tasks.filter((t) => {
|
||||
if (t.isCompleted) return false;
|
||||
// Include tasks without dueDate as "today" tasks (inbox behavior)
|
||||
if (!t.dueDate) return true;
|
||||
const taskDate = startOfDay(new Date(t.dueDate));
|
||||
return taskDate.getTime() === today.getTime();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks for next 7 days
|
||||
*/
|
||||
get upcomingTasks(): Task[] {
|
||||
const today = startOfDay(new Date());
|
||||
const weekFromNow = addDays(today, 7);
|
||||
|
|
@ -201,8 +229,7 @@ export const tasksStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Create a new task
|
||||
* Requires authentication - demo mode shows auth gate
|
||||
* Create a new task — writes to IndexedDB instantly.
|
||||
*/
|
||||
async createTask(data: {
|
||||
title: string;
|
||||
|
|
@ -215,15 +242,22 @@ export const tasksStore = {
|
|||
recurrenceRule?: string;
|
||||
}) {
|
||||
error = null;
|
||||
|
||||
// Demo mode: require authentication
|
||||
if (!authStore.isAuthenticated) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Authenticated: create via API
|
||||
try {
|
||||
const newTask = await tasksApi.createTask(data);
|
||||
const newLocal: LocalTask = {
|
||||
id: crypto.randomUUID(),
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
projectId: data.projectId ?? null,
|
||||
priority: data.priority ?? 'medium',
|
||||
isCompleted: false,
|
||||
dueDate: data.dueDate ?? null,
|
||||
order: tasks.length,
|
||||
recurrenceRule: data.recurrenceRule ?? null,
|
||||
subtasks: data.subtasks,
|
||||
};
|
||||
|
||||
const inserted = await taskCollection.insert(newLocal);
|
||||
const newTask = toTask(inserted);
|
||||
tasks = [...tasks, newTask];
|
||||
TodoEvents.taskCreated(!!data.dueDate);
|
||||
return newTask;
|
||||
|
|
@ -235,8 +269,7 @@ export const tasksStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Update an existing task
|
||||
* Demo tasks require authentication
|
||||
* Update a task — writes to IndexedDB instantly.
|
||||
*/
|
||||
async updateTask(
|
||||
id: string,
|
||||
|
|
@ -260,17 +293,13 @@ export const tasksStore = {
|
|||
}
|
||||
) {
|
||||
error = null;
|
||||
|
||||
// Demo task: require authentication
|
||||
if (isDemoTask(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Cloud task: update via API
|
||||
try {
|
||||
const updatedTask = await tasksApi.updateTask(id, data);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
const updated = await taskCollection.update(id, data as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const updatedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update task';
|
||||
console.error('Failed to update task:', e);
|
||||
|
|
@ -279,9 +308,7 @@ export const tasksStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Update task optimistically (for drag and drop)
|
||||
* Updates local state immediately, then syncs with server
|
||||
* Demo tasks require authentication
|
||||
* Optimistic update — for drag-and-drop. Instant local write.
|
||||
*/
|
||||
async updateTaskOptimistic(
|
||||
id: string,
|
||||
|
|
@ -290,56 +317,24 @@ export const tasksStore = {
|
|||
isCompleted?: boolean;
|
||||
}
|
||||
) {
|
||||
// Demo task: require authentication
|
||||
if (isDemoTask(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Optimistic update - immediately update local state
|
||||
const originalTask = tasks.find((t) => t.id === id);
|
||||
if (!originalTask) return;
|
||||
|
||||
// Immediate local state update
|
||||
tasks = tasks.map((t) => (t.id === id ? { ...t, ...data } : t));
|
||||
|
||||
try {
|
||||
// Handle completion state change first
|
||||
if (data.isCompleted !== undefined && data.isCompleted !== originalTask.isCompleted) {
|
||||
if (data.isCompleted) {
|
||||
const updatedTask = await tasksApi.completeTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
} else {
|
||||
const updatedTask = await tasksApi.uncompleteTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle due date change
|
||||
if (data.dueDate !== undefined) {
|
||||
const updatedTask = await tasksApi.updateTask(id, { dueDate: data.dueDate });
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
}
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
console.error('Failed to update task:', e);
|
||||
tasks = tasks.map((t) => (t.id === id ? originalTask : t));
|
||||
// Persist to IndexedDB
|
||||
const updateData: Partial<LocalTask> = {};
|
||||
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;
|
||||
if (data.isCompleted !== undefined) {
|
||||
updateData.isCompleted = data.isCompleted;
|
||||
updateData.completedAt = data.isCompleted ? new Date().toISOString() : null;
|
||||
}
|
||||
|
||||
await taskCollection.update(id, updateData);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a task
|
||||
* Demo tasks require authentication
|
||||
*/
|
||||
async deleteTask(id: string) {
|
||||
error = null;
|
||||
|
||||
// Demo task: require authentication
|
||||
if (isDemoTask(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Cloud task: delete via API
|
||||
try {
|
||||
await tasksApi.deleteTask(id);
|
||||
await taskCollection.delete(id);
|
||||
tasks = tasks.filter((t) => t.id !== id);
|
||||
TodoEvents.taskDeleted();
|
||||
} catch (e) {
|
||||
|
|
@ -349,24 +344,19 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark task as complete
|
||||
* Demo tasks require authentication
|
||||
*/
|
||||
async completeTask(id: string) {
|
||||
error = null;
|
||||
|
||||
// Demo task: require authentication
|
||||
if (isDemoTask(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Cloud task: complete via API
|
||||
try {
|
||||
const completedTask = await tasksApi.completeTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? completedTask : t));
|
||||
TodoEvents.taskCompleted();
|
||||
return completedTask;
|
||||
const updated = await taskCollection.update(id, {
|
||||
isCompleted: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
} as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const completedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? completedTask : t));
|
||||
TodoEvents.taskCompleted();
|
||||
return completedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to complete task';
|
||||
console.error('Failed to complete task:', e);
|
||||
|
|
@ -374,24 +364,19 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark task as incomplete
|
||||
* Demo tasks require authentication
|
||||
*/
|
||||
async uncompleteTask(id: string) {
|
||||
error = null;
|
||||
|
||||
// Demo task: require authentication
|
||||
if (isDemoTask(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Cloud task: uncomplete via API
|
||||
try {
|
||||
const uncompletedTask = await tasksApi.uncompleteTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? uncompletedTask : t));
|
||||
TodoEvents.taskUncompleted();
|
||||
return uncompletedTask;
|
||||
const updated = await taskCollection.update(id, {
|
||||
isCompleted: false,
|
||||
completedAt: null,
|
||||
} as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const uncompletedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? uncompletedTask : t));
|
||||
TodoEvents.taskUncompleted();
|
||||
return uncompletedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to uncomplete task';
|
||||
console.error('Failed to uncomplete task:', e);
|
||||
|
|
@ -399,15 +384,15 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Move task to a different project
|
||||
*/
|
||||
async moveTask(id: string, projectId: string | null) {
|
||||
error = null;
|
||||
try {
|
||||
const movedTask = await tasksApi.moveTask(id, projectId);
|
||||
tasks = tasks.map((t) => (t.id === id ? movedTask : t));
|
||||
return movedTask;
|
||||
const updated = await taskCollection.update(id, { projectId } as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const movedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? movedTask : t));
|
||||
return movedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to move task';
|
||||
console.error('Failed to move task:', e);
|
||||
|
|
@ -415,15 +400,19 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update task labels
|
||||
*/
|
||||
async updateLabels(id: string, labelIds: string[]) {
|
||||
// Labels are stored via the central tag system, not locally.
|
||||
// For now, update the task metadata to track label associations.
|
||||
error = null;
|
||||
try {
|
||||
const updatedTask = await tasksApi.updateTaskLabels(id, labelIds);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
const updated = await taskCollection.update(id, {
|
||||
metadata: { labelIds },
|
||||
} as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const updatedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update labels';
|
||||
console.error('Failed to update labels:', e);
|
||||
|
|
@ -431,15 +420,15 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update subtasks
|
||||
*/
|
||||
async updateSubtasks(id: string, subtasks: Subtask[]) {
|
||||
error = null;
|
||||
try {
|
||||
const updatedTask = await tasksApi.updateSubtasks(id, subtasks);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
const updated = await taskCollection.update(id, { subtasks } as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const updatedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update subtasks';
|
||||
console.error('Failed to update subtasks:', e);
|
||||
|
|
@ -447,30 +436,25 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder tasks
|
||||
*/
|
||||
async reorderTasks(taskIds: string[]) {
|
||||
error = null;
|
||||
const previousTasks = [...tasks];
|
||||
try {
|
||||
// Optimistic update - set new order values
|
||||
// Update order in local state immediately
|
||||
tasks = tasks.map((t) => {
|
||||
const newOrder = taskIds.indexOf(t.id);
|
||||
return newOrder !== -1 ? { ...t, order: newOrder } : t;
|
||||
});
|
||||
await tasksApi.reorderTasks(taskIds);
|
||||
|
||||
// Persist each order change to IndexedDB
|
||||
for (let i = 0; i < taskIds.length; i++) {
|
||||
await taskCollection.update(taskIds[i], { order: i } as Partial<LocalTask>);
|
||||
}
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
tasks = previousTasks;
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder tasks';
|
||||
console.error('Failed to reorder tasks:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state (for logout)
|
||||
*/
|
||||
clear() {
|
||||
tasks = [];
|
||||
loading = false;
|
||||
|
|
@ -478,9 +462,9 @@ export const tasksStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Check if a task is a demo task (static sample data)
|
||||
* No longer relevant — all tasks are local and editable.
|
||||
*/
|
||||
isDemoTask(taskId: string) {
|
||||
return isDemoTask(taskId);
|
||||
isDemoTask(_taskId: string) {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,8 +43,20 @@
|
|||
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from '$lib/utils/task-parser';
|
||||
import { todoOnboarding } from '$lib/stores/app-onboarding.svelte';
|
||||
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
||||
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
|
||||
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { TodoEvents } from '@manacore/shared-utils/analytics';
|
||||
import { todoStore } from '$lib/data/local-store';
|
||||
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
|
||||
|
||||
// Guest welcome modal state
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
function initGuestWelcome() {
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('todo')) {
|
||||
showGuestWelcome = true;
|
||||
}
|
||||
}
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('todo');
|
||||
|
|
@ -167,8 +179,8 @@
|
|||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
// User email for user dropdown — empty string for guests so PillNav shows login button
|
||||
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
|
||||
|
||||
// Toggle FilterStrip visibility
|
||||
function handleFilterToggle() {
|
||||
|
|
@ -290,15 +302,30 @@
|
|||
}
|
||||
|
||||
async function handleAuthReady() {
|
||||
// Initialize local-first database (opens IndexedDB, seeds guest data)
|
||||
await todoStore.initialize();
|
||||
|
||||
// If authenticated, start syncing to server
|
||||
if (authStore.isAuthenticated) {
|
||||
todoStore.startSync(() => authStore.getValidToken());
|
||||
}
|
||||
|
||||
// Initialize split-panel from URL/localStorage
|
||||
splitPanel.initialize();
|
||||
|
||||
// Initialize todo settings
|
||||
todoSettings.initialize();
|
||||
|
||||
// Load projects, labels, and user settings
|
||||
// Show guest welcome modal on first visit
|
||||
initGuestWelcome();
|
||||
|
||||
// Load projects from IndexedDB (guest seed or synced data)
|
||||
await projectsStore.fetchProjects();
|
||||
await Promise.all([labelsStore.fetchLabels(), userSettings.load()]);
|
||||
|
||||
// Labels and user settings need auth (central mana-core-auth service)
|
||||
if (authStore.isAuthenticated) {
|
||||
await Promise.all([labelsStore.fetchLabels(), userSettings.load()]);
|
||||
}
|
||||
|
||||
// Redirect to start page if on root and a custom start page is set
|
||||
const currentPath = window.location.pathname;
|
||||
|
|
@ -320,7 +347,7 @@
|
|||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
|
||||
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
|
||||
<SplitPaneContainer>
|
||||
<div class="layout-container">
|
||||
<a
|
||||
|
|
@ -351,7 +378,7 @@
|
|||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={true}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#8b5cf6"
|
||||
|
|
@ -478,6 +505,10 @@
|
|||
class="main-content bg-background"
|
||||
class:immersive={todoSettings.immersiveModeEnabled}
|
||||
>
|
||||
<!-- Sync status indicator (top right) -->
|
||||
<div class="sync-indicator-wrapper">
|
||||
<SyncIndicator />
|
||||
</div>
|
||||
<div
|
||||
class="content-wrapper"
|
||||
class:full-width={$page.url.pathname === '/kanban'}
|
||||
|
|
@ -494,7 +525,19 @@
|
|||
<MiniOnboardingModal store={todoOnboarding} appName="Todo" appEmoji="✅" />
|
||||
{/if}
|
||||
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
<!-- Guest Welcome Modal -->
|
||||
<GuestWelcomeModal
|
||||
appId="todo"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
/>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
{/if}
|
||||
</AuthGate>
|
||||
|
||||
<style>
|
||||
|
|
@ -524,6 +567,13 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.sync-indicator-wrapper {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.75rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 900px;
|
||||
margin-left: auto;
|
||||
|
|
|
|||
|
|
@ -125,31 +125,25 @@
|
|||
const task = tasksStore.tasks.find((t) => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
let result;
|
||||
if (targetDate === 'completed') {
|
||||
// Mark task as completed (optimistic)
|
||||
if (!task.isCompleted) {
|
||||
result = await tasksStore.updateTaskOptimistic(taskId, { isCompleted: true });
|
||||
await tasksStore.updateTaskOptimistic(taskId, { isCompleted: true });
|
||||
}
|
||||
} else if (targetDate === 'overdue') {
|
||||
// Set to yesterday (optimistic)
|
||||
const yesterday = subDays(startOfDay(new Date()), 1);
|
||||
result = await tasksStore.updateTaskOptimistic(taskId, {
|
||||
await tasksStore.updateTaskOptimistic(taskId, {
|
||||
dueDate: yesterday.toISOString(),
|
||||
isCompleted: task.isCompleted ? false : undefined,
|
||||
});
|
||||
} else {
|
||||
// Set to specific date (optimistic)
|
||||
result = await tasksStore.updateTaskOptimistic(taskId, {
|
||||
await tasksStore.updateTaskOptimistic(taskId, {
|
||||
dueDate: targetDate.toISOString(),
|
||||
isCompleted: task.isCompleted ? false : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue