@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user