This commit is contained in:
+34
-2
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user