From 5a3ca4eb40a4f92899a784214fdc4490eda5988c Mon Sep 17 00:00:00 2001 From: nfel Date: Wed, 29 Jul 2026 13:53:31 +0330 Subject: [PATCH] feat: expand kanban assignment and filtering --- README.md | 2 +- internal/app/server.go | 92 +++++++++++++++--- internal/app/server_test.go | 84 +++++++++++++++- internal/app/static/board.js | 4 + internal/app/static/style.css | 64 ++++++++++-- internal/app/store.go | 156 ++++++++++++++++++++++++------ internal/app/templates/board.html | 121 ++++++++++++++++------- 7 files changed, 432 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 001683c..843a70b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Hamkar is a small team presence tracker built for startups that work with the Pe - Team day detail showing who is present, remote, absent, or expected at the office - Saturday–Friday team week schedule as the default team view, with calendar-style presence records - Period-based teammate work updates for completed, blocked, and pending tasks -- Shared Trello-style team board with reusable colored tags, assignment, Jalali due dates, and drag-and-drop columns +- Shared Trello-style team board with reusable colored tags, multiple assignees, importance levels, card archiving, Jalali due dates, and drag-and-drop columns - Time-off and remote-day requests with a dependency-free Jalali date picker and typed Persian-date fallback - Admin approval/rejection with review notes - Excel-compatible UTF-8 CSV reports for attendance and teammate work updates diff --git a/internal/app/server.go b/internal/app/server.go index 3651b7c..c5151b6 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -97,10 +97,18 @@ type PageData struct { Board []BoardColumn BoardTags []BoardTag ArchivedTasks []BoardTask + BoardFilter BoardFilter } type Stats struct{ Present, Remote, Leave int } +type BoardFilter struct { + TagID int64 + AssigneeID int64 + Priority string + Active bool +} + type ReportTotals struct { Present int Remote int @@ -217,6 +225,14 @@ func New(cfg Config) (*Server, error) { } return false }, + "hasUser": func(users []User, id int64) bool { + for _, user := range users { + if user.ID == id { + return true + } + } + return false + }, } tmpl, err := template.New("root").Funcs(funcs).ParseFS(assets, "templates/*.html") if err != nil { @@ -275,6 +291,7 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /board/tasks/{id}/restore", s.requireAuth(s.csrf(s.restoreBoardTask))) 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/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)) @@ -763,6 +780,22 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load board tags", http.StatusInternalServerError) return } + filter := BoardFilter{Priority: r.URL.Query().Get("priority")} + filter.TagID, _ = strconv.ParseInt(r.URL.Query().Get("tag"), 10, 64) + filter.AssigneeID, _ = strconv.ParseInt(r.URL.Query().Get("assignee"), 10, 64) + if !validBoardPriority(filter.Priority) { + filter.Priority = "" + } + filter.Active = filter.TagID > 0 || filter.AssigneeID > 0 || filter.Priority != "" + if filter.Active { + filtered := make([]BoardTask, 0, len(tasks)) + for _, task := range tasks { + if boardTaskMatchesFilter(task, filter) { + filtered = append(filtered, task) + } + } + tasks = filtered + } columns := []BoardColumn{ {Status: "backlog", Title: "Backlog", Hint: "Ready to pick up"}, {Status: "in_progress", Title: "In progress", Hint: "Actively being worked"}, @@ -779,26 +812,52 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) { } s.render(w, "board.html", PageData{ Title: "Team board", User: currentUser(r), CSRF: csrfToken(r), Users: users, Board: columns, BoardTags: tags, - ArchivedTasks: archivedTasks, - TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"), + ArchivedTasks: archivedTasks, BoardFilter: filter, + TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "board", }) } +func validBoardPriority(priority string) bool { + return priority == "" || priority == "low" || priority == "normal" || priority == "high" || priority == "urgent" +} + +func boardTaskMatchesFilter(task BoardTask, filter BoardFilter) bool { + if filter.Priority != "" && task.Importance != filter.Priority { + return false + } + if filter.TagID > 0 { + found := false + for _, tag := range task.Tags { + if tag.ID == filter.TagID { + found = true + break + } + } + if !found { + return false + } + } + if filter.AssigneeID > 0 { + found := false + for _, assignee := range task.Assignees { + if assignee.ID == filter.AssigneeID { + found = true + break + } + } + if !found { + return false + } + } + return true +} + func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(r.FormValue("title")) == "" { http.Redirect(w, r, "/board?error=Task+title+is+required", http.StatusSeeOther) return } - var assigneeID *int64 - if raw := r.FormValue("assignee_id"); raw != "" { - id, err := strconv.ParseInt(raw, 10, 64) - if err != nil || id < 1 { - http.Redirect(w, r, "/board?error=Choose+a+valid+assignee", http.StatusSeeOther) - return - } - assigneeID = &id - } dueDate := "" if rawDue := strings.TrimSpace(r.FormValue("due_date")); rawDue != "" { parsed, ok := parseUserDate(rawDue) @@ -819,7 +878,7 @@ func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) { } taskID, err := s.store.CreateBoardTask( currentUser(r).ID, r.FormValue("title"), r.FormValue("description"), - r.FormValue("status"), assigneeID, dueDate, + r.FormValue("status"), r.FormValue("importance"), parseIDList(r.Form["assignee_ids"]), dueDate, ) if err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) @@ -892,6 +951,15 @@ func (s *Server) setBoardTaskTags(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/board?flash=Task+labels+updated", http.StatusSeeOther) } +func (s *Server) setBoardTaskDetails(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err := s.store.SetBoardTaskDetails(id, parseIDList(r.Form["assignee_ids"]), r.FormValue("importance")); err != nil { + http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/board?flash=Card+details+updated", http.StatusSeeOther) +} + func (s *Server) createBoardTag(w http.ResponseWriter, r *http.Request) { if err := s.store.CreateBoardTag(r.FormValue("name"), r.FormValue("color")); err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 5cab6b9..409148f 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -40,7 +40,7 @@ func TestExistingDatabaseAddsNewColumns(t *testing.T) { due_date TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); - INSERT INTO board_tasks(title,creator_id) VALUES('Legacy card',1)`) + INSERT INTO board_tasks(title,assignee_id,creator_id) VALUES('Legacy card',1,1)`) if err != nil { t.Fatal(err) } @@ -67,6 +67,41 @@ func TestExistingDatabaseAddsNewColumns(t *testing.T) { if archivedAt.Valid { t.Fatalf("legacy card was unexpectedly archived: %q", archivedAt.String) } + var importance string + if err := store.db.QueryRow(`SELECT importance FROM board_tasks WHERE title='Legacy card'`).Scan(&importance); err != nil || importance != "normal" { + t.Fatalf("legacy card importance: %q, %v", importance, err) + } + var migratedAssignees int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM board_task_assignees WHERE task_id=1 AND user_id=1`).Scan(&migratedAssignees); err != nil || migratedAssignees != 1 { + t.Fatalf("legacy card assignees: %d, %v", migratedAssignees, err) + } +} + +func TestBoardTasksSortByCreationDate(t *testing.T) { + store, err := OpenStore(filepath.Join(t.TempDir(), "sort.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + olderID, err := store.CreateBoardTask(1, "Created later", "", "backlog", "normal", nil, "") + if err != nil { + t.Fatal(err) + } + newerID, err := store.CreateBoardTask(1, "Created earlier", "", "backlog", "normal", nil, "") + if err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(`UPDATE board_tasks SET created_at=CASE id WHEN ? THEN ? ELSE ? END`, + olderID, "2026-07-29 12:00:00", "2026-07-29 11:00:00"); err != nil { + t.Fatal(err) + } + tasks, err := store.BoardTasks() + if err != nil { + t.Fatal(err) + } + if len(tasks) != 2 || tasks[0].ID != olderID || tasks[1].ID != newerID { + t.Fatalf("cards were not sorted newest-created-first: %#v", tasks) + } } func TestLoginAttendanceAndReportFlow(t *testing.T) { @@ -642,8 +677,12 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { "title": {"Ship the onboarding flow"}, "description": {"Finish QA and publish the release."}, "status": {"backlog"}, - "assignee_id": {strconv.FormatInt(memberID, 10)}, - "due_date": {"1405-05-06"}, + "assignee_ids": { + "1", + strconv.FormatInt(memberID, 10), + }, + "importance": {"high"}, + "due_date": {"1405-05-06"}, "tag_ids": { strconv.FormatInt(tags[0].ID, 10), strconv.FormatInt(tags[1].ID, 10), @@ -658,7 +697,7 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { if err != nil || len(tasks) != 1 { t.Fatalf("board tasks after create: %#v, %v", tasks, err) } - if tasks[0].AssigneeName != "Board Member" || tasks[0].DueDate != "2026-07-28" { + if len(tasks[0].Assignees) != 2 || tasks[0].Importance != "high" || tasks[0].DueDate != "2026-07-28" { t.Fatalf("unexpected assigned board task: %#v", tasks[0]) } if len(tasks[0].Tags) != 3 { @@ -677,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", "1405-05-06", "Manage tags", "Tags", "select multiple", "Create a new tag", "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", "Manage tags", "Tags", "select multiple", "Create a new tag", "People & importance", "Card controls", "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) } @@ -704,6 +743,41 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { if len(tasks[0].Tags) != 1 || tasks[0].Tags[0].ID != tags[0].ID { t.Fatalf("task tags were not updated: %#v", tasks[0].Tags) } + detailsPath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/details" + setDetails := formRequest(t, s.http.Handler, detailsPath, url.Values{ + "csrf": {memberCSRF}, + "assignee_ids": {strconv.FormatInt(memberID, 10)}, + "importance": {"urgent"}, + }, memberCookie) + if setDetails.Code != http.StatusSeeOther || setDetails.Header().Get("Location") != "/board?flash=Card+details+updated" { + t.Fatalf("set task details: got %d location %q", setDetails.Code, setDetails.Header().Get("Location")) + } + tasks, _ = s.store.BoardTasks() + 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]) + } + matchingFilterPath := "/board?tag=" + strconv.FormatInt(tags[0].ID, 10) + + "&assignee=" + strconv.FormatInt(memberID, 10) + "&priority=urgent" + matchingFilterRequest := httptest.NewRequest(http.MethodGet, matchingFilterPath, nil) + matchingFilterRequest.AddCookie(memberCookie) + matchingFilter := httptest.NewRecorder() + s.http.Handler.ServeHTTP(matchingFilter, matchingFilterRequest) + if matchingFilter.Code != http.StatusOK || !strings.Contains(matchingFilter.Body.String(), "Ship the onboarding flow") || !strings.Contains(matchingFilter.Body.String(), "Clear") { + t.Fatalf("matching board filter: %d %s", matchingFilter.Code, matchingFilter.Body.String()) + } + for _, path := range []string{ + "/board?priority=high", + "/board?assignee=1", + "/board?tag=999999", + } { + request := httptest.NewRequest(http.MethodGet, path, nil) + request.AddCookie(memberCookie) + response := httptest.NewRecorder() + s.http.Handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "Ship the onboarding flow") || !strings.Contains(response.Body.String(), "No matching cards") { + t.Fatalf("non-matching board filter %q: %d %s", path, response.Code, response.Body.String()) + } + } deleteTagPath := "/board/tags/" + strconv.FormatInt(tags[1].ID, 10) + "/delete" deleteTag := formRequest(t, s.http.Handler, deleteTagPath, url.Values{"csrf": {adminCSRF}}, adminCookie) diff --git a/internal/app/static/board.js b/internal/app/static/board.js index f8590d3..6acf0de 100644 --- a/internal/app/static/board.js +++ b/internal/app/static/board.js @@ -12,6 +12,10 @@ board.querySelectorAll("[data-task-id]").forEach(function (card) { card.addEventListener("dragstart", function (event) { + if (event.target.closest("button, input, select, textarea, label, summary, a")) { + event.preventDefault(); + return; + } dragged = card; card.classList.add("dragging"); event.dataTransfer.effectAllowed = "move"; diff --git a/internal/app/static/style.css b/internal/app/static/style.css index 6f8cd64..dfd368d 100644 --- a/internal/app/static/style.css +++ b/internal/app/static/style.css @@ -262,7 +262,7 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .new-task-panel > summary { list-style: none; user-select: none; } .new-task-panel > summary::-webkit-details-marker { display: none; } .new-task-panel[open] > summary { background: var(--ink); } -.new-task-form { position: absolute; z-index: 60; top: calc(100% + 9px); right: 0; display: grid; gap: 14px; width: min(430px, calc(100vw - 40px)); padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--white); box-shadow: 0 22px 60px rgba(24, 36, 31, .22); } +.new-task-form { position: absolute; z-index: 60; top: calc(100% + 9px); right: 0; display: grid; gap: 14px; width: min(430px, calc(100vw - 40px)); max-height: calc(100vh - 110px); padding: 22px; overflow-y: auto; border: 1px solid var(--line); border-radius: 15px; background: var(--white); box-shadow: 0 22px 60px rgba(24, 36, 31, .22); } .new-task-form-head h2 { margin-bottom: 0; } .tag-manager-panel { position: relative; } .tag-manager-panel > summary { list-style: none; user-select: none; } @@ -304,6 +304,26 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .inline-tag-fields { display: grid; grid-template-columns: 1fr 105px; gap: 8px; margin-top: 10px; } .inline-tag-fields input, .inline-tag-fields select { margin-top: 5px; padding-top: 9px; padding-bottom: 9px; background: var(--white); } .inline-tag-creator > small { display: block; margin-top: 7px; color: var(--muted); font-size: .57rem; } +.assignee-picker, .compact-assignee-picker { margin: 0; padding: 0; border: 0; } +.assignee-picker legend, .compact-assignee-picker legend { width: 100%; margin-bottom: 8px; color: #49564f; font-size: .72rem; font-weight: 700; } +.assignee-picker legend small { float: right; color: #9aa39e; font-weight: 500; } +.assignee-picker > div, .compact-assignee-picker > div { display: flex; flex-wrap: wrap; gap: 6px; } +.assignee-picker label, .compact-assignee-picker label { position: relative; cursor: pointer; } +.assignee-picker input, .compact-assignee-picker input { position: absolute; opacity: 0; pointer-events: none; } +.assignee-picker label > span, .compact-assignee-picker label > span { display: inline-flex; gap: 6px; align-items: center; min-height: 31px; padding: 4px 9px 4px 5px; border: 1px solid var(--line); border-radius: 20px; background: var(--paper); color: var(--muted); font-size: .59rem; font-weight: 700; } +.assignee-picker label > span .avatar, .compact-assignee-picker label > span .avatar { width: 21px; height: 21px; font-size: .5rem; } +.assignee-picker input:checked + span, .compact-assignee-picker input:checked + span { border-color: #5f8f78; background: #edf6f1; color: var(--green-2); box-shadow: inset 0 0 0 1px #5f8f78; } +.assignee-picker input:focus-visible + span, .compact-assignee-picker input:focus-visible + span { outline: 2px solid var(--ink); outline-offset: 2px; } +.board-filter-bar { display: flex; gap: 16px; align-items: center; max-width: 1380px; margin: 0 auto 14px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 13px; background: var(--white); box-shadow: var(--shadow); } +.board-filter-title { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; padding-right: 15px; border-right: 1px solid var(--line); color: var(--ink); font-size: .66rem; } +.board-filter-title span { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 8px; background: var(--mint); color: var(--green); font-size: .9rem; } +.board-filter-bar form { display: grid; grid-template-columns: repeat(3, minmax(135px, 180px)) auto; gap: 8px; align-items: end; } +.board-filter-bar label { min-width: 0; color: var(--muted); font-size: .52rem; } +.board-filter-bar label > span { display: block; padding-left: 2px; } +.board-filter-bar select { min-height: 33px; margin-top: 3px; padding: 6px 27px 6px 9px; border-radius: 8px; background-color: var(--paper); font-size: .59rem; } +.board-filter-actions { display: flex; gap: 7px; align-items: center; min-height: 33px; } +.board-filter-actions .button { min-height: 33px; padding: 6px 13px; font-size: .59rem; } +.board-filter-actions .text-button { padding: 6px 2px; color: var(--muted); font-size: .57rem; } .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-column-head { display: flex; justify-content: space-between; align-items: start; min-height: 51px; padding: 4px 5px 12px; } @@ -322,24 +342,39 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .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; align-items: center; margin-bottom: 9px; } +.card-topline { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin-bottom: 9px; } +.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-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; } .task-assignee i.avatar { width: 24px; height: 24px; } .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-tag-editor { position: relative; margin-top: 9px; } -.card-tag-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 { display: none; } -.card-tag-editor form { position: absolute; z-index: 8; top: calc(100% + 5px); left: -7px; width: 230px; 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 { min-height: 31px; margin-top: 10px; font-size: .62rem; } -.task-actions { display: flex; justify-content: space-between; align-items: center; margin: 11px -3px -5px; } +.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 > summary::-webkit-details-marker { display: none; } +.card-controls > summary i { font-size: .8rem; font-style: normal; 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-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; } .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; } @@ -586,12 +621,17 @@ html[data-theme="dark"] .metric.pending, html[data-theme="dark"] .pending-icon { background: #453820; color: #e3bd72; } html[data-theme="dark"] .new-task-form { box-shadow: 0 22px 65px rgba(0, 0, 0, .52); } html[data-theme="dark"] .tag-manager, -html[data-theme="dark"] .card-tag-editor form { box-shadow: 0 22px 65px rgba(0, 0, 0, .52); } +html[data-theme="dark"] .card-tag-editor form, +html[data-theme="dark"] .card-detail-editor form { box-shadow: 0 22px 65px rgba(0, 0, 0, .52); } +html[data-theme="dark"] .assignee-picker input:checked + span, +html[data-theme="dark"] .compact-assignee-picker input:checked + span { border-color: #5c9b7b; background: #1b3027; color: #9ed4b9; } html[data-theme="dark"] .kanban-column { background: #141d18; } html[data-theme="dark"] .kanban-card { border-color: #35443d; box-shadow: 0 4px 13px rgba(0, 0, 0, .22); } 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"] .kanban-cards.drag-over { background: rgba(107, 172, 142, .1); } @media (max-width: 980px) { @@ -678,6 +718,12 @@ html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142 .tag-manager { position: fixed; top: 76px; right: 15px; left: 15px; width: auto; max-height: calc(100vh - 100px); overflow-y: auto; } .tag-create-form { grid-template-columns: 1fr 1fr; } .tag-create-form .button { grid-column: 1 / -1; } + .board-filter-bar { display: block; padding: 10px; } + .board-filter-title { margin-bottom: 9px; padding: 0 0 8px; border-right: 0; border-bottom: 1px solid var(--line); } + .board-filter-title span { width: 27px; height: 27px; } + .board-filter-bar form { grid-template-columns: 1fr 1fr; } + .board-filter-bar form > label:nth-child(3) { grid-column: 1 / -1; } + .board-filter-actions { justify-content: flex-end; } .kanban-board { grid-template-columns: repeat(4, 82vw); margin-right: -15px; padding-right: 0; padding-left: 15px; scroll-snap-type: x mandatory; } .kanban-column { scroll-snap-align: start; } .archived-card { align-items: flex-start; flex-direction: column; gap: 11px; } diff --git a/internal/app/store.go b/internal/app/store.go index ba5db44..d3639a3 100644 --- a/internal/app/store.go +++ b/internal/app/store.go @@ -93,19 +93,18 @@ func (s PersonReportSummary) TotalUpdates() int { } type BoardTask struct { - ID int64 - Title string - Description string - Status string - AssigneeID sql.NullInt64 - AssigneeName string - AssigneeUser string - CreatorID int64 - CreatorName string - DueDate string - CreatedAt string - ArchivedAt string - Tags []BoardTag + ID int64 + Title string + Description string + Status string + Importance string + CreatorID int64 + CreatorName string + DueDate string + CreatedAt string + ArchivedAt string + Assignees []User + Tags []BoardTag } type BoardTag struct { @@ -232,6 +231,7 @@ CREATE TABLE IF NOT EXISTS board_tasks ( title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','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, @@ -239,6 +239,12 @@ CREATE TABLE IF NOT EXISTS board_tasks ( created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at); +CREATE TABLE IF NOT EXISTS board_task_assignees ( + task_id INTEGER NOT NULL REFERENCES board_tasks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + 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_tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE COLLATE NOCASE, @@ -261,6 +267,16 @@ CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,tas if err := s.ensureColumn("board_tasks", "archived_at", "archived_at DATETIME"); err != nil { return err } + 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.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 + } + if _, err := s.db.Exec(`UPDATE board_tasks SET assignee_id=NULL WHERE assignee_id IS NOT NULL`); err != nil { + return err + } var count int if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil { return err @@ -914,7 +930,11 @@ func validBoardStatus(status string) bool { return status == "backlog" || status == "in_progress" || status == "blocked" || status == "done" } -func (s *Store) CreateBoardTask(creatorID int64, title, description, status string, assigneeID *int64, dueDate string) (int64, error) { +func validBoardImportance(importance string) bool { + return importance == "low" || importance == "normal" || importance == "high" || importance == "urgent" +} + +func (s *Store) CreateBoardTask(creatorID int64, title, description, status, importance string, assigneeIDs []int64, dueDate string) (int64, error) { title = strings.TrimSpace(title) description = strings.TrimSpace(description) if title == "" { @@ -926,20 +946,31 @@ func (s *Store) CreateBoardTask(creatorID int64, title, description, status stri if !validBoardStatus(status) { status = "backlog" } - var assignee any - if assigneeID != nil && *assigneeID > 0 { - assignee = *assigneeID + if !validBoardImportance(importance) { + importance = "normal" } var due any if dueDate != "" { due = dueDate } - result, err := s.db.Exec(`INSERT INTO board_tasks(title,description,status,assignee_id,creator_id,due_date) - VALUES(?,?,?,?,?,?)`, title, description, status, assignee, creatorID, due) + tx, err := s.db.Begin() if err != nil { return 0, err } - return result.LastInsertId() + defer tx.Rollback() + result, err := tx.Exec(`INSERT INTO board_tasks(title,description,status,importance,creator_id,due_date,created_at) + VALUES(?,?,?,?,?,?,?)`, title, description, status, importance, creatorID, due, time.Now().UTC()) + if err != nil { + return 0, err + } + taskID, err := result.LastInsertId() + if err != nil { + return 0, err + } + if err := setBoardTaskAssignees(tx, taskID, assigneeIDs); err != nil { + return 0, err + } + return taskID, tx.Commit() } func (s *Store) BoardTasks() ([]BoardTask, error) { @@ -952,13 +983,11 @@ func (s *Store) ArchivedBoardTasks() ([]BoardTask, error) { func (s *Store) boardTasks(archived bool) ([]BoardTask, error) { query := `SELECT - t.id,t.title,t.description,t.status,t.assignee_id, - COALESCE(a.display_name,''),COALESCE(a.username,''), + t.id,t.title,t.description,t.status,t.importance, t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT), COALESCE(CAST(t.archived_at AS TEXT),'') FROM board_tasks t - JOIN users c ON c.id=t.creator_id - LEFT JOIN users a ON a.id=t.assignee_id` + JOIN users c ON c.id=t.creator_id` if archived { query += ` WHERE t.archived_at IS NOT NULL ORDER BY t.archived_at DESC,t.id DESC` @@ -966,7 +995,7 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) { 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, - t.created_at,t.id` + t.created_at DESC` } rows, err := s.db.Query(query) if err != nil { @@ -976,8 +1005,8 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) { for rows.Next() { var task BoardTask if err := rows.Scan( - &task.ID, &task.Title, &task.Description, &task.Status, &task.AssigneeID, - &task.AssigneeName, &task.AssigneeUser, &task.CreatorID, &task.CreatorName, + &task.ID, &task.Title, &task.Description, &task.Status, &task.Importance, + &task.CreatorID, &task.CreatorName, &task.DueDate, &task.CreatedAt, &task.ArchivedAt, ); err != nil { return nil, err @@ -995,6 +1024,34 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) { for index := range tasks { taskIndex[tasks[index].ID] = index } + assigneeRows, err := s.db.Query(`SELECT a.task_id,u.id,u.username,u.display_name, + COALESCE(u.email,''),u.role,u.avatar_url,u.active + FROM board_task_assignees a JOIN users u ON u.id=a.user_id + ORDER BY u.display_name`) + if err != nil { + return nil, err + } + for assigneeRows.Next() { + var taskID int64 + var user User + if err := assigneeRows.Scan( + &taskID, &user.ID, &user.Username, &user.DisplayName, + &user.Email, &user.Role, &user.AvatarURL, &user.Active, + ); err != nil { + assigneeRows.Close() + return nil, err + } + if index, ok := taskIndex[taskID]; ok { + tasks[index].Assignees = append(tasks[index].Assignees, user) + } + } + if err := assigneeRows.Err(); err != nil { + assigneeRows.Close() + return nil, err + } + if err := assigneeRows.Close(); err != nil { + return nil, err + } tagRows, err := s.db.Query(`SELECT tt.task_id,t.id,t.name,t.color FROM board_task_tags tt JOIN board_tags t ON t.id=tt.tag_id ORDER BY t.name`) @@ -1015,6 +1072,51 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) { return tasks, tagRows.Err() } +type sqlExecutor interface { + Exec(query string, args ...any) (sql.Result, error) +} + +func setBoardTaskAssignees(executor sqlExecutor, taskID int64, userIDs []int64) error { + seen := map[int64]bool{} + for _, userID := range userIDs { + if userID < 1 || seen[userID] { + continue + } + seen[userID] = true + if _, err := executor.Exec(`INSERT INTO board_task_assignees(task_id,user_id) + SELECT ?,id FROM users WHERE id=? AND active=1`, taskID, userID); err != nil { + return err + } + } + return nil +} + +func (s *Store) SetBoardTaskDetails(taskID int64, assigneeIDs []int64, importance string) error { + if !validBoardImportance(importance) { + return errors.New("choose a valid importance") + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.Exec(`UPDATE board_tasks SET importance=? WHERE id=?`, importance, taskID) + if err != nil { + return err + } + count, _ := result.RowsAffected() + if count == 0 { + return errors.New("task was not found") + } + if _, err := tx.Exec(`DELETE FROM board_task_assignees WHERE task_id=?`, taskID); err != nil { + return err + } + if err := setBoardTaskAssignees(tx, taskID, assigneeIDs); err != nil { + return err + } + return tx.Commit() +} + func (s *Store) MoveBoardTask(id int64, status string) error { if !validBoardStatus(status) { return errors.New("invalid board column") diff --git a/internal/app/templates/board.html b/internal/app/templates/board.html index de0ad75..e871c79 100644 --- a/internal/app/templates/board.html +++ b/internal/app/templates/board.html @@ -42,8 +42,12 @@ The new tag will be created and added to this card. +
+ Assignees Optional · select multiple +
{{range .Users}}{{end}}
+
- +
@@ -54,6 +58,32 @@ {{template "notice" .}} +{{$filter := .BoardFilter}} +
+
Filter cards
+
+ + + +
+ {{if $filter.Active}}Clear{{end}} + +
+
+
+
{{range .Board}}
@@ -66,53 +96,70 @@ {{$task := .}}
{{if .Tags}}
{{range .Tags}}{{.Name}}{{end}}
{{end}} -
#{{.ID}}{{if .DueDate}}Due {{dateFA .DueDate}}{{end}}
+
#{{.ID}}{{if eq .Importance "urgent"}}Urgent{{else if eq .Importance "high"}}High{{else if eq .Importance "low"}}Low{{else}}Normal{{end}}{{if .DueDate}}Due {{dateFA .DueDate}}{{end}}

{{.Title}}

{{if .Description}}

{{.Description}}

{{end}}
- {{if .AssigneeID.Valid}}{{initial .AssigneeName}}{{.AssigneeName}}{{else}}?Unassigned{{end}} + {{if .Assignees}}{{range .Assignees}}{{initial .DisplayName}}{{.DisplayName}}{{end}}{{else}}?Unassigned{{end}} by {{.CreatorName}}
- {{if $.BoardTags}} -
- Labels -
- -
{{range $.BoardTags}}{{end}}
- -
-
- {{end}} -
-
- - - -
- {{if or (eq .CreatorID $.User.ID) (eq $.User.Role "admin")}} -
-
- - -
-
- Delete -
+
+ Card controls +
+
+ People & importance + - +
+ Assignees +
{{range $.Users}}{{end}}
+
+ +
+ {{if $.BoardTags}} +
+ Labels +
+ +
{{range $.BoardTags}}{{end}}
+ +
+
+ {{end}} +
+
+ + + +
+ {{if or (eq .CreatorID $.User.ID) (eq $.User.Role "admin")}} +
+
+ + +
+
+ Delete +
+ + +
+
+
+ {{end}} +
- {{end}} -
+
{{end}} - {{if not .Tasks}}
Drop tasks here
{{end}} + {{if not .Tasks}}
{{if $filter.Active}}No matching cards{{else}}Drop tasks here{{end}}
{{end}}
{{end}}