From 0fd54eddc40f517cf491310d4f8a60b0d79dc937 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 4 Mar 2026 11:12:44 -1000 Subject: 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 --- cmd/dashboard/main.go | 1 + internal/api/google_tasks.go | 2 +- internal/handlers/agent.go | 5 +- internal/handlers/handlers.go | 23 +- internal/handlers/handlers_test.go | 404 ++++++++++++++++++++++++++++++++++- internal/handlers/settings.go | 50 +++-- internal/store/sqlite.go | 70 ++++++ migrations/016_sync_log.sql | 6 + web/templates/partials/sync-log.html | 15 ++ web/templates/settings.html | 24 ++- 10 files changed, 553 insertions(+), 47 deletions(-) create mode 100644 migrations/016_sync_log.sql create mode 100644 web/templates/partials/sync-log.html diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index e44d533..8d66359 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -272,6 +272,7 @@ func main() { // Settings r.Get("/settings", h.HandleSettingsPage) r.Post("/settings/sync", h.HandleSyncSources) + r.Post("/settings/clear-cache", h.HandleClearCache) r.Post("/settings/toggle", h.HandleToggleSourceConfig) r.Post("/settings/features", h.HandleCreateFeature) r.Post("/settings/features/toggle", h.HandleToggleFeature) diff --git a/internal/api/google_tasks.go b/internal/api/google_tasks.go index ecacb6d..8a6488e 100644 --- a/internal/api/google_tasks.go +++ b/internal/api/google_tasks.go @@ -115,7 +115,7 @@ func (c *GoogleTasksClient) getTasksFromList(ctx context.Context, listID string) } // Build URL to Google Tasks - task.URL = fmt.Sprintf("https://tasks.google.com/embed/?origin=https://mail.google.com&fullWidth=1") + task.URL = "https://tasks.google.com/embed/?origin=https://mail.google.com&fullWidth=1" result = append(result, task) } 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 { diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 158febc..366b24e 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -1397,6 +1397,37 @@ func (s *Store) SyncSourceConfigs(source, itemType string, items []models.Source return tx.Commit() } +// InvalidateAllCaches removes cache metadata for all known cache keys +func (s *Store) InvalidateAllCaches() error { + _, err := s.db.Exec(`DELETE FROM cache_metadata WHERE key IN (?, ?, ?, ?)`, + CacheKeyTodoistTasks, CacheKeyTrelloBoards, CacheKeyPlanToEatMeals, CacheKeyGoogleCalendar) + return err +} + +// GetProjectsFromTasks returns distinct projects from the tasks table +func (s *Store) GetProjectsFromTasks() ([]models.Project, error) { + rows, err := s.db.Query(` + SELECT DISTINCT project_id, project_name + FROM tasks + WHERE project_id != '' + ORDER BY project_name + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var projects []models.Project + for rows.Next() { + var p models.Project + if err := rows.Scan(&p.ID, &p.Name); err != nil { + return nil, err + } + projects = append(projects, p) + } + return projects, rows.Err() +} + // Feature toggles // GetFeatureToggles returns all feature toggles @@ -1457,3 +1488,42 @@ func (s *Store) DeleteFeatureToggle(name string) error { _, err := s.db.Exec(`DELETE FROM feature_toggles WHERE name = ?`, name) return err } + +// SyncLogEntry represents a single entry in the sync activity log +type SyncLogEntry struct { + ID int64 + EventType string + Message string + CreatedAt time.Time +} + +// AddSyncLogEntry records a sync or cache event in the log +func (s *Store) AddSyncLogEntry(eventType, message string) error { + _, err := s.db.Exec( + `INSERT INTO sync_log (event_type, message) VALUES (?, ?)`, + eventType, message, + ) + return err +} + +// GetRecentSyncLog returns the most recent sync log entries, newest first +func (s *Store) GetRecentSyncLog(limit int) ([]SyncLogEntry, error) { + rows, err := s.db.Query( + `SELECT id, event_type, message, created_at FROM sync_log ORDER BY id DESC LIMIT ?`, + limit, + ) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var entries []SyncLogEntry + for rows.Next() { + var e SyncLogEntry + if err := rows.Scan(&e.ID, &e.EventType, &e.Message, &e.CreatedAt); err != nil { + return nil, err + } + entries = append(entries, e) + } + return entries, rows.Err() +} diff --git a/migrations/016_sync_log.sql b/migrations/016_sync_log.sql new file mode 100644 index 0000000..a281fce --- /dev/null +++ b/migrations/016_sync_log.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS sync_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + message TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/web/templates/partials/sync-log.html b/web/templates/partials/sync-log.html new file mode 100644 index 0000000..e7f8191 --- /dev/null +++ b/web/templates/partials/sync-log.html @@ -0,0 +1,15 @@ +{{define "sync-log"}} +
+ {{if .}} +
+
Sync Activity
+ {{range .}} +
+ {{.CreatedAt.Format "15:04:05"}} + {{.Message}} +
+ {{end}} +
+ {{end}} +
+{{end}} diff --git a/web/templates/settings.html b/web/templates/settings.html index 964d319..4df4cb0 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -157,7 +157,8 @@ .htmx-request .htmx-indicator { display: inline; } - + +
← Back to Dashboard

Settings

@@ -312,12 +313,23 @@

Data Sources

-
- +
+ - +
+ + {{template "sync-log" .SyncLog}}
{{range .Sources}} -- cgit v1.2.3