diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/google_calendar.go | 101 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 22 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 28 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 121 |
4 files changed, 121 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 |
