From 5e7d074593d7558804148dee294612dc9bed2af6 Mon Sep 17 00:00:00 2001 From: DevOps Date: Thu, 18 Jun 2026 21:34:07 +0200 Subject: [PATCH] feat: Linear-style Task Board mit Drag&Drop --- backend/Controllers/DashboardController.cs | 30 + backend/Data/Entities.cs | 57 +- backend/Models/Dashboard.cs | 18 + backend/Services/ITaskService.cs | 6 + backend/Services/TaskService.cs | 149 +++- frontend/src/App.vue | 2 +- frontend/src/router.ts | 3 +- frontend/src/stores/tasks.ts | 141 +++- frontend/src/views/TaskBoardView.vue | 787 +++++++++++++++++++++ 9 files changed, 1177 insertions(+), 16 deletions(-) create mode 100644 frontend/src/views/TaskBoardView.vue diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index c277e75..770ddf8 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -158,6 +158,36 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic }; } + // ── Task Board Endpoints ── + + [HttpGet("tasks/board")] + public async Task GetBoard(CancellationToken ct) + => await taskService.GetBoardAsync(ct); + + [HttpPatch("tasks/{id:guid}/move")] + public async Task> MoveTask( + Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.State)) + return BadRequest(new { error = "State is required." }); + + var result = await taskService.MoveTaskAsync(id, request.State, ct); + return result.Outcome switch + { + TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported state: '{request.State}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }), + TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }), + _ => Ok(MapToDto(result.Task!)) + }; + } + + [HttpPost("tasks/import-from-iris-todo")] + public async Task> ImportFromIrisTodo( + [FromQuery] bool delete = false, CancellationToken ct = default) + { + var result = await taskService.ImportFromIrisTodoAsync(delete, ct); + return Ok(new ImportResultDto(result)); + } + private static DashboardTaskDto MapToDto(WorkTask t) => new( t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt); } diff --git a/backend/Data/Entities.cs b/backend/Data/Entities.cs index 7ce0c02..1892dc1 100644 --- a/backend/Data/Entities.cs +++ b/backend/Data/Entities.cs @@ -19,7 +19,8 @@ public enum TaskState Backlog, InProgress, Blocked, - Done + Done, + Review } public static class TaskStateHelper @@ -29,7 +30,8 @@ public static class TaskStateHelper [TaskState.Backlog] = "Backlog", [TaskState.InProgress] = "In progress", [TaskState.Blocked] = "Blocked", - [TaskState.Done] = "Done" + [TaskState.Done] = "Done", + [TaskState.Review] = "Review" }; private static readonly Dictionary StringToState = new(StringComparer.OrdinalIgnoreCase) @@ -37,11 +39,22 @@ public static class TaskStateHelper ["Backlog"] = TaskState.Backlog, ["In progress"] = TaskState.InProgress, ["Blocked"] = TaskState.Blocked, - ["Done"] = TaskState.Done + ["Done"] = TaskState.Done, + ["Review"] = TaskState.Review + }; + + /// Mapping from state string to display label. + private static readonly Dictionary DisplayLabels = new(StringComparer.OrdinalIgnoreCase) + { + ["Backlog"] = "Offen", + ["In progress"] = "In Bearbeitung", + ["Review"] = "Review", + ["Blocked"] = "Blockiert", + ["Done"] = "Erledigt" }; /// Valid task-state string values for API validation. - public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done"]; + public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done", "Review"]; /// Convert a TaskState enum to its API string representation. public static string ToStateString(this TaskState state) => StateToString[state]; @@ -54,6 +67,10 @@ public static class TaskStateHelper public static bool IsValidState(string? state) => !string.IsNullOrWhiteSpace(state) && StringToState.ContainsKey(state); + /// Returns the German display label for a state string. + public static string ToDisplayString(string? state) => + state is not null && DisplayLabels.TryGetValue(state, out var label) ? label : state ?? ""; + public static bool IsInProgressOrBlocked(string? state) => string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase) || string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase); @@ -61,6 +78,38 @@ public static class TaskStateHelper public static bool IsDoneOrBacklog(string? state) => string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase) || string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase); + + /// Group key for board responses (lowercased English state). + public static string BoardGroupKey(string? state) + { + if (string.IsNullOrWhiteSpace(state)) return "offen"; + var lower = state.ToLowerInvariant(); + return lower switch + { + "backlog" => "offen", + "in progress" => "inProgress", + "review" => "review", + "blocked" => "blocked", + "done" => "done", + _ => "offen" + }; + } + + /// Map a board group key back to the canonical state string. + public static string? BoardGroupToState(string? groupKey) + { + if (string.IsNullOrWhiteSpace(groupKey)) return null; + var lower = groupKey.ToLowerInvariant(); + return lower switch + { + "offen" => "Backlog", + "inprogress" => "In progress", + "review" => "Review", + "blocked" => "Blocked", + "done" => "Done", + _ => null + }; + } } public sealed class Project diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index 3516818..7025071 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -114,3 +114,21 @@ public sealed record AgentActivityEntry( string Time, string Text ); + +// ── Task Board DTOs ── + +public sealed record TaskBoardResponse( + List Offen, + List InProgress, + List Review, + List Blocked, + List Done +); + +public sealed record MoveTaskRequest( + string State +); + +public sealed record ImportResultDto( + int Imported +); diff --git a/backend/Services/ITaskService.cs b/backend/Services/ITaskService.cs index 7bd7d6b..7768f66 100644 --- a/backend/Services/ITaskService.cs +++ b/backend/Services/ITaskService.cs @@ -1,5 +1,6 @@ using Nexus.Api.Data; using Nexus.Api.DTOs; +using Nexus.Api.Models; namespace Nexus.Api.Services; @@ -26,4 +27,9 @@ public interface ITaskService Task UpdateStatusAsync(Guid id, string status, CancellationToken ct = default); Task CompleteViaQueueAsync(Guid id, CancellationToken ct = default); Task CyclePriorityAsync(Guid id, CancellationToken ct = default); + + // Task Board + Task GetBoardAsync(CancellationToken ct = default); + Task MoveTaskAsync(Guid id, string newState, CancellationToken ct = default); + Task ImportFromIrisTodoAsync(bool deleteAfterImport = false, CancellationToken ct = default); } diff --git a/backend/Services/TaskService.cs b/backend/Services/TaskService.cs index 3986f18..2056caf 100644 --- a/backend/Services/TaskService.cs +++ b/backend/Services/TaskService.cs @@ -1,5 +1,7 @@ +using System.Text.RegularExpressions; using Nexus.Api.Data; using Nexus.Api.DTOs; +using Nexus.Api.Models; using Nexus.Api.Repositories; namespace Nexus.Api.Services; @@ -121,7 +123,7 @@ public sealed class TaskService( Detail = detail?.Trim(), Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(), Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(), - AssignedTo = assignedTo?.Trim() + AssignedTo = ValidateAssignedTo(assignedTo) }; await taskRepo.AddAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct); @@ -138,7 +140,7 @@ public sealed class TaskService( if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim(); if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim(); if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim(); - if (assignedTo is not null) task.AssignedTo = string.IsNullOrWhiteSpace(assignedTo) ? null : assignedTo.Trim(); + if (assignedTo is not null) task.AssignedTo = ValidateAssignedTo(assignedTo); await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, ct); @@ -188,4 +190,145 @@ public sealed class TaskService( await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } -} + + // ── Board operations ── + + public async Task GetBoardAsync(CancellationToken ct = default) + { + var all = await taskRepo.GetAllAsync(ct); + var offen = new List(); + var inProgress = new List(); + var review = new List(); + var blocked = new List(); + var done = new List(); + + foreach (var task in all) + { + var dto = MapToDto(task); + switch (task.State.ToLowerInvariant()) + { + case "backlog": + offen.Add(dto); break; + case "in progress": + inProgress.Add(dto); break; + case "review": + review.Add(dto); break; + case "blocked": + blocked.Add(dto); break; + case "done": + done.Add(dto); break; + default: + offen.Add(dto); break; + } + } + + return new TaskBoardResponse(offen, inProgress, review, blocked, done); + } + + public async Task MoveTaskAsync(Guid id, string newState, CancellationToken ct = default) + { + // Resolve canonical state: accept board group keys or canonical strings + var canonical = TaskStateHelper.AllStates + .FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase)); + + if (canonical is null) + { + // Try mapping from board group key + canonical = TaskStateHelper.BoardGroupToState(newState); + } + + if (canonical is null) + return new TaskOperationResult(TaskOperationOutcome.InvalidState); + + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); + + task.State = canonical; + await taskRepo.UpdateAsync(task, ct); + await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}" }, ct); + return new TaskOperationResult(TaskOperationOutcome.Success, task); + } + + public async Task ImportFromIrisTodoAsync(bool deleteAfterImport = false, CancellationToken ct = default) + { + var todoPath = "/mnt/workspace-iris/TODO.md"; + if (!File.Exists(todoPath)) return 0; + + var content = await File.ReadAllTextAsync(todoPath, ct); + var lines = content.Split('\n'); + + var imported = 0; + string? currentPriority = null; + + // Parse sections and extract tasks with assignee info + // Pattern: ### N. Title 👤 Person + var taskPattern = new Regex(@"^###\s+\d+\.\s+(.+?)(?:\s+👤\s+(.+?))?$", RegexOptions.Compiled); + foreach (var line in lines) + { + var trimmed = line.Trim(); + + // Detect priority section + if (trimmed.StartsWith("## ")) + { + var sectionLower = trimmed.ToLowerInvariant(); + if (sectionLower.Contains("high")) currentPriority = "High"; + else if (sectionLower.Contains("medium")) currentPriority = "Medium"; + else if (sectionLower.Contains("low")) currentPriority = "Low"; + else currentPriority = "Medium"; + continue; + } + + var match = taskPattern.Match(trimmed); + if (!match.Success) continue; + + var title = match.Groups[1].Value.Trim(); + var assigneeRaw = match.Groups[2].Success ? match.Groups[2].Value.Trim() : null; + + // Resolve assignee: "Iris" → "iris", "Bao" → "bao", "Iris+Bao" → "iris" + string? assignedTo = null; + if (!string.IsNullOrWhiteSpace(assigneeRaw)) + { + var lower = assigneeRaw.ToLowerInvariant(); + if (lower.Contains("iris")) assignedTo = "iris"; + else if (lower.Contains("bao")) assignedTo = "bao"; + } + + var task = new WorkTask + { + Title = title, + Source = "iris", + Priority = currentPriority ?? "Medium", + AssignedTo = assignedTo + }; + await taskRepo.AddAsync(task, ct); + imported++; + } + + if (deleteAfterImport) + { + File.Delete(todoPath); + await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Imported {imported} tasks from TODO.md and deleted the file" }, ct); + } + else + { + await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Imported {imported} tasks from TODO.md" }, ct); + } + + return imported; + } + + private static DashboardTaskDto MapToDto(WorkTask t) => new( + t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt); + + /// + /// Validates AssignedTo — only "bao", "iris", or null are accepted. + /// Returns null for invalid values. + /// + private static string? ValidateAssignedTo(string? assignedTo) + { + if (string.IsNullOrWhiteSpace(assignedTo)) return null; + var lower = assignedTo.Trim().ToLowerInvariant(); + if (lower is "bao" or "iris") return lower; + return null; + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5bf48d9..ab651f5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -32,7 +32,7 @@ const navigate = (label: string) => { } const mobileNavOpen = ref(false) -const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents'].includes(activeView.value)) +const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board'].includes(activeView.value)) onMounted(() => { if (auth.isAuthenticated) store.refresh() diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 2bd7ba9..16fdecc 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -11,6 +11,7 @@ import IncidentsView from './views/IncidentsView.vue' import CalendarView from './views/CalendarView.vue' import NexusLayout from './layouts/NexusLayout.vue' import FlowBoard from './views/Dashboard/FlowBoard.vue' +import TaskBoardView from './views/TaskBoardView.vue' const routes = [ { path: '/login', name: 'Login', component: LoginView, meta: { public: true } }, @@ -33,7 +34,7 @@ const routes = [ { path: '/calendar', name: 'Calendar', component: CalendarView }, { path: '/projects', name: 'Projects', component: { template: '' } }, { path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView }, - { path: '/tasks', name: 'Task Board', component: { template: '' } }, + { path: '/tasks', name: 'Task Board', component: TaskBoardView }, { path: '/agents', name: 'Agents', component: AgentsIndexView }, { path: '/models', name: 'Models', component: { template: '' } }, { path: '/activity', name: 'Activity', component: { template: '' } }, diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index d93d5af..b0ff9fc 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -1,9 +1,10 @@ /** - * Task Store – V2 Dashboard + * Task Store – V2 Dashboard + Task Board * - * Fetches tasks from /api/dashboard/tasks and maps them into - * TaskItem[] format for the TaskStrip component. + * Fetches tasks from /api/dashboard/tasks and /api/dashboard/tasks/board + * and maps them into TaskItem[] format for the TaskStrip component. * + * Board state: grouped by column (offen, inProgress, review, done, blocked) * Auto-refresh: every 30 seconds. */ import { defineStore } from 'pinia' @@ -24,6 +25,14 @@ interface DashboardTaskDto { updatedAt: string } +export interface BoardGroup { + offen: DashboardTaskDto[] + inProgress: DashboardTaskDto[] + review: DashboardTaskDto[] + done: DashboardTaskDto[] + blocked: DashboardTaskDto[] +} + /* ── State Mapping ────────────────────────────────── */ function mapPriority(priority: string): TaskItem['priority'] { @@ -67,6 +76,18 @@ export const useTaskStore = defineStore('tasks', { loading: false, error: null as string | null, refreshInterval: null as ReturnType | null, + boardRefreshInterval: null as ReturnType | null, + + // Board state + board: { + offen: [] as DashboardTaskDto[], + inProgress: [] as DashboardTaskDto[], + review: [] as DashboardTaskDto[], + done: [] as DashboardTaskDto[], + blocked: [] as DashboardTaskDto[], + } as BoardGroup, + boardLoading: false, + boardError: null as string | null, }), getters: { @@ -74,7 +95,7 @@ export const useTaskStore = defineStore('tasks', { }, actions: { - /* ── API: Fetch tasks ───────────────────────── */ + /* ── API: Fetch tasks (for TaskStrip) ─────────── */ async fetchTasks() { this.loading = true try { @@ -91,7 +112,97 @@ export const useTaskStore = defineStore('tasks', { } }, - /* ── API: Add task ──────────────────────────── */ + /* ── API: Fetch board (for TaskBoardView) ─────── */ + async fetchBoard() { + this.boardLoading = true + try { + const res = await apiFetch('/api/dashboard/tasks/board') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data: BoardGroup = await res.json() + this.board = data + this.boardError = null + } catch (err) { + console.warn('[TaskStore] fetchBoard failed', err) + this.boardError = 'Board could not be loaded' + } finally { + this.boardLoading = false + } + }, + + /* ── API: Move task (Drag & Drop) ─────────────── */ + async moveTask(id: string, newState: string) { + // Map board group key to canonical state string for the API payload + const canonicalMap: Record = { + offen: 'Backlog', + inProgress: 'In progress', + review: 'Review', + done: 'Done', + blocked: 'Blocked', + } + + // Save previous state for rollback + const prevBoard = JSON.parse(JSON.stringify(this.board)) as BoardGroup + + // Optimistic: find the task in current board and move it + const findAndRemove = (arr: DashboardTaskDto[]): DashboardTaskDto | null => { + const idx = arr.findIndex(t => t.id === id) + if (idx === -1) return null + return arr.splice(idx, 1)[0] + } + + const task = + findAndRemove(this.board.offen) ?? + findAndRemove(this.board.inProgress) ?? + findAndRemove(this.board.review) ?? + findAndRemove(this.board.blocked) ?? + findAndRemove(this.board.done) + + if (task) { + const canonicalState = canonicalMap[newState] ?? newState + task.state = canonicalState + const targetKey = newState as keyof BoardGroup + if (this.board[targetKey]) { + this.board[targetKey].push(task) + } + } + + // Actually call API with the board group key (backend handles mapping) + try { + const res = await apiFetch(`/api/dashboard/tasks/${id}/move`, { + method: 'PATCH', + body: JSON.stringify({ state: newState }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + } catch (err) { + console.warn('[TaskStore] moveTask failed, rolling back', err) + this.board = prevBoard + } + }, + + /* ── API: Create task ─────────────────────────── */ + async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) { + try { + const res = await apiFetch('/api/dashboard/tasks', { + method: 'POST', + body: JSON.stringify({ + title: data.title, + detail: data.detail ?? null, + priority: data.priority ?? 'Medium', + assignedTo: data.assignedTo ?? 'bao', + source: 'bao', + }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + // Refresh board + task list + await this.fetchBoard() + await this.fetchTasks() + } catch (err) { + console.warn('[TaskStore] createTask failed', err) + throw err + } + }, + + /* ── API: Add task (for TaskStrip) ────────────── */ async addTask(title: string, detail?: string, priority?: string, assignedTo?: string) { try { const res = await apiFetch('/api/dashboard/tasks', { @@ -112,7 +223,7 @@ export const useTaskStore = defineStore('tasks', { } }, - /* ── API: Update task ───────────────────────── */ + /* ── API: Update task ─────────────────────────── */ async updateTask(id: string, updates: { title?: string; detail?: string; priority?: string; assignedTo?: string }) { try { const res = await apiFetch(`/api/dashboard/tasks/${id}`, { @@ -121,12 +232,13 @@ export const useTaskStore = defineStore('tasks', { }) if (!res.ok) throw new Error(`HTTP ${res.status}`) await this.fetchTasks() + await this.fetchBoard() } catch (err) { console.warn('[TaskStore] updateTask failed', err) } }, - /* ── Polling ─────────────────────────────────── */ + /* ── Polling ──────────────────────────────────── */ startPolling() { if (this.refreshInterval) return this.fetchTasks() @@ -141,5 +253,20 @@ export const useTaskStore = defineStore('tasks', { this.refreshInterval = null } }, + + startBoardPolling() { + if (this.boardRefreshInterval) return + this.fetchBoard() + this.boardRefreshInterval = setInterval(() => { + this.fetchBoard() + }, 30000) + }, + + stopBoardPolling() { + if (this.boardRefreshInterval) { + clearInterval(this.boardRefreshInterval) + this.boardRefreshInterval = null + } + }, }, }) diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue new file mode 100644 index 0000000..5d1a803 --- /dev/null +++ b/frontend/src/views/TaskBoardView.vue @@ -0,0 +1,787 @@ + + + + +