Kanban-Board Organisation mit SvelteKit
Wie ich mit SvelteKit, Zod und nativem HTML5-Drag-and-Drop ein einfaches Kanban-Board für meine eigenen Dev-Tasks erstelle.
Ein Kanban-Board für eigene Dev-Tasks
Ich wollte ein kleines Tool, um meine eigenen Aufgaben – Refactorings, Organisatorisches, Programmieraufgaben – nicht mehr in einer losen Notiz-App zu sammeln, sondern in einem echten Board mit Spalten und Drag-and-Drop. Quasi ein eigenes privates DevBoard: SvelteKit im Backend und Frontend, Zod für die Validierung, und eine simple JSON-Datei als Speicher. Kein Framework-Overkill, pragmatische Architektur.
Datenmodell mit Zod
Der erste Schritt war, die Form eines Tasks zu definieren – und zwar nicht als TypeScript-Interface, sondern als Zod-Schema, aus dem sich der Typ automatisch ableiten lässt. So existiert die Form nur an einer Stelle:
export const TaskCategory = z.enum(['Refactoring', 'Organisation']);
export const TaskPriority = z.enum(['Low', 'Mid', 'High']);
export const TaskStatus = z.enum(['Todo', 'In Progress', 'Done']);
export const TaskSchema = z.object({
id: z.string(),
title: z.string().min(1, 'Title is required'),
description: z.string().optional().default(''),
category: TaskCategory,
priority: TaskPriority,
status: TaskStatus,
createdAt: z.coerce.date(),
});
export type Task = z.infer<typeof TaskSchema>;
Für das Erstellen eines Tasks gibt es ein abgeleitetes Schema ohne id und createdAt, weil beide Felder serverseitig erzeugt werden:
export const NewTaskSchema = TaskSchema.omit({ id: true, createdAt: true });
Jede Formulareingabe läuft vor dem Speichern durch NewTaskSchema.safeParse(...) – ungültige Daten kommen gar nicht erst bis zum Speicherpunkt durch.
Board-Layout und Spalten
Das Board selbst gruppiert Tasks client-seitig nach Status in drei Spalten (Todo, In Progress, Done). Mit Svelte 5 Runes lässt sich das ganz direkt als abgeleiteter Zustand ausdrücken:
let tasksByStatus = $derived(
Object.fromEntries(
STATUSES.map((status) => [status, filteredTasks.filter((t) => t.status === status)])
)
);
Dazu kommen Filter-Chips nach Kategorie und eine kleine Statistik-Leiste, die die Verteilung der Tasks über die drei Status anzeigt – auch das rein über $derived-Werte, ohne manuelles Neu-Berechnen bei jeder Änderung.
Neue Tasks anlegen
Neue Tasks entstehen über ein Modal mit einem klassischen SvelteKit-Formular, das progressiv mit use:enhance angereichert ist. Der zugehörige Form-Action validiert die rohen FormData und reicht sie erst nach erfolgreicher Prüfung weiter:
createTask: async ({ request }) => {
const formData = await request.formData();
const raw = {
title: formData.get('title'),
description: formData.get('description'),
category: formData.get('category'),
priority: formData.get('priority'),
status: formData.get('status'),
};
const result = NewTaskSchema.safeParse(raw);
if (!result.success) {
return fail(400, { data: raw, errors: result.error.flatten().fieldErrors });
}
const task = { id: randomUUID(), createdAt: new Date(), ...result.data };
await createTask(task);
return { success: true, task };
}
Validierungsfehler kommen als Teil des form-Objekts zurück und werden im Formular direkt unter dem jeweiligen Feld angezeigt.
Drag-and-Drop für Status-Wechsel
Den Status eines Tasks ändert man per Drag-and-Drop zwischen den Spalten – ganz ohne externe Library, nur mit den nativen HTML5-Drag-Events:
<article
draggable="true"
ondragstart={() => handleDragStart(task.id)}
ondragend={handleDragEnd}
>
Jede Spalte ist gleichzeitig Drop-Ziel. Beim Drop wird eine FormData mit id und neuem status an einen eigenen Form-Action gesendet:
moveTask: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id');
const status = formData.get('status');
const result = TaskStatus.safeParse(status);
if (!result.success) {
return fail(400, { error: 'Invalid status' });
}
await updateTaskStatus(id as string, result.data);
return { success: true };
}
Aktuell lädt die Seite nach einem erfolgreichen Drop komplett neu (location.reload()) – funktional, aber nicht die eleganteste Lösung. Ein Wechsel auf SvelteKits eingebaute Invalidierung steht auf der Liste offener Punkte.
Persistenz als JSON-Datei
Statt einer Datenbank reicht für ein Ein-Personen-Tool erstmal eine flache JSON-Datei, die bei jeder Änderung komplett neu geschrieben wird:
const dataFile = path.resolve('static/tasks.json');
export async function getTasks(): Promise<Task[]> {
const file = await readFile(dataFile, 'utf-8');
return file.trim() ? JSON.parse(file) : [];
}
export async function createTask(task: Task) {
const tasks = await getTasks();
tasks.push(task);
await writeFile(dataFile, JSON.stringify(tasks, null, 2), 'utf-8');
return task;
}
Das hat einen klaren Nachteil wenn es mehrere Nutzer gibt: Es gibt kein Locking, gleichzeitige Schreibvorgänge könnten sich theoretisch überschreiben. Für den aktuellen Einsatzzweck – ein lokales Tool für die eigene Aufgabenliste – ist die JSON File eine Art Provisorium, das sich bei Bedarf später gegen eine Datenbank austauschen lässt.
Damit steht das Grundgerüst: Tasks anlegen, filtern, per Drag-and-Drop durch die Spalten schieben, alles typsicher validiert und persistiert. Im nächsten Teil geht es um das Bearbeiten bestehender Tasks.