summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJulian Jørgensen <julian@jtle.dk>2024-05-23 21:34:28 +0200
committerJulian Jørgensen <julian@jtle.dk>2024-05-23 21:34:28 +0200
commit19f8454a680c5231df68fee36ed9758587df316c (patch)
tree33521610573c6e075a5625e861c8677caa5dc615
parentdd11cf4ab199e5d53d03dc95b24007a12727ad70 (diff)
Rudimentary task editing
-rw-r--r--db.go101
-rw-r--r--go.mod2
-rw-r--r--go.sum2
-rw-r--r--model.go11
-rw-r--r--page.go59
-rw-r--r--templates/index.html75
-rw-r--r--templates/parts/entry.html81
-rw-r--r--templates/parts/entryRows.html12
-rw-r--r--tidsreg.go223
9 files changed, 503 insertions, 63 deletions
diff --git a/db.go b/db.go
new file mode 100644
index 0000000..ccee504
--- /dev/null
+++ b/db.go
@@ -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, &copyTask)
+
+ return &copyTask, 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
+}
diff --git a/go.mod b/go.mod
index 421fa47..ab6e58d 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,5 @@
module tidsreg
go 1.22.2
+
+require github.com/gorilla/mux v1.8.1 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7128337
--- /dev/null
+++ b/go.sum
@@ -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
+}
diff --git a/page.go b/page.go
new file mode 100644
index 0000000..384ca57
--- /dev/null
+++ b/page.go
@@ -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>
diff --git a/tidsreg.go b/tidsreg.go
index 9821672..410a5b5 100644
--- a/tidsreg.go
+++ b/tidsreg.go
@@ -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))
}