Add task completion toggle, custom task templates, and umlaut UI updates

This commit is contained in:
Kai
2026-02-16 13:40:02 +01:00
parent a1c1ef31a3
commit 723e9142b2
13 changed files with 548 additions and 118 deletions

View File

@@ -6,32 +6,14 @@ import (
"strings" "strings"
) )
func buildTasksForDay(plans []Plan, stepMap map[int64][]CropStep, month, day int) []Task { func buildTasksForDay(plans []Plan, stepMap map[int64][]CropStep, customTasks []CustomTask, doneMap map[string]bool, month, day int) []Task {
var tasks []Task tasks := collectTasksForDate(plans, stepMap, customTasks, month, day, 0)
for _, p := range plans { applyCompletion(tasks, doneMap)
tasks = append(tasks, expandPlanDayTasks(p, stepMap[p.CropID], month, day)...)
}
sortTasks(tasks) sortTasks(tasks)
return tasks return tasks
} }
func buildCalendar(plans []Plan, stepMap map[int64][]CropStep, startMonth, startDay, daysPerMonth, spanMonths int) []CalendarMonth { func buildCalendar(plans []Plan, stepMap map[int64][]CropStep, customTasks []CustomTask, doneMap map[string]bool, 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])
}
var out []CalendarMonth var out []CalendarMonth
for offset := 0; offset < spanMonths; offset++ { for offset := 0; offset < spanMonths; offset++ {
month := wrapMonth(startMonth + offset) month := wrapMonth(startMonth + offset)
@@ -45,10 +27,12 @@ func buildCalendar(plans []Plan, stepMap map[int64][]CropStep, startMonth, start
if offset == 0 { if offset == 0 {
fromDay = startDay fromDay = startDay
} }
var days []CalendarDay var days []CalendarDay
for d := fromDay; d <= daysPerMonth; d++ { for d := fromDay; d <= daysPerMonth; d++ {
key := fmt.Sprintf("%d-%d", month, d) items := collectTasksForDate(plans, stepMap, customTasks, month, d, yearOffset)
items := append([]Task(nil), tasksByKey[key]...) applyCompletion(items, doneMap)
sortTasks(items)
days = append(days, CalendarDay{Day: d, Tasks: items}) days = append(days, CalendarDay{Day: d, Tasks: items})
} }
out = append(out, CalendarMonth{ out = append(out, CalendarMonth{
@@ -62,19 +46,56 @@ func buildCalendar(plans []Plan, stepMap map[int64][]CropStep, startMonth, start
return out 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 field := p.TargetName
if strings.TrimSpace(field) == "" { if strings.TrimSpace(field) == "" {
field = "Unbekanntes Feld" field = "Unbekanntes Feld"
} }
var out []Task var out []Task
baseHarvest := p.HarvestMonth baseHarvest := p.HarvestMonth
if p.StartMonth == month && p.StartDay == day { if p.StartMonth == month && p.StartDay == day && yearOffset == 0 {
out = append(out, Task{ out = append(out, Task{
UID: fmt.Sprintf("plan:%d:sow:0", p.ID),
Type: "Aussaat", Type: "Aussaat",
Field: field, Field: field,
Message: withOptionalNote(fmt.Sprintf("Aussaat %s", p.CropName), p.Notes), Message: withOptionalNote(fmt.Sprintf("Aussaat %s", p.CropName), p.Notes),
Month: month,
Day: day,
YearOffset: yearOffset,
SortOrder: 10, SortOrder: 10,
}) })
} }
@@ -90,12 +111,18 @@ func expandPlanDayTasks(p Plan, steps []CropStep, month, day int) []Task {
} }
} }
harvestMonths = uniqueMonths(harvestMonths) harvestMonths = uniqueMonths(harvestMonths)
for _, hm := range harvestMonths {
for idx, hm := range harvestMonths {
if hm == month && p.HarvestDay == day { if hm == month && p.HarvestDay == day {
uid := fmt.Sprintf("plan:%d:harvest:%d", p.ID, idx)
out = append(out, Task{ out = append(out, Task{
UID: uid,
Type: "Ernte", Type: "Ernte",
Field: field, Field: field,
Message: withOptionalNote(fmt.Sprintf("Ernte %s", p.CropName), p.Notes), Message: withOptionalNote(fmt.Sprintf("Ernte %s", p.CropName), p.Notes),
Month: month,
Day: day,
YearOffset: yearOffset,
SortOrder: 20, SortOrder: 20,
}) })
} }
@@ -105,22 +132,30 @@ func expandPlanDayTasks(p Plan, steps []CropStep, month, day int) []Task {
switch s.Phase { switch s.Phase {
case "pre": case "pre":
taskMonth := wrapMonth(p.StartMonth - s.MonthOffset) taskMonth := wrapMonth(p.StartMonth - s.MonthOffset)
if taskMonth == month && p.StartDay == day { if taskMonth == month && p.StartDay == day && yearOffset == 0 {
out = append(out, Task{ out = append(out, Task{
UID: fmt.Sprintf("plan:%d:pre:%d", p.ID, s.ID),
Type: "Vorbereitung", Type: "Vorbereitung",
Field: field, Field: field,
Message: s.Title, Message: s.Title,
Month: month,
Day: day,
YearOffset: yearOffset,
SortOrder: 5, SortOrder: 5,
}) })
} }
case "post": case "post":
for _, hm := range harvestMonths { for idx, hm := range harvestMonths {
taskMonth := wrapMonth(hm + s.MonthOffset) taskMonth := wrapMonth(hm + s.MonthOffset)
if taskMonth == month && p.HarvestDay == day { if taskMonth == month && p.HarvestDay == day {
out = append(out, Task{ out = append(out, Task{
UID: fmt.Sprintf("plan:%d:post:%d:%d", p.ID, s.ID, idx),
Type: "Nachbereitung", Type: "Nachbereitung",
Field: field, Field: field,
Message: s.Title, Message: s.Title,
Month: month,
Day: day,
YearOffset: yearOffset,
SortOrder: 30, SortOrder: 30,
}) })
} }
@@ -131,6 +166,13 @@ func expandPlanDayTasks(p Plan, steps []CropStep, month, day int) []Task {
return out 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 { func (p Plan) HarvestDistance() int {
if p.GrowMonths <= 0 { if p.GrowMonths <= 0 {
return 1 return 1
@@ -148,6 +190,9 @@ func withOptionalNote(base, note string) string {
func sortTasks(tasks []Task) { func sortTasks(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool { 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].SortOrder == tasks[j].SortOrder {
if tasks[i].Field == tasks[j].Field { if tasks[i].Field == tasks[j].Field {
return tasks[i].Message < tasks[j].Message return tasks[i].Message < tasks[j].Message
@@ -169,3 +214,7 @@ func uniqueMonths(values []int) []int {
} }
return out return out
} }
func completionKey(uid string, month, day, yearOffset int) string {
return fmt.Sprintf("%s|%d|%d|%d", uid, month, day, yearOffset)
}

View File

@@ -53,6 +53,32 @@ func ensureSchema(db *sql.DB) error {
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_crop_steps_crop FOREIGN KEY(crop_id) REFERENCES crops(id) ON DELETE CASCADE 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( `CREATE TABLE IF NOT EXISTS plans(
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -199,6 +225,67 @@ func (a *App) listCropStepsMap() (map[int64][]CropStep, error) {
return m, nil 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 { func seedCrops(db *sql.DB) error {
items := []Crop{ items := []Crop{
{Name: "Weizen", SowStartMonth: 9, SowEndMonth: 11, GrowMonths: 10}, {Name: "Weizen", SowStartMonth: 9, SowEndMonth: 11, GrowMonths: 10},

View File

@@ -1,4 +1,4 @@
package main package main
import ( import (
"errors" "errors"
@@ -116,7 +116,8 @@ func validateCropInput(name string, start, end, grow, regrowCycles int) error {
return errors.New("Wachstumsdauer muss 1-24 sein") return errors.New("Wachstumsdauer muss 1-24 sein")
} }
if regrowCycles < 0 || regrowCycles > 120 { 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 return nil
} }

View File

@@ -1,4 +1,4 @@
package main package main
import ( import (
"database/sql" "database/sql"
@@ -16,7 +16,7 @@ func (a *App) handleSetDaysPerMonth(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/general", "error", "Form ungueltig") redirectWithMessage(w, r, "/general", "error", "Form ungültig")
return return
} }
days := mustInt(r.FormValue("days_per_month"), 2) days := mustInt(r.FormValue("days_per_month"), 2)
@@ -37,7 +37,7 @@ func (a *App) handleSetCurrentTime(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/", "error", "Form ungueltig") redirectWithMessage(w, r, "/", "error", "Form ungültig")
return return
} }
settings, err := a.getSettings() 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) month := mustInt(r.FormValue("current_month"), 1)
day := mustInt(r.FormValue("current_day"), 1) day := mustInt(r.FormValue("current_day"), 1)
if month < 1 || month > 12 || day < 1 || day > settings.DaysPerMonth { 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 return
} }
if _, err := a.db.Exec(`UPDATE settings SET current_month=?, current_day=? WHERE id=1`, month, day); err != nil { 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 return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/fields", "error", "Form ungueltig") redirectWithMessage(w, r, "/fields", "error", "Form ungültig")
return return
} }
number := mustInt(r.FormValue("number"), 0) number := mustInt(r.FormValue("number"), 0)
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
owned := r.FormValue("owned") == "on" owned := r.FormValue("owned") == "on"
if number <= 0 { if number <= 0 {
redirectWithMessage(w, r, "/fields", "error", "Feldnummer ungueltig") redirectWithMessage(w, r, "/fields", "error", "Feldnummer ungültig")
return return
} }
if _, err := a.db.Exec(`INSERT INTO fields(number,name,owned) VALUES (?,?,?)`, number, name, owned); err != nil { 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 return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/fields", "error", "Form ungueltig") redirectWithMessage(w, r, "/fields", "error", "Form ungültig")
return return
} }
id := mustInt64(r.FormValue("id"), 0) id := mustInt64(r.FormValue("id"), 0)
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
owned := r.FormValue("owned") == "on" owned := r.FormValue("owned") == "on"
if id <= 0 { if id <= 0 {
redirectWithMessage(w, r, "/fields", "error", "Feld-ID ungueltig") redirectWithMessage(w, r, "/fields", "error", "Feld-ID ungültig")
return return
} }
if owned { if owned {
@@ -117,19 +117,19 @@ func (a *App) handleDeleteField(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/fields", "error", "Form ungueltig") redirectWithMessage(w, r, "/fields", "error", "Form ungültig")
return return
} }
id := mustInt64(r.FormValue("id"), 0) id := mustInt64(r.FormValue("id"), 0)
if id <= 0 { if id <= 0 {
redirectWithMessage(w, r, "/fields", "error", "Feld-ID ungueltig") redirectWithMessage(w, r, "/fields", "error", "Feld-ID ungültig")
return return
} }
if _, err := a.db.Exec(`DELETE FROM fields WHERE id=?`, id); err != nil { 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 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) { 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 return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/fields", "error", "Form ungueltig") redirectWithMessage(w, r, "/fields", "error", "Form ungültig")
return return
} }
ids, err := parseInt64List(r.Form["field_ids"]) ids, err := parseInt64List(r.Form["field_ids"])
if err != nil || len(ids) == 0 { 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 return
} }
fields, err := a.getFieldsByIDs(ids) fields, err := a.getFieldsByIDs(ids)
@@ -182,19 +182,19 @@ func (a *App) handleDeleteFieldGroup(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/fields", "error", "Form ungueltig") redirectWithMessage(w, r, "/fields", "error", "Form ungültig")
return return
} }
key := strings.TrimSpace(r.FormValue("group_key")) key := strings.TrimSpace(r.FormValue("group_key"))
if key == "" { if key == "" {
redirectWithMessage(w, r, "/fields", "error", "Gruppen-ID ungueltig") redirectWithMessage(w, r, "/fields", "error", "Gruppen-ID ungültig")
return return
} }
if _, err := a.db.Exec(`UPDATE fields SET group_key='',group_name='' WHERE group_key=?`, key); err != nil { 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 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) { 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 return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") redirectWithMessage(w, r, "/crops", "error", "Form ungültig")
return return
} }
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
@@ -232,7 +232,7 @@ func (a *App) handleUpdateCrop(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") redirectWithMessage(w, r, "/crops", "error", "Form ungültig")
return return
} }
id := mustInt64(r.FormValue("id"), 0) 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" regrowEnabled := r.FormValue("regrow_enabled") == "on"
regrowCycles := mustInt(r.FormValue("regrow_cycles"), 0) regrowCycles := mustInt(r.FormValue("regrow_cycles"), 0)
if id <= 0 { if id <= 0 {
redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungueltig") redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungültig")
return return
} }
if err := validateCropInput(name, start, end, grow, regrowCycles); err != nil { 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 return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") redirectWithMessage(w, r, "/crops", "error", "Form ungültig")
return return
} }
id := mustInt64(r.FormValue("id"), 0) id := mustInt64(r.FormValue("id"), 0)
if id <= 0 { if id <= 0 {
redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungueltig") redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungültig")
return return
} }
if _, err := a.db.Exec(`DELETE FROM crops WHERE id=?`, id); err != nil { 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 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) { 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 return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/planning", "error", "Form ungueltig") redirectWithMessage(w, r, "/planning", "error", "Form ungültig")
return return
} }
targetRef := strings.TrimSpace(r.FormValue("target_ref")) 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) startDay := mustInt(r.FormValue("start_day"), 1)
notes := strings.TrimSpace(r.FormValue("notes")) notes := strings.TrimSpace(r.FormValue("notes"))
if targetRef == "" || cropID <= 0 { if targetRef == "" || cropID <= 0 {
redirectWithMessage(w, r, "/planning", "error", "Planungsziel oder Feldfrucht ungueltig") redirectWithMessage(w, r, "/planning", "error", "Planungsziel oder Feldfrucht ungültig")
return return
} }
@@ -306,7 +306,7 @@ func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) {
return return
} }
if startMonth < 1 || startMonth > 12 || startDay < 1 || startDay > settings.DaysPerMonth { 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 return
} }
@@ -318,7 +318,7 @@ func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) {
return return
} }
if !monthInWindow(startMonth, c.SowStartMonth, c.SowEndMonth) { 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 return
} }
@@ -347,19 +347,148 @@ func (a *App) handleDeletePlan(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/planning", "error", "Form ungueltig") redirectWithMessage(w, r, "/planning", "error", "Form ungültig")
return return
} }
id := mustInt64(r.FormValue("id"), 0) id := mustInt64(r.FormValue("id"), 0)
if id <= 0 { if id <= 0 {
redirectWithMessage(w, r, "/planning", "error", "Plan-ID ungueltig") redirectWithMessage(w, r, "/planning", "error", "Plan-ID ungültig")
return return
} }
if _, err := a.db.Exec(`DELETE FROM plans WHERE id=?`, id); err != nil { 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 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) { 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 return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") redirectWithMessage(w, r, "/crops", "error", "Form ungültig")
return return
} }
cropID := mustInt64(r.FormValue("crop_id"), 0) 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) offset := mustInt(r.FormValue("month_offset"), -1)
title := strings.TrimSpace(r.FormValue("title")) title := strings.TrimSpace(r.FormValue("title"))
if cropID <= 0 || (phase != "pre" && phase != "post") || offset < 0 || offset > 24 || 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 return
} }
if _, err := a.db.Exec(`INSERT INTO crop_steps(crop_id,phase,month_offset,title) VALUES (?,?,?,?)`, cropID, phase, offset, title); err != nil { 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 return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") redirectWithMessage(w, r, "/crops", "error", "Form ungültig")
return return
} }
id := mustInt64(r.FormValue("id"), 0) id := mustInt64(r.FormValue("id"), 0)
if id <= 0 { if id <= 0 {
redirectWithMessage(w, r, "/crops", "error", "Schritt-ID ungueltig") redirectWithMessage(w, r, "/crops", "error", "Schritt-ID ungültig")
return return
} }
if _, err := a.db.Exec(`DELETE FROM crop_steps WHERE id=?`, id); err != nil { 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 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) { func (a *App) resolvePlanningTarget(ref string) (sql.NullInt64, string, error) {
if strings.HasPrefix(ref, "f:") { if strings.HasPrefix(ref, "f:") {
id := mustInt64(strings.TrimPrefix(ref, "f:"), 0) id := mustInt64(strings.TrimPrefix(ref, "f:"), 0)
if id <= 0 { if id <= 0 {
return sql.NullInt64{}, "", errors.New("Feld-Ziel ungueltig") return sql.NullInt64{}, "", errors.New("Feld-Ziel ungültig")
} }
var f Field 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) 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:") { if strings.HasPrefix(ref, "g:") {
key := strings.TrimPrefix(ref, "g:") key := strings.TrimPrefix(ref, "g:")
if key == "" { 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) rows, err := a.db.Query(`SELECT number FROM fields WHERE group_key=? AND owned=1 ORDER BY number`, key)
if err != nil { 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{}, groupName, nil
} }
return sql.NullInt64{}, "", errors.New("Planungsziel ungueltig") return sql.NullInt64{}, "", errors.New("Planungsziel ungültig")
} }

View File

@@ -36,6 +36,16 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "steps read failed", http.StatusInternalServerError) http.Error(w, "steps read failed", http.StatusInternalServerError)
return 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{ data := DashboardPage{
BasePage: BasePage{ BasePage: BasePage{
ActivePath: "/", ActivePath: "/",
@@ -44,8 +54,8 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
}, },
Settings: settings, Settings: settings,
CurrentMonth: monthNames[settings.CurrentMonth-1], CurrentMonth: monthNames[settings.CurrentMonth-1],
TodayTasks: buildTasksForDay(plans, stepMap, settings.CurrentMonth, settings.CurrentDay), TodayTasks: buildTasksForDay(plans, stepMap, customTasks, doneMap, settings.CurrentMonth, settings.CurrentDay),
Calendar: buildCalendar(plans, stepMap, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14), Calendar: buildCalendar(plans, stepMap, customTasks, doneMap, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14),
PlanningCount: len(plans), PlanningCount: len(plans),
} }
a.renderTemplate(w, "templates/dashboard.html", data) 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) http.Error(w, "plans read failed", http.StatusInternalServerError)
return 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{ data := PlanningPage{
BasePage: BasePage{ BasePage: BasePage{
ActivePath: "/planning", ActivePath: "/planning",
@@ -136,6 +156,8 @@ func (a *App) handlePlanningPage(w http.ResponseWriter, r *http.Request) {
Crops: crops, Crops: crops,
Plans: plans, Plans: plans,
PlanningTargets: buildPlanningTargets(fields), PlanningTargets: buildPlanningTargets(fields),
TaskTemplates: taskTemplates,
CustomTasks: customTasks,
} }
a.renderTemplate(w, "templates/planning.html", data) a.renderTemplate(w, "templates/planning.html", data)
} }

View File

@@ -13,7 +13,7 @@ import (
) )
var monthNames = []string{ var monthNames = []string{
"Januar", "Februar", "Maerz", "April", "Mai", "Juni", "Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember", "Juli", "August", "September", "Oktober", "November", "Dezember",
} }
@@ -61,9 +61,13 @@ func main() {
mux.HandleFunc("/crops/delete", app.handleDeleteCrop) mux.HandleFunc("/crops/delete", app.handleDeleteCrop)
mux.HandleFunc("/crop-steps/create", app.handleCreateCropStep) mux.HandleFunc("/crop-steps/create", app.handleCreateCropStep)
mux.HandleFunc("/crop-steps/delete", app.handleDeleteCropStep) 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/create", app.handleCreatePlan)
mux.HandleFunc("/plans/delete", app.handleDeletePlan) mux.HandleFunc("/plans/delete", app.handleDeletePlan)
mux.HandleFunc("/tasks/toggle", app.handleToggleTaskDone)
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
srv := &http.Server{ srv := &http.Server{

View File

@@ -59,10 +59,31 @@ type CropStep struct {
Title string 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 { type Task struct {
UID string
Type string Type string
Field string Field string
Message string Message string
Month int
Day int
YearOffset int
Completed bool
SortOrder int SortOrder int
} }
@@ -123,6 +144,8 @@ type PlanningPage struct {
Crops []Crop Crops []Crop
Plans []Plan Plans []Plan
PlanningTargets []PlanningTarget PlanningTargets []PlanningTarget
TaskTemplates []CustomTaskTemplate
CustomTasks []CustomTask
} }
type GeneralPage struct { type GeneralPage struct {

View File

@@ -156,6 +156,8 @@ button:hover { background: linear-gradient(180deg, #7cbc19, #5e8a12); }
.btn-small { padding: .35rem .58rem; font-size: .9rem; } .btn-small { padding: .35rem .58rem; font-size: .9rem; }
.danger { background: linear-gradient(180deg, #cc4747, #9a2e2e); } .danger { background: linear-gradient(180deg, #cc4747, #9a2e2e); }
.danger:hover { background: linear-gradient(180deg, #b43d3d, #842626); } .danger:hover { background: linear-gradient(180deg, #b43d3d, #842626); }
.secondary { background: linear-gradient(180deg, #7b8a97, #596673); }
.secondary:hover { background: linear-gradient(180deg, #6d7b87, #4c5863); }
.check { .check {
display: inline-flex; display: inline-flex;
@@ -197,6 +199,13 @@ button:hover { background: linear-gradient(180deg, #7cbc19, #5e8a12); }
} }
.tasks li { margin: .3rem 0; } .tasks li { margin: .3rem 0; }
.tasks li,
.task-sublist li {
display: flex;
gap: .5rem;
align-items: flex-start;
justify-content: space-between;
}
.task-sublist { .task-sublist {
margin: .3rem 0 0; margin: .3rem 0 0;
@@ -204,6 +213,15 @@ button:hover { background: linear-gradient(180deg, #7cbc19, #5e8a12); }
} }
.muted { color: var(--muted); } .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 { .calendar-grid {
display: grid; display: grid;

View File

@@ -3,16 +3,16 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>FarmCal - Feldfruechte</title> <title>FarmCal - Feldfrüchte</title>
<link rel="icon" type="image/svg+xml" href="/static/farmcal-icon.svg"> <link rel="icon" type="image/svg+xml" href="/static/farmcal-icon.svg">
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>
<header class="top"><h1><img src="/static/farmcal-icon.svg" alt="" class="brand-icon">FarmCal</h1><p>Feldfruechte verwalten</p></header> <header class="top"><h1><img src="/static/farmcal-icon.svg" alt="" class="brand-icon">FarmCal</h1><p>Feldfrüchte verwalten</p></header>
<nav class="tabs"> <nav class="tabs">
<a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a> <a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a>
<a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a> <a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a>
<a href="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfruechte</a> <a href="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfrüchte</a>
<a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a> <a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a>
<a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a> <a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a>
</nav> </nav>
@@ -25,7 +25,7 @@
<label>Aussaat von Monat<input type="number" name="sow_start_month" min="1" max="12" required></label> <label>Aussaat von Monat<input type="number" name="sow_start_month" min="1" max="12" required></label>
<label>Aussaat bis Monat<input type="number" name="sow_end_month" min="1" max="12" required></label> <label>Aussaat bis Monat<input type="number" name="sow_end_month" min="1" max="12" required></label>
<label>Wachstum (Monate)<input type="number" name="grow_months" min="1" max="24" required></label> <label>Wachstum (Monate)<input type="number" name="grow_months" min="1" max="24" required></label>
<label class="check"><input type="checkbox" name="regrow_enabled"> Waechst nach Ernte erneut</label> <label class="check"><input type="checkbox" name="regrow_enabled"> Wächst nach Ernte erneut</label>
<label>Zusatz-Ernten (0 = unendlich) <label>Zusatz-Ernten (0 = unendlich)
<input type="number" name="regrow_cycles" min="0" max="120" value="0"> <input type="number" name="regrow_cycles" min="0" max="120" value="0">
</label> </label>
@@ -34,7 +34,7 @@
</section> </section>
<section class="card full-width"> <section class="card full-width">
<h2>Bestehende Feldfruechte</h2> <h2>Bestehende Feldfrüchte</h2>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead><tr><th>Name</th><th>Aussaat</th><th>Wachstum</th><th>Aktionen</th></tr></thead> <thead><tr><th>Name</th><th>Aussaat</th><th>Wachstum</th><th>Aktionen</th></tr></thead>
@@ -56,13 +56,13 @@
</td> </td>
<td class="actions"> <td class="actions">
<button type="submit" class="btn-small">Speichern</button> <button type="submit" class="btn-small">Speichern</button>
<button type="submit" formaction="/crops/delete" formnovalidate class="btn-small danger" onclick="return confirm('Feldfrucht wirklich loeschen?')">Loeschen</button> <button type="submit" formaction="/crops/delete" formnovalidate class="btn-small danger" onclick="return confirm('Feldfrucht wirklich löschen?')">Löschen</button>
</td> </td>
</form> </form>
</tr> </tr>
{{end}} {{end}}
{{else}} {{else}}
<tr><td colspan="4">Keine Feldfruechte vorhanden.</td></tr> <tr><td colspan="4">Keine Feldfrüchte vorhanden.</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
@@ -74,7 +74,7 @@
<form method="post" action="/crop-steps/create" class="grid"> <form method="post" action="/crop-steps/create" class="grid">
<label>Feldfrucht <label>Feldfrucht
<select name="crop_id" required> <select name="crop_id" required>
<option value="">Bitte waehlen</option> <option value="">Bitte wählen</option>
{{range .Crops}} {{range .Crops}}
<option value="{{.ID}}">{{.Name}}</option> <option value="{{.ID}}">{{.Name}}</option>
{{end}} {{end}}
@@ -109,7 +109,7 @@
<td> <td>
<form method="post" action="/crop-steps/delete"> <form method="post" action="/crop-steps/delete">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn-small danger" onclick="return confirm('Schritt loeschen?')">Loeschen</button> <button type="submit" class="btn-small danger" onclick="return confirm('Schritt löschen?')">Löschen</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -16,7 +16,7 @@
<nav class="tabs"> <nav class="tabs">
<a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a> <a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a>
<a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a> <a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a>
<a href="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfruechte</a> <a href="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfrüchte</a>
<a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a> <a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a>
<a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a> <a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a>
</nav> </nav>
@@ -25,13 +25,13 @@
<section class="card"> <section class="card">
<h2>Aktuelle Ingame-Zeit</h2> <h2>Aktuelle Ingame-Zeit</h2>
<p><strong>{{.CurrentMonth}} Tag {{.Settings.CurrentDay}}</strong> bei {{.Settings.DaysPerMonth}} Tagen pro Monat.</p> <p><strong>{{.CurrentMonth}} Tag {{.Settings.CurrentDay}}</strong> bei {{.Settings.DaysPerMonth}} Tagen pro Monat.</p>
<p>{{.PlanningCount}} Plan-Eintraege insgesamt.</p> <p>{{.PlanningCount}} Plan-Einträge insgesamt.</p>
<form method="post" action="/settings/time" class="grid"> <form method="post" action="/settings/time" class="grid">
<label>Monat <label>Monat
<select name="current_month"> <select name="current_month">
<option value="1" {{if eq .Settings.CurrentMonth 1}}selected{{end}}>Januar</option> <option value="1" {{if eq .Settings.CurrentMonth 1}}selected{{end}}>Januar</option>
<option value="2" {{if eq .Settings.CurrentMonth 2}}selected{{end}}>Februar</option> <option value="2" {{if eq .Settings.CurrentMonth 2}}selected{{end}}>Februar</option>
<option value="3" {{if eq .Settings.CurrentMonth 3}}selected{{end}}>Maerz</option> <option value="3" {{if eq .Settings.CurrentMonth 3}}selected{{end}}>März</option>
<option value="4" {{if eq .Settings.CurrentMonth 4}}selected{{end}}>April</option> <option value="4" {{if eq .Settings.CurrentMonth 4}}selected{{end}}>April</option>
<option value="5" {{if eq .Settings.CurrentMonth 5}}selected{{end}}>Mai</option> <option value="5" {{if eq .Settings.CurrentMonth 5}}selected{{end}}>Mai</option>
<option value="6" {{if eq .Settings.CurrentMonth 6}}selected{{end}}>Juni</option> <option value="6" {{if eq .Settings.CurrentMonth 6}}selected{{end}}>Juni</option>
@@ -55,11 +55,21 @@
{{if .TodayTasks}} {{if .TodayTasks}}
<ul class="tasks"> <ul class="tasks">
{{range .TodayTasks}} {{range .TodayTasks}}
<li><strong>{{.Type}}:</strong> {{.Message}}</li> <li class="{{if .Completed}}done-task{{end}}">
<span><strong>{{.Type}}:</strong> {{.Message}}</span>
<form method="post" action="/tasks/toggle" class="inline-form">
<input type="hidden" name="uid" value="{{.UID}}">
<input type="hidden" name="month" value="{{.Month}}">
<input type="hidden" name="day" value="{{.Day}}">
<input type="hidden" name="year_offset" value="{{.YearOffset}}">
<input type="hidden" name="completed" value="{{if .Completed}}1{{else}}0{{end}}">
<button type="submit" class="btn-small {{if .Completed}}secondary{{end}}">{{if .Completed}}Offen{{else}}Erledigt{{end}}</button>
</form>
</li>
{{end}} {{end}}
</ul> </ul>
{{else}} {{else}}
<p>Keine Aufgaben fuer den aktuellen Ingame-Tag.</p> <p>Keine Aufgaben für den aktuellen Ingame-Tag.</p>
{{end}} {{end}}
</section> </section>
@@ -76,11 +86,21 @@
{{if .Tasks}} {{if .Tasks}}
<ul class="task-sublist"> <ul class="task-sublist">
{{range .Tasks}} {{range .Tasks}}
<li>{{.Field}}: {{.Message}}</li> <li class="{{if .Completed}}done-task{{end}}">
<span>{{.Field}}: {{.Message}}</span>
<form method="post" action="/tasks/toggle" class="inline-form">
<input type="hidden" name="uid" value="{{.UID}}">
<input type="hidden" name="month" value="{{.Month}}">
<input type="hidden" name="day" value="{{.Day}}">
<input type="hidden" name="year_offset" value="{{.YearOffset}}">
<input type="hidden" name="completed" value="{{if .Completed}}1{{else}}0{{end}}">
<button type="submit" class="btn-small {{if .Completed}}secondary{{end}}">{{if .Completed}}Offen{{else}}Erledigt{{end}}</button>
</form>
</li>
{{end}} {{end}}
</ul> </ul>
{{else}} {{else}}
<span class="muted">Keine Eintraege</span> <span class="muted">Keine Einträge</span>
{{end}} {{end}}
</li> </li>
{{end}} {{end}}

View File

@@ -12,7 +12,7 @@
<nav class="tabs"> <nav class="tabs">
<a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a> <a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a>
<a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a> <a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a>
<a href="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfruechte</a> <a href="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfrüchte</a>
<a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a> <a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a>
<a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a> <a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a>
</nav> </nav>
@@ -44,7 +44,7 @@
<td>{{if .GroupName}}{{.GroupName}}{{else}}-{{end}}</td> <td>{{if .GroupName}}{{.GroupName}}{{else}}-{{end}}</td>
<td class="actions"> <td class="actions">
<button type="submit" class="btn-small">Speichern</button> <button type="submit" class="btn-small">Speichern</button>
<button type="submit" formaction="/fields/delete" formnovalidate class="btn-small danger" onclick="return confirm('Feld wirklich loeschen?')">Loeschen</button> <button type="submit" formaction="/fields/delete" formnovalidate class="btn-small danger" onclick="return confirm('Feld wirklich löschen?')">Löschen</button>
</td> </td>
</form> </form>
</tr> </tr>
@@ -84,7 +84,7 @@
<td> <td>
<form method="post" action="/field-groups/delete"> <form method="post" action="/field-groups/delete">
<input type="hidden" name="group_key" value="{{.Key}}"> <input type="hidden" name="group_key" value="{{.Key}}">
<button type="submit" class="btn-small danger" onclick="return confirm('Gruppe aufloesen?')">Aufloesen</button> <button type="submit" class="btn-small danger" onclick="return confirm('Gruppe auflösen?')">Auflösen</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -12,7 +12,7 @@
<nav class="tabs"> <nav class="tabs">
<a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a> <a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a>
<a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a> <a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a>
<a href="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfruechte</a> <a href="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfrüchte</a>
<a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a> <a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a>
<a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a> <a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a>
</nav> </nav>

View File

@@ -12,18 +12,18 @@
<nav class="tabs"> <nav class="tabs">
<a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a> <a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a>
<a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a> <a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a>
<a href="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfruechte</a> <a href="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfrüchte</a>
<a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a> <a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a>
<a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a> <a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a>
</nav> </nav>
<main class="layout"> <main class="layout">
<section class="card"> <section class="card">
<h2>Neuer Plan</h2> <h2>Neuer Anbau-Plan</h2>
<form method="post" action="/plans/create" class="grid"> <form method="post" action="/plans/create" class="grid">
<label>Planungsziel <label>Planungsziel
<select name="target_ref" required> <select name="target_ref" required>
<option value="">Bitte waehlen</option> <option value="">Bitte wählen</option>
{{range .PlanningTargets}} {{range .PlanningTargets}}
<option value="{{.Ref}}">{{.Label}}</option> <option value="{{.Ref}}">{{.Label}}</option>
{{end}} {{end}}
@@ -31,7 +31,7 @@
</label> </label>
<label>Feldfrucht <label>Feldfrucht
<select name="crop_id" required> <select name="crop_id" required>
<option value="">Bitte waehlen</option> <option value="">Bitte wählen</option>
{{range .Crops}} {{range .Crops}}
<option value="{{.ID}}">{{.Name}} (Monat {{.SowStartMonth}}-{{.SowEndMonth}})</option> <option value="{{.ID}}">{{.Name}} (Monat {{.SowStartMonth}}-{{.SowEndMonth}})</option>
{{end}} {{end}}
@@ -54,8 +54,55 @@
</form> </form>
</section> </section>
<section class="card">
<h2>Aufgaben-Template speichern</h2>
<form method="post" action="/task-templates/create" class="grid">
<label class="full">Template-Titel
<input type="text" name="title" maxlength="140" placeholder="z.B. Schweine füttern" required>
</label>
<button type="submit">Template speichern</button>
</form>
</section>
<section class="card">
<h2>Eigene Aufgabe einplanen</h2>
<form method="post" action="/custom-tasks/create" class="grid">
<label>Template (optional)
<select name="template_id">
<option value="0">Kein Template</option>
{{range .TaskTemplates}}
<option value="{{.ID}}">{{.Title}}</option>
{{end}}
</select>
</label>
<label>Alternativer Titel
<input type="text" name="title" maxlength="140" placeholder="leer lassen wenn Template genutzt wird">
</label>
<label>Monat
<select name="month">
{{range .Months}}
<option value="{{.Value}}" {{if eq $.Settings.CurrentMonth .Value}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</label>
<label>Tag
<input type="number" name="day" min="1" max="{{.Settings.DaysPerMonth}}" value="{{.Settings.CurrentDay}}">
</label>
<label>Jahr-Offset
<input type="number" name="year_offset" min="0" max="4" value="0">
</label>
<label>Ziel (optional)
<input type="text" name="target_name" maxlength="120" placeholder="z.B. Hof oder Feld 1+2">
</label>
<label class="full">Notiz (optional)
<input type="text" name="notes" maxlength="255">
</label>
<button type="submit">Aufgabe einplanen</button>
</form>
</section>
<section class="card full-width"> <section class="card full-width">
<h2>Geplante Durchlaeufe</h2> <h2>Geplante Durchläufe</h2>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead><tr><th>Ziel</th><th>Frucht</th><th>Aussaat</th><th>Ernte</th><th>Notiz</th><th>Aktion</th></tr></thead> <thead><tr><th>Ziel</th><th>Frucht</th><th>Aussaat</th><th>Ernte</th><th>Notiz</th><th>Aktion</th></tr></thead>
@@ -71,7 +118,7 @@
<td> <td>
<form method="post" action="/plans/delete"> <form method="post" action="/plans/delete">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn-small danger" onclick="return confirm('Plan wirklich loeschen?')">Loeschen</button> <button type="submit" class="btn-small danger" onclick="return confirm('Plan wirklich löschen?')">Löschen</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -83,6 +130,35 @@
</table> </table>
</div> </div>
</section> </section>
<section class="card full-width">
<h2>Eigene Aufgaben</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Titel</th><th>Ziel</th><th>Datum</th><th>Notiz</th><th>Aktion</th></tr></thead>
<tbody>
{{if .CustomTasks}}
{{range .CustomTasks}}
<tr>
<td>{{.Title}}</td>
<td>{{if .TargetName}}{{.TargetName}}{{else}}Allgemein{{end}}</td>
<td>Monat {{.Month}} Tag {{.Day}} (Jahr +{{.YearOffset}})</td>
<td>{{.Notes}}</td>
<td>
<form method="post" action="/custom-tasks/delete">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn-small danger" onclick="return confirm('Aufgabe wirklich löschen?')">Löschen</button>
</form>
</td>
</tr>
{{end}}
{{else}}
<tr><td colspan="5">Keine eigenen Aufgaben geplant.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
</main> </main>
{{if .Error}}<div class="toast error">{{.Error}}</div>{{end}} {{if .Error}}<div class="toast error">{{.Error}}</div>{{end}}
{{if .Info}}<div class="toast info">{{.Info}}</div>{{end}} {{if .Info}}<div class="toast info">{{.Info}}</div>{{end}}