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 }