feat(tasks): customizable kanban columns, task detail page, hashtag filtering and clean task list
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s

This commit is contained in:
sandrodach committed 2026-10-25 13:13:11 +01:00
1 parent 0afa2f9c2d
commit b5df4a34b4
8 files changed
+1847 -689

No files matched your search

+9 -7
View File
@@ -690,13 +690,14 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
KanbanStatus *string `json:"kanbanStatus"`
Notes *string `json:"notes"`
Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks)
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
@@ -1590,17 +1591,18 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
var req struct {
Title *string `json:"title"`
Icon *string `json:"icon"`
Color *string `json:"color"`
TeamID *string `json:"teamId"`
SpaceID *string `json:"spaceId"`
Title *string `json:"title"`
Icon *string `json:"icon"`
Color *string `json:"color"`
TeamID *string `json:"teamId"`
SpaceID *string `json:"spaceId"`
Columns *[]domain.KanbanColumn `json:"columns"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID)
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
+8
View File
@@ -175,6 +175,13 @@ type TaskListMember struct {
Role string `json:"role"` // owner | member
}
type KanbanColumn struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color,omitempty"`
IsDone bool `json:"isDone,omitempty"`
}
type TaskList struct {
ID string `json:"id"`
UserID string `json:"userId"`
@@ -186,6 +193,7 @@ type TaskList struct {
IsOwner bool `json:"isOwner"`
Members []TaskListMember `json:"members,omitempty"`
Shares []TaskListShare `json:"shares,omitempty"`
Columns []KanbanColumn `json:"columns,omitempty"`
Title string `json:"title"`
Icon string `json:"icon"`
Color string `json:"color"`
+65 -17
View File
@@ -129,6 +129,7 @@ func (s *Store) migrate() error {
title TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '📋',
color TEXT NOT NULL DEFAULT '#2563eb',
columns TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -324,6 +325,7 @@ func (s *Store) migrate() error {
"CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);",
"ALTER TABLE task_lists ADD COLUMN team_id TEXT NOT NULL DEFAULT '';",
"ALTER TABLE task_lists ADD COLUMN columns TEXT NOT NULL DEFAULT '[]';",
"CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));",
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);",
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_list ON task_list_shares(task_list_id);",
@@ -1028,7 +1030,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
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, subtasks *[]domain.SubTask) (*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, tags *[]string) (*domain.Thought, error) {
thought, err := s.GetThoughtByID(userID, thoughtID)
if err != nil {
return nil, err
@@ -1038,6 +1040,9 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
thought.Content = strings.TrimSpace(*content)
thought.Tags = domain.ExtractTags(thought.Content)
}
if tags != nil {
thought.Tags = *tags
}
if isCompleted != nil {
thought.IsCompleted = *isCompleted
if *isCompleted {
@@ -1049,18 +1054,24 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
if dueDate != nil {
thought.DueDate = *dueDate
}
if priority != nil && *priority != "" {
thought.Priority = *priority
if priority != nil {
p := strings.ToLower(strings.TrimSpace(*priority))
if p == "" || p == "none" {
p = "normal"
}
thought.Priority = p
}
if taskListID != nil {
thought.TaskListID = *taskListID
}
if kanbanStatus != nil && *kanbanStatus != "" {
thought.KanbanStatus = *kanbanStatus
if *kanbanStatus == "done" {
thought.IsCompleted = true
} else {
thought.IsCompleted = false
if isCompleted == nil {
if *kanbanStatus == "done" {
thought.IsCompleted = true
} else {
thought.IsCompleted = false
}
}
}
if notes != nil {
@@ -2551,7 +2562,7 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
// --- TASK LISTS ---
func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string) (*domain.TaskList, error) {
func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string, columns ...[]domain.KanbanColumn) (*domain.TaskList, error) {
if strings.TrimSpace(title) == "" {
title = "Meine Aufgaben"
}
@@ -2561,6 +2572,16 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
if color == "" {
color = "#2563eb"
}
cols := []domain.KanbanColumn{
{ID: "todo", Name: "Zu erledigen"},
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
}
if len(columns) > 0 && len(columns[0]) > 0 {
cols = columns[0]
}
colsJSON, _ := json.Marshal(cols)
now := domain.NowISO()
tl := &domain.TaskList{
ID: domain.NewID("tkl"),
@@ -2570,13 +2591,14 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
Title: strings.TrimSpace(title),
Icon: icon,
Color: color,
Columns: cols,
CreatedAt: now,
UpdatedAt: now,
IsOwner: true,
}
_, err := s.db.Exec(
`INSERT INTO task_lists (id, user_id, space_id, team_id, title, icon, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tl.ID, tl.UserID, tl.SpaceID, tl.TeamID, tl.Title, tl.Icon, tl.Color, tl.CreatedAt, tl.UpdatedAt,
`INSERT INTO task_lists (id, user_id, space_id, team_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tl.ID, tl.UserID, tl.SpaceID, tl.TeamID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt,
)
if err != nil {
return nil, err
@@ -2729,7 +2751,7 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb
func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
rows, err := s.db.Query(
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, created_at, updated_at
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
FROM task_lists
WHERE user_id = ?
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
@@ -2746,11 +2768,23 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
return []domain.TaskList{}, err
}
defaultCols := []domain.KanbanColumn{
{ID: "todo", Name: "Zu erledigen"},
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
}
list := make([]domain.TaskList, 0)
for rows.Next() {
var tl domain.TaskList
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
var colsJSON string
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
tl.IsOwner = (tl.UserID == userID)
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
if len(tl.Columns) == 0 {
tl.Columns = defaultCols
}
list = append(list, tl)
}
}
@@ -2779,8 +2813,9 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) {
var tl domain.TaskList
var colsJSON string
err := s.db.QueryRow(
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, created_at, updated_at
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
FROM task_lists
WHERE id = ? AND (
user_id = ?
@@ -2793,11 +2828,20 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
)`,
listID, userID, userID, userID, userID, userID,
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt)
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
if err != nil {
return nil, err
}
tl.IsOwner = (tl.UserID == userID)
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
if len(tl.Columns) == 0 {
tl.Columns = []domain.KanbanColumn{
{ID: "todo", Name: "Zu erledigen"},
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
}
}
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount)
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName)
if tl.TeamID != "" {
@@ -2809,7 +2853,7 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
return &tl, nil
}
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID, spaceID *string) (*domain.TaskList, error) {
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) {
tl, err := s.GetTaskListByID(userID, listID)
if err != nil {
return nil, err
@@ -2826,6 +2870,9 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
if spaceID != nil {
tl.SpaceID = *spaceID
}
if columns != nil {
tl.Columns = *columns
}
if teamID != nil {
oldTeamID := tl.TeamID
tl.TeamID = *teamID
@@ -2837,9 +2884,10 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
}
}
tl.UpdatedAt = domain.NowISO()
colsJSON, _ := json.Marshal(tl.Columns)
_, err = s.db.Exec(
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, team_id = ?, updated_at = ? WHERE id = ?`,
tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.TeamID, tl.UpdatedAt, listID,
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, team_id = ?, columns = ?, updated_at = ? WHERE id = ?`,
tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.TeamID, string(colsJSON), tl.UpdatedAt, listID,
)
if err != nil {
return nil, err