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
| Name | Aussaat | Wachstum | Aktionen |
@@ -56,13 +56,13 @@
-
+
|
{{end}}
{{else}}
- | Keine Feldfruechte vorhanden. |
+ | Keine Feldfrüchte vorhanden. |
{{end}}
@@ -74,7 +74,7 @@
@@ -84,7 +84,7 @@
|
diff --git a/templates/general.html b/templates/general.html
index f9abe17..8b6d4cc 100644
--- a/templates/general.html
+++ b/templates/general.html
@@ -12,7 +12,7 @@
diff --git a/templates/planning.html b/templates/planning.html
index c3f932c..b566c64 100644
--- a/templates/planning.html
+++ b/templates/planning.html
@@ -12,18 +12,18 @@
- Neuer Plan
+ Neuer Anbau-Plan
+
+
+
+
- Geplante Durchlaeufe
+ Geplante Durchläufe
| Ziel | Frucht | Aussaat | Ernte | Notiz | Aktion |
@@ -71,7 +118,7 @@
|
@@ -83,6 +130,35 @@
+
+
+ Eigene Aufgaben
+
+
+ | Titel | Ziel | Datum | Notiz | Aktion |
+
+ {{if .CustomTasks}}
+ {{range .CustomTasks}}
+
+ | {{.Title}} |
+ {{if .TargetName}}{{.TargetName}}{{else}}Allgemein{{end}} |
+ Monat {{.Month}} Tag {{.Day}} (Jahr +{{.YearOffset}}) |
+ {{.Notes}} |
+
+
+ |
+
+ {{end}}
+ {{else}}
+ | Keine eigenen Aufgaben geplant. |
+ {{end}}
+
+
+
+
{{if .Error}}
{{.Error}}
{{end}}
{{if .Info}}
{{.Info}}
{{end}}