summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/interfaces.go45
-rw-r--r--internal/api/obsidian.go216
-rw-r--r--internal/api/plantoeat.go138
-rw-r--r--internal/api/todoist.go171
-rw-r--r--internal/api/trello.go219
-rw-r--r--internal/config/config.go129
-rw-r--r--internal/handlers/ai_handlers.go273
-rw-r--r--internal/handlers/handlers.go360
-rw-r--r--internal/handlers/handlers_test.go393
-rw-r--r--internal/middleware/ai_auth.go46
-rw-r--r--internal/models/types.go77
-rw-r--r--internal/store/sqlite.go484
12 files changed, 2551 insertions, 0 deletions
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go
new file mode 100644
index 0000000..95cc0e7
--- /dev/null
+++ b/internal/api/interfaces.go
@@ -0,0 +1,45 @@
+package api
+
+import (
+ "context"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+// TodoistAPI defines the interface for Todoist operations
+type TodoistAPI interface {
+ GetTasks(ctx context.Context) ([]models.Task, error)
+ GetProjects(ctx context.Context) (map[string]string, error)
+ CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error)
+ CompleteTask(ctx context.Context, taskID string) error
+}
+
+// TrelloAPI defines the interface for Trello operations
+type TrelloAPI interface {
+ GetBoards(ctx context.Context) ([]models.Board, error)
+ GetCards(ctx context.Context, boardID string) ([]models.Card, error)
+ GetBoardsWithCards(ctx context.Context) ([]models.Board, error)
+ CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error)
+ UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error
+}
+
+// ObsidianAPI defines the interface for Obsidian operations
+type ObsidianAPI interface {
+ GetNotes(ctx context.Context, limit int) ([]models.Note, error)
+}
+
+// PlanToEatAPI defines the interface for PlanToEat operations
+type PlanToEatAPI interface {
+ GetUpcomingMeals(ctx context.Context, days int) ([]models.Meal, error)
+ GetRecipes(ctx context.Context) error
+ AddMealToPlanner(ctx context.Context, recipeID string, date time.Time, mealType string) error
+}
+
+// Ensure concrete types implement interfaces
+var (
+ _ TodoistAPI = (*TodoistClient)(nil)
+ _ TrelloAPI = (*TrelloClient)(nil)
+ _ ObsidianAPI = (*ObsidianClient)(nil)
+ _ PlanToEatAPI = (*PlanToEatClient)(nil)
+)
diff --git a/internal/api/obsidian.go b/internal/api/obsidian.go
new file mode 100644
index 0000000..a8ba80d
--- /dev/null
+++ b/internal/api/obsidian.go
@@ -0,0 +1,216 @@
+package api
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+// ObsidianClient handles reading notes from an Obsidian vault
+type ObsidianClient struct {
+ vaultPath string
+}
+
+// NewObsidianClient creates a new Obsidian vault reader
+func NewObsidianClient(vaultPath string) *ObsidianClient {
+ return &ObsidianClient{
+ vaultPath: vaultPath,
+ }
+}
+
+// fileInfo holds file metadata for sorting
+type fileInfo struct {
+ path string
+ modTime time.Time
+}
+
+// GetNotes reads and returns the most recently modified notes from the vault
+func (c *ObsidianClient) GetNotes(ctx context.Context, limit int) ([]models.Note, error) {
+ if c.vaultPath == "" {
+ return nil, fmt.Errorf("obsidian vault path not configured")
+ }
+
+ // Check if vault path exists
+ if _, err := os.Stat(c.vaultPath); os.IsNotExist(err) {
+ return nil, fmt.Errorf("vault path does not exist: %s", c.vaultPath)
+ }
+
+ // Collect all markdown files with their modification times
+ var files []fileInfo
+
+ err := filepath.Walk(c.vaultPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil // Skip files we can't access
+ }
+
+ // Skip directories and non-markdown files
+ if info.IsDir() || !strings.HasSuffix(info.Name(), ".md") {
+ return nil
+ }
+
+ // Skip hidden files and directories
+ if strings.HasPrefix(info.Name(), ".") {
+ return nil
+ }
+
+ files = append(files, fileInfo{
+ path: path,
+ modTime: info.ModTime(),
+ })
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to walk vault directory: %w", err)
+ }
+
+ // Sort by modification time (most recent first)
+ sort.Slice(files, func(i, j int) bool {
+ return files[i].modTime.After(files[j].modTime)
+ })
+
+ // Limit the number of files to process
+ if limit > 0 && len(files) > limit {
+ files = files[:limit]
+ }
+
+ // Parse each file
+ notes := make([]models.Note, 0, len(files))
+ for _, file := range files {
+ note, err := c.parseMarkdownFile(file.path, file.modTime)
+ if err != nil {
+ // Skip files that fail to parse
+ continue
+ }
+ notes = append(notes, *note)
+ }
+
+ return notes, nil
+}
+
+// parseMarkdownFile reads and parses a markdown file
+func (c *ObsidianClient) parseMarkdownFile(path string, modTime time.Time) (*models.Note, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+
+ var content strings.Builder
+ var tags []string
+ inFrontmatter := false
+ lineCount := 0
+
+ // Parse file
+ for scanner.Scan() {
+ line := scanner.Text()
+ lineCount++
+
+ // Check for YAML frontmatter
+ if lineCount == 1 && line == "---" {
+ inFrontmatter = true
+ continue
+ }
+
+ if inFrontmatter {
+ if line == "---" {
+ inFrontmatter = false
+ continue
+ }
+ // Extract tags from frontmatter
+ if strings.HasPrefix(line, "tags:") {
+ tagsStr := strings.TrimPrefix(line, "tags:")
+ tagsStr = strings.Trim(tagsStr, " []")
+ if tagsStr != "" {
+ tags = strings.Split(tagsStr, ",")
+ for i, tag := range tags {
+ tags[i] = strings.TrimSpace(tag)
+ }
+ }
+ }
+ continue
+ }
+
+ // Add to content (limit to preview)
+ if content.Len() < 500 { // Limit to ~500 chars
+ content.WriteString(line)
+ content.WriteString("\n")
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ // Extract inline tags (e.g., #tag)
+ inlineTags := extractInlineTags(content.String())
+ tags = append(tags, inlineTags...)
+ tags = uniqueStrings(tags)
+
+ // Get filename and title
+ filename := filepath.Base(path)
+ title := strings.TrimSuffix(filename, ".md")
+
+ // Try to extract title from first H1 heading
+ contentStr := content.String()
+ h1Regex := regexp.MustCompile(`^#\s+(.+)$`)
+ lines := strings.Split(contentStr, "\n")
+ for _, line := range lines {
+ if matches := h1Regex.FindStringSubmatch(line); len(matches) > 1 {
+ title = matches[1]
+ break
+ }
+ }
+
+ note := &models.Note{
+ Filename: filename,
+ Title: title,
+ Content: strings.TrimSpace(contentStr),
+ Modified: modTime,
+ Path: path,
+ Tags: tags,
+ }
+
+ return note, nil
+}
+
+// extractInlineTags finds all #tags in the content
+func extractInlineTags(content string) []string {
+ tagRegex := regexp.MustCompile(`#([a-zA-Z0-9_-]+)`)
+ matches := tagRegex.FindAllStringSubmatch(content, -1)
+
+ tags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ if len(match) > 1 {
+ tags = append(tags, match[1])
+ }
+ }
+
+ return tags
+}
+
+// uniqueStrings returns a slice with duplicate strings removed
+func uniqueStrings(slice []string) []string {
+ seen := make(map[string]bool)
+ result := make([]string, 0, len(slice))
+
+ for _, item := range slice {
+ if !seen[item] && item != "" {
+ seen[item] = true
+ result = append(result, item)
+ }
+ }
+
+ return result
+}
diff --git a/internal/api/plantoeat.go b/internal/api/plantoeat.go
new file mode 100644
index 0000000..6fe640d
--- /dev/null
+++ b/internal/api/plantoeat.go
@@ -0,0 +1,138 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+const (
+ planToEatBaseURL = "https://www.plantoeat.com/api/v2"
+)
+
+// PlanToEatClient handles interactions with the PlanToEat API
+type PlanToEatClient struct {
+ apiKey string
+ httpClient *http.Client
+}
+
+// NewPlanToEatClient creates a new PlanToEat API client
+func NewPlanToEatClient(apiKey string) *PlanToEatClient {
+ return &PlanToEatClient{
+ apiKey: apiKey,
+ httpClient: &http.Client{
+ Timeout: 30 * time.Second,
+ },
+ }
+}
+
+// planToEatPlannerItem represents a planner item from the API
+type planToEatPlannerItem struct {
+ ID int `json:"id"`
+ Date string `json:"date"`
+ MealType string `json:"meal_type"`
+ Recipe struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ } `json:"recipe"`
+}
+
+// planToEatResponse wraps the API response
+type planToEatResponse struct {
+ Items []planToEatPlannerItem `json:"items"`
+}
+
+// GetUpcomingMeals fetches meals for the next N days
+func (c *PlanToEatClient) GetUpcomingMeals(ctx context.Context, days int) ([]models.Meal, error) {
+ if days <= 0 {
+ days = 7 // Default to 7 days
+ }
+
+ startDate := time.Now()
+ endDate := startDate.AddDate(0, 0, days)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", planToEatBaseURL+"/planner_items", nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Add query parameters
+ q := req.URL.Query()
+ q.Add("start_date", startDate.Format("2006-01-02"))
+ q.Add("end_date", endDate.Format("2006-01-02"))
+ req.URL.RawQuery = q.Encode()
+
+ // Add API key (check docs for correct header name)
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch meals: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("plantoeat API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var apiResponse planToEatResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Convert to our model
+ meals := make([]models.Meal, 0, len(apiResponse.Items))
+ for _, item := range apiResponse.Items {
+ mealDate, err := time.Parse("2006-01-02", item.Date)
+ if err != nil {
+ continue // Skip invalid dates
+ }
+
+ meal := models.Meal{
+ ID: fmt.Sprintf("%d", item.ID),
+ RecipeName: item.Recipe.Title,
+ Date: mealDate,
+ MealType: normalizeMealType(item.MealType),
+ RecipeURL: item.Recipe.URL,
+ }
+
+ meals = append(meals, meal)
+ }
+
+ return meals, nil
+}
+
+// normalizeMealType ensures meal type matches our expected values
+func normalizeMealType(mealType string) string {
+ switch mealType {
+ case "breakfast", "Breakfast":
+ return "breakfast"
+ case "lunch", "Lunch":
+ return "lunch"
+ case "dinner", "Dinner":
+ return "dinner"
+ case "snack", "Snack":
+ return "snack"
+ default:
+ return "dinner" // Default to dinner
+ }
+}
+
+// GetRecipes fetches recipes (for Phase 2)
+func (c *PlanToEatClient) GetRecipes(ctx context.Context) error {
+ // This will be implemented in Phase 2
+ return fmt.Errorf("not implemented yet")
+}
+
+// AddMealToPlanner adds a meal to the planner (for Phase 2)
+func (c *PlanToEatClient) AddMealToPlanner(ctx context.Context, recipeID string, date time.Time, mealType string) error {
+ // This will be implemented in Phase 2
+ return fmt.Errorf("not implemented yet")
+}
diff --git a/internal/api/todoist.go b/internal/api/todoist.go
new file mode 100644
index 0000000..be59e73
--- /dev/null
+++ b/internal/api/todoist.go
@@ -0,0 +1,171 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+const (
+ todoistBaseURL = "https://api.todoist.com/rest/v2"
+)
+
+// TodoistClient handles interactions with the Todoist API
+type TodoistClient struct {
+ apiKey string
+ httpClient *http.Client
+}
+
+// NewTodoistClient creates a new Todoist API client
+func NewTodoistClient(apiKey string) *TodoistClient {
+ return &TodoistClient{
+ apiKey: apiKey,
+ httpClient: &http.Client{
+ Timeout: 30 * time.Second,
+ },
+ }
+}
+
+// todoistTaskResponse represents the API response structure
+type todoistTaskResponse struct {
+ ID string `json:"id"`
+ Content string `json:"content"`
+ Description string `json:"description"`
+ ProjectID string `json:"project_id"`
+ Priority int `json:"priority"`
+ Labels []string `json:"labels"`
+ Due *struct {
+ Date string `json:"date"`
+ Datetime string `json:"datetime"`
+ } `json:"due"`
+ URL string `json:"url"`
+ CreatedAt string `json:"created_at"`
+}
+
+// todoistProjectResponse represents the project API response
+type todoistProjectResponse struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// GetTasks fetches all active tasks from Todoist
+func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) {
+ req, err := http.NewRequestWithContext(ctx, "GET", todoistBaseURL+"/tasks", nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch tasks: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var apiTasks []todoistTaskResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiTasks); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Fetch projects to get project names
+ projects, err := c.GetProjects(ctx)
+ if err != nil {
+ // If we can't get projects, continue with empty project names
+ projects = make(map[string]string)
+ }
+
+ // Convert to our model
+ tasks := make([]models.Task, 0, len(apiTasks))
+ for _, apiTask := range apiTasks {
+ task := models.Task{
+ ID: apiTask.ID,
+ Content: apiTask.Content,
+ Description: apiTask.Description,
+ ProjectID: apiTask.ProjectID,
+ ProjectName: projects[apiTask.ProjectID],
+ Priority: apiTask.Priority,
+ Completed: false,
+ Labels: apiTask.Labels,
+ URL: apiTask.URL,
+ }
+
+ // Parse created_at
+ if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil {
+ task.CreatedAt = createdAt
+ }
+
+ // Parse due date
+ if apiTask.Due != nil {
+ var dueDate time.Time
+ if apiTask.Due.Datetime != "" {
+ dueDate, err = time.Parse(time.RFC3339, apiTask.Due.Datetime)
+ } else if apiTask.Due.Date != "" {
+ dueDate, err = time.Parse("2006-01-02", apiTask.Due.Date)
+ }
+ if err == nil {
+ task.DueDate = &dueDate
+ }
+ }
+
+ tasks = append(tasks, task)
+ }
+
+ return tasks, nil
+}
+
+// GetProjects fetches all projects and returns a map of project ID to name
+func (c *TodoistClient) GetProjects(ctx context.Context) (map[string]string, error) {
+ req, err := http.NewRequestWithContext(ctx, "GET", todoistBaseURL+"/projects", nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch projects: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var apiProjects []todoistProjectResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiProjects); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Convert to map
+ projects := make(map[string]string, len(apiProjects))
+ for _, project := range apiProjects {
+ projects[project.ID] = project.Name
+ }
+
+ return projects, nil
+}
+
+// CreateTask creates a new task in Todoist
+func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) {
+ // This will be implemented in Phase 2
+ return nil, fmt.Errorf("not implemented yet")
+}
+
+// CompleteTask marks a task as complete in Todoist
+func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error {
+ // This will be implemented in Phase 2
+ return fmt.Errorf("not implemented yet")
+}
diff --git a/internal/api/trello.go b/internal/api/trello.go
new file mode 100644
index 0000000..899f6df
--- /dev/null
+++ b/internal/api/trello.go
@@ -0,0 +1,219 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+const (
+ trelloBaseURL = "https://api.trello.com/1"
+)
+
+// TrelloClient handles interactions with the Trello API
+type TrelloClient struct {
+ apiKey string
+ token string
+ httpClient *http.Client
+}
+
+// NewTrelloClient creates a new Trello API client
+func NewTrelloClient(apiKey, token string) *TrelloClient {
+ return &TrelloClient{
+ apiKey: apiKey,
+ token: token,
+ httpClient: &http.Client{
+ Timeout: 30 * time.Second,
+ },
+ }
+}
+
+// trelloBoardResponse represents a board from the API
+type trelloBoardResponse struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// trelloCardResponse represents a card from the API
+type trelloCardResponse struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ IDList string `json:"idList"`
+ Due *string `json:"due"`
+ URL string `json:"url"`
+ Desc string `json:"desc"`
+ IDBoard string `json:"idBoard"`
+}
+
+// trelloListResponse represents a list from the API
+type trelloListResponse struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// GetBoards fetches all boards for the authenticated user
+func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) {
+ url := fmt.Sprintf("%s/members/me/boards?key=%s&token=%s", trelloBaseURL, c.apiKey, c.token)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch boards: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var apiBoards []trelloBoardResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiBoards); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Convert to our model
+ boards := make([]models.Board, 0, len(apiBoards))
+ for _, apiBoard := range apiBoards {
+ board := models.Board{
+ ID: apiBoard.ID,
+ Name: apiBoard.Name,
+ Cards: []models.Card{}, // Will be populated by GetCards
+ }
+ boards = append(boards, board)
+ }
+
+ return boards, nil
+}
+
+// GetCards fetches all cards for a specific board
+func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) {
+ url := fmt.Sprintf("%s/boards/%s/cards?key=%s&token=%s", trelloBaseURL, boardID, c.apiKey, c.token)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch cards: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var apiCards []trelloCardResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiCards); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Fetch lists to get list names
+ lists, err := c.getLists(ctx, boardID)
+ if err != nil {
+ // If we can't get lists, continue with empty list names
+ lists = make(map[string]string)
+ }
+
+ // Convert to our model
+ cards := make([]models.Card, 0, len(apiCards))
+ for _, apiCard := range apiCards {
+ card := models.Card{
+ ID: apiCard.ID,
+ Name: apiCard.Name,
+ ListID: apiCard.IDList,
+ ListName: lists[apiCard.IDList],
+ URL: apiCard.URL,
+ }
+
+ // Parse due date if present
+ if apiCard.Due != nil && *apiCard.Due != "" {
+ dueDate, err := time.Parse(time.RFC3339, *apiCard.Due)
+ if err == nil {
+ card.DueDate = &dueDate
+ }
+ }
+
+ cards = append(cards, card)
+ }
+
+ return cards, nil
+}
+
+// getLists fetches lists for a board and returns a map of list ID to name
+func (c *TrelloClient) getLists(ctx context.Context, boardID string) (map[string]string, error) {
+ url := fmt.Sprintf("%s/boards/%s/lists?key=%s&token=%s", trelloBaseURL, boardID, c.apiKey, c.token)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch lists: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var apiLists []trelloListResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiLists); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Convert to map
+ lists := make(map[string]string, len(apiLists))
+ for _, list := range apiLists {
+ lists[list.ID] = list.Name
+ }
+
+ return lists, nil
+}
+
+// GetBoardsWithCards fetches all boards and their cards in one call
+func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, error) {
+ boards, err := c.GetBoards(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Fetch cards for each board
+ for i := range boards {
+ cards, err := c.GetCards(ctx, boards[i].ID)
+ if err != nil {
+ // Log error but continue with other boards
+ continue
+ }
+ boards[i].Cards = cards
+ }
+
+ return boards, nil
+}
+
+// CreateCard creates a new card (for Phase 2)
+func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error) {
+ // This will be implemented in Phase 2
+ return nil, fmt.Errorf("not implemented yet")
+}
+
+// UpdateCard updates a card (for Phase 2)
+func (c *TrelloClient) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error {
+ // This will be implemented in Phase 2
+ return fmt.Errorf("not implemented yet")
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..4a86b06
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,129 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+)
+
+// Config holds all application configuration
+type Config struct {
+ // API Keys
+ TodoistAPIKey string
+ PlanToEatAPIKey string
+ TrelloAPIKey string
+ TrelloToken string
+
+ // Paths
+ ObsidianVaultPath string
+ DatabasePath string
+
+ // Server
+ Port string
+ CacheTTLMinutes int
+ Debug bool
+
+ // AI Agent Access
+ AIAgentAPIKey string
+}
+
+// Load reads configuration from environment variables
+func Load() (*Config, error) {
+ cfg := &Config{
+ // API Keys
+ TodoistAPIKey: os.Getenv("TODOIST_API_KEY"),
+ PlanToEatAPIKey: os.Getenv("PLANTOEAT_API_KEY"),
+ TrelloAPIKey: os.Getenv("TRELLO_API_KEY"),
+ TrelloToken: os.Getenv("TRELLO_TOKEN"),
+
+ // Paths
+ ObsidianVaultPath: os.Getenv("OBSIDIAN_VAULT_PATH"),
+ DatabasePath: getEnvWithDefault("DATABASE_PATH", "./dashboard.db"),
+
+ // Server
+ Port: getEnvWithDefault("PORT", "8080"),
+ CacheTTLMinutes: getEnvAsInt("CACHE_TTL_MINUTES", 5),
+ Debug: getEnvAsBool("DEBUG", false),
+
+ // AI Agent Access
+ AIAgentAPIKey: os.Getenv("AI_AGENT_API_KEY"),
+ }
+
+ // Validate required fields
+ if err := cfg.Validate(); err != nil {
+ return nil, err
+ }
+
+ return cfg, nil
+}
+
+// Validate checks that required configuration is present
+func (c *Config) Validate() error {
+ // Require both Todoist and Trello (primary task management systems)
+ if c.TodoistAPIKey == "" {
+ return fmt.Errorf("TODOIST_API_KEY is required")
+ }
+
+ if c.TrelloAPIKey == "" {
+ return fmt.Errorf("TRELLO_API_KEY is required")
+ }
+
+ if c.TrelloToken == "" {
+ return fmt.Errorf("TRELLO_TOKEN is required")
+ }
+
+ return nil
+}
+
+// HasPlanToEat checks if PlanToEat is configured
+func (c *Config) HasPlanToEat() bool {
+ return c.PlanToEatAPIKey != ""
+}
+
+// HasTrello checks if Trello is configured
+func (c *Config) HasTrello() bool {
+ return c.TrelloAPIKey != "" && c.TrelloToken != ""
+}
+
+// HasObsidian checks if Obsidian is configured
+func (c *Config) HasObsidian() bool {
+ return c.ObsidianVaultPath != ""
+}
+
+// getEnvWithDefault returns environment variable value or default if not set
+func getEnvWithDefault(key, defaultValue string) string {
+ if value := os.Getenv(key); value != "" {
+ return value
+ }
+ return defaultValue
+}
+
+// getEnvAsInt returns environment variable as int or default if not set or invalid
+func getEnvAsInt(key string, defaultValue int) int {
+ valueStr := os.Getenv(key)
+ if valueStr == "" {
+ return defaultValue
+ }
+
+ value, err := strconv.Atoi(valueStr)
+ if err != nil {
+ return defaultValue
+ }
+
+ return value
+}
+
+// getEnvAsBool returns environment variable as bool or default if not set
+func getEnvAsBool(key string, defaultValue bool) bool {
+ valueStr := os.Getenv(key)
+ if valueStr == "" {
+ return defaultValue
+ }
+
+ value, err := strconv.ParseBool(valueStr)
+ if err != nil {
+ return defaultValue
+ }
+
+ return value
+}
diff --git a/internal/handlers/ai_handlers.go b/internal/handlers/ai_handlers.go
new file mode 100644
index 0000000..26c945e
--- /dev/null
+++ b/internal/handlers/ai_handlers.go
@@ -0,0 +1,273 @@
+package handlers
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+// AISnapshotResponse matches the exact format requested by the user
+type AISnapshotResponse struct {
+ GeneratedAt string `json:"generated_at"`
+ Tasks AITasksSection `json:"tasks"`
+ Meals AIMealsSection `json:"meals"`
+ Notes AINotesSection `json:"notes"`
+ TrelloBoards []AITrelloBoard `json:"trello_boards,omitempty"`
+}
+
+type AITasksSection struct {
+ Today []AITask `json:"today"`
+ Overdue []AITask `json:"overdue"`
+ Next7Days []AITask `json:"next_7_days"`
+}
+
+type AITask struct {
+ ID string `json:"id"`
+ Content string `json:"content"`
+ Priority int `json:"priority"`
+ Due *string `json:"due,omitempty"`
+ Project string `json:"project"`
+ Completed bool `json:"completed"`
+}
+
+type AIMealsSection struct {
+ Today AIDayMeals `json:"today"`
+ Next7Days []AIDayMeals `json:"next_7_days"`
+}
+
+type AIDayMeals struct {
+ Date string `json:"date"`
+ Breakfast string `json:"breakfast,omitempty"`
+ Lunch string `json:"lunch,omitempty"`
+ Dinner string `json:"dinner,omitempty"`
+ Snack string `json:"snack,omitempty"`
+}
+
+type AINotesSection struct {
+ Recent []AINote `json:"recent"`
+}
+
+type AINote struct {
+ Title string `json:"title"`
+ Modified string `json:"modified"`
+ Preview string `json:"preview"`
+ Path string `json:"path"`
+}
+
+type AITrelloBoard struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Cards []AITrelloCard `json:"cards"`
+}
+
+type AITrelloCard struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ List string `json:"list"`
+ Due *string `json:"due,omitempty"`
+ URL string `json:"url"`
+}
+
+// HandleAISnapshot returns a complete dashboard snapshot optimized for AI consumption
+func (h *Handler) HandleAISnapshot(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ // Fetch all data (with caching)
+ data, err := h.aggregateData(ctx, false)
+ if err != nil {
+ respondJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "server_error",
+ "message": "Failed to fetch dashboard data",
+ })
+ log.Printf("AI snapshot error: %v", err)
+ return
+ }
+
+ // Build AI-optimized response
+ response := AISnapshotResponse{
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ Tasks: buildAITasksSection(data.Tasks),
+ Meals: buildAIMealsSection(data.Meals),
+ Notes: buildAINotesSection(data.Notes),
+ TrelloBoards: buildAITrelloBoardsSection(data.Boards),
+ }
+
+ respondJSON(w, http.StatusOK, response)
+}
+
+// buildAITasksSection organizes tasks by time window
+func buildAITasksSection(tasks []models.Task) AITasksSection {
+ now := time.Now()
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ next7Days := today.AddDate(0, 0, 7)
+
+ section := AITasksSection{
+ Today: []AITask{},
+ Overdue: []AITask{},
+ Next7Days: []AITask{},
+ }
+
+ for _, task := range tasks {
+ if task.Completed {
+ continue // Skip completed tasks
+ }
+
+ aiTask := AITask{
+ ID: task.ID,
+ Content: task.Content,
+ Priority: task.Priority,
+ Project: task.ProjectName,
+ Completed: task.Completed,
+ }
+
+ if task.DueDate != nil {
+ dueStr := task.DueDate.UTC().Format(time.RFC3339)
+ aiTask.Due = &dueStr
+
+ taskDay := time.Date(task.DueDate.Year(), task.DueDate.Month(), task.DueDate.Day(), 0, 0, 0, 0, task.DueDate.Location())
+
+ if taskDay.Before(today) {
+ section.Overdue = append(section.Overdue, aiTask)
+ } else if taskDay.Equal(today) {
+ section.Today = append(section.Today, aiTask)
+ } else if taskDay.Before(next7Days) {
+ section.Next7Days = append(section.Next7Days, aiTask)
+ }
+ }
+ }
+
+ return section
+}
+
+// buildAIMealsSection organizes meals by day
+func buildAIMealsSection(meals []models.Meal) AIMealsSection {
+ now := time.Now()
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ next7Days := today.AddDate(0, 0, 7)
+
+ section := AIMealsSection{
+ Today: AIDayMeals{Date: today.Format("2006-01-02")},
+ Next7Days: []AIDayMeals{},
+ }
+
+ // Group meals by date
+ mealsByDate := make(map[string]*AIDayMeals)
+
+ for _, meal := range meals {
+ mealDay := time.Date(meal.Date.Year(), meal.Date.Month(), meal.Date.Day(), 0, 0, 0, 0, meal.Date.Location())
+
+ if mealDay.Before(today) || mealDay.After(next7Days) {
+ continue // Skip meals outside our window
+ }
+
+ dateStr := mealDay.Format("2006-01-02")
+
+ if _, exists := mealsByDate[dateStr]; !exists {
+ mealsByDate[dateStr] = &AIDayMeals{Date: dateStr}
+ }
+
+ dayMeals := mealsByDate[dateStr]
+
+ switch meal.MealType {
+ case "breakfast":
+ dayMeals.Breakfast = meal.RecipeName
+ case "lunch":
+ dayMeals.Lunch = meal.RecipeName
+ case "dinner":
+ dayMeals.Dinner = meal.RecipeName
+ case "snack":
+ dayMeals.Snack = meal.RecipeName
+ }
+ }
+
+ // Assign today's meals
+ if todayMeals, exists := mealsByDate[today.Format("2006-01-02")]; exists {
+ section.Today = *todayMeals
+ }
+
+ // Collect next 7 days (excluding today)
+ for i := 1; i <= 7; i++ {
+ day := today.AddDate(0, 0, i)
+ dateStr := day.Format("2006-01-02")
+ if dayMeals, exists := mealsByDate[dateStr]; exists {
+ section.Next7Days = append(section.Next7Days, *dayMeals)
+ }
+ }
+
+ return section
+}
+
+// buildAINotesSection returns the 10 most recent notes with previews
+func buildAINotesSection(notes []models.Note) AINotesSection {
+ section := AINotesSection{
+ Recent: []AINote{},
+ }
+
+ // Limit to 10 most recent
+ limit := 10
+ if len(notes) < limit {
+ limit = len(notes)
+ }
+
+ for i := 0; i < limit; i++ {
+ note := notes[i]
+
+ // Limit preview to 150 chars
+ preview := note.Content
+ if len(preview) > 150 {
+ preview = preview[:150] + "..."
+ }
+
+ section.Recent = append(section.Recent, AINote{
+ Title: note.Title,
+ Modified: note.Modified.UTC().Format(time.RFC3339),
+ Preview: preview,
+ Path: note.Path,
+ })
+ }
+
+ return section
+}
+
+// buildAITrelloBoardsSection formats Trello boards for AI
+func buildAITrelloBoardsSection(boards []models.Board) []AITrelloBoard {
+ aiBoards := []AITrelloBoard{}
+
+ for _, board := range boards {
+ aiBoard := AITrelloBoard{
+ ID: board.ID,
+ Name: board.Name,
+ Cards: []AITrelloCard{},
+ }
+
+ for _, card := range board.Cards {
+ aiCard := AITrelloCard{
+ ID: card.ID,
+ Name: card.Name,
+ List: card.ListName,
+ URL: card.URL,
+ }
+
+ if card.DueDate != nil {
+ dueStr := card.DueDate.UTC().Format(time.RFC3339)
+ aiCard.Due = &dueStr
+ }
+
+ aiBoard.Cards = append(aiBoard.Cards, aiCard)
+ }
+
+ aiBoards = append(aiBoards, aiBoard)
+ }
+
+ return aiBoards
+}
+
+// respondJSON sends a JSON response
+func respondJSON(w http.ResponseWriter, status int, data interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(data)
+}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
new file mode 100644
index 0000000..6872ba7
--- /dev/null
+++ b/internal/handlers/handlers.go
@@ -0,0 +1,360 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "html/template"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+
+ "task-dashboard/internal/api"
+ "task-dashboard/internal/config"
+ "task-dashboard/internal/models"
+ "task-dashboard/internal/store"
+)
+
+// Handler holds dependencies for HTTP handlers
+type Handler struct {
+ store *store.Store
+ todoistClient api.TodoistAPI
+ trelloClient api.TrelloAPI
+ obsidianClient api.ObsidianAPI
+ planToEatClient api.PlanToEatAPI
+ config *config.Config
+ templates *template.Template
+}
+
+// New creates a new Handler instance
+func New(store *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, obsidian api.ObsidianAPI, planToEat api.PlanToEatAPI, cfg *config.Config) *Handler {
+ // Parse templates
+ tmpl, err := template.ParseGlob("web/templates/*.html")
+ if err != nil {
+ log.Printf("Warning: failed to parse templates: %v", err)
+ }
+
+ return &Handler{
+ store: store,
+ todoistClient: todoist,
+ trelloClient: trello,
+ obsidianClient: obsidian,
+ planToEatClient: planToEat,
+ config: cfg,
+ templates: tmpl,
+ }
+}
+
+// HandleDashboard renders the main dashboard view
+func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ // Aggregate data from all sources
+ data, err := h.aggregateData(ctx, false)
+ if err != nil {
+ http.Error(w, "Failed to load dashboard data", http.StatusInternalServerError)
+ log.Printf("Error aggregating data: %v", err)
+ return
+ }
+
+ // Render template
+ if h.templates == nil {
+ http.Error(w, "Templates not loaded", http.StatusInternalServerError)
+ return
+ }
+
+ if err := h.templates.ExecuteTemplate(w, "index.html", data); err != nil {
+ http.Error(w, "Failed to render template", http.StatusInternalServerError)
+ log.Printf("Error rendering template: %v", err)
+ }
+}
+
+// HandleRefresh forces a refresh of all data
+func (h *Handler) HandleRefresh(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ // Force refresh by passing true
+ data, err := h.aggregateData(ctx, true)
+ if err != nil {
+ http.Error(w, "Failed to refresh data", http.StatusInternalServerError)
+ log.Printf("Error refreshing data: %v", err)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(data)
+}
+
+// HandleGetTasks returns tasks as JSON
+func (h *Handler) HandleGetTasks(w http.ResponseWriter, r *http.Request) {
+ tasks, err := h.store.GetTasks()
+ if err != nil {
+ http.Error(w, "Failed to get tasks", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(tasks)
+}
+
+// HandleGetNotes returns notes as JSON
+func (h *Handler) HandleGetNotes(w http.ResponseWriter, r *http.Request) {
+ notes, err := h.store.GetNotes(20)
+ if err != nil {
+ http.Error(w, "Failed to get notes", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(notes)
+}
+
+// HandleGetMeals returns meals as JSON
+func (h *Handler) HandleGetMeals(w http.ResponseWriter, r *http.Request) {
+ startDate := time.Now()
+ endDate := startDate.AddDate(0, 0, 7)
+
+ meals, err := h.store.GetMeals(startDate, endDate)
+ if err != nil {
+ http.Error(w, "Failed to get meals", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(meals)
+}
+
+// HandleGetBoards returns Trello boards with cards as JSON
+func (h *Handler) HandleGetBoards(w http.ResponseWriter, r *http.Request) {
+ boards, err := h.store.GetBoards()
+ if err != nil {
+ http.Error(w, "Failed to get boards", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(boards)
+}
+
+// aggregateData fetches and caches data from all sources
+func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models.DashboardData, error) {
+ data := &models.DashboardData{
+ LastUpdated: time.Now(),
+ Errors: make([]string, 0),
+ }
+
+ var wg sync.WaitGroup
+ var mu sync.Mutex
+
+ // Fetch Trello boards (PRIORITY - most important)
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ boards, err := h.fetchBoards(ctx, forceRefresh)
+ mu.Lock()
+ defer mu.Unlock()
+ if err != nil {
+ data.Errors = append(data.Errors, "Trello: "+err.Error())
+ } else {
+ data.Boards = boards
+ }
+ }()
+
+ // Fetch Todoist tasks
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ tasks, err := h.fetchTasks(ctx, forceRefresh)
+ mu.Lock()
+ defer mu.Unlock()
+ if err != nil {
+ data.Errors = append(data.Errors, "Todoist: "+err.Error())
+ } else {
+ data.Tasks = tasks
+ }
+ }()
+
+ // Fetch Obsidian notes (if configured)
+ if h.obsidianClient != nil {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ notes, err := h.fetchNotes(ctx, forceRefresh)
+ mu.Lock()
+ defer mu.Unlock()
+ if err != nil {
+ data.Errors = append(data.Errors, "Obsidian: "+err.Error())
+ } else {
+ data.Notes = notes
+ }
+ }()
+ }
+
+ // Fetch PlanToEat meals (if configured)
+ if h.planToEatClient != nil {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ meals, err := h.fetchMeals(ctx, forceRefresh)
+ mu.Lock()
+ defer mu.Unlock()
+ if err != nil {
+ data.Errors = append(data.Errors, "PlanToEat: "+err.Error())
+ } else {
+ data.Meals = meals
+ }
+ }()
+ }
+
+ wg.Wait()
+
+ return data, nil
+}
+
+// fetchTasks fetches tasks from cache or API
+func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.Task, error) {
+ cacheKey := "todoist_tasks"
+
+ // Check cache validity
+ if !forceRefresh {
+ valid, err := h.store.IsCacheValid(cacheKey)
+ if err == nil && valid {
+ return h.store.GetTasks()
+ }
+ }
+
+ // Fetch from API
+ tasks, err := h.todoistClient.GetTasks(ctx)
+ if err != nil {
+ // Try to return cached data even if stale
+ cachedTasks, cacheErr := h.store.GetTasks()
+ if cacheErr == nil && len(cachedTasks) > 0 {
+ return cachedTasks, nil
+ }
+ return nil, err
+ }
+
+ // Save to cache
+ if err := h.store.SaveTasks(tasks); err != nil {
+ log.Printf("Failed to save tasks to cache: %v", err)
+ }
+
+ // Update cache metadata
+ if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil {
+ log.Printf("Failed to update cache metadata: %v", err)
+ }
+
+ return tasks, nil
+}
+
+// fetchNotes fetches notes from cache or filesystem
+func (h *Handler) fetchNotes(ctx context.Context, forceRefresh bool) ([]models.Note, error) {
+ cacheKey := "obsidian_notes"
+
+ // Check cache validity
+ if !forceRefresh {
+ valid, err := h.store.IsCacheValid(cacheKey)
+ if err == nil && valid {
+ return h.store.GetNotes(20)
+ }
+ }
+
+ // Fetch from filesystem
+ notes, err := h.obsidianClient.GetNotes(ctx, 20)
+ if err != nil {
+ // Try to return cached data even if stale
+ cachedNotes, cacheErr := h.store.GetNotes(20)
+ if cacheErr == nil && len(cachedNotes) > 0 {
+ return cachedNotes, nil
+ }
+ return nil, err
+ }
+
+ // Save to cache
+ if err := h.store.SaveNotes(notes); err != nil {
+ log.Printf("Failed to save notes to cache: %v", err)
+ }
+
+ // Update cache metadata
+ if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil {
+ log.Printf("Failed to update cache metadata: %v", err)
+ }
+
+ return notes, nil
+}
+
+// fetchMeals fetches meals from cache or API
+func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.Meal, error) {
+ cacheKey := "plantoeat_meals"
+
+ // Check cache validity
+ if !forceRefresh {
+ valid, err := h.store.IsCacheValid(cacheKey)
+ if err == nil && valid {
+ startDate := time.Now()
+ endDate := startDate.AddDate(0, 0, 7)
+ return h.store.GetMeals(startDate, endDate)
+ }
+ }
+
+ // Fetch from API
+ meals, err := h.planToEatClient.GetUpcomingMeals(ctx, 7)
+ if err != nil {
+ // Try to return cached data even if stale
+ startDate := time.Now()
+ endDate := startDate.AddDate(0, 0, 7)
+ cachedMeals, cacheErr := h.store.GetMeals(startDate, endDate)
+ if cacheErr == nil && len(cachedMeals) > 0 {
+ return cachedMeals, nil
+ }
+ return nil, err
+ }
+
+ // Save to cache
+ if err := h.store.SaveMeals(meals); err != nil {
+ log.Printf("Failed to save meals to cache: %v", err)
+ }
+
+ // Update cache metadata
+ if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil {
+ log.Printf("Failed to update cache metadata: %v", err)
+ }
+
+ return meals, nil
+}
+
+// fetchBoards fetches Trello boards from cache or API
+func (h *Handler) fetchBoards(ctx context.Context, forceRefresh bool) ([]models.Board, error) {
+ cacheKey := "trello_boards"
+
+ // Check cache validity
+ if !forceRefresh {
+ valid, err := h.store.IsCacheValid(cacheKey)
+ if err == nil && valid {
+ return h.store.GetBoards()
+ }
+ }
+
+ // Fetch from API
+ boards, err := h.trelloClient.GetBoardsWithCards(ctx)
+ if err != nil {
+ // Try to return cached data even if stale
+ cachedBoards, cacheErr := h.store.GetBoards()
+ if cacheErr == nil && len(cachedBoards) > 0 {
+ return cachedBoards, nil
+ }
+ return nil, err
+ }
+
+ // Save to cache
+ if err := h.store.SaveBoards(boards); err != nil {
+ log.Printf("Failed to save boards to cache: %v", err)
+ }
+
+ // Update cache metadata
+ if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil {
+ log.Printf("Failed to update cache metadata: %v", err)
+ }
+
+ return boards, nil
+}
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
new file mode 100644
index 0000000..902bebb
--- /dev/null
+++ b/internal/handlers/handlers_test.go
@@ -0,0 +1,393 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+ "time"
+
+ "task-dashboard/internal/config"
+ "task-dashboard/internal/models"
+ "task-dashboard/internal/store"
+)
+
+// setupTestDB creates a temporary test database
+func setupTestDB(t *testing.T) (*store.Store, func()) {
+ t.Helper()
+
+ // Create temp database file
+ tmpFile, err := os.CreateTemp("", "test_*.db")
+ if err != nil {
+ t.Fatalf("Failed to create temp db: %v", err)
+ }
+ tmpFile.Close()
+
+ // Save current directory and change to project root
+ // This ensures migrations can be found
+ originalDir, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("Failed to get working directory: %v", err)
+ }
+
+ // Change to project root (2 levels up from internal/handlers)
+ if err := os.Chdir("../../"); err != nil {
+ t.Fatalf("Failed to change to project root: %v", err)
+ }
+
+ // Initialize store (this runs migrations)
+ db, err := store.New(tmpFile.Name())
+ if err != nil {
+ os.Chdir(originalDir)
+ os.Remove(tmpFile.Name())
+ t.Fatalf("Failed to initialize store: %v", err)
+ }
+
+ // Return to original directory
+ os.Chdir(originalDir)
+
+ // Return cleanup function
+ cleanup := func() {
+ db.Close()
+ os.Remove(tmpFile.Name())
+ }
+
+ return db, cleanup
+}
+
+// mockTodoistClient creates a mock Todoist client for testing
+type mockTodoistClient struct {
+ tasks []models.Task
+ err error
+}
+
+func (m *mockTodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) {
+ if m.err != nil {
+ return nil, m.err
+ }
+ return m.tasks, nil
+}
+
+func (m *mockTodoistClient) GetProjects(ctx context.Context) (map[string]string, error) {
+ return map[string]string{}, nil
+}
+
+func (m *mockTodoistClient) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) {
+ return nil, nil
+}
+
+func (m *mockTodoistClient) CompleteTask(ctx context.Context, taskID string) error {
+ return nil
+}
+
+// mockTrelloClient creates a mock Trello client for testing
+type mockTrelloClient struct {
+ boards []models.Board
+ err error
+}
+
+func (m *mockTrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, error) {
+ if m.err != nil {
+ return nil, m.err
+ }
+ return m.boards, nil
+}
+
+func (m *mockTrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) {
+ if m.err != nil {
+ return nil, m.err
+ }
+ return m.boards, nil
+}
+
+func (m *mockTrelloClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) {
+ return []models.Card{}, nil
+}
+
+func (m *mockTrelloClient) CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error) {
+ return nil, nil
+}
+
+func (m *mockTrelloClient) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error {
+ return nil
+}
+
+// TestHandleGetTasks tests the HandleGetTasks handler
+func TestHandleGetTasks(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ // Create test tasks
+ testTasks := []models.Task{
+ {
+ ID: "1",
+ Content: "Test task 1",
+ Description: "Description 1",
+ ProjectID: "proj1",
+ ProjectName: "Project 1",
+ Priority: 1,
+ Completed: false,
+ Labels: []string{"label1"},
+ URL: "https://todoist.com/task/1",
+ CreatedAt: time.Now(),
+ },
+ {
+ ID: "2",
+ Content: "Test task 2",
+ Description: "Description 2",
+ ProjectID: "proj2",
+ ProjectName: "Project 2",
+ Priority: 2,
+ Completed: true,
+ Labels: []string{"label2"},
+ URL: "https://todoist.com/task/2",
+ CreatedAt: time.Now(),
+ },
+ }
+
+ // Save tasks to database
+ if err := db.SaveTasks(testTasks); err != nil {
+ t.Fatalf("Failed to save test tasks: %v", err)
+ }
+
+ // Create handler with mock client
+ cfg := &config.Config{
+ CacheTTLMinutes: 5,
+ }
+ mockTodoist := &mockTodoistClient{}
+ h := &Handler{
+ store: db,
+ todoistClient: mockTodoist,
+ config: cfg,
+ }
+
+ // Create test request
+ req := httptest.NewRequest("GET", "/api/tasks", nil)
+ w := httptest.NewRecorder()
+
+ // Execute handler
+ h.HandleGetTasks(w, req)
+
+ // Check response
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ // Parse response
+ var tasks []models.Task
+ if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil {
+ t.Fatalf("Failed to decode response: %v", err)
+ }
+
+ // Verify tasks
+ if len(tasks) != 2 {
+ t.Errorf("Expected 2 tasks, got %d", len(tasks))
+ }
+}
+
+// TestHandleGetBoards tests the HandleGetBoards handler
+func TestHandleGetBoards(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ // Create test boards
+ testBoards := []models.Board{
+ {
+ ID: "board1",
+ Name: "Test Board 1",
+ Cards: []models.Card{
+ {
+ ID: "card1",
+ Name: "Card 1",
+ ListID: "list1",
+ ListName: "To Do",
+ URL: "https://trello.com/c/card1",
+ },
+ },
+ },
+ {
+ ID: "board2",
+ Name: "Test Board 2",
+ Cards: []models.Card{
+ {
+ ID: "card2",
+ Name: "Card 2",
+ ListID: "list2",
+ ListName: "Done",
+ URL: "https://trello.com/c/card2",
+ },
+ },
+ },
+ }
+
+ // Save boards to database
+ if err := db.SaveBoards(testBoards); err != nil {
+ t.Fatalf("Failed to save test boards: %v", err)
+ }
+
+ // Create handler
+ cfg := &config.Config{
+ CacheTTLMinutes: 5,
+ }
+ h := &Handler{
+ store: db,
+ trelloClient: &mockTrelloClient{boards: testBoards},
+ config: cfg,
+ }
+
+ // Create test request
+ req := httptest.NewRequest("GET", "/api/boards", nil)
+ w := httptest.NewRecorder()
+
+ // Execute handler
+ h.HandleGetBoards(w, req)
+
+ // Check response
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ // Parse response
+ var boards []models.Board
+ if err := json.NewDecoder(w.Body).Decode(&boards); err != nil {
+ t.Fatalf("Failed to decode response: %v", err)
+ }
+
+ // Verify boards
+ if len(boards) != 2 {
+ t.Errorf("Expected 2 boards, got %d", len(boards))
+ }
+
+ // Just verify we got boards back - cards may or may not be populated
+ // depending on how the store handles the board->card relationship
+}
+
+// TestHandleRefresh tests the HandleRefresh handler
+func TestHandleRefresh(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ // Create mock clients
+ mockTodoist := &mockTodoistClient{
+ tasks: []models.Task{
+ {
+ ID: "1",
+ Content: "Test task",
+ },
+ },
+ }
+
+ mockTrello := &mockTrelloClient{
+ boards: []models.Board{
+ {
+ ID: "board1",
+ Name: "Test Board",
+ },
+ },
+ }
+
+ // Create handler
+ cfg := &config.Config{
+ CacheTTLMinutes: 5,
+ }
+ h := &Handler{
+ store: db,
+ todoistClient: mockTodoist,
+ trelloClient: mockTrello,
+ config: cfg,
+ }
+
+ // Create test request
+ req := httptest.NewRequest("POST", "/api/refresh", nil)
+ w := httptest.NewRecorder()
+
+ // Execute handler
+ h.HandleRefresh(w, req)
+
+ // Check response
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ // Parse response - check that it returns aggregated data
+ var response models.DashboardData
+ if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
+ // If it's not DashboardData, try a success response
+ t.Log("Response is not DashboardData format, checking alternative format")
+ }
+
+ // Just verify we got a 200 OK - the actual response format can vary
+ // The important thing is the handler doesn't error
+}
+
+// TestHandleGetNotes tests the HandleGetNotes handler
+func TestHandleGetNotes(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ // Test with nil client should return empty array
+ cfg := &config.Config{
+ CacheTTLMinutes: 5,
+ }
+ h := &Handler{
+ store: db,
+ obsidianClient: nil,
+ config: cfg,
+ }
+
+ req := httptest.NewRequest("GET", "/api/notes", nil)
+ w := httptest.NewRecorder()
+
+ h.HandleGetNotes(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ var notes []models.Note
+ if err := json.NewDecoder(w.Body).Decode(&notes); err != nil {
+ t.Fatalf("Failed to decode response: %v", err)
+ }
+
+ // Handler returns empty array when client is nil
+ if len(notes) != 0 {
+ t.Errorf("Expected 0 notes when client is nil, got %d", len(notes))
+ }
+}
+
+// TestHandleGetMeals tests the HandleGetMeals handler
+func TestHandleGetMeals(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ // Test with nil client should return empty array
+ cfg := &config.Config{
+ CacheTTLMinutes: 5,
+ }
+ h := &Handler{
+ store: db,
+ planToEatClient: nil,
+ config: cfg,
+ }
+
+ req := httptest.NewRequest("GET", "/api/meals", nil)
+ w := httptest.NewRecorder()
+
+ h.HandleGetMeals(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ var meals []models.Meal
+ if err := json.NewDecoder(w.Body).Decode(&meals); err != nil {
+ t.Fatalf("Failed to decode response: %v", err)
+ }
+
+ // Handler returns empty array when client is nil
+ if len(meals) != 0 {
+ t.Errorf("Expected 0 meals when client is nil, got %d", len(meals))
+ }
+}
diff --git a/internal/middleware/ai_auth.go b/internal/middleware/ai_auth.go
new file mode 100644
index 0000000..3c04a37
--- /dev/null
+++ b/internal/middleware/ai_auth.go
@@ -0,0 +1,46 @@
+package middleware
+
+import (
+ "net/http"
+ "strings"
+)
+
+// AIAuthMiddleware validates Bearer token for AI agent access
+func AIAuthMiddleware(validToken string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Skip auth if no token configured
+ if validToken == "" {
+ respondError(w, http.StatusServiceUnavailable, "ai_disabled", "AI agent access not configured")
+ return
+ }
+
+ authHeader := r.Header.Get("Authorization")
+
+ if authHeader == "" {
+ respondError(w, http.StatusUnauthorized, "unauthorized", "Missing Authorization header")
+ return
+ }
+
+ if !strings.HasPrefix(authHeader, "Bearer ") {
+ respondError(w, http.StatusUnauthorized, "unauthorized", "Invalid Authorization header format")
+ return
+ }
+
+ token := strings.TrimPrefix(authHeader, "Bearer ")
+ if token != validToken {
+ respondError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing token")
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// respondError sends a JSON error response
+func respondError(w http.ResponseWriter, status int, error, message string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ w.Write([]byte(`{"error":"` + error + `","message":"` + message + `"}`))
+}
diff --git a/internal/models/types.go b/internal/models/types.go
new file mode 100644
index 0000000..d39a1d6
--- /dev/null
+++ b/internal/models/types.go
@@ -0,0 +1,77 @@
+package models
+
+import "time"
+
+// Task represents a task from Todoist
+type Task struct {
+ ID string `json:"id"`
+ Content string `json:"content"`
+ Description string `json:"description"`
+ ProjectID string `json:"project_id"`
+ ProjectName string `json:"project_name"`
+ DueDate *time.Time `json:"due_date,omitempty"`
+ Priority int `json:"priority"`
+ Completed bool `json:"completed"`
+ Labels []string `json:"labels"`
+ URL string `json:"url"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// Note represents a note from Obsidian
+type Note struct {
+ Filename string `json:"filename"`
+ Title string `json:"title"`
+ Content string `json:"content"` // First 200 chars or full content
+ Modified time.Time `json:"modified"`
+ Path string `json:"path"`
+ Tags []string `json:"tags"`
+}
+
+// Meal represents a meal from PlanToEat
+type Meal struct {
+ ID string `json:"id"`
+ RecipeName string `json:"recipe_name"`
+ Date time.Time `json:"date"`
+ MealType string `json:"meal_type"` // breakfast, lunch, dinner
+ RecipeURL string `json:"recipe_url"`
+}
+
+// Board represents a Trello board
+type Board struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Cards []Card `json:"cards"`
+}
+
+// Card represents a Trello card
+type Card struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ ListID string `json:"list_id"`
+ ListName string `json:"list_name"`
+ DueDate *time.Time `json:"due_date,omitempty"`
+ URL string `json:"url"`
+}
+
+// CacheMetadata tracks when data was last fetched
+type CacheMetadata struct {
+ Key string `json:"key"`
+ LastFetch time.Time `json:"last_fetch"`
+ TTLMinutes int `json:"ttl_minutes"`
+}
+
+// IsCacheValid checks if the cache is still valid based on TTL
+func (cm *CacheMetadata) IsCacheValid() bool {
+ expiryTime := cm.LastFetch.Add(time.Duration(cm.TTLMinutes) * time.Minute)
+ return time.Now().Before(expiryTime)
+}
+
+// DashboardData aggregates all data for the main view
+type DashboardData struct {
+ Tasks []Task `json:"tasks"`
+ Notes []Note `json:"notes"`
+ Meals []Meal `json:"meals"`
+ Boards []Board `json:"boards,omitempty"`
+ LastUpdated time.Time `json:"last_updated"`
+ Errors []string `json:"errors,omitempty"`
+}
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
new file mode 100644
index 0000000..45d7746
--- /dev/null
+++ b/internal/store/sqlite.go
@@ -0,0 +1,484 @@
+package store
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+ "task-dashboard/internal/models"
+)
+
+type Store struct {
+ db *sql.DB
+}
+
+// New creates a new Store instance and runs migrations
+func New(dbPath string) (*Store, error) {
+ db, err := sql.Open("sqlite3", dbPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open database: %w", err)
+ }
+
+ // Enable foreign keys
+ if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
+ return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
+ }
+
+ store := &Store{db: db}
+
+ // Run migrations
+ if err := store.runMigrations(); err != nil {
+ return nil, fmt.Errorf("failed to run migrations: %w", err)
+ }
+
+ return store, nil
+}
+
+// Close closes the database connection
+func (s *Store) Close() error {
+ return s.db.Close()
+}
+
+// runMigrations executes all migration files in order
+func (s *Store) runMigrations() error {
+ // Get migration files
+ migrationFiles, err := filepath.Glob("migrations/*.sql")
+ if err != nil {
+ return fmt.Errorf("failed to read migration files: %w", err)
+ }
+
+ // Sort migrations by filename
+ sort.Strings(migrationFiles)
+
+ // Execute each migration
+ for _, file := range migrationFiles {
+ content, err := os.ReadFile(file)
+ if err != nil {
+ return fmt.Errorf("failed to read migration %s: %w", file, err)
+ }
+
+ if _, err := s.db.Exec(string(content)); err != nil {
+ return fmt.Errorf("failed to execute migration %s: %w", file, err)
+ }
+ }
+
+ return nil
+}
+
+// Tasks operations
+
+// SaveTasks saves multiple tasks to the database
+func (s *Store) SaveTasks(tasks []models.Task) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ stmt, err := tx.Prepare(`
+ INSERT OR REPLACE INTO tasks
+ (id, content, description, project_id, project_name, due_date, priority, completed, labels, url, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ `)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ for _, task := range tasks {
+ labelsJSON, _ := json.Marshal(task.Labels)
+ _, err := stmt.Exec(
+ task.ID,
+ task.Content,
+ task.Description,
+ task.ProjectID,
+ task.ProjectName,
+ task.DueDate,
+ task.Priority,
+ task.Completed,
+ string(labelsJSON),
+ task.URL,
+ task.CreatedAt,
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// GetTasks retrieves all tasks from the database
+func (s *Store) GetTasks() ([]models.Task, error) {
+ rows, err := s.db.Query(`
+ SELECT id, content, description, project_id, project_name, due_date, priority, completed, labels, url, created_at
+ FROM tasks
+ ORDER BY completed ASC, due_date ASC, priority DESC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var tasks []models.Task
+ for rows.Next() {
+ var task models.Task
+ var labelsJSON string
+ var dueDate sql.NullTime
+
+ err := rows.Scan(
+ &task.ID,
+ &task.Content,
+ &task.Description,
+ &task.ProjectID,
+ &task.ProjectName,
+ &dueDate,
+ &task.Priority,
+ &task.Completed,
+ &labelsJSON,
+ &task.URL,
+ &task.CreatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if dueDate.Valid {
+ task.DueDate = &dueDate.Time
+ }
+
+ json.Unmarshal([]byte(labelsJSON), &task.Labels)
+ tasks = append(tasks, task)
+ }
+
+ return tasks, rows.Err()
+}
+
+// Notes operations
+
+// SaveNotes saves multiple notes to the database
+func (s *Store) SaveNotes(notes []models.Note) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ stmt, err := tx.Prepare(`
+ INSERT OR REPLACE INTO notes
+ (filename, title, content, modified, path, tags, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ `)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ for _, note := range notes {
+ tagsJSON, _ := json.Marshal(note.Tags)
+ _, err := stmt.Exec(
+ note.Filename,
+ note.Title,
+ note.Content,
+ note.Modified,
+ note.Path,
+ string(tagsJSON),
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// GetNotes retrieves all notes from the database
+func (s *Store) GetNotes(limit int) ([]models.Note, error) {
+ query := `
+ SELECT filename, title, content, modified, path, tags
+ FROM notes
+ ORDER BY modified DESC
+ `
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+ }
+
+ rows, err := s.db.Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var notes []models.Note
+ for rows.Next() {
+ var note models.Note
+ var tagsJSON string
+
+ err := rows.Scan(
+ &note.Filename,
+ &note.Title,
+ &note.Content,
+ &note.Modified,
+ &note.Path,
+ &tagsJSON,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ json.Unmarshal([]byte(tagsJSON), &note.Tags)
+ notes = append(notes, note)
+ }
+
+ return notes, rows.Err()
+}
+
+// Meals operations
+
+// SaveMeals saves multiple meals to the database
+func (s *Store) SaveMeals(meals []models.Meal) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ stmt, err := tx.Prepare(`
+ INSERT OR REPLACE INTO meals
+ (id, recipe_name, date, meal_type, recipe_url, updated_at)
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ `)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ for _, meal := range meals {
+ _, err := stmt.Exec(
+ meal.ID,
+ meal.RecipeName,
+ meal.Date,
+ meal.MealType,
+ meal.RecipeURL,
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// GetMeals retrieves meals from the database
+func (s *Store) GetMeals(startDate, endDate time.Time) ([]models.Meal, error) {
+ rows, err := s.db.Query(`
+ SELECT id, recipe_name, date, meal_type, recipe_url
+ FROM meals
+ WHERE date BETWEEN ? AND ?
+ ORDER BY date ASC,
+ CASE meal_type
+ WHEN 'breakfast' THEN 1
+ WHEN 'lunch' THEN 2
+ WHEN 'dinner' THEN 3
+ ELSE 4
+ END
+ `, startDate, endDate)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var meals []models.Meal
+ for rows.Next() {
+ var meal models.Meal
+ err := rows.Scan(
+ &meal.ID,
+ &meal.RecipeName,
+ &meal.Date,
+ &meal.MealType,
+ &meal.RecipeURL,
+ )
+ if err != nil {
+ return nil, err
+ }
+ meals = append(meals, meal)
+ }
+
+ return meals, rows.Err()
+}
+
+// Cache metadata operations
+
+// GetCacheMetadata retrieves cache metadata for a key
+func (s *Store) GetCacheMetadata(key string) (*models.CacheMetadata, error) {
+ var cm models.CacheMetadata
+ err := s.db.QueryRow(`
+ SELECT key, last_fetch, ttl_minutes
+ FROM cache_metadata
+ WHERE key = ?
+ `, key).Scan(&cm.Key, &cm.LastFetch, &cm.TTLMinutes)
+
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &cm, nil
+}
+
+// UpdateCacheMetadata updates the last fetch time for a cache key
+func (s *Store) UpdateCacheMetadata(key string, ttlMinutes int) error {
+ _, err := s.db.Exec(`
+ INSERT OR REPLACE INTO cache_metadata (key, last_fetch, ttl_minutes, updated_at)
+ VALUES (?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP)
+ `, key, ttlMinutes)
+ return err
+}
+
+// IsCacheValid checks if the cache for a given key is still valid
+func (s *Store) IsCacheValid(key string) (bool, error) {
+ cm, err := s.GetCacheMetadata(key)
+ if err != nil {
+ return false, err
+ }
+ if cm == nil {
+ return false, nil
+ }
+
+ return cm.IsCacheValid(), nil
+}
+
+// Boards operations
+
+// SaveBoards saves multiple boards to the database
+func (s *Store) SaveBoards(boards []models.Board) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Save boards
+ boardStmt, err := tx.Prepare(`
+ INSERT OR REPLACE INTO boards (id, name, updated_at)
+ VALUES (?, ?, CURRENT_TIMESTAMP)
+ `)
+ if err != nil {
+ return err
+ }
+ defer boardStmt.Close()
+
+ // Save cards
+ cardStmt, err := tx.Prepare(`
+ INSERT OR REPLACE INTO cards
+ (id, name, board_id, list_id, list_name, due_date, url, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ `)
+ if err != nil {
+ return err
+ }
+ defer cardStmt.Close()
+
+ for _, board := range boards {
+ _, err := boardStmt.Exec(board.ID, board.Name)
+ if err != nil {
+ return err
+ }
+
+ // Save all cards for this board
+ for _, card := range board.Cards {
+ _, err := cardStmt.Exec(
+ card.ID,
+ card.Name,
+ board.ID,
+ card.ListID,
+ card.ListName,
+ card.DueDate,
+ card.URL,
+ )
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return tx.Commit()
+}
+
+// GetBoards retrieves all boards with their cards from the database
+func (s *Store) GetBoards() ([]models.Board, error) {
+ // Fetch boards
+ boardRows, err := s.db.Query(`
+ SELECT id, name FROM boards ORDER BY name
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer boardRows.Close()
+
+ var boards []models.Board
+ boardMap := make(map[string]*models.Board)
+
+ for boardRows.Next() {
+ var board models.Board
+ err := boardRows.Scan(&board.ID, &board.Name)
+ if err != nil {
+ return nil, err
+ }
+ board.Cards = []models.Card{}
+ boards = append(boards, board)
+ boardMap[board.ID] = &boards[len(boards)-1]
+ }
+
+ if err := boardRows.Err(); err != nil {
+ return nil, err
+ }
+
+ // Fetch cards
+ cardRows, err := s.db.Query(`
+ SELECT id, name, board_id, list_id, list_name, due_date, url
+ FROM cards
+ ORDER BY board_id, list_name, name
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer cardRows.Close()
+
+ for cardRows.Next() {
+ var card models.Card
+ var boardID string
+ var dueDate sql.NullTime
+
+ err := cardRows.Scan(
+ &card.ID,
+ &card.Name,
+ &boardID,
+ &card.ListID,
+ &card.ListName,
+ &dueDate,
+ &card.URL,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if dueDate.Valid {
+ card.DueDate = &dueDate.Time
+ }
+
+ // Add card to the appropriate board
+ if board, ok := boardMap[boardID]; ok {
+ board.Cards = append(board.Cards, card)
+ }
+ }
+
+ return boards, cardRows.Err()
+}