diff --git a/internal/app/server.go b/internal/app/server.go index e30625e..91d707d 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -293,6 +293,9 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /board/tasks/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTask))) mux.HandleFunc("POST /board/tasks/{id}/tags", s.requireAuth(s.csrf(s.setBoardTaskTags))) mux.HandleFunc("POST /board/tasks/{id}/details", s.requireAuth(s.csrf(s.setBoardTaskDetails))) + mux.HandleFunc("POST /board/tasks/{id}/todos", s.requireAuth(s.csrf(s.createBoardTaskTodo))) + mux.HandleFunc("POST /board/tasks/{id}/todos/{todoID}/toggle", s.requireAuth(s.csrf(s.toggleBoardTaskTodo))) + mux.HandleFunc("POST /board/tasks/{id}/todos/{todoID}/delete", s.requireAuth(s.csrf(s.deleteBoardTaskTodo))) mux.HandleFunc("POST /board/tags", s.requireAuth(s.csrf(s.createBoardTag))) mux.HandleFunc("POST /board/tags/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTag))) mux.HandleFunc("GET /admin/requests", s.requireAdmin(s.adminPage)) @@ -807,9 +810,9 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) { tasks = filtered } columns := []BoardColumn{ - {Status: "backlog", Title: "Backlog", Hint: "Ready to pick up"}, + {Status: "backlog", Title: "Backlog", Hint: "Untriaged work"}, + {Status: "todo", Title: "To do", Hint: "Ready to start"}, {Status: "in_progress", Title: "In progress", Hint: "Actively being worked"}, - {Status: "blocked", Title: "Blocked", Hint: "Needs help"}, {Status: "done", Title: "Done", Hint: "Completed work"}, } for _, task := range tasks { @@ -970,6 +973,35 @@ func (s *Server) setBoardTaskDetails(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/board?flash=Card+details+updated", http.StatusSeeOther) } +func (s *Server) createBoardTaskTodo(w http.ResponseWriter, r *http.Request) { + taskID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err := s.store.CreateBoardTaskTodo(taskID, r.FormValue("body")); err != nil { + http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/board?flash=To-do+item+added", http.StatusSeeOther) +} + +func (s *Server) toggleBoardTaskTodo(w http.ResponseWriter, r *http.Request) { + taskID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + todoID, _ := strconv.ParseInt(r.PathValue("todoID"), 10, 64) + if err := s.store.SetBoardTaskTodoCompleted(taskID, todoID, r.FormValue("completed") == "on"); err != nil { + http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/board?flash=To-do+item+updated", http.StatusSeeOther) +} + +func (s *Server) deleteBoardTaskTodo(w http.ResponseWriter, r *http.Request) { + taskID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + todoID, _ := strconv.ParseInt(r.PathValue("todoID"), 10, 64) + if err := s.store.DeleteBoardTaskTodo(taskID, todoID); err != nil { + http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/board?flash=To-do+item+removed", http.StatusSeeOther) +} + func (s *Server) createBoardTag(w http.ResponseWriter, r *http.Request) { if err := s.store.CreateBoardTag(r.FormValue("name"), r.FormValue("color")); err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 41c7623..187469e 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -716,7 +716,7 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { if boardResponse.Code != http.StatusOK { t.Fatalf("board page status: %d body %s", boardResponse.Code, boardResponse.Body.String()) } - for _, expected := range []string{"Ship the onboarding flow", "Board Member", "Workspace Admin", "1405-05-06", "High", "Filters", "Manage tags", "Tags", "select multiple", "Create a new tag", "People & importance", "Card controls", "Backend", "Frontend", "Urgent", "/static/board.js", `data-board-column="backlog"`} { + for _, expected := range []string{"Ship the onboarding flow", "Board Member", "Workspace Admin", "1405-05-06", "High priority", "Backlog", "To do", "Filters", "Manage tags", "Tags", "select multiple", "Create a new tag", "People & importance", "To-do list", "Backend", "Frontend", "Urgent", "/static/board.js", `data-board-column="backlog"`} { if !strings.Contains(boardResponse.Body.String(), expected) { t.Fatalf("board page does not include %q", expected) } @@ -756,6 +756,39 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { if len(tasks[0].Assignees) != 1 || tasks[0].Assignees[0].ID != memberID || tasks[0].Importance != "urgent" { t.Fatalf("task people or importance were not updated: %#v", tasks[0]) } + todoPath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/todos" + createTodo := formRequest(t, s.http.Handler, todoPath, url.Values{ + "csrf": {memberCSRF}, + "body": {"Confirm the onboarding handoff"}, + }, memberCookie) + if createTodo.Code != http.StatusSeeOther || createTodo.Header().Get("Location") != "/board?flash=To-do+item+added" { + t.Fatalf("create to-do: got %d location %q", createTodo.Code, createTodo.Header().Get("Location")) + } + tasks, _ = s.store.BoardTasks() + if len(tasks[0].Todos) != 1 || tasks[0].Todos[0].Body != "Confirm the onboarding handoff" || tasks[0].TodoDone != 0 { + t.Fatalf("created to-do was not loaded: %#v", tasks[0]) + } + toggleTodoPath := todoPath + "/" + strconv.FormatInt(tasks[0].Todos[0].ID, 10) + "/toggle" + toggleTodo := formRequest(t, s.http.Handler, toggleTodoPath, url.Values{ + "csrf": {memberCSRF}, + "completed": {"on"}, + }, memberCookie) + if toggleTodo.Code != http.StatusSeeOther || toggleTodo.Header().Get("Location") != "/board?flash=To-do+item+updated" { + t.Fatalf("toggle to-do: got %d location %q", toggleTodo.Code, toggleTodo.Header().Get("Location")) + } + tasks, _ = s.store.BoardTasks() + if !tasks[0].Todos[0].Completed || tasks[0].TodoDone != 1 { + t.Fatalf("to-do completion was not saved: %#v", tasks[0].Todos) + } + deleteTodoPath := todoPath + "/" + strconv.FormatInt(tasks[0].Todos[0].ID, 10) + "/delete" + deleteTodo := formRequest(t, s.http.Handler, deleteTodoPath, url.Values{"csrf": {memberCSRF}}, memberCookie) + if deleteTodo.Code != http.StatusSeeOther || deleteTodo.Header().Get("Location") != "/board?flash=To-do+item+removed" { + t.Fatalf("delete to-do: got %d location %q", deleteTodo.Code, deleteTodo.Header().Get("Location")) + } + tasks, _ = s.store.BoardTasks() + if len(tasks[0].Todos) != 0 { + t.Fatalf("to-do was not deleted: %#v", tasks[0].Todos) + } matchingFilterPath := "/board?tag=" + strconv.FormatInt(tags[0].ID, 10) + "&assignee=" + strconv.FormatInt(memberID, 10) + "&priority=urgent" matchingFilterRequest := httptest.NewRequest(http.MethodGet, matchingFilterPath, nil) diff --git a/internal/app/static/board.js b/internal/app/static/board.js index 694c571..d31041e 100644 --- a/internal/app/static/board.js +++ b/internal/app/static/board.js @@ -24,6 +24,12 @@ var dragged = null; var csrf = board.dataset.csrf; + board.querySelectorAll("[data-todo-toggle] input[type=checkbox]").forEach(function (checkbox) { + checkbox.addEventListener("change", function () { + checkbox.closest("form").submit(); + }); + }); + board.querySelectorAll("[data-task-id]").forEach(function (card) { card.addEventListener("dragstart", function (event) { if (event.target.closest("button, input, select, textarea, label, summary, a")) { diff --git a/internal/app/static/style.css b/internal/app/static/style.css index 62efbb9..9ca260f 100644 --- a/internal/app/static/style.css +++ b/internal/app/static/style.css @@ -329,8 +329,8 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .board-filter-popover label > span { display: block; color: var(--muted); font-size: .57rem; } .board-filter-popover select { min-height: 35px; margin-top: 5px; padding: 7px 29px 7px 9px; border: 0; background-color: var(--paper); font-size: .62rem; } .board-filter-popover .button { min-height: 37px; font-size: .62rem; } -.kanban-board { display: grid; grid-template-columns: repeat(4, minmax(270px, 1fr)); gap: 14px; align-items: start; max-width: 1380px; margin: 0 auto; overflow-x: auto; padding-bottom: 18px; } -.kanban-column { min-width: 270px; padding: 13px; border: 1px solid var(--line); border-radius: 15px; background: #eef2ef; } +.kanban-board { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; align-items: start; max-width: 1500px; margin: 0 auto; padding-bottom: 18px; } +.kanban-column { min-width: 0; padding: 13px; border: 1px solid var(--line); border-radius: 15px; background: #eef2ef; } .kanban-column-head { display: flex; justify-content: space-between; align-items: start; min-height: 51px; padding: 4px 5px 12px; } .kanban-column-head > div { display: grid; grid-template-columns: 9px 1fr; column-gap: 8px; align-items: center; } .column-dot { width: 8px; height: 8px; border-radius: 50%; background: #87938d; } @@ -342,22 +342,21 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .kanban-column-head > b { display: grid; min-width: 23px; height: 23px; place-items: center; border-radius: 20px; background: var(--white); color: var(--muted); font-size: .62rem; } .kanban-cards { display: grid; gap: 9px; min-height: 100px; border-radius: 11px; transition: background .16s ease, box-shadow .16s ease; } .kanban-cards.drag-over { background: rgba(70, 126, 100, .1); box-shadow: inset 0 0 0 2px #6c9c85; } -.kanban-card { padding: 14px; border: 1px solid #dfe4e1; border-radius: 11px; background: var(--white); box-shadow: 0 3px 10px rgba(24, 36, 31, .045); cursor: grab; transition: opacity .15s ease, transform .15s ease, box-shadow .15s ease; } +.kanban-card { position: relative; padding: 13px 14px; border: 1px solid #dfe4e1; border-radius: 11px; background: var(--white); box-shadow: 0 3px 10px rgba(24, 36, 31, .045); cursor: grab; transition: opacity .15s ease, transform .15s ease, box-shadow .15s ease; } .kanban-card:hover { transform: translateY(-1px); box-shadow: 0 7px 18px rgba(24, 36, 31, .09); } .kanban-card.dragging { opacity: .45; transform: rotate(1.5deg); } .kanban-card.moving { opacity: .35; pointer-events: none; } -.card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 9px; } -.card-topline { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin-bottom: 9px; } +.card-tags { display: flex; flex-wrap: wrap; gap: 4px; } +.card-topline { display: flex; justify-content: space-between; gap: 8px; align-items: start; min-height: 21px; margin-bottom: 7px; } .card-signals { display: flex; flex-wrap: wrap; gap: 5px; justify-content: flex-end; } -.task-id { color: #9aa39e; font-size: .58rem; font-weight: 700; } -.task-due { padding: 4px 6px; border-radius: 6px; background: var(--sand); color: #786039; font-size: .55rem; font-weight: 700; } -.importance-badge { padding: 4px 6px; border-radius: 6px; background: var(--paper); color: var(--muted); font-size: .53rem; font-weight: 800; text-transform: uppercase; } -.importance-badge.low { background: #edf0ee; color: #68736e; } -.importance-badge.high { background: #f6eddc; color: #87662c; } -.importance-badge.urgent { background: #f5e6e4; color: #9d4a44; } -.kanban-card h3 { margin-bottom: 7px; font-size: .84rem; line-height: 1.35; } -.kanban-card > p { display: -webkit-box; overflow: hidden; margin-bottom: 13px; color: #64706a; font-size: .68rem; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 4; } -.task-meta { display: flex; justify-content: space-between; gap: 8px; align-items: center; padding-top: 10px; border-top: 1px solid var(--line); } +.task-due { padding: 3px 5px; border-radius: 6px; background: var(--sand); color: #786039; font-size: .53rem; font-weight: 700; } +.todo-progress { padding: 3px 5px; border-radius: 6px; background: var(--mint); color: var(--green-2); font-size: .53rem; font-weight: 800; } +.importance-emoji { display: grid; width: 21px; height: 21px; place-items: center; border-radius: 6px; background: var(--paper); font-size: .66rem; line-height: 1; } +.importance-emoji.high { background: #f6eddc; } +.importance-emoji.urgent { background: #f5e6e4; } +.kanban-card h3 { margin: 0 0 6px; font-size: .84rem; line-height: 1.3; } +.kanban-card > p { display: -webkit-box; overflow: hidden; margin: 0 0 10px; color: #64706a; font-size: .68rem; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 3; } +.task-meta { display: flex; justify-content: space-between; gap: 8px; align-items: center; padding-top: 9px; padding-right: 27px; border-top: 1px solid var(--line); } .task-assignees { display: flex; min-width: 0; flex-wrap: wrap; gap: 7px; } .task-assignee { display: flex; min-width: 0; gap: 6px; align-items: center; color: #53605a; font-size: .61rem; font-weight: 700; } .task-assignee i { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; border-radius: 50%; background: var(--blue); color: #4d6e94; font-size: .59rem; font-style: normal; } @@ -365,34 +364,49 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .task-assignee span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .task-assignee.unassigned { color: var(--muted); font-weight: 500; } .task-creator { overflow: hidden; color: #9aa39e; font-size: .54rem; text-overflow: ellipsis; white-space: nowrap; } -.card-controls { margin: 10px -3px -5px; } -.card-controls > summary { display: flex; justify-content: space-between; align-items: center; padding: 5px 3px; color: var(--muted); font-size: .57rem; font-weight: 700; cursor: pointer; list-style: none; } +.card-controls { position: static; } +.card-controls > summary { position: absolute; right: 10px; bottom: 10px; display: grid; width: 25px; height: 25px; place-items: center; border-radius: 8px; color: var(--muted); font-size: .95rem; font-weight: 800; line-height: 1; cursor: pointer; list-style: none; } +.card-controls > summary:hover, .card-controls[open] > summary { background: var(--paper); color: var(--ink); } .card-controls > summary::-webkit-details-marker { display: none; } -.card-controls > summary i { font-size: .8rem; font-style: normal; transition: transform .15s ease; } +.card-controls > summary i { display: block; font-size: 1.05rem; font-style: normal; line-height: 1; transition: transform .15s ease; } .card-controls[open] > summary i { transform: rotate(180deg); } -.card-controls-body { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding-top: 8px; border-top: 1px dashed var(--line); } -.card-tag-editor, .card-detail-editor { position: relative; } -.card-tag-editor > summary, .card-detail-editor > summary { width: max-content; color: var(--muted); font-size: .56rem; font-weight: 700; cursor: pointer; list-style: none; text-decoration: underline; } -.card-tag-editor > summary::-webkit-details-marker, .card-detail-editor > summary::-webkit-details-marker { display: none; } +.card-controls-body { display: flex; width: 100%; flex-wrap: wrap; gap: 10px; align-items: flex-start; align-content: flex-start; margin-top: 9px; padding: 11px 0 1px; border-top: 1px solid var(--line); } +.card-tag-editor, .card-detail-editor, .card-todo-editor { position: relative; } +.card-tag-editor > summary, .card-detail-editor > summary, .card-todo-editor > summary { width: max-content; color: var(--muted); font-size: .56rem; font-weight: 700; cursor: pointer; list-style: none; text-decoration: underline; } +.card-tag-editor > summary::-webkit-details-marker, .card-detail-editor > summary::-webkit-details-marker, .card-todo-editor > summary::-webkit-details-marker { display: none; } +.card-todo-editor > summary span { color: var(--green); } .card-tag-editor form, .card-detail-editor form { position: absolute; z-index: 8; top: calc(100% + 5px); left: -7px; width: 250px; padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--white); box-shadow: 0 12px 28px rgba(24, 36, 31, .2); } .card-tag-editor form .button, .card-detail-editor form .button { min-height: 31px; margin-top: 10px; font-size: .62rem; } .card-detail-editor form { display: grid; gap: 10px; width: 270px; } .compact-assignee-picker > div { max-height: 120px; overflow-y: auto; } .card-detail-editor select { margin-top: 5px; padding-top: 7px; padding-bottom: 7px; } -.task-actions { display: flex; width: 100%; justify-content: space-between; align-items: center; } +.card-todo-editor[open] { width: 100%; } +.todo-list { display: grid; gap: 5px; max-height: 154px; margin: 8px 0; overflow-y: auto; } +.todo-item { display: flex; gap: 6px; align-items: center; padding: 5px 6px; border-radius: 7px; background: var(--paper); } +.todo-item > form:first-child { min-width: 0; flex: 1; } +.todo-item label { display: flex; min-width: 0; gap: 6px; align-items: center; color: var(--ink); font-size: .6rem; cursor: pointer; } +.todo-item input { width: 14px; height: 14px; margin: 0; accent-color: var(--green); } +.todo-item label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.todo-item.completed label span { color: var(--muted); text-decoration: line-through; } +.todo-item .text-button { padding: 0 2px; color: var(--muted); font-size: .9rem; line-height: 1; } +.todo-empty { margin: 8px 0; color: var(--muted); font-size: .58rem; } +.todo-add-form { display: flex; gap: 6px; } +.todo-add-form input { min-width: 0; margin: 0; padding: 7px 8px; font-size: .6rem; } +.todo-add-form .button { min-height: 31px; padding: 5px 9px; font-size: .58rem; } +.task-actions { display: flex; width: 100%; box-sizing: border-box; justify-content: space-between; align-items: center; padding-right: 31px; } .task-actions > form:first-child { display: flex; gap: 4px; align-items: center; } .task-actions select { width: auto; min-width: 104px; margin: 0; padding: 5px 25px 5px 7px; border: 0; background-color: var(--paper); font-size: .58rem; font-weight: 700; } .task-actions .text-button { margin: 0; } .task-move-button { display: none; } .task-actions form:focus-within .task-move-button { display: inline-block; } -.task-lifecycle-actions { display: flex; gap: 9px; align-items: center; } +.task-lifecycle-actions { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: flex-end; margin-left: auto; } .task-lifecycle-actions > form { display: flex; } .archive-task { color: var(--green); font-weight: 700; } .task-remove { position: relative; color: var(--muted); font-size: .58rem; } .task-remove > summary { cursor: pointer; list-style: none; text-decoration: underline; } .task-remove > summary::-webkit-details-marker { display: none; } -.task-remove[open] > summary { visibility: hidden; } -.task-remove form { position: absolute; right: 0; bottom: -1px; width: max-content; } +.task-remove[open] > summary { display: none; } +.task-remove form { position: static; display: flex; width: max-content; } .task-remove form .text-button { color: var(--red); font-weight: 700; } .kanban-empty { display: grid; min-height: 74px; place-items: center; border: 1px dashed #b8c2bd; border-radius: 10px; color: var(--muted); font-size: .62rem; } .archived-cards-panel { max-width: 1380px; margin: 10px auto 0; border: 1px solid var(--line); border-radius: 13px; background: var(--white); } @@ -405,6 +419,7 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .archived-card { display: flex; justify-content: space-between; gap: 20px; align-items: center; padding: 14px 17px; border-bottom: 1px solid var(--line); } .archived-card:last-child { border-bottom: 0; } .archived-card h3 { margin: 4px 0 2px; font-size: .76rem; } +.archived-status { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: capitalize; } .archived-card small { color: var(--muted); font-size: .58rem; } .archived-card .card-tags { margin-bottom: 6px; } .archived-card-actions { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; } @@ -412,8 +427,8 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .archived-delete { position: relative; } .archived-delete > summary { color: var(--red); font-size: .6rem; font-weight: 700; cursor: pointer; list-style: none; } .archived-delete > summary::-webkit-details-marker { display: none; } -.archived-delete[open] > summary { visibility: hidden; } -.archived-delete form { position: absolute; right: 0; bottom: 0; width: max-content; } +.archived-delete[open] > summary { display: none; } +.archived-delete form { position: static; display: flex; width: max-content; } .archived-delete form .text-button { color: var(--red); font-weight: 700; } .updates-layout { grid-template-columns: minmax(390px, .8fr) minmax(460px, 1.2fr); } @@ -636,8 +651,9 @@ html[data-theme="dark"] .kanban-card { border-color: #35443d; box-shadow: 0 4px html[data-theme="dark"] .kanban-card > p { color: #aab6b0; } html[data-theme="dark"] .task-assignee { color: #bdc8c2; } html[data-theme="dark"] .task-due { background: #3b3222; color: #dec18b; } -html[data-theme="dark"] .importance-badge.high { background: #453820; color: #e3bd72; } -html[data-theme="dark"] .importance-badge.urgent { background: #432725; color: #efa09a; } +html[data-theme="dark"] .todo-progress { background: #1d3b2c; color: #8fd0ae; } +html[data-theme="dark"] .importance-emoji.high { background: #453820; } +html[data-theme="dark"] .importance-emoji.urgent { background: #432725; } html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142, .1); } @media (max-width: 980px) { diff --git a/internal/app/store.go b/internal/app/store.go index d3639a3..27109b3 100644 --- a/internal/app/store.go +++ b/internal/app/store.go @@ -105,6 +105,8 @@ type BoardTask struct { ArchivedAt string Assignees []User Tags []BoardTag + Todos []BoardTodo + TodoDone int } type BoardTag struct { @@ -113,6 +115,13 @@ type BoardTag struct { Color string } +type BoardTodo struct { + ID int64 + TaskID int64 + Body string + Completed bool +} + type Store struct{ db *sql.DB } func OpenStore(path string) (*Store, error) { @@ -230,7 +239,7 @@ CREATE TABLE IF NOT EXISTS board_tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','in_progress','blocked','done')), + status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','todo','in_progress','blocked','done')), importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent')), assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL, creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -245,6 +254,14 @@ CREATE TABLE IF NOT EXISTS board_task_assignees ( PRIMARY KEY(task_id,user_id) ); CREATE INDEX IF NOT EXISTS idx_board_task_assignees_user ON board_task_assignees(user_id,task_id); +CREATE TABLE IF NOT EXISTS board_task_todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL REFERENCES board_tasks(id) ON DELETE CASCADE, + body TEXT NOT NULL, + completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0,1)), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_board_task_todos_task ON board_task_todos(task_id,completed,created_at); CREATE TABLE IF NOT EXISTS board_tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE COLLATE NOCASE, @@ -270,6 +287,9 @@ CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,tas if err := s.ensureColumn("board_tasks", "importance", "importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent'))"); err != nil { return err } + if err := s.ensureBoardTaskTodoStatus(); err != nil { + return err + } if _, err := s.db.Exec(`INSERT OR IGNORE INTO board_task_assignees(task_id,user_id) SELECT id,assignee_id FROM board_tasks WHERE assignee_id IS NOT NULL`); err != nil { return err @@ -297,6 +317,57 @@ CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,tas return nil } +func (s *Store) ensureBoardTaskTodoStatus() error { + var tableSQL string + err := s.db.QueryRow(`SELECT COALESCE(sql,'') FROM sqlite_master WHERE type='table' AND name='board_tasks'`).Scan(&tableSQL) + if err != nil { + return err + } + if strings.Contains(strings.ToLower(tableSQL), "'todo'") { + return nil + } + if _, err := s.db.Exec(`PRAGMA foreign_keys=OFF`); err != nil { + return err + } + restoreForeignKeys := func() { _, _ = s.db.Exec(`PRAGMA foreign_keys=ON`) } + if _, err := s.db.Exec(`CREATE TABLE board_tasks_migrated ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','todo','in_progress','blocked','done')), + importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent')), + assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + due_date TEXT, + archived_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); err != nil { + restoreForeignKeys() + return err + } + if _, err := s.db.Exec(`INSERT INTO board_tasks_migrated(id,title,description,status,importance,assignee_id,creator_id,due_date,archived_at,created_at) + SELECT id,title,description,status,importance,assignee_id,creator_id,due_date,archived_at,created_at FROM board_tasks`); err != nil { + _, _ = s.db.Exec(`DROP TABLE board_tasks_migrated`) + restoreForeignKeys() + return err + } + if _, err := s.db.Exec(`DROP TABLE board_tasks`); err != nil { + _, _ = s.db.Exec(`DROP TABLE board_tasks_migrated`) + restoreForeignKeys() + return err + } + if _, err := s.db.Exec(`ALTER TABLE board_tasks_migrated RENAME TO board_tasks`); err != nil { + restoreForeignKeys() + return err + } + if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at)`); err != nil { + restoreForeignKeys() + return err + } + restoreForeignKeys() + return nil +} + func hashPassword(password string) (string, error) { salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { @@ -927,7 +998,7 @@ func (s *Store) ReportSummary(start, end string) ([]PersonReportSummary, error) } func validBoardStatus(status string) bool { - return status == "backlog" || status == "in_progress" || status == "blocked" || status == "done" + return status == "backlog" || status == "todo" || status == "in_progress" || status == "blocked" || status == "done" } func validBoardImportance(importance string) bool { @@ -994,7 +1065,7 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) { } else { query += ` WHERE t.archived_at IS NULL ORDER BY CASE t.status - WHEN 'backlog' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END, + WHEN 'backlog' THEN 0 WHEN 'todo' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'blocked' THEN 3 ELSE 4 END, t.created_at DESC` } rows, err := s.db.Query(query) @@ -1069,7 +1140,28 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) { tasks[index].Tags = append(tasks[index].Tags, tag) } } - return tasks, tagRows.Err() + if err := tagRows.Err(); err != nil { + return nil, err + } + todoRows, err := s.db.Query(`SELECT id,task_id,body,completed FROM board_task_todos + ORDER BY completed,created_at,id`) + if err != nil { + return nil, err + } + defer todoRows.Close() + for todoRows.Next() { + var todo BoardTodo + if err := todoRows.Scan(&todo.ID, &todo.TaskID, &todo.Body, &todo.Completed); err != nil { + return nil, err + } + if index, ok := taskIndex[todo.TaskID]; ok { + tasks[index].Todos = append(tasks[index].Todos, todo) + if todo.Completed { + tasks[index].TodoDone++ + } + } + } + return tasks, todoRows.Err() } type sqlExecutor interface { @@ -1117,6 +1209,50 @@ func (s *Store) SetBoardTaskDetails(taskID int64, assigneeIDs []int64, importanc return tx.Commit() } +func (s *Store) CreateBoardTaskTodo(taskID int64, body string) error { + body = strings.TrimSpace(body) + if body == "" || len([]rune(body)) > 240 { + return errors.New("to-do item must be between 1 and 240 characters") + } + result, err := s.db.Exec(`INSERT INTO board_task_todos(task_id,body) SELECT id,? FROM board_tasks WHERE id=?`, body, taskID) + if err != nil { + return err + } + count, _ := result.RowsAffected() + if count == 0 { + return errors.New("task was not found") + } + return nil +} + +func (s *Store) SetBoardTaskTodoCompleted(taskID, todoID int64, completed bool) error { + value := 0 + if completed { + value = 1 + } + result, err := s.db.Exec(`UPDATE board_task_todos SET completed=? WHERE id=? AND task_id=?`, value, todoID, taskID) + if err != nil { + return err + } + count, _ := result.RowsAffected() + if count == 0 { + return errors.New("to-do item was not found") + } + return nil +} + +func (s *Store) DeleteBoardTaskTodo(taskID, todoID int64) error { + result, err := s.db.Exec(`DELETE FROM board_task_todos WHERE id=? AND task_id=?`, todoID, taskID) + if err != nil { + return err + } + count, _ := result.RowsAffected() + if count == 0 { + return errors.New("to-do item was not found") + } + return nil +} + func (s *Store) MoveBoardTask(id int64, status string) error { if !validBoardStatus(status) { return errors.New("invalid board column") diff --git a/internal/app/templates/board.html b/internal/app/templates/board.html index 7c65d0a..74e62e3 100644 --- a/internal/app/templates/board.html +++ b/internal/app/templates/board.html @@ -75,7 +75,7 @@
- +
@@ -96,8 +96,10 @@ {{range .Tasks}} {{$task := .}}
- {{if .Tags}}
{{range .Tags}}{{.Name}}{{end}}
{{end}} -
#{{.ID}}{{if eq .Importance "urgent"}}Urgent{{else if eq .Importance "high"}}High{{else if eq .Importance "low"}}Low{{else}}Normal{{end}}{{if .DueDate}}Due {{dateFA .DueDate}}{{end}}
+
+ {{if .Tags}}
{{range .Tags}}{{.Name}}{{end}}
{{else}}{{end}} + {{if .Todos}}☑ {{.TodoDone}}/{{len .Todos}}{{end}}{{if eq .Importance "urgent"}}🚨{{else if eq .Importance "high"}}🔶{{else if eq .Importance "low"}}🟢{{else}}⚪{{end}}{{if .DueDate}}{{dateFA .DueDate}}{{end}} +

{{.Title}}

{{if .Description}}

{{.Description}}

{{end}}
@@ -105,8 +107,30 @@ by {{.CreatorName}}
- Card controls +
+
+ To-do list {{.TodoDone}}/{{len .Todos}} +
+ {{range .Todos}} +
+
+ + +
+
+ + +
+
+ {{else}}

No to-do items yet.

{{end}} +
+
+ + + +
+
People & importance
@@ -134,8 +158,8 @@ @@ -173,7 +197,7 @@
{{if .Tags}}
{{range .Tags}}{{.Name}}{{end}}
{{end}} - #{{.ID}} · {{.Status}} + {{.Status}}

{{.Title}}

Originally created by {{.CreatorName}}