Remove blocked column from kanban board
Test and publish / verify (push) Successful in 2m41s

This commit is contained in:
2026-08-02 12:52:08 +03:30
parent 0800487ddd
commit b0f486f026
6 changed files with 289 additions and 42 deletions
+34 -2
View File
@@ -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)
+34 -1
View File
@@ -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)
+6
View File
@@ -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")) {
+45 -29
View File
@@ -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) {
+140 -4
View File
@@ -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")
+30 -6
View File
@@ -75,7 +75,7 @@
</fieldset>
<div class="form-row">
<label>Importance<select name="importance"><option value="normal">Normal</option><option value="low">Low</option><option value="high">High</option><option value="urgent">Urgent</option></select></label>
<label>Column<select name="status"><option value="backlog">Backlog</option><option value="in_progress">In progress</option><option value="blocked">Blocked</option><option value="done">Done</option></select></label>
<label>Column<select name="status"><option value="backlog">Backlog</option><option value="todo">To do</option><option value="in_progress">In progress</option><option value="done">Done</option></select></label>
</div>
<label>Due date <small>Optional · Persian</small><span class="jalali-field"><input name="due_date" data-jalali-picker autocomplete="off" inputmode="numeric" placeholder="{{.TodayJalali}}" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"><button type="button" class="jalali-trigger" aria-label="Choose due date" title="Open Persian calendar"></button></span></label>
<button class="button dark full" type="submit">Create task</button>
@@ -96,8 +96,10 @@
{{range .Tasks}}
{{$task := .}}
<article class="kanban-card" draggable="true" data-task-id="{{.ID}}">
{{if .Tags}}<div class="card-tags">{{range .Tags}}<span class="board-tag {{.Color}}">{{.Name}}</span>{{end}}</div>{{end}}
<div class="card-topline"><span class="task-id">#{{.ID}}</span><span class="card-signals"><span class="importance-badge {{.Importance}}">{{if eq .Importance "urgent"}}Urgent{{else if eq .Importance "high"}}High{{else if eq .Importance "low"}}Low{{else}}Normal{{end}}</span>{{if .DueDate}}<span class="task-due">Due {{dateFA .DueDate}}</span>{{end}}</span></div>
<div class="card-topline">
{{if .Tags}}<div class="card-tags">{{range .Tags}}<span class="board-tag {{.Color}}">{{.Name}}</span>{{end}}</div>{{else}}<span></span>{{end}}
<span class="card-signals">{{if .Todos}}<span class="todo-progress" title="To-do list">☑ {{.TodoDone}}/{{len .Todos}}</span>{{end}}<span class="importance-emoji {{.Importance}}" title="{{if eq .Importance "urgent"}}Urgent priority{{else if eq .Importance "high"}}High priority{{else if eq .Importance "low"}}Low priority{{else}}Normal priority{{end}}">{{if eq .Importance "urgent"}}🚨{{else if eq .Importance "high"}}🔶{{else if eq .Importance "low"}}🟢{{else}}⚪{{end}}</span>{{if .DueDate}}<span class="task-due">{{dateFA .DueDate}}</span>{{end}}</span>
</div>
<h3>{{.Title}}</h3>
{{if .Description}}<p>{{.Description}}</p>{{end}}
<div class="task-meta">
@@ -105,8 +107,30 @@
<span class="task-creator">by {{.CreatorName}}</span>
</div>
<details class="card-controls">
<summary><span>Card controls</span><i></i></summary>
<summary aria-label="Open card controls" title="Card controls"><i aria-hidden="true"></i></summary>
<div class="card-controls-body">
<details class="card-todo-editor">
<summary>To-do list <span>{{.TodoDone}}/{{len .Todos}}</span></summary>
<div class="todo-list">
{{range .Todos}}
<div class="todo-item {{if .Completed}}completed{{end}}">
<form method="post" action="/board/tasks/{{$task.ID}}/todos/{{.ID}}/toggle" data-todo-toggle>
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label><input type="checkbox" name="completed" {{if .Completed}}checked{{end}}><span>{{.Body}}</span></label>
</form>
<form method="post" action="/board/tasks/{{$task.ID}}/todos/{{.ID}}/delete">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="text-button" type="submit" aria-label="Delete to-do item" title="Delete">×</button>
</form>
</div>
{{else}}<p class="todo-empty">No to-do items yet.</p>{{end}}
</div>
<form method="post" action="/board/tasks/{{.ID}}/todos" class="todo-add-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input name="body" required maxlength="240" placeholder="Add a to-do item">
<button class="button secondary" type="submit">Add</button>
</form>
</details>
<details class="card-detail-editor">
<summary>People &amp; importance</summary>
<form method="post" action="/board/tasks/{{.ID}}/details">
@@ -134,8 +158,8 @@
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<select name="status" aria-label="Move task">
<option value="backlog" {{if eq .Status "backlog"}}selected{{end}}>Backlog</option>
<option value="todo" {{if eq .Status "todo"}}selected{{end}}>To do</option>
<option value="in_progress" {{if eq .Status "in_progress"}}selected{{end}}>In progress</option>
<option value="blocked" {{if eq .Status "blocked"}}selected{{end}}>Blocked</option>
<option value="done" {{if eq .Status "done"}}selected{{end}}>Done</option>
</select>
<button class="text-button task-move-button" type="submit">Move</button>
@@ -173,7 +197,7 @@
<article class="archived-card">
<div>
{{if .Tags}}<div class="card-tags">{{range .Tags}}<span class="board-tag {{.Color}}">{{.Name}}</span>{{end}}</div>{{end}}
<span class="task-id">#{{.ID}} · {{.Status}}</span>
<span class="archived-status">{{.Status}}</span>
<h3>{{.Title}}</h3>
<small>Originally created by {{.CreatorName}}</small>
</div>