feat: enhance team planning and deployment
Test and publish / verify (push) Successful in 2m45s

This commit is contained in:
2026-07-29 12:09:21 +03:30
parent c3083b137f
commit eba6b3565c
10 changed files with 472 additions and 22 deletions
+7 -1
View File
@@ -1,4 +1,4 @@
.PHONY: run test build docker-build .PHONY: run test build docker-build compose-up compose-down
run: run:
go run ./cmd/server go run ./cmd/server
@@ -11,3 +11,9 @@ build:
docker-build: docker-build:
docker build -f build/dockerfile -t hamkar:local . 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
+26 -2
View File
@@ -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 - Office and remote check-in/check-out
- Persian month calendar with attendance, approved leave, remote days, Fridays, and fixed Solar Hijri public holidays - 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 - Team day detail showing who is present, remote, absent, or expected at the office
- SaturdayFriday team week schedule with calendar-style presence records - 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 - 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 - Time-off and remote-day requests with a dependency-free Jalali date picker and typed Persian-date fallback
- Admin approval/rejection with review notes - Admin approval/rejection with review notes
- Excel-compatible UTF-8 CSV reports for attendance and teammate work updates - Excel-compatible UTF-8 CSV reports for attendance and teammate work updates
@@ -85,6 +85,30 @@ docker run --rm -p 8080:8080 \
hamkar:local hamkar:local
``` ```
### Docker Compose
Compose builds from `build/dockerfile`, persists SQLite in the `hamkar-data` volume, and publishes the application at <http://localhost:8080>.
```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 ## 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. 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.
+35
View File
@@ -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:
+74 -4
View File
@@ -95,6 +95,7 @@ type PageData struct {
ReportSummary []PersonReportSummary ReportSummary []PersonReportSummary
ReportTotals ReportTotals ReportTotals ReportTotals
Board []BoardColumn Board []BoardColumn
BoardTags []BoardTag
} }
type Stats struct{ Present, Remote, Leave int } type Stats struct{ Present, Remote, Leave int }
@@ -207,6 +208,14 @@ func New(cfg Config) (*Server, error) {
r, _ := utf8.DecodeRuneInString(v) r, _ := utf8.DecodeRuneInString(v)
return string(r) 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") tmpl, err := template.New("root").Funcs(funcs).ParseFS(assets, "templates/*.html")
if err != nil { 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", 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}/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}/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("GET /admin/requests", s.requireAdmin(s.adminPage))
mux.HandleFunc("POST /admin/requests/{id}/review", s.requireAdmin(s.csrf(s.reviewRequest))) mux.HandleFunc("POST /admin/requests/{id}/review", s.requireAdmin(s.csrf(s.reviewRequest)))
mux.HandleFunc("GET /admin/users", s.requireAdmin(s.usersPage)) 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) selected, _ := time.Parse("2006-01-02", day)
view := r.URL.Query().Get("view") view := r.URL.Query().Get("view")
if view != "week" { if view != "day" {
view = "day" view = "week"
} }
var detail DayDetail var detail DayDetail
var week WeekDetail 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) http.Error(w, "could not load teammates", http.StatusInternalServerError)
return return
} }
tags, err := s.store.BoardTags()
if err != nil {
http.Error(w, "could not load board tags", http.StatusInternalServerError)
return
}
columns := []BoardColumn{ columns := []BoardColumn{
{Status: "backlog", Title: "Backlog", Hint: "Ready to pick up"}, {Status: "backlog", Title: "Backlog", Hint: "Ready to pick up"},
{Status: "in_progress", Title: "In progress", Hint: "Actively being worked"}, {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{ 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"), TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"),
Flash: r.URL.Query().Get("flash"), SelectedSection: "board", Flash: r.URL.Query().Get("flash"), SelectedSection: "board",
}) })
} }
func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) { 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 var assigneeID *int64
if raw := r.FormValue("assignee_id"); raw != "" { if raw := r.FormValue("assignee_id"); raw != "" {
id, err := strconv.ParseInt(raw, 10, 64) id, err := strconv.ParseInt(raw, 10, 64)
@@ -777,7 +798,16 @@ func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
} }
dueDate = parsed 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"), currentUser(r).ID, r.FormValue("title"), r.FormValue("description"),
r.FormValue("status"), assigneeID, dueDate, 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) http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return 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) 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) { func (s *Server) moveBoardTask(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := s.store.MoveBoardTask(id, r.FormValue("status")); err != nil { 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) 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) { func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) {
requests, err := s.store.Requests(currentUser(r).ID, true, r.URL.Query().Get("status")) requests, err := s.store.Requests(currentUser(r).ID, true, r.URL.Query().Get("status"))
if err != nil { if err != nil {
+65 -2
View File
@@ -331,7 +331,7 @@ func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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}) request.AddCookie(&http.Cookie{Name: "teammate_session", Value: token})
response := httptest.NewRecorder() response := httptest.NewRecorder()
s.http.Handler.ServeHTTP(response, request) s.http.Handler.ServeHTTP(response, request)
@@ -372,6 +372,14 @@ func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) {
t.Fatalf("week page does not include %q", expected) 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) { func TestWorkUpdateSubmissionFeedAndCSV(t *testing.T) {
@@ -487,6 +495,30 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
adminCookie := &http.Cookie{Name: "teammate_session", Value: adminToken} 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{ create := formRequest(t, s.http.Handler, "/board/tasks", url.Values{
"csrf": {adminCSRF}, "csrf": {adminCSRF},
"title": {"Ship the onboarding flow"}, "title": {"Ship the onboarding flow"},
@@ -494,6 +526,12 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
"status": {"backlog"}, "status": {"backlog"},
"assignee_id": {strconv.FormatInt(memberID, 10)}, "assignee_id": {strconv.FormatInt(memberID, 10)},
"due_date": {"1405-05-06"}, "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) }, adminCookie)
if create.Code != http.StatusSeeOther || create.Header().Get("Location") != "/board?flash=Task+added+to+the+team+board" { 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()) 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" { if tasks[0].AssigneeName != "Board Member" || tasks[0].DueDate != "2026-07-28" {
t.Fatalf("unexpected assigned board task: %#v", tasks[0]) 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) memberToken, memberCSRF, err := s.store.CreateSession(memberID)
if err != nil { if err != nil {
@@ -518,12 +559,34 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
if boardResponse.Code != http.StatusOK { if boardResponse.Code != http.StatusOK {
t.Fatalf("board page status: %d body %s", boardResponse.Code, boardResponse.Body.String()) t.Fatalf("board page status: %d body %s", boardResponse.Code, boardResponse.Body.String())
} }
for _, expected := range []string{"Ship the onboarding flow", "Board Member", "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) { if !strings.Contains(boardResponse.Body.String(), expected) {
t.Fatalf("board page does not include %q", expected) t.Fatalf("board page does not include %q", expected)
} }
} }
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" movePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/move"
move := formRequest(t, s.http.Handler, movePath, url.Values{ move := formRequest(t, s.http.Handler, movePath, url.Values{
"csrf": {memberCSRF}, "csrf": {memberCSRF},
+54
View File
@@ -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; } .report-export-grid .report-form .button { grid-column: 1 / -1; }
.board-page-head { position: relative; align-items: center; } .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 { position: relative; }
.new-task-panel > summary { list-style: none; user-select: none; } .new-task-panel > summary { list-style: none; user-select: none; }
.new-task-panel > summary::-webkit-details-marker { display: none; } .new-task-panel > summary::-webkit-details-marker { display: none; }
.new-task-panel[open] > summary { background: var(--ink); } .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)); 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; } .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-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 { 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; } .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:hover { transform: translateY(-1px); box-shadow: 0 7px 18px rgba(24, 36, 31, .09); }
.kanban-card.dragging { opacity: .45; transform: rotate(1.5deg); } .kanban-card.dragging { opacity: .45; transform: rotate(1.5deg); }
.kanban-card.moving { opacity: .35; pointer-events: none; } .kanban-card.moving { opacity: .35; pointer-events: none; }
.card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 9px; }
.card-topline { display: flex; justify-content: space-between; align-items: center; 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-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; } .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 span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-assignee.unassigned { color: var(--muted); font-weight: 500; } .task-assignee.unassigned { color: var(--muted); font-weight: 500; }
.task-creator { overflow: hidden; color: #9aa39e; font-size: .54rem; text-overflow: ellipsis; white-space: nowrap; } .task-creator { overflow: hidden; color: #9aa39e; font-size: .54rem; text-overflow: ellipsis; white-space: nowrap; }
.card-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 { 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 > form:first-child { display: flex; gap: 4px; align-items: center; }
.task-actions select { width: auto; min-width: 104px; margin: 0; padding: 5px 25px 5px 7px; border: 0; background-color: var(--paper); font-size: .58rem; font-weight: 700; } .task-actions select { width: auto; min-width: 104px; margin: 0; padding: 5px 25px 5px 7px; border: 0; background-color: var(--paper); font-size: .58rem; font-weight: 700; }
@@ -506,6 +553,8 @@ html[data-theme="dark"] .blocked-icon { background: #432725; color: #efa09a; }
html[data-theme="dark"] .metric.pending, html[data-theme="dark"] .metric.pending,
html[data-theme="dark"] .pending-icon { background: #453820; color: #e3bd72; } 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"] .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-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 { 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"] .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; } .report-export-grid .report-card { grid-template-columns: 1fr; }
.board-page-head { align-items: center; } .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; } .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-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; } .kanban-column { scroll-snap-align: start; }
.filter-tabs { display: none; } .filter-tabs { display: none; }
+159 -7
View File
@@ -103,6 +103,13 @@ type BoardTask struct {
CreatorName string CreatorName string
DueDate string DueDate string
CreatedAt string CreatedAt string
Tags []BoardTag
}
type BoardTag struct {
ID int64
Name string
Color string
} }
type Store struct{ db *sql.DB } 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 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at); 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 { if _, err := s.db.Exec(schema); err != nil {
return err return err
@@ -772,14 +791,14 @@ func validBoardStatus(status string) bool {
return status == "backlog" || status == "in_progress" || status == "blocked" || status == "done" 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) title = strings.TrimSpace(title)
description = strings.TrimSpace(description) description = strings.TrimSpace(description)
if title == "" { 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 { 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) { if !validBoardStatus(status) {
status = "backlog" status = "backlog"
@@ -792,9 +811,12 @@ func (s *Store) CreateBoardTask(creatorID int64, title, description, status stri
if dueDate != "" { if dueDate != "" {
due = 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) VALUES(?,?,?,?,?,?)`, title, description, status, assignee, creatorID, due)
return err if err != nil {
return 0, err
}
return result.LastInsertId()
} }
func (s *Store) BoardTasks() ([]BoardTask, error) { func (s *Store) BoardTasks() ([]BoardTask, error) {
@@ -811,7 +833,6 @@ func (s *Store) BoardTasks() ([]BoardTask, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var tasks []BoardTask var tasks []BoardTask
for rows.Next() { for rows.Next() {
var task BoardTask var task BoardTask
@@ -824,7 +845,35 @@ func (s *Store) BoardTasks() ([]BoardTask, error) {
} }
tasks = append(tasks, task) 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 { 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 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
}
+46
View File
@@ -2,6 +2,24 @@
{{template "shell-start" .}} {{template "shell-start" .}}
<header class="page-head board-page-head"> <header class="page-head board-page-head">
<div><p class="eyebrow">SHARED WORKSPACE</p><h1>Team board</h1><p>Plan, assign, and move work forward together.</p></div> <div><p class="eyebrow">SHARED WORKSPACE</p><h1>Team board</h1><p>Plan, assign, and move work forward together.</p></div>
<div class="board-head-actions">
<details class="tag-manager-panel">
<summary class="button secondary">◉ Manage tags</summary>
<div class="tag-manager">
<div><p class="eyebrow">BOARD LABELS</p><h2>Tag manager</h2><p>Create reusable labels for every card.</p></div>
<form method="post" action="/board/tags" class="tag-create-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Tag name<input name="name" required maxlength="40" placeholder="e.g. Backend"></label>
<label>Color<select name="color"><option value="green">Green</option><option value="blue">Blue</option><option value="red">Red</option><option value="amber">Amber</option><option value="purple">Purple</option><option value="slate">Slate</option></select></label>
<button class="button primary" type="submit">Add tag</button>
</form>
<div class="tag-manager-list">
{{range .BoardTags}}
<div><span class="board-tag {{.Color}}">{{.Name}}</span><details class="tag-delete"><summary>Delete</summary><form method="post" action="/board/tags/{{.ID}}/delete"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="text-button" type="submit">Confirm</button></form></details></div>
{{else}}<p>No tags yet. Create the first one above.</p>{{end}}
</div>
</div>
</details>
<details class="new-task-panel" {{if .Error}}open{{end}}> <details class="new-task-panel" {{if .Error}}open{{end}}>
<summary class="button primary"> Add task</summary> <summary class="button primary"> Add task</summary>
<form method="post" action="/board/tasks" class="new-task-form"> <form method="post" action="/board/tasks" class="new-task-form">
@@ -9,6 +27,21 @@
<div class="new-task-form-head"><div><p class="eyebrow">NEW CARD</p><h2>Add work to the board</h2></div></div> <div class="new-task-form-head"><div><p class="eyebrow">NEW CARD</p><h2>Add work to the board</h2></div></div>
<label>Task title<input name="title" required maxlength="160" autofocus placeholder="What needs to be done?"></label> <label>Task title<input name="title" required maxlength="160" autofocus placeholder="What needs to be done?"></label>
<label>Description <small>Optional</small><textarea name="description" rows="4" maxlength="4000" placeholder="Add context, acceptance criteria, or links…"></textarea></label> <label>Description <small>Optional</small><textarea name="description" rows="4" maxlength="4000" placeholder="Add context, acceptance criteria, or links…"></textarea></label>
<fieldset class="card-tag-picker">
<legend>Tags <small>Optional · select multiple</small></legend>
<div class="tag-options">
{{range .BoardTags}}<label><input type="checkbox" name="tag_ids" value="{{.ID}}"><span class="board-tag {{.Color}}">{{.Name}}</span></label>
{{else}}<p class="tag-picker-empty">No tags yet. Create the first one below.</p>{{end}}
</div>
<details class="inline-tag-creator">
<summary> Create a new tag</summary>
<div class="inline-tag-fields">
<label>Tag name<input name="new_tag_name" maxlength="40" placeholder="e.g. Backend"></label>
<label>Color<select name="new_tag_color"><option value="green">Green</option><option value="blue">Blue</option><option value="red">Red</option><option value="amber">Amber</option><option value="purple">Purple</option><option value="slate">Slate</option></select></label>
</div>
<small>The new tag will be created and added to this card.</small>
</details>
</fieldset>
<div class="form-row"> <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>Assignee<select name="assignee_id"><option value="">Unassigned</option>{{range .Users}}<option value="{{.ID}}">{{.DisplayName}}</option>{{end}}</select></label>
<label>Column<select name="status"><option value="backlog">Backlog</option><option value="in_progress">In progress</option><option value="blocked">Blocked</option><option value="done">Done</option></select></label> <label>Column<select name="status"><option value="backlog">Backlog</option><option value="in_progress">In progress</option><option value="blocked">Blocked</option><option value="done">Done</option></select></label>
@@ -17,6 +50,7 @@
<button class="button dark full" type="submit">Create task</button> <button class="button dark full" type="submit">Create task</button>
</form> </form>
</details> </details>
</div>
</header> </header>
{{template "notice" .}} {{template "notice" .}}
@@ -29,7 +63,9 @@
</header> </header>
<div class="kanban-cards" data-drop-zone="{{.Status}}"> <div class="kanban-cards" data-drop-zone="{{.Status}}">
{{range .Tasks}} {{range .Tasks}}
{{$task := .}}
<article class="kanban-card" draggable="true" data-task-id="{{.ID}}"> <article class="kanban-card" draggable="true" data-task-id="{{.ID}}">
{{if .Tags}}<div class="card-tags">{{range .Tags}}<span class="board-tag {{.Color}}">{{.Name}}</span>{{end}}</div>{{end}}
<div class="card-topline"><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>{{if .DueDate}}<span class="task-due">Due {{dateFA .DueDate}}</span>{{end}}</div>
<h3>{{.Title}}</h3> <h3>{{.Title}}</h3>
{{if .Description}}<p>{{.Description}}</p>{{end}} {{if .Description}}<p>{{.Description}}</p>{{end}}
@@ -37,6 +73,16 @@
{{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 .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}}
<span class="task-creator">by {{.CreatorName}}</span> <span class="task-creator">by {{.CreatorName}}</span>
</div> </div>
{{if $.BoardTags}}
<details class="card-tag-editor">
<summary>Labels</summary>
<form method="post" action="/board/tasks/{{.ID}}/tags">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<div>{{range $.BoardTags}}<label><input type="checkbox" name="tag_ids" value="{{.ID}}" {{if hasTag $task.Tags .ID}}checked{{end}}><span class="board-tag {{.Color}}">{{.Name}}</span></label>{{end}}</div>
<button class="button primary full" type="submit">Save labels</button>
</form>
</details>
{{end}}
<div class="task-actions"> <div class="task-actions">
<form method="post" action="/board/tasks/{{.ID}}/move"> <form method="post" action="/board/tasks/{{.ID}}/move">
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
+1 -1
View File
@@ -17,7 +17,7 @@
<div class="calendar-grid days"> <div class="calendar-grid days">
{{range .Calendar.Cells}} {{range .Calendar.Cells}}
{{if .InMonth}} {{if .InMonth}}
<a href="/day?date={{.Gregorian}}" class="day {{if .IsToday}}today{{end}} {{if .IsFriday}}friday{{end}} {{if .Holiday}}holiday{{end}}" title="View team details for this day"> <a href="/day?date={{.Gregorian}}&view=day" class="day {{if .IsToday}}today{{end}} {{if .IsFriday}}friday{{end}} {{if .Holiday}}holiday{{end}}" title="View team details for this day">
<span class="day-number">{{.Day}}</span> <span class="day-number">{{.Day}}</span>
{{if .Status}}<i class="status-dot {{.Status}}" title="{{.Status}}"></i>{{end}} {{if .Status}}<i class="status-dot {{.Status}}" title="{{.Status}}"></i>{{end}}
{{if .Holiday}}<small title="{{.Holiday}}">{{.Holiday}}</small>{{end}} {{if .Holiday}}<small title="{{.Holiday}}">{{.Holiday}}</small>{{end}}
+5 -5
View File
@@ -8,11 +8,11 @@
</div> </div>
<div class="day-view-controls"> <div class="day-view-controls">
<nav class="view-switch" aria-label="Schedule view"> <nav class="view-switch" aria-label="Schedule view">
<a href="/day?date={{.Day.Jalali}}" class="{{if not .Week.Enabled}}active{{end}}">Day</a> <a href="/day?date={{.Day.Jalali}}&view=day" class="{{if not .Week.Enabled}}active{{end}}">Day</a>
<a href="/day?date={{.Day.Jalali}}&view=week" class="{{if .Week.Enabled}}active{{end}}">Week</a> <a href="/day?date={{.Day.Jalali}}&view=week" class="{{if .Week.Enabled}}active{{end}}">Week</a>
</nav> </nav>
<form method="get" action="/day" class="day-date-form"> <form method="get" action="/day" class="day-date-form">
{{if .Week.Enabled}}<input type="hidden" name="view" value="week">{{end}} <input type="hidden" name="view" value="{{if .Week.Enabled}}week{{else}}day{{end}}">
<label>Persian date <label>Persian date
<span class="jalali-field"> <span class="jalali-field">
<input name="date" value="{{.Day.Jalali}}" data-jalali-picker required autocomplete="off" inputmode="numeric" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"> <input name="date" value="{{.Day.Jalali}}" data-jalali-picker required autocomplete="off" inputmode="numeric" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
@@ -43,7 +43,7 @@
<div class="week-grid"> <div class="week-grid">
{{range .Week.Days}} {{range .Week.Days}}
<section class="week-day-column {{if .IsToday}}today{{end}} {{if eq .Weekday "Friday"}}friday{{end}}"> <section class="week-day-column {{if .IsToday}}today{{end}} {{if eq .Weekday "Friday"}}friday{{end}}">
<a class="week-day-head" href="/day?date={{.Jalali}}"> <a class="week-day-head" href="/day?date={{.Jalali}}&view=day">
<span>{{.Weekday}}</span> <span>{{.Weekday}}</span>
<strong>{{.Jalali}}</strong> <strong>{{.Jalali}}</strong>
<small>{{.Gregorian}}{{if .DayNote}} · {{.DayNote}}{{end}}</small> <small>{{.Gregorian}}{{if .DayNote}} · {{.DayNote}}{{end}}</small>
@@ -71,13 +71,13 @@
<div class="day-layout"> <div class="day-layout">
<section class="card day-summary-card"> <section class="card day-summary-card">
<div class="day-date-nav"> <div class="day-date-nav">
<a href="/day?date={{.Day.Prev}}" aria-label="Previous day"></a> <a href="/day?date={{.Day.Prev}}&view=day" aria-label="Previous day"></a>
<div> <div>
<p class="eyebrow">PERSIAN CALENDAR</p> <p class="eyebrow">PERSIAN CALENDAR</p>
<h2>{{.Day.Jalali}}</h2> <h2>{{.Day.Jalali}}</h2>
<span>{{.Day.Weekday}} · {{.Day.Gregorian}}{{if .Day.DayNote}} · {{.Day.DayNote}}{{end}}</span> <span>{{.Day.Weekday}} · {{.Day.Gregorian}}{{if .Day.DayNote}} · {{.Day.DayNote}}{{end}}</span>
</div> </div>
<a href="/day?date={{.Day.Next}}" aria-label="Next day"></a> <a href="/day?date={{.Day.Next}}&view=day" aria-label="Next day"></a>
</div> </div>
<div class="day-stats"> <div class="day-stats">
<div><i class="presence-mark present"></i><span><strong>{{.Day.Present}}</strong><small>Present</small></span></div> <div><i class="presence-mark present"></i><span><strong>{{.Day.Present}}</strong><small>Present</small></span></div>