Files
Gardomatic/internal/web/tasks.go
T
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

328 lines
10 KiB
Go

package web
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
type taskForm struct {
CSRFToken string `form:"csrf_token"`
PlantID int `form:"plant_id"`
LocationID int `form:"location_id"`
Title string `form:"title"`
Description string `form:"description"`
DueAtStart string `form:"due_at_start"`
DueAtEnd string `form:"due_at_end"`
Recurrence string `form:"recurrence"`
RecurrenceInterval int `form:"recurrence_interval"`
Priority int `form:"priority"`
Keywords string `form:"keywords"`
PlantStatusOnCompletion string `form:"status_on_completion"`
Errors map[string]string
}
func (app *application) tasks(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
tasks, _, err := apiClient.Tasks(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
plants, _, err := apiClient.Plants(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
priorities, _, err := apiClient.TaskPriorities(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
taskYears := taskYears(tasks)
filters := collectionFilters(r, "status", "plant", "location", "priority", "month", "year", "sort")
if !r.URL.Query().Has("status") {
filters["status"] = "open"
}
tasks = filterTasks(tasks, filters)
sortTasks(tasks, filters["sort"])
tasks, pagination := paginateCollection(r, tasks)
data := app.newTemplateData(r)
data.Garden, data.Tasks, data.Plants, data.Locations, data.TaskPriorities, data.TaskYears = &garden, tasks, plants, locations, priorities, taskYears
data.Filters, data.Pagination = filters, pagination
app.render(w, http.StatusOK, "tasks.tmpl", data)
}
func filterTasks(tasks []client.Task, filters map[string]string) []client.Task {
result := make([]client.Task, 0, len(tasks))
plantID, _ := strconv.Atoi(filters["plant"])
locationID, _ := strconv.Atoi(filters["location"])
priority, _ := strconv.Atoi(filters["priority"])
month, _ := strconv.Atoi(filters["month"])
year, _ := strconv.Atoi(filters["year"])
for _, task := range tasks {
if task.Active != nil && !*task.Active {
continue
}
if filters["status"] == "open" && task.CompletedAt != nil {
continue
}
if filters["status"] == "done" && task.CompletedAt == nil {
continue
}
if plantID > 0 && (task.PlantID == nil || *task.PlantID != plantID) {
continue
}
if locationID > 0 && (task.LocationID == nil || *task.LocationID != locationID) {
continue
}
if filters["priority"] != "" && task.Priority != priority {
continue
}
if month > 0 && !taskInMonth(task, month) {
continue
}
if year > 0 && !taskInYear(task, year) {
continue
}
result = append(result, task)
}
return result
}
func (app *application) taskCreate(w http.ResponseWriter, r *http.Request) {
app.renderTaskForm(w, r, taskForm{RecurrenceInterval: 1, Errors: map[string]string{}}, http.StatusOK, 0)
}
func (app *application) taskEdit(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
taskID, err := app.readPathID(r, "taskID")
if err != nil {
app.notFound(w)
return
}
task, _, err := client.FromContext(r.Context()).Task(r.Context(), gardenID, taskID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
form := taskForm{Title: task.Title, Description: task.Description, Recurrence: task.Recurrence, RecurrenceInterval: task.RecurrenceInterval, Priority: task.Priority, Keywords: strings.Join(task.Tags, ", "), Errors: map[string]string{}}
if form.RecurrenceInterval < 1 {
form.RecurrenceInterval = 1
}
if task.PlantID != nil {
form.PlantID = *task.PlantID
}
if task.LocationID != nil {
form.LocationID = *task.LocationID
}
if task.PlantStatusOnCompletion != nil {
form.PlantStatusOnCompletion = *task.PlantStatusOnCompletion
}
if task.DueAtStart != nil {
form.DueAtStart = task.DueAtStart.Local().Format("2006-01-02")
}
if task.DueAtEnd != nil {
form.DueAtEnd = task.DueAtEnd.Local().Format("2006-01-02")
}
app.renderTaskForm(w, r, form, http.StatusOK, taskID)
}
func (app *application) taskSave(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
taskID := 0
if id, readErr := app.readPathID(r, "taskID"); readErr == nil {
taskID = id
}
var form taskForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Title, form.Description = strings.TrimSpace(form.Title), strings.TrimSpace(form.Description)
form.Errors = map[string]string{}
if form.Title == "" {
form.Errors["title"] = "Ein Titel ist erforderlich."
}
input := client.TaskInput{Title: &form.Title, Description: &form.Description, Priority: &form.Priority, Tags: parseKeywords(form.Keywords)}
if form.PlantID > 0 {
input.PlantID = &form.PlantID
} else {
input.ClearPlantID = true
}
if form.LocationID > 0 {
input.LocationID = &form.LocationID
} else {
input.ClearLocationID = true
}
input.DueAtStart, input.ClearDueAtStart = parseTaskTime(form.DueAtStart, "due_at_start", form.Errors)
input.DueAtEnd, input.ClearDueAtEnd = parseTaskTime(form.DueAtEnd, "due_at_end", form.Errors)
if input.DueAtStart != nil && input.DueAtEnd != nil && input.DueAtStart.After(*input.DueAtEnd) {
form.Errors["due_at_end"] = "Das Ende darf nicht vor dem Beginn liegen."
}
if form.Recurrence != "" && form.Recurrence != "daily" && form.Recurrence != "weekly" && form.Recurrence != "monthly" && form.Recurrence != "yearly" {
form.Errors["recurrence"] = "Bitte eine gültige Wiederholung auswählen."
}
if form.Recurrence != "" && input.DueAtStart == nil && input.DueAtEnd == nil {
form.Errors["recurrence"] = "Für eine Wiederholung ist ein Fälligkeitsdatum erforderlich."
}
if form.Recurrence != "" && form.RecurrenceInterval < 1 {
form.Errors["recurrence_interval"] = "Der Faktor muss mindestens 1 sein."
}
input.Recurrence = &form.Recurrence
input.RecurrenceInterval = &form.RecurrenceInterval
input.PlantStatusOnCompletion = &form.PlantStatusOnCompletion
if form.PlantStatusOnCompletion != "" && form.PlantID < 1 {
form.Errors["plant_status_on_completion"] = "Bitte zuerst eine Pflanze auswählen."
}
if len(form.Errors) == 0 {
apiClient := client.FromContext(r.Context())
var saveErr error
if taskID > 0 {
_, _, saveErr = apiClient.UpdateTask(r.Context(), gardenID, taskID, input)
} else {
_, _, saveErr = apiClient.CreateTask(r.Context(), gardenID, input)
}
if saveErr == nil {
http.Redirect(w, r, webPath("tasks", gardenID), http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(saveErr, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, saveErr)
return
}
}
app.renderTaskForm(w, r, form, http.StatusUnprocessableEntity, taskID)
}
func parseTaskTime(value, field string, validation map[string]string) (*time.Time, bool) {
if strings.TrimSpace(value) == "" {
return nil, true
}
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
if err != nil {
// Accept legacy clients that still submit a datetime; current forms are day-only.
parsed, err = time.ParseInLocation("2006-01-02T15:04", value, time.Local)
}
if err != nil {
validation[field] = "Bitte ein vollständiges Datum angeben."
return nil, false
}
return &parsed, false
}
func (app *application) taskComplete(w http.ResponseWriter, r *http.Request) {
app.setTaskCompleted(w, r, true)
}
func (app *application) taskReopen(w http.ResponseWriter, r *http.Request) {
app.setTaskCompleted(w, r, false)
}
func (app *application) setTaskCompleted(w http.ResponseWriter, r *http.Request, completed bool) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
taskID, err := app.readPathID(r, "taskID")
if err != nil {
app.notFound(w)
return
}
if _, _, err := client.FromContext(r.Context()).UpdateTask(r.Context(), gardenID, taskID, client.TaskInput{Completed: &completed}); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("tasks", gardenID), http.StatusSeeOther)
}
func (app *application) taskDelete(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
taskID, err := app.readPathID(r, "taskID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeleteTask(r.Context(), gardenID, taskID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("tasks", gardenID), http.StatusSeeOther)
}
func (app *application) renderTaskForm(w http.ResponseWriter, r *http.Request, form taskForm, status, taskID int) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
plants, _, err := apiClient.Plants(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
priorities, _, err := apiClient.TaskPriorities(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden, data.Plants, data.Locations, data.TaskPriorities, data.Form, data.TaskID = &garden, plants, locations, priorities, form, taskID
if taskID > 0 {
task, _, taskErr := apiClient.Task(r.Context(), gardenID, taskID)
if taskErr != nil {
app.handleAPIError(w, r, taskErr)
return
}
data.TaskCreatedBy = task.CreatedBy
}
data.TagSuggestions = app.loadTagSuggestions(r, gardenID)
app.render(w, status, "task_form.tmpl", data)
}