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}/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}/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}/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", s.requireAuth(s.csrf(s.createBoardTag)))
mux.HandleFunc("POST /board/tags/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTag))) mux.HandleFunc("POST /board/tags/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTag)))
mux.HandleFunc("GET /admin/requests", s.requireAdmin(s.adminPage)) 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 tasks = filtered
} }
columns := []BoardColumn{ 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: "in_progress", Title: "In progress", Hint: "Actively being worked"},
{Status: "blocked", Title: "Blocked", Hint: "Needs help"},
{Status: "done", Title: "Done", Hint: "Completed work"}, {Status: "done", Title: "Done", Hint: "Completed work"},
} }
for _, task := range tasks { 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) 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) { func (s *Server) createBoardTag(w http.ResponseWriter, r *http.Request) {
if err := s.store.CreateBoardTag(r.FormValue("name"), r.FormValue("color")); err != nil { 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) 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 { if boardResponse.Code != http.StatusOK {
t.Fatalf("board page status: %d body %s", boardResponse.Code, boardResponse.Body.String()) 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) { if !strings.Contains(boardResponse.Body.String(), expected) {
t.Fatalf("board page does not include %q", 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" { 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]) 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) + matchingFilterPath := "/board?tag=" + strconv.FormatInt(tags[0].ID, 10) +
"&assignee=" + strconv.FormatInt(memberID, 10) + "&priority=urgent" "&assignee=" + strconv.FormatInt(memberID, 10) + "&priority=urgent"
matchingFilterRequest := httptest.NewRequest(http.MethodGet, matchingFilterPath, nil) matchingFilterRequest := httptest.NewRequest(http.MethodGet, matchingFilterPath, nil)
+6
View File
@@ -24,6 +24,12 @@
var dragged = null; var dragged = null;
var csrf = board.dataset.csrf; 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) { board.querySelectorAll("[data-task-id]").forEach(function (card) {
card.addEventListener("dragstart", function (event) { card.addEventListener("dragstart", function (event) {
if (event.target.closest("button, input, select, textarea, label, summary, a")) { 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 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 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; } .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-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: 270px; padding: 13px; border: 1px solid var(--line); border-radius: 15px; background: #eef2ef; } .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 { 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; } .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; } .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-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 { 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-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: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.dragging { opacity: .45; transform: rotate(1.5deg); }
.kanban-card.moving { opacity: .35; pointer-events: none; } .kanban-card.moving { opacity: .35; pointer-events: none; }
.card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 9px; } .card-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.card-topline { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin-bottom: 9px; } .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; } .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: 3px 5px; border-radius: 6px; background: var(--sand); color: #786039; font-size: .53rem; font-weight: 700; }
.task-due { padding: 4px 6px; border-radius: 6px; background: var(--sand); color: #786039; font-size: .55rem; 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-badge { padding: 4px 6px; border-radius: 6px; background: var(--paper); color: var(--muted); font-size: .53rem; font-weight: 800; text-transform: uppercase; } .importance-emoji { display: grid; width: 21px; height: 21px; place-items: center; border-radius: 6px; background: var(--paper); font-size: .66rem; line-height: 1; }
.importance-badge.low { background: #edf0ee; color: #68736e; } .importance-emoji.high { background: #f6eddc; }
.importance-badge.high { background: #f6eddc; color: #87662c; } .importance-emoji.urgent { background: #f5e6e4; }
.importance-badge.urgent { background: #f5e6e4; color: #9d4a44; } .kanban-card h3 { margin: 0 0 6px; font-size: .84rem; line-height: 1.3; }
.kanban-card h3 { margin-bottom: 7px; font-size: .84rem; line-height: 1.35; } .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; }
.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: 9px; padding-right: 27px; border-top: 1px solid var(--line); }
.task-meta { display: flex; justify-content: space-between; gap: 8px; align-items: center; padding-top: 10px; border-top: 1px solid var(--line); }
.task-assignees { display: flex; min-width: 0; flex-wrap: wrap; gap: 7px; } .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 { 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; } .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 span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-assignee.unassigned { color: var(--muted); font-weight: 500; } .task-assignee.unassigned { color: var(--muted); font-weight: 500; }
.task-creator { overflow: hidden; color: #9aa39e; font-size: .54rem; text-overflow: ellipsis; white-space: nowrap; } .task-creator { overflow: hidden; color: #9aa39e; font-size: .54rem; text-overflow: ellipsis; white-space: nowrap; }
.card-controls { margin: 10px -3px -5px; } .card-controls { position: static; }
.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 > 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::-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[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-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 { position: relative; } .card-tag-editor, .card-detail-editor, .card-todo-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, .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 { display: none; } .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, .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-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; } .card-detail-editor form { display: grid; gap: 10px; width: 270px; }
.compact-assignee-picker > div { max-height: 120px; overflow-y: auto; } .compact-assignee-picker > div { max-height: 120px; overflow-y: auto; }
.card-detail-editor select { margin-top: 5px; padding-top: 7px; padding-bottom: 7px; } .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 > 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 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-actions .text-button { margin: 0; }
.task-move-button { display: none; } .task-move-button { display: none; }
.task-actions form:focus-within .task-move-button { display: inline-block; } .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; } .task-lifecycle-actions > form { display: flex; }
.archive-task { color: var(--green); font-weight: 700; } .archive-task { color: var(--green); font-weight: 700; }
.task-remove { position: relative; color: var(--muted); font-size: .58rem; } .task-remove { position: relative; color: var(--muted); font-size: .58rem; }
.task-remove > summary { cursor: pointer; list-style: none; text-decoration: underline; } .task-remove > summary { cursor: pointer; list-style: none; text-decoration: underline; }
.task-remove > summary::-webkit-details-marker { display: none; } .task-remove > summary::-webkit-details-marker { display: none; }
.task-remove[open] > summary { visibility: hidden; } .task-remove[open] > summary { display: none; }
.task-remove form { position: absolute; right: 0; bottom: -1px; width: max-content; } .task-remove form { position: static; display: flex; width: max-content; }
.task-remove form .text-button { color: var(--red); font-weight: 700; } .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; } .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); } .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 { 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:last-child { border-bottom: 0; }
.archived-card h3 { margin: 4px 0 2px; font-size: .76rem; } .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 small { color: var(--muted); font-size: .58rem; }
.archived-card .card-tags { margin-bottom: 6px; } .archived-card .card-tags { margin-bottom: 6px; }
.archived-card-actions { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; } .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 { position: relative; }
.archived-delete > summary { color: var(--red); font-size: .6rem; font-weight: 700; cursor: pointer; list-style: none; } .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 > summary::-webkit-details-marker { display: none; }
.archived-delete[open] > summary { visibility: hidden; } .archived-delete[open] > summary { display: none; }
.archived-delete form { position: absolute; right: 0; bottom: 0; width: max-content; } .archived-delete form { position: static; display: flex; width: max-content; }
.archived-delete form .text-button { color: var(--red); font-weight: 700; } .archived-delete form .text-button { color: var(--red); font-weight: 700; }
.updates-layout { grid-template-columns: minmax(390px, .8fr) minmax(460px, 1.2fr); } .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"] .kanban-card > p { color: #aab6b0; }
html[data-theme="dark"] .task-assignee { color: #bdc8c2; } html[data-theme="dark"] .task-assignee { color: #bdc8c2; }
html[data-theme="dark"] .task-due { background: #3b3222; color: #dec18b; } 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"] .todo-progress { background: #1d3b2c; color: #8fd0ae; }
html[data-theme="dark"] .importance-badge.urgent { background: #432725; color: #efa09a; } 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); } html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142, .1); }
@media (max-width: 980px) { @media (max-width: 980px) {
+140 -4
View File
@@ -105,6 +105,8 @@ type BoardTask struct {
ArchivedAt string ArchivedAt string
Assignees []User Assignees []User
Tags []BoardTag Tags []BoardTag
Todos []BoardTodo
TodoDone int
} }
type BoardTag struct { type BoardTag struct {
@@ -113,6 +115,13 @@ type BoardTag struct {
Color string Color string
} }
type BoardTodo struct {
ID int64
TaskID int64
Body string
Completed bool
}
type Store struct{ db *sql.DB } type Store struct{ db *sql.DB }
func OpenStore(path string) (*Store, error) { func OpenStore(path string) (*Store, error) {
@@ -230,7 +239,7 @@ CREATE TABLE IF NOT EXISTS board_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', 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')), importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent')),
assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL, assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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) 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 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 ( CREATE TABLE IF NOT EXISTS board_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE, 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 { if err := s.ensureColumn("board_tasks", "importance", "importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent'))"); err != nil {
return err 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) 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 { SELECT id,assignee_id FROM board_tasks WHERE assignee_id IS NOT NULL`); err != nil {
return err 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 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) { func hashPassword(password string) (string, error) {
salt := make([]byte, 16) salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil { 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 { 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 { func validBoardImportance(importance string) bool {
@@ -994,7 +1065,7 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) {
} else { } else {
query += ` WHERE t.archived_at IS NULL query += ` WHERE t.archived_at IS NULL
ORDER BY CASE t.status 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` t.created_at DESC`
} }
rows, err := s.db.Query(query) 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) 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 { type sqlExecutor interface {
@@ -1117,6 +1209,50 @@ func (s *Store) SetBoardTaskDetails(taskID int64, assigneeIDs []int64, importanc
return tx.Commit() 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 { func (s *Store) MoveBoardTask(id int64, status string) error {
if !validBoardStatus(status) { if !validBoardStatus(status) {
return errors.New("invalid board column") return errors.New("invalid board column")
+30 -6
View File
@@ -75,7 +75,7 @@
</fieldset> </fieldset>
<div class="form-row"> <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>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> </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> <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> <button class="button dark full" type="submit">Create task</button>
@@ -96,8 +96,10 @@
{{range .Tasks}} {{range .Tasks}}
{{$task := .}} {{$task := .}}
<article class="kanban-card" draggable="true" data-task-id="{{.ID}}"> <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">
<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> {{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> <h3>{{.Title}}</h3>
{{if .Description}}<p>{{.Description}}</p>{{end}} {{if .Description}}<p>{{.Description}}</p>{{end}}
<div class="task-meta"> <div class="task-meta">
@@ -105,8 +107,30 @@
<span class="task-creator">by {{.CreatorName}}</span> <span class="task-creator">by {{.CreatorName}}</span>
</div> </div>
<details class="card-controls"> <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"> <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"> <details class="card-detail-editor">
<summary>People &amp; importance</summary> <summary>People &amp; importance</summary>
<form method="post" action="/board/tasks/{{.ID}}/details"> <form method="post" action="/board/tasks/{{.ID}}/details">
@@ -134,8 +158,8 @@
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<select name="status" aria-label="Move task"> <select name="status" aria-label="Move task">
<option value="backlog" {{if eq .Status "backlog"}}selected{{end}}>Backlog</option> <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="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> <option value="done" {{if eq .Status "done"}}selected{{end}}>Done</option>
</select> </select>
<button class="text-button task-move-button" type="submit">Move</button> <button class="text-button task-move-button" type="submit">Move</button>
@@ -173,7 +197,7 @@
<article class="archived-card"> <article class="archived-card">
<div> <div>
{{if .Tags}}<div class="card-tags">{{range .Tags}}<span class="board-tag {{.Color}}">{{.Name}}</span>{{end}}</div>{{end}} {{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> <h3>{{.Title}}</h3>
<small>Originally created by {{.CreatorName}}</small> <small>Originally created by {{.CreatorName}}</small>
</div> </div>