diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ddb4283 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM golang:1.21-alpine + +# Install build dependencies +RUN apk add --no-cache gcc musl-dev sqlite-dev + +WORKDIR /app + +# Copy go mod files first for better caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=1 go build -o /todo-app + +# Install Ollama for AI analysis +RUN wget https://ollama.ai/install.sh -O install.sh && \ + chmod +x install.sh && \ + ./install.sh + +# Create volume for persistent data +VOLUME /data + +# Set environment variables +ENV DB_PATH=/data/todoai.db \ + AI_MODEL_NAME=deepseek-r1:latest \ + AI_PROVIDER=ollama + +# Expose port if needed for future API/web interface +EXPOSE 8080 + +CMD ["/todo-app"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..69eaaac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 TodoAI Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fc810a5 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +.PHONY: build test clean run docker-build docker-run + +# Build variables +BINARY_NAME=todo +VERSION=$(shell git describe --tags --always --dirty) +BUILD_TIME=$(shell date +%FT%T%z) +LDFLAGS=-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" + +build: + go build ${LDFLAGS} -o ${BINARY_NAME} + +test: + go test -v ./... + +clean: + go clean + rm -f ${BINARY_NAME} + rm -f todo.log + rm -f test.db + +run: build + ./${BINARY_NAME} + +docker-build: + docker build -t todoai:${VERSION} . + +docker-run: + docker run -it --rm \ + -v ${PWD}/data:/data \ + -e DB_PATH=/data/todoai.db \ + todoai:${VERSION} + +install-deps: + go mod download + +lint: + go vet ./... + go fmt ./... + +# Run this before committing +pre-commit: lint test \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..25710e0 --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module github.com/your-username/todo + +go 1.23.0 + +toolchain go1.23.6 + +require ( + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/bubbletea v0.24.2 + github.com/charmbracelet/lipgloss v0.7.1 + github.com/mattn/go-sqlite3 v1.14.17 + golang.org/x/time v0.11.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.10.0 // indirect + golang.org/x/term v0.10.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8cb588c --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= +github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= diff --git a/improvements.md b/improvements.md new file mode 100644 index 0000000..eee6408 --- /dev/null +++ b/improvements.md @@ -0,0 +1,44 @@ +# Todo App Improvement Plan + +## 1. Code Structure Improvements + +- Split code into multiple files for better organization (e.g., database.go, models.go, ui.go, etc.) +- Use dependency injection to make code more testable +- Add proper error handling and logging throughout the application + +## 2. Database Enhancements + +- Add database migrations to handle schema changes +- Implement connection pooling for better performance +- Add indexes to improve query performance + +## 3. UI/UX Improvements + +- Add support for task editing and deletion +- Add search/filter functionality for tasks +- Add support for task categories and tags +- Add keyboard shortcuts for common actions + +## 4. AI Integration + +- Add support for multiple AI models +- Add configuration file for AI model settings +- Add fallback mechanism if AI model is unavailable + +## 5. Testing + +- Add unit tests for core functionality +- Add integration tests for database and AI integration +- Add end-to-end tests for the TUI + +## 6. Documentation + +- Add README with setup instructions and usage +- Add API documentation for AI integration +- Add changelog to track changes + +## 7. Deployment + +- Add Dockerfile for containerized deployment +- Add CI/CD pipeline for automated testing and deployment +- Add support for configuration via environment variables diff --git a/logs/analysis.log b/logs/analysis.log new file mode 100644 index 0000000..1206fb6 --- /dev/null +++ b/logs/analysis.log @@ -0,0 +1,5 @@ +[2025-05-08 20:59:59] Task: "go to gym" Result: {"priority":3,"category":"work","estimate":15,"related":"","summary":"go to gym"} +[2025-05-08 21:00:22] Task: "test" Result: {"priority":3,"category":"work","estimate":15,"related":"","summary":"test"} +[2025-05-08 21:00:49] Task: "visit dentist" Result: {"priority":3,"category":"personal","estimate":15,"related":"","summary":"visit dentist"} +[2025-05-08 21:07:37] Task: "test" Result: {"priority":3,"category":"work","estimate":15,"related":"","summary":"test"} +[2025-05-08 21:07:53] Task: "work on communiction skills" Result: {"priority":3,"category":"work","estimate":30,"related":"","summary":"work on communiction skills"} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bc9d25c --- /dev/null +++ b/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/your-username/todo/src/ai" + "github.com/your-username/todo/src/config" + "github.com/your-username/todo/src/database" + "github.com/your-username/todo/src/ui" +) + +func main() { + // Setup logging + logFile, err := os.OpenFile("todo.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatal(err) + } + defer logFile.Close() + log.SetOutput(logFile) + + // Create context that can be cancelled on signal + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle shutdown signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + cancel() + }() + + // Load configuration + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + // Initialize database + db, err := database.NewDatabase(cfg.Database) + if err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + defer db.Close() + + // Initialize AI service + aiService := ai.NewAIService(&cfg.AI) + + // Start background analyzer if enabled + if cfg.UI.IdleStartHour >= 0 { + analyzer := ai.NewBackgroundAnalyzer(db, aiService, cfg) + go analyzer.Start(ctx) + } + + // Initialize and start UI + if err := tea.NewProgram(ui.New(db, aiService)).Start(); err != nil { + log.Printf("Error running program: %v", err) + os.Exit(1) + } +} diff --git a/src/ai/background.go b/src/ai/background.go new file mode 100644 index 0000000..d51d9ed --- /dev/null +++ b/src/ai/background.go @@ -0,0 +1,203 @@ +package ai + +import ( + "context" + "log" + "sync" + "time" + + "golang.org/x/time/rate" + + "github.com/your-username/todo/src/config" + "github.com/your-username/todo/src/database" +) + +type BackgroundAnalyzer struct { + db database.Database + aiService *AIService + config *config.Config + rateLimiter *rate.Limiter + stats *analyzerStats +} + +type analyzerStats struct { + mu sync.Mutex + totalProcessed int64 + totalSuccesses int64 + totalFailures int64 + lastProcessed time.Time + lastError error + consecutiveErrs int + backoffUntil time.Time +} + +func NewBackgroundAnalyzer(db database.Database, aiService *AIService, cfg *config.Config) *BackgroundAnalyzer { + // Process one task per minute by default + rps := rate.Every(time.Minute) + if cfg.AI.RequestsPerMinute > 0 { + rps = rate.Every(time.Minute / time.Duration(cfg.AI.RequestsPerMinute)) + } + + return &BackgroundAnalyzer{ + db: db, + aiService: aiService, + config: cfg, + rateLimiter: rate.NewLimiter(rps, 1), // Burst of 1 for background tasks + stats: &analyzerStats{}, + } +} + +func (b *BackgroundAnalyzer) Start(ctx context.Context) { + log.Printf("Starting background analyzer. Idle hours: %d:00-%d:00", + b.config.UI.IdleStartHour, + b.config.UI.IdleEndHour) + + ticker := time.NewTicker(time.Duration(b.config.UI.RefreshInterval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Println("Background analyzer shutting down...") + return + case <-ticker.C: + if !b.isIdleTime() { + continue + } + + if time.Now().Before(b.stats.backoffUntil) { + continue + } + + if b.stats.consecutiveErrs >= 5 { + backoff := 5 * time.Minute * time.Duration(1< 1*time.Hour { + backoff = 1 * time.Hour + } + log.Printf("Too many consecutive errors (%d), backing off for %v", + b.stats.consecutiveErrs, backoff) + b.stats.backoffUntil = time.Now().Add(backoff) + continue + } + + b.processPendingTasks(ctx) + } + } +} + +func (b *BackgroundAnalyzer) isIdleTime() bool { + hour := time.Now().Hour() + start := b.config.UI.IdleStartHour + end := b.config.UI.IdleEndHour + + if start < end { + return hour >= start && hour < end + } + // Handle overnight period (e.g., 22:00 - 06:00) + return hour >= start || hour < end +} + +func (b *BackgroundAnalyzer) processPendingTasks(ctx context.Context) { + // Get tasks without analysis + tasks, err := b.db.GetTasks(ctx, false) + if err != nil { + log.Printf("Error getting tasks: %v", err) + b.recordError(err) + return + } + + for _, task := range tasks { + // Check rate limit + if err := b.rateLimiter.Wait(ctx); err != nil { + log.Printf("Rate limit wait failed: %v", err) + return + } + + select { + case <-ctx.Done(): + return + default: + // Check if task already has analysis + analysis, err := b.db.GetTaskAnalysis(ctx, int(task.ID)) + if err != nil { + log.Printf("Error checking task analysis for task %d: %v", task.ID, err) + b.recordError(err) + continue + } + if analysis != nil { + continue + } + + taskCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + + // Analyze task + result, err := b.aiService.AnalyzeTask(taskCtx, task.Title) + cancel() + + if err != nil { + log.Printf("Error analyzing task %d: %v", task.ID, err) + b.recordError(err) + continue + } + + // Store analysis result + jsonResult, err := result.ToJSON() + if err != nil { + log.Printf("Error serializing analysis for task %d: %v", task.ID, err) + b.recordError(err) + continue + } + + err = b.db.StoreAnalysis(ctx, &database.Analysis{ + TaskID: task.ID, + Result: jsonResult, + AnalyzedAt: time.Now(), + }) + if err != nil { + log.Printf("Error storing analysis for task %d: %v", task.ID, err) + b.recordError(err) + continue + } + + b.recordSuccess() + log.Printf("Successfully analyzed task %d: %s", task.ID, result.Summary) + } + } +} + +func (b *BackgroundAnalyzer) recordError(err error) { + b.stats.mu.Lock() + defer b.stats.mu.Unlock() + + b.stats.totalFailures++ + b.stats.consecutiveErrs++ + b.stats.lastError = err + b.stats.lastProcessed = time.Now() +} + +func (b *BackgroundAnalyzer) recordSuccess() { + b.stats.mu.Lock() + defer b.stats.mu.Unlock() + + b.stats.totalSuccesses++ + b.stats.totalProcessed++ + b.stats.consecutiveErrs = 0 + b.stats.lastProcessed = time.Now() +} + +// GetStats returns current analyzer statistics +func (b *BackgroundAnalyzer) GetStats() *analyzerStats { + b.stats.mu.Lock() + defer b.stats.mu.Unlock() + + // Return a copy to prevent concurrent access issues + return &analyzerStats{ + totalProcessed: b.stats.totalProcessed, + totalSuccesses: b.stats.totalSuccesses, + totalFailures: b.stats.totalFailures, + lastProcessed: b.stats.lastProcessed, + lastError: b.stats.lastError, + consecutiveErrs: b.stats.consecutiveErrs, + backoffUntil: b.stats.backoffUntil, + } +} diff --git a/src/ai/service.go b/src/ai/service.go new file mode 100644 index 0000000..12b31b1 --- /dev/null +++ b/src/ai/service.go @@ -0,0 +1,439 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "golang.org/x/time/rate" + + "github.com/your-username/todo/src/config" +) + +type AIService struct { + config *config.AIConfig + client *http.Client + limiter *rate.Limiter +} + +type AnalysisResult struct { + Priority int `json:"priority"` + Category string `json:"category"` + TimeEstimate int `json:"estimate"` + RelatedTasks string `json:"related"` + Summary string `json:"summary"` +} + +type ollamaRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Stream bool `json:"stream"` + MaxTokens int `json:"max_tokens"` +} + +type ollamaResponse struct { + Response string `json:"response"` +} + +var ( + analysisLogFile *os.File + analysisLogger *log.Logger + logMutex sync.Mutex +) + +func InitAnalysisLogger(logPath string) error { + var err error + analysisLogFile, err = os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open analysis log file: %w", err) + } + analysisLogger = log.New(analysisLogFile, "", log.LstdFlags) + return nil +} + +func CloseAnalysisLogger() error { + if analysisLogFile != nil { + return analysisLogFile.Close() + } + return nil +} + +func logAnalysis(taskTitle string, result *AnalysisResult, source string) { + if analysisLogger == nil { + return + } + + logMutex.Lock() + defer logMutex.Unlock() + + analysisLogger.Printf("Task: %q\nSource: %s\nPriority: %d\nCategory: %s\nTime Estimate: %d minutes\nSummary: %s\nRelated Tasks: %s\n---\n", + taskTitle, + source, + result.Priority, + result.Category, + result.TimeEstimate, + result.Summary, + result.RelatedTasks) +} + +func (s *AIService) logAnalysis(taskTitle string, result *AnalysisResult, inputErr error) { + logDir := "logs" + if err := os.MkdirAll(logDir, 0755); err != nil { + log.Printf("Failed to create log directory: %v", err) + return + } + + logFile := filepath.Join(logDir, "analysis.log") + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Printf("Failed to open analysis log file: %v", err) + return + } + defer f.Close() + + timestamp := time.Now().Format("2006-01-02 15:04:05") + var logEntry string + if inputErr != nil { + logEntry = fmt.Sprintf("[%s] Task: %q Error: %v\n", timestamp, taskTitle, inputErr) + } else { + jsonResult, _ := result.ToJSON() + logEntry = fmt.Sprintf("[%s] Task: %q Result: %s\n", timestamp, taskTitle, jsonResult) + } + + if _, err := f.WriteString(logEntry); err != nil { + log.Printf("Failed to write to analysis log: %v", err) + } +} + +func NewAIService(cfg *config.AIConfig) *AIService { + client := &http.Client{ + Timeout: time.Duration(cfg.Timeout) * time.Second, + } + + // Create rate limiter - default 10 requests per minute + rps := rate.Every(time.Minute / time.Duration(cfg.RequestsPerMinute)) + limiter := rate.NewLimiter(rps, cfg.BurstLimit) + + return &AIService{ + config: cfg, + client: client, + limiter: limiter, + } +} + +func (s *AIService) AnalyzeTask(ctx context.Context, taskTitle string) (*AnalysisResult, error) { + var lastErr error + + // Wait for rate limiter + if err := s.limiter.Wait(ctx); err != nil { + return s.fallbackAnalysis(taskTitle), nil + } + + // Try each configured model in order + for _, model := range s.config.Models { + var analyzer TaskAnalyzer + switch model.Provider { + case "ollama": + analyzer = &OllamaAnalyzer{ + baseURL: s.config.OllamaBaseURL, + model: model.Name, + client: s.client, + config: s.config, + } + default: + continue + } + + // Try with retries + for attempt := 0; attempt < s.config.MaxRetries; attempt++ { + select { + case <-ctx.Done(): + return s.fallbackAnalysis(taskTitle), nil + default: + result, err := analyzer.Analyze(ctx, taskTitle) + if err == nil { + s.logAnalysis(taskTitle, result, nil) + return result, nil + } + + // Check if error is retryable + var retryable bool + if errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) || + strings.Contains(err.Error(), "connection refused") || + strings.Contains(err.Error(), "timeout") { + retryable = true + } + + if !retryable { + break + } + + lastErr = err + backoff := time.Duration(s.config.RetryDelay*(1<= 500 { + return nil, fmt.Errorf("retryable error: server error %d", resp.StatusCode) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var ollamaResp ollamaResponse + if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + // Clean up response text by removing ANSI escape sequences and markers + cleanText := stripAnsiCodes(ollamaResp.Response) + + // Try to extract JSON from the response text + var result AnalysisResult + jsonStart := strings.Index(cleanText, "{") + jsonEnd := strings.LastIndex(cleanText, "}") + + if jsonStart >= 0 && jsonEnd > jsonStart { + jsonStr := cleanText[jsonStart : jsonEnd+1] + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + log.Printf("JSON parse error: %v\nContent: %q", err, jsonStr) + // Try to extract structured data from text + extractedResult := a.extractFromText(cleanText, taskTitle) + logAnalysis(taskTitle, extractedResult, "text-extraction") + return extractedResult, nil + } + + validatedResult := a.validateResult(&result, taskTitle) + logAnalysis(taskTitle, validatedResult, "ollama-json") + return validatedResult, nil + } + + // If no JSON found, try to extract structured data from text + extractedResult := a.extractFromText(cleanText, taskTitle) + logAnalysis(taskTitle, extractedResult, "text-extraction") + return extractedResult, nil +} + +// stripAnsiCodes removes ANSI escape sequences and code block markers from text +func stripAnsiCodes(text string) string { + // Remove ANSI escape sequences + ansiRegex := regexp.MustCompile(`\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`) + text = ansiRegex.ReplaceAllString(text, "") + + // Remove code block markers and "json" language identifier + text = regexp.MustCompile("(?m)^```json\\s*$").ReplaceAllString(text, "") + text = regexp.MustCompile("(?m)^```\\s*$").ReplaceAllString(text, "") + + // Clean up any remaining whitespace + text = strings.TrimSpace(text) + + return text +} + +func (a *OllamaAnalyzer) extractFromText(text string, taskTitle string) *AnalysisResult { + result := &AnalysisResult{ + Summary: taskTitle, + } + + // Extract priority (1-5) + if idx := strings.Index(text, "priority"); idx >= 0 { + for i := idx; i < len(text) && i < idx+20; i++ { + if p := text[i]; p >= '1' && p <= '5' { + result.Priority = int(p - '0') + break + } + } + } + + // Extract category + categories := []string{"work", "personal", "shopping", "health", "education"} + for _, cat := range categories { + if strings.Contains(strings.ToLower(text), cat) { + result.Category = cat + break + } + } + + // Extract time estimate + if idx := strings.Index(text, "estimate"); idx >= 0 { + var estimate int + fmt.Sscanf(text[idx:], "estimate: %d", &estimate) + if estimate > 0 { + result.TimeEstimate = estimate + } + } + + return a.validateResult(result, taskTitle) +} + +func (a *OllamaAnalyzer) validateResult(result *AnalysisResult, taskTitle string) *AnalysisResult { + if result == nil { + result = &AnalysisResult{} + } + + // Validate and set defaults + if result.Priority < 1 || result.Priority > 5 { + // Try to infer priority from keywords + priority := 3 // default medium priority + lowPriority := []string{"whenever", "someday", "maybe", "if possible", "low"} + highPriority := []string{"urgent", "asap", "important", "critical", "due", "deadline"} + + taskLower := strings.ToLower(taskTitle) + for _, word := range lowPriority { + if strings.Contains(taskLower, word) { + priority = 4 + break + } + } + for _, word := range highPriority { + if strings.Contains(taskLower, word) { + priority = 2 + break + } + } + result.Priority = priority + } + + if result.TimeEstimate <= 0 { + // Set default based on typical task duration + switch { + case len(taskTitle) < 20: + result.TimeEstimate = 15 // Quick tasks + case len(taskTitle) < 50: + result.TimeEstimate = 30 // Medium tasks + default: + result.TimeEstimate = 60 // Longer tasks + } + } + + if result.Category == "" { + result.Category = inferCategory(taskTitle) + } + + if result.Summary == "" { + result.Summary = taskTitle + } + + return result +} + +func (r *AnalysisResult) ToJSON() (string, error) { + data, err := json.Marshal(r) + if err != nil { + return "", err + } + return string(data), nil +} + +func inferCategory(taskTitle string) string { + taskTitle = strings.ToLower(taskTitle) + + categories := map[string][]string{ + "work": {"report", "meeting", "project", "email", "presentation", "deadline"}, + "education": {"study", "learn", "read", "homework", "assignment", "course"}, + "shopping": {"buy", "purchase", "grocery", "store", "market", "shop"}, + "health": {"exercise", "gym", "workout", "doctor", "medical", "appointment"}, + "maintenance": {"clean", "fix", "repair", "maintain", "organize"}, + "communication": {"call", "email", "message", "contact", "meet"}, + } + + for category, keywords := range categories { + for _, keyword := range keywords { + if strings.Contains(taskTitle, keyword) { + return category + } + } + } + + return "general" +} diff --git a/src/ai/service_test.go b/src/ai/service_test.go new file mode 100644 index 0000000..152e2b1 --- /dev/null +++ b/src/ai/service_test.go @@ -0,0 +1,295 @@ +package ai + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/your-username/todo/src/config" +) + +// MockAnalyzer implements Analyzer interface for testing +type MockAnalyzer struct { + result *AnalysisResult + err error +} + +func (m *MockAnalyzer) AnalyzeTask(_ context.Context, _ string) (*AnalysisResult, error) { + return m.result, m.err +} + +func TestAIService(t *testing.T) { + mockResult := &AnalysisResult{ + Priority: 3, + Category: "test", + TimeEstimate: 30, + Summary: "Test task", + } + + t.Run("SingleModel", func(t *testing.T) { + service := NewAIService([]ModelConfig{{Name: "mock", Provider: "mock"}}) + service.analyzers = map[string]Analyzer{ + "mock": &MockAnalyzer{result: mockResult}, + } + + result, err := service.AnalyzeTask(context.Background(), "Test task") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if result == nil { + t.Fatal("Expected analysis result") + } + if result.Priority != mockResult.Priority { + t.Errorf("Expected priority %d, got %d", mockResult.Priority, result.Priority) + } + }) + + t.Run("Fallback", func(t *testing.T) { + service := NewAIService([]ModelConfig{{Name: "failing", Provider: "mock"}}) + service.analyzers = map[string]Analyzer{ + "failing": &MockAnalyzer{err: fmt.Errorf("mock error")}, + } + + result, err := service.AnalyzeTask(context.Background(), "Test task") + if err != nil { + t.Errorf("Expected no error with fallback, got %v", err) + } + if result == nil { + t.Fatal("Expected fallback analysis result") + } + if result.Category != "uncategorized" { + t.Errorf("Expected uncategorized category, got %s", result.Category) + } + }) + + t.Run("MultipleModels", func(t *testing.T) { + service := NewAIService([]ModelConfig{ + {Name: "failing1", Provider: "mock"}, + {Name: "working", Provider: "mock"}, + }) + service.analyzers = map[string]Analyzer{ + "failing1": &MockAnalyzer{err: fmt.Errorf("mock error")}, + "working": &MockAnalyzer{result: mockResult}, + } + + result, err := service.AnalyzeTask(context.Background(), "Test task") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if result == nil { + t.Fatal("Expected analysis result from second model") + } + if result.Priority != mockResult.Priority { + t.Errorf("Expected priority %d, got %d", mockResult.Priority, result.Priority) + } + }) +} + +func TestAIService_AnalyzeTask(t *testing.T) { + // Mock Ollama server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/generate" { + t.Errorf("Expected to request '/api/generate', got: %s", r.URL.Path) + } + if r.Method != "POST" { + t.Errorf("Expected POST request, got: %s", r.Method) + } + + response := ollamaResponse{ + Response: `{ + "priority": 2, + "category": "work", + "time_estimate": 60, + "related_tasks": "Follow up with team", + "summary": "Project status report for Q2" + }`, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create test config + cfg := &config.AIConfig{ + OllamaBaseURL: server.URL, + MaxRetries: 2, + RetryDelay: 100, + Timeout: 5, + MaxTokens: 2048, + Models: []config.AIModelConfig{ + { + Name: "test-model", + Provider: "ollama", + }, + }, + } + + service := NewAIService(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + result, err := service.AnalyzeTask(ctx, "Create project status report") + if err != nil { + t.Fatalf("Failed to analyze task: %v", err) + } + + if result.Priority != 2 { + t.Errorf("Expected priority 2, got %d", result.Priority) + } + if result.Category != "work" { + t.Errorf("Expected category 'work', got %s", result.Category) + } + if result.TimeEstimate != 60 { + t.Errorf("Expected time estimate 60, got %d", result.TimeEstimate) + } +} + +func TestAIService_Fallback(t *testing.T) { + // Create config with invalid URL to force fallback + cfg := &config.AIConfig{ + OllamaBaseURL: "http://invalid-url", + MaxRetries: 1, + RetryDelay: 100, + Timeout: 1, + Models: []config.AIModelConfig{ + { + Name: "test-model", + Provider: "ollama", + }, + }, + } + + service := NewAIService(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + result, err := service.AnalyzeTask(ctx, "Buy groceries") + if err == nil { + t.Error("Expected error due to invalid URL, got nil") + } + + // Should still get a result from fallback + if result == nil { + t.Fatal("Expected fallback result, got nil") + } + if result.Category != "shopping" { + t.Errorf("Expected category 'shopping', got %s", result.Category) + } +} + +func TestInferCategory(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"Education", "Learn Python programming", "education"}, + {"Work", "Complete project report", "work"}, + {"Shopping", "Buy groceries", "shopping"}, + {"Communication", "Call mom", "communication"}, + {"Maintenance", "Fix the leaky faucet", "maintenance"}, + {"Health", "Go to the gym", "health"}, + {"Default", "Random task", "general"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := inferCategory(tt.input) + if result != tt.expected { + t.Errorf("inferCategory(%q) = %q; want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestAnalysisResult_ToJSON(t *testing.T) { + result := &AnalysisResult{ + Priority: 1, + Category: "work", + TimeEstimate: 30, + RelatedTasks: "Follow up", + Summary: "Test task", + } + + json, err := result.ToJSON() + if err != nil { + t.Fatalf("Failed to convert to JSON: %v", err) + } + + expected := `{"priority":1,"category":"work","time_estimate":30,"related_tasks":"Follow up","summary":"Test task"}` + if json != expected { + t.Errorf("Expected JSON %q, got %q", expected, json) + } +} + +func TestOllamaAnalyzer_ValidateResult(t *testing.T) { + analyzer := &OllamaAnalyzer{ + model: "test-model", + } + + tests := []struct { + name string + input *AnalysisResult + taskTitle string + wantPriority int + wantEstimate int + }{ + { + name: "Valid result", + input: &AnalysisResult{ + Priority: 2, + TimeEstimate: 45, + Category: "work", + Summary: "Valid task", + }, + taskTitle: "Test task", + wantPriority: 2, + wantEstimate: 45, + }, + { + name: "Invalid priority", + input: &AnalysisResult{ + Priority: 0, + TimeEstimate: 45, + Category: "work", + }, + taskTitle: "Test task", + wantPriority: 3, + wantEstimate: 45, + }, + { + name: "Invalid estimate", + input: &AnalysisResult{ + Priority: 2, + TimeEstimate: 0, + Category: "work", + }, + taskTitle: "Test task", + wantPriority: 2, + wantEstimate: 30, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateResult(tt.input, tt.taskTitle) + if result.Priority != tt.wantPriority { + t.Errorf("Priority = %d; want %d", result.Priority, tt.wantPriority) + } + if result.TimeEstimate != tt.wantEstimate { + t.Errorf("TimeEstimate = %d; want %d", result.TimeEstimate, tt.wantEstimate) + } + if result.Summary == "" { + t.Error("Summary should not be empty") + } + if result.Category == "" { + t.Error("Category should not be empty") + } + }) + } +} diff --git a/src/config/config.go b/src/config/config.go new file mode 100644 index 0000000..91cdfd8 --- /dev/null +++ b/src/config/config.go @@ -0,0 +1,168 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// Config holds all application configuration +type Config struct { + Database DatabaseConfig + AI AIConfig + UI UIConfig +} + +type DatabaseConfig struct { + Path string + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime int // in minutes +} + +type AIConfig struct { + DefaultModel string + Models []AIModelConfig + MaxRetries int + RetryDelay int // in milliseconds + OllamaBaseURL string // URL for Ollama API + Timeout int // request timeout in seconds + RequestsPerMinute int // Rate limit in requests per minute + BurstLimit int // Maximum burst size for rate limiter + BackgroundEnabled bool // Whether to enable background analysis + MaxTokens int // Maximum tokens to generate in responses + RetryableErrors []string // List of error strings that should trigger retries +} + +type AIModelConfig struct { + Name string + Provider string + APIKey string + Priority int // Lower numbers mean higher priority + Tags []string // Model capabilities/specialties + MaxTokens int // Maximum tokens per request +} + +type UIConfig struct { + Theme string + RefreshInterval int // in seconds + IdleStartHour int // Hour to start background analysis (24h format) + IdleEndHour int // Hour to end background analysis (24h format) + MaxDisplayTasks int +} + +// LoadConfig loads configuration from environment variables +func LoadConfig() (*Config, error) { + cfg := &Config{ + Database: DatabaseConfig{ + Path: getEnv("DB_PATH", "todoai.db"), + MaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25), + MaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 5), + ConnMaxLifetime: getEnvInt("DB_CONN_MAX_LIFETIME", 5), + }, + AI: AIConfig{ + DefaultModel: getEnv("AI_DEFAULT_MODEL", "gemma3:1b"), + MaxRetries: getEnvInt("AI_MAX_RETRIES", 3), + RetryDelay: getEnvInt("AI_RETRY_DELAY_MS", 500), + OllamaBaseURL: getEnv("AI_OLLAMA_URL", "http://localhost:11434"), + Timeout: getEnvInt("AI_TIMEOUT_SECONDS", 10), + RequestsPerMinute: getEnvInt("AI_REQUESTS_PER_MINUTE", 10), + BurstLimit: getEnvInt("AI_BURST_LIMIT", 3), + BackgroundEnabled: getEnvBool("AI_BACKGROUND_ENABLED", true), + MaxTokens: getEnvInt("AI_MAX_TOKENS", 2048), + RetryableErrors: getEnvArray("AI_RETRYABLE_ERRORS", []string{ + "connection refused", + "timeout", + "too many requests", + "server error", + }), + Models: []AIModelConfig{ + { + Name: getEnv("AI_MODEL_NAME", "gemma3:1b"), + Provider: getEnv("AI_PROVIDER", "ollama"), + APIKey: getEnv("AI_API_KEY", ""), + Priority: 1, + Tags: getEnvArray("AI_MODEL_TAGS", []string{"general"}), + MaxTokens: getEnvInt("AI_MAX_TOKENS", 2048), + }, + { + Name: "mistral:7b", + Provider: "ollama", + Priority: 2, + Tags: []string{"general", "fallback"}, + MaxTokens: 2048, + }, + }, + }, + UI: UIConfig{ + Theme: getEnv("UI_THEME", "default"), + RefreshInterval: getEnvInt("UI_REFRESH_INTERVAL", 60), + IdleStartHour: getEnvInt("UI_IDLE_START_HOUR", 22), + IdleEndHour: getEnvInt("UI_IDLE_END_HOUR", 6), + MaxDisplayTasks: getEnvInt("UI_MAX_DISPLAY_TASKS", 100), + }, + } + + return cfg, cfg.validate() +} + +func (c *Config) validate() error { + if c.Database.Path == "" { + return fmt.Errorf("database path cannot be empty") + } + + if len(c.AI.Models) == 0 { + return fmt.Errorf("at least one AI model must be configured") + } + + if c.AI.RequestsPerMinute < 1 { + return fmt.Errorf("AI requests per minute must be at least 1") + } + + if c.AI.BurstLimit < 1 { + return fmt.Errorf("AI burst limit must be at least 1") + } + + if c.UI.IdleStartHour < 0 || c.UI.IdleStartHour > 23 { + return fmt.Errorf("idle start hour must be between 0 and 23") + } + + if c.UI.IdleEndHour < 0 || c.UI.IdleEndHour > 23 { + return fmt.Errorf("idle end hour must be between 0 and 23") + } + + return nil +} + +func getEnv(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if value, exists := os.LookupEnv(key); exists { + if intVal, err := strconv.Atoi(value); err == nil { + return intVal + } + } + return defaultValue +} + +func getEnvArray(key string, defaultValue []string) []string { + if value, exists := os.LookupEnv(key); exists { + return strings.Split(value, ",") + } + return defaultValue +} + +func getEnvBool(key string, fallback bool) bool { + if value, ok := os.LookupEnv(key); ok { + if b, err := strconv.ParseBool(value); err == nil { + return b + } + } + return fallback +} diff --git a/src/database/database.go b/src/database/database.go new file mode 100644 index 0000000..33d3d37 --- /dev/null +++ b/src/database/database.go @@ -0,0 +1,173 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/your-username/todo/src/config" +) + +// Database interface defines the methods required for task storage and analysis +type Database interface { + Close() error + AddTask(ctx context.Context, title string, body string) (int64, error) + UpdateTask(ctx context.Context, id int64, title string, body string) error + CompleteTask(ctx context.Context, id int) error + GetTasks(ctx context.Context, includeCompleted bool) ([]Task, error) + GetTaskAnalysis(ctx context.Context, taskID int) (*Analysis, error) + StoreAnalysis(ctx context.Context, analysis *Analysis) error +} + +// SQLiteDB implements the Database interface +type SQLiteDB struct { + db *sql.DB +} + +// Task represents a todo task +type Task struct { + ID int64 + Title string + Body string + Completed bool + CreatedAt time.Time +} + +// Analysis represents an AI analysis of a task +type Analysis struct { + TaskID int64 + Result string + AnalyzedAt time.Time +} + +func NewDatabase(cfg config.DatabaseConfig) (Database, error) { + db, err := sql.Open("sqlite3", cfg.Path) + if err != nil { + return nil, fmt.Errorf("error opening database: %w", err) + } + + // Configure connection pool + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Minute) + + // Initialize schema + if err := initSchema(db); err != nil { + db.Close() + return nil, fmt.Errorf("error initializing schema: %w", err) + } + + return &SQLiteDB{db: db}, nil +} + +func initSchema(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + body TEXT, + completed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS analysis ( + task_id INTEGER, + result TEXT, + analyzed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(task_id) REFERENCES tasks(id) + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed); + CREATE INDEX IF NOT EXISTS idx_analysis_task_id ON analysis(task_id); + `) + return err +} + +func (d *SQLiteDB) Close() error { + return d.db.Close() +} + +func (d *SQLiteDB) AddTask(ctx context.Context, title string, body string) (int64, error) { + result, err := d.db.ExecContext(ctx, + "INSERT INTO tasks (title, body) VALUES (?, ?)", + title, body) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func (d *SQLiteDB) UpdateTask(ctx context.Context, id int64, title string, body string) error { + result, err := d.db.ExecContext(ctx, + "UPDATE tasks SET title = ?, body = ? WHERE id = ?", + title, body, id) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return sql.ErrNoRows + } + return nil +} + +func (d *SQLiteDB) CompleteTask(ctx context.Context, id int) error { + _, err := d.db.ExecContext(ctx, + "UPDATE tasks SET completed = TRUE WHERE id = ?", + id) + return err +} + +func (d *SQLiteDB) GetTasks(ctx context.Context, includeCompleted bool) ([]Task, error) { + query := "SELECT id, title, body, completed, created_at FROM tasks" + if !includeCompleted { + query += " WHERE completed = FALSE" + } + query += " ORDER BY created_at DESC" + + rows, err := d.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var tasks []Task + for rows.Next() { + var t Task + err := rows.Scan(&t.ID, &t.Title, &t.Body, &t.Completed, &t.CreatedAt) + if err != nil { + return nil, err + } + tasks = append(tasks, t) + } + return tasks, rows.Err() +} + +func (d *SQLiteDB) GetTaskAnalysis(ctx context.Context, taskID int) (*Analysis, error) { + var analysis Analysis + err := d.db.QueryRowContext(ctx, ` + SELECT task_id, result, analyzed_at + FROM analysis + WHERE task_id = ? + ORDER BY analyzed_at DESC LIMIT 1`, + taskID).Scan(&analysis.TaskID, &analysis.Result, &analysis.AnalyzedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &analysis, nil +} + +func (d *SQLiteDB) StoreAnalysis(ctx context.Context, analysis *Analysis) error { + _, err := d.db.ExecContext(ctx, + "INSERT INTO analysis (task_id, result, analyzed_at) VALUES (?, ?, ?)", + analysis.TaskID, analysis.Result, analysis.AnalyzedAt) + return err +} diff --git a/src/database/database_test.go b/src/database/database_test.go new file mode 100644 index 0000000..eab0fb9 --- /dev/null +++ b/src/database/database_test.go @@ -0,0 +1,99 @@ +package database + +import ( + "context" + "os" + "testing" + "time" +) + +func TestSQLiteDB(t *testing.T) { + // Create temp database for testing + tmpDB := "test.db" + db, err := NewSQLiteDB(tmpDB) + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer os.Remove(tmpDB) + + ctx := context.Background() + if err := db.Init(ctx); err != nil { + t.Fatalf("Failed to initialize database: %v", err) + } + + t.Run("AddTask", func(t *testing.T) { + id, err := db.AddTask(ctx, "Test task") + if err != nil { + t.Errorf("Failed to add task: %v", err) + } + if id <= 0 { + t.Error("Expected positive task ID") + } + }) + + t.Run("GetTasks", func(t *testing.T) { + tasks, err := db.GetTasks(ctx, false) + if err != nil { + t.Errorf("Failed to get tasks: %v", err) + } + if len(tasks) == 0 { + t.Error("Expected at least one task") + } + }) + + t.Run("CompleteTask", func(t *testing.T) { + // Add a task first + id, _ := db.AddTask(ctx, "Task to complete") + + // Complete it + err := db.CompleteTask(ctx, int(id)) + if err != nil { + t.Errorf("Failed to complete task: %v", err) + } + + // Verify it's in completed tasks + tasks, _ := db.GetTasks(ctx, true) + found := false + for _, task := range tasks { + if task.ID == id { + found = true + if !task.Completed { + t.Error("Task should be marked as completed") + } + break + } + } + if !found { + t.Error("Completed task not found") + } + }) + + t.Run("TaskAnalysis", func(t *testing.T) { + // Add a task + id, _ := db.AddTask(ctx, "Task for analysis") + + // Store analysis + analysis := &Analysis{ + TaskID: id, + Result: `{"priority":3,"category":"test"}`, + AnalyzedAt: time.Now(), + } + + err := db.StoreAnalysis(ctx, analysis) + if err != nil { + t.Errorf("Failed to store analysis: %v", err) + } + + // Retrieve analysis + retrieved, err := db.GetTaskAnalysis(ctx, int(id)) + if err != nil { + t.Errorf("Failed to get analysis: %v", err) + } + if retrieved == nil { + t.Error("Expected to retrieve analysis") + } + if retrieved.Result != analysis.Result { + t.Errorf("Expected result %s, got %s", analysis.Result, retrieved.Result) + } + }) +} diff --git a/src/models/task.go b/src/models/task.go new file mode 100644 index 0000000..6efdfea --- /dev/null +++ b/src/models/task.go @@ -0,0 +1,25 @@ +package models + +import ( + "time" +) + +// Task represents a todo item +type Task struct { + ID int64 `json:"id"` + Title string `json:"title"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// TaskAnalysis represents the AI analysis of a task +type TaskAnalysis struct { + TaskID int64 `json:"task_id"` + Priority int `json:"priority"` + Category string `json:"category"` + TimeEstimate int `json:"time_estimate"` + RelatedTasks string `json:"related_tasks"` + Summary string `json:"summary"` + AnalyzedAt time.Time `json:"analyzed_at"` +} diff --git a/src/ui/tui.go b/src/ui/tui.go new file mode 100644 index 0000000..077ab6a --- /dev/null +++ b/src/ui/tui.go @@ -0,0 +1,552 @@ +package ui + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/your-username/todo/src/ai" + "github.com/your-username/todo/src/database" + "github.com/your-username/todo/src/models" +) + +var ( + titleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("62")) + + completedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("42")) // Green color + + taskStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(1, 2) + + statusStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("34")) // Blue color + + dimmedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) // Dimmed gray color +) + +type Model struct { + list list.Model // Changed from *list.Model to list.Model + titleInput textinput.Model + bodyInput textinput.Model + searchInput textinput.Model + spinner spinner.Model + db database.Database + aiService *ai.AIService + state State + selected *database.Task + errorMsg string + filter string + currentPage int + itemsPerPage int + showCompleted bool + analysisResults map[int64]*database.Analysis + isLoading bool // Track loading state + analyzingTasks map[int]bool + analysisTimeout time.Duration +} + +type State int + +const ( + StateNormal State = iota + StateCreating + StateEditing + StateSearching + StateViewing + StateAnalyzing +) + +func New(db database.Database, aiService *ai.AIService) Model { + ti := textinput.New() + ti.Placeholder = "Task title" + ti.Focus() + + bi := textinput.New() + bi.Placeholder = "Task description (optional)" + + si := textinput.New() + si.Placeholder = "Search tasks..." + + delegate := list.NewDefaultDelegate() + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("62")) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("241")) + + taskList := list.New(nil, delegate, 0, 0) + taskList.Title = "Todo Tasks" + taskList.SetShowTitle(true) + taskList.SetFilteringEnabled(true) + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + m := Model{ + list: taskList, + titleInput: ti, + bodyInput: bi, + searchInput: si, + spinner: s, + db: db, + aiService: aiService, + state: StateNormal, + itemsPerPage: 10, + analysisResults: make(map[int64]*database.Analysis), + isLoading: true, + analyzingTasks: make(map[int]bool), + showCompleted: true, // Show all tasks by default + analysisTimeout: 5 * time.Second, + } + + return m +} + +func (m Model) Init() tea.Cmd { + m.isLoading = true // Set initial loading state + return tea.Batch(m.loadTasks, m.spinner.Tick) +} + +func (m Model) loadTasks() tea.Msg { + dbTasks, err := m.db.GetTasks(context.Background(), m.showCompleted) + if err != nil { + return errMsg{err} + } + + // Convert database.Task to models.Task + var items []list.Item + for _, t := range dbTasks { + items = append(items, taskItem{ + task: models.Task{ + ID: t.ID, + Title: t.Title, + Completed: t.Completed, + CreatedAt: t.CreatedAt, + }, + analyzing: m.analyzingTasks[int(t.ID)], + }) + } + + return tasksLoadedMsg{items: items} +} + +func (m Model) updateTaskList(tasks []models.Task) { + items := make([]list.Item, len(tasks)) + for i, task := range tasks { + items[i] = taskItem{ + task: task, + analyzing: m.analyzingTasks[int(task.ID)], + } + } + m.list.SetItems(items) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case nil: + // Initial update, load tasks + m.isLoading = true + return m, m.loadTasks + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case tasksLoadedMsg: + m.isLoading = false + m.list.SetItems(msg.items) + if len(msg.items) > 0 { + m.list.Select(0) + } + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "n": + if m.state == StateNormal { + m.state = StateCreating + m.titleInput.Focus() + return m, textinput.Blink + } + case "/": + if m.state == StateNormal { + m.state = StateSearching + m.searchInput.Focus() + return m, textinput.Blink + } + case "e": + if m.state == StateNormal { + if i, ok := m.list.SelectedItem().(taskItem); ok { + m.state = StateEditing + m.selected = &database.Task{ID: i.task.ID, Title: i.task.Title, Completed: i.task.Completed} + m.titleInput.SetValue(i.task.Title) + m.titleInput.Focus() + return m, textinput.Blink + } + } + case "tab": + if m.state == StateCreating || m.state == StateEditing { + if m.titleInput.Focused() { + m.titleInput.Blur() + m.bodyInput.Focus() + return m, textinput.Blink + } else if m.bodyInput.Focused() { + m.bodyInput.Blur() + m.titleInput.Focus() + return m, textinput.Blink + } + } + m.showCompleted = !m.showCompleted + m.filter = "" // Clear filter when toggling completed tasks + m.isLoading = true + return m, m.loadTasks + case "shift+tab": + if m.state == StateCreating || m.state == StateEditing { + if m.titleInput.Focused() { + m.titleInput.Blur() + m.bodyInput.Focus() + return m, textinput.Blink + } else if m.bodyInput.Focused() { + m.bodyInput.Blur() + m.titleInput.Focus() + return m, textinput.Blink + } + } + case "c": + if m.state == StateNormal { + if i, ok := m.list.SelectedItem().(taskItem); ok { + err := m.db.CompleteTask(context.Background(), int(i.task.ID)) + if err != nil { + m.errorMsg = fmt.Sprintf("Failed to complete task: %v", err) + } + m.showCompleted = true // Show completed tasks after completing one + m.filter = "" // Clear filter when showing completed tasks + m.isLoading = true + return m, m.loadTasks + } + } + case "a": + if m.state == StateNormal || m.state == StateViewing { + if i, ok := m.list.SelectedItem().(taskItem); ok { + m.state = StateAnalyzing + m.selected = &database.Task{ID: i.task.ID, Title: i.task.Title, Completed: i.task.Completed} + m.analyzingTasks[int(i.task.ID)] = true + return m, m.analyzeTask(i.task) + } + } + return m, nil + case "enter": + switch m.state { + case StateCreating: + if m.titleInput.Focused() && m.titleInput.Value() != "" { + m.titleInput.Blur() + m.bodyInput.Focus() + return m, textinput.Blink + } else if m.bodyInput.Focused() { + if m.titleInput.Value() != "" { + _, err := m.db.AddTask(context.Background(), m.titleInput.Value(), m.bodyInput.Value()) + if err != nil { + m.errorMsg = fmt.Sprintf("Failed to add task: %v", err) + } + m.titleInput.Reset() + m.bodyInput.Reset() + m.state = StateNormal + m.isLoading = true + return m, m.loadTasks + } + } + case StateEditing: + if m.titleInput.Focused() && m.titleInput.Value() != "" { + m.titleInput.Blur() + m.bodyInput.Focus() + return m, textinput.Blink + } else if m.bodyInput.Focused() && m.selected != nil { + err := m.db.UpdateTask(context.Background(), m.selected.ID, m.titleInput.Value(), m.bodyInput.Value()) + if err != nil { + m.errorMsg = fmt.Sprintf("Failed to update task: %v", err) + } + m.titleInput.Reset() + m.bodyInput.Reset() + m.selected = nil + m.state = StateNormal + m.isLoading = true + return m, m.loadTasks + } + case StateSearching: + m.filter = m.searchInput.Value() + m.searchInput.Reset() + m.state = StateNormal + m.isLoading = true + return m, m.loadTasks + case StateNormal: + if i, ok := m.list.SelectedItem().(taskItem); ok { + m.selected = &database.Task{ID: i.task.ID, Title: i.task.Title, Completed: i.task.Completed} + m.state = StateViewing + + // Load existing analysis when viewing task + analysis, err := m.db.GetTaskAnalysis(context.Background(), int(i.task.ID)) + if err == nil && analysis != nil { + if m.analysisResults == nil { + m.analysisResults = make(map[int64]*database.Analysis) + } + m.analysisResults[i.task.ID] = analysis + return m, nil + } + + // If no analysis exists, run the analysis + return m, m.analyzeTask(i.task) + } + } + case "esc": + if m.state != StateNormal { + m.state = StateNormal + m.errorMsg = "" + m.selected = nil + m.titleInput.Reset() + m.bodyInput.Reset() + if m.state == StateSearching { + m.filter = "" + } + m.isLoading = true + return m, m.loadTasks + } + } + + case tea.WindowSizeMsg: + m.list.SetWidth(msg.Width) + m.list.SetHeight(msg.Height) + return m, nil + + case analysisMsg: + if m.state == StateAnalyzing { + // Get the currently selected task + if i, ok := m.list.SelectedItem().(taskItem); ok { + // Create clean, properly formatted JSON + analysis := &database.Analysis{ + TaskID: i.task.ID, + Result: fmt.Sprintf(`{"priority":%d,"category":"%s","estimate":%d,"related":"%s","summary":"%s"}`, + msg.result.Priority, + msg.result.Category, + msg.result.TimeEstimate, + msg.result.RelatedTasks, + msg.result.Summary), + AnalyzedAt: time.Now(), + } + + if err := m.db.StoreAnalysis(context.Background(), analysis); err != nil { + m.errorMsg = fmt.Sprintf("Failed to store analysis: %v", err) + } else { + if m.analysisResults == nil { + m.analysisResults = make(map[int64]*database.Analysis) + } + m.analysisResults[i.task.ID] = analysis + } + delete(m.analyzingTasks, int(i.task.ID)) + } + m.state = StateViewing + return m, nil + } + + case errMsg: + m.errorMsg = msg.Error() + return m, nil + } + + // Update the appropriate input based on current state + var cmd tea.Cmd + switch m.state { + case StateAnalyzing: + var spinnerCmd tea.Cmd + m.spinner, spinnerCmd = m.spinner.Update(msg) + cmds = append(cmds, spinnerCmd) + case StateCreating, StateEditing: + m.titleInput, cmd = m.titleInput.Update(msg) + cmds = append(cmds, cmd) + m.bodyInput, cmd = m.bodyInput.Update(msg) + cmds = append(cmds, cmd) + case StateSearching: + m.searchInput, cmd = m.searchInput.Update(msg) + cmds = append(cmds, cmd) + case StateNormal: + if !m.isLoading { + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + } + var spinnerCmd tea.Cmd + m.spinner, spinnerCmd = m.spinner.Update(msg) + cmds = append(cmds, spinnerCmd) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + var b strings.Builder + + if m.errorMsg != "" { + b.WriteString(fmt.Sprintf("Error: %s\n\n", m.errorMsg)) + } + + switch m.state { + case StateCreating: + b.WriteString("Add new task:\n\n") + b.WriteString("Title:\n") + b.WriteString(m.titleInput.View()) + b.WriteString("\n\nDescription:\n") + b.WriteString(m.bodyInput.View()) + b.WriteString("\n\n(Tab/Shift+Tab to switch fields, Enter to move to description or save, ESC to cancel)") + + case StateEditing: + b.WriteString("Edit task:\n\n") + b.WriteString("Title:\n") + b.WriteString(m.titleInput.View()) + b.WriteString("\n\nDescription:\n") + b.WriteString(m.bodyInput.View()) + b.WriteString("\n\n(Tab/Shift+Tab to switch fields, Enter to move to description or save, ESC to cancel)") + + case StateSearching: + b.WriteString("Search tasks:\n\n") + b.WriteString(m.searchInput.View()) + b.WriteString("\n\n(ESC to cancel)") + + case StateViewing: + if m.selected != nil { + b.WriteString(m.renderTaskDetail()) + return b.String() + } + + case StateAnalyzing: + b.WriteString(fmt.Sprintf("\n\n%s Analyzing task...\n", m.spinner.View())) + return taskStyle.Render(b.String()) + + default: + header := fmt.Sprintf( + "%s\n\nPress 'n' for new task, 'e' to edit task, '/' to search, TAB to toggle completed, 'c' to complete task\n", + titleStyle.Render("Your Tasks"), + ) + b.WriteString(header) + if m.isLoading { + b.WriteString(fmt.Sprintf("\n%s Loading tasks...\n", m.spinner.View())) + } else { + b.WriteString(m.list.View()) + } + } + + return b.String() +} + +func (m Model) renderTaskDetail() string { + var b strings.Builder + + if m.selected == nil { + return "" + } + + title := m.selected.Title + if m.selected.Completed { + title = completedStyle.Render(title) + } + + b.WriteString(fmt.Sprintf("Title: %s\n\n", title)) + + // Add analysis results if available + if analysis, ok := m.analysisResults[m.selected.ID]; ok { + var result ai.AnalysisResult + if err := json.Unmarshal([]byte(analysis.Result), &result); err == nil { + b.WriteString("\nAnalysis Results:\n") + b.WriteString(fmt.Sprintf("Priority: %d/5\n", result.Priority)) + b.WriteString(fmt.Sprintf("Category: %s\n", result.Category)) + b.WriteString(fmt.Sprintf("Time Estimate: %d minutes\n", result.TimeEstimate)) + if result.RelatedTasks != "" { + b.WriteString(fmt.Sprintf("Related Tasks: %s\n", result.RelatedTasks)) + } + if result.Summary != "" && result.Summary != title { + b.WriteString(fmt.Sprintf("Summary: %s\n", result.Summary)) + } + b.WriteString(fmt.Sprintf("\nAnalyzed at: %s", analysis.AnalyzedAt.Format("2006-01-02 15:04:05"))) + } + } + + b.WriteString("\n\nPress 'a' to analyze task, ESC to return") + return taskStyle.Render(b.String()) +} + +func (m Model) analyzeTask(task models.Task) tea.Cmd { + return func() tea.Msg { + // Create a timeout channel + done := make(chan struct{}) + var result *ai.AnalysisResult + var err error + + // Run analysis in a goroutine + go func() { + result, err = m.aiService.AnalyzeTask(context.Background(), task.Title) + close(done) + }() + + // Wait for either completion or timeout + select { + case <-done: + if err != nil { + return errMsg{err} + } + return analysisMsg{result} + case <-time.After(m.analysisTimeout): + return analysisMsg{&ai.AnalysisResult{ + Priority: 3, + Category: "task", + TimeEstimate: 30, + RelatedTasks: "", + Summary: "Analysis timed out for: " + task.Title, + }} + } + } +} + +type taskItem struct { + task models.Task + analyzing bool +} + +func (i taskItem) Title() string { + status := "[ ]" + if i.task.Completed { + status = "[x]" + } + + aiStatus := "" + if i.analyzing { + aiStatus = " (analyzing...)" + } + + return fmt.Sprintf("%s %s%s", status, i.task.Title, aiStatus) +} + +func (i taskItem) Description() string { return "" } +func (i taskItem) FilterValue() string { return i.task.Title } + +type tasksLoadedMsg struct { + items []list.Item +} + +type analysisMsg struct { + result *ai.AnalysisResult +} + +type errMsg struct{ error } diff --git a/src/ui/tui_test.go b/src/ui/tui_test.go new file mode 100644 index 0000000..ca8841e --- /dev/null +++ b/src/ui/tui_test.go @@ -0,0 +1,342 @@ +package ui + +import ( + "context" + "database/sql" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/your-username/todo/src/ai" + "github.com/your-username/todo/src/database" +) + +// MockDatabase implements Database interface for testing +type MockDatabase struct { + tasks []database.Task + analyses map[int64]*database.Analysis + lastID int64 +} + +func NewMockDB() *MockDatabase { + return &MockDatabase{ + tasks: make([]database.Task, 0), + analyses: make(map[int64]*database.Analysis), + lastID: 0, + } +} + +func (m *MockDatabase) Reset() { + m.tasks = make([]database.Task, 0) + m.analyses = make(map[int64]*database.Analysis) + m.lastID = 0 +} + +func (m *MockDatabase) AddTask(_ context.Context, title string, body string) (int64, error) { + m.lastID++ + task := database.Task{ + ID: m.lastID, + Title: title, + Body: body, + CreatedAt: time.Now(), + } + m.tasks = append(m.tasks, task) + return task.ID, nil +} + +func (m *MockDatabase) GetTasks(_ context.Context, includeCompleted bool) ([]database.Task, error) { + var result []database.Task + for _, t := range m.tasks { + if includeCompleted || !t.Completed { + result = append(result, t) + } + } + return result, nil +} + +func (m *MockDatabase) CompleteTask(_ context.Context, id int) error { + for i := range m.tasks { + if m.tasks[i].ID == int64(id) { + m.tasks[i].Completed = true + return nil + } + } + return sql.ErrNoRows +} + +func (m *MockDatabase) UpdateTask(_ context.Context, id int64, title string, body string) error { + for i := range m.tasks { + if m.tasks[i].ID == id { + m.tasks[i].Title = title + m.tasks[i].Body = body + return nil + } + } + return sql.ErrNoRows +} + +func (m *MockDatabase) GetTaskAnalysis(_ context.Context, taskID int) (*database.Analysis, error) { + return m.analyses[int64(taskID)], nil +} + +func (m *MockDatabase) StoreAnalysis(_ context.Context, analysis *database.Analysis) error { + m.analyses[analysis.TaskID] = analysis + return nil +} + +// MockAIService for testing +type MockAIService struct { + result *ai.AnalysisResult +} + +func NewMockAIService() *ai.AIService { + return ai.NewAIService([]ai.ModelConfig{ + { + Name: "mock", + Provider: "mock", + }, + }) +} + +type MockAnalyzer struct { + result *ai.AnalysisResult +} + +func (m *MockAnalyzer) AnalyzeTask(_ context.Context, _ string) (*ai.AnalysisResult, error) { + return m.result, nil +} + +func TestTUI(t *testing.T) { + mockDB := NewMockDB() + mockAI := NewMockAIService() + + t.Run("InitialState", func(t *testing.T) { + mockDB.Reset() + model := New(mockDB, mockAI) + if model.state != StateNormal { + t.Errorf("Expected initial state to be StateNormal") + } + }) + + t.Run("AddTask", func(t *testing.T) { + mockDB.Reset() + model := New(mockDB, mockAI) + + // Simulate pressing 'n' to enter creation mode + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + model = updatedModel.(Model) + + if model.state != StateCreating { + t.Error("Expected state to be StateCreating after pressing 'n'") + } + + // Simulate typing task title + model.titleInput.SetValue("Test task") + + // Simulate pressing enter to create task + updatedModel, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updatedModel.(Model) + + // Execute any pending commands + if cmd != nil { + msg := cmd() + updatedModel, _ = model.Update(msg) + model = updatedModel.(Model) + } + + // Verify task was added + tasks, _ := mockDB.GetTasks(context.Background(), false) + if len(tasks) != 1 { + t.Error("Expected one task to be added") + } + if len(tasks) > 0 && tasks[0].Title != "Test task" { + t.Errorf("Expected task title 'Test task', got '%s'", tasks[0].Title) + } + }) + + t.Run("CompleteTask", func(t *testing.T) { + mockDB.Reset() + model := New(mockDB, mockAI) + + // Add a task first + id, _ := mockDB.AddTask(context.Background(), "Task to complete", "") + + // Reload task list and execute the command + updatedModel, cmd := model.Update(nil) + model = updatedModel.(Model) + if cmd != nil { + msg := cmd() + updatedModel, cmd = model.Update(msg) + model = updatedModel.(Model) + } + + // Verify task is in list + items := model.list.Items() + if len(items) != 1 { + t.Errorf("Expected 1 task in list before completion, got %d", len(items)) + } + + // Select first task + model.list.Select(0) + + // Verify correct task is selected + if i, ok := model.list.SelectedItem().(taskItem); ok { + if i.Task.ID != id { + t.Errorf("Expected selected task ID %d, got %d", id, i.Task.ID) + } + } else { + t.Error("Could not get selected task") + } + + // Simulate pressing 'c' to complete + updatedModel, cmd = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) + model = updatedModel.(Model) + + // Execute any pending commands + if cmd != nil { + msg := cmd() + updatedModel, _ = model.Update(msg) + model = updatedModel.(Model) + } + + // Verify task was completed + tasks, _ := mockDB.GetTasks(context.Background(), true) + found := false + for _, task := range tasks { + if task.ID == id && task.Completed { + found = true + break + } + } + if !found { + t.Error("Expected task to be marked as completed") + } + }) + + t.Run("SearchTasks", func(t *testing.T) { + mockDB.Reset() + model := New(mockDB, mockAI) + + // Add test tasks + mockDB.AddTask(context.Background(), "Test task 1", "") + mockDB.AddTask(context.Background(), "Test task 2", "") + mockDB.AddTask(context.Background(), "Different task", "") + + // Reload task list and execute the command + updatedModel, cmd := model.Update(nil) + model = updatedModel.(Model) + if cmd != nil { + msg := cmd() + updatedModel, cmd = model.Update(msg) + model = updatedModel.(Model) + } + + // Verify tasks are in list + items := model.list.Items() + if len(items) != 3 { + t.Errorf("Expected 3 tasks in list before search, got %d", len(items)) + } + + // Simulate pressing '/' to enter search mode + updatedModel, cmd = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + model = updatedModel.(Model) + + if model.state != StateSearching { + t.Error("Expected state to be StateSearching") + } + + // Set search filter + model.searchInput.SetValue("Test") + updatedModel, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updatedModel.(Model) + + // Execute any pending commands + if cmd != nil { + msg := cmd() + updatedModel, _ = model.Update(msg) + model = updatedModel.(Model) + } + + // Verify filtered tasks + items = model.list.Items() + if len(items) != 2 { + t.Errorf("Expected 2 filtered tasks, got %d", len(items)) + for _, item := range items { + t.Logf("Task: %s", item.FilterValue()) + } + } + }) + + t.Run("EditTask", func(t *testing.T) { + mockDB.Reset() + model := New(mockDB, mockAI) + + // Add a task first + id, _ := mockDB.AddTask(context.Background(), "Task to edit", "Original description") + + // Reload task list and execute the command + updatedModel, cmd := model.Update(nil) + model = updatedModel.(Model) + if cmd != nil { + msg := cmd() + updatedModel, cmd = model.Update(msg) + model = updatedModel.(Model) + } + + // Select first task + model.list.Select(0) + + // Verify correct task is selected + if i, ok := model.list.SelectedItem().(taskItem); ok { + if i.Task.ID != id { + t.Errorf("Expected selected task ID %d, got %d", id, i.Task.ID) + } + } else { + t.Error("Could not get selected task") + } + + // Simulate pressing 'e' to edit + updatedModel, cmd = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) + model = updatedModel.(Model) + + if model.state != StateEditing { + t.Error("Expected state to be StateEditing") + } + + // Set new values + model.titleInput.SetValue("Updated task") + model.bodyInput.SetValue("Updated description") + + // Simulate pressing enter to move to body input + updatedModel, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updatedModel.(Model) + + // Simulate pressing enter to save + updatedModel, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updatedModel.(Model) + + // Execute any pending commands + if cmd != nil { + msg := cmd() + updatedModel, _ = model.Update(msg) + model = updatedModel.(Model) + } + + // Verify task was updated + tasks, _ := mockDB.GetTasks(context.Background(), false) + found := false + for _, task := range tasks { + if task.ID == id { + if task.Title != "Updated task" || task.Body != "Updated description" { + t.Errorf("Expected task to be updated with new title and description") + } + found = true + break + } + } + if !found { + t.Error("Could not find updated task") + } + }) +} diff --git a/todo b/todo new file mode 100755 index 0000000..f7c3c84 Binary files /dev/null and b/todo differ diff --git a/todo.log b/todo.log new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/todo.log @@ -0,0 +1 @@ + diff --git a/todoai b/todoai new file mode 100755 index 0000000..fbd22bf Binary files /dev/null and b/todoai differ diff --git a/todoai.db b/todoai.db new file mode 100644 index 0000000..4ac850b Binary files /dev/null and b/todoai.db differ