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
+346 -16

No files matched your search

+243 -1
View File
@@ -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>
)} )}
+2
View File
@@ -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}`, {
+8
View File
@@ -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;
+2 -1
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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,
) )