diff --git a/cmd/server/calendar.go b/cmd/server/calendar.go index 6a05969..0cb4a14 100644 --- a/cmd/server/calendar.go +++ b/cmd/server/calendar.go @@ -6,32 +6,14 @@ import ( "strings" ) -func buildTasksForDay(plans []Plan, stepMap map[int64][]CropStep, month, day int) []Task { - var tasks []Task - for _, p := range plans { - tasks = append(tasks, expandPlanDayTasks(p, stepMap[p.CropID], month, day)...) - } +func buildTasksForDay(plans []Plan, stepMap map[int64][]CropStep, customTasks []CustomTask, doneMap map[string]bool, month, day int) []Task { + tasks := collectTasksForDate(plans, stepMap, customTasks, month, day, 0) + applyCompletion(tasks, doneMap) sortTasks(tasks) return tasks } -func buildCalendar(plans []Plan, stepMap map[int64][]CropStep, startMonth, startDay, daysPerMonth, spanMonths int) []CalendarMonth { - tasksByKey := make(map[string][]Task) - for _, p := range plans { - for m := 1; m <= 12; m++ { - for d := 1; d <= daysPerMonth; d++ { - dayTasks := expandPlanDayTasks(p, stepMap[p.CropID], m, d) - if len(dayTasks) > 0 { - key := fmt.Sprintf("%d-%d", m, d) - tasksByKey[key] = append(tasksByKey[key], dayTasks...) - } - } - } - } - for k := range tasksByKey { - sortTasks(tasksByKey[k]) - } - +func buildCalendar(plans []Plan, stepMap map[int64][]CropStep, customTasks []CustomTask, doneMap map[string]bool, startMonth, startDay, daysPerMonth, spanMonths int) []CalendarMonth { var out []CalendarMonth for offset := 0; offset < spanMonths; offset++ { month := wrapMonth(startMonth + offset) @@ -45,10 +27,12 @@ func buildCalendar(plans []Plan, stepMap map[int64][]CropStep, startMonth, start if offset == 0 { fromDay = startDay } + var days []CalendarDay for d := fromDay; d <= daysPerMonth; d++ { - key := fmt.Sprintf("%d-%d", month, d) - items := append([]Task(nil), tasksByKey[key]...) + items := collectTasksForDate(plans, stepMap, customTasks, month, d, yearOffset) + applyCompletion(items, doneMap) + sortTasks(items) days = append(days, CalendarDay{Day: d, Tasks: items}) } out = append(out, CalendarMonth{ @@ -62,20 +46,57 @@ func buildCalendar(plans []Plan, stepMap map[int64][]CropStep, startMonth, start return out } -func expandPlanDayTasks(p Plan, steps []CropStep, month, day int) []Task { +func collectTasksForDate(plans []Plan, stepMap map[int64][]CropStep, customTasks []CustomTask, month, day, yearOffset int) []Task { + var tasks []Task + + for _, p := range plans { + tasks = append(tasks, expandPlanDayTasks(p, stepMap[p.CropID], month, day, yearOffset)...) + } + for _, c := range customTasks { + if c.Month == month && c.Day == day && c.YearOffset == yearOffset { + field := c.TargetName + if strings.TrimSpace(field) == "" { + field = "Allgemein" + } + msg := c.Title + if strings.TrimSpace(c.Notes) != "" { + msg = fmt.Sprintf("%s (Notiz: %s)", msg, c.Notes) + } + tasks = append(tasks, Task{ + UID: fmt.Sprintf("custom:%d", c.ID), + Type: "Aufgabe", + Field: field, + Message: msg, + Month: month, + Day: day, + YearOffset: yearOffset, + SortOrder: 40, + }) + } + } + + return tasks +} + +func expandPlanDayTasks(p Plan, steps []CropStep, month, day, yearOffset int) []Task { field := p.TargetName if strings.TrimSpace(field) == "" { field = "Unbekanntes Feld" } + var out []Task baseHarvest := p.HarvestMonth - if p.StartMonth == month && p.StartDay == day { + if p.StartMonth == month && p.StartDay == day && yearOffset == 0 { out = append(out, Task{ - Type: "Aussaat", - Field: field, - Message: withOptionalNote(fmt.Sprintf("Aussaat %s", p.CropName), p.Notes), - SortOrder: 10, + UID: fmt.Sprintf("plan:%d:sow:0", p.ID), + Type: "Aussaat", + Field: field, + Message: withOptionalNote(fmt.Sprintf("Aussaat %s", p.CropName), p.Notes), + Month: month, + Day: day, + YearOffset: yearOffset, + SortOrder: 10, }) } @@ -90,13 +111,19 @@ func expandPlanDayTasks(p Plan, steps []CropStep, month, day int) []Task { } } harvestMonths = uniqueMonths(harvestMonths) - for _, hm := range harvestMonths { + + for idx, hm := range harvestMonths { if hm == month && p.HarvestDay == day { + uid := fmt.Sprintf("plan:%d:harvest:%d", p.ID, idx) out = append(out, Task{ - Type: "Ernte", - Field: field, - Message: withOptionalNote(fmt.Sprintf("Ernte %s", p.CropName), p.Notes), - SortOrder: 20, + UID: uid, + Type: "Ernte", + Field: field, + Message: withOptionalNote(fmt.Sprintf("Ernte %s", p.CropName), p.Notes), + Month: month, + Day: day, + YearOffset: yearOffset, + SortOrder: 20, }) } } @@ -105,23 +132,31 @@ func expandPlanDayTasks(p Plan, steps []CropStep, month, day int) []Task { switch s.Phase { case "pre": taskMonth := wrapMonth(p.StartMonth - s.MonthOffset) - if taskMonth == month && p.StartDay == day { + if taskMonth == month && p.StartDay == day && yearOffset == 0 { out = append(out, Task{ - Type: "Vorbereitung", - Field: field, - Message: s.Title, - SortOrder: 5, + UID: fmt.Sprintf("plan:%d:pre:%d", p.ID, s.ID), + Type: "Vorbereitung", + Field: field, + Message: s.Title, + Month: month, + Day: day, + YearOffset: yearOffset, + SortOrder: 5, }) } case "post": - for _, hm := range harvestMonths { + for idx, hm := range harvestMonths { taskMonth := wrapMonth(hm + s.MonthOffset) if taskMonth == month && p.HarvestDay == day { out = append(out, Task{ - Type: "Nachbereitung", - Field: field, - Message: s.Title, - SortOrder: 30, + UID: fmt.Sprintf("plan:%d:post:%d:%d", p.ID, s.ID, idx), + Type: "Nachbereitung", + Field: field, + Message: s.Title, + Month: month, + Day: day, + YearOffset: yearOffset, + SortOrder: 30, }) } } @@ -131,6 +166,13 @@ func expandPlanDayTasks(p Plan, steps []CropStep, month, day int) []Task { return out } +func applyCompletion(tasks []Task, doneMap map[string]bool) { + for i := range tasks { + k := completionKey(tasks[i].UID, tasks[i].Month, tasks[i].Day, tasks[i].YearOffset) + tasks[i].Completed = doneMap[k] + } +} + func (p Plan) HarvestDistance() int { if p.GrowMonths <= 0 { return 1 @@ -148,6 +190,9 @@ func withOptionalNote(base, note string) string { func sortTasks(tasks []Task) { sort.Slice(tasks, func(i, j int) bool { + if tasks[i].Completed != tasks[j].Completed { + return !tasks[i].Completed + } if tasks[i].SortOrder == tasks[j].SortOrder { if tasks[i].Field == tasks[j].Field { return tasks[i].Message < tasks[j].Message @@ -169,3 +214,7 @@ func uniqueMonths(values []int) []int { } return out } + +func completionKey(uid string, month, day, yearOffset int) string { + return fmt.Sprintf("%s|%d|%d|%d", uid, month, day, yearOffset) +} diff --git a/cmd/server/db.go b/cmd/server/db.go index cee252c..23ae727 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -53,6 +53,32 @@ func ensureSchema(db *sql.DB) error { created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_crop_steps_crop FOREIGN KEY(crop_id) REFERENCES crops(id) ON DELETE CASCADE )`, + `CREATE TABLE IF NOT EXISTS custom_task_templates( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(140) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS custom_tasks( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + template_id BIGINT NULL, + title VARCHAR(140) NOT NULL, + month TINYINT NOT NULL, + day TINYINT NOT NULL, + year_offset SMALLINT NOT NULL DEFAULT 0, + target_name VARCHAR(120) NOT NULL DEFAULT '', + notes VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_custom_tasks_template FOREIGN KEY(template_id) REFERENCES custom_task_templates(id) ON DELETE SET NULL + )`, + `CREATE TABLE IF NOT EXISTS task_completions( + task_uid VARCHAR(190) NOT NULL, + month TINYINT NOT NULL, + day TINYINT NOT NULL, + year_offset SMALLINT NOT NULL DEFAULT 0, + done TINYINT(1) NOT NULL DEFAULT 1, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY(task_uid,month,day,year_offset) + )`, `CREATE TABLE IF NOT EXISTS plans( id BIGINT AUTO_INCREMENT PRIMARY KEY, @@ -199,6 +225,67 @@ func (a *App) listCropStepsMap() (map[int64][]CropStep, error) { return m, nil } +func (a *App) listCustomTaskTemplates() ([]CustomTaskTemplate, error) { + rows, err := a.db.Query(`SELECT id,title FROM custom_task_templates ORDER BY title`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []CustomTaskTemplate + for rows.Next() { + var t CustomTaskTemplate + if err := rows.Scan(&t.ID, &t.Title); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (a *App) listCustomTasks() ([]CustomTask, error) { + rows, err := a.db.Query(` + SELECT id,template_id,title,month,day,year_offset,COALESCE(target_name,''),COALESCE(notes,'') + FROM custom_tasks + ORDER BY year_offset, month, day, title`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []CustomTask + for rows.Next() { + var t CustomTask + if err := rows.Scan(&t.ID, &t.TemplateID, &t.Title, &t.Month, &t.Day, &t.YearOffset, &t.TargetName, &t.Notes); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (a *App) listTaskCompletionsMap() (map[string]bool, error) { + rows, err := a.db.Query(`SELECT task_uid,month,day,year_offset,done FROM task_completions WHERE done=1`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]bool) + for rows.Next() { + var uid string + var month, day, yearOffset int + var done bool + if err := rows.Scan(&uid, &month, &day, &yearOffset, &done); err != nil { + return nil, err + } + if done { + out[completionKey(uid, month, day, yearOffset)] = true + } + } + return out, rows.Err() +} + func seedCrops(db *sql.DB) error { items := []Crop{ {Name: "Weizen", SowStartMonth: 9, SowEndMonth: 11, GrowMonths: 10}, diff --git a/cmd/server/domain.go b/cmd/server/domain.go index 2fab1cd..a5d8d50 100644 --- a/cmd/server/domain.go +++ b/cmd/server/domain.go @@ -1,4 +1,4 @@ -package main +package main import ( "errors" @@ -116,7 +116,8 @@ func validateCropInput(name string, start, end, grow, regrowCycles int) error { return errors.New("Wachstumsdauer muss 1-24 sein") } if regrowCycles < 0 || regrowCycles > 120 { - return errors.New("Regrow-Zyklen muessen 0-120 sein") + return errors.New("Regrow-Zyklen müssen 0-120 sein") } return nil } + diff --git a/cmd/server/handlers_actions.go b/cmd/server/handlers_actions.go index 965e7ed..b7f56ae 100644 --- a/cmd/server/handlers_actions.go +++ b/cmd/server/handlers_actions.go @@ -1,4 +1,4 @@ -package main +package main import ( "database/sql" @@ -16,7 +16,7 @@ func (a *App) handleSetDaysPerMonth(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/general", "error", "Form ungueltig") + redirectWithMessage(w, r, "/general", "error", "Form ungültig") return } days := mustInt(r.FormValue("days_per_month"), 2) @@ -37,7 +37,7 @@ func (a *App) handleSetCurrentTime(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/", "error", "Form ungueltig") + redirectWithMessage(w, r, "/", "error", "Form ungültig") return } settings, err := a.getSettings() @@ -48,7 +48,7 @@ func (a *App) handleSetCurrentTime(w http.ResponseWriter, r *http.Request) { month := mustInt(r.FormValue("current_month"), 1) day := mustInt(r.FormValue("current_day"), 1) if month < 1 || month > 12 || day < 1 || day > settings.DaysPerMonth { - redirectWithMessage(w, r, "/", "error", "Ingame-Zeit ungueltig") + redirectWithMessage(w, r, "/", "error", "Ingame-Zeit ungültig") return } if _, err := a.db.Exec(`UPDATE settings SET current_month=?, current_day=? WHERE id=1`, month, day); err != nil { @@ -64,14 +64,14 @@ func (a *App) handleCreateField(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/fields", "error", "Form ungueltig") + redirectWithMessage(w, r, "/fields", "error", "Form ungültig") return } number := mustInt(r.FormValue("number"), 0) name := strings.TrimSpace(r.FormValue("name")) owned := r.FormValue("owned") == "on" if number <= 0 { - redirectWithMessage(w, r, "/fields", "error", "Feldnummer ungueltig") + redirectWithMessage(w, r, "/fields", "error", "Feldnummer ungültig") return } if _, err := a.db.Exec(`INSERT INTO fields(number,name,owned) VALUES (?,?,?)`, number, name, owned); err != nil { @@ -87,14 +87,14 @@ func (a *App) handleUpdateField(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/fields", "error", "Form ungueltig") + redirectWithMessage(w, r, "/fields", "error", "Form ungültig") return } id := mustInt64(r.FormValue("id"), 0) name := strings.TrimSpace(r.FormValue("name")) owned := r.FormValue("owned") == "on" if id <= 0 { - redirectWithMessage(w, r, "/fields", "error", "Feld-ID ungueltig") + redirectWithMessage(w, r, "/fields", "error", "Feld-ID ungültig") return } if owned { @@ -117,19 +117,19 @@ func (a *App) handleDeleteField(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/fields", "error", "Form ungueltig") + redirectWithMessage(w, r, "/fields", "error", "Form ungültig") return } id := mustInt64(r.FormValue("id"), 0) if id <= 0 { - redirectWithMessage(w, r, "/fields", "error", "Feld-ID ungueltig") + redirectWithMessage(w, r, "/fields", "error", "Feld-ID ungültig") return } if _, err := a.db.Exec(`DELETE FROM fields WHERE id=?`, id); err != nil { - redirectWithMessage(w, r, "/fields", "error", "Feld nicht geloescht") + redirectWithMessage(w, r, "/fields", "error", "Feld nicht gelöscht") return } - redirectWithMessage(w, r, "/fields", "info", "Feld geloescht") + redirectWithMessage(w, r, "/fields", "info", "Feld gelöscht") } func (a *App) handleCreateFieldGroup(w http.ResponseWriter, r *http.Request) { @@ -138,12 +138,12 @@ func (a *App) handleCreateFieldGroup(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/fields", "error", "Form ungueltig") + redirectWithMessage(w, r, "/fields", "error", "Form ungültig") return } ids, err := parseInt64List(r.Form["field_ids"]) if err != nil || len(ids) == 0 { - redirectWithMessage(w, r, "/fields", "error", "Mindestens ein Feld auswaehlen") + redirectWithMessage(w, r, "/fields", "error", "Mindestens ein Feld auswählen") return } fields, err := a.getFieldsByIDs(ids) @@ -182,19 +182,19 @@ func (a *App) handleDeleteFieldGroup(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/fields", "error", "Form ungueltig") + redirectWithMessage(w, r, "/fields", "error", "Form ungültig") return } key := strings.TrimSpace(r.FormValue("group_key")) if key == "" { - redirectWithMessage(w, r, "/fields", "error", "Gruppen-ID ungueltig") + redirectWithMessage(w, r, "/fields", "error", "Gruppen-ID ungültig") return } if _, err := a.db.Exec(`UPDATE fields SET group_key='',group_name='' WHERE group_key=?`, key); err != nil { - redirectWithMessage(w, r, "/fields", "error", "Gruppe nicht aufgeloest") + redirectWithMessage(w, r, "/fields", "error", "Gruppe nicht aufgelöst") return } - redirectWithMessage(w, r, "/fields", "info", "Gruppe aufgeloest") + redirectWithMessage(w, r, "/fields", "info", "Gruppe aufgelöst") } func (a *App) handleCreateCrop(w http.ResponseWriter, r *http.Request) { @@ -203,7 +203,7 @@ func (a *App) handleCreateCrop(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") + redirectWithMessage(w, r, "/crops", "error", "Form ungültig") return } name := strings.TrimSpace(r.FormValue("name")) @@ -232,7 +232,7 @@ func (a *App) handleUpdateCrop(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") + redirectWithMessage(w, r, "/crops", "error", "Form ungültig") return } id := mustInt64(r.FormValue("id"), 0) @@ -243,7 +243,7 @@ func (a *App) handleUpdateCrop(w http.ResponseWriter, r *http.Request) { regrowEnabled := r.FormValue("regrow_enabled") == "on" regrowCycles := mustInt(r.FormValue("regrow_cycles"), 0) if id <= 0 { - redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungueltig") + redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungültig") return } if err := validateCropInput(name, start, end, grow, regrowCycles); err != nil { @@ -266,19 +266,19 @@ func (a *App) handleDeleteCrop(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") + redirectWithMessage(w, r, "/crops", "error", "Form ungültig") return } id := mustInt64(r.FormValue("id"), 0) if id <= 0 { - redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungueltig") + redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungültig") return } if _, err := a.db.Exec(`DELETE FROM crops WHERE id=?`, id); err != nil { - redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht geloescht") + redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht gelöscht") return } - redirectWithMessage(w, r, "/crops", "info", "Feldfrucht geloescht") + redirectWithMessage(w, r, "/crops", "info", "Feldfrucht gelöscht") } func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) { @@ -287,7 +287,7 @@ func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/planning", "error", "Form ungueltig") + redirectWithMessage(w, r, "/planning", "error", "Form ungültig") return } targetRef := strings.TrimSpace(r.FormValue("target_ref")) @@ -296,7 +296,7 @@ func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) { startDay := mustInt(r.FormValue("start_day"), 1) notes := strings.TrimSpace(r.FormValue("notes")) if targetRef == "" || cropID <= 0 { - redirectWithMessage(w, r, "/planning", "error", "Planungsziel oder Feldfrucht ungueltig") + redirectWithMessage(w, r, "/planning", "error", "Planungsziel oder Feldfrucht ungültig") return } @@ -306,7 +306,7 @@ func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) { return } if startMonth < 1 || startMonth > 12 || startDay < 1 || startDay > settings.DaysPerMonth { - redirectWithMessage(w, r, "/planning", "error", "Startdatum ungueltig") + redirectWithMessage(w, r, "/planning", "error", "Startdatum ungültig") return } @@ -318,7 +318,7 @@ func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) { return } if !monthInWindow(startMonth, c.SowStartMonth, c.SowEndMonth) { - redirectWithMessage(w, r, "/planning", "error", "Aussaat ausserhalb des Zeitfensters") + redirectWithMessage(w, r, "/planning", "error", "Aussaat außerhalb des Zeitfensters") return } @@ -347,19 +347,148 @@ func (a *App) handleDeletePlan(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/planning", "error", "Form ungueltig") + redirectWithMessage(w, r, "/planning", "error", "Form ungültig") return } id := mustInt64(r.FormValue("id"), 0) if id <= 0 { - redirectWithMessage(w, r, "/planning", "error", "Plan-ID ungueltig") + redirectWithMessage(w, r, "/planning", "error", "Plan-ID ungültig") return } if _, err := a.db.Exec(`DELETE FROM plans WHERE id=?`, id); err != nil { - redirectWithMessage(w, r, "/planning", "error", "Plan nicht geloescht") + redirectWithMessage(w, r, "/planning", "error", "Plan nicht gelöscht") return } - redirectWithMessage(w, r, "/planning", "info", "Plan geloescht") + redirectWithMessage(w, r, "/planning", "info", "Plan gelöscht") +} + +func (a *App) handleToggleTaskDone(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + redirectWithMessage(w, r, "/", "error", "Form ungültig") + return + } + + uid := strings.TrimSpace(r.FormValue("uid")) + month := mustInt(r.FormValue("month"), 0) + day := mustInt(r.FormValue("day"), 0) + yearOffset := mustInt(r.FormValue("year_offset"), 0) + currentDone := r.FormValue("completed") == "1" + if uid == "" || month < 1 || month > 12 || day < 1 { + redirectWithMessage(w, r, "/", "error", "Aufgabe ungültig") + return + } + + if currentDone { + if _, err := a.db.Exec(`DELETE FROM task_completions WHERE task_uid=? AND month=? AND day=? AND year_offset=?`, uid, month, day, yearOffset); err != nil { + redirectWithMessage(w, r, "/", "error", "Status konnte nicht geändert werden") + return + } + redirectWithMessage(w, r, "/", "info", "Aufgabe wieder offen") + return + } + if _, err := a.db.Exec(`INSERT INTO task_completions(task_uid,month,day,year_offset,done) VALUES (?,?,?,?,1) ON DUPLICATE KEY UPDATE done=1`, uid, month, day, yearOffset); err != nil { + redirectWithMessage(w, r, "/", "error", "Status konnte nicht geändert werden") + return + } + redirectWithMessage(w, r, "/", "info", "Aufgabe erledigt") +} + +func (a *App) handleCreateTaskTemplate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + redirectWithMessage(w, r, "/planning", "error", "Form ungültig") + return + } + title := strings.TrimSpace(r.FormValue("title")) + if title == "" { + redirectWithMessage(w, r, "/planning", "error", "Template-Titel fehlt") + return + } + if _, err := a.db.Exec(`INSERT INTO custom_task_templates(title) VALUES (?)`, title); err != nil { + redirectWithMessage(w, r, "/planning", "error", "Template konnte nicht gespeichert werden") + return + } + redirectWithMessage(w, r, "/planning", "info", "Template gespeichert") +} + +func (a *App) handleCreateCustomTask(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + redirectWithMessage(w, r, "/planning", "error", "Form ungültig") + return + } + + templateID := mustInt64(r.FormValue("template_id"), 0) + title := strings.TrimSpace(r.FormValue("title")) + month := mustInt(r.FormValue("month"), 0) + day := mustInt(r.FormValue("day"), 0) + yearOffset := mustInt(r.FormValue("year_offset"), 0) + targetName := strings.TrimSpace(r.FormValue("target_name")) + notes := strings.TrimSpace(r.FormValue("notes")) + settings, err := a.getSettings() + if err != nil { + redirectWithMessage(w, r, "/planning", "error", "Einstellungen nicht lesbar") + return + } + if month < 1 || month > 12 || day < 1 || day > settings.DaysPerMonth || yearOffset < 0 || yearOffset > 4 { + redirectWithMessage(w, r, "/planning", "error", "Aufgaben-Datum ungültig") + return + } + if templateID > 0 { + if err = a.db.QueryRow(`SELECT title FROM custom_task_templates WHERE id=?`, templateID).Scan(&title); err != nil { + redirectWithMessage(w, r, "/planning", "error", "Template nicht gefunden") + return + } + } + if title == "" { + redirectWithMessage(w, r, "/planning", "error", "Aufgaben-Titel fehlt") + return + } + + if _, err = a.db.Exec(`INSERT INTO custom_tasks(template_id,title,month,day,year_offset,target_name,notes) VALUES (?,?,?,?,?,?,?)`, + nullInt64(templateID), title, month, day, yearOffset, targetName, notes); err != nil { + redirectWithMessage(w, r, "/planning", "error", "Aufgabe konnte nicht gespeichert werden") + return + } + redirectWithMessage(w, r, "/planning", "info", "Aufgabe eingeplant") +} + +func (a *App) handleDeleteCustomTask(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + redirectWithMessage(w, r, "/planning", "error", "Form ungültig") + return + } + id := mustInt64(r.FormValue("id"), 0) + if id <= 0 { + redirectWithMessage(w, r, "/planning", "error", "Aufgaben-ID ungültig") + return + } + if _, err := a.db.Exec(`DELETE FROM custom_tasks WHERE id=?`, id); err != nil { + redirectWithMessage(w, r, "/planning", "error", "Aufgabe konnte nicht gelöscht werden") + return + } + redirectWithMessage(w, r, "/planning", "info", "Aufgabe gelöscht") +} + +func nullInt64(v int64) sql.NullInt64 { + if v <= 0 { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: v, Valid: true} } func (a *App) handleCreateCropStep(w http.ResponseWriter, r *http.Request) { @@ -368,7 +497,7 @@ func (a *App) handleCreateCropStep(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") + redirectWithMessage(w, r, "/crops", "error", "Form ungültig") return } cropID := mustInt64(r.FormValue("crop_id"), 0) @@ -376,7 +505,7 @@ func (a *App) handleCreateCropStep(w http.ResponseWriter, r *http.Request) { offset := mustInt(r.FormValue("month_offset"), -1) title := strings.TrimSpace(r.FormValue("title")) if cropID <= 0 || (phase != "pre" && phase != "post") || offset < 0 || offset > 24 || title == "" { - redirectWithMessage(w, r, "/crops", "error", "Vor/Nachbereitung ungueltig") + redirectWithMessage(w, r, "/crops", "error", "Vor/Nachbereitung ungültig") return } if _, err := a.db.Exec(`INSERT INTO crop_steps(crop_id,phase,month_offset,title) VALUES (?,?,?,?)`, cropID, phase, offset, title); err != nil { @@ -392,26 +521,26 @@ func (a *App) handleDeleteCropStep(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") + redirectWithMessage(w, r, "/crops", "error", "Form ungültig") return } id := mustInt64(r.FormValue("id"), 0) if id <= 0 { - redirectWithMessage(w, r, "/crops", "error", "Schritt-ID ungueltig") + redirectWithMessage(w, r, "/crops", "error", "Schritt-ID ungültig") return } if _, err := a.db.Exec(`DELETE FROM crop_steps WHERE id=?`, id); err != nil { - redirectWithMessage(w, r, "/crops", "error", "Schritt nicht geloescht") + redirectWithMessage(w, r, "/crops", "error", "Schritt nicht gelöscht") return } - redirectWithMessage(w, r, "/crops", "info", "Schritt geloescht") + redirectWithMessage(w, r, "/crops", "info", "Schritt gelöscht") } func (a *App) resolvePlanningTarget(ref string) (sql.NullInt64, string, error) { if strings.HasPrefix(ref, "f:") { id := mustInt64(strings.TrimPrefix(ref, "f:"), 0) if id <= 0 { - return sql.NullInt64{}, "", errors.New("Feld-Ziel ungueltig") + return sql.NullInt64{}, "", errors.New("Feld-Ziel ungültig") } var f Field err := a.db.QueryRow(`SELECT id,number,name,owned FROM fields WHERE id=?`, id).Scan(&f.ID, &f.Number, &f.Name, &f.Owned) @@ -426,7 +555,7 @@ func (a *App) resolvePlanningTarget(ref string) (sql.NullInt64, string, error) { if strings.HasPrefix(ref, "g:") { key := strings.TrimPrefix(ref, "g:") if key == "" { - return sql.NullInt64{}, "", errors.New("Gruppen-Ziel ungueltig") + return sql.NullInt64{}, "", errors.New("Gruppen-Ziel ungültig") } rows, err := a.db.Query(`SELECT number FROM fields WHERE group_key=? AND owned=1 ORDER BY number`, key) if err != nil { @@ -451,5 +580,6 @@ func (a *App) resolvePlanningTarget(ref string) (sql.NullInt64, string, error) { } return sql.NullInt64{}, groupName, nil } - return sql.NullInt64{}, "", errors.New("Planungsziel ungueltig") + return sql.NullInt64{}, "", errors.New("Planungsziel ungültig") } + diff --git a/cmd/server/handlers_pages.go b/cmd/server/handlers_pages.go index 252c33d..016fe22 100644 --- a/cmd/server/handlers_pages.go +++ b/cmd/server/handlers_pages.go @@ -36,6 +36,16 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { http.Error(w, "steps read failed", http.StatusInternalServerError) return } + customTasks, err := a.listCustomTasks() + if err != nil { + http.Error(w, "custom tasks read failed", http.StatusInternalServerError) + return + } + doneMap, err := a.listTaskCompletionsMap() + if err != nil { + http.Error(w, "completions read failed", http.StatusInternalServerError) + return + } data := DashboardPage{ BasePage: BasePage{ ActivePath: "/", @@ -44,8 +54,8 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { }, Settings: settings, CurrentMonth: monthNames[settings.CurrentMonth-1], - TodayTasks: buildTasksForDay(plans, stepMap, settings.CurrentMonth, settings.CurrentDay), - Calendar: buildCalendar(plans, stepMap, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14), + TodayTasks: buildTasksForDay(plans, stepMap, customTasks, doneMap, settings.CurrentMonth, settings.CurrentDay), + Calendar: buildCalendar(plans, stepMap, customTasks, doneMap, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14), PlanningCount: len(plans), } a.renderTemplate(w, "templates/dashboard.html", data) @@ -125,6 +135,16 @@ func (a *App) handlePlanningPage(w http.ResponseWriter, r *http.Request) { http.Error(w, "plans read failed", http.StatusInternalServerError) return } + taskTemplates, err := a.listCustomTaskTemplates() + if err != nil { + http.Error(w, "templates read failed", http.StatusInternalServerError) + return + } + customTasks, err := a.listCustomTasks() + if err != nil { + http.Error(w, "custom tasks read failed", http.StatusInternalServerError) + return + } data := PlanningPage{ BasePage: BasePage{ ActivePath: "/planning", @@ -136,6 +156,8 @@ func (a *App) handlePlanningPage(w http.ResponseWriter, r *http.Request) { Crops: crops, Plans: plans, PlanningTargets: buildPlanningTargets(fields), + TaskTemplates: taskTemplates, + CustomTasks: customTasks, } a.renderTemplate(w, "templates/planning.html", data) } diff --git a/cmd/server/main.go b/cmd/server/main.go index da90b12..5ba471a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,7 +13,7 @@ import ( ) var monthNames = []string{ - "Januar", "Februar", "Maerz", "April", "Mai", "Juni", + "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember", } @@ -61,9 +61,13 @@ func main() { mux.HandleFunc("/crops/delete", app.handleDeleteCrop) mux.HandleFunc("/crop-steps/create", app.handleCreateCropStep) mux.HandleFunc("/crop-steps/delete", app.handleDeleteCropStep) + mux.HandleFunc("/task-templates/create", app.handleCreateTaskTemplate) + mux.HandleFunc("/custom-tasks/create", app.handleCreateCustomTask) + mux.HandleFunc("/custom-tasks/delete", app.handleDeleteCustomTask) mux.HandleFunc("/plans/create", app.handleCreatePlan) mux.HandleFunc("/plans/delete", app.handleDeletePlan) + mux.HandleFunc("/tasks/toggle", app.handleToggleTaskDone) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) srv := &http.Server{ diff --git a/cmd/server/types.go b/cmd/server/types.go index 43ee23d..ddbab14 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -59,10 +59,31 @@ type CropStep struct { Title string } +type CustomTaskTemplate struct { + ID int64 + Title string +} + +type CustomTask struct { + ID int64 + TemplateID sql.NullInt64 + Title string + Month int + Day int + YearOffset int + TargetName string + Notes string +} + type Task struct { + UID string Type string Field string Message string + Month int + Day int + YearOffset int + Completed bool SortOrder int } @@ -123,6 +144,8 @@ type PlanningPage struct { Crops []Crop Plans []Plan PlanningTargets []PlanningTarget + TaskTemplates []CustomTaskTemplate + CustomTasks []CustomTask } type GeneralPage struct { diff --git a/static/styles.css b/static/styles.css index 4b2bceb..67c1716 100644 --- a/static/styles.css +++ b/static/styles.css @@ -156,6 +156,8 @@ button:hover { background: linear-gradient(180deg, #7cbc19, #5e8a12); } .btn-small { padding: .35rem .58rem; font-size: .9rem; } .danger { background: linear-gradient(180deg, #cc4747, #9a2e2e); } .danger:hover { background: linear-gradient(180deg, #b43d3d, #842626); } +.secondary { background: linear-gradient(180deg, #7b8a97, #596673); } +.secondary:hover { background: linear-gradient(180deg, #6d7b87, #4c5863); } .check { display: inline-flex; @@ -197,6 +199,13 @@ button:hover { background: linear-gradient(180deg, #7cbc19, #5e8a12); } } .tasks li { margin: .3rem 0; } +.tasks li, +.task-sublist li { + display: flex; + gap: .5rem; + align-items: flex-start; + justify-content: space-between; +} .task-sublist { margin: .3rem 0 0; @@ -204,6 +213,15 @@ button:hover { background: linear-gradient(180deg, #7cbc19, #5e8a12); } } .muted { color: var(--muted); } +.done-task { + opacity: .75; + text-decoration: line-through; +} +.inline-form { + margin: 0; + display: inline-block; + flex: 0 0 auto; +} .calendar-grid { display: grid; diff --git a/templates/crops.html b/templates/crops.html index e24e536..2e3af63 100644 --- a/templates/crops.html +++ b/templates/crops.html @@ -3,16 +3,16 @@ - FarmCal - Feldfruechte + FarmCal - Feldfrüchte -

FarmCal

Feldfruechte verwalten

+

FarmCal

Feldfrüchte verwalten

@@ -25,7 +25,7 @@ - + @@ -34,7 +34,7 @@
-

Bestehende Feldfruechte

+

Bestehende Feldfrüchte

@@ -56,13 +56,13 @@ {{end}} {{else}} - + {{end}}
NameAussaatWachstumAktionen
- +
Keine Feldfruechte vorhanden.
Keine Feldfrüchte vorhanden.
@@ -74,7 +74,7 @@