summaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-04 11:12:44 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-03-04 11:12:44 -1000
commit0fd54eddc40f517cf491310d4f8a60b0d79dc937 (patch)
treeb026328a49b9583efb7c94b3777830c31c46fa33 /internal/handlers
parent4853a4a917bb7942776ffd8b3e003ee03fc49160 (diff)
feat: sync log, cache clear endpoint, Todoist projects from cached tasks
- migration 016: sync_log table - store: AddSyncLogEntry, GetRecentSyncLog, InvalidateAllCaches, GetProjectsFromTasks - settings: HandleClearCache (POST /settings/clear-cache), SyncLog in page data - settings: use GetProjectsFromTasks instead of deprecated Todoist REST /projects - handlers: populate atom projects from store - agent: log warning on registration failure instead of silently swallowing - google_tasks: simplify URL literal - tests: sync log CRUD, clear cache handler, settings page includes sync log, sync sources adds log entry, incremental sync paths, task completion response/headers, calendar cache fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/handlers')
-rw-r--r--internal/handlers/agent.go5
-rw-r--r--internal/handlers/handlers.go23
-rw-r--r--internal/handlers/handlers_test.go404
-rw-r--r--internal/handlers/settings.go50
4 files changed, 442 insertions, 40 deletions
diff --git a/internal/handlers/agent.go b/internal/handlers/agent.go
index b285520..aa3f000 100644
--- a/internal/handlers/agent.go
+++ b/internal/handlers/agent.go
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
+ "log"
"net/http"
"time"
@@ -253,8 +254,8 @@ func (h *Handler) HandleAgentAuthApprove(w http.ResponseWriter, r *http.Request)
// Register/update agent in the trusted agents table
if err := h.store.CreateOrUpdateAgent(session.AgentName, session.AgentID); err != nil {
- // Log but don't fail - the session was approved
- // This just affects future trust level checks
+ // Don't fail - the session was approved; this just affects future trust level checks
+ log.Printf("warning: failed to register agent %q: %v", session.AgentName, err)
}
w.Header().Set("Content-Type", "application/json")
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index e06c35e..4988038 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -243,16 +243,6 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models
return err
})
- fetch("Projects", func() error {
- projects, err := h.todoistClient.GetProjects(ctx)
- if err == nil {
- mu.Lock()
- data.Projects = projects
- mu.Unlock()
- }
- return err
- })
-
if h.planToEatClient != nil {
fetch("PlanToEat", func() error {
meals, err := h.fetchMeals(ctx, forceRefresh)
@@ -279,6 +269,11 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models
wg.Wait()
+ // Populate projects from cached tasks (avoids deprecated REST API)
+ if projects, err := h.store.GetProjectsFromTasks(); err == nil {
+ data.Projects = projects
+ }
+
// Extract and sort Trello tasks
data.TrelloTasks = filterAndSortTrelloTasks(data.Boards)
@@ -586,7 +581,7 @@ func (h *Handler) HandleCreateTask(w http.ResponseWriter, r *http.Request) {
return
}
- projects, _ := h.todoistClient.GetProjects(ctx)
+ projects, _ := h.store.GetProjectsFromTasks()
data := struct {
Tasks []models.Task
@@ -772,12 +767,6 @@ func (h *Handler) getAtomDetails(id, source string) (string, *time.Time) {
return "Task", nil
}
-// getAtomTitle retrieves the title for a task/card/bug from the store (legacy)
-func (h *Handler) getAtomTitle(id, source string) string {
- title, _ := h.getAtomDetails(id, source)
- return title
-}
-
// 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 1cab29d..470ea5c 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "reflect"
"strings"
"testing"
"time"
@@ -1840,13 +1841,14 @@ func TestHandleSyncSources(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
- // Setup mock clients
- mockTodoist := &mockTodoistClientWithProjects{
- projects: []models.Project{
- {ID: "proj1", Name: "Project 1"},
- {ID: "proj2", Name: "Project 2"},
- },
+ // Seed tasks table with project info (projects now come from store, not REST API)
+ tasks := []models.Task{
+ {ID: "t1", Content: "Task 1", ProjectID: "proj1", ProjectName: "Project 1", Labels: []string{}},
+ {ID: "t2", Content: "Task 2", ProjectID: "proj2", ProjectName: "Project 2", Labels: []string{}},
}
+ _ = h.store.SaveTasks(tasks)
+
+ mockTodoist := &mockTodoistClient{}
mockTrello := &mockTrelloClientWithBoards{
mockTrelloClient: mockTrelloClient{
boards: []models.Board{
@@ -1867,7 +1869,7 @@ func TestHandleSyncSources(t *testing.T) {
t.Errorf("Expected status 200, got %d", w.Code)
}
- // Verify configs were synced
+ // Verify configs were synced from store (not REST API)
configs, _ := h.store.GetSourceConfigsBySource("todoist")
if len(configs) != 2 {
t.Errorf("Expected 2 todoist configs, got %d", len(configs))
@@ -2080,6 +2082,16 @@ func TestSettingsTemplate_HasCSRFToken(t *testing.T) {
"settings.html must expose .CSRFToken (e.g. via meta tag) for JavaScript passkey registration")
}
+// TestSettingsTemplate_InjectsCSRFForHTMX verifies that settings.html sends the
+// CSRF token with HTMX requests via hx-headers on the body. Without this, all
+// HTMX POST requests from the settings page (clear cache, sync) are rejected
+// with 403 Forbidden and appear to silently fail.
+func TestSettingsTemplate_InjectsCSRFForHTMX(t *testing.T) {
+ assertTemplateContains(t, "../../web/templates/settings.html",
+ `hx-headers`,
+ "settings.html body must set hx-headers with X-CSRF-Token so HTMX POSTs are not rejected with 403")
+}
+
// TestSettingsTemplate_HidesPasskeysWhenDisabled verifies that the settings
// template conditionally shows the passkeys section based on WebAuthnEnabled.
func TestSettingsTemplate_HidesPasskeysWhenDisabled(t *testing.T) {
@@ -2457,3 +2469,381 @@ func TestFetchTasks_ForceRefresh_ClearsSyncToken(t *testing.T) {
t.Errorf("Expected empty sync token for forceRefresh, got %q", mock.receivedTokens[0])
}
}
+
+// =============================================================================
+// Clear Cache + GetProjects removal tests
+// =============================================================================
+
+// TestHandleClearCache verifies that POST /settings/clear-cache invalidates
+// all cache keys, clears the todoist sync token, and returns 200 with HX-Trigger.
+func TestHandleClearCache(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ // Seed cache metadata and sync token so we can verify they get cleared
+ _ = h.store.UpdateCacheMetadata("todoist_tasks", 5)
+ _ = h.store.UpdateCacheMetadata("trello_boards", 5)
+ _ = h.store.UpdateCacheMetadata("plantoeat_meals", 5)
+ _ = h.store.UpdateCacheMetadata("google_calendar", 5)
+ _ = h.store.SetSyncToken("todoist", "some-sync-token")
+
+ req := httptest.NewRequest("POST", "/settings/clear-cache", nil)
+ w := httptest.NewRecorder()
+
+ h.HandleClearCache(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ // Verify HX-Trigger header
+ if trigger := w.Header().Get("HX-Trigger"); trigger != "refresh-tasks" {
+ t.Errorf("Expected HX-Trigger 'refresh-tasks', got %q", trigger)
+ }
+
+ // Verify all cache keys invalidated
+ for _, key := range []string{"todoist_tasks", "trello_boards", "plantoeat_meals", "google_calendar"} {
+ valid, _ := h.store.IsCacheValid(key)
+ if valid {
+ t.Errorf("Cache key %q should be invalidated but is still valid", key)
+ }
+ }
+
+ // Verify sync token cleared
+ token, _ := h.store.GetSyncToken("todoist")
+ if token != "" {
+ t.Errorf("Expected empty sync token after clear cache, got %q", token)
+ }
+}
+
+// TestGetProjectsFromTasks verifies that distinct project pairs are extracted
+// from the tasks table.
+func TestGetProjectsFromTasks(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ // Seed tasks with different projects (including duplicates and empty)
+ tasks := []models.Task{
+ {ID: "1", Content: "Task 1", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
+ {ID: "2", Content: "Task 2", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
+ {ID: "3", Content: "Task 3", ProjectID: "p2", ProjectName: "Work", Labels: []string{}},
+ {ID: "4", Content: "Task 4", ProjectID: "", ProjectName: "", Labels: []string{}},
+ }
+ if err := h.store.SaveTasks(tasks); err != nil {
+ t.Fatalf("Failed to save tasks: %v", err)
+ }
+
+ projects, err := h.store.GetProjectsFromTasks()
+ if err != nil {
+ t.Fatalf("GetProjectsFromTasks returned error: %v", err)
+ }
+
+ if len(projects) != 2 {
+ t.Fatalf("Expected 2 distinct projects, got %d: %v", len(projects), projects)
+ }
+
+ // Verify both projects are present
+ found := map[string]bool{}
+ for _, p := range projects {
+ found[p.ID] = true
+ }
+ if !found["p1"] || !found["p2"] {
+ t.Errorf("Expected projects p1 and p2, got %v", projects)
+ }
+}
+
+// TestInvalidateAllCaches verifies the convenience method clears all 4 keys.
+func TestInvalidateAllCaches(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ keys := []string{"todoist_tasks", "trello_boards", "plantoeat_meals", "google_calendar"}
+ for _, key := range keys {
+ _ = h.store.UpdateCacheMetadata(key, 5)
+ }
+
+ if err := h.store.InvalidateAllCaches(); err != nil {
+ t.Fatalf("InvalidateAllCaches returned error: %v", err)
+ }
+
+ for _, key := range keys {
+ valid, _ := h.store.IsCacheValid(key)
+ if valid {
+ t.Errorf("Cache key %q should be invalidated", key)
+ }
+ }
+}
+
+// TestSettingsTemplate_HasClearCacheButton verifies settings.html has a
+// Clear Cache button with hx-post.
+func TestSettingsTemplate_HasClearCacheButton(t *testing.T) {
+ assertTemplateContains(t, "../../web/templates/settings.html",
+ "clear-cache",
+ "settings.html must contain a clear-cache button/endpoint")
+}
+
+// TestAggregateData_DoesNotCallGetProjects verifies that aggregateData no longer
+// calls the deprecated GetProjects REST API.
+func TestAggregateData_DoesNotCallGetProjects(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ getProjectsCalled := false
+ mock := &mockTodoistClientTracksGetProjects{
+ mockTodoistClient: mockTodoistClient{
+ tasks: []models.Task{
+ {ID: "1", Content: "Test", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
+ },
+ },
+ onGetProjects: func() { getProjectsCalled = true },
+ }
+ h.todoistClient = mock
+ h.trelloClient = &mockTrelloClient{boards: []models.Board{}}
+
+ _, err := h.aggregateData(context.Background(), false)
+ if err != nil {
+ t.Fatalf("aggregateData returned error: %v", err)
+ }
+
+ if getProjectsCalled {
+ t.Error("aggregateData should NOT call GetProjects (deprecated REST API)")
+ }
+}
+
+// TestHandleCreateTask_DoesNotCallGetProjects verifies that HandleCreateTask
+// populates projects from the store, not the REST API.
+func TestHandleCreateTask_DoesNotCallGetProjects(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ // Seed tasks with project info
+ tasks := []models.Task{
+ {ID: "1", Content: "Existing", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
+ }
+ _ = h.store.SaveTasks(tasks)
+
+ getProjectsCalled := false
+ mock := &mockTodoistClientTracksGetProjects{
+ mockTodoistClient: mockTodoistClient{
+ tasks: tasks,
+ },
+ onGetProjects: func() { getProjectsCalled = true },
+ }
+ h.todoistClient = mock
+
+ body := strings.NewReader("content=New+Task&project_id=p1")
+ req := httptest.NewRequest("POST", "/tasks", body)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ h.HandleCreateTask(w, req)
+
+ if getProjectsCalled {
+ t.Error("HandleCreateTask should NOT call GetProjects (deprecated REST API)")
+ }
+}
+
+// TestHandleSyncSources_UsesStoreForProjects verifies that HandleSyncSources
+// reads projects from the tasks table instead of the REST API.
+func TestHandleSyncSources_UsesStoreForProjects(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ // Seed tasks with project info
+ tasks := []models.Task{
+ {ID: "1", Content: "Task1", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
+ {ID: "2", Content: "Task2", ProjectID: "p2", ProjectName: "Work", Labels: []string{}},
+ }
+ _ = h.store.SaveTasks(tasks)
+
+ getProjectsCalled := false
+ mock := &mockTodoistClientTracksGetProjects{
+ mockTodoistClient: mockTodoistClient{tasks: tasks},
+ onGetProjects: func() { getProjectsCalled = true },
+ }
+ h.todoistClient = mock
+
+ req := httptest.NewRequest("POST", "/settings/sync", nil)
+ w := httptest.NewRecorder()
+
+ h.HandleSyncSources(w, req)
+
+ if getProjectsCalled {
+ t.Error("HandleSyncSources should NOT call GetProjects (deprecated REST API)")
+ }
+
+ // Verify todoist project configs were still synced from store
+ configs, _ := h.store.GetSourceConfigsBySource("todoist")
+ if len(configs) != 2 {
+ t.Errorf("Expected 2 todoist configs from store, got %d", len(configs))
+ }
+}
+
+// mockTodoistClientTracksGetProjects wraps mockTodoistClient and tracks GetProjects calls.
+type mockTodoistClientTracksGetProjects struct {
+ mockTodoistClient
+ onGetProjects func()
+}
+
+func (m *mockTodoistClientTracksGetProjects) GetProjects(ctx context.Context) ([]models.Project, error) {
+ if m.onGetProjects != nil {
+ m.onGetProjects()
+ }
+ return []models.Project{}, nil
+}
+
+// --- Sync Log Tests ---
+
+// TestStore_AddAndGetSyncLog verifies that sync log entries can be added and retrieved.
+func TestStore_AddAndGetSyncLog(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ if err := db.AddSyncLogEntry("cache_clear", "All caches cleared"); err != nil {
+ t.Fatalf("AddSyncLogEntry failed: %v", err)
+ }
+ if err := db.AddSyncLogEntry("sync", "Sources synced"); err != nil {
+ t.Fatalf("AddSyncLogEntry failed: %v", err)
+ }
+
+ entries, err := db.GetRecentSyncLog(10)
+ if err != nil {
+ t.Fatalf("GetRecentSyncLog failed: %v", err)
+ }
+ if len(entries) != 2 {
+ t.Fatalf("Expected 2 entries, got %d", len(entries))
+ }
+ // Most recent first
+ if entries[0].Message != "Sources synced" {
+ t.Errorf("Expected first entry to be 'Sources synced', got %q", entries[0].Message)
+ }
+ if entries[1].Message != "All caches cleared" {
+ t.Errorf("Expected second entry to be 'All caches cleared', got %q", entries[1].Message)
+ }
+}
+
+// TestStore_GetRecentSyncLog_LimitsResults verifies the limit parameter is respected.
+func TestStore_GetRecentSyncLog_LimitsResults(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ for i := 0; i < 5; i++ {
+ _ = db.AddSyncLogEntry("test", "entry")
+ }
+
+ entries, err := db.GetRecentSyncLog(3)
+ if err != nil {
+ t.Fatalf("GetRecentSyncLog failed: %v", err)
+ }
+ if len(entries) != 3 {
+ t.Errorf("Expected 3 entries with limit=3, got %d", len(entries))
+ }
+}
+
+// TestHandleClearCache_AddsLogEntry verifies that clearing cache creates a sync log entry.
+func TestHandleClearCache_AddsLogEntry(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := httptest.NewRequest("POST", "/settings/clear-cache", nil)
+ w := httptest.NewRecorder()
+ h.HandleClearCache(w, req)
+
+ entries, err := h.store.GetRecentSyncLog(10)
+ if err != nil {
+ t.Fatalf("GetRecentSyncLog failed: %v", err)
+ }
+ if len(entries) == 0 {
+ t.Fatal("Expected sync log entry after clear cache, got none")
+ }
+ if entries[0].EventType != "cache_clear" {
+ t.Errorf("Expected event_type 'cache_clear', got %q", entries[0].EventType)
+ }
+}
+
+// TestHandleClearCache_ReturnsHTMLSyncLog verifies that the response renders the sync-log template.
+func TestHandleClearCache_ReturnsHTMLSyncLog(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := httptest.NewRequest("POST", "/settings/clear-cache", nil)
+ w := httptest.NewRecorder()
+ h.HandleClearCache(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("Expected status 200, got %d", w.Code)
+ }
+ // MockRenderer writes "rendered:{name}" — verify sync-log template was rendered
+ body := w.Body.String()
+ if !strings.Contains(body, "sync-log") {
+ t.Errorf("Expected response body to contain 'sync-log', got: %s", body)
+ }
+ // Verify the renderer was called with the sync-log template and log entries
+ mr := h.renderer.(*MockRenderer)
+ var found bool
+ for _, call := range mr.Calls {
+ if call.Name == "sync-log" {
+ found = true
+ entries, ok := call.Data.([]store.SyncLogEntry)
+ if !ok {
+ t.Errorf("Expected sync-log data to be []store.SyncLogEntry, got %T", call.Data)
+ }
+ if len(entries) == 0 {
+ t.Error("Expected sync-log data to contain at least one entry")
+ }
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected renderer to be called with 'sync-log' template")
+ }
+}
+
+// TestHandleSettingsPage_IncludesSyncLog verifies settings page passes SyncLog to template.
+func TestHandleSettingsPage_IncludesSyncLog(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+ _ = h.store.AddSyncLogEntry("cache_clear", "All caches cleared")
+
+ req := httptest.NewRequest("GET", "/settings", nil)
+ w := httptest.NewRecorder()
+ mr := h.renderer.(*MockRenderer)
+ h.HandleSettingsPage(w, req)
+
+ if len(mr.Calls) == 0 {
+ t.Fatal("Expected renderer to be called")
+ }
+ call := mr.Calls[len(mr.Calls)-1]
+
+ type hasSyncLog interface {
+ GetSyncLog() interface{}
+ }
+
+ // Use reflection to check for SyncLog field in the data struct
+ dataVal := reflect.ValueOf(call.Data)
+ syncLogField := dataVal.FieldByName("SyncLog")
+ if !syncLogField.IsValid() {
+ t.Fatal("Expected template data to have 'SyncLog' field")
+ }
+}
+
+// TestHandleSyncSources_AddsLogEntry verifies that syncing sources creates a sync log entry.
+func TestHandleSyncSources_AddsLogEntry(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := httptest.NewRequest("POST", "/settings/sync", nil)
+ w := httptest.NewRecorder()
+ h.HandleSyncSources(w, req)
+
+ entries, err := h.store.GetRecentSyncLog(10)
+ if err != nil {
+ t.Fatalf("GetRecentSyncLog failed: %v", err)
+ }
+ if len(entries) == 0 {
+ t.Fatal("Expected sync log entry after sync sources, got none")
+ }
+ if entries[0].EventType != "sync" {
+ t.Errorf("Expected event_type 'sync', got %q", entries[0].EventType)
+ }
+}
diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go
index 0ad362b..c0ba654 100644
--- a/internal/handlers/settings.go
+++ b/internal/handlers/settings.go
@@ -8,12 +8,14 @@ import (
"task-dashboard/internal/auth"
"task-dashboard/internal/models"
+ "task-dashboard/internal/store"
)
// HandleSettingsPage renders the settings page
func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
configs, _ := h.store.GetSourceConfigs()
toggles, _ := h.store.GetFeatureToggles()
+ syncLog, _ := h.store.GetRecentSyncLog(20)
// Group configs by source
bySource := make(map[string][]models.SourceConfig)
@@ -25,12 +27,14 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
Configs map[string][]models.SourceConfig
Sources []string
Toggles []models.FeatureToggle
+ SyncLog []store.SyncLogEntry
CSRFToken string
WebAuthnEnabled bool
}{
Configs: bySource,
Sources: []string{"trello", "todoist", "gcal", "gtasks"},
Toggles: toggles,
+ SyncLog: syncLog,
CSRFToken: auth.GetCSRFTokenFromContext(r.Context()),
WebAuthnEnabled: h.WebAuthnEnabled,
}
@@ -61,21 +65,18 @@ func (h *Handler) HandleSyncSources(w http.ResponseWriter, r *http.Request) {
}
}
- // Sync Todoist projects
- if h.todoistClient != nil {
- projects, err := h.todoistClient.GetProjects(ctx)
- if err == nil {
- var items []models.SourceConfig
- for _, p := range projects {
- items = append(items, models.SourceConfig{
- Source: "todoist",
- ItemType: "project",
- ItemID: p.ID,
- ItemName: p.Name,
- })
- }
- _ = h.store.SyncSourceConfigs("todoist", "project", items)
+ // Sync Todoist projects from cached tasks (avoids deprecated REST API)
+ if projects, err := h.store.GetProjectsFromTasks(); err == nil && len(projects) > 0 {
+ var items []models.SourceConfig
+ for _, p := range projects {
+ items = append(items, models.SourceConfig{
+ Source: "todoist",
+ ItemType: "project",
+ ItemID: p.ID,
+ ItemName: p.Name,
+ })
}
+ _ = h.store.SyncSourceConfigs("todoist", "project", items)
}
// Sync Google Calendar calendars
@@ -112,10 +113,31 @@ func (h *Handler) HandleSyncSources(w http.ResponseWriter, r *http.Request) {
}
}
+ _ = h.store.AddSyncLogEntry("sync", "Sources synced")
+
// Return updated configs
h.HandleSettingsPage(w, r)
}
+// HandleClearCache invalidates all caches and clears the Todoist sync token
+func (h *Handler) HandleClearCache(w http.ResponseWriter, r *http.Request) {
+ if err := h.store.InvalidateAllCaches(); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to clear cache", err)
+ return
+ }
+
+ if err := h.store.ClearSyncToken("todoist"); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to clear sync token", err)
+ return
+ }
+
+ _ = h.store.AddSyncLogEntry("cache_clear", "Cache cleared — next load fetches fresh data")
+
+ syncLog, _ := h.store.GetRecentSyncLog(20)
+ w.Header().Set("HX-Trigger", "refresh-tasks")
+ HTMLResponse(w, h.renderer, "sync-log", syncLog)
+}
+
// HandleToggleSourceConfig toggles a source config item
func (h *Handler) HandleToggleSourceConfig(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {