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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 01:53:48 +02:00
parent 970dc8b260
commit 4116715db0
5 changed files with 1005 additions and 0 deletions

View file

@ -0,0 +1,171 @@
<script lang="ts">
import type { Task } from '../../types';
import { isToday, isPast, format } from 'date-fns';
import { de } from 'date-fns/locale';
import { Check, Circle, CalendarBlank, CheckSquare, Flag, Trash } from '@manacore/shared-icons';
interface Props {
task: Task;
labels?: { id: string; name: string; color: string }[];
onToggleComplete?: () => void;
onSave?: (data: Partial<Task>) => void;
onDelete?: () => void;
}
let { task, labels = [], onToggleComplete, onSave, onDelete }: Props = $props();
// Label resolution
let taskLabelIds = $derived((task.metadata as { labelIds?: string[] })?.labelIds ?? []);
let taskLabels = $derived(
taskLabelIds
.map((id) => labels.find((l) => l.id === id))
.filter((l): l is NonNullable<typeof l> => l != null)
.slice(0, 3)
);
// Due date display
let dueInfo = $derived.by(() => {
if (!task.dueDate) return null;
const d = new Date(task.dueDate);
const overdue = isPast(d) && !isToday(d) && !task.isCompleted;
const today = isToday(d);
return {
text: today ? 'Heute' : format(d, 'd. MMM', { locale: de }),
overdue,
today,
};
});
// Subtask progress
let subtaskInfo = $derived.by(() => {
if (!task.subtasks?.length) return null;
const done = task.subtasks.filter((s) => s.isCompleted).length;
return { done, total: task.subtasks.length };
});
// Priority colors
const priorityColors: Record<string, string> = {
urgent: '#ef4444',
high: '#f59e0b',
medium: '#3b82f6',
low: '#9ca3af',
};
// Inline title editing
let isEditing = $state(false);
let editTitle = $state('');
function startEdit(e: MouseEvent) {
e.stopPropagation();
editTitle = task.title;
isEditing = true;
}
function saveTitle() {
const trimmed = editTitle.trim();
if (trimmed && trimmed !== task.title) {
onSave?.({ title: trimmed });
}
isEditing = false;
}
</script>
<div
class="group relative rounded-lg border border-border bg-card p-2.5 transition-shadow hover:shadow-md"
style="border-left: 3px solid {priorityColors[task.priority] ?? priorityColors.medium}"
>
<div class="flex items-start gap-2">
<!-- Complete toggle -->
{#if onToggleComplete}
<button
onclick={(e) => {
e.stopPropagation();
onToggleComplete?.();
}}
class="mt-0.5 flex-shrink-0 rounded-full p-0.5 transition-colors {task.isCompleted
? 'text-green-500'
: 'text-muted-foreground hover:text-foreground'}"
>
{#if task.isCompleted}
<Check size={16} weight="bold" />
{:else}
<Circle size={16} />
{/if}
</button>
{/if}
<!-- Content -->
<div class="min-w-0 flex-1">
{#if isEditing}
<input
bind:value={editTitle}
onblur={saveTitle}
onkeydown={(e) => {
if (e.key === 'Enter') saveTitle();
if (e.key === 'Escape') isEditing = false;
}}
class="w-full bg-transparent text-sm text-foreground outline-none"
autofocus
/>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
onclick={startEdit}
class="block cursor-text text-sm leading-snug {task.isCompleted
? 'text-muted-foreground line-through'
: 'text-foreground'}"
>
{task.title}
</span>
{/if}
<!-- Meta row -->
{#if dueInfo || subtaskInfo || taskLabels.length > 0}
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
{#if dueInfo}
<span
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.625rem] font-medium {dueInfo.overdue
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: dueInfo.today
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
: 'bg-muted text-muted-foreground'}"
>
<CalendarBlank size={10} />
{dueInfo.text}
</span>
{/if}
{#if subtaskInfo}
<span class="inline-flex items-center gap-1 text-[0.625rem] text-muted-foreground">
<CheckSquare size={10} />
{subtaskInfo.done}/{subtaskInfo.total}
</span>
{/if}
{#each taskLabels as label (label.id)}
<span
class="rounded px-1.5 py-0.5 text-[0.625rem] font-medium"
style="background: color-mix(in srgb, {label.color} 15%, transparent); color: {label.color}"
>
{label.name}
</span>
{/each}
</div>
{/if}
</div>
<!-- Delete (hover) -->
{#if onDelete}
<button
onclick={(e) => {
e.stopPropagation();
onDelete?.();
}}
class="flex-shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<Trash size={14} />
</button>
{/if}
</div>
</div>

View file

@ -0,0 +1,77 @@
<script lang="ts">
import { Plus } from '@manacore/shared-icons';
interface Props {
onAdd: (title: string) => void;
placeholder?: string;
}
let { onAdd, placeholder = 'Aufgabe hinzufügen...' }: Props = $props();
let active = $state(false);
let title = $state('');
let inputEl = $state<HTMLInputElement | undefined>(undefined);
function activate() {
active = true;
requestAnimationFrame(() => inputEl?.focus());
}
function submit() {
const text = title.trim();
if (text) {
onAdd(text);
title = '';
}
// Keep active for rapid entry
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
submit();
} else if (e.key === 'Escape') {
active = false;
title = '';
}
}
function handleBlur() {
if (title.trim()) {
submit();
}
active = false;
}
</script>
<div class="mt-1">
{#if !active}
<button
onclick={activate}
class="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/50"
>
<span
class="flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-current opacity-50"
>
<Plus size={12} />
</span>
<span class="opacity-60">{placeholder}</span>
</button>
{:else}
<div class="flex items-center gap-2.5 rounded-lg px-2.5 py-1.5">
<span
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<Plus size={12} />
</span>
<input
bind:this={inputEl}
bind:value={title}
onkeydown={handleKeydown}
onblur={handleBlur}
{placeholder}
class="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/50"
/>
</div>
{/if}
</div>

View file

@ -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<string, unknown> {
// 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<TodoAppSettings>('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 });
},
};

View file

@ -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<ParserLocale, RegExp[]> = {
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<ParserLocale, Record<TaskPriority, string>> = {
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(' · ');
}

View file

@ -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<string>();
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<string, Task[]> = {
'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<string, unknown> {
const update: Record<string, unknown> = {};
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;
}