diff options
| -rw-r--r-- | DESIGN.md | 42 | ||||
| -rw-r--r-- | SESSION_STATE.md | 5 | ||||
| -rw-r--r-- | cmd/dashboard/main.go | 8 | ||||
| -rw-r--r-- | internal/api/google_calendar.go | 21 | ||||
| -rw-r--r-- | internal/api/google_tasks.go | 17 | ||||
| -rw-r--r-- | internal/api/interfaces.go | 2 | ||||
| -rw-r--r-- | internal/handlers/settings.go | 232 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 4 | ||||
| -rw-r--r-- | internal/models/types.go | 30 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 179 | ||||
| -rw-r--r-- | migrations/012_feature_toggles.sql | 15 | ||||
| -rw-r--r-- | migrations/013_source_config.sql | 13 | ||||
| -rw-r--r-- | web/templates/settings.html | 257 |
13 files changed, 824 insertions, 1 deletions
@@ -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">← 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> |
