diff --git a/backend/allowance_planner.db3.backup.3 b/backend/allowance_planner.db3.backup.3 deleted file mode 100644 index 380cafd..0000000 Binary files a/backend/allowance_planner.db3.backup.3 and /dev/null differ diff --git a/backend/api_test.go b/backend/api_test.go index dec630e..7959b17 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -15,8 +15,9 @@ const ( func startServer(t *testing.T) *httpexpect.Expect { config := ServerConfig{ Datasource: ":memory:", - Addr: ":0", - Started: make(chan bool), + //Datasource: "test.db", + Addr: ":0", + Started: make(chan bool), } go start(t.Context(), &config) <-config.Started @@ -284,6 +285,54 @@ func TestCreateTask(t *testing.T) { responseWithUser.Value("id").Number().IsEqual(2) } +func TestCreateScheduleTask(t *testing.T) { + e := startServer(t) + + // Create a new task without assigned user + requestBody := map[string]interface{}{ + "name": "Test Task", + "reward": 100, + "schedule": "0 */5 * * * *", + } + + response := e.POST("/tasks"). + WithJSON(requestBody). + Expect(). + Status(201). // Expect Created status + JSON().Object() + + requestBody["schedule"] = "every 5 seconds" + e.POST("/tasks").WithJSON(requestBody).Expect().Status(400) + + // Verify the response has an ID + response.ContainsKey("id") + response.Value("id").Number().IsEqual(1) + + e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1) + + // Get task + result := e.GET("/task/1").Expect().Status(200).JSON().Object() + result.Value("id").IsEqual(1) + result.Value("name").IsEqual("Test Task") + result.Value("schedule").IsEqual("0 */5 * * * *") + result.Value("reward").IsEqual(100) + result.Value("assigned").IsNull() + + // Complete the task + e.POST("/task/1/complete").Expect().Status(200) + + // Set expires date to 1 second in the past + db.db.Query("update tasks set next_run = ? where id = 1").Bind(time.Now().Add(10 * -time.Minute).Unix()).MustExec() + + // Verify a new task is created + newTask := e.GET("/task/2").Expect().Status(200).JSON().Object() + newTask.Value("id").IsEqual(2) + newTask.Value("name").IsEqual("Test Task") + newTask.Value("schedule").IsEqual("0 */5 * * * *") + newTask.Value("reward").IsEqual(100) + newTask.Value("assigned").IsNull() +} + func TestDeleteTask(t *testing.T) { e := startServer(t) diff --git a/backend/db.go b/backend/db.go index 4c0aaf3..71b7d9d 100644 --- a/backend/db.go +++ b/backend/db.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "github.com/adhocore/gronx" "log" "math" "time" @@ -313,10 +314,20 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) { } defer tx.MustRollback() + var nextRun *int64 + if task.Schedule != nil { + nextRunTime, err := gronx.NextTick(*task.Schedule, false) + if err != nil { + return 0, fmt.Errorf("failed to calculate next run: %w", err) + } + nextRunTimeAsInt := nextRunTime.Unix() + nextRun = &nextRunTimeAsInt + } + // Insert the new task reward := int(math.Round(task.Reward * 100.0)) - err = tx.Query("insert into tasks (name, reward, assigned) values (?, ?, ?)"). - Bind(task.Name, reward, task.Assigned). + err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) values (?, ?, ?, ?, ?)"). + Bind(task.Name, reward, task.Assigned, task.Schedule, nextRun). Exec() if err != nil { @@ -340,13 +351,17 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) { } func (db *Db) GetTasks() ([]Task, error) { - tasks := make([]Task, 0) - var err error + err := db.UpdateScheduledTasks() + if err != nil { + return nil, fmt.Errorf("failed to update scheduled tasks: %w", err) + } - for row := range db.db.Query("select id, name, reward, assigned from tasks").Range(&err) { + tasks := make([]Task, 0) + + for row := range db.db.Query("select id, name, reward, assigned, schedule from tasks where completed is null").Range(&err) { task := Task{} var reward int64 - err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned) + err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule) task.Reward = float64(reward) / 100.0 if err != nil { return nil, err @@ -362,16 +377,78 @@ func (db *Db) GetTasks() ([]Task, error) { func (db *Db) GetTask(id int) (Task, error) { task := Task{} - var reward int64 - err := db.db.Query("select id, name, reward, assigned from tasks where id = ?"). - Bind(id).ScanSingle(&task.ID, &task.Name, &reward, &task.Assigned) - task.Reward = float64(reward) / 100.0 + err := db.UpdateScheduledTasks() if err != nil { - return Task{}, err + return Task{}, fmt.Errorf("failed to update scheduled tasks: %w", err) } + + var reward int64 + err = db.db.Query("select id, name, reward, assigned, schedule from tasks where id = ? and completed is null"). + Bind(id).ScanSingle(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule) + if err != nil { + return task, err + } + task.Reward = float64(reward) / 100.0 return task, nil } +func (db *Db) UpdateScheduledTasks() error { + type ScheduledTask struct { + ID int + Schedule string + Expires int64 + } + tasks := make([]ScheduledTask, 0) + var err error + + for row := range db.db.Query("select id, schedule, next_run from tasks where schedule is not null").Range(&err) { + task := ScheduledTask{} + err := row.Scan(&task.ID, &task.Schedule, &task.Expires) + if err != nil { + return err + } + if time.Now().Unix() >= task.Expires { + tasks = append(tasks, task) + } + } + if err != nil { + return fmt.Errorf("failed to fetch scheduled tasks: %w", err) + } + + tx, err := db.db.Begin() + if err != nil { + return err + } + defer tx.MustRollback() + + for _, task := range tasks { + nextRun, err := gronx.NextTickAfter(task.Schedule, time.Now(), false) + if err != nil { + return fmt.Errorf("failed to calculate next run for task %d: %w", task.ID, err) + } + + err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) select name, reward, assigned, schedule, ? from tasks where id = ?"). + Bind(nextRun.Unix(), task.ID). + Exec() + if err != nil { + return err + } + + err = tx.Query("update tasks set schedule = null where id = ?").Bind(task.ID).Exec() + if err != nil { + return err + } + + tx.Query("select last_insert_rowid()").MustScanSingle(&task.ID) + log.Printf("Task %d scheduled for %s", task.ID, nextRun) + } + if err != nil { + return err + } + + return tx.Commit() +} + func (db *Db) DeleteTask(id int) error { tx, err := db.db.Begin() if err != nil { @@ -453,7 +530,10 @@ func (db *Db) CompleteTask(taskId int) error { } // Remove the task - err = tx.Query("delete from tasks where id = ?").Bind(taskId).Exec() + err = tx.Query("update tasks set completed=? where id = ?").Bind(time.Now().Unix(), taskId).Exec() + if err != nil { + return err + } return tx.Commit() } diff --git a/backend/dto.go b/backend/dto.go index d51831c..6b89d3c 100644 --- a/backend/dto.go +++ b/backend/dto.go @@ -29,7 +29,8 @@ type Task struct { ID int `json:"id"` Name string `json:"name"` Reward float64 `json:"reward"` - Assigned *int `json:"assigned"` // Pointer to allow null + Assigned *int `json:"assigned"` + Schedule *string `json:"schedule"` } type Allowance struct { @@ -68,6 +69,7 @@ type CreateTaskRequest struct { Name string `json:"name" binding:"required"` Reward float64 `json:"reward"` Assigned *int `json:"assigned"` + Schedule *string `json:"schedule"` } type CreateTaskResponse struct { diff --git a/backend/go.mod b/backend/go.mod index 8ded2c3..4e1850c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,11 +6,12 @@ require ( gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 github.com/gavv/httpexpect/v2 v2.17.0 github.com/gin-contrib/cors v1.7.5 - github.com/gin-gonic/gin v1.10.0 + github.com/gin-gonic/gin v1.10.1 ) require ( github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect + github.com/adhocore/gronx v1.19.6 // indirect github.com/ajg/form v1.5.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/bytedance/sonic v1.13.2 // indirect @@ -49,7 +50,7 @@ require ( github.com/sergi/go-diff v1.3.1 // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect + github.com/ugorji/go/codec v1.2.14 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.62.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -68,10 +69,10 @@ require ( gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.65.7 // indirect + modernc.org/libc v1.65.8 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.37.0 // indirect + modernc.org/sqlite v1.37.1 // indirect moul.io/http2curl/v2 v2.3.0 // indirect - zombiezen.com/go/sqlite v1.4.0 // indirect + zombiezen.com/go/sqlite v1.4.2 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 0a43417..411208b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -2,6 +2,8 @@ gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= +github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= +github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= @@ -34,6 +36,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -127,6 +131,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw= +github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0= @@ -216,6 +222,8 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= +modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q= +modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -226,6 +234,8 @@ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= +modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= @@ -235,3 +245,5 @@ moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHc nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= +zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= +zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc= diff --git a/backend/main.go b/backend/main.go index c1481a2..514972c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,7 +4,9 @@ import ( "context" "embed" "errors" + "fmt" "gitea.seeseepuff.be/seeseemelk/mysqlite" + "github.com/adhocore/gronx" "log" "net" "net/http" @@ -436,6 +438,14 @@ func createTask(c *gin.Context) { return } + if taskRequest.Schedule != nil { + valid := gronx.IsValid(*taskRequest.Schedule) + if !valid { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid cron schedule: %s", *taskRequest.Schedule)}) + return + } + } + // If assigned is not nil, check if user exists if taskRequest.Assigned != nil { exists, err := db.UserExists(*taskRequest.Assigned) @@ -513,6 +523,11 @@ func putTask(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"}) return } + if err != nil { + log.Printf("Error getting task: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) + return + } err = db.UpdateTask(taskId, &taskRequest) if err != nil { diff --git a/backend/migrations/5_add_schedules.sql b/backend/migrations/5_add_schedules.sql new file mode 100644 index 0000000..16c2807 --- /dev/null +++ b/backend/migrations/5_add_schedules.sql @@ -0,0 +1,3 @@ +alter table tasks add column schedule text; +alter table tasks add column completed date; +alter table tasks add column next_run date; diff --git a/backend/web.go b/backend/web.go index d8188e1..02e178b 100644 --- a/backend/web.go +++ b/backend/web.go @@ -71,10 +71,18 @@ func renderCreateTask(c *gin.Context) { return } - _, err = db.CreateTask(&CreateTaskRequest{ + request := &CreateTaskRequest{ Name: name, Reward: reward, - }) + } + + schedule := c.PostForm("schedule") + if schedule != "" { + request.Schedule = &schedule + } + + _, err = db.CreateTask(request) + if err != nil { renderError(c, http.StatusInternalServerError, err) return diff --git a/backend/web.gohtml b/backend/web.gohtml index cd23150..5d838c0 100644 --- a/backend/web.gohtml +++ b/backend/web.gohtml @@ -81,6 +81,7 @@