summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/dashboard/main.go4
-rw-r--r--internal/api/interfaces.go1
-rw-r--r--internal/api/plantoeat.go6
-rw-r--r--internal/handlers/atoms.go15
-rw-r--r--internal/handlers/handlers.go49
-rw-r--r--internal/handlers/handlers_test.go194
-rw-r--r--internal/models/atom.go24
-rw-r--r--internal/models/atom_test.go30
-rw-r--r--internal/models/types.go8
-rw-r--r--internal/store/sqlite.go114
-rw-r--r--internal/store/sqlite_test.go188
-rw-r--r--migrations/018_drop_bugs.sql1
-rwxr-xr-xscripts/deploy4
-rw-r--r--web/templates/index.html34
-rw-r--r--web/templates/partials/bugs.html12
15 files changed, 115 insertions, 569 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 8d66359..4112640 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -250,10 +250,6 @@ func main() {
r.Get("/tasks/detail", h.HandleGetTaskDetail)
r.Post("/tasks/update", h.HandleUpdateTask)
- // Bug reporting
- r.Get("/bugs", h.HandleGetBugs)
- r.Post("/bugs", h.HandleReportBug)
-
// Shopping quick-add
r.Post("/shopping/add", h.HandleShoppingQuickAdd)
r.Post("/shopping/toggle", h.HandleShoppingToggle)
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go
index 1c102a7..c9962c9 100644
--- a/internal/api/interfaces.go
+++ b/internal/api/interfaces.go
@@ -33,7 +33,6 @@ type PlanToEatAPI interface {
GetUpcomingMeals(ctx context.Context, days int) ([]models.Meal, error)
GetShoppingList(ctx context.Context) ([]models.ShoppingItem, error)
GetRecipes(ctx context.Context) error
- AddMealToPlanner(ctx context.Context, recipeID string, date time.Time, mealType string) error
}
// GoogleCalendarAPI defines the interface for Google Calendar operations
diff --git a/internal/api/plantoeat.go b/internal/api/plantoeat.go
index ab5d2cd..770987a 100644
--- a/internal/api/plantoeat.go
+++ b/internal/api/plantoeat.go
@@ -7,7 +7,6 @@ import (
"log"
"net/http"
"strings"
- "time"
"github.com/PuerkitoBio/goquery"
@@ -190,11 +189,6 @@ func (c *PlanToEatClient) GetRecipes(ctx context.Context) error {
return fmt.Errorf("not implemented yet")
}
-// AddMealToPlanner adds a meal to the planner (for Phase 2)
-func (c *PlanToEatClient) AddMealToPlanner(ctx context.Context, recipeID string, date time.Time, mealType string) error {
- return fmt.Errorf("not implemented yet")
-}
-
// GetShoppingList fetches the shopping list by scraping the web interface
// Requires a valid session cookie set via SetSessionCookie
func (c *PlanToEatClient) GetShoppingList(ctx context.Context) ([]models.ShoppingItem, error) {
diff --git a/internal/handlers/atoms.go b/internal/handlers/atoms.go
index 7bc4465..0ebf4e6 100644
--- a/internal/handlers/atoms.go
+++ b/internal/handlers/atoms.go
@@ -7,7 +7,7 @@ import (
"task-dashboard/internal/store"
)
-// BuildUnifiedAtomList creates a list of atoms from tasks, cards, and bugs
+// BuildUnifiedAtomList creates a list of atoms from tasks and cards
func BuildUnifiedAtomList(s *store.Store) ([]models.Atom, []models.Board, error) {
tasks, err := s.GetTasks()
if err != nil {
@@ -19,9 +19,7 @@ func BuildUnifiedAtomList(s *store.Store) ([]models.Atom, []models.Board, error)
return nil, nil, err
}
- bugs, _ := s.GetUnresolvedBugs() // Ignore error, bugs are optional
-
- atoms := make([]models.Atom, 0, len(tasks)+len(bugs))
+ atoms := make([]models.Atom, 0, len(tasks))
// Add incomplete tasks
for _, task := range tasks {
@@ -39,15 +37,6 @@ func BuildUnifiedAtomList(s *store.Store) ([]models.Atom, []models.Board, error)
}
}
- // Add unresolved bugs
- for _, bug := range bugs {
- atoms = append(atoms, models.BugToAtom(models.Bug{
- ID: bug.ID,
- Description: bug.Description,
- CreatedAt: bug.CreatedAt,
- }))
- }
-
// Compute UI fields for all atoms
for i := range atoms {
atoms[i].ComputeUIFields()
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index d860595..c2e903f 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -610,18 +610,6 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl
}
case "trello":
err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"closed": complete})
- case "bug":
- // Bug IDs are prefixed with "bug-", extract the numeric ID
- var bugID int64
- if _, parseErr := fmt.Sscanf(id, "bug-%d", &bugID); parseErr != nil {
- JSONError(w, http.StatusBadRequest, "Invalid bug ID format", parseErr)
- return
- }
- if complete {
- err = h.store.ResolveBug(bugID)
- } else {
- err = h.store.UnresolveBug(bugID)
- }
case "gtasks":
// Google Tasks - need list ID from form or use default
listID := r.FormValue("listId")
@@ -710,17 +698,6 @@ func (h *Handler) getAtomDetails(id, source string) (string, *time.Time) {
}
}
}
- case "bug":
- if bugs, err := h.store.GetBugs(); err == nil {
- var bugID int64
- if _, err := fmt.Sscanf(id, "bug-%d", &bugID); err == nil {
- for _, b := range bugs {
- if b.ID == bugID {
- return b.Description, nil
- }
- }
- }
- }
case "gtasks":
// Google Tasks don't have local cache, return generic title
return "Google Task", nil
@@ -799,32 +776,6 @@ func (h *Handler) HandleGetListsOptions(w http.ResponseWriter, r *http.Request)
HTMLResponse(w, h.renderer, "lists-options", lists)
}
-// HandleGetBugs returns the list of reported bugs
-func (h *Handler) HandleGetBugs(w http.ResponseWriter, r *http.Request) {
- bugs, err := h.store.GetBugs()
- if err != nil {
- JSONError(w, http.StatusInternalServerError, "Failed to fetch bugs", err)
- return
- }
- HTMLResponse(w, h.renderer, "bugs", bugs)
-}
-
-// HandleReportBug saves a new bug report
-func (h *Handler) HandleReportBug(w http.ResponseWriter, r *http.Request) {
- description, ok := requireFormValue(w, r, "description")
- if !ok {
- return
- }
- description = strings.TrimSpace(description)
-
- if err := h.store.SaveBug(description); err != nil {
- JSONError(w, http.StatusInternalServerError, "Failed to save bug", err)
- return
- }
-
- h.HandleGetBugs(w, r)
-}
-
// HandleGetTaskDetail returns task details as HTML for modal
func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index e30c6d5..793ccdd 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -1367,33 +1367,6 @@ func TestShoppingTabFiltersCheckedItems(t *testing.T) {
}
// =============================================================================
-// Bug Reporting Tests
-// NOTE: Some tests skipped due to schema mismatch (resolved_at column)
-// =============================================================================
-
-func TestHandleReportBug_MissingDescription(t *testing.T) {
- db, cleanup := setupTestDB(t)
- defer cleanup()
-
- h := &Handler{
- store: db,
- config: &config.Config{},
- }
-
- req := httptest.NewRequest("POST", "/report-bug", nil)
- req.Form = map[string][]string{
- "description": {""},
- }
- w := httptest.NewRecorder()
-
- h.HandleReportBug(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("Expected status 400 for empty description, got %d", w.Code)
- }
-}
-
-// =============================================================================
// Tab Handler Tests
// =============================================================================
@@ -2828,58 +2801,6 @@ func TestHandleSettingsPage_IncludesSyncLog(t *testing.T) {
}
// =============================================================================
-// HandleGetBugs template tests
-// =============================================================================
-
-// TestHandleGetBugs_RendersTemplate verifies that HandleGetBugs uses the renderer
-// with the "bugs" template and passes the bug list as data.
-func TestHandleGetBugs_RendersTemplate(t *testing.T) {
- h, cleanup := setupTestHandler(t)
- defer cleanup()
-
- _ = h.store.SaveBug("test bug description")
-
- req := httptest.NewRequest("GET", "/bugs", nil)
- w := httptest.NewRecorder()
- h.HandleGetBugs(w, req)
-
- mr := h.renderer.(*MockRenderer)
- var found bool
- for _, call := range mr.Calls {
- if call.Name == "bugs" {
- found = true
- break
- }
- }
- if !found {
- t.Error("Expected renderer to be called with 'bugs' template")
- }
-}
-
-// TestHandleGetBugs_NoBugs_RendersTemplate verifies that HandleGetBugs uses the
-// renderer even when there are no bugs.
-func TestHandleGetBugs_NoBugs_RendersTemplate(t *testing.T) {
- h, cleanup := setupTestHandler(t)
- defer cleanup()
-
- req := httptest.NewRequest("GET", "/bugs", nil)
- w := httptest.NewRecorder()
- h.HandleGetBugs(w, req)
-
- mr := h.renderer.(*MockRenderer)
- var found bool
- for _, call := range mr.Calls {
- if call.Name == "bugs" {
- found = true
- break
- }
- }
- if !found {
- t.Error("Expected renderer to be called with 'bugs' template even when no bugs")
- }
-}
-
-// =============================================================================
// HandleGetTaskDetail template tests
// =============================================================================
@@ -3223,118 +3144,3 @@ func TestHandleTabMeals_GroupingMergesRecipes(t *testing.T) {
}
}
-// =============================================================================
-// HandleGetBugs and HandleReportBug data tests
-// =============================================================================
-
-// TestHandleGetBugs_ReturnsBugList verifies that the bugs template receives a
-// non-empty bug list when bugs exist in the store.
-func TestHandleGetBugs_ReturnsBugList(t *testing.T) {
- h, cleanup := setupTestHandler(t)
- defer cleanup()
-
- _ = h.store.SaveBug("login button not working")
- _ = h.store.SaveBug("dashboard shows wrong date")
-
- req := httptest.NewRequest("GET", "/bugs", nil)
- w := httptest.NewRecorder()
- h.HandleGetBugs(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("Expected 200, got %d", w.Code)
- }
-
- mr := h.renderer.(*MockRenderer)
- var found bool
- for _, call := range mr.Calls {
- if call.Name == "bugs" {
- found = true
- bugs, ok := call.Data.([]store.Bug)
- if !ok {
- t.Fatalf("Expected []store.Bug data, got %T", call.Data)
- }
- if len(bugs) != 2 {
- t.Errorf("Expected 2 bugs in template data, got %d", len(bugs))
- }
- break
- }
- }
- if !found {
- t.Error("Expected renderer called with 'bugs' template")
- }
-}
-
-// TestHandleGetBugs_EmptyList verifies that the bugs template receives an
-// empty (non-nil) slice when no bugs exist.
-func TestHandleGetBugs_EmptyList(t *testing.T) {
- h, cleanup := setupTestHandler(t)
- defer cleanup()
-
- req := httptest.NewRequest("GET", "/bugs", nil)
- w := httptest.NewRecorder()
- h.HandleGetBugs(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("Expected 200, got %d", w.Code)
- }
-
- mr := h.renderer.(*MockRenderer)
- var found bool
- for _, call := range mr.Calls {
- if call.Name == "bugs" {
- found = true
- bugs, ok := call.Data.([]store.Bug)
- if !ok {
- t.Fatalf("Expected []store.Bug data, got %T", call.Data)
- }
- if len(bugs) != 0 {
- t.Errorf("Expected 0 bugs in template data, got %d", len(bugs))
- }
- break
- }
- }
- if !found {
- t.Error("Expected renderer called with 'bugs' template")
- }
-}
-
-// TestHandleReportBug_Success verifies that a valid bug report is saved and the
-// updated bugs list is re-rendered.
-func TestHandleReportBug_Success(t *testing.T) {
- h, cleanup := setupTestHandler(t)
- defer cleanup()
-
- req := httptest.NewRequest("POST", "/report-bug", strings.NewReader("description=button+broken"))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- w := httptest.NewRecorder()
- h.HandleReportBug(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("Expected 200, got %d", w.Code)
- }
-
- // Verify bug was saved to store
- bugs, err := h.store.GetBugs()
- if err != nil {
- t.Fatalf("GetBugs failed: %v", err)
- }
- if len(bugs) != 1 {
- t.Fatalf("Expected 1 bug saved, got %d", len(bugs))
- }
- if bugs[0].Description != "button broken" {
- t.Errorf("Expected description 'button broken', got %q", bugs[0].Description)
- }
-
- // Verify renderer was called with "bugs" template (HandleGetBugs is called internally)
- mr := h.renderer.(*MockRenderer)
- var bugsRendered bool
- for _, call := range mr.Calls {
- if call.Name == "bugs" {
- bugsRendered = true
- break
- }
- }
- if !bugsRendered {
- t.Error("Expected 'bugs' template to be rendered after successful report")
- }
-}
diff --git a/internal/models/atom.go b/internal/models/atom.go
index 3804de4..9c519ba 100644
--- a/internal/models/atom.go
+++ b/internal/models/atom.go
@@ -1,7 +1,6 @@
package models
import (
- "fmt"
"time"
"task-dashboard/internal/config"
@@ -13,16 +12,13 @@ const (
SourceTrello AtomSource = "trello"
SourceTodoist AtomSource = "todoist"
SourceMeal AtomSource = "plantoeat"
- SourceBug AtomSource = "bug"
)
type AtomType string
const (
TypeTask AtomType = "task"
- TypeNote AtomType = "note"
TypeMeal AtomType = "meal"
- TypeBug AtomType = "bug"
)
// Atom represents a unified unit of work or information
@@ -147,23 +143,3 @@ func MealToAtom(m Meal) Atom {
}
}
-// BugToAtom converts a Bug to an Atom
-func BugToAtom(b Bug) Atom {
- // Bugs get high priority (3) to encourage fixing
- priority := 3
-
- return Atom{
- ID: fmt.Sprintf("bug-%d", b.ID),
- Title: b.Description,
- Description: "Bug Report",
- Source: SourceBug,
- Type: TypeBug,
- URL: "",
- DueDate: nil, // Bugs don't have due dates
- CreatedAt: b.CreatedAt,
- Priority: priority,
- SourceIcon: "🐛",
- ColorClass: "border-red-700",
- Raw: b,
- }
-}
diff --git a/internal/models/atom_test.go b/internal/models/atom_test.go
index 537d232..3ed4774 100644
--- a/internal/models/atom_test.go
+++ b/internal/models/atom_test.go
@@ -123,36 +123,6 @@ func TestMealToAtom(t *testing.T) {
}
}
-func TestBugToAtom(t *testing.T) {
- now := time.Now()
- bug := Bug{
- ID: 42,
- Description: "Something is broken",
- CreatedAt: now,
- }
-
- atom := BugToAtom(bug)
-
- if atom.ID != "bug-42" {
- t.Errorf("Expected ID 'bug-42', got '%s'", atom.ID)
- }
- if atom.Title != "Something is broken" {
- t.Errorf("Expected title 'Something is broken', got '%s'", atom.Title)
- }
- if atom.Source != SourceBug {
- t.Errorf("Expected source Bug, got '%s'", atom.Source)
- }
- if atom.Type != TypeBug {
- t.Errorf("Expected type Bug, got '%s'", atom.Type)
- }
- if atom.Priority != 3 {
- t.Errorf("Expected high priority 3, got %d", atom.Priority)
- }
- if atom.SourceIcon != "🐛" {
- t.Error("Expected bug icon")
- }
-}
-
func TestAtom_ComputeUIFields(t *testing.T) {
// Test nil due date
t.Run("nil due date", func(t *testing.T) {
diff --git a/internal/models/types.go b/internal/models/types.go
index 8ec2095..194eca9 100644
--- a/internal/models/types.go
+++ b/internal/models/types.go
@@ -159,14 +159,6 @@ type TaskListInfo struct {
Name string `json:"name"`
}
-// Bug represents a bug report
-type Bug struct {
- ID int64 `json:"id"`
- Description string `json:"description"`
- CreatedAt time.Time `json:"created_at"`
- ResolvedAt *time.Time `json:"resolved_at,omitempty"`
-}
-
// CacheMetadata tracks when data was last fetched
type CacheMetadata struct {
Key string `json:"key"`
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index 366b24e..33edbf2 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -73,20 +73,54 @@ func (s *Store) DB() *sql.DB {
return s.db
}
-// runMigrations executes all migration files in order
+// runMigrations executes all migration files in order, skipping already-applied ones.
func (s *Store) runMigrations() error {
- // Get migration files from configured directory
+ // Check if schema_migrations exists before creating it — used to detect legacy DBs.
+ var trackingExists int
+ s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'`).Scan(&trackingExists)
+
+ if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
+ filename TEXT PRIMARY KEY,
+ applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`); err != nil {
+ return fmt.Errorf("failed to create schema_migrations table: %w", err)
+ }
+
pattern := filepath.Join(s.migrationDir, "*.sql")
migrationFiles, err := filepath.Glob(pattern)
if err != nil {
return fmt.Errorf("failed to read migration files: %w", err)
}
- // Sort migrations by filename
sort.Strings(migrationFiles)
- // Execute each migration
+ // If schema_migrations was just created on a pre-existing DB (legacy), seed all
+ // current migration filenames as applied so we don't re-run them.
+ if trackingExists == 0 {
+ var existingTables int
+ s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name != 'schema_migrations'`).Scan(&existingTables)
+ if existingTables > 0 {
+ for _, file := range migrationFiles {
+ name := filepath.Base(file)
+ if _, err := s.db.Exec(`INSERT OR IGNORE INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
+ return fmt.Errorf("failed to seed migration record %s: %w", name, err)
+ }
+ }
+ return nil
+ }
+ }
+
for _, file := range migrationFiles {
+ name := filepath.Base(file)
+
+ var count int
+ if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE filename = ?`, name).Scan(&count); err != nil {
+ return fmt.Errorf("failed to check migration %s: %w", name, err)
+ }
+ if count > 0 {
+ continue // already applied
+ }
+
content, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", file, err)
@@ -95,6 +129,10 @@ func (s *Store) runMigrations() error {
if _, err := s.db.Exec(string(content)); err != nil {
return fmt.Errorf("failed to execute migration %s: %w", file, err)
}
+
+ if _, err := s.db.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
+ return fmt.Errorf("failed to record migration %s: %w", name, err)
+ }
}
return nil
@@ -588,74 +626,6 @@ func (s *Store) ClearSyncToken(service string) error {
return err
}
-// Bug represents a user-reported bug
-type Bug struct {
- ID int64
- Description string
- CreatedAt time.Time
- ResolvedAt *time.Time
-}
-
-// SaveBug saves a new bug report
-func (s *Store) SaveBug(description string) error {
- _, err := s.db.Exec(`INSERT INTO bugs (description) VALUES (?)`, description)
- return err
-}
-
-// GetBugs retrieves all bugs, newest first
-func (s *Store) GetBugs() ([]Bug, error) {
- rows, err := s.db.Query(`SELECT id, description, created_at, resolved_at FROM bugs ORDER BY created_at DESC`)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
-
- var bugs []Bug
- for rows.Next() {
- var b Bug
- var resolvedAt sql.NullTime
- if err := rows.Scan(&b.ID, &b.Description, &b.CreatedAt, &resolvedAt); err != nil {
- return nil, err
- }
- if resolvedAt.Valid {
- b.ResolvedAt = &resolvedAt.Time
- }
- bugs = append(bugs, b)
- }
- return bugs, rows.Err()
-}
-
-// GetUnresolvedBugs retrieves bugs that haven't been resolved yet
-func (s *Store) GetUnresolvedBugs() ([]Bug, error) {
- rows, err := s.db.Query(`SELECT id, description, created_at FROM bugs WHERE resolved_at IS NULL ORDER BY created_at DESC`)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
-
- var bugs []Bug
- for rows.Next() {
- var b Bug
- if err := rows.Scan(&b.ID, &b.Description, &b.CreatedAt); err != nil {
- return nil, err
- }
- bugs = append(bugs, b)
- }
- return bugs, rows.Err()
-}
-
-// ResolveBug marks a bug as resolved
-func (s *Store) ResolveBug(id int64) error {
- _, err := s.db.Exec(`UPDATE bugs SET resolved_at = CURRENT_TIMESTAMP WHERE id = ?`, id)
- return err
-}
-
-// UnresolveBug marks a bug as unresolved (reopens it)
-func (s *Store) UnresolveBug(id int64) error {
- _, err := s.db.Exec(`UPDATE bugs SET resolved_at = NULL WHERE id = ?`, id)
- return err
-}
-
// UserShoppingItem represents a user-added shopping item
type UserShoppingItem struct {
ID int64
diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go
index c6c428a..0514eeb 100644
--- a/internal/store/sqlite_test.go
+++ b/internal/store/sqlite_test.go
@@ -2,6 +2,7 @@ package store
import (
"database/sql"
+ "os"
"path/filepath"
"testing"
"time"
@@ -10,6 +11,73 @@ import (
"task-dashboard/internal/models"
)
+func TestRunMigrations_LegacyDB_SeedsTrackingTable(t *testing.T) {
+ tempDir := t.TempDir()
+ dbPath := filepath.Join(tempDir, "test.db")
+ migDir := filepath.Join(tempDir, "migrations")
+ if err := os.MkdirAll(migDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Simulate a legacy DB: tables exist but no schema_migrations
+ db, err := sql.Open("sqlite3", dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec("CREATE TABLE legacy_items (id INTEGER PRIMARY KEY); ALTER TABLE legacy_items ADD COLUMN created_at DATETIME;"); err != nil {
+ t.Fatal(err)
+ }
+ db.Close()
+
+ migration := []byte("CREATE TABLE legacy_items (id INTEGER PRIMARY KEY);\nALTER TABLE legacy_items ADD COLUMN created_at DATETIME;\n")
+ if err := os.WriteFile(filepath.Join(migDir, "001_test.sql"), migration, 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Should not fail even though migration SQL would fail if re-run
+ store, err := New(dbPath, migDir)
+ if err != nil {
+ t.Fatalf("New() on legacy DB failed: %v", err)
+ }
+ defer store.Close()
+
+ // Confirm migration was recorded as applied
+ var count int
+ store.db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE filename = '001_test.sql'").Scan(&count)
+ if count != 1 {
+ t.Errorf("expected migration to be seeded in schema_migrations, got count=%d", count)
+ }
+}
+
+func TestRunMigrations_IdempotentOnRestart(t *testing.T) {
+ tempDir := t.TempDir()
+ dbPath := filepath.Join(tempDir, "test.db")
+ migDir := filepath.Join(tempDir, "migrations")
+ if err := os.MkdirAll(migDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Write a migration with a non-idempotent ALTER TABLE
+ migration := []byte("CREATE TABLE foo (id INTEGER PRIMARY KEY);\nALTER TABLE foo ADD COLUMN bar TEXT;\n")
+ if err := os.WriteFile(filepath.Join(migDir, "001_test.sql"), migration, 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // First run — should succeed
+ store1, err := New(dbPath, migDir)
+ if err != nil {
+ t.Fatalf("first New() failed: %v", err)
+ }
+ store1.Close()
+
+ // Second run (simulating service restart) — should also succeed
+ store2, err := New(dbPath, migDir)
+ if err != nil {
+ t.Fatalf("second New() failed (migration re-run not skipped): %v", err)
+ }
+ store2.Close()
+}
+
// setupTestStoreWithTasks creates a test store with tasks table
func setupTestStoreWithTasks(t *testing.T) *Store {
t.Helper()
@@ -631,126 +699,6 @@ func TestGetCardsByDateRange(t *testing.T) {
}
}
-// setupTestStoreWithBugs creates a test store with bugs table
-func setupTestStoreWithBugs(t *testing.T) *Store {
- t.Helper()
-
- tempDir := t.TempDir()
- dbPath := filepath.Join(tempDir, "test.db")
-
- db, err := sql.Open("sqlite3", dbPath)
- if err != nil {
- t.Fatalf("Failed to open test database: %v", err)
- }
-
- db.SetMaxOpenConns(1)
-
- store := &Store{db: db}
-
- schema := `
- CREATE TABLE IF NOT EXISTS bugs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- description TEXT NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- resolved_at DATETIME DEFAULT NULL
- );
- `
- if _, err := db.Exec(schema); err != nil {
- t.Fatalf("Failed to create schema: %v", err)
- }
-
- return store
-}
-
-func TestBugResolution(t *testing.T) {
- store := setupTestStoreWithBugs(t)
- defer func() { _ = store.Close() }()
-
- // Save some bugs
- if err := store.SaveBug("Bug 1"); err != nil {
- t.Fatalf("Failed to save bug 1: %v", err)
- }
- if err := store.SaveBug("Bug 2"); err != nil {
- t.Fatalf("Failed to save bug 2: %v", err)
- }
- if err := store.SaveBug("Bug 3"); err != nil {
- t.Fatalf("Failed to save bug 3: %v", err)
- }
-
- // Verify all 3 bugs are unresolved
- unresolved, err := store.GetUnresolvedBugs()
- if err != nil {
- t.Fatalf("GetUnresolvedBugs failed: %v", err)
- }
- if len(unresolved) != 3 {
- t.Errorf("Expected 3 unresolved bugs, got %d", len(unresolved))
- }
-
- // Resolve bug 2
- if err := store.ResolveBug(2); err != nil {
- t.Fatalf("ResolveBug failed: %v", err)
- }
-
- // Verify only 2 unresolved bugs remain
- unresolved, err = store.GetUnresolvedBugs()
- if err != nil {
- t.Fatalf("GetUnresolvedBugs after resolve failed: %v", err)
- }
- if len(unresolved) != 2 {
- t.Errorf("Expected 2 unresolved bugs, got %d", len(unresolved))
- }
-
- // Verify bug 2 is not in the unresolved list
- for _, bug := range unresolved {
- if bug.ID == 2 {
- t.Error("Bug 2 should have been resolved but is still in unresolved list")
- }
- }
-
- // Verify all bugs still exist in GetBugs (including resolved)
- allBugs, err := store.GetBugs()
- if err != nil {
- t.Fatalf("GetBugs failed: %v", err)
- }
- if len(allBugs) != 3 {
- t.Errorf("Expected 3 total bugs, got %d", len(allBugs))
- }
-
- // Verify bug 2 has resolved_at set
- for _, bug := range allBugs {
- if bug.ID == 2 {
- if bug.ResolvedAt == nil {
- t.Error("Bug 2 should have resolved_at set")
- }
- }
- }
-
- // Unresolve bug 2
- if err := store.UnresolveBug(2); err != nil {
- t.Fatalf("UnresolveBug failed: %v", err)
- }
-
- // Verify all 3 bugs are unresolved again
- unresolved, err = store.GetUnresolvedBugs()
- if err != nil {
- t.Fatalf("GetUnresolvedBugs after unresolve failed: %v", err)
- }
- if len(unresolved) != 3 {
- t.Errorf("Expected 3 unresolved bugs after unresolve, got %d", len(unresolved))
- }
-}
-
-func TestResolveBug_NonExistent(t *testing.T) {
- store := setupTestStoreWithBugs(t)
- defer func() { _ = store.Close() }()
-
- // Resolving a non-existent bug should not error (no rows affected is fine)
- err := store.ResolveBug(999)
- if err != nil {
- t.Errorf("ResolveBug on non-existent bug should not error, got: %v", err)
- }
-}
-
// =============================================================================
// User Shopping Items Tests
// =============================================================================
diff --git a/migrations/018_drop_bugs.sql b/migrations/018_drop_bugs.sql
new file mode 100644
index 0000000..2dcc47a
--- /dev/null
+++ b/migrations/018_drop_bugs.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS bugs;
diff --git a/scripts/deploy b/scripts/deploy
index e6746d8..3a98290 100755
--- a/scripts/deploy
+++ b/scripts/deploy
@@ -31,8 +31,8 @@ mv app ${SITE_DIR}/app
echo "==> Setting permissions..."
chown -R www-data:www-data ${SITE_DIR}
-find ${SITE_DIR} -type d -exec chmod 755 {} \;
-find ${SITE_DIR} -type f -exec chmod 644 {} \;
+find ${SITE_DIR} -type d -exec chmod 755 {} +
+find ${SITE_DIR} -type f -exec chmod 644 {} +
chmod +x ${SITE_DIR}/app
chmod 600 ${SITE_DIR}/.env 2>/dev/null || true
diff --git a/web/templates/index.html b/web/templates/index.html
index 2a07a32..87be542 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -135,10 +135,6 @@
class="px-3 py-1 rounded-lg text-sm font-medium text-white/60 hover:bg-white/10">
🛒 Shopping
</button>
- <button onclick="switchActionTab('bug')" id="tab-bug"
- class="px-3 py-1 rounded-lg text-sm font-medium text-white/60 hover:bg-white/10">
- 🐛 Bug
- </button>
</div>
<button onclick="closeActionModal()" class="text-white/40 hover:text-white">✕</button>
</div>
@@ -195,31 +191,6 @@
</form>
</div>
- <!-- Bug Report Tab -->
- <div id="panel-bug" class="p-4 hidden">
- <form hx-post="/bugs"
- hx-target="#bug-list"
- hx-swap="innerHTML"
- hx-on::after-request="if(event.detail.successful) { this.reset(); closeActionModal(); }">
- <textarea name="description"
- placeholder="Describe the bug..."
- class="w-full bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 mb-3 h-24"
- required></textarea>
- <button type="submit"
- class="w-full bg-red-900/50 hover:bg-red-900/70 text-red-300 px-4 py-2 rounded-lg text-sm font-medium">
- Submit Bug Report
- </button>
- </form>
- <div class="mt-4 border-t border-white/10 pt-4">
- <h3 class="text-sm font-medium text-white/70 mb-2">Recent Reports</h3>
- <div id="bug-list"
- class="max-h-32 overflow-y-auto"
- hx-get="/bugs"
- hx-trigger="load">
- <p class="text-white/40 text-sm">Loading...</p>
- </div>
- </div>
- </div>
</div>
</div>
@@ -242,7 +213,6 @@
function switchActionTab(tab) {
document.getElementById('panel-add').classList.toggle('hidden', tab !== 'add');
document.getElementById('panel-shopping').classList.toggle('hidden', tab !== 'shopping');
- document.getElementById('panel-bug').classList.toggle('hidden', tab !== 'bug');
// Task tab styling
document.getElementById('tab-add').classList.toggle('bg-white/20', tab === 'add');
document.getElementById('tab-add').classList.toggle('text-white', tab === 'add');
@@ -251,10 +221,6 @@
document.getElementById('tab-shopping').classList.toggle('bg-green-900/50', tab === 'shopping');
document.getElementById('tab-shopping').classList.toggle('text-green-300', tab === 'shopping');
document.getElementById('tab-shopping').classList.toggle('text-white/60', tab !== 'shopping');
- // Bug tab styling
- document.getElementById('tab-bug').classList.toggle('bg-red-900/50', tab === 'bug');
- document.getElementById('tab-bug').classList.toggle('text-red-300', tab === 'bug');
- document.getElementById('tab-bug').classList.toggle('text-white/60', tab !== 'bug');
// Load shopping lists when switching to shopping tab
if (tab === 'shopping') {
loadShoppingLists();
diff --git a/web/templates/partials/bugs.html b/web/templates/partials/bugs.html
deleted file mode 100644
index cb5ba25..0000000
--- a/web/templates/partials/bugs.html
+++ /dev/null
@@ -1,12 +0,0 @@
-{{define "bugs"}}
-{{if .}}
-{{range .}}
-<div class="text-sm border-b border-gray-100 py-2">
- <p class="text-gray-900">{{.Description}}</p>
- <p class="text-gray-400 text-xs">{{.CreatedAt.Format "Jan 2, 3:04 PM"}}</p>
-</div>
-{{end}}
-{{else}}
-<p class="text-gray-500 text-sm">No bugs reported yet.</p>
-{{end}}
-{{end}}