First pass on tasks frontend

This commit is contained in:
Arthur Belleville 2026-05-10 23:14:47 +02:00
parent faf3199b71
commit c80a8a875e
No known key found for this signature in database
11 changed files with 3563 additions and 307 deletions

View file

@ -53,6 +53,7 @@ type CreateInput struct {
type UpdateInput struct {
ID uuid.UUID
OwnerID uuid.UUID
TabloID uuid.UUID
Title string
Description string
Status Status
@ -66,6 +67,29 @@ type ListByTabloInput struct {
TabloID uuid.UUID
}
type TaskView string
const (
TaskViewKanban TaskView = "kanban"
TaskViewList TaskView = "list"
TaskViewRoadmap TaskView = "roadmap"
)
type TaskRoadmapMode string
const (
TaskRoadmapModeWeek TaskRoadmapMode = "week"
TaskRoadmapModeMonth TaskRoadmapMode = "month"
)
type TaskPageState struct {
View TaskView
RoadmapMode TaskRoadmapMode
TabloIDs []uuid.UUID
AssigneeIDs []uuid.UUID
Statuses []Status
}
func ParseStatus(raw string) (Status, error) {
switch Status(strings.TrimSpace(raw)) {
case StatusTodo, StatusInProgress, StatusInReview, StatusDone:
@ -74,3 +98,23 @@ func ParseStatus(raw string) (Status, error) {
return "", ErrInvalidStatus
}
}
func NormalizeTaskView(raw string) TaskView {
switch TaskView(strings.TrimSpace(raw)) {
case TaskViewList:
return TaskViewList
case TaskViewRoadmap:
return TaskViewRoadmap
default:
return TaskViewKanban
}
}
func NormalizeTaskRoadmapMode(raw string) TaskRoadmapMode {
switch TaskRoadmapMode(strings.TrimSpace(raw)) {
case TaskRoadmapModeMonth:
return TaskRoadmapModeMonth
default:
return TaskRoadmapModeWeek
}
}

View file

@ -150,18 +150,25 @@ func TestTasksPageSidebarShowsRealTablos(t *testing.T) {
}
body := rec.Body.String()
projectSection := body
if index := strings.Index(body, `id="sidebar-projects-section"`); index >= 0 {
projectSection = body[index:]
if end := strings.Index(projectSection, `<ul class="sidebar-list sidebar-footer-links"`); end >= 0 {
projectSection = projectSection[:end]
}
}
for _, want := range []string{
"Green",
"Purple",
"Red",
"Hidden Fifth",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected sidebar to contain %q, got %q", want, body)
if !strings.Contains(projectSection, want) {
t.Fatalf("expected sidebar to contain %q, got %q", want, projectSection)
}
}
if strings.Contains(body, "Blue") {
t.Fatalf("expected sidebar to limit to the four most recent tablos, got %q", body)
if strings.Contains(projectSection, "Blue") {
t.Fatalf("expected sidebar to limit to the four most recent tablos, got %q", projectSection)
}
if !strings.Contains(body, `href="/tablos/`) {
t.Fatalf("expected sidebar project links, got %q", body)

View file

@ -216,10 +216,14 @@ func (r *InMemoryAuthRepository) UpdateTask(_ context.Context, input UpdateTaskI
if !ok || record.OwnerID != input.OwnerID || record.DeletedAt != nil {
return TaskRecord{}, taskmodel.ErrNotFound
}
if err := r.validateTaskInputLocked(input.OwnerID, record.TabloID, input.Status, input.AssigneeID, record.IsEtape, input.ParentTaskID); err != nil {
if input.TabloID == uuid.Nil {
input.TabloID = record.TabloID
}
if err := r.validateTaskInputLocked(input.OwnerID, input.TabloID, input.Status, input.AssigneeID, record.IsEtape, input.ParentTaskID); err != nil {
return TaskRecord{}, err
}
record.TabloID = input.TabloID
record.Title = input.Title
record.Description = input.Description
record.Status = input.Status

View file

@ -11,6 +11,7 @@ import (
"time"
"github.com/google/uuid"
tablomodel "xtablo-backend/internal/tablos"
taskmodel "xtablo-backend/internal/tasks"
"xtablo-backend/internal/web/views"
)
@ -67,22 +68,9 @@ func (h *AuthHandler) renderTasksPage(w http.ResponseWriter, r *http.Request) {
return
}
assigneeLabels := make(map[uuid.UUID]string)
for _, record := range tasks {
if record.AssigneeID == nil {
continue
}
if _, exists := assigneeLabels[*record.AssigneeID]; exists {
continue
}
publicUser, err := h.repo.GetPublicUserByID(r.Context(), *record.AssigneeID)
if err != nil {
continue
}
assigneeLabels[*record.AssigneeID] = publicUser.DisplayName
}
vm := views.NewTasksPageViewModel(tablos, tasks, assigneeLabels)
assigneeLabels := h.buildAssigneeLabels(r.Context(), user.ID, tasks)
state := parseTaskPageState(r)
vm := views.NewTasksPageViewModel(tablos, tasks, assigneeLabels, state, time.Now().UTC())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
content := views.TasksPageContent(vm)
@ -122,6 +110,16 @@ func (h *AuthHandler) PostTasks() http.HandlerFunc {
return
}
taskRecords, err := h.repo.(taskPageRepository).ListTasksByOwner(r.Context(), user.ID)
if err != nil {
http.Error(w, "failed to load tasks", http.StatusInternalServerError)
return
}
if err := validateTaskTabloAndParent(taskRecords, input.TabloID, input.ParentTaskID); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
if _, err := repo.CreateTask(r.Context(), input); err != nil {
if isTaskValidationError(err) {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
@ -165,16 +163,20 @@ func (h *AuthHandler) GetEditTaskModal() http.HandlerFunc {
return
}
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{OwnerID: user.ID})
if err != nil {
http.Error(w, "failed to load projects", http.StatusInternalServerError)
return
}
taskRecords, err := h.repo.(taskPageRepository).ListTasksByOwner(r.Context(), user.ID)
if err != nil {
http.Error(w, "failed to load tasks", http.StatusInternalServerError)
return
}
assigneeLabels := h.buildAssigneeLabels(r.Context(), user.ID, taskRecords)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprintf(
w,
`<form class="task-edit-form" data-task-id="%s"><input name="title" value="%s"><textarea name="description">%s</textarea><input name="status" value="%s"><input name="assignee_id" value="%s"></form>`,
html.EscapeString(record.ID.String()),
html.EscapeString(record.Title),
html.EscapeString(record.Description),
html.EscapeString(string(record.Status)),
html.EscapeString(optionalUUIDString(record.AssigneeID)),
)
_, _ = fmt.Fprint(w, renderTaskEditForm(record, tablos, taskRecords, assigneeLabels))
}
}
@ -219,6 +221,16 @@ func (h *AuthHandler) PatchTask() http.HandlerFunc {
return
}
taskRecords, err := h.repo.(taskPageRepository).ListTasksByOwner(r.Context(), user.ID)
if err != nil {
http.Error(w, "failed to load tasks", http.StatusInternalServerError)
return
}
if err := validateTaskTabloAndParent(taskRecords, input.TabloID, input.ParentTaskID); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
if _, err := repo.UpdateTask(r.Context(), input); err != nil {
if isTaskValidationError(err) {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
@ -312,6 +324,16 @@ func parseCreateTaskInput(r *http.Request, ownerID uuid.UUID) (CreateTaskInput,
}
func parseUpdateTaskInput(r *http.Request, ownerID uuid.UUID, current TaskRecord) (UpdateTaskInput, error) {
tabloRaw := strings.TrimSpace(r.FormValue("tablo_id"))
tabloID := current.TabloID
if tabloRaw != "" {
parsedTabloID, err := uuid.Parse(tabloRaw)
if err != nil {
return UpdateTaskInput{}, errors.New("tablo_id invalide")
}
tabloID = parsedTabloID
}
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
return UpdateTaskInput{}, errors.New("le titre est requis")
@ -344,6 +366,7 @@ func parseUpdateTaskInput(r *http.Request, ownerID uuid.UUID, current TaskRecord
return UpdateTaskInput{
ID: current.ID,
OwnerID: ownerID,
TabloID: tabloID,
Title: title,
Description: strings.TrimSpace(r.FormValue("description")),
Status: status,
@ -365,6 +388,43 @@ func parseTaskStatusFormValue(raw string) (TaskStatus, error) {
return status, nil
}
func parseTaskPageState(r *http.Request) taskmodel.TaskPageState {
query := r.URL.Query()
state := taskmodel.TaskPageState{
View: taskmodel.NormalizeTaskView(query.Get("view")),
RoadmapMode: taskmodel.NormalizeTaskRoadmapMode(query.Get("roadmap_mode")),
TabloIDs: parseUUIDList(query["tablo"]),
AssigneeIDs: parseUUIDList(query["assignee"]),
Statuses: parseStatusList(query["status"]),
}
if state.View != taskmodel.TaskViewRoadmap {
state.RoadmapMode = taskmodel.TaskRoadmapModeWeek
}
return state
}
func parseUUIDList(values []string) []uuid.UUID {
parsed := make([]uuid.UUID, 0, len(values))
for _, value := range values {
id, err := uuid.Parse(strings.TrimSpace(value))
if err == nil {
parsed = append(parsed, id)
}
}
return parsed
}
func parseStatusList(values []string) []taskmodel.Status {
parsed := make([]taskmodel.Status, 0, len(values))
for _, value := range values {
status, err := taskmodel.ParseStatus(strings.TrimSpace(value))
if err == nil {
parsed = append(parsed, status)
}
}
return parsed
}
func parseOptionalUUID(raw string) (*uuid.UUID, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
@ -399,3 +459,104 @@ func optionalUUIDString(id *uuid.UUID) string {
func isTaskValidationError(err error) bool {
return errors.Is(err, taskmodel.ErrInvalidParent) || errors.Is(err, taskmodel.ErrInvalidAssignee) || errors.Is(err, taskmodel.ErrInvalidStatus)
}
func validateTaskTabloAndParent(records []TaskRecord, tabloID uuid.UUID, parentTaskID *uuid.UUID) error {
if parentTaskID == nil {
return nil
}
for _, record := range records {
if record.ID != *parentTaskID {
continue
}
if !record.IsEtape {
return errors.New("parent_task_id must reference an étape")
}
if record.TabloID != tabloID {
return errors.New("parent_task_id must belong to the selected tablo")
}
return nil
}
return errors.New("parent_task_id must reference an active étape")
}
func (h *AuthHandler) buildAssigneeLabels(ctx context.Context, ownerID uuid.UUID, tasks []TaskRecord) map[uuid.UUID]string {
assigneeLabels := make(map[uuid.UUID]string)
if publicUser, err := h.repo.GetPublicUserByID(ctx, ownerID); err == nil {
assigneeLabels[ownerID] = publicUser.DisplayName
}
for _, record := range tasks {
if record.AssigneeID == nil {
continue
}
if _, exists := assigneeLabels[*record.AssigneeID]; exists {
continue
}
publicUser, err := h.repo.GetPublicUserByID(ctx, *record.AssigneeID)
if err != nil {
continue
}
assigneeLabels[*record.AssigneeID] = publicUser.DisplayName
}
return assigneeLabels
}
func renderTaskEditForm(record TaskRecord, tablos []tablomodel.Record, tasks []TaskRecord, assigneeLabels map[uuid.UUID]string) string {
var builder strings.Builder
builder.WriteString(`<form class="task-edit-form" data-task-id="`)
builder.WriteString(html.EscapeString(record.ID.String()))
builder.WriteString(`">`)
builder.WriteString(`<label>Tablo<select name="tablo_id">`)
for _, tablo := range tablos {
builder.WriteString(`<option value="`)
builder.WriteString(html.EscapeString(tablo.ID.String()))
builder.WriteString(`"`)
if tablo.ID == record.TabloID {
builder.WriteString(` selected`)
}
builder.WriteString(`>`)
builder.WriteString(html.EscapeString(tablo.Name))
builder.WriteString(`</option>`)
}
builder.WriteString(`</select></label>`)
builder.WriteString(`<input name="title" value="`)
builder.WriteString(html.EscapeString(record.Title))
builder.WriteString(`">`)
builder.WriteString(`<textarea name="description">`)
builder.WriteString(html.EscapeString(record.Description))
builder.WriteString(`</textarea>`)
builder.WriteString(`<input name="status" value="`)
builder.WriteString(html.EscapeString(string(record.Status)))
builder.WriteString(`">`)
builder.WriteString(`<input name="assignee_id" value="`)
builder.WriteString(html.EscapeString(optionalUUIDString(record.AssigneeID)))
builder.WriteString(`">`)
builder.WriteString(`<select name="parent_task_id"><option value="">Sans étape</option>`)
for _, task := range tasks {
if !task.IsEtape || task.TabloID != record.TabloID {
continue
}
builder.WriteString(`<option value="`)
builder.WriteString(html.EscapeString(task.ID.String()))
builder.WriteString(`"`)
if record.ParentTaskID != nil && *record.ParentTaskID == task.ID {
builder.WriteString(` selected`)
}
builder.WriteString(`>`)
builder.WriteString(html.EscapeString(task.Title))
builder.WriteString(`</option>`)
}
builder.WriteString(`</select>`)
if len(assigneeLabels) > 0 {
builder.WriteString(`<div class="task-assignees">`)
for id, label := range assigneeLabels {
builder.WriteString(`<span data-assignee-id="`)
builder.WriteString(html.EscapeString(id.String()))
builder.WriteString(`">`)
builder.WriteString(html.EscapeString(label))
builder.WriteString(`</span>`)
}
builder.WriteString(`</div>`)
}
builder.WriteString(`</form>`)
return builder.String()
}

View file

@ -7,9 +7,12 @@ import (
"net/url"
"strings"
"testing"
"time"
"github.com/google/uuid"
tablomodel "xtablo-backend/internal/tablos"
taskmodel "xtablo-backend/internal/tasks"
"xtablo-backend/internal/web/views"
)
func TestGetTasksPageRendersEtapesAndSansEtapeSections(t *testing.T) {
@ -40,13 +43,134 @@ func TestGetTasksPageRendersEtapesAndSansEtapeSections(t *testing.T) {
}
body := rec.Body.String()
for _, want := range []string{"Mes tâches", "Production", "Sans étape", "Inbox task", "Cut footage"} {
for _, want := range []string{"Mes Tâches", "Production", "Sans étape", "Inbox task", "Cut footage"} {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q, got %q", want, body)
}
}
}
func TestGetTasksPageDefaultsToKanbanView(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected authenticated user")
}
tablo := mustCreateOwnedTablo(t, repo, userID)
_ = mustCreateTask(t, repo, userID, tablo.ID, nil, "Shell task")
req = httptest.NewRequest(http.MethodGet, "/tasks", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTasksPage().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`data-current-view="kanban"`,
`Nouvelle tâche`,
`Tableau`,
`Liste`,
`Roadmap`,
`Calendrier`,
`Bientôt`,
`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4`,
`draggable="true"`,
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"`,
`/tasks/`,
`Supprimer la tâche Shell task`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q, got %q", want, body)
}
}
}
func TestGetTasksPageIgnoresInvalidQueryState(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/tasks?view=nope&roadmap_mode=nope&status=bad", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTasksPage().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `data-current-view="kanban"`) {
t.Fatalf("expected normalized kanban view, got %q", body)
}
if strings.Contains(body, `value="bad" selected`) {
t.Fatalf("expected invalid status filter to be dropped, got %q", body)
}
}
func TestTasksListGroupsByStatusAcrossTablos(t *testing.T) {
now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
tabloA := tablomodel.Record{ID: uuid.New(), Name: "Alpha", Color: "#111111"}
tabloB := tablomodel.Record{ID: uuid.New(), Name: "Beta", Color: "#222222"}
records := []taskmodel.Record{
{ID: uuid.New(), TabloID: tabloA.ID, Title: "Write copy", Status: taskmodel.StatusTodo, CreatedAt: now},
{ID: uuid.New(), TabloID: tabloB.ID, Title: "Ship docs", Status: taskmodel.StatusTodo, CreatedAt: now.Add(time.Minute)},
{ID: uuid.New(), TabloID: tabloB.ID, Title: "Review launch", Status: taskmodel.StatusInReview, CreatedAt: now.Add(2 * time.Minute)},
}
vm := views.NewTasksPageViewModel(
[]tablomodel.Record{tabloA, tabloB},
records,
nil,
taskmodel.TaskPageState{View: taskmodel.TaskViewList, RoadmapMode: taskmodel.TaskRoadmapModeWeek},
now,
)
if len(vm.List.Groups) != 4 {
t.Fatalf("expected four status groups, got %d", len(vm.List.Groups))
}
if got := len(vm.List.Groups[0].Tasks); got != 2 {
t.Fatalf("expected two todo tasks across tablos, got %d", got)
}
}
func TestTasksRoadmapCreatesPerTabloSansEtapeLanes(t *testing.T) {
now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
tabloA := tablomodel.Record{ID: uuid.New(), Name: "Alpha"}
tabloB := tablomodel.Record{ID: uuid.New(), Name: "Beta"}
due := now.AddDate(0, 0, 3)
records := []taskmodel.Record{
{ID: uuid.New(), TabloID: tabloA.ID, Title: "A task", Status: taskmodel.StatusTodo, DueDate: &due, CreatedAt: now},
{ID: uuid.New(), TabloID: tabloB.ID, Title: "B task", Status: taskmodel.StatusTodo, DueDate: &due, CreatedAt: now},
}
vm := views.NewTasksPageViewModel(
[]tablomodel.Record{tabloA, tabloB},
records,
nil,
taskmodel.TaskPageState{View: taskmodel.TaskViewRoadmap, RoadmapMode: taskmodel.TaskRoadmapModeWeek},
now,
)
if len(vm.Roadmap.Lanes) != 2 {
t.Fatalf("expected one Sans étape lane per tablo, got %d", len(vm.Roadmap.Lanes))
}
}
func TestPostTasksCreatesEtape(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
@ -76,8 +200,27 @@ func TestPostTasksCreatesEtape(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Launch") {
t.Fatalf("expected response to contain new etape, got %q", rec.Body.String())
tasks, err := repo.ListTasksByOwner(context.Background(), userID)
if err != nil {
t.Fatalf("list tasks: %v", err)
}
var created *TaskRecord
for i := range tasks {
if tasks[i].Title == "Launch" {
created = &tasks[i]
break
}
}
if created == nil {
t.Fatalf("expected created etape to be persisted")
}
if !created.IsEtape {
t.Fatalf("expected created record to be an etape, got %#v", created)
}
if created.ParentTaskID != nil {
t.Fatalf("expected created etape to have no parent, got %#v", created.ParentTaskID)
}
}
@ -182,6 +325,7 @@ func TestPatchTaskUpdatesEditableFields(t *testing.T) {
task := mustCreateTask(t, repo, userID, tablo.ID, nil, "Old title")
form := url.Values{}
form.Set("tablo_id", tablo.ID.String())
form.Set("title", "New title")
form.Set("description", "New description")
form.Set("status", string(taskmodel.StatusInReview))
@ -222,6 +366,76 @@ func TestPatchTaskUpdatesEditableFields(t *testing.T) {
}
}
func TestPatchTaskRejectsParentEtapeFromAnotherTablo(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected authenticated user")
}
tabloA := mustCreateOwnedTablo(t, repo, userID)
tabloB := mustCreateOwnedTablo(t, repo, userID)
task := mustCreateTask(t, repo, userID, tabloA.ID, nil, "Alpha task")
foreignEtape := mustCreateEtape(t, repo, userID, tabloB.ID, "Beta stage")
form := url.Values{}
form.Set("tablo_id", tabloA.ID.String())
form.Set("title", task.Title)
form.Set("status", string(taskmodel.StatusTodo))
form.Set("parent_task_id", foreignEtape.ID.String())
patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+task.ID.String(), strings.NewReader(form.Encode()))
patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
patchReq.SetPathValue("taskID", task.ID.String())
patchReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.PatchTask().ServeHTTP(rec, patchReq)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 for cross-tablo parent, got %d", rec.Code)
}
}
func TestGetEditTaskModalShowsOnlyEtapesFromSelectedTablo(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected authenticated user")
}
tabloA := mustCreateOwnedTablo(t, repo, userID)
tabloB := mustCreateOwnedTablo(t, repo, userID)
task := mustCreateTask(t, repo, userID, tabloA.ID, nil, "Editable")
etapeA := mustCreateEtape(t, repo, userID, tabloA.ID, "Stage A")
_ = mustCreateEtape(t, repo, userID, tabloB.ID, "Stage B")
editReq := httptest.NewRequest(http.MethodGet, "/tasks/"+task.ID.String()+"/edit", nil)
editReq.SetPathValue("taskID", task.ID.String())
editReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetEditTaskModal().ServeHTTP(rec, editReq)
body := rec.Body.String()
if !strings.Contains(body, etapeA.ID.String()) {
t.Fatalf("expected same-tablo etape in edit form, got %q", body)
}
if strings.Contains(body, "Stage B") {
t.Fatalf("did not expect foreign tablo etape in edit form, got %q", body)
}
}
func TestDeleteTaskSoftDeletesRegularTask(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
@ -253,6 +467,135 @@ func TestDeleteTaskSoftDeletesRegularTask(t *testing.T) {
}
}
func TestGetTasksPageRendersListViewAcrossTablos(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected authenticated user")
}
tabloA := mustCreateOwnedTablo(t, repo, userID)
tabloB := mustCreateOwnedTablo(t, repo, userID)
_ = mustCreateTask(t, repo, userID, tabloA.ID, nil, "Alpha task")
_ = mustCreateTask(t, repo, userID, tabloB.ID, nil, "Beta task")
pageReq := httptest.NewRequest(http.MethodGet, "/tasks?view=list", nil)
pageReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTasksPage().ServeHTTP(rec, pageReq)
body := rec.Body.String()
for _, want := range []string{
`data-current-view="list"`,
`Alpha task`,
`Beta task`,
`Liste`,
`À faire`,
`data-task-list`,
`data-status-group="todo"`,
`Tablo`,
`Assignée`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q, got %q", want, body)
}
}
}
func TestGetTasksPageRendersRoadmapWeekMode(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected authenticated user")
}
tablo := mustCreateOwnedTablo(t, repo, userID)
etape := mustCreateEtape(t, repo, userID, tablo.ID, "Préparation")
task := mustCreateTask(t, repo, userID, tablo.ID, &etape.ID, "Planifier")
due := time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC)
_, err := repo.UpdateTask(context.Background(), UpdateTaskInput{
ID: task.ID,
OwnerID: userID,
TabloID: tablo.ID,
Title: task.Title,
Description: task.Description,
Status: task.Status,
DueDate: &due,
ParentTaskID: &etape.ID,
})
if err != nil {
t.Fatalf("update task due date: %v", err)
}
pageReq := httptest.NewRequest(http.MethodGet, "/tasks?view=roadmap&roadmap_mode=week", nil)
pageReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTasksPage().ServeHTTP(rec, pageReq)
body := rec.Body.String()
for _, want := range []string{
`data-current-view="roadmap"`,
`Roadmap`,
`Préparation`,
`Planifier`,
`Semaine`,
`Mois`,
`data-task-roadmap`,
`data-roadmap-lane=`,
`data-roadmap-bucket=`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q, got %q", want, body)
}
}
}
func TestPatchTaskRendersCurrentTaskQueryState(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected authenticated user")
}
tablo := mustCreateOwnedTablo(t, repo, userID)
task := mustCreateTask(t, repo, userID, tablo.ID, nil, "Stateful")
form := url.Values{}
form.Set("tablo_id", tablo.ID.String())
form.Set("title", "Stateful")
form.Set("status", string(taskmodel.StatusDone))
patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+task.ID.String()+"?view=list&status=done", strings.NewReader(form.Encode()))
patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
patchReq.SetPathValue("taskID", task.ID.String())
patchReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.PatchTask().ServeHTTP(rec, patchReq)
body := rec.Body.String()
if !strings.Contains(body, `data-current-view="list"`) {
t.Fatalf("expected response to preserve current list view, got %q", body)
}
}
func TestInMemoryTasksListExcludesSoftDeletedRows(t *testing.T) {
repo := NewInMemoryAuthRepository()
user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")

View file

@ -0,0 +1,326 @@
package views
import taskmodel "xtablo-backend/internal/tasks"
templ TasksPageContent(vm TasksPageViewModel) {
<div class="min-h-screen" data-current-view={ string(vm.State.View) }>
<div class="px-4 md:px-6 pt-6 md:pt-10 pb-5">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Mes Tâches</h1>
<button class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 h-9 px-4 py-2 has-[>svg]:px-3 bg-purple-600 hover:bg-purple-700 text-white w-full md:w-auto gap-2" type="button">
@TasksIcon("plus", "lucide lucide-plus w-4 h-4")
Nouvelle tâche
</button>
</div>
@TasksViewTabs(vm.State)
<div class="flex flex-col md:flex-row md:items-center md:justify-end gap-3">
<button class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 h-9 px-4 py-2 has-[>svg]:px-3 w-full md:w-auto gap-2 bg-transparent" type="button">
@TasksIcon("settings2", "lucide lucide-settings2 w-4 h-4")
Filtrer
</button>
</div>
</div>
<main class="px-4 md:px-6 pb-6">
if !vm.HasTasks {
<div class="rounded-[12px] border border-dashed border-[#D0D5DD] bg-[#F9FAFB] dark:bg-gray-800/60 p-8 text-center text-sm text-[#667085] dark:text-gray-400">
Aucune tâche pour le moment.
</div>
} else if vm.State.View == taskmodel.TaskViewList {
@TasksListLayout(vm.List, vm.State)
} else if vm.State.View == taskmodel.TaskViewRoadmap {
@TasksRoadmapLayout(vm.Roadmap, vm.State)
} else {
@TasksKanbanLayout(vm.Kanban, vm.State)
}
</main>
</div>
}
templ TasksViewTabs(state taskmodel.TaskPageState) {
<div class="flex flex-wrap items-center gap-2 md:gap-6 mb-4 border-b border-[#EAECF0] dark:border-gray-700">
<a href={ templ.SafeURL(taskViewHref(state, taskmodel.TaskViewKanban)) } class={ taskViewTabClass(state, taskmodel.TaskViewKanban) } if state.View == taskmodel.TaskViewKanban { aria-current="page" }>
@TasksIcon("kanban", "w-4 h-4")
<span>Tableau</span>
</a>
<a href={ templ.SafeURL(taskViewHref(state, taskmodel.TaskViewList)) } class={ taskViewTabClass(state, taskmodel.TaskViewList) } if state.View == taskmodel.TaskViewList { aria-current="page" }>
@TasksIcon("list", "w-4 h-4")
<span>Liste</span>
</a>
<a href={ templ.SafeURL(taskViewHref(state, taskmodel.TaskViewRoadmap)) } class={ taskViewTabClass(state, taskmodel.TaskViewRoadmap) } if state.View == taskmodel.TaskViewRoadmap { aria-current="page" }>
@TasksIcon("map", "w-4 h-4")
<span>Roadmap</span>
</a>
<button type="button" disabled class="flex items-center gap-2 pb-3 pt-1 px-2 text-sm font-semibold transition-colors border-b-2 min-h-[44px] text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100 cursor-not-allowed">
@TasksIcon("calendar", "lucide lucide-calendar w-4 h-4 opacity-40")
<span class="opacity-40">Calendrier</span>
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 leading-none">Bientôt</span>
</button>
</div>
if state.View == taskmodel.TaskViewRoadmap {
<div class="mb-4 flex flex-wrap items-center gap-2">
<a href={ templ.SafeURL(taskRoadmapModeHref(state, taskmodel.TaskRoadmapModeWeek)) } class={ taskRoadmapModeClass(state, taskmodel.TaskRoadmapModeWeek) }>
Semaine
</a>
<a href={ templ.SafeURL(taskRoadmapModeHref(state, taskmodel.TaskRoadmapModeMonth)) } class={ taskRoadmapModeClass(state, taskmodel.TaskRoadmapModeMonth) }>
Mois
</a>
</div>
}
}
templ TasksKanbanLayout(view TasksKanbanView, state taskmodel.TaskPageState) {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" data-task-kanban>
for _, column := range view.Columns {
<div class="w-full h-fit bg-[#F9FAFB] dark:bg-gray-800/60 rounded-[12px] p-4" data-status-column={ column.ID }>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2">
@TasksIcon("circle", "lucide lucide-circle w-5 h-5 " + statusIconClass(column.ID))
<h2 class="font-semibold text-gray-800 dark:text-gray-100">{ column.Label }</h2>
<span class="bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-medium px-2 py-0.5 rounded-full">{ len(column.Tasks) }</span>
</div>
<button type="button" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded p-2 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors">
@TasksIcon("plus", "lucide lucide-plus w-[18px] h-[18px]")
</button>
</div>
<div class="space-y-3 pr-1 min-h-[80px]">
for _, task := range column.Tasks {
@TaskCard(task, state, false)
}
</div>
</div>
}
</div>
}
templ TasksListLayout(view TasksListView, state taskmodel.TaskPageState) {
<div class="space-y-6" data-task-list>
for _, group := range view.Groups {
<section class="rounded-[12px] bg-[#F9FAFB] dark:bg-gray-800/60 p-4" data-status-group={ group.ID }>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
@TasksIcon("circle", "lucide lucide-circle w-5 h-5 " + statusIconClass(group.ID))
<h2 class="font-semibold text-gray-800 dark:text-gray-100">{ group.Label }</h2>
<span class="bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-medium px-2 py-0.5 rounded-full">{ len(group.Tasks) }</span>
</div>
<button type="button" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded p-2 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors">
@TasksIcon("plus", "lucide lucide-plus w-[18px] h-[18px]")
</button>
</div>
<div class="space-y-3">
for _, task := range group.Tasks {
@TasksListRow(task, state)
}
</div>
</section>
}
</div>
}
templ TasksRoadmapLayout(view TasksRoadmapView, state taskmodel.TaskPageState) {
<div class="space-y-6" data-task-roadmap>
for _, lane := range view.Lanes {
<section class="rounded-[12px] bg-[#F9FAFB] dark:bg-gray-800/60 p-4" data-roadmap-lane={ lane.ID }>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
<div>
<h2 class="font-semibold text-gray-800 dark:text-gray-100">{ lane.Label }</h2>
<p class="text-xs text-[#667085] dark:text-gray-400">Étape comme lane horizontale, avec bucketisation par date d'échéance.</p>
</div>
<button type="button" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded p-2 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors">
@TasksIcon("plus", "lucide lucide-plus w-[18px] h-[18px]")
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
for _, bucket := range lane.Buckets {
<div class="rounded-[12px] bg-white dark:bg-gray-900 border border-[#EAECF0] dark:border-gray-700 p-4" data-roadmap-bucket={ bucket.ID }>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">{ bucket.Label }</h3>
<span class="text-[11px] font-medium px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">{ len(bucket.Tasks) }</span>
</div>
<div class="space-y-3 min-h-[56px]">
for _, task := range bucket.Tasks {
@TaskCard(task, state, false)
}
</div>
</div>
}
</div>
</section>
}
</div>
}
templ TaskCard(task TaskCardView, state taskmodel.TaskPageState, compact bool) {
<article draggable="true" class={ taskCardClass(compact) } data-task-id={ task.ID }>
<div class="flex items-start justify-between gap-2 mb-2">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-tight line-clamp-2 flex-1">{ task.Title }</h3>
<button type="button" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 shrink-0 p-2 -m-1 min-h-[44px] min-w-[44px] flex items-center justify-center" hx-get={ taskEditHref(task, state) } hx-target="#app-main-content" hx-swap="beforeend">
@TasksIcon("ellipsis-vertical", "lucide lucide-ellipsis-vertical w-4 h-4")
</button>
<button type="button" aria-label={ taskDeleteAriaLabel(task) } class="text-gray-400 hover:text-red-500 shrink-0 p-2 -m-1 min-h-[44px] min-w-[44px] flex items-center justify-center" hx-delete={ taskDeleteHref(task, state) } hx-target="#app-main-content" hx-swap="outerHTML">
@TasksIcon("trash2", "lucide lucide-trash2 w-4 h-4")
</button>
</div>
if task.DueDate != "" {
<div class={ "flex items-center text-xs mb-3 " + dueDateToneClass(task.DueDateValue) }>
@TasksIcon("calendar", "lucide lucide-calendar w-3.5 h-3.5 mr-1.5")
{ task.DueDate }
</div>
}
<div class="flex items-center mb-3 border-b border-dashed border-[#D0D5DD] dark:border-gray-600 pb-3">
<div class={ "w-5 h-5 rounded-[5px] mr-2 flex items-center justify-center shrink-0 " + etapeBadgeClass(task) }>
@TasksIcon(etapeIconName(task), "w-3 h-3 text-white")
</div>
<span class="text-xs text-gray-600 dark:text-gray-400 truncate">{ etapeLabel(task) }</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3 text-gray-500 dark:text-gray-400">
<div class="flex items-center text-xs">
@TasksIcon("message-square", "lucide lucide-message-square w-3.5 h-3.5 mr-1")
0
</div>
<div class="flex items-center text-xs">
@TasksIcon("paperclip", "lucide lucide-paperclip w-3.5 h-3.5 mr-1")
0
</div>
<div class="flex items-center text-xs">
<span class="font-medium text-gray-700 dark:text-gray-300">Tablo</span>&nbsp;{ task.TabloName }
</div>
</div>
<div class="flex -space-x-2">
@TaskAssigneeAvatar(task)
</div>
</div>
</article>
}
templ TasksListRow(task TaskCardView, state taskmodel.TaskPageState) {
<div class="bg-white dark:bg-gray-900 rounded-[12px] border border-[#EAECF0] dark:border-gray-700 p-4 shadow-sm" data-task-id={ task.ID }>
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] gap-4">
@TaskCard(task, state, true)
<div class="rounded-lg bg-[#F9FAFB] dark:bg-gray-800/60 p-4 text-sm text-gray-700 dark:text-gray-300">
<dl class="space-y-3">
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Tablo</dt>
<dd class="mt-1">{ task.TabloName }</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Étape</dt>
<dd class="mt-1">{ etapeLabel(task) }</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Assignée</dt>
<dd class="mt-1">{ emptyFallback(task.Assignee, "Non assignée") }</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Statut</dt>
<dd class="mt-1">{ task.StatusLabel }</dd>
</div>
</dl>
</div>
</div>
</div>
}
templ TaskAssigneeAvatar(task TaskCardView) {
if taskHasAssignee(task) {
<div class="w-6 h-6 rounded-full bg-purple-500 border-2 border-white dark:border-gray-800 flex items-center justify-center text-white text-[10px] font-medium">
{ assigneeInitials(task.Assignee) }
</div>
} else {
<div class="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-600 border-2 border-white dark:border-gray-800 flex items-center justify-center">
@TasksIcon("user", "lucide lucide-user w-3 h-3 text-gray-400 dark:text-gray-300")
</div>
}
}
templ TasksIcon(kind string, className string) {
switch kind {
case "circle":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<circle cx="12" cy="12" r="10"></circle>
</svg>
case "plus":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
case "kanban":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M6 5v11"></path>
<path d="M12 5v6"></path>
<path d="M18 5v14"></path>
</svg>
case "list":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M3 12h.01"></path>
<path d="M3 18h.01"></path>
<path d="M3 6h.01"></path>
<path d="M8 12h13"></path>
<path d="M8 18h13"></path>
<path d="M8 6h13"></path>
</svg>
case "map":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z"></path>
<path d="M15 5.764v15"></path>
<path d="M9 3.236v15"></path>
</svg>
case "calendar":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M8 2v4"></path>
<path d="M16 2v4"></path>
<rect width="18" height="18" x="3" y="4" rx="2"></rect>
<path d="M3 10h18"></path>
</svg>
case "settings2":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M20 7h-9"></path>
<path d="M14 17H5"></path>
<circle cx="17" cy="17" r="3"></circle>
<circle cx="7" cy="7" r="3"></circle>
</svg>
case "ellipsis-vertical":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</svg>
case "trash2":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" x2="10" y1="11" y2="17"></line>
<line x1="14" x2="14" y1="11" y2="17"></line>
</svg>
case "message-square":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
case "paperclip":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
</svg>
case "user":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
case "gem":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M6 3h12l4 6-10 13L2 9Z"></path>
<path d="M11 3 8 9l4 13 4-13-3-6"></path>
<path d="M2 9h20"></path>
</svg>
case "flame":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path>
</svg>
case "zap":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path>
</svg>
default:
@TasksIcon("circle", className)
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,50 +1,90 @@
package views
import (
"context"
"fmt"
"html"
"io"
"net/url"
"slices"
"strings"
"time"
"github.com/a-h/templ"
"github.com/google/uuid"
tablomodel "xtablo-backend/internal/tablos"
taskmodel "xtablo-backend/internal/tasks"
)
type TasksPageViewModel struct {
Tablos []TasksTabloGroupView
State taskmodel.TaskPageState
Filters TasksFiltersView
Form TasksFormOptionsView
Kanban TasksKanbanView
List TasksListView
Roadmap TasksRoadmapView
HasTasks bool
}
type TasksTabloGroupView struct {
ID string
Name string
Color string
Sections []TasksSectionView
type TasksFiltersView struct {
Tablos []TasksOptionView
Assignees []TasksOptionView
Statuses []TasksOptionView
}
type TasksSectionView struct {
type TasksFormOptionsView struct {
Tablos []TasksOptionView
Assignees []TasksOptionView
EtapesByTablo map[string][]TasksOptionView
DefaultTabloID string
}
type TasksOptionView struct {
Value string
Label string
Selected bool
}
type TasksKanbanView struct {
Columns []TasksKanbanColumnView
}
type TasksKanbanColumnView struct {
ID string
Label string
Tasks []TaskCardView
}
type TasksListView struct {
Groups []TasksStatusGroupView
}
type TasksStatusGroupView struct {
ID string
Label string
Tasks []TaskCardView
}
type TasksRoadmapView struct {
Mode string
Lanes []TasksRoadmapLaneView
}
type TasksRoadmapLaneView struct {
ID string
Label string
Buckets []TasksRoadmapBucketTasksView
}
type TasksRoadmapBucketTasksView struct {
ID string
Label string
Tasks []TaskCardView
}
type TaskCardView struct {
ID string
Title string
Description string
IsEtape bool
Tasks []TaskRowView
Status string
StatusValue string
DueDate string
DueDateValue string
Assignee string
AssigneeID string
}
type TaskRowView struct {
ID string
Title string
Description string
Status string
TabloID string
TabloName string
EtapeName string
StatusLabel string
StatusValue string
DueDate string
DueDateValue string
@ -53,178 +93,529 @@ type TaskRowView struct {
ParentTaskID string
}
func NewTasksPageViewModel(tablos []tablomodel.Record, tasks []taskmodel.Record, assigneeLabels map[uuid.UUID]string) TasksPageViewModel {
groups := make([]TasksTabloGroupView, 0, len(tablos))
tasksByTablo := make(map[uuid.UUID][]taskmodel.Record)
for _, record := range tasks {
tasksByTablo[record.TabloID] = append(tasksByTablo[record.TabloID], record)
type etapeMeta struct {
ID uuid.UUID
TabloID uuid.UUID
Title string
}
func NewTasksPageViewModel(tablos []tablomodel.Record, tasks []taskmodel.Record, assigneeLabels map[uuid.UUID]string, state taskmodel.TaskPageState, now time.Time) TasksPageViewModel {
tabloByID := make(map[uuid.UUID]tablomodel.Record, len(tablos))
for _, tablo := range tablos {
tabloByID[tablo.ID] = tablo
}
for _, tablo := range tablos {
records := tasksByTablo[tablo.ID]
if len(records) == 0 {
etapesByID := make(map[uuid.UUID]etapeMeta)
etapesByTablo := make(map[string][]TasksOptionView)
for _, record := range tasks {
if !record.IsEtape {
continue
}
etapes := make([]taskmodel.Record, 0)
childrenByParent := make(map[uuid.UUID][]taskmodel.Record)
parentless := make([]taskmodel.Record, 0)
for _, record := range records {
if record.IsEtape {
etapes = append(etapes, record)
continue
}
if record.ParentTaskID == nil {
parentless = append(parentless, record)
continue
}
childrenByParent[*record.ParentTaskID] = append(childrenByParent[*record.ParentTaskID], record)
etapesByID[record.ID] = etapeMeta{
ID: record.ID,
TabloID: record.TabloID,
Title: record.Title,
}
sections := make([]TasksSectionView, 0, len(etapes)+1)
slices.SortFunc(etapes, func(a, b taskmodel.Record) int {
return a.CreatedAt.Compare(b.CreatedAt)
etapesByTablo[record.TabloID.String()] = append(etapesByTablo[record.TabloID.String()], TasksOptionView{
Value: record.ID.String(),
Label: record.Title,
})
for _, etape := range etapes {
children := childrenByParent[etape.ID]
slices.SortFunc(children, func(a, b taskmodel.Record) int {
return a.CreatedAt.Compare(b.CreatedAt)
})
sections = append(sections, TasksSectionView{
ID: etape.ID.String(),
Title: etape.Title,
Description: etape.Description,
IsEtape: true,
Status: taskStatusLabel(etape.Status),
StatusValue: string(etape.Status),
DueDate: formatOptionalDate(etape.DueDate),
DueDateValue: formatOptionalDateInput(etape.DueDate),
Assignee: assigneeName(etape.AssigneeID, assigneeLabels),
AssigneeID: optionalUUIDString(etape.AssigneeID),
Tasks: toTaskRows(children, assigneeLabels),
})
}
if len(parentless) > 0 {
slices.SortFunc(parentless, func(a, b taskmodel.Record) int {
return a.CreatedAt.Compare(b.CreatedAt)
})
sections = append(sections, TasksSectionView{
ID: "sans-etape-" + tablo.ID.String(),
Title: "Sans étape",
IsEtape: false,
Tasks: toTaskRows(parentless, assigneeLabels),
})
}
groups = append(groups, TasksTabloGroupView{
ID: tablo.ID.String(),
Name: tablo.Name,
Color: tablo.Color,
Sections: sections,
}
for key := range etapesByTablo {
slices.SortFunc(etapesByTablo[key], func(a, b TasksOptionView) int {
return strings.Compare(a.Label, b.Label)
})
}
return TasksPageViewModel{Tablos: groups}
filteredTasks := filterTasks(tasks, state)
form := buildTasksFormOptions(tablos, assigneeLabels, etapesByTablo)
return TasksPageViewModel{
State: state,
Filters: buildTasksFilters(tablos, assigneeLabels, state),
Form: form,
Kanban: buildKanbanView(filteredTasks, tabloByID, etapesByID, assigneeLabels),
List: buildListView(filteredTasks, tabloByID, etapesByID, assigneeLabels),
Roadmap: buildRoadmapView(filteredTasks, tabloByID, etapesByID, assigneeLabels, state.RoadmapMode, now),
HasTasks: len(filteredTasks) > 0,
}
}
func TasksPageContent(vm TasksPageViewModel) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
_, _ = io.WriteString(w, `<div class="app-section-page tasks-page">`)
_, _ = io.WriteString(w, `<div class="app-section-surface">`)
_, _ = io.WriteString(w, `<div class="app-section-eyebrow">Espace de travail</div>`)
_, _ = io.WriteString(w, `<h2>Mes tâches</h2>`)
_, _ = io.WriteString(w, `<p>Suivez les tâches de votre équipe, les priorités en cours et ce qui reste à livrer.</p>`)
_, _ = io.WriteString(w, `</div>`)
func buildTasksFilters(tablos []tablomodel.Record, assigneeLabels map[uuid.UUID]string, state taskmodel.TaskPageState) TasksFiltersView {
sortedTablos := append([]tablomodel.Record(nil), tablos...)
view := TasksFiltersView{
Tablos: make([]TasksOptionView, 0, len(sortedTablos)),
Assignees: make([]TasksOptionView, 0, len(assigneeLabels)),
Statuses: []TasksOptionView{
{Value: string(taskmodel.StatusTodo), Label: "À faire", Selected: containsStatus(state.Statuses, taskmodel.StatusTodo)},
{Value: string(taskmodel.StatusInProgress), Label: "En cours", Selected: containsStatus(state.Statuses, taskmodel.StatusInProgress)},
{Value: string(taskmodel.StatusInReview), Label: "Vérification", Selected: containsStatus(state.Statuses, taskmodel.StatusInReview)},
{Value: string(taskmodel.StatusDone), Label: "Terminé", Selected: containsStatus(state.Statuses, taskmodel.StatusDone)},
},
}
if len(vm.Tablos) == 0 {
_, _ = io.WriteString(w, `<div class="app-section-surface"><p>Aucune tâche pour le moment.</p></div></div>`)
return nil
}
for _, group := range vm.Tablos {
if err := renderTaskTabloGroup(w, group); err != nil {
return err
}
}
_, _ = io.WriteString(w, `</div>`)
return nil
slices.SortFunc(sortedTablos, func(a, b tablomodel.Record) int {
return strings.Compare(a.Name, b.Name)
})
}
func renderTaskTabloGroup(w io.Writer, group TasksTabloGroupView) error {
if _, err := fmt.Fprintf(w, `<section class="app-section-surface" data-task-tablo-id="%s"><h3>%s</h3>`, html.EscapeString(group.ID), html.EscapeString(group.Name)); err != nil {
return err
}
if _, err := fmt.Fprintf(w, `<div class="tasks-create-actions"><form class="task-create-form" hx-post="/tasks" hx-target="#app-main-content" hx-swap="outerHTML"><input type="hidden" name="tablo_id" value="%s"><input type="hidden" name="status" value="todo"><input name="title" placeholder="Nouvelle tâche"><button type="submit">Créer une tâche</button></form><form class="task-create-form" hx-post="/tasks" hx-target="#app-main-content" hx-swap="outerHTML"><input type="hidden" name="tablo_id" value="%s"><input type="hidden" name="status" value="todo"><input type="hidden" name="is_etape" value="true"><input name="title" placeholder="Nouvelle étape"><button type="submit">Créer une étape</button></form></div>`, html.EscapeString(group.ID), html.EscapeString(group.ID)); err != nil {
return err
}
for _, section := range group.Sections {
if _, err := fmt.Fprintf(w, `<div class="tasks-section" data-task-section-id="%s"><div class="tasks-section-header"><h4>%s</h4>`, html.EscapeString(section.ID), html.EscapeString(section.Title)); err != nil {
return err
}
if section.IsEtape {
if _, err := fmt.Fprintf(w, `<p>%s</p>`, html.EscapeString(joinTaskMeta(section.Status, section.DueDate, section.Assignee))); err != nil {
return err
}
if _, err := fmt.Fprintf(w, `<form class="task-inline-form" hx-patch="/tasks/%s" hx-target="#app-main-content" hx-swap="outerHTML"><input name="title" value="%s"><textarea name="description">%s</textarea><input name="status" value="%s"><input name="due_date" value="%s"><input name="assignee_id" value="%s"><button type="submit">Enregistrer l'étape</button></form><button type="button" hx-delete="/tasks/%s" hx-target="#app-main-content" hx-swap="outerHTML">Supprimer l'étape</button>`, html.EscapeString(section.ID), html.EscapeString(section.Title), html.EscapeString(section.Description), html.EscapeString(section.StatusValue), html.EscapeString(section.DueDateValue), html.EscapeString(section.AssigneeID), html.EscapeString(section.ID)); err != nil {
return err
}
}
if _, err := io.WriteString(w, `</div><div class="task-list">`); err != nil {
return err
}
for _, task := range section.Tasks {
if err := renderTaskRow(w, task); err != nil {
return err
}
}
if _, err := io.WriteString(w, `</div></div>`); err != nil {
return err
}
}
_, err := io.WriteString(w, `</section>`)
return err
}
func renderTaskRow(w io.Writer, task TaskRowView) error {
if _, err := fmt.Fprintf(w, `<article class="task-row" data-task-id="%s"><div class="task-body"><p>%s</p>`, html.EscapeString(task.ID), html.EscapeString(task.Title)); err != nil {
return err
}
if strings.TrimSpace(task.Description) != "" {
if _, err := fmt.Fprintf(w, `<div class="task-description">%s</div>`, html.EscapeString(task.Description)); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, `<div class="task-meta"><span>%s</span></div>`, html.EscapeString(joinTaskMeta(task.Status, task.DueDate, task.Assignee))); err != nil {
return err
}
if _, err := fmt.Fprintf(w, `<form class="task-inline-form" hx-patch="/tasks/%s" hx-target="#app-main-content" hx-swap="outerHTML"><input name="title" value="%s"><textarea name="description">%s</textarea><input name="status" value="%s"><input name="due_date" value="%s"><input name="assignee_id" value="%s"><input name="parent_task_id" value="%s"><button type="submit">Enregistrer</button></form><button type="button" hx-delete="/tasks/%s" hx-target="#app-main-content" hx-swap="outerHTML">Supprimer</button>`, html.EscapeString(task.ID), html.EscapeString(task.Title), html.EscapeString(task.Description), html.EscapeString(task.StatusValue), html.EscapeString(task.DueDateValue), html.EscapeString(task.AssigneeID), html.EscapeString(task.ParentTaskID), html.EscapeString(task.ID)); err != nil {
return err
}
_, err := io.WriteString(w, `</div></article>`)
return err
}
func toTaskRows(records []taskmodel.Record, assigneeLabels map[uuid.UUID]string) []TaskRowView {
rows := make([]TaskRowView, 0, len(records))
for _, record := range records {
rows = append(rows, TaskRowView{
ID: record.ID.String(),
Title: record.Title,
Description: record.Description,
Status: taskStatusLabel(record.Status),
StatusValue: string(record.Status),
DueDate: formatOptionalDate(record.DueDate),
DueDateValue: formatOptionalDateInput(record.DueDate),
Assignee: assigneeName(record.AssigneeID, assigneeLabels),
AssigneeID: optionalUUIDString(record.AssigneeID),
ParentTaskID: optionalUUIDString(record.ParentTaskID),
for _, tablo := range sortedTablos {
view.Tablos = append(view.Tablos, TasksOptionView{
Value: tablo.ID.String(),
Label: tablo.Name,
Selected: containsUUID(state.TabloIDs, tablo.ID),
})
}
return rows
assigneeIDs := make([]uuid.UUID, 0, len(assigneeLabels))
for id := range assigneeLabels {
assigneeIDs = append(assigneeIDs, id)
}
slices.SortFunc(assigneeIDs, func(a, b uuid.UUID) int {
return strings.Compare(assigneeLabels[a], assigneeLabels[b])
})
for _, id := range assigneeIDs {
view.Assignees = append(view.Assignees, TasksOptionView{
Value: id.String(),
Label: assigneeLabels[id],
Selected: containsUUID(state.AssigneeIDs, id),
})
}
return view
}
func buildTasksFormOptions(tablos []tablomodel.Record, assigneeLabels map[uuid.UUID]string, etapesByTablo map[string][]TasksOptionView) TasksFormOptionsView {
sortedTablos := append([]tablomodel.Record(nil), tablos...)
form := TasksFormOptionsView{
Tablos: make([]TasksOptionView, 0, len(sortedTablos)),
Assignees: make([]TasksOptionView, 0, len(assigneeLabels)),
EtapesByTablo: etapesByTablo,
}
slices.SortFunc(sortedTablos, func(a, b tablomodel.Record) int {
return strings.Compare(a.Name, b.Name)
})
for index, tablo := range sortedTablos {
if index == 0 {
form.DefaultTabloID = tablo.ID.String()
}
form.Tablos = append(form.Tablos, TasksOptionView{
Value: tablo.ID.String(),
Label: tablo.Name,
})
}
assigneeIDs := make([]uuid.UUID, 0, len(assigneeLabels))
for id := range assigneeLabels {
assigneeIDs = append(assigneeIDs, id)
}
slices.SortFunc(assigneeIDs, func(a, b uuid.UUID) int {
return strings.Compare(assigneeLabels[a], assigneeLabels[b])
})
for _, id := range assigneeIDs {
form.Assignees = append(form.Assignees, TasksOptionView{
Value: id.String(),
Label: assigneeLabels[id],
})
}
return form
}
func filterTasks(tasks []taskmodel.Record, state taskmodel.TaskPageState) []taskmodel.Record {
filtered := make([]taskmodel.Record, 0, len(tasks))
for _, record := range tasks {
if record.IsEtape {
continue
}
if len(state.TabloIDs) > 0 && !containsUUID(state.TabloIDs, record.TabloID) {
continue
}
if len(state.AssigneeIDs) > 0 {
if record.AssigneeID == nil || !containsUUID(state.AssigneeIDs, *record.AssigneeID) {
continue
}
}
if len(state.Statuses) > 0 && !containsStatus(state.Statuses, record.Status) {
continue
}
filtered = append(filtered, record)
}
return filtered
}
func buildKanbanView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, etapesByID map[uuid.UUID]etapeMeta, assigneeLabels map[uuid.UUID]string) TasksKanbanView {
columns := []TasksKanbanColumnView{
{ID: string(taskmodel.StatusTodo), Label: "À faire"},
{ID: string(taskmodel.StatusInProgress), Label: "En cours"},
{ID: string(taskmodel.StatusInReview), Label: "Vérification"},
{ID: string(taskmodel.StatusDone), Label: "Terminé"},
}
for _, record := range sortTaskRecords(records) {
card := taskCardFromRecord(record, tabloByID, etapesByID, assigneeLabels)
for index := range columns {
if columns[index].ID == string(record.Status) {
columns[index].Tasks = append(columns[index].Tasks, card)
break
}
}
}
return TasksKanbanView{Columns: columns}
}
func buildListView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, etapesByID map[uuid.UUID]etapeMeta, assigneeLabels map[uuid.UUID]string) TasksListView {
groups := []TasksStatusGroupView{
{ID: string(taskmodel.StatusTodo), Label: "À faire"},
{ID: string(taskmodel.StatusInProgress), Label: "En cours"},
{ID: string(taskmodel.StatusInReview), Label: "Vérification"},
{ID: string(taskmodel.StatusDone), Label: "Terminé"},
}
for _, record := range sortTaskRecords(records) {
card := taskCardFromRecord(record, tabloByID, etapesByID, assigneeLabels)
for index := range groups {
if groups[index].ID == string(record.Status) {
groups[index].Tasks = append(groups[index].Tasks, card)
break
}
}
}
return TasksListView{Groups: groups}
}
func buildRoadmapView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, etapesByID map[uuid.UUID]etapeMeta, assigneeLabels map[uuid.UUID]string, mode taskmodel.TaskRoadmapMode, now time.Time) TasksRoadmapView {
if now.IsZero() {
now = time.Now().UTC()
}
bucketIDs, bucketLabels := roadmapBuckets(mode, now)
lanesByID := map[string]*TasksRoadmapLaneView{}
laneOrder := make([]string, 0)
for _, record := range sortTaskRecords(records) {
laneID, laneLabel := roadmapLane(record, tabloByID, etapesByID)
lane, ok := lanesByID[laneID]
if !ok {
lane = &TasksRoadmapLaneView{
ID: laneID,
Label: laneLabel,
Buckets: make([]TasksRoadmapBucketTasksView, 0, len(bucketIDs)+1),
}
for index, bucketID := range bucketIDs {
lane.Buckets = append(lane.Buckets, TasksRoadmapBucketTasksView{
ID: bucketID,
Label: bucketLabels[index],
})
}
lane.Buckets = append(lane.Buckets, TasksRoadmapBucketTasksView{
ID: "sans-date",
Label: "Sans date",
})
lanesByID[laneID] = lane
laneOrder = append(laneOrder, laneID)
}
card := taskCardFromRecord(record, tabloByID, etapesByID, assigneeLabels)
bucketIndex := roadmapBucketIndex(record.DueDate, mode, now, len(bucketIDs))
lane.Buckets[bucketIndex].Tasks = append(lane.Buckets[bucketIndex].Tasks, card)
}
lanes := make([]TasksRoadmapLaneView, 0, len(laneOrder))
for _, laneID := range laneOrder {
lanes = append(lanes, *lanesByID[laneID])
}
return TasksRoadmapView{
Mode: map[taskmodel.TaskRoadmapMode]string{
taskmodel.TaskRoadmapModeMonth: "Mois",
}[mode],
Lanes: lanes,
}
}
func taskCardFromRecord(record taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, etapesByID map[uuid.UUID]etapeMeta, assigneeLabels map[uuid.UUID]string) TaskCardView {
tabloName := record.TabloID.String()
if tablo, ok := tabloByID[record.TabloID]; ok {
tabloName = tablo.Name
}
etapeName := "Sans étape"
if record.ParentTaskID != nil {
if etape, ok := etapesByID[*record.ParentTaskID]; ok {
etapeName = etape.Title
}
}
return TaskCardView{
ID: record.ID.String(),
Title: record.Title,
Description: record.Description,
TabloID: record.TabloID.String(),
TabloName: tabloName,
EtapeName: etapeName,
StatusLabel: taskStatusLabel(record.Status),
StatusValue: string(record.Status),
DueDate: formatOptionalDate(record.DueDate),
DueDateValue: formatOptionalDateInput(record.DueDate),
Assignee: assigneeName(record.AssigneeID, assigneeLabels),
AssigneeID: optionalUUIDString(record.AssigneeID),
ParentTaskID: optionalUUIDString(record.ParentTaskID),
}
}
func taskViewHref(state taskmodel.TaskPageState, view taskmodel.TaskView) string {
nextState := state
nextState.View = view
if view != taskmodel.TaskViewRoadmap {
nextState.RoadmapMode = taskmodel.TaskRoadmapModeWeek
}
return stateAction("/tasks", nextState)
}
func taskRoadmapModeHref(state taskmodel.TaskPageState, mode taskmodel.TaskRoadmapMode) string {
nextState := state
nextState.RoadmapMode = mode
return stateAction("/tasks", nextState)
}
func taskViewTabClass(state taskmodel.TaskPageState, view taskmodel.TaskView) string {
base := "flex items-center gap-2 pb-3 pt-1 px-2 text-sm font-semibold transition-colors border-b-2 min-h-[44px] "
if state.View == view {
return base + "text-purple-600 border-purple-600 dark:text-purple-400 dark:border-purple-400"
}
return base + "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100"
}
func taskRoadmapModeClass(state taskmodel.TaskPageState, mode taskmodel.TaskRoadmapMode) string {
base := "inline-flex items-center rounded-full px-3 py-1.5 text-xs font-semibold "
if state.RoadmapMode == mode {
return base + "bg-purple-50 text-purple-700 border border-purple-200 dark:bg-purple-950/40 dark:text-purple-300 dark:border-purple-900"
}
return base + "bg-gray-100 text-gray-600 border border-transparent hover:text-gray-900 dark:bg-gray-800 dark:text-gray-300"
}
func taskCardClass(compact bool) string {
if compact {
return "bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm border border-gray-100 dark:border-gray-700"
}
return "bg-white dark:bg-gray-800 rounded-lg p-4 mb-3 shadow-sm hover:shadow-md transition-shadow border border-gray-100 dark:border-gray-700 cursor-pointer"
}
func taskEditHref(task TaskCardView, state taskmodel.TaskPageState) string {
return stateAction("/tasks/"+task.ID+"/edit", state)
}
func taskDeleteHref(task TaskCardView, state taskmodel.TaskPageState) string {
return stateAction("/tasks/"+task.ID, state)
}
func taskDeleteAriaLabel(task TaskCardView) string {
return "Supprimer la tâche " + task.Title
}
func taskHasAssignee(task TaskCardView) bool {
return strings.TrimSpace(task.Assignee) != ""
}
func statusIconClass(statusID string) string {
switch statusID {
case string(taskmodel.StatusInProgress):
return "text-yellow-500"
case string(taskmodel.StatusInReview):
return "text-blue-500"
case string(taskmodel.StatusDone):
return "text-green-500"
default:
return "text-gray-400"
}
}
func dueDateToneClass(raw string) string {
if raw == "" {
return "text-gray-500 dark:text-gray-400"
}
due, err := time.Parse("2006-01-02", raw)
if err != nil {
return "text-gray-500 dark:text-gray-400"
}
if due.Before(time.Now().UTC().Add(24 * time.Hour)) {
return "text-red-500"
}
return "text-gray-500 dark:text-gray-400"
}
func etapeLabel(task TaskCardView) string {
if task.EtapeName == "" {
return "Sans étape"
}
return task.EtapeName
}
func etapeBadgeClass(task TaskCardView) string {
if task.EtapeName == "" || task.EtapeName == "Sans étape" {
return "bg-blue-500"
}
if strings.Contains(strings.ToLower(task.EtapeName), "pré") || strings.Contains(strings.ToLower(task.EtapeName), "commenc") {
return "bg-blue-500"
}
if strings.Contains(strings.ToLower(task.EtapeName), "livr") || strings.Contains(strings.ToLower(task.EtapeName), "review") {
return "bg-purple-500"
}
return "bg-red-500"
}
func etapeIconName(task TaskCardView) string {
if task.EtapeName == "" || task.EtapeName == "Sans étape" {
return "zap"
}
if strings.Contains(strings.ToLower(task.EtapeName), "livr") || strings.Contains(strings.ToLower(task.EtapeName), "review") {
return "gem"
}
if strings.Contains(strings.ToLower(task.EtapeName), "margot") {
return "flame"
}
return "zap"
}
func assigneeInitials(name string) string {
parts := strings.Fields(strings.TrimSpace(name))
if len(parts) == 0 {
return "?"
}
if len(parts) == 1 {
runes := []rune(parts[0])
if len(runes) == 1 {
return strings.ToUpper(string(runes[0]))
}
return strings.ToUpper(string(runes[0:2]))
}
return strings.ToUpper(string([]rune(parts[0])[0]) + string([]rune(parts[len(parts)-1])[0]))
}
func emptyFallback(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func stateAction(path string, state taskmodel.TaskPageState) string {
values := url.Values{}
if state.View != "" {
values.Set("view", string(state.View))
}
if state.View == taskmodel.TaskViewRoadmap {
values.Set("roadmap_mode", string(state.RoadmapMode))
}
for _, id := range state.TabloIDs {
values.Add("tablo", id.String())
}
for _, id := range state.AssigneeIDs {
values.Add("assignee", id.String())
}
for _, status := range state.Statuses {
values.Add("status", string(status))
}
encoded := values.Encode()
if encoded == "" {
return path
}
return path + "?" + encoded
}
func roadmapBuckets(mode taskmodel.TaskRoadmapMode, now time.Time) ([]string, []string) {
switch mode {
case taskmodel.TaskRoadmapModeMonth:
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
ids := make([]string, 0, 6)
labels := make([]string, 0, 6)
for i := 0; i < 6; i++ {
bucket := start.AddDate(0, i, 0)
ids = append(ids, bucket.Format("2006-01"))
labels = append(labels, bucket.Format("Jan 2006"))
}
return ids, labels
default:
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
ids := make([]string, 0, 6)
labels := make([]string, 0, 6)
for i := 0; i < 6; i++ {
bucket := start.AddDate(0, 0, i*7)
ids = append(ids, bucket.Format("2006-01-02"))
labels = append(labels, bucket.Format("02 Jan"))
}
return ids, labels
}
}
func roadmapBucketIndex(dueDate *time.Time, mode taskmodel.TaskRoadmapMode, now time.Time, bucketCount int) int {
if dueDate == nil {
return bucketCount
}
if mode == taskmodel.TaskRoadmapModeMonth {
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
target := time.Date(dueDate.Year(), dueDate.Month(), 1, 0, 0, 0, 0, time.UTC)
months := int(target.Month()) - int(start.Month()) + (target.Year()-start.Year())*12
if months <= 0 {
return 0
}
if months >= bucketCount {
return bucketCount - 1
}
return months
}
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
days := int(dueDate.Sub(start).Hours() / 24)
if days <= 0 {
return 0
}
weeks := days / 7
if weeks >= bucketCount {
return bucketCount - 1
}
return weeks
}
func roadmapLane(record taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, etapesByID map[uuid.UUID]etapeMeta) (string, string) {
tabloName := record.TabloID.String()
if tablo, ok := tabloByID[record.TabloID]; ok {
tabloName = tablo.Name
}
if record.ParentTaskID == nil {
return record.TabloID.String() + ":sans-etape", tabloName + " / Sans étape"
}
if etape, ok := etapesByID[*record.ParentTaskID]; ok {
return record.TabloID.String() + ":" + etape.ID.String(), tabloName + " / " + etape.Title
}
return record.TabloID.String() + ":sans-etape", tabloName + " / Sans étape"
}
func sortTaskRecords(records []taskmodel.Record) []taskmodel.Record {
cloned := append([]taskmodel.Record(nil), records...)
slices.SortFunc(cloned, func(a, b taskmodel.Record) int {
if diff := strings.Compare(string(a.Status), string(b.Status)); diff != 0 {
return diff
}
return a.CreatedAt.Compare(b.CreatedAt)
})
return cloned
}
func containsUUID(ids []uuid.UUID, want uuid.UUID) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
func containsStatus(statuses []taskmodel.Status, want taskmodel.Status) bool {
for _, status := range statuses {
if status == want {
return true
}
}
return false
}
func taskStatusLabel(status taskmodel.Status) string {
@ -232,7 +623,7 @@ func taskStatusLabel(status taskmodel.Status) string {
case taskmodel.StatusInProgress:
return "En cours"
case taskmodel.StatusInReview:
return "En revue"
return "Vérification"
case taskmodel.StatusDone:
return "Terminé"
default:
@ -244,7 +635,7 @@ func formatOptionalDate(value *time.Time) string {
if value == nil {
return ""
}
return value.Format("02/01/2006")
return value.Format("02 Jan")
}
func formatOptionalDateInput(value *time.Time) string {

View file

@ -257,8 +257,11 @@ func TestTasksPageRendersFullDashboardPage(t *testing.T) {
for _, want := range []string{
`class="sidebar-nav-shell"`,
`id="app-main-content"`,
"Tâches",
"Suivez les tâches de votre équipe",
`data-current-view="kanban"`,
"Mes Tâches",
"Nouvelle tâche",
"Tableau",
"Filtrer",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected tasks page to contain %q", want)
@ -487,8 +490,10 @@ func TestTasksPageReturnsHTMXMainContentSwap(t *testing.T) {
`id="app-main-content"`,
`hx-swap-oob="outerHTML"`,
`id="sidebar-nav-tasks"`,
"Tâches",
"Suivez les tâches de votre équipe",
`data-current-view="kanban"`,
"Mes Tâches",
"Nouvelle tâche",
"Tableau",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected HTMX tasks response to contain %q", want)

View file

@ -25,9 +25,13 @@
--color-blue-950: oklch(28.2% 0.091 267.935);
--color-indigo-500: oklch(58.5% 0.233 277.117);
--color-purple-50: oklch(97.7% 0.014 308.299);
--color-purple-200: oklch(90.2% 0.063 306.703);
--color-purple-300: oklch(82.7% 0.119 306.383);
--color-purple-400: oklch(71.4% 0.203 305.504);
--color-purple-500: oklch(62.7% 0.265 303.9);
--color-purple-600: oklch(55.8% 0.288 302.321);
--color-purple-700: oklch(49.6% 0.265 301.924);
--color-purple-900: oklch(38.1% 0.176 304.987);
--color-purple-950: oklch(29.1% 0.149 302.717);
--color-pink-500: oklch(65.6% 0.241 354.308);
--color-gray-50: oklch(98.5% 0.002 247.839);
@ -51,7 +55,10 @@
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--tracking-wide: 0.025em;
--tracking-wider: 0.05em;
--leading-tight: 1.25;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--default-transition-duration: 150ms;
@ -87,18 +94,48 @@
.isolate {
isolation: isolate;
}
.-m-1 {
margin: calc(var(--spacing) * -1);
}
.-mx-4 {
margin-inline: calc(var(--spacing) * -4);
}
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
.mr-1 {
margin-right: calc(var(--spacing) * 1);
}
.mr-1\.5 {
margin-right: calc(var(--spacing) * 1.5);
}
.mr-2 {
margin-right: calc(var(--spacing) * 2);
}
.mb-1 {
margin-bottom: calc(var(--spacing) * 1);
}
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
.mb-3 {
margin-bottom: calc(var(--spacing) * 3);
}
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
.mb-6 {
margin-bottom: calc(var(--spacing) * 6);
}
.mb-8 {
margin-bottom: calc(var(--spacing) * 8);
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.flex {
display: flex;
}
@ -111,6 +148,9 @@
.inline {
display: inline;
}
.inline-flex {
display: inline-flex;
}
.table {
display: table;
}
@ -153,30 +193,75 @@
.h-2 {
height: calc(var(--spacing) * 2);
}
.h-3 {
height: calc(var(--spacing) * 3);
}
.h-3\.5 {
height: calc(var(--spacing) * 3.5);
}
.h-4 {
height: calc(var(--spacing) * 4);
}
.h-5 {
height: calc(var(--spacing) * 5);
}
.h-6 {
height: calc(var(--spacing) * 6);
}
.h-8 {
height: calc(var(--spacing) * 8);
}
.h-9 {
height: calc(var(--spacing) * 9);
}
.h-\[18px\] {
height: 18px;
}
.h-fit {
height: fit-content;
}
.min-h-\[44px\] {
min-height: 44px;
}
.min-h-\[56px\] {
min-height: 56px;
}
.min-h-\[80px\] {
min-height: 80px;
}
.min-h-screen {
min-height: 100vh;
}
.w-3 {
width: calc(var(--spacing) * 3);
}
.w-3\.5 {
width: calc(var(--spacing) * 3.5);
}
.w-4 {
width: calc(var(--spacing) * 4);
}
.w-5 {
width: calc(var(--spacing) * 5);
}
.w-6 {
width: calc(var(--spacing) * 6);
}
.w-8 {
width: calc(var(--spacing) * 8);
}
.w-12 {
width: calc(var(--spacing) * 12);
}
.w-\[18px\] {
width: 18px;
}
.w-full {
width: 100%;
}
.min-w-\[44px\] {
min-width: 44px;
}
.min-w-\[80px\] {
min-width: 80px;
}
@ -190,6 +275,9 @@
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.cursor-not-allowed {
cursor: not-allowed;
}
.cursor-pointer {
cursor: pointer;
}
@ -205,6 +293,12 @@
.items-center {
align-items: center;
}
.items-start {
align-items: flex-start;
}
.justify-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
@ -229,6 +323,41 @@
.gap-6 {
gap: calc(var(--spacing) * 6);
}
.space-y-3 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-6 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
}
}
.-space-x-2 {
:where(& > :not(:last-child)) {
--tw-space-x-reverse: 0;
margin-inline-start: calc(calc(var(--spacing) * -2) * var(--tw-space-x-reverse));
margin-inline-end: calc(calc(var(--spacing) * -2) * calc(1 - var(--tw-space-x-reverse)));
}
}
.space-x-2 {
:where(& > :not(:last-child)) {
--tw-space-x-reverse: 0;
margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));
margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)));
}
}
.space-x-3 {
:where(& > :not(:last-child)) {
--tw-space-x-reverse: 0;
margin-inline-start: calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse));
margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)));
}
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
@ -243,15 +372,27 @@
.overflow-x-auto {
overflow-x: auto;
}
.rounded {
border-radius: 0.25rem;
}
.rounded-\[5px\] {
border-radius: 5px;
}
.rounded-\[8px\] {
border-radius: 8px;
}
.rounded-\[12px\] {
border-radius: 12px;
}
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-md {
border-radius: var(--radius-md);
}
.rounded-xl {
border-radius: var(--radius-xl);
}
@ -259,6 +400,10 @@
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
}
.border-t {
border-top-style: var(--tw-border-style);
border-top-width: 1px;
@ -275,6 +420,9 @@
--tw-border-style: dashed;
border-style: dashed;
}
.border-\[\#D0D5DD\] {
border-color: #D0D5DD;
}
.border-\[\#DB9729\] {
border-color: #DB9729;
}
@ -284,9 +432,15 @@
.border-blue-200 {
border-color: var(--color-blue-200);
}
.border-gray-100 {
border-color: var(--color-gray-100);
}
.border-green-200 {
border-color: var(--color-green-200);
}
.border-purple-200 {
border-color: var(--color-purple-200);
}
.border-purple-600 {
border-color: var(--color-purple-600);
}
@ -296,6 +450,12 @@
.border-transparent {
border-color: transparent;
}
.border-white {
border-color: var(--color-white);
}
.bg-\[\#F9FAFB\] {
background-color: #F9FAFB;
}
.bg-\[\#FFF4E2\] {
background-color: #FFF4E2;
}
@ -311,6 +471,9 @@
.bg-gray-50 {
background-color: var(--color-gray-50);
}
.bg-gray-100 {
background-color: var(--color-gray-100);
}
.bg-gray-200 {
background-color: var(--color-gray-200);
}
@ -347,6 +510,9 @@
.bg-teal-500 {
background-color: var(--color-teal-500);
}
.bg-transparent {
background-color: transparent;
}
.bg-white {
background-color: var(--color-white);
}
@ -361,12 +527,42 @@
.bg-yellow-500 {
background-color: var(--color-yellow-500);
}
.p-2 {
padding: calc(var(--spacing) * 2);
}
.p-3 {
padding: calc(var(--spacing) * 3);
}
.p-4 {
padding: calc(var(--spacing) * 4);
}
.p-8 {
padding: calc(var(--spacing) * 8);
}
.px-1\.5 {
padding-inline: calc(var(--spacing) * 1.5);
}
.px-2 {
padding-inline: calc(var(--spacing) * 2);
}
.px-3 {
padding-inline: calc(var(--spacing) * 3);
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-0\.5 {
padding-block: calc(var(--spacing) * 0.5);
}
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
.py-2\.5 {
padding-block: calc(var(--spacing) * 2.5);
}
@ -379,15 +575,27 @@
.py-10 {
padding-block: calc(var(--spacing) * 10);
}
.pt-1 {
padding-top: calc(var(--spacing) * 1);
}
.pt-6 {
padding-top: calc(var(--spacing) * 6);
}
.pt-8 {
padding-top: calc(var(--spacing) * 8);
}
.pr-1 {
padding-right: calc(var(--spacing) * 1);
}
.pr-4 {
padding-right: calc(var(--spacing) * 4);
}
.pb-3 {
padding-bottom: calc(var(--spacing) * 3);
}
.pb-5 {
padding-bottom: calc(var(--spacing) * 5);
}
.pb-6 {
padding-bottom: calc(var(--spacing) * 6);
}
@ -415,6 +623,20 @@
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
}
.text-\[10px\] {
font-size: 10px;
}
.text-\[11px\] {
font-size: 11px;
}
.leading-none {
--tw-leading: 1;
line-height: 1;
}
.leading-tight {
--tw-leading: var(--leading-tight);
line-height: var(--leading-tight);
}
.font-bold {
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
@ -427,6 +649,10 @@
--tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold);
}
.tracking-wide {
--tw-tracking: var(--tracking-wide);
letter-spacing: var(--tracking-wide);
}
.tracking-wider {
--tw-tracking: var(--tracking-wider);
letter-spacing: var(--tracking-wider);
@ -434,9 +660,15 @@
.whitespace-nowrap {
white-space: nowrap;
}
.text-\[\#667085\] {
color: #667085;
}
.text-\[\#DB9729\] {
color: #DB9729;
}
.text-blue-500 {
color: var(--color-blue-500);
}
.text-blue-600 {
color: var(--color-blue-600);
}
@ -452,21 +684,36 @@
.text-gray-700 {
color: var(--color-gray-700);
}
.text-gray-800 {
color: var(--color-gray-800);
}
.text-gray-900 {
color: var(--color-gray-900);
}
.text-green-500 {
color: var(--color-green-500);
}
.text-green-600 {
color: var(--color-green-600);
}
.text-purple-600 {
color: var(--color-purple-600);
}
.text-purple-700 {
color: var(--color-purple-700);
}
.text-red-500 {
color: var(--color-red-500);
}
.text-red-700 {
color: var(--color-red-700);
}
.text-white {
color: var(--color-white);
}
.text-yellow-500 {
color: var(--color-yellow-500);
}
.uppercase {
text-transform: uppercase;
}
@ -475,6 +722,17 @@
color: var(--color-gray-400);
}
}
.opacity-40 {
opacity: 40%;
}
.shadow-sm {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-xs {
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
@ -488,6 +746,11 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.transition-shadow {
transition-property: box-shadow;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.hover\:bg-gray-50 {
&:hover {
@media (hover: hover) {
@ -495,6 +758,27 @@
}
}
}
.hover\:bg-gray-200 {
&:hover {
@media (hover: hover) {
background-color: var(--color-gray-200);
}
}
}
.hover\:bg-purple-700 {
&:hover {
@media (hover: hover) {
background-color: var(--color-purple-700);
}
}
}
.hover\:text-gray-600 {
&:hover {
@media (hover: hover) {
color: var(--color-gray-600);
}
}
}
.hover\:text-gray-700 {
&:hover {
@media (hover: hover) {
@ -502,6 +786,28 @@
}
}
}
.hover\:text-gray-900 {
&:hover {
@media (hover: hover) {
color: var(--color-gray-900);
}
}
}
.hover\:text-red-500 {
&:hover {
@media (hover: hover) {
color: var(--color-red-500);
}
}
}
.hover\:shadow-md {
&:hover {
@media (hover: hover) {
--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
}
.focus\:ring-2 {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
@ -519,6 +825,39 @@
outline-style: none;
}
}
.focus-visible\:ring-2 {
&:focus-visible {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus-visible\:ring-offset-2 {
&:focus-visible {
--tw-ring-offset-width: 2px;
--tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
}
}
.focus-visible\:outline-none {
&:focus-visible {
--tw-outline-style: none;
outline-style: none;
}
}
.disabled\:pointer-events-none {
&:disabled {
pointer-events: none;
}
}
.disabled\:opacity-50 {
&:disabled {
opacity: 50%;
}
}
.has-\[\>svg\]\:px-3 {
&:has(>svg) {
padding-inline: calc(var(--spacing) * 3);
}
}
.sm\:mx-0 {
@media (width >= 40rem) {
margin-inline: calc(var(--spacing) * 0);
@ -539,6 +878,16 @@
width: 350px;
}
}
.md\:w-auto {
@media (width >= 48rem) {
width: auto;
}
}
.md\:grid-cols-2 {
@media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.md\:flex-row {
@media (width >= 48rem) {
flex-direction: row;
@ -554,11 +903,41 @@
justify-content: space-between;
}
}
.md\:justify-end {
@media (width >= 48rem) {
justify-content: flex-end;
}
}
.md\:gap-6 {
@media (width >= 48rem) {
gap: calc(var(--spacing) * 6);
}
}
.md\:px-6 {
@media (width >= 48rem) {
padding-inline: calc(var(--spacing) * 6);
}
}
.md\:pt-10 {
@media (width >= 48rem) {
padding-top: calc(var(--spacing) * 10);
}
}
.lg\:grid-cols-3 {
@media (width >= 64rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.lg\:grid-cols-\[minmax\(0\,2fr\)_minmax\(0\,1fr\)\] {
@media (width >= 64rem) {
grid-template-columns: minmax(0,2fr) minmax(0,1fr);
}
}
.xl\:grid-cols-4 {
@media (width >= 80rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
@ -569,11 +948,21 @@
border-color: var(--color-blue-800);
}
}
.dark\:border-gray-600 {
&:is(.dark *) {
border-color: var(--color-gray-600);
}
}
.dark\:border-gray-700 {
&:is(.dark *) {
border-color: var(--color-gray-700);
}
}
.dark\:border-gray-800 {
&:is(.dark *) {
border-color: var(--color-gray-800);
}
}
.dark\:border-green-800 {
&:is(.dark *) {
border-color: var(--color-green-800);
@ -584,6 +973,11 @@
border-color: var(--color-purple-400);
}
}
.dark\:border-purple-900 {
&:is(.dark *) {
border-color: var(--color-purple-900);
}
}
.dark\:bg-blue-950\/30 {
&:is(.dark *) {
background-color: color-mix(in srgb, oklch(28.2% 0.091 267.935) 30%, transparent);
@ -594,6 +988,11 @@
}
}
}
.dark\:bg-gray-600 {
&:is(.dark *) {
background-color: var(--color-gray-600);
}
}
.dark\:bg-gray-700 {
&:is(.dark *) {
background-color: var(--color-gray-700);
@ -624,6 +1023,11 @@
}
}
}
.dark\:bg-gray-900 {
&:is(.dark *) {
background-color: var(--color-gray-900);
}
}
.dark\:bg-green-950\/30 {
&:is(.dark *) {
background-color: color-mix(in srgb, oklch(26.6% 0.065 152.934) 30%, transparent);
@ -644,6 +1048,16 @@
}
}
}
.dark\:bg-purple-950\/40 {
&:is(.dark *) {
background-color: color-mix(in srgb, oklch(29.1% 0.149 302.717) 40%, transparent);
@supports (color: color-mix(in lab, red, red)) {
& {
background-color: color-mix(in oklab, var(--color-purple-950) 40%, transparent);
}
}
}
}
.dark\:text-blue-400 {
&:is(.dark *) {
color: var(--color-blue-400);
@ -669,11 +1083,25 @@
color: var(--color-green-400);
}
}
.dark\:text-purple-300 {
&:is(.dark *) {
color: var(--color-purple-300);
}
}
.dark\:text-purple-400 {
&:is(.dark *) {
color: var(--color-purple-400);
}
}
.dark\:hover\:bg-gray-700 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
background-color: var(--color-gray-700);
}
}
}
}
.dark\:hover\:bg-gray-800 {
&:is(.dark *) {
&:hover {
@ -683,6 +1111,15 @@
}
}
}
.dark\:hover\:text-gray-100 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-gray-100);
}
}
}
}
.dark\:hover\:text-gray-200 {
&:is(.dark *) {
&:hover {
@ -692,6 +1129,22 @@
}
}
}
.\[\&_svg\]\:pointer-events-none {
& svg {
pointer-events: none;
}
}
.\[\&_svg\]\:size-4 {
& svg {
width: calc(var(--spacing) * 4);
height: calc(var(--spacing) * 4);
}
}
.\[\&_svg\]\:shrink-0 {
& svg {
flex-shrink: 0;
}
}
.\[\&\>svg\]\:h-4 {
&>svg {
height: calc(var(--spacing) * 4);
@ -722,11 +1175,25 @@
inherits: false;
initial-value: 0;
}
@property --tw-space-y-reverse {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-space-x-reverse {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-leading {
syntax: "*";
inherits: false;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
@ -735,59 +1202,6 @@
syntax: "*";
inherits: false;
}
@property --tw-blur {
syntax: "*";
inherits: false;
}
@property --tw-brightness {
syntax: "*";
inherits: false;
}
@property --tw-contrast {
syntax: "*";
inherits: false;
}
@property --tw-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-invert {
syntax: "*";
inherits: false;
}
@property --tw-opacity {
syntax: "*";
inherits: false;
}
@property --tw-saturate {
syntax: "*";
inherits: false;
}
@property --tw-sepia {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-drop-shadow-size {
syntax: "*";
inherits: false;
}
@property --tw-shadow {
syntax: "*";
inherits: false;
@ -853,28 +1267,71 @@
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-blur {
syntax: "*";
inherits: false;
}
@property --tw-brightness {
syntax: "*";
inherits: false;
}
@property --tw-contrast {
syntax: "*";
inherits: false;
}
@property --tw-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-invert {
syntax: "*";
inherits: false;
}
@property --tw-opacity {
syntax: "*";
inherits: false;
}
@property --tw-saturate {
syntax: "*";
inherits: false;
}
@property --tw-sepia {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-drop-shadow-size {
syntax: "*";
inherits: false;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-translate-z: 0;
--tw-space-y-reverse: 0;
--tw-space-x-reverse: 0;
--tw-border-style: solid;
--tw-leading: initial;
--tw-font-weight: initial;
--tw-tracking: initial;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
--tw-grayscale: initial;
--tw-hue-rotate: initial;
--tw-invert: initial;
--tw-opacity: initial;
--tw-saturate: initial;
--tw-sepia: initial;
--tw-drop-shadow: initial;
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-shadow-alpha: 100%;
@ -889,6 +1346,19 @@
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
--tw-grayscale: initial;
--tw-hue-rotate: initial;
--tw-invert: initial;
--tw-opacity: initial;
--tw-saturate: initial;
--tw-sepia: initial;
--tw-drop-shadow: initial;
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
}
}
}

View file

@ -4,24 +4,24 @@
@custom-variant dark (&:is(.dark *));
@theme {
--color-surface: #ffffff;
--color-surface-muted: #f9fafb;
--color-text-strong: #111827;
--color-text-muted: #6b7280;
--color-border-subtle: #eaecf0;
--color-primary: #7c3aed;
--color-primary-strong: #6d28d9;
--color-danger: #dc2626;
--color-danger-strong: #b91c1c;
--color-warning-bg: #fff4e2;
--color-warning-fg: #db9729;
--color-warning-border: #db9729;
--color-info-bg: #eff6ff;
--color-info-fg: #2563eb;
--color-info-border: #bfdbfe;
--color-success-bg: #ecfdf3;
--color-success-fg: #16a34a;
--color-success-border: #bbf7d0;
--color-surface: #ffffff;
--color-surface-muted: #f9fafb;
--color-text-strong: #111827;
--color-text-muted: #6b7280;
--color-border-subtle: #eaecf0;
--color-primary: #7c3aed;
--color-primary-strong: #6d28d9;
--color-danger: #dc2626;
--color-danger-strong: #b91c1c;
--color-warning-bg: #fff4e2;
--color-warning-fg: #db9729;
--color-warning-border: #db9729;
--color-info-bg: #eff6ff;
--color-info-fg: #2563eb;
--color-info-border: #bfdbfe;
--color-success-bg: #ecfdf3;
--color-success-fg: #16a34a;
--color-success-border: #bbf7d0;
}
@source "./internal/web/views/**/*.templ";