diff --git a/cmd/tracks/main.go b/cmd/tracks/main.go index 60780b55..19c87419 100644 --- a/cmd/tracks/main.go +++ b/cmd/tracks/main.go @@ -104,8 +104,13 @@ func setupRoutes(router *gin.Engine, cfg *config.Config) { webProtected.GET("/", webHandler.ShowDashboard) webProtected.GET("/dashboard", webHandler.ShowDashboard) webProtected.GET("/todos", webHandler.ShowTodos) + webProtected.POST("/todos", webHandler.HandleCreateTodo) + webProtected.POST("/todos/:id/delete", webHandler.HandleDeleteTodo) webProtected.GET("/projects", webHandler.ShowProjects) webProtected.GET("/contexts", webHandler.ShowContexts) + webProtected.POST("/contexts", webHandler.HandleCreateContext) + webProtected.POST("/contexts/:id/delete", webHandler.HandleDeleteContext) + webProtected.GET("/contexts/:id/feed.rss", webHandler.HandleContextFeed) // Admin web routes webAdmin := webProtected.Group("/admin") diff --git a/cmd/tracks/web/templates/contexts.html b/cmd/tracks/web/templates/contexts.html index a50328a4..1a18a194 100644 --- a/cmd/tracks/web/templates/contexts.html +++ b/cmd/tracks/web/templates/contexts.html @@ -56,11 +56,56 @@ background-color: var(--text-secondary); color: white; } + + .modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; + } + + .modal.active { + display: flex; + } + + .modal-content { + background-color: var(--bg-primary); + border-radius: 8px; + padding: 2rem; + max-width: 500px; + width: 90%; + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + } + + .close-btn { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-secondary); + } + + .btn-sm { + padding: 0.4rem 0.8rem; + font-size: 0.85rem; + } {{if .Contexts}} @@ -68,7 +113,12 @@ {{range .Contexts}}
{{.Name}}
- {{.State}} +
+ {{.State}} +
+ +
+
{{end}} @@ -77,7 +127,46 @@
šŸ·ļø

No contexts yet. Create contexts to categorize where or how you'll complete todos!

+

Examples: @home, @work, @computer, @phone, @errands

{{end}} + + + + + {{end}} diff --git a/cmd/tracks/web/templates/todos.html b/cmd/tracks/web/templates/todos.html index 174a3f8f..d35fce2b 100644 --- a/cmd/tracks/web/templates/todos.html +++ b/cmd/tracks/web/templates/todos.html @@ -78,11 +78,58 @@ padding: 3rem; color: var(--text-secondary); } + + .modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; + } + + .modal.active { + display: flex; + } + + .modal-content { + background-color: var(--bg-primary); + border-radius: 8px; + padding: 2rem; + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + } + + .close-btn { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-secondary); + } + + .btn-sm { + padding: 0.4rem 0.8rem; + font-size: 0.85rem; + }
@@ -105,7 +152,9 @@ {{if eq .State "active"}} {{end}} - +
+ +
{{end}} @@ -117,4 +166,65 @@ {{end}} + + + + + {{end}} diff --git a/internal/handlers/web_handler.go b/internal/handlers/web_handler.go index f5719cb7..f08cc7bd 100644 --- a/internal/handlers/web_handler.go +++ b/internal/handlers/web_handler.go @@ -1,6 +1,8 @@ package handlers import ( + "encoding/xml" + "fmt" "html/template" "net/http" "time" @@ -197,10 +199,18 @@ func (h *WebHandler) ShowTodos(c *gin.Context) { Order("created_at DESC"). Find(&todos) + // Get user's contexts for the dropdown + var contexts []models.Context + database.DB. + Where("user_id = ? AND state = ?", user.ID, "active"). + Order("position ASC"). + Find(&contexts) + data := gin.H{ - "Title": "Todos", - "User": user, - "Todos": todos, + "Title": "Todos", + "User": user, + "Todos": todos, + "Contexts": contexts, } c.Header("Content-Type", "text/html; charset=utf-8") @@ -248,3 +258,232 @@ func (h *WebHandler) ShowContexts(c *gin.Context) { c.Header("Content-Type", "text/html; charset=utf-8") h.templates.ExecuteTemplate(c.Writer, "base.html", data) } + +// HandleCreateContext processes context creation form +func (h *WebHandler) HandleCreateContext(c *gin.Context) { + user, _ := middleware.GetCurrentUser(c) + + name := c.PostForm("name") + if name == "" { + c.Redirect(http.StatusFound, "/contexts?error=Context name is required") + return + } + + // Get the highest position value for proper ordering + var maxPosition int + database.DB.Model(&models.Context{}). + Where("user_id = ?", user.ID). + Select("COALESCE(MAX(position), 0)"). + Scan(&maxPosition) + + // Create context + context := models.Context{ + UserID: user.ID, + Name: name, + State: "active", + Position: maxPosition + 1, + } + + if err := database.DB.Create(&context).Error; err != nil { + c.Redirect(http.StatusFound, "/contexts?error="+err.Error()) + return + } + + // Redirect back to contexts page + c.Redirect(http.StatusFound, "/contexts") +} + +// HandleDeleteContext processes context deletion +func (h *WebHandler) HandleDeleteContext(c *gin.Context) { + user, _ := middleware.GetCurrentUser(c) + contextID := c.Param("id") + + // Verify context belongs to user + var context models.Context + if err := database.DB.Where("id = ? AND user_id = ?", contextID, user.ID).First(&context).Error; err != nil { + c.Redirect(http.StatusFound, "/contexts?error=Context not found") + return + } + + // Delete context + if err := database.DB.Delete(&context).Error; err != nil { + c.Redirect(http.StatusFound, "/contexts?error="+err.Error()) + return + } + + // Redirect back to contexts page + c.Redirect(http.StatusFound, "/contexts") +} + +// HandleCreateTodo processes todo creation form +func (h *WebHandler) HandleCreateTodo(c *gin.Context) { + user, _ := middleware.GetCurrentUser(c) + + description := c.PostForm("description") + if description == "" { + c.Redirect(http.StatusFound, "/todos?error=Description is required") + return + } + + notes := c.PostForm("notes") + contextIDStr := c.PostForm("context_id") + dueDateStr := c.PostForm("due_date") + + // Parse context ID (required) + if contextIDStr == "" { + c.Redirect(http.StatusFound, "/todos?error=Context is required") + return + } + + var contextID uint + if _, err := fmt.Sscanf(contextIDStr, "%d", &contextID); err != nil { + c.Redirect(http.StatusFound, "/todos?error=Invalid context") + return + } + + // Verify context exists and belongs to user + var context models.Context + if err := database.DB.Where("id = ? AND user_id = ?", contextID, user.ID).First(&context).Error; err != nil { + c.Redirect(http.StatusFound, "/todos?error=Context not found") + return + } + + // Create todo + todo := models.Todo{ + UserID: user.ID, + ContextID: contextID, + Description: description, + Notes: notes, + State: "active", + } + + // Parse and set due date if provided + if dueDateStr != "" { + if dueDate, err := time.Parse("2006-01-02", dueDateStr); err == nil { + todo.DueDate = &dueDate + } + } + + if err := database.DB.Create(&todo).Error; err != nil { + c.Redirect(http.StatusFound, "/todos?error="+err.Error()) + return + } + + // Redirect back to todos page + c.Redirect(http.StatusFound, "/todos") +} + +// HandleDeleteTodo processes todo deletion +func (h *WebHandler) HandleDeleteTodo(c *gin.Context) { + user, _ := middleware.GetCurrentUser(c) + todoID := c.Param("id") + + // Verify todo belongs to user + var todo models.Todo + if err := database.DB.Where("id = ? AND user_id = ?", todoID, user.ID).First(&todo).Error; err != nil { + c.Redirect(http.StatusFound, "/todos?error=Todo not found") + return + } + + // Delete todo + if err := database.DB.Delete(&todo).Error; err != nil { + c.Redirect(http.StatusFound, "/todos?error="+err.Error()) + return + } + + // Redirect back to todos page + c.Redirect(http.StatusFound, "/todos") +} + +// RSS feed structures +type RSSFeed struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel RSSChannel +} + +type RSSChannel struct { + XMLName xml.Name `xml:"channel"` + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Language string `xml:"language"` + PubDate string `xml:"pubDate"` + Items []RSSItem +} + +type RSSItem struct { + XMLName xml.Name `xml:"item"` + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + PubDate string `xml:"pubDate"` + GUID string `xml:"guid"` +} + +// HandleContextFeed generates an RSS feed for todos in a specific context +func (h *WebHandler) HandleContextFeed(c *gin.Context) { + user, _ := middleware.GetCurrentUser(c) + contextID := c.Param("id") + + // Verify context belongs to user + var context models.Context + if err := database.DB.Where("id = ? AND user_id = ?", contextID, user.ID).First(&context).Error; err != nil { + c.XML(http.StatusNotFound, gin.H{"error": "Context not found"}) + return + } + + // Get all todos for this context + var todos []models.Todo + database.DB. + Preload("Project"). + Where("user_id = ? AND context_id = ?", user.ID, contextID). + Order("created_at DESC"). + Find(&todos) + + // Build RSS feed + feed := RSSFeed{ + Version: "2.0", + Channel: RSSChannel{ + Title: fmt.Sprintf("Tracks - %s Todos", context.Name), + Link: fmt.Sprintf("%s/contexts/%s", c.Request.Host, contextID), + Description: fmt.Sprintf("Todos for context: %s", context.Name), + Language: "en-us", + PubDate: time.Now().Format(time.RFC1123Z), + Items: make([]RSSItem, 0, len(todos)), + }, + } + + // Add todos as RSS items + for _, todo := range todos { + description := todo.Description + if todo.Notes != "" { + description += "\n\n" + todo.Notes + } + if todo.Project != nil { + description += fmt.Sprintf("\n\nProject: %s", todo.Project.Name) + } + if todo.DueDate != nil { + description += fmt.Sprintf("\n\nDue: %s", todo.DueDate.Format("2006-01-02")) + } + + item := RSSItem{ + Title: todo.Description, + Link: fmt.Sprintf("%s/todos/%d", c.Request.Host, todo.ID), + Description: description, + PubDate: todo.CreatedAt.Format(time.RFC1123Z), + GUID: fmt.Sprintf("todo-%d", todo.ID), + } + feed.Channel.Items = append(feed.Channel.Items, item) + } + + // Return RSS XML + c.Header("Content-Type", "application/rss+xml; charset=utf-8") + xmlData, err := xml.MarshalIndent(feed, "", " ") + if err != nil { + c.XML(http.StatusInternalServerError, gin.H{"error": "Failed to generate RSS feed"}) + return + } + + c.Data(http.StatusOK, "application/rss+xml; charset=utf-8", append([]byte(xml.Header), xmlData...)) +} diff --git a/test-tracks-crud.js b/test-tracks-crud.js new file mode 100644 index 00000000..21bcc561 --- /dev/null +++ b/test-tracks-crud.js @@ -0,0 +1,229 @@ +const { chromium } = require('playwright'); + +const BASE_URL = 'http://localhost:8080'; +const USERNAME = 'admin'; +const PASSWORD = 'admin'; + +async function main() { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + + let testsPassed = 0; + let testsFailed = 0; + + // Helper function to log test results + function logTest(name, passed, error = null) { + if (passed) { + console.log(`āœ“ ${name}`); + testsPassed++; + } else { + console.log(`āœ— ${name}`); + if (error) console.log(` Error: ${error}`); + testsFailed++; + } + } + + try { + console.log('\n=== Starting Tracks CRUD Tests ===\n'); + + // Test 1: Login with default credentials + console.log('Test 1: Login with admin/admin'); + await page.goto(`${BASE_URL}/login`); + await page.fill('input[name="login"]', USERNAME); + await page.fill('input[name="password"]', PASSWORD); + await page.click('button[type="submit"]'); + await page.waitForTimeout(1000); + + const currentUrl = page.url(); + const isLoggedIn = currentUrl.includes('/dashboard') || currentUrl === `${BASE_URL}/` || await page.locator('text=Dashboard').count() > 0; + logTest('Login successful', isLoggedIn); + + if (!isLoggedIn) { + console.log('Current URL:', currentUrl); + throw new Error('Login failed - cannot continue tests'); + } + + // Test 2: Navigate to Contexts page + console.log('\nTest 2: Navigate to Contexts page'); + await page.click('a[href="/contexts"]'); + await page.waitForTimeout(500); + const onContextsPage = await page.locator('h2:has-text("Contexts")').count() > 0; + logTest('Navigate to Contexts page', onContextsPage); + + // Test 3: Create a new context + console.log('\nTest 3: Create a new context'); + const contextName = `@test-context-${Date.now()}`; + await page.click('button:has-text("New Context")'); + await page.waitForTimeout(300); + await page.fill('input[name="name"]', contextName); + await page.click('button[type="submit"]:has-text("Create Context")'); + await page.waitForTimeout(1000); + + const contextCreated = await page.locator(`text=${contextName}`).count() > 0; + logTest(`Create context: ${contextName}`, contextCreated); + + // Get the context ID for later use + let contextId = null; + if (contextCreated) { + const contextCard = await page.locator(`.context-card:has-text("${contextName}")`).first(); + const deleteForm = await contextCard.locator('form[action*="/contexts/"]').first(); + const action = await deleteForm.getAttribute('action'); + const match = action.match(/\/contexts\/(\d+)\/delete/); + if (match) { + contextId = match[1]; + console.log(` Context ID: ${contextId}`); + } + } + + // Test 4: Navigate to Todos page + console.log('\nTest 4: Navigate to Todos page'); + await page.click('a[href="/todos"]'); + await page.waitForTimeout(500); + const onTodosPage = await page.locator('h2:has-text("Todos")').count() > 0; + logTest('Navigate to Todos page', onTodosPage); + + // Test 5: Create a new todo with the context + console.log('\nTest 5: Create a new todo with context assignment'); + const todoDescription = `Test todo ${Date.now()}`; + await page.click('button:has-text("New Todo")'); + await page.waitForTimeout(300); + await page.fill('input[name="description"]', todoDescription); + await page.selectOption('select[name="context_id"]', { label: contextName }); + await page.fill('textarea[name="notes"]', 'This is a test todo created by Playwright'); + await page.click('button[type="submit"]:has-text("Create Todo")'); + await page.waitForTimeout(1000); + + const todoCreated = await page.locator(`text=${todoDescription}`).count() > 0; + logTest(`Create todo: ${todoDescription}`, todoCreated); + + // Verify the todo has the correct context + if (todoCreated) { + const todoItem = await page.locator(`.todo-item:has-text("${todoDescription}")`).first(); + const hasContext = await todoItem.locator(`text=${contextName}`).count() > 0; + logTest(`Todo has correct context: ${contextName}`, hasContext); + } + + // Get the todo ID for later use + let todoId = null; + if (todoCreated) { + const todoItem = await page.locator(`.todo-item:has-text("${todoDescription}")`).first(); + const deleteForm = await todoItem.locator('form[action*="/todos/"]').first(); + const action = await deleteForm.getAttribute('action'); + const match = action.match(/\/todos\/(\d+)\/delete/); + if (match) { + todoId = match[1]; + console.log(` Todo ID: ${todoId}`); + } + } + + // Test 6: Verify RSS feed for the context + console.log('\nTest 6: Retrieve RSS feed for context'); + if (contextId) { + const feedUrl = `${BASE_URL}/contexts/${contextId}/feed.rss`; + console.log(` Feed URL: ${feedUrl}`); + + const feedResponse = await page.goto(feedUrl); + const feedContent = await feedResponse.text(); + + const isValidRSS = feedContent.includes(' { + console.log(` Confirmation dialog: ${dialog.message()}`); + dialog.accept(); + }); + + const todoItem = await page.locator(`.todo-item:has-text("${todoDescription}")`).first(); + const deleteButton = await todoItem.locator('button:has-text("Delete")').first(); + await deleteButton.click(); + await page.waitForTimeout(1000); + + const todoDeleted = await page.locator(`text=${todoDescription}`).count() === 0; + logTest(`Delete todo: ${todoDescription}`, todoDeleted); + } else { + logTest('Delete todo (skipped - todo not found)', false); + } + + // Test 8: Delete the context + console.log('\nTest 8: Delete the context'); + await page.goto(`${BASE_URL}/contexts`); + await page.waitForTimeout(500); + + if (contextCreated) { + // Set up dialog handler for confirmation + page.once('dialog', dialog => { + console.log(` Confirmation dialog: ${dialog.message()}`); + dialog.accept(); + }); + + const contextCard = await page.locator(`.context-card:has-text("${contextName}")`).first(); + const deleteButton = await contextCard.locator('button:has-text("Delete")').first(); + await deleteButton.click(); + await page.waitForTimeout(1000); + + const contextDeleted = await page.locator(`text=${contextName}`).count() === 0; + logTest(`Delete context: ${contextName}`, contextDeleted); + } else { + logTest('Delete context (skipped - context not found)', false); + } + + // Test 9: Verify context RSS feed returns 404 after deletion + console.log('\nTest 9: Verify RSS feed returns error after context deletion'); + if (contextId) { + const feedUrl = `${BASE_URL}/contexts/${contextId}/feed.rss`; + try { + const feedResponse = await page.goto(feedUrl); + const status = feedResponse.status(); + const feedGone = status === 404 || status === 302; // 302 is redirect to error page + logTest('RSS feed returns error after deletion', feedGone); + } catch (error) { + logTest('RSS feed returns error after deletion', true); + } + } else { + logTest('RSS feed deletion test (skipped - no context ID)', false); + } + + // Summary + console.log('\n=== Test Summary ==='); + console.log(`Tests Passed: ${testsPassed}`); + console.log(`Tests Failed: ${testsFailed}`); + console.log(`Total Tests: ${testsPassed + testsFailed}`); + + if (testsFailed === 0) { + console.log('\nāœ“ All tests passed!'); + } else { + console.log('\nāœ— Some tests failed!'); + } + + } catch (error) { + console.error('\nāœ— Test suite failed with error:'); + console.error(error); + testsFailed++; + } finally { + await browser.close(); + process.exit(testsFailed > 0 ? 1 : 0); + } +} + +main();