746 lines
21 KiB
Go
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, " · ")
|
|
}
|