From 4116715db01534569a17d2855e8b18e9d671ebb0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 01:53:48 +0200 Subject: [PATCH] feat(manacore/web): add todo kanban board components and view grouping Add KanbanTaskCard, QuickAddTaskInline components, task-parser utility, settings store with view/layout preferences, and a pure-function view grouping engine for board views (by status, date, priority). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/kanban/KanbanTaskCard.svelte | 171 ++++++++ .../kanban/QuickAddTaskInline.svelte | 77 ++++ .../modules/todo/stores/settings.svelte.ts | 155 ++++++++ .../src/lib/modules/todo/utils/task-parser.ts | 376 ++++++++++++++++++ .../web/src/lib/modules/todo/view-grouping.ts | 226 +++++++++++ 5 files changed, 1005 insertions(+) create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/components/kanban/KanbanTaskCard.svelte create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/components/kanban/QuickAddTaskInline.svelte create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/stores/settings.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/utils/task-parser.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/view-grouping.ts diff --git a/apps/manacore/apps/web/src/lib/modules/todo/components/kanban/KanbanTaskCard.svelte b/apps/manacore/apps/web/src/lib/modules/todo/components/kanban/KanbanTaskCard.svelte new file mode 100644 index 000000000..46425a385 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/components/kanban/KanbanTaskCard.svelte @@ -0,0 +1,171 @@ + + +
+
+ + {#if onToggleComplete} + + {/if} + + +
+ {#if isEditing} + { + if (e.key === 'Enter') saveTitle(); + if (e.key === 'Escape') isEditing = false; + }} + class="w-full bg-transparent text-sm text-foreground outline-none" + autofocus + /> + {:else} + + + + {task.title} + + {/if} + + + {#if dueInfo || subtaskInfo || taskLabels.length > 0} +
+ {#if dueInfo} + + + {dueInfo.text} + + {/if} + + {#if subtaskInfo} + + + {subtaskInfo.done}/{subtaskInfo.total} + + {/if} + + {#each taskLabels as label (label.id)} + + {label.name} + + {/each} +
+ {/if} +
+ + + {#if onDelete} + + {/if} +
+
diff --git a/apps/manacore/apps/web/src/lib/modules/todo/components/kanban/QuickAddTaskInline.svelte b/apps/manacore/apps/web/src/lib/modules/todo/components/kanban/QuickAddTaskInline.svelte new file mode 100644 index 000000000..1b2d8c172 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/components/kanban/QuickAddTaskInline.svelte @@ -0,0 +1,77 @@ + + +
+ {#if !active} + + {:else} +
+ + + + +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/lib/modules/todo/stores/settings.svelte.ts b/apps/manacore/apps/web/src/lib/modules/todo/stores/settings.svelte.ts new file mode 100644 index 000000000..f2837d6c8 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/stores/settings.svelte.ts @@ -0,0 +1,155 @@ +/** + * Todo Settings Store — User preferences for the Todo module + * Uses @manacore/shared-stores createAppSettingsStore factory + */ + +import { createAppSettingsStore } from '@manacore/shared-stores'; +import type { TaskPriority } from '../types'; + +// Settings types +export type TodoView = 'inbox' | 'today' | 'upcoming' | 'kanban' | 'completed'; +export type KanbanCardSize = 'compact' | 'normal' | 'large'; +export type LayoutMode = 'fokus' | 'uebersicht' | 'matrix'; +export type PageWidth = 'narrow' | 'medium' | 'wide' | 'full'; + +export interface TodoAppSettings extends Record { + // Task Behavior + defaultPriority: TaskPriority; + defaultDueTime: string | null; + autoArchiveCompletedDays: number | null; + quickAddProject: string | null; + + // View & Display + defaultView: TodoView; + showTaskCounts: boolean; + compactMode: boolean; + showSubtaskProgress: boolean; + groupByProject: boolean; + + // Kanban Board + kanbanCardSize: KanbanCardSize; + showLabelsOnCards: boolean; + wipLimitPerColumn: number | null; + + // Notifications & Reminders + defaultReminderMinutes: number | null; + dailyDigestEnabled: boolean; + overdueNotifications: boolean; + + // Smart Duration + smartDurationEnabled: boolean; + defaultTaskDuration: number; + + // Productivity + focusMode: boolean; + pomodoroEnabled: boolean; + dailyGoal: number | null; + showStreak: boolean; + + // Immersive Mode + immersiveModeEnabled: boolean; + + // Navigation UI + filterStripCollapsed: boolean; + + // View layout + activeLayoutMode: LayoutMode; + + // Page width + pageWidth: PageWidth; +} + +const DEFAULT_SETTINGS: TodoAppSettings = { + defaultPriority: 'medium', + defaultDueTime: '09:00', + autoArchiveCompletedDays: null, + quickAddProject: null, + + defaultView: 'inbox', + showTaskCounts: true, + compactMode: false, + showSubtaskProgress: true, + groupByProject: false, + + kanbanCardSize: 'normal', + showLabelsOnCards: true, + wipLimitPerColumn: null, + + defaultReminderMinutes: null, + dailyDigestEnabled: false, + overdueNotifications: true, + + smartDurationEnabled: true, + defaultTaskDuration: 30, + + focusMode: false, + pomodoroEnabled: false, + dailyGoal: null, + showStreak: false, + + immersiveModeEnabled: false, + filterStripCollapsed: false, + activeLayoutMode: 'fokus' as LayoutMode, + pageWidth: 'medium' as PageWidth, +}; + +const baseStore = createAppSettingsStore('todo-settings', DEFAULT_SETTINGS); + +export const todoSettings = { + get settings() { + return baseStore.settings; + }, + initialize: baseStore.initialize, + set: baseStore.set, + update: baseStore.update, + reset: baseStore.reset, + getDefaults: baseStore.getDefaults, + + // Convenience getters + get defaultPriority() { + return baseStore.settings.defaultPriority; + }, + get defaultView() { + return baseStore.settings.defaultView; + }, + get showTaskCounts() { + return baseStore.settings.showTaskCounts; + }, + get compactMode() { + return baseStore.settings.compactMode; + }, + get showSubtaskProgress() { + return baseStore.settings.showSubtaskProgress; + }, + get kanbanCardSize() { + return baseStore.settings.kanbanCardSize; + }, + get showLabelsOnCards() { + return baseStore.settings.showLabelsOnCards; + }, + get wipLimitPerColumn() { + return baseStore.settings.wipLimitPerColumn; + }, + get smartDurationEnabled() { + return baseStore.settings.smartDurationEnabled; + }, + get defaultTaskDuration() { + return baseStore.settings.defaultTaskDuration; + }, + get focusMode() { + return baseStore.settings.focusMode; + }, + get pageWidth() { + return baseStore.settings.pageWidth; + }, + get activeLayoutMode() { + return baseStore.settings.activeLayoutMode; + }, + get filterStripCollapsed() { + return baseStore.settings.filterStripCollapsed; + }, + + toggleFilterStrip() { + baseStore.update({ filterStripCollapsed: !baseStore.settings.filterStripCollapsed }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/todo/utils/task-parser.ts b/apps/manacore/apps/web/src/lib/modules/todo/utils/task-parser.ts new file mode 100644 index 000000000..afffe5a80 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/utils/task-parser.ts @@ -0,0 +1,376 @@ +/** + * Task Parser for Todo App + * + * Extends the base parser with task-specific patterns: + * - Priority: !hoch, !!, !!!, !dringend + * - Project: @ProjectName + * - Duration: 30min, 2h, 1.5 Stunden + * - Subtasks: "Title: item1, item2, item3" + * - Multi-task: "Task1, danach Task2" + */ + +import { + parseBaseInput, + extractRecurrence, + combineDateAndTime, + formatDatePreview, + formatTimePreview, +} from '@manacore/shared-utils'; +import type { ParserLocale } from '@manacore/shared-utils'; +import type { TaskPriority } from '../types'; + +export interface ParsedTask { + title: string; + dueDate?: Date; + dueTime?: string; // HH:mm format + priority?: TaskPriority; + labelNames: string[]; + recurrenceRule?: string; + subtasks?: string[]; + estimatedDuration?: number; // in minutes +} + +interface Label { + id: string; + name: string; +} + +export interface ParsedTaskWithIds { + title: string; + dueDate?: string; + dueTime?: string; + priority?: TaskPriority; + labelIds: string[]; + recurrenceRule?: string; + subtasks?: string[]; + estimatedDuration?: number; +} + +// Priority keyword translations per locale +const PRIORITY_KEYWORDS: Record< + ParserLocale, + { urgent: string; high: string; medium: string; low: string } +> = { + de: { urgent: 'dringend', high: 'wichtig', medium: 'normal', low: 'sp[aä]ter' }, + en: { urgent: 'urgent', high: 'important', medium: 'normal', low: 'later' }, + fr: { urgent: 'urgent', high: 'important', medium: 'normal', low: 'plus\\s+tard' }, + es: { urgent: 'urgente', high: 'importante', medium: 'normal', low: 'despu[eé]s' }, + it: { urgent: 'urgente', high: 'importante', medium: 'normale', low: 'dopo' }, +}; + +// ─── Duration Extraction ─────────────────────────────────── + +const DURATION_PATTERNS: Record = { + de: [ + /(\d+(?:[.,]\d+)?)\s*(?:std|stunden?)\b/i, + /(\d+(?:[.,]\d+)?)\s*h\b/i, + /(\d+)\s*min(?:uten?)?\b/i, + /(\d+(?:[.,]\d+)?)\s*(?:tage?)\b/i, + ], + en: [ + /(\d+(?:[.,]\d+)?)\s*(?:hours?|hrs?)\b/i, + /(\d+(?:[.,]\d+)?)\s*h\b/i, + /(\d+)\s*min(?:utes?)?\b/i, + /(\d+(?:[.,]\d+)?)\s*(?:days?)\b/i, + ], + fr: [ + /(\d+(?:[.,]\d+)?)\s*(?:heures?|hrs?)\b/i, + /(\d+(?:[.,]\d+)?)\s*h\b/i, + /(\d+)\s*min(?:utes?)?\b/i, + /(\d+(?:[.,]\d+)?)\s*(?:jours?)\b/i, + ], + es: [ + /(\d+(?:[.,]\d+)?)\s*(?:horas?|hrs?)\b/i, + /(\d+(?:[.,]\d+)?)\s*h\b/i, + /(\d+)\s*min(?:utos?)?\b/i, + /(\d+(?:[.,]\d+)?)\s*(?:días?)\b/i, + ], + it: [ + /(\d+(?:[.,]\d+)?)\s*(?:ore?)\b/i, + /(\d+(?:[.,]\d+)?)\s*h\b/i, + /(\d+)\s*min(?:uti?)?\b/i, + /(\d+(?:[.,]\d+)?)\s*(?:giorni?)\b/i, + ], +}; + +// Multiplier: [hours, hours, minutes, days] +const DURATION_MULTIPLIERS = [60, 60, 1, 480]; + +/** + * Extract duration from text (e.g. "30min", "2h", "1.5 Stunden") + * Returns duration in minutes. + */ +function extractDuration( + text: string, + locale: ParserLocale = 'de' +): { duration?: number; remaining: string } { + const patterns = DURATION_PATTERNS[locale]; + for (let i = 0; i < patterns.length; i++) { + const match = text.match(patterns[i]); + if (match) { + const value = parseFloat(match[1].replace(',', '.')); + const minutes = Math.round(value * DURATION_MULTIPLIERS[i]); + if (minutes > 0) { + return { + duration: minutes, + remaining: text + .replace(match[0], '') + .replace(/\s{2,}/g, ' ') + .trim(), + }; + } + } + } + return { remaining: text }; +} + +// ─── Multi-Task Splitting ────────────────────────────────── + +const TASK_SPLITTERS = + /\s*(?:,\s*(?:danach|dann|und dann|anschließend|außerdem|afterwards|then|and then|also)\s+|;\s*|\s+(?:danach|dann|und dann|anschließend|afterwards|then|and then)\s+)/i; + +/** + * Parse input that may contain multiple tasks separated by keywords. + * Subsequent tasks inherit date/time context from the first task. + */ +export function parseMultiTaskInput(input: string, locale: ParserLocale = 'de'): ParsedTask[] { + const parts = input.split(TASK_SPLITTERS).filter((s) => s.trim().length > 0); + + if (parts.length <= 1) { + return [parseTaskInput(input, locale)]; + } + + const results: ParsedTask[] = []; + let contextDate: Date | undefined; + let contextTime: string | undefined; + let lastEndMinutes: number | undefined; + + for (let i = 0; i < parts.length; i++) { + const parsed = parseTaskInput(parts[i].trim(), locale); + + if (i === 0) { + contextDate = parsed.dueDate; + contextTime = parsed.dueTime; + if (parsed.dueDate && parsed.estimatedDuration) { + lastEndMinutes = + parsed.dueDate.getHours() * 60 + parsed.dueDate.getMinutes() + parsed.estimatedDuration; + } else if (parsed.dueDate) { + lastEndMinutes = parsed.dueDate.getHours() * 60 + parsed.dueDate.getMinutes(); + } + } else { + if (!parsed.dueDate && contextDate) { + if (lastEndMinutes !== undefined && lastEndMinutes > 0) { + const inherited = new Date(contextDate); + inherited.setHours(Math.floor(lastEndMinutes / 60), lastEndMinutes % 60, 0, 0); + parsed.dueDate = inherited; + parsed.dueTime = `${String(Math.floor(lastEndMinutes / 60)).padStart(2, '0')}:${String(lastEndMinutes % 60).padStart(2, '0')}`; + } else { + parsed.dueDate = contextDate; + parsed.dueTime = contextTime; + } + } + if (parsed.dueDate && parsed.estimatedDuration) { + lastEndMinutes = + parsed.dueDate.getHours() * 60 + parsed.dueDate.getMinutes() + parsed.estimatedDuration; + } else if (parsed.dueDate) { + lastEndMinutes = undefined; + } + } + + results.push(parsed); + } + + return results; +} + +/** + * Extract subtasks from "Title: item1, item2, item3" pattern + */ +function extractSubtasks(text: string): { title: string; subtasks?: string[] } { + const colonIndex = text.indexOf(':'); + if (colonIndex === -1 || colonIndex < 2) return { title: text }; + + const beforeColon = text.substring(0, colonIndex).trim(); + const afterColon = text.substring(colonIndex + 1).trim(); + + if (!afterColon) return { title: text }; + + const items = afterColon + .split(/[,;]/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + if (items.length < 2) return { title: text }; + + return { title: beforeColon, subtasks: items }; +} + +/** + * Build locale-aware priority patterns + */ +function buildPriorityPatterns( + locale: ParserLocale +): { pattern: RegExp; priority: TaskPriority }[] { + const kw = PRIORITY_KEYWORDS[locale]; + return [ + { pattern: new RegExp(`!{3,}|!?${kw.urgent}\\b`, 'i'), priority: 'urgent' }, + { pattern: new RegExp(`!{2}|!?${kw.high}\\b`, 'i'), priority: 'high' }, + { pattern: new RegExp(`!?${kw.medium}\\b`, 'i'), priority: 'medium' }, + { pattern: new RegExp(`!?${kw.low}\\b`, 'i'), priority: 'low' }, + ]; +} + +/** + * Extract priority from text + */ +function extractPriority( + text: string, + locale: ParserLocale = 'de' +): { priority?: TaskPriority; remaining: string } { + const patterns = buildPriorityPatterns(locale); + for (const { pattern, priority } of patterns) { + if (pattern.test(text)) { + return { + priority, + remaining: text.replace(pattern, '').trim(), + }; + } + } + return { priority: undefined, remaining: text }; +} + +/** + * Parse natural language task input + * + * Examples: + * - "Meeting morgen 14 Uhr !hoch @Arbeit #wichtig" + * - "Einkaufen heute #privat" + * - "Report in 3 Tagen !!" + */ +export function parseTaskInput(input: string, locale: ParserLocale = 'de'): ParsedTask { + let text = input.trim(); + + // Extract recurrence (before priority, since "jeden Tag" shouldn't be confused) + const recurrenceResult = extractRecurrence(text, locale); + text = recurrenceResult.remaining; + const recurrenceRule = recurrenceResult.value; + + // Extract priority (task-specific) + const priorityResult = extractPriority(text, locale); + text = priorityResult.remaining; + const priority = priorityResult.priority; + + // Extract duration (before date parsing to avoid "2h" being confused) + const durationResult = extractDuration(text, locale); + text = durationResult.remaining; + const estimatedDuration = durationResult.duration; + + // Use base parser for common patterns (date, time, tags) + const base = parseBaseInput(text, locale); + + // Combine date and time + const dueDate = combineDateAndTime(base.date, base.time); + + // Preserve time as HH:mm string for context inheritance + const dueTime = base.time + ? `${String(base.time.hours).padStart(2, '0')}:${String(base.time.minutes).padStart(2, '0')}` + : undefined; + + // Check for subtask pattern "Title: item1, item2, item3" + const subtaskResult = extractSubtasks(base.title); + + return { + title: subtaskResult.title, + dueDate, + dueTime, + priority, + labelNames: base.tagNames, + recurrenceRule, + subtasks: subtaskResult.subtasks, + estimatedDuration, + }; +} + +/** + * Resolve label names to IDs + */ +export function resolveTaskIds(parsed: ParsedTask, labels: Label[]): ParsedTaskWithIds { + const labelIds: string[] = []; + + for (const labelName of parsed.labelNames) { + const label = labels.find((l) => l.name.toLowerCase() === labelName.toLowerCase()); + if (label) { + labelIds.push(label.id); + } + } + + return { + title: parsed.title, + dueDate: parsed.dueDate?.toISOString(), + dueTime: parsed.dueTime, + priority: parsed.priority, + labelIds, + recurrenceRule: parsed.recurrenceRule, + subtasks: parsed.subtasks, + estimatedDuration: parsed.estimatedDuration, + }; +} + +// Priority display labels per locale +const PRIORITY_LABELS: Record> = { + de: { low: 'Niedrig', medium: 'Normal', high: 'Wichtig', urgent: 'Dringend' }, + en: { low: 'Later', medium: 'Normal', high: 'Important', urgent: 'Urgent' }, + fr: { low: 'Plus tard', medium: 'Normal', high: 'Important', urgent: 'Urgent' }, + es: { low: 'Despues', medium: 'Normal', high: 'Importante', urgent: 'Urgente' }, + it: { low: 'Dopo', medium: 'Normale', high: 'Importante', urgent: 'Urgente' }, +}; + +/** + * Format duration in minutes to human-readable string + */ +export function formatDuration(minutes: number): string { + if (minutes < 60) return `${minutes}min`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m > 0 ? `${h}h ${m}min` : `${h}h`; +} + +/** + * Format parsed task for preview display + */ +export function formatParsedTaskPreview(parsed: ParsedTask, locale: ParserLocale = 'de'): string { + const parts: string[] = []; + + if (parsed.dueDate) { + let dateStr = formatDatePreview(parsed.dueDate); + if (parsed.dueDate.getHours() !== 0 || parsed.dueDate.getMinutes() !== 0) { + dateStr += ` ${formatTimePreview({ + hours: parsed.dueDate.getHours(), + minutes: parsed.dueDate.getMinutes(), + })}`; + } + parts.push(dateStr); + } + + if (parsed.priority) { + parts.push(PRIORITY_LABELS[locale][parsed.priority]); + } + + if (parsed.recurrenceRule) { + parts.push(parsed.recurrenceRule); + } + + if (parsed.estimatedDuration) { + parts.push(formatDuration(parsed.estimatedDuration)); + } + + if (parsed.subtasks && parsed.subtasks.length > 0) { + parts.push(`${parsed.subtasks.length} Subtasks`); + } + + if (parsed.labelNames.length > 0) { + parts.push(parsed.labelNames.join(', ')); + } + + return parts.join(' · '); +} diff --git a/apps/manacore/apps/web/src/lib/modules/todo/view-grouping.ts b/apps/manacore/apps/web/src/lib/modules/todo/view-grouping.ts new file mode 100644 index 000000000..11f45a0d1 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/view-grouping.ts @@ -0,0 +1,226 @@ +/** + * View Grouping Engine — Pure Functions + * + * Groups tasks into columns based on a BoardView configuration. + * No side effects, no store dependencies — easy to test. + */ + +import type { Task, LocalBoardView, ViewColumn, DropAction } from './types'; +import { isToday, isPast, isTomorrow, startOfDay, addDays, isFuture } from 'date-fns'; + +// ─── Output Type ─────────────────────────────────────────── + +export interface GroupedColumn { + id: string; + name: string; + color: string; + tasks: Task[]; + onDrop?: DropAction; +} + +// ─── Main Grouping Function ──────────────────────────────── + +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); + + // Apply view-level filter + const filtered = applyViewFilter(activeTasks, view.filter); + + switch (view.groupBy) { + case 'status': + return groupByStatus(filtered, view.columns); + case 'priority': + return groupByPriority(filtered, view.columns); + case 'dueDate': + return groupByDueDate(filtered, view.columns); + case 'tag': + return groupByTag(filtered, view.columns); + case 'custom': + return groupByCustom(filtered, view); + default: + return groupByStatus(filtered, view.columns); + } +} + +// ─── Group By Implementations ────────────────────────────── + +function groupByStatus(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] { + return columns.map((col) => ({ + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: tasks.filter((t) => { + if (col.match.value === 'completed') return t.isCompleted; + if (col.match.value === 'pending') return !t.isCompleted; + return false; + }), + })); +} + +function groupByPriority(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] { + return columns.map((col) => ({ + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: tasks.filter((t) => t.priority === col.match.value), + })); +} + +function groupByDueDate(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] { + const today = startOfDay(new Date()); + const tomorrowDate = addDays(today, 1); + const weekEnd = addDays(today, 7); + + return columns.map((col) => ({ + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: tasks.filter((t) => { + if (!t.dueDate) return col.match.value === 'none'; + const d = new Date(t.dueDate); + const dayStart = startOfDay(d); + switch (col.match.value) { + case 'overdue': + return isPast(dayStart) && !isToday(d); + case 'today': + return isToday(d); + case 'tomorrow': + return isTomorrow(d); + case 'week': + return isFuture(d) && !isTomorrow(d) && d <= weekEnd; + case 'later': + return d > weekEnd; + default: + return false; + } + }), + })); +} + +function groupByTag(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] { + return columns.map((col) => ({ + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: tasks.filter((t) => { + const labelIds: string[] = (t.metadata as { labelIds?: string[] })?.labelIds ?? []; + return labelIds.includes(col.match.value ?? ''); + }), + })); +} + +function groupByCustom(tasks: Task[], view: LocalBoardView): GroupedColumn[] { + // Eisenhower matrix: priority + dueDate combination + if (view.id === 'view-eisenhower') { + return groupEisenhower(tasks, view.columns); + } + + // Generic custom: use taskIds per column + const assigned = new Set(); + const result = view.columns.map((col) => { + const colTaskIds = new Set(col.match.taskIds ?? []); + const colTasks = tasks.filter((t) => { + if (colTaskIds.has(t.id)) { + assigned.add(t.id); + return true; + } + return false; + }); + return { + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: colTasks, + }; + }); + + // Unassigned tasks go to last column + const unassigned = tasks.filter((t) => !assigned.has(t.id)); + if (unassigned.length > 0 && result.length > 0) { + result[result.length - 1].tasks = [...result[result.length - 1].tasks, ...unassigned]; + } + + return result; +} + +// ─── Eisenhower Matrix ───────────────────────────────────── + +function groupEisenhower(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] { + const today = startOfDay(new Date()); + const soonThreshold = addDays(today, 3); + + function isImportant(t: Task): boolean { + return t.priority === 'urgent' || t.priority === 'high'; + } + + function isUrgent(t: Task): boolean { + if (!t.dueDate) return false; + const d = new Date(t.dueDate); + return isPast(startOfDay(d)) || d <= soonThreshold; + } + + const buckets: Record = { + 'urgent-important': [], + important: [], + urgent: [], + neither: [], + }; + + for (const t of tasks.filter((t) => !t.isCompleted)) { + const imp = isImportant(t); + const urg = isUrgent(t); + if (imp && urg) buckets['urgent-important'].push(t); + else if (imp) buckets['important'].push(t); + else if (urg) buckets['urgent'].push(t); + else buckets['neither'].push(t); + } + + return columns.map((col) => ({ + id: col.id, + name: col.name, + color: col.color, + onDrop: col.onDrop, + tasks: buckets[col.match.value ?? ''] ?? [], + })); +} + +// ─── Helpers ─────────────────────────────────────────────── + +function applyViewFilter( + tasks: Task[], + filter?: { tagIds?: string[]; priorities?: string[] } +): Task[] { + if (!filter) return tasks; + let result = tasks; + + if (filter.priorities && filter.priorities.length > 0) { + result = result.filter((t) => filter.priorities!.includes(t.priority)); + } + if (filter.tagIds && filter.tagIds.length > 0) { + result = result.filter((t) => { + const labelIds: string[] = (t.metadata as { labelIds?: string[] })?.labelIds ?? []; + return labelIds.some((id) => filter.tagIds!.includes(id)); + }); + } + + return result; +} + +/** + * Apply a column's drop action to a task — returns the update payload. + */ +export function getDropActionUpdate(action: DropAction): Record { + const update: Record = {}; + if (action.setCompleted !== undefined) { + update.isCompleted = action.setCompleted; + update.completedAt = action.setCompleted ? new Date().toISOString() : null; + } + if (action.setPriority) update.priority = action.setPriority; + return update; +}