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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 54s
This commit is contained in:
1 parent
537b916533
commit
0ec1a8b7f7
7 files changed
+346
-16
No files matched your search
@@ -15,8 +15,9 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Share2,
|
Share2,
|
||||||
Users,
|
Users,
|
||||||
|
CheckSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskList, Thought, Space } from '../../lib/types';
|
import { TaskList, Thought, Space, SubTask } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
|
||||||
const getInitials = (name?: string): string => {
|
const getInitials = (name?: string): string => {
|
||||||
@@ -67,6 +68,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const [activeNotesTaskId, setActiveNotesTaskId] = useState<string | null>(null);
|
const [activeNotesTaskId, setActiveNotesTaskId] = useState<string | null>(null);
|
||||||
const [notesDraft, setNotesDraft] = useState('');
|
const [notesDraft, setNotesDraft] = useState('');
|
||||||
|
|
||||||
|
// Subtask creation state
|
||||||
|
const [addingSubtaskTaskId, setAddingSubtaskTaskId] = useState<string | null>(null);
|
||||||
|
const [subtaskDraft, setSubtaskDraft] = useState('');
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -125,6 +130,168 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
setActiveNotesTaskId(null);
|
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) => {
|
const handleConvertToDoc = async (task: Thought) => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
|
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
|
||||||
@@ -657,6 +824,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Subtasks Progress Bar on Title */}
|
||||||
|
{renderTaskProgressBar(task)}
|
||||||
|
|
||||||
{/* Notes Preview / Inline Editor */}
|
{/* Notes Preview / Inline Editor */}
|
||||||
{activeNotesTaskId === task.id ? (
|
{activeNotesTaskId === task.id ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -695,6 +865,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Subtasks Checklist & Inline Creator */}
|
||||||
|
{renderSubtaskList(task)}
|
||||||
|
|
||||||
{/* Footer & Movement Controls */}
|
{/* 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 justify-between gap-1 pt-1 border-t border-subtle text-[11px]">
|
||||||
<div className="flex items-center gap-1.5 text-muted">
|
<div className="flex items-center gap-1.5 text-muted">
|
||||||
@@ -714,6 +887,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
+ Notiz
|
+ Notiz
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -764,6 +949,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Subtasks Progress Bar on Title */}
|
||||||
|
{renderTaskProgressBar(task)}
|
||||||
|
|
||||||
{/* Notes Preview / Inline Editor */}
|
{/* Notes Preview / Inline Editor */}
|
||||||
{activeNotesTaskId === task.id ? (
|
{activeNotesTaskId === task.id ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -801,8 +989,38 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Subtasks Checklist & Inline Creator */}
|
||||||
|
{renderSubtaskList(task)}
|
||||||
|
|
||||||
{/* Movement Controls */}
|
{/* Movement Controls */}
|
||||||
<div className="flex items-center justify-between gap-1 pt-1 border-t border-subtle text-[11px]">
|
<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">
|
||||||
|
{!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>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleKanbanMove(task, 'todo')}
|
onClick={() => handleKanbanMove(task, 'todo')}
|
||||||
className="btn-action h-6 px-1.5 text-[10px] flex items-center gap-1"
|
className="btn-action h-6 px-1.5 text-[10px] flex items-center gap-1"
|
||||||
@@ -822,6 +1040,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{inProgressKanban.length === 0 && (
|
{inProgressKanban.length === 0 && (
|
||||||
<div className="p-4 text-center text-faint text-xs">Keine aktiven Aufgaben</div>
|
<div className="p-4 text-center text-faint text-xs">Keine aktiven Aufgaben</div>
|
||||||
@@ -849,6 +1068,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
{task.content}
|
{task.content}
|
||||||
</p>
|
</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]">
|
<div className="flex items-center justify-between gap-1 pt-1 border-t border-subtle text-[11px]">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleKanbanMove(task, 'todo')}
|
onClick={() => handleKanbanMove(task, 'todo')}
|
||||||
@@ -991,6 +1214,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
{task.content}
|
{task.content}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Subtasks Progress Bar on Title */}
|
||||||
|
{renderTaskProgressBar(task)}
|
||||||
|
|
||||||
{/* Inline Notes or Editor */}
|
{/* Inline Notes or Editor */}
|
||||||
{activeNotesTaskId === task.id ? (
|
{activeNotesTaskId === task.id ? (
|
||||||
<div className="space-y-1 pt-1">
|
<div className="space-y-1 pt-1">
|
||||||
@@ -1029,6 +1255,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Subtasks Checklist & Inline Creator */}
|
||||||
|
{renderSubtaskList(task)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mt-0.5 empty:hidden">
|
<div className="flex flex-wrap items-center gap-1.5 mt-0.5 empty:hidden">
|
||||||
{task.priority === 'urgent' && (
|
{task.priority === 'urgent' && (
|
||||||
<span className="badge-accent">Dringend</span>
|
<span className="badge-accent">Dringend</span>
|
||||||
@@ -1052,6 +1281,19 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
{/* Action Controls */}
|
{/* Action Controls */}
|
||||||
<div className="flex items-center gap-2 self-end sm:self-center flex-shrink-0">
|
<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 && (
|
{!task.notes && activeNotesTaskId !== task.id && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -523,6 +523,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setSlashSearch('');
|
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
|
// Drag & Drop Handlers on the Bullet Dot
|
||||||
const handleDragStart = (e: React.DragEvent, blockId: string) => {
|
const handleDragStart = (e: React.DragEvent, blockId: string) => {
|
||||||
e.dataTransfer.setData('text/plain', blockId);
|
e.dataTransfer.setData('text/plain', blockId);
|
||||||
@@ -1467,7 +1483,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<div className="flex-1 min-w-0 pt-0.5 relative">
|
<div className="flex-1 min-w-0 pt-0.5 relative">
|
||||||
{/* TYPE: TODO */}
|
{/* TYPE: TODO */}
|
||||||
{block.type === 'todo' && (
|
{block.type === 'todo' && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 group/todo">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!block.checked}
|
checked={!!block.checked}
|
||||||
@@ -1487,6 +1504,47 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
block.checked ? 'line-through text-faint' : 'text-main'
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Team,
|
Team,
|
||||||
TaskList,
|
TaskList,
|
||||||
Thought,
|
Thought,
|
||||||
|
SubTask,
|
||||||
User,
|
User,
|
||||||
UserInvite,
|
UserInvite,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -309,6 +310,7 @@ export const api = {
|
|||||||
taskListId?: string;
|
taskListId?: string;
|
||||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
subtasks?: SubTask[];
|
||||||
}
|
}
|
||||||
): Promise<Thought> {
|
): Promise<Thought> {
|
||||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ export interface UserInvite {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubTask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
isCompleted: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Thought {
|
export interface Thought {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -49,6 +56,7 @@ export interface Thought {
|
|||||||
taskListId?: string;
|
taskListId?: string;
|
||||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
subtasks?: SubTask[];
|
||||||
alarmSent?: boolean;
|
alarmSent?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
triagedAt?: string;
|
triagedAt?: string;
|
||||||
|
|||||||
@@ -644,13 +644,14 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
TaskListID *string `json:"taskListId"`
|
TaskListID *string `json:"taskListId"`
|
||||||
KanbanStatus *string `json:"kanbanStatus"`
|
KanbanStatus *string `json:"kanbanStatus"`
|
||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
|
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes)
|
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ const (
|
|||||||
StatusConverted ThoughtStatus = "converted"
|
StatusConverted ThoughtStatus = "converted"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SubTask struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
IsCompleted bool `json:"isCompleted"`
|
||||||
|
CreatedAt string `json:"createdAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Thought struct {
|
type Thought struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
@@ -92,6 +99,7 @@ type Thought struct {
|
|||||||
TaskListID string `json:"taskListId,omitempty"`
|
TaskListID string `json:"taskListId,omitempty"`
|
||||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
|
Subtasks []SubTask `json:"subtasks"`
|
||||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
TriagedAt string `json:"triagedAt,omitempty"`
|
TriagedAt string `json:"triagedAt,omitempty"`
|
||||||
|
|||||||
+24
-13
@@ -108,6 +108,7 @@ func (s *Store) migrate() error {
|
|||||||
kanban_status TEXT NOT NULL DEFAULT 'todo',
|
kanban_status TEXT NOT NULL DEFAULT 'todo',
|
||||||
notes TEXT NOT NULL DEFAULT '',
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
alarm_sent INTEGER NOT NULL DEFAULT 0,
|
alarm_sent INTEGER NOT NULL DEFAULT 0,
|
||||||
|
subtasks TEXT NOT NULL DEFAULT '[]',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
triaged_at TEXT DEFAULT '',
|
triaged_at TEXT DEFAULT '',
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
@@ -263,6 +264,7 @@ func (s *Store) migrate() error {
|
|||||||
"ALTER TABLE thoughts ADD COLUMN kanban_status TEXT NOT NULL DEFAULT 'todo';",
|
"ALTER TABLE thoughts ADD COLUMN kanban_status TEXT NOT NULL DEFAULT 'todo';",
|
||||||
"ALTER TABLE thoughts ADD COLUMN notes TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE thoughts ADD COLUMN notes TEXT NOT NULL DEFAULT '';",
|
||||||
"ALTER TABLE thoughts ADD COLUMN alarm_sent INTEGER NOT NULL DEFAULT 0;",
|
"ALTER TABLE thoughts ADD COLUMN alarm_sent INTEGER NOT NULL DEFAULT 0;",
|
||||||
|
"ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';",
|
||||||
|
|
||||||
"ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';",
|
||||||
}
|
}
|
||||||
@@ -645,13 +647,14 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at)
|
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`,
|
||||||
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt,
|
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
t.Subtasks = []domain.SubTask{}
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,13 +662,13 @@ func scanThought(rows interface {
|
|||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}) (*domain.Thought, error) {
|
}) (*domain.Thought, error) {
|
||||||
t := &domain.Thought{}
|
t := &domain.Thought{}
|
||||||
var tagsStr, statusStr string
|
var tagsStr, subtasksStr, statusStr string
|
||||||
var isCompleted, alarmSent int
|
var isCompleted, alarmSent int
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
||||||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||||||
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
||||||
&t.CreatedAt, &t.TriagedAt,
|
&t.CreatedAt, &t.TriagedAt, &subtasksStr,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -682,12 +685,16 @@ func scanThought(rows interface {
|
|||||||
}
|
}
|
||||||
t.Tags = []string{}
|
t.Tags = []string{}
|
||||||
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
|
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
|
||||||
|
t.Subtasks = []domain.SubTask{}
|
||||||
|
if subtasksStr != "" && subtasksStr != "null" {
|
||||||
|
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
|
||||||
|
}
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||||||
row := s.db.QueryRow(
|
row := s.db.QueryRow(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))`,
|
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))`,
|
||||||
thoughtID, userID, userID,
|
thoughtID, userID, userID,
|
||||||
)
|
)
|
||||||
@@ -703,14 +710,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
|
|||||||
|
|
||||||
if statusFilter != "" && statusFilter != "all" {
|
if statusFilter != "" && statusFilter != "all" {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||||
FROM thoughts WHERE user_id = ? AND status = ?
|
FROM thoughts WHERE user_id = ? AND status = ?
|
||||||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`,
|
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`,
|
||||||
userID, statusFilter, limit,
|
userID, statusFilter, limit,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||||
FROM thoughts WHERE user_id = ?
|
FROM thoughts WHERE user_id = ?
|
||||||
ORDER BY created_at DESC LIMIT ?`,
|
ORDER BY created_at DESC LIMIT ?`,
|
||||||
userID, limit,
|
userID, limit,
|
||||||
@@ -736,7 +743,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
|||||||
var err error
|
var err error
|
||||||
if taskListID != "" {
|
if taskListID != "" {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||||
FROM thoughts
|
FROM thoughts
|
||||||
WHERE (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))
|
WHERE (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))
|
||||||
AND task_list_id = ?
|
AND task_list_id = ?
|
||||||
@@ -746,7 +753,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||||
FROM thoughts
|
FROM thoughts
|
||||||
WHERE (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))
|
WHERE (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))
|
||||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||||
@@ -839,7 +846,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
return s.GetThoughtByID(userID, thoughtID)
|
return s.GetThoughtByID(userID, thoughtID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string) (*domain.Thought, error) {
|
func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string, subtasks *[]domain.SubTask) (*domain.Thought, error) {
|
||||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -877,16 +884,20 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
|||||||
if notes != nil {
|
if notes != nil {
|
||||||
thought.Notes = *notes
|
thought.Notes = *notes
|
||||||
}
|
}
|
||||||
|
if subtasks != nil {
|
||||||
|
thought.Subtasks = *subtasks
|
||||||
|
}
|
||||||
|
|
||||||
tagsJSON, _ := json.Marshal(thought.Tags)
|
tagsJSON, _ := json.Marshal(thought.Tags)
|
||||||
|
subtasksJSON, _ := json.Marshal(thought.Subtasks)
|
||||||
completedInt := 0
|
completedInt := 0
|
||||||
if thought.IsCompleted {
|
if thought.IsCompleted {
|
||||||
completedInt = 1
|
completedInt = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.db.Exec(
|
_, err = s.db.Exec(
|
||||||
`UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ? WHERE id = ? AND user_id = ?`,
|
`UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ? AND user_id = ?`,
|
||||||
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, thoughtID, userID,
|
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID, userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1574,7 +1585,7 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
|
|||||||
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
|
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
|
||||||
|
|
||||||
tRows, err := s.db.Query(
|
tRows, err := s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||||
FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`,
|
FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`,
|
||||||
userID, q,
|
userID, q,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user