mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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:
parent
970dc8b260
commit
4116715db0
5 changed files with 1005 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
376
apps/manacore/apps/web/src/lib/modules/todo/utils/task-parser.ts
Normal file
376
apps/manacore/apps/web/src/lib/modules/todo/utils/task-parser.ts
Normal 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(' · ');
|
||||
}
|
||||
226
apps/manacore/apps/web/src/lib/modules/todo/view-grouping.ts
Normal file
226
apps/manacore/apps/web/src/lib/modules/todo/view-grouping.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue