Initial FarmCal MVP
This commit is contained in:
453
cmd/server/main.go
Normal file
453
cmd/server/main.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
var monthNames = []string{
|
||||
"Januar", "Februar", "Maerz", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
}
|
||||
|
||||
type App struct {
|
||||
db *sql.DB
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
type Field struct {
|
||||
ID int64
|
||||
Number int
|
||||
Name string
|
||||
}
|
||||
|
||||
type Crop struct {
|
||||
ID int64
|
||||
Name string
|
||||
SowStartMonth int
|
||||
SowEndMonth int
|
||||
GrowMonths int
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
ID int64
|
||||
FieldID int64
|
||||
FieldNumber int
|
||||
FieldName string
|
||||
CropID int64
|
||||
CropName string
|
||||
StartMonth int
|
||||
StartDay int
|
||||
HarvestMonth int
|
||||
HarvestDay int
|
||||
Notes string
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Type string
|
||||
Field string
|
||||
Message string
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
type MonthOption struct {
|
||||
Value int
|
||||
Label string
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
NowMonth int
|
||||
NowDay int
|
||||
DaysPerMonth int
|
||||
Months []MonthOption
|
||||
Fields []Field
|
||||
Crops []Crop
|
||||
Plans []Plan
|
||||
Tasks []Task
|
||||
Error string
|
||||
Info string
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := readEnv("APP_ADDR", ":8080")
|
||||
dsn := readEnv("DB_DSN", "farmcal:farmcal@tcp(127.0.0.1:3306)/farmcal?parseTime=true")
|
||||
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("db open failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalf("db ping failed: %v", err)
|
||||
}
|
||||
if err := ensureSchema(db); err != nil {
|
||||
log.Fatalf("schema setup failed: %v", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseFiles("templates/index.html")
|
||||
if err != nil {
|
||||
log.Fatalf("template parse failed: %v", err)
|
||||
}
|
||||
|
||||
app := &App{db: db, tmpl: tmpl}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", app.handleIndex)
|
||||
mux.HandleFunc("/fields", app.handleCreateField)
|
||||
mux.HandleFunc("/plans", app.handleCreatePlan)
|
||||
mux.HandleFunc("/settings", app.handleSettings)
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: withLogging(mux),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
log.Printf("FarmCal listening on %s", addr)
|
||||
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("server stopped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
daysPerMonth, err := a.getDaysPerMonth()
|
||||
if err != nil {
|
||||
http.Error(w, "settings read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
nowMonth := mustInt(r.URL.Query().Get("month"), 1)
|
||||
nowDay := mustInt(r.URL.Query().Get("day"), 1)
|
||||
if nowMonth < 1 || nowMonth > 12 {
|
||||
nowMonth = 1
|
||||
}
|
||||
if nowDay < 1 || nowDay > daysPerMonth {
|
||||
nowDay = 1
|
||||
}
|
||||
|
||||
fields, err := a.listFields()
|
||||
if err != nil {
|
||||
http.Error(w, "fields read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
crops, err := a.listCrops()
|
||||
if err != nil {
|
||||
http.Error(w, "crops read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
plans, err := a.listPlans()
|
||||
if err != nil {
|
||||
http.Error(w, "plans read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := PageData{
|
||||
NowMonth: nowMonth,
|
||||
NowDay: nowDay,
|
||||
DaysPerMonth: daysPerMonth,
|
||||
Months: monthOptions(),
|
||||
Fields: fields,
|
||||
Crops: crops,
|
||||
Plans: plans,
|
||||
Tasks: buildTasksForDay(plans, nowMonth, nowDay),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
Info: r.URL.Query().Get("info"),
|
||||
}
|
||||
|
||||
if err := a.tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "template render failed", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleCreateField(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 {
|
||||
http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
number := mustInt(r.FormValue("number"), 0)
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if number <= 0 {
|
||||
http.Redirect(w, r, "/?error=Feldnummer+ungueltig", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, err := a.db.Exec(`INSERT INTO fields(number,name) VALUES (?,?)`, number, name); err != nil {
|
||||
http.Redirect(w, r, "/?error=Feld+nicht+angelegt", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/?info=Feld+angelegt", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleCreatePlan(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 {
|
||||
http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
fieldID := mustInt64(r.FormValue("field_id"), 0)
|
||||
cropID := mustInt64(r.FormValue("crop_id"), 0)
|
||||
startMonth := mustInt(r.FormValue("start_month"), 1)
|
||||
startDay := mustInt(r.FormValue("start_day"), 1)
|
||||
notes := strings.TrimSpace(r.FormValue("notes"))
|
||||
|
||||
if fieldID <= 0 || cropID <= 0 {
|
||||
http.Redirect(w, r, "/?error=Feld+oder+Frucht+ungueltig", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
daysPerMonth, err := a.getDaysPerMonth()
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=Einstellungen+nicht+lesbar", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if startMonth < 1 || startMonth > 12 || startDay < 1 || startDay > daysPerMonth {
|
||||
http.Redirect(w, r, "/?error=Startdatum+ungueltig", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var c Crop
|
||||
err = a.db.QueryRow(`SELECT id,name,sow_start_month,sow_end_month,grow_months FROM crops WHERE id=?`, cropID).
|
||||
Scan(&c.ID, &c.Name, &c.SowStartMonth, &c.SowEndMonth, &c.GrowMonths)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=Feldfrucht+nicht+gefunden", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if !monthInWindow(startMonth, c.SowStartMonth, c.SowEndMonth) {
|
||||
http.Redirect(w, r, "/?error=Aussaat+ausserhalb+des+Zeitfensters", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
harvestMonth := wrapMonth(startMonth + c.GrowMonths)
|
||||
harvestDay := startDay
|
||||
_, err = a.db.Exec(
|
||||
`INSERT INTO plans(field_id,crop_id,start_month,start_day,harvest_month,harvest_day,notes) VALUES (?,?,?,?,?,?,?)`,
|
||||
fieldID, cropID, startMonth, startDay, harvestMonth, harvestDay, notes,
|
||||
)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=Plan+nicht+gespeichert", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/?info=Plan+gespeichert", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleSettings(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 {
|
||||
http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
daysPerMonth := mustInt(r.FormValue("days_per_month"), 2)
|
||||
if daysPerMonth < 1 || daysPerMonth > 31 {
|
||||
http.Redirect(w, r, "/?error=Tage+pro+Monat+1-31", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, err := a.db.Exec(`UPDATE settings SET days_per_month=? WHERE id=1`, daysPerMonth); err != nil {
|
||||
http.Redirect(w, r, "/?error=Einstellungen+nicht+gespeichert", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/?info=Einstellungen+gespeichert", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) getDaysPerMonth() (int, error) {
|
||||
var v int
|
||||
err := a.db.QueryRow(`SELECT days_per_month FROM settings WHERE id=1`).Scan(&v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (a *App) listFields() ([]Field, error) {
|
||||
rows, err := a.db.Query(`SELECT id,number,name FROM fields ORDER BY number`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Field
|
||||
for rows.Next() {
|
||||
var x Field
|
||||
if err := rows.Scan(&x.ID, &x.Number, &x.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) listCrops() ([]Crop, error) {
|
||||
rows, err := a.db.Query(`SELECT id,name,sow_start_month,sow_end_month,grow_months FROM crops ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Crop
|
||||
for rows.Next() {
|
||||
var x Crop
|
||||
if err := rows.Scan(&x.ID, &x.Name, &x.SowStartMonth, &x.SowEndMonth, &x.GrowMonths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) listPlans() ([]Plan, error) {
|
||||
rows, err := a.db.Query(`
|
||||
SELECT p.id,p.field_id,f.number,COALESCE(f.name,''),p.crop_id,c.name,p.start_month,p.start_day,p.harvest_month,p.harvest_day,COALESCE(p.notes,'')
|
||||
FROM plans p
|
||||
JOIN fields f ON f.id=p.field_id
|
||||
JOIN crops c ON c.id=p.crop_id
|
||||
ORDER BY p.start_month,p.start_day,f.number`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Plan
|
||||
for rows.Next() {
|
||||
var x Plan
|
||||
if err := rows.Scan(&x.ID, &x.FieldID, &x.FieldNumber, &x.FieldName, &x.CropID, &x.CropName, &x.StartMonth, &x.StartDay, &x.HarvestMonth, &x.HarvestDay, &x.Notes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func ensureSchema(db *sql.DB) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS settings(id TINYINT PRIMARY KEY,days_per_month INT NOT NULL DEFAULT 2)`,
|
||||
`INSERT INTO settings(id,days_per_month) VALUES (1,2) ON DUPLICATE KEY UPDATE id=id`,
|
||||
`CREATE TABLE IF NOT EXISTS fields(id BIGINT AUTO_INCREMENT PRIMARY KEY,number INT NOT NULL UNIQUE,name VARCHAR(120) NOT NULL DEFAULT '',created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
`CREATE TABLE IF NOT EXISTS crops(id BIGINT AUTO_INCREMENT PRIMARY KEY,name VARCHAR(80) NOT NULL UNIQUE,sow_start_month TINYINT NOT NULL,sow_end_month TINYINT NOT NULL,grow_months TINYINT NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS plans(id BIGINT AUTO_INCREMENT PRIMARY KEY,field_id BIGINT NOT NULL,crop_id BIGINT NOT NULL,start_month TINYINT NOT NULL,start_day TINYINT NOT NULL,harvest_month TINYINT NOT NULL,harvest_day TINYINT NOT NULL,notes VARCHAR(255) NOT NULL DEFAULT '',created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,CONSTRAINT fk_plans_fields FOREIGN KEY(field_id) REFERENCES fields(id) ON DELETE CASCADE,CONSTRAINT fk_plans_crops FOREIGN KEY(crop_id) REFERENCES crops(id) ON DELETE RESTRICT)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return seedCrops(db)
|
||||
}
|
||||
|
||||
func seedCrops(db *sql.DB) error {
|
||||
items := []Crop{
|
||||
{Name: "Weizen", SowStartMonth: 9, SowEndMonth: 11, GrowMonths: 10},
|
||||
{Name: "Gerste", SowStartMonth: 9, SowEndMonth: 10, GrowMonths: 9},
|
||||
{Name: "Hafer", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 4},
|
||||
{Name: "Raps", SowStartMonth: 8, SowEndMonth: 9, GrowMonths: 11},
|
||||
{Name: "Mais", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 6},
|
||||
{Name: "Sorghum", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5},
|
||||
{Name: "Sojabohnen", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5},
|
||||
{Name: "Sonnenblumen", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5},
|
||||
{Name: "Kartoffeln", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 7},
|
||||
{Name: "Zuckerrueben", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 8},
|
||||
{Name: "Baumwolle", SowStartMonth: 2, SowEndMonth: 3, GrowMonths: 8},
|
||||
}
|
||||
for _, c := range items {
|
||||
if _, err := db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE sow_start_month=VALUES(sow_start_month),sow_end_month=VALUES(sow_end_month),grow_months=VALUES(grow_months)`, c.Name, c.SowStartMonth, c.SowEndMonth, c.GrowMonths); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildTasksForDay(plans []Plan, month, day int) []Task {
|
||||
var tasks []Task
|
||||
for _, p := range plans {
|
||||
field := fmt.Sprintf("Feld %d", p.FieldNumber)
|
||||
if p.FieldName != "" {
|
||||
field = fmt.Sprintf("Feld %d (%s)", p.FieldNumber, p.FieldName)
|
||||
}
|
||||
if p.StartMonth == month && p.StartDay == day {
|
||||
tasks = append(tasks, Task{Type: "Aussaat", Field: field, Message: fmt.Sprintf("%s auf %s aussaeen", p.CropName, field), SortOrder: 1})
|
||||
}
|
||||
if p.HarvestMonth == month && p.HarvestDay == day {
|
||||
tasks = append(tasks, Task{Type: "Ernte", Field: field, Message: fmt.Sprintf("%s auf %s ernten", p.CropName, field), SortOrder: 2})
|
||||
}
|
||||
}
|
||||
sort.Slice(tasks, func(i, j int) bool {
|
||||
if tasks[i].SortOrder == tasks[j].SortOrder {
|
||||
return tasks[i].Field < tasks[j].Field
|
||||
}
|
||||
return tasks[i].SortOrder < tasks[j].SortOrder
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
func monthOptions() []MonthOption {
|
||||
out := make([]MonthOption, 0, 12)
|
||||
for i, m := range monthNames {
|
||||
out = append(out, MonthOption{Value: i + 1, Label: m})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func monthInWindow(month, start, end int) bool {
|
||||
if start <= end {
|
||||
return month >= start && month <= end
|
||||
}
|
||||
return month >= start || month <= end
|
||||
}
|
||||
|
||||
func wrapMonth(v int) int {
|
||||
m := v % 12
|
||||
if m == 0 {
|
||||
return 12
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func mustInt(v string, fallback int) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func mustInt64(v string, fallback int64) int64 {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func readEnv(key, fallback string) string {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user