diff --git a/.gitginore b/.gitginore new file mode 100644 index 0000000..64598c6 --- /dev/null +++ b/.gitginore @@ -0,0 +1 @@ +.#* diff --git a/.gitignore b/.gitignore index a9a5aec..02ead8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ tmp +todo*.txt +\#* +\.#* diff --git a/README.md b/README.md index 0aefd19..c420693 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,29 @@ # Todo App with GO and HTMX -## Plan +## Plan day 1 - [x] create Go webserver - [x] create landing page - [x] import HTMX code - [x] display todo list -- [ ] add tailwind CSS -- [ ] show todo list -- [ ] let todos be marked completed -- [ ] remove todos -- [ ] add new todo with form -- [ ] persist todos in redis -- [ ] spruce up +- [x] add tailwind CSS +- [x] show todo list +- [x] let todos be marked completed +- [x] remove todos +- [x] add new todo with form +- [x] persist todos in redis +- [x] spruce up +## Plan day 2 + +make it use only battery included features, + +i.e. replace redis with an event driven approach + +- [x] modularize app +- [x] replace redis with json stream +- [x] use hashmap for fast individual access +- [ ] cache data to avoid reading each time +- [ ] create event store +- [ ] make events +- [ ] implement event store diff --git a/internal/model.go b/internal/model.go new file mode 100644 index 0000000..fc9e22a --- /dev/null +++ b/internal/model.go @@ -0,0 +1,8 @@ +package model + +type Todo struct { + Id int + Title string + Completed bool +} +var MaxId int = 0 diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..187ac39 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,10 @@ +package model + +type Todo struct { + Id int + Title string + Completed bool +} + + +type Todos map[int]Todo diff --git a/internal/persist/persist.go b/internal/persist/persist.go new file mode 100644 index 0000000..a7dba66 --- /dev/null +++ b/internal/persist/persist.go @@ -0,0 +1,132 @@ +package persist + +import ( + "encoding/json" + "fmt" + "os" + "io" +) + +import "snamellit.com/play/todo/internal/model" + +var EMPTY_TODO = model.Todo{Id: 0, Title: "", Completed: false} + +var maxId int = 0 + +var todos model.Todos + +type eventType string +const ( + ADDED eventType = "ADDED" + DELETED eventType = "DELETED" + CHANGED eventType = "CHANGED" +) + +type event struct { + Kind eventType + Todo model.Todo +} + +func readTodos() (model.Todos, error) { + f, err := os.OpenFile("todo_events.txt", os.O_RDONLY|os.O_CREATE, 0644) + defer f.Close() + if err != nil { + return nil, err + } + + dec := json.NewDecoder(f) + + todos = make(model.Todos) + for { + var e event + err = dec.Decode(&e) + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + fmt.Println("Read event", e) + id := e.Todo.Id + if id > maxId { + maxId = id + } + applyEvent(e.Kind, e.Todo) + } + return todos, nil +} + +func applyEvent(kind eventType, todo model.Todo) error { + id := todo.Id + switch t := kind; t { + case ADDED: + fmt.Println("apply add ", todo) + todos[id] = todo + case CHANGED: + fmt.Println("apply change ", todo) + todos[id] = todo + case DELETED: + fmt.Println("apply delete ", todo) + delete(todos, id) + default: + return fmt.Errorf("Unknown event type %s", t) + } + return nil +} + +func addEvent(kind eventType, todo model.Todo) error { + err := applyEvent(kind, todo) + if err != nil { + return err + } + + f, err := os.OpenFile("todo_events.txt", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) + defer f.Close() + if err != nil { + return err + } + fmt.Println("Write todo", todo) + enc := json.NewEncoder(f) + e := event{Kind: kind, Todo: todo} + fmt.Println("Write event", e) + err = enc.Encode(e) + if err != nil { + return err + } + return nil +} + +func GetTodos() (model.Todos, error) { + if len(todos) == 0 { + return readTodos() + } + return todos, nil +} + + +func GetTodoById(id int) (model.Todo, error) { + todo, present := todos[id] + if !present { + return EMPTY_TODO, fmt.Errorf("Todo with id %d not found", id) + } + return todo, nil +} + +func SetTodo(todo model.Todo) error { + fmt.Println("SetTodo", todo) + todos[todo.Id] = todo + return addEvent(CHANGED, todo) +} + +func AddTodo(title string) error { + fmt.Println("AddTodo", title) + maxId++; + todo := model.Todo{Id: maxId, Title: title, Completed: false} + return addEvent(ADDED, todo) +} + +func DeleteTodoById(id int) error { + delete(todos, id) + return addEvent(DELETED, model.Todo{Id: id}) +} + diff --git a/internal/webserver/webserver.go b/internal/webserver/webserver.go new file mode 100644 index 0000000..4b16e51 --- /dev/null +++ b/internal/webserver/webserver.go @@ -0,0 +1,75 @@ +package webserver + +import ( + "fmt" + "html/template" + "net/http" + "strconv" + "snamellit.com/play/todo/internal/persist" +) + + +func IndexHandler(w http.ResponseWriter, r *http.Request) { + t, _ := template.ParseFiles("templates/index.html") + t.Execute(w, nil) +} + + +func renderTodos(w http.ResponseWriter) { + fmt.Println("renderTodos") + todos,err := persist.GetTodos() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Error: %s", err) + } + t, _ := template.ParseFiles("templates/todos.html") + t.Execute(w, todos) +} + +func TodosHandler(w http.ResponseWriter, r *http.Request) { + fmt.Println("TodosHandler") + if r.Method == "POST" { + r.ParseForm() + todoTitle := r.Form.Get("title") + persist.AddTodo(todoTitle) + } + renderTodos(w) +} + +func ToggleTodoHandler(w http.ResponseWriter, r *http.Request) { + fmt.Println("ToggleTodoHandler") + todoId, err := strconv.Atoi(r.URL.Path) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Error: %s", err) + } + fmt.Println("Toggle Todo Id: %d", todoId) + todo, err := persist.GetTodoById(todoId) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Error: %s", err) + } + todo.Completed = !todo.Completed + err = persist.SetTodo(todo) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Error: %s", err) + } + renderTodos(w) +} + +func TodoHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprintf(w, "Method %s not allowed", r.Method) + return + } + todoId, err := strconv.Atoi(r.URL.Path) + if err != nil { + panic(err) + } + fmt.Println("Todo Id: %d", todoId) + persist.DeleteTodoById(todoId) + TodosHandler(w, r) + return +} diff --git a/main.go b/main.go index a7714d5..de40fa9 100644 --- a/main.go +++ b/main.go @@ -1,141 +1,23 @@ package main import ( - "context" "fmt" - "github.com/redis/go-redis/v9" "log" - "encoding/json" "net/http" - "html/template" - "strconv" ) -type todo struct { - Id int - Title string - Completed bool -} -var maxId int = 0 - -var ctx = context.Background() -var rdb = redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - Password: "", // no password set - DB: 0, // use default DB -}) - -func GetTodos() []todo { - var cursor uint64 = 0 - todosJson, cursor, err := rdb.Scan(ctx, cursor, "todo:*", 20).Result() - if err != nil { - panic(err) - } - var todos []todo = make([]todo, len(todosJson)) - for idx, todoJson := range todosJson { - todoJson := rdb.Get(ctx, todoJson).Val() - fmt.Println("Todo: %s", todoJson) - err = json.Unmarshal([]byte(todoJson), &todos[idx]) - if err != nil { - panic(err) - } - if todos[idx].Id > maxId { - maxId = todos[idx].Id - } - } - return todos -} - -func GetTodoById(id int) todo { - todoJson, err := rdb.Get(ctx, fmt.Sprintf("todo:%d", id)).Result() - if err != nil { - panic(err) - } - - var todo todo - err = json.Unmarshal([]byte(todoJson), &todo) - if err != nil { - panic(err) - } - return todo -} - -func SetTodo(todo todo) { - todoJson, err := json.Marshal(todo) - if err != nil { - panic(err) - } - key := fmt.Sprintf("todo:%d", todo.Id) - err = rdb.Set(ctx, key, string(todoJson), 0).Err() - if err != nil { - panic(err) - } - fmt.Println("Set todo: %s", todoJson) -} - -func DeleteTodoById(id int) { - err := rdb.Del(ctx, fmt.Sprintf("todo:%d", id)).Err() - if err != nil { - panic(err) - } -} - -func IndexHandler(w http.ResponseWriter, r *http.Request) { - t, _ := template.ParseFiles("templates/index.html") - t.Execute(w, nil) -} - -func TodosHandler(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" { - r.ParseForm() - todoTitle := r.Form.Get("title") - SetTodo(todo{Id: maxId + 1, Title: todoTitle, Completed: false}) - } - todos := GetTodos() - t, _ := template.ParseFiles("templates/todos.html") - t.Execute(w, todos) -} - -func ToggleTodoHandler(w http.ResponseWriter, r *http.Request) { - todoId, err := strconv.Atoi(r.URL.Path) - if err != nil { - panic(err) - } - fmt.Println("Todo Id: %d", todoId) - todo := GetTodoById(todoId) - todo.Completed = !todo.Completed - SetTodo(todo) - TodosHandler(w, r) - return -} - -func TodoHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "DELETE" { - w.WriteHeader(http.StatusMethodNotAllowed) - fmt.Fprintf(w, "Method %s not allowed", r.Method) - return - } - todoId, err := strconv.Atoi(r.URL.Path) - if err != nil { - panic(err) - } - fmt.Println("Todo Id: %d", todoId) - DeleteTodoById(todoId) - TodosHandler(w, r) - return -} - +import "snamellit.com/play/todo/internal/webserver" func main() { fmt.Println("Starting web server on port 8080") fs := http.FileServer(http.Dir("./static")) http.Handle("/static/", http.StripPrefix("/static/", fs)) - http.HandleFunc("/", IndexHandler) - http.HandleFunc("/todos", TodosHandler) + http.HandleFunc("/", webserver.IndexHandler) + http.HandleFunc("/todos", webserver.TodosHandler) togglePath := "/toggle-todo/" - http.Handle(togglePath, http.StripPrefix(togglePath, http.HandlerFunc(ToggleTodoHandler))) + http.Handle(togglePath, http.StripPrefix(togglePath, http.HandlerFunc(webserver.ToggleTodoHandler))) todoPath := "/todo/" - http.Handle(todoPath, http.StripPrefix(todoPath, http.HandlerFunc(TodoHandler))) + http.Handle(todoPath, http.StripPrefix(todoPath, http.HandlerFunc(webserver.TodoHandler))) log.Fatal(http.ListenAndServe(":8080", nil)) } diff --git a/static/output.css b/static/output.css index 466b8a7..a91fd8e 100644 --- a/static/output.css +++ b/static/output.css @@ -534,10 +534,6 @@ video { --tw-backdrop-sepia: ; } -.block { - display: block; -} - .flex { display: flex; } @@ -554,10 +550,6 @@ video { grid-template-columns: repeat(1, minmax(0, 1fr)); } -.flex-row { - flex-direction: row; -} - .gap-2 { gap: 0.5rem; } @@ -568,24 +560,10 @@ video { margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); } -.rounded { - border-radius: 0.25rem; -} - .rounded-xl { border-radius: 0.75rem; } -.bg-slate-100 { - --tw-bg-opacity: 1; - background-color: rgb(241 245 249 / var(--tw-bg-opacity)); -} - -.bg-slate-200 { - --tw-bg-opacity: 1; - background-color: rgb(226 232 240 / var(--tw-bg-opacity)); -} - .bg-red-500 { --tw-bg-opacity: 1; background-color: rgb(239 68 68 / var(--tw-bg-opacity)); @@ -596,6 +574,16 @@ video { background-color: rgb(14 165 233 / var(--tw-bg-opacity)); } +.bg-slate-100 { + --tw-bg-opacity: 1; + background-color: rgb(241 245 249 / var(--tw-bg-opacity)); +} + +.bg-slate-200 { + --tw-bg-opacity: 1; + background-color: rgb(226 232 240 / var(--tw-bg-opacity)); +} + .p-4 { padding: 1rem; } diff --git a/templates/todos.html b/templates/todos.html index 0e2b297..17d46fe 100644 --- a/templates/todos.html +++ b/templates/todos.html @@ -2,10 +2,10 @@ {{range $index, $task := . }}
  • + hx-post="/toggle-todo/{{$index}}" hx-swap="outerHTML" hx-target="#todos"> {{$task.Title}} + hx-delete="/todo/{{$index}}" hx-swap="outerHTML" hx-target="#todos">X
  • {{end}}