refactor(todo): remove projects concept, unify views with Fokus/Übersicht/Matrix

- Remove LocalProject, projectId from all tasks, and project-related stores/queries
- Add 'fokus' layout mode to LocalBoardView; activeLayoutMode setting in settings store
- Build FokusLayout.svelte: scroll-snap paper sheets with cross-page DnD ('task-dnd')
- Inline column edit mode: ViewColumnHeader with color picker popup, rename, reorder, delete
- Add "Neues Board" placeholder as last column in all layout modes
- PillNav now state-based (Fokus/Übersicht/Matrix tabs) instead of route-based
- Unified filter strip: merge TagStrip + FilterStrip with "Tags:" and "Filter:" label pills
- Fix all 3 test files after project removal; 100/100 tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 16:34:51 +02:00
parent d460c9ec07
commit ed9672ef2b
30 changed files with 74 additions and 866 deletions

View file

@ -1,10 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects, getProjectById } from '$lib/data/task-queries';
import {
parseMultiTaskInput,
resolveTaskIds,
@ -16,12 +13,11 @@
import { labelCollection } from '$lib/data/local-store';
import { todoSettings } from '$lib/stores/settings.svelte';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import type { TaskPriority } from '@todo/shared';
import { PRIORITY_OPTIONS } from '@todo/shared';
import { format, addDays } from 'date-fns';
import { de } from 'date-fns/locale';
import { CalendarBlank, Folder, Plus, Flag, ArrowRight } from '@manacore/shared-icons';
import { CalendarBlank, Plus, Flag, ArrowRight } from '@manacore/shared-icons';
let inputValue = $state('');
let isLoading = $state(false);
@ -30,12 +26,10 @@
// Task options (used as fallback when parser doesn't extract these)
let selectedDate = $state<Date>(new Date());
let selectedPriority = $state<TaskPriority>('medium');
let selectedProjectId = $state<string | undefined>(undefined);
// Dropdown states
let showDatePicker = $state(false);
let showPriorityPicker = $state(false);
let showProjectPicker = $state(false);
// Parser preview
let parsePreview = $state('');
@ -53,9 +47,6 @@
// Derived values
let currentPriority = $derived(PRIORITY_OPTIONS.find((p) => p.value === selectedPriority)!);
let selectedProject = $derived(
selectedProjectId ? getProjectById(projectsCtx.value, selectedProjectId) : undefined
);
let dateLabel = $derived(() => {
const today = new Date();
if (selectedDate.toDateString() === today.toDateString()) return 'Heute';
@ -106,7 +97,6 @@
.filter((t) => t.isCompleted && t.completedAt)
.map((t) => ({
title: t.title,
projectId: t.projectId,
priority: t.priority,
estimatedDuration: t.estimatedDuration,
completedAt: t.completedAt,
@ -116,7 +106,6 @@
const estimate = estimateDuration(
{
title: parsed.title,
projectId: selectedProjectId,
priority: selectedPriority,
},
completed
@ -131,11 +120,6 @@
onMount(() => {
inputRef?.focus();
// Set project if in project view
if (viewStore.currentView === 'project' && viewStore.currentProjectId) {
selectedProjectId = viewStore.currentProjectId;
}
});
async function handleSubmit(event: Event) {
@ -147,14 +131,13 @@
isLoading = true;
try {
const projects = projectsCtx.value.map((p) => ({ id: p.id, name: p.name }));
const allLabels = await labelCollection.getAll();
const labels = allLabels.map((l) => ({ id: l.id, name: l.name }));
const parsedTasks = parseMultiTaskInput(text);
for (const parsed of parsedTasks) {
const resolved = resolveTaskIds(parsed, projects, labels);
const resolved = resolveTaskIds(parsed, labels);
// Duration: explicit from parser > auto-estimated > default (if enabled)
const duration =
@ -164,7 +147,6 @@
await tasksStore.createTask({
title: resolved.title,
projectId: resolved.projectId ?? selectedProjectId,
dueDate: resolved.dueDate ?? selectedDate.toISOString(),
priority: resolved.priority ?? selectedPriority,
labelIds: resolved.labelIds.length > 0 ? resolved.labelIds : undefined,
@ -186,9 +168,6 @@
autoEstimatedDuration = null;
selectedDate = new Date();
selectedPriority = 'medium';
if (viewStore.currentView !== 'project') {
selectedProjectId = undefined;
}
} catch (error) {
console.error('Failed to create task:', error);
} finally {
@ -204,7 +183,6 @@
inputValue = '';
showDatePicker = false;
showPriorityPicker = false;
showProjectPicker = false;
inputRef?.blur();
}
}
@ -212,25 +190,16 @@
function closeAllPickers() {
showDatePicker = false;
showPriorityPicker = false;
showProjectPicker = false;
}
function toggleDatePicker() {
showDatePicker = !showDatePicker;
showPriorityPicker = false;
showProjectPicker = false;
}
function togglePriorityPicker() {
showPriorityPicker = !showPriorityPicker;
showDatePicker = false;
showProjectPicker = false;
}
function toggleProjectPicker() {
showProjectPicker = !showProjectPicker;
showDatePicker = false;
showPriorityPicker = false;
}
function selectDate(date: Date) {
@ -242,11 +211,6 @@
selectedPriority = priority;
showPriorityPicker = false;
}
function selectProject(projectId: string | undefined) {
selectedProjectId = projectId;
showProjectPicker = false;
}
</script>
<svelte:window onclick={closeAllPickers} />
@ -347,47 +311,6 @@
{/if}
</div>
<!-- Project picker -->
<div class="option-wrapper">
<button
type="button"
class="option-btn"
class:active={showProjectPicker}
onclick={(e) => {
e.stopPropagation();
toggleProjectPicker();
}}
title="Projekt"
>
<Folder size={20} class="option-icon" />
</button>
{#if showProjectPicker}
<div class="dropdown" onclick={(e) => e.stopPropagation()} role="menu">
<button
type="button"
class="dropdown-item"
class:selected={!selectedProjectId}
onclick={() => selectProject(undefined)}
>
<span class="project-dot" style="background-color: #6b7280"></span>
Kein Projekt
</button>
{#each getActiveProjects(projectsCtx.value) as project}
<button
type="button"
class="dropdown-item"
class:selected={selectedProjectId === project.id}
onclick={() => selectProject(project.id)}
>
<span class="project-dot" style="background-color: {project.color}"></span>
{project.name}
</button>
{/each}
</div>
{/if}
</div>
<!-- Divider -->
<div class="option-divider"></div>

View file

@ -1,11 +1,6 @@
<script lang="ts">
import type { Task, Subtask, UpdateTaskInput } from '@todo/shared';
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects } from '$lib/data/task-queries';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import { contactsStore } from '$lib/stores/contacts.svelte';
import { useTaskForm } from '$lib/composables/useTaskForm.svelte';
import SubtaskList from './SubtaskList.svelte';
@ -198,19 +193,6 @@
</select>
</div>
<!-- Projekt -->
<div class="form-section">
<label class="form-label" for="task-project">Projekt</label>
<select id="task-project" class="form-select" bind:value={form.projectId}>
<option value={null}>Kein Projekt</option>
{#each getActiveProjects(projectsCtx.value) as project}
<option value={project.id}>
{project.name}
</option>
{/each}
</select>
</div>
<!-- Tags -->
<div class="form-section">
<label class="form-label">Tags</label>

View file

@ -2,10 +2,6 @@
import { goto } from '$app/navigation';
import type { TaskPriority } from '@todo/shared';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects } from '$lib/data/task-queries';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import type { Tag } from '@manacore/shared-tags';
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
@ -18,13 +14,11 @@
// Filter state (owned by parent)
selectedPriorities: TaskPriority[];
selectedProjectId: string | null;
selectedLabelIds: string[];
searchQuery: string;
// Callbacks
onPrioritiesChange: (priorities: TaskPriority[]) => void;
onProjectChange: (projectId: string | null) => void;
onLabelsChange: (labelIds: string[]) => void;
onSearchChange: (query: string) => void;
onClearFilters: () => void;
@ -49,11 +43,9 @@
let {
variant,
selectedPriorities,
selectedProjectId,
selectedLabelIds,
searchQuery,
onPrioritiesChange,
onProjectChange,
onLabelsChange,
onSearchChange,
onClearFilters,
@ -86,10 +78,7 @@
let showLabelsDropdown = $state(false);
let hasActiveFilters = $derived(
selectedPriorities.length > 0 ||
selectedProjectId !== null ||
selectedLabelIds.length > 0 ||
searchQuery.trim() !== ''
selectedPriorities.length > 0 || selectedLabelIds.length > 0 || searchQuery.trim() !== ''
);
function togglePriority(priority: TaskPriority) {
@ -166,23 +155,6 @@
</button>
{/each}
<!-- Project Filter Pills -->
{#if getActiveProjects(projectsCtx.value).length > 0}
<span class="strip-divider"></span>
{#each getActiveProjects(projectsCtx.value) as project (project.id)}
<button
class="project-pill glass-pill"
class:selected={selectedProjectId === project.id}
onclick={() => onProjectChange(selectedProjectId === project.id ? null : project.id)}
title={project.name}
style="--project-color: {project.color || '#6b7280'}"
>
<span class="project-dot"></span>
<span class="pill-label">{project.name}</span>
</button>
{/each}
{/if}
<!-- Sort Pills -->
{#if showSort && onSortChange}
<span class="strip-divider"></span>
@ -276,25 +248,6 @@
</div>
</div>
<div class="h-6 w-px bg-border hidden sm:block"></div>
<!-- Project filter -->
<div class="filter-group flex items-center gap-2">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide"
>Projekt</span
>
<select
class="px-3 py-1.5 text-sm bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all cursor-pointer"
value={selectedProjectId || ''}
onchange={(e) => onProjectChange(e.currentTarget.value || null)}
>
<option value="">Alle Projekte</option>
{#each getActiveProjects(projectsCtx.value) as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
<!-- Labels filter -->
{#if showLabels}
<div class="h-6 w-px bg-border hidden sm:block"></div>
@ -538,28 +491,6 @@
background: rgba(255, 255, 255, 0.15);
}
/* Project pills */
.project-pill .project-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--project-color);
flex-shrink: 0;
}
.project-pill.selected {
background: var(--project-color) !important;
border-color: var(--project-color) !important;
}
.project-pill.selected .project-dot {
background-color: white;
}
.project-pill.selected .pill-label {
color: white;
}
/* Priority pills */
.priority-pill.selected {
background: var(--priority-color) !important;

View file

@ -6,11 +6,6 @@
import { formatDueDate } from '$lib/utils/date-display';
import { getSubtaskProgress } from '$lib/utils/task-helpers';
import { useTaskForm } from '$lib/composables/useTaskForm.svelte';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects, getProjectColor } from '$lib/data/task-queries';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import { contactsStore } from '$lib/stores/contacts.svelte';
import { ContactAvatar, ContactSelector } from '@manacore/shared-ui';
import SubtaskList from './SubtaskList.svelte';
@ -99,7 +94,6 @@
form.startDate,
form.priority,
form.status,
form.projectId,
form.selectedLabelIds,
form.subtasks,
form.recurrenceRule,
@ -229,12 +223,6 @@
return isPast(date) && !isToday(date);
});
// Get project color
let projectColor = $derived(() => {
if (!task.projectId) return null;
return getProjectColor(projectsCtx.value, task.projectId);
});
// Subtasks progress
let subtaskProgress = $derived(() => getSubtaskProgress(task.subtasks));
@ -479,25 +467,14 @@
<PrioritySelector value={form.priority} onChange={(p) => (form.priority = p)} />
</div>
<!-- Status & Project row -->
<div class="form-row-2">
<div class="form-section">
<label class="form-label" for="task-status-{task.id}">Status</label>
<select id="task-status-{task.id}" class="form-select" bind:value={form.status}>
{#each STATUS_OPTIONS as s}
<option value={s.value}>{s.label}</option>
{/each}
</select>
</div>
<div class="form-section">
<label class="form-label" for="task-project-{task.id}">Projekt</label>
<select id="task-project-{task.id}" class="form-select" bind:value={form.projectId}>
<option value={null}>Kein Projekt</option>
{#each getActiveProjects(projectsCtx.value) as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
<!-- Status -->
<div class="form-section">
<label class="form-label" for="task-status-{task.id}">Status</label>
<select id="task-status-{task.id}" class="form-select" bind:value={form.status}>
{#each STATUS_OPTIONS as s}
<option value={s.value}>{s.label}</option>
{/each}
</select>
</div>
<!-- Tags -->

View file

@ -4,10 +4,6 @@
import TaskItem from './TaskItem.svelte';
import { getContext, untrack } from 'svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects } from '$lib/data/task-queries';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
// Context menu state
@ -67,27 +63,7 @@
},
];
// Add project move options if there are projects
const projects = getActiveProjects(projectsCtx.value);
if (projects.length > 0) {
items.push({ id: 'divider-2', label: '', type: 'divider' });
items.push({
id: 'move-inbox',
label: 'In Inbox verschieben',
action: () => tasksStore.moveTask(task.id, null),
disabled: !task.projectId,
});
for (const project of projects) {
items.push({
id: `move-${project.id}`,
label: project.name,
action: () => tasksStore.moveTask(task.id, project.id),
disabled: task.projectId === project.id,
});
}
}
items.push({ id: 'divider-3', label: '', type: 'divider' });
items.push({ id: 'divider-2', label: '', type: 'divider' });
items.push({
id: 'delete',
label: 'Löschen',

View file

@ -1,11 +1,6 @@
<script lang="ts">
import type { TaskPriority } from '@todo/shared';
import { PRIORITY_OPTIONS } from '@todo/shared';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects } from '$lib/data/task-queries';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import { viewStore, type SortBy } from '$lib/stores/view.svelte';
import { todoSettings } from '$lib/stores/settings.svelte';
import { PillToolbarButton, PillToolbarDivider, PillViewSwitcher } from '@manacore/shared-ui';
@ -35,7 +30,6 @@
// Filter dropdown states
let showFilterDropdown = $state(false);
let selectedPriorityFilters = $state<TaskPriority[]>([]);
let selectedProjectFilter = $state<string | null>(null);
let selectedLabelFilters = $state<string[]>([]);
const priorities: { value: TaskPriority; label: string; color: string }[] = [
@ -53,9 +47,7 @@
];
// Count active filters
let activeFilterCount = $derived(
selectedPriorityFilters.length + (selectedProjectFilter ? 1 : 0) + selectedLabelFilters.length
);
let activeFilterCount = $derived(selectedPriorityFilters.length + selectedLabelFilters.length);
function handleSortChange(value: string) {
onSortChange(value as SortBy);
@ -75,7 +67,6 @@
function clearAllFilters() {
selectedPriorityFilters = [];
selectedProjectFilter = null;
selectedLabelFilters = [];
}
</script>
@ -133,20 +124,6 @@
</div>
</div>
<div class="filter-section">
<div class="filter-section-header">Projekt</div>
<select
class="filter-select"
value={selectedProjectFilter || ''}
onchange={(e) => (selectedProjectFilter = e.currentTarget.value || null)}
>
<option value="">Alle Projekte</option>
{#each getActiveProjects(projectsCtx.value) as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
{#if activeFilterCount > 0}
<button type="button" class="clear-filters-btn" onclick={clearAllFilters}>
Filter zurücksetzen

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Task, Project } from '@todo/shared';
import type { Task } from '@todo/shared';
import type { LocalBoardView } from '$lib/data/local-store';
import { groupTasksByView, getDropActionUpdate } from '$lib/data/view-grouping';
import { tasksStore } from '$lib/stores/tasks.svelte';
@ -30,12 +30,11 @@
let activeLayout = $derived(layoutOverride || view.layout);
// Get tasks and projects from context (set by layout)
// Get tasks from context (set by layout)
const tasksCtx: { readonly value: Task[] } = getContext('tasks');
const projectsCtx: { readonly value: Project[] } = getContext('projects');
// Group tasks by the current view configuration
let columns = $derived(groupTasksByView(view, tasksCtx.value, projectsCtx.value));
let columns = $derived(groupTasksByView(view, tasksCtx.value));
// ─── Task Callbacks ──────────────────────────────────────
@ -65,7 +64,6 @@
const updateData: Record<string, unknown> = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
if (data.projectId !== undefined) updateData.projectId = data.projectId;
if (data.dueDate !== undefined) {
updateData.dueDate = data.dueDate instanceof Date ? data.dueDate.toISOString() : data.dueDate;
}

View file

@ -102,13 +102,10 @@
const createData: Record<string, unknown> = { title };
if (column.onDrop) {
if (column.onDrop.setPriority) createData.priority = column.onDrop.setPriority;
if (column.onDrop.setProjectId !== undefined)
createData.projectId = column.onDrop.setProjectId;
}
tasksStore.createTask(
createData as {
title: string;
projectId?: string;
priority?: 'low' | 'medium' | 'high' | 'urgent';
}
);

View file

@ -76,7 +76,6 @@
const updateData: Record<string, unknown> = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
if (data.projectId !== undefined) updateData.projectId = data.projectId;
if (data.dueDate !== undefined) {
updateData.dueDate = data.dueDate instanceof Date ? data.dueDate.toISOString() : data.dueDate;
}
@ -104,14 +103,10 @@
if (column.onDrop.setPriority) {
createData.priority = column.onDrop.setPriority;
}
if (column.onDrop.setProjectId !== undefined) {
createData.projectId = column.onDrop.setProjectId;
}
}
await tasksStore.createTask(
createData as {
title: string;
projectId?: string;
priority?: 'low' | 'medium' | 'high' | 'urgent';
}
);

View file

@ -68,7 +68,6 @@
const groupByOptions = [
{ value: 'status', label: 'Status' },
{ value: 'priority', label: 'Priorität' },
{ value: 'project', label: 'Projekt' },
{ value: 'dueDate', label: 'Fälligkeit' },
{ value: 'tag', label: 'Tag' },
{ value: 'custom', label: 'Manuell' },
@ -181,8 +180,6 @@
onDrop: { setPriority: 'low' },
},
];
case 'project':
return []; // dynamically generated
case 'dueDate':
return [
{

View file

@ -23,7 +23,6 @@ export function useTaskForm() {
let startDate = $state('');
let priority = $state<TaskPriority>('medium');
let status = $state<TaskStatus>('pending');
let projectId = $state<string | null>(null);
let selectedLabelIds = $state<string[]>([]);
let subtasks = $state<Subtask[]>([]);
let recurrenceRule = $state('');
@ -51,7 +50,6 @@ export function useTaskForm() {
startDate = task.startDate ? format(new Date(task.startDate), 'yyyy-MM-dd') : '';
priority = task.priority || 'medium';
status = task.status || 'pending';
projectId = task.projectId || null;
selectedLabelIds = task.labels?.map((l) => l.id) || [];
subtasks = task.subtasks ? [...task.subtasks] : [];
recurrenceRule = task.recurrenceRule || '';
@ -97,7 +95,6 @@ export function useTaskForm() {
startDate: startDate ? new Date(startDate).toISOString() : null,
priority,
status,
projectId: projectId || null,
subtasks: subtasks.length > 0 ? subtasks : null,
recurrenceRule: recurrenceRule || null,
metadata: {
@ -157,12 +154,6 @@ export function useTaskForm() {
set status(v: TaskStatus) {
status = v;
},
get projectId() {
return projectId;
},
set projectId(v: string | null) {
projectId = v;
},
get selectedLabelIds() {
return selectedLabelIds;
},

View file

@ -14,8 +14,8 @@ export function getTodoHelpContent(locale: string): HelpContent {
id: 'faq-quick-add',
question: isDE ? 'Wie funktioniert die Schnelleingabe?' : 'How does quick add work?',
answer: isDE
? '<p>Die Schnelleingabe erkennt automatisch verschiedene Muster:</p><ul><li><strong>Priorität</strong>: <code>!!!</code> (dringend), <code>!!</code> (hoch), <code>!</code> (mittel)</li><li><strong>Projekt</strong>: <code>@Projektname</code></li><li><strong>Labels</strong>: <code>#label1 #label2</code></li><li><strong>Datum</strong>: heute, morgen, nächsten Montag, 15.12.</li><li><strong>Wiederholung</strong>: täglich, wöchentlich, monatlich</li><li><strong>Unteraufgaben</strong>: <code>Titel: Item1, Item2, Item3</code></li></ul><p>Beispiel: <code>Einkaufen: Milch, Brot morgen !! @Privat #wichtig</code></p>'
: '<p>Quick add automatically recognizes various patterns:</p><ul><li><strong>Priority</strong>: <code>!!!</code> (urgent), <code>!!</code> (high), <code>!</code> (medium)</li><li><strong>Project</strong>: <code>@ProjectName</code></li><li><strong>Labels</strong>: <code>#label1 #label2</code></li><li><strong>Date</strong>: today, tomorrow, next Monday</li><li><strong>Recurrence</strong>: daily, weekly, monthly</li><li><strong>Subtasks</strong>: <code>Title: Item1, Item2, Item3</code></li></ul><p>Example: <code>Shopping: Milk, Bread tomorrow !! @Personal #important</code></p>',
? '<p>Die Schnelleingabe erkennt automatisch verschiedene Muster:</p><ul><li><strong>Priorität</strong>: <code>!!!</code> (dringend), <code>!!</code> (hoch), <code>!</code> (mittel)</li><li><strong>Labels</strong>: <code>#label1 #label2</code></li><li><strong>Datum</strong>: heute, morgen, nächsten Montag, 15.12.</li><li><strong>Wiederholung</strong>: täglich, wöchentlich, monatlich</li><li><strong>Unteraufgaben</strong>: <code>Titel: Item1, Item2, Item3</code></li></ul><p>Beispiel: <code>Einkaufen: Milch, Brot morgen !! #wichtig</code></p>'
: '<p>Quick add automatically recognizes various patterns:</p><ul><li><strong>Priority</strong>: <code>!!!</code> (urgent), <code>!!</code> (high), <code>!</code> (medium)</li><li><strong>Labels</strong>: <code>#label1 #label2</code></li><li><strong>Date</strong>: today, tomorrow, next Monday</li><li><strong>Recurrence</strong>: daily, weekly, monthly</li><li><strong>Subtasks</strong>: <code>Title: Item1, Item2, Item3</code></li></ul><p>Example: <code>Shopping: Milk, Bread tomorrow !! #important</code></p>',
category: 'features',
order: 1,
language: isDE ? 'de' : 'en',
@ -23,19 +23,17 @@ export function getTodoHelpContent(locale: string): HelpContent {
tags: isDE ? ['schnelleingabe', 'erstellen', 'syntax'] : ['quick-add', 'create', 'syntax'],
},
{
id: 'faq-projects',
id: 'faq-tags',
question: isDE
? 'Wie organisiere ich Aufgaben in Projekten?'
: 'How do I organize tasks in projects?',
? 'Wie organisiere ich Aufgaben mit Tags?'
: 'How do I organize tasks with tags?',
answer: isDE
? '<p>Projekte helfen dir, Aufgaben thematisch zu gruppieren:</p><ul><li>Erstelle Projekte über das <strong>+</strong> Symbol in der Seitenleiste</li><li>Weise jeder Aufgabe ein Projekt zu (oder lasse sie im Posteingang)</li><li>Jedes Projekt hat eine eigene Farbe zur visuellen Unterscheidung</li><li>Ziehe Aufgaben per Drag & Drop zwischen Projekten</li></ul>'
: '<p>Projects help you group tasks by topic:</p><ul><li>Create projects via the <strong>+</strong> icon in the sidebar</li><li>Assign tasks to a project (or leave them in the inbox)</li><li>Each project has its own color for visual distinction</li><li>Drag and drop tasks between projects</li></ul>',
? '<p>Tags helfen dir, Aufgaben flexibel zu organisieren:</p><ul><li>Erstelle Tags in der Tags-Ansicht</li><li>Weise Aufgaben beliebig viele Tags zu</li><li>Jeder Tag hat eine eigene Farbe zur visuellen Unterscheidung</li><li>Filtere Aufgaben über die Filterleiste nach Tags</li></ul>'
: '<p>Tags help you organize tasks flexibly:</p><ul><li>Create tags in the Tags view</li><li>Assign any number of tags to tasks</li><li>Each tag has its own color for visual distinction</li><li>Filter tasks by tags using the filter strip</li></ul>',
category: 'features',
order: 2,
language: isDE ? 'de' : 'en',
tags: isDE
? ['projekte', 'organisation', 'sortierung']
: ['projects', 'organize', 'sorting'],
tags: isDE ? ['tags', 'organisation', 'sortierung'] : ['tags', 'organize', 'sorting'],
},
{
id: 'faq-kanban',
@ -73,8 +71,8 @@ export function getTodoHelpContent(locale: string): HelpContent {
icon: '⚡',
category: 'core',
highlights: isDE
? ['Automatische Erkennung', 'Datum & Priorität', 'Projekte & Labels']
: ['Auto-detection', 'Date & priority', 'Projects & labels'],
? ['Automatische Erkennung', 'Datum & Priorität', 'Tags & Labels']
: ['Auto-detection', 'Date & priority', 'Tags & labels'],
content: '',
order: 1,
language: isDE ? 'de' : 'en',
@ -95,16 +93,16 @@ export function getTodoHelpContent(locale: string): HelpContent {
language: isDE ? 'de' : 'en',
},
{
id: 'feature-projects',
title: isDE ? 'Projekte' : 'Projects',
id: 'feature-tags',
title: 'Tags',
description: isDE
? 'Organisiere Aufgaben in farbcodierten Projekten'
: 'Organize tasks in color-coded projects',
icon: '📁',
? 'Organisiere Aufgaben mit farbcodierten Tags'
: 'Organize tasks with color-coded tags',
icon: '🏷️',
category: 'core',
highlights: isDE
? ['Farbcodierung', 'Drag & Drop', 'Archivierung']
: ['Color coding', 'Drag & drop', 'Archiving'],
? ['Farbcodierung', 'Mehrere Tags pro Aufgabe', 'Filterbar']
: ['Color coding', 'Multiple tags per task', 'Filterable'],
content: '',
order: 3,
language: isDE ? 'de' : 'en',

View file

@ -5,31 +5,7 @@
* They serve as onboarding content that teaches the user how the app works.
*/
import type { LocalTask, LocalProject, LocalLabel, LocalBoardView } from './local-store';
const ONBOARDING_PROJECT_ID = 'onboarding-project';
const PERSONAL_PROJECT_ID = 'personal-project';
export const guestProjects: LocalProject[] = [
{
id: ONBOARDING_PROJECT_ID,
name: 'Erste Schritte',
color: '#3b82f6',
icon: 'sparkle',
order: 0,
isArchived: false,
isDefault: false,
},
{
id: PERSONAL_PROJECT_ID,
name: 'Persönlich',
color: '#10b981',
icon: 'home',
order: 1,
isArchived: false,
isDefault: true,
},
];
import type { LocalTask, LocalLabel, LocalBoardView } from './local-store';
export const guestLabels: LocalLabel[] = [
{
@ -147,22 +123,13 @@ export const guestBoardViews: LocalBoardView[] = [
},
],
},
{
id: 'view-project',
name: 'Projekte',
icon: 'folders',
groupBy: 'project',
layout: 'kanban',
order: 3,
columns: [], // dynamically generated from projects
},
{
id: 'view-due',
name: 'Fälligkeit',
icon: 'calendar',
groupBy: 'dueDate',
layout: 'kanban',
order: 4,
order: 3,
columns: [
{
id: 'col-due-overdue',
@ -219,7 +186,6 @@ export const guestTasks: LocalTask[] = [
title: 'Willkommen bei Todo! Tippe hier, um diese Aufgabe zu bearbeiten ✏️',
description:
'Du kannst Titel, Beschreibung, Priorität und Fälligkeitsdatum ändern. Probiere es aus!',
projectId: ONBOARDING_PROJECT_ID,
priority: 'medium',
isCompleted: false,
order: 0,
@ -232,7 +198,6 @@ export const guestTasks: LocalTask[] = [
{
id: 'onboard-2',
title: 'Klicke den Kreis links, um diese Aufgabe abzuschließen ✓',
projectId: ONBOARDING_PROJECT_ID,
priority: 'low',
isCompleted: false,
order: 1,
@ -240,7 +205,6 @@ export const guestTasks: LocalTask[] = [
{
id: 'onboard-3',
title: 'Erstelle eine neue Aufgabe mit dem + Button oben',
projectId: ONBOARDING_PROJECT_ID,
priority: 'medium',
isCompleted: false,
order: 2,
@ -248,7 +212,6 @@ export const guestTasks: LocalTask[] = [
{
id: 'onboard-4',
title: 'Wechsle zur Board-Ansicht über die Navigation',
projectId: ONBOARDING_PROJECT_ID,
priority: 'low',
isCompleted: false,
order: 3,
@ -258,7 +221,6 @@ export const guestTasks: LocalTask[] = [
title: 'Melde dich an, um deine Aufgaben auf allen Geräten zu synchronisieren',
description:
'Ohne Anmeldung werden deine Daten nur in diesem Browser gespeichert. Mit einem Account synchronisieren wir sie automatisch.',
projectId: ONBOARDING_PROJECT_ID,
priority: 'high',
isCompleted: false,
order: 4,
@ -269,7 +231,6 @@ export const guestTasks: LocalTask[] = [
id: 'sample-1',
title: 'Einkaufen gehen',
description: 'Milch, Brot, Obst',
projectId: PERSONAL_PROJECT_ID,
priority: 'medium',
isCompleted: false,
dueDate: tomorrow.toISOString(),
@ -283,7 +244,6 @@ export const guestTasks: LocalTask[] = [
{
id: 'sample-2',
title: 'Wohnung aufräumen',
projectId: PERSONAL_PROJECT_ID,
priority: 'low',
isCompleted: false,
dueDate: nextWeek.toISOString(),

View file

@ -7,14 +7,13 @@
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import type { Subtask as SharedSubtask } from '@todo/shared';
import { guestProjects, guestTasks, guestLabels, guestBoardViews } from './guest-seed.js';
import { guestTasks, guestLabels, guestBoardViews } from './guest-seed.js';
// ─── Types ──────────────────────────────────────────────────
export interface LocalTask extends BaseRecord {
title: string;
description?: string;
projectId?: string | null;
userId?: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
isCompleted: boolean;
@ -31,16 +30,6 @@ export interface LocalTask extends BaseRecord {
export type { SharedSubtask as Subtask };
export interface LocalProject extends BaseRecord {
name: string;
color: string;
icon?: string | null;
userId?: string;
order: number;
isArchived: boolean;
isDefault: boolean;
}
export interface LocalLabel extends BaseRecord {
name: string;
color: string;
@ -63,7 +52,7 @@ export interface LocalReminder extends BaseRecord {
// ─── Board Views ────────────────────────────────────────────
export interface TaskMatcher {
type: 'status' | 'priority' | 'project' | 'tag' | 'dueDate' | 'custom';
type: 'status' | 'priority' | 'tag' | 'dueDate' | 'custom';
value?: string | null;
/** For 'custom' groupBy: manually assigned task IDs */
taskIds?: string[];
@ -72,7 +61,6 @@ export interface TaskMatcher {
export interface DropAction {
setCompleted?: boolean;
setPriority?: 'low' | 'medium' | 'high' | 'urgent';
setProjectId?: string | null;
}
export interface ViewColumn {
@ -84,7 +72,6 @@ export interface ViewColumn {
}
export interface ViewFilter {
projectId?: string;
tagIds?: string[];
priorities?: string[];
}
@ -92,7 +79,7 @@ export interface ViewFilter {
export interface LocalBoardView extends BaseRecord {
name: string;
icon: string;
groupBy: 'status' | 'priority' | 'project' | 'dueDate' | 'tag' | 'custom';
groupBy: 'status' | 'priority' | 'dueDate' | 'tag' | 'custom';
columns: ViewColumn[];
filter?: ViewFilter;
layout: 'kanban' | 'grid' | 'fokus';
@ -108,22 +95,9 @@ export const todoStore = createLocalStore({
collections: [
{
name: 'tasks',
indexes: [
'projectId',
'dueDate',
'isCompleted',
'priority',
'order',
'[isCompleted+order]',
'[projectId+order]',
],
indexes: ['dueDate', 'isCompleted', 'priority', 'order', '[isCompleted+order]'],
guestSeed: guestTasks,
},
{
name: 'projects',
indexes: ['order', 'isArchived'],
guestSeed: guestProjects,
},
{
name: 'labels',
indexes: [],
@ -150,7 +124,6 @@ export const todoStore = createLocalStore({
// Typed collection accessors
export const taskCollection = todoStore.collection<LocalTask>('tasks');
export const projectCollection = todoStore.collection<LocalProject>('projects');
export const labelCollection = todoStore.collection<LocalLabel>('labels');
export const taskLabelCollection = todoStore.collection<LocalTaskLabel>('taskLabels');
export const reminderCollection = todoStore.collection<LocalReminder>('reminders');

View file

@ -9,13 +9,11 @@
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
taskCollection,
projectCollection,
boardViewCollection,
type LocalTask,
type LocalProject,
type LocalBoardView,
} from './local-store';
import type { Task, Project } from '@todo/shared';
import type { Task } from '@todo/shared';
import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
// ─── Type Converters ───────────────────────────────────────
@ -23,7 +21,6 @@ import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
export function toTask(local: LocalTask): Task {
return {
id: local.id,
projectId: local.projectId,
userId: local.userId ?? 'guest',
title: local.title,
description: local.description,
@ -44,21 +41,6 @@ export function toTask(local: LocalTask): Task {
};
}
export function toProject(local: LocalProject): Project {
return {
id: local.id,
userId: local.userId ?? 'guest',
name: local.name,
color: local.color,
icon: local.icon,
order: local.order,
isArchived: local.isArchived,
isDefault: local.isDefault,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All tasks, sorted by order. Auto-updates on any change. */
@ -72,17 +54,6 @@ export function useAllTasks() {
}, [] as Task[]);
}
/** All projects, sorted by order. Auto-updates on any change. */
export function useAllProjects() {
return useLiveQueryWithDefault(async () => {
const locals = await projectCollection.getAll(undefined, {
sortBy: 'order',
sortDirection: 'asc',
});
return locals.map(toProject);
}, [] as Project[]);
}
/** All board views, sorted by order. Auto-updates on any change. */
export function useAllBoardViews() {
return useLiveQueryWithDefault(async () => {
@ -132,36 +103,6 @@ export function filterUpcoming(tasks: Task[]): Task[] {
});
}
export function filterByProject(tasks: Task[], projectId: string | null): Task[] {
if (projectId === null) {
return tasks.filter((t) => !t.projectId);
}
return tasks.filter((t) => t.projectId === projectId);
}
export function filterByLabel(tasks: Task[], labelId: string): Task[] {
return tasks.filter((t) => t.labels?.some((l) => l.id === labelId));
}
// ─── Pure Project Helpers ──────────────────────────────────
export function getActiveProjects(projects: Project[]): Project[] {
return projects.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order);
}
export function getArchivedProjects(projects: Project[]): Project[] {
return projects.filter((p) => p.isArchived);
}
export function getInboxProject(projects: Project[]): Project | undefined {
return projects.find((p) => p.isDefault);
}
export function getProjectById(projects: Project[], id: string): Project | undefined {
return projects.find((p) => p.id === id);
}
export function getProjectColor(projects: Project[], projectId: string): string {
const project = projects.find((p) => p.id === projectId);
return project?.color || '#6b7280';
}

View file

@ -5,7 +5,7 @@
* No side effects, no store dependencies easy to test.
*/
import type { Task, Project } from '@todo/shared';
import type { Task } from '@todo/shared';
import type { LocalBoardView, ViewColumn, DropAction } from './local-store';
import { isToday, isPast, isTomorrow, startOfDay, addDays, isFuture } from 'date-fns';
@ -21,11 +21,7 @@ export interface GroupedColumn {
// ─── Main Grouping Function ────────────────────────────────
export function groupTasksByView(
view: LocalBoardView,
tasks: Task[],
projects: Project[]
): GroupedColumn[] {
export function groupTasksByView(view: LocalBoardView, tasks: Task[]): GroupedColumn[] {
// Only group incomplete tasks (unless status view includes completed)
const activeTasks = view.groupBy === 'status' ? tasks : tasks.filter((t) => !t.isCompleted);
@ -37,8 +33,6 @@ export function groupTasksByView(
return groupByStatus(filtered, view.columns);
case 'priority':
return groupByPriority(filtered, view.columns);
case 'project':
return groupByProject(filtered, view.columns, projects);
case 'dueDate':
return groupByDueDate(filtered, view.columns);
case 'tag':
@ -76,44 +70,6 @@ function groupByPriority(tasks: Task[], columns: ViewColumn[]): GroupedColumn[]
}));
}
function groupByProject(
tasks: Task[],
columns: ViewColumn[],
projects: Project[]
): GroupedColumn[] {
// Dynamic: generate columns from projects
if (columns.length === 0) {
const activeProjects = projects.filter((p) => !p.isArchived);
const dynamicColumns: GroupedColumn[] = [
{
id: 'col-inbox',
name: 'Inbox',
color: '#6B7280',
tasks: tasks.filter((t) => !t.projectId),
onDrop: { setProjectId: null },
},
...activeProjects.map((p) => ({
id: `col-proj-${p.id}`,
name: p.name,
color: p.color,
tasks: tasks.filter((t) => t.projectId === p.id),
onDrop: { setProjectId: p.id } as DropAction,
})),
];
return dynamicColumns;
}
// Static columns from config
return columns.map((col) => ({
id: col.id,
name: col.name,
color: col.color,
onDrop: col.onDrop,
tasks: tasks.filter((t) =>
col.match.value === null ? !t.projectId : t.projectId === col.match.value
),
}));
}
function groupByDueDate(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
const today = startOfDay(new Date());
const tomorrowDate = addDays(today, 1);
@ -152,9 +108,7 @@ function groupByTag(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
name: col.name,
color: col.color,
onDrop: col.onDrop,
tasks: tasks.filter(
(t) => t.labels?.some((l) => l.id === col.match.value) ?? false
),
tasks: tasks.filter((t) => t.labels?.some((l) => l.id === col.match.value) ?? false),
}));
}
@ -236,13 +190,13 @@ function groupEisenhower(tasks: Task[], columns: ViewColumn[]): GroupedColumn[]
// ─── Helpers ───────────────────────────────────────────────
function applyViewFilter(tasks: Task[], filter?: { projectId?: string; tagIds?: string[]; priorities?: string[] }): Task[] {
function applyViewFilter(
tasks: Task[],
filter?: { tagIds?: string[]; priorities?: string[] }
): Task[] {
if (!filter) return tasks;
let result = tasks;
if (filter.projectId) {
result = result.filter((t) => t.projectId === filter.projectId);
}
if (filter.priorities && filter.priorities.length > 0) {
result = result.filter((t) => filter.priorities!.includes(t.priority));
}
@ -263,6 +217,5 @@ export function getDropActionUpdate(action: DropAction): Record<string, unknown>
update.completedAt = action.setCompleted ? new Date().toISOString() : null;
}
if (action.setPriority) update.priority = action.setPriority;
if (action.setProjectId !== undefined) update.projectId = action.setProjectId;
return update;
}

View file

@ -54,7 +54,7 @@ const todoOnboardingSteps: AppOnboardingStep[] = [
{
id: 'normal',
label: 'Normal',
description: 'Mit Projekt, Labels und Fälligkeit',
description: 'Mit Tags, Labels und Fälligkeit',
emoji: '📝',
},
{
@ -74,10 +74,10 @@ const todoOnboardingSteps: AppOnboardingStep[] = [
emoji: '🎉',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Schnelleingabe: "Meeting morgen 14 Uhr !hoch @Arbeit #wichtig"',
'Nutze @Projekt und #Label direkt beim Erstellen',
'Schnelleingabe: "Meeting morgen 14 Uhr !hoch #wichtig"',
'Nutze #Tags direkt beim Erstellen',
'Drücke "F" für den Fokus-Modus ohne Ablenkungen',
'Erstelle Projekte, um Aufgaben zu organisieren',
'Nutze Tags, um Aufgaben zu organisieren',
],
},
];

View file

@ -1,5 +1,4 @@
export { authStore } from './auth.svelte';
export { projectsStore } from './projects.svelte';
export { tasksStore } from './tasks.svelte';
export { viewStore } from './view.svelte';
export type { ViewType, SortBy, SortOrder } from './view.svelte';

View file

@ -1,103 +1 @@
/**
* Projects Store Mutation-Only Service
*
* All reads are handled by useLiveQuery() hooks in task-queries.ts.
* This store only provides write operations (create, update, delete, etc.).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import type { Project } from '@todo/shared';
import { projectCollection, type LocalProject } from '$lib/data/local-store';
import { toProject } from '$lib/data/task-queries';
import { TodoEvents } from '@manacore/shared-utils/analytics';
import { withErrorHandling } from './store-helpers';
let error = $state<string | null>(null);
const setError = (e: string | null) => (error = e);
export const projectsStore = {
get error() {
return error;
},
async createProject(data: { name: string; description?: string; color?: string; icon?: string }) {
return withErrorHandling(
setError,
async () => {
const count = await projectCollection.count();
const newLocal: LocalProject = {
id: crypto.randomUUID(),
name: data.name,
color: data.color ?? '#6b7280',
icon: data.icon ?? null,
order: count,
isArchived: false,
isDefault: false,
};
const inserted = await projectCollection.insert(newLocal);
TodoEvents.projectCreated();
return toProject(inserted);
},
'Failed to create project'
);
},
async updateProject(
id: string,
data: { name?: string; description?: string; color?: string; icon?: string }
) {
return withErrorHandling(
setError,
async () => {
const updated = await projectCollection.update(id, data as Partial<LocalProject>);
if (updated) {
return toProject(updated);
}
},
'Failed to update project'
);
},
async deleteProject(id: string) {
return withErrorHandling(
setError,
async () => {
await projectCollection.delete(id);
TodoEvents.projectDeleted();
},
'Failed to delete project'
);
},
async archiveProject(id: string) {
return withErrorHandling(
setError,
async () => {
const updated = await projectCollection.update(id, {
isArchived: true,
} as Partial<LocalProject>);
if (updated) {
return toProject(updated);
}
},
'Failed to archive project'
);
},
async reorderProjects(projectIds: string[]) {
return withErrorHandling(
setError,
async () => {
for (let i = 0; i < projectIds.length; i++) {
await projectCollection.update(projectIds[i], { order: i } as Partial<LocalProject>);
}
},
'Failed to reorder projects'
);
},
get guestInboxId() {
return 'personal-project';
},
};
export {};

View file

@ -23,7 +23,6 @@ export const tasksStore = {
async createTask(data: {
title: string;
description?: string;
projectId?: string;
dueDate?: string;
priority?: TaskPriority;
labelIds?: string[];
@ -39,7 +38,6 @@ export const tasksStore = {
id: crypto.randomUUID(),
title: data.title,
description: data.description,
projectId: data.projectId ?? null,
priority: data.priority ?? 'medium',
isCompleted: false,
dueDate: data.dueDate ?? null,
@ -62,7 +60,6 @@ export const tasksStore = {
data: {
title?: string;
description?: string | null;
projectId?: string | null;
parentTaskId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
@ -152,19 +149,6 @@ export const tasksStore = {
);
},
async moveTask(id: string, projectId: string | null) {
return withErrorHandling(
setError,
async () => {
const updated = await taskCollection.update(id, { projectId } as Partial<LocalTask>);
if (updated) {
return toTask(updated);
}
},
'Failed to move task'
);
},
async updateLabels(id: string, labelIds: string[]) {
return withErrorHandling(
setError,

View file

@ -4,20 +4,12 @@
import type { TaskPriority } from '@todo/shared';
export type ViewType =
| 'inbox'
| 'today'
| 'upcoming'
| 'project'
| 'label'
| 'completed'
| 'search';
export type ViewType = 'inbox' | 'today' | 'upcoming' | 'label' | 'completed' | 'search';
export type SortBy = 'dueDate' | 'priority' | 'title' | 'createdAt' | 'order';
export type SortOrder = 'asc' | 'desc';
// State
let currentView = $state<ViewType>('inbox');
let currentProjectId = $state<string | null>(null);
let currentLabelId = $state<string | null>(null);
let searchQuery = $state('');
let sortBy = $state<SortBy>('order');
@ -26,7 +18,6 @@ let showCompleted = $state(false);
// Filter state (used by TaskFilters strip in list view)
let filterPriorities = $state<TaskPriority[]>([]);
let filterProjectId = $state<string | null>(null);
let filterLabelIds = $state<string[]>([]);
let filterSearchQuery = $state('');
@ -35,9 +26,6 @@ export const viewStore = {
get currentView() {
return currentView;
},
get currentProjectId() {
return currentProjectId;
},
get currentLabelId() {
return currentLabelId;
},
@ -56,9 +44,6 @@ export const viewStore = {
get filterPriorities() {
return filterPriorities;
},
get filterProjectId() {
return filterProjectId;
},
get filterLabelIds() {
return filterLabelIds;
},
@ -71,7 +56,6 @@ export const viewStore = {
*/
setInbox() {
currentView = 'inbox';
currentProjectId = null;
currentLabelId = null;
searchQuery = '';
},
@ -81,7 +65,6 @@ export const viewStore = {
*/
setToday() {
currentView = 'today';
currentProjectId = null;
currentLabelId = null;
searchQuery = '';
},
@ -91,17 +74,6 @@ export const viewStore = {
*/
setUpcoming() {
currentView = 'upcoming';
currentProjectId = null;
currentLabelId = null;
searchQuery = '';
},
/**
* Set current view to a specific project
*/
setProject(projectId: string) {
currentView = 'project';
currentProjectId = projectId;
currentLabelId = null;
searchQuery = '';
},
@ -111,7 +83,6 @@ export const viewStore = {
*/
setLabel(labelId: string) {
currentView = 'label';
currentProjectId = null;
currentLabelId = labelId;
searchQuery = '';
},
@ -121,7 +92,6 @@ export const viewStore = {
*/
setCompleted() {
currentView = 'completed';
currentProjectId = null;
currentLabelId = null;
searchQuery = '';
},
@ -131,7 +101,6 @@ export const viewStore = {
*/
setSearch(query: string) {
currentView = 'search';
currentProjectId = null;
currentLabelId = null;
searchQuery = query;
},
@ -172,13 +141,6 @@ export const viewStore = {
filterPriorities = priorities;
},
/**
* Set filter project
*/
setFilterProjectId(id: string | null) {
filterProjectId = id;
},
/**
* Set filter label IDs
*/
@ -198,7 +160,6 @@ export const viewStore = {
*/
clearFilters() {
filterPriorities = [];
filterProjectId = null;
filterLabelIds = [];
filterSearchQuery = '';
},
@ -208,14 +169,12 @@ export const viewStore = {
*/
reset() {
currentView = 'inbox';
currentProjectId = null;
currentLabelId = null;
searchQuery = '';
sortBy = 'order';
sortOrder = 'asc';
showCompleted = false;
filterPriorities = [];
filterProjectId = null;
filterLabelIds = [];
filterSearchQuery = '';
},

View file

@ -12,10 +12,6 @@ describe('viewStore filter methods', () => {
expect(viewStore.filterPriorities).toEqual([]);
});
it('has null filter project', () => {
expect(viewStore.filterProjectId).toBeNull();
});
it('has empty filter labels', () => {
expect(viewStore.filterLabelIds).toEqual([]);
});
@ -39,19 +35,6 @@ describe('viewStore filter methods', () => {
});
});
describe('setFilterProjectId', () => {
it('sets project ID', () => {
viewStore.setFilterProjectId('proj-1');
expect(viewStore.filterProjectId).toBe('proj-1');
});
it('can clear to null', () => {
viewStore.setFilterProjectId('proj-1');
viewStore.setFilterProjectId(null);
expect(viewStore.filterProjectId).toBeNull();
});
});
describe('setFilterLabelIds', () => {
it('sets label IDs', () => {
viewStore.setFilterLabelIds(['label-1', 'label-2']);
@ -70,14 +53,12 @@ describe('viewStore filter methods', () => {
describe('clearFilters', () => {
it('resets all filter state', () => {
viewStore.setFilterPriorities(['urgent']);
viewStore.setFilterProjectId('proj-1');
viewStore.setFilterLabelIds(['label-1']);
viewStore.setFilterSearchQuery('test');
viewStore.clearFilters();
expect(viewStore.filterPriorities).toEqual([]);
expect(viewStore.filterProjectId).toBeNull();
expect(viewStore.filterLabelIds).toEqual([]);
expect(viewStore.filterSearchQuery).toBe('');
});
@ -99,13 +80,11 @@ describe('viewStore filter methods', () => {
describe('reset', () => {
it('resets filter state along with everything else', () => {
viewStore.setFilterPriorities(['urgent']);
viewStore.setFilterProjectId('proj-1');
viewStore.setSort('title', 'desc');
viewStore.reset();
expect(viewStore.filterPriorities).toEqual([]);
expect(viewStore.filterProjectId).toBeNull();
expect(viewStore.sortBy).toBe('order');
expect(viewStore.sortOrder).toBe('asc');
expect(viewStore.currentView).toBe('inbox');

View file

@ -34,33 +34,30 @@ function makeTask(overrides: Partial<Task> = {}): Task {
const emptyFilters: TaskFilterCriteria = {
priorities: [],
projectId: null,
labelIds: [],
searchQuery: '',
};
describe('applyTaskFilters', () => {
const tasks: Task[] = [
makeTask({ id: '1', title: 'Buy groceries', priority: 'low', projectId: 'proj-a' }),
makeTask({ id: '1', title: 'Buy groceries', priority: 'low' }),
makeTask({
id: '2',
title: 'Urgent meeting',
priority: 'urgent',
projectId: 'proj-b',
labels: [makeLabel({ id: 'label-1', name: 'Work', color: '#f00' })],
}),
makeTask({
id: '3',
title: 'Write report',
priority: 'high',
projectId: 'proj-a',
description: 'Quarterly financial report',
labels: [
makeLabel({ id: 'label-1', name: 'Work', color: '#f00' }),
makeLabel({ id: 'label-2', name: 'Important', color: '#0f0' }),
],
}),
makeTask({ id: '4', title: 'Relax', priority: 'low', projectId: null }),
makeTask({ id: '4', title: 'Relax', priority: 'low' }),
];
it('returns all tasks when no filters are active', () => {
@ -96,26 +93,6 @@ describe('applyTaskFilters', () => {
});
});
// Project filtering
describe('project filter', () => {
it('filters by project ID', () => {
const result = applyTaskFilters(tasks, { ...emptyFilters, projectId: 'proj-a' });
expect(result).toHaveLength(2);
expect(result.map((t) => t.id).sort()).toEqual(['1', '3']);
});
it('does not match tasks with null projectId when filtering', () => {
const result = applyTaskFilters(tasks, { ...emptyFilters, projectId: 'proj-b' });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('2');
});
it('skips project filter when null', () => {
const result = applyTaskFilters(tasks, { ...emptyFilters, projectId: null });
expect(result).toHaveLength(4);
});
});
// Label filtering
describe('label filter', () => {
it('filters by single label', () => {
@ -140,7 +117,6 @@ describe('applyTaskFilters', () => {
it('excludes tasks with no labels', () => {
const result = applyTaskFilters(tasks, { ...emptyFilters, labelIds: ['label-1'] });
// Tasks 1 and 4 have no labels
expect(result.find((t) => t.id === '1')).toBeUndefined();
expect(result.find((t) => t.id === '4')).toBeUndefined();
});
@ -180,16 +156,6 @@ describe('applyTaskFilters', () => {
// Combined filters
describe('combined filters', () => {
it('applies priority + project filter together (AND)', () => {
const result = applyTaskFilters(tasks, {
...emptyFilters,
priorities: ['low'],
projectId: 'proj-a',
});
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1');
});
it('applies priority + label filter together', () => {
const result = applyTaskFilters(tasks, {
...emptyFilters,
@ -203,7 +169,6 @@ describe('applyTaskFilters', () => {
it('applies all filters together', () => {
const result = applyTaskFilters(tasks, {
priorities: ['high'],
projectId: 'proj-a',
labelIds: ['label-1'],
searchQuery: 'report',
});
@ -213,9 +178,8 @@ describe('applyTaskFilters', () => {
it('returns empty when combined filters contradict', () => {
const result = applyTaskFilters(tasks, {
priorities: ['urgent'],
projectId: 'proj-a', // task 2 is urgent but in proj-b
labelIds: [],
priorities: ['low'],
labelIds: ['label-1'], // tasks 1,4 are low but have no label-1
searchQuery: '',
});
expect(result).toHaveLength(0);

View file

@ -2,7 +2,6 @@ import type { Task, TaskPriority } from '@todo/shared';
export interface TaskFilterCriteria {
priorities: TaskPriority[];
projectId: string | null;
labelIds: string[];
searchQuery: string;
}
@ -18,10 +17,6 @@ export function applyTaskFilters(tasks: Task[], filters: TaskFilterCriteria): Ta
filtered = filtered.filter((t) => filters.priorities.includes(t.priority));
}
if (filters.projectId) {
filtered = filtered.filter((t) => t.projectId === filters.projectId);
}
if (filters.labelIds.length > 0) {
filtered = filtered.filter((t) => t.labels?.some((l) => filters.labelIds.includes(l.id)));
}

View file

@ -12,7 +12,6 @@ describe('parseTaskInput', () => {
const result = parseTaskInput('Einkaufen gehen');
expect(result.title).toBe('Einkaufen gehen');
expect(result.priority).toBeUndefined();
expect(result.projectName).toBeUndefined();
expect(result.labelNames).toEqual([]);
});
@ -48,12 +47,6 @@ describe('parseTaskInput', () => {
expect(result.priority).toBe('low');
});
it('should parse @project', () => {
const result = parseTaskInput('Task erledigen @Arbeit');
expect(result.projectName).toBe('Arbeit');
expect(result.title).not.toContain('@Arbeit');
});
it('should parse #labels', () => {
const result = parseTaskInput('Anrufen #arbeit #privat');
expect(result.labelNames).toEqual(['arbeit', 'privat']);
@ -61,9 +54,8 @@ describe('parseTaskInput', () => {
});
it('should parse complex input with all fields', () => {
const result = parseTaskInput('Meeting vorbereiten !!! @Arbeit #wichtig #team');
const result = parseTaskInput('Meeting vorbereiten !!! #wichtig #team');
expect(result.priority).toBe('urgent');
expect(result.projectName).toBe('Arbeit');
expect(result.labelNames).toEqual(['wichtig', 'team']);
expect(result.title).toContain('Meeting vorbereiten');
});
@ -92,9 +84,8 @@ describe('parseTaskInput', () => {
});
it('should parse recurrence "wöchentlich"', () => {
const result = parseTaskInput('Review wöchentlich @Arbeit');
const result = parseTaskInput('Review wöchentlich');
expect(result.recurrenceRule).toBe('FREQ=WEEKLY');
expect(result.projectName).toBe('Arbeit');
});
it('should have no recurrence for normal input', () => {
@ -120,57 +111,38 @@ describe('parseTaskInput', () => {
});
it('should parse subtasks with other fields', () => {
const result = parseTaskInput('Einkaufen: Milch, Brot morgen !! @Privat');
const result = parseTaskInput('Einkaufen: Milch, Brot morgen !!');
expect(result.title).toBe('Einkaufen');
expect(result.subtasks).toEqual(['Milch', 'Brot']);
expect(result.priority).toBe('high');
expect(result.projectName).toBe('Privat');
});
});
describe('resolveTaskIds', () => {
const projects = [
{ id: 'proj-1', name: 'Arbeit' },
{ id: 'proj-2', name: 'Privat' },
];
const labels = [
{ id: 'label-1', name: 'Wichtig' },
{ id: 'label-2', name: 'Team' },
{ id: 'label-3', name: 'Bug' },
];
it('should resolve project name to ID (case-insensitive)', () => {
const parsed = parseTaskInput('Task @arbeit');
const resolved = resolveTaskIds(parsed, projects, labels);
expect(resolved.projectId).toBe('proj-1');
});
it('should resolve label names to IDs (case-insensitive)', () => {
// Note: "wichtig" is consumed by priority extraction, so use "bug" instead
const parsed = parseTaskInput('Task #bug #team');
const resolved = resolveTaskIds(parsed, projects, labels);
const resolved = resolveTaskIds(parsed, labels);
expect(resolved.labelIds).toEqual(['label-3', 'label-2']);
});
it('should skip unknown project', () => {
const parsed = parseTaskInput('Task @Unbekannt');
const resolved = resolveTaskIds(parsed, projects, labels);
expect(resolved.projectId).toBeUndefined();
});
it('should skip unknown labels', () => {
const parsed = parseTaskInput('Task #nichtda');
const resolved = resolveTaskIds(parsed, projects, labels);
const resolved = resolveTaskIds(parsed, labels);
expect(resolved.labelIds).toEqual([]);
});
it('should preserve title and priority', () => {
const parsed = parseTaskInput('Meeting vorbereiten !!! @Arbeit #wichtig');
const resolved = resolveTaskIds(parsed, projects, labels);
const parsed = parseTaskInput('Meeting vorbereiten !!! #wichtig');
const resolved = resolveTaskIds(parsed, labels);
expect(resolved.title).toContain('Meeting vorbereiten');
expect(resolved.priority).toBe('urgent');
expect(resolved.projectId).toBe('proj-1');
expect(resolved.labelIds).toEqual(['label-1']);
});
});
@ -182,12 +154,6 @@ describe('formatParsedTaskPreview', () => {
expect(preview).toContain('Dringend');
});
it('should format project', () => {
const parsed = parseTaskInput('Task @Arbeit');
const preview = formatParsedTaskPreview(parsed);
expect(preview).toContain('Arbeit');
});
it('should format labels', () => {
const parsed = parseTaskInput('Task #arbeit #team');
const preview = formatParsedTaskPreview(parsed);
@ -201,9 +167,9 @@ describe('formatParsedTaskPreview', () => {
});
it('should join parts with separator', () => {
const parsed = parseTaskInput('Task !!! @Arbeit');
const parsed = parseTaskInput('Task !!!');
const preview = formatParsedTaskPreview(parsed);
expect(preview).toContain(' · ');
expect(preview).not.toBe('');
});
it('should format duration in preview', () => {
@ -248,10 +214,9 @@ describe('duration extraction', () => {
});
it('should work with other fields', () => {
const result = parseTaskInput('Meeting 2h morgen !! @Arbeit');
const result = parseTaskInput('Meeting 2h morgen !!');
expect(result.estimatedDuration).toBe(120);
expect(result.priority).toBe('high');
expect(result.projectName).toBe('Arbeit');
});
});
@ -293,13 +258,6 @@ describe('parseMultiTaskInput', () => {
expect(tasks[0].dueDate!.toDateString()).toBe(tasks[1].dueDate!.toDateString());
});
it('should inherit project context from first task', () => {
const tasks = parseMultiTaskInput('Meeting @Arbeit danach Report schreiben');
expect(tasks).toHaveLength(2);
expect(tasks[0].projectName).toBe('Arbeit');
expect(tasks[1].projectName).toBe('Arbeit');
});
it('should offset time when first task has duration', () => {
const tasks = parseMultiTaskInput('Meeting 14 Uhr 1h danach Notizen');
expect(tasks).toHaveLength(2);

View file

@ -8,7 +8,6 @@
import {
parseBaseInput,
extractAtReference,
extractRecurrence,
combineDateAndTime,
formatDatePreview,
@ -22,18 +21,12 @@ export interface ParsedTask {
dueDate?: Date;
dueTime?: string; // HH:mm format
priority?: TaskPriority;
projectName?: string;
labelNames: string[];
recurrenceRule?: string;
subtasks?: string[];
estimatedDuration?: number; // in minutes
}
interface Project {
id: string;
name: string;
}
interface Label {
id: string;
name: string;
@ -44,7 +37,6 @@ export interface ParsedTaskWithIds {
dueDate?: string;
dueTime?: string;
priority?: TaskPriority;
projectId?: string;
labelIds: string[];
recurrenceRule?: string;
subtasks?: string[];
@ -152,7 +144,6 @@ export function parseMultiTaskInput(input: string, locale: ParserLocale = 'de'):
const results: ParsedTask[] = [];
let contextDate: Date | undefined;
let contextTime: string | undefined;
let contextProject: string | undefined;
let lastEndMinutes: number | undefined; // track end time for "danach" offset
for (let i = 0; i < parts.length; i++) {
@ -162,7 +153,6 @@ export function parseMultiTaskInput(input: string, locale: ParserLocale = 'de'):
// First task sets the context
contextDate = parsed.dueDate;
contextTime = parsed.dueTime;
contextProject = parsed.projectName;
// Calculate end time if duration is known
if (parsed.dueDate && parsed.estimatedDuration) {
@ -185,10 +175,6 @@ export function parseMultiTaskInput(input: string, locale: ParserLocale = 'de'):
parsed.dueTime = contextTime;
}
}
if (!parsed.projectName && contextProject) {
parsed.projectName = contextProject;
}
// Update end time for next task
if (parsed.dueDate && parsed.estimatedDuration) {
lastEndMinutes =
@ -289,11 +275,6 @@ export function parseTaskInput(input: string, locale: ParserLocale = 'de'): Pars
text = durationResult.remaining;
const estimatedDuration = durationResult.duration;
// Extract project (@ProjectName) - task-specific
const projectResult = extractAtReference(text);
text = projectResult.remaining;
const projectName = projectResult.value;
// Use base parser for common patterns (date, time, tags)
const base = parseBaseInput(text, locale);
@ -313,7 +294,6 @@ export function parseTaskInput(input: string, locale: ParserLocale = 'de'): Pars
dueDate,
dueTime,
priority,
projectName,
labelNames: base.tagNames,
recurrenceRule,
subtasks: subtaskResult.subtasks,
@ -322,26 +302,11 @@ export function parseTaskInput(input: string, locale: ParserLocale = 'de'): Pars
}
/**
* Resolve project and label names to IDs
* Resolve label names to IDs
*/
export function resolveTaskIds(
parsed: ParsedTask,
projects: Project[],
labels: Label[]
): ParsedTaskWithIds {
let projectId: string | undefined;
export function resolveTaskIds(parsed: ParsedTask, labels: Label[]): ParsedTaskWithIds {
const labelIds: string[] = [];
// Find project by name (case-insensitive)
if (parsed.projectName) {
const project = projects.find(
(p) => p.name.toLowerCase() === parsed.projectName!.toLowerCase()
);
if (project) {
projectId = project.id;
}
}
// Find labels by name (case-insensitive)
for (const labelName of parsed.labelNames) {
const label = labels.find((l) => l.name.toLowerCase() === labelName.toLowerCase());
@ -355,7 +320,6 @@ export function resolveTaskIds(
dueDate: parsed.dueDate?.toISOString(),
dueTime: parsed.dueTime,
priority: parsed.priority,
projectId,
labelIds,
recurrenceRule: parsed.recurrenceRule,
subtasks: parsed.subtasks,
@ -406,10 +370,6 @@ export function formatParsedTaskPreview(parsed: ParsedTask, locale: ParserLocale
parts.push(PRIORITY_LABELS[locale][parsed.priority]);
}
if (parsed.projectName) {
parts.push(`📁 ${parsed.projectName}`);
}
if (parsed.recurrenceRule) {
parts.push(`🔄 ${parsed.recurrenceRule}`);
}

View file

@ -9,7 +9,6 @@
export interface CompletedTaskData {
title: string;
projectId?: string | null;
labelIds?: string[];
priority: string;
estimatedDuration?: number | null; // minutes
@ -104,17 +103,12 @@ function titleOverlap(a: string[], b: string[]): number {
* Compute similarity score between a new task and a historical task
*/
function similarity(
newTask: { title: string; projectId?: string | null; labelIds?: string[]; priority: string },
newTask: { title: string; labelIds?: string[]; priority: string },
historical: CompletedTaskData,
newTokens: string[]
): number {
let score = 0;
// Same project is the strongest signal
if (newTask.projectId && historical.projectId && newTask.projectId === historical.projectId) {
score += 3;
}
// Shared labels
if (newTask.labelIds && historical.labelIds) {
const histSet = new Set(historical.labelIds);
@ -168,7 +162,7 @@ function getEffectiveDuration(task: CompletedTaskData): number | null {
* @returns Estimate or null if insufficient data
*/
export function estimateDuration(
newTask: { title: string; projectId?: string | null; labelIds?: string[]; priority: string },
newTask: { title: string; labelIds?: string[]; priority: string },
history: CompletedTaskData[],
minSamples = 3
): DurationEstimate | null {

View file

@ -19,7 +19,6 @@
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { todoSettings } from '$lib/stores/settings.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import {
tagLocalStore,
@ -48,18 +47,12 @@
import { TodoEvents } from '@manacore/shared-utils/analytics';
import { todoStore, taskCollection } from '$lib/data/local-store';
import type { LocalBoardView } from '$lib/data/local-store';
import {
useAllTasks,
useAllProjects,
useAllBoardViews,
getActiveProjects,
} from '$lib/data/task-queries';
import { useAllTasks, useAllBoardViews } from '$lib/data/task-queries';
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
import { List, X } from '@manacore/shared-icons';
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allTasks = useAllTasks();
const allProjects = useAllProjects();
const allTags = useAllSharedTags();
// ─── Board View Management ──────────────────────────────
@ -69,7 +62,6 @@
let activeView = $derived(boardViews.value[0] ?? null);
// Provide data to child components via Svelte context
setContext('projects', allProjects);
setContext('tasks', allTasks);
setContext('tags', allTags);
setContext('activeView', {
@ -92,9 +84,6 @@
},
});
// Derived active projects for UI
let activeProjects = $derived(getActiveProjects(allProjects.value));
// Guest welcome modal state
let showGuestWelcome = $state(false);
@ -162,13 +151,12 @@
try {
const parsed = parseTaskInput(query);
const resolved = resolveTaskIds(parsed, allProjects.value, allTags.value);
const resolved = resolveTaskIds(parsed, allTags.value);
await tasksStore.createTask({
title: resolved.title,
dueDate: resolved.dueDate,
priority: resolved.priority,
projectId: resolved.projectId,
labelIds: resolved.labelIds,
});
TodoEvents.quickAddUsed();
@ -462,11 +450,9 @@
<TaskFilters
variant="strip"
selectedPriorities={viewStore.filterPriorities}
selectedProjectId={viewStore.filterProjectId}
selectedLabelIds={viewStore.filterLabelIds}
searchQuery={viewStore.filterSearchQuery}
onPrioritiesChange={(p: TaskPriority[]) => viewStore.setFilterPriorities(p)}
onProjectChange={(id: string | null) => viewStore.setFilterProjectId(id)}
onLabelsChange={(ids: string[]) => viewStore.setFilterLabelIds(ids)}
onSearchChange={(q: string) => viewStore.setFilterSearchQuery(q)}
onClearFilters={() => viewStore.clearFilters()}

View file

@ -27,7 +27,6 @@
const GROUPBY_OPTIONS = [
{ value: 'status', label: 'Status' },
{ value: 'priority', label: 'Priorität' },
{ value: 'project', label: 'Projekt' },
{ value: 'dueDate', label: 'Fälligkeit' },
{ value: 'custom', label: 'Benutzerdefiniert' },
];

View file

@ -10,10 +10,6 @@
type KanbanCardSize,
type PageMode,
} from '$lib/stores/settings.svelte';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import type { TaskPriority } from '@todo/shared';
import { PRIORITY_OPTIONS } from '@todo/shared';
import {
@ -40,7 +36,6 @@
EnvelopeSimple,
Eye,
Fire,
FolderSimple,
GraduationCap,
GridFour,
Hash,
@ -56,7 +51,6 @@
SignOut,
SortAscending,
SquaresFour,
Stack,
Tag,
Timer,
Trash,
@ -107,12 +101,6 @@
{ value: '120', label: '2 Stunden' },
];
// Project options for quick add (computed)
let projectOptions = $derived([
{ value: null, label: 'Inbox' },
...projectsCtx.value.map((p) => ({ value: p.id, label: p.name })),
]);
onMount(async () => {
// Load user settings and projects from server (only if authenticated)
if (authStore.isAuthenticated) {
@ -227,19 +215,6 @@
{/snippet}
</SettingsTimeInput>
<SettingsSelect
label="Standard-Projekt"
description="Projekt für Quick-Add"
options={projectOptions}
value={todoSettings.quickAddProject}
onchange={(v: string | number | null) =>
todoSettings.set('quickAddProject', v as string | null)}
>
{#snippet icon()}
<FolderSimple size={20} />
{/snippet}
</SettingsSelect>
<SettingsNumberInput
label="Auto-Archivierung"
description="Erledigte Tasks nach X Tagen archivieren"
@ -339,21 +314,10 @@
description="Fortschrittsbalken für Subtasks anzeigen"
isOn={todoSettings.showSubtaskProgress}
onToggle={(v) => todoSettings.set('showSubtaskProgress', v)}
>
{#snippet icon()}
<ChartBar size={20} />
{/snippet}
</SettingsToggle>
<SettingsToggle
label="Nach Projekt gruppieren"
description="Tasks nach Projekt gruppieren"
isOn={todoSettings.groupByProject}
onToggle={(v) => todoSettings.set('groupByProject', v)}
border={false}
>
{#snippet icon()}
<Stack size={20} />
<ChartBar size={20} />
{/snippet}
</SettingsToggle>
</SettingsCard>