summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-23 16:10:52 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-23 16:10:52 -1000
commit7828e19501b3ca8b2e86ca7297f580c659e5c9b8 (patch)
treee3113af67fcecc459d81b213d7f043630dccae98
parentd11334c0999efb670a8eab93527a50f644fdfceb (diff)
Fix bugs #24-27: calendar dedup, uncomplete tasks, planning view
Bug fixes: - #24: Deduplicate calendar events across multiple calendars using summary + start time as key - #25: Change event icon from calendar to clock to avoid confusion with date display - #26: Add task uncomplete functionality via ReopenTask API for Todoist and closed=false for Trello - #27: Restructure planning view with sections for Scheduled (timed events/tasks), Today (unscheduled), Quick Add, and Upcoming (3 days) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--cmd/dashboard/main.go1
-rw-r--r--internal/api/google_calendar.go22
-rw-r--r--internal/api/interfaces.go1
-rw-r--r--internal/api/todoist.go23
-rw-r--r--internal/handlers/handlers.go69
-rw-r--r--internal/handlers/handlers_test.go4
-rw-r--r--internal/handlers/tabs.go178
-rw-r--r--web/templates/partials/planning-tab.html166
8 files changed, 413 insertions, 51 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index ef94427..050c8d0 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -148,6 +148,7 @@ func main() {
// Unified task completion (for Tasks tab Atoms)
r.Post("/complete-atom", h.HandleCompleteAtom)
+ r.Post("/uncomplete-atom", h.HandleUncompleteAtom)
// Unified Quick Add (for Tasks tab)
r.Post("/unified-add", h.HandleUnifiedAdd)
diff --git a/internal/api/google_calendar.go b/internal/api/google_calendar.go
index 8dd48f0..2154351 100644
--- a/internal/api/google_calendar.go
+++ b/internal/api/google_calendar.go
@@ -76,15 +76,27 @@ func (c *GoogleCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults
}
}
+ // Deduplicate events (same event may appear in multiple calendars)
+ seen := make(map[string]bool)
+ var uniqueEvents []models.CalendarEvent
+ for _, event := range allEvents {
+ // Use summary + start time as dedup key
+ key := event.Summary + event.Start.Format(time.RFC3339)
+ if !seen[key] {
+ seen[key] = true
+ uniqueEvents = append(uniqueEvents, event)
+ }
+ }
+
// Sort all events by start time
- sort.Slice(allEvents, func(i, j int) bool {
- return allEvents[i].Start.Before(allEvents[j].Start)
+ sort.Slice(uniqueEvents, func(i, j int) bool {
+ return uniqueEvents[i].Start.Before(uniqueEvents[j].Start)
})
// Limit to maxResults
- if len(allEvents) > maxResults {
- allEvents = allEvents[:maxResults]
+ if len(uniqueEvents) > maxResults {
+ uniqueEvents = uniqueEvents[:maxResults]
}
- return allEvents, nil
+ return uniqueEvents, nil
}
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go
index e2521f4..842814c 100644
--- a/internal/api/interfaces.go
+++ b/internal/api/interfaces.go
@@ -14,6 +14,7 @@ type TodoistAPI interface {
CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error)
UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error
CompleteTask(ctx context.Context, taskID string) error
+ ReopenTask(ctx context.Context, taskID string) error
Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error)
}
diff --git a/internal/api/todoist.go b/internal/api/todoist.go
index 689bf10..b3d4579 100644
--- a/internal/api/todoist.go
+++ b/internal/api/todoist.go
@@ -442,3 +442,26 @@ func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error {
return nil
}
+
+// ReopenTask marks a completed task as active in Todoist
+func (c *TodoistClient) ReopenTask(ctx context.Context, taskID string) error {
+ url := fmt.Sprintf("%s/tasks/%s/reopen", c.baseURL, taskID)
+ req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to reopen task: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ return nil
+}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index b8fc574..c364188 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -827,21 +827,78 @@ func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) {
}
}
- // Return completed task HTML (stays visible with strikethrough until refresh)
+ // Return completed task HTML with uncomplete option
w.Header().Set("Content-Type", "text/html")
- completedHTML := fmt.Sprintf(`<div class="task-item bg-gray-100 rounded-lg shadow-sm border-l-4 border-gray-300 opacity-60">
+ completedHTML := fmt.Sprintf(`<div class="bg-white/5 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" checked disabled class="mt-1 h-5 w-5 rounded border-gray-300 text-green-600 cursor-not-allowed flex-shrink-0">
+ <input type="checkbox" checked
+ hx-post="/uncomplete-atom"
+ hx-vals='{"id": "%s", "source": "%s"}'
+ hx-target="closest div.rounded-lg"
+ hx-swap="outerHTML"
+ class="mt-1 h-5 w-5 rounded bg-black/40 border-white/30 text-green-600 cursor-pointer flex-shrink-0">
<span class="text-lg flex-shrink-0">✓</span>
<div class="flex-1 min-w-0">
- <h3 class="text-sm font-medium text-gray-400 line-through break-words">%s</h3>
- <div class="text-xs text-green-600 mt-1">Completed</div>
+ <h3 class="text-sm font-medium text-white/40 line-through break-words">%s</h3>
+ <div class="text-xs text-green-400/70 mt-1">Completed - click to undo</div>
</div>
</div>
- </div>`, template.HTMLEscapeString(title))
+ </div>`, template.HTMLEscapeString(id), template.HTMLEscapeString(source), template.HTMLEscapeString(title))
w.Write([]byte(completedHTML))
}
+// HandleUncompleteAtom handles reopening a completed task
+func (h *Handler) HandleUncompleteAtom(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Failed to parse form", http.StatusBadRequest)
+ log.Printf("Error parsing form: %v", err)
+ return
+ }
+
+ id := r.FormValue("id")
+ source := r.FormValue("source")
+
+ if id == "" || source == "" {
+ http.Error(w, "Missing id or source", http.StatusBadRequest)
+ return
+ }
+
+ var err error
+ switch source {
+ case "todoist":
+ err = h.todoistClient.ReopenTask(ctx, id)
+ case "trello":
+ // Reopen the card (closed = false)
+ updates := map[string]interface{}{
+ "closed": false,
+ }
+ err = h.trelloClient.UpdateCard(ctx, id, updates)
+ default:
+ http.Error(w, "Unknown source: "+source, http.StatusBadRequest)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, "Failed to reopen task", http.StatusInternalServerError)
+ log.Printf("Error reopening atom (source=%s, id=%s): %v", source, id, err)
+ return
+ }
+
+ // Invalidate cache to force refresh
+ switch source {
+ case "todoist":
+ h.store.InvalidateCache(store.CacheKeyTodoistTasks)
+ case "trello":
+ h.store.InvalidateCache(store.CacheKeyTrelloBoards)
+ }
+
+ // Trigger refresh
+ w.Header().Set("HX-Trigger", "refresh-tasks")
+ w.WriteHeader(http.StatusOK)
+}
+
// HandleUnifiedAdd creates a task in Todoist or a card in Trello from the Quick Add form
func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index 5628237..d14f71b 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -87,6 +87,10 @@ func (m *mockTodoistClient) CompleteTask(ctx context.Context, taskID string) err
return nil
}
+func (m *mockTodoistClient) ReopenTask(ctx context.Context, taskID string) error {
+ return nil
+}
+
func (m *mockTodoistClient) Sync(ctx context.Context, syncToken string) (*api.TodoistSyncResponse, error) {
if m.err != nil {
return nil, m.err
diff --git a/internal/handlers/tabs.go b/internal/handlers/tabs.go
index 986222a..87be344 100644
--- a/internal/handlers/tabs.go
+++ b/internal/handlers/tabs.go
@@ -171,39 +171,185 @@ func (h *TabsHandler) HandleTasks(w http.ResponseWriter, r *http.Request) {
}
}
-// HandlePlanning renders the Planning tab (Trello boards)
+// ScheduledItem represents a scheduled event or task for the planning view
+type ScheduledItem struct {
+ Type string // "event" or "task"
+ ID string
+ Title string
+ Description string
+ Start time.Time
+ End time.Time
+ URL string
+ Source string // "todoist", "trello", "calendar"
+ SourceIcon string
+ Priority int
+}
+
+// HandlePlanning renders the Planning tab with structured sections
func (h *TabsHandler) HandlePlanning(w http.ResponseWriter, r *http.Request) {
+ now := time.Now()
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ tomorrow := today.AddDate(0, 0, 1)
+ in3Days := today.AddDate(0, 0, 4) // End of 3rd day
+
// Fetch Trello boards
boards, err := h.store.GetBoards()
if err != nil {
- http.Error(w, "Failed to fetch boards", http.StatusInternalServerError)
log.Printf("Error fetching boards: %v", err)
- return
+ boards = []models.Board{}
+ }
+
+ // Fetch Todoist tasks
+ tasks, err := h.store.GetTasks()
+ if err != nil {
+ log.Printf("Error fetching tasks: %v", err)
+ tasks = []models.Task{}
}
// Fetch Google Calendar events
var events []models.CalendarEvent
if h.googleCalendarClient != nil {
- var err error
- events, err = h.googleCalendarClient.GetUpcomingEvents(r.Context(), 10)
+ events, err = h.googleCalendarClient.GetUpcomingEvents(r.Context(), 20)
if err != nil {
log.Printf("Error fetching calendar events: %v", err)
- // Don't fail the whole request, just show empty events
- } else {
- log.Printf("Fetched %d calendar events", len(events))
}
- } else {
- log.Printf("Google Calendar client not configured")
}
+ // Categorize into sections
+ var scheduled []ScheduledItem // Events and timed tasks for today
+ var unscheduled []models.Atom // Tasks due today without specific time
+ var upcoming []ScheduledItem // Events and tasks for next 3 days
+
+ // Process calendar events
+ for _, event := range events {
+ item := ScheduledItem{
+ Type: "event",
+ ID: event.ID,
+ Title: event.Summary,
+ Description: event.Description,
+ Start: event.Start,
+ End: event.End,
+ URL: event.HTMLLink,
+ Source: "calendar",
+ SourceIcon: "📅",
+ }
+
+ if event.Start.Before(tomorrow) {
+ scheduled = append(scheduled, item)
+ } else if event.Start.Before(in3Days) {
+ upcoming = append(upcoming, item)
+ }
+ }
+
+ // Process Todoist tasks
+ for _, task := range tasks {
+ if task.Completed || task.DueDate == nil {
+ continue
+ }
+ dueDate := *task.DueDate
+
+ // Check if task has a specific time (not midnight)
+ hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0
+
+ if dueDate.Before(tomorrow) {
+ if hasTime {
+ // Timed task for today -> scheduled
+ scheduled = append(scheduled, ScheduledItem{
+ Type: "task",
+ ID: task.ID,
+ Title: task.Content,
+ Start: dueDate,
+ URL: task.URL,
+ Source: "todoist",
+ SourceIcon: "✓",
+ Priority: task.Priority,
+ })
+ } else {
+ // All-day task for today -> unscheduled
+ atom := models.TaskToAtom(task)
+ atom.ComputeUIFields()
+ unscheduled = append(unscheduled, atom)
+ }
+ } else if dueDate.Before(in3Days) {
+ upcoming = append(upcoming, ScheduledItem{
+ Type: "task",
+ ID: task.ID,
+ Title: task.Content,
+ Start: dueDate,
+ URL: task.URL,
+ Source: "todoist",
+ SourceIcon: "✓",
+ Priority: task.Priority,
+ })
+ }
+ }
+
+ // Process Trello cards with due dates
+ for _, board := range boards {
+ for _, card := range board.Cards {
+ if card.DueDate == nil {
+ continue
+ }
+ dueDate := *card.DueDate
+ hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0
+
+ if dueDate.Before(tomorrow) {
+ if hasTime {
+ scheduled = append(scheduled, ScheduledItem{
+ Type: "task",
+ ID: card.ID,
+ Title: card.Name,
+ Start: dueDate,
+ URL: card.URL,
+ Source: "trello",
+ SourceIcon: "📋",
+ })
+ } else {
+ atom := models.CardToAtom(card)
+ atom.ComputeUIFields()
+ unscheduled = append(unscheduled, atom)
+ }
+ } else if dueDate.Before(in3Days) {
+ upcoming = append(upcoming, ScheduledItem{
+ Type: "task",
+ ID: card.ID,
+ Title: card.Name,
+ Start: dueDate,
+ URL: card.URL,
+ Source: "trello",
+ SourceIcon: "📋",
+ })
+ }
+ }
+ }
+
+ // Sort scheduled by start time
+ sort.Slice(scheduled, func(i, j int) bool {
+ return scheduled[i].Start.Before(scheduled[j].Start)
+ })
+
+ // Sort unscheduled by priority (higher first)
+ sort.Slice(unscheduled, func(i, j int) bool {
+ return unscheduled[i].Priority > unscheduled[j].Priority
+ })
+
+ // Sort upcoming by date
+ sort.Slice(upcoming, func(i, j int) bool {
+ return upcoming[i].Start.Before(upcoming[j].Start)
+ })
+
data := struct {
- Boards []models.Board
- Projects []models.Project
- Events []models.CalendarEvent
+ Scheduled []ScheduledItem
+ Unscheduled []models.Atom
+ Upcoming []ScheduledItem
+ Boards []models.Board
+ Today string
}{
- Boards: boards,
- Projects: []models.Project{}, // Empty for now
- Events: events,
+ Scheduled: scheduled,
+ Unscheduled: unscheduled,
+ Upcoming: upcoming,
+ Boards: boards,
+ Today: today.Format("2006-01-02"),
}
if err := h.templates.ExecuteTemplate(w, "planning-tab", data); err != nil {
diff --git a/web/templates/partials/planning-tab.html b/web/templates/partials/planning-tab.html
index 77bd3d8..bfb3eee 100644
--- a/web/templates/partials/planning-tab.html
+++ b/web/templates/partials/planning-tab.html
@@ -1,43 +1,161 @@
{{define "planning-tab"}}
-<div class="space-y-6">
- <!-- Google Calendar Events -->
- {{if .Events}}
- <div class="bg-white/10 backdrop-blur-sm rounded-xl p-4 shadow-sm border border-white/10">
- <h2 class="text-xl font-semibold mb-4 flex items-center gap-2 text-shadow-sm">
- <span>📅</span> Upcoming Events
+<div class="space-y-6 text-shadow-sm"
+ hx-get="/tabs/planning"
+ hx-trigger="refresh-tasks from:body"
+ hx-target="#tab-content"
+ hx-swap="innerHTML">
+
+ <!-- Scheduled Section (Events + Timed Tasks) -->
+ {{if .Scheduled}}
+ <div>
+ <h2 class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/90">
+ <span>⏰</span> Scheduled
</h2>
<div class="space-y-2">
- {{range .Events}}
- <a href="{{.HTMLLink}}" target="_blank" class="task-item border-l-4 border-blue-500 block group">
- <div class="flex items-start gap-3 p-3 sm:p-4">
- <span class="text-lg flex-shrink-0">📅</span>
+ {{range .Scheduled}}
+ <div class="bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5 border-l-4 {{if eq .Type "event"}}border-l-blue-500{{else}}border-l-green-500{{end}}">
+ <div class="flex items-start gap-3 p-3">
+ <span class="text-lg flex-shrink-0 mt-0.5">{{.SourceIcon}}</span>
<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 group-hover:underline">{{.Summary}}</h3>
- <svg class="w-4 h-4 text-white/50 flex-shrink-0" 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>
+ <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>
- <div class="flex flex-wrap items-center gap-2 mt-1 text-xs text-white/50">
- <span>{{.Start.Format "Jan 2"}}</span>
- {{if eq (.Start.Format "15:04") "00:00"}}
- <span>All Day</span>
+ <div class="text-xs text-white/50 mt-1">
+ {{if eq .Type "event"}}
+ {{if eq (.Start.Format "15:04") "00:00"}}
+ All Day
+ {{else}}
+ {{.Start.Format "3:04 PM"}} - {{.End.Format "3:04 PM"}}
+ {{end}}
{{else}}
- <span>{{.Start.Format "3:04 PM"}} - {{.End.Format "3:04 PM"}}</span>
+ {{.Start.Format "3:04 PM"}}
+ {{end}}
+ </div>
+ </div>
+ </div>
+ </div>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+
+ <!-- Unscheduled Section (Tasks due today without time) -->
+ {{if .Unscheduled}}
+ <div>
+ <h2 class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/90">
+ <span>📝</span> Today
+ </h2>
+ <div class="grid gap-2 sm:grid-cols-2">
+ {{range .Unscheduled}}
+ <div class="bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5">
+ <div class="flex items-start gap-2 p-3">
+ <input type="checkbox"
+ hx-post="/complete-atom"
+ 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">
+ <span class="text-lg flex-shrink-0">{{.SourceIcon}}</span>
+ <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 .IsOverdue}}text-amber-300{{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>
- {{if .Description}}
- <p class="text-xs text-white/40 mt-1 line-clamp-2">{{.Description}}</p>
+ {{if gt .Priority 2}}
+ <span class="text-xs text-amber-300/80">P{{.Priority}}</span>
{{end}}
</div>
</div>
- </a>
+ </div>
{{end}}
</div>
</div>
{{end}}
- <!-- Trello Boards Section -->
- {{template "trello-boards" .}}
+ <!-- Quick Add Section -->
+ <div class="bg-white/5 backdrop-blur-sm rounded-lg p-4 border border-white/10">
+ <h2 class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/90">
+ <span>+</span> Quick Add
+ </h2>
+ <form hx-post="/unified-add"
+ hx-swap="none"
+ hx-on::after-request="if(event.detail.successful) { this.reset(); htmx.trigger(document.body, 'refresh-tasks'); }">
+ <div class="flex gap-2">
+ <input type="text"
+ name="title"
+ placeholder="Add a task for today..."
+ class="flex-1 bg-black/40 border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50"
+ required>
+ <input type="hidden" name="due_date" value="{{.Today}}">
+ <input type="hidden" name="source" value="todoist">
+ <button type="submit"
+ class="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg text-sm font-medium">
+ Add
+ </button>
+ </div>
+ </form>
+ </div>
+
+ <!-- Upcoming Section (Next 3 days) -->
+ {{if .Upcoming}}
+ <div>
+ <h2 class="text-lg font-semibold mb-3 flex items-center gap-2 text-white/90">
+ <span>📆</span> Upcoming
+ </h2>
+ <div class="space-y-2">
+ {{range .Upcoming}}
+ <div class="bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5 opacity-70">
+ <div class="flex items-start gap-3 p-3">
+ <span class="text-lg flex-shrink-0 mt-0.5">{{.SourceIcon}}</span>
+ <div class="flex-1 min-w-0">
+ <div class="flex items-start justify-between gap-2">
+ <h3 class="text-sm text-white/80 font-medium break-words">{{.Title}}</h3>
+ {{if .URL}}
+ <a href="{{.URL}}" target="_blank" class="text-white/40 hover:text-white/70 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="text-xs text-white/40 mt-1">
+ {{.Start.Format "Mon, Jan 2"}}
+ {{if eq .Type "event"}}
+ {{if ne (.Start.Format "15:04") "00:00"}}
+ at {{.Start.Format "3:04 PM"}}
+ {{end}}
+ {{else}}
+ {{if or (ne .Start.Hour 0) (ne .Start.Minute 0)}}
+ at {{.Start.Format "3:04 PM"}}
+ {{end}}
+ {{end}}
+ </div>
+ </div>
+ </div>
+ </div>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+
+ <!-- Empty state if nothing scheduled -->
+ {{if and (not .Scheduled) (not .Unscheduled) (not .Upcoming)}}
+ <div class="text-center py-8 text-white/50">
+ <p>No scheduled items for the next few days.</p>
+ </div>
+ {{end}}
</div>
{{end}}