Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+475
View File
@@ -0,0 +1,475 @@
package web
import (
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
func (app *application) gardenDashboard(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
}
journalEntries, _, err := apiClient.JournalEntries(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
pinboardEntries, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard)
if err != nil {
app.handleAPIError(w, r, err)
return
}
images, _, err := apiClient.Images(r.Context(), gardenID, "", "")
if err != nil {
var apiError *client.APIError
if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusNotFound {
app.handleAPIError(w, r, err)
return
}
}
horizon := time.Now().AddDate(0, 0, 30)
upcoming := make([]client.Task, 0)
for _, task := range tasks {
if task.CompletedAt != nil {
continue
}
due := task.DueAtEnd
if due == nil {
due = task.DueAtStart
}
if due != nil && !due.After(horizon) {
upcoming = append(upcoming, task)
}
}
sort.SliceStable(upcoming, func(i, j int) bool { return taskSortTime(upcoming[i]).Before(taskSortTime(upcoming[j])) })
if len(upcoming) > 8 {
upcoming = upcoming[:8]
}
data := app.newTemplateData(r)
data.Garden = &garden
data.Tasks = upcoming
data.Plants = plants
data.Locations = locations
data.JournalEntries = journalEntries
data.PinboardEntries = pinboardEntries
data.PhotoCount = len(images)
app.render(w, http.StatusOK, "dashboard.tmpl", data)
}
func taskSortTime(task client.Task) time.Time {
if task.DueAtEnd != nil {
return *task.DueAtEnd
}
if task.DueAtStart != nil {
return *task.DueAtStart
}
return time.Unix(1<<62, 0)
}
func (app *application) taskCalendar(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
view := r.URL.Query().Get("view")
if view != "month" {
view = "week"
}
anchor := time.Now()
if value := r.URL.Query().Get("date"); value != "" {
if parsed, parseErr := time.Parse("2006-01-02", value); parseErr == nil {
anchor = parsed
}
}
start, end := calendarRange(anchor, view)
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
}
days := make([]calendarDay, 0)
for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
entry := calendarDay{Date: day}
for _, task := range tasks {
if task.CompletedAt == nil && taskOverlapsDay(task, day) {
entry.Tasks = append(entry.Tasks, task)
}
}
days = append(days, entry)
}
step := 7
if view == "month" {
step = 1
}
previous := start.AddDate(0, 0, -step)
next := end
if view == "month" {
previous = start.AddDate(0, -1, 0)
next = start.AddDate(0, 1, 0)
}
data := app.newTemplateData(r)
data.Garden = &garden
data.CalendarDays = days
data.CalendarStart = start
data.CalendarEnd = end.Add(-time.Nanosecond)
data.CalendarView = view
data.PreviousDate = previous.Format("2006-01-02")
data.NextDate = next.Format("2006-01-02")
app.render(w, http.StatusOK, "task_calendar.tmpl", data)
}
func calendarRange(anchor time.Time, view string) (time.Time, time.Time) {
y, m, d := anchor.Date()
loc := anchor.Location()
start := time.Date(y, m, d, 0, 0, 0, 0, loc)
if view == "month" {
start = time.Date(y, m, 1, 0, 0, 0, 0, loc)
return start, start.AddDate(0, 1, 0)
}
offset := (int(start.Weekday()) + 6) % 7
start = start.AddDate(0, 0, -offset)
return start, start.AddDate(0, 0, 7)
}
func taskOverlapsDay(task client.Task, day time.Time) bool {
dayEnd := day.AddDate(0, 0, 1)
start, end := task.DueAtStart, task.DueAtEnd
if start == nil {
start = end
}
if end == nil {
end = start
}
if start == nil {
return false
}
return start.Before(dayEnd) && !end.Before(day)
}
func (app *application) gardenSearch(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
mode := r.URL.Query().Get("mode")
month, _ := strconv.Atoi(r.URL.Query().Get("month"))
if month < 1 || month > 12 {
month = 0
}
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year < 1 {
year = 0
}
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
}
result := searchResults{Query: query, Mode: mode, Month: month, MonthName: germanMonthName(month), Year: year, Years: taskYears(tasks)}
if query != "" || month > 0 || year > 0 {
plants, _, err := apiClient.Plants(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
journal, _, err := apiClient.JournalEntries(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
pinboard, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard)
if err != nil {
app.handleAPIError(w, r, err)
return
}
tags, _, err := apiClient.GardenTags(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
if mode != "month" {
if queryMonth := parseGermanMonth(query); queryMonth > 0 {
month = queryMonth
}
}
matchedTags := map[string]string{}
if mode != "month" {
for _, tag := range tags {
if containsTerms(query, tag) {
matchedTags[strings.ToLower(tag)] = tag
}
}
}
addMonthTags := func(values []string) {
if mode == "month" {
for _, tag := range values {
matchedTags[strings.ToLower(tag)] = tag
}
}
}
for _, item := range tasks {
textMatch := query == "" || containsTerms(query, item.Title, item.Description, strings.Join(item.Tags, " "))
monthMatch := month == 0 || taskInMonth(item, month)
yearMatch := year == 0 || taskInYear(item, year)
if textMatch && monthMatch && yearMatch {
addMonthTags(item.Tags)
result.Tasks = append(result.Tasks, taskSearchCard(gardenID, item))
result.All = append(result.All, result.Tasks[len(result.Tasks)-1])
}
}
for _, item := range plants {
if (mode == "month" && plantInMonth(item, month)) || (mode != "month" && containsTerms(query, item.Name, item.Notes, string(item.Attributes), strings.Join(item.Tags, " "))) {
addMonthTags(item.Tags)
result.Plants = append(result.Plants, plantSearchCard(gardenID, item))
result.All = append(result.All, result.Plants[len(result.Plants)-1])
}
}
for _, item := range species {
if (mode == "month" && speciesInMonth(item, month)) || (mode != "month" && (containsTerms(query, item.CommonName, item.Cultivar, item.BotanicalName, item.Category, item.Notes, string(item.Attributes), strings.Join(item.Tags, " ")) || (month > 0 && speciesInMonth(item, month)))) {
addMonthTags(item.Tags)
result.Species = append(result.Species, speciesSearchCard(gardenID, item))
result.All = append(result.All, result.Species[len(result.Species)-1])
}
}
for _, item := range journal {
if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, strings.Join(item.Tags, " "))) {
addMonthTags(item.Tags)
result.Journal = append(result.Journal, gardenEntrySearchCard(gardenID, item))
result.All = append(result.All, result.Journal[len(result.Journal)-1])
}
}
for _, item := range pinboard {
if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, item.Body, strings.Join(item.Tags, " "))) {
addMonthTags(item.Tags)
result.Pinboard = append(result.Pinboard, gardenEntrySearchCard(gardenID, item))
result.All = append(result.All, result.Pinboard[len(result.Pinboard)-1])
}
}
seenTagUsages := map[string]bool{}
addTagUsages := func(card searchCard, values []string) {
for _, tag := range values {
if _, ok := matchedTags[strings.ToLower(tag)]; !ok {
continue
}
if seenTagUsages[card.URL] {
continue
}
seenTagUsages[card.URL] = true
usage := tagUsageCard(card, matchedTags[strings.ToLower(tag)])
result.Tags = append(result.Tags, usage)
allSeen := false
for _, existing := range result.All {
if existing.URL == usage.URL {
allSeen = true
break
}
}
if !allSeen {
result.All = append(result.All, usage)
}
}
}
for _, item := range tasks {
addTagUsages(taskSearchCard(gardenID, item), item.Tags)
}
for _, item := range plants {
addTagUsages(plantSearchCard(gardenID, item), item.Tags)
}
for _, item := range species {
addTagUsages(speciesSearchCard(gardenID, item), item.Tags)
}
for _, item := range journal {
addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags)
}
for _, item := range pinboard {
addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags)
}
}
data := app.newTemplateData(r)
data.Garden = &garden
data.Search = result
app.render(w, http.StatusOK, "search.tmpl", data)
}
func taskSearchCard(gardenID int, item client.Task) searchCard {
return searchCard{item.Title, item.Description, taskDue(item), "Aufgabe", webPath("task.edit", gardenID, item.ID)}
}
func plantSearchCard(gardenID int, item client.Plant) searchCard {
return searchCard{item.Name, item.Notes, plantStatusName(item.Status), "Im Garten", webPath("plant.edit", gardenID, item.ID)}
}
func speciesSearchCard(gardenID int, item client.Species) searchCard {
title := item.CommonName
if item.Cultivar != "" {
title += " · " + item.Cultivar
}
return searchCard{title, item.BotanicalName, speciesSeason(item), "Pflanze", webPath("species.edit", gardenID, item.ID)}
}
func gardenEntrySearchCard(gardenID int, item client.JournalEntry) searchCard {
path, label := "journal", "Tagebuch"
if item.EntryType == client.JournalEntryTypePinboard {
path, label = "pinboard", "Pinnwand"
}
return searchCard{item.Title, item.AuthorName, item.CreatedAt.Local().Format("02.01.2006 · 15:04 Uhr"), label, webPath("garden."+path, gardenID) + fmt.Sprintf("#entry-%d", item.ID)}
}
func tagUsageCard(card searchCard, tag string) searchCard {
card.Meta = "#" + tag + " · " + card.Meta
return card
}
func speciesSeason(item client.Species) string {
if item.HarvestMonthFrom != nil {
return fmt.Sprintf("Erntezeit ab %s", germanMonthName(*item.HarvestMonthFrom))
}
return item.Category
}
func plantInMonth(item client.Plant, month int) bool {
return (item.AcquiredAt != nil && int(item.AcquiredAt.Month()) == month) || int(item.CreatedAt.Month()) == month
}
func germanMonthName(month int) string {
names := []string{"", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"}
if month >= 1 && month <= 12 {
return names[month]
}
return ""
}
func containsTerms(query string, values ...string) bool {
query = strings.ToLower(strings.TrimSpace(query))
for _, term := range strings.Fields(query) {
term = strings.TrimPrefix(term, "#")
found := false
for _, value := range values {
if strings.Contains(strings.ToLower(value), term) {
found = true
break
}
}
if !found {
return false
}
}
return query != ""
}
func parseKeywords(value string) []string {
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
result = append(result, part)
}
}
return result
}
func parseGermanMonth(value string) int {
names := []string{"januar", "februar", "märz", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "dezember"}
value = strings.ToLower(strings.TrimSpace(value))
for i, name := range names {
if value == name || value == strings.TrimSuffix(name, "uar") {
return i + 1
}
}
return 0
}
func taskInMonth(task client.Task, month int) bool {
return (task.DueAtStart != nil && int(task.DueAtStart.Month()) == month) ||
(task.DueAtEnd != nil && int(task.DueAtEnd.Month()) == month)
}
func taskInYear(task client.Task, year int) bool {
return (task.DueAtStart != nil && task.DueAtStart.Year() == year) ||
(task.DueAtEnd != nil && task.DueAtEnd.Year() == year)
}
func taskYears(tasks []client.Task) []int {
minYear, maxYear := 0, 0
setYear := func(year int) {
if minYear == 0 || year < minYear {
minYear = year
}
if year > maxYear {
maxYear = year
}
}
for _, task := range tasks {
if task.DueAtStart != nil {
setYear(task.DueAtStart.Year())
}
if task.DueAtEnd != nil {
setYear(task.DueAtEnd.Year())
}
}
if minYear == 0 {
return nil
}
result := make([]int, 0, maxYear-minYear+1)
for year := minYear; year <= maxYear; year++ {
result = append(result, year)
}
return result
}
func speciesInMonth(item client.Species, month int) bool {
return monthInRange(month, item.SowMonthFrom, item.SowMonthTo) || monthInRange(month, item.PlantingMonthFrom, item.PlantingMonthTo) || monthInRange(month, item.HarvestMonthFrom, item.HarvestMonthTo)
}
func monthInRange(month int, from, to *int) bool {
if from == nil || to == nil {
return false
}
return monthRangeMatches(month, *from, *to)
}
func monthRangeMatches(month, from, to int) bool {
if from <= to {
return month >= from && month <= to
}
return month >= from || month <= to
}