Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
06c8ebcbcc | |||
5a20e76df2 | |||
02c5c6ea68 | |||
9cbb8756d1 | |||
604b92b3b3 | |||
c7236394d9 | |||
720ef83c2e | |||
5b1d107cac | |||
662257ebc5 | |||
ad48882bca |
1
backend/.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
*.db3
|
*.db3
|
||||||
*.db3-*
|
*.db3-*
|
||||||
|
*.db3.*
|
||||||
/allowance_planner
|
/allowance_planner
|
||||||
|
@ -15,8 +15,9 @@ const (
|
|||||||
func startServer(t *testing.T) *httpexpect.Expect {
|
func startServer(t *testing.T) *httpexpect.Expect {
|
||||||
config := ServerConfig{
|
config := ServerConfig{
|
||||||
Datasource: ":memory:",
|
Datasource: ":memory:",
|
||||||
Addr: ":0",
|
//Datasource: "test.db",
|
||||||
Started: make(chan bool),
|
Addr: ":0",
|
||||||
|
Started: make(chan bool),
|
||||||
}
|
}
|
||||||
go start(t.Context(), &config)
|
go start(t.Context(), &config)
|
||||||
<-config.Started
|
<-config.Started
|
||||||
@ -284,6 +285,54 @@ func TestCreateTask(t *testing.T) {
|
|||||||
responseWithUser.Value("id").Number().IsEqual(2)
|
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) {
|
func TestDeleteTask(t *testing.T) {
|
||||||
e := startServer(t)
|
e := startServer(t)
|
||||||
|
|
||||||
|
104
backend/db.go
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/adhocore/gronx"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
@ -313,10 +314,20 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) {
|
|||||||
}
|
}
|
||||||
defer tx.MustRollback()
|
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
|
// Insert the new task
|
||||||
reward := int(math.Round(task.Reward * 100.0))
|
reward := int(math.Round(task.Reward * 100.0))
|
||||||
err = tx.Query("insert into tasks (name, reward, assigned) values (?, ?, ?)").
|
err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) values (?, ?, ?, ?, ?)").
|
||||||
Bind(task.Name, reward, task.Assigned).
|
Bind(task.Name, reward, task.Assigned, task.Schedule, nextRun).
|
||||||
Exec()
|
Exec()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -340,13 +351,17 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *Db) GetTasks() ([]Task, error) {
|
func (db *Db) GetTasks() ([]Task, error) {
|
||||||
tasks := make([]Task, 0)
|
err := db.UpdateScheduledTasks()
|
||||||
var err error
|
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{}
|
task := Task{}
|
||||||
var reward int64
|
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
|
task.Reward = float64(reward) / 100.0
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -362,16 +377,78 @@ func (db *Db) GetTasks() ([]Task, error) {
|
|||||||
func (db *Db) GetTask(id int) (Task, error) {
|
func (db *Db) GetTask(id int) (Task, error) {
|
||||||
task := Task{}
|
task := Task{}
|
||||||
|
|
||||||
var reward int64
|
err := db.UpdateScheduledTasks()
|
||||||
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
|
|
||||||
if err != nil {
|
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
|
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 {
|
func (db *Db) DeleteTask(id int) error {
|
||||||
tx, err := db.db.Begin()
|
tx, err := db.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -453,7 +530,10 @@ func (db *Db) CompleteTask(taskId int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove the task
|
// 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()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,8 @@ type Task struct {
|
|||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Reward float64 `json:"reward"`
|
Reward float64 `json:"reward"`
|
||||||
Assigned *int `json:"assigned"` // Pointer to allow null
|
Assigned *int `json:"assigned"`
|
||||||
|
Schedule *string `json:"schedule"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Allowance struct {
|
type Allowance struct {
|
||||||
@ -68,6 +69,7 @@ type CreateTaskRequest struct {
|
|||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Reward float64 `json:"reward"`
|
Reward float64 `json:"reward"`
|
||||||
Assigned *int `json:"assigned"`
|
Assigned *int `json:"assigned"`
|
||||||
|
Schedule *string `json:"schedule"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateTaskResponse struct {
|
type CreateTaskResponse struct {
|
||||||
|
@ -6,11 +6,12 @@ require (
|
|||||||
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0
|
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0
|
||||||
github.com/gavv/httpexpect/v2 v2.17.0
|
github.com/gavv/httpexpect/v2 v2.17.0
|
||||||
github.com/gin-contrib/cors v1.7.5
|
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 (
|
require (
|
||||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
|
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/ajg/form v1.5.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||||
github.com/bytedance/sonic v1.13.2 // 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/sergi/go-diff v1.3.1 // indirect
|
||||||
github.com/stretchr/testify v1.10.0 // indirect
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // 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/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.62.0 // indirect
|
github.com/valyala/fasthttp v1.62.0 // indirect
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // 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/fsnotify.v1 v1.4.7 // indirect
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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
|
moul.io/http2curl/v2 v2.3.0 // indirect
|
||||||
zombiezen.com/go/sqlite v1.4.0 // indirect
|
zombiezen.com/go/sqlite v1.4.2 // indirect
|
||||||
)
|
)
|
||||||
|
@ -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=
|
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 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
|
||||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
|
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 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
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-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 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
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=
|
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/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 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0=
|
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/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
|
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.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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
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/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
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.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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
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=
|
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 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU=
|
||||||
zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik=
|
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=
|
||||||
|
@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"gitea.seeseepuff.be/seeseemelk/mysqlite"
|
"gitea.seeseepuff.be/seeseemelk/mysqlite"
|
||||||
|
"github.com/adhocore/gronx"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -43,6 +45,11 @@ type ServerConfig struct {
|
|||||||
Started chan bool
|
Started chan bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DefaultDomain = "localhost:8080"
|
||||||
|
|
||||||
|
// The domain that the server is reachable at.
|
||||||
|
var domain = DefaultDomain
|
||||||
|
|
||||||
func getUsers(c *gin.Context) {
|
func getUsers(c *gin.Context) {
|
||||||
users, err := db.GetUsers()
|
users, err := db.GetUsers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -431,6 +438,14 @@ func createTask(c *gin.Context) {
|
|||||||
return
|
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 assigned is not nil, check if user exists
|
||||||
if taskRequest.Assigned != nil {
|
if taskRequest.Assigned != nil {
|
||||||
exists, err := db.UserExists(*taskRequest.Assigned)
|
exists, err := db.UserExists(*taskRequest.Assigned)
|
||||||
@ -508,6 +523,11 @@ func putTask(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
|
||||||
return
|
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)
|
err = db.UpdateTask(taskId, &taskRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -706,5 +726,10 @@ func main() {
|
|||||||
config.Datasource = "allowance_planner.db3"
|
config.Datasource = "allowance_planner.db3"
|
||||||
log.Printf("Warning: No DB_PATH set, using default of %s", config.Datasource)
|
log.Printf("Warning: No DB_PATH set, using default of %s", config.Datasource)
|
||||||
}
|
}
|
||||||
|
domain = os.Getenv("DOMAIN")
|
||||||
|
if domain == "" {
|
||||||
|
domain = DefaultDomain
|
||||||
|
log.Printf("Warning: No DOMAIN set, using default of %s", domain)
|
||||||
|
}
|
||||||
start(context.Background(), &config)
|
start(context.Background(), &config)
|
||||||
}
|
}
|
||||||
|
3
backend/migrations/5_add_schedules.sql
Normal file
@ -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;
|
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
@ -26,11 +27,22 @@ func loadWebEndpoints(router *gin.Engine) {
|
|||||||
router.GET("/completeAllowance", renderCompleteAllowance)
|
router.GET("/completeAllowance", renderCompleteAllowance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func redirectToPage(c *gin.Context, page string) {
|
||||||
|
redirectToPageStatus(c, page, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectToPageStatus(c *gin.Context, page string, status int) {
|
||||||
|
scheme := c.Request.URL.Scheme
|
||||||
|
target := scheme + domain + page
|
||||||
|
c.Redirect(status, target)
|
||||||
|
}
|
||||||
|
|
||||||
func renderLogin(c *gin.Context) {
|
func renderLogin(c *gin.Context) {
|
||||||
if c.Query("user") != "" {
|
if c.Query("user") != "" {
|
||||||
c.SetCookie("user", c.Query("user"), 3600, "/", "localhost", false, true)
|
log.Println("Set cookie for user:", c.Query("user"))
|
||||||
|
c.SetCookie("user", c.Query("user"), 3600, "", "", false, true)
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, "/")
|
redirectToPage(c, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderIndex(c *gin.Context) {
|
func renderIndex(c *gin.Context) {
|
||||||
@ -59,16 +71,24 @@ func renderCreateTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.CreateTask(&CreateTaskRequest{
|
request := &CreateTaskRequest{
|
||||||
Name: name,
|
Name: name,
|
||||||
Reward: reward,
|
Reward: reward,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
schedule := c.PostForm("schedule")
|
||||||
|
if schedule != "" {
|
||||||
|
request.Schedule = &schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.CreateTask(request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderError(c, http.StatusInternalServerError, err)
|
renderError(c, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "/")
|
redirectToPageStatus(c, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderCompleteTask(c *gin.Context) {
|
func renderCompleteTask(c *gin.Context) {
|
||||||
@ -85,7 +105,7 @@ func renderCompleteTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "/")
|
redirectToPageStatus(c, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderCreateAllowance(c *gin.Context) {
|
func renderCreateAllowance(c *gin.Context) {
|
||||||
@ -122,7 +142,7 @@ func renderCreateAllowance(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "/")
|
redirectToPageStatus(c, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderCompleteAllowance(c *gin.Context) {
|
func renderCompleteAllowance(c *gin.Context) {
|
||||||
@ -144,11 +164,12 @@ func renderCompleteAllowance(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "/")
|
redirectToPageStatus(c, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCurrentUser(c *gin.Context) *int {
|
func getCurrentUser(c *gin.Context) *int {
|
||||||
currentUserStr, err := c.Cookie("user")
|
currentUserStr, err := c.Cookie("user")
|
||||||
|
log.Println("Cookie string:", currentUserStr)
|
||||||
if errors.Is(err, http.ErrNoCookie) {
|
if errors.Is(err, http.ErrNoCookie) {
|
||||||
renderNoUser(c)
|
renderNoUser(c)
|
||||||
return nil
|
return nil
|
||||||
@ -172,7 +193,7 @@ func getCurrentUser(c *gin.Context) *int {
|
|||||||
|
|
||||||
func unsetUserCookie(c *gin.Context) {
|
func unsetUserCookie(c *gin.Context) {
|
||||||
c.SetCookie("user", "", -1, "/", "localhost", false, true)
|
c.SetCookie("user", "", -1, "/", "localhost", false, true)
|
||||||
c.Redirect(http.StatusFound, "/")
|
redirectToPageStatus(c, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderNoUser(c *gin.Context) {
|
func renderNoUser(c *gin.Context) {
|
||||||
|
@ -3,9 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>Allowance Planner 2000</title>
|
<title>Allowance Planner 2000</title>
|
||||||
<style>
|
<style>
|
||||||
|
<!--
|
||||||
tr:hover {
|
tr:hover {
|
||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
-->
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -27,7 +29,7 @@
|
|||||||
{{if ne .CurrentUser 0}}
|
{{if ne .CurrentUser 0}}
|
||||||
<h2>Allowances</h2>
|
<h2>Allowances</h2>
|
||||||
<form action="/createAllowance" method="post">
|
<form action="/createAllowance" method="post">
|
||||||
<table border="1">
|
<table border=1>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@ -43,7 +45,7 @@
|
|||||||
<td></td>
|
<td></td>
|
||||||
<td><label><input type="number" name="target" placeholder="Target"></label></td>
|
<td><label><input type="number" name="target" placeholder="Target"></label></td>
|
||||||
<td><label><input type="number" name="weight" placeholder="Weight"></label></td>
|
<td><label><input type="number" name="weight" placeholder="Weight"></label></td>
|
||||||
<td><button>Create</button></td>
|
<td><input type="submit" value="Create"></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{range .Allowances}}
|
{{range .Allowances}}
|
||||||
{{if eq .ID 0}}
|
{{if eq .ID 0}}
|
||||||
@ -79,6 +81,7 @@
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Assigned</th>
|
<th>Assigned</th>
|
||||||
<th>Reward</th>
|
<th>Reward</th>
|
||||||
|
<th>Schedule</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -94,6 +97,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>{{.Reward}}</td>
|
<td>{{.Reward}}</td>
|
||||||
|
<td>{{.Schedule}}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="/completeTask?task={{.ID}}">Mark as completed</a>
|
<a href="/completeTask?task={{.ID}}">Mark as completed</a>
|
||||||
</td>
|
</td>
|
||||||
@ -103,7 +107,8 @@
|
|||||||
<td><label><input type="text" name="name" placeholder="Name"></label></td>
|
<td><label><input type="text" name="name" placeholder="Name"></label></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td><label><input type="number" name="reward" placeholder="Reward"></label></td>
|
<td><label><input type="number" name="reward" placeholder="Reward"></label></td>
|
||||||
<td><button>Create</button></td>
|
<td><label><input type="text" name="schedule" placeholder="Schedule"></label></td>
|
||||||
|
<td><input type="submit" value="Create"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -422,7 +422,10 @@ components:
|
|||||||
description: The task name
|
description: The task name
|
||||||
reward:
|
reward:
|
||||||
type: integer
|
type: integer
|
||||||
description: The task reward, in cents
|
description: The task reward
|
||||||
|
schedule:
|
||||||
|
type: string
|
||||||
|
description: The schedule of the task, in cron format
|
||||||
assigned:
|
assigned:
|
||||||
type: integer
|
type: integer
|
||||||
description: The user ID of the user assigned to the task
|
description: The user ID of the user assigned to the task
|
||||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 9.1 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 91 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 122 KiB |
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 9.1 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 65 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 119 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 16 KiB |
@ -1,5 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
@ -1,5 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
After Width: | Height: | Size: 660 B |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 5.1 KiB |
After Width: | Height: | Size: 296 B |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 408 B |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 1006 B |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 7.5 KiB |
After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
@ -1,7 +1,7 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">allowance-planner-v2</string>
|
<string name="app_name">Allowance Planner V2</string>
|
||||||
<string name="title_activity_main">allowance-planner-v2</string>
|
<string name="title_activity_main">Allowance Planner V2</string>
|
||||||
<string name="package_name">io.ionic.starter</string>
|
<string name="package_name">io.ionic.starter</string>
|
||||||
<string name="custom_url_scheme">io.ionic.starter</string>
|
<string name="custom_url_scheme">io.ionic.starter</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
BIN
frontend/allowance-planner-v2/assets/icon-background.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
frontend/allowance-planner-v2/assets/icon-foreground.png
Normal file
After Width: | Height: | Size: 163 KiB |
BIN
frontend/allowance-planner-v2/assets/splash.png
Normal file
After Width: | Height: | Size: 130 KiB |
@ -2,7 +2,7 @@ import type { CapacitorConfig } from '@capacitor/cli';
|
|||||||
|
|
||||||
const config: CapacitorConfig = {
|
const config: CapacitorConfig = {
|
||||||
appId: 'io.ionic.starter',
|
appId: 'io.ionic.starter',
|
||||||
appName: 'allowance-planner-v2',
|
appName: 'Allowance Planner V2',
|
||||||
webDir: 'www'
|
webDir: 'www'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
3471
frontend/allowance-planner-v2/package-lock.json
generated
@ -47,6 +47,7 @@
|
|||||||
"@angular/cli": "^19.0.0",
|
"@angular/cli": "^19.0.0",
|
||||||
"@angular/compiler-cli": "^19.0.0",
|
"@angular/compiler-cli": "^19.0.0",
|
||||||
"@angular/language-service": "^19.0.0",
|
"@angular/language-service": "^19.0.0",
|
||||||
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@capacitor/cli": "7.2.0",
|
"@capacitor/cli": "7.2.0",
|
||||||
"@ionic/angular-toolkit": "^12.0.0",
|
"@ionic/angular-toolkit": "^12.0.0",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
|
@ -18,6 +18,16 @@ form,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
border: 1px solid var(--ion-color-primary);
|
||||||
|
border-radius: 5px;
|
||||||
|
width: 250px;
|
||||||
|
height: 40px;
|
||||||
|
padding-inline: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
color: var(--ion-color-primary);
|
color: var(--ion-color-primary);
|
||||||
margin-top: 25px;
|
margin-top: 25px;
|
||||||
@ -30,8 +40,7 @@ button {
|
|||||||
color: white;
|
color: white;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
margin-top: auto;
|
margin-top: 100px;
|
||||||
margin-bottom: 50px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled,
|
button:disabled,
|
||||||
|
@ -36,7 +36,7 @@ export class AllowancePage implements ViewWillEnter {
|
|||||||
allowance[0].name = 'Main Allowance';
|
allowance[0].name = 'Main Allowance';
|
||||||
this.allowance$.next(allowance);
|
this.allowance$.next(allowance);
|
||||||
})
|
})
|
||||||
}, 50);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
canFinishGoal(allowance: Allowance): boolean {
|
canFinishGoal(allowance: Allowance): boolean {
|
||||||
|
@ -8,6 +8,7 @@ import { EditAllowancePageRoutingModule } from './edit-allowance-routing.module'
|
|||||||
|
|
||||||
import { EditAllowancePage } from './edit-allowance.page';
|
import { EditAllowancePage } from './edit-allowance.page';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@ -16,7 +17,8 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
IonicModule,
|
IonicModule,
|
||||||
EditAllowancePageRoutingModule,
|
EditAllowancePageRoutingModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
MatSelectModule
|
||||||
],
|
],
|
||||||
declarations: [EditAllowancePage]
|
declarations: [EditAllowancePage]
|
||||||
})
|
})
|
||||||
|
@ -7,11 +7,6 @@
|
|||||||
<ion-title *ngIf="isAddMode">Create Goal</ion-title>
|
<ion-title *ngIf="isAddMode">Create Goal</ion-title>
|
||||||
<ion-title *ngIf="!isAddMode && goalId != 0">Edit Goal</ion-title>
|
<ion-title *ngIf="!isAddMode && goalId != 0">Edit Goal</ion-title>
|
||||||
<ion-title *ngIf="!isAddMode && goalId == 0">Edit Allowance</ion-title>
|
<ion-title *ngIf="!isAddMode && goalId == 0">Edit Allowance</ion-title>
|
||||||
<button
|
|
||||||
*ngIf="!isAddMode && goalId !=0"
|
|
||||||
class="remove-button"
|
|
||||||
(click)="deleteAllowance()"
|
|
||||||
>Delete Goal</button>
|
|
||||||
</div>
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
@ -33,9 +28,9 @@
|
|||||||
|
|
||||||
<div class="item" *ngIf="isAddMode || goalId != 0">
|
<div class="item" *ngIf="isAddMode || goalId != 0">
|
||||||
<label>Colour</label>
|
<label>Colour</label>
|
||||||
<select formControlName="color">
|
<mat-select [(value)]="selectedColor" formControlName="color" [style.--color]="selectedColor">
|
||||||
<option *ngFor="let color of possibleColors" [value]="color" [style.--background]="color">{{color}}</option>
|
<mat-option *ngFor="let color of possibleColors" [value]="color" [style.--background]="color">{{color}}</mat-option>
|
||||||
</select>
|
</mat-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" [disabled]="!form.valid" (click)="submit()">
|
<button type="button" [disabled]="!form.valid" (click)="submit()">
|
||||||
@ -43,5 +38,10 @@
|
|||||||
<span *ngIf="!isAddMode && goalId != 0">Update Goal</span>
|
<span *ngIf="!isAddMode && goalId != 0">Update Goal</span>
|
||||||
<span *ngIf="!isAddMode && goalId == 0">Update Allowance</span>
|
<span *ngIf="!isAddMode && goalId == 0">Update Allowance</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
*ngIf="!isAddMode && goalId !=0"
|
||||||
|
class="remove-button"
|
||||||
|
(click)="deleteAllowance()"
|
||||||
|
>Delete Goal</button>
|
||||||
</form>
|
</form>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
@ -4,10 +4,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.remove-button {
|
.remove-button {
|
||||||
background-color: var(--ion-color-primary);
|
margin-top: 10px;
|
||||||
margin-right: 15px;
|
background-color: var(--negative-amount-color);
|
||||||
width: 100px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
@ -28,17 +26,23 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select {
|
mat-select {
|
||||||
|
--color: black;
|
||||||
|
color: var(--color);
|
||||||
border: 1px solid var(--ion-color-primary);
|
border: 1px solid var(--ion-color-primary);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
|
height: 40px;
|
||||||
|
padding-inline: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: (--ion-font-family);
|
||||||
}
|
}
|
||||||
|
|
||||||
option {
|
mat-option {
|
||||||
--background: white;
|
--background: white;
|
||||||
background-color: var(--background);
|
|
||||||
color: var(--background);
|
color: var(--background);
|
||||||
font-family: var(--ion-font-family);
|
font-family: (--ion-font-family);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@ -47,8 +51,7 @@ button {
|
|||||||
color: white;
|
color: white;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
margin-top: auto;
|
margin-top: 100px;
|
||||||
margin-bottom: 50px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled,
|
button:disabled,
|
||||||
|
@ -15,6 +15,7 @@ export class EditAllowancePage implements OnInit {
|
|||||||
public goalId: number;
|
public goalId: number;
|
||||||
public userId: number;
|
public userId: number;
|
||||||
public isAddMode: boolean;
|
public isAddMode: boolean;
|
||||||
|
public selectedColor: string = '';
|
||||||
public possibleColors: Array<string> = [
|
public possibleColors: Array<string> = [
|
||||||
'#6199D9',
|
'#6199D9',
|
||||||
'#D98B61',
|
'#D98B61',
|
||||||
@ -73,6 +74,7 @@ export class EditAllowancePage implements OnInit {
|
|||||||
weight: allowance.weight,
|
weight: allowance.weight,
|
||||||
color: allowance.colour
|
color: allowance.colour
|
||||||
});
|
});
|
||||||
|
this.selectedColor = this.form.value.color;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import { EditTaskPageRoutingModule } from './edit-task-routing.module';
|
|||||||
|
|
||||||
import { EditTaskPage } from './edit-task.page';
|
import { EditTaskPage } from './edit-task.page';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@ -16,7 +17,8 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
IonicModule,
|
IonicModule,
|
||||||
EditTaskPageRoutingModule,
|
EditTaskPageRoutingModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
MatSelectModule
|
||||||
],
|
],
|
||||||
declarations: [EditTaskPage]
|
declarations: [EditTaskPage]
|
||||||
})
|
})
|
||||||
|
@ -6,11 +6,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<ion-title *ngIf="isAddMode">Create Task</ion-title>
|
<ion-title *ngIf="isAddMode">Create Task</ion-title>
|
||||||
<ion-title *ngIf="!isAddMode">Edit Task</ion-title>
|
<ion-title *ngIf="!isAddMode">Edit Task</ion-title>
|
||||||
<button
|
|
||||||
*ngIf="!isAddMode"
|
|
||||||
class="remove-button"
|
|
||||||
(click)="deleteTask()"
|
|
||||||
>Delete task</button>
|
|
||||||
</div>
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
@ -24,13 +19,18 @@
|
|||||||
<input id="reward" type="number" placeholder="0.00" name="price" min="0" value="0" step="0.01" formControlName="reward"/>
|
<input id="reward" type="number" placeholder="0.00" name="price" min="0" value="0" step="0.01" formControlName="reward"/>
|
||||||
|
|
||||||
<label>Assigned</label>
|
<label>Assigned</label>
|
||||||
<select formControlName="assigned">
|
<mat-select formControlName="assigned">
|
||||||
<option *ngFor="let user of users" [value]="user.id">{{ user.name }}</option>
|
<mat-option *ngFor="let user of users" [value]="user.id">{{ user.name }}</mat-option>
|
||||||
</select>
|
</mat-select>
|
||||||
|
|
||||||
<button type="button" [disabled]="!form.valid" (click)="submit()">
|
<button type="button" [disabled]="!form.valid" (click)="submit()">
|
||||||
<span *ngIf="isAddMode">Add Task</span>
|
<span *ngIf="isAddMode">Add Task</span>
|
||||||
<span *ngIf="!isAddMode">Update Task</span>
|
<span *ngIf="!isAddMode">Update Task</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
*ngIf="!isAddMode"
|
||||||
|
class="remove-button"
|
||||||
|
(click)="deleteTask()"
|
||||||
|
>Delete task</button>
|
||||||
</form>
|
</form>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
@ -4,10 +4,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.remove-button {
|
.remove-button {
|
||||||
background-color: var(--ion-color-primary);
|
margin-top: 10px;
|
||||||
margin-right: 15px;
|
background-color: var(--negative-amount-color);
|
||||||
width: 95px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
@ -24,10 +22,15 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select {
|
mat-select {
|
||||||
border: 1px solid var(--ion-color-primary);
|
border: 1px solid var(--ion-color-primary);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
|
height: 40px;
|
||||||
|
padding-inline: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: (--ion-font-family);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@ -36,8 +39,7 @@ button {
|
|||||||
color: white;
|
color: white;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
margin-top: auto;
|
margin-top: 100px;
|
||||||
margin-bottom: 50px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled,
|
button:disabled,
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
.left {
|
.left {
|
||||||
width: 70%;
|
width: 70%;
|
||||||
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date {
|
.date {
|
||||||
|
@ -18,8 +18,12 @@
|
|||||||
<div class="task" *ngFor="let task of tasks$ | async">
|
<div class="task" *ngFor="let task of tasks$ | async">
|
||||||
<button (click)="completeTask(task.id)">Done</button>
|
<button (click)="completeTask(task.id)">Done</button>
|
||||||
<div (click)="updateTask(task.id)" class="item">
|
<div (click)="updateTask(task.id)" class="item">
|
||||||
<div class="name">{{ task.name }}</div>
|
<div class="text">
|
||||||
<div class="assigned">{{ usernames[task.assigned ? task.assigned : 0] }}</div>
|
<div class="name">
|
||||||
|
{{ task.name }}
|
||||||
|
<span class="assigned">{{ usernames[task.assigned ? task.assigned : 0] }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="reward"
|
class="reward"
|
||||||
[ngClass]="{ 'negative': task.reward < 0 }"
|
[ngClass]="{ 'negative': task.reward < 0 }"
|
||||||
|
@ -31,6 +31,8 @@ mat-icon {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--line-color);
|
border-bottom: 1px solid var(--line-color);
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
|
padding-block: 10px;
|
||||||
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
@ -41,7 +43,6 @@ mat-icon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
margin-left: 10px;
|
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,6 +50,7 @@ mat-icon {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
color: var(--positive-amount-color);
|
color: var(--positive-amount-color);
|
||||||
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.negative {
|
.negative {
|
||||||
@ -56,21 +58,28 @@ mat-icon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
width: 57px;
|
height: 45px;
|
||||||
height: 30px;
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: white;
|
color: white;
|
||||||
background: var(--confirm-button-color);
|
background: var(--confirm-button-color);
|
||||||
|
padding-inline: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-button {
|
.add-button {
|
||||||
background-color: var(--ion-color-primary);
|
background-color: var(--ion-color-primary);
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
width: 75px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assigned {
|
.assigned {
|
||||||
color: var(--line-color);
|
color: var(--line-color);
|
||||||
margin-left: 3px;
|
margin-left: 3px;
|
||||||
font-size: 12px;
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 60%;
|
||||||
|
margin-left: 10px;
|
||||||
}
|
}
|
@ -33,7 +33,7 @@ export class TasksPage implements ViewWillEnter {
|
|||||||
this.taskService.getTaskList().subscribe(tasks => {
|
this.taskService.getTaskList().subscribe(tasks => {
|
||||||
this.tasks$.next(tasks);
|
this.tasks$.next(tasks);
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
createTask() {
|
createTask() {
|
||||||
|
@ -38,6 +38,7 @@
|
|||||||
|
|
||||||
ion-title {
|
ion-title {
|
||||||
color: var(--ion-color-primary);
|
color: var(--ion-color-primary);
|
||||||
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
ion-header {
|
ion-header {
|
||||||
@ -46,4 +47,25 @@ ion-header {
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-header.md {
|
||||||
|
ion-toolbar:first-child {
|
||||||
|
--padding-top: 30px;
|
||||||
|
--padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-alert .alert-wrapper.sc-ion-alert-md {
|
||||||
|
background-color: var(--ion-background-color) !important;
|
||||||
|
--background: unset !important;
|
||||||
|
box-shadow: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-alert .alert-tappable.sc-ion-alert-md {
|
||||||
|
background-color: var(--test-color);
|
||||||
}
|
}
|