From 4195326938158ce1f0323935cec37d3362da7a5e Mon Sep 17 00:00:00 2001 From: root Date: Mon, 14 Sep 2026 20:51:40 +0200 Subject: [PATCH] vibes --- backend/db.go | 26 ++++ backend/handlers.go | 261 ++++++++++++++++++++++++++++++++++ backend/main.go | 6 + frontend/src/App.vue | 324 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 603 insertions(+), 14 deletions(-) diff --git a/backend/db.go b/backend/db.go index f9c03e5..34b9388 100644 --- a/backend/db.go +++ b/backend/db.go @@ -32,6 +32,14 @@ CREATE TABLE IF NOT EXISTS items ( position REAL NOT NULL DEFAULT 0, sublist_id INTEGER REFERENCES sublists(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS recurring_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id TEXT NOT NULL REFERENCES lists(id), + name TEXT NOT NULL, + sublist_id INTEGER REFERENCES sublists(id), + position REAL NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );` const mysqlSchema = ` @@ -58,6 +66,16 @@ CREATE TABLE IF NOT EXISTS items ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (list_id) REFERENCES lists(id), FOREIGN KEY (sublist_id) REFERENCES sublists(id) +); +CREATE TABLE IF NOT EXISTS recurring_items ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + list_id VARCHAR(64) NOT NULL, + name VARCHAR(255) NOT NULL, + sublist_id INTEGER, + position DOUBLE NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (list_id) REFERENCES lists(id), + FOREIGN KEY (sublist_id) REFERENCES sublists(id) );` const postgresSchema = ` @@ -81,6 +99,14 @@ CREATE TABLE IF NOT EXISTS items ( position DOUBLE PRECISION NOT NULL DEFAULT 0, sublist_id INTEGER REFERENCES sublists(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS recurring_items ( + id SERIAL PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES lists(id), + name TEXT NOT NULL, + sublist_id INTEGER REFERENCES sublists(id), + position DOUBLE PRECISION NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );` // querier is the subset of *sql.DB every handler uses. Postgres needs its own diff --git a/backend/handlers.go b/backend/handlers.go index e4f5cc8..c6fe080 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -30,6 +30,14 @@ type Sublist struct { CreatedAt string `json:"created_at"` } +type RecurringItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + SublistID *int64 `json:"sublist_id"` + Position float64 `json:"position"` + CreatedAt string `json:"created_at"` +} + type api struct { db querier hub *hub @@ -79,9 +87,58 @@ func (a *api) getList(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, l) } +// ensureGeneralSublist guarantees every item lands in a sublist: it makes +// sure the list has at least one (creating a "General" catch-all if it has +// none) and backfills any item/recurring row still missing a sublist_id +// (left over from before sublists were required) onto it. +func (a *api) ensureGeneralSublist(listID string) error { + var orphanItems, orphanRecurring int + if err := a.db.QueryRow("SELECT COUNT(*) FROM items WHERE list_id = ? AND sublist_id IS NULL", listID).Scan(&orphanItems); err != nil { + return err + } + if err := a.db.QueryRow("SELECT COUNT(*) FROM recurring_items WHERE list_id = ? AND sublist_id IS NULL", listID).Scan(&orphanRecurring); err != nil { + return err + } + + var sublistCount int + if err := a.db.QueryRow("SELECT COUNT(*) FROM sublists WHERE list_id = ?", listID).Scan(&sublistCount); err != nil { + return err + } + + if sublistCount > 0 && orphanItems == 0 && orphanRecurring == 0 { + return nil + } + + var generalID int64 + err := a.db.QueryRow("SELECT id FROM sublists WHERE list_id = ? AND name = ? ORDER BY id LIMIT 1", listID, "General").Scan(&generalID) + if err == sql.ErrNoRows { + generalID, err = a.db.insertReturningID("INSERT INTO sublists (list_id, name, color) VALUES (?, ?, ?)", listID, "General", "#9ca3af") + } + if err != nil { + return err + } + + if orphanItems > 0 { + if _, err := a.db.Exec("UPDATE items SET sublist_id = ? WHERE list_id = ? AND sublist_id IS NULL", generalID, listID); err != nil { + return err + } + } + if orphanRecurring > 0 { + if _, err := a.db.Exec("UPDATE recurring_items SET sublist_id = ? WHERE list_id = ? AND sublist_id IS NULL", generalID, listID); err != nil { + return err + } + } + return nil +} + func (a *api) listSublists(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") + if err := a.ensureGeneralSublist(listID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + rows, err := a.db.Query("SELECT id, name, color, created_at FROM sublists WHERE list_id = ? ORDER BY id", listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) @@ -150,6 +207,210 @@ func (a *api) createSublist(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, s) } +func (a *api) updateSublist(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + id := r.PathValue("id") + + var in struct { + Name *string `json:"name"` + Color *string `json:"color"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if in.Name != nil { + if *in.Name == "" { + writeError(w, http.StatusBadRequest, "name cannot be empty") + return + } + if _, err := a.db.Exec("UPDATE sublists SET name = ? WHERE id = ? AND list_id = ?", *in.Name, id, listID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + if in.Color != nil { + if *in.Color == "" { + writeError(w, http.StatusBadRequest, "color cannot be empty") + return + } + if _, err := a.db.Exec("UPDATE sublists SET color = ? WHERE id = ? AND list_id = ?", *in.Color, id, listID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + + var s Sublist + err := a.db.QueryRow("SELECT id, name, color, created_at FROM sublists WHERE id = ? AND list_id = ?", id, listID). + Scan(&s.ID, &s.Name, &s.Color, &s.CreatedAt) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "sublist not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + a.hub.broadcast(listID) + writeJSON(w, http.StatusOK, s) +} + +func (a *api) deleteSublist(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + id := r.PathValue("id") + + if _, err := a.db.Exec("DELETE FROM items WHERE sublist_id = ? AND list_id = ?", id, listID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if _, err := a.db.Exec("DELETE FROM recurring_items WHERE sublist_id = ? AND list_id = ?", id, listID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + res, err := a.db.Exec("DELETE FROM sublists WHERE id = ? AND list_id = ?", id, listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + writeError(w, http.StatusNotFound, "sublist not found") + return + } + + a.hub.broadcast(listID) + w.WriteHeader(http.StatusNoContent) +} + +func (a *api) listRecurring(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + + rows, err := a.db.Query("SELECT id, name, sublist_id, position, created_at FROM recurring_items WHERE list_id = ? ORDER BY position, id", listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + + recurring := []RecurringItem{} + for rows.Next() { + var ri RecurringItem + if err := rows.Scan(&ri.ID, &ri.Name, &ri.SublistID, &ri.Position, &ri.CreatedAt); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + recurring = append(recurring, ri) + } + + writeJSON(w, http.StatusOK, recurring) +} + +func (a *api) createRecurring(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + + exists, err := a.listExists(listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !exists { + writeError(w, http.StatusNotFound, "list not found") + return + } + + var in struct { + Name string `json:"name"` + SublistID *int64 `json:"sublist_id"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if in.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + var maxPosition sql.NullFloat64 + if err := a.db.QueryRow("SELECT MAX(position) FROM recurring_items WHERE list_id = ?", listID).Scan(&maxPosition); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + position := maxPosition.Float64 + 1 + + id, err := a.db.insertReturningID("INSERT INTO recurring_items (list_id, name, sublist_id, position) VALUES (?, ?, ?, ?)", listID, in.Name, in.SublistID, position) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + var ri RecurringItem + err = a.db.QueryRow("SELECT id, name, sublist_id, position, created_at FROM recurring_items WHERE id = ?", id). + Scan(&ri.ID, &ri.Name, &ri.SublistID, &ri.Position, &ri.CreatedAt) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + a.hub.broadcast(listID) + writeJSON(w, http.StatusCreated, ri) +} + +func (a *api) updateRecurring(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + id := r.PathValue("id") + + var in struct { + Position *float64 `json:"position"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if in.Position != nil { + if _, err := a.db.Exec("UPDATE recurring_items SET position = ? WHERE id = ? AND list_id = ?", *in.Position, id, listID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + + var ri RecurringItem + err := a.db.QueryRow("SELECT id, name, sublist_id, position, created_at FROM recurring_items WHERE id = ? AND list_id = ?", id, listID). + Scan(&ri.ID, &ri.Name, &ri.SublistID, &ri.Position, &ri.CreatedAt) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "recurring item not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + a.hub.broadcast(listID) + writeJSON(w, http.StatusOK, ri) +} + +func (a *api) deleteRecurring(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + id := r.PathValue("id") + + res, err := a.db.Exec("DELETE FROM recurring_items WHERE id = ? AND list_id = ?", id, listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + writeError(w, http.StatusNotFound, "recurring item not found") + return + } + + a.hub.broadcast(listID) + w.WriteHeader(http.StatusNoContent) +} + func (a *api) listSocket(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") diff --git a/backend/main.go b/backend/main.go index c4a32b3..5ec61a2 100644 --- a/backend/main.go +++ b/backend/main.go @@ -26,6 +26,12 @@ func main() { mux.HandleFunc("GET /api/lists/{listId}/ws", a.listSocket) mux.HandleFunc("GET /api/lists/{listId}/sublists", a.listSublists) mux.HandleFunc("POST /api/lists/{listId}/sublists", a.createSublist) + mux.HandleFunc("PATCH /api/lists/{listId}/sublists/{id}", a.updateSublist) + mux.HandleFunc("DELETE /api/lists/{listId}/sublists/{id}", a.deleteSublist) + mux.HandleFunc("GET /api/lists/{listId}/recurring", a.listRecurring) + mux.HandleFunc("POST /api/lists/{listId}/recurring", a.createRecurring) + mux.HandleFunc("PATCH /api/lists/{listId}/recurring/{id}", a.updateRecurring) + mux.HandleFunc("DELETE /api/lists/{listId}/recurring/{id}", a.deleteRecurring) mux.HandleFunc("GET /api/lists/{listId}/items", a.listItems) mux.HandleFunc("POST /api/lists/{listId}/items", a.createItem) mux.HandleFunc("PATCH /api/lists/{listId}/items/{id}", a.updateItem) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1544027..3b9aabd 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@