feat: expand kanban assignment and filtering
Test and publish / verify (push) Successful in 2m22s

This commit is contained in:
2026-07-29 13:53:31 +03:30
parent e1475d0df0
commit 5a3ca4eb40
7 changed files with 432 additions and 91 deletions
+1 -1
View File
@@ -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
- SaturdayFriday 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
+79 -11
View File
@@ -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,
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)
+78 -4
View File
@@ -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,7 +677,11 @@ 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)},
"assignee_ids": {
"1",
strconv.FormatInt(memberID, 10),
},
"importance": {"high"},
"due_date": {"1405-05-06"},
"tag_ids": {
strconv.FormatInt(tags[0].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 &amp; 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)
+4
View File
@@ -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";
+55 -9
View File
@@ -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; }
+119 -17
View File
@@ -97,14 +97,13 @@ type BoardTask struct {
Title string
Description string
Status string
AssigneeID sql.NullInt64
AssigneeName string
AssigneeUser string
Importance string
CreatorID int64
CreatorName string
DueDate string
CreatedAt string
ArchivedAt string
Assignees []User
Tags []BoardTag
}
@@ -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")
+51 -4
View File
@@ -42,8 +42,12 @@
<small>The new tag will be created and added to this card.</small>
</details>
</fieldset>
<fieldset class="assignee-picker">
<legend>Assignees <small>Optional · select multiple</small></legend>
<div>{{range .Users}}<label><input type="checkbox" name="assignee_ids" value="{{.ID}}"><span><i class="avatar">{{initial .DisplayName}}</i>{{.DisplayName}}</span></label>{{end}}</div>
</fieldset>
<div class="form-row">
<label>Assignee<select name="assignee_id"><option value="">Unassigned</option>{{range .Users}}<option value="{{.ID}}">{{.DisplayName}}</option>{{end}}</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>
</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>
@@ -54,6 +58,32 @@
</header>
{{template "notice" .}}
{{$filter := .BoardFilter}}
<section class="board-filter-bar">
<div class="board-filter-title"><span></span><strong>Filter cards</strong></div>
<form method="get" action="/board">
<label><span>Tag</span><select name="tag" aria-label="Filter by tag">
<option value="">All tags</option>
{{range .BoardTags}}<option value="{{.ID}}" {{if eq $filter.TagID .ID}}selected{{end}}>{{.Name}}</option>{{end}}
</select></label>
<label><span>Assignee</span><select name="assignee" aria-label="Filter by assignee">
<option value="">Everyone</option>
{{range .Users}}<option value="{{.ID}}" {{if eq $filter.AssigneeID .ID}}selected{{end}}>{{.DisplayName}}</option>{{end}}
</select></label>
<label><span>Priority</span><select name="priority" aria-label="Filter by priority">
<option value="">All priorities</option>
<option value="urgent" {{if eq $filter.Priority "urgent"}}selected{{end}}>Urgent</option>
<option value="high" {{if eq $filter.Priority "high"}}selected{{end}}>High</option>
<option value="normal" {{if eq $filter.Priority "normal"}}selected{{end}}>Normal</option>
<option value="low" {{if eq $filter.Priority "low"}}selected{{end}}>Low</option>
</select></label>
<div class="board-filter-actions">
{{if $filter.Active}}<a href="/board" class="text-button">Clear</a>{{end}}
<button class="button secondary" type="submit">Apply</button>
</div>
</form>
</section>
<div class="kanban-board" data-board data-csrf="{{.CSRF}}">
{{range .Board}}
<section class="kanban-column {{.Status}}" data-board-column="{{.Status}}">
@@ -66,13 +96,28 @@
{{$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>{{if .DueDate}}<span class="task-due">Due {{dateFA .DueDate}}</span>{{end}}</div>
<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>
<h3>{{.Title}}</h3>
{{if .Description}}<p>{{.Description}}</p>{{end}}
<div class="task-meta">
{{if .AssigneeID.Valid}}<span class="task-assignee"><i class="avatar">{{initial .AssigneeName}}</i><span>{{.AssigneeName}}</span></span>{{else}}<span class="task-assignee unassigned"><i>?</i><span>Unassigned</span></span>{{end}}
{{if .Assignees}}<span class="task-assignees">{{range .Assignees}}<span class="task-assignee"><i class="avatar">{{initial .DisplayName}}</i><span>{{.DisplayName}}</span></span>{{end}}</span>{{else}}<span class="task-assignee unassigned"><i>?</i><span>Unassigned</span></span>{{end}}
<span class="task-creator">by {{.CreatorName}}</span>
</div>
<details class="card-controls">
<summary><span>Card controls</span><i></i></summary>
<div class="card-controls-body">
<details class="card-detail-editor">
<summary>People &amp; importance</summary>
<form method="post" action="/board/tasks/{{.ID}}/details">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset class="compact-assignee-picker">
<legend>Assignees</legend>
<div>{{range $.Users}}<label><input type="checkbox" name="assignee_ids" value="{{.ID}}" {{if hasUser $task.Assignees .ID}}checked{{end}}><span><i class="avatar">{{initial .DisplayName}}</i>{{.DisplayName}}</span></label>{{end}}</div>
</fieldset>
<label>Importance<select name="importance"><option value="low" {{if eq $task.Importance "low"}}selected{{end}}>Low</option><option value="normal" {{if eq $task.Importance "normal"}}selected{{end}}>Normal</option><option value="high" {{if eq $task.Importance "high"}}selected{{end}}>High</option><option value="urgent" {{if eq $task.Importance "urgent"}}selected{{end}}>Urgent</option></select></label>
<button class="button primary full" type="submit">Save details</button>
</form>
</details>
{{if $.BoardTags}}
<details class="card-tag-editor">
<summary>Labels</summary>
@@ -110,9 +155,11 @@
</div>
{{end}}
</div>
</div>
</details>
</article>
{{end}}
{{if not .Tasks}}<div class="kanban-empty"><span>Drop tasks here</span></div>{{end}}
{{if not .Tasks}}<div class="kanban-empty"><span>{{if $filter.Active}}No matching cards{{else}}Drop tasks here{{end}}</span></div>{{end}}
</div>
</section>
{{end}}