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();