summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/api/google_calendar.go101
-rw-r--r--internal/handlers/handlers.go22
-rw-r--r--internal/handlers/handlers_test.go28
-rw-r--r--internal/store/sqlite.go121
-rw-r--r--web/templates/partials/completed-atom.html17
5 files changed, 138 insertions, 151 deletions
diff --git a/internal/api/google_calendar.go b/internal/api/google_calendar.go
index 8217b49..dc61f3d 100644
--- a/internal/api/google_calendar.go
+++ b/internal/api/google_calendar.go
@@ -19,6 +19,39 @@ type GoogleCalendarClient struct {
calendarIDs []string
}
+// parseEventTime extracts start/end times from a Google Calendar event, converting to local timezone
+func parseEventTime(item *calendar.Event) (start, end time.Time) {
+ if item.Start.DateTime == "" {
+ // All-day event - parse in local timezone
+ start, _ = time.ParseInLocation("2006-01-02", item.Start.Date, time.Local)
+ end, _ = time.ParseInLocation("2006-01-02", item.End.Date, time.Local)
+ } else {
+ // Timed event - parse RFC3339 then convert to local
+ start, _ = time.Parse(time.RFC3339, item.Start.DateTime)
+ end, _ = time.Parse(time.RFC3339, item.End.DateTime)
+ start = start.Local()
+ end = end.Local()
+ }
+ return
+}
+
+// deduplicateEvents removes duplicate events (same summary + start time)
+func deduplicateEvents(events []models.CalendarEvent) []models.CalendarEvent {
+ seen := make(map[string]bool)
+ var unique []models.CalendarEvent
+ for _, event := range events {
+ key := fmt.Sprintf("%s|%d", event.Summary, event.Start.Unix())
+ if !seen[key] {
+ seen[key] = true
+ unique = append(unique, event)
+ }
+ }
+ sort.Slice(unique, func(i, j int) bool {
+ return unique[i].Start.Before(unique[j].Start)
+ })
+ return unique
+}
+
// NewGoogleCalendarClient creates a client that fetches from multiple calendars.
// calendarIDs can be comma-separated (e.g., "cal1@group.calendar.google.com,cal2@group.calendar.google.com")
func NewGoogleCalendarClient(ctx context.Context, credentialsFile, calendarIDs string) (*GoogleCalendarClient, error) {
@@ -51,23 +84,11 @@ func (c *GoogleCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults
SingleEvents(true).TimeMin(t).MaxResults(int64(maxResults)).OrderBy("startTime").Do()
if err != nil {
log.Printf("Warning: failed to fetch events from calendar %s: %v", calendarID, err)
- continue // Don't fail entirely, just skip this calendar
+ continue
}
for _, item := range events.Items {
- var start, end time.Time
- if item.Start.DateTime == "" {
- // All-day event - parse in local timezone
- start, _ = time.ParseInLocation("2006-01-02", item.Start.Date, time.Local)
- end, _ = time.ParseInLocation("2006-01-02", item.End.Date, time.Local)
- } else {
- // Timed event - parse RFC3339 then convert to local
- start, _ = time.Parse(time.RFC3339, item.Start.DateTime)
- end, _ = time.Parse(time.RFC3339, item.End.DateTime)
- start = start.Local()
- end = end.Local()
- }
-
+ start, end := parseEventTime(item)
allEvents = append(allEvents, models.CalendarEvent{
ID: item.Id,
Summary: item.Summary,
@@ -79,29 +100,10 @@ func (c *GoogleCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults
}
}
- // Deduplicate events (same event may appear in multiple calendars)
- // Use Unix timestamp to handle timezone differences
- seen := make(map[string]bool)
- var uniqueEvents []models.CalendarEvent
- for _, event := range allEvents {
- // Use summary + unix timestamp as dedup key (handles timezone differences)
- key := fmt.Sprintf("%s|%d", event.Summary, event.Start.Unix())
- if !seen[key] {
- seen[key] = true
- uniqueEvents = append(uniqueEvents, event)
- }
- }
-
- // Sort all events by start time
- sort.Slice(uniqueEvents, func(i, j int) bool {
- return uniqueEvents[i].Start.Before(uniqueEvents[j].Start)
- })
-
- // Limit to maxResults
+ uniqueEvents := deduplicateEvents(allEvents)
if len(uniqueEvents) > maxResults {
uniqueEvents = uniqueEvents[:maxResults]
}
-
return uniqueEvents, nil
}
@@ -119,19 +121,7 @@ func (c *GoogleCalendarClient) GetEventsByDateRange(ctx context.Context, start,
}
for _, item := range events.Items {
- var evtStart, evtEnd time.Time
- if item.Start.DateTime == "" {
- // All-day event - parse in local timezone
- evtStart, _ = time.ParseInLocation("2006-01-02", item.Start.Date, time.Local)
- evtEnd, _ = time.ParseInLocation("2006-01-02", item.End.Date, time.Local)
- } else {
- // Timed event - parse RFC3339 then convert to local
- evtStart, _ = time.Parse(time.RFC3339, item.Start.DateTime)
- evtEnd, _ = time.Parse(time.RFC3339, item.End.DateTime)
- evtStart = evtStart.Local()
- evtEnd = evtEnd.Local()
- }
-
+ evtStart, evtEnd := parseEventTime(item)
allEvents = append(allEvents, models.CalendarEvent{
ID: item.Id,
Summary: item.Summary,
@@ -143,20 +133,5 @@ func (c *GoogleCalendarClient) GetEventsByDateRange(ctx context.Context, start,
}
}
- // Deduplicate
- seen := make(map[string]bool)
- var uniqueEvents []models.CalendarEvent
- for _, event := range allEvents {
- key := fmt.Sprintf("%s|%d", event.Summary, event.Start.Unix())
- if !seen[key] {
- seen[key] = true
- uniqueEvents = append(uniqueEvents, event)
- }
- }
-
- sort.Slice(uniqueEvents, func(i, j int) bool {
- return uniqueEvents[i].Start.Before(uniqueEvents[j].Start)
- })
-
- return uniqueEvents, nil
+ return deduplicateEvents(allEvents), nil
}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index ee28a87..e0e185d 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -665,22 +665,12 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl
}
// Return completed task HTML with uncomplete option
- 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
- 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-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(id), template.HTMLEscapeString(source), template.HTMLEscapeString(title))
- HTMLString(w, completedHTML)
+ data := struct {
+ ID string
+ Source string
+ Title string
+ }{id, source, title}
+ HTMLResponse(w, h.templates, "completed-atom", data)
} else {
// Invalidate cache to force refresh
switch source {
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index d14f71b..3658e0e 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -3,9 +3,11 @@ package handlers
import (
"context"
"encoding/json"
+ "html/template"
"net/http"
"net/http/httptest"
"os"
+ "path/filepath"
"testing"
"time"
@@ -58,6 +60,30 @@ func setupTestDB(t *testing.T) (*store.Store, func()) {
return db, cleanup
}
+// loadTestTemplates loads templates for testing from project root
+func loadTestTemplates(t *testing.T) *template.Template {
+ t.Helper()
+
+ // Get path relative to project root
+ tmpl, err := template.ParseGlob(filepath.Join("web", "templates", "*.html"))
+ if err != nil {
+ // Try from internal/handlers (2 levels up)
+ tmpl, err = template.ParseGlob(filepath.Join("..", "..", "web", "templates", "*.html"))
+ if err != nil {
+ t.Logf("Warning: failed to parse templates: %v", err)
+ return nil
+ }
+ }
+
+ // Parse partials
+ tmpl, err = tmpl.ParseGlob(filepath.Join("web", "templates", "partials", "*.html"))
+ if err != nil {
+ tmpl, _ = tmpl.ParseGlob(filepath.Join("..", "..", "web", "templates", "partials", "*.html"))
+ }
+
+ return tmpl
+}
+
// mockTodoistClient creates a mock Todoist client for testing
type mockTodoistClient struct {
tasks []models.Task
@@ -457,6 +483,7 @@ func TestHandleCompleteAtom_Todoist(t *testing.T) {
store: db,
todoistClient: mockTodoist,
config: &config.Config{},
+ templates: loadTestTemplates(t),
}
// Create request
@@ -523,6 +550,7 @@ func TestHandleCompleteAtom_Trello(t *testing.T) {
store: db,
trelloClient: mockTrello,
config: &config.Config{},
+ templates: loadTestTemplates(t),
}
// Create request
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index 5b95607..c5c52a4 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -98,6 +98,53 @@ func (s *Store) runMigrations() error {
// Tasks operations
+// scanTask scans a single task row from the database
+func scanTask(rows *sql.Rows) (models.Task, error) {
+ var task models.Task
+ var labelsJSON string
+ var dueDate sql.NullTime
+
+ err := rows.Scan(
+ &task.ID,
+ &task.Content,
+ &task.Description,
+ &task.ProjectID,
+ &task.ProjectName,
+ &dueDate,
+ &task.Priority,
+ &task.Completed,
+ &labelsJSON,
+ &task.URL,
+ &task.CreatedAt,
+ )
+ if err != nil {
+ return task, err
+ }
+
+ if dueDate.Valid {
+ task.DueDate = &dueDate.Time
+ }
+
+ if err := json.Unmarshal([]byte(labelsJSON), &task.Labels); err != nil {
+ log.Printf("Warning: failed to unmarshal labels for task %s: %v", task.ID, err)
+ task.Labels = []string{}
+ }
+ return task, nil
+}
+
+// scanTasks scans multiple task rows from the database
+func scanTasks(rows *sql.Rows) ([]models.Task, error) {
+ var tasks []models.Task
+ for rows.Next() {
+ task, err := scanTask(rows)
+ if err != nil {
+ return nil, err
+ }
+ tasks = append(tasks, task)
+ }
+ return tasks, rows.Err()
+}
+
// SaveTasks saves multiple tasks to the database
func (s *Store) SaveTasks(tasks []models.Task) error {
tx, err := s.db.Begin()
@@ -155,42 +202,7 @@ func (s *Store) GetTasks() ([]models.Task, error) {
return nil, err
}
defer rows.Close()
-
- var tasks []models.Task
- for rows.Next() {
- var task models.Task
- var labelsJSON string
- var dueDate sql.NullTime
-
- err := rows.Scan(
- &task.ID,
- &task.Content,
- &task.Description,
- &task.ProjectID,
- &task.ProjectName,
- &dueDate,
- &task.Priority,
- &task.Completed,
- &labelsJSON,
- &task.URL,
- &task.CreatedAt,
- )
- if err != nil {
- return nil, err
- }
-
- if dueDate.Valid {
- task.DueDate = &dueDate.Time
- }
-
- if err := json.Unmarshal([]byte(labelsJSON), &task.Labels); err != nil {
- log.Printf("Warning: failed to unmarshal labels for task %s: %v", task.ID, err)
- task.Labels = []string{}
- }
- tasks = append(tasks, task)
- }
-
- return tasks, rows.Err()
+ return scanTasks(rows)
}
// DeleteTask removes a task from the cache by ID
@@ -690,42 +702,7 @@ func (s *Store) GetTasksByDateRange(start, end time.Time) ([]models.Task, error)
return nil, err
}
defer rows.Close()
-
- var tasks []models.Task
- for rows.Next() {
- var task models.Task
- var labelsJSON string
- var dueDate sql.NullTime
-
- err := rows.Scan(
- &task.ID,
- &task.Content,
- &task.Description,
- &task.ProjectID,
- &task.ProjectName,
- &dueDate,
- &task.Priority,
- &task.Completed,
- &labelsJSON,
- &task.URL,
- &task.CreatedAt,
- )
- if err != nil {
- return nil, err
- }
-
- if dueDate.Valid {
- task.DueDate = &dueDate.Time
- }
-
- if err := json.Unmarshal([]byte(labelsJSON), &task.Labels); err != nil {
- log.Printf("Warning: failed to unmarshal labels for task %s: %v", task.ID, err)
- task.Labels = []string{}
- }
- tasks = append(tasks, task)
- }
-
- return tasks, rows.Err()
+ return scanTasks(rows)
}
// GetMealsByDateRange retrieves meals within a specific date range
diff --git a/web/templates/partials/completed-atom.html b/web/templates/partials/completed-atom.html
new file mode 100644
index 0000000..74b605d
--- /dev/null
+++ b/web/templates/partials/completed-atom.html
@@ -0,0 +1,17 @@
+{{define "completed-atom"}}
+<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
+ hx-post="/uncomplete-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-green-600 cursor-pointer flex-shrink-0">
+ <span class="text-lg flex-shrink-0">&#10003;</span>
+ <div class="flex-1 min-w-0">
+ <h3 class="text-sm font-medium text-white/40 line-through break-words">{{.Title}}</h3>
+ <div class="text-xs text-green-400/70 mt-1">Completed - click to undo</div>
+ </div>
+ </div>
+</div>
+{{end}}