From e862d494aa48f61f45832f3eb9ae9caecbb2b7ce Mon Sep 17 00:00:00 2001 From: cclohmar Date: Sun, 21 Jun 2026 19:00:34 +0000 Subject: [PATCH] feat: add product search with HTMX live filtering - Search purchasers by product name, store, or notes (case-insensitive) - GET /search?q=... returns purchase list fragment - 300ms debounced keyup trigger on search input - Empty query returns all purchases --- internal/database/db.go | 39 ++++++++++++++++++++++++++++++++++ internal/handlers/dashboard.go | 34 +++++++++++++++++++++++++++++ main.go | 3 +++ templates/dashboard.html | 8 +++++++ 4 files changed, 84 insertions(+) diff --git a/internal/database/db.go b/internal/database/db.go index d3f0d48..7864141 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -312,6 +312,45 @@ func UpdatePurchase(db *sql.DB, p Purchase) error { return err } +// SearchPurchases searches purchases by product_name, store, or notes (case-insensitive). +// When query is empty, falls back to GetPurchasesByUser. +func SearchPurchases(db *sql.DB, userID, query string) ([]Purchase, error) { + if query == "" { + return GetPurchasesByUser(db, userID) + } + + rows, err := db.Query( + `SELECT id, user_id, product_name, store, category, amount, currency, + purchase_date, warranty_months, return_days, COALESCE(notes, ''), image_path, created_at + FROM purchases WHERE user_id = ? + AND (product_name LIKE ? OR store LIKE ? OR notes LIKE ?) + ORDER BY purchase_date DESC, created_at DESC`, + userID, "%"+query+"%", "%"+query+"%", "%"+query+"%", + ) + if err != nil { + log.Printf("ERROR [%s] database: SearchPurchases(%s, %s): %v", + time.Now().Format(time.RFC3339), userID, query, err) + return nil, err + } + defer rows.Close() + + var purchases []Purchase + for rows.Next() { + var p Purchase + if err := rows.Scan( + &p.ID, &p.UserID, &p.ProductName, &p.Store, &p.Category, + &p.Amount, &p.Currency, &p.PurchaseDate, + &p.WarrantyMonths, &p.ReturnDays, &p.Notes, &p.ImagePath, &p.CreatedAt, + ); err != nil { + log.Printf("ERROR [%s] database: SearchPurchases scan: %v", + time.Now().Format(time.RFC3339), err) + return nil, err + } + purchases = append(purchases, p) + } + return purchases, rows.Err() +} + // DeletePurchase removes a single purchase by its ID. func DeletePurchase(db *sql.DB, id string) error { _, err := db.Exec("DELETE FROM purchases WHERE id = ?", id) diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 6b00d33..f6442d2 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -6,6 +6,7 @@ package handlers import ( "database/sql" + "fmt" "log" "net/http" "time" @@ -58,6 +59,7 @@ func (h *DashboardHandler) Dashboard(w http.ResponseWriter, r *http.Request) { data := map[string]interface{}{ "Purchases": purchases, + "Query": "", } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -66,3 +68,35 @@ func (h *DashboardHandler) Dashboard(w http.ResponseWriter, r *http.Request) { time.Now().Format(time.RFC3339), err) } } + +// --------------------------------------------------------------------------- +// GET /search — SearchPurchases +// --------------------------------------------------------------------------- + +// SearchPurchases handles the HTMX search request, returning only the purchase +// list fragment filtered by the search query. +func (h *DashboardHandler) SearchPurchases(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + if userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + query := r.URL.Query().Get("q") + + purchases, err := database.SearchPurchases(h.DB, userID, query) + if err != nil { + log.Printf("ERROR [%s] handlers: SearchPurchases: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Search failed", http.StatusInternalServerError) + return + } + + // Normalize ImagePath for all purchases. + for i := range purchases { + purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderPurchaseList(purchases)) +} diff --git a/main.go b/main.go index 1492495..f145c8d 100644 --- a/main.go +++ b/main.go @@ -200,6 +200,9 @@ func main() { // Dashboard. r.Get("/dashboard", dashboardHandler.Dashboard) + // Search. + r.Get("/search", dashboardHandler.SearchPurchases) + // Purchases. r.Post("/purchases/upload", purchaseHandler.UploadReceipt) r.Post("/purchases", purchaseHandler.SavePurchase) diff --git a/templates/dashboard.html b/templates/dashboard.html index 1e0096d..861ec47 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -24,6 +24,14 @@

My Purchases

+ +
+ +
+
{{if .Purchases}}