Compare commits

...

50 Commits

Author SHA1 Message Date
cdbac17215 Update README.md (#142)
All checks were successful
Backend Build and Test / build (push) Successful in 3m22s
Backend Deploy / build (push) Successful in 15s
Reviewed-on: #142
2025-06-26 09:12:49 +02:00
ecd43906ce AP-139 (#141)
Some checks failed
Backend Build and Test / build (push) Successful in 3m40s
Backend Deploy / build (push) Has been cancelled
Reviewed-on: #141
2025-06-26 09:08:57 +02:00
d6935d2f54 Update README.md (#140)
All checks were successful
Backend Build and Test / build (push) Successful in 3m58s
Backend Deploy / build (push) Successful in 22s
Add backend links

Reviewed-on: #140
2025-06-26 08:51:15 +02:00
06c8ebcbcc Add support for schedules (#137)
All checks were successful
Backend Build and Test / build (push) Successful in 3m42s
Backend Deploy / build (push) Successful in 4m46s
Reviewed-on: #137
2025-05-30 20:22:33 +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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
f9fb956efd Fix colour not being sent properly by backend (#81)
All checks were successful
Backend Deploy / build (push) Successful in 2m46s
Backend Build and Test / build (push) Successful in 3m0s
Reviewed-on: #81
2025-05-25 14:36:17 +02:00
5a233073c7 Do deploy on main branch (#80)
All checks were successful
Backend Build and Test / build (push) Successful in 3m21s
Backend Deploy / build (push) Successful in 4m3s
Reviewed-on: #80
2025-05-25 14:24:25 +02:00
cd23e72882 Allow null colour (#79)
Some checks failed
Backend Build and Test / build (push) Has been cancelled
Reviewed-on: #79
2025-05-25 14:22:58 +02:00
a82040720a Add gitea workflow (#78)
All checks were successful
Backend Build and Test / build (push) Successful in 2m9s
Reviewed-on: #78
2025-05-25 14:20:38 +02:00
a8e3332723 Add support for colour attribute on allowances in backend (#77)
Closes #76

Reviewed-on: #77
2025-05-25 13:43:24 +02:00
f8d1f195de Support decimal currency amounts (#74)
Reviewed-on: #74
2025-05-24 06:28:07 +02:00
426e456ba7 Add lite website (#73)
Reviewed-on: #73
2025-05-24 06:11:39 +02:00
93ec3cbc19 Add delete task functionality (#72)
closes #62

Reviewed-on: #72
2025-05-19 09:43:11 +02:00
5bcbde46ea Add complete task functionality (#71)
closes #61
closes #58

Reviewed-on: #71
2025-05-19 09:07:51 +02:00
f04529067a update task functionality (#59)
Reviewed-on: #59
2025-05-18 16:43:53 +02:00
6e07d44733 update task functionality 2025-05-18 16:34:49 +02:00
1f21924805 AP-45 (#57)
Reviewed-on: #57
2025-05-18 16:18:07 +02:00
e85a60ab16 Merge branch 'main' into AP-45 2025-05-18 16:17:13 +02:00
61694e340f Add functionalty to add task 2025-05-18 16:15:34 +02:00
f72cc8a802 test 2025-05-18 10:48:52 +02:00
da17f351de Add bulk allowance edit endpoint (#56)
Closes #15

Reviewed-on: #56
2025-05-18 09:24:36 +02:00
79dcfbc02c Implement completion endpoint for allowance (#55)
Closes #19

Reviewed-on: #55
2025-05-18 09:02:33 +02:00
505faa95a3 Add different cors config 2025-05-18 08:54:22 +02:00
a675d51718 AP-45 wip post request 2025-05-18 08:52:20 +02:00
242 changed files with 16673 additions and 190 deletions

View File

@ -0,0 +1,24 @@
name: Backend Build and Test
on: [push]
jobs:
build:
runs-on: standard-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '>=1.24'
- name: Build
run: |
cd backend
go build .
- name: Test
run: |
cd backend
go test . -v

View File

@ -0,0 +1,27 @@
name: Backend Deploy
on:
push:
branches:
- main
jobs:
build:
runs-on: standard-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login
with:
package_rw: ${{ secrets.PACKAGE_RW }}
run: docker login gitea.seeseepuff.be -u seeseemelk -p ${{ secrets.PACKAGE_RW }}
- name: Build
run: |
cd backend
docker build -t gitea.seeseepuff.be/seeseemelk/allowance-planner:$(git rev-parse --short HEAD) .
- name: Push
run: |
cd backend
docker push gitea.seeseepuff.be/seeseemelk/allowance-planner:$(git rev-parse --short HEAD)

View File

@ -2,8 +2,31 @@
An improved Allowance Planner app. An improved Allowance Planner app.
## Running backend ## Running backend
In order to run the backend, go to the `backend directory and run: In order to run the backend, go to the `backend` directory and run:
```bash ```bash
$ go run . $ go run .
``` ```
## Running frontend
In order to run the frontend, go to the `allowance-planner-v2` directory in the `frontend` directory and run:
```bash
$ ionic serve
```
## Running frontend
In order to build the frontend for android, go to the `allowance-planner-v2` directory in the `frontend` directory and run:
```bash
$ ionic capacitor build android
```
## Backend links
```bash
Main: https://allowanceplanner.seeseepuff.be/api
```
```bash
Test: http://localhost:8080/api
```

2
backend/.gitignore vendored
View File

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

14
backend/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM golang:1.24.2-alpine3.21
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY migrations ./migrations/
COPY *.go ./
COPY *.gohtml ./
RUN go build -o /allowance_planner
EXPOSE 8080
ENV GIN_MODE=release
CMD ["/allowance_planner"]

View File

@ -9,12 +9,13 @@ import (
) )
const ( const (
TestAllowanceName = "Test History" TestHistoryName = "Test History"
) )
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),
} }
@ -62,7 +63,7 @@ func TestGetUserAllowance(t *testing.T) {
// Create a new allowance // Create a new allowance
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"name": TestAllowanceName, "name": TestHistoryName,
"target": 5000, "target": 5000,
"weight": 10, "weight": 10,
} }
@ -73,7 +74,7 @@ func TestGetUserAllowance(t *testing.T) {
result.Length().IsEqual(2) result.Length().IsEqual(2)
item := result.Value(1).Object() item := result.Value(1).Object()
item.Value("id").IsEqual(1) item.Value("id").IsEqual(1)
item.Value("name").IsEqual(TestAllowanceName) item.Value("name").IsEqual(TestHistoryName)
item.Value("target").IsEqual(5000) item.Value("target").IsEqual(5000)
item.Value("weight").IsEqual(10) item.Value("weight").IsEqual(10)
item.Value("progress").IsEqual(0) item.Value("progress").IsEqual(0)
@ -95,7 +96,7 @@ func TestCreateUserAllowance(t *testing.T) {
// Create a new allowance // Create a new allowance
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"name": TestAllowanceName, "name": TestHistoryName,
"target": 5000, "target": 5000,
"weight": 10, "weight": 10,
} }
@ -120,7 +121,7 @@ func TestCreateUserAllowance(t *testing.T) {
allowance := allowances.Value(1).Object() allowance := allowances.Value(1).Object()
allowance.Value("id").IsEqual(allowanceId) allowance.Value("id").IsEqual(allowanceId)
allowance.Value("name").IsEqual(TestAllowanceName) allowance.Value("name").IsEqual(TestHistoryName)
allowance.Value("target").IsEqual(5000) allowance.Value("target").IsEqual(5000)
allowance.Value("weight").IsEqual(10) allowance.Value("weight").IsEqual(10)
allowance.Value("progress").IsEqual(0) allowance.Value("progress").IsEqual(0)
@ -130,7 +131,7 @@ func TestCreateUserAllowanceNoUser(t *testing.T) {
e := startServer(t) e := startServer(t)
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"name": TestAllowanceName, "name": TestHistoryName,
"target": 5000, "target": 5000,
"weight": 10, "weight": 10,
} }
@ -171,7 +172,7 @@ func TestCreateUserAllowanceBadId(t *testing.T) {
e := startServer(t) e := startServer(t)
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"name": TestAllowanceName, "name": TestHistoryName,
"target": 5000, "target": 5000,
"weight": 10, "weight": 10,
} }
@ -187,7 +188,7 @@ func TestDeleteUserAllowance(t *testing.T) {
// Create a new allowance to delete // Create a new allowance to delete
createRequest := map[string]interface{}{ createRequest := map[string]interface{}{
"name": TestAllowanceName, "name": TestHistoryName,
"target": 1000, "target": 1000,
"weight": 5, "weight": 5,
} }
@ -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)
@ -361,10 +410,6 @@ func TestGetTaskWhenNoTasks(t *testing.T) {
result.Length().IsEqual(0) result.Length().IsEqual(0)
} }
func createTestTask(e *httpexpect.Expect) int {
return createTestTaskWithAmount(e, 100)
}
func createTestTaskWithAmount(e *httpexpect.Expect, amount int) int { func createTestTaskWithAmount(e *httpexpect.Expect, amount int) int {
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"name": "Test Task", "name": "Test Task",
@ -438,37 +483,50 @@ func TestPutTaskInvalidTaskId(t *testing.T) {
e.PUT("/task/999").WithJSON(requestBody).Expect().Status(404) e.PUT("/task/999").WithJSON(requestBody).Expect().Status(404)
} }
func TestPostAllowance(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)
} }
func TestPostAllowanceInvalidUserId(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) {
@ -476,9 +534,10 @@ func TestGetUserAllowanceById(t *testing.T) {
// Create a new allowance // Create a new allowance
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"name": TestAllowanceName, "name": TestHistoryName,
"target": 5000, "target": 5000,
"weight": 10, "weight": 10,
"colour": "#FF5733",
} }
resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object() resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object()
allowanceId := int(resp.Value("id").Number().Raw()) allowanceId := int(resp.Value("id").Number().Raw())
@ -486,10 +545,21 @@ func TestGetUserAllowanceById(t *testing.T) {
// Retrieve the created allowance by ID // Retrieve the created allowance by ID
result := e.GET("/user/1/allowance/" + strconv.Itoa(allowanceId)).Expect().Status(200).JSON().Object() result := e.GET("/user/1/allowance/" + strconv.Itoa(allowanceId)).Expect().Status(200).JSON().Object()
result.Value("id").IsEqual(allowanceId) result.Value("id").IsEqual(allowanceId)
result.Value("name").IsEqual(TestAllowanceName) result.Value("name").IsEqual(TestHistoryName)
result.Value("target").IsEqual(5000) result.Value("target").IsEqual(5000)
result.Value("weight").IsEqual(10) result.Value("weight").IsEqual(10)
result.Value("progress").IsEqual(0) result.Value("progress").IsEqual(0)
result.Value("colour").IsEqual("#FF5733")
resultArray := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
resultArray.Length().IsEqual(2)
result = resultArray.Value(1).Object()
result.Value("id").IsEqual(allowanceId)
result.Value("name").IsEqual(TestHistoryName)
result.Value("target").IsEqual(5000)
result.Value("weight").IsEqual(10)
result.Value("progress").IsEqual(0)
result.Value("colour").IsEqual("#FF5733")
} }
func TestGetUserByAllowanceIdInvalidAllowance(t *testing.T) { func TestGetUserByAllowanceIdInvalidAllowance(t *testing.T) {
@ -517,9 +587,10 @@ func TestPutAllowanceById(t *testing.T) {
// Create a new allowance // Create a new allowance
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"name": TestAllowanceName, "name": TestHistoryName,
"target": 5000, "target": 5000,
"weight": 10, "weight": 10,
"colour": "#FF5733",
} }
resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object() resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object()
allowanceId := int(resp.Value("id").Number().Raw()) allowanceId := int(resp.Value("id").Number().Raw())
@ -529,6 +600,7 @@ func TestPutAllowanceById(t *testing.T) {
"name": "Updated Allowance", "name": "Updated Allowance",
"target": 6000, "target": 6000,
"weight": 15, "weight": 15,
"colour": "#3357FF",
} }
e.PUT("/user/1/allowance/" + strconv.Itoa(allowanceId)).WithJSON(updateRequest).Expect().Status(200) e.PUT("/user/1/allowance/" + strconv.Itoa(allowanceId)).WithJSON(updateRequest).Expect().Status(200)
@ -538,6 +610,7 @@ func TestPutAllowanceById(t *testing.T) {
result.Value("name").IsEqual("Updated Allowance") result.Value("name").IsEqual("Updated Allowance")
result.Value("target").IsEqual(6000) result.Value("target").IsEqual(6000)
result.Value("weight").IsEqual(15) result.Value("weight").IsEqual(15)
result.Value("colour").IsEqual("#3357FF")
} }
func TestCompleteTask(t *testing.T) { func TestCompleteTask(t *testing.T) {
@ -553,12 +626,12 @@ func TestCompleteTask(t *testing.T) {
// Create two allowance goals // Create two allowance goals
e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{
Name: "Test Allowance 1", Name: "Test Allowance 1",
Target: 1000, Target: 100,
Weight: 50, Weight: 50,
}).Expect().Status(201) }).Expect().Status(201)
e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{
Name: "Test Allowance 1", Name: "Test Allowance 1",
Target: 1000, Target: 10,
Weight: 25, Weight: 25,
}).Expect().Status(201) }).Expect().Status(201)
@ -572,11 +645,11 @@ func TestCompleteTask(t *testing.T) {
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Length().IsEqual(3) allowances.Length().IsEqual(3)
allowances.Value(0).Object().Value("id").Number().IsEqual(0) allowances.Value(0).Object().Value("id").Number().IsEqual(0)
allowances.Value(0).Object().Value("progress").Number().IsEqual(26) allowances.Value(0).Object().Value("progress").Number().InDelta(30.34, 0.01)
allowances.Value(1).Object().Value("id").Number().IsEqual(1) allowances.Value(1).Object().Value("id").Number().IsEqual(1)
allowances.Value(1).Object().Value("progress").Number().IsEqual(50) allowances.Value(1).Object().Value("progress").Number().InDelta(60.66, 0.01)
allowances.Value(2).Object().Value("id").Number().IsEqual(2) allowances.Value(2).Object().Value("id").Number().IsEqual(2)
allowances.Value(2).Object().Value("progress").Number().IsEqual(25) allowances.Value(2).Object().Value("progress").Number().IsEqual(10)
// And also for user 2 // And also for user 2
allowances = e.GET("/user/2/allowance").Expect().Status(200).JSON().Array() allowances = e.GET("/user/2/allowance").Expect().Status(200).JSON().Array()
@ -594,8 +667,299 @@ 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) {
e := startServer(t)
taskId := createTestTaskWithAmount(e, 101)
e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1)
// Update rest allowance
e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{
Weight: 0,
}).Expect().Status(200)
// Create an allowance goal
createTestAllowance(e, "Test Allowance 1", 1000, 0)
// 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(2)
allowances.Value(0).Object().Value("id").Number().IsEqual(0)
allowances.Value(0).Object().Value("progress").Number().IsEqual(101)
allowances.Value(1).Object().Value("id").Number().IsEqual(1)
allowances.Value(1).Object().Value("progress").Number().IsEqual(0)
}
func TestCompleteTaskInvalidId(t *testing.T) {
e := startServer(t)
e.POST("/task/999/complete").Expect().Status(404)
}
func TestCompleteAllowance(t *testing.T) {
e := startServer(t)
createTestTaskWithAmount(e, 100)
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
e.POST("/task/1/complete").Expect().Status(200)
// Complete allowance goal
e.POST("/user/1/allowance/1/complete").Expect().Status(200)
// Verify the allowance no longer exists
e.GET("/user/1/allowance/1").Expect().Status(404)
// Verify history is updated
history := e.GET("/user/1/history").Expect().Status(200).JSON().Array()
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("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("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) {
e := startServer(t)
e.POST("/user/999/allowance/1/complete").Expect().Status(404)
}
func TestCompleteAllowanceInvalidAllowanceId(t *testing.T) {
e := startServer(t)
e.POST("/user/1/allowance/999/complete").Expect().Status(404)
}
func TestPutBulkAllowance(t *testing.T) {
e := startServer(t)
createTestAllowance(e, "Test Allowance 1", 1000, 1)
createTestAllowance(e, "Test Allowance 2", 1000, 2)
// Bulk edit
request := []map[string]interface{}{
{
"id": 1,
"weight": 5,
},
{
"id": 0,
"weight": 99,
},
{
"id": 2,
"weight": 10,
},
}
e.PUT("/user/1/allowance").WithJSON(request).Expect().Status(200)
// Verify the allowances are updated
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Length().IsEqual(3)
allowances.Value(0).Object().Value("id").Number().IsEqual(0)
allowances.Value(0).Object().Value("weight").Number().IsEqual(99)
allowances.Value(1).Object().Value("id").Number().IsEqual(1)
allowances.Value(1).Object().Value("weight").Number().IsEqual(5)
allowances.Value(2).Object().Value("id").Number().IsEqual(2)
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)
return start, end return start, end
} }
func createTestAllowance(e *httpexpect.Expect, name string, target float64, weight float64) {
e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{
Name: name,
Target: target,
Weight: weight,
}).Expect().Status(201)
}
func createTestTask(e *httpexpect.Expect) int {
return createTestTaskWithAmount(e, 100)
}

34
backend/colour.go Normal file
View File

@ -0,0 +1,34 @@
package main
import (
"errors"
"fmt"
)
func ConvertStringToColour(colourStr string) (int, error) {
if len(colourStr) == 0 {
return 0xFF0000, nil // Default colour if no string is provided
}
if colourStr[0] == '#' {
colourStr = colourStr[1:]
}
if len(colourStr) != 6 && len(colourStr) != 3 {
return 0, errors.New("colour must be a valid hex string")
}
var colour int
_, err := fmt.Sscanf(colourStr, "%x", &colour)
if err != nil {
return 0, fmt.Errorf("invalid colour format: %v", err)
}
if len(colourStr) == 3 {
r := (colour & 0xF00) >> 8
g := (colour & 0x0F0) >> 4
b := (colour & 0x00F) >> 0
colour = (r << 16 << 4) | (g << 8 << 4) | (b << 0 << 4)
}
return colour, nil
}
func ConvertColourToString(colour int) string {
return fmt.Sprintf("#%06X", colour)
}

30
backend/colour_test.go Normal file
View File

@ -0,0 +1,30 @@
package main
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestConvertStringToColourWithSign(t *testing.T) {
colour, err := ConvertStringToColour("#123456")
require.NoError(t, err)
require.Equal(t, 0x123456, colour)
}
func TestConvertStringToColourWithoutSign(t *testing.T) {
colour, err := ConvertStringToColour("123456")
require.NoError(t, err)
require.Equal(t, 0x123456, colour)
}
func TestConvertStringToColourWithSignThreeDigits(t *testing.T) {
colour, err := ConvertStringToColour("#ABC")
require.NoError(t, err)
require.Equal(t, 0xA0B0C0, colour)
}
func TestConvertStringToColourWithoutSignThreeDigits(t *testing.T) {
colour, err := ConvertStringToColour("ABC")
require.NoError(t, err)
require.Equal(t, 0xA0B0C0, colour)
}

View File

@ -2,7 +2,10 @@ package main
import ( import (
"errors" "errors"
"fmt"
"github.com/adhocore/gronx"
"log" "log"
"math"
"time" "time"
"gitea.seeseepuff.be/seeseemelk/mysqlite" "gitea.seeseepuff.be/seeseemelk/mysqlite"
@ -49,8 +52,10 @@ func (db *Db) GetUsers() ([]User, error) {
func (db *Db) GetUser(id int) (*UserWithAllowance, error) { func (db *Db) GetUser(id int) (*UserWithAllowance, error) {
user := &UserWithAllowance{} user := &UserWithAllowance{}
var allowance int
err := db.db.Query("select u.id, u.name, (select ifnull(sum(h.amount), 0) from history h where h.user_id = u.id) from users u where u.id = ?"). err := db.db.Query("select u.id, u.name, (select ifnull(sum(h.amount), 0) from history h where h.user_id = u.id) from users u where u.id = ?").
Bind(id).ScanSingle(&user.ID, &user.Name, &user.Allowance) Bind(id).ScanSingle(&user.ID, &user.Name, &allowance)
user.Allowance = float64(allowance) / 100.0
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -70,18 +75,24 @@ func (db *Db) UserExists(userId int) (bool, error) {
func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) { func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) {
allowances := make([]Allowance, 0) allowances := make([]Allowance, 0)
var err error var err error
var progress int64
totalAllowance := Allowance{} totalAllowance := Allowance{}
err = db.db.Query("select balance, weight from users where id = ?").Bind(userId).ScanSingle(&totalAllowance.Progress, &totalAllowance.Weight) err = db.db.Query("select balance, weight from users where id = ?").Bind(userId).ScanSingle(&progress, &totalAllowance.Weight)
if err != nil { if err != nil {
return nil, err return nil, err
} }
totalAllowance.Progress = float64(progress) / 100.0
allowances = append(allowances, totalAllowance) allowances = append(allowances, totalAllowance)
for row := range db.db.Query("select id, name, target, balance, weight from allowances where user_id = ?"). for row := range db.db.Query("select id, name, target, balance, weight, colour from allowances where user_id = ?").
Bind(userId).Range(&err) { Bind(userId).Range(&err) {
allowance := Allowance{} allowance := Allowance{}
err = row.Scan(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight) var target, progress, colour int
err = row.Scan(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight, &colour)
allowance.Target = float64(target) / 100.0
allowance.Progress = float64(progress) / 100.0
allowance.Colour = ConvertColourToString(colour)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -96,15 +107,22 @@ func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) {
func (db *Db) GetUserAllowanceById(userId int, allowanceId int) (*Allowance, error) { func (db *Db) GetUserAllowanceById(userId int, allowanceId int) (*Allowance, error) {
allowance := &Allowance{} allowance := &Allowance{}
if allowanceId == 0 { if allowanceId == 0 {
var progress int64
err := db.db.Query("select balance, weight from users where id = ?"). err := db.db.Query("select balance, weight from users where id = ?").
Bind(userId).ScanSingle(&allowance.Progress, &allowance.Weight) Bind(userId).ScanSingle(&progress, &allowance.Weight)
allowance.Progress = float64(progress) / 100.0
if err != nil { if err != nil {
return nil, err return nil, err
} }
} else { } else {
err := db.db.Query("select id, name, target, balance, weight from allowances where user_id = ? and id = ?"). var target, progress int64
var colour int
err := db.db.Query("select id, name, target, balance, weight, colour from allowances where user_id = ? and id = ?").
Bind(userId, allowanceId). Bind(userId, allowanceId).
ScanSingle(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight) ScanSingle(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight, &colour)
allowance.Target = float64(target) / 100.0
allowance.Progress = float64(progress) / 100.0
allowance.Colour = ConvertColourToString(colour)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -128,9 +146,15 @@ func (db *Db) CreateAllowance(userId int, allowance *CreateAllowanceRequest) (in
} }
defer tx.MustRollback() defer tx.MustRollback()
// Convert string colour to a valid hex format
colour, err := ConvertStringToColour(allowance.Colour)
if err != nil {
return 0, err
}
// Insert the new allowance // Insert the new allowance
err = tx.Query("insert into allowances (user_id, name, target, weight) values (?, ?, ?, ?)"). err = tx.Query("insert into allowances (user_id, name, target, weight, colour) values (?, ?, ?, ?, ?)").
Bind(userId, allowance.Name, allowance.Target, allowance.Weight). Bind(userId, allowance.Name, int(math.Round(allowance.Target*100.0)), allowance.Weight, colour).
Exec() Exec()
if err != nil { if err != nil {
@ -175,6 +199,40 @@ func (db *Db) DeleteAllowance(userId int, allowanceId int) error {
return nil return nil
} }
func (db *Db) CompleteAllowance(userId int, allowanceId int) error {
tx, err := db.db.Begin()
if err != nil {
return err
}
defer tx.MustRollback()
// Get the cost of the allowance
var cost int
var allowanceName string
err = tx.Query("select balance, name from allowances where id = ? and user_id = ?").
Bind(allowanceId, userId).ScanSingle(&cost, &allowanceName)
if err != nil {
return err
}
// Delete the allowance
err = tx.Query("delete from allowances where id = ? and user_id = ?").
Bind(allowanceId, userId).Exec()
if err != nil {
return err
}
// Add a history entry
err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)").
Bind(userId, time.Now().Unix(), -cost, fmt.Sprintf("Allowance completed: %s", allowanceName)).
Exec()
if err != nil {
return err
}
return tx.Commit()
}
func (db *Db) UpdateUserAllowance(userId int, allowance *UpdateAllowanceRequest) error { func (db *Db) UpdateUserAllowance(userId int, allowance *UpdateAllowanceRequest) error {
tx, err := db.db.Begin() tx, err := db.db.Begin()
if err != nil { if err != nil {
@ -209,8 +267,14 @@ func (db *Db) UpdateAllowance(userId int, allowanceId int, allowance *UpdateAllo
} }
defer tx.MustRollback() defer tx.MustRollback()
err = tx.Query("update allowances set name=?, target=?, weight=? where id = ? and user_id = ?"). colour, err := ConvertStringToColour(allowance.Colour)
Bind(allowance.Name, allowance.Target, allowance.Weight, allowanceId, userId). if err != nil {
return err
}
target := int(math.Round(allowance.Target * 100.0))
err = tx.Query("update allowances set name=?, target=?, weight=?, colour=? where id = ? and user_id = ?").
Bind(allowance.Name, target, allowance.Weight, colour, allowanceId, userId).
Exec() Exec()
if err != nil { if err != nil {
return err return err
@ -218,6 +282,31 @@ func (db *Db) UpdateAllowance(userId int, allowanceId int, allowance *UpdateAllo
return tx.Commit() return tx.Commit()
} }
func (db *Db) BulkUpdateAllowance(userId int, allowances []BulkUpdateAllowanceRequest) error {
tx, err := db.db.Begin()
if err != nil {
return err
}
defer tx.MustRollback()
for _, allowance := range allowances {
if allowance.ID == 0 {
err = tx.Query("update users set weight=? where id = ?").
Bind(allowance.Weight, userId).
Exec()
} else {
err = tx.Query("update allowances set weight=? where id = ? and user_id = ?").
Bind(allowance.Weight, allowance.ID, userId).
Exec()
}
if err != nil {
return err
}
}
return tx.Commit()
}
func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) { func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) {
tx, err := db.db.Begin() tx, err := db.db.Begin()
if err != nil { if err != nil {
@ -225,9 +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
err = tx.Query("insert into tasks (name, reward, assigned) values (?, ?, ?)"). reward := int(math.Round(task.Reward * 100.0))
Bind(task.Name, task.Reward, task.Assigned). err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) values (?, ?, ?, ?, ?)").
Bind(task.Name, reward, task.Assigned, task.Schedule, nextRun).
Exec() Exec()
if err != nil { if err != nil {
@ -251,12 +351,18 @@ 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{}
err = row.Scan(&task.ID, &task.Name, &task.Reward, &task.Assigned) var reward int64
err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule)
task.Reward = float64(reward) / 100.0
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -271,14 +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{}
err := db.db.Query("select id, name, reward, assigned from tasks where id = ?"). err := db.UpdateScheduledTasks()
Bind(id).ScanSingle(&task.ID, &task.Name, &task.Reward, &task.Assigned)
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 {
@ -311,8 +481,9 @@ func (db *Db) UpdateTask(id int, task *CreateTaskRequest) error {
} }
defer tx.MustRollback() defer tx.MustRollback()
reward := int(math.Round(task.Reward * 100.0))
err = tx.Query("update tasks set name=?, reward=?, assigned=? where id = ?"). err = tx.Query("update tasks set name=?, reward=?, assigned=? where id = ?").
Bind(task.Name, task.Reward, task.Assigned, id). Bind(task.Name, reward, task.Assigned, id).
Exec() Exec()
if err != nil { if err != nil {
return err return err
@ -328,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)
@ -356,17 +552,22 @@ func (db *Db) CompleteTask(taskId int) error {
remainingReward := reward remainingReward := reward
if sumOfWeights > 0 {
// Distribute the reward to the allowances // Distribute the reward to the allowances
for allowanceRow := range tx.Query("select id, weight from allowances where user_id = ? and weight > 0").Bind(userId).Range(&err) { for allowanceRow := range tx.Query("select id, weight, target, balance from allowances where user_id = ? and weight > 0 order by (target - balance) asc").Bind(userId).Range(&err) {
var allowanceId int var allowanceId, allowanceTarget, allowanceBalance int
var allowanceWeight float64 var allowanceWeight float64
err = allowanceRow.Scan(&allowanceId, &allowanceWeight) err = allowanceRow.Scan(&allowanceId, &allowanceWeight, &allowanceTarget, &allowanceBalance)
if err != nil { if err != nil {
return err return err
} }
// Calculate the amount to add to the allowance // Calculate the amount to add to the allowance
amount := int((allowanceWeight / sumOfWeights) * float64(remainingReward)) amount := int((allowanceWeight / sumOfWeights) * float64(remainingReward))
if allowanceBalance+amount > allowanceTarget {
// If the amount reaches past the target, set it to the target
amount = allowanceTarget - allowanceBalance
}
sumOfWeights -= allowanceWeight sumOfWeights -= allowanceWeight
err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?").
Bind(amount, allowanceId, userId).Exec() Bind(amount, allowanceId, userId).Exec()
@ -375,23 +576,13 @@ func (db *Db) CompleteTask(taskId int) error {
} }
remainingReward -= amount remainingReward -= amount
} }
}
// 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 {
tx, err := db.db.Begin() tx, err := db.db.Begin()
@ -400,8 +591,9 @@ func (db *Db) AddHistory(userId int, allowance *PostHistory) error {
} }
defer tx.MustRollback() defer tx.MustRollback()
err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). amount := int(math.Round(allowance.Allowance * 100.0))
Bind(userId, time.Now().Unix(), allowance.Allowance). err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)").
Bind(userId, time.Now().Unix(), amount, allowance.Description).
Exec() Exec()
if err != nil { if err != nil {
return err return err
@ -413,14 +605,15 @@ 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 int64 var timestamp, amount int64
err = row.Scan(&allowance.Allowance, &timestamp) err = row.Scan(&amount, &timestamp, &allowance.Description)
if err != nil { if err != nil {
return nil, err return nil, err
} }
allowance.Allowance = float64(amount) / 100.0
allowance.Timestamp = time.Unix(timestamp, 0) allowance.Timestamp = time.Unix(timestamp, 0)
history = append(history, allowance) history = append(history, allowance)
} }
@ -429,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

@ -10,44 +10,55 @@ type User struct {
type UserWithAllowance struct { type UserWithAllowance struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Allowance int `json:"allowance"` Allowance float64 `json:"allowance"`
} }
type History struct { type History struct {
Allowance int `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 int `json:"allowance"` Allowance float64 `json:"allowance"`
Description string `json:"description"`
} }
// Task represents a task in the system. // Task represents a task in the system.
type Task struct { type Task struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Reward int `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 {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Target int `json:"target"` Target float64 `json:"target"`
Progress int `json:"progress"` Progress float64 `json:"progress"`
Weight int `json:"weight"` Weight float64 `json:"weight"`
Colour string `json:"colour"`
} }
type CreateAllowanceRequest struct { type CreateAllowanceRequest struct {
Name string `json:"name"` Name string `json:"name"`
Target int `json:"target"` Target float64 `json:"target"`
Weight int `json:"weight"` Weight float64 `json:"weight"`
Colour string `json:"colour"`
} }
type UpdateAllowanceRequest struct { type UpdateAllowanceRequest struct {
Name string `json:"name"` Name string `json:"name"`
Target int `json:"target"` Target float64 `json:"target"`
Weight int `json:"weight"` Weight float64 `json:"weight"`
Colour string `json:"colour"`
}
type BulkUpdateAllowanceRequest struct {
ID int `json:"id"`
Weight float64 `json:"weight"`
} }
type CreateGoalResponse struct { type CreateGoalResponse struct {
@ -56,10 +67,16 @@ type CreateGoalResponse struct {
type CreateTaskRequest struct { type CreateTaskRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Reward int `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

@ -1,11 +1,9 @@
gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0 h1:kl0VFgvm52UKxJhZpf1hvucxZdOoXY50g/VmzsWH+/8=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.13.0 h1:nqSXu5i5fHB1rrx/kfi8Phn/J6eFa2yh02FiGc9U1yg=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.13.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW2VzJfItVk4t8sw= gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW2VzJfItVk4t8sw=
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=
@ -38,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=
@ -131,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=
@ -218,14 +220,12 @@ modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 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.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE=
modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA=
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.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
@ -234,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=
@ -243,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 {
@ -189,6 +196,44 @@ func createUserAllowance(c *gin.Context) {
c.IndentedJSON(http.StatusCreated, response) c.IndentedJSON(http.StatusCreated, response)
} }
func bulkPutUserAllowance(c *gin.Context) {
userIdStr := c.Param("userId")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
log.Printf(ErrInvalidUserID+": %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidUserID})
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 []BulkUpdateAllowanceRequest
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.BulkUpdateAllowance(userId, allowanceRequest)
if err != nil {
log.Printf("Error updating allowance: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
return
}
c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance updated successfully"})
}
func deleteUserAllowance(c *gin.Context) { func deleteUserAllowance(c *gin.Context) {
userIdStr := c.Param("userId") userIdStr := c.Param("userId")
allowanceIdStr := c.Param("allowanceId") allowanceIdStr := c.Param("allowanceId")
@ -287,6 +332,99 @@ func putUserAllowance(c *gin.Context) {
c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance updated successfully"}) c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance updated successfully"})
} }
func completeAllowance(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
}
err = db.CompleteAllowance(userId, allowanceId)
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 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 {
@ -300,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)
@ -377,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 {
@ -458,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)
@ -506,18 +662,26 @@ func start(ctx context.Context, config *ServerConfig) {
defer db.db.MustClose() defer db.db.MustClose()
router := gin.Default() router := gin.Default()
router.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, corsConfig := cors.DefaultConfig()
})) corsConfig.AllowAllOrigins = true
router.Use(cors.New(corsConfig))
// Web endpoints
loadWebEndpoints(router)
// API endpoints
router.GET("/api/users", getUsers) router.GET("/api/users", getUsers)
router.GET("/api/user/:userId", getUser) router.GET("/api/user/:userId", getUser)
router.POST("/api/user/:userId/history", postHistory) router.POST("/api/user/:userId/history", postHistory)
router.GET("/api/user/:userId/history", getHistory) router.GET("/api/user/:userId/history", getHistory)
router.GET("/api/user/:userId/allowance", getUserAllowance) router.GET("/api/user/:userId/allowance", getUserAllowance)
router.POST("/api/user/:userId/allowance", createUserAllowance) router.POST("/api/user/:userId/allowance", createUserAllowance)
router.PUT("/api/user/:userId/allowance", bulkPutUserAllowance)
router.GET("/api/user/:userId/allowance/:allowanceId", getUserAllowanceById) router.GET("/api/user/:userId/allowance/:allowanceId", getUserAllowanceById)
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/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)
@ -558,5 +722,14 @@ func main() {
Datasource: os.Getenv("DB_PATH"), Datasource: os.Getenv("DB_PATH"),
Addr: ":8080", Addr: ":8080",
} }
if config.Datasource == "" {
config.Datasource = "allowance_planner.db3"
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 1.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,2 @@
alter table allowances
add column colour integer;

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;

249
backend/web.go Normal file
View File

@ -0,0 +1,249 @@
package main
import (
"errors"
"github.com/gin-gonic/gin"
"log"
"net/http"
"strconv"
)
type ViewModel struct {
Users []User
CurrentUser int
Allowances []Allowance
Tasks []Task
History []History
Error string
}
func loadWebEndpoints(router *gin.Engine) {
router.LoadHTMLFiles("web.gohtml")
router.GET("/", renderIndex)
router.GET("/login", renderLogin)
router.POST("/createTask", renderCreateTask)
router.GET("/completeTask", renderCompleteTask)
router.POST("/createAllowance", renderCreateAllowance)
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) {
if c.Query("user") != "" {
log.Println("Set cookie for user:", c.Query("user"))
c.SetCookie("user", c.Query("user"), 3600, "", "", false, true)
}
redirectToPage(c, "/")
}
func renderIndex(c *gin.Context) {
currentUser := getCurrentUser(c)
if currentUser == nil {
return
}
renderWithUser(c, *currentUser)
}
func renderCreateTask(c *gin.Context) {
currentUser := getCurrentUser(c)
if currentUser == nil {
return
}
name := c.PostForm("name")
rewardStr := c.PostForm("reward")
reward, err := strconv.ParseFloat(rewardStr, 64)
if err != nil {
renderError(c, http.StatusBadRequest, err)
return
}
if name == "" || reward <= 0 {
renderError(c, http.StatusBadRequest, err)
return
}
request := &CreateTaskRequest{
Name: name,
Reward: reward,
}
schedule := c.PostForm("schedule")
if schedule != "" {
request.Schedule = &schedule
}
_, err = db.CreateTask(request)
if err != nil {
renderError(c, http.StatusInternalServerError, err)
return
}
redirectToPageStatus(c, "/", http.StatusFound)
}
func renderCompleteTask(c *gin.Context) {
taskIDStr := c.Query("task")
taskID, err := strconv.Atoi(taskIDStr)
if err != nil {
renderError(c, http.StatusBadRequest, err)
return
}
err = db.CompleteTask(taskID)
if err != nil {
renderError(c, http.StatusInternalServerError, err)
return
}
redirectToPageStatus(c, "/", http.StatusFound)
}
func renderCreateAllowance(c *gin.Context) {
currentUser := getCurrentUser(c)
if currentUser == nil {
return
}
name := c.PostForm("name")
targetStr := c.PostForm("target")
target, err := strconv.ParseFloat(targetStr, 64)
if err != nil {
renderError(c, http.StatusBadRequest, err)
return
}
weightStr := c.PostForm("weight")
weight, err := strconv.ParseFloat(weightStr, 64)
if err != nil {
renderError(c, http.StatusBadRequest, err)
return
}
if name == "" || target <= 0 || weight <= 0 {
renderError(c, http.StatusBadRequest, err)
return
}
_, err = db.CreateAllowance(*currentUser, &CreateAllowanceRequest{
Name: name,
Target: target,
Weight: weight,
})
if err != nil {
renderError(c, http.StatusInternalServerError, err)
return
}
redirectToPageStatus(c, "/", http.StatusFound)
}
func renderCompleteAllowance(c *gin.Context) {
currentUser := getCurrentUser(c)
if currentUser == nil {
return
}
allowanceIDStr := c.Query("allowance")
allowanceID, err := strconv.Atoi(allowanceIDStr)
if err != nil {
renderError(c, http.StatusBadRequest, err)
return
}
err = db.CompleteAllowance(*currentUser, allowanceID)
if err != nil {
renderError(c, http.StatusInternalServerError, err)
return
}
redirectToPageStatus(c, "/", http.StatusFound)
}
func getCurrentUser(c *gin.Context) *int {
currentUserStr, err := c.Cookie("user")
log.Println("Cookie string:", currentUserStr)
if errors.Is(err, http.ErrNoCookie) {
renderNoUser(c)
return nil
}
if err != nil {
unsetUserCookie(c)
return nil
}
currentUser, err := strconv.Atoi(currentUserStr)
if err != nil {
unsetUserCookie(c)
return nil
}
userExists, err := db.UserExists(currentUser)
if !userExists || err != nil {
unsetUserCookie(c)
return nil
}
return &currentUser
}
func unsetUserCookie(c *gin.Context) {
c.SetCookie("user", "", -1, "/", "localhost", false, true)
redirectToPageStatus(c, "/", http.StatusFound)
}
func renderNoUser(c *gin.Context) {
users, err := db.GetUsers()
if err != nil {
renderError(c, http.StatusInternalServerError, err)
return
}
c.HTML(http.StatusOK, "web.gohtml", ViewModel{
Users: users,
})
}
func renderWithUser(c *gin.Context, currentUser int) {
users, err := db.GetUsers()
if err != nil {
renderError(c, http.StatusInternalServerError, err)
return
}
allowances, err := db.GetUserAllowances(currentUser)
if err != nil {
renderError(c, http.StatusInternalServerError, err)
return
}
tasks, err := db.GetTasks()
if err != nil {
renderError(c, http.StatusInternalServerError, err)
return
}
history, err := db.GetHistory(currentUser)
if err != nil {
renderError(c, http.StatusInternalServerError, err)
return
}
c.HTML(http.StatusOK, "web.gohtml", ViewModel{
Users: users,
CurrentUser: currentUser,
Allowances: allowances,
Tasks: tasks,
History: history,
})
}
func renderError(c *gin.Context, statusCode int, err error) {
c.HTML(statusCode, "web.gohtml", ViewModel{
Error: err.Error(),
})
}

137
backend/web.gohtml Normal file
View File

@ -0,0 +1,137 @@
{{- /*gotype: allowance_planner.ViewModel*/}}
<html lang="en">
<head>
<title>Allowance Planner 2000</title>
<style>
<!--
tr:hover {
background-color: #f0f0f0;
}
-->
</style>
</head>
<body>
<h1>Allowance Planner 2000</h1>
{{if ne .Error ""}}
<h2>Error</h2>
<p>{{.Error}}</p>
{{else}}
<h2>Users</h2>
{{range .Users}}
{{if eq $.CurrentUser .ID}}
<strong>{{.Name}}</strong>
{{else}}
<a href="/login?user={{.ID}}">{{.Name}}</a>
{{end}}
{{end}}
{{if ne .CurrentUser 0}}
<h2>Allowances</h2>
<form action="/createAllowance" method="post">
<table border=1>
<thead>
<tr>
<th>Name</th>
<th>Progress</th>
<th>Target</th>
<th>Weight</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td><label><input type="text" name="name" placeholder="Name"></label></td>
<td></td>
<td><label><input type="number" name="target" placeholder="Target"></label></td>
<td><label><input type="number" name="weight" placeholder="Weight"></label></td>
<td><input type="submit" value="Create"></td>
</tr>
{{range .Allowances}}
{{if eq .ID 0}}
<tr>
<td>Total</td>
<td>{{.Progress}}</td>
<td></td>
<td>{{.Weight}}</td>
</tr>
{{else}}
<tr>
<td>{{.Name}}</td>
<td><progress max="{{.Target}}" value="{{.Progress}}"></progress> ({{.Progress}})</td>
<td>{{.Target}}</td>
<td>{{.Weight}}</td>
{{if ge .Progress .Target}}
<td>
<a href="/completeAllowance?allowance={{.ID}}">Mark as completed</a>
</td>
{{end}}
</tr>
{{end}}
{{end}}
</tbody>
</table>
</form>
<h2>Tasks</h2>
<form method="post" action="/createTask">
<table border="1">
<thead>
<tr>
<th>Name</th>
<th>Assigned</th>
<th>Reward</th>
<th>Schedule</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Tasks}}
<tr>
<td>{{.Name}}</td>
<td>
{{if eq .Assigned nil}}
None
{{else}}
{{.Assigned}}
{{end}}
</td>
<td>{{.Reward}}</td>
<td>{{.Schedule}}</td>
<td>
<a href="/completeTask?task={{.ID}}">Mark as completed</a>
</td>
</tr>
{{end}}
<tr>
<td><label><input type="text" name="name" placeholder="Name"></label></td>
<td></td>
<td><label><input type="number" name="reward" placeholder="Reward"></label></td>
<td><label><input type="text" name="schedule" placeholder="Schedule"></label></td>
<td><input type="submit" value="Create"></td>
</tr>
</tbody>
</table>
</form>
<h2>History</h2>
<table border="1">
<thead>
<tr>
<th>Timestamp</th>
<th>Allowance</th>
</tr>
</thead>
<tbody>
{{range .History}}
<tr>
<td>{{.Timestamp}}</td>
<td>{{.Allowance}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{end}}
</body>
</html>

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

View File

@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

View File

@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

View File

@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace "io.ionic.starter"
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "io.ionic.starter"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View File

@ -0,0 +1,22 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-status-bar')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@ -0,0 +1,5 @@
package io.ionic.starter;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<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>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 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: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Allowance Planner V2</string>
<string name="title_activity_main">Allowance Planner V2</string>
<string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string>
</resources>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

View File

@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.2'
classpath 'com.google.gms:google-services:4.4.2'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@ -0,0 +1,15 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')

View File

@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

View File

@ -0,0 +1,16 @@
ext {
minSdkVersion = 23
compileSdkVersion = 35
targetSdkVersion = 35
androidxActivityVersion = '1.9.2'
androidxAppCompatVersion = '1.7.0'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.15.0'
androidxFragmentVersion = '1.8.4'
coreSplashScreenVersion = '1.0.1'
androidxWebkitVersion = '1.12.1'
junitVersion = '4.13.2'
androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.1'
cordovaAndroidVersion = '10.1.1'
}

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

@ -23,6 +23,7 @@
"@angular/platform-browser": "^19.0.0", "@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0", "@angular/router": "^19.0.0",
"@capacitor/android": "7.2.0",
"@capacitor/app": "7.0.1", "@capacitor/app": "7.0.1",
"@capacitor/core": "7.2.0", "@capacitor/core": "7.2.0",
"@capacitor/haptics": "7.0.1", "@capacitor/haptics": "7.0.1",
@ -46,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

@ -3,11 +3,12 @@ import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router'; import { RouteReuseStrategy } from '@angular/router';
import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { Drivers, Storage } from '@ionic/storage'; import { Drivers } from '@ionic/storage';
import { IonicStorageModule } from '@ionic/storage-angular'; import { IonicStorageModule } from '@ionic/storage-angular';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({ @NgModule({
declarations: [AppComponent], declarations: [AppComponent],
@ -15,6 +16,7 @@ import { AppComponent } from './app.component';
BrowserModule, BrowserModule,
IonicModule.forRoot(), IonicModule.forRoot(),
AppRoutingModule, AppRoutingModule,
ReactiveFormsModule,
IonicStorageModule.forRoot({ IonicStorageModule.forRoot({
name: '__mydb', name: '__mydb',
driverOrder: [Drivers.IndexedDB, Drivers.LocalStorage] driverOrder: [Drivers.IndexedDB, Drivers.LocalStorage]

View File

@ -0,0 +1,10 @@
export interface Allowance {
id: number;
name: string;
target: number;
// Current allowance value
progress: number;
// Can be any positive number (backend checks for number relative to each other)
weight: number;
colour: string;
}

View File

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

View File

@ -2,5 +2,5 @@ export interface Task {
id: number; id: number;
name: string; name: string;
reward: number; reward: number;
assigned: number; assigned: number | null;
} }

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 {}

Some files were not shown because too many files have changed in this diff Show More