28 Commits

Author SHA1 Message Date
1e463fec55 Add support for schedules
All checks were successful
Backend Build and Test / build (push) Successful in 6m55s
2025-05-30 20:19:20 +02:00
5a20e76df2 Improve compatibility with old browsers (#136)
All checks were successful
Backend Deploy / build (push) Successful in 3m16s
Backend Build and Test / build (push) Successful in 3m22s
Reviewed-on: #136
2025-05-29 13:54:52 +02:00
Huffle
02c5c6ea68 fix toolbar (#135)
All checks were successful
Backend Build and Test / build (push) Successful in 3m44s
Backend Deploy / build (push) Successful in 20s
closes #134

Reviewed-on: #135
2025-05-28 12:02:43 +02:00
Huffle
9cbb8756d1 fix select (#133)
All checks were successful
Backend Build and Test / build (push) Successful in 3m9s
Backend Deploy / build (push) Successful in 20s
closes #117

Reviewed-on: #133
2025-05-28 11:47:11 +02:00
Huffle
604b92b3b3 AP-116 (#131)
All checks were successful
Backend Build and Test / build (push) Successful in 3m17s
Backend Deploy / build (push) Successful in 18s
closes #130

Reviewed-on: #131
2025-05-28 10:27:11 +02:00
Huffle
c7236394d9 add app logo (#129)
Some checks failed
Backend Deploy / build (push) Successful in 17s
Backend Build and Test / build (push) Failing after 2m53s
closes #116

Reviewed-on: #129
2025-05-28 10:19:23 +02:00
Huffle
720ef83c2e change font size and move add and delete button in edit screens (#128)
All checks were successful
Backend Deploy / build (push) Successful in 18s
Backend Build and Test / build (push) Successful in 2m35s
closes #118
closes #119

Reviewed-on: #128
2025-05-28 10:00:18 +02:00
Huffle
5b1d107cac make done button bigger (#127)
All checks were successful
Backend Deploy / build (push) Successful in 20s
Backend Build and Test / build (push) Successful in 2m39s
closes #122

Reviewed-on: #127
2025-05-28 09:31:07 +02:00
Huffle
662257ebc5 fix statusbar (#126)
All checks were successful
Backend Deploy / build (push) Successful in 24s
Backend Build and Test / build (push) Successful in 2m59s
closes #123

Reviewed-on: #126
2025-05-28 09:18:29 +02:00
Huffle
ad48882bca icon (#115)
All checks were successful
Backend Build and Test / build (push) Successful in 3m16s
Backend Deploy / build (push) Successful in 22s
Reviewed-on: #115
2025-05-27 19:13:23 +02:00
Huffle
89d31fe150 Change server url (#111)
All checks were successful
Backend Deploy / build (push) Successful in 29s
Backend Build and Test / build (push) Successful in 3m15s
closes #103

Reviewed-on: #111
2025-05-27 19:06:23 +02:00
Huffle
305566c911 add UI to show assigned user (#110)
All checks were successful
Backend Deploy / build (push) Successful in 32s
Backend Build and Test / build (push) Successful in 3m7s
closes #105

Reviewed-on: #110
2025-05-27 18:48:03 +02:00
Huffle
8c2af22c85 Add functionality to add allowance (#101)
All checks were successful
Backend Deploy / build (push) Successful in 20s
Backend Build and Test / build (push) Successful in 2m33s
closes #69
closes #107

Reviewed-on: #101
2025-05-27 18:39:51 +02:00
a0d0c37fdb Add ability to subtract allowances (#108)
All checks were successful
Backend Build and Test / build (push) Successful in 3m38s
Backend Deploy / build (push) Successful in 1m53s
Closes #104

Reviewed-on: #108
2025-05-27 17:02:14 +02:00
2714f550a4 Add support for adding allowance to ID=0 (#106)
Some checks failed
Backend Build and Test / build (push) Has been cancelled
Backend Deploy / build (push) Has been cancelled
Closes #102

Reviewed-on: #106
2025-05-27 17:00:19 +02:00
Huffle
344f7a7eef add history description (#100)
Some checks failed
Backend Deploy / build (push) Successful in 21s
Backend Build and Test / build (push) Has been cancelled
closes #93

Reviewed-on: #100
2025-05-27 14:32:37 +02:00
8380e95217 Add endpoint to add funds (#99)
All checks were successful
Backend Deploy / build (push) Successful in 2m55s
Backend Build and Test / build (push) Successful in 3m5s
Closes #91

Reviewed-on: #99
2025-05-27 12:02:07 +02:00
db2f518cc2 Add history description (#98)
All checks were successful
Backend Deploy / build (push) Successful in 3m1s
Backend Build and Test / build (push) Successful in 3m5s
Closes #20

Reviewed-on: #98
2025-05-27 10:55:13 +02:00
Huffle
56a19acd0f add back button in edit pages (#96)
All checks were successful
Backend Deploy / build (push) Successful in 24s
Backend Build and Test / build (push) Successful in 3m2s
closes #89

Reviewed-on: #96
2025-05-26 13:57:17 +02:00
Huffle
8fa4918743 comment out code (#95)
All checks were successful
Backend Build and Test / build (push) Successful in 3m30s
Backend Deploy / build (push) Successful in 21s
Reviewed-on: #95
2025-05-26 13:43:14 +02:00
Huffle
11913d72aa add history list (#94)
Some checks failed
Backend Build and Test / build (push) Has been cancelled
Backend Deploy / build (push) Has been cancelled
closes #68

Reviewed-on: #94
2025-05-26 13:40:14 +02:00
Huffle
45f40a7976 add possibility to finish a goal (#90)
All checks were successful
Backend Deploy / build (push) Successful in 16s
Backend Build and Test / build (push) Successful in 2m47s
closes #67

Reviewed-on: #90
2025-05-26 10:29:40 +02:00
Huffle
63982115a7 add way of removing a goal (#88)
All checks were successful
Backend Deploy / build (push) Successful in 34s
Backend Build and Test / build (push) Successful in 3m5s
closes #66

Reviewed-on: #88
2025-05-26 10:04:57 +02:00
Huffle
e7b4adfa95 AP-65 (#87)
All checks were successful
Backend Deploy / build (push) Successful in 22s
Backend Build and Test / build (push) Successful in 2m11s
closes #65

Reviewed-on: #87
2025-05-26 09:52:10 +02:00
Huffle
550933db11 AP-64 (#86)
Some checks failed
Backend Deploy / build (push) Successful in 23s
Backend Build and Test / build (push) Failing after 2m2s
closes #64

Reviewed-on: #86
2025-05-26 09:10:25 +02:00
Huffle
daebcdeccd AP-63 (#85)
All checks were successful
Backend Deploy / build (push) Successful in 29s
Backend Build and Test / build (push) Successful in 3m3s
closes #63

Reviewed-on: #85
2025-05-25 17:05:31 +02:00
302ceaa629 Update default allowance, but differently (#84)
All checks were successful
Backend Build and Test / build (push) Successful in 3m27s
Backend Deploy / build (push) Successful in 1m47s
Reviewed-on: #84
2025-05-25 15:07:51 +02:00
8cbfff81f6 Set default total allowance to 10 (#83)
Some checks failed
Backend Build and Test / build (push) Has started running
Backend Deploy / build (push) Has been cancelled
Reviewed-on: #83
2025-05-25 15:04:59 +02:00
88 changed files with 4990 additions and 167 deletions

1
backend/.gitignore vendored
View File

@@ -1,3 +1,4 @@
*.db3 *.db3
*.db3-* *.db3-*
*.db3.*
/allowance_planner /allowance_planner

View File

@@ -15,6 +15,7 @@ const (
func startServer(t *testing.T) *httpexpect.Expect { func startServer(t *testing.T) *httpexpect.Expect {
config := ServerConfig{ config := ServerConfig{
Datasource: ":memory:", Datasource: ":memory:",
//Datasource: "test.db",
Addr: ":0", Addr: ":0",
Started: make(chan bool), Started: make(chan bool),
} }
@@ -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)
@@ -437,9 +486,9 @@ func TestPutTaskInvalidTaskId(t *testing.T) {
func TestPostHistory(t *testing.T) { func TestPostHistory(t *testing.T) {
e := startServer(t) e := startServer(t)
e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100}).Expect().Status(200) e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100, Description: "Add a 100"}).Expect().Status(200)
e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20}).Expect().Status(200) e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20, Description: "Lolol"}).Expect().Status(200)
e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10}).Expect().Status(200) e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10, Description: "Subtracting"}).Expect().Status(200)
response := e.GET("/user/1").Expect().Status(200).JSON().Object() response := e.GET("/user/1").Expect().Status(200).JSON().Object()
response.Value("allowance").Number().IsEqual(100 + 20 - 10) response.Value("allowance").Number().IsEqual(100 + 20 - 10)
@@ -448,23 +497,36 @@ func TestPostHistory(t *testing.T) {
func TestPostHistoryInvalidUserId(t *testing.T) { func TestPostHistoryInvalidUserId(t *testing.T) {
e := startServer(t) e := startServer(t)
e.POST("/user/999/history").WithJSON(PostHistory{Allowance: 100}).Expect(). e.POST("/user/999/history").WithJSON(PostHistory{Allowance: 100, Description: "Good"}).Expect().
Status(404) Status(404)
} }
func TestPostHistoryInvalidDescription(t *testing.T) {
e := startServer(t)
e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100}).Expect().
Status(400)
}
func TestGetHistory(t *testing.T) { func TestGetHistory(t *testing.T) {
e := startServer(t) e := startServer(t)
e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100}).Expect().Status(200) e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100, Description: "Add 100"}).Expect().Status(200)
e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20}).Expect().Status(200) e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20, Description: "Add 20"}).Expect().Status(200)
e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10}).Expect().Status(200) e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10, Description: "Subtract 10"}).Expect().Status(200)
response := e.GET("/user/1/history").Expect().Status(200).JSON().Array() response := e.GET("/user/1/history").Expect().Status(200).JSON().Array()
response.Length().IsEqual(3) response.Length().IsEqual(3)
response.Value(0).Object().Length().IsEqual(3)
response.Value(0).Object().Value("allowance").Number().IsEqual(100) response.Value(0).Object().Value("allowance").Number().IsEqual(100)
response.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) response.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0))
response.Value(0).Object().Value("description").String().IsEqual("Add 100")
response.Value(1).Object().Value("allowance").Number().IsEqual(20) response.Value(1).Object().Value("allowance").Number().IsEqual(20)
response.Value(1).Object().Value("description").String().IsEqual("Add 20")
response.Value(2).Object().Value("allowance").Number().IsEqual(-10) response.Value(2).Object().Value("allowance").Number().IsEqual(-10)
response.Value(2).Object().Value("description").String().IsEqual("Subtract 10")
} }
func TestGetUserAllowanceById(t *testing.T) { func TestGetUserAllowanceById(t *testing.T) {
@@ -605,6 +667,36 @@ func TestCompleteTask(t *testing.T) {
} }
} }
func TestCompleteTaskWithNoWeights(t *testing.T) {
e := startServer(t)
taskId := createTestTaskWithAmount(e, 101)
e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1)
// Ensure main allowance has no weight
e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{
Weight: 0,
}).Expect().Status(200)
// Complete the task
e.POST("/task/" + strconv.Itoa(taskId) + "/complete").Expect().Status(200)
// Verify the task is marked as completed
e.GET("/task/" + strconv.Itoa(taskId)).Expect().Status(404)
// Verify the allowances are updated for user 1
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Length().IsEqual(1)
allowances.Value(0).Object().Value("id").Number().IsEqual(0)
allowances.Value(0).Object().Value("progress").Number().InDelta(101.00, 0.01)
// And also for user 2
allowances = e.GET("/user/2/allowance").Expect().Status(200).JSON().Array()
allowances.Length().IsEqual(1)
allowances.Value(0).Object().Value("id").Number().IsEqual(0)
allowances.Value(0).Object().Value("progress").Number().InDelta(101.00, 0.01)
}
func TestCompleteTaskAllowanceWeightsSumTo0(t *testing.T) { func TestCompleteTaskAllowanceWeightsSumTo0(t *testing.T) {
e := startServer(t) e := startServer(t)
taskId := createTestTaskWithAmount(e, 101) taskId := createTestTaskWithAmount(e, 101)
@@ -643,6 +735,11 @@ func TestCompleteAllowance(t *testing.T) {
createTestTaskWithAmount(e, 100) createTestTaskWithAmount(e, 100)
createTestAllowance(e, "Test Allowance 1", 100, 50) createTestAllowance(e, "Test Allowance 1", 100, 50)
// Update base allowance
e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{
Weight: 0,
}).Expect().Status(200)
// Complete the task // Complete the task
e.POST("/task/1/complete").Expect().Status(200) e.POST("/task/1/complete").Expect().Status(200)
@@ -655,10 +752,15 @@ func TestCompleteAllowance(t *testing.T) {
// Verify history is updated // Verify history is updated
history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() history := e.GET("/user/1/history").Expect().Status(200).JSON().Array()
history.Length().IsEqual(2) history.Length().IsEqual(2)
history.Value(0).Object().Length().IsEqual(3)
history.Value(0).Object().Value("allowance").Number().IsEqual(100) history.Value(0).Object().Value("allowance").Number().IsEqual(100)
history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0))
history.Value(0).Object().Value("description").String().IsEqual("Task completed: Test Task")
history.Value(1).Object().Length().IsEqual(3)
history.Value(1).Object().Value("allowance").Number().IsEqual(-100) history.Value(1).Object().Value("allowance").Number().IsEqual(-100)
history.Value(1).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) history.Value(1).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0))
history.Value(1).Object().Value("description").String().IsEqual("Allowance completed: Test Allowance 1")
} }
func TestCompleteAllowanceInvalidUserId(t *testing.T) { func TestCompleteAllowanceInvalidUserId(t *testing.T) {
@@ -705,6 +807,145 @@ func TestPutBulkAllowance(t *testing.T) {
allowances.Value(2).Object().Value("weight").Number().IsEqual(10) allowances.Value(2).Object().Value("weight").Number().IsEqual(10)
} }
func TestAddAllowanceSimple(t *testing.T) {
e := startServer(t)
createTestAllowance(e, "Test Allowance 1", 1000, 1)
request := map[string]interface{}{
"amount": 10,
"description": "Added to allowance 1",
}
e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200)
// Verify the allowance is updated
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Value(1).Object().Value("id").Number().IsEqual(1)
allowances.Value(1).Object().Value("progress").Number().InDelta(10.0, 0.01)
// Verify the history is updated
history := e.GET("/user/1/history").Expect().Status(200).JSON().Array()
history.Length().IsEqual(1)
history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01)
history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0))
history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1")
}
func TestAddAllowanceWithSpillage(t *testing.T) {
e := startServer(t)
createTestAllowance(e, "Test Allowance 1", 5, 1)
createTestAllowance(e, "Test Allowance 2", 5, 1)
e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{Weight: 1}).Expect().Status(200)
request := map[string]interface{}{
"amount": 10,
"description": "Added to allowance 1",
}
e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200)
// Verify the allowance is updated
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Value(1).Object().Value("id").Number().IsEqual(1)
allowances.Value(1).Object().Value("progress").Number().InDelta(5.0, 0.01)
allowances.Value(2).Object().Value("id").Number().IsEqual(2)
allowances.Value(2).Object().Value("progress").Number().InDelta(2.5, 0.01)
allowances.Value(0).Object().Value("id").Number().IsEqual(0)
allowances.Value(0).Object().Value("progress").Number().InDelta(2.5, 0.01)
// Verify the history is updated
history := e.GET("/user/1/history").Expect().Status(200).JSON().Array()
history.Length().IsEqual(1)
history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01)
history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0))
history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1")
}
func TestAddAllowanceIdZero(t *testing.T) {
e := startServer(t)
createTestAllowance(e, "Test Allowance 1", 1000, 1)
request := map[string]interface{}{
"amount": 10,
"description": "Added to allowance 1",
}
e.POST("/user/1/allowance/0/add").WithJSON(request).Expect().Status(200)
// Verify the allowance is updated
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Value(0).Object().Value("id").Number().IsEqual(0)
allowances.Value(0).Object().Value("progress").Number().InDelta(10.0, 0.01)
// Verify the history is updated
history := e.GET("/user/1/history").Expect().Status(200).JSON().Array()
history.Length().IsEqual(1)
history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01)
history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0))
history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1")
}
func TestSubtractAllowanceSimple(t *testing.T) {
e := startServer(t)
createTestAllowance(e, "Test Allowance 1", 1000, 1)
request := map[string]interface{}{
"amount": 10,
"description": "Added to allowance 1",
}
e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200)
request["amount"] = -2.5
e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200)
// Verify the allowance is updated
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Value(1).Object().Value("id").Number().IsEqual(1)
allowances.Value(1).Object().Value("progress").Number().InDelta(7.5, 0.01)
// Verify the history is updated
history := e.GET("/user/1/history").Expect().Status(200).JSON().Array()
history.Length().IsEqual(2)
history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01)
history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0))
history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1")
history.Value(1).Object().Value("allowance").Number().InDelta(-2.5, 0.01)
history.Value(1).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0))
history.Value(1).Object().Value("description").String().IsEqual("Added to allowance 1")
}
func TestSubtractllowanceIdZero(t *testing.T) {
e := startServer(t)
createTestAllowance(e, "Test Allowance 1", 1000, 1)
request := map[string]interface{}{
"amount": 10,
"description": "Added to allowance 1",
}
e.POST("/user/1/allowance/0/add").WithJSON(request).Expect().Status(200)
request["amount"] = -2.5
e.POST("/user/1/allowance/0/add").WithJSON(request).Expect().Status(200)
// Verify the allowance is updated
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Value(0).Object().Value("id").Number().IsEqual(0)
allowances.Value(0).Object().Value("progress").Number().InDelta(7.5, 0.01)
// Verify the history is updated
history := e.GET("/user/1/history").Expect().Status(200).JSON().Array()
history.Length().IsEqual(2)
history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01)
history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0))
history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1")
history.Value(1).Object().Value("allowance").Number().InDelta(-2.5, 0.01)
history.Value(1).Object().Value("description").String().IsEqual("Added to allowance 1")
}
func getDelta(base time.Time, delta float64) (time.Time, time.Time) { func getDelta(base time.Time, delta float64) (time.Time, time.Time) {
start := base.Add(-time.Duration(delta) * time.Second) start := base.Add(-time.Duration(delta) * time.Second)
end := base.Add(time.Duration(delta) * time.Second) end := base.Add(time.Duration(delta) * time.Second)

View File

@@ -2,6 +2,8 @@ package main
import ( import (
"errors" "errors"
"fmt"
"github.com/adhocore/gronx"
"log" "log"
"math" "math"
"time" "time"
@@ -206,8 +208,9 @@ func (db *Db) CompleteAllowance(userId int, allowanceId int) error {
// Get the cost of the allowance // Get the cost of the allowance
var cost int var cost int
err = tx.Query("select balance from allowances where id = ? and user_id = ?"). var allowanceName string
Bind(allowanceId, userId).ScanSingle(&cost) err = tx.Query("select balance, name from allowances where id = ? and user_id = ?").
Bind(allowanceId, userId).ScanSingle(&cost, &allowanceName)
if err != nil { if err != nil {
return err return err
} }
@@ -220,8 +223,8 @@ func (db *Db) CompleteAllowance(userId int, allowanceId int) error {
} }
// Add a history entry // Add a history entry
err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)").
Bind(userId, time.Now().Unix(), -cost). Bind(userId, time.Now().Unix(), -cost, fmt.Sprintf("Allowance completed: %s", allowanceName)).
Exec() Exec()
if err != nil { if err != nil {
return err return err
@@ -311,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 {
@@ -338,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
@@ -360,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 {
@@ -420,27 +499,52 @@ func (db *Db) CompleteTask(taskId int) error {
defer tx.MustRollback() defer tx.MustRollback()
var reward int var reward int
err = tx.Query("select reward from tasks where id = ?").Bind(taskId).ScanSingle(&reward) var rewardName string
err = tx.Query("select reward, name from tasks where id = ?").Bind(taskId).ScanSingle(&reward, &rewardName)
if err != nil { if err != nil {
return err return err
} }
for userRow := range tx.Query("select id, weight from users").Range(&err) { for userRow := range tx.Query("select id from users").Range(&err) {
var userId int var userId int
var userWeight float64 err = userRow.Scan(&userId)
err = userRow.Scan(&userId, &userWeight)
if err != nil { if err != nil {
return err return err
} }
// Add the history entry // Add the history entry
err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)").
Bind(userId, time.Now().Unix(), reward). Bind(userId, time.Now().Unix(), reward, fmt.Sprintf("Task completed: %s", rewardName)).
Exec() Exec()
if err != nil { if err != nil {
return err return err
} }
err := db.addDistributedReward(tx, userId, reward)
if err != nil {
return err
}
}
if err != nil {
return err
}
// Remove the task
err = tx.Query("update tasks set completed=? where id = ?").Bind(time.Now().Unix(), taskId).Exec()
if err != nil {
return err
}
return tx.Commit()
}
func (db *Db) addDistributedReward(tx *mysqlite.Tx, userId int, reward int) error {
var userWeight float64
err := tx.Query("select weight from users where id = ?").Bind(userId).ScanSingle(&userWeight)
if err != nil {
return err
}
// Calculate the sums of all weights // Calculate the sums of all weights
var sumOfWeights float64 var sumOfWeights float64
err = tx.Query("select sum(weight) from allowances where user_id = ? and weight > 0").Bind(userId).ScanSingle(&sumOfWeights) err = tx.Query("select sum(weight) from allowances where user_id = ? and weight > 0").Bind(userId).ScanSingle(&sumOfWeights)
@@ -477,18 +581,7 @@ func (db *Db) CompleteTask(taskId int) error {
// Add the remaining reward to the user // Add the remaining reward to the user
err = tx.Query("update users set balance = balance + ? where id = ?"). err = tx.Query("update users set balance = balance + ? where id = ?").
Bind(remainingReward, userId).Exec() Bind(remainingReward, userId).Exec()
if err != nil {
return err return err
}
}
if err != nil {
return err
}
// Remove the task
err = tx.Query("delete from tasks where id = ?").Bind(taskId).Exec()
return tx.Commit()
} }
func (db *Db) AddHistory(userId int, allowance *PostHistory) error { func (db *Db) AddHistory(userId int, allowance *PostHistory) error {
@@ -499,8 +592,8 @@ func (db *Db) AddHistory(userId int, allowance *PostHistory) error {
defer tx.MustRollback() defer tx.MustRollback()
amount := int(math.Round(allowance.Allowance * 100.0)) amount := int(math.Round(allowance.Allowance * 100.0))
err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)").
Bind(userId, time.Now().Unix(), amount). Bind(userId, time.Now().Unix(), amount, allowance.Description).
Exec() Exec()
if err != nil { if err != nil {
return err return err
@@ -512,11 +605,11 @@ func (db *Db) GetHistory(userId int) ([]History, error) {
history := make([]History, 0) history := make([]History, 0)
var err error var err error
for row := range db.db.Query("select amount, `timestamp` from history where user_id = ? order by `timestamp` desc"). for row := range db.db.Query("select amount, `timestamp`, description from history where user_id = ? order by `timestamp` desc").
Bind(userId).Range(&err) { Bind(userId).Range(&err) {
allowance := History{} allowance := History{}
var timestamp, amount int64 var timestamp, amount int64
err = row.Scan(&amount, &timestamp) err = row.Scan(&amount, &timestamp, &allowance.Description)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -529,3 +622,92 @@ func (db *Db) GetHistory(userId int) ([]History, error) {
} }
return history, nil return history, nil
} }
func (db *Db) AddAllowanceAmount(userId int, allowanceId int, request AddAllowanceAmountRequest) error {
tx, err := db.db.Begin()
if err != nil {
return err
}
defer tx.MustRollback()
// Convert amount to integer (cents)
remainingAmount := int(math.Round(request.Amount * 100))
// Insert history entry
err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)").
Bind(userId, time.Now().Unix(), remainingAmount, request.Description).
Exec()
if err != nil {
return err
}
if allowanceId == 0 {
if remainingAmount < 0 {
var userBalance int
err = tx.Query("select balance from users where id = ?").
Bind(userId).ScanSingle(&userBalance)
if err != nil {
return err
}
if remainingAmount > userBalance {
return fmt.Errorf("cannot remove more than the current balance: %d", userBalance)
}
}
err = tx.Query("update users set balance = balance + ? where id = ?").
Bind(remainingAmount, userId).Exec()
if err != nil {
return err
}
} else if remainingAmount < 0 {
var progress int
err = tx.Query("select balance from allowances where id = ? and user_id = ?").
Bind(allowanceId, userId).ScanSingle(&progress)
if err != nil {
return err
}
if remainingAmount > progress {
return fmt.Errorf("cannot remove more than the current allowance balance: %d", progress)
}
err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?").
Bind(remainingAmount, allowanceId, userId).Exec()
if err != nil {
return err
}
} else {
// Fetch the target and progress of the specified allowance
var target, progress int
err = tx.Query("select target, balance from allowances where id = ? and user_id = ?").
Bind(allowanceId, userId).ScanSingle(&target, &progress)
if err != nil {
return err
}
// Calculate the amount to add to the current allowance
toAdd := remainingAmount
if progress+toAdd > target {
toAdd = target - progress
}
remainingAmount -= toAdd
// Update the current allowance
if toAdd > 0 {
err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?").
Bind(toAdd, allowanceId, userId).Exec()
if err != nil {
return err
}
}
// If there's remaining amount, distribute it to the user's allowances
if remainingAmount > 0 {
err = db.addDistributedReward(tx, userId, remainingAmount)
if err != nil {
return err
}
}
}
return tx.Commit()
}

View File

@@ -16,10 +16,12 @@ type UserWithAllowance struct {
type History struct { type History struct {
Allowance float64 `json:"allowance"` Allowance float64 `json:"allowance"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
Description string `json:"description"`
} }
type PostHistory struct { type PostHistory struct {
Allowance float64 `json:"allowance"` Allowance float64 `json:"allowance"`
Description string `json:"description"`
} }
// Task represents a task in the system. // Task represents a task in the system.
@@ -27,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 {
@@ -66,8 +69,14 @@ 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 {
ID int `json:"id"` ID int `json:"id"`
} }
type AddAllowanceAmountRequest struct {
Amount float64 `json:"amount"`
Description string `json:"description"`
}

View File

@@ -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
) )

View File

@@ -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=

View File

@@ -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 {
@@ -368,6 +375,56 @@ func completeAllowance(c *gin.Context) {
c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance completed successfully"}) c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance completed successfully"})
} }
func addToAllowance(c *gin.Context) {
userIdStr := c.Param("userId")
allowanceIdStr := c.Param("allowanceId")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
log.Printf(ErrInvalidUserID+": %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidUserID})
return
}
allowanceId, err := strconv.Atoi(allowanceIdStr)
if err != nil {
log.Printf("Invalid allowance ID: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid allowance ID"})
return
}
exists, err := db.UserExists(userId)
if err != nil {
log.Printf(ErrCheckingUserExist, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": ErrUserNotFound})
return
}
var allowanceRequest AddAllowanceAmountRequest
if err := c.ShouldBindJSON(&allowanceRequest); err != nil {
log.Printf("Error parsing request body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = db.AddAllowanceAmount(userId, allowanceId, allowanceRequest)
if errors.Is(err, mysqlite.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Allowance not found"})
return
}
if err != nil {
log.Printf("Error completing allowance: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
return
}
c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance completed successfully"})
}
func createTask(c *gin.Context) { func createTask(c *gin.Context) {
var taskRequest CreateTaskRequest var taskRequest CreateTaskRequest
if err := c.ShouldBindJSON(&taskRequest); err != nil { if err := c.ShouldBindJSON(&taskRequest); err != nil {
@@ -381,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)
@@ -458,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 {
@@ -539,6 +609,11 @@ func postHistory(c *gin.Context) {
return return
} }
if historyRequest.Description == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Description cannot be empty"})
return
}
exists, err := db.UserExists(userId) exists, err := db.UserExists(userId)
if err != nil { if err != nil {
log.Printf(ErrCheckingUserExist, err) log.Printf(ErrCheckingUserExist, err)
@@ -606,6 +681,7 @@ func start(ctx context.Context, config *ServerConfig) {
router.DELETE("/api/user/:userId/allowance/:allowanceId", deleteUserAllowance) router.DELETE("/api/user/:userId/allowance/:allowanceId", deleteUserAllowance)
router.PUT("/api/user/:userId/allowance/:allowanceId", putUserAllowance) router.PUT("/api/user/:userId/allowance/:allowanceId", putUserAllowance)
router.POST("/api/user/:userId/allowance/:allowanceId/complete", completeAllowance) router.POST("/api/user/:userId/allowance/:allowanceId/complete", completeAllowance)
router.POST("/api/user/:userId/allowance/:allowanceId/add", addToAllowance)
router.POST("/api/tasks", createTask) router.POST("/api/tasks", createTask)
router.GET("/api/tasks", getTasks) router.GET("/api/tasks", getTasks)
router.GET("/api/task/:taskId", getTask) router.GET("/api/task/:taskId", getTask)
@@ -650,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)
} }

View File

@@ -2,7 +2,7 @@ create table users
( (
id integer primary key, id integer primary key,
name text not null, name text not null,
weight real not null default 0.0, weight real not null default 10.0,
balance integer not null default 0 balance integer not null default 0
) strict; ) strict;

View File

@@ -0,0 +1 @@
update users set weight = 10.0 where weight = 0.0;

View File

@@ -0,0 +1,2 @@
alter table history
add column description text;

View 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;

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -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'
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -11,7 +11,6 @@ const routes: Routes = [
path: '', path: '',
loadChildren: () => import('./pages/tabs/tabs.module').then(m => m.TabsPageModule) loadChildren: () => import('./pages/tabs/tabs.module').then(m => m.TabsPageModule)
}, },
]; ];
@NgModule({ @NgModule({
imports: [ imports: [

View File

@@ -0,0 +1,5 @@
export interface History {
timestamp: string;
allowance: number;
description: string;
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AddAllowancePage } from './add-allowance.page';
const routes: Routes = [
{
path: '',
component: AddAllowancePage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AddAllowancePageRoutingModule {}

View File

@@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { AddAllowancePageRoutingModule } from './add-allowance-routing.module';
import { AddAllowancePage } from './add-allowance.page';
import { MatIconModule } from '@angular/material/icon';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
AddAllowancePageRoutingModule,
ReactiveFormsModule,
MatIconModule
],
declarations: [AddAllowancePage]
})
export class AddAllowancePageModule {}

View File

@@ -0,0 +1,27 @@
<ion-header [translucent]="true">
<ion-toolbar>
<div class="toolbar">
<div class="icon" (click)="navigateBack()">
<mat-icon>arrow_back</mat-icon>
</div>
<ion-title *ngIf="isAddMode && goalId == 0">Add to Allowance</ion-title>
<ion-title *ngIf="isAddMode && goalId != 0">Add to Goal</ion-title>
<ion-title *ngIf="!isAddMode">Spend Allowance</ion-title>
</div>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<form [formGroup]="form">
<label>Amount</label>
<input id="amount" type="number" placeholder="0.00" name="price" min="0" value="0" step="0.01" formControlName="amount"/>
<label>Description</label>
<input id="description" type="text" formControlName="description"/>
<button type="button" [disabled]="!form.valid" (click)="changeAllowance()">
<span *ngIf="isAddMode">Add</span>
<span *ngIf="!isAddMode">Spend</span>
</button>
</form>
</ion-content>

View File

@@ -0,0 +1,49 @@
.toolbar {
display: flex;
align-items: center;
}
.icon {
margin-left: 5px;
}
form {
height: 100%;
}
form,
.item {
display: flex;
flex-direction: column;
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 {
color: var(--ion-color-primary);
margin-top: 25px;
margin-bottom: 10px;
}
button {
background-color: var(--ion-color-primary);
border-radius: 5px;
color: white;
padding: 10px;
width: 250px;
margin-top: 100px;
}
button:disabled,
button[disabled]{
opacity: 0.5;
}

View File

@@ -0,0 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AddAllowancePage } from './add-allowance.page';
describe('AddAllowancePage', () => {
let component: AddAllowancePage;
let fixture: ComponentFixture<AddAllowancePage>;
beforeEach(() => {
fixture = TestBed.createComponent(AddAllowancePage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,51 @@
import { Location } from '@angular/common';
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { AllowanceService } from 'src/app/services/allowance.service';
@Component({
selector: 'app-add-allowance',
templateUrl: './add-allowance.page.html',
styleUrls: ['./add-allowance.page.scss'],
standalone: false,
})
export class AddAllowancePage {
public form: FormGroup;
public goalId: number;
public userId: number;
public isAddMode = true;
// Marcus' first comment
// b ........a`.OK ø¶Ópppppppp--P09OP
constructor(
private allowanceService: AllowanceService,
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private router: Router,
private location: Location
) {
this.userId = this.route.snapshot.params['id'];
this.goalId = this.route.snapshot.params['goalId'];
this.form = this.formBuilder.group({
amount: ['', Validators.required],
description: ['', Validators.required]
});
}
changeAllowance() {
this.allowanceService.addOrSpendAllowance(
this.goalId,
this.userId,
this.form.value.amount,
this.form.value.description
);
this.router.navigate(['/tabs/allowance', this.userId]);
}
navigateBack() {
this.location.back();
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { SpendllowancePage } from './spend-allowance.page';
const routes: Routes = [
{
path: '',
component: SpendllowancePage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class SpendAllowancePageRoutingModule {}

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { SpendAllowancePageRoutingModule } from './spend-allowance-routing.module';
import { SpendllowancePage } from './spend-allowance.page';
import { MatIconModule } from '@angular/material/icon';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
SpendAllowancePageRoutingModule,
ReactiveFormsModule,
MatIconModule
],
declarations: [SpendllowancePage]
})
export class SpendAllowancePageModule {}

View File

@@ -0,0 +1,52 @@
import { Location } from '@angular/common';
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { AllowanceService } from 'src/app/services/allowance.service';
@Component({
selector: 'app-spend-allowance',
templateUrl: './add-allowance.page.html',
styleUrls: ['./add-allowance.page.scss'],
standalone: false,
})
export class SpendllowancePage {
public form: FormGroup;
public goalId: number;
public userId: number;
public isAddMode = false;
constructor(
private allowanceService: AllowanceService,
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private router: Router,
private location: Location
) {
this.userId = this.route.snapshot.params['id'];
this.goalId = this.route.snapshot.params['goalId'];
this.form = this.formBuilder.group({
amount: ['', Validators.required],
description: ['', Validators.required]
});
this.allowanceService.getAllowanceById(this.goalId, this.userId).subscribe(allowance => {
this.form.controls['amount'].addValidators([Validators.max(allowance.progress)]);
});
}
changeAllowance() {
this.allowanceService.addOrSpendAllowance(
this.goalId,
this.userId,
-this.form.value.amount,
this.form.value.description
);
this.router.navigate(['/tabs/allowance', this.userId]);
}
navigateBack() {
this.location.back();
}
}

View File

@@ -6,6 +6,22 @@ const routes: Routes = [
{ {
path: ':id', path: ':id',
component: AllowancePage, component: AllowancePage,
},
{
path: ':id/add',
loadChildren: () => import('../edit-allowance/edit-allowance.module').then(m => m.EditAllowancePageModule)
},
{
path: ':id/edit/:goalId',
loadChildren: () => import('../edit-allowance/edit-allowance.module').then(m => m.EditAllowancePageModule)
},
{
path: ':id/increase/:goalId',
loadChildren: () => import('../add-allowance/add-allowance.module').then(m => m.AddAllowancePageModule)
},
{
path: ':id/spend/:goalId',
loadChildren: () => import('../add-allowance/spend-allowance.module').then(m => m.SpendAllowancePageModule)
} }
]; ];

View File

@@ -1,8 +1,11 @@
<ion-header [translucent]="true" class="ion-no-border"> <ion-header [translucent]="true" class="ion-no-border">
<ion-toolbar> <ion-toolbar>
<div class="toolbar">
<ion-title> <ion-title>
Allowance Allowance
</ion-title> </ion-title>
<button class="top-add-button" (click)="createAllowance()">Add Goal</button>
</div>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
@@ -34,15 +37,15 @@
<div class="main" *ngIf="goal.id === 0; else other_goal"> <div class="main" *ngIf="goal.id === 0; else other_goal">
<div class="title"> <div class="title">
<div class="name">Main Allowance</div> <div class="name">Main Allowance</div>
<div class="icon"> <div class="icon" (click)="updateAllowance(goal.id)">
<mat-icon>settings</mat-icon> <mat-icon>settings</mat-icon>
</div> </div>
</div> </div>
<div class="progress">{{ goal.progress }} SP</div> <div class="progress">{{ goal.progress }} SP</div>
<div class="buttons"> <div class="buttons">
<button class="add-button">Add</button> <button class="add-button" (click)="addAllowance(goal.id)">Add</button>
<!-- <button class="move-button">Move</button> --> <!-- <button class="move-button">Move</button> -->
<button class="spend-button">Spend</button> <button class="spend-button" (click)="spendAllowance(goal.id)">Spend</button>
</div> </div>
</div> </div>
<ng-template #other_goal> <ng-template #other_goal>
@@ -50,15 +53,15 @@
<div> <div>
<div class="title"> <div class="title">
<div class="name">{{ goal.name }}</div> <div class="name">{{ goal.name }}</div>
<div class="icon"> <div class="icon" (click)="updateAllowance(goal.id)">
<mat-icon>settings</mat-icon> <mat-icon>settings</mat-icon>
</div> </div>
</div> </div>
<div class="progress">{{ goal.progress }} / {{ goal.target }} SP</div> <div class="progress">{{ goal.progress }} / {{ goal.target }} SP</div>
<div class="buttons"> <div class="buttons">
<button class="add-button">Add</button> <button class="add-button" (click)="addAllowance(goal.id)">Add</button>
<!-- <button class="move-button">Move</button> --> <!-- <button class="move-button">Move</button> -->
<button class="spend-button" [disabled]="!canFinishGoal(goal)">Finish goal</button> <button class="spend-button" [disabled]="!canFinishGoal(goal)" (click)="completeGoal(goal.id)">Finish goal</button>
</div> </div>
</div> </div>
<div class="color" [style.--background]="hexToRgb(goal.colour)" [style.width.%]="getPercentage(goal)"></div> <div class="color" [style.--background]="hexToRgb(goal.colour)" [style.width.%]="getPercentage(goal)"></div>

View File

@@ -58,11 +58,10 @@ button {
padding-inline: 30px; padding-inline: 30px;
border-radius: 10px; border-radius: 10px;
color: white; color: white;
font-size: 16px;
} }
button:disabled, button:disabled,
button[disabled]{ button[disabled] {
opacity: 0.5; opacity: 0.5;
} }
@@ -128,3 +127,13 @@ button[disabled]{
border-radius: 20px; border-radius: 20px;
margin-right: 2px; margin-right: 2px;
} }
.toolbar {
display: flex;
}
.top-add-button {
background-color: var(--ion-color-primary);
margin-right: 15px;
padding-inline: 15px;
}

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { Allowance } from 'src/app/models/allowance'; import { Allowance } from 'src/app/models/allowance';
import { AllowanceService } from 'src/app/services/allowance.service'; import { AllowanceService } from 'src/app/services/allowance.service';
@@ -14,23 +14,11 @@ import { ViewWillEnter } from '@ionic/angular';
}) })
export class AllowancePage implements ViewWillEnter { export class AllowancePage implements ViewWillEnter {
private id: number; private id: number;
// Move to add/edit page later
private possibleColors: Array<string> = [
'#6199D9',
'#D98B61',
'#DBC307',
'#13DEB5',
'#7DCB7D',
'#CF1DBD',
'#F53311',
'#2F00FF',
'#098B0D',
'#1BC2E8'
];
public allowance$: BehaviorSubject<Array<Allowance>> = new BehaviorSubject<Array<Allowance>>([]); public allowance$: BehaviorSubject<Array<Allowance>> = new BehaviorSubject<Array<Allowance>>([]);
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router,
private allowanceService: AllowanceService private allowanceService: AllowanceService
) { ) {
this.id = this.route.snapshot.params['id']; this.id = this.route.snapshot.params['id'];
@@ -46,10 +34,9 @@ export class AllowancePage implements ViewWillEnter {
this.allowanceService.getAllowanceList(this.id).subscribe(allowance => { this.allowanceService.getAllowanceList(this.id).subscribe(allowance => {
allowance[0].colour = '#9C4BE4'; allowance[0].colour = '#9C4BE4';
allowance[0].name = 'Main Allowance'; allowance[0].name = 'Main Allowance';
console.log('Allowance list: ', allowance);
this.allowance$.next(allowance); this.allowance$.next(allowance);
}) })
}, 10); }, 100);
} }
canFinishGoal(allowance: Allowance): boolean { canFinishGoal(allowance: Allowance): boolean {
@@ -70,6 +57,30 @@ export class AllowancePage implements ViewWillEnter {
for (let allowance of allowanceList) { for (let allowance of allowanceList) {
allowanceTotal += allowance.progress; allowanceTotal += allowance.progress;
} }
if (allowanceTotal === 0) {
return 0;
}
return goal.progress / allowanceTotal * 100; return goal.progress / allowanceTotal * 100;
} }
createAllowance() {
this.router.navigate(['add'], { relativeTo: this.route });
}
updateAllowance(id: number) {
this.router.navigate(['edit', id], { relativeTo: this.route });
}
completeGoal(goalId: number) {
this.allowanceService.completeGoal(goalId, this.id);
this.getAllowance();
}
addAllowance(id: number) {
this.router.navigate(['increase', id], { relativeTo: this.route });
}
spendAllowance(id: number) {
this.router.navigate(['spend', id], { relativeTo: this.route });
}
} }

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { EditAllowancePage } from './edit-allowance.page';
const routes: Routes = [
{
path: '',
component: EditAllowancePage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class EditAllowancePageRoutingModule {}

View File

@@ -0,0 +1,25 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { EditAllowancePageRoutingModule } from './edit-allowance-routing.module';
import { EditAllowancePage } from './edit-allowance.page';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
EditAllowancePageRoutingModule,
ReactiveFormsModule,
MatIconModule,
MatSelectModule
],
declarations: [EditAllowancePage]
})
export class EditAllowancePageModule {}

View File

@@ -0,0 +1,47 @@
<ion-header [translucent]="true">
<ion-toolbar>
<div class="toolbar">
<div class="icon" (click)="navigateBack()">
<mat-icon>arrow_back</mat-icon>
</div>
<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 Allowance</ion-title>
</div>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<form [formGroup]="form">
<div class="item" *ngIf="isAddMode || goalId != 0">
<label>Goal Name</label>
<input id="name" type="text" formControlName="name"/>
</div>
<div class="item" *ngIf="isAddMode || goalId != 0">
<label>Target</label>
<input id="target" type="number" placeholder="0.00" name="price" min="0" value="0" step="0.01" formControlName="target"/>
</div>
<label>Weight</label>
<input id="weight" type="number" placeholder="0.00" name="price" min="0" value="0" step="0.01" formControlName="weight"/>
<div class="item" *ngIf="isAddMode || goalId != 0">
<label>Colour</label>
<mat-select [(value)]="selectedColor" formControlName="color" [style.--color]="selectedColor">
<mat-option *ngFor="let color of possibleColors" [value]="color" [style.--background]="color">{{color}}</mat-option>
</mat-select>
</div>
<button type="button" [disabled]="!form.valid" (click)="submit()">
<span *ngIf="isAddMode">Add Goal</span>
<span *ngIf="!isAddMode && goalId != 0">Update Goal</span>
<span *ngIf="!isAddMode && goalId == 0">Update Allowance</span>
</button>
<button
*ngIf="!isAddMode && goalId !=0"
class="remove-button"
(click)="deleteAllowance()"
>Delete Goal</button>
</form>
</ion-content>

View File

@@ -0,0 +1,64 @@
.toolbar {
display: flex;
align-items: center;
}
.remove-button {
margin-top: 10px;
background-color: var(--negative-amount-color);
}
form {
height: 100%;
}
form,
.item {
display: flex;
flex-direction: column;
align-items: center;
}
label {
color: var(--ion-color-primary);
margin-top: 25px;
margin-bottom: 10px;
}
input,
mat-select {
--color: black;
color: var(--color);
border: 1px solid var(--ion-color-primary);
border-radius: 5px;
width: 250px;
height: 40px;
padding-inline: 10px;
display: flex;
align-items: center;
font-family: (--ion-font-family);
}
mat-option {
--background: white;
color: var(--background);
font-family: (--ion-font-family);
}
button {
background-color: var(--ion-color-primary);
border-radius: 5px;
color: white;
padding: 10px;
width: 250px;
margin-top: 100px;
}
button:disabled,
button[disabled]{
opacity: 0.5;
}
.icon {
margin-left: 5px;
}

View File

@@ -0,0 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EditAllowancePage } from './edit-allowance.page';
describe('EditAllowancePage', () => {
let component: EditAllowancePage;
let fixture: ComponentFixture<EditAllowancePage>;
beforeEach(() => {
fixture = TestBed.createComponent(EditAllowancePage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,109 @@
import { Location } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { AllowanceService } from 'src/app/services/allowance.service';
@Component({
selector: 'app-edit-allowance',
templateUrl: './edit-allowance.page.html',
styleUrls: ['./edit-allowance.page.scss'],
standalone: false
})
export class EditAllowancePage implements OnInit {
public form: FormGroup;
public goalId: number;
public userId: number;
public isAddMode: boolean;
public selectedColor: string = '';
public possibleColors: Array<string> = [
'#6199D9',
'#D98B61',
'#DBC307',
'#13DEB5',
'#7DCB7D',
'#CF1DBD',
'#F53311',
'#2F00FF',
'#098B0D',
'#1BC2E8'
];
constructor(
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private allowanceService: AllowanceService,
private router: Router,
private location: Location
) {
this.userId = this.route.snapshot.params['id'];
this.goalId = this.route.snapshot.params['goalId'];
this.isAddMode = !this.goalId;
this.allowanceService.getAllowanceList(this.userId).subscribe((list) => {
for (let allowance of list) {
this.possibleColors = this.possibleColors.filter(color => color !== allowance.colour);
if (!this.isAddMode && +this.goalId === allowance.id) {
this.possibleColors.unshift(allowance.colour);
}
}
});
this.form = this.formBuilder.group({
name: ['', Validators.required],
target: ['', Validators.required],
weight: ['', Validators.required],
color: ['', Validators.required]
});
}
ngOnInit() {
if (!this.isAddMode) {
this.allowanceService.getAllowanceById(this.goalId, this.userId).subscribe((allowance) => {
if (+this.goalId === 0) {
this.form.setValue({
name: 'Main Allowance',
target: 0,
weight: allowance.weight,
color: '#9C4BE4'
});
} else {
this.form.setValue({
name: allowance.name,
target: allowance.target,
weight: allowance.weight,
color: allowance.colour
});
this.selectedColor = this.form.value.color;
}
});
}
}
submit() {
const formValue = this.form.value;
const allowance = {
name: formValue.name,
target: formValue.target,
weight: formValue.weight,
colour: formValue.color,
};
if (this.isAddMode) {
this.allowanceService.createAllowance(allowance, this.userId);
} else {
this.allowanceService.updateAllowance(allowance, this.goalId, this.userId);
}
this.router.navigate(['/tabs/allowance', this.userId]);
}
deleteAllowance() {
this.allowanceService.deleteAllowance(this.goalId, this.userId);
this.router.navigate(['/tabs/allowance', this.userId]);
}
navigateBack() {
this.location.back();
}
}

View File

@@ -7,6 +7,8 @@ import { IonicModule } from '@ionic/angular';
import { EditTaskPageRoutingModule } from './edit-task-routing.module'; 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 { MatSelectModule } from '@angular/material/select';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -14,7 +16,9 @@ import { EditTaskPage } from './edit-task.page';
FormsModule, FormsModule,
IonicModule, IonicModule,
EditTaskPageRoutingModule, EditTaskPageRoutingModule,
ReactiveFormsModule ReactiveFormsModule,
MatIconModule,
MatSelectModule
], ],
declarations: [EditTaskPage] declarations: [EditTaskPage]
}) })

View File

@@ -1,13 +1,11 @@
<ion-header [translucent]="true"> <ion-header [translucent]="true">
<ion-toolbar> <ion-toolbar>
<div class="toolbar"> <div class="toolbar">
<div class="icon" (click)="navigateBack()">
<mat-icon>arrow_back</mat-icon>
</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>
@@ -18,16 +16,21 @@
<input id="name" type="text" formControlName="name"/> <input id="name" type="text" formControlName="name"/>
<label>Reward</label> <label>Reward</label>
<input id="name" 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>

View File

@@ -1,12 +1,11 @@
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center;
} }
.remove-button { .remove-button {
background-color: var(--ion-color-primary); margin-top: 10px;
margin-right: 15px; background-color: var(--negative-amount-color);
width: 85px;
margin-bottom: 0;
} }
form { form {
@@ -23,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 {
@@ -35,11 +39,14 @@ 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,
button[disabled]{ button[disabled]{
opacity: 0.5; opacity: 0.5;
} }
.icon {
margin-left: 5px;
}

View File

@@ -23,7 +23,8 @@ export class EditTaskPage implements OnInit {
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private taskService: TaskService, private taskService: TaskService,
private userService: UserService, private userService: UserService,
private router: Router private router: Router,
private location: Location
) { ) {
this.id = this.route.snapshot.params['id']; this.id = this.route.snapshot.params['id'];
this.isAddMode = !this.id; this.isAddMode = !this.id;
@@ -77,4 +78,8 @@ export class EditTaskPage implements OnInit {
this.taskService.deleteTask(this.id); this.taskService.deleteTask(this.id);
this.router.navigate(['/tabs/tasks']); this.router.navigate(['/tabs/tasks']);
} }
navigateBack() {
this.location.back();
}
} }

View File

@@ -5,6 +5,8 @@ import { FormsModule } from '@angular/forms';
import { HistoryPage } from './history.page'; import { HistoryPage } from './history.page';
import { HistoryPageRoutingModule } from './history-routing.module'; import { HistoryPageRoutingModule } from './history-routing.module';
import { provideHttpClient } from '@angular/common/http';
import { HistoryService } from 'src/app/services/history.service';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -13,6 +15,10 @@ import { HistoryPageRoutingModule } from './history-routing.module';
FormsModule, FormsModule,
HistoryPageRoutingModule HistoryPageRoutingModule
], ],
declarations: [HistoryPage] declarations: [HistoryPage],
providers: [
provideHttpClient(),
HistoryService
]
}) })
export class HistoryPageModule {} export class HistoryPageModule {}

View File

@@ -7,5 +7,14 @@
</ion-header> </ion-header>
<ion-content> <ion-content>
<div class="item" *ngFor="let history of history$ | async">
<div class="left">
<div class="date">{{ history.timestamp | date: 'yyyy-MM-dd' }}</div>
<div class="description">{{ history.description }}</div>
</div>
<div
class="amount"
[ngClass]="{ 'negative': history.allowance < 0 }"
>{{ history.allowance }} SP</div>
</div>
</ion-content> </ion-content>

View File

@@ -0,0 +1,30 @@
.item {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--line-color);
padding: 8px;
}
.left {
width: 70%;
font-size: 18px;
}
.date {
color: var(--line-color);
}
.description {
color: var(--font-color);
}
.amount {
margin-left: auto;
font-size: 22px;
color: var(--positive-amount-color);
}
.negative {
color: var(--negative-amount-color);
}

View File

@@ -1,4 +1,9 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ViewWillEnter } from '@ionic/angular';
import { BehaviorSubject } from 'rxjs';
import { History } from 'src/app/models/history';
import { HistoryService } from 'src/app/services/history.service';
@Component({ @Component({
selector: 'app-history', selector: 'app-history',
@@ -6,8 +11,28 @@ import { Component } from '@angular/core';
styleUrls: ['history.page.scss'], styleUrls: ['history.page.scss'],
standalone: false, standalone: false,
}) })
export class HistoryPage { export class HistoryPage implements ViewWillEnter {
userId: number;
public history$: BehaviorSubject<Array<History>> = new BehaviorSubject<Array<History>>([]);
constructor() {}
constructor(
private route: ActivatedRoute,
private historyService: HistoryService
) {
this.userId = this.route.snapshot.params['id'];
this.getHistory();
}
ionViewWillEnter(): void {
this.getHistory();
}
getHistory() {
setTimeout(() => {
this.historyService.getHistoryList(this.userId).subscribe(history => {
this.history$.next(history);
})
}, 20);
}
} }

View File

@@ -8,7 +8,7 @@ const routes: Routes = [
component: TabsPage, component: TabsPage,
children: [ children: [
{ {
path: 'history', path: 'history/:id',
loadChildren: () => import('../history/history.module').then(m => m.HistoryPageModule) loadChildren: () => import('../history/history.module').then(m => m.HistoryPageModule)
}, },
{ {

View File

@@ -1,6 +1,6 @@
<ion-tabs> <ion-tabs>
<ion-tab-bar slot="bottom"> <ion-tab-bar slot="bottom">
<ion-tab-button tab="history" href="/tabs/history"> <ion-tab-button [tab]="historyTab" [href]="historyNav">
<mat-icon>history</mat-icon> <mat-icon>history</mat-icon>
</ion-tab-button> </ion-tab-button>
<ion-tab-button tab="allowance" href="/tabs/allowance"> <ion-tab-button tab="allowance" href="/tabs/allowance">

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { StorageService } from 'src/app/services/storage.service';
@Component({ @Component({
selector: 'app-tabs', selector: 'app-tabs',
@@ -7,6 +8,16 @@ import { Component } from '@angular/core';
standalone: false, standalone: false,
}) })
export class TabsPage { export class TabsPage {
constructor() {} historyNav = '';
historyTab = '';
constructor(private storageService: StorageService) {
this.storageService.getCurrentUserId().then((userId) => {
if (userId !== undefined && userId !== null) {
this.historyNav = `/tabs/history/${userId}`;
this.historyTab = `history/${userId}`;
}
});
}
} }

View File

@@ -11,14 +11,19 @@
<ion-content> <ion-content>
<div class="content"> <div class="content">
<div class="icon"> <!-- <div class="icon">
<mat-icon>filter_alt</mat-icon> <mat-icon>filter_alt</mat-icon>
</div> </div> -->
<div class="list"> <div class="list">
<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="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 }"

View File

@@ -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,15 +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 {
color: var(--line-color);
margin-left: 3px;
font-size: 15px;
}
.text {
display: flex;
align-items: center;
width: 60%;
margin-left: 10px;
} }

View File

@@ -14,6 +14,7 @@ import { ViewWillEnter } from '@ionic/angular';
}) })
export class TasksPage implements ViewWillEnter { export class TasksPage implements ViewWillEnter {
public tasks$: BehaviorSubject<Array<Task>> = new BehaviorSubject<Array<Task>>([]); public tasks$: BehaviorSubject<Array<Task>> = new BehaviorSubject<Array<Task>>([]);
public usernames = ['', 'See', 'Huffle']
constructor( constructor(
private taskService: TaskService, private taskService: TaskService,
@@ -32,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);
}); });
}, 10); }, 100);
} }
createTask() { createTask() {

View File

@@ -7,11 +7,35 @@ import { Allowance } from '../models/allowance';
providedIn: 'root' providedIn: 'root'
}) })
export class AllowanceService { export class AllowanceService {
private url = 'http://localhost:8080/api'; private url = 'https://allowanceplanner.seeseepuff.be/api';
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
getAllowanceList(userId: number): Observable<Array<Allowance>> { getAllowanceList(userId: number): Observable<Array<Allowance>> {
return this.http.get<Allowance[]>(`${this.url}/user/${userId}/allowance`); return this.http.get<Allowance[]>(`${this.url}/user/${userId}/allowance`);
} }
getAllowanceById(allowanceId: number, userId: number): Observable<Allowance> {
return this.http.get<Allowance>(`${this.url}/user/${userId}/allowance/${allowanceId}`);
}
createAllowance(allowance: Partial<Allowance>, userId: number) {
this.http.post(`${this.url}/user/${userId}/allowance`, allowance).subscribe();
}
updateAllowance(allowance: Partial<Allowance>, allowanceId: number, userId: number) {
this.http.put(`${this.url}/user/${userId}/allowance/${allowanceId}`, allowance).subscribe();
}
deleteAllowance(allowanceId: number, userId: number) {
this.http.delete(`${this.url}/user/${userId}/allowance/${allowanceId}`).subscribe();
}
completeGoal(goalId: number, userId: number) {
this.http.post(`${this.url}/user/${userId}/allowance/${goalId}/complete`, {}).subscribe();
}
addOrSpendAllowance(goalId: number, userId: number, amount: number, description: string) {
this.http.post(`${this.url}/user/${userId}/allowance/${goalId}/add`, { amount, description }).subscribe();
}
} }

View File

@@ -0,0 +1,17 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { History } from '../models/history';
@Injectable({
providedIn: 'root'
})
export class HistoryService {
private url = 'https://allowanceplanner.seeseepuff.be/api';
constructor(private http: HttpClient) {}
getHistoryList(userId: number): Observable<Array<History>> {
return this.http.get<History[]>(`${this.url}/user/${userId}/history`);
}
}

View File

@@ -7,7 +7,7 @@ import { Task } from '../models/task';
providedIn: 'root' providedIn: 'root'
}) })
export class TaskService { export class TaskService {
private url = 'http://localhost:8080/api'; private url = 'https://allowanceplanner.seeseepuff.be/api';
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}

View File

@@ -7,7 +7,7 @@ import { User } from '../models/user';
providedIn: 'root', providedIn: 'root',
}) })
export class UserService { export class UserService {
private url = 'http://localhost:8080/api'; private url = 'https://allowanceplanner.seeseepuff.be/api';
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
getUserList(): Observable<Array<User>> { getUserList(): Observable<Array<User>> {

View File

@@ -38,8 +38,34 @@
ion-title { ion-title {
color: var(--ion-color-primary); color: var(--ion-color-primary);
font-size: 24px;
} }
ion-header { ion-header {
border-bottom: 1px solid var(--line-color); border-bottom: 1px solid var(--line-color);
} }
button {
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);
}