From bb59227affa24215886990ef3c022b776ba9844d Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 7 Dec 2025 16:36:17 +0100 Subject: [PATCH] feat(todo): add Kanban board with drag & drop and filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add kanban_columns table for custom column support - Add columnId and columnOrder fields to tasks - Create NestJS Kanban module with CRUD endpoints - Implement KanbanBoard, KanbanColumn, KanbanTaskCard components - Add drag & drop support between columns using svelte-dnd-action - Add Quick Add Task inline in each column - Add filter panel (priority, project, labels, search) - Add /kanban route and navigation link 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/todo/apps/backend/src/app.module.ts | 2 + apps/todo/apps/backend/src/db/schema/index.ts | 1 + .../src/db/schema/kanban-columns.schema.ts | 35 ++ .../backend/src/db/schema/tasks.schema.ts | 6 + .../src/kanban/dto/create-column.dto.ts | 29 ++ .../todo/apps/backend/src/kanban/dto/index.ts | 4 + .../backend/src/kanban/dto/move-task.dto.ts | 18 ++ .../src/kanban/dto/reorder-columns.dto.ts | 7 + .../src/kanban/dto/update-column.dto.ts | 22 ++ .../backend/src/kanban/kanban.controller.ts | 97 ++++++ .../apps/backend/src/kanban/kanban.module.ts | 10 + .../apps/backend/src/kanban/kanban.service.ts | 302 ++++++++++++++++++ apps/todo/apps/web/src/lib/api/kanban.ts | 104 ++++++ .../components/kanban/AddColumnButton.svelte | 69 ++++ .../lib/components/kanban/KanbanBoard.svelte | 183 +++++++++++ .../lib/components/kanban/KanbanColumn.svelte | 129 ++++++++ .../kanban/KanbanColumnHeader.svelte | 191 +++++++++++ .../components/kanban/KanbanFilters.svelte | 193 +++++++++++ .../components/kanban/KanbanTaskCard.svelte | 146 +++++++++ .../kanban/QuickAddTaskInline.svelte | 89 ++++++ .../web/src/lib/components/kanban/index.ts | 6 + .../apps/web/src/lib/stores/kanban.svelte.ts | 288 +++++++++++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 1 + .../web/src/routes/(app)/kanban/+page.svelte | 75 +++++ apps/todo/packages/shared/src/types/index.ts | 1 + apps/todo/packages/shared/src/types/kanban.ts | 45 +++ apps/todo/packages/shared/src/types/task.ts | 4 + 27 files changed, 2057 insertions(+) create mode 100644 apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts create mode 100644 apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts create mode 100644 apps/todo/apps/backend/src/kanban/dto/index.ts create mode 100644 apps/todo/apps/backend/src/kanban/dto/move-task.dto.ts create mode 100644 apps/todo/apps/backend/src/kanban/dto/reorder-columns.dto.ts create mode 100644 apps/todo/apps/backend/src/kanban/dto/update-column.dto.ts create mode 100644 apps/todo/apps/backend/src/kanban/kanban.controller.ts create mode 100644 apps/todo/apps/backend/src/kanban/kanban.module.ts create mode 100644 apps/todo/apps/backend/src/kanban/kanban.service.ts create mode 100644 apps/todo/apps/web/src/lib/api/kanban.ts create mode 100644 apps/todo/apps/web/src/lib/components/kanban/AddColumnButton.svelte create mode 100644 apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte create mode 100644 apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte create mode 100644 apps/todo/apps/web/src/lib/components/kanban/KanbanColumnHeader.svelte create mode 100644 apps/todo/apps/web/src/lib/components/kanban/KanbanFilters.svelte create mode 100644 apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte create mode 100644 apps/todo/apps/web/src/lib/components/kanban/QuickAddTaskInline.svelte create mode 100644 apps/todo/apps/web/src/lib/components/kanban/index.ts create mode 100644 apps/todo/apps/web/src/lib/stores/kanban.svelte.ts create mode 100644 apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte create mode 100644 apps/todo/packages/shared/src/types/kanban.ts diff --git a/apps/todo/apps/backend/src/app.module.ts b/apps/todo/apps/backend/src/app.module.ts index 7ad467f5b..5c03f584b 100644 --- a/apps/todo/apps/backend/src/app.module.ts +++ b/apps/todo/apps/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { ProjectModule } from './project/project.module'; import { TaskModule } from './task/task.module'; import { LabelModule } from './label/label.module'; import { ReminderModule } from './reminder/reminder.module'; +import { KanbanModule } from './kanban/kanban.module'; @Module({ imports: [ @@ -21,6 +22,7 @@ import { ReminderModule } from './reminder/reminder.module'; TaskModule, LabelModule, ReminderModule, + KanbanModule, ], }) export class AppModule {} diff --git a/apps/todo/apps/backend/src/db/schema/index.ts b/apps/todo/apps/backend/src/db/schema/index.ts index d2f17ee12..d365c634d 100644 --- a/apps/todo/apps/backend/src/db/schema/index.ts +++ b/apps/todo/apps/backend/src/db/schema/index.ts @@ -1,4 +1,5 @@ export * from './projects.schema'; +export * from './kanban-columns.schema'; export * from './tasks.schema'; export * from './labels.schema'; export * from './task-labels.schema'; diff --git a/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts b/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts new file mode 100644 index 000000000..f10ab3321 --- /dev/null +++ b/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts @@ -0,0 +1,35 @@ +import { pgTable, uuid, timestamp, varchar, boolean, integer, index } from 'drizzle-orm/pg-core'; +import { projects } from './projects.schema'; + +// Define locally to avoid circular dependency with tasks.schema +export type KanbanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export const kanbanColumns = pgTable( + 'kanban_columns', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }), + + // Column properties + name: varchar('name', { length: 100 }).notNull(), + color: varchar('color', { length: 7 }).default('#6B7280'), + order: integer('order').default(0).notNull(), + + // Behavior + isDefault: boolean('is_default').default(false), + defaultStatus: varchar('default_status', { length: 20 }).$type(), + autoComplete: boolean('auto_complete').default(false), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdx: index('kanban_columns_user_idx').on(table.userId), + projectIdx: index('kanban_columns_project_idx').on(table.projectId), + orderIdx: index('kanban_columns_order_idx').on(table.userId, table.projectId, table.order), + }) +); + +export type KanbanColumn = typeof kanbanColumns.$inferSelect; +export type NewKanbanColumn = typeof kanbanColumns.$inferInsert; diff --git a/apps/todo/apps/backend/src/db/schema/tasks.schema.ts b/apps/todo/apps/backend/src/db/schema/tasks.schema.ts index 401139c42..52da64654 100644 --- a/apps/todo/apps/backend/src/db/schema/tasks.schema.ts +++ b/apps/todo/apps/backend/src/db/schema/tasks.schema.ts @@ -10,6 +10,7 @@ import { index, } from 'drizzle-orm/pg-core'; import { projects } from './projects.schema'; +import { kanbanColumns } from './kanban-columns.schema'; export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent'; export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; @@ -56,6 +57,10 @@ export const tasks = pgTable( // Ordering order: integer('order').default(0), + // Kanban + columnId: uuid('column_id').references(() => kanbanColumns.id, { onDelete: 'set null' }), + columnOrder: integer('column_order').default(0), + // Recurrence (RFC 5545 RRULE format) recurrenceRule: varchar('recurrence_rule', { length: 500 }), recurrenceEndDate: timestamp('recurrence_end_date', { withTimezone: true }), @@ -77,6 +82,7 @@ export const tasks = pgTable( statusIdx: index('tasks_status_idx').on(table.isCompleted, table.status), parentIdx: index('tasks_parent_idx').on(table.parentTaskId), orderIdx: index('tasks_order_idx').on(table.projectId, table.order), + columnIdx: index('tasks_column_idx').on(table.columnId, table.columnOrder), }) ); diff --git a/apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts b/apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts new file mode 100644 index 000000000..f0a6358a5 --- /dev/null +++ b/apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts @@ -0,0 +1,29 @@ +import { IsString, IsOptional, IsBoolean, MaxLength, IsIn } from 'class-validator'; +import type { KanbanTaskStatus } from '../../db/schema/kanban-columns.schema'; + +export class CreateColumnDto { + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + @MaxLength(7) + color?: string; + + @IsOptional() + @IsString() + projectId?: string; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsIn(['pending', 'in_progress', 'completed', 'cancelled']) + defaultStatus?: KanbanTaskStatus; + + @IsOptional() + @IsBoolean() + autoComplete?: boolean; +} diff --git a/apps/todo/apps/backend/src/kanban/dto/index.ts b/apps/todo/apps/backend/src/kanban/dto/index.ts new file mode 100644 index 000000000..44d6e0b58 --- /dev/null +++ b/apps/todo/apps/backend/src/kanban/dto/index.ts @@ -0,0 +1,4 @@ +export * from './create-column.dto'; +export * from './update-column.dto'; +export * from './reorder-columns.dto'; +export * from './move-task.dto'; diff --git a/apps/todo/apps/backend/src/kanban/dto/move-task.dto.ts b/apps/todo/apps/backend/src/kanban/dto/move-task.dto.ts new file mode 100644 index 000000000..a748d1bf5 --- /dev/null +++ b/apps/todo/apps/backend/src/kanban/dto/move-task.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsNumber, IsOptional } from 'class-validator'; + +export class MoveTaskToColumnDto { + @IsString() + columnId: string; + + @IsOptional() + @IsNumber() + order?: number; +} + +export class ReorderTasksDto { + @IsString() + columnId: string; + + @IsString({ each: true }) + taskIds: string[]; +} diff --git a/apps/todo/apps/backend/src/kanban/dto/reorder-columns.dto.ts b/apps/todo/apps/backend/src/kanban/dto/reorder-columns.dto.ts new file mode 100644 index 000000000..a4bf970bc --- /dev/null +++ b/apps/todo/apps/backend/src/kanban/dto/reorder-columns.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsString } from 'class-validator'; + +export class ReorderColumnsDto { + @IsArray() + @IsString({ each: true }) + columnIds: string[]; +} diff --git a/apps/todo/apps/backend/src/kanban/dto/update-column.dto.ts b/apps/todo/apps/backend/src/kanban/dto/update-column.dto.ts new file mode 100644 index 000000000..0d538eef4 --- /dev/null +++ b/apps/todo/apps/backend/src/kanban/dto/update-column.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsOptional, IsBoolean, MaxLength, IsIn } from 'class-validator'; +import type { KanbanTaskStatus } from '../../db/schema/kanban-columns.schema'; + +export class UpdateColumnDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(7) + color?: string; + + @IsOptional() + @IsIn(['pending', 'in_progress', 'completed', 'cancelled']) + defaultStatus?: KanbanTaskStatus; + + @IsOptional() + @IsBoolean() + autoComplete?: boolean; +} diff --git a/apps/todo/apps/backend/src/kanban/kanban.controller.ts b/apps/todo/apps/backend/src/kanban/kanban.controller.ts new file mode 100644 index 000000000..1b5e43484 --- /dev/null +++ b/apps/todo/apps/backend/src/kanban/kanban.controller.ts @@ -0,0 +1,97 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { KanbanService } from './kanban.service'; +import { + CreateColumnDto, + UpdateColumnDto, + ReorderColumnsDto, + MoveTaskToColumnDto, + ReorderTasksDto, +} from './dto'; + +@Controller('kanban') +@UseGuards(JwtAuthGuard) +export class KanbanController { + constructor(private readonly kanbanService: KanbanService) {} + + // Column endpoints + + @Get('columns') + async getColumns(@CurrentUser() user: CurrentUserData, @Query('projectId') projectId?: string) { + const columns = await this.kanbanService.findAllColumns(user.userId, projectId); + return { columns }; + } + + @Post('columns') + async createColumn(@CurrentUser() user: CurrentUserData, @Body() dto: CreateColumnDto) { + const column = await this.kanbanService.createColumn(user.userId, dto); + return { column }; + } + + @Put('columns/:id') + async updateColumn( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateColumnDto + ) { + const column = await this.kanbanService.updateColumn(id, user.userId, dto); + return { column }; + } + + @Delete('columns/:id') + async deleteColumn(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + await this.kanbanService.deleteColumn(id, user.userId); + return { success: true }; + } + + @Put('columns/reorder') + async reorderColumns(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderColumnsDto) { + const columns = await this.kanbanService.reorderColumns(user.userId, dto.columnIds); + return { columns }; + } + + @Post('columns/init') + async initializeColumns( + @CurrentUser() user: CurrentUserData, + @Query('projectId') projectId?: string + ) { + const columns = await this.kanbanService.initializeDefaultColumns(user.userId, projectId); + return { columns }; + } + + // Task endpoints + + @Get('tasks') + async getTasksGrouped( + @CurrentUser() user: CurrentUserData, + @Query('projectId') projectId?: string + ) { + const result = await this.kanbanService.getTasksGroupedByColumn(user.userId, projectId); + return result; + } + + @Post('tasks/:taskId/move') + async moveTaskToColumn( + @CurrentUser() user: CurrentUserData, + @Param('taskId') taskId: string, + @Body() dto: MoveTaskToColumnDto + ) { + const task = await this.kanbanService.moveTaskToColumn( + taskId, + user.userId, + dto.columnId, + dto.order + ); + return { task }; + } + + @Put('tasks/reorder') + async reorderTasks(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderTasksDto) { + const tasks = await this.kanbanService.reorderTasksInColumn( + user.userId, + dto.columnId, + dto.taskIds + ); + return { tasks }; + } +} diff --git a/apps/todo/apps/backend/src/kanban/kanban.module.ts b/apps/todo/apps/backend/src/kanban/kanban.module.ts new file mode 100644 index 000000000..38f22fc66 --- /dev/null +++ b/apps/todo/apps/backend/src/kanban/kanban.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { KanbanController } from './kanban.controller'; +import { KanbanService } from './kanban.service'; + +@Module({ + controllers: [KanbanController], + providers: [KanbanService], + exports: [KanbanService], +}) +export class KanbanModule {} diff --git a/apps/todo/apps/backend/src/kanban/kanban.service.ts b/apps/todo/apps/backend/src/kanban/kanban.service.ts new file mode 100644 index 000000000..4998f86dd --- /dev/null +++ b/apps/todo/apps/backend/src/kanban/kanban.service.ts @@ -0,0 +1,302 @@ +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { eq, and, asc, isNull } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { + kanbanColumns, + type KanbanColumn, + type NewKanbanColumn, + type KanbanTaskStatus, +} from '../db/schema/kanban-columns.schema'; +import { tasks, type Task } from '../db/schema/tasks.schema'; +import { CreateColumnDto, UpdateColumnDto } from './dto'; + +// Default columns configuration +const DEFAULT_COLUMNS: Omit[] = [ + { + name: 'To Do', + color: '#6B7280', + order: 0, + isDefault: true, + defaultStatus: 'pending' as KanbanTaskStatus, + autoComplete: false, + }, + { + name: 'In Arbeit', + color: '#3B82F6', + order: 1, + isDefault: true, + defaultStatus: 'in_progress' as KanbanTaskStatus, + autoComplete: false, + }, + { + name: 'Erledigt', + color: '#22C55E', + order: 2, + isDefault: true, + defaultStatus: 'completed' as KanbanTaskStatus, + autoComplete: true, + }, +]; + +@Injectable() +export class KanbanService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + // Column operations + + async findAllColumns(userId: string, projectId?: string | null): Promise { + if (projectId) { + return this.db.query.kanbanColumns.findMany({ + where: and(eq(kanbanColumns.userId, userId), eq(kanbanColumns.projectId, projectId)), + orderBy: [asc(kanbanColumns.order)], + }); + } + + // Global columns (no project) + return this.db.query.kanbanColumns.findMany({ + where: and(eq(kanbanColumns.userId, userId), isNull(kanbanColumns.projectId)), + orderBy: [asc(kanbanColumns.order)], + }); + } + + async findColumnById(id: string, userId: string): Promise { + const result = await this.db.query.kanbanColumns.findFirst({ + where: and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId)), + }); + return result ?? null; + } + + async findColumnByIdOrThrow(id: string, userId: string): Promise { + const column = await this.findColumnById(id, userId); + if (!column) { + throw new NotFoundException(`Column with id ${id} not found`); + } + return column; + } + + async createColumn(userId: string, dto: CreateColumnDto): Promise { + // Get the highest order value for this scope + const existingColumns = await this.findAllColumns(userId, dto.projectId); + const maxOrder = existingColumns.reduce((max, c) => Math.max(max, c.order ?? 0), -1); + + const newColumn: NewKanbanColumn = { + userId, + projectId: dto.projectId ?? null, + name: dto.name, + color: dto.color ?? '#6B7280', + order: maxOrder + 1, + isDefault: dto.isDefault ?? false, + defaultStatus: dto.defaultStatus, + autoComplete: dto.autoComplete ?? false, + }; + + const [created] = await this.db.insert(kanbanColumns).values(newColumn).returning(); + return created; + } + + async updateColumn(id: string, userId: string, dto: UpdateColumnDto): Promise { + await this.findColumnByIdOrThrow(id, userId); + + const [updated] = await this.db + .update(kanbanColumns) + .set({ + ...dto, + updatedAt: new Date(), + }) + .where(and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId))) + .returning(); + + return updated; + } + + async deleteColumn(id: string, userId: string): Promise { + const column = await this.findColumnByIdOrThrow(id, userId); + + // Get first column to move tasks to + const columns = await this.findAllColumns(userId, column.projectId); + const firstColumn = columns.find((c) => c.id !== id); + + if (!firstColumn) { + throw new BadRequestException('Cannot delete the last column'); + } + + // Move all tasks from this column to the first column + await this.db + .update(tasks) + .set({ + columnId: firstColumn.id, + updatedAt: new Date(), + }) + .where(eq(tasks.columnId, id)); + + // Delete the column + await this.db + .delete(kanbanColumns) + .where(and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId))); + } + + async reorderColumns(userId: string, columnIds: string[]): Promise { + const updates = columnIds.map((id, index) => + this.db + .update(kanbanColumns) + .set({ order: index, updatedAt: new Date() }) + .where(and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId))) + ); + + await Promise.all(updates); + + // Determine projectId from first column + const firstColumn = await this.findColumnById(columnIds[0], userId); + return this.findAllColumns(userId, firstColumn?.projectId); + } + + async initializeDefaultColumns( + userId: string, + projectId?: string | null + ): Promise { + // Check if columns already exist + const existing = await this.findAllColumns(userId, projectId); + if (existing.length > 0) { + return existing; + } + + // Create default columns + const columnsToCreate: NewKanbanColumn[] = DEFAULT_COLUMNS.map((col) => ({ + ...col, + userId, + projectId: projectId ?? null, + })); + + await this.db.insert(kanbanColumns).values(columnsToCreate); + + return this.findAllColumns(userId, projectId); + } + + // Task operations + + async getTasksGroupedByColumn( + userId: string, + projectId?: string | null + ): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record }> { + // Ensure columns exist + const columns = await this.initializeDefaultColumns(userId, projectId); + + // Get all tasks for this user + let userTasks: Task[]; + if (projectId) { + userTasks = await this.db.query.tasks.findMany({ + where: and(eq(tasks.userId, userId), eq(tasks.projectId, projectId)), + orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)], + }); + } else { + userTasks = await this.db.query.tasks.findMany({ + where: eq(tasks.userId, userId), + orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)], + }); + } + + // Group tasks by column + const tasksByColumn: Record = {}; + + // Initialize empty arrays for each column + for (const column of columns) { + tasksByColumn[column.id] = []; + } + + // Distribute tasks + for (const task of userTasks) { + if (task.columnId && tasksByColumn[task.columnId]) { + // Task has explicit column assignment + tasksByColumn[task.columnId].push(task); + } else { + // Map based on status to default column + const matchingColumn = columns.find((c) => c.defaultStatus === task.status); + if (matchingColumn) { + tasksByColumn[matchingColumn.id].push(task); + } else { + // Fallback to first column + const firstColumn = columns[0]; + if (firstColumn) { + tasksByColumn[firstColumn.id].push(task); + } + } + } + } + + return { columns, tasksByColumn }; + } + + async moveTaskToColumn( + taskId: string, + userId: string, + columnId: string, + order?: number + ): Promise { + // Verify task exists and belongs to user + const task = await this.db.query.tasks.findFirst({ + where: and(eq(tasks.id, taskId), eq(tasks.userId, userId)), + }); + + if (!task) { + throw new NotFoundException(`Task with id ${taskId} not found`); + } + + // Verify column exists and belongs to user + const column = await this.findColumnByIdOrThrow(columnId, userId); + + // Determine new status and completion state + const updateData: Partial = { + columnId, + columnOrder: order ?? 0, + updatedAt: new Date(), + }; + + // If column has autoComplete, mark task as completed + if (column.autoComplete) { + updateData.isCompleted = true; + updateData.completedAt = new Date(); + updateData.status = 'completed'; + } else if (column.defaultStatus) { + // Update status based on column's default status + updateData.status = column.defaultStatus; + if (column.defaultStatus !== 'completed') { + updateData.isCompleted = false; + updateData.completedAt = null; + } + } + + const [updated] = await this.db + .update(tasks) + .set(updateData) + .where(and(eq(tasks.id, taskId), eq(tasks.userId, userId))) + .returning(); + + return updated; + } + + async reorderTasksInColumn(userId: string, columnId: string, taskIds: string[]): Promise { + // Verify column exists + await this.findColumnByIdOrThrow(columnId, userId); + + // Update order for each task + const updates = taskIds.map((id, index) => + this.db + .update(tasks) + .set({ + columnId, + columnOrder: index, + updatedAt: new Date(), + }) + .where(and(eq(tasks.id, id), eq(tasks.userId, userId))) + ); + + await Promise.all(updates); + + // Return updated tasks + return this.db.query.tasks.findMany({ + where: and(eq(tasks.userId, userId), eq(tasks.columnId, columnId)), + orderBy: [asc(tasks.columnOrder)], + }); + } +} diff --git a/apps/todo/apps/web/src/lib/api/kanban.ts b/apps/todo/apps/web/src/lib/api/kanban.ts new file mode 100644 index 000000000..6bc041726 --- /dev/null +++ b/apps/todo/apps/web/src/lib/api/kanban.ts @@ -0,0 +1,104 @@ +import { apiClient } from './client'; +import type { KanbanColumn, Task } from '@todo/shared'; + +interface ColumnsResponse { + columns: KanbanColumn[]; +} + +interface ColumnResponse { + column: KanbanColumn; +} + +interface KanbanTasksResponse { + columns: KanbanColumn[]; + tasksByColumn: Record; +} + +interface TaskResponse { + task: Task; +} + +interface TasksResponse { + tasks: Task[]; +} + +interface CreateColumnDto { + name: string; + color?: string; + projectId?: string; + isDefault?: boolean; + defaultStatus?: string; + autoComplete?: boolean; +} + +interface UpdateColumnDto { + name?: string; + color?: string; + defaultStatus?: string; + autoComplete?: boolean; +} + +// Column operations + +export async function getColumns(projectId?: string): Promise { + const query = projectId ? `?projectId=${projectId}` : ''; + const response = await apiClient.get(`/api/v1/kanban/columns${query}`); + return response.columns; +} + +export async function createColumn(data: CreateColumnDto): Promise { + const response = await apiClient.post('/api/v1/kanban/columns', data); + return response.column; +} + +export async function updateColumn(id: string, data: UpdateColumnDto): Promise { + const response = await apiClient.put(`/api/v1/kanban/columns/${id}`, data); + return response.column; +} + +export async function deleteColumn(id: string): Promise { + await apiClient.delete(`/api/v1/kanban/columns/${id}`); +} + +export async function reorderColumns(columnIds: string[]): Promise { + const response = await apiClient.put('/api/v1/kanban/columns/reorder', { + columnIds, + }); + return response.columns; +} + +export async function initializeColumns(projectId?: string): Promise { + const query = projectId ? `?projectId=${projectId}` : ''; + const response = await apiClient.post(`/api/v1/kanban/columns/init${query}`); + return response.columns; +} + +// Task operations + +export async function getKanbanTasks( + projectId?: string +): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record }> { + const query = projectId ? `?projectId=${projectId}` : ''; + const response = await apiClient.get(`/api/v1/kanban/tasks${query}`); + return response; +} + +export async function moveTaskToColumn( + taskId: string, + columnId: string, + order?: number +): Promise { + const response = await apiClient.post(`/api/v1/kanban/tasks/${taskId}/move`, { + columnId, + order, + }); + return response.task; +} + +export async function reorderTasksInColumn(columnId: string, taskIds: string[]): Promise { + const response = await apiClient.put('/api/v1/kanban/tasks/reorder', { + columnId, + taskIds, + }); + return response.tasks; +} diff --git a/apps/todo/apps/web/src/lib/components/kanban/AddColumnButton.svelte b/apps/todo/apps/web/src/lib/components/kanban/AddColumnButton.svelte new file mode 100644 index 000000000..c039032ff --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/kanban/AddColumnButton.svelte @@ -0,0 +1,69 @@ + + +
+ {#if isAdding} +
+ +
+ + +
+
+ {:else} + + {/if} +
diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte new file mode 100644 index 000000000..74a661af6 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte @@ -0,0 +1,183 @@ + + +
+ {#if kanbanStore.loading} +
+
+
+ {:else if kanbanStore.error} +
+ {kanbanStore.error} +
+ {:else} +
+ {#each localColumns.filter((c) => c.id !== SHADOW_PLACEHOLDER_ITEM_ID) as column (column.id)} +
+ handleUpdateColumn(column.id, data)} + onDeleteColumn={() => handleDeleteColumn(column.id)} + onTasksReorder={(taskIds) => handleTasksReorder(column.id, taskIds)} + onTaskMove={(taskId, toColumnId, order) => handleTaskMove(taskId, toColumnId, order)} + onAddTask={(title) => handleAddTask(column.id, title)} + /> +
+ {/each} + + +
+ +
+
+ {/if} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte new file mode 100644 index 000000000..c6d8fba71 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte @@ -0,0 +1,129 @@ + + +
+ + + + +
+ {#each localTasks.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID) as task (task.id)} +
+ handleToggleComplete(task)} /> +
+ {/each} + + {#if localTasks.length === 0} +
+

Keine Aufgaben

+
+ {/if} +
+ + + {#if onAddTask} +
+ +
+ {/if} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanColumnHeader.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanColumnHeader.svelte new file mode 100644 index 000000000..3e9a00249 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanColumnHeader.svelte @@ -0,0 +1,191 @@ + + +
+
+ +
+ + + {#if isEditing} + + {:else} + + {/if} + + + + {taskCount} + +
+ + + {#if onUpdate || onDelete} +
+ + + {#if showMenu} +
+ {#if onUpdate} + + + + + {#if showColorPicker} +
+ {#each colors as color} + + {/each} +
+ {/if} + {/if} + + {#if onDelete && !column.isDefault} + + {/if} +
+ {/if} +
+ {/if} +
+ + +{#if showMenu} + +{/if} diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanFilters.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanFilters.svelte new file mode 100644 index 000000000..1ce759ddc --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanFilters.svelte @@ -0,0 +1,193 @@ + + +
+ +
+ onSearchChange(e.currentTarget.value)} + placeholder="Suchen..." + class="w-40 px-3 py-1.5 text-sm bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 placeholder:text-muted-foreground" + /> + {#if searchQuery} + + {/if} +
+ + +
+ Priorität: + {#each priorities as priority} + + {/each} +
+ + +
+ Projekt: + +
+ + +
+ + + {#if showLabelsDropdown} + +
(showLabelsDropdown = false)}>
+
+ {#if labelsStore.labels.length === 0} +

Keine Labels vorhanden

+ {:else} + {#each labelsStore.labels as label} + + {/each} + {/if} +
+ {/if} +
+ + + {#if hasActiveFilters} + + {/if} +
diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte new file mode 100644 index 000000000..aaebbcadc --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanTaskCard.svelte @@ -0,0 +1,146 @@ + + +
+ +
+
+ +
+ +

+ {task.title} +

+ + + {#if dueDateText() || subtaskProgress() || (task.labels && task.labels.length > 0)} +
+ {#if dueDateText()} + + + + + {dueDateText()} + + {/if} + + {#if subtaskProgress()} + + + + + {subtaskProgress()} + + {/if} + + {#if task.labels && task.labels.length > 0} +
+ {#each task.labels.slice(0, 2) as label} + + {/each} + {#if task.labels.length > 2} + +{task.labels.length - 2} + {/if} +
+ {/if} +
+ {/if} +
+ + + {#if onToggleComplete} + + {/if} +
+
+ + diff --git a/apps/todo/apps/web/src/lib/components/kanban/QuickAddTaskInline.svelte b/apps/todo/apps/web/src/lib/components/kanban/QuickAddTaskInline.svelte new file mode 100644 index 000000000..bcb8b2f65 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/kanban/QuickAddTaskInline.svelte @@ -0,0 +1,89 @@ + + +
+ {#if isAdding} +
+ +
+ + +
+
+ {:else} + + {/if} +
diff --git a/apps/todo/apps/web/src/lib/components/kanban/index.ts b/apps/todo/apps/web/src/lib/components/kanban/index.ts new file mode 100644 index 000000000..4d5cbc8db --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/kanban/index.ts @@ -0,0 +1,6 @@ +export { default as KanbanBoard } from './KanbanBoard.svelte'; +export { default as KanbanColumn } from './KanbanColumn.svelte'; +export { default as KanbanColumnHeader } from './KanbanColumnHeader.svelte'; +export { default as KanbanTaskCard } from './KanbanTaskCard.svelte'; +export { default as AddColumnButton } from './AddColumnButton.svelte'; +export { default as KanbanFilters } from './KanbanFilters.svelte'; diff --git a/apps/todo/apps/web/src/lib/stores/kanban.svelte.ts b/apps/todo/apps/web/src/lib/stores/kanban.svelte.ts new file mode 100644 index 000000000..dae60a761 --- /dev/null +++ b/apps/todo/apps/web/src/lib/stores/kanban.svelte.ts @@ -0,0 +1,288 @@ +/** + * Kanban Store - Manages kanban board state using Svelte 5 runes + */ + +import type { KanbanColumn, Task } from '@todo/shared'; +import * as kanbanApi from '$lib/api/kanban'; +import * as tasksApi from '$lib/api/tasks'; + +// State +let columns = $state([]); +let tasksByColumn = $state>({}); +let loading = $state(false); +let error = $state(null); + +export const kanbanStore = { + // Getters + get columns() { + return columns; + }, + get tasksByColumn() { + return tasksByColumn; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + + /** + * Fetch columns and tasks grouped by column + */ + async fetchKanbanData(projectId?: string) { + loading = true; + error = null; + try { + const data = await kanbanApi.getKanbanTasks(projectId); + columns = data.columns; + tasksByColumn = data.tasksByColumn; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch kanban data'; + console.error('Failed to fetch kanban data:', e); + } finally { + loading = false; + } + }, + + /** + * Fetch only columns + */ + async fetchColumns(projectId?: string) { + loading = true; + error = null; + try { + columns = await kanbanApi.getColumns(projectId); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch columns'; + console.error('Failed to fetch columns:', e); + } finally { + loading = false; + } + }, + + /** + * Create a new column + */ + async createColumn(data: { + name: string; + color?: string; + projectId?: string; + defaultStatus?: string; + autoComplete?: boolean; + }) { + error = null; + try { + const newColumn = await kanbanApi.createColumn(data); + columns = [...columns, newColumn]; + tasksByColumn[newColumn.id] = []; + return newColumn; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create column'; + console.error('Failed to create column:', e); + throw e; + } + }, + + /** + * Update a column + */ + async updateColumn( + id: string, + data: { + name?: string; + color?: string; + defaultStatus?: string; + autoComplete?: boolean; + } + ) { + error = null; + try { + const updated = await kanbanApi.updateColumn(id, data); + columns = columns.map((c) => (c.id === id ? updated : c)); + return updated; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update column'; + console.error('Failed to update column:', e); + throw e; + } + }, + + /** + * Delete a column + */ + async deleteColumn(id: string) { + error = null; + try { + await kanbanApi.deleteColumn(id); + columns = columns.filter((c) => c.id !== id); + delete tasksByColumn[id]; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete column'; + console.error('Failed to delete column:', e); + throw e; + } + }, + + /** + * Reorder columns (optimistic update) + */ + async reorderColumns(columnIds: string[]) { + error = null; + const previousColumns = [...columns]; + try { + // Optimistic update + columns = columnIds + .map((id) => columns.find((c) => c.id === id)) + .filter((c): c is KanbanColumn => c !== undefined); + + // Persist to server + const updated = await kanbanApi.reorderColumns(columnIds); + columns = updated; + } catch (e) { + // Rollback on error + columns = previousColumns; + error = e instanceof Error ? e.message : 'Failed to reorder columns'; + console.error('Failed to reorder columns:', e); + throw e; + } + }, + + /** + * Move task to a different column (optimistic update) + */ + async moveTaskToColumn(taskId: string, fromColumnId: string, toColumnId: string, order?: number) { + error = null; + const previousTasksByColumn = { ...tasksByColumn }; + + try { + // Find the task + const task = tasksByColumn[fromColumnId]?.find((t) => t.id === taskId); + if (!task) { + throw new Error('Task not found'); + } + + // Optimistic update + tasksByColumn[fromColumnId] = tasksByColumn[fromColumnId].filter((t) => t.id !== taskId); + + if (!tasksByColumn[toColumnId]) { + tasksByColumn[toColumnId] = []; + } + + const insertIndex = order ?? tasksByColumn[toColumnId].length; + const updatedTask = { ...task, columnId: toColumnId, columnOrder: insertIndex }; + tasksByColumn[toColumnId] = [ + ...tasksByColumn[toColumnId].slice(0, insertIndex), + updatedTask, + ...tasksByColumn[toColumnId].slice(insertIndex), + ]; + + // Persist to server + await kanbanApi.moveTaskToColumn(taskId, toColumnId, order); + } catch (e) { + // Rollback on error + tasksByColumn = previousTasksByColumn; + error = e instanceof Error ? e.message : 'Failed to move task'; + console.error('Failed to move task:', e); + throw e; + } + }, + + /** + * Reorder tasks within a column (optimistic update) + */ + async reorderTasksInColumn(columnId: string, taskIds: string[]) { + error = null; + const previousTasks = [...(tasksByColumn[columnId] || [])]; + + try { + // Optimistic update + const columnTasks = tasksByColumn[columnId] || []; + tasksByColumn[columnId] = taskIds + .map((id) => columnTasks.find((t) => t.id === id)) + .filter((t): t is Task => t !== undefined); + + // Persist to server + await kanbanApi.reorderTasksInColumn(columnId, taskIds); + } catch (e) { + // Rollback on error + tasksByColumn[columnId] = previousTasks; + error = e instanceof Error ? e.message : 'Failed to reorder tasks'; + console.error('Failed to reorder tasks:', e); + throw e; + } + }, + + /** + * Initialize default columns if none exist + */ + async initializeDefaultColumns(projectId?: string) { + error = null; + try { + const newColumns = await kanbanApi.initializeColumns(projectId); + columns = newColumns; + // Initialize empty task arrays for each column + for (const col of newColumns) { + if (!tasksByColumn[col.id]) { + tasksByColumn[col.id] = []; + } + } + return newColumns; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to initialize columns'; + console.error('Failed to initialize columns:', e); + throw e; + } + }, + + /** + * Get tasks for a specific column + */ + getTasksForColumn(columnId: string): Task[] { + return tasksByColumn[columnId] || []; + }, + + /** + * Create a new task in a specific column + */ + async createTaskInColumn(columnId: string, title: string, projectId?: string) { + error = null; + try { + // Find the column to get its default status + const column = columns.find((c) => c.id === columnId); + const status = column?.defaultStatus || 'pending'; + + // Create the task + const newTask = await tasksApi.createTask({ + title, + projectId, + priority: 'medium', + }); + + // Move task to the column (this will set columnId and status) + const movedTask = await kanbanApi.moveTaskToColumn(newTask.id, columnId, 0); + + // Add to local state at the beginning of the column + if (!tasksByColumn[columnId]) { + tasksByColumn[columnId] = []; + } + tasksByColumn[columnId] = [movedTask, ...tasksByColumn[columnId]]; + + return movedTask; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create task'; + console.error('Failed to create task in column:', e); + throw e; + } + }, + + /** + * Clear all state (for logout) + */ + clear() { + columns = []; + tasksByColumn = {}; + loading = false; + error = null; + }, +}; diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index 067a337c9..d69055524 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -66,6 +66,7 @@ // Navigation items for Todo const navItems: PillNavItem[] = [ { href: '/', label: 'Aufgaben', icon: 'list' }, + { href: '/kanban', label: 'Kanban', icon: 'columns' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]; diff --git a/apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte b/apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte new file mode 100644 index 000000000..4739f2316 --- /dev/null +++ b/apps/todo/apps/web/src/routes/(app)/kanban/+page.svelte @@ -0,0 +1,75 @@ + + + + Kanban Board - Todo + + +
+
+

Kanban Board

+

+ Ziehe Aufgaben zwischen Spalten, um ihren Status zu ändern +

+
+ +
+ (filterPriorities = priorities)} + onProjectChange={(projectId) => (filterProjectId = projectId)} + onLabelsChange={(labelIds) => (filterLabelIds = labelIds)} + onSearchChange={(query) => (filterSearchQuery = query)} + onClearFilters={clearFilters} + /> +
+ +
+ +
+
+ + diff --git a/apps/todo/packages/shared/src/types/index.ts b/apps/todo/packages/shared/src/types/index.ts index e642c9f6b..d49a5616a 100644 --- a/apps/todo/packages/shared/src/types/index.ts +++ b/apps/todo/packages/shared/src/types/index.ts @@ -2,3 +2,4 @@ export * from './project'; export * from './task'; export * from './label'; export * from './reminder'; +export * from './kanban'; diff --git a/apps/todo/packages/shared/src/types/kanban.ts b/apps/todo/packages/shared/src/types/kanban.ts new file mode 100644 index 000000000..050f30645 --- /dev/null +++ b/apps/todo/packages/shared/src/types/kanban.ts @@ -0,0 +1,45 @@ +import type { TaskStatus } from './task'; + +export interface KanbanColumn { + id: string; + userId: string; + projectId?: string | null; + name: string; + color: string; + order: number; + isDefault: boolean; + defaultStatus?: TaskStatus | null; + autoComplete: boolean; + createdAt: Date | string; + updatedAt: Date | string; +} + +export interface CreateColumnInput { + name: string; + color?: string; + projectId?: string; + isDefault?: boolean; + defaultStatus?: TaskStatus; + autoComplete?: boolean; +} + +export interface UpdateColumnInput { + name?: string; + color?: string; + defaultStatus?: TaskStatus; + autoComplete?: boolean; +} + +export interface ReorderColumnsInput { + columnIds: string[]; +} + +export interface MoveTaskToColumnInput { + columnId: string; + order?: number; +} + +export interface ReorderTasksInColumnInput { + columnId: string; + taskIds: string[]; +} diff --git a/apps/todo/packages/shared/src/types/task.ts b/apps/todo/packages/shared/src/types/task.ts index c29c32b24..9c1c567d1 100644 --- a/apps/todo/packages/shared/src/types/task.ts +++ b/apps/todo/packages/shared/src/types/task.ts @@ -43,6 +43,10 @@ export interface Task { // Ordering order: number; + // Kanban + columnId?: string | null; + columnOrder?: number; + // Recurrence recurrenceRule?: string | null; recurrenceEndDate?: Date | string | null;