diff --git a/Makefile b/Makefile index 785a0df..96c0da7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: run test build docker-build +.PHONY: run test build docker-build compose-up compose-down run: go run ./cmd/server @@ -11,3 +11,9 @@ build: docker-build: docker build -f build/dockerfile -t hamkar:local . + +compose-up: + docker compose -f compose.yml up -d --build + +compose-down: + docker compose -f compose.yml down diff --git a/README.md b/README.md index 0ede122..001683c 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ Hamkar is a small team presence tracker built for startups that work with the Pe - Office and remote check-in/check-out - Persian month calendar with attendance, approved leave, remote days, Fridays, and fixed Solar Hijri public holidays - Team day detail showing who is present, remote, absent, or expected at the office -- Saturday–Friday team week schedule with calendar-style presence records +- 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 assignment, Jalali due dates, and drag-and-drop columns +- Shared Trello-style team board with reusable colored tags, assignment, 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 @@ -85,6 +85,30 @@ docker run --rm -p 8080:8080 \ hamkar:local ``` +### Docker Compose + +Compose builds from `build/dockerfile`, persists SQLite in the `hamkar-data` volume, and publishes the application at . + +```bash +cp .env.example .env +docker compose -f compose.yml up -d --build +docker compose -f compose.yml ps +``` + +Stop the application without deleting its database: + +```bash +docker compose -f compose.yml down +``` + +To intentionally delete the database volume as well: + +```bash +docker compose -f compose.yml down --volumes +``` + +Set a secure `INITIAL_ADMIN_PASSWORD` before the first start. For HTTPS deployments, also set the public `APP_BASE_URL` and `SESSION_SECURE=true`. + ## Gitea Actions The workflow under `.gitea/workflows/ci.yaml` is adapted from the Sana API pipeline. Pull requests build and test the container without publishing it. Pushes to `main` build and publish both `main` and immutable commit-SHA tags. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..3de47c5 --- /dev/null +++ b/compose.yml @@ -0,0 +1,35 @@ +name: hamkar + +services: + app: + image: ${HAMKAR_IMAGE:-hamkar:local} + build: + context: . + dockerfile: build/dockerfile + args: + GO_PROXY: ${GOPROXY:-https://proxy.golang.org/} + restart: unless-stopped + ports: + - "${APP_PORT:-8080}:8080" + environment: + APP_ADDR: ":8080" + APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8080} + APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Tehran} + DATABASE_PATH: /data/teammate.db + SESSION_SECURE: ${SESSION_SECURE:-false} + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123} + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + volumes: + - hamkar-data:/data + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + security_opt: + - no-new-privileges:true + stop_grace_period: 15s + +volumes: + hamkar-data: diff --git a/internal/app/server.go b/internal/app/server.go index 0c4857f..e6f02e2 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -95,6 +95,7 @@ type PageData struct { ReportSummary []PersonReportSummary ReportTotals ReportTotals Board []BoardColumn + BoardTags []BoardTag } type Stats struct{ Present, Remote, Leave int } @@ -207,6 +208,14 @@ func New(cfg Config) (*Server, error) { r, _ := utf8.DecodeRuneInString(v) return string(r) }, + "hasTag": func(tags []BoardTag, id int64) bool { + for _, tag := range tags { + if tag.ID == id { + return true + } + } + return false + }, } tmpl, err := template.New("root").Funcs(funcs).ParseFS(assets, "templates/*.html") if err != nil { @@ -262,6 +271,9 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /board/tasks", s.requireAuth(s.csrf(s.createBoardTask))) mux.HandleFunc("POST /board/tasks/{id}/move", s.requireAuth(s.csrf(s.moveBoardTask))) 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/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)) mux.HandleFunc("POST /admin/requests/{id}/review", s.requireAdmin(s.csrf(s.reviewRequest))) mux.HandleFunc("GET /admin/users", s.requireAdmin(s.usersPage)) @@ -285,8 +297,8 @@ func (s *Server) dayPage(w http.ResponseWriter, r *http.Request) { } selected, _ := time.Parse("2006-01-02", day) view := r.URL.Query().Get("view") - if view != "week" { - view = "day" + if view != "day" { + view = "week" } var detail DayDetail var week WeekDetail @@ -737,6 +749,11 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load teammates", http.StatusInternalServerError) return } + tags, err := s.store.BoardTags() + if err != nil { + http.Error(w, "could not load board tags", http.StatusInternalServerError) + return + } columns := []BoardColumn{ {Status: "backlog", Title: "Backlog", Hint: "Ready to pick up"}, {Status: "in_progress", Title: "In progress", Hint: "Actively being worked"}, @@ -752,13 +769,17 @@ 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, + Title: "Team board", User: currentUser(r), CSRF: csrfToken(r), Users: users, Board: columns, BoardTags: tags, TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "board", }) } 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) @@ -777,7 +798,16 @@ func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) { } dueDate = parsed } - err := s.store.CreateBoardTask( + tagIDs := parseIDList(r.Form["tag_ids"]) + if newTagName := strings.TrimSpace(r.FormValue("new_tag_name")); newTagName != "" { + tag, err := s.store.FindOrCreateBoardTag(newTagName, r.FormValue("new_tag_color")) + if err != nil { + http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + tagIDs = append(tagIDs, tag.ID) + } + taskID, err := s.store.CreateBoardTask( currentUser(r).ID, r.FormValue("title"), r.FormValue("description"), r.FormValue("status"), assigneeID, dueDate, ) @@ -785,9 +815,23 @@ func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } + if err := s.store.SetBoardTaskTags(taskID, tagIDs); err != nil { + http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } http.Redirect(w, r, "/board?flash=Task+added+to+the+team+board", http.StatusSeeOther) } +func parseIDList(values []string) []int64 { + ids := make([]int64, 0, len(values)) + for _, raw := range values { + if id, err := strconv.ParseInt(raw, 10, 64); err == nil && id > 0 { + ids = append(ids, id) + } + } + return ids +} + func (s *Server) moveBoardTask(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) if err := s.store.MoveBoardTask(id, r.FormValue("status")); err != nil { @@ -807,6 +851,32 @@ func (s *Server) deleteBoardTask(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/board?flash=Task+removed", http.StatusSeeOther) } +func (s *Server) setBoardTaskTags(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err := s.store.SetBoardTaskTags(id, parseIDList(r.Form["tag_ids"])); err != nil { + http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/board?flash=Task+labels+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) + return + } + http.Redirect(w, r, "/board?flash=Tag+created", http.StatusSeeOther) +} + +func (s *Server) deleteBoardTag(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err := s.store.DeleteBoardTag(id); err != nil { + http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/board?flash=Tag+deleted", http.StatusSeeOther) +} + func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) { requests, err := s.store.Requests(currentUser(r).ID, true, r.URL.Query().Get("status")) if err != nil { diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 7828e50..6a5fbec 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -331,7 +331,7 @@ func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) { if err != nil { t.Fatal(err) } - request := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay, nil) + request := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay+"&view=day", nil) request.AddCookie(&http.Cookie{Name: "teammate_session", Value: token}) response := httptest.NewRecorder() s.http.Handler.ServeHTTP(response, request) @@ -372,6 +372,14 @@ func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) { t.Fatalf("week page does not include %q", expected) } } + + defaultRequest := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay, nil) + defaultRequest.AddCookie(&http.Cookie{Name: "teammate_session", Value: token}) + defaultResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(defaultResponse, defaultRequest) + if defaultResponse.Code != http.StatusOK || !strings.Contains(defaultResponse.Body.String(), "TEAM WEEK") { + t.Fatalf("default team view is not weekly: %d %s", defaultResponse.Code, defaultResponse.Body.String()) + } } func TestWorkUpdateSubmissionFeedAndCSV(t *testing.T) { @@ -487,6 +495,30 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { t.Fatal(err) } adminCookie := &http.Cookie{Name: "teammate_session", Value: adminToken} + for _, tag := range []struct { + name string + color string + }{ + {"Backend", "green"}, + {"Urgent", "red"}, + } { + response := formRequest(t, s.http.Handler, "/board/tags", url.Values{ + "csrf": {adminCSRF}, + "name": {tag.name}, + "color": {tag.color}, + }, adminCookie) + if response.Code != http.StatusSeeOther { + t.Fatalf("create board tag %s: %d %s", tag.name, response.Code, response.Body.String()) + } + } + tags, err := s.store.BoardTags() + if err != nil || len(tags) != 2 { + t.Fatalf("board tags after create: %#v, %v", tags, err) + } + reusedTag, err := s.store.FindOrCreateBoardTag("backend", "red") + if err != nil || reusedTag.ID != tags[0].ID || reusedTag.Color != "green" { + t.Fatalf("existing inline tag was not reused: %#v, %v", reusedTag, err) + } create := formRequest(t, s.http.Handler, "/board/tasks", url.Values{ "csrf": {adminCSRF}, "title": {"Ship the onboarding flow"}, @@ -494,6 +526,12 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { "status": {"backlog"}, "assignee_id": {strconv.FormatInt(memberID, 10)}, "due_date": {"1405-05-06"}, + "tag_ids": { + strconv.FormatInt(tags[0].ID, 10), + strconv.FormatInt(tags[1].ID, 10), + }, + "new_tag_name": {"Frontend"}, + "new_tag_color": {"blue"}, }, adminCookie) if create.Code != http.StatusSeeOther || create.Header().Get("Location") != "/board?flash=Task+added+to+the+team+board" { t.Fatalf("create board task: got %d location %q body %s", create.Code, create.Header().Get("Location"), create.Body.String()) @@ -505,6 +543,9 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { if tasks[0].AssigneeName != "Board Member" || tasks[0].DueDate != "2026-07-28" { t.Fatalf("unexpected assigned board task: %#v", tasks[0]) } + if len(tasks[0].Tags) != 3 { + t.Fatalf("task tags after create: %#v", tasks[0].Tags) + } memberToken, memberCSRF, err := s.store.CreateSession(memberID) if err != nil { @@ -518,12 +559,34 @@ 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", "/static/board.js", `data-board-column="backlog"`} { + 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"`} { if !strings.Contains(boardResponse.Body.String(), expected) { t.Fatalf("board page does not include %q", expected) } } + tagPath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/tags" + setTags := formRequest(t, s.http.Handler, tagPath, url.Values{ + "csrf": {memberCSRF}, + "tag_ids": {strconv.FormatInt(tags[0].ID, 10)}, + }, memberCookie) + if setTags.Code != http.StatusSeeOther { + t.Fatalf("set task tags status: %d", setTags.Code) + } + tasks, _ = s.store.BoardTasks() + 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) + } + + deleteTagPath := "/board/tags/" + strconv.FormatInt(tags[1].ID, 10) + "/delete" + deleteTag := formRequest(t, s.http.Handler, deleteTagPath, url.Values{"csrf": {adminCSRF}}, adminCookie) + if deleteTag.Code != http.StatusSeeOther { + t.Fatalf("delete tag status: %d", deleteTag.Code) + } + if remainingTags, _ := s.store.BoardTags(); len(remainingTags) != 2 { + t.Fatalf("tag manager did not delete tag: %#v", remainingTags) + } + movePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/move" move := formRequest(t, s.http.Handler, movePath, url.Values{ "csrf": {memberCSRF}, diff --git a/internal/app/static/style.css b/internal/app/static/style.css index 9e3f517..3af80b9 100644 --- a/internal/app/static/style.css +++ b/internal/app/static/style.css @@ -248,12 +248,53 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .report-export-grid .report-form .button { grid-column: 1 / -1; } .board-page-head { position: relative; align-items: center; } +.board-head-actions { display: flex; gap: 8px; align-items: center; } .new-task-panel { position: relative; } .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-head h2 { margin-bottom: 0; } +.tag-manager-panel { position: relative; } +.tag-manager-panel > summary { list-style: none; user-select: none; } +.tag-manager-panel > summary::-webkit-details-marker { display: none; } +.tag-manager { position: absolute; z-index: 61; top: calc(100% + 9px); right: 0; width: min(440px, 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); } +.tag-manager h2 { margin-bottom: 3px; } +.tag-manager > div:first-child > p:last-child { color: var(--muted); font-size: .7rem; } +.tag-create-form { display: grid; grid-template-columns: 1fr 110px auto; gap: 8px; align-items: end; margin-top: 18px; } +.tag-create-form input, .tag-create-form select { margin-top: 5px; padding-top: 9px; padding-bottom: 9px; } +.tag-create-form .button { min-height: 39px; } +.tag-manager-list { display: grid; gap: 6px; max-height: 240px; margin-top: 18px; overflow-y: auto; } +.tag-manager-list > div { display: flex; justify-content: space-between; align-items: center; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; background: var(--paper); } +.tag-manager-list > p { margin: 0; padding: 18px; color: var(--muted); font-size: .7rem; text-align: center; } +.tag-delete { position: relative; color: var(--muted); font-size: .6rem; } +.tag-delete > summary { cursor: pointer; list-style: none; text-decoration: underline; } +.tag-delete > summary::-webkit-details-marker { display: none; } +.tag-delete[open] > summary { visibility: hidden; } +.tag-delete form { position: absolute; right: 0; bottom: -1px; } +.tag-delete .text-button { color: var(--red); font-weight: 700; } +.board-tag { display: inline-flex; align-items: center; max-width: 100%; min-height: 21px; padding: 3px 8px; border-radius: 6px; color: #fff; font-size: .55rem; font-weight: 700; line-height: 1.2; overflow-wrap: anywhere; } +.board-tag.green { background: #3f8966; } +.board-tag.blue { background: #4e7eae; } +.board-tag.red { background: #b65b53; } +.board-tag.amber { background: #a77931; } +.board-tag.purple { background: #7a62a4; } +.board-tag.slate { background: #65736c; } +.card-tag-picker { margin: 0; padding: 0; border: 0; } +.card-tag-picker legend { width: 100%; margin-bottom: 8px; color: #49564f; font-size: .72rem; font-weight: 700; } +.card-tag-picker legend small { float: right; color: #9aa39e; font-weight: 500; } +.card-tag-picker > .tag-options, .card-tag-editor form > div { display: flex; flex-wrap: wrap; gap: 6px; } +.card-tag-picker label, .card-tag-editor label { position: relative; cursor: pointer; } +.card-tag-picker .tag-options input, .card-tag-editor input { position: absolute; opacity: 0; pointer-events: none; } +.card-tag-picker .tag-options input:not(:checked) + .board-tag, .card-tag-editor input:not(:checked) + .board-tag { opacity: .38; filter: saturate(.45); } +.card-tag-picker .tag-options input:focus-visible + .board-tag, .card-tag-editor input:focus-visible + .board-tag { outline: 2px solid var(--ink); outline-offset: 2px; } +.tag-picker-empty { margin: 0; padding: 10px 12px; border: 1px dashed #bdc6c1; border-radius: 9px; background: var(--paper); color: var(--muted); font-size: .66rem; line-height: 1.5; } +.inline-tag-creator { margin-top: 9px; padding: 9px 11px; border: 1px dashed var(--line); border-radius: 9px; background: var(--paper); } +.inline-tag-creator > summary { color: var(--green); font-size: .66rem; font-weight: 700; cursor: pointer; list-style: none; } +.inline-tag-creator > summary::-webkit-details-marker { display: none; } +.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; } .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; } @@ -271,6 +312,7 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .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; align-items: center; margin-bottom: 9px; } .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; } @@ -283,6 +325,11 @@ 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-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; } .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; } @@ -506,6 +553,8 @@ html[data-theme="dark"] .blocked-icon { background: #432725; color: #efa09a; } 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"] .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; } @@ -592,6 +641,11 @@ html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142 .report-export-grid .report-card { grid-template-columns: 1fr; } .board-page-head { align-items: center; } .new-task-form { position: fixed; top: 76px; right: 15px; left: 15px; width: auto; max-height: calc(100vh - 100px); overflow-y: auto; } + .board-head-actions { align-items: stretch; } + .board-head-actions .button { min-width: 0; padding-right: 12px; padding-left: 12px; } + .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; } .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; } .filter-tabs { display: none; } diff --git a/internal/app/store.go b/internal/app/store.go index 0337738..e7558d2 100644 --- a/internal/app/store.go +++ b/internal/app/store.go @@ -103,6 +103,13 @@ type BoardTask struct { CreatorName string DueDate string CreatedAt string + Tags []BoardTag +} + +type BoardTag struct { + ID int64 + Name string + Color string } type Store struct{ db *sql.DB } @@ -196,6 +203,18 @@ 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_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + color TEXT NOT NULL DEFAULT 'green', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS board_task_tags ( + task_id INTEGER NOT NULL REFERENCES board_tasks(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES board_tags(id) ON DELETE CASCADE, + PRIMARY KEY(task_id,tag_id) +); +CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,task_id); ` if _, err := s.db.Exec(schema); err != nil { return err @@ -772,14 +791,14 @@ 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) error { +func (s *Store) CreateBoardTask(creatorID int64, title, description, status string, assigneeID *int64, dueDate string) (int64, error) { title = strings.TrimSpace(title) description = strings.TrimSpace(description) if title == "" { - return errors.New("task title is required") + return 0, errors.New("task title is required") } if len([]rune(title)) > 160 || len([]rune(description)) > 4000 { - return errors.New("task title or description is too long") + return 0, errors.New("task title or description is too long") } if !validBoardStatus(status) { status = "backlog" @@ -792,9 +811,12 @@ func (s *Store) CreateBoardTask(creatorID int64, title, description, status stri if dueDate != "" { due = dueDate } - _, err := s.db.Exec(`INSERT INTO board_tasks(title,description,status,assignee_id,creator_id,due_date) + 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) - return err + if err != nil { + return 0, err + } + return result.LastInsertId() } func (s *Store) BoardTasks() ([]BoardTask, error) { @@ -811,7 +833,6 @@ func (s *Store) BoardTasks() ([]BoardTask, error) { if err != nil { return nil, err } - defer rows.Close() var tasks []BoardTask for rows.Next() { var task BoardTask @@ -824,7 +845,35 @@ func (s *Store) BoardTasks() ([]BoardTask, error) { } tasks = append(tasks, task) } - return tasks, rows.Err() + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + taskIndex := make(map[int64]int, len(tasks)) + for index := range tasks { + taskIndex[tasks[index].ID] = index + } + 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`) + if err != nil { + return nil, err + } + defer tagRows.Close() + for tagRows.Next() { + var taskID int64 + var tag BoardTag + if err := tagRows.Scan(&taskID, &tag.ID, &tag.Name, &tag.Color); err != nil { + return nil, err + } + if index, ok := taskIndex[taskID]; ok { + tasks[index].Tags = append(tasks[index].Tags, tag) + } + } + return tasks, tagRows.Err() } func (s *Store) MoveBoardTask(id int64, status string) error { @@ -859,3 +908,106 @@ func (s *Store) DeleteBoardTask(id, userID int64, admin bool) error { } return nil } + +func validTagColor(color string) bool { + switch color { + case "green", "blue", "red", "amber", "purple", "slate": + return true + default: + return false + } +} + +func (s *Store) CreateBoardTag(name, color string) error { + name = strings.TrimSpace(name) + color = strings.TrimSpace(color) + if name == "" || len([]rune(name)) > 40 { + return errors.New("tag name must be between 1 and 40 characters") + } + if !validTagColor(color) { + return errors.New("choose a valid tag color") + } + _, err := s.db.Exec(`INSERT INTO board_tags(name,color) VALUES(?,?)`, name, color) + if err != nil && strings.Contains(strings.ToLower(err.Error()), "unique") { + return errors.New("a tag with that name already exists") + } + return err +} + +func (s *Store) FindOrCreateBoardTag(name, color string) (BoardTag, error) { + name = strings.TrimSpace(name) + color = strings.TrimSpace(color) + if name == "" || len([]rune(name)) > 40 { + return BoardTag{}, errors.New("tag name must be between 1 and 40 characters") + } + if !validTagColor(color) { + return BoardTag{}, errors.New("choose a valid tag color") + } + if _, err := s.db.Exec(`INSERT INTO board_tags(name,color) VALUES(?,?) + ON CONFLICT(name) DO NOTHING`, name, color); err != nil { + return BoardTag{}, err + } + var tag BoardTag + err := s.db.QueryRow(`SELECT id,name,color FROM board_tags + WHERE name = ? COLLATE NOCASE`, name).Scan(&tag.ID, &tag.Name, &tag.Color) + return tag, err +} + +func (s *Store) BoardTags() ([]BoardTag, error) { + rows, err := s.db.Query(`SELECT id,name,color FROM board_tags ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + var tags []BoardTag + for rows.Next() { + var tag BoardTag + if err := rows.Scan(&tag.ID, &tag.Name, &tag.Color); err != nil { + return nil, err + } + tags = append(tags, tag) + } + return tags, rows.Err() +} + +func (s *Store) SetBoardTaskTags(taskID int64, tagIDs []int64) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var exists int + if err := tx.QueryRow(`SELECT COUNT(*) FROM board_tasks WHERE id=?`, taskID).Scan(&exists); err != nil { + return err + } + if exists == 0 { + return errors.New("task was not found") + } + if _, err := tx.Exec(`DELETE FROM board_task_tags WHERE task_id=?`, taskID); err != nil { + return err + } + seen := map[int64]bool{} + for _, tagID := range tagIDs { + if tagID < 1 || seen[tagID] { + continue + } + seen[tagID] = true + if _, err := tx.Exec(`INSERT INTO board_task_tags(task_id,tag_id) + SELECT ?,id FROM board_tags WHERE id=?`, taskID, tagID); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Store) DeleteBoardTag(id int64) error { + result, err := s.db.Exec(`DELETE FROM board_tags WHERE id=?`, id) + if err != nil { + return err + } + count, _ := result.RowsAffected() + if count == 0 { + return errors.New("tag was not found") + } + return nil +} diff --git a/internal/app/templates/board.html b/internal/app/templates/board.html index 8222f74..0a368fb 100644 --- a/internal/app/templates/board.html +++ b/internal/app/templates/board.html @@ -2,6 +2,24 @@ {{template "shell-start" .}}

SHARED WORKSPACE

Team board

Plan, assign, and move work forward together.

+
+
+ ◉ Manage tags +
+

BOARD LABELS

Tag manager

Create reusable labels for every card.

+
+ + + + +
+
+ {{range .BoardTags}} +
{{.Name}}
Delete
+ {{else}}

No tags yet. Create the first one above.

{{end}} +
+
+
+ Add task
@@ -9,6 +27,21 @@

NEW CARD

Add work to the board

+
+ Tags Optional · select multiple +
+ {{range .BoardTags}} + {{else}}

No tags yet. Create the first one below.

{{end}} +
+
+ + Create a new tag +
+ + +
+ The new tag will be created and added to this card. +
+
@@ -17,6 +50,7 @@
+
{{template "notice" .}} @@ -29,7 +63,9 @@
{{range .Tasks}} + {{$task := .}}
+ {{if .Tags}}
{{range .Tags}}{{.Name}}{{end}}
{{end}}
#{{.ID}}{{if .DueDate}}Due {{dateFA .DueDate}}{{end}}

{{.Title}}

{{if .Description}}

{{.Description}}

{{end}} @@ -37,6 +73,16 @@ {{if .AssigneeID.Valid}}{{initial .AssigneeName}}{{.AssigneeName}}{{else}}?Unassigned{{end}} by {{.CreatorName}}
+ {{if $.BoardTags}} +
+ Labels +
+ +
{{range $.BoardTags}}{{end}}
+ +
+
+ {{end}}
diff --git a/internal/app/templates/calendar.html b/internal/app/templates/calendar.html index 35d4931..81a4fe5 100644 --- a/internal/app/templates/calendar.html +++ b/internal/app/templates/calendar.html @@ -17,7 +17,7 @@
- {{if .Week.Enabled}}{{end}} +