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:
Till JS 2026-03-27 11:17:58 +01:00
parent 4ddff8485b
commit 2e4bb9bad7
41 changed files with 4388 additions and 340 deletions

View file

@ -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:*",

View file

@ -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;

View file

@ -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();

View 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>

View file

@ -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>

View file

@ -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) {

View file

@ -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) {

View 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,
},
];

View 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');

View file

@ -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);

View file

@ -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';
},
};

View file

@ -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;
},
};

View file

@ -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;

View file

@ -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>