feat(manacore/web): refactor todo page into modular components with i18n

Extract the monolithic todo page into reusable components (TaskList, TaskItem,
TaskEditModal, QuickAddTask, TodoToolbar, etc.), add new stores (reminders,
contacts, settings, minimized-pages), composables (useTaskForm), board view
components, skeleton loading states, and a settings page. Add todo i18n strings
for de/en/es/fr.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 11:12:57 +02:00
parent 3b5f77dd86
commit f408d70461
45 changed files with 4169 additions and 376 deletions

View file

@ -187,6 +187,187 @@
"invoices": "Rechnungen",
"no_invoices": "Noch keine Rechnungen vorhanden"
},
"todo": {
"title": "Todo",
"tasks": "Aufgaben",
"completed": "erledigt",
"overdue": "überfällig",
"today": "heute",
"inbox": "Inbox",
"todayView": "Heute",
"upcoming": "Bald fällig",
"completedView": "Erledigt",
"search": "Suche",
"newTask": "Neue Aufgabe",
"quickAddPlaceholder": "z.B. 'Meeting morgen um 14 Uhr !hoch #wichtig'",
"addTask": "Hinzufügen",
"cancel": "Abb.",
"syntaxHelp": "Syntax-Hilfe",
"noTasks": "Keine Aufgaben",
"noTasksInbox": "Inbox ist leer",
"noTasksToday": "Keine Aufgaben für heute",
"noTasksUpcoming": "Keine anstehenden Aufgaben",
"noTasksCompleted": "Noch keine Aufgaben erledigt",
"firstTaskHint": "Erstelle deine erste Aufgabe mit dem + Button oben.",
"edit": "Bearbeiten",
"markDone": "Erledigen",
"reopen": "Wieder öffnen",
"deleteConfirm": "Wirklich löschen?",
"yesDelete": "Ja, löschen",
"save": "Speichern",
"close": "Schließen",
"description": "Beschreibung",
"addDescription": "Beschreibung hinzufügen...",
"subtasks": "Teilaufgaben",
"addSubtask": "Teilaufgabe hinzufügen...",
"status": "Status",
"statusPending": "Offen",
"statusInProgress": "In Arbeit",
"statusCompleted": "Erledigt",
"statusCancelled": "Abgebrochen",
"priority": "Priorität",
"priorityUrgent": "Dringend",
"priorityHigh": "Hoch",
"priorityMedium": "Mittel",
"priorityLow": "Niedrig",
"dueDate": "Fällig",
"time": "Uhrzeit",
"startDate": "Start",
"recurrence": "Wiederholung",
"recurrenceNone": "Keine",
"recurrenceDaily": "Täglich",
"recurrenceWeekly": "Wöchentlich",
"recurrenceMonthly": "Monatlich",
"recurrenceYearly": "Jährlich",
"reminder": "Erinnerung",
"reminderNone": "Keine",
"tags": "Tags",
"storypoints": "Punkte",
"duration": "Dauer",
"fun": "Spaß",
"projects": "Projekte",
"labels": "Labels",
"sort": "Sortierung",
"sortManual": "Manuell",
"sortDueDate": "Fälligkeit",
"sortPriority": "Priorität",
"sortName": "Name",
"sortCreated": "Erstellt",
"showCompleted": "Erledigt",
"boardView": "Board",
"listView": "Liste",
"board": {
"new": "Neues Board",
"edit": "Board bearbeiten",
"create": "Board erstellen",
"name": "Board-Name...",
"groupBy": "Gruppierung",
"layout": "Layout",
"columns": "Spalten",
"addColumn": "Spalte",
"columnName": "Spaltenname...",
"delete": "Löschen",
"noTasks": "Keine Aufgaben",
"groupStatus": "Status",
"groupPriority": "Priorität",
"groupDueDate": "Fälligkeit",
"groupTag": "Tag",
"groupCustom": "Benutzerdefiniert",
"layoutKanban": "Kanban",
"layoutGrid": "Grid",
"layoutFocus": "Fokus"
},
"settings": {
"title": "Todo Einstellungen",
"taskBehavior": "Aufgaben-Verhalten",
"defaultPriority": "Standard-Priorität",
"defaultDueTime": "Standard-Fälligkeit",
"autoArchive": "Auto-Archivierung (Tage)",
"viewDisplay": "Ansicht & Darstellung",
"defaultView": "Standard-Ansicht",
"compactMode": "Kompaktmodus",
"showTaskCounts": "Aufgabenzahl anzeigen",
"showSubtaskProgress": "Teilaufgaben-Fortschritt",
"groupByProject": "Nach Projekt gruppieren",
"kanbanSettings": "Kanban Board",
"cardSize": "Kartengröße",
"cardSizeCompact": "Kompakt",
"cardSizeNormal": "Normal",
"cardSizeLarge": "Groß",
"showLabelsOnCards": "Labels auf Karten",
"wipLimit": "WIP-Limit pro Spalte",
"notifications": "Benachrichtigungen",
"defaultReminder": "Standard-Erinnerung",
"dailyDigest": "Tägliche Zusammenfassung",
"overdueNotifications": "Überfällig-Benachrichtigungen",
"smartDuration": "Smarte Dauer",
"smartDurationEnabled": "Smarte Dauer-Schätzung",
"defaultTaskDuration": "Standard-Dauer (Min.)",
"productivity": "Produktivität",
"focusMode": "Fokus-Modus",
"pomodoro": "Pomodoro",
"dailyGoal": "Tagesziel",
"showStreak": "Streak anzeigen",
"immersiveMode": "Immersiver Modus",
"reset": "Einstellungen zurücksetzen"
},
"syntaxHelpContent": {
"title": "Quick-Add Syntax",
"date": "Datum: heute, morgen, nächsten Montag, 15.12.",
"time": "Uhrzeit: um 14 Uhr, 14:00",
"priority": "Priorität: !hoch, !niedrig, !dringend, !!!",
"labels": "Labels: #wichtig #idee",
"duration": "Dauer: 30min, 2h, 1.5 Stunden",
"recurrence": "Wiederholung: jeden Tag, wöchentlich",
"multi": "Mehrere: Task1, danach Task2",
"subtasks": "Subtasks: Titel: item1, item2, item3"
},
"onboarding": {
"welcome": "Willkommen bei Todo!",
"intro": "Dein persönlicher Aufgabenplaner mit Kanban-Boards, smarter Eingabe und mehr.",
"step1Title": "Natürliche Sprache",
"step1": "Erstelle Aufgaben wie \"Meeting morgen um 14 Uhr !hoch #arbeit\" — Datum, Priorität und Labels werden automatisch erkannt.",
"step2Title": "Kanban-Boards",
"step2": "Organisiere deine Aufgaben visuell mit verschiedenen Board-Ansichten: Status, Priorität oder benutzerdefiniert.",
"step3Title": "Los geht's!",
"step3": "Erstelle deine erste Aufgabe mit dem + Button. Tipps findest du jederzeit über das ? Symbol.",
"next": "Weiter",
"letsGo": "Los geht's",
"getStarted": "Los geht's"
},
"syntaxHelp": {
"title": "Quick-Add Syntax",
"description": "Nutze natürliche Sprache, um Aufgaben schnell zu erstellen. Alle Muster werden automatisch erkannt.",
"date": "Datum",
"dateToday": "Heute",
"dateTomorrow": "Morgen",
"dateNextWeekday": "Nächster Wochentag",
"dateSpecific": "Bestimmtes Datum",
"time": "Uhrzeit",
"priority": "Priorität",
"priorityUrgent": "Dringend",
"priorityHigh": "Hoch",
"priorityLow": "Niedrig",
"labels": "Labels",
"labelsAdd": "Tags hinzufügen",
"duration": "Dauer",
"duration30m": "30 Minuten",
"duration2h": "2 Stunden",
"duration90m": "90 Minuten",
"recurrence": "Wiederholung",
"recurrenceDaily": "Täglich",
"recurrenceWeekly": "Wöchentlich",
"recurrenceMonthly": "Monatlich",
"multiTask": "Mehrere Aufgaben",
"multiTaskChain": "Aufgaben verketten",
"multiTaskSemicolon": "Mit Semikolon trennen",
"subtasks": "Subtasks",
"subtasksColonComma": "Doppelpunkt + Komma",
"exampleTitle": "Beispiel",
"exampleInput": "Meeting morgen um 14 Uhr 1h !hoch #arbeit",
"exampleOutput": "→ \"Meeting\" am nächsten Tag um 14:00, Dauer 1h, Priorität Hoch, Label \"arbeit\""
}
},
"app_slider": {
"title": "Teil des Mana Ökosystems",
"memoro_desc": "KI-gestützte Sprachnotizen",

View file

@ -187,6 +187,187 @@
"invoices": "Invoices",
"no_invoices": "No invoices yet"
},
"todo": {
"title": "Todo",
"tasks": "Tasks",
"completed": "completed",
"overdue": "overdue",
"today": "today",
"inbox": "Inbox",
"todayView": "Today",
"upcoming": "Upcoming",
"completedView": "Completed",
"search": "Search",
"newTask": "New task",
"quickAddPlaceholder": "e.g. 'Meeting tomorrow at 2pm !high #important'",
"addTask": "Add",
"cancel": "Cancel",
"syntaxHelp": "Syntax help",
"noTasks": "No tasks",
"noTasksInbox": "Inbox is empty",
"noTasksToday": "No tasks for today",
"noTasksUpcoming": "No upcoming tasks",
"noTasksCompleted": "No tasks completed yet",
"firstTaskHint": "Create your first task with the + button above.",
"edit": "Edit",
"markDone": "Complete",
"reopen": "Reopen",
"deleteConfirm": "Really delete?",
"yesDelete": "Yes, delete",
"save": "Save",
"close": "Close",
"description": "Description",
"addDescription": "Add description...",
"subtasks": "Subtasks",
"addSubtask": "Add subtask...",
"status": "Status",
"statusPending": "Open",
"statusInProgress": "In Progress",
"statusCompleted": "Completed",
"statusCancelled": "Cancelled",
"priority": "Priority",
"priorityUrgent": "Urgent",
"priorityHigh": "High",
"priorityMedium": "Medium",
"priorityLow": "Low",
"dueDate": "Due",
"time": "Time",
"startDate": "Start",
"recurrence": "Recurrence",
"recurrenceNone": "None",
"recurrenceDaily": "Daily",
"recurrenceWeekly": "Weekly",
"recurrenceMonthly": "Monthly",
"recurrenceYearly": "Yearly",
"reminder": "Reminder",
"reminderNone": "None",
"tags": "Tags",
"storypoints": "Points",
"duration": "Duration",
"fun": "Fun",
"projects": "Projects",
"labels": "Labels",
"sort": "Sort",
"sortManual": "Manual",
"sortDueDate": "Due date",
"sortPriority": "Priority",
"sortName": "Name",
"sortCreated": "Created",
"showCompleted": "Completed",
"boardView": "Board",
"listView": "List",
"board": {
"new": "New board",
"edit": "Edit board",
"create": "Create board",
"name": "Board name...",
"groupBy": "Group by",
"layout": "Layout",
"columns": "Columns",
"addColumn": "Column",
"columnName": "Column name...",
"delete": "Delete",
"noTasks": "No tasks",
"groupStatus": "Status",
"groupPriority": "Priority",
"groupDueDate": "Due date",
"groupTag": "Tag",
"groupCustom": "Custom",
"layoutKanban": "Kanban",
"layoutGrid": "Grid",
"layoutFocus": "Focus"
},
"settings": {
"title": "Todo Settings",
"taskBehavior": "Task Behavior",
"defaultPriority": "Default priority",
"defaultDueTime": "Default due time",
"autoArchive": "Auto-archive (days)",
"viewDisplay": "View & Display",
"defaultView": "Default view",
"compactMode": "Compact mode",
"showTaskCounts": "Show task counts",
"showSubtaskProgress": "Subtask progress",
"groupByProject": "Group by project",
"kanbanSettings": "Kanban Board",
"cardSize": "Card size",
"cardSizeCompact": "Compact",
"cardSizeNormal": "Normal",
"cardSizeLarge": "Large",
"showLabelsOnCards": "Labels on cards",
"wipLimit": "WIP limit per column",
"notifications": "Notifications",
"defaultReminder": "Default reminder",
"dailyDigest": "Daily digest",
"overdueNotifications": "Overdue notifications",
"smartDuration": "Smart Duration",
"smartDurationEnabled": "Smart duration estimation",
"defaultTaskDuration": "Default duration (min)",
"productivity": "Productivity",
"focusMode": "Focus mode",
"pomodoro": "Pomodoro",
"dailyGoal": "Daily goal",
"showStreak": "Show streak",
"immersiveMode": "Immersive mode",
"reset": "Reset settings"
},
"syntaxHelpContent": {
"title": "Quick-Add Syntax",
"date": "Date: today, tomorrow, next Monday, Dec 15",
"time": "Time: at 2pm, 14:00",
"priority": "Priority: !high, !low, !urgent, !!!",
"labels": "Labels: #important #idea",
"duration": "Duration: 30min, 2h, 1.5 hours",
"recurrence": "Recurrence: every day, weekly",
"multi": "Multiple: Task1, then Task2",
"subtasks": "Subtasks: Title: item1, item2, item3"
},
"onboarding": {
"welcome": "Welcome to Todo!",
"intro": "Your personal task planner with Kanban boards, smart input, and more.",
"step1Title": "Natural Language",
"step1": "Create tasks like \"Meeting tomorrow at 2pm !high #work\" — date, priority and labels are detected automatically.",
"step2Title": "Kanban Boards",
"step2": "Organize your tasks visually with different board views: status, priority, or custom.",
"step3Title": "Let's go!",
"step3": "Create your first task with the + button. Tips are always available via the ? icon.",
"next": "Next",
"letsGo": "Let's go",
"getStarted": "Get started"
},
"syntaxHelp": {
"title": "Quick-Add Syntax",
"description": "Use natural language to quickly create tasks. All patterns are automatically detected.",
"date": "Date",
"dateToday": "Today",
"dateTomorrow": "Tomorrow",
"dateNextWeekday": "Next weekday",
"dateSpecific": "Specific date",
"time": "Time",
"priority": "Priority",
"priorityUrgent": "Urgent",
"priorityHigh": "High",
"priorityLow": "Low",
"labels": "Labels",
"labelsAdd": "Add tags",
"duration": "Duration",
"duration30m": "30 minutes",
"duration2h": "2 hours",
"duration90m": "90 minutes",
"recurrence": "Recurrence",
"recurrenceDaily": "Daily",
"recurrenceWeekly": "Weekly",
"recurrenceMonthly": "Monthly",
"multiTask": "Multiple tasks",
"multiTaskChain": "Chain tasks",
"multiTaskSemicolon": "Separate with semicolons",
"subtasks": "Subtasks",
"subtasksColonComma": "Colon + comma",
"exampleTitle": "Example",
"exampleInput": "Meeting tomorrow at 2pm 1h !high #work",
"exampleOutput": "→ \"Meeting\" tomorrow at 14:00, duration 1h, priority High, label \"work\""
}
},
"app_slider": {
"title": "Part of the Mana Ecosystem",
"memoro_desc": "AI-powered voice notes",

View file

@ -6,6 +6,145 @@
"back": "Atrás",
"loading": "Cargando..."
},
"todo": {
"title": "Tareas",
"tasks": "Tareas",
"completed": "completadas",
"overdue": "vencidas",
"today": "hoy",
"inbox": "Bandeja",
"todayView": "Hoy",
"upcoming": "Próximas",
"completedView": "Completadas",
"search": "Buscar",
"newTask": "Nueva tarea",
"quickAddPlaceholder": "ej. 'Reunión mañana a las 14h !alta #importante'",
"addTask": "Añadir",
"cancel": "Canc.",
"syntaxHelp": "Ayuda sintaxis",
"noTasks": "Sin tareas",
"noTasksInbox": "Bandeja vacía",
"noTasksToday": "Sin tareas para hoy",
"noTasksUpcoming": "Sin tareas próximas",
"noTasksCompleted": "Ninguna tarea completada",
"firstTaskHint": "Crea tu primera tarea con el botón + arriba.",
"edit": "Editar",
"markDone": "Completar",
"reopen": "Reabrir",
"deleteConfirm": "¿Eliminar?",
"yesDelete": "Sí, eliminar",
"save": "Guardar",
"close": "Cerrar",
"description": "Descripción",
"addDescription": "Añadir descripción...",
"subtasks": "Subtareas",
"addSubtask": "Añadir subtarea...",
"status": "Estado",
"statusPending": "Abierta",
"statusInProgress": "En curso",
"statusCompleted": "Completada",
"statusCancelled": "Cancelada",
"priority": "Prioridad",
"priorityUrgent": "Urgente",
"priorityHigh": "Alta",
"priorityMedium": "Media",
"priorityLow": "Baja",
"dueDate": "Vence",
"time": "Hora",
"startDate": "Inicio",
"recurrence": "Recurrencia",
"recurrenceNone": "Ninguna",
"recurrenceDaily": "Diario",
"recurrenceWeekly": "Semanal",
"recurrenceMonthly": "Mensual",
"recurrenceYearly": "Anual",
"reminder": "Recordatorio",
"reminderNone": "Ninguno",
"tags": "Tags",
"storypoints": "Puntos",
"duration": "Duración",
"fun": "Diversión",
"projects": "Proyectos",
"labels": "Etiquetas",
"sort": "Ordenar",
"sortManual": "Manual",
"sortDueDate": "Vencimiento",
"sortPriority": "Prioridad",
"sortName": "Nombre",
"sortCreated": "Creación",
"showCompleted": "Completadas",
"boardView": "Board",
"listView": "Lista",
"board": {
"new": "Nuevo board",
"edit": "Editar board",
"create": "Crear board",
"name": "Nombre del board...",
"groupBy": "Agrupar por",
"layout": "Diseño",
"columns": "Columnas",
"addColumn": "Columna",
"columnName": "Nombre de columna...",
"delete": "Eliminar",
"noTasks": "Sin tareas",
"groupStatus": "Estado",
"groupPriority": "Prioridad",
"groupDueDate": "Vencimiento",
"groupTag": "Tag",
"groupCustom": "Personalizado",
"layoutKanban": "Kanban",
"layoutGrid": "Cuadrícula",
"layoutFocus": "Enfoque"
},
"settings": {
"title": "Ajustes Todo"
},
"onboarding": {
"welcome": "¡Bienvenido a Todo!",
"intro": "Tu planificador de tareas personal con boards Kanban, entrada inteligente y más.",
"step1Title": "Lenguaje natural",
"step1": "Crea tareas como \"Reunión mañana a las 14h !alta #trabajo\" — fecha, prioridad y etiquetas se detectan automáticamente.",
"step2Title": "Boards Kanban",
"step2": "Organiza tus tareas visualmente con diferentes vistas: estado, prioridad o personalizado.",
"step3Title": "¡Vamos!",
"step3": "Crea tu primera tarea con el botón +. Los consejos están disponibles en el icono ?.",
"next": "Siguiente",
"letsGo": "¡Vamos!",
"getStarted": "Empezar"
},
"syntaxHelp": {
"title": "Sintaxis Quick-Add",
"description": "Usa lenguaje natural para crear tareas rápidamente.",
"date": "Fecha",
"dateToday": "Hoy",
"dateTomorrow": "Mañana",
"dateNextWeekday": "Próximo día",
"dateSpecific": "Fecha específica",
"time": "Hora",
"priority": "Prioridad",
"priorityUrgent": "Urgente",
"priorityHigh": "Alta",
"priorityLow": "Baja",
"labels": "Etiquetas",
"labelsAdd": "Añadir tags",
"duration": "Duración",
"duration30m": "30 minutos",
"duration2h": "2 horas",
"duration90m": "90 minutos",
"recurrence": "Recurrencia",
"recurrenceDaily": "Diario",
"recurrenceWeekly": "Semanal",
"recurrenceMonthly": "Mensual",
"multiTask": "Múltiples tareas",
"multiTaskChain": "Encadenar tareas",
"multiTaskSemicolon": "Separar con punto y coma",
"subtasks": "Subtareas",
"subtasksColonComma": "Dos puntos + coma",
"exampleTitle": "Ejemplo",
"exampleInput": "Reunión mañana a las 14h 1h !alta #trabajo",
"exampleOutput": "→ \"Reunión\" mañana a las 14:00, duración 1h, prioridad Alta, etiqueta \"trabajo\""
}
},
"app_slider": {
"title": "Parte del ecosistema Mana",
"memoro_desc": "Notas de voz con IA",

View file

@ -6,6 +6,145 @@
"back": "Retour",
"loading": "Chargement..."
},
"todo": {
"title": "Tâches",
"tasks": "Tâches",
"completed": "terminées",
"overdue": "en retard",
"today": "aujourd'hui",
"inbox": "Boîte de réception",
"todayView": "Aujourd'hui",
"upcoming": "À venir",
"completedView": "Terminées",
"search": "Recherche",
"newTask": "Nouvelle tâche",
"quickAddPlaceholder": "ex. 'Réunion demain à 14h !haute #important'",
"addTask": "Ajouter",
"cancel": "Ann.",
"syntaxHelp": "Aide syntaxe",
"noTasks": "Aucune tâche",
"noTasksInbox": "Boîte de réception vide",
"noTasksToday": "Aucune tâche aujourd'hui",
"noTasksUpcoming": "Aucune tâche à venir",
"noTasksCompleted": "Aucune tâche terminée",
"firstTaskHint": "Créez votre première tâche avec le bouton + ci-dessus.",
"edit": "Modifier",
"markDone": "Terminer",
"reopen": "Rouvrir",
"deleteConfirm": "Vraiment supprimer ?",
"yesDelete": "Oui, supprimer",
"save": "Enregistrer",
"close": "Fermer",
"description": "Description",
"addDescription": "Ajouter une description...",
"subtasks": "Sous-tâches",
"addSubtask": "Ajouter une sous-tâche...",
"status": "Statut",
"statusPending": "Ouvert",
"statusInProgress": "En cours",
"statusCompleted": "Terminé",
"statusCancelled": "Annulé",
"priority": "Priorité",
"priorityUrgent": "Urgent",
"priorityHigh": "Haute",
"priorityMedium": "Moyenne",
"priorityLow": "Basse",
"dueDate": "Échéance",
"time": "Heure",
"startDate": "Début",
"recurrence": "Récurrence",
"recurrenceNone": "Aucune",
"recurrenceDaily": "Quotidien",
"recurrenceWeekly": "Hebdomadaire",
"recurrenceMonthly": "Mensuel",
"recurrenceYearly": "Annuel",
"reminder": "Rappel",
"reminderNone": "Aucun",
"tags": "Tags",
"storypoints": "Points",
"duration": "Durée",
"fun": "Fun",
"projects": "Projets",
"labels": "Labels",
"sort": "Tri",
"sortManual": "Manuel",
"sortDueDate": "Échéance",
"sortPriority": "Priorité",
"sortName": "Nom",
"sortCreated": "Création",
"showCompleted": "Terminées",
"boardView": "Board",
"listView": "Liste",
"board": {
"new": "Nouveau board",
"edit": "Modifier le board",
"create": "Créer un board",
"name": "Nom du board...",
"groupBy": "Grouper par",
"layout": "Disposition",
"columns": "Colonnes",
"addColumn": "Colonne",
"columnName": "Nom de colonne...",
"delete": "Supprimer",
"noTasks": "Aucune tâche",
"groupStatus": "Statut",
"groupPriority": "Priorité",
"groupDueDate": "Échéance",
"groupTag": "Tag",
"groupCustom": "Personnalisé",
"layoutKanban": "Kanban",
"layoutGrid": "Grille",
"layoutFocus": "Focus"
},
"settings": {
"title": "Paramètres Todo"
},
"onboarding": {
"welcome": "Bienvenue dans Todo !",
"intro": "Votre planificateur de tâches personnel avec boards Kanban, saisie intelligente et plus.",
"step1Title": "Langage naturel",
"step1": "Créez des tâches comme \"Réunion demain à 14h !haute #travail\" — date, priorité et labels sont détectés automatiquement.",
"step2Title": "Boards Kanban",
"step2": "Organisez vos tâches visuellement avec différentes vues : statut, priorité ou personnalisé.",
"step3Title": "C'est parti !",
"step3": "Créez votre première tâche avec le bouton +. Des astuces sont disponibles via l'icône ?.",
"next": "Suivant",
"letsGo": "C'est parti",
"getStarted": "Commencer"
},
"syntaxHelp": {
"title": "Syntaxe Quick-Add",
"description": "Utilisez le langage naturel pour créer rapidement des tâches.",
"date": "Date",
"dateToday": "Aujourd'hui",
"dateTomorrow": "Demain",
"dateNextWeekday": "Prochain jour",
"dateSpecific": "Date spécifique",
"time": "Heure",
"priority": "Priorité",
"priorityUrgent": "Urgent",
"priorityHigh": "Haute",
"priorityLow": "Basse",
"labels": "Labels",
"labelsAdd": "Ajouter des tags",
"duration": "Durée",
"duration30m": "30 minutes",
"duration2h": "2 heures",
"duration90m": "90 minutes",
"recurrence": "Récurrence",
"recurrenceDaily": "Quotidien",
"recurrenceWeekly": "Hebdomadaire",
"recurrenceMonthly": "Mensuel",
"multiTask": "Tâches multiples",
"multiTaskChain": "Enchaîner les tâches",
"multiTaskSemicolon": "Séparer par point-virgule",
"subtasks": "Sous-tâches",
"subtasksColonComma": "Deux-points + virgule",
"exampleTitle": "Exemple",
"exampleInput": "Réunion demain à 14h 1h !haute #travail",
"exampleOutput": "→ \"Réunion\" demain à 14:00, durée 1h, priorité Haute, label \"travail\""
}
},
"app_slider": {
"title": "Partie de l'écosystème Mana",
"memoro_desc": "Notes vocales IA",

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { minimizedPagesStore } from '../stores/minimized-pages.svelte';
import { X, ArrowsOut } from '@manacore/shared-icons';
let pages = $derived(minimizedPagesStore.pages);
</script>
{#if pages.length > 0}
<div
class="fixed bottom-16 left-1/2 z-50 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-border bg-card/95 px-2 py-1.5 shadow-lg backdrop-blur-sm"
>
{#each pages as page (page.id)}
<div
class="group flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors
{minimizedPagesStore.activePageId === page.id
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
>
<button
onclick={() => minimizedPagesStore.maximize(page.id)}
class="truncate max-w-[120px]"
>
{page.title}
</button>
<button
onclick={() => minimizedPagesStore.remove(page.id)}
class="opacity-0 transition-opacity group-hover:opacity-100"
>
<X size={12} />
</button>
</div>
{/each}
</div>
{/if}

View file

@ -0,0 +1,102 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { CheckCircle, ListChecks, Columns, Sparkle } from '@manacore/shared-icons';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
let step = $state(0);
let steps = $derived([
{
icon: Sparkle,
title: $_('todo.onboarding.welcome'),
desc: $_('todo.onboarding.intro'),
},
{
icon: ListChecks,
title: $_('todo.onboarding.step1Title'),
desc: $_('todo.onboarding.step1'),
},
{
icon: Columns,
title: $_('todo.onboarding.step2Title'),
desc: $_('todo.onboarding.step2'),
},
{
icon: CheckCircle,
title: $_('todo.onboarding.step3Title'),
desc: $_('todo.onboarding.step3'),
},
]);
function next() {
if (step < steps.length - 1) {
step++;
} else {
finish();
}
}
function finish() {
try {
localStorage.setItem('todo-onboarding-done', 'true');
} catch {}
onClose();
}
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[9997] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
onclick={(e) => e.target === e.currentTarget && finish()}
>
<div class="w-full max-w-sm rounded-2xl border border-border bg-card shadow-2xl">
<div class="flex flex-col items-center p-8 text-center">
{@const CurrentIcon = steps[step].icon}
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<CurrentIcon size={32} class="text-primary" />
</div>
<h2 class="mb-2 text-xl font-bold text-foreground">{steps[step].title}</h2>
<p class="mb-6 text-sm text-muted-foreground">{steps[step].desc}</p>
<!-- Progress dots -->
<div class="mb-6 flex gap-1.5">
{#each steps as _, i}
<div
class="h-1.5 rounded-full transition-all {i === step
? 'w-6 bg-primary'
: i < step
? 'w-1.5 bg-primary/40'
: 'w-1.5 bg-muted'}"
></div>
{/each}
</div>
<div class="flex gap-2">
{#if step < steps.length - 1}
<button
onclick={finish}
class="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
{$_('common.cancel')}
</button>
{/if}
<button
onclick={next}
class="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-primary-foreground"
>
{step < steps.length - 1 ? $_('todo.onboarding.next') : $_('todo.onboarding.letsGo')}
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,196 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { tasksStore } from '../stores/tasks.svelte';
import {
parseTaskInput,
parseMultiTaskInput,
resolveTaskIds,
formatDuration,
} from '../utils/task-parser';
import type { ParsedTask } from '../utils/task-parser';
import type { LocalLabel } from '../types';
import { getPriorityColor } from '../queries';
import {
Plus,
CalendarBlank,
Flag,
ArrowsClockwise,
Timer,
Tag,
Info,
} from '@manacore/shared-icons';
interface Props {
labels?: LocalLabel[];
locale?: string;
onShowSyntaxHelp?: () => void;
}
let { labels = [], locale = 'de', onShowSyntaxHelp }: Props = $props();
let input = $state('');
let isOpen = $state(false);
let inputEl = $state<HTMLInputElement | undefined>(undefined);
// Parse preview
let parsed = $derived.by((): ParsedTask[] => {
if (!input.trim()) return [];
try {
return parseMultiTaskInput(input, locale);
} catch {
return [];
}
});
let hasParsedMeta = $derived(
parsed.some(
(p) =>
p.dueDate ||
p.priority ||
p.recurrenceRule ||
p.estimatedDuration ||
p.labelNames.length > 0
)
);
function open() {
isOpen = true;
requestAnimationFrame(() => inputEl?.focus());
}
async function handleSubmit() {
if (!input.trim()) return;
const tasks = parseMultiTaskInput(input, locale);
for (const task of tasks) {
const resolved = resolveTaskIds(task, labels);
await tasksStore.createTask({
title: resolved.title,
dueDate: resolved.dueDate ?? undefined,
priority: resolved.priority,
recurrenceRule: resolved.recurrenceRule,
estimatedDuration: resolved.estimatedDuration,
subtasks: resolved.subtasks?.map((s, i) => ({
id: crypto.randomUUID(),
title: s,
isCompleted: false,
order: i,
})),
});
// Apply labels
if (resolved.labelIds.length > 0) {
// We'd need to get the last created task ID - handled via createTask return
}
}
input = '';
isOpen = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
} else if (e.key === 'Escape') {
isOpen = false;
input = '';
}
}
</script>
{#if isOpen}
<div class="mb-4 rounded-lg border border-primary/50 bg-card">
<div class="flex items-center gap-2 p-3">
<input
bind:this={inputEl}
bind:value={input}
onkeydown={handleKeydown}
placeholder={$_('todo.quickAddPlaceholder')}
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none"
/>
<button
onclick={handleSubmit}
disabled={!input.trim()}
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground disabled:opacity-50"
>
{$_('todo.addTask')}
</button>
{#if onShowSyntaxHelp}
<button
onclick={onShowSyntaxHelp}
class="text-muted-foreground hover:text-foreground"
title="Syntax-Hilfe"
>
<Info size={16} />
</button>
{/if}
<button
onclick={() => {
isOpen = false;
input = '';
}}
class="text-xs text-muted-foreground hover:text-foreground"
>
{$_('todo.cancel')}
</button>
</div>
<!-- NLP Preview -->
{#if parsed.length > 0 && hasParsedMeta}
<div class="border-t border-border px-3 py-2">
{#each parsed as task, i}
<div class="flex items-center gap-2 py-1 text-xs text-muted-foreground">
{#if parsed.length > 1}
<span class="font-medium text-foreground">#{i + 1}</span>
{/if}
<span class="font-medium text-foreground">{task.title}</span>
{#if task.dueDate}
<span class="inline-flex items-center gap-0.5 text-amber-500">
<CalendarBlank size={10} />
{task.dueDate.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })}
{#if task.dueTime}
{task.dueTime}
{/if}
</span>
{/if}
{#if task.priority}
<span
class="inline-flex items-center gap-0.5"
style="color: {getPriorityColor(task.priority)}"
>
<Flag size={10} />
{task.priority}
</span>
{/if}
{#if task.recurrenceRule}
<span class="inline-flex items-center gap-0.5">
<ArrowsClockwise size={10} />
</span>
{/if}
{#if task.estimatedDuration}
<span class="inline-flex items-center gap-0.5">
<Timer size={10} />
{formatDuration(task.estimatedDuration)}
</span>
{/if}
{#each task.labelNames as name}
<span class="inline-flex items-center gap-0.5">
<Tag size={10} />
{name}
</span>
{/each}
</div>
{/each}
</div>
{/if}
</div>
{:else}
<button
onclick={open}
class="mb-4 flex w-full items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary hover:text-primary"
>
<Plus size={16} />
{$_('todo.newTask')}
</button>
{/if}

View file

@ -0,0 +1,104 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Subtask } from '../types';
import { Check, Circle, Plus, X } from '@manacore/shared-icons';
interface Props {
subtasks: Subtask[];
onChange: (subtasks: Subtask[]) => void;
readonly?: boolean;
}
let { subtasks, onChange, readonly = false }: Props = $props();
let newTitle = $state('');
function toggleSubtask(id: string) {
onChange(
subtasks.map((s) =>
s.id === id
? {
...s,
isCompleted: !s.isCompleted,
completedAt: !s.isCompleted ? new Date().toISOString() : null,
}
: s
)
);
}
function removeSubtask(id: string) {
onChange(subtasks.filter((s) => s.id !== id));
}
function addSubtask() {
const text = newTitle.trim();
if (!text) return;
onChange([
...subtasks,
{
id: crypto.randomUUID(),
title: text,
isCompleted: false,
order: subtasks.length,
},
]);
newTitle = '';
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
addSubtask();
}
}
</script>
<div class="space-y-1">
{#each subtasks as subtask (subtask.id)}
<div class="group flex items-center gap-2">
<button
type="button"
onclick={() => toggleSubtask(subtask.id)}
class="flex-shrink-0 transition-colors {subtask.isCompleted
? 'text-green-500'
: 'text-muted-foreground hover:text-foreground'}"
>
{#if subtask.isCompleted}
<Check size={14} weight="bold" />
{:else}
<Circle size={14} />
{/if}
</button>
<span
class="flex-1 text-sm {subtask.isCompleted
? 'text-muted-foreground line-through'
: 'text-foreground'}"
>
{subtask.title}
</span>
{#if !readonly}
<button
type="button"
onclick={() => removeSubtask(subtask.id)}
class="flex-shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<X size={12} />
</button>
{/if}
</div>
{/each}
{#if !readonly}
<div class="flex items-center gap-2">
<Plus size={14} class="flex-shrink-0 text-muted-foreground" />
<input
type="text"
bind:value={newTitle}
onkeydown={handleKeydown}
placeholder={$_('todo.addSubtask')}
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none"
/>
</div>
{/if}
</div>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { CloudCheck, CloudSlash, ArrowsClockwise } from '@manacore/shared-icons';
interface Props {
status?: 'synced' | 'syncing' | 'offline' | 'error';
}
let { status = 'synced' }: Props = $props();
</script>
<div
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs"
title={status === 'synced'
? 'Synchronisiert'
: status === 'syncing'
? 'Synchronisiere...'
: status === 'offline'
? 'Offline — Änderungen werden lokal gespeichert'
: 'Sync-Fehler'}
>
{#if status === 'synced'}
<CloudCheck size={14} class="text-green-500" />
<span class="text-muted-foreground">Sync</span>
{:else if status === 'syncing'}
<ArrowsClockwise size={14} class="animate-spin text-primary" />
<span class="text-primary">Sync...</span>
{:else if status === 'offline'}
<CloudSlash size={14} class="text-amber-500" />
<span class="text-amber-500">Offline</span>
{:else}
<CloudSlash size={14} class="text-red-500" />
<span class="text-red-500">Fehler</span>
{/if}
</div>

View file

@ -0,0 +1,132 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { X } from '@manacore/shared-icons';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
let sections = $derived([
{
title: $_('todo.syntaxHelp.date'),
examples: [
{ input: 'heute', desc: $_('todo.syntaxHelp.dateToday') },
{ input: 'morgen', desc: $_('todo.syntaxHelp.dateTomorrow') },
{ input: 'nächsten Montag', desc: $_('todo.syntaxHelp.dateNextWeekday') },
{ input: '15.12.', desc: $_('todo.syntaxHelp.dateSpecific') },
],
},
{
title: $_('todo.syntaxHelp.time'),
examples: [
{ input: 'um 14 Uhr', desc: '14:00' },
{ input: '14:30', desc: '14:30' },
],
},
{
title: $_('todo.syntaxHelp.priority'),
examples: [
{ input: '!dringend / !!!', desc: $_('todo.syntaxHelp.priorityUrgent') },
{ input: '!hoch / !!', desc: $_('todo.syntaxHelp.priorityHigh') },
{ input: '!niedrig / !', desc: $_('todo.syntaxHelp.priorityLow') },
],
},
{
title: $_('todo.syntaxHelp.labels'),
examples: [{ input: '#wichtig #idee', desc: $_('todo.syntaxHelp.labelsAdd') }],
},
{
title: $_('todo.syntaxHelp.duration'),
examples: [
{ input: '30min', desc: $_('todo.syntaxHelp.duration30m') },
{ input: '2h', desc: $_('todo.syntaxHelp.duration2h') },
{ input: '1.5 Stunden', desc: $_('todo.syntaxHelp.duration90m') },
],
},
{
title: $_('todo.syntaxHelp.recurrence'),
examples: [
{ input: 'jeden Tag', desc: $_('todo.syntaxHelp.recurrenceDaily') },
{ input: 'wöchentlich', desc: $_('todo.syntaxHelp.recurrenceWeekly') },
{ input: 'monatlich', desc: $_('todo.syntaxHelp.recurrenceMonthly') },
],
},
{
title: $_('todo.syntaxHelp.multiTask'),
examples: [
{ input: 'Task1, danach Task2', desc: $_('todo.syntaxHelp.multiTaskChain') },
{ input: 'Task1; Task2; Task3', desc: $_('todo.syntaxHelp.multiTaskSemicolon') },
],
},
{
title: $_('todo.syntaxHelp.subtasks'),
examples: [
{ input: 'Einkaufen: Milch, Brot, Obst', desc: $_('todo.syntaxHelp.subtasksColonComma') },
],
},
]);
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[9996] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
onclick={(e) => e.target === e.currentTarget && onClose()}
>
<div
class="max-h-[80vh] w-full max-w-md overflow-y-auto rounded-2xl border border-border bg-card shadow-2xl"
>
<div class="flex items-center justify-between border-b border-border px-5 py-3">
<h2 class="text-lg font-semibold text-foreground">{$_('todo.syntaxHelp.title')}</h2>
<button
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
onclick={onClose}
>
<X size={18} />
</button>
</div>
<div class="p-5">
<p class="mb-4 text-sm text-muted-foreground">
{$_('todo.syntaxHelp.description')}
</p>
<div class="space-y-4">
{#each sections as section}
<div>
<h3 class="mb-1.5 text-xs font-bold uppercase tracking-wider text-muted-foreground">
{section.title}
</h3>
<div class="space-y-1">
{#each section.examples as example}
<div class="flex items-baseline gap-3 rounded-md px-2 py-1 hover:bg-muted/50">
<code
class="flex-shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-primary"
>
{example.input}
</code>
<span class="text-xs text-muted-foreground">{example.desc}</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
<div class="mt-5 rounded-lg bg-muted/50 p-3">
<p class="text-xs font-medium text-foreground">{$_('todo.syntaxHelp.exampleTitle')}</p>
<code class="mt-1 block text-xs text-primary">
{$_('todo.syntaxHelp.exampleInput')}
</code>
<p class="mt-1 text-xs text-muted-foreground">
{$_('todo.syntaxHelp.exampleOutput')}
</p>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,48 @@
<script lang="ts">
import type { LocalLabel } from '../types';
import { viewStore } from '../stores/view.svelte';
import { X } from '@manacore/shared-icons';
interface Props {
labels: LocalLabel[];
collapsed?: boolean;
onToggleCollapse?: () => void;
}
let { labels, collapsed = false, onToggleCollapse }: Props = $props();
function handleSelect(id: string) {
if (viewStore.currentView === 'label' && viewStore.currentLabelId === id) {
viewStore.setInbox();
} else {
viewStore.setLabel(id);
}
}
</script>
{#if labels.length > 0 && !collapsed}
<div class="flex items-center gap-1.5 overflow-x-auto pb-1">
{#if viewStore.currentView === 'label'}
<button
onclick={() => viewStore.setInbox()}
class="flex items-center gap-1 rounded-full border border-primary/30 bg-primary/5 px-2 py-0.5 text-xs text-primary"
>
<X size={10} />
Filter
</button>
{/if}
{#each labels as label (label.id)}
<button
onclick={() => handleSelect(label.id)}
class="flex-shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors
{viewStore.currentView === 'label' && viewStore.currentLabelId === label.id
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:border-primary/50'}"
>
<span class="mr-1 inline-block h-2 w-2 rounded-full" style="background-color: {label.color}"
></span>
{label.name}
</button>
{/each}
</div>
{/if}

View file

@ -0,0 +1,315 @@
<script lang="ts">
import type { Task, Subtask, TaskPriority } from '../types';
import { tasksStore } from '../stores/tasks.svelte';
import { useTaskForm } from '../composables/useTaskForm.svelte';
import SubtaskList from './SubtaskList.svelte';
import {
PrioritySelector,
TagSelector,
ReminderSelector,
DurationPicker,
StorypointsSelector,
FunRatingPicker,
} from './form';
import { X, Trash, CalendarBlank, Clock, ArrowsClockwise, Flag } from '@manacore/shared-icons';
import { _ } from 'svelte-i18n';
interface Props {
task: Task;
open: boolean;
onClose: () => void;
}
let { task, open, onClose }: Props = $props();
const form = useTaskForm();
$effect(() => {
if (open && task) form.initFromTask(task);
});
let RECURRENCE_OPTIONS = $derived([
{ value: '', label: $_('todo.recurrenceNone') },
{ value: 'FREQ=DAILY', label: $_('todo.recurrenceDaily') },
{ value: 'FREQ=WEEKLY', label: $_('todo.recurrenceWeekly') },
{ value: 'FREQ=MONTHLY', label: $_('todo.recurrenceMonthly') },
{ value: 'FREQ=YEARLY', label: $_('todo.recurrenceYearly') },
]);
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleSave();
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
async function handleSave() {
if (!form.title.trim()) return;
form.isLoading = true;
try {
const payload = form.buildUpdatePayload();
await tasksStore.updateTask(task.id, payload);
await form.persistReminder(task.id);
onClose();
} finally {
form.isLoading = false;
}
}
async function handleDelete() {
if (form.showDeleteConfirm) {
await tasksStore.deleteTask(task.id);
onClose();
} else {
form.showDeleteConfirm = true;
}
}
function handleSubtasksChange(newSubtasks: Subtask[]) {
form.subtasks = newSubtasks;
}
function autoGrow(node: HTMLTextAreaElement) {
function resize() {
node.style.height = 'auto';
node.style.height = node.scrollHeight + 'px';
}
node.addEventListener('input', resize);
resize();
return { destroy: () => node.removeEventListener('input', resize) };
}
</script>
<svelte:window onkeydown={open ? handleKeydown : undefined} />
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[9995] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm sm:p-8"
onclick={handleBackdropClick}
>
<div
class="flex max-h-[calc(100vh-4rem)] w-full max-w-[1040px] flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl max-sm:max-h-[calc(100vh-60px)] max-sm:rounded-b-none"
>
<!-- Top bar -->
<div
class="flex flex-shrink-0 items-center justify-between border-b border-border px-5 py-2.5"
>
<div class="flex items-center gap-2">
{#if form.showDeleteConfirm}
<span class="text-sm font-medium text-red-500">{$_('todo.deleteConfirm')}</span>
<button
class="rounded-md px-2.5 py-1 text-sm text-red-500 transition-colors hover:bg-red-500/10"
onclick={handleDelete}>{$_('todo.yesDelete')}</button
>
<button
class="rounded-md px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted"
onclick={() => (form.showDeleteConfirm = false)}>{$_('common.cancel')}</button
>
{:else}
<button
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-red-500/10 hover:text-red-500"
onclick={handleDelete}
title={$_('common.delete')}
>
<Trash size={16} />
</button>
{/if}
</div>
<div class="flex items-center gap-2">
<button
class="rounded-lg bg-primary px-3.5 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-45"
onclick={handleSave}
disabled={form.isLoading || !form.title.trim()}
>
{#if form.isLoading}
<span
class="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-transparent border-t-white"
></span>
{:else}
{$_('common.save')}
{/if}
</button>
<button
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={onClose}
title={$_('todo.close')}
>
<X size={18} />
</button>
</div>
</div>
<!-- Title -->
<div class="flex-shrink-0 px-6 pb-3 pt-4">
<textarea
class="w-full resize-none overflow-hidden border-none bg-transparent p-0 text-2xl font-bold text-foreground outline-none placeholder:text-border"
bind:value={form.title}
placeholder={$_('todo.newTask')}
rows="1"
use:autoGrow
></textarea>
</div>
<!-- Content: Description | Subtasks -->
<div
class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden border-t border-border sm:grid-cols-2"
>
<div class="flex flex-col gap-2.5 overflow-y-auto p-5">
<span class="text-[0.6875rem] font-bold uppercase tracking-wider text-muted-foreground"
>{$_('todo.description')}</span
>
<textarea
class="min-h-[100px] flex-1 resize-none border-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-border"
bind:value={form.description}
placeholder={$_('todo.addDescription')}
rows="5"
></textarea>
</div>
<div
class="flex flex-col gap-2.5 overflow-y-auto border-t border-border p-5 sm:border-l sm:border-t-0"
>
<span class="text-[0.6875rem] font-bold uppercase tracking-wider text-muted-foreground"
>{$_('todo.subtasks')}</span
>
<SubtaskList subtasks={form.subtasks} onChange={handleSubtasksChange} />
</div>
</div>
<!-- Properties strip -->
<div class="flex flex-shrink-0 flex-wrap items-stretch border-t border-border bg-muted/30">
<!-- Status -->
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
>{$_('todo.status')}</span
>
<select
class="border-none bg-transparent text-sm text-foreground outline-none"
bind:value={form.status}
>
<option value="pending">{$_('todo.statusPending')}</option>
<option value="in_progress">{$_('todo.statusInProgress')}</option>
<option value="completed">{$_('todo.statusCompleted')}</option>
<option value="cancelled">{$_('todo.statusCancelled')}</option>
</select>
</div>
<!-- Priority -->
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
>{$_('todo.priority')}</span
>
<PrioritySelector value={form.priority} onChange={(p) => (form.priority = p)} />
</div>
<!-- Due Date -->
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
>{$_('todo.dueDate')}</span
>
<input
type="date"
class="border-none bg-transparent text-sm text-foreground outline-none"
bind:value={form.dueDate}
/>
</div>
<!-- Time -->
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
>{$_('todo.time')}</span
>
<input
type="time"
class="border-none bg-transparent text-sm text-foreground outline-none"
bind:value={form.dueTime}
/>
</div>
<!-- Start Date -->
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
>{$_('todo.startDate')}</span
>
<input
type="date"
class="border-none bg-transparent text-sm text-foreground outline-none"
bind:value={form.startDate}
/>
</div>
<!-- Recurrence -->
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
>{$_('todo.recurrence')}</span
>
<select
class="border-none bg-transparent text-sm text-foreground outline-none"
bind:value={form.recurrenceRule}
>
{#each RECURRENCE_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Reminder -->
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
>{$_('todo.reminder')}</span
>
<ReminderSelector
value={form.reminderMinutes}
onChange={(v) => (form.reminderMinutes = v)}
disabled={!form.dueDate}
/>
</div>
<!-- Tags -->
<div class="flex min-w-[140px] flex-1 flex-col gap-1 border-r border-border px-3.5 py-2">
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
>{$_('todo.tags')}</span
>
<TagSelector
selectedIds={form.selectedLabelIds}
onChange={(ids) => (form.selectedLabelIds = ids)}
/>
</div>
<!-- Story Points -->
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
>{$_('todo.storypoints')}</span
>
<StorypointsSelector value={form.storyPoints} onChange={(v) => (form.storyPoints = v)} />
</div>
<!-- Duration -->
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
>{$_('todo.duration')}</span
>
<DurationPicker
value={form.effectiveDuration}
onChange={(v) => (form.effectiveDuration = v)}
/>
</div>
<!-- Fun Rating -->
<div class="flex flex-col gap-1 px-3.5 py-2">
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
>{$_('todo.fun')}</span
>
<FunRatingPicker value={form.funRating} onChange={(v) => (form.funRating = v)} />
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,132 @@
<script lang="ts">
import type { Task, TaskPriority } from '../types';
import { getPriorityLabel, getPriorityColor } from '../queries';
import { Check, Circle, CalendarBlank, CheckSquare } from '@manacore/shared-icons';
import { isToday, isPast, format } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
task: Task;
tags?: { id: string; name: string; color: string }[];
compact?: boolean;
onToggleComplete: () => void;
onClick: () => void;
onContextMenu: (e: MouseEvent) => void;
}
let {
task,
tags = [],
compact = false,
onToggleComplete,
onClick,
onContextMenu,
}: Props = $props();
let taskLabelIds = $derived((task.metadata as { labelIds?: string[] })?.labelIds ?? []);
let taskTags = $derived(
taskLabelIds
.map((id) => tags.find((t) => t.id === id))
.filter((t): t is NonNullable<typeof t> => t != null)
.slice(0, 3)
);
let dueInfo = $derived.by(() => {
if (!task.dueDate) return null;
const d = new Date(task.dueDate);
const overdue = isPast(d) && !isToday(d) && !task.isCompleted;
const today = isToday(d);
return {
text: today ? 'Heute' : format(d, 'd. MMM', { locale: de }),
overdue,
today,
};
});
let subtaskInfo = $derived.by(() => {
if (!task.subtasks?.length) return null;
const done = task.subtasks.filter((s) => s.isCompleted).length;
return { done, total: task.subtasks.length };
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group flex items-start gap-3 rounded-lg border border-transparent px-3 transition-colors hover:border-border hover:bg-card {compact
? 'py-1.5'
: 'py-2.5'}"
role="button"
tabindex="0"
onclick={onClick}
oncontextmenu={(e) => {
e.preventDefault();
onContextMenu(e);
}}
onkeydown={(e) => e.key === 'Enter' && onClick()}
>
<!-- Completion toggle -->
<button
onclick|stopPropagation={onToggleComplete}
class="mt-0.5 flex-shrink-0 transition-colors {task.isCompleted
? 'text-green-500'
: 'text-muted-foreground hover:text-primary'}"
>
{#if task.isCompleted}
<Check size={20} weight="bold" />
{:else}
<Circle size={20} />
{/if}
</button>
<!-- Content -->
<div class="min-w-0 flex-1">
<span
class="text-sm {task.isCompleted ? 'text-muted-foreground line-through' : 'text-foreground'}"
>
{task.title}
</span>
<!-- Meta row -->
<div class="mt-0.5 flex items-center gap-2.5 text-xs">
{#if dueInfo}
<span
class="inline-flex items-center gap-1 {dueInfo.overdue
? 'text-red-500'
: dueInfo.today
? 'text-amber-500'
: 'text-muted-foreground'}"
>
<CalendarBlank size={11} />
{dueInfo.text}
</span>
{/if}
{#if task.priority !== 'medium'}
<span style="color: {getPriorityColor(task.priority)}">
{getPriorityLabel(task.priority)}
</span>
{/if}
{#if subtaskInfo}
<span class="inline-flex items-center gap-1 text-muted-foreground">
<CheckSquare size={11} />
{subtaskInfo.done}/{subtaskInfo.total}
</span>
{/if}
{#each taskTags as tag (tag.id)}
<span
class="rounded-full px-1.5 py-0.5 text-[0.625rem] font-medium"
style="background: color-mix(in srgb, {tag.color} 15%, transparent); color: {tag.color}"
>
{tag.name}
</span>
{/each}
</div>
</div>
<!-- Priority dot -->
{#if task.priority === 'urgent' || task.priority === 'high'}
<div
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
style="background-color: {getPriorityColor(task.priority)}"
></div>
{/if}
</div>

View file

@ -0,0 +1,172 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Task, TaskPriority } from '../types';
import { tasksStore } from '../stores/tasks.svelte';
import TaskItem from './TaskItem.svelte';
import { dndzone, SOURCES, TRIGGERS } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
interface Props {
tasks: Task[];
tags?: { id: string; name: string; color: string }[];
compact?: boolean;
dragEnabled?: boolean;
onOpenTask: (task: Task) => void;
}
let { tasks, tags = [], compact = false, dragEnabled = true, onOpenTask }: Props = $props();
// DnD state
let items = $state<Task[]>([]);
$effect(() => {
items = [...tasks];
});
// Context menu
let contextMenu = $state<{ x: number; y: number; task: Task } | null>(null);
function handleContextMenu(task: Task, e: MouseEvent) {
contextMenu = { x: e.clientX, y: e.clientY, task };
}
function closeContextMenu() {
contextMenu = null;
}
async function handleSetPriority(priority: TaskPriority) {
if (!contextMenu) return;
await tasksStore.updateTask(contextMenu.task.id, { priority });
closeContextMenu();
}
async function handleComplete() {
if (!contextMenu) return;
await tasksStore.toggleComplete(contextMenu.task.id);
closeContextMenu();
}
async function handleDelete() {
if (!contextMenu) return;
await tasksStore.deleteTask(contextMenu.task.id);
closeContextMenu();
}
// DnD handlers
function handleDndConsider(e: CustomEvent<{ items: Task[] }>) {
items = e.detail.items;
}
function handleDndFinalize(
e: CustomEvent<{ items: Task[]; info: { source: string; trigger: string } }>
) {
items = e.detail.items;
if (e.detail.info.source === SOURCES.POINTER) {
tasksStore.reorderTasks(items.map((t) => t.id));
}
}
const flipDurationMs = 200;
</script>
{#if dragEnabled}
<div
use:dndzone={{
items,
flipDurationMs,
dropTargetStyle: {},
dropTargetClasses: ['bg-primary/5', 'rounded-lg'],
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="space-y-0.5"
>
{#each items as task (task.id)}
<div animate:flip={{ duration: flipDurationMs }}>
<TaskItem
{task}
{tags}
{compact}
onToggleComplete={() => tasksStore.toggleComplete(task.id)}
onClick={() => onOpenTask(task)}
onContextMenu={(e) => handleContextMenu(task, e)}
/>
</div>
{/each}
</div>
{:else}
<div class="space-y-0.5">
{#each tasks as task (task.id)}
<TaskItem
{task}
{tags}
{compact}
onToggleComplete={() => tasksStore.toggleComplete(task.id)}
onClick={() => onOpenTask(task)}
onContextMenu={(e) => handleContextMenu(task, e)}
/>
{/each}
</div>
{/if}
<!-- Context Menu -->
{#if contextMenu}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-[9990]" onclick={closeContextMenu}></div>
<div
class="fixed z-[9991] min-w-[160px] rounded-lg border border-border bg-card p-1 shadow-xl"
style="left: {contextMenu.x}px; top: {contextMenu.y}px"
>
<button
onclick={() => {
onOpenTask(contextMenu!.task);
closeContextMenu();
}}
class="flex w-full items-center rounded-md px-3 py-1.5 text-sm text-foreground hover:bg-muted"
>
{$_('todo.edit')}
</button>
<button
onclick={handleComplete}
class="flex w-full items-center rounded-md px-3 py-1.5 text-sm text-foreground hover:bg-muted"
>
{contextMenu.task.isCompleted ? $_('todo.reopen') : $_('todo.markDone')}
</button>
<div class="my-1 border-t border-border"></div>
<div class="px-3 py-1 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">
{$_('todo.priority')}
</div>
{#each ['urgent', 'high', 'medium', 'low'] as p}
<button
onclick={() => handleSetPriority(p as TaskPriority)}
class="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm hover:bg-muted
{contextMenu.task.priority === p ? 'text-primary font-medium' : 'text-foreground'}"
>
<span
class="h-2 w-2 rounded-full"
style="background-color: {p === 'urgent'
? '#ef4444'
: p === 'high'
? '#f59e0b'
: p === 'medium'
? '#3b82f6'
: '#6b7280'}"
></span>
{p === 'urgent'
? $_('todo.priorityUrgent')
: p === 'high'
? $_('todo.priorityHigh')
: p === 'medium'
? $_('todo.priorityMedium')
: $_('todo.priorityLow')}
</button>
{/each}
<div class="my-1 border-t border-border"></div>
<button
onclick={handleDelete}
class="flex w-full items-center rounded-md px-3 py-1.5 text-sm text-red-500 hover:bg-red-500/10"
>
{$_('common.delete')}
</button>
</div>
{/if}

View file

@ -0,0 +1,124 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { viewStore } from '../stores/view.svelte';
import type { SortBy } from '../types';
import {
SortAscending,
SortDescending,
MagnifyingGlass,
FunnelSimple,
Columns,
List,
} from '@manacore/shared-icons';
interface Props {
showBoardToggle?: boolean;
isBoardView?: boolean;
onToggleBoard?: () => void;
}
let { showBoardToggle = false, isBoardView = false, onToggleBoard }: Props = $props();
let showSortMenu = $state(false);
const sortOptions: { value: SortBy; label: string }[] = $derived([
{ value: 'order', label: $_('todo.sortManual') },
{ value: 'dueDate', label: $_('todo.sortDueDate') },
{ value: 'priority', label: $_('todo.sortPriority') },
{ value: 'title', label: $_('todo.sortName') },
{ value: 'createdAt', label: $_('todo.sortCreated') },
]);
</script>
<div class="flex items-center gap-2">
<!-- Search toggle -->
<button
onclick={() => {
if (viewStore.currentView === 'search') viewStore.setInbox();
else viewStore.setSearch('');
}}
class="flex h-8 w-8 items-center justify-center rounded-lg transition-colors
{viewStore.currentView === 'search'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
title={$_('todo.search')}
>
<MagnifyingGlass size={16} />
</button>
<!-- Sort -->
<div class="relative">
<button
onclick={() => (showSortMenu = !showSortMenu)}
class="flex h-8 items-center gap-1 rounded-lg px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={$_('todo.sort')}
>
{#if viewStore.sortOrder === 'asc'}
<SortAscending size={16} />
{:else}
<SortDescending size={16} />
{/if}
<span class="text-xs"
>{sortOptions.find((o) => o.value === viewStore.sortBy)?.label ?? 'Sort'}</span
>
</button>
{#if showSortMenu}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showSortMenu = false)}></div>
<div
class="absolute right-0 top-full z-50 mt-1 min-w-[140px] rounded-lg border border-border bg-card p-1 shadow-lg"
>
{#each sortOptions as opt}
<button
onclick={() => {
if (viewStore.sortBy === opt.value) {
viewStore.toggleSortOrder();
} else {
viewStore.setSort(opt.value);
}
showSortMenu = false;
}}
class="flex w-full items-center justify-between rounded-md px-3 py-1.5 text-sm hover:bg-muted
{viewStore.sortBy === opt.value ? 'text-primary font-medium' : 'text-foreground'}"
>
{opt.label}
{#if viewStore.sortBy === opt.value}
<span class="text-xs text-muted-foreground">
{viewStore.sortOrder === 'asc' ? '↑' : '↓'}
</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Show completed toggle -->
<button
onclick={() => viewStore.toggleShowCompleted()}
class="flex h-8 items-center gap-1 rounded-lg px-2 text-xs transition-colors
{viewStore.showCompleted
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
>
<FunnelSimple size={14} />
{$_('todo.showCompleted')}
</button>
<!-- Board/List toggle -->
{#if showBoardToggle}
<button
onclick={onToggleBoard}
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={isBoardView ? $_('todo.listView') : $_('todo.boardView')}
>
{#if isBoardView}
<List size={16} />
{:else}
<Columns size={16} />
{/if}
</button>
{/if}
</div>

View file

@ -0,0 +1,51 @@
<script lang="ts">
import type { Task, LocalLabel, LocalBoardView } from '../../types';
import KanbanLayout from './KanbanLayout.svelte';
import GridLayout from './GridLayout.svelte';
import FokusLayout from './FokusLayout.svelte';
interface Props {
view: LocalBoardView;
tasks: Task[];
labels: LocalLabel[];
wipLimit?: number | null;
cardSize?: 'compact' | 'normal' | 'large';
onToggleComplete: (taskId: string) => void;
onSaveTask: (taskId: string, data: Partial<Task>) => void;
onDeleteTask: (taskId: string) => void;
onQuickAdd: (title: string, columnId: string) => void;
onOpenTask: (task: Task) => void;
}
let {
view,
tasks,
labels,
wipLimit = null,
cardSize = 'normal',
onToggleComplete,
onSaveTask,
onDeleteTask,
onQuickAdd,
onOpenTask,
}: Props = $props();
</script>
{#if view.layout === 'grid'}
<GridLayout {view} {tasks} {labels} {onToggleComplete} {onSaveTask} {onDeleteTask} {onOpenTask} />
{:else if view.layout === 'fokus'}
<FokusLayout {view} {tasks} {labels} {onToggleComplete} {onOpenTask} />
{:else}
<KanbanLayout
{view}
{tasks}
{labels}
{wipLimit}
{cardSize}
{onToggleComplete}
{onSaveTask}
{onDeleteTask}
{onQuickAdd}
{onOpenTask}
/>
{/if}

View file

@ -0,0 +1,90 @@
<script lang="ts">
import type { Task, LocalLabel, LocalBoardView } from '../../types';
import { groupTasksByView } from '../../view-grouping';
import { Check, Circle, CaretRight } from '@manacore/shared-icons';
import { getPriorityColor } from '../../queries';
interface Props {
view: LocalBoardView;
tasks: Task[];
labels: LocalLabel[];
onToggleComplete: (taskId: string) => void;
onOpenTask: (task: Task) => void;
}
let { view, tasks, labels, onToggleComplete, onOpenTask }: Props = $props();
let columns = $derived(groupTasksByView(view, tasks));
// Focus mode: show first non-empty column's first few tasks prominently
let focusColumn = $derived(columns.find((c) => c.tasks.length > 0) ?? columns[0]);
let focusTasks = $derived(focusColumn?.tasks.slice(0, 5) ?? []);
let remainingColumns = $derived(columns.filter((c) => c.id !== focusColumn?.id));
</script>
<div class="space-y-6">
<!-- Focus section -->
{#if focusColumn}
<div>
<div class="mb-3 flex items-center gap-2">
<span class="h-3 w-3 rounded-full" style="background-color: {focusColumn.color}"></span>
<span class="text-lg font-bold text-foreground">{focusColumn.name}</span>
</div>
<div class="space-y-2">
{#each focusTasks as task, i (task.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={() => onOpenTask(task)}
class="group flex cursor-pointer items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:shadow-md"
style="border-left: 4px solid {getPriorityColor(task.priority)}"
>
<button
onclick|stopPropagation={() => onToggleComplete(task.id)}
class="flex-shrink-0 transition-colors {task.isCompleted
? 'text-green-500'
: 'text-muted-foreground hover:text-primary'}"
>
{#if task.isCompleted}
<Check size={24} weight="bold" />
{:else}
<Circle size={24} />
{/if}
</button>
<div class="min-w-0 flex-1">
<span
class="text-base font-medium {task.isCompleted
? 'text-muted-foreground line-through'
: 'text-foreground'}"
>
{task.title}
</span>
{#if task.description}
<p class="mt-0.5 truncate text-sm text-muted-foreground">
{task.description}
</p>
{/if}
</div>
<CaretRight
size={16}
class="flex-shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100"
/>
</div>
{/each}
</div>
</div>
{/if}
<!-- Remaining columns collapsed -->
{#each remainingColumns as column (column.id)}
{#if column.tasks.length > 0}
<div class="rounded-lg border border-border bg-muted/20 p-3">
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full" style="background-color: {column.color}"></span>
<span class="text-sm font-medium text-muted-foreground">{column.name}</span>
<span class="text-xs text-muted-foreground">({column.tasks.length})</span>
</div>
</div>
{/if}
{/each}
</div>

View file

@ -0,0 +1,47 @@
<script lang="ts">
import type { Task, LocalLabel, LocalBoardView } from '../../types';
import { groupTasksByView } from '../../view-grouping';
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
interface Props {
view: LocalBoardView;
tasks: Task[];
labels: LocalLabel[];
onToggleComplete: (taskId: string) => void;
onSaveTask: (taskId: string, data: Partial<Task>) => void;
onDeleteTask: (taskId: string) => void;
onOpenTask: (task: Task) => void;
}
let { view, tasks, labels, onToggleComplete, onSaveTask, onDeleteTask, onOpenTask }: Props =
$props();
let columns = $derived(groupTasksByView(view, tasks));
</script>
<div class="space-y-6">
{#each columns as column (column.id)}
<div>
<div class="mb-2 flex items-center gap-2">
<span class="h-2.5 w-2.5 rounded-full" style="background-color: {column.color}"></span>
<span class="text-sm font-semibold text-foreground">{column.name}</span>
<span class="text-xs text-muted-foreground">({column.tasks.length})</span>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each column.tasks as task (task.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={() => onOpenTask(task)} class="cursor-pointer">
<KanbanTaskCard
{task}
{labels}
onToggleComplete={() => onToggleComplete(task.id)}
onSave={(data) => onSaveTask(task.id, data)}
onDelete={() => onDeleteTask(task.id)}
/>
</div>
{/each}
</div>
</div>
{/each}
</div>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import type { Task, LocalLabel, LocalBoardView } from '../../types';
import { groupTasksByView, getDropActionUpdate } from '../../view-grouping';
import { tasksStore } from '../../stores/tasks.svelte';
import ViewColumn from './ViewColumn.svelte';
interface Props {
view: LocalBoardView;
tasks: Task[];
labels: LocalLabel[];
wipLimit?: number | null;
cardSize?: 'compact' | 'normal' | 'large';
onToggleComplete: (taskId: string) => void;
onSaveTask: (taskId: string, data: Partial<Task>) => void;
onDeleteTask: (taskId: string) => void;
onQuickAdd: (title: string, columnId: string) => void;
onOpenTask: (task: Task) => void;
}
let {
view,
tasks,
labels,
wipLimit = null,
cardSize = 'normal',
onToggleComplete,
onSaveTask,
onDeleteTask,
onQuickAdd,
onOpenTask,
}: Props = $props();
let columns = $derived(groupTasksByView(view, tasks));
// Handle drop: apply the column's drop action to the task
async function handleDropTask(taskId: string, columnId: string) {
const column = view.columns.find((c) => c.id === columnId);
if (!column?.onDrop) return;
const update = getDropActionUpdate(column.onDrop);
if (Object.keys(update).length > 0) {
await tasksStore.updateTask(taskId, update);
}
}
</script>
<div class="flex gap-3 overflow-x-auto pb-4">
{#each columns as column (column.id)}
<ViewColumn
{column}
{labels}
{wipLimit}
{cardSize}
{onToggleComplete}
{onSaveTask}
{onDeleteTask}
{onQuickAdd}
{onOpenTask}
onDropTask={handleDropTask}
/>
{/each}
</div>

View file

@ -0,0 +1,100 @@
<script lang="ts">
import type { Task, LocalLabel } from '../../types';
import type { GroupedColumn } from '../../view-grouping';
import ViewColumnHeader from './ViewColumnHeader.svelte';
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
import QuickAddTaskInline from '../kanban/QuickAddTaskInline.svelte';
import { dndzone, SOURCES, TRIGGERS } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
interface Props {
column: GroupedColumn;
labels: LocalLabel[];
wipLimit?: number | null;
cardSize?: 'compact' | 'normal' | 'large';
onToggleComplete: (taskId: string) => void;
onSaveTask: (taskId: string, data: Partial<Task>) => void;
onDeleteTask: (taskId: string) => void;
onQuickAdd: (title: string, columnId: string) => void;
onOpenTask: (task: Task) => void;
onDropTask?: (taskId: string, columnId: string) => void;
}
let {
column,
labels,
wipLimit = null,
cardSize = 'normal',
onToggleComplete,
onSaveTask,
onDeleteTask,
onQuickAdd,
onOpenTask,
onDropTask,
}: Props = $props();
// DnD items — tracks the current column's tasks for drag operations
let items = $state<Task[]>([]);
$effect(() => {
items = [...column.tasks];
});
const flipDurationMs = 200;
function handleDndConsider(e: CustomEvent<{ items: Task[] }>) {
items = e.detail.items;
}
function handleDndFinalize(
e: CustomEvent<{ items: Task[]; info: { source: string; trigger: string } }>
) {
items = e.detail.items;
// Notify parent about tasks that were dropped into this column
if (e.detail.info.source === SOURCES.POINTER) {
for (const task of items) {
// If task wasn't originally in this column, it was dropped here
if (!column.tasks.find((t) => t.id === task.id)) {
onDropTask?.(task.id, column.id);
}
}
}
}
</script>
<div class="flex min-w-[260px] max-w-[340px] flex-shrink-0 flex-col rounded-xl bg-muted/30">
<ViewColumnHeader {column} {wipLimit} />
<div
use:dndzone={{
items,
flipDurationMs,
dropTargetStyle: {},
dropTargetClasses: ['ring-2', 'ring-primary/30', 'rounded-lg'],
type: 'kanban-task',
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="flex min-h-[60px] flex-1 flex-col gap-1.5 overflow-y-auto px-2 pb-2"
class:py-0={cardSize === 'compact'}
>
{#each items as task (task.id)}
<div animate:flip={{ duration: flipDurationMs }}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={() => onOpenTask(task)} class="cursor-pointer">
<KanbanTaskCard
{task}
{labels}
onToggleComplete={() => onToggleComplete(task.id)}
onSave={(data) => onSaveTask(task.id, data)}
onDelete={() => onDeleteTask(task.id)}
/>
</div>
</div>
{/each}
</div>
<div class="px-1 pb-1">
<QuickAddTaskInline onAdd={(title) => onQuickAdd(title, column.id)} />
</div>
</div>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import type { GroupedColumn } from '../../view-grouping';
interface Props {
column: GroupedColumn;
wipLimit?: number | null;
}
let { column, wipLimit = null }: Props = $props();
let isOverWip = $derived(wipLimit != null && column.tasks.length > wipLimit);
</script>
<div class="flex items-center justify-between px-2 py-2">
<div class="flex items-center gap-2">
<span class="h-2.5 w-2.5 rounded-full" style="background-color: {column.color}"></span>
<span class="text-sm font-semibold text-foreground">{column.name}</span>
<span
class="rounded-full bg-muted px-1.5 py-0.5 text-[0.625rem] font-medium {isOverWip
? 'text-red-500'
: 'text-muted-foreground'}"
>
{column.tasks.length}{wipLimit != null ? `/${wipLimit}` : ''}
</span>
</div>
</div>

View file

@ -0,0 +1,228 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { LocalBoardView, ViewColumn, TaskMatcher } from '../../types';
import { boardViewsStore } from '../../stores/board-views.svelte';
import { X, Plus, Trash } from '@manacore/shared-icons';
interface Props {
view?: LocalBoardView | null;
open: boolean;
onClose: () => void;
}
let { view = null, open, onClose }: Props = $props();
let name = $state('');
let groupBy = $state<LocalBoardView['groupBy']>('status');
let layout = $state<LocalBoardView['layout']>('kanban');
let columns = $state<ViewColumn[]>([]);
$effect(() => {
if (open) {
if (view) {
name = view.name;
groupBy = view.groupBy;
layout = view.layout;
columns = [...view.columns];
} else {
name = '';
groupBy = 'status';
layout = 'kanban';
columns = [];
}
}
});
let GROUP_OPTIONS = $derived([
{ value: 'status', label: $_('todo.board.groupStatus') },
{ value: 'priority', label: $_('todo.board.groupPriority') },
{ value: 'dueDate', label: $_('todo.board.groupDueDate') },
{ value: 'tag', label: $_('todo.board.groupTag') },
{ value: 'custom', label: $_('todo.board.groupCustom') },
] as const);
let LAYOUT_OPTIONS = $derived([
{ value: 'kanban', label: $_('todo.board.layoutKanban') },
{ value: 'grid', label: $_('todo.board.layoutGrid') },
{ value: 'fokus', label: $_('todo.board.layoutFocus') },
] as const);
function addColumn() {
columns = [
...columns,
{
id: crypto.randomUUID(),
name: `Spalte ${columns.length + 1}`,
color: '#6B7280',
match: { type: groupBy as TaskMatcher['type'], value: '' },
},
];
}
function removeColumn(id: string) {
columns = columns.filter((c) => c.id !== id);
}
async function handleSave() {
if (!name.trim()) return;
if (view) {
await boardViewsStore.updateView(view.id, {
name: name.trim(),
groupBy,
layout,
columns,
});
} else {
await boardViewsStore.createView({
name: name.trim(),
icon: layout === 'kanban' ? 'columns' : layout === 'grid' ? 'grid' : 'target',
groupBy,
layout,
columns,
order: 0,
});
}
onClose();
}
async function handleDelete() {
if (view) {
await boardViewsStore.deleteView(view.id);
onClose();
}
}
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[9996] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
onclick={(e) => e.target === e.currentTarget && onClose()}
>
<div class="w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-border px-5 py-3">
<h2 class="text-lg font-semibold text-foreground">
{view ? $_('todo.board.edit') : $_('todo.board.new')}
</h2>
<button
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
onclick={onClose}
>
<X size={18} />
</button>
</div>
<!-- Form -->
<div class="space-y-4 p-5">
<div>
<label class="mb-1 block text-xs font-medium text-muted-foreground">Name</label>
<input
type="text"
bind:value={name}
placeholder={$_('todo.board.name')}
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-xs font-medium text-muted-foreground"
>{$_('todo.board.groupBy')}</label
>
<select
bind:value={groupBy}
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
>
{#each GROUP_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-muted-foreground"
>{$_('todo.board.layout')}</label
>
<select
bind:value={layout}
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
>
{#each LAYOUT_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>
<!-- Columns -->
<div>
<div class="mb-2 flex items-center justify-between">
<label class="text-xs font-medium text-muted-foreground"
>{$_('todo.board.columns')}</label
>
<button
onclick={addColumn}
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-primary hover:bg-primary/10"
>
<Plus size={12} />
{$_('todo.board.addColumn')}
</button>
</div>
<div class="space-y-2">
{#each columns as col, i (col.id)}
<div class="flex items-center gap-2">
<input
type="color"
bind:value={columns[i].color}
class="h-8 w-8 cursor-pointer rounded border-none"
/>
<input
type="text"
bind:value={columns[i].name}
placeholder={$_('todo.board.columnName')}
class="flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm focus:border-primary focus:outline-none"
/>
<button
onclick={() => removeColumn(col.id)}
class="text-muted-foreground hover:text-red-500"
>
<Trash size={14} />
</button>
</div>
{/each}
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between border-t border-border px-5 py-3">
<div>
{#if view}
<button
onclick={handleDelete}
class="rounded-md px-3 py-1.5 text-sm text-red-500 hover:bg-red-500/10"
>
{$_('todo.board.delete')}
</button>
{/if}
</div>
<div class="flex gap-2">
<button
onclick={onClose}
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted"
>
{$_('common.cancel')}
</button>
<button
onclick={handleSave}
disabled={!name.trim()}
class="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-45"
>
{$_('common.save')}
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,47 @@
<script lang="ts">
import type { LocalBoardView } from '../../types';
import { Columns, GridFour, Target, Plus } from '@manacore/shared-icons';
interface Props {
views: LocalBoardView[];
activeViewId: string | null;
onSelect: (viewId: string) => void;
onCreateView: () => void;
}
let { views, activeViewId, onSelect, onCreateView }: Props = $props();
function getIcon(layout: string) {
switch (layout) {
case 'grid':
return GridFour;
case 'fokus':
return Target;
default:
return Columns;
}
}
</script>
<div class="flex items-center gap-1">
{#each views as view (view.id)}
{@const Icon = getIcon(view.layout)}
<button
onclick={() => onSelect(view.id)}
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors
{activeViewId === view.id
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
>
<Icon size={14} />
{view.name}
</button>
{/each}
<button
onclick={onCreateView}
class="flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Neues Board"
>
<Plus size={12} />
</button>
</div>

View file

@ -0,0 +1,8 @@
export { default as BoardViewRenderer } from './BoardViewRenderer.svelte';
export { default as KanbanLayout } from './KanbanLayout.svelte';
export { default as GridLayout } from './GridLayout.svelte';
export { default as FokusLayout } from './FokusLayout.svelte';
export { default as ViewColumn } from './ViewColumn.svelte';
export { default as ViewColumnHeader } from './ViewColumnHeader.svelte';
export { default as ViewSelector } from './ViewSelector.svelte';
export { default as ViewEditorModal } from './ViewEditorModal.svelte';

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { Timer } from '@manacore/shared-icons';
interface Props {
value: number | null;
onChange: (minutes: number | null) => void;
}
let { value, onChange }: Props = $props();
const presets = [
{ label: '-', value: null },
{ label: '15m', value: 15 },
{ label: '30m', value: 30 },
{ label: '1h', value: 60 },
{ label: '2h', value: 120 },
{ label: '4h', value: 240 },
{ label: '8h', value: 480 },
];
function formatDuration(mins: number | null): string {
if (mins == null) return '-';
if (mins < 60) return `${mins}m`;
const h = Math.floor(mins / 60);
const m = mins % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
</script>
<div class="flex items-center gap-1.5">
<Timer size={14} class="text-muted-foreground" />
<div class="flex gap-0.5">
{#each presets as preset}
<button
type="button"
onclick={() => onChange(preset.value)}
class="rounded px-1.5 py-0.5 text-[0.625rem] font-medium transition-colors
{value === preset.value
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
>
{preset.label}
</button>
{/each}
</div>
{#if value && !presets.some((p) => p.value === value)}
<span class="text-xs text-primary">{formatDuration(value)}</span>
{/if}
</div>

View file

@ -0,0 +1,41 @@
<script lang="ts">
import { Smiley, SmileyMeh, SmileySad } from '@manacore/shared-icons';
interface Props {
value: number | null;
onChange: (rating: number | null) => void;
}
let { value, onChange }: Props = $props();
const ratings = [1, 2, 3, 4, 5];
function getColor(r: number): string {
if (r <= 2) return '#ef4444';
if (r === 3) return '#f59e0b';
return '#22c55e';
}
</script>
<div class="flex gap-0.5">
{#each ratings as r}
<button
type="button"
onclick={() => onChange(value === r ? null : r)}
class="flex h-6 w-6 items-center justify-center rounded transition-colors
{value === r ? 'ring-1' : 'opacity-40 hover:opacity-70'}"
style={value === r
? `color: ${getColor(r)}; --tw-ring-color: ${getColor(r)}`
: `color: ${getColor(r)}`}
title="Spaß-Faktor {r}/5"
>
{#if r <= 2}
<SmileySad size={16} weight={value === r ? 'fill' : 'regular'} />
{:else if r === 3}
<SmileyMeh size={16} weight={value === r ? 'fill' : 'regular'} />
{:else}
<Smiley size={16} weight={value === r ? 'fill' : 'regular'} />
{/if}
</button>
{/each}
</div>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import type { TaskPriority } from '../../types';
import { getPriorityLabel, getPriorityColor } from '../../queries';
import { Flag } from '@manacore/shared-icons';
interface Props {
value: TaskPriority;
onChange: (priority: TaskPriority) => void;
}
let { value, onChange }: Props = $props();
const priorities: TaskPriority[] = ['urgent', 'high', 'medium', 'low'];
</script>
<div class="flex gap-1">
{#each priorities as p}
<button
type="button"
onclick={() => onChange(p)}
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors
{value === p ? 'ring-1 ring-current' : 'opacity-50 hover:opacity-80'}"
style="color: {getPriorityColor(p)}; background: color-mix(in srgb, {getPriorityColor(
p
)} {value === p ? '15%' : '5%'}, transparent)"
title={getPriorityLabel(p)}
>
<Flag size={12} weight={value === p ? 'fill' : 'regular'} />
<span class="hidden sm:inline">{getPriorityLabel(p)}</span>
</button>
{/each}
</div>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { Bell } from '@manacore/shared-icons';
interface Props {
value: number | null;
onChange: (minutes: number | null) => void;
disabled?: boolean;
}
let { value, onChange, disabled = false }: Props = $props();
const options = [
{ label: 'Keine', value: null },
{ label: '5 Min', value: 5 },
{ label: '15 Min', value: 15 },
{ label: '30 Min', value: 30 },
{ label: '1 Std', value: 60 },
{ label: '1 Tag', value: 1440 },
];
</script>
<div class="flex items-center gap-1.5">
<Bell size={14} class="text-muted-foreground" />
<select
{disabled}
value={value ?? ''}
onchange={(e) => {
const v = e.currentTarget.value;
onChange(v === '' ? null : Number(v));
}}
class="rounded-md border border-border bg-transparent px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none disabled:opacity-40"
>
{#each options as opt}
<option value={opt.value ?? ''}>{opt.label}</option>
{/each}
</select>
</div>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { Lightning } from '@manacore/shared-icons';
interface Props {
value: number | null;
onChange: (points: number | null) => void;
}
let { value, onChange }: Props = $props();
const points = [1, 2, 3, 5, 8, 13];
</script>
<div class="flex items-center gap-1.5">
<Lightning size={14} class="text-muted-foreground" />
<div class="flex gap-0.5">
{#each points as p}
<button
type="button"
onclick={() => onChange(value === p ? null : p)}
class="flex h-6 w-6 items-center justify-center rounded text-[0.625rem] font-bold transition-colors
{value === p
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
>
{p}
</button>
{/each}
</div>
</div>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import type { LocalLabel } from '../../types';
import { X, Plus, Tag } from '@manacore/shared-icons';
interface Props {
selectedIds: string[];
onChange: (ids: string[]) => void;
}
let { selectedIds, onChange }: Props = $props();
const allLabels$: Observable<LocalLabel[]> = getContext('labels');
let allLabels = $state<LocalLabel[]>([]);
$effect(() => {
const sub = allLabels$.subscribe((l) => (allLabels = l));
return () => sub.unsubscribe();
});
let showPicker = $state(false);
function toggle(id: string) {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((i) => i !== id));
} else {
onChange([...selectedIds, id]);
}
}
let selectedLabels = $derived(
selectedIds
.map((id) => allLabels.find((l) => l.id === id))
.filter((l): l is LocalLabel => l != null)
);
let availableLabels = $derived(allLabels.filter((l) => !selectedIds.includes(l.id)));
</script>
<div class="flex flex-wrap items-center gap-1">
{#each selectedLabels as label (label.id)}
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem] font-medium"
style="background: color-mix(in srgb, {label.color} 15%, transparent); color: {label.color}"
>
{label.name}
<button type="button" onclick={() => toggle(label.id)} class="hover:opacity-70">
<X size={10} />
</button>
</span>
{/each}
<div class="relative">
<button
type="button"
onclick={() => (showPicker = !showPicker)}
class="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted"
>
<Tag size={12} />
<Plus size={10} />
</button>
{#if showPicker && availableLabels.length > 0}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute left-0 top-full z-50 mt-1 min-w-[140px] rounded-lg border border-border bg-card p-1 shadow-lg"
onclick|stopPropagation
>
{#each availableLabels as label (label.id)}
<button
type="button"
onclick={() => {
toggle(label.id);
if (availableLabels.length <= 1) showPicker = false;
}}
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs text-foreground transition-colors hover:bg-muted"
>
<span class="h-2.5 w-2.5 rounded-full" style="background-color: {label.color}"></span>
{label.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
{#if showPicker}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showPicker = false)}></div>
{/if}

View file

@ -0,0 +1,6 @@
export { default as PrioritySelector } from './PrioritySelector.svelte';
export { default as TagSelector } from './TagSelector.svelte';
export { default as ReminderSelector } from './ReminderSelector.svelte';
export { default as DurationPicker } from './DurationPicker.svelte';
export { default as StorypointsSelector } from './StorypointsSelector.svelte';
export { default as FunRatingPicker } from './FunRatingPicker.svelte';

View file

@ -0,0 +1,15 @@
<script lang="ts">
import KanbanColumnSkeleton from './KanbanColumnSkeleton.svelte';
interface Props {
columns?: number;
}
let { columns = 3 }: Props = $props();
</script>
<div class="flex gap-3 overflow-hidden pb-4">
{#each Array(columns) as _}
<KanbanColumnSkeleton />
{/each}
</div>

View file

@ -0,0 +1,16 @@
<div class="flex min-w-[260px] max-w-[340px] flex-shrink-0 flex-col rounded-xl bg-muted/30 p-2">
<div class="mb-2 flex items-center gap-2 px-2 py-2">
<div class="h-2.5 w-2.5 animate-pulse rounded-full bg-muted"></div>
<div class="h-4 w-20 animate-pulse rounded bg-muted"></div>
<div class="h-4 w-6 animate-pulse rounded-full bg-muted"></div>
</div>
{#each Array(3) as _}
<div class="mb-1.5 rounded-lg border border-border bg-card p-2.5">
<div class="mb-2 h-4 w-4/5 animate-pulse rounded bg-muted"></div>
<div class="flex gap-2">
<div class="h-3 w-14 animate-pulse rounded bg-muted"></div>
<div class="h-3 w-10 animate-pulse rounded bg-muted"></div>
</div>
</div>
{/each}
</div>

View file

@ -0,0 +1,5 @@
<div class="flex gap-4">
{#each Array(4) as _}
<div class="h-4 w-20 animate-pulse rounded bg-muted"></div>
{/each}
</div>

View file

@ -0,0 +1,10 @@
<div class="flex items-start gap-3 px-3 py-2.5">
<div class="mt-0.5 h-5 w-5 animate-pulse rounded-full bg-muted"></div>
<div class="flex-1 space-y-2">
<div class="h-4 w-3/4 animate-pulse rounded bg-muted"></div>
<div class="flex gap-3">
<div class="h-3 w-16 animate-pulse rounded bg-muted"></div>
<div class="h-3 w-12 animate-pulse rounded bg-muted"></div>
</div>
</div>
</div>

View file

@ -0,0 +1,15 @@
<script lang="ts">
import TaskItemSkeleton from './TaskItemSkeleton.svelte';
interface Props {
count?: number;
}
let { count = 5 }: Props = $props();
</script>
<div class="space-y-1">
{#each Array(count) as _, i}
<TaskItemSkeleton />
{/each}
</div>

View file

@ -0,0 +1,5 @@
export { default as TaskItemSkeleton } from './TaskItemSkeleton.svelte';
export { default as TaskListSkeleton } from './TaskListSkeleton.svelte';
export { default as KanbanColumnSkeleton } from './KanbanColumnSkeleton.svelte';
export { default as KanbanBoardSkeleton } from './KanbanBoardSkeleton.svelte';
export { default as StatisticsSkeleton } from './StatisticsSkeleton.svelte';

View file

@ -0,0 +1,233 @@
/**
* useTaskForm Shared form state logic for TaskEditModal & inline editing.
*/
import type { Task, TaskPriority, Subtask } from '../types';
import { reminderTable } from '../collections';
export interface TaskFormState {
title: string;
description: string;
dueDate: string;
dueTime: string;
startDate: string;
priority: TaskPriority;
status: string;
selectedLabelIds: string[];
subtasks: Subtask[];
recurrenceRule: string;
storyPoints: number | null;
effectiveDuration: number | null;
funRating: number | null;
reminderMinutes: number | null;
showDeleteConfirm: boolean;
isLoading: boolean;
}
export function useTaskForm() {
let title = $state('');
let description = $state('');
let dueDate = $state('');
let dueTime = $state('');
let startDate = $state('');
let priority = $state<TaskPriority>('medium');
let status = $state('pending');
let selectedLabelIds = $state<string[]>([]);
let subtasks = $state<Subtask[]>([]);
let recurrenceRule = $state('');
let storyPoints = $state<number | null>(null);
let effectiveDuration = $state<number | null>(null);
let funRating = $state<number | null>(null);
let reminderMinutes = $state<number | null>(null);
let showDeleteConfirm = $state(false);
let isLoading = $state(false);
function initFromTask(task: Task) {
title = task.title;
description = task.description ?? '';
dueDate = task.dueDate ? task.dueDate.split('T')[0] : '';
dueTime = task.scheduledStartTime ?? '';
startDate = task.scheduledDate ? task.scheduledDate.split('T')[0] : '';
priority = task.priority;
status = task.status;
subtasks = task.subtasks ? [...task.subtasks] : [];
recurrenceRule = task.recurrenceRule ?? '';
effectiveDuration = task.estimatedDuration ?? null;
showDeleteConfirm = false;
isLoading = false;
const meta = task.metadata as Record<string, unknown> | null;
selectedLabelIds = (meta?.labelIds as string[]) ?? [];
storyPoints = (meta?.storyPoints as number) ?? null;
funRating = (meta?.funRating as number) ?? null;
reminderMinutes = null;
// Load existing reminder
loadReminder(task.id);
}
async function loadReminder(taskId: string) {
try {
const reminders = await reminderTable.where('taskId').equals(taskId).toArray();
const active = reminders.find((r) => !r.deletedAt && r.status === 'pending');
if (active) {
reminderMinutes = active.minutesBefore;
}
} catch {
// Reminders table may not exist yet
}
}
function buildUpdatePayload(): Record<string, unknown> {
const update: Record<string, unknown> = {
title: title.trim(),
description: description || undefined,
priority,
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
scheduledDate: startDate ? new Date(startDate).toISOString() : null,
scheduledStartTime: dueTime || null,
estimatedDuration: effectiveDuration,
recurrenceRule: recurrenceRule || null,
subtasks: subtasks.length > 0 ? subtasks : null,
isCompleted: status === 'completed',
completedAt: status === 'completed' ? new Date().toISOString() : null,
metadata: {
labelIds: selectedLabelIds,
storyPoints,
funRating,
},
};
return update;
}
async function persistReminder(taskId: string) {
try {
// Remove old reminders
const existing = await reminderTable.where('taskId').equals(taskId).toArray();
for (const r of existing) {
if (!r.deletedAt) {
await reminderTable.update(r.id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
}
// Create new if set
if (reminderMinutes != null && dueDate) {
await reminderTable.add({
id: crypto.randomUUID(),
taskId,
minutesBefore: reminderMinutes,
type: 'push',
status: 'pending',
});
}
} catch {
// Silently ignore if table doesn't exist
}
}
return {
get title() {
return title;
},
set title(v: string) {
title = v;
},
get description() {
return description;
},
set description(v: string) {
description = v;
},
get dueDate() {
return dueDate;
},
set dueDate(v: string) {
dueDate = v;
},
get dueTime() {
return dueTime;
},
set dueTime(v: string) {
dueTime = v;
},
get startDate() {
return startDate;
},
set startDate(v: string) {
startDate = v;
},
get priority() {
return priority;
},
set priority(v: TaskPriority) {
priority = v;
},
get status() {
return status;
},
set status(v: string) {
status = v;
},
get selectedLabelIds() {
return selectedLabelIds;
},
set selectedLabelIds(v: string[]) {
selectedLabelIds = v;
},
get subtasks() {
return subtasks;
},
set subtasks(v: Subtask[]) {
subtasks = v;
},
get recurrenceRule() {
return recurrenceRule;
},
set recurrenceRule(v: string) {
recurrenceRule = v;
},
get storyPoints() {
return storyPoints;
},
set storyPoints(v: number | null) {
storyPoints = v;
},
get effectiveDuration() {
return effectiveDuration;
},
set effectiveDuration(v: number | null) {
effectiveDuration = v;
},
get funRating() {
return funRating;
},
set funRating(v: number | null) {
funRating = v;
},
get reminderMinutes() {
return reminderMinutes;
},
set reminderMinutes(v: number | null) {
reminderMinutes = v;
},
get showDeleteConfirm() {
return showDeleteConfirm;
},
set showDeleteConfirm(v: boolean) {
showDeleteConfirm = v;
},
get isLoading() {
return isLoading;
},
set isLoading(v: boolean) {
isLoading = v;
},
initFromTask,
buildUpdatePayload,
persistReminder,
};
}

View file

@ -2,10 +2,17 @@
* Todo module barrel exports.
*/
// Stores
export { tasksStore } from './stores/tasks.svelte';
export { boardViewsStore } from './stores/board-views.svelte';
export { viewStore } from './stores/view.svelte';
export { labelsStore } from './stores/labels.svelte';
export { remindersStore } from './stores/reminders.svelte';
export { todoSettings } from './stores/settings.svelte';
export { minimizedPagesStore } from './stores/minimized-pages.svelte';
export { contactsStore } from './stores/contacts.svelte';
// Queries
export {
useAllTasks,
useAllLabels,
@ -25,6 +32,8 @@ export {
getPriorityColor,
getTaskStats,
} from './queries';
// Collections
export {
taskTable,
todoProjectTable,
@ -34,6 +43,26 @@ export {
boardViewTable,
TODO_GUEST_SEED,
} from './collections';
// View Grouping
export { groupTasksByView, getDropActionUpdate } from './view-grouping';
export type { GroupedColumn } from './view-grouping';
// Utilities
export {
parseTaskInput,
parseMultiTaskInput,
resolveTaskIds,
formatDuration,
} from './utils/task-parser';
export { estimateDuration } from './utils/time-estimator';
export type { ParsedTask, ParsedTaskWithIds } from './utils/task-parser';
export type { DurationEstimate } from './utils/time-estimator';
// Composables
export { useTaskForm } from './composables/useTaskForm.svelte';
// Types
export type {
LocalTask,
LocalLabel,

View file

@ -0,0 +1,66 @@
/**
* Contacts Integration Store Task assignment via Contacts app.
*
* Checks if the Contacts module is available and provides search functionality.
*/
import { db } from '$lib/data/database';
export interface ContactResult {
id: string;
name: string;
email?: string;
}
let isAvailable = $state(false);
let cache = $state<Map<string, ContactResult>>(new Map());
async function checkAvailability() {
try {
const tables = db.tables.map((t) => t.name);
isAvailable = tables.includes('contacts');
} catch {
isAvailable = false;
}
}
// Check on init
checkAvailability();
export const contactsStore = {
get isAvailable() {
return isAvailable;
},
async searchContacts(query: string): Promise<ContactResult[]> {
if (!isAvailable || !query.trim()) return [];
try {
const q = query.toLowerCase();
const all = await db.table('contacts').toArray();
return all
.filter((c: any) => {
if (c.deletedAt) return false;
const name = `${c.firstName ?? ''} ${c.lastName ?? ''}`.toLowerCase();
const email = (c.email ?? '').toLowerCase();
return name.includes(q) || email.includes(q);
})
.slice(0, 10)
.map((c: any) => {
const result: ContactResult = {
id: c.id,
name: `${c.firstName ?? ''} ${c.lastName ?? ''}`.trim(),
email: c.email,
};
cache.set(c.id, result);
return result;
});
} catch {
return [];
}
},
getFromCache(id: string): ContactResult | undefined {
return cache.get(id);
},
};

View file

@ -0,0 +1,63 @@
/**
* Minimized Pages Store Multi-page system with minimized tabs.
*
* Allows users to "minimize" views to a tab bar and restore them later.
*/
export interface MinimizedPage {
id: string;
title: string;
icon?: string;
route?: string;
}
let pages = $state<MinimizedPage[]>([]);
let activePageId = $state<string | null>(null);
let showPicker = $state(false);
export const minimizedPagesStore = {
get pages() {
return pages;
},
get activePageId() {
return activePageId;
},
get showPicker() {
return showPicker;
},
minimize(page: MinimizedPage) {
if (!pages.find((p) => p.id === page.id)) {
pages = [...pages, page];
}
},
restore(id: string) {
activePageId = id;
},
remove(id: string) {
pages = pages.filter((p) => p.id !== id);
if (activePageId === id) {
activePageId = null;
}
},
maximize(id: string) {
activePageId = id;
},
togglePicker() {
showPicker = !showPicker;
},
closePicker() {
showPicker = false;
},
clear() {
pages = [];
activePageId = null;
showPicker = false;
},
};

View file

@ -0,0 +1,61 @@
/**
* Reminders Store Mutation-Only Service
*
* Reads via liveQuery (useAllReminders in queries.ts).
* This store handles create and delete operations for task reminders.
*/
import { reminderTable } from '../collections';
import type { LocalReminder } from '../types';
export const remindersStore = {
async createReminder(data: {
taskId: string;
minutesBefore: number;
type?: 'push' | 'email' | 'both';
}) {
const newReminder: LocalReminder = {
id: crypto.randomUUID(),
taskId: data.taskId,
minutesBefore: data.minutesBefore,
type: data.type ?? 'push',
status: 'pending',
};
await reminderTable.add(newReminder);
return newReminder;
},
async deleteReminder(id: string) {
await reminderTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async deleteRemindersForTask(taskId: string) {
const reminders = await reminderTable.where('taskId').equals(taskId).toArray();
for (const r of reminders) {
if (!r.deletedAt) {
await reminderTable.update(r.id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
}
},
async replaceReminder(
taskId: string,
minutesBefore: number | null,
type?: 'push' | 'email' | 'both'
) {
// Delete existing
await this.deleteRemindersForTask(taskId);
// Create new if set
if (minutesBefore != null) {
return this.createReminder({ taskId, minutesBefore, type });
}
return null;
},
};

View file

@ -0,0 +1,100 @@
/**
* Smart Duration Estimation History-based task duration estimation.
*
* Analyzes completed tasks with known durations to suggest durations for new tasks.
* Uses weighted scoring by: labels, title similarity, priority.
* Fully offline reads from IndexedDB.
*/
import { taskTable } from '../collections';
import type { LocalTask, TaskPriority } from '../types';
export interface DurationEstimate {
minutes: number;
confidence: 'low' | 'medium' | 'high';
sampleSize: number;
}
/**
* Estimate duration for a new task based on historical data.
*/
export async function estimateDuration(
title: string,
options?: {
priority?: TaskPriority;
labelIds?: string[];
defaultDuration?: number;
}
): Promise<DurationEstimate | null> {
const allTasks = await taskTable.toArray();
const completed = allTasks.filter(
(t) => t.isCompleted && t.estimatedDuration && t.estimatedDuration > 0 && !t.deletedAt
);
if (completed.length < 3) return null;
// Score each historical task by relevance
const scored = completed.map((t) => {
let score = 1;
// Title similarity (simple word overlap)
const titleWords = new Set(
title
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 2)
);
const taskWords = new Set(
t.title
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 2)
);
let overlap = 0;
for (const w of titleWords) {
if (taskWords.has(w)) overlap++;
}
if (titleWords.size > 0) {
score += (overlap / titleWords.size) * 3;
}
// Priority match
if (options?.priority && t.priority === options.priority) {
score += 1;
}
// Label overlap
if (options?.labelIds && options.labelIds.length > 0) {
const taskLabels: string[] = (t.metadata as { labelIds?: string[] })?.labelIds ?? [];
const labelOverlap = options.labelIds.filter((id) => taskLabels.includes(id)).length;
score += labelOverlap * 1.5;
}
return { task: t, score, duration: t.estimatedDuration! };
});
// Sort by score descending and take top matches
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, 10);
if (top.length === 0) return null;
// Weighted average
const totalWeight = top.reduce((sum, s) => sum + s.score, 0);
const weightedAvg = top.reduce((sum, s) => sum + s.duration * s.score, 0) / totalWeight;
// Round to nearest 5 minutes
const minutes = Math.round(weightedAvg / 5) * 5;
// Determine confidence
const maxScore = Math.max(...top.map((s) => s.score));
let confidence: DurationEstimate['confidence'] = 'low';
if (top.length >= 5 && maxScore > 3) confidence = 'high';
else if (top.length >= 3 && maxScore > 2) confidence = 'medium';
return {
minutes: Math.max(5, minutes),
confidence,
sampleSize: top.length,
};
}

View file

@ -1,16 +1,15 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { getContext, onMount } from 'svelte';
import type { Observable } from 'dexie';
import { dropTarget } from '@manacore/shared-ui/dnd';
import type { DragPayload, TagDragData } from '@manacore/shared-ui/dnd';
import { useAllTags } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import {
type Task,
type LocalLabel,
type LocalBoardView,
type LocalTodoProject,
type TaskPriority,
tasksStore,
taskTable,
viewStore,
@ -22,70 +21,80 @@
filterByProject,
searchTasks,
sortTasks,
getPriorityLabel,
getPriorityColor,
getTaskStats,
} from '$lib/modules/todo';
import {
Plus,
Check,
Circle,
MagnifyingGlass,
Tray,
CalendarBlank,
CalendarCheck,
Flag,
FunnelSimple,
CaretRight,
Folder,
CheckCircle,
ShareNetwork,
MagnifyingGlass,
Gear,
} from '@manacore/shared-icons';
import { ShareModal } from '@manacore/shared-uload';
// Components
import TaskList from '$lib/modules/todo/components/TaskList.svelte';
import TaskEditModal from '$lib/modules/todo/components/TaskEditModal.svelte';
import QuickAddTask from '$lib/modules/todo/components/QuickAddTask.svelte';
import TodoToolbar from '$lib/modules/todo/components/TodoToolbar.svelte';
import TagStrip from '$lib/modules/todo/components/TagStrip.svelte';
import SyncIndicator from '$lib/modules/todo/components/SyncIndicator.svelte';
import SyntaxHelpOverlay from '$lib/modules/todo/components/SyntaxHelpOverlay.svelte';
import OnboardingModal from '$lib/modules/todo/components/OnboardingModal.svelte';
import { TaskListSkeleton, StatisticsSkeleton } from '$lib/modules/todo/components/skeletons';
import {
BoardViewRenderer,
ViewSelector,
ViewEditorModal,
} from '$lib/modules/todo/components/board-views';
import { todoSettings } from '$lib/modules/todo/stores/settings.svelte';
// Get data from layout context
const allTasks$: Observable<Task[]> = getContext('tasks');
const allLabels$: Observable<LocalLabel[]> = getContext('labels');
const allProjects$: Observable<LocalTodoProject[]> = getContext('projects');
const allBoardViews$: Observable<LocalBoardView[]> = getContext('boardViews');
let allTasks = $state<Task[]>([]);
let allLabels = $state<LocalLabel[]>([]);
let allProjects = $state<LocalTodoProject[]>([]);
let allBoardViews = $state<LocalBoardView[]>([]);
let isLoaded = $state(false);
$effect(() => {
const sub = allTasks$.subscribe((t) => {
allTasks = t;
isLoaded = true;
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = allLabels$.subscribe((l) => {
allLabels = l;
});
const sub = allLabels$.subscribe((l) => (allLabels = l));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = allProjects$.subscribe((p) => {
allProjects = p;
});
const sub = allProjects$.subscribe((p) => (allProjects = p));
return () => sub.unsubscribe();
});
// Global tags for resolving labelIds to names/colors
$effect(() => {
const sub = allBoardViews$.subscribe((v) => (allBoardViews = v));
return () => sub.unsubscribe();
});
// Tags for resolving labelIds
const globalTags = useAllTags();
const tagMap = $derived(new Map((globalTags.value ?? []).map((t) => [t.id, t])));
const tagList = $derived(
(globalTags.value ?? []).map((t) => ({ id: t.id, name: t.name, color: t.color }))
);
function getTaskTags(task: Task) {
const ids: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? [];
return ids.map((id) => tagMap.get(id)).filter((t): t is NonNullable<typeof t> => t != null);
}
// Task stats
// Stats
let stats = $derived(getTaskStats(allTasks));
// Filtered tasks based on current view
// Filtered tasks
let displayTasks = $derived.by(() => {
let tasks = allTasks;
switch (viewStore.currentView) {
@ -101,44 +110,52 @@
case 'search':
tasks = searchTasks(allTasks, viewStore.searchQuery);
break;
case 'label':
tasks = filterIncomplete(allTasks).filter((t) => {
const ids: string[] = (t.metadata as { labelIds?: string[] })?.labelIds ?? [];
return ids.includes(viewStore.currentLabelId ?? '');
});
break;
default:
tasks = filterIncomplete(allTasks);
}
if (viewStore.showCompleted && viewStore.currentView !== 'completed') {
tasks = [...tasks, ...filterCompleted(allTasks)];
}
return sortTasks(tasks, viewStore.sortBy, viewStore.sortOrder);
});
// Share modal state
// Board view state
let isBoardView = $state(false);
let activeBoardId = $state<string | null>(null);
let activeBoard = $derived(
allBoardViews.find((v) => v.id === activeBoardId) ?? allBoardViews[0] ?? null
);
// Modal states
let editTask = $state<Task | null>(null);
let shareTask = $state<Task | null>(null);
let showSyntaxHelp = $state(false);
let showBoardEditor = $state(false);
let editBoardView = $state<LocalBoardView | null>(null);
let showOnboarding = $state(false);
let shareUrl = $derived(
shareTask
? `${typeof window !== 'undefined' ? window.location.origin : ''}/todo?task=${shareTask.id}`
: ''
);
// Quick add task
let newTaskTitle = $state('');
let isAdding = $state(false);
// Check onboarding
onMount(() => {
try {
if (!localStorage.getItem('todo-onboarding-done')) {
showOnboarding = true;
}
} catch {}
});
async function handleQuickAdd() {
if (!newTaskTitle.trim()) return;
await tasksStore.createTask({ title: newTaskTitle.trim() });
newTaskTitle = '';
isAdding = false;
}
async function handleToggleComplete(e: MouseEvent, task: Task) {
e.stopPropagation();
e.preventDefault();
await tasksStore.toggleComplete(task.id);
}
async function handleDeleteTask(e: MouseEvent, task: Task) {
e.stopPropagation();
e.preventDefault();
await tasksStore.deleteTask(task.id);
}
// ── DnD: register tag drop handler for passive drops (task→tag in TagStrip)
// DnD tag drop handler
const tagDropCtx = getContext<{
set: (handler: (tagId: string, payload: DragPayload) => void) => void;
clear: () => void;
@ -157,109 +174,104 @@
return () => tagDropCtx?.clear();
});
// ── DnD: tag dropped onto a task ────────────────────────
function handleTagDrop(task: Task, payload: DragPayload) {
const tagData = payload.data as TagDragData;
const currentLabels: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? [];
if (!currentLabels.includes(tagData.id)) {
tasksStore.updateLabels(task.id, [...currentLabels, tagData.id]);
}
// Board view callbacks
async function handleBoardQuickAdd(title: string, _columnId: string) {
await tasksStore.createTask({ title });
}
function tagNotAlreadyOnTask(task: Task) {
return (payload: DragPayload) => {
const tagData = payload.data as TagDragData;
const currentLabels: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? [];
return !currentLabels.includes(tagData.id);
};
}
// View navigation items
const views = [
{ id: 'inbox', label: 'Inbox', icon: Tray },
{ id: 'today', label: 'Heute', icon: CalendarBlank },
{ id: 'upcoming', label: 'Bald faellig', icon: CalendarCheck },
{ id: 'completed', label: 'Erledigt', icon: CheckCircle },
] as const;
let selectedTaskId = $state<string | null>(null);
let selectedTask = $derived(allTasks.find((t) => t.id === selectedTaskId));
function formatDueDate(date: string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrowStart = new Date(todayStart);
tomorrowStart.setDate(tomorrowStart.getDate() + 1);
if (d < todayStart) return 'Ueberfaellig';
if (d >= todayStart && d < tomorrowStart) return 'Heute';
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
function getDueDateColor(date: string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (d < todayStart) return 'text-red-500';
return 'text-muted-foreground';
}
// View navigation
let views = $derived([
{ id: 'inbox', label: $_('todo.inbox'), icon: Tray },
{ id: 'today', label: $_('todo.todayView'), icon: CalendarBlank },
{ id: 'upcoming', label: $_('todo.upcoming'), icon: CalendarCheck },
{ id: 'completed', label: $_('todo.completedView'), icon: CheckCircle },
] as const);
</script>
<svelte:head>
<title>Todo - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<!-- Header with Stats -->
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Todo</h1>
<div class="mt-2 flex gap-4 text-sm text-muted-foreground">
<span>{stats.total} Aufgaben</span>
<span>{stats.completed} erledigt</span>
{#if stats.overdue > 0}
<span class="text-red-500">{stats.overdue} ueberfaellig</span>
{/if}
{#if stats.today > 0}
<span class="text-amber-500">{stats.today} heute</span>
<div class="mx-auto max-w-4xl">
<!-- Header -->
<header class="mb-4 flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">{$_('todo.title')}</h1>
{#if isLoaded}
<div class="mt-1 flex gap-4 text-sm text-muted-foreground">
<span>{stats.total} {$_('todo.tasks')}</span>
<span>{stats.completed} {$_('todo.completed')}</span>
{#if stats.overdue > 0}
<span class="text-red-500">{stats.overdue} {$_('todo.overdue')}</span>
{/if}
{#if stats.today > 0}
<span class="text-amber-500">{stats.today} {$_('todo.today')}</span>
{/if}
</div>
{:else}
<StatisticsSkeleton />
{/if}
</div>
<div class="flex items-center gap-2">
<SyncIndicator />
<a
href="/todo/settings"
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
title={$_('todo.settings.title')}
>
<Gear size={16} />
</a>
</div>
</header>
<!-- View Tabs -->
<div class="mb-4 flex gap-1 rounded-lg border border-border bg-card p-1">
{#each views as view}
<button
onclick={() => {
switch (view.id) {
case 'inbox':
viewStore.setInbox();
break;
case 'today':
viewStore.setToday();
break;
case 'upcoming':
viewStore.setUpcoming();
break;
case 'completed':
viewStore.setCompleted();
break;
}
}}
class="flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors
{viewStore.currentView === view.id
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
>
<view.icon size={16} />
<span class="hidden sm:inline">{view.label}</span>
</button>
{/each}
<!-- View Tabs + Toolbar -->
<div class="mb-3 flex items-center justify-between gap-2">
<div class="flex gap-1 rounded-lg border border-border bg-card p-1">
{#each views as view}
<button
onclick={() => {
isBoardView = false;
switch (view.id) {
case 'inbox':
viewStore.setInbox();
break;
case 'today':
viewStore.setToday();
break;
case 'upcoming':
viewStore.setUpcoming();
break;
case 'completed':
viewStore.setCompleted();
break;
}
}}
class="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors
{!isBoardView && viewStore.currentView === view.id
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
>
<view.icon size={16} />
<span class="hidden sm:inline">{view.label}</span>
</button>
{/each}
</div>
<TodoToolbar
showBoardToggle={allBoardViews.length > 0}
{isBoardView}
onToggleBoard={() => (isBoardView = !isBoardView)}
/>
</div>
<!-- Search (for search view) -->
<!-- Tag Strip -->
<TagStrip
labels={allLabels}
collapsed={todoSettings.filterStripCollapsed}
onToggleCollapse={() => todoSettings.toggleFilterStrip()}
/>
<!-- Search -->
{#if viewStore.currentView === 'search'}
<div class="relative mb-4">
<MagnifyingGlass
@ -268,246 +280,95 @@
/>
<input
type="text"
placeholder="Aufgaben suchen..."
placeholder={$_('todo.search') + '...'}
value={viewStore.searchQuery}
oninput={(e) => viewStore.updateSearchQuery(e.currentTarget.value)}
class="w-full rounded-lg border border-border bg-card py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
autofocus
/>
</div>
{/if}
<!-- Board View Selector -->
{#if isBoardView}
<div class="mb-4">
<ViewSelector
views={allBoardViews}
activeViewId={activeBoardId}
onSelect={(id) => (activeBoardId = id)}
onCreateView={() => {
editBoardView = null;
showBoardEditor = true;
}}
/>
</div>
{/if}
<!-- Quick Add -->
{#if isAdding}
<div class="mb-4 rounded-lg border border-primary bg-card p-3">
<form
onsubmit={(e) => {
e.preventDefault();
handleQuickAdd();
}}
class="flex gap-2"
>
<input
type="text"
placeholder="Was moechtest du erledigen?"
bind:value={newTaskTitle}
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autofocus
/>
<button
type="submit"
disabled={!newTaskTitle.trim()}
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground disabled:opacity-50"
>
Hinzufuegen
</button>
<button
type="button"
onclick={() => {
isAdding = false;
newTaskTitle = '';
}}
class="text-xs text-muted-foreground hover:text-foreground"
>
Abbrechen
</button>
</form>
</div>
{:else}
<button
onclick={() => (isAdding = true)}
class="mb-4 flex w-full items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary hover:text-primary"
>
<Plus size={16} />
Neue Aufgabe
</button>
{#if !isBoardView}
<QuickAddTask labels={allLabels} onShowSyntaxHelp={() => (showSyntaxHelp = true)} />
{/if}
<!-- Task List -->
{#if displayTasks.length === 0}
<!-- Main Content -->
{#if !isLoaded}
<TaskListSkeleton />
{:else if isBoardView && activeBoard}
<BoardViewRenderer
view={activeBoard}
tasks={allTasks}
labels={allLabels}
wipLimit={todoSettings.wipLimitPerColumn}
cardSize={todoSettings.kanbanCardSize}
onToggleComplete={(id) => tasksStore.toggleComplete(id)}
onSaveTask={(id, data) => tasksStore.updateTask(id, data)}
onDeleteTask={(id) => tasksStore.deleteTask(id)}
onQuickAdd={handleBoardQuickAdd}
onOpenTask={(task) => (editTask = task)}
/>
{:else if displayTasks.length === 0}
<div class="flex flex-col items-center py-12 text-center">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Tray size={32} class="text-muted-foreground" />
</div>
<h2 class="mb-1 text-lg font-semibold text-foreground">
{#if viewStore.currentView === 'completed'}
Noch keine Aufgaben erledigt
{$_('todo.noTasksCompleted')}
{:else if viewStore.currentView === 'today'}
Keine Aufgaben fuer heute
{$_('todo.noTasksToday')}
{:else if viewStore.currentView === 'upcoming'}
Keine anstehenden Aufgaben
{$_('todo.noTasksUpcoming')}
{:else if viewStore.currentView === 'search'}
{$_('todo.noTasks')}
{:else}
Inbox ist leer
{$_('todo.noTasksInbox')}
{/if}
</h2>
<p class="text-sm text-muted-foreground">
{#if viewStore.currentView === 'inbox'}
Erstelle deine erste Aufgabe mit dem + Button oben.
{$_('todo.firstTaskHint')}
{/if}
</p>
</div>
{:else}
<div class="space-y-1">
{#each displayTasks as task (task.id)}
<div
class="group flex items-start gap-3 rounded-lg border border-transparent px-3 py-2.5 transition-colors hover:border-border hover:bg-card"
role="button"
tabindex="0"
onclick={() => (selectedTaskId = selectedTaskId === task.id ? null : task.id)}
use:dropTarget={{
accepts: ['tag'],
onDrop: (payload) => handleTagDrop(task, payload),
canDrop: tagNotAlreadyOnTask(task),
}}
>
<!-- Completion Toggle -->
<button
onclick={(e) => handleToggleComplete(e, task)}
class="mt-0.5 flex-shrink-0 transition-colors
{task.isCompleted ? 'text-green-500' : `text-muted-foreground hover:text-primary`}"
title={task.isCompleted ? 'Als offen markieren' : 'Als erledigt markieren'}
>
{#if task.isCompleted}
<Check size={20} weight="bold" />
{:else}
<Circle size={20} />
{/if}
</button>
<!-- Task Content -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="text-sm {task.isCompleted
? 'line-through text-muted-foreground'
: 'text-foreground'}"
>
{task.title}
</span>
</div>
<!-- Metadata Row -->
<div class="mt-0.5 flex items-center gap-3 text-xs">
{#if task.dueDate}
<span class={getDueDateColor(task.dueDate)}>
{formatDueDate(task.dueDate)}
</span>
{/if}
{#if task.priority !== 'medium'}
<span style="color: {getPriorityColor(task.priority)}">
{getPriorityLabel(task.priority)}
</span>
{/if}
{#if task.subtasks?.length}
<span class="text-muted-foreground">
{task.subtasks.filter((s) => s.isCompleted).length}/{task.subtasks.length} Teilaufgaben
</span>
{/if}
{#each getTaskTags(task).slice(0, 3) as tag (tag.id)}
<span
class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[0.625rem] font-medium"
style="background: color-mix(in srgb, {tag.color} 15%, transparent); color: {tag.color}"
>
{tag.name}
</span>
{/each}
</div>
<!-- Expanded Detail -->
{#if selectedTaskId === task.id}
<div class="mt-3 space-y-2 border-t border-border pt-3">
{#if task.description}
<p class="text-sm text-muted-foreground">{task.description}</p>
{/if}
{#if task.subtasks?.length}
<div class="space-y-1">
{#each task.subtasks as subtask (subtask.id)}
<div class="flex items-center gap-2 text-sm">
{#if subtask.isCompleted}
<Check size={14} class="text-green-500" />
{:else}
<Circle size={14} class="text-muted-foreground" />
{/if}
<span
class={subtask.isCompleted
? 'line-through text-muted-foreground'
: 'text-foreground'}
>
{subtask.title}
</span>
</div>
{/each}
</div>
{/if}
<div class="flex gap-2 pt-1">
<select
value={task.priority}
onchange={(e) =>
tasksStore.updateTask(task.id, {
priority: e.currentTarget.value as TaskPriority,
})}
class="rounded-md border border-border bg-background px-2 py-1 text-xs focus:border-primary focus:outline-none"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="urgent">Dringend</option>
</select>
<input
type="date"
value={task.dueDate ? task.dueDate.split('T')[0] : ''}
onchange={(e) =>
tasksStore.updateTask(task.id, {
dueDate: e.currentTarget.value
? new Date(e.currentTarget.value).toISOString()
: null,
})}
class="rounded-md border border-border bg-background px-2 py-1 text-xs focus:border-primary focus:outline-none"
/>
<button
onclick={(e) => {
e.stopPropagation();
shareTask = task;
}}
class="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted"
title="Kurzlink teilen"
>
<ShareNetwork size={14} />
</button>
<button
onclick={(e) => handleDeleteTask(e, task)}
class="ml-auto rounded-md px-2 py-1 text-xs text-red-500 transition-colors hover:bg-red-500/10"
>
Loeschen
</button>
</div>
</div>
{/if}
</div>
<!-- Priority indicator -->
{#if task.priority === 'urgent' || task.priority === 'high'}
<div
class="mt-1 h-2 w-2 flex-shrink-0 rounded-full"
style="background-color: {getPriorityColor(task.priority)}"
title={getPriorityLabel(task.priority)}
></div>
{/if}
</div>
{/each}
</div>
<TaskList
tasks={displayTasks}
tags={tagList}
compact={todoSettings.compactMode}
dragEnabled={viewStore.sortBy === 'order'}
onOpenTask={(task) => (editTask = task)}
/>
<p class="mt-4 text-center text-xs text-muted-foreground">
{displayTasks.length} Aufgabe{displayTasks.length !== 1 ? 'n' : ''}
{displayTasks.length}
{$_('todo.tasks')}
</p>
{/if}
<!-- Projects Section -->
{#if allProjects.length > 0}
{#if !isBoardView && allProjects.length > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Projekte
{$_('todo.projects')}
</h2>
<div class="space-y-1">
{#each allProjects as project (project.id)}
@ -519,33 +380,6 @@
style="background-color: {project.color ?? '#6b7280'}"
></div>
<span class="flex-1 text-left text-foreground">{project.name}</span>
<CaretRight size={14} class="text-muted-foreground" />
</button>
{/each}
</div>
</div>
{/if}
<!-- Labels Section -->
{#if allLabels.length > 0}
<div class="mt-6">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Labels
</h2>
<div class="flex flex-wrap gap-2">
{#each allLabels as label (label.id)}
<button
onclick={() => viewStore.setLabel(label.id)}
class="rounded-full border px-3 py-1 text-xs font-medium transition-colors
{viewStore.currentView === 'label' && viewStore.currentLabelId === label.id
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:border-primary/50'}"
>
<span
class="mr-1 inline-block h-2 w-2 rounded-full"
style="background-color: {label.color}"
></span>
{label.name}
</button>
{/each}
</div>
@ -553,7 +387,25 @@
{/if}
</div>
<!-- Share Modal (uLoad integration) -->
<!-- Task Edit Modal -->
{#if editTask}
<TaskEditModal task={editTask} open={true} onClose={() => (editTask = null)} />
{/if}
<!-- Board Editor Modal -->
<ViewEditorModal
view={editBoardView}
open={showBoardEditor}
onClose={() => (showBoardEditor = false)}
/>
<!-- Syntax Help Overlay -->
<SyntaxHelpOverlay open={showSyntaxHelp} onClose={() => (showSyntaxHelp = false)} />
<!-- Onboarding Modal -->
<OnboardingModal open={showOnboarding} onClose={() => (showOnboarding = false)} />
<!-- Share Modal -->
<ShareModal
visible={shareTask !== null}
onClose={() => (shareTask = null)}
@ -564,7 +416,6 @@
/>
<style>
/* DnD: tag hovering over task item */
:global(.mana-drop-target-hover) {
outline: 2px solid var(--color-primary, #6366f1);
outline-offset: -2px;

View file

@ -0,0 +1,345 @@
<script lang="ts">
import {
todoSettings,
type KanbanCardSize,
type LayoutMode,
} from '$lib/modules/todo/stores/settings.svelte';
import type { TaskPriority } from '$lib/modules/todo/types';
import { ArrowLeft } from '@manacore/shared-icons';
function toggle(key: string) {
todoSettings.update({ [key]: !todoSettings.settings[key] });
}
</script>
<svelte:head>
<title>Todo Einstellungen - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<header class="mb-6 flex items-center gap-3">
<a
href="/todo"
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
>
<ArrowLeft size={18} />
</a>
<h1 class="text-2xl font-bold text-foreground">Todo Einstellungen</h1>
</header>
<div class="space-y-6">
<!-- Task Behavior -->
<section class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Aufgaben-Verhalten
</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<label class="text-sm text-foreground">Standard-Priorität</label>
<select
value={todoSettings.defaultPriority}
onchange={(e) =>
todoSettings.update({ defaultPriority: e.currentTarget.value as TaskPriority })}
class="rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="urgent">Dringend</option>
</select>
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-foreground">Standard-Fälligkeitszeit</label>
<input
type="time"
value={todoSettings.settings.defaultDueTime ?? ''}
onchange={(e) =>
todoSettings.update({
defaultDueTime: e.currentTarget.value || null,
})}
class="rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
/>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-foreground">Auto-Archivierung</label>
<p class="text-xs text-muted-foreground">Erledigte Aufgaben nach X Tagen archivieren</p>
</div>
<input
type="number"
min="0"
placeholder="-"
value={todoSettings.settings.autoArchiveCompletedDays ?? ''}
onchange={(e) =>
todoSettings.update({
autoArchiveCompletedDays: e.currentTarget.value
? Number(e.currentTarget.value)
: null,
})}
class="w-20 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
/>
</div>
</div>
</section>
<!-- View & Display -->
<section class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Ansicht & Darstellung
</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<label class="text-sm text-foreground">Standard-Ansicht</label>
<select
value={todoSettings.defaultView}
onchange={(e) => todoSettings.update({ defaultView: e.currentTarget.value as any })}
class="rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
>
<option value="inbox">Inbox</option>
<option value="today">Heute</option>
<option value="upcoming">Anstehend</option>
<option value="kanban">Kanban</option>
</select>
</div>
{#each [{ key: 'compactMode', label: 'Kompaktmodus', desc: 'Weniger Abstand zwischen Aufgaben' }, { key: 'showTaskCounts', label: 'Aufgabenzahl', desc: 'Anzahl der Aufgaben anzeigen' }, { key: 'showSubtaskProgress', label: 'Teilaufgaben-Fortschritt', desc: 'Fortschritt der Subtasks anzeigen' }, { key: 'groupByProject', label: 'Nach Projekt gruppieren', desc: 'Aufgaben nach Projekt gruppieren' }] as toggle_item}
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-foreground">{toggle_item.label}</label>
<p class="text-xs text-muted-foreground">{toggle_item.desc}</p>
</div>
<button
onclick={() => toggle(toggle_item.key)}
class="relative h-6 w-11 rounded-full transition-colors {todoSettings.settings[
toggle_item.key
]
? 'bg-primary'
: 'bg-muted'}"
>
<span
class="absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform {todoSettings
.settings[toggle_item.key]
? 'translate-x-5'
: 'translate-x-0.5'}"
></span>
</button>
</div>
{/each}
</div>
</section>
<!-- Kanban Board -->
<section class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Kanban Board
</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<label class="text-sm text-foreground">Kartengröße</label>
<div class="flex gap-1">
{#each ['compact', 'normal', 'large'] as size}
<button
onclick={() => todoSettings.update({ kanbanCardSize: size as KanbanCardSize })}
class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors
{todoSettings.kanbanCardSize === size
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted'}"
>
{size === 'compact' ? 'Kompakt' : size === 'normal' ? 'Normal' : 'Groß'}
</button>
{/each}
</div>
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-foreground">Labels auf Karten</label>
<button
onclick={() => toggle('showLabelsOnCards')}
class="relative h-6 w-11 rounded-full transition-colors {todoSettings.showLabelsOnCards
? 'bg-primary'
: 'bg-muted'}"
>
<span
class="absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform {todoSettings.showLabelsOnCards
? 'translate-x-5'
: 'translate-x-0.5'}"
></span>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-foreground">WIP-Limit pro Spalte</label>
<p class="text-xs text-muted-foreground">Maximum Aufgaben pro Spalte</p>
</div>
<input
type="number"
min="0"
placeholder="-"
value={todoSettings.wipLimitPerColumn ?? ''}
onchange={(e) =>
todoSettings.update({
wipLimitPerColumn: e.currentTarget.value ? Number(e.currentTarget.value) : null,
})}
class="w-20 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
/>
</div>
</div>
</section>
<!-- Smart Duration -->
<section class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Smarte Dauer
</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-foreground">Smarte Dauer-Schätzung</label>
<p class="text-xs text-muted-foreground">
Dauer automatisch basierend auf ähnlichen Aufgaben schätzen
</p>
</div>
<button
onclick={() => toggle('smartDurationEnabled')}
class="relative h-6 w-11 rounded-full transition-colors {todoSettings.smartDurationEnabled
? 'bg-primary'
: 'bg-muted'}"
>
<span
class="absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform {todoSettings.smartDurationEnabled
? 'translate-x-5'
: 'translate-x-0.5'}"
></span>
</button>
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-foreground">Standard-Dauer (Minuten)</label>
<input
type="number"
min="5"
step="5"
value={todoSettings.defaultTaskDuration}
onchange={(e) =>
todoSettings.update({ defaultTaskDuration: Number(e.currentTarget.value) || 30 })}
class="w-20 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
/>
</div>
</div>
</section>
<!-- Notifications -->
<section class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Benachrichtigungen
</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<label class="text-sm text-foreground">Standard-Erinnerung</label>
<select
value={todoSettings.settings.defaultReminderMinutes ?? ''}
onchange={(e) =>
todoSettings.update({
defaultReminderMinutes: e.currentTarget.value
? Number(e.currentTarget.value)
: null,
})}
class="rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
>
<option value="">Keine</option>
<option value="5">5 Min</option>
<option value="15">15 Min</option>
<option value="30">30 Min</option>
<option value="60">1 Std</option>
<option value="1440">1 Tag</option>
</select>
</div>
{#each [{ key: 'dailyDigestEnabled', label: 'Tägliche Zusammenfassung' }, { key: 'overdueNotifications', label: 'Überfällig-Benachrichtigungen' }] as item}
<div class="flex items-center justify-between">
<label class="text-sm text-foreground">{item.label}</label>
<button
onclick={() => toggle(item.key)}
class="relative h-6 w-11 rounded-full transition-colors {todoSettings.settings[
item.key
]
? 'bg-primary'
: 'bg-muted'}"
>
<span
class="absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform {todoSettings
.settings[item.key]
? 'translate-x-5'
: 'translate-x-0.5'}"
></span>
</button>
</div>
{/each}
</div>
</section>
<!-- Productivity -->
<section class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Produktivität
</h2>
<div class="space-y-4">
{#each [{ key: 'focusMode', label: 'Fokus-Modus', desc: 'Ablenkungen minimieren' }, { key: 'pomodoroEnabled', label: 'Pomodoro', desc: 'Pomodoro-Timer integrieren' }, { key: 'showStreak', label: 'Streak anzeigen', desc: 'Tägliche Erledigungsserie' }, { key: 'immersiveModeEnabled', label: 'Immersiver Modus', desc: 'Navigation ausblenden' }] as item}
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-foreground">{item.label}</label>
<p class="text-xs text-muted-foreground">{item.desc}</p>
</div>
<button
onclick={() => toggle(item.key)}
class="relative h-6 w-11 rounded-full transition-colors {todoSettings.settings[
item.key
]
? 'bg-primary'
: 'bg-muted'}"
>
<span
class="absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform {todoSettings
.settings[item.key]
? 'translate-x-5'
: 'translate-x-0.5'}"
></span>
</button>
</div>
{/each}
<div class="flex items-center justify-between">
<label class="text-sm text-foreground">Tagesziel (Aufgaben)</label>
<input
type="number"
min="0"
placeholder="-"
value={todoSettings.settings.dailyGoal ?? ''}
onchange={(e) =>
todoSettings.update({
dailyGoal: e.currentTarget.value ? Number(e.currentTarget.value) : null,
})}
class="w-20 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
/>
</div>
</div>
</section>
<!-- Reset -->
<div class="flex justify-end pb-8">
<button
onclick={() => {
if (confirm('Alle Todo-Einstellungen zurücksetzen?')) {
todoSettings.reset();
}
}}
class="rounded-md px-4 py-2 text-sm text-red-500 transition-colors hover:bg-red-500/10"
>
Einstellungen zurücksetzen
</button>
</div>
</div>
</div>