diff options
-rw-r--r-- | db.go | 101 | ||||
-rw-r--r-- | go.mod | 2 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | model.go | 11 | ||||
-rw-r--r-- | page.go | 59 | ||||
-rw-r--r-- | templates/index.html | 75 | ||||
-rw-r--r-- | templates/parts/entry.html | 81 | ||||
-rw-r--r-- | templates/parts/entryRows.html | 12 | ||||
-rw-r--r-- | tidsreg.go | 223 |
9 files changed, 503 insertions, 63 deletions
@@ -0,0 +1,101 @@ +package main + +import ( + "errors" + "time" +) + +type Database interface { + GetTracking() (*Task, error) + GetTasks() ([]*Task, error) + QueryTask(id *int) (*Task, error) + StartNewEntry(now time.Time, task *Task) (*Task, error) + StopEntry(now time.Time) error + SaveEntry(task *Task) (*Task, error) +} + +type InMemDb struct { + tasks []*Task + current *Task +} + +var ( + ErrNotFound = errors.New("Not found") + ErrNotRunning = errors.New("Not running") +) + +func NewInMemDb() Database { + return &InMemDb { + tasks: []*Task{}, + current: nil, + } +} + +func (db *InMemDb) GetTracking() (*Task, error) { + return db.current, nil +} + +func (db *InMemDb) GetTasks() ([]*Task, error) { + res := make([]*Task, 0, len(db.tasks)) + for _, task := range db.tasks { + res = append(res, task) + } + + return res, nil +} + +func (db *InMemDb) QueryTask(id *int) (*Task, error) { + for _, task := range db.tasks { + if id != nil && task.Id == *id { + return task, nil + } + } + + return nil, ErrNotFound +} + +func (db *InMemDb) StartNewEntry(now time.Time, task *Task) (*Task, error) { + newTask := &Task { + Id: len(db.tasks), + From: task.From, + To: nil, + Tag: task.Tag, + Comment: task.Comment, + } + + if newTask.From == nil { + newTask.From = &now + } + + db.tasks = append(db.tasks, newTask) + db.StopEntry(now) + db.current = newTask + return newTask, nil +} + +func (db *InMemDb) StopEntry(now time.Time) error { + if db.current != nil { + db.current.To = &now + db.current = nil + } else { + return ErrNotRunning + } + return nil +} + +func (db *InMemDb) SaveEntry(task *Task) (*Task, error) { + if task.Id < 0 || task.Id >= len(db.tasks) { + copyTask := *task + copyTask.Id = len(db.tasks) + db.tasks = append(db.tasks, ©Task) + + return ©Task, nil + } + + existent := db.tasks[task.Id] + existent.Comment = task.Comment + existent.Tag = task.Tag + existent.From = task.From + existent.To = task.To + return existent, nil +} @@ -1,3 +1,5 @@ module tidsreg go 1.22.2 + +require github.com/gorilla/mux v1.8.1 // indirect @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/model.go b/model.go new file mode 100644 index 0000000..0f52ef9 --- /dev/null +++ b/model.go @@ -0,0 +1,11 @@ +package main + +import "time" + +type Task struct { + Id int + From *time.Time + To *time.Time + Tag *string + Comment string +} @@ -0,0 +1,59 @@ +package main + +type Service struct { + Db Database +} + +type EntryPage struct { + Task *Task + Detached bool + Tracking *Task +} + +type RootPage struct { + Entry *EntryPage + Tasks []*Task +} + +func NewService(db Database) *Service { + return &Service { + Db: db, + } +} + +func (srv *Service) GetEntryPage(detached *Task) (*EntryPage, error) { + tracking, err := srv.Db.GetTracking() + if err != nil { + return nil, err + } + + page := &EntryPage { + Task: tracking, + Detached: false, + Tracking: tracking, + } + + if detached != nil { + page.Task = detached + page.Detached = true + } + + + return page, nil +} + +func (srv *Service) GetRootPage() (*RootPage, error) { + entry, err := srv.GetEntryPage(nil) + if err != nil { + return nil, err + } + tasks, err := srv.Db.GetTasks() + if err != nil { + return nil, err + } + + return &RootPage { + Entry: entry, + Tasks: tasks, + }, nil +} diff --git a/templates/index.html b/templates/index.html index 3654855..c408fb0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,9 @@ <title>Tidsreg</title> + <!-- TODO REMOVE --> + <script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.js" integrity="sha384-qbtR4rS9RrUMECUWDWM2+YGgN3U4V4ZncZ0BvUcg9FGct0jqXz3PUdVpU1p0yrXS" crossorigin="anonymous"></script> + <style> .flex { display: flex; @@ -28,7 +31,14 @@ border-color: green; } -.entry-bar { +.status-stopped { + border-color: red; +} +.status-detached { + border-color: blue; +} + +#entry-bar { border-width: 3px; border-style: solid; padding: 10px; @@ -46,48 +56,11 @@ <body> <div id="controls-bar"> <button>I går</button> - <input type="date"</input> + <input type="date" /> <button>I morgen</button> </div> <div class="flex"> - <div class="status-started entry-bar"> - <div class="flex just-start"> - <div class="entry-box"> - <b>Interval</b> - <div> - <label for="fromTime">Fra: </label> - <input id="fromTime" type="time" class="form-control" aria-label="Time start"> - </div> - <div> - <label for="toTime">Til: </label> - <input id="toTime" type="time" class="form-control" aria-label="Time stop"> - </div> - </div> - <div class="entry-box"> - <b>Mærker</b><br> - <select> - <option value="-">-</option> - <option value="SVT-232">SVT-232</option> - <option value="Ferie">Ferie</option> - </select><br> - <select> - <option value="-">-</option> - <option value="SVT-232">SVT-232</option> - <option value="Ferie">Ferie</option> - </select> - </div> - <div class="entry-box"> - <b>Kommentar</b><br> - <textarea></textarea> - </div> - <div class="entry-box"> - <b>Status</b><br> - <i class="status-started">I gang</i><br> - <span>1:34 timer</span> - </div> - </div> - <button>Start ny</button> - </div> + {{template "entry.html" .Entry}} <div class="flex-grow week-bar"> <b>Uge 42</b> </div> @@ -96,20 +69,16 @@ <div class="flex-grow"> <table> <thead> - <th>-</th> - <th>Fra</th> - <th>Til</th> - <th>Mærker</th> - <th>Beskrivelse</th> - </thead> - <tbody> <tr> - <td><input type="checkbox" /></td> - <td><input type="time" /></td> - <td></td> - <td>Ferie, SVT-232</td> - <td>Ting og sager</td> - </tbody> + <th>Id</th> + <th>Fra</th> + <th>Til</th> + <th>Mærker</th> + <th>Beskrivelse</th> + <th>Handlinger</th> + </tr> + </thead> + {{ template "entryRows.html" . }} </table> </div> </div> diff --git a/templates/parts/entry.html b/templates/parts/entry.html new file mode 100644 index 0000000..f99fb90 --- /dev/null +++ b/templates/parts/entry.html @@ -0,0 +1,81 @@ +<form id="entry-bar" autocomplete="off" class="status-{{if .Detached}}detached{{else}}{{if .Task}}started{{else}}stopped{{end}}{{end}}"> + <input style="display: none;" value="{{ if .Task }}{{ .Task.Id }}{{end}}" type="text" name="id" /> + {{ if .Detached }} + <span>{{ if gt .Task.Id -1 }}Redigerer opgave {{ .Task.Id }}{{else}}Redigerer ny opgave{{end}}{{ if .Tracking }}, med opgave i baggrunden!{{else}}.{{end}}</span><br> + {{end}} + <div class="flex just-start"> + <div class="entry-box"> + <b>Interval</b> + <div> + <label for="fromTime">Fra: </label> + <input name="from" id="fromTime" type="time" class="form-control" {{if .Task}}value="{{formatTime .Task.From}}" required{{end}} aria-label="Time start"> + </div> + <div> + <label for="toTime">Til: </label> + <input name="to" id="toTime" type="time" class="form-control" {{if not .Detached}}disabled{{end}} aria-label="Time stop"> + </div> + </div> + <div class="entry-box"> + <b>Mærker</b><br> + <select> + <option value="-">-</option> + <option value="SVT-232">SVT-232</option> + <option value="Ferie">Ferie</option> + </select><br> + <select> + <option value="-">-</option> + <option value="SVT-232">SVT-232</option> + <option value="Ferie">Ferie</option> + </select> + </div> + <div class="entry-box"> + <b>Kommentar</b><br> + <textarea name="comment">{{if .Task}}{{.Task.Comment}}{{end}}</textarea> + </div> + <div class="entry-box"> + <b>Status</b><br> + {{ if not .Detached}}<i>{{ if .Task }}I gang{{ else }}Stoppet{{ end }}</i><br>{{end}} + {{ if .Task }}<span>1:34 timer</span>{{ end }} + </div> + </div> + + {{ if .Task }} + <button + hx-put="/save{{if .Detached}}?detached=true{{end}}" + hx-trigger="click" + hx-target="#entry-bar" + hx-swap="outerHTML" + >Gem + </button> + {{end}} + {{ if .Detached }} + <button + hx-get="/tracking" + hx-trigger="click" + hx-target="#entry-bar" + hx-swap="outerHTML" + >Tilbage</button> + {{ else }} + {{ if .Task }} + <button + hx-post="/stop" + hx-trigger="click" + hx-target="#entry-bar" + hx-swap="outerHTML" + >Stop</button> + {{ else }} + <button + hx-post="/start" + hx-trigger="click" + hx-target="#entry-bar" + hx-swap="outerHTML" + >Start ny</button> + {{ end }} + <button + hx-get="/newDetached" + hx-trigger="click" + hx-target="#entry-bar" + hx-swap="outerHTML" + >Manuel</button> + {{ end }} +</form> diff --git a/templates/parts/entryRows.html b/templates/parts/entryRows.html new file mode 100644 index 0000000..1b364e3 --- /dev/null +++ b/templates/parts/entryRows.html @@ -0,0 +1,12 @@ +<tbody hx-trigger="changedTasks from:body" hx-get="/entryRows"> + {{ range $task := .Tasks }} + <tr> + <td>{{ $task.Id }}</td> + <td><input type="time" disabled value="{{formatTime $task.From }}" /></td> + <td><input type="time" disabled value="{{formatTime $task.To }}" /></td> + <td>{{ if $task.Tag}}{{ $task.Tag }}{{end}}</td> + <td>{{ $task.Comment }}</td> + <td></td> + </tr> + {{ end }} +</tbody> @@ -4,34 +4,237 @@ import ( "fmt" "log" "net/http" + "strconv" "text/template" + "time" + + "github.com/gorilla/mux" ) type Server struct { template *template.Template + srv *Service +} + +func funcMapFormatTime(t *time.Time) string { + if t == nil { + return "" + } + return t.Format("15:04") +} + +func parseStringTime(str string) (*time.Time, error) { + if str == "" { + return nil, nil + } + t, err := time.Parse("15:04", str) + return &t, err } -func (c *Server) rootHandle(w http.ResponseWriter, r * http.Request) { - tmpl, err := template.ParseFiles("templates/index.html") +func parseTaskFromForm(r *http.Request) (*Task, error) { + err := r.ParseForm() + if err != nil { + return nil, err + } + + from, err := parseStringTime(r.PostFormValue("from")) + if err != nil { + return nil, err + } + to, err := parseStringTime(r.PostFormValue("to")) + if err != nil { + return nil, err + } + var id int64 + if r.PostFormValue("id") == "" { + id = -1 + } else { + id, err = strconv.ParseInt(r.PostFormValue("id"), 10, 32) + if err != nil { + return nil, err + } + } + + task := &Task { + Id: int(id), + From: from, + To: to, + Comment: r.PostFormValue("comment"), + } + return task, nil +} + +func (s *Server) renderTemplate(w http.ResponseWriter, name string, page interface{}) { + tmpl := template.New("").Funcs(template.FuncMap { + "formatTime": funcMapFormatTime, + }) + tmpl, err := tmpl.ParseFiles("templates/index.html", "templates/parts/entry.html", "templates/parts/entryRows.html") + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + err = tmpl.ExecuteTemplate(w, name, page) if err != nil { log.Println(err) + fmt.Fprintf(w, "RENDER ERROR: %s\n", err.Error()) + } +} + +func writeError(w http.ResponseWriter, err string, code int) { + log.Println(err) + w.WriteHeader(code) + fmt.Fprintf(w, "%d: %s\n", code, err) + return +} + +func (s *Server) rootHandle(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintln(w, "Not found 404") return } - tmpl.Execute(w, nil) + + page, err := s.srv.GetRootPage() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + s.renderTemplate(w, "index.html", page) } -func main() { - fmt.Println("Hello world!") - template, err := template.ParseFS(templates, "templates/*.html") +func (s *Server) postStart(w http.ResponseWriter, r *http.Request) { + now := time.Now() + + task, err := parseTaskFromForm(r) if err != nil { - log.Fatal(err) + writeError(w, err.Error(), http.StatusBadRequest) + return } - s := Server { - template: template, + _, err = s.srv.Db.StartNewEntry(now, task) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + page, err := s.srv.GetEntryPage(nil) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Add("HX-Trigger", "changedTasks") + + s.renderTemplate(w, "entry.html", page) +} + +func (s *Server) postStop(w http.ResponseWriter, r *http.Request) { + now := time.Now() + task, err := parseTaskFromForm(r) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + _, err = s.srv.Db.SaveEntry(task) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return } + err = s.srv.Db.StopEntry(now) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("HX-Trigger", "changedTasks") + + s.getTracking(w, r) +} + +func (s *Server) getTracking(w http.ResponseWriter, _ *http.Request) { + page, err := s.srv.GetEntryPage(nil) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + s.renderTemplate(w, "entry.html", page) +} + +func (s *Server) putSave(w http.ResponseWriter, r *http.Request) { + task, err := parseTaskFromForm(r) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + task, err = s.srv.Db.SaveEntry(task) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + var detached *Task = nil + if r.URL.Query().Get("detached") == "true" { + detached = task + } + + page, err := s.srv.GetEntryPage(detached) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("HX-Trigger", "changedTasks") + + s.renderTemplate(w, "entry.html", page) +} + +func (s *Server) getNew(w http.ResponseWriter, _ *http.Request) { + task := &Task { + Id: -1, + From: nil, + To: nil, + Tag: nil, + Comment: "", + } + + page, err := s.srv.GetEntryPage(task) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + s.renderTemplate(w, "entry.html", page) +} + +func (s *Server) getEntries(w http.ResponseWriter, _ *http.Request) { + page, err := s.srv.GetRootPage() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + s.renderTemplate(w, "entryRows.html", page) +} + +func main() { + fmt.Println("Hello world!") + + s := Server{ + template: nil, + srv: NewService(NewInMemDb()), + } + + r := mux.NewRouter() + r.HandleFunc("/start", s.postStart).Methods("POST") + r.HandleFunc("/newDetached", s.getNew).Methods("GET") + r.HandleFunc("/tracking", s.getTracking).Methods("GET") + r.HandleFunc("/stop", s.postStop).Methods("POST") + r.HandleFunc("/save", s.putSave).Methods("PUT") + r.HandleFunc("/entryRows", s.getEntries).Methods("GET") + r.HandleFunc("/", s.rootHandle) - http.HandleFunc("/", s.rootHandle) + http.Handle("/", r) log.Fatal(http.ListenAndServe(":8080", nil)) } |