xtablo-source/deprecated/internal/web/views/tasks_view.go
Arthur Belleville 5d0c201e86
Some checks failed
backend-ci / Backend tests (pull_request) Failing after 53s
backend-ci / Backend tests (push) Failing after 1s
Some work
2026-05-23 17:26:01 +02:00

746 lines
21 KiB
Go

package views
import (
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/google/uuid"
tablomodel "xtablo-backend/internal/tablos"
taskmodel "xtablo-backend/internal/tasks"
"xtablo-backend/internal/web/ui"
)
type TasksPageViewModel struct {
State taskmodel.TaskPageState
Filters TasksFiltersView
Form TasksFormOptionsView
Kanban TasksKanbanView
List TasksListView
Roadmap TasksRoadmapView
HasTasks bool
}
type TasksFiltersView struct {
Tablos []TasksOptionView
Assignees []TasksOptionView
Statuses []TasksOptionView
}
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
TabloID string
TabloName string
EtapeName string
StatusLabel string
StatusValue string
DueDate string
DueDateValue string
Assignee string
AssigneeID string
ParentTaskID string
}
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
}
etapesByID := make(map[uuid.UUID]etapeMeta)
etapesByTablo := make(map[string][]TasksOptionView)
for _, record := range tasks {
if !record.IsEtape {
continue
}
etapesByID[record.ID] = etapeMeta{
ID: record.ID,
TabloID: record.TabloID,
Title: record.Title,
}
etapesByTablo[record.TabloID.String()] = append(etapesByTablo[record.TabloID.String()], TasksOptionView{
Value: record.ID.String(),
Label: record.Title,
})
}
for key := range etapesByTablo {
slices.SortFunc(etapesByTablo[key], func(a, b TasksOptionView) int {
return strings.Compare(a.Label, b.Label)
})
}
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 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)},
},
}
slices.SortFunc(sortedTablos, func(a, b tablomodel.Record) int {
return strings.Compare(a.Name, b.Name)
})
for _, tablo := range sortedTablos {
view.Tablos = append(view.Tablos, TasksOptionView{
Value: tablo.ID.String(),
Label: tablo.Name,
Selected: containsUUID(state.TabloIDs, tablo.ID),
})
}
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 taskRoadmapModeSelectProps(state taskmodel.TaskPageState) ui.SelectProps {
return ui.SelectProps{
ID: "tasks-roadmap-mode",
Name: "roadmap_mode_nav",
Value: taskRoadmapModeHref(state, state.RoadmapMode),
Options: []ui.SelectOption{
{Value: taskRoadmapModeHref(state, taskmodel.TaskRoadmapModeWeek), Label: "Semaine"},
{Value: taskRoadmapModeHref(state, taskmodel.TaskRoadmapModeMonth), Label: "Mois"},
},
Attrs: map[string]any{
"onchange": "if (this.value) window.location.href=this.value",
},
}
}
func tasksFilterSummaryLabel(filters TasksFiltersView) string {
count := 0
for _, option := range filters.Tablos {
if option.Selected {
count++
}
}
for _, option := range filters.Statuses {
if option.Selected {
count++
}
}
for _, option := range filters.Assignees {
if option.Selected {
count++
}
}
if count == 0 {
return "Filtrer"
}
return "Filtrer (" + strconv.Itoa(count) + ")"
}
func tasksFilterGroupAllSelected(options []TasksOptionView) bool {
for _, option := range options {
if option.Selected {
return false
}
}
return true
}
func tasksFilterGroupHasChoices(options []TasksOptionView) bool {
return len(options) > 0
}
func tasksClearTabloFiltersHref(state taskmodel.TaskPageState) string {
nextState := state
nextState.TabloIDs = nil
return stateAction("/tasks", nextState)
}
func tasksClearStatusFiltersHref(state taskmodel.TaskPageState) string {
nextState := state
nextState.Statuses = nil
return stateAction("/tasks", nextState)
}
func tasksClearAssigneeFiltersHref(state taskmodel.TaskPageState) string {
nextState := state
nextState.AssigneeIDs = nil
return stateAction("/tasks", nextState)
}
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 {
switch status {
case taskmodel.StatusInProgress:
return "En cours"
case taskmodel.StatusInReview:
return "Vérification"
case taskmodel.StatusDone:
return "Terminé"
default:
return "À faire"
}
}
func formatOptionalDate(value *time.Time) string {
if value == nil {
return ""
}
return value.Format("02 Jan")
}
func formatOptionalDateInput(value *time.Time) string {
if value == nil {
return ""
}
return value.Format("2006-01-02")
}
func assigneeName(id *uuid.UUID, labels map[uuid.UUID]string) string {
if id == nil {
return ""
}
if label, ok := labels[*id]; ok {
return label
}
return ""
}
func optionalUUIDString(id *uuid.UUID) string {
if id == nil {
return ""
}
return id.String()
}
func joinTaskMeta(parts ...string) string {
filtered := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
filtered = append(filtered, part)
}
return strings.Join(filtered, " · ")
}