Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 119 additions & 1 deletion internal/app/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ type Service interface {

SearchLists(searchQuery string) ([]*List, error)
ListTasksSearch(id int, searchQuery string) ([]*Task, error)

Groups() ([]*ListGroup, error)
CreateGroup(name string, position int) error
UpdateGroup(id int, name string) error
DeleteGroup(id int) error
}

type cacheEntry struct {
Expand Down Expand Up @@ -730,6 +735,7 @@ func (s *service) Lists(filter string) ([]*List, error) {
&data.Position,
&data.CreatedAt,
&data.UpdatedAt,
&data.GroupId,
)
if err != nil {
return nil, fmt.Errorf("DB.Lists - result scan failed: %v", err)
Expand Down Expand Up @@ -926,7 +932,8 @@ func (s *service) List(id string) (*List, error) {
&list.Base,
&list.Position,
&list.CreatedAt,
&list.UpdatedAt)
&list.UpdatedAt,
&list.GroupId)
if err != nil {
return nil, fmt.Errorf("DB.List - query result failed: %v", err)
}
Expand Down Expand Up @@ -1273,3 +1280,114 @@ func (s *service) ListTasksSearch(id int, searchQuery string) ([]*Task, error) {

return tasks, nil
}

func (s *service) Groups() ([]*ListGroup, error) {
const key = "groups"
if cached, ok := s.cache.get(key); ok {
return cached.([]*ListGroup), nil
}

query, err := s.db.Prepare("SELECT * FROM list_group ORDER BY position ASC, created_at ASC")
if err != nil {
return nil, fmt.Errorf("DB.Groups - prepare query failed: %v", err)
}
defer query.Close()

result, err := query.Query()
if err != nil {
return nil, fmt.Errorf("DB.Groups - query result failed: %v", err)
}

groups := make([]*ListGroup, 0)
for result.Next() {
data := new(ListGroup)
err := result.Scan(
&data.ID,
&data.Name,
&data.Position,
&data.CreatedAt,
&data.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("DB.Groups - result scan failed: %v", err)
}
groups = append(groups, data)
}

// Populate each group with its lists
lists, err := s.Lists("")
if err != nil {
return nil, fmt.Errorf("DB.Groups - get lists failed: %v", err)
}
for _, g := range groups {
g.Lists = make([]*List, 0)
for _, l := range lists {
if l.GroupId == g.ID {
g.Lists = append(g.Lists, l)
}
}
}

s.cache.set(key, groups)
return groups, nil
}

func (s *service) CreateGroup(name string, position int) error {
query, err := s.db.Prepare("INSERT INTO list_group (name, position) VALUES (?, ?)")
if err != nil {
return fmt.Errorf("DB.CreateGroup - prepare query failed: %v", err)
}
defer query.Close()

_, err = query.Exec(name, position)
if err != nil {
return fmt.Errorf("DB.CreateGroup - exec failed: %v", err)
}

s.cache.invalidate()
return nil
}

func (s *service) UpdateGroup(id int, name string) error {
query, err := s.db.Prepare("UPDATE list_group SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
if err != nil {
return fmt.Errorf("DB.UpdateGroup - prepare query failed: %v", err)
}
defer query.Close()

_, err = query.Exec(name, id)
if err != nil {
return fmt.Errorf("DB.UpdateGroup - exec failed: %v", err)
}

s.cache.invalidate()
return nil
}

func (s *service) DeleteGroup(id int) error {
// Unassign lists from this group first
unassign, err := s.db.Prepare("UPDATE list SET group_id = 0 WHERE group_id = ?")
if err != nil {
return fmt.Errorf("DB.DeleteGroup - prepare unassign query failed: %v", err)
}
defer unassign.Close()

_, err = unassign.Exec(id)
if err != nil {
return fmt.Errorf("DB.DeleteGroup - unassign exec failed: %v", err)
}

query, err := s.db.Prepare("DELETE FROM list_group WHERE id = ?")
if err != nil {
return fmt.Errorf("DB.DeleteGroup - prepare delete query failed: %v", err)
}
defer query.Close()

_, err = query.Exec(id)
if err != nil {
return fmt.Errorf("DB.DeleteGroup - delete exec failed: %v", err)
}

s.cache.invalidate()
return nil
}
11 changes: 11 additions & 0 deletions internal/app/database/migrations/004_add_list_groups.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Add list_group table and group_id to list

CREATE TABLE IF NOT EXISTS list_group (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
position INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

ALTER TABLE list ADD COLUMN group_id INTEGER DEFAULT 0;
10 changes: 10 additions & 0 deletions internal/app/database/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,21 @@ type List struct {
Pinned bool `json:"pinned"`
Base bool `json:"base"`
Position int `json:"position"`
GroupId int `json:"group_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Tasks []*Task
}

type ListGroup struct {
ID int `json:"id"`
Name string `json:"name"`
Position int `json:"position"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Lists []*List
}

type Total struct {
Tasks int `json:"total_tasks"`
Completed int `json:"total_completed"`
Expand Down
130 changes: 130 additions & 0 deletions internal/handlers/tasks/groups.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package tasks

import (
"rminder/internal/app"
"rminder/web"
"strconv"

"github.com/gin-gonic/gin"
)

func CreateGroup(ctx *gin.Context) {
db := app.GetUserDatabase(ctx)
log := app.GetLogger(ctx)

err := ctx.Request.ParseForm()
if err != nil {
log.Error("error parsing form", "error", err)
app.ErrorBadRequestHTML(ctx, "Invalid form data.")
return
}

name := ctx.Request.FormValue("name")
if name == "" || len(name) < 3 || len(name) > 255 {
log.Error("error form field validation failed", "name", name)
app.ErrorBadRequestHTML(ctx, "Name must be between 3 and 255 characters.")
return
}

groups, err := db.Groups()
if err != nil {
log.Error("error loading groups for position", "error", err)
app.ErrorInternalHTML(ctx, "Failed to load groups.")
return
}

if err := db.CreateGroup(name, len(groups)+1); err != nil {
log.Error("error creating group", "error", err)
app.ErrorInternalHTML(ctx, "Failed to create group.")
return
}
log.Info("group created", "name", name)

renderSidebarLists(ctx)
}

func UpdateGroup(ctx *gin.Context) {
db := app.GetUserDatabase(ctx)
log := app.GetLogger(ctx)

groupID := ctx.Param("groupID")
id, _ := strconv.Atoi(groupID)

err := ctx.Request.ParseForm()
if err != nil {
log.Error("error parsing form", "error", err)
app.ErrorBadRequestHTML(ctx, "Invalid form data.")
return
}

name := ctx.Request.FormValue("name")
if name == "" || len(name) < 3 || len(name) > 255 {
log.Error("error form field validation failed", "name", name)
app.ErrorBadRequestHTML(ctx, "Name must be between 3 and 255 characters.")
return
}

if err := db.UpdateGroup(id, name); err != nil {
log.Error("error updating group", "groupID", groupID, "error", err)
app.ErrorInternalHTML(ctx, "Failed to update group.")
return
}
log.Info("group updated", "groupID", groupID, "name", name)

renderSidebarLists(ctx)
}

func DeleteGroup(ctx *gin.Context) {
db := app.GetUserDatabase(ctx)
log := app.GetLogger(ctx)

groupID := ctx.Param("groupID")
id, _ := strconv.Atoi(groupID)

if err := db.DeleteGroup(id); err != nil {
log.Error("error deleting group", "groupID", groupID, "error", err)
app.ErrorInternalHTML(ctx, "Failed to delete group.")
return
}
log.Info("group deleted", "groupID", groupID)

renderSidebarLists(ctx)
}

// renderSidebarLists is a shared helper to re-render the sidebar lists after group mutations.
func renderSidebarLists(ctx *gin.Context) {
db := app.GetUserDatabase(ctx)
log := app.GetLogger(ctx)

lists, err := db.Lists("")
if err != nil {
log.Error("error loading lists", "error", err)
app.ErrorInternalHTML(ctx, "Failed to load lists.")
return
}

groups, err := db.Groups()
if err != nil {
log.Error("error loading groups", "error", err)
app.ErrorInternalHTML(ctx, "Failed to load groups.")
return
}

persistence, err := db.Persistence()
if err != nil {
log.Error("error loading persistence", "error", err)
app.ErrorInternalHTML(ctx, "Failed to load state.")
return
}

ctx.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
err = web.Render(ctx.Writer, "sidebar-lists", map[string]any{
"Lists": lists,
"Groups": groups,
"Persistence": persistence,
})
if err != nil {
log.Error("error rendering sidebar-lists", "error", err)
app.ErrorInternalHTML(ctx, "Failed to render lists.")
}
}
Loading