summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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
7 files changed, 485 insertions, 0 deletions
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
+}