feat(tasks): support subtasks with inline creation, checklist and title progress bar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 54s

This commit is contained in:
sandrodach committed 2026-10-25 01:43:14 +02:00
1 parent 537b916533
commit 0ec1a8b7f7
7 files changed
+388 -58

No files matched your search

+259 -17
View File
@@ -15,8 +15,9 @@ import {
Layers,
Share2,
Users,
CheckSquare,
} from 'lucide-react';
import { TaskList, Thought, Space } from '../../lib/types';
import { TaskList, Thought, Space, SubTask } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
const getInitials = (name?: string): string => {
@@ -67,6 +68,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
const [activeNotesTaskId, setActiveNotesTaskId] = useState<string | null>(null);
const [notesDraft, setNotesDraft] = useState('');
// Subtask creation state
const [addingSubtaskTaskId, setAddingSubtaskTaskId] = useState<string | null>(null);
const [subtaskDraft, setSubtaskDraft] = useState('');
const loadData = async () => {
setLoading(true);
try {
@@ -125,6 +130,168 @@ export const TasksView: React.FC<TasksViewProps> = ({
setActiveNotesTaskId(null);
};
const handleAddSubtask = async (task: Thought, title: string) => {
const trimmed = title.trim();
if (!trimmed) return;
triggerHaptic(10);
const newSub: SubTask = {
id: `sub_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
title: trimmed,
isCompleted: false,
createdAt: new Date().toISOString(),
};
const updatedSubtasks = [...(task.subtasks || []), newSub];
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
);
setSubtaskDraft('');
await api.updateThought(task.id, { subtasks: updatedSubtasks });
};
const handleToggleSubtask = async (task: Thought, subtaskId: string) => {
triggerHaptic(10);
const updatedSubtasks = (task.subtasks || []).map((s) =>
s.id === subtaskId ? { ...s, isCompleted: !s.isCompleted } : s
);
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
);
await api.updateThought(task.id, { subtasks: updatedSubtasks });
};
const handleDeleteSubtask = async (task: Thought, subtaskId: string) => {
triggerHaptic(10);
const updatedSubtasks = (task.subtasks || []).filter((s) => s.id !== subtaskId);
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
);
await api.updateThought(task.id, { subtasks: updatedSubtasks });
};
const renderTaskProgressBar = (task: Thought) => {
if (!task.subtasks || task.subtasks.length === 0) return null;
const total = task.subtasks.length;
const done = task.subtasks.filter((s) => s.isCompleted).length;
const percent = Math.round((done / total) * 100);
return (
<div
className="space-y-1 mt-1.5 mb-1 select-none"
title={`${done} von ${total} Unteraufgaben erledigt (${percent}%)`}
>
<div className="flex items-center justify-between text-[10px] text-muted font-medium">
<span className="flex items-center gap-1">
<CheckSquare className="w-2.5 h-2.5 text-accent" />
<span>
{done}/{total} Unteraufgaben
</span>
</span>
<span className={percent === 100 ? 'text-status-ok font-bold' : 'text-muted'}>
{percent}%
</span>
</div>
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
percent === 100 ? 'bg-status-ok' : 'bg-accent'
}`}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
};
const renderSubtaskList = (task: Thought) => {
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
const isAdding = addingSubtaskTaskId === task.id;
if (!hasSubtasks && !isAdding) return null;
return (
<div className="space-y-1 pt-1 pb-0.5">
{hasSubtasks && (
<div className="space-y-0.5">
{task.subtasks!.map((sub) => (
<div
key={sub.id}
className="flex items-center justify-between group/sub py-0.5 px-1 rounded hover:bg-canvas text-xs transition-colors"
>
<label className="flex items-center gap-2 cursor-pointer flex-1 min-w-0 select-none">
<input
type="checkbox"
checked={sub.isCompleted}
onChange={() => handleToggleSubtask(task, sub.id)}
className="checkbox-square w-3.5 h-3.5 accent-accent cursor-pointer flex-shrink-0"
/>
<span
className={`truncate leading-snug ${
sub.isCompleted ? 'line-through text-faint' : 'text-main'
}`}
>
{sub.title}
</span>
</label>
<button
type="button"
onClick={() => handleDeleteSubtask(task, sub.id)}
className="opacity-0 group-hover/sub:opacity-100 text-faint hover:text-status-err transition-opacity p-0.5 ml-1"
title="Unteraufgabe entfernen"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
</div>
))}
</div>
)}
{isAdding && (
<form
onSubmit={(e) => {
e.preventDefault();
handleAddSubtask(task, subtaskDraft);
}}
className="flex items-center gap-1 pt-1"
>
<input
type="text"
value={subtaskDraft}
onChange={(e) => setSubtaskDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setAddingSubtaskTaskId(null);
setSubtaskDraft('');
}
}}
placeholder="Neue Unteraufgabe… (Enter)"
autoFocus
className="flex-1 h-6 px-1.5 text-xs bg-canvas border border-accent rounded-btn text-main placeholder-faint focus:outline-none"
/>
<button
type="submit"
disabled={!subtaskDraft.trim()}
className="h-6 px-2 bg-accent text-on-accent text-[11px] rounded-btn font-medium disabled:opacity-40 cursor-pointer"
title="Hinzufügen"
>
<Plus className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => {
setAddingSubtaskTaskId(null);
setSubtaskDraft('');
}}
className="h-6 px-1 text-muted hover:text-main cursor-pointer"
title="Abbrechen"
>
<X className="w-3 h-3" />
</button>
</form>
)}
</div>
);
};
const handleConvertToDoc = async (task: Thought) => {
triggerHaptic(20);
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
@@ -657,6 +824,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button>
</div>
{/* Subtasks Progress Bar on Title */}
{renderTaskProgressBar(task)}
{/* Notes Preview / Inline Editor */}
{activeNotesTaskId === task.id ? (
<div className="space-y-1">
@@ -695,6 +865,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
) : null}
{/* Subtasks Checklist & Inline Creator */}
{renderSubtaskList(task)}
{/* Footer & Movement Controls */}
<div className="flex items-center justify-between gap-1 pt-1 border-t border-subtle text-[11px]">
<div className="flex items-center gap-1.5 text-muted">
@@ -714,6 +887,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
+ Notiz
</button>
)}
<button
type="button"
onClick={() => {
setAddingSubtaskTaskId(task.id);
setSubtaskDraft('');
}}
className="text-[10px] text-faint hover:text-main flex items-center gap-0.5 cursor-pointer"
title="Unteraufgabe hinzufügen"
>
<Plus className="w-2.5 h-2.5 text-accent" />
<span>+ Unteraufgabe</span>
</button>
</div>
<div className="flex items-center gap-1">
@@ -764,6 +949,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button>
</div>
{/* Subtasks Progress Bar on Title */}
{renderTaskProgressBar(task)}
{/* Notes Preview / Inline Editor */}
{activeNotesTaskId === task.id ? (
<div className="space-y-1">
@@ -801,25 +989,56 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
) : null}
{/* Subtasks Checklist & Inline Creator */}
{renderSubtaskList(task)}
{/* Movement Controls */}
<div className="flex items-center justify-between gap-1 pt-1 border-t border-subtle text-[11px]">
<button
onClick={() => handleKanbanMove(task, 'todo')}
className="btn-action h-6 px-1.5 text-[10px] flex items-center gap-1"
title="Zurück zu 'Zu erledigen'"
>
<ArrowLeft className="w-3 h-3 text-muted" />
<span>Zurück</span>
</button>
<div className="flex items-center gap-1.5 text-muted">
{!task.notes && (
<button
onClick={() => {
setActiveNotesTaskId(task.id);
setNotesDraft('');
}}
className="text-[10px] text-faint hover:text-main"
>
+ Notiz
</button>
)}
<button
type="button"
onClick={() => {
setAddingSubtaskTaskId(task.id);
setSubtaskDraft('');
}}
className="text-[10px] text-faint hover:text-main flex items-center gap-0.5 cursor-pointer"
title="Unteraufgabe hinzufügen"
>
<Plus className="w-2.5 h-2.5 text-accent" />
<span>+ Unteraufgabe</span>
</button>
</div>
<button
onClick={() => handleKanbanMove(task, 'done')}
className="btn-primary h-6 px-2 text-[10px] flex items-center gap-1"
title="Als Erledigt markieren"
>
<span>Fertig</span>
<Check className="w-3 h-3" />
</button>
<div className="flex items-center gap-1">
<button
onClick={() => handleKanbanMove(task, 'todo')}
className="btn-action h-6 px-1.5 text-[10px] flex items-center gap-1"
title="Zurück zu 'Zu erledigen'"
>
<ArrowLeft className="w-3 h-3 text-muted" />
<span>Zurück</span>
</button>
<button
onClick={() => handleKanbanMove(task, 'done')}
className="btn-primary h-6 px-2 text-[10px] flex items-center gap-1"
title="Als Erledigt markieren"
>
<span>Fertig</span>
<Check className="w-3 h-3" />
</button>
</div>
</div>
</div>
))}
@@ -849,6 +1068,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.content}
</p>
{/* Subtasks Progress Bar & Checklist */}
{renderTaskProgressBar(task)}
{renderSubtaskList(task)}
<div className="flex items-center justify-between gap-1 pt-1 border-t border-subtle text-[11px]">
<button
onClick={() => handleKanbanMove(task, 'todo')}
@@ -991,6 +1214,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.content}
</p>
{/* Subtasks Progress Bar on Title */}
{renderTaskProgressBar(task)}
{/* Inline Notes or Editor */}
{activeNotesTaskId === task.id ? (
<div className="space-y-1 pt-1">
@@ -1029,6 +1255,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
</p>
) : null}
{/* Subtasks Checklist & Inline Creator */}
{renderSubtaskList(task)}
<div className="flex flex-wrap items-center gap-1.5 mt-0.5 empty:hidden">
{task.priority === 'urgent' && (
<span className="badge-accent">Dringend</span>
@@ -1052,6 +1281,19 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Action Controls */}
<div className="flex items-center gap-2 self-end sm:self-center flex-shrink-0">
<button
type="button"
onClick={() => {
setAddingSubtaskTaskId(task.id);
setSubtaskDraft('');
}}
className="btn-action text-xs"
title="Unteraufgabe hinzufügen"
>
<Plus className="w-3 h-3 text-accent" />
<span className="hidden md:inline">Unteraufgabe</span>
</button>
{!task.notes && activeNotesTaskId !== task.id && (
<button
onClick={() => {
@@ -523,6 +523,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setSlashSearch('');
};
const getDirectChildTodos = (blocks: DocumentBlock[], parentIndex: number): DocumentBlock[] => {
const parent = blocks[parentIndex];
if (!parent) return [];
const parentLevel = parent.level || 0;
const childTodos: DocumentBlock[] = [];
for (let i = parentIndex + 1; i < blocks.length; i++) {
const cur = blocks[i];
const curLevel = cur.level || 0;
if (curLevel <= parentLevel) break;
if (cur.type === 'todo') {
childTodos.push(cur);
}
}
return childTodos;
};
// Drag & Drop Handlers on the Bullet Dot
const handleDragStart = (e: React.DragEvent, blockId: string) => {
e.dataTransfer.setData('text/plain', blockId);
@@ -1467,26 +1483,68 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div className="flex-1 min-w-0 pt-0.5 relative">
{/* TYPE: TODO */}
{block.type === 'todo' && (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={!!block.checked}
onChange={(e) =>
updateBlock(block.id, { checked: e.target.checked })
}
className="w-4 h-4 checkbox-square cursor-pointer flex-shrink-0"
/>
<input
ref={(el) => (blockInputRefs.current[block.id] = el)}
type="text"
value={block.content}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
placeholder="Aufgabe tippen… (/ für Befehle)"
className={`w-full bg-transparent text-sm focus:outline-none placeholder:text-faint ${
block.checked ? 'line-through text-faint' : 'text-main'
}`}
/>
<div className="space-y-1">
<div className="flex items-center gap-2 group/todo">
<input
type="checkbox"
checked={!!block.checked}
onChange={(e) =>
updateBlock(block.id, { checked: e.target.checked })
}
className="w-4 h-4 checkbox-square cursor-pointer flex-shrink-0"
/>
<input
ref={(el) => (blockInputRefs.current[block.id] = el)}
type="text"
value={block.content}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
placeholder="Aufgabe tippen… (/ für Befehle)"
className={`w-full bg-transparent text-sm focus:outline-none placeholder:text-faint ${
block.checked ? 'line-through text-faint' : 'text-main'
}`}
/>
<button
type="button"
onClick={() => insertNewBulletAfter(originalIndex, (block.level || 0) + 1, 'todo')}
className="opacity-0 group-hover/todo:opacity-100 p-0.5 text-muted hover:text-accent rounded transition-opacity cursor-pointer flex-shrink-0"
title="Unteraufgabe erstellen"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{/* Progress bar for child todos if any exist */}
{(() => {
const childTodos = getDirectChildTodos(selectedDoc?.blocks || [], originalIndex);
if (childTodos.length === 0) return null;
const doneChild = childTodos.filter((c) => c.checked).length;
const totalChild = childTodos.length;
const pct = Math.round((doneChild / totalChild) * 100);
return (
<div
className="space-y-0.5 ml-6 pt-0.5 select-none"
title={`${doneChild} von ${totalChild} Unteraufgaben erledigt (${pct}%)`}
>
<div className="flex items-center justify-between text-[10px] text-muted">
<span>
{doneChild}/{totalChild} erledigt
</span>
<span className={pct === 100 ? 'text-status-ok font-bold' : ''}>
{pct}%
</span>
</div>
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
pct === 100 ? 'bg-status-ok' : 'bg-accent'
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})()}
</div>
)}
+2
View File
@@ -13,6 +13,7 @@ import {
Team,
TaskList,
Thought,
SubTask,
User,
UserInvite,
} from './types';
@@ -309,6 +310,7 @@ export const api = {
taskListId?: string;
kanbanStatus?: 'todo' | 'in_progress' | 'done';
notes?: string;
subtasks?: SubTask[];
}
): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
+8
View File
@@ -34,6 +34,13 @@ export interface UserInvite {
expiresAt: string;
}
export interface SubTask {
id: string;
title: string;
isCompleted: boolean;
createdAt?: string;
}
export interface Thought {
id: string;
userId: string;
@@ -49,6 +56,7 @@ export interface Thought {
taskListId?: string;
kanbanStatus?: 'todo' | 'in_progress' | 'done';
notes?: string;
subtasks?: SubTask[];
alarmSent?: boolean;
createdAt: string;
triagedAt?: string;