summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/dashboard/main.go4
-rw-r--r--internal/handlers/handlers.go150
-rw-r--r--internal/handlers/timeline.go27
-rw-r--r--internal/handlers/timeline_logic.go36
-rw-r--r--internal/models/timeline.go30
-rw-r--r--internal/models/types.go7
-rw-r--r--internal/store/sqlite.go84
-rw-r--r--internal/store/sqlite_test.go120
-rw-r--r--migrations/008_user_shopping_items.sql11
-rw-r--r--web/static/js/app.js58
-rw-r--r--web/templates/index.html116
-rw-r--r--web/templates/partials/shopping-tab.html32
-rw-r--r--web/templates/partials/tasks-tab.html4
-rw-r--r--web/templates/partials/timeline-tab.html162
14 files changed, 717 insertions, 124 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 620b889..2d2f63b 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -170,6 +170,10 @@ func main() {
// 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)
})
// Start server
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 5c86ce2..ee28a87 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -62,7 +62,7 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
// Extract tab query parameter for state persistence
tab := r.URL.Query().Get("tab")
if tab == "" {
- tab = "tasks"
+ tab = "timeline"
}
// Aggregate data from all sources
@@ -626,6 +626,18 @@ 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)
+ }
default:
JSONError(w, http.StatusBadRequest, "Unknown source: "+source, nil)
return
@@ -684,7 +696,7 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl
}
}
-// getAtomTitle retrieves the title for a task/card from the store
+// getAtomTitle retrieves the title for a task/card/bug from the store
func (h *Handler) getAtomTitle(id, source string) string {
switch source {
case "todoist":
@@ -705,6 +717,16 @@ func (h *Handler) getAtomTitle(id, source string) string {
}
}
}
+ case "bug":
+ if bugs, err := h.store.GetBugs(); err == nil {
+ var bugID int64
+ fmt.Sscanf(id, "bug-%d", &bugID)
+ for _, b := range bugs {
+ if b.ID == bugID {
+ return b.Description
+ }
+ }
+ }
}
return "Task"
}
@@ -943,7 +965,7 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
-// HandleTabTasks renders the unified Tasks tab (Todoist + Trello cards with due dates)
+// HandleTabTasks renders the unified Tasks tab (Todoist + Trello cards with due dates + Bugs)
func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := h.store.GetTasks()
if err != nil {
@@ -957,6 +979,12 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) {
return
}
+ bugs, err := h.store.GetUnresolvedBugs()
+ if err != nil {
+ log.Printf("Warning: failed to fetch bugs: %v", err)
+ bugs = nil
+ }
+
atoms := make([]models.Atom, 0)
for _, task := range tasks {
@@ -973,6 +1001,15 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) {
}
}
+ // Add unresolved bugs as atoms
+ for _, bug := range bugs {
+ atoms = append(atoms, models.BugToAtom(models.Bug{
+ ID: bug.ID,
+ Description: bug.Description,
+ CreatedAt: bug.CreatedAt,
+ }))
+ }
+
for i := range atoms {
atoms[i].ComputeUIFields()
}
@@ -1171,14 +1208,89 @@ func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) {
HTMLResponse(w, h.templates, "meals-tab", struct{ Meals []models.Meal }{meals})
}
-// HandleTabShopping renders the Shopping tab (Trello Shopping board + PlanToEat)
+// HandleTabShopping renders the Shopping tab (Trello Shopping board + PlanToEat + User items)
func (h *Handler) HandleTabShopping(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stores := h.aggregateShoppingLists(ctx)
HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{stores})
}
-// aggregateShoppingLists combines Trello and PlanToEat shopping items by store
+// HandleShoppingQuickAdd adds a user shopping item
+func (h *Handler) HandleShoppingQuickAdd(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
+ return
+ }
+
+ name := strings.TrimSpace(r.FormValue("name"))
+ store := strings.TrimSpace(r.FormValue("store"))
+
+ if name == "" {
+ JSONError(w, http.StatusBadRequest, "Name is required", nil)
+ return
+ }
+ if store == "" {
+ store = "Quick Add"
+ }
+
+ if err := h.store.SaveUserShoppingItem(name, store); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to save item", err)
+ return
+ }
+
+ // Return refreshed shopping tab
+ ctx := r.Context()
+ stores := h.aggregateShoppingLists(ctx)
+ HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{stores})
+}
+
+// HandleShoppingToggle toggles a shopping item's checked state
+func (h *Handler) HandleShoppingToggle(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
+ return
+ }
+
+ id := r.FormValue("id")
+ source := r.FormValue("source")
+ checked := r.FormValue("checked") == "true"
+
+ if source == "user" {
+ var userID int64
+ if _, err := fmt.Sscanf(id, "user-%d", &userID); err != nil {
+ JSONError(w, http.StatusBadRequest, "Invalid user item ID", err)
+ return
+ }
+ if err := h.store.ToggleUserShoppingItem(userID, checked); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err)
+ return
+ }
+ }
+ // Note: Trello and PlanToEat toggle would need their own API calls
+
+ // Return updated item HTML
+ checkedClass := ""
+ checkedAttr := ""
+ textClass := "text-white"
+ if checked {
+ checkedClass = "opacity-50"
+ checkedAttr = "checked"
+ textClass = "line-through text-white/40"
+ }
+ html := fmt.Sprintf(`<li class="flex items-center gap-3 p-3 bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5 %s">
+ <input type="checkbox" %s
+ hx-post="/shopping/toggle"
+ hx-vals='{"id":"%s","source":"%s","checked":%t}'
+ hx-target="closest li"
+ hx-swap="outerHTML"
+ class="h-5 w-5 rounded bg-black/40 border-white/30 text-green-500 focus:ring-white/30 cursor-pointer flex-shrink-0">
+ <span class="flex-1 %s">%s</span>
+ <span class="text-xs px-2 py-0.5 rounded bg-purple-900/50 text-purple-300">user</span>
+ </li>`, checkedClass, checkedAttr, template.HTMLEscapeString(id), template.HTMLEscapeString(source), !checked, textClass, template.HTMLEscapeString(r.FormValue("name")))
+ HTMLString(w, html)
+}
+
+// aggregateShoppingLists combines Trello, PlanToEat, and user shopping items by store
func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingStore {
storeMap := make(map[string]map[string][]models.UnifiedShoppingItem)
@@ -1245,7 +1357,33 @@ func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingS
log.Printf("DEBUG [Shopping/PlanToEat]: Client not configured")
}
- // 3. Convert map to sorted slice
+ // 3. Fetch user-added shopping items
+ userItems, err := h.store.GetUserShoppingItems()
+ if err != nil {
+ log.Printf("ERROR [Shopping/User]: %v", err)
+ } else {
+ for _, item := range userItems {
+ storeName := item.Store
+ if storeName == "" {
+ storeName = "Quick Add"
+ }
+
+ if storeMap[storeName] == nil {
+ storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem)
+ }
+
+ unified := models.UnifiedShoppingItem{
+ ID: fmt.Sprintf("user-%d", item.ID),
+ Name: item.Name,
+ Store: storeName,
+ Source: "user",
+ Checked: item.Checked,
+ }
+ storeMap[storeName][""] = append(storeMap[storeName][""], unified)
+ }
+ }
+
+ // 4. Convert map to sorted slice
var stores []models.ShoppingStore
for storeName, categories := range storeMap {
store := models.ShoppingStore{Name: storeName}
diff --git a/internal/handlers/timeline.go b/internal/handlers/timeline.go
index b923d3e..ce0e831 100644
--- a/internal/handlers/timeline.go
+++ b/internal/handlers/timeline.go
@@ -8,6 +8,15 @@ import (
"task-dashboard/internal/models"
)
+// TimelineData holds grouped timeline items for the template
+type TimelineData struct {
+ TodayItems []models.TimelineItem
+ TomorrowItems []models.TimelineItem
+ LaterItems []models.TimelineItem
+ Start time.Time
+ Days int
+}
+
// HandleTimeline renders the timeline view
func (h *Handler) HandleTimeline(w http.ResponseWriter, r *http.Request) {
// Parse query params
@@ -45,15 +54,21 @@ func (h *Handler) HandleTimeline(w http.ResponseWriter, r *http.Request) {
return
}
- data := struct {
- Items []models.TimelineItem
- Start time.Time
- Days int
- }{
- Items: items,
+ // Group items by day section
+ data := TimelineData{
Start: start,
Days: days,
}
+ for _, item := range items {
+ switch item.DaySection {
+ case models.DaySectionToday:
+ data.TodayItems = append(data.TodayItems, item)
+ case models.DaySectionTomorrow:
+ data.TomorrowItems = append(data.TomorrowItems, item)
+ case models.DaySectionLater:
+ data.LaterItems = append(data.LaterItems, item)
+ }
+ }
HTMLResponse(w, h.templates, "timeline-tab", data)
}
diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go
index 1aba780..c51262a 100644
--- a/internal/handlers/timeline_logic.go
+++ b/internal/handlers/timeline_logic.go
@@ -13,6 +13,7 @@ import (
// BuildTimeline aggregates and normalizes data into a timeline structure
func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.GoogleCalendarAPI, start, end time.Time) ([]models.TimelineItem, error) {
var items []models.TimelineItem
+ now := time.Now()
// 1. Fetch Tasks
tasks, err := s.GetTasksByDateRange(start, end)
@@ -23,7 +24,7 @@ func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.Googl
if task.DueDate == nil {
continue
}
- items = append(items, models.TimelineItem{
+ item := models.TimelineItem{
ID: task.ID,
Type: models.TimelineItemTypeTask,
Title: task.Content,
@@ -31,7 +32,11 @@ func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.Googl
Description: task.Description,
URL: task.URL,
OriginalItem: task,
- })
+ IsCompleted: task.Completed,
+ Source: "todoist",
+ }
+ item.ComputeDaySection(now)
+ items = append(items, item)
}
// 2. Fetch Meals
@@ -53,14 +58,18 @@ func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.Googl
mealTime = time.Date(mealTime.Year(), mealTime.Month(), mealTime.Day(), 12, 0, 0, 0, mealTime.Location())
}
- items = append(items, models.TimelineItem{
+ item := models.TimelineItem{
ID: meal.ID,
Type: models.TimelineItemTypeMeal,
Title: meal.RecipeName,
Time: mealTime,
URL: meal.RecipeURL,
OriginalItem: meal,
- })
+ IsCompleted: false, // Meals don't have completion status
+ Source: "plantoeat",
+ }
+ item.ComputeDaySection(now)
+ items = append(items, item)
}
// 3. Fetch Cards
@@ -72,14 +81,18 @@ func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.Googl
if card.DueDate == nil {
continue
}
- items = append(items, models.TimelineItem{
+ item := models.TimelineItem{
ID: card.ID,
Type: models.TimelineItemTypeCard,
Title: card.Name,
Time: *card.DueDate,
URL: card.URL,
OriginalItem: card,
- })
+ IsCompleted: false, // Cards in timeline are not completed (closed cards filtered out)
+ Source: "trello",
+ }
+ item.ComputeDaySection(now)
+ items = append(items, item)
}
// 4. Fetch Events
@@ -87,16 +100,21 @@ func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.Googl
events, err := calendarClient.GetEventsByDateRange(ctx, start, end)
if err == nil {
for _, event := range events {
- items = append(items, models.TimelineItem{
+ endTime := event.End
+ item := models.TimelineItem{
ID: event.ID,
Type: models.TimelineItemTypeEvent,
Title: event.Summary,
Time: event.Start,
- EndTime: &event.End,
+ EndTime: &endTime,
Description: event.Description,
URL: event.HTMLLink,
OriginalItem: event,
- })
+ IsCompleted: false, // Events don't have completion status
+ Source: "calendar",
+ }
+ item.ComputeDaySection(now)
+ items = append(items, item)
}
}
}
diff --git a/internal/models/timeline.go b/internal/models/timeline.go
index 4a619fa..469cce4 100644
--- a/internal/models/timeline.go
+++ b/internal/models/timeline.go
@@ -11,6 +11,14 @@ const (
TimelineItemTypeEvent TimelineItemType = "event"
)
+type DaySection string
+
+const (
+ DaySectionToday DaySection = "today"
+ DaySectionTomorrow DaySection = "tomorrow"
+ DaySectionLater DaySection = "later"
+)
+
type TimelineItem struct {
ID string `json:"id"`
Type TimelineItemType `json:"type"`
@@ -20,4 +28,26 @@ type TimelineItem struct {
Description string `json:"description,omitempty"`
URL string `json:"url,omitempty"`
OriginalItem interface{} `json:"-"`
+
+ // UI enhancement fields
+ IsCompleted bool `json:"is_completed"`
+ DaySection DaySection `json:"day_section"`
+ Source string `json:"source"` // "todoist", "trello", "plantoeat", "calendar"
+}
+
+// ComputeDaySection sets the DaySection based on the item's time
+func (item *TimelineItem) ComputeDaySection(now time.Time) {
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ tomorrow := today.AddDate(0, 0, 1)
+ dayAfterTomorrow := today.AddDate(0, 0, 2)
+
+ itemDay := time.Date(item.Time.Year(), item.Time.Month(), item.Time.Day(), 0, 0, 0, 0, item.Time.Location())
+
+ if itemDay.Before(tomorrow) {
+ item.DaySection = DaySectionToday
+ } else if itemDay.Before(dayAfterTomorrow) {
+ item.DaySection = DaySectionTomorrow
+ } else {
+ item.DaySection = DaySectionLater
+ }
}
diff --git a/internal/models/types.go b/internal/models/types.go
index 0284a3a..6dc8716 100644
--- a/internal/models/types.go
+++ b/internal/models/types.go
@@ -103,9 +103,10 @@ type CalendarEvent struct {
// Bug represents a bug report
type Bug struct {
- ID int64 `json:"id"`
- Description string `json:"description"`
- CreatedAt time.Time `json:"created_at"`
+ 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
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index 5b67234..5b95607 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -569,6 +569,7 @@ type Bug struct {
ID int64
Description string
CreatedAt time.Time
+ ResolvedAt *time.Time
}
// SaveBug saves a new bug report
@@ -579,7 +580,30 @@ func (s *Store) SaveBug(description string) error {
// GetBugs retrieves all bugs, newest first
func (s *Store) GetBugs() ([]Bug, error) {
- rows, err := s.db.Query(`SELECT id, description, created_at FROM bugs ORDER BY created_at DESC`)
+ 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 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
}
@@ -596,6 +620,64 @@ func (s *Store) GetBugs() ([]Bug, error) {
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
+ Name string
+ Store string
+ Checked bool
+ CreatedAt time.Time
+}
+
+// SaveUserShoppingItem saves a new user shopping item
+func (s *Store) SaveUserShoppingItem(name, store string) error {
+ _, err := s.db.Exec(`INSERT INTO user_shopping_items (name, store) VALUES (?, ?)`, name, store)
+ return err
+}
+
+// GetUserShoppingItems retrieves all user shopping items
+func (s *Store) GetUserShoppingItems() ([]UserShoppingItem, error) {
+ rows, err := s.db.Query(`SELECT id, name, store, checked, created_at FROM user_shopping_items ORDER BY store, created_at DESC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var items []UserShoppingItem
+ for rows.Next() {
+ var item UserShoppingItem
+ if err := rows.Scan(&item.ID, &item.Name, &item.Store, &item.Checked, &item.CreatedAt); err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+ return items, rows.Err()
+}
+
+// ToggleUserShoppingItem toggles the checked state of a user shopping item
+func (s *Store) ToggleUserShoppingItem(id int64, checked bool) error {
+ _, err := s.db.Exec(`UPDATE user_shopping_items SET checked = ? WHERE id = ?`, checked, id)
+ return err
+}
+
+// DeleteUserShoppingItem removes a user shopping item
+func (s *Store) DeleteUserShoppingItem(id int64) error {
+ _, err := s.db.Exec(`DELETE FROM user_shopping_items WHERE id = ?`, id)
+ return err
+}
+
// GetTasksByDateRange retrieves tasks due within a specific date range
func (s *Store) GetTasksByDateRange(start, end time.Time) ([]models.Task, error) {
rows, err := s.db.Query(`
diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go
index 5719e24..fc8a3b7 100644
--- a/internal/store/sqlite_test.go
+++ b/internal/store/sqlite_test.go
@@ -569,3 +569,123 @@ func TestGetCardsByDateRange(t *testing.T) {
t.Errorf("Expected card1, got %s", results[0].ID)
}
}
+
+// 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 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 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)
+ }
+}
diff --git a/migrations/008_user_shopping_items.sql b/migrations/008_user_shopping_items.sql
new file mode 100644
index 0000000..686ccb6
--- /dev/null
+++ b/migrations/008_user_shopping_items.sql
@@ -0,0 +1,11 @@
+-- User-added shopping items (quick-add feature)
+CREATE TABLE IF NOT EXISTS user_shopping_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ store TEXT NOT NULL,
+ checked BOOLEAN DEFAULT FALSE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_shopping_store ON user_shopping_items(store);
+CREATE INDEX IF NOT EXISTS idx_user_shopping_checked ON user_shopping_items(checked);
diff --git a/web/static/js/app.js b/web/static/js/app.js
index 52d0c91..6646400 100644
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -207,3 +207,61 @@ function toggleTask(taskId) {
console.log('Toggle task:', taskId);
// To be implemented in Phase 2
}
+
+// Mobile Swipe Navigation
+(function() {
+ let touchStartX = 0;
+ let touchEndX = 0;
+ const SWIPE_THRESHOLD = 50; // Minimum px for a swipe
+
+ // Get ordered list of tab names (main tabs only for swipe)
+ const TAB_ORDER = ['timeline', 'shopping', 'tasks', 'planning', 'meals'];
+
+ function handleSwipe() {
+ const swipeDistance = touchEndX - touchStartX;
+
+ if (Math.abs(swipeDistance) < SWIPE_THRESHOLD) {
+ return; // Not a significant swipe
+ }
+
+ const currentIndex = TAB_ORDER.indexOf(currentTab);
+ if (currentIndex === -1) return;
+
+ let newIndex;
+ if (swipeDistance > 0) {
+ // Swiped right -> previous tab
+ newIndex = currentIndex - 1;
+ } else {
+ // Swiped left -> next tab
+ newIndex = currentIndex + 1;
+ }
+
+ // Bounds check
+ if (newIndex < 0 || newIndex >= TAB_ORDER.length) {
+ return;
+ }
+
+ const newTab = TAB_ORDER[newIndex];
+ const tabButton = document.querySelector(`[hx-get="/tabs/${newTab}"]`);
+
+ if (tabButton) {
+ console.log(`Swipe navigation: ${currentTab} -> ${newTab}`);
+ tabButton.click();
+ }
+ }
+
+ // Set up touch event listeners on the tab content area
+ document.addEventListener('DOMContentLoaded', function() {
+ const tabContent = document.getElementById('tab-content');
+ if (!tabContent) return;
+
+ tabContent.addEventListener('touchstart', function(e) {
+ touchStartX = e.changedTouches[0].screenX;
+ }, { passive: true });
+
+ tabContent.addEventListener('touchend', function(e) {
+ touchEndX = e.changedTouches[0].screenX;
+ handleSwipe();
+ }, { passive: true });
+ });
+})();
diff --git a/web/templates/index.html b/web/templates/index.html
index b55f348..f4c0b59 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -7,8 +7,23 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/output.css">
<style>
+ :root {
+ --panel-bg: rgba(0, 0, 0, 0.4);
+ --card-bg: rgba(255, 255, 255, 0.05);
+ --card-bg-hover: rgba(255, 255, 255, 0.1);
+ --input-bg: rgba(0, 0, 0, 0.4);
+ --modal-bg: rgba(0, 0, 0, 0.8);
+ --modal-overlay: rgba(0, 0, 0, 0.7);
+ }
.text-shadow { text-shadow: 0 0 8px black, 0 0 8px black; }
.text-shadow-sm { text-shadow: 0 0 4px black; }
+ /* CSS variable-based backgrounds */
+ .bg-panel { background-color: var(--panel-bg); }
+ .bg-card { background-color: var(--card-bg); }
+ .bg-card-hover:hover { background-color: var(--card-bg-hover); }
+ .bg-input { background-color: var(--input-bg); }
+ .bg-modal { background-color: var(--modal-bg); }
+ .bg-modal-overlay { background-color: var(--modal-overlay); }
</style>
</head>
<body class="min-h-screen bg-gray-900 text-white" style="background-image: url('{{.BackgroundURL}}'); background-size: cover; background-position: center; background-attachment: fixed;" hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>
@@ -31,31 +46,8 @@
<!-- Tab Navigation -->
<div class="mb-4 sm:mb-6 no-print">
- <nav class="flex space-x-1 bg-black/50 backdrop-blur-sm rounded-xl p-1 text-shadow-sm">
- <button
- class="tab-button {{if eq .ActiveTab "tasks"}}tab-button-active{{end}}"
- hx-get="/tabs/tasks"
- hx-target="#tab-content"
- hx-push-url="?tab=tasks"
- onclick="setActiveTab(this)">
- ✓ Tasks
- </button>
- <button
- class="tab-button {{if eq .ActiveTab "planning"}}tab-button-active{{end}}"
- hx-get="/tabs/planning"
- hx-target="#tab-content"
- hx-push-url="?tab=planning"
- onclick="setActiveTab(this)">
- 📋 Planning
- </button>
- <button
- class="tab-button {{if eq .ActiveTab "meals"}}tab-button-active{{end}}"
- hx-get="/tabs/meals"
- hx-target="#tab-content"
- hx-push-url="?tab=meals"
- onclick="setActiveTab(this)">
- 🍽️ Meals
- </button>
+ <nav class="flex space-x-1 bg-panel backdrop-blur-sm rounded-xl p-1 text-shadow-sm">
+ <!-- Main Tabs -->
<button
class="tab-button {{if eq .ActiveTab "timeline"}}tab-button-active{{end}}"
hx-get="/tabs/timeline"
@@ -77,6 +69,44 @@
target="_blank">
🌋 Conditions
</a>
+
+ <!-- Details Dropdown -->
+ <div class="relative" id="details-dropdown">
+ <button
+ class="tab-button {{if or (eq .ActiveTab "tasks") (eq .ActiveTab "planning") (eq .ActiveTab "meals")}}tab-button-active{{end}}"
+ onclick="toggleDetailsDropdown(event)">
+ 📁 Details
+ <svg class="w-3 h-3 ml-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
+ </svg>
+ </button>
+ <div id="details-menu" class="hidden absolute top-full left-0 mt-1 bg-panel backdrop-blur-sm rounded-lg p-1 min-w-[140px] z-20">
+ <button
+ class="w-full text-left tab-button {{if eq .ActiveTab "tasks"}}tab-button-active{{end}}"
+ hx-get="/tabs/tasks"
+ hx-target="#tab-content"
+ hx-push-url="?tab=tasks"
+ onclick="setActiveTab(this); closeDetailsDropdown();">
+ ✓ Tasks
+ </button>
+ <button
+ class="w-full text-left tab-button {{if eq .ActiveTab "planning"}}tab-button-active{{end}}"
+ hx-get="/tabs/planning"
+ hx-target="#tab-content"
+ hx-push-url="?tab=planning"
+ onclick="setActiveTab(this); closeDetailsDropdown();">
+ 📋 Planning
+ </button>
+ <button
+ class="w-full text-left tab-button {{if eq .ActiveTab "meals"}}tab-button-active{{end}}"
+ hx-get="/tabs/meals"
+ hx-target="#tab-content"
+ hx-push-url="?tab=meals"
+ onclick="setActiveTab(this); closeDetailsDropdown();">
+ 🍽️ Meals
+ </button>
+ </div>
+ </div>
</nav>
</div>
@@ -102,8 +132,8 @@
</button>
<!-- Unified Action Modal -->
- <div id="action-modal" class="hidden fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50">
- <div class="bg-black/80 backdrop-blur-sm rounded-lg max-w-md w-full max-h-[80vh] overflow-hidden" style="box-shadow: 0 0 20px black;">
+ <div id="action-modal" class="hidden fixed inset-0 bg-modal-overlay flex items-center justify-center p-4 z-50">
+ <div class="bg-modal backdrop-blur-sm rounded-lg max-w-md w-full max-h-[80vh] overflow-hidden" style="box-shadow: 0 0 20px black;">
<div class="p-4 border-b border-white/10 flex justify-between items-center">
<div class="flex gap-2">
<button onclick="switchActionTab('add')" id="tab-add"
@@ -130,15 +160,15 @@
<input type="text"
name="title"
placeholder="Task name..."
- class="w-full bg-black/40 border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 mb-3"
+ class="w-full bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 mb-3"
required
autofocus>
<div class="flex gap-2 mb-3">
<input type="date"
name="due_date"
id="modal-add-date"
- class="flex-1 bg-black/40 border border-white/20 rounded-lg px-3 py-2 text-sm text-white">
- <select name="source" class="bg-black/40 border border-white/20 rounded-lg px-3 py-2 text-sm text-white">
+ class="flex-1 bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white">
+ <select name="source" class="bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white">
<option value="todoist">Todoist</option>
<option value="trello">Trello</option>
</select>
@@ -159,11 +189,11 @@
<input type="text"
name="title"
placeholder="Item name..."
- class="w-full bg-black/40 border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 mb-3"
+ class="w-full bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 mb-3"
required>
<select name="list_id"
id="shopping-list-select"
- class="w-full bg-black/40 border border-white/20 rounded-lg px-3 py-2 text-sm text-white mb-3"
+ class="w-full bg-input border border-white/20 rounded-lg px-3 py-2 text-sm text-white mb-3"
required>
<option value="">Loading stores...</option>
</select>
@@ -182,7 +212,7 @@
hx-on::after-request="if(event.detail.successful) { this.reset(); closeActionModal(); }">
<textarea name="description"
placeholder="Describe the bug..."
- class="w-full bg-black/40 border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 mb-3 h-24"
+ 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">
@@ -272,11 +302,27 @@
function closeTaskModal() {
document.getElementById('task-edit-modal').classList.add('hidden');
}
+ // Details dropdown functions
+ function toggleDetailsDropdown(e) {
+ e.stopPropagation();
+ var menu = document.getElementById('details-menu');
+ menu.classList.toggle('hidden');
+ }
+ function closeDetailsDropdown() {
+ document.getElementById('details-menu').classList.add('hidden');
+ }
+ // Close dropdown when clicking outside
+ document.addEventListener('click', function(e) {
+ var dropdown = document.getElementById('details-dropdown');
+ if (dropdown && !dropdown.contains(e.target)) {
+ closeDetailsDropdown();
+ }
+ });
</script>
<!-- Task Edit Modal -->
- <div id="task-edit-modal" class="hidden fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50">
- <div class="bg-black/80 backdrop-blur-sm rounded-lg max-w-md w-full max-h-[80vh] overflow-hidden" style="box-shadow: 0 0 20px black;">
+ <div id="task-edit-modal" class="hidden fixed inset-0 bg-modal-overlay flex items-center justify-center p-4 z-50">
+ <div class="bg-modal backdrop-blur-sm rounded-lg max-w-md w-full max-h-[80vh] overflow-hidden" style="box-shadow: 0 0 20px black;">
<div class="p-4 border-b border-white/10 flex justify-between items-center">
<h2 class="font-medium text-white">Edit Task</h2>
<button onclick="closeTaskModal()" class="text-white/40 hover:text-white">✕</button>
diff --git a/web/templates/partials/shopping-tab.html b/web/templates/partials/shopping-tab.html
index 19c570f..1f589e5 100644
--- a/web/templates/partials/shopping-tab.html
+++ b/web/templates/partials/shopping-tab.html
@@ -4,25 +4,47 @@
hx-trigger="refresh-tasks from:body"
hx-target="#tab-content"
hx-swap="innerHTML">
+
+ <!-- Quick Add Form -->
+ <form hx-post="/shopping/add"
+ hx-target="#tab-content"
+ hx-swap="innerHTML"
+ class="bg-panel backdrop-blur-sm rounded-xl p-4 sm:p-5">
+ <div class="flex gap-2">
+ <input type="text" name="name" placeholder="Add item..."
+ class="flex-1 bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-white/30 text-sm"
+ required>
+ <select name="store" class="bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-white/30">
+ <option value="Quick Add">Quick Add</option>
+ {{range .Stores}}
+ <option value="{{.Name}}">{{.Name}}</option>
+ {{end}}
+ </select>
+ <button type="submit" class="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">
+ Add
+ </button>
+ </div>
+ </form>
+
{{if .Stores}}
{{range .Stores}}
- <section class="bg-black/40 backdrop-blur-sm rounded-xl p-4 sm:p-5">
+ <section class="bg-panel backdrop-blur-sm rounded-xl p-4 sm:p-5">
<h2 class="text-xl font-medium mb-4 text-white">{{.Name}}</h2>
{{range .Categories}}
<div class="mb-4 last:mb-0">
{{if .Name}}<h3 class="text-sm text-white/60 mb-2 uppercase tracking-wide">{{.Name}}</h3>{{end}}
<ul class="space-y-2">
{{range .Items}}
- <li class="flex items-center gap-3 p-3 bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5 {{if .Checked}}opacity-50{{end}}">
+ <li class="flex items-center gap-3 p-3 bg-card bg-card-hover transition-colors rounded-lg border border-white/5 {{if .Checked}}opacity-50{{end}}">
<input type="checkbox" {{if .Checked}}checked{{end}}
hx-post="/shopping/toggle"
- hx-vals='{"id":"{{.ID}}","source":"{{.Source}}","checked":{{if .Checked}}false{{else}}true{{end}}}'
+ hx-vals='{"id":"{{.ID}}","source":"{{.Source}}","checked":{{if .Checked}}false{{else}}true{{end}},"name":"{{.Name}}"}'
hx-target="closest li"
hx-swap="outerHTML"
class="h-5 w-5 rounded bg-black/40 border-white/30 text-green-500 focus:ring-white/30 cursor-pointer flex-shrink-0">
<span class="flex-1 {{if .Checked}}line-through text-white/40{{else}}text-white{{end}}">{{.Name}}</span>
{{if .Quantity}}<span class="text-white/50 text-sm">{{.Quantity}}</span>{{end}}
- <span class="text-xs px-2 py-0.5 rounded {{if eq .Source "trello"}}bg-blue-900/50 text-blue-300{{else}}bg-green-900/50 text-green-300{{end}}">
+ <span class="text-xs px-2 py-0.5 rounded {{if eq .Source "trello"}}bg-blue-900/50 text-blue-300{{else if eq .Source "user"}}bg-purple-900/50 text-purple-300{{else}}bg-green-900/50 text-green-300{{end}}">
{{.Source}}
</span>
</li>
@@ -35,7 +57,7 @@
{{else}}
<div class="text-center py-16 text-white/50">
<p class="text-lg mb-2">No shopping items</p>
- <p class="text-sm">Add items from PlanToEat or Trello's Shopping board</p>
+ <p class="text-sm">Use the form above to add items quickly</p>
</div>
{{end}}
</div>
diff --git a/web/templates/partials/tasks-tab.html b/web/templates/partials/tasks-tab.html
index db986bb..9e030e4 100644
--- a/web/templates/partials/tasks-tab.html
+++ b/web/templates/partials/tasks-tab.html
@@ -8,7 +8,7 @@
{{if .Atoms}}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{{range .Atoms}}
- <div class="bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5 {{if .IsFuture}}opacity-60{{end}}">
+ <div class="bg-card bg-card-hover transition-colors rounded-lg border border-white/5 {{if .IsFuture}}opacity-60{{end}}">
<div class="flex items-start gap-2 sm:gap-3 p-3 sm:p-4">
<!-- Checkbox for completing -->
<input type="checkbox"
@@ -70,7 +70,7 @@
</summary>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mt-2">
{{range .FutureAtoms}}
- <div class="bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5 opacity-60">
+ <div class="bg-card bg-card-hover transition-colors rounded-lg border border-white/5 opacity-60">
<div class="flex items-start gap-2 sm:gap-3 p-3 sm:p-4">
<input type="checkbox"
hx-post="/complete-atom"
diff --git a/web/templates/partials/timeline-tab.html b/web/templates/partials/timeline-tab.html
index e73a643..aeaba40 100644
--- a/web/templates/partials/timeline-tab.html
+++ b/web/templates/partials/timeline-tab.html
@@ -5,70 +5,118 @@
hx-target="#tab-content"
hx-swap="innerHTML">
- {{$currentDay := ""}}
- {{range .Items}}
- {{$day := .Time.Format "Monday, January 2"}}
- {{if ne $day $currentDay}}
- {{if ne $currentDay ""}}
- </div> <!-- Close previous day items -->
- </div> <!-- Close previous day container -->
+ <!-- Today Section -->
+ {{if .TodayItems}}
+ <div>
+ <h2 class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/90 sticky top-0 bg-black/20 backdrop-blur-md py-2 z-10 rounded-lg px-2">
+ <span>📅</span> Today
+ </h2>
+ <div class="space-y-2 relative pl-4 border-l border-white/10 ml-2">
+ {{range .TodayItems}}
+ {{template "timeline-item" .}}
{{end}}
- {{$currentDay = $day}}
- <div>
- <h2 class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/90 sticky top-0 bg-black/20 backdrop-blur-md py-2 z-10 rounded-lg px-2">
- <span>🗓️</span> {{$day}}
- </h2>
- <div class="space-y-2 relative pl-4 border-l border-white/10 ml-2">
+ </div>
+ </div>
+ {{end}}
+
+ <!-- Tomorrow Section (Collapsed) -->
+ {{if .TomorrowItems}}
+ <details class="group">
+ <summary class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/70 cursor-pointer hover:text-white/90 sticky top-0 bg-black/20 backdrop-blur-md py-2 z-10 rounded-lg px-2 list-none">
+ <span>🗓️</span> Tomorrow
+ <span class="text-sm font-normal text-white/50">({{len .TomorrowItems}} items)</span>
+ <svg class="w-4 h-4 ml-auto transform transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
+ </svg>
+ </summary>
+ <div class="space-y-2 relative pl-4 border-l border-white/10 ml-2">
+ {{range .TomorrowItems}}
+ {{template "timeline-item" .}}
+ {{end}}
+ </div>
+ </details>
+ {{end}}
+
+ <!-- Later Section (Collapsed) -->
+ {{if .LaterItems}}
+ <details class="group">
+ <summary class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/60 cursor-pointer hover:text-white/80 sticky top-0 bg-black/20 backdrop-blur-md py-2 z-10 rounded-lg px-2 list-none">
+ <span>📆</span> Later
+ <span class="text-sm font-normal text-white/40">({{len .LaterItems}} items)</span>
+ <svg class="w-4 h-4 ml-auto transform transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
+ </svg>
+ </summary>
+ <div class="space-y-2 relative pl-4 border-l border-white/10 ml-2">
+ {{range .LaterItems}}
+ {{template "timeline-item" .}}
+ {{end}}
+ </div>
+ </details>
+ {{end}}
+
+ {{if and (not .TodayItems) (not .TomorrowItems) (not .LaterItems)}}
+ <div class="text-center py-8 text-white/50">
+ <p>No items found for the selected range.</p>
+ </div>
+ {{end}}
+
+</div>
+{{end}}
+
+{{define "timeline-item"}}
+<div class="bg-card bg-card-hover transition-colors rounded-lg border border-white/5 relative {{if .IsCompleted}}opacity-60{{end}}">
+ <!-- Time Indicator -->
+ <div class="absolute -left-[21px] top-4 w-2.5 h-2.5 rounded-full
+ {{if eq .Type "event"}}bg-blue-500{{else if eq .Type "meal"}}bg-orange-500{{else if eq .Type "task"}}bg-green-500{{else}}bg-purple-500{{end}}">
+ </div>
+
+ <div class="flex items-start gap-3 p-3">
+ <!-- Checkbox for tasks/cards -->
+ {{if or (eq .Type "task") (eq .Type "card")}}
+ <input type="checkbox"
+ {{if .IsCompleted}}checked{{end}}
+ hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}"
+ hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"}'
+ hx-target="closest div.rounded-lg"
+ hx-swap="outerHTML"
+ class="mt-1 h-5 w-5 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0">
{{end}}
- <div class="bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5 relative">
- <!-- Time Indicator -->
- <div class="absolute -left-[21px] top-4 w-2.5 h-2.5 rounded-full
- {{if eq .Type "event"}}bg-blue-500{{else if eq .Type "meal"}}bg-orange-500{{else if eq .Type "task"}}bg-green-500{{else}}bg-purple-500{{end}}">
+ <div class="flex flex-col items-center min-w-[60px] text-xs text-white/50 pt-0.5">
+ <span class="font-medium text-white/80">{{.Time.Format "3:04 PM"}}</span>
+ {{if .EndTime}}
+ <span class="text-[10px] opacity-70">{{.EndTime.Format "3:04 PM"}}</span>
+ {{end}}
+ </div>
+
+ <div class="flex-1 min-w-0">
+ <div class="flex items-start justify-between gap-2">
+ <h3 class="text-sm text-white font-medium break-words {{if .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}</h3>
+ {{if .URL}}
+ <a href="{{.URL}}" target="_blank" class="text-white/50 hover:text-white flex-shrink-0">
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
+ </svg>
+ </a>
+ {{end}}
</div>
- <div class="flex items-start gap-3 p-3">
- <div class="flex flex-col items-center min-w-[60px] text-xs text-white/50 pt-0.5">
- <span class="font-medium text-white/80">{{.Time.Format "3:04 PM"}}</span>
- {{if .EndTime}}
- <span class="text-[10px] opacity-70">{{.EndTime.Format "3:04 PM"}}</span>
- {{end}}
- </div>
-
- <div class="flex-1 min-w-0">
- <div class="flex items-start justify-between gap-2">
- <h3 class="text-sm text-white font-medium break-words">{{.Title}}</h3>
- {{if .URL}}
- <a href="{{.URL}}" target="_blank" class="text-white/50 hover:text-white flex-shrink-0">
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
- </svg>
- </a>
- {{end}}
- </div>
-
- {{if .Description}}
- <p class="text-xs text-white/50 mt-1 line-clamp-2">{{.Description}}</p>
- {{end}}
+ {{if .Description}}
+ <p class="text-xs text-white/50 mt-1 line-clamp-2">{{.Description}}</p>
+ {{end}}
- <div class="flex items-center gap-2 mt-2">
- <span class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60 uppercase tracking-wider">
- {{.Type}}
- </span>
- </div>
- </div>
+ <div class="flex items-center gap-2 mt-2">
+ <span class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60 uppercase tracking-wider">
+ {{.Type}}
+ </span>
+ {{if .IsCompleted}}
+ <span class="text-[10px] px-1.5 py-0.5 rounded bg-green-500/20 text-green-400 uppercase tracking-wider">
+ done
+ </span>
+ {{end}}
</div>
</div>
- {{end}}
-
- {{if ne $currentDay ""}}
- </div> <!-- Close last day items -->
- </div> <!-- Close last day container -->
- {{else}}
- <div class="text-center py-8 text-white/50">
- <p>No items found for the selected range.</p>
- </div>
- {{end}}
-
+ </div>
</div>
{{end}}