From cbb0b53de1d06918c142171fd084f14f03798bc1 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 31 Jan 2026 20:16:12 -1000 Subject: Add feature toggles system with settings UI (#74) - Add feature_toggles table (migration 012) - Add source_config table for future source selection (migration 013) - Create settings page at /settings with: - Feature toggle management (enable/disable/create/delete) - Data source configuration (sync and toggle boards/calendars) - Add store methods for feature toggles and source config - Add GetCalendarList and GetTaskLists to Google API clients - Document feature toggle workflow in DESIGN.md Co-Authored-By: Claude Opus 4.5 --- internal/handlers/settings.go | 232 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 internal/handlers/settings.go (limited to 'internal/handlers/settings.go') 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) +} -- cgit v1.2.3