summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DESIGN.md42
-rw-r--r--SESSION_STATE.md5
-rw-r--r--cmd/dashboard/main.go8
-rw-r--r--internal/api/google_calendar.go21
-rw-r--r--internal/api/google_tasks.go17
-rw-r--r--internal/api/interfaces.go2
-rw-r--r--internal/handlers/settings.go232
-rw-r--r--internal/handlers/timeline_logic_test.go4
-rw-r--r--internal/models/types.go30
-rw-r--r--internal/store/sqlite.go179
-rw-r--r--migrations/012_feature_toggles.sql15
-rw-r--r--migrations/013_source_config.sql13
-rw-r--r--web/templates/settings.html257
13 files changed, 824 insertions, 1 deletions
diff --git a/DESIGN.md b/DESIGN.md
index 87c1f3c..75812ee 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -546,6 +546,48 @@ func TestHandler_HandleDashboard(t *testing.T) {
7. **Don't refactor working code** - Only touch what's needed
8. **Don't alter git history** - Never amend, rebase, or force push. Keep a clean, linear history with new commits only.
+### Feature Toggles
+
+Feature toggles allow gradual rollout of new features and easy rollback if issues arise.
+
+**When to use feature toggles:**
+- New features that may need adjustment after deployment
+- Experimental features being tested
+- Features that depend on external services that may be unreliable
+- Any change that could break existing functionality
+
+**Creating a feature toggle:**
+
+1. Add a migration or use the Settings UI at `/settings`
+2. Check the toggle in your handler:
+```go
+if h.store.IsFeatureEnabled("my_feature") {
+ // New behavior
+} else {
+ // Old behavior or disabled state
+}
+```
+
+3. Document the toggle in this file under "Active Feature Toggles"
+
+**Managing feature toggles:**
+- Access the Settings page at `/settings` to view/toggle/create features
+- Store methods: `IsFeatureEnabled()`, `SetFeatureEnabled()`, `CreateFeatureToggle()`
+- Toggles are stored in the `feature_toggles` table
+
+**Lifecycle:**
+1. **Create** toggle (disabled by default) before deploying feature
+2. **Enable** after deployment if tests pass
+3. **Monitor** for issues
+4. **Remove** toggle and conditional code once feature is stable (usually after 1-2 weeks)
+
+**Active Feature Toggles:**
+| Name | Description | Status |
+|------|-------------|--------|
+| `source_config` | Configure which boards/lists/calendars to fetch | Disabled |
+| `calendar_timeline` | Show timeline as a calendar view with time slots | Disabled |
+| `completed_log` | Track and display completed tasks log | Enabled |
+
### Git Practices
**Clean history principles:**
diff --git a/SESSION_STATE.md b/SESSION_STATE.md
index cc637a7..56ff56e 100644
--- a/SESSION_STATE.md
+++ b/SESSION_STATE.md
@@ -1,7 +1,10 @@
# Session State
## Current Focus
-Agent Context API - Refactored for simplicity and clarity
+Implementing bugs from production database
+
+## Recently Completed
+- **#74**: Feature toggles - Added feature toggle system with UI at `/settings`
## Active Feature
**Agent Context API** — `issues/feature_agent_context_api.md`
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 8f87e30..f1428ed 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -232,6 +232,14 @@ func main() {
r.Get("/shopping/mode/{store}", h.HandleShoppingMode)
r.Post("/shopping/mode/{store}/toggle", h.HandleShoppingModeToggle)
+ // Settings
+ r.Get("/settings", h.HandleSettingsPage)
+ r.Post("/settings/sync", h.HandleSyncSources)
+ r.Post("/settings/toggle", h.HandleToggleSourceConfig)
+ r.Post("/settings/features", h.HandleCreateFeature)
+ r.Post("/settings/features/toggle", h.HandleToggleFeature)
+ r.Delete("/settings/features/{name}", h.HandleDeleteFeature)
+
// WebSocket for notifications
r.Get("/ws/notifications", h.HandleWebSocket)
})
diff --git a/internal/api/google_calendar.go b/internal/api/google_calendar.go
index d2d4355..68d423b 100644
--- a/internal/api/google_calendar.go
+++ b/internal/api/google_calendar.go
@@ -175,3 +175,24 @@ func (c *GoogleCalendarClient) GetEventsByDateRange(ctx context.Context, start,
return deduplicateEvents(allEvents), nil
}
+
+// GetCalendarList returns all calendars accessible to the user
+func (c *GoogleCalendarClient) GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error) {
+ list, err := c.srv.CalendarList.List().Do()
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch calendar list: %w", err)
+ }
+
+ var calendars []models.CalendarInfo
+ for _, item := range list.Items {
+ name := item.Summary
+ if name == "" {
+ name = item.Id
+ }
+ calendars = append(calendars, models.CalendarInfo{
+ ID: item.Id,
+ Name: name,
+ })
+ }
+ return calendars, nil
+}
diff --git a/internal/api/google_tasks.go b/internal/api/google_tasks.go
index 0b4d7c2..77a00ed 100644
--- a/internal/api/google_tasks.go
+++ b/internal/api/google_tasks.go
@@ -171,3 +171,20 @@ func (c *GoogleTasksClient) UncompleteTask(ctx context.Context, listID, taskID s
}
return nil
}
+
+// GetTaskLists returns all task lists accessible to the user
+func (c *GoogleTasksClient) GetTaskLists(ctx context.Context) ([]models.TaskListInfo, error) {
+ list, err := c.srv.Tasklists.List().Context(ctx).Do()
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch task lists: %v", err)
+ }
+
+ var lists []models.TaskListInfo
+ for _, item := range list.Items {
+ lists = append(lists, models.TaskListInfo{
+ ID: item.Id,
+ Name: item.Title,
+ })
+ }
+ return lists, nil
+}
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go
index aa351ab..1c102a7 100644
--- a/internal/api/interfaces.go
+++ b/internal/api/interfaces.go
@@ -40,6 +40,7 @@ type PlanToEatAPI interface {
type GoogleCalendarAPI interface {
GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error)
GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error)
+ GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error)
}
// GoogleTasksAPI defines the interface for Google Tasks operations
@@ -48,6 +49,7 @@ type GoogleTasksAPI interface {
GetTasksByDateRange(ctx context.Context, start, end time.Time) ([]models.GoogleTask, error)
CompleteTask(ctx context.Context, listID, taskID string) error
UncompleteTask(ctx context.Context, listID, taskID string) error
+ GetTaskLists(ctx context.Context) ([]models.TaskListInfo, error)
}
// Ensure concrete types implement interfaces
diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go
new file mode 100644
index 0000000..1eabdf5
--- /dev/null
+++ b/internal/handlers/settings.go
@@ -0,0 +1,232 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ "task-dashboard/internal/models"
+)
+
+// HandleSettingsPage renders the settings page
+func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
+ configs, _ := h.store.GetSourceConfigs()
+ toggles, _ := h.store.GetFeatureToggles()
+
+ // Group configs by source
+ bySource := make(map[string][]models.SourceConfig)
+ for _, cfg := range configs {
+ bySource[cfg.Source] = append(bySource[cfg.Source], cfg)
+ }
+
+ data := struct {
+ Configs map[string][]models.SourceConfig
+ Sources []string
+ Toggles []models.FeatureToggle
+ }{
+ Configs: bySource,
+ Sources: []string{"trello", "todoist", "gcal", "gtasks"},
+ Toggles: toggles,
+ }
+
+ if err := h.templates.ExecuteTemplate(w, "settings.html", data); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to render settings", err)
+ }
+}
+
+// HandleSyncSources fetches available items from all sources and syncs to config
+func (h *Handler) HandleSyncSources(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ // Sync Trello boards
+ if h.trelloClient != nil {
+ boards, err := h.trelloClient.GetBoards(ctx)
+ if err == nil {
+ var items []models.SourceConfig
+ for _, b := range boards {
+ items = append(items, models.SourceConfig{
+ Source: "trello",
+ ItemType: "board",
+ ItemID: b.ID,
+ ItemName: b.Name,
+ })
+ }
+ _ = h.store.SyncSourceConfigs("trello", "board", items)
+ }
+ }
+
+ // 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 Google Calendar calendars
+ if h.googleCalendarClient != nil {
+ calendars, err := h.googleCalendarClient.GetCalendarList(ctx)
+ if err == nil {
+ var items []models.SourceConfig
+ for _, c := range calendars {
+ items = append(items, models.SourceConfig{
+ Source: "gcal",
+ ItemType: "calendar",
+ ItemID: c.ID,
+ ItemName: c.Name,
+ })
+ }
+ _ = h.store.SyncSourceConfigs("gcal", "calendar", items)
+ }
+ }
+
+ // Sync Google Tasks lists
+ if h.googleTasksClient != nil {
+ lists, err := h.googleTasksClient.GetTaskLists(ctx)
+ if err == nil {
+ var items []models.SourceConfig
+ for _, l := range lists {
+ items = append(items, models.SourceConfig{
+ Source: "gtasks",
+ ItemType: "tasklist",
+ ItemID: l.ID,
+ ItemName: l.Name,
+ })
+ }
+ _ = h.store.SyncSourceConfigs("gtasks", "tasklist", items)
+ }
+ }
+
+ // Return updated configs
+ h.HandleSettingsPage(w, r)
+}
+
+// HandleToggleSourceConfig toggles a source config item
+func (h *Handler) HandleToggleSourceConfig(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
+ return
+ }
+
+ source := r.FormValue("source")
+ itemType := r.FormValue("item_type")
+ itemID := r.FormValue("item_id")
+ enabled := r.FormValue("enabled") == "true"
+
+ if source == "" || itemType == "" || itemID == "" {
+ JSONError(w, http.StatusBadRequest, "Missing required fields", nil)
+ return
+ }
+
+ if err := h.store.SetSourceConfigEnabled(source, itemType, itemID, enabled); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to update config", err)
+ return
+ }
+
+ // Invalidate relevant cache
+ switch source {
+ case "trello":
+ _ = h.store.InvalidateCache("trello_boards")
+ case "todoist":
+ _ = h.store.InvalidateCache("todoist_tasks")
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]bool{"enabled": enabled})
+}
+
+// HandleGetSourceOptions returns available options for a source (HTMX partial)
+func (h *Handler) HandleGetSourceOptions(w http.ResponseWriter, r *http.Request) {
+ source := chi.URLParam(r, "source")
+ if source == "" {
+ JSONError(w, http.StatusBadRequest, "Source required", nil)
+ return
+ }
+
+ configs, err := h.store.GetSourceConfigsBySource(source)
+ if err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to load configs", err)
+ return
+ }
+
+ data := struct {
+ Source string
+ Configs []models.SourceConfig
+ }{source, configs}
+
+ HTMLResponse(w, h.templates, "settings-source-options", data)
+}
+
+// HandleToggleFeature toggles a feature flag
+func (h *Handler) HandleToggleFeature(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
+ return
+ }
+
+ name := r.FormValue("name")
+ enabled := r.FormValue("enabled") == "true"
+
+ if name == "" {
+ JSONError(w, http.StatusBadRequest, "Feature name required", nil)
+ return
+ }
+
+ if err := h.store.SetFeatureEnabled(name, enabled); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to update feature", err)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]bool{"enabled": enabled})
+}
+
+// HandleCreateFeature creates a new feature toggle
+func (h *Handler) HandleCreateFeature(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
+ return
+ }
+
+ name := r.FormValue("name")
+ description := r.FormValue("description")
+
+ if name == "" {
+ JSONError(w, http.StatusBadRequest, "Feature name required", nil)
+ return
+ }
+
+ if err := h.store.CreateFeatureToggle(name, description, false); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to create feature", err)
+ return
+ }
+
+ // Return updated toggles list
+ h.HandleSettingsPage(w, r)
+}
+
+// HandleDeleteFeature removes a feature toggle
+func (h *Handler) HandleDeleteFeature(w http.ResponseWriter, r *http.Request) {
+ name := chi.URLParam(r, "name")
+ if name == "" {
+ JSONError(w, http.StatusBadRequest, "Feature name required", nil)
+ return
+ }
+
+ if err := h.store.DeleteFeatureToggle(name); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to delete feature", err)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go
index 5d0a425..5fe995f 100644
--- a/internal/handlers/timeline_logic_test.go
+++ b/internal/handlers/timeline_logic_test.go
@@ -27,6 +27,10 @@ func (m *MockCalendarClient) GetEventsByDateRange(ctx context.Context, start, en
return m.Events, m.Err
}
+func (m *MockCalendarClient) GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error) {
+ return nil, m.Err
+}
+
func setupTestStore(t *testing.T) *store.Store {
t.Helper()
tempDir := t.TempDir()
diff --git a/internal/models/types.go b/internal/models/types.go
index e28d985..ab06ea2 100644
--- a/internal/models/types.go
+++ b/internal/models/types.go
@@ -114,6 +114,18 @@ type GoogleTask struct {
UpdatedAt time.Time `json:"updated_at"`
}
+// CalendarInfo represents basic info about a Google Calendar
+type CalendarInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// TaskListInfo represents basic info about a Google Tasks list
+type TaskListInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
// Bug represents a bug report
type Bug struct {
ID int64 `json:"id"`
@@ -207,3 +219,21 @@ type CompletedTask struct {
DueDate *time.Time `json:"due_date,omitempty"`
CompletedAt time.Time `json:"completed_at"`
}
+
+// SourceConfig represents a configurable item from a data source
+type SourceConfig struct {
+ ID int64 `json:"id"`
+ Source string `json:"source"` // trello, todoist, gcal, gtasks
+ ItemType string `json:"item_type"` // board, project, calendar, tasklist
+ ItemID string `json:"item_id"`
+ ItemName string `json:"item_name"`
+ Enabled bool `json:"enabled"`
+}
+
+// FeatureToggle represents a feature flag
+type FeatureToggle struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Enabled bool `json:"enabled"`
+}
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index 48bcae5..465c3c1 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -1193,3 +1193,182 @@ func (s *Store) GetCompletedTasks(limit int) ([]models.CompletedTask, error) {
}
return tasks, rows.Err()
}
+
+// Source configuration
+
+// GetSourceConfigs retrieves all source configurations
+func (s *Store) GetSourceConfigs() ([]models.SourceConfig, error) {
+ rows, err := s.db.Query(`
+ SELECT id, source, item_type, item_id, item_name, enabled
+ FROM source_config
+ ORDER BY source, item_type, item_name
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var configs []models.SourceConfig
+ for rows.Next() {
+ var cfg models.SourceConfig
+ if err := rows.Scan(&cfg.ID, &cfg.Source, &cfg.ItemType, &cfg.ItemID, &cfg.ItemName, &cfg.Enabled); err != nil {
+ return nil, err
+ }
+ configs = append(configs, cfg)
+ }
+ return configs, rows.Err()
+}
+
+// GetSourceConfigsBySource retrieves configurations for a specific source
+func (s *Store) GetSourceConfigsBySource(source string) ([]models.SourceConfig, error) {
+ rows, err := s.db.Query(`
+ SELECT id, source, item_type, item_id, item_name, enabled
+ FROM source_config
+ WHERE source = ?
+ ORDER BY item_type, item_name
+ `, source)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var configs []models.SourceConfig
+ for rows.Next() {
+ var cfg models.SourceConfig
+ if err := rows.Scan(&cfg.ID, &cfg.Source, &cfg.ItemType, &cfg.ItemID, &cfg.ItemName, &cfg.Enabled); err != nil {
+ return nil, err
+ }
+ configs = append(configs, cfg)
+ }
+ return configs, rows.Err()
+}
+
+// GetEnabledSourceIDs returns enabled item IDs for a source and type
+func (s *Store) GetEnabledSourceIDs(source, itemType string) ([]string, error) {
+ rows, err := s.db.Query(`
+ SELECT item_id FROM source_config
+ WHERE source = ? AND item_type = ? AND enabled = 1
+ `, source, itemType)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ return ids, rows.Err()
+}
+
+// UpsertSourceConfig creates or updates a source configuration
+func (s *Store) UpsertSourceConfig(cfg models.SourceConfig) error {
+ _, err := s.db.Exec(`
+ INSERT INTO source_config (source, item_type, item_id, item_name, enabled, updated_at)
+ VALUES (?, ?, ?, ?, ?, datetime('now', 'localtime'))
+ ON CONFLICT(source, item_type, item_id) DO UPDATE SET
+ item_name = excluded.item_name,
+ enabled = excluded.enabled,
+ updated_at = datetime('now', 'localtime')
+ `, cfg.Source, cfg.ItemType, cfg.ItemID, cfg.ItemName, cfg.Enabled)
+ return err
+}
+
+// SetSourceConfigEnabled updates the enabled state for a config item
+func (s *Store) SetSourceConfigEnabled(source, itemType, itemID string, enabled bool) error {
+ _, err := s.db.Exec(`
+ UPDATE source_config SET enabled = ?, updated_at = datetime('now', 'localtime')
+ WHERE source = ? AND item_type = ? AND item_id = ?
+ `, enabled, source, itemType, itemID)
+ return err
+}
+
+// SyncSourceConfigs updates the config table with available items from a source
+func (s *Store) SyncSourceConfigs(source, itemType string, items []models.SourceConfig) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ for _, item := range items {
+ // Insert new items as enabled by default, preserve existing enabled state
+ _, err := tx.Exec(`
+ INSERT INTO source_config (source, item_type, item_id, item_name, enabled, updated_at)
+ VALUES (?, ?, ?, ?, 1, datetime('now', 'localtime'))
+ ON CONFLICT(source, item_type, item_id) DO UPDATE SET
+ item_name = excluded.item_name,
+ updated_at = datetime('now', 'localtime')
+ `, source, itemType, item.ItemID, item.ItemName)
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// Feature toggles
+
+// GetFeatureToggles returns all feature toggles
+func (s *Store) GetFeatureToggles() ([]models.FeatureToggle, error) {
+ rows, err := s.db.Query(`
+ SELECT id, name, description, enabled FROM feature_toggles ORDER BY name
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var toggles []models.FeatureToggle
+ for rows.Next() {
+ var t models.FeatureToggle
+ var desc sql.NullString
+ if err := rows.Scan(&t.ID, &t.Name, &desc, &t.Enabled); err != nil {
+ return nil, err
+ }
+ if desc.Valid {
+ t.Description = desc.String
+ }
+ toggles = append(toggles, t)
+ }
+ return toggles, rows.Err()
+}
+
+// IsFeatureEnabled checks if a feature toggle is enabled
+func (s *Store) IsFeatureEnabled(name string) bool {
+ var enabled bool
+ err := s.db.QueryRow(`SELECT enabled FROM feature_toggles WHERE name = ?`, name).Scan(&enabled)
+ if err != nil {
+ return false
+ }
+ return enabled
+}
+
+// SetFeatureEnabled updates a feature toggle's enabled state
+func (s *Store) SetFeatureEnabled(name string, enabled bool) error {
+ _, err := s.db.Exec(`
+ UPDATE feature_toggles SET enabled = ?, updated_at = datetime('now', 'localtime')
+ WHERE name = ?
+ `, enabled, name)
+ return err
+}
+
+// CreateFeatureToggle creates a new feature toggle
+func (s *Store) CreateFeatureToggle(name, description string, enabled bool) error {
+ _, err := s.db.Exec(`
+ INSERT INTO feature_toggles (name, description, enabled)
+ VALUES (?, ?, ?)
+ `, name, description, enabled)
+ return err
+}
+
+// DeleteFeatureToggle removes a feature toggle
+func (s *Store) DeleteFeatureToggle(name string) error {
+ _, err := s.db.Exec(`DELETE FROM feature_toggles WHERE name = ?`, name)
+ return err
+}
diff --git a/migrations/012_feature_toggles.sql b/migrations/012_feature_toggles.sql
new file mode 100644
index 0000000..db74588
--- /dev/null
+++ b/migrations/012_feature_toggles.sql
@@ -0,0 +1,15 @@
+-- Feature toggles for gradual rollout and experimentation
+CREATE TABLE IF NOT EXISTS feature_toggles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ description TEXT,
+ enabled INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
+);
+
+-- Insert some initial toggles
+INSERT OR IGNORE INTO feature_toggles (name, description, enabled) VALUES
+ ('source_config', 'Configure which boards/lists/calendars to fetch from each source', 0),
+ ('calendar_timeline', 'Show timeline as a calendar view with time slots', 0),
+ ('completed_log', 'Track and display completed tasks log', 1);
diff --git a/migrations/013_source_config.sql b/migrations/013_source_config.sql
new file mode 100644
index 0000000..7083d33
--- /dev/null
+++ b/migrations/013_source_config.sql
@@ -0,0 +1,13 @@
+-- Source configuration for selecting which items to fetch
+CREATE TABLE IF NOT EXISTS source_config (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source TEXT NOT NULL, -- 'trello', 'todoist', 'gcal', 'gtasks'
+ item_type TEXT NOT NULL, -- 'board', 'project', 'calendar', 'tasklist'
+ item_id TEXT NOT NULL, -- ID from the source
+ item_name TEXT NOT NULL, -- Display name
+ enabled INTEGER NOT NULL DEFAULT 1,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
+ UNIQUE(source, item_type, item_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_source_config_source ON source_config(source, enabled);
diff --git a/web/templates/settings.html b/web/templates/settings.html
new file mode 100644
index 0000000..db84860
--- /dev/null
+++ b/web/templates/settings.html
@@ -0,0 +1,257 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Settings - Task Dashboard</title>
+ <script src="https://unpkg.com/htmx.org@1.9.10"></script>
+ <style>
+ :root {
+ --bg-primary: #1a1a2e;
+ --bg-secondary: #16213e;
+ --bg-tertiary: #0f3460;
+ --text-primary: #eee;
+ --text-secondary: #888;
+ --accent: #e94560;
+ --success: #28a745;
+ --danger: #dc3545;
+ --border: #333;
+ }
+ body {
+ font-family: system-ui, sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ margin: 0;
+ padding: 20px;
+ min-height: 100vh;
+ }
+ .container { max-width: 900px; margin: 0 auto; }
+ h1 { color: var(--accent); margin-bottom: 8px; }
+ h2 { color: var(--text-primary); font-size: 1.1em; margin: 24px 0 16px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
+ .subtitle { color: var(--text-secondary); margin-bottom: 24px; }
+ .back-link { color: var(--accent); text-decoration: none; display: inline-block; margin-bottom: 16px; }
+ .back-link:hover { text-decoration: underline; }
+
+ .card {
+ background: var(--bg-secondary);
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 16px;
+ }
+ .card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+ }
+ .card-title {
+ font-size: 1.1em;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+ .source-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 0.7em;
+ font-weight: normal;
+ }
+ .source-trello { background: #0079bf; }
+ .source-todoist { background: #e44332; }
+ .source-gcal { background: #9b59b6; }
+ .source-gtasks { background: #f39c12; }
+
+ .btn {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.9em;
+ }
+ .btn:hover { background: #1a4a80; }
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
+ .btn-danger { border-color: var(--danger); }
+ .btn-danger:hover { background: var(--danger); }
+ .btn-sm { padding: 4px 8px; font-size: 0.8em; }
+
+ .items-list { display: grid; gap: 8px; }
+ .item-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ background: var(--bg-tertiary);
+ border-radius: 4px;
+ }
+ .item-row:hover { background: #1a4a80; }
+
+ .toggle-switch {
+ position: relative;
+ width: 44px;
+ height: 24px;
+ flex-shrink: 0;
+ }
+ .toggle-switch input { opacity: 0; width: 0; height: 0; }
+ .toggle-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background-color: #555;
+ transition: .3s;
+ border-radius: 24px;
+ }
+ .toggle-slider:before {
+ position: absolute;
+ content: "";
+ height: 18px;
+ width: 18px;
+ left: 3px;
+ bottom: 3px;
+ background-color: white;
+ transition: .3s;
+ border-radius: 50%;
+ }
+ input:checked + .toggle-slider { background-color: var(--success); }
+ input:checked + .toggle-slider:before { transform: translateX(20px); }
+
+ .item-name { flex: 1; }
+ .item-desc { color: var(--text-secondary); font-size: 0.85em; }
+ .item-id {
+ font-family: monospace;
+ font-size: 0.75em;
+ color: var(--text-secondary);
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .empty-state {
+ color: var(--text-secondary);
+ padding: 20px;
+ text-align: center;
+ }
+
+ .add-form {
+ display: flex;
+ gap: 8px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+ }
+ .add-form input {
+ flex: 1;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ color: var(--text-primary);
+ padding: 8px 12px;
+ border-radius: 4px;
+ }
+ .add-form input:focus { outline: none; border-color: var(--accent); }
+
+ .htmx-indicator { display: none; }
+ .htmx-request .htmx-indicator { display: inline; }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <a href="/" class="back-link">&larr; Back to Dashboard</a>
+ <h1>Settings</h1>
+ <p class="subtitle">Configure feature toggles and data sources.</p>
+
+ <!-- Feature Toggles Section -->
+ <div class="card">
+ <div class="card-header">
+ <div class="card-title">Feature Toggles</div>
+ </div>
+ <div class="items-list" id="toggles-list">
+ {{if .Toggles}}
+ {{range .Toggles}}
+ <div class="item-row" id="toggle-{{.Name}}">
+ <label class="toggle-switch">
+ <input type="checkbox"
+ {{if .Enabled}}checked{{end}}
+ hx-post="/settings/features/toggle"
+ hx-vals='{"name": "{{.Name}}", "enabled": "{{if .Enabled}}false{{else}}true{{end}}"}'
+ hx-swap="none"
+ hx-on::after-request="this.checked = !this.checked; if(event.detail.successful) this.checked = !this.checked;">
+ <span class="toggle-slider"></span>
+ </label>
+ <div class="item-name">
+ <strong>{{.Name}}</strong>
+ {{if .Description}}<div class="item-desc">{{.Description}}</div>{{end}}
+ </div>
+ <button class="btn btn-danger btn-sm"
+ hx-delete="/settings/features/{{.Name}}"
+ hx-target="#toggle-{{.Name}}"
+ hx-swap="outerHTML"
+ hx-confirm="Delete feature toggle '{{.Name}}'?">
+ Delete
+ </button>
+ </div>
+ {{end}}
+ {{else}}
+ <div class="empty-state">No feature toggles configured.</div>
+ {{end}}
+ </div>
+ <form class="add-form" hx-post="/settings/features" hx-target="#toggles-list" hx-swap="beforeend">
+ <input type="text" name="name" placeholder="Feature name (snake_case)" required pattern="[a-z_]+">
+ <input type="text" name="description" placeholder="Description (optional)">
+ <button type="submit" class="btn">Add Toggle</button>
+ </form>
+ </div>
+
+ <!-- Source Configuration Section -->
+ <h2>Data Sources</h2>
+ <form hx-post="/settings/sync" hx-swap="outerHTML" hx-target="#sources-container" hx-indicator=".sync-indicator">
+ <button type="submit" class="btn">
+ <span class="sync-indicator htmx-indicator">Syncing...</span>
+ Sync Available Sources
+ </button>
+ </form>
+
+ <div id="sources-container" style="margin-top: 16px;">
+ {{range .Sources}}
+ <div class="card">
+ <div class="card-header">
+ <div class="card-title">
+ <span class="source-badge source-{{.}}">{{.}}</span>
+ {{if eq . "trello"}}Trello Boards{{end}}
+ {{if eq . "todoist"}}Todoist Projects{{end}}
+ {{if eq . "gcal"}}Google Calendars{{end}}
+ {{if eq . "gtasks"}}Google Task Lists{{end}}
+ </div>
+ </div>
+ <div class="items-list">
+ {{$configs := index $.Configs .}}
+ {{if $configs}}
+ {{range $configs}}
+ <div class="item-row">
+ <label class="toggle-switch">
+ <input type="checkbox"
+ {{if .Enabled}}checked{{end}}
+ hx-post="/settings/toggle"
+ hx-vals='{"source": "{{.Source}}", "item_type": "{{.ItemType}}", "item_id": "{{.ItemID}}", "enabled": "{{if .Enabled}}false{{else}}true{{end}}"}'
+ hx-swap="none"
+ hx-on::after-request="this.checked = !this.checked; if(event.detail.successful) this.checked = !this.checked;">
+ <span class="toggle-slider"></span>
+ </label>
+ <span class="item-name">{{.ItemName}}</span>
+ <span class="item-id" title="{{.ItemID}}">{{.ItemID}}</span>
+ </div>
+ {{end}}
+ {{else}}
+ <div class="empty-state">
+ No items configured. Click "Sync Available Sources" to fetch.
+ </div>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ </div>
+ </div>
+</body>
+</html>