Compare commits
	
		
			75 Commits
		
	
	
		
			c81a0d3294
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| c9a96f937a | |||
| 
						 | 
					cdbac17215 | ||
| 
						 | 
					ecd43906ce | ||
| 
						 | 
					d6935d2f54 | ||
| 06c8ebcbcc | |||
| 5a20e76df2 | |||
| 
						 | 
					02c5c6ea68 | ||
| 
						 | 
					9cbb8756d1 | ||
| 
						 | 
					604b92b3b3 | ||
| 
						 | 
					c7236394d9 | ||
| 
						 | 
					720ef83c2e | ||
| 
						 | 
					5b1d107cac | ||
| 
						 | 
					662257ebc5 | ||
| 
						 | 
					ad48882bca | ||
| 
						 | 
					89d31fe150 | ||
| 
						 | 
					305566c911 | ||
| 
						 | 
					8c2af22c85 | ||
| a0d0c37fdb | |||
| 2714f550a4 | |||
| 
						 | 
					344f7a7eef | ||
| 8380e95217 | |||
| db2f518cc2 | |||
| 
						 | 
					56a19acd0f | ||
| 
						 | 
					8fa4918743 | ||
| 
						 | 
					11913d72aa | ||
| 
						 | 
					45f40a7976 | ||
| 
						 | 
					63982115a7 | ||
| 
						 | 
					e7b4adfa95 | ||
| 
						 | 
					550933db11 | ||
| 
						 | 
					daebcdeccd | ||
| 302ceaa629 | |||
| 8cbfff81f6 | |||
| f9fb956efd | |||
| 5a233073c7 | |||
| cd23e72882 | |||
| a82040720a | |||
| a8e3332723 | |||
| f8d1f195de | |||
| 426e456ba7 | |||
| 
						 | 
					93ec3cbc19 | ||
| 
						 | 
					5bcbde46ea | ||
| 
						 | 
					f04529067a | ||
| 
						 | 
					6e07d44733 | ||
| 
						 | 
					1f21924805 | ||
| 
						 | 
					e85a60ab16 | ||
| 
						 | 
					61694e340f | ||
| 
						 | 
					f72cc8a802 | ||
| da17f351de | |||
| 79dcfbc02c | |||
| 505faa95a3 | |||
| 
						 | 
					a675d51718 | ||
| 9cb71d53cf | |||
| b5aae3be3d | |||
| 238aedb5c9 | |||
| d1774c1ce0 | |||
| 8fedac21bb | |||
| 
						 | 
					361baac8f3 | ||
| 
						 | 
					0007f10ae3 | ||
| 
						 | 
					b48d082edd | ||
| 
						 | 
					bfc1d135de | ||
| 
						 | 
					0749d8ce7a | ||
| 
						 | 
					ef86deb222 | ||
| 
						 | 
					6d6460ac3e | ||
| 1589bc9422 | |||
| 790ee3c622 | |||
| 6979368eda | |||
| 
						 | 
					fd14c12a4a | ||
| cc817ed061 | |||
| 
						 | 
					df1b8e4ed7 | ||
| 4355e1b1b7 | |||
| 
						 | 
					2486bbf1ec | ||
| 
						 | 
					b3e50dadb2 | ||
| 
						 | 
					572c3c2a41 | ||
| 
						 | 
					47f43cb0dc | ||
| 94a20af04d | 
							
								
								
									
										24
									
								
								.gitea/workflows/build.yml
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										27
									
								
								.gitea/workflows/deploy.yml
									
									
									
									
									
										Normal 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)
 | 
			
		||||
							
								
								
									
										32
									
								
								README.md
									
									
									
									
									
								
							
							
						
						@@ -1,2 +1,32 @@
 | 
			
		||||
# Allowance Planner 2000
 | 
			
		||||
An improved Allowance Planner app.
 | 
			
		||||
An improved Allowance Planner app.
 | 
			
		||||
 | 
			
		||||
## Running backend
 | 
			
		||||
In order to run the backend, go to the `backend` directory and run:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
$ 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
									
									
								
							
							
						
						@@ -1,2 +1,4 @@
 | 
			
		||||
*.db3
 | 
			
		||||
*.db3-*
 | 
			
		||||
*.db3.*
 | 
			
		||||
/allowance_planner
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								backend/Dockerfile
									
									
									
									
									
										Normal 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"]
 | 
			
		||||
@@ -2,20 +2,23 @@ package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"github.com/gavv/httpexpect/v2"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/gavv/httpexpect/v2"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	TestGoalName = "Test Goal"
 | 
			
		||||
	TestHistoryName = "Test History"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func startServer(t *testing.T) *httpexpect.Expect {
 | 
			
		||||
	config := ServerConfig{
 | 
			
		||||
		Datasource: ":memory:",
 | 
			
		||||
		Addr:       ":0",
 | 
			
		||||
		Started:    make(chan bool),
 | 
			
		||||
		//Datasource: "test.db",
 | 
			
		||||
		Addr:    ":0",
 | 
			
		||||
		Started: make(chan bool),
 | 
			
		||||
	}
 | 
			
		||||
	go start(t.Context(), &config)
 | 
			
		||||
	<-config.Started
 | 
			
		||||
@@ -35,6 +38,7 @@ func TestGetUser(t *testing.T) {
 | 
			
		||||
	result := e.GET("/user/1").Expect().Status(200).JSON().Object()
 | 
			
		||||
	result.Value("name").IsEqual("Seeseemelk")
 | 
			
		||||
	result.Value("id").IsEqual(1)
 | 
			
		||||
	result.Value("allowance").IsEqual(0)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetUserUnknown(t *testing.T) {
 | 
			
		||||
@@ -47,56 +51,58 @@ func TestGetUserBadId(t *testing.T) {
 | 
			
		||||
	e.GET("/user/bad-id").Expect().Status(400)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetUserGoalsWhenNoGoalsPresent(t *testing.T) {
 | 
			
		||||
func TestGetUserAllowanceWhenNoAllowancePresent(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
	result := e.GET("/user/1/goals").Expect().Status(200).JSON().Array()
 | 
			
		||||
	result.Length().IsEqual(0)
 | 
			
		||||
	result := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
 | 
			
		||||
	result.Length().IsEqual(1)
 | 
			
		||||
	item := result.Value(0).Object()
 | 
			
		||||
	item.Value("id").IsEqual(0)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetUserGoals(t *testing.T) {
 | 
			
		||||
func TestGetUserAllowance(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Create a new goal
 | 
			
		||||
	// Create a new allowance
 | 
			
		||||
	requestBody := map[string]interface{}{
 | 
			
		||||
		"name":   TestGoalName,
 | 
			
		||||
		"name":   TestHistoryName,
 | 
			
		||||
		"target": 5000,
 | 
			
		||||
		"weight": 10,
 | 
			
		||||
	}
 | 
			
		||||
	e.POST("/user/1/goals").WithJSON(requestBody).Expect().Status(201)
 | 
			
		||||
	e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201)
 | 
			
		||||
 | 
			
		||||
	// Validate goal
 | 
			
		||||
	result := e.GET("/user/1/goals").Expect().Status(200).JSON().Array()
 | 
			
		||||
	result.Length().IsEqual(1)
 | 
			
		||||
	item := result.Value(0).Object()
 | 
			
		||||
	// Validate allowance
 | 
			
		||||
	result := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
 | 
			
		||||
	result.Length().IsEqual(2)
 | 
			
		||||
	item := result.Value(1).Object()
 | 
			
		||||
	item.Value("id").IsEqual(1)
 | 
			
		||||
	item.Value("name").IsEqual(TestGoalName)
 | 
			
		||||
	item.Value("name").IsEqual(TestHistoryName)
 | 
			
		||||
	item.Value("target").IsEqual(5000)
 | 
			
		||||
	item.Value("weight").IsEqual(10)
 | 
			
		||||
	item.Value("progress").IsEqual(0)
 | 
			
		||||
	item.NotContainsKey("user_id")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetUserGoalsNoUser(t *testing.T) {
 | 
			
		||||
func TestGetUserAllowanceNoUser(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
	e.GET("/user/999/goals").Expect().Status(404)
 | 
			
		||||
	e.GET("/user/999/allowance").Expect().Status(404)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetUserGoalsBadId(t *testing.T) {
 | 
			
		||||
func TestGetUserAllowanceBadId(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
	e.GET("/user/bad-id/goals").Expect().Status(400)
 | 
			
		||||
	e.GET("/user/bad-id/allowance").Expect().Status(400)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestCreateUserGoal(t *testing.T) {
 | 
			
		||||
func TestCreateUserAllowance(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Create a new goal
 | 
			
		||||
	// Create a new allowance
 | 
			
		||||
	requestBody := map[string]interface{}{
 | 
			
		||||
		"name":   TestGoalName,
 | 
			
		||||
		"name":   TestHistoryName,
 | 
			
		||||
		"target": 5000,
 | 
			
		||||
		"weight": 10,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	response := e.POST("/user/1/goals").
 | 
			
		||||
	response := e.POST("/user/1/allowance").
 | 
			
		||||
		WithJSON(requestBody).
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(201).
 | 
			
		||||
@@ -104,40 +110,40 @@ func TestCreateUserGoal(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	// Verify the response has an ID
 | 
			
		||||
	response.ContainsKey("id")
 | 
			
		||||
	goalId := response.Value("id").Number().Raw()
 | 
			
		||||
	allowanceId := response.Value("id").Number().Raw()
 | 
			
		||||
 | 
			
		||||
	// Verify the goal exists in the list of goals
 | 
			
		||||
	goals := e.GET("/user/1/goals").
 | 
			
		||||
	// Verify the allowance exists in the list of allowances
 | 
			
		||||
	allowances := e.GET("/user/1/allowance").
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(200).
 | 
			
		||||
		JSON().Array()
 | 
			
		||||
 | 
			
		||||
	goals.Length().IsEqual(1)
 | 
			
		||||
	allowances.Length().IsEqual(2)
 | 
			
		||||
 | 
			
		||||
	goal := goals.Value(0).Object()
 | 
			
		||||
	goal.Value("id").IsEqual(goalId)
 | 
			
		||||
	goal.Value("name").IsEqual(TestGoalName)
 | 
			
		||||
	goal.Value("target").IsEqual(5000)
 | 
			
		||||
	goal.Value("weight").IsEqual(10)
 | 
			
		||||
	goal.Value("progress").IsEqual(0)
 | 
			
		||||
	allowance := allowances.Value(1).Object()
 | 
			
		||||
	allowance.Value("id").IsEqual(allowanceId)
 | 
			
		||||
	allowance.Value("name").IsEqual(TestHistoryName)
 | 
			
		||||
	allowance.Value("target").IsEqual(5000)
 | 
			
		||||
	allowance.Value("weight").IsEqual(10)
 | 
			
		||||
	allowance.Value("progress").IsEqual(0)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestCreateUserGoalNoUser(t *testing.T) {
 | 
			
		||||
func TestCreateUserAllowanceNoUser(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	requestBody := map[string]interface{}{
 | 
			
		||||
		"name":   TestGoalName,
 | 
			
		||||
		"name":   TestHistoryName,
 | 
			
		||||
		"target": 5000,
 | 
			
		||||
		"weight": 10,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	e.POST("/user/999/goals").
 | 
			
		||||
	e.POST("/user/999/allowance").
 | 
			
		||||
		WithJSON(requestBody).
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(404)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestCreateUserGoalInvalidInput(t *testing.T) {
 | 
			
		||||
func TestCreateUserAllowanceInvalidInput(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Test with empty name
 | 
			
		||||
@@ -147,7 +153,7 @@ func TestCreateUserGoalInvalidInput(t *testing.T) {
 | 
			
		||||
		"weight": 10,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	e.POST("/user/1/goals").
 | 
			
		||||
	e.POST("/user/1/allowance").
 | 
			
		||||
		WithJSON(requestBody).
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(400)
 | 
			
		||||
@@ -157,76 +163,81 @@ func TestCreateUserGoalInvalidInput(t *testing.T) {
 | 
			
		||||
		"target": 5000,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	e.POST("/user/1/goals").
 | 
			
		||||
	e.POST("/user/1/allowance").
 | 
			
		||||
		WithJSON(invalidRequest).
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(400)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestCreateUserGoalBadId(t *testing.T) {
 | 
			
		||||
func TestCreateUserAllowanceBadId(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	requestBody := map[string]interface{}{
 | 
			
		||||
		"name":   TestGoalName,
 | 
			
		||||
		"name":   TestHistoryName,
 | 
			
		||||
		"target": 5000,
 | 
			
		||||
		"weight": 10,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	e.POST("/user/bad-id/goals").
 | 
			
		||||
	e.POST("/user/bad-id/allowance").
 | 
			
		||||
		WithJSON(requestBody).
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(400)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestDeleteUserGoal(t *testing.T) {
 | 
			
		||||
func TestDeleteUserAllowance(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Create a new goal to delete
 | 
			
		||||
	// Create a new allowance to delete
 | 
			
		||||
	createRequest := map[string]interface{}{
 | 
			
		||||
		"name":   TestGoalName,
 | 
			
		||||
		"name":   TestHistoryName,
 | 
			
		||||
		"target": 1000,
 | 
			
		||||
		"weight": 5,
 | 
			
		||||
	}
 | 
			
		||||
	response := e.POST("/user/1/goals").
 | 
			
		||||
	response := e.POST("/user/1/allowance").
 | 
			
		||||
		WithJSON(createRequest).
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(201).
 | 
			
		||||
		JSON().Object()
 | 
			
		||||
 | 
			
		||||
	goalId := response.Value("id").Number().Raw()
 | 
			
		||||
	allowanceId := response.Value("id").Number().Raw()
 | 
			
		||||
 | 
			
		||||
	// Delete the goal
 | 
			
		||||
	e.DELETE("/user/1/goal/" + strconv.Itoa(int(goalId))).
 | 
			
		||||
	// Delete the allowance
 | 
			
		||||
	e.DELETE("/user/1/allowance/" + strconv.Itoa(int(allowanceId))).
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(200).
 | 
			
		||||
		JSON().Object().Value("message").IsEqual("Goal deleted successfully")
 | 
			
		||||
		JSON().Object().Value("message").IsEqual("History deleted successfully")
 | 
			
		||||
 | 
			
		||||
	// Verify the goal no longer exists
 | 
			
		||||
	goals := e.GET("/user/1/goals").
 | 
			
		||||
	// Verify the allowance no longer exists
 | 
			
		||||
	allowances := e.GET("/user/1/allowance").
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(200).
 | 
			
		||||
		JSON().Array()
 | 
			
		||||
	goals.Length().IsEqual(0)
 | 
			
		||||
	allowances.Length().IsEqual(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestDeleteUserGoalNotFound(t *testing.T) {
 | 
			
		||||
func TestDeleteUserRestAllowance(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
	e.DELETE("/user/1/allowance/0").Expect().Status(400)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestDeleteUserAllowanceNotFound(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Attempt to delete a non-existent goal
 | 
			
		||||
	e.DELETE("/user/1/goal/999").
 | 
			
		||||
	// Attempt to delete a non-existent allowance
 | 
			
		||||
	e.DELETE("/user/1/allowance/999").
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(404).
 | 
			
		||||
		JSON().Object().Value("error").IsEqual("Goal not found")
 | 
			
		||||
		JSON().Object().Value("error").IsEqual("History not found")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestDeleteUserGoalInvalidId(t *testing.T) {
 | 
			
		||||
func TestDeleteUserAllowanceInvalidId(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Attempt to delete a goal with an invalid ID
 | 
			
		||||
	e.DELETE("/user/1/goal/invalid-id").
 | 
			
		||||
	// Attempt to delete an allowance with an invalid ID
 | 
			
		||||
	e.DELETE("/user/1/allowance/invalid-id").
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(400).
 | 
			
		||||
		JSON().Object().Value("error").IsEqual("Invalid goal ID")
 | 
			
		||||
		JSON().Object().Value("error").IsEqual("Invalid allowance ID")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestCreateTask(t *testing.T) {
 | 
			
		||||
@@ -246,7 +257,16 @@ func TestCreateTask(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	// Verify the response has an ID
 | 
			
		||||
	response.ContainsKey("id")
 | 
			
		||||
	taskId := response.Value("id").Number().Raw()
 | 
			
		||||
	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("reward").IsEqual(100)
 | 
			
		||||
	result.Value("assigned").IsNull()
 | 
			
		||||
 | 
			
		||||
	// Create a new task with assigned user
 | 
			
		||||
	assignedUserId := 1
 | 
			
		||||
@@ -263,7 +283,85 @@ func TestCreateTask(t *testing.T) {
 | 
			
		||||
		JSON().Object()
 | 
			
		||||
 | 
			
		||||
	responseWithUser.ContainsKey("id")
 | 
			
		||||
	responseWithUser.Value("id").Number().NotEqual(taskId) // Ensure different ID
 | 
			
		||||
	responseWithUser.Value("id").Number().IsEqual(2)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//func TestCreateScheduleTask(t *testing.T) {
 | 
			
		||||
//	e := startServer(t)
 | 
			
		||||
//
 | 
			
		||||
//	// Create a new task without assigned user
 | 
			
		||||
//	requestBody := map[string]interface{}{
 | 
			
		||||
//		"name":     "Test Task",
 | 
			
		||||
//		"reward":   100,
 | 
			
		||||
//		"schedule": "0 */5 * * * *",
 | 
			
		||||
//	}
 | 
			
		||||
//
 | 
			
		||||
//	response := e.POST("/tasks").
 | 
			
		||||
//		WithJSON(requestBody).
 | 
			
		||||
//		Expect().
 | 
			
		||||
//		Status(201). // Expect Created status
 | 
			
		||||
//		JSON().Object()
 | 
			
		||||
//
 | 
			
		||||
//	requestBody["schedule"] = "every 5 seconds"
 | 
			
		||||
//	e.POST("/tasks").WithJSON(requestBody).Expect().Status(400)
 | 
			
		||||
//
 | 
			
		||||
//	// Verify the response has an ID
 | 
			
		||||
//	response.ContainsKey("id")
 | 
			
		||||
//	response.Value("id").Number().IsEqual(1)
 | 
			
		||||
//
 | 
			
		||||
//	e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1)
 | 
			
		||||
//
 | 
			
		||||
//	// Get task
 | 
			
		||||
//	result := e.GET("/task/1").Expect().Status(200).JSON().Object()
 | 
			
		||||
//	result.Value("id").IsEqual(1)
 | 
			
		||||
//	result.Value("name").IsEqual("Test Task")
 | 
			
		||||
//	result.Value("schedule").IsEqual("0 */5 * * * *")
 | 
			
		||||
//	result.Value("reward").IsEqual(100)
 | 
			
		||||
//	result.Value("assigned").IsNull()
 | 
			
		||||
//
 | 
			
		||||
//	// Complete the task
 | 
			
		||||
//	e.POST("/task/1/complete").Expect().Status(200)
 | 
			
		||||
//
 | 
			
		||||
//	// Set expires date to 1 second in the past
 | 
			
		||||
//	db.db.Query("update tasks set next_run = ? where id = 1").Bind(time.Now().Add(10 * -time.Minute).Unix()).MustExec()
 | 
			
		||||
//
 | 
			
		||||
//	// Verify a new task is created
 | 
			
		||||
//	newTask := e.GET("/task/2").Expect().Status(200).JSON().Object()
 | 
			
		||||
//	newTask.Value("id").IsEqual(2)
 | 
			
		||||
//	newTask.Value("name").IsEqual("Test Task")
 | 
			
		||||
//	newTask.Value("schedule").IsEqual("0 */5 * * * *")
 | 
			
		||||
//	newTask.Value("reward").IsEqual(100)
 | 
			
		||||
//	newTask.Value("assigned").IsNull()
 | 
			
		||||
//}
 | 
			
		||||
 | 
			
		||||
func TestDeleteTask(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Create a new task without assigned user
 | 
			
		||||
	requestBody := map[string]interface{}{
 | 
			
		||||
		"name":   "Test Task",
 | 
			
		||||
		"reward": 100,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	response := e.POST("/tasks").
 | 
			
		||||
		WithJSON(requestBody).
 | 
			
		||||
		Expect().
 | 
			
		||||
		Status(201). // Expect Created status
 | 
			
		||||
		JSON().Object()
 | 
			
		||||
 | 
			
		||||
	// Verify the response has an ID
 | 
			
		||||
	response.ContainsKey("id")
 | 
			
		||||
	taskId := response.Value("id").Number().Raw()
 | 
			
		||||
 | 
			
		||||
	// Delete the task
 | 
			
		||||
	e.DELETE("/task/" + strconv.Itoa(int(taskId))).Expect().Status(200)
 | 
			
		||||
	// Verify the task no longer exists
 | 
			
		||||
	e.GET("/task/" + strconv.Itoa(int(taskId))).Expect().Status(404)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestDeleteTaskNotFound(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
	e.DELETE("/task/1").Expect().Status(404)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestCreateTaskNoName(t *testing.T) {
 | 
			
		||||
@@ -313,15 +411,15 @@ func TestGetTaskWhenNoTasks(t *testing.T) {
 | 
			
		||||
	result.Length().IsEqual(0)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func createTestTask(e *httpexpect.Expect) {
 | 
			
		||||
func createTestTaskWithAmount(e *httpexpect.Expect, amount int) int {
 | 
			
		||||
	requestBody := map[string]interface{}{
 | 
			
		||||
		"name":   "Test Task",
 | 
			
		||||
		"reward": 100,
 | 
			
		||||
		"reward": amount,
 | 
			
		||||
	}
 | 
			
		||||
	e.POST("/tasks").WithJSON(requestBody).Expect().Status(201)
 | 
			
		||||
	return int(e.POST("/tasks").WithJSON(requestBody).Expect().Status(201).JSON().Object().Value("id").Number().Raw())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetTaskSWhenTasks(t *testing.T) {
 | 
			
		||||
func TestGetTasksWhenTasks(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
	createTestTask(e)
 | 
			
		||||
 | 
			
		||||
@@ -386,21 +484,568 @@ func TestPutTaskInvalidTaskId(t *testing.T) {
 | 
			
		||||
	e.PUT("/task/999").WithJSON(requestBody).Expect().Status(404)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestPostAllowance(t *testing.T) {
 | 
			
		||||
func TestPostHistory(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	e.POST("/user/1/allowance").WithJSON(PostAllowance{Allowance: 100}).Expect().Status(200)
 | 
			
		||||
	e.POST("/user/1/allowance").WithJSON(PostAllowance{Allowance: 20}).Expect().Status(200)
 | 
			
		||||
	e.POST("/user/1/allowance").WithJSON(PostAllowance{Allowance: -10}).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, Description: "Lolol"}).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.Value("allowance").Number().IsEqual(100 + 20 - 10)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestPostAllowanceInvalidUserId(t *testing.T) {
 | 
			
		||||
func TestPostHistoryInvalidUserId(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	e.POST("/user/999/allowance").WithJSON(PostAllowance{Allowance: 100}).Expect().
 | 
			
		||||
	e.POST("/user/999/history").WithJSON(PostHistory{Allowance: 100, Description: "Good"}).Expect().
 | 
			
		||||
		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) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100, Description: "Add 100"}).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, Description: "Subtract 10"}).Expect().Status(200)
 | 
			
		||||
 | 
			
		||||
	response := e.GET("/user/1/history").Expect().Status(200).JSON().Array()
 | 
			
		||||
	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("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("description").String().IsEqual("Add 20")
 | 
			
		||||
 | 
			
		||||
	response.Value(2).Object().Value("allowance").Number().IsEqual(-10)
 | 
			
		||||
	response.Value(2).Object().Value("description").String().IsEqual("Subtract 10")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetUserAllowanceById(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Create a new allowance
 | 
			
		||||
	requestBody := map[string]interface{}{
 | 
			
		||||
		"name":   TestHistoryName,
 | 
			
		||||
		"target": 5000,
 | 
			
		||||
		"weight": 10,
 | 
			
		||||
		"colour": "#FF5733",
 | 
			
		||||
	}
 | 
			
		||||
	resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object()
 | 
			
		||||
	allowanceId := int(resp.Value("id").Number().Raw())
 | 
			
		||||
 | 
			
		||||
	// Retrieve the created allowance by ID
 | 
			
		||||
	result := e.GET("/user/1/allowance/" + strconv.Itoa(allowanceId)).Expect().Status(200).JSON().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")
 | 
			
		||||
 | 
			
		||||
	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) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
	e.GET("/user/1/allowance/9999").Expect().Status(404)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetUserByAllowanceByIdInvalidUserId(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
	e.GET("/user/999/allowance/1").Expect().Status(404)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetUserByAllowanceByIdBadUserId(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
	e.GET("/user/bad/allowance/1").Expect().Status(400)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetUserByAllowanceByIdBadAllowanceId(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
	e.GET("/user/1/allowance/bad").Expect().Status(400)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestPutAllowanceById(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Create a new allowance
 | 
			
		||||
	requestBody := map[string]interface{}{
 | 
			
		||||
		"name":   TestHistoryName,
 | 
			
		||||
		"target": 5000,
 | 
			
		||||
		"weight": 10,
 | 
			
		||||
		"colour": "#FF5733",
 | 
			
		||||
	}
 | 
			
		||||
	resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object()
 | 
			
		||||
	allowanceId := int(resp.Value("id").Number().Raw())
 | 
			
		||||
 | 
			
		||||
	// Update the allowance
 | 
			
		||||
	updateRequest := map[string]interface{}{
 | 
			
		||||
		"name":   "Updated Allowance",
 | 
			
		||||
		"target": 6000,
 | 
			
		||||
		"weight": 15,
 | 
			
		||||
		"colour": "#3357FF",
 | 
			
		||||
	}
 | 
			
		||||
	e.PUT("/user/1/allowance/" + strconv.Itoa(allowanceId)).WithJSON(updateRequest).Expect().Status(200)
 | 
			
		||||
 | 
			
		||||
	// Verify the allowance is updated
 | 
			
		||||
	result := e.GET("/user/1/allowance/" + strconv.Itoa(allowanceId)).Expect().Status(200).JSON().Object()
 | 
			
		||||
	result.Value("id").IsEqual(allowanceId)
 | 
			
		||||
	result.Value("name").IsEqual("Updated Allowance")
 | 
			
		||||
	result.Value("target").IsEqual(6000)
 | 
			
		||||
	result.Value("weight").IsEqual(15)
 | 
			
		||||
	result.Value("colour").IsEqual("#3357FF")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestCompleteTask(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: 25,
 | 
			
		||||
	}).Expect().Status(200)
 | 
			
		||||
	// Create two allowance goals
 | 
			
		||||
	e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{
 | 
			
		||||
		Name:   "Test Allowance 1",
 | 
			
		||||
		Target: 100,
 | 
			
		||||
		Weight: 50,
 | 
			
		||||
	}).Expect().Status(201)
 | 
			
		||||
	e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{
 | 
			
		||||
		Name:   "Test Allowance 1",
 | 
			
		||||
		Target: 10,
 | 
			
		||||
		Weight: 25,
 | 
			
		||||
	}).Expect().Status(201)
 | 
			
		||||
 | 
			
		||||
	// 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(3)
 | 
			
		||||
	allowances.Value(0).Object().Value("id").Number().IsEqual(0)
 | 
			
		||||
	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("progress").Number().InDelta(60.66, 0.01)
 | 
			
		||||
	allowances.Value(2).Object().Value("id").Number().IsEqual(2)
 | 
			
		||||
	allowances.Value(2).Object().Value("progress").Number().IsEqual(10)
 | 
			
		||||
 | 
			
		||||
	// 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().IsEqual(101)
 | 
			
		||||
 | 
			
		||||
	for userId := 1; userId <= 2; userId++ {
 | 
			
		||||
		userIdStr := strconv.Itoa(userId)
 | 
			
		||||
		// Ensure the history got updated
 | 
			
		||||
		history := e.GET("/user/" + userIdStr + "/history").Expect().Status(200).JSON().Array()
 | 
			
		||||
		history.Length().IsEqual(1)
 | 
			
		||||
		history.Value(0).Object().Value("allowance").Number().IsEqual(101)
 | 
			
		||||
		history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0))
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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) {
 | 
			
		||||
	start := base.Add(-time.Duration(delta) * time.Second)
 | 
			
		||||
	end := base.Add(time.Duration(delta) * time.Second)
 | 
			
		||||
	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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Transfer tests
 | 
			
		||||
func TestTransferSuccessful(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Create two allowances for user 1
 | 
			
		||||
	createTestAllowance(e, "From Allowance", 100, 1)
 | 
			
		||||
	createTestAllowance(e, "To Allowance", 100, 1)
 | 
			
		||||
 | 
			
		||||
	// Add 30 to allowance 1
 | 
			
		||||
	req := map[string]interface{}{"amount": 30, "description": "funds"}
 | 
			
		||||
	e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
 | 
			
		||||
 | 
			
		||||
	// Transfer 10 from 1 to 2
 | 
			
		||||
	transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
 | 
			
		||||
	e.POST("/transfer").WithJSON(transfer).Expect().Status(200).JSON().Object().Value("message").IsEqual("Transfer successful")
 | 
			
		||||
 | 
			
		||||
	// Verify balances
 | 
			
		||||
	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
 | 
			
		||||
	allowances.Value(1).Object().Value("progress").Number().InDelta(20.0, 0.01)
 | 
			
		||||
	allowances.Value(2).Object().Value("progress").Number().InDelta(10.0, 0.01)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestTransferCapsAtTarget(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Create two allowances
 | 
			
		||||
	createTestAllowance(e, "From Allowance", 100, 1)
 | 
			
		||||
	createTestAllowance(e, "To Allowance", 5, 1)
 | 
			
		||||
 | 
			
		||||
	// Add 10 to allowance 1
 | 
			
		||||
	req := map[string]interface{}{"amount": 10, "description": "funds"}
 | 
			
		||||
	e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
 | 
			
		||||
 | 
			
		||||
	// Transfer 10 from 1 to 2, but to only needs 5
 | 
			
		||||
	transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
 | 
			
		||||
	e.POST("/transfer").WithJSON(transfer).Expect().Status(200)
 | 
			
		||||
 | 
			
		||||
	// Verify capped transfer
 | 
			
		||||
	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
 | 
			
		||||
	allowances.Value(1).Object().Value("progress").Number().InDelta(5.0, 0.01) // from had 10, transferred 5 -> left 5
 | 
			
		||||
	allowances.Value(2).Object().Value("progress").Number().InDelta(5.0, 0.01) // to reached target
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestTransferDifferentUsersFails(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Create allowance for user 1 and user 2
 | 
			
		||||
	createTestAllowance(e, "User1 Allowance", 100, 1)
 | 
			
		||||
	// create for user 2
 | 
			
		||||
	e.POST("/user/2/allowance").WithJSON(CreateAllowanceRequest{Name: "User2 Allowance", Target: 100, Weight: 1}).Expect().Status(201)
 | 
			
		||||
 | 
			
		||||
	// Add to user1 allowance
 | 
			
		||||
	req := map[string]interface{}{"amount": 10, "description": "funds"}
 | 
			
		||||
	e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
 | 
			
		||||
 | 
			
		||||
	// Attempt transfer between different users
 | 
			
		||||
	transfer := map[string]interface{}{"from": 1, "to": 1 /* wrong id to simulate different user's id? */}
 | 
			
		||||
	// To ensure different user, fetch the allowance id for user2 (it's 1 for user2 in its own context but global id will be 2)
 | 
			
		||||
	// Create above for user2 produced global id 2, so use that
 | 
			
		||||
	transfer = map[string]interface{}{"from": 1, "to": 2, "amount": 5}
 | 
			
		||||
	e.POST("/transfer").WithJSON(transfer).Expect().Status(400)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestTransferInsufficientFunds(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// Create two allowances
 | 
			
		||||
	createTestAllowance(e, "From Allowance", 100, 1)
 | 
			
		||||
	createTestAllowance(e, "To Allowance", 100, 1)
 | 
			
		||||
 | 
			
		||||
	// Ensure from has 0 balance
 | 
			
		||||
	transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
 | 
			
		||||
	resp := e.POST("/transfer").WithJSON(transfer).Expect().Status(400).JSON().Object()
 | 
			
		||||
	// Error text should mention insufficient funds
 | 
			
		||||
	resp.Value("error").String().ContainsFold("insufficient")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestTransferNotFound(t *testing.T) {
 | 
			
		||||
	e := startServer(t)
 | 
			
		||||
 | 
			
		||||
	// No allowances exist yet (only user rows). Attempt transfer with non-existent IDs
 | 
			
		||||
	transfer := map[string]interface{}{"from": 999, "to": 1000, "amount": 1}
 | 
			
		||||
	e.POST("/transfer").WithJSON(transfer).Expect().Status(404)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										34
									
								
								backend/colour.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						@@ -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)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										601
									
								
								backend/db.go
									
									
									
									
									
								
							
							
						
						@@ -2,7 +2,10 @@ package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"github.com/adhocore/gronx"
 | 
			
		||||
	"log"
 | 
			
		||||
	"math"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"gitea.seeseepuff.be/seeseemelk/mysqlite"
 | 
			
		||||
@@ -49,11 +52,10 @@ func (db *Db) GetUsers() ([]User, error) {
 | 
			
		||||
func (db *Db) GetUser(id int) (*UserWithAllowance, error) {
 | 
			
		||||
	user := &UserWithAllowance{}
 | 
			
		||||
 | 
			
		||||
	err := db.db.Query("select u.id, u.name, sum(h.amount) from users u join history h on h.user_id = u.id where u.id = ?").
 | 
			
		||||
		Bind(id).ScanSingle(&user.ID, &user.Name, &user.Allowance)
 | 
			
		||||
	if errors.Is(err, mysqlite.ErrNoRows) {
 | 
			
		||||
		return nil, nil
 | 
			
		||||
	}
 | 
			
		||||
	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 = ?").
 | 
			
		||||
		Bind(id).ScanSingle(&user.ID, &user.Name, &allowance)
 | 
			
		||||
	user.Allowance = float64(allowance) / 100.0
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
@@ -70,27 +72,66 @@ func (db *Db) UserExists(userId int) (bool, error) {
 | 
			
		||||
	return count > 0, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) GetUserGoals(userId int) ([]Goal, error) {
 | 
			
		||||
	goals := make([]Goal, 0)
 | 
			
		||||
func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) {
 | 
			
		||||
	allowances := make([]Allowance, 0)
 | 
			
		||||
	var err error
 | 
			
		||||
	var progress int64
 | 
			
		||||
 | 
			
		||||
	for row := range db.db.Query("select id, name, target, progress, weight from goals where user_id = ?").
 | 
			
		||||
	totalAllowance := Allowance{}
 | 
			
		||||
	err = db.db.Query("select balance, weight from users where id = ?").Bind(userId).ScanSingle(&progress, &totalAllowance.Weight)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	totalAllowance.Progress = float64(progress) / 100.0
 | 
			
		||||
	allowances = append(allowances, totalAllowance)
 | 
			
		||||
 | 
			
		||||
	for row := range db.db.Query("select id, name, target, balance, weight, colour from allowances where user_id = ?").
 | 
			
		||||
		Bind(userId).Range(&err) {
 | 
			
		||||
		goal := Goal{}
 | 
			
		||||
		err = row.Scan(&goal.ID, &goal.Name, &goal.Target, &goal.Progress, &goal.Weight)
 | 
			
		||||
		allowance := Allowance{}
 | 
			
		||||
		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 {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
		goals = append(goals, goal)
 | 
			
		||||
		allowances = append(allowances, allowance)
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	return goals, nil
 | 
			
		||||
	return allowances, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) CreateGoal(userId int, goal *CreateGoalRequest) (int, error) {
 | 
			
		||||
	// Check if user exists before attempting to create a goal
 | 
			
		||||
func (db *Db) GetUserAllowanceById(userId int, allowanceId int) (*Allowance, error) {
 | 
			
		||||
	allowance := &Allowance{}
 | 
			
		||||
	if allowanceId == 0 {
 | 
			
		||||
		var progress int64
 | 
			
		||||
		err := db.db.Query("select balance, weight from users where id = ?").
 | 
			
		||||
			Bind(userId).ScanSingle(&progress, &allowance.Weight)
 | 
			
		||||
		allowance.Progress = float64(progress) / 100.0
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		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).
 | 
			
		||||
			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 {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return allowance, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) CreateAllowance(userId int, allowance *CreateAllowanceRequest) (int, error) {
 | 
			
		||||
	// Check if user exists before attempting to create an allowance
 | 
			
		||||
	exists, err := db.UserExists(userId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, err
 | 
			
		||||
@@ -105,9 +146,15 @@ func (db *Db) CreateGoal(userId int, goal *CreateGoalRequest) (int, error) {
 | 
			
		||||
	}
 | 
			
		||||
	defer tx.MustRollback()
 | 
			
		||||
 | 
			
		||||
	// Insert the new goal
 | 
			
		||||
	err = tx.Query("insert into goals (user_id, name, target, progress, weight) values (?, ?, ?, 0, ?)").
 | 
			
		||||
		Bind(userId, goal.Name, goal.Target, goal.Weight).
 | 
			
		||||
	// Convert string colour to a valid hex format
 | 
			
		||||
	colour, err := ConvertStringToColour(allowance.Colour)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Insert the new allowance
 | 
			
		||||
	err = tx.Query("insert into allowances (user_id, name, target, weight, colour) values (?, ?, ?, ?, ?)").
 | 
			
		||||
		Bind(userId, allowance.Name, int(math.Round(allowance.Target*100.0)), allowance.Weight, colour).
 | 
			
		||||
		Exec()
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -130,21 +177,21 @@ func (db *Db) CreateGoal(userId int, goal *CreateGoalRequest) (int, error) {
 | 
			
		||||
	return lastId, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) DeleteGoal(userId int, goalId int) error {
 | 
			
		||||
	// Check if the goal exists for the user
 | 
			
		||||
func (db *Db) DeleteAllowance(userId int, allowanceId int) error {
 | 
			
		||||
	// Check if the allowance exists for the user
 | 
			
		||||
	count := 0
 | 
			
		||||
	err := db.db.Query("select count(*) from goals where id = ? and user_id = ?").
 | 
			
		||||
		Bind(goalId, userId).ScanSingle(&count)
 | 
			
		||||
	err := db.db.Query("select count(*) from allowances where id = ? and user_id = ?").
 | 
			
		||||
		Bind(allowanceId, userId).ScanSingle(&count)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	if count == 0 {
 | 
			
		||||
		return errors.New("goal not found")
 | 
			
		||||
		return errors.New("allowance not found")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Delete the goal
 | 
			
		||||
	err = db.db.Query("delete from goals where id = ? and user_id = ?").
 | 
			
		||||
		Bind(goalId, userId).Exec()
 | 
			
		||||
	// Delete the allowance
 | 
			
		||||
	err = db.db.Query("delete from allowances where id = ? and user_id = ?").
 | 
			
		||||
		Bind(allowanceId, userId).Exec()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
@@ -152,6 +199,114 @@ func (db *Db) DeleteGoal(userId int, goalId int) error {
 | 
			
		||||
	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 {
 | 
			
		||||
	tx, err := db.db.Begin()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer tx.MustRollback()
 | 
			
		||||
 | 
			
		||||
	err = tx.Query("update users set weight=? where id = ?").
 | 
			
		||||
		Bind(allowance.Weight, userId).
 | 
			
		||||
		Exec()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	return tx.Commit()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) UpdateAllowance(userId int, allowanceId int, allowance *UpdateAllowanceRequest) error {
 | 
			
		||||
	// Check if the allowance exists for the user
 | 
			
		||||
	count := 0
 | 
			
		||||
	err := db.db.Query("select count(*) from allowances where id = ? and user_id = ?").
 | 
			
		||||
		Bind(allowanceId, userId).ScanSingle(&count)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	if count == 0 {
 | 
			
		||||
		return errors.New("allowance not found")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tx, err := db.db.Begin()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer tx.MustRollback()
 | 
			
		||||
 | 
			
		||||
	colour, err := ConvertStringToColour(allowance.Colour)
 | 
			
		||||
	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()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	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) {
 | 
			
		||||
	tx, err := db.db.Begin()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -159,9 +314,20 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) {
 | 
			
		||||
	}
 | 
			
		||||
	defer tx.MustRollback()
 | 
			
		||||
 | 
			
		||||
	var nextRun *int64
 | 
			
		||||
	if task.Schedule != nil {
 | 
			
		||||
		nextRunTime, err := gronx.NextTick(*task.Schedule, false)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return 0, fmt.Errorf("failed to calculate next run: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		nextRunTimeAsInt := nextRunTime.Unix()
 | 
			
		||||
		nextRun = &nextRunTimeAsInt
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Insert the new task
 | 
			
		||||
	err = tx.Query("insert into tasks (name, reward, assigned) values (?, ?, ?)").
 | 
			
		||||
		Bind(task.Name, task.Reward, task.Assigned).
 | 
			
		||||
	reward := int(math.Round(task.Reward * 100.0))
 | 
			
		||||
	err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) values (?, ?, ?, ?, ?)").
 | 
			
		||||
		Bind(task.Name, reward, task.Assigned, task.Schedule, nextRun).
 | 
			
		||||
		Exec()
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -185,12 +351,18 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) GetTasks() ([]Task, error) {
 | 
			
		||||
	tasks := make([]Task, 0)
 | 
			
		||||
	var err error
 | 
			
		||||
	err := db.UpdateScheduledTasks()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to update scheduled tasks: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for row := range db.db.Query("select id, name, reward, assigned from tasks").Range(&err) {
 | 
			
		||||
	tasks := make([]Task, 0)
 | 
			
		||||
 | 
			
		||||
	for row := range db.db.Query("select id, name, reward, assigned, schedule from tasks where completed is null").Range(&err) {
 | 
			
		||||
		task := Task{}
 | 
			
		||||
		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 {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
@@ -205,14 +377,93 @@ func (db *Db) GetTasks() ([]Task, error) {
 | 
			
		||||
func (db *Db) GetTask(id int) (Task, error) {
 | 
			
		||||
	task := Task{}
 | 
			
		||||
 | 
			
		||||
	err := db.db.Query("select id, name, reward, assigned from tasks where id = ?").
 | 
			
		||||
		Bind(id).ScanSingle(&task.ID, &task.Name, &task.Reward, &task.Assigned)
 | 
			
		||||
	err := db.UpdateScheduledTasks()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return Task{}, err
 | 
			
		||||
		return Task{}, fmt.Errorf("failed to update scheduled tasks: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var reward int64
 | 
			
		||||
	err = db.db.Query("select id, name, reward, assigned, schedule from tasks where id = ? and completed is null").
 | 
			
		||||
		Bind(id).ScanSingle(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return task, err
 | 
			
		||||
	}
 | 
			
		||||
	task.Reward = float64(reward) / 100.0
 | 
			
		||||
	return task, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) UpdateScheduledTasks() error {
 | 
			
		||||
	type ScheduledTask struct {
 | 
			
		||||
		ID       int
 | 
			
		||||
		Schedule string
 | 
			
		||||
		Expires  int64
 | 
			
		||||
	}
 | 
			
		||||
	tasks := make([]ScheduledTask, 0)
 | 
			
		||||
	var err error
 | 
			
		||||
 | 
			
		||||
	for row := range db.db.Query("select id, schedule, next_run from tasks where schedule is not null").Range(&err) {
 | 
			
		||||
		task := ScheduledTask{}
 | 
			
		||||
		err := row.Scan(&task.ID, &task.Schedule, &task.Expires)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
		if time.Now().Unix() >= task.Expires {
 | 
			
		||||
			tasks = append(tasks, task)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to fetch scheduled tasks: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tx, err := db.db.Begin()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer tx.MustRollback()
 | 
			
		||||
 | 
			
		||||
	for _, task := range tasks {
 | 
			
		||||
		nextRun, err := gronx.NextTickAfter(task.Schedule, time.Now(), false)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to calculate next run for task %d: %w", task.ID, err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) select name, reward, assigned, schedule, ? from tasks where id = ?").
 | 
			
		||||
			Bind(nextRun.Unix(), task.ID).
 | 
			
		||||
			Exec()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		err = tx.Query("update tasks set schedule = null where id = ?").Bind(task.ID).Exec()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		tx.Query("select last_insert_rowid()").MustScanSingle(&task.ID)
 | 
			
		||||
		log.Printf("Task %d scheduled for %s", task.ID, nextRun)
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return tx.Commit()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) DeleteTask(id int) error {
 | 
			
		||||
	tx, err := db.db.Begin()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer tx.MustRollback()
 | 
			
		||||
 | 
			
		||||
	err = tx.Query("delete from tasks where id = ?").Bind(id).Exec()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return tx.Commit()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) HasTask(id int) (bool, error) {
 | 
			
		||||
	count := 0
 | 
			
		||||
	err := db.db.Query("select count(*) from tasks where id = ?").
 | 
			
		||||
@@ -230,8 +481,9 @@ func (db *Db) UpdateTask(id int, task *CreateTaskRequest) error {
 | 
			
		||||
	}
 | 
			
		||||
	defer tx.MustRollback()
 | 
			
		||||
 | 
			
		||||
	reward := int(math.Round(task.Reward * 100.0))
 | 
			
		||||
	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()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
@@ -239,18 +491,291 @@ func (db *Db) UpdateTask(id int, task *CreateTaskRequest) error {
 | 
			
		||||
	return tx.Commit()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) AddAllowance(userId int, allowance *PostAllowance) error {
 | 
			
		||||
func (db *Db) CompleteTask(taskId int) error {
 | 
			
		||||
	tx, err := db.db.Begin()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer tx.MustRollback()
 | 
			
		||||
 | 
			
		||||
	err = tx.Query("insert into history (user_id, date, amount) values (?, ?, ?)").
 | 
			
		||||
		Bind(userId, time.Now().Unix(), allowance.Allowance).
 | 
			
		||||
	var reward int
 | 
			
		||||
	var rewardName string
 | 
			
		||||
	err = tx.Query("select reward, name from tasks where id = ?").Bind(taskId).ScanSingle(&reward, &rewardName)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for userRow := range tx.Query("select id from users").Range(&err) {
 | 
			
		||||
		var userId int
 | 
			
		||||
		err = userRow.Scan(&userId)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Add the history entry
 | 
			
		||||
		err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)").
 | 
			
		||||
			Bind(userId, time.Now().Unix(), reward, fmt.Sprintf("Task completed: %s", rewardName)).
 | 
			
		||||
			Exec()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			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
 | 
			
		||||
	var sumOfWeights float64
 | 
			
		||||
	err = tx.Query("select sum(weight) from allowances where user_id = ? and weight > 0").Bind(userId).ScanSingle(&sumOfWeights)
 | 
			
		||||
	sumOfWeights += userWeight
 | 
			
		||||
 | 
			
		||||
	remainingReward := reward
 | 
			
		||||
 | 
			
		||||
	if sumOfWeights > 0 {
 | 
			
		||||
		// Distribute the reward to the allowances
 | 
			
		||||
		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, allowanceTarget, allowanceBalance int
 | 
			
		||||
			var allowanceWeight float64
 | 
			
		||||
			err = allowanceRow.Scan(&allowanceId, &allowanceWeight, &allowanceTarget, &allowanceBalance)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Calculate the amount to add to the allowance
 | 
			
		||||
			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
 | 
			
		||||
			err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?").
 | 
			
		||||
				Bind(amount, allowanceId, userId).Exec()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
			remainingReward -= amount
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Add the remaining reward to the user
 | 
			
		||||
	err = tx.Query("update users set balance = balance + ? where id = ?").
 | 
			
		||||
		Bind(remainingReward, userId).Exec()
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) AddHistory(userId int, allowance *PostHistory) error {
 | 
			
		||||
	tx, err := db.db.Begin()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer tx.MustRollback()
 | 
			
		||||
 | 
			
		||||
	amount := int(math.Round(allowance.Allowance * 100.0))
 | 
			
		||||
	err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)").
 | 
			
		||||
		Bind(userId, time.Now().Unix(), amount, allowance.Description).
 | 
			
		||||
		Exec()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	return tx.Commit()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) GetHistory(userId int) ([]History, error) {
 | 
			
		||||
	history := make([]History, 0)
 | 
			
		||||
	var err error
 | 
			
		||||
 | 
			
		||||
	for row := range db.db.Query("select amount, `timestamp`, description from history where user_id = ? order by `timestamp` desc").
 | 
			
		||||
		Bind(userId).Range(&err) {
 | 
			
		||||
		allowance := History{}
 | 
			
		||||
		var timestamp, amount int64
 | 
			
		||||
		err = row.Scan(&amount, ×tamp, &allowance.Description)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
		allowance.Allowance = float64(amount) / 100.0
 | 
			
		||||
		allowance.Timestamp = time.Unix(timestamp, 0)
 | 
			
		||||
		history = append(history, allowance)
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	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()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (db *Db) TransferAllowance(fromId int, toId int, amount float64) error {
 | 
			
		||||
	if fromId == toId {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	amountCents := int(math.Round(amount * 100.0))
 | 
			
		||||
	if amountCents <= 0 {
 | 
			
		||||
		return fmt.Errorf("amount must be positive")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tx, err := db.db.Begin()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer tx.MustRollback()
 | 
			
		||||
 | 
			
		||||
	// Fetch from allowance (user_id, balance)
 | 
			
		||||
	var fromUserId int
 | 
			
		||||
	var fromBalance int
 | 
			
		||||
	err = tx.Query("select user_id, balance from allowances where id = ?").Bind(fromId).ScanSingle(&fromUserId, &fromBalance)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Fetch to allowance (user_id, target, balance)
 | 
			
		||||
	var toUserId int
 | 
			
		||||
	var toTarget int
 | 
			
		||||
	var toBalance int
 | 
			
		||||
	err = tx.Query("select user_id, target, balance from allowances where id = ?").Bind(toId).ScanSingle(&toUserId, &toTarget, &toBalance)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Ensure same owner
 | 
			
		||||
	if fromUserId != toUserId {
 | 
			
		||||
		return fmt.Errorf(ErrDifferentUsers)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Calculate how much the 'to' goal still needs
 | 
			
		||||
	remainingTo := toTarget - toBalance
 | 
			
		||||
	if remainingTo <= 0 {
 | 
			
		||||
		// Nothing to transfer
 | 
			
		||||
		return fmt.Errorf("target already reached")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Limit transfer to what 'to' still needs
 | 
			
		||||
	transfer := amountCents
 | 
			
		||||
	if transfer > remainingTo {
 | 
			
		||||
		transfer = remainingTo
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Ensure 'from' has enough balance
 | 
			
		||||
	if fromBalance < transfer {
 | 
			
		||||
		return fmt.Errorf(ErrInsufficientFunds)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Perform updates
 | 
			
		||||
	err = tx.Query("update allowances set balance = balance - ? where id = ? and user_id = ?").Bind(transfer, fromId, fromUserId).Exec()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?").Bind(transfer, toId, toUserId).Exec()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return tx.Commit()
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,45 +1,64 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import "time"
 | 
			
		||||
 | 
			
		||||
type User struct {
 | 
			
		||||
	ID   int    `json:"id"`
 | 
			
		||||
	Name string `json:"name"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UserWithAllowance struct {
 | 
			
		||||
	ID        int    `json:"id"`
 | 
			
		||||
	Name      string `json:"name"`
 | 
			
		||||
	Allowance int    `json:"allowance"`
 | 
			
		||||
	ID        int     `json:"id"`
 | 
			
		||||
	Name      string  `json:"name"`
 | 
			
		||||
	Allowance float64 `json:"allowance"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Allowance struct {
 | 
			
		||||
	Allowance int    `json:"allowance"`
 | 
			
		||||
	Goals     []Goal `json:"goals"`
 | 
			
		||||
type History struct {
 | 
			
		||||
	Allowance   float64   `json:"allowance"`
 | 
			
		||||
	Timestamp   time.Time `json:"timestamp"`
 | 
			
		||||
	Description string    `json:"description"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type PostAllowance struct {
 | 
			
		||||
	Allowance int `json:"allowance"`
 | 
			
		||||
type PostHistory struct {
 | 
			
		||||
	Allowance   float64 `json:"allowance"`
 | 
			
		||||
	Description string  `json:"description"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Task represents a task in the system.
 | 
			
		||||
type Task struct {
 | 
			
		||||
	ID       int    `json:"id"`
 | 
			
		||||
	Name     string `json:"name"`
 | 
			
		||||
	Reward   int    `json:"reward"`
 | 
			
		||||
	Assigned *int   `json:"assigned"` // Pointer to allow null
 | 
			
		||||
	ID       int     `json:"id"`
 | 
			
		||||
	Name     string  `json:"name"`
 | 
			
		||||
	Reward   float64 `json:"reward"`
 | 
			
		||||
	Assigned *int    `json:"assigned"`
 | 
			
		||||
	Schedule *string `json:"schedule"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Goal struct {
 | 
			
		||||
	ID       int    `json:"id"`
 | 
			
		||||
	Name     string `json:"name"`
 | 
			
		||||
	Target   int    `json:"target"`
 | 
			
		||||
	Progress int    `json:"progress"`
 | 
			
		||||
	Weight   int    `json:"weight"`
 | 
			
		||||
type Allowance struct {
 | 
			
		||||
	ID       int     `json:"id"`
 | 
			
		||||
	Name     string  `json:"name"`
 | 
			
		||||
	Target   float64 `json:"target"`
 | 
			
		||||
	Progress float64 `json:"progress"`
 | 
			
		||||
	Weight   float64 `json:"weight"`
 | 
			
		||||
	Colour   string  `json:"colour"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type CreateGoalRequest struct {
 | 
			
		||||
	Name   string `json:"name"`
 | 
			
		||||
	Target int    `json:"target"`
 | 
			
		||||
	Weight int    `json:"weight"`
 | 
			
		||||
type CreateAllowanceRequest struct {
 | 
			
		||||
	Name   string  `json:"name"`
 | 
			
		||||
	Target float64 `json:"target"`
 | 
			
		||||
	Weight float64 `json:"weight"`
 | 
			
		||||
	Colour string  `json:"colour"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UpdateAllowanceRequest struct {
 | 
			
		||||
	Name   string  `json:"name"`
 | 
			
		||||
	Target float64 `json:"target"`
 | 
			
		||||
	Weight float64 `json:"weight"`
 | 
			
		||||
	Colour string  `json:"colour"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type BulkUpdateAllowanceRequest struct {
 | 
			
		||||
	ID     int     `json:"id"`
 | 
			
		||||
	Weight float64 `json:"weight"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type CreateGoalResponse struct {
 | 
			
		||||
@@ -47,11 +66,23 @@ type CreateGoalResponse struct {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type CreateTaskRequest struct {
 | 
			
		||||
	Name     string `json:"name" binding:"required"`
 | 
			
		||||
	Reward   int    `json:"reward"`
 | 
			
		||||
	Assigned *int   `json:"assigned"`
 | 
			
		||||
	Name     string  `json:"name" binding:"required"`
 | 
			
		||||
	Reward   float64 `json:"reward"`
 | 
			
		||||
	Assigned *int    `json:"assigned"`
 | 
			
		||||
	Schedule *string `json:"schedule"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type CreateTaskResponse struct {
 | 
			
		||||
	ID int `json:"id"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type AddAllowanceAmountRequest struct {
 | 
			
		||||
	Amount      float64 `json:"amount"`
 | 
			
		||||
	Description string  `json:"description"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type TransferRequest struct {
 | 
			
		||||
	From   int     `json:"from"`
 | 
			
		||||
	To     int     `json:"to"`
 | 
			
		||||
	Amount float64 `json:"amount"`
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,29 +3,34 @@ module allowance_planner
 | 
			
		||||
go 1.24.2
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	gitea.seeseepuff.be/seeseemelk/mysqlite v0.11.1
 | 
			
		||||
	gitea.seeseepuff.be/seeseemelk/mysqlite v0.15.0
 | 
			
		||||
	github.com/adhocore/gronx v1.19.6
 | 
			
		||||
	github.com/gavv/httpexpect/v2 v2.17.0
 | 
			
		||||
	github.com/gin-gonic/gin v1.10.0
 | 
			
		||||
	github.com/gin-contrib/cors v1.7.6
 | 
			
		||||
	github.com/gin-gonic/gin v1.11.0
 | 
			
		||||
	github.com/stretchr/testify v1.11.1
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
 | 
			
		||||
	github.com/ajg/form v1.5.1 // indirect
 | 
			
		||||
	github.com/andybalholm/brotli v1.1.1 // indirect
 | 
			
		||||
	github.com/bytedance/sonic v1.13.2 // indirect
 | 
			
		||||
	github.com/bytedance/sonic/loader v0.2.4 // indirect
 | 
			
		||||
	github.com/cloudwego/base64x v0.1.5 // indirect
 | 
			
		||||
	github.com/andybalholm/brotli v1.2.0 // indirect
 | 
			
		||||
	github.com/bytedance/gopkg v0.1.3 // indirect
 | 
			
		||||
	github.com/bytedance/sonic v1.14.1 // indirect
 | 
			
		||||
	github.com/bytedance/sonic/loader v0.3.0 // indirect
 | 
			
		||||
	github.com/cloudwego/base64x v0.1.6 // indirect
 | 
			
		||||
	github.com/davecgh/go-spew v1.1.1 // indirect
 | 
			
		||||
	github.com/dustin/go-humanize v1.0.1 // indirect
 | 
			
		||||
	github.com/fatih/color v1.18.0 // indirect
 | 
			
		||||
	github.com/fatih/structs v1.1.0 // indirect
 | 
			
		||||
	github.com/gabriel-vasile/mimetype v1.4.9 // indirect
 | 
			
		||||
	github.com/gabriel-vasile/mimetype v1.4.10 // indirect
 | 
			
		||||
	github.com/gin-contrib/sse v1.1.0 // indirect
 | 
			
		||||
	github.com/go-playground/locales v0.14.1 // indirect
 | 
			
		||||
	github.com/go-playground/universal-translator v0.18.1 // indirect
 | 
			
		||||
	github.com/go-playground/validator/v10 v10.26.0 // indirect
 | 
			
		||||
	github.com/go-playground/validator/v10 v10.28.0 // indirect
 | 
			
		||||
	github.com/gobwas/glob v0.2.3 // indirect
 | 
			
		||||
	github.com/goccy/go-json v0.10.5 // indirect
 | 
			
		||||
	github.com/goccy/go-yaml v1.18.0 // indirect
 | 
			
		||||
	github.com/google/go-querystring v1.1.0 // indirect
 | 
			
		||||
	github.com/google/uuid v1.6.0 // indirect
 | 
			
		||||
	github.com/gorilla/websocket v1.5.3 // indirect
 | 
			
		||||
@@ -33,44 +38,49 @@ require (
 | 
			
		||||
	github.com/imkira/go-interpol v1.1.0 // indirect
 | 
			
		||||
	github.com/json-iterator/go v1.1.12 // indirect
 | 
			
		||||
	github.com/klauspost/compress v1.18.0 // indirect
 | 
			
		||||
	github.com/klauspost/cpuid/v2 v2.2.10 // indirect
 | 
			
		||||
	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 | 
			
		||||
	github.com/leodido/go-urn v1.4.0 // indirect
 | 
			
		||||
	github.com/mattn/go-colorable v0.1.14 // indirect
 | 
			
		||||
	github.com/mattn/go-isatty v0.0.20 // indirect
 | 
			
		||||
	github.com/mitchellh/go-wordwrap v1.0.1 // indirect
 | 
			
		||||
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 | 
			
		||||
	github.com/modern-go/reflect2 v1.0.2 // indirect
 | 
			
		||||
	github.com/ncruces/go-strftime v0.1.9 // indirect
 | 
			
		||||
	github.com/ncruces/go-strftime v1.0.0 // indirect
 | 
			
		||||
	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
 | 
			
		||||
	github.com/pmezard/go-difflib v1.0.0 // indirect
 | 
			
		||||
	github.com/quic-go/qpack v0.5.1 // indirect
 | 
			
		||||
	github.com/quic-go/quic-go v0.55.0 // indirect
 | 
			
		||||
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 | 
			
		||||
	github.com/sanity-io/litter v1.5.8 // indirect
 | 
			
		||||
	github.com/sergi/go-diff v1.3.1 // indirect
 | 
			
		||||
	github.com/stretchr/testify v1.10.0 // indirect
 | 
			
		||||
	github.com/sergi/go-diff v1.4.0 // indirect
 | 
			
		||||
	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
 | 
			
		||||
	github.com/ugorji/go/codec v1.2.12 // indirect
 | 
			
		||||
	github.com/ugorji/go/codec v1.3.0 // indirect
 | 
			
		||||
	github.com/valyala/bytebufferpool v1.0.0 // indirect
 | 
			
		||||
	github.com/valyala/fasthttp v1.62.0 // indirect
 | 
			
		||||
	github.com/valyala/fasthttp v1.67.0 // indirect
 | 
			
		||||
	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
 | 
			
		||||
	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
 | 
			
		||||
	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
 | 
			
		||||
	github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect
 | 
			
		||||
	github.com/yudai/gojsondiff v1.0.0 // indirect
 | 
			
		||||
	github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
 | 
			
		||||
	golang.org/x/arch v0.17.0 // indirect
 | 
			
		||||
	golang.org/x/crypto v0.38.0 // indirect
 | 
			
		||||
	golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
 | 
			
		||||
	golang.org/x/net v0.40.0 // indirect
 | 
			
		||||
	golang.org/x/sys v0.33.0 // indirect
 | 
			
		||||
	golang.org/x/text v0.25.0 // indirect
 | 
			
		||||
	google.golang.org/protobuf v1.36.6 // indirect
 | 
			
		||||
	go.uber.org/mock v0.6.0 // indirect
 | 
			
		||||
	golang.org/x/arch v0.22.0 // indirect
 | 
			
		||||
	golang.org/x/crypto v0.42.0 // indirect
 | 
			
		||||
	golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
 | 
			
		||||
	golang.org/x/mod v0.29.0 // indirect
 | 
			
		||||
	golang.org/x/net v0.45.0 // indirect
 | 
			
		||||
	golang.org/x/sync v0.17.0 // indirect
 | 
			
		||||
	golang.org/x/sys v0.37.0 // indirect
 | 
			
		||||
	golang.org/x/text v0.30.0 // indirect
 | 
			
		||||
	golang.org/x/tools v0.37.0 // indirect
 | 
			
		||||
	google.golang.org/protobuf v1.36.10 // indirect
 | 
			
		||||
	gopkg.in/fsnotify.v1 v1.4.7 // indirect
 | 
			
		||||
	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
 | 
			
		||||
	gopkg.in/yaml.v3 v3.0.1 // indirect
 | 
			
		||||
	modernc.org/libc v1.65.2 // indirect
 | 
			
		||||
	modernc.org/libc v1.66.10 // indirect
 | 
			
		||||
	modernc.org/mathutil v1.7.1 // indirect
 | 
			
		||||
	modernc.org/memory v1.10.0 // indirect
 | 
			
		||||
	modernc.org/sqlite v1.37.0 // indirect
 | 
			
		||||
	modernc.org/memory v1.11.0 // indirect
 | 
			
		||||
	modernc.org/sqlite v1.39.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
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										158
									
								
								backend/go.sum
									
									
									
									
									
								
							
							
						
						@@ -1,19 +1,21 @@
 | 
			
		||||
gitea.seeseepuff.be/seeseemelk/mysqlite v0.11.1 h1:5s0r2IRpomGJC6pjirdMk7HAcAYEydLK5AhBZy+V1Ys=
 | 
			
		||||
gitea.seeseepuff.be/seeseemelk/mysqlite v0.11.1/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
 | 
			
		||||
gitea.seeseepuff.be/seeseemelk/mysqlite v0.15.0 h1:+k0iBYM/aZJxz7++EKi/G9e66E9u4bPS3DFLrBeDb9Y=
 | 
			
		||||
gitea.seeseepuff.be/seeseemelk/mysqlite v0.15.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
 | 
			
		||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
 | 
			
		||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
 | 
			
		||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
 | 
			
		||||
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
 | 
			
		||||
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
 | 
			
		||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
 | 
			
		||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
 | 
			
		||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
 | 
			
		||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
 | 
			
		||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
 | 
			
		||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
 | 
			
		||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
 | 
			
		||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
 | 
			
		||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
 | 
			
		||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
 | 
			
		||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
 | 
			
		||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
 | 
			
		||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
 | 
			
		||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
 | 
			
		||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
 | 
			
		||||
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
 | 
			
		||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
 | 
			
		||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
 | 
			
		||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
 | 
			
		||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
 | 
			
		||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
 | 
			
		||||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
			
		||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
			
		||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 | 
			
		||||
@@ -24,29 +26,33 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
 | 
			
		||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
 | 
			
		||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
 | 
			
		||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
 | 
			
		||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
 | 
			
		||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
 | 
			
		||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
 | 
			
		||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
 | 
			
		||||
github.com/gavv/httpexpect/v2 v2.17.0 h1:nIJqt5v5e4P7/0jODpX2gtSw+pHXUqdP28YcjqwDZmE=
 | 
			
		||||
github.com/gavv/httpexpect/v2 v2.17.0/go.mod h1:E8ENFlT9MZ3Si2sfM6c6ONdwXV2noBCGkhA+lkJgkP0=
 | 
			
		||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
 | 
			
		||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
 | 
			
		||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
 | 
			
		||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
 | 
			
		||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
 | 
			
		||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
 | 
			
		||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
 | 
			
		||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
 | 
			
		||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 | 
			
		||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 | 
			
		||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
 | 
			
		||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 | 
			
		||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 | 
			
		||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
 | 
			
		||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
 | 
			
		||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
 | 
			
		||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
 | 
			
		||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
 | 
			
		||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 | 
			
		||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 | 
			
		||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
 | 
			
		||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 | 
			
		||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
 | 
			
		||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
 | 
			
		||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 | 
			
		||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 | 
			
		||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 | 
			
		||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 | 
			
		||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 | 
			
		||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 | 
			
		||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 | 
			
		||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 | 
			
		||||
@@ -64,12 +70,11 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
 | 
			
		||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 | 
			
		||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
 | 
			
		||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
 | 
			
		||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 | 
			
		||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
 | 
			
		||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
 | 
			
		||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
 | 
			
		||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 | 
			
		||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
 | 
			
		||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
 | 
			
		||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 | 
			
		||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
 | 
			
		||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
 | 
			
		||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 | 
			
		||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 | 
			
		||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 | 
			
		||||
@@ -86,8 +91,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
 | 
			
		||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 | 
			
		||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 | 
			
		||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 | 
			
		||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
 | 
			
		||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 | 
			
		||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
 | 
			
		||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 | 
			
		||||
github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo=
 | 
			
		||||
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 | 
			
		||||
github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME=
 | 
			
		||||
@@ -98,34 +103,39 @@ github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnI
 | 
			
		||||
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 | 
			
		||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 | 
			
		||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 | 
			
		||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
 | 
			
		||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
 | 
			
		||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
 | 
			
		||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
 | 
			
		||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 | 
			
		||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 | 
			
		||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
 | 
			
		||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
 | 
			
		||||
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
 | 
			
		||||
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
 | 
			
		||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 | 
			
		||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
 | 
			
		||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
 | 
			
		||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
 | 
			
		||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
 | 
			
		||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 | 
			
		||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 | 
			
		||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 | 
			
		||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 | 
			
		||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 | 
			
		||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 | 
			
		||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 | 
			
		||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 | 
			
		||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 | 
			
		||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 | 
			
		||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 | 
			
		||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 | 
			
		||||
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
 | 
			
		||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
 | 
			
		||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
 | 
			
		||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
 | 
			
		||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
 | 
			
		||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
 | 
			
		||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
 | 
			
		||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 | 
			
		||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 | 
			
		||||
github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0=
 | 
			
		||||
github.com/valyala/fasthttp v1.62.0/go.mod h1:FCINgr4GKdKqV8Q0xv8b+UxPV+H/O5nNFo3D+r54Htg=
 | 
			
		||||
github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac=
 | 
			
		||||
github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM=
 | 
			
		||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
 | 
			
		||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
 | 
			
		||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
 | 
			
		||||
@@ -144,52 +154,55 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDf
 | 
			
		||||
github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI=
 | 
			
		||||
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
 | 
			
		||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 | 
			
		||||
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
 | 
			
		||||
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
 | 
			
		||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
 | 
			
		||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
 | 
			
		||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
 | 
			
		||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 | 
			
		||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
 | 
			
		||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
 | 
			
		||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
 | 
			
		||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
 | 
			
		||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
 | 
			
		||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
 | 
			
		||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
 | 
			
		||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
 | 
			
		||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 | 
			
		||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 | 
			
		||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
 | 
			
		||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
 | 
			
		||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
 | 
			
		||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
 | 
			
		||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 | 
			
		||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 | 
			
		||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 | 
			
		||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
 | 
			
		||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
 | 
			
		||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
 | 
			
		||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
 | 
			
		||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
 | 
			
		||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 | 
			
		||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
 | 
			
		||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 | 
			
		||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
			
		||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
 | 
			
		||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 | 
			
		||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
 | 
			
		||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 | 
			
		||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
			
		||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 | 
			
		||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
 | 
			
		||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
 | 
			
		||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
 | 
			
		||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
 | 
			
		||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 | 
			
		||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 | 
			
		||||
golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 | 
			
		||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
 | 
			
		||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
 | 
			
		||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
 | 
			
		||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
 | 
			
		||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
			
		||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
			
		||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
			
		||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
			
		||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
 | 
			
		||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 | 
			
		||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
 | 
			
		||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 | 
			
		||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 | 
			
		||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
 | 
			
		||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 | 
			
		||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 | 
			
		||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 | 
			
		||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
 | 
			
		||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 | 
			
		||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
 | 
			
		||||
@@ -200,32 +213,33 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
			
		||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
 | 
			
		||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
 | 
			
		||||
modernc.org/ccgo/v4 v4.27.1 h1:emhLB4uoOmkZUnTDFcMI3AbkmU/Evjuerit9Taqe6Ss=
 | 
			
		||||
modernc.org/ccgo/v4 v4.27.1/go.mod h1:543Q0qQhJWekKVS5P6yL5fO6liNhla9Lbm2/B3rEKDE=
 | 
			
		||||
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
 | 
			
		||||
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
 | 
			
		||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
 | 
			
		||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
 | 
			
		||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
 | 
			
		||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
 | 
			
		||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
 | 
			
		||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
 | 
			
		||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
 | 
			
		||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
 | 
			
		||||
modernc.org/libc v1.65.2 h1:drWL1QO9fKXr3kXDN8y+4lKyBr8bA3mtUBQpftq3IJw=
 | 
			
		||||
modernc.org/libc v1.65.2/go.mod h1:VI3V2S5mNka4deJErQ0jsMXe7jgxojE2fOB/mWoHlbc=
 | 
			
		||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
 | 
			
		||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
 | 
			
		||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
 | 
			
		||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
 | 
			
		||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
 | 
			
		||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
 | 
			
		||||
modernc.org/memory v1.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/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
 | 
			
		||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
 | 
			
		||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
 | 
			
		||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
 | 
			
		||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
 | 
			
		||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
 | 
			
		||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
 | 
			
		||||
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
 | 
			
		||||
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
 | 
			
		||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
 | 
			
		||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
 | 
			
		||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
 | 
			
		||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
 | 
			
		||||
moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs=
 | 
			
		||||
moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE=
 | 
			
		||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
 | 
			
		||||
zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU=
 | 
			
		||||
zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik=
 | 
			
		||||
zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo=
 | 
			
		||||
zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc=
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										467
									
								
								backend/main.go
									
									
									
									
									
								
							
							
						
						@@ -4,13 +4,15 @@ import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"embed"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"gitea.seeseepuff.be/seeseemelk/mysqlite"
 | 
			
		||||
	"log"
 | 
			
		||||
	"net"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strconv"
 | 
			
		||||
 | 
			
		||||
	"gitea.seeseepuff.be/seeseemelk/mysqlite"
 | 
			
		||||
 | 
			
		||||
	"github.com/gin-contrib/cors"
 | 
			
		||||
	"github.com/gin-gonic/gin"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@@ -23,6 +25,8 @@ const (
 | 
			
		||||
	ErrInvalidUserID       = "Invalid user ID"
 | 
			
		||||
	ErrUserNotFound        = "User not found"
 | 
			
		||||
	ErrCheckingUserExist   = "Error checking user existence: %v"
 | 
			
		||||
	ErrInsufficientFunds   = "Insufficient funds in source allowance"
 | 
			
		||||
	ErrDifferentUsers      = "Allowances do not belong to the same user"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// ServerConfig holds configuration for the server.
 | 
			
		||||
@@ -42,6 +46,11 @@ type ServerConfig struct {
 | 
			
		||||
	Started chan bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const DefaultDomain = "localhost:8080"
 | 
			
		||||
 | 
			
		||||
// The domain that the server is reachable at.
 | 
			
		||||
var domain = DefaultDomain
 | 
			
		||||
 | 
			
		||||
func getUsers(c *gin.Context) {
 | 
			
		||||
	users, err := db.GetUsers()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -62,20 +71,20 @@ func getUser(c *gin.Context) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	user, err := db.GetUser(userId)
 | 
			
		||||
	if errors.Is(err, mysqlite.ErrNoRows) {
 | 
			
		||||
		c.JSON(http.StatusNotFound, gin.H{"error": ErrUserNotFound})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Error getting user: %v", err)
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if user == nil {
 | 
			
		||||
		c.JSON(http.StatusNotFound, gin.H{"error": ErrUserNotFound})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.IndentedJSON(http.StatusOK, user)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getUserGoals(c *gin.Context) {
 | 
			
		||||
func getUserAllowance(c *gin.Context) {
 | 
			
		||||
	userIdStr := c.Param("userId")
 | 
			
		||||
	userId, err := strconv.Atoi(userIdStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -96,16 +105,59 @@ func getUserGoals(c *gin.Context) {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	goals, err := db.GetUserGoals(userId)
 | 
			
		||||
	allowances, err := db.GetUserAllowances(userId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Error getting user goals: %v", err)
 | 
			
		||||
		log.Printf("Error getting user allowance: %v", err)
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	c.IndentedJSON(http.StatusOK, goals)
 | 
			
		||||
	c.IndentedJSON(http.StatusOK, allowances)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func createUserGoal(c *gin.Context) {
 | 
			
		||||
func getUserAllowanceById(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
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	allowance, err := db.GetUserAllowanceById(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 getting allowance: %v", err)
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.IndentedJSON(http.StatusOK, allowance)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func createUserAllowance(c *gin.Context) {
 | 
			
		||||
	userIdStr := c.Param("userId")
 | 
			
		||||
	userId, err := strconv.Atoi(userIdStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -115,7 +167,7 @@ func createUserGoal(c *gin.Context) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse request body
 | 
			
		||||
	var goalRequest CreateGoalRequest
 | 
			
		||||
	var goalRequest CreateAllowanceRequest
 | 
			
		||||
	if err := c.ShouldBindJSON(&goalRequest); err != nil {
 | 
			
		||||
		log.Printf("Error parsing request body: %v", err)
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
 | 
			
		||||
@@ -124,12 +176,12 @@ func createUserGoal(c *gin.Context) {
 | 
			
		||||
 | 
			
		||||
	// Validate request
 | 
			
		||||
	if goalRequest.Name == "" {
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Goal name cannot be empty"})
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Allowance name cannot be empty"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create goal in database
 | 
			
		||||
	goalId, err := db.CreateGoal(userId, &goalRequest)
 | 
			
		||||
	goalId, err := db.CreateAllowance(userId, &goalRequest)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Error creating goal: %v", err)
 | 
			
		||||
		if err.Error() == "user does not exist" {
 | 
			
		||||
@@ -145,9 +197,8 @@ func createUserGoal(c *gin.Context) {
 | 
			
		||||
	c.IndentedJSON(http.StatusCreated, response)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func deleteUserGoal(c *gin.Context) {
 | 
			
		||||
func bulkPutUserAllowance(c *gin.Context) {
 | 
			
		||||
	userIdStr := c.Param("userId")
 | 
			
		||||
	goalIdStr := c.Param("goalId")
 | 
			
		||||
 | 
			
		||||
	userId, err := strconv.Atoi(userIdStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -156,13 +207,6 @@ func deleteUserGoal(c *gin.Context) {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	goalId, err := strconv.Atoi(goalIdStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Invalid goal ID: %v", err)
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	exists, err := db.UserExists(userId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf(ErrCheckingUserExist, err)
 | 
			
		||||
@@ -174,18 +218,212 @@ func deleteUserGoal(c *gin.Context) {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = db.DeleteGoal(userId, goalId)
 | 
			
		||||
	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 {
 | 
			
		||||
		if err.Error() == "goal not found" {
 | 
			
		||||
			c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"})
 | 
			
		||||
		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) {
 | 
			
		||||
	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
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if allowanceId == 0 {
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Allowance id zero cannot be deleted"})
 | 
			
		||||
		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.DeleteAllowance(userId, allowanceId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if err.Error() == "allowance not found" {
 | 
			
		||||
			c.JSON(http.StatusNotFound, gin.H{"error": "History not found"})
 | 
			
		||||
		} else {
 | 
			
		||||
			log.Printf("Error deleting goal: %v", err)
 | 
			
		||||
			log.Printf("Error deleting allowance: %v", err)
 | 
			
		||||
			c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		}
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.JSON(http.StatusOK, gin.H{"message": "Goal deleted successfully"})
 | 
			
		||||
	c.IndentedJSON(http.StatusOK, gin.H{"message": "History deleted successfully"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func putUserAllowance(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 UpdateAllowanceRequest
 | 
			
		||||
	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
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if allowanceId == 0 {
 | 
			
		||||
		err = db.UpdateUserAllowance(userId, &allowanceRequest)
 | 
			
		||||
	} else {
 | 
			
		||||
		err = db.UpdateAllowance(userId, allowanceId, &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 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) {
 | 
			
		||||
@@ -201,6 +439,11 @@ func createTask(c *gin.Context) {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if taskRequest.Schedule != nil {
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Schedules are not yet supported"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// If assigned is not nil, check if user exists
 | 
			
		||||
	if taskRequest.Assigned != nil {
 | 
			
		||||
		exists, err := db.UserExists(*taskRequest.Assigned)
 | 
			
		||||
@@ -233,7 +476,7 @@ func getTasks(c *gin.Context) {
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	c.JSON(http.StatusOK, &response)
 | 
			
		||||
	c.IndentedJSON(http.StatusOK, &response)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getTask(c *gin.Context) {
 | 
			
		||||
@@ -278,6 +521,11 @@ func putTask(c *gin.Context) {
 | 
			
		||||
		c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Error getting task: %v", err)
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = db.UpdateTask(taskId, &taskRequest)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -289,7 +537,61 @@ func putTask(c *gin.Context) {
 | 
			
		||||
	c.JSON(http.StatusOK, gin.H{"message": "Task updated successfully"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func postAllowance(c *gin.Context) {
 | 
			
		||||
func deleteTask(c *gin.Context) {
 | 
			
		||||
	taskIdStr := c.Param("taskId")
 | 
			
		||||
	taskId, err := strconv.Atoi(taskIdStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Invalid task ID: %v", err)
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	hasTask, err := db.HasTask(taskId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Error checking task existence: %v", err)
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !hasTask {
 | 
			
		||||
		c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = db.DeleteTask(taskId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Error deleting task: %v", err)
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.JSON(http.StatusOK, gin.H{"message": "Task deleted successfully"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func completeTask(c *gin.Context) {
 | 
			
		||||
	taskIdStr := c.Param("taskId")
 | 
			
		||||
	taskId, err := strconv.Atoi(taskIdStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Invalid task ID: %v", err)
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = db.CompleteTask(taskId)
 | 
			
		||||
	if errors.Is(err, mysqlite.ErrNoRows) {
 | 
			
		||||
		c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Error completing task: %v", err)
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.JSON(http.StatusOK, gin.H{"message": "Task completed successfully"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func postHistory(c *gin.Context) {
 | 
			
		||||
	userIdStr := c.Param("userId")
 | 
			
		||||
	userId, err := strconv.Atoi(userIdStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -298,24 +600,84 @@ func postAllowance(c *gin.Context) {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var allowanceRequest PostAllowance
 | 
			
		||||
	if err := c.ShouldBindJSON(&allowanceRequest); err != nil {
 | 
			
		||||
	var historyRequest PostHistory
 | 
			
		||||
	if err := c.ShouldBindJSON(&historyRequest); err != nil {
 | 
			
		||||
		log.Printf("Error parsing request body: %v", err)
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = db.AddAllowance(userId, &allowanceRequest)
 | 
			
		||||
	if historyRequest.Description == "" {
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Description cannot be empty"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	exists, err := db.UserExists(userId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Error updating allowance: %v", err)
 | 
			
		||||
		log.Printf(ErrCheckingUserExist, err)
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	c.JSON(http.StatusOK, gin.H{"message": "Allowance updated successfully"})
 | 
			
		||||
	if !exists {
 | 
			
		||||
		c.JSON(http.StatusNotFound, gin.H{"error": ErrUserNotFound})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = db.AddHistory(userId, &historyRequest)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Error updating history: %v", err)
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	c.JSON(http.StatusOK, gin.H{"message": "History updated successfully"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getHistory(c *gin.Context) {
 | 
			
		||||
	userIdStr := c.Param("userId")
 | 
			
		||||
	userId, err := strconv.Atoi(userIdStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Invalid user ID: %v", err)
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	history, err := db.GetHistory(userId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Error getting history: %v", err)
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.IndentedJSON(http.StatusOK, history)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func transfer(c *gin.Context) {
 | 
			
		||||
	var transferRequest TransferRequest
 | 
			
		||||
	if err := c.ShouldBindJSON(&transferRequest); err != nil {
 | 
			
		||||
		log.Printf("Error parsing request body: %v", err)
 | 
			
		||||
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err := db.TransferAllowance(transferRequest.From, transferRequest.To, transferRequest.Amount)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if errors.Is(err, mysqlite.ErrNoRows) {
 | 
			
		||||
			c.JSON(http.StatusNotFound, gin.H{"error": "Allowance not found"})
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		if err.Error() == ErrInsufficientFunds || err.Error() == ErrDifferentUsers {
 | 
			
		||||
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		log.Printf("Error transferring allowance: %v", err)
 | 
			
		||||
		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.JSON(http.StatusOK, gin.H{"message": "Transfer successful"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
*
 | 
			
		||||
Initialises the database, and then starts the server.
 | 
			
		||||
If the context gets cancelled, the server is shutdown and the database is closed.
 | 
			
		||||
*/
 | 
			
		||||
@@ -324,16 +686,33 @@ func start(ctx context.Context, config *ServerConfig) {
 | 
			
		||||
	defer db.db.MustClose()
 | 
			
		||||
 | 
			
		||||
	router := gin.Default()
 | 
			
		||||
 | 
			
		||||
	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/user/:userId", getUser)
 | 
			
		||||
	router.GET("/api/user/:userId/goals", getUserGoals)
 | 
			
		||||
	router.POST("/api/user/:userId/goals", createUserGoal)
 | 
			
		||||
	router.DELETE("/api/user/:userId/goal/:goalId", deleteUserGoal)
 | 
			
		||||
	router.POST("/api/user/:userId/history", postHistory)
 | 
			
		||||
	router.GET("/api/user/:userId/history", getHistory)
 | 
			
		||||
	router.GET("/api/user/:userId/allowance", getUserAllowance)
 | 
			
		||||
	router.POST("/api/user/:userId/allowance", createUserAllowance)
 | 
			
		||||
	router.PUT("/api/user/:userId/allowance", bulkPutUserAllowance)
 | 
			
		||||
	router.GET("/api/user/:userId/allowance/:allowanceId", getUserAllowanceById)
 | 
			
		||||
	router.DELETE("/api/user/:userId/allowance/:allowanceId", deleteUserAllowance)
 | 
			
		||||
	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.GET("/api/tasks", getTasks)
 | 
			
		||||
	router.GET("/api/task/:taskId", getTask)
 | 
			
		||||
	router.PUT("/api/task/:taskId", putTask)
 | 
			
		||||
	router.POST("/api/user/:userId/allowance", postAllowance)
 | 
			
		||||
	router.DELETE("/api/task/:taskId", deleteTask)
 | 
			
		||||
	router.POST("/api/task/:taskId/complete", completeTask)
 | 
			
		||||
	router.POST("/api/transfer", transfer)
 | 
			
		||||
 | 
			
		||||
	srv := &http.Server{
 | 
			
		||||
		Addr:    config.Addr,
 | 
			
		||||
@@ -366,6 +745,16 @@ func start(ctx context.Context, config *ServerConfig) {
 | 
			
		||||
func main() {
 | 
			
		||||
	config := ServerConfig{
 | 
			
		||||
		Datasource: os.Getenv("DB_PATH"),
 | 
			
		||||
		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)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,24 +1,26 @@
 | 
			
		||||
create table users
 | 
			
		||||
(
 | 
			
		||||
    id   integer primary key,
 | 
			
		||||
    name text not null
 | 
			
		||||
    name text not null,
 | 
			
		||||
    weight real not null default 10.0,
 | 
			
		||||
    balance integer not null default 0
 | 
			
		||||
) strict;
 | 
			
		||||
 | 
			
		||||
create table history
 | 
			
		||||
(
 | 
			
		||||
    id integer primary key,
 | 
			
		||||
    user_id integer not null,
 | 
			
		||||
    date date not null,
 | 
			
		||||
    timestamp date not null,
 | 
			
		||||
    amount integer not null
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
create table goals
 | 
			
		||||
create table allowances
 | 
			
		||||
(
 | 
			
		||||
    id integer primary key,
 | 
			
		||||
	user_id integer not null,
 | 
			
		||||
    name text not null,
 | 
			
		||||
    target integer not null,
 | 
			
		||||
    progress integer not null,
 | 
			
		||||
    balance integer not null default 0,
 | 
			
		||||
    weight real not null
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								backend/migrations/2_add_colour.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,2 @@
 | 
			
		||||
alter table allowances
 | 
			
		||||
add column colour integer;
 | 
			
		||||
							
								
								
									
										1
									
								
								backend/migrations/3_change_weight_default.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1 @@
 | 
			
		||||
update users set weight = 10.0 where weight = 0.0;
 | 
			
		||||
							
								
								
									
										2
									
								
								backend/migrations/4_add_history_description.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,2 @@
 | 
			
		||||
alter table history
 | 
			
		||||
add column description text;
 | 
			
		||||
							
								
								
									
										3
									
								
								backend/migrations/5_add_schedules.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,3 @@
 | 
			
		||||
alter table tasks add column schedule text;
 | 
			
		||||
alter table tasks add column completed date;
 | 
			
		||||
alter table tasks add column next_run date;
 | 
			
		||||
							
								
								
									
										249
									
								
								backend/web.go
									
									
									
									
									
										Normal 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 ¤tUser
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
									
								
							
							
						
						@@ -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>
 | 
			
		||||
							
								
								
									
										120
									
								
								common/api.yaml
									
									
									
									
									
								
							
							
						
						@@ -59,7 +59,33 @@ paths:
 | 
			
		||||
        404:
 | 
			
		||||
          description: The users could not be found.
 | 
			
		||||
 | 
			
		||||
  /user/{userId}/allowance:
 | 
			
		||||
  /user/{userId}/history:
 | 
			
		||||
    get:
 | 
			
		||||
      summary: Gets the allowance history of a user
 | 
			
		||||
      parameters:
 | 
			
		||||
        - in: path
 | 
			
		||||
          name: userId
 | 
			
		||||
          description: The user ID
 | 
			
		||||
          required: true
 | 
			
		||||
          schema:
 | 
			
		||||
            type: integer
 | 
			
		||||
      responses:
 | 
			
		||||
        200:
 | 
			
		||||
          description: Information about the allowance history of the user
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                type: array
 | 
			
		||||
                items:
 | 
			
		||||
                  type: object
 | 
			
		||||
                  properties:
 | 
			
		||||
                    date:
 | 
			
		||||
                      type: string
 | 
			
		||||
                      format: date-time
 | 
			
		||||
                      description: The date of the allowance or expense.
 | 
			
		||||
                    amount:
 | 
			
		||||
                      type: integer
 | 
			
		||||
                      description: The amount of the allowance to be added, in cents. A negative value
 | 
			
		||||
    post:
 | 
			
		||||
      summary: Updates the allowance of a user
 | 
			
		||||
      parameters:
 | 
			
		||||
@@ -88,35 +114,7 @@ paths:
 | 
			
		||||
        400:
 | 
			
		||||
          description: The allowance could not be updated.
 | 
			
		||||
 | 
			
		||||
  /user/{userId}/history:
 | 
			
		||||
    get:
 | 
			
		||||
      summary: Gets the allowance history of a user
 | 
			
		||||
      parameters:
 | 
			
		||||
        - in: path
 | 
			
		||||
          name: userId
 | 
			
		||||
          description: The user ID
 | 
			
		||||
          required: true
 | 
			
		||||
          schema:
 | 
			
		||||
            type: integer
 | 
			
		||||
      responses:
 | 
			
		||||
        200:
 | 
			
		||||
          description: Information about the allowance history of the user
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                type: array
 | 
			
		||||
                items:
 | 
			
		||||
                  type: object
 | 
			
		||||
                  properties:
 | 
			
		||||
                    date:
 | 
			
		||||
                      type: string
 | 
			
		||||
                      format: date-time
 | 
			
		||||
                      description: The date of the allowance or expense.
 | 
			
		||||
                    amount:
 | 
			
		||||
                      type: integer
 | 
			
		||||
                      description: The amount of the allowance to be added, in cents. A negative value
 | 
			
		||||
 | 
			
		||||
  /user/{userId}/goals:
 | 
			
		||||
  /user/{userId}/allowance:
 | 
			
		||||
    get:
 | 
			
		||||
      summary: Gets all goals
 | 
			
		||||
      parameters:
 | 
			
		||||
@@ -203,7 +201,7 @@ paths:
 | 
			
		||||
        404:
 | 
			
		||||
          description: The goals could not be found.
 | 
			
		||||
 | 
			
		||||
  /user/{userId}/goal/{goalId}:
 | 
			
		||||
  /user/{userId}/allowance/{goalId}:
 | 
			
		||||
    get:
 | 
			
		||||
      summary: Gets information about a goal
 | 
			
		||||
      parameters:
 | 
			
		||||
@@ -286,7 +284,7 @@ paths:
 | 
			
		||||
        404:
 | 
			
		||||
          description: The goal could not be found.
 | 
			
		||||
 | 
			
		||||
  /user/{userId}/goal/{goalId}/complete:
 | 
			
		||||
  /user/{userId}/allowance/{goalId}/complete:
 | 
			
		||||
    post:
 | 
			
		||||
      summary: Completes a goal.
 | 
			
		||||
      description: Completes a goal. This will subtract this goal's value from the user's allowance and then remove the goal.
 | 
			
		||||
@@ -411,6 +409,59 @@ paths:
 | 
			
		||||
        404:
 | 
			
		||||
          description: The task could not be found.
 | 
			
		||||
 | 
			
		||||
  /api/transfer:
 | 
			
		||||
    post:
 | 
			
		||||
      summary: Transfer amount between allowances
 | 
			
		||||
      requestBody:
 | 
			
		||||
        required: true
 | 
			
		||||
        content:
 | 
			
		||||
          application/json:
 | 
			
		||||
            schema:
 | 
			
		||||
              type: object
 | 
			
		||||
              properties:
 | 
			
		||||
                from:
 | 
			
		||||
                  type: integer
 | 
			
		||||
                  description: Source allowance ID
 | 
			
		||||
                to:
 | 
			
		||||
                  type: integer
 | 
			
		||||
                  description: Destination allowance ID
 | 
			
		||||
                amount:
 | 
			
		||||
                  type: number
 | 
			
		||||
                  format: float
 | 
			
		||||
                  description: Amount to transfer
 | 
			
		||||
              required:
 | 
			
		||||
                - from
 | 
			
		||||
                - to
 | 
			
		||||
                - amount
 | 
			
		||||
      responses:
 | 
			
		||||
        '200':
 | 
			
		||||
          description: Transfer successful
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                type: object
 | 
			
		||||
                properties:
 | 
			
		||||
                  message:
 | 
			
		||||
                    type: string
 | 
			
		||||
        '400':
 | 
			
		||||
          description: Invalid request
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                type: object
 | 
			
		||||
                properties:
 | 
			
		||||
                  error:
 | 
			
		||||
                    type: string
 | 
			
		||||
        '404':
 | 
			
		||||
          description: Allowance not found
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                type: object
 | 
			
		||||
                properties:
 | 
			
		||||
                  error:
 | 
			
		||||
                    type: string
 | 
			
		||||
 | 
			
		||||
components:
 | 
			
		||||
  schemas:
 | 
			
		||||
    task:
 | 
			
		||||
@@ -424,7 +475,10 @@ components:
 | 
			
		||||
          description: The task name
 | 
			
		||||
        reward:
 | 
			
		||||
          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:
 | 
			
		||||
          type: integer
 | 
			
		||||
          description: The user ID of the user assigned to the task
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										16
									
								
								frontend/allowance-planner-v2/.browserslistrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,16 @@
 | 
			
		||||
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
 | 
			
		||||
# For additional information regarding the format and rule options, please see:
 | 
			
		||||
# https://github.com/browserslist/browserslist#queries
 | 
			
		||||
 | 
			
		||||
# For the full list of supported browsers by the Angular framework, please see:
 | 
			
		||||
# https://angular.io/guide/browser-support
 | 
			
		||||
 | 
			
		||||
# You can see what browsers were selected by your queries by running:
 | 
			
		||||
#   npx browserslist
 | 
			
		||||
 | 
			
		||||
Chrome >=79
 | 
			
		||||
ChromeAndroid >=79
 | 
			
		||||
Firefox >=70
 | 
			
		||||
Edge >=79
 | 
			
		||||
Safari >=14
 | 
			
		||||
iOS >=14
 | 
			
		||||
							
								
								
									
										16
									
								
								frontend/allowance-planner-v2/.editorconfig
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,16 @@
 | 
			
		||||
# Editor configuration, see https://editorconfig.org
 | 
			
		||||
root = true
 | 
			
		||||
 | 
			
		||||
[*]
 | 
			
		||||
charset = utf-8
 | 
			
		||||
indent_style = space
 | 
			
		||||
indent_size = 2
 | 
			
		||||
insert_final_newline = true
 | 
			
		||||
trim_trailing_whitespace = true
 | 
			
		||||
 | 
			
		||||
[*.ts]
 | 
			
		||||
quote_type = single
 | 
			
		||||
 | 
			
		||||
[*.md]
 | 
			
		||||
max_line_length = off
 | 
			
		||||
trim_trailing_whitespace = false
 | 
			
		||||
							
								
								
									
										47
									
								
								frontend/allowance-planner-v2/.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,47 @@
 | 
			
		||||
{
 | 
			
		||||
  "root": true,
 | 
			
		||||
  "ignorePatterns": ["projects/**/*"],
 | 
			
		||||
  "overrides": [
 | 
			
		||||
    {
 | 
			
		||||
      "files": ["*.ts"],
 | 
			
		||||
      "parserOptions": {
 | 
			
		||||
        "project": ["tsconfig.json"],
 | 
			
		||||
        "createDefaultProgram": true
 | 
			
		||||
      },
 | 
			
		||||
      "extends": [
 | 
			
		||||
        "plugin:@angular-eslint/recommended",
 | 
			
		||||
        "plugin:@angular-eslint/template/process-inline-templates"
 | 
			
		||||
      ],
 | 
			
		||||
      "rules": {
 | 
			
		||||
        "@angular-eslint/prefer-standalone": "off",
 | 
			
		||||
        "@angular-eslint/component-class-suffix": [
 | 
			
		||||
          "error",
 | 
			
		||||
          {
 | 
			
		||||
            "suffixes": ["Page", "Component"]
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "@angular-eslint/component-selector": [
 | 
			
		||||
          "error",
 | 
			
		||||
          {
 | 
			
		||||
            "type": "element",
 | 
			
		||||
            "prefix": "app",
 | 
			
		||||
            "style": "kebab-case"
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "@angular-eslint/directive-selector": [
 | 
			
		||||
          "error",
 | 
			
		||||
          {
 | 
			
		||||
            "type": "attribute",
 | 
			
		||||
            "prefix": "app",
 | 
			
		||||
            "style": "camelCase"
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "files": ["*.html"],
 | 
			
		||||
      "extends": ["plugin:@angular-eslint/template/recommended"],
 | 
			
		||||
      "rules": {}
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										70
									
								
								frontend/allowance-planner-v2/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,70 @@
 | 
			
		||||
# Specifies intentionally untracked files to ignore when using Git
 | 
			
		||||
# http://git-scm.com/docs/gitignore
 | 
			
		||||
 | 
			
		||||
*~
 | 
			
		||||
*.sw[mnpcod]
 | 
			
		||||
.tmp
 | 
			
		||||
*.tmp
 | 
			
		||||
*.tmp.*
 | 
			
		||||
UserInterfaceState.xcuserstate
 | 
			
		||||
$RECYCLE.BIN/
 | 
			
		||||
 | 
			
		||||
*.log
 | 
			
		||||
log.txt
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/.sourcemaps
 | 
			
		||||
/.versions
 | 
			
		||||
/coverage
 | 
			
		||||
 | 
			
		||||
# Ionic
 | 
			
		||||
/.ionic
 | 
			
		||||
/www
 | 
			
		||||
/platforms
 | 
			
		||||
/plugins
 | 
			
		||||
 | 
			
		||||
# Compiled output
 | 
			
		||||
/dist
 | 
			
		||||
/tmp
 | 
			
		||||
/out-tsc
 | 
			
		||||
/bazel-out
 | 
			
		||||
 | 
			
		||||
# Node
 | 
			
		||||
/node_modules
 | 
			
		||||
npm-debug.log
 | 
			
		||||
yarn-error.log
 | 
			
		||||
 | 
			
		||||
# IDEs and editors
 | 
			
		||||
.idea/
 | 
			
		||||
.project
 | 
			
		||||
.classpath
 | 
			
		||||
.c9/
 | 
			
		||||
*.launch
 | 
			
		||||
.settings/
 | 
			
		||||
*.sublime-project
 | 
			
		||||
*.sublime-workspace
 | 
			
		||||
 | 
			
		||||
# Visual Studio Code
 | 
			
		||||
.vscode/*
 | 
			
		||||
!.vscode/settings.json
 | 
			
		||||
!.vscode/tasks.json
 | 
			
		||||
!.vscode/launch.json
 | 
			
		||||
!.vscode/extensions.json
 | 
			
		||||
.history/*
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Miscellaneous
 | 
			
		||||
/.angular
 | 
			
		||||
/.angular/cache
 | 
			
		||||
.sass-cache/
 | 
			
		||||
/.nx
 | 
			
		||||
/.nx/cache
 | 
			
		||||
/connect.lock
 | 
			
		||||
/coverage
 | 
			
		||||
/libpeerconnection.log
 | 
			
		||||
testem.log
 | 
			
		||||
/typings
 | 
			
		||||
 | 
			
		||||
# System files
 | 
			
		||||
.DS_Store
 | 
			
		||||
Thumbs.db
 | 
			
		||||
							
								
								
									
										5
									
								
								frontend/allowance-planner-v2/.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
    "recommendations": [
 | 
			
		||||
      "ionic.ionic"
 | 
			
		||||
    ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										3
									
								
								frontend/allowance-planner-v2/.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,3 @@
 | 
			
		||||
{
 | 
			
		||||
  "typescript.preferences.autoImportFileExcludePatterns": ["@ionic/angular/common", "@ionic/angular/standalone"]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										101
									
								
								frontend/allowance-planner-v2/android/.gitignore
									
									
									
									
										vendored
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										2
									
								
								frontend/allowance-planner-v2/android/app/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,2 @@
 | 
			
		||||
/build/*
 | 
			
		||||
!/build/.npmkeep
 | 
			
		||||
							
								
								
									
										54
									
								
								frontend/allowance-planner-v2/android/app/build.gradle
									
									
									
									
									
										Normal 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")
 | 
			
		||||
}
 | 
			
		||||
@@ -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()
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								frontend/allowance-planner-v2/android/app/proguard-rules.pro
									
									
									
									
										vendored
									
									
										Normal 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
 | 
			
		||||
@@ -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());
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -0,0 +1,5 @@
 | 
			
		||||
package io.ionic.starter;
 | 
			
		||||
 | 
			
		||||
import com.getcapacitor.BridgeActivity;
 | 
			
		||||
 | 
			
		||||
public class MainActivity extends BridgeActivity {}
 | 
			
		||||
| 
		 After Width: | Height: | Size: 33 KiB  | 
| 
		 After Width: | Height: | Size: 9.1 KiB  | 
| 
		 After Width: | Height: | Size: 16 KiB  | 
| 
		 After Width: | Height: | Size: 64 KiB  | 
| 
		 After Width: | Height: | Size: 91 KiB  | 
| 
		 After Width: | Height: | Size: 122 KiB  | 
| 
		 After Width: | Height: | Size: 32 KiB  | 
| 
		 After Width: | Height: | Size: 9.1 KiB  | 
| 
		 After Width: | Height: | Size: 16 KiB  | 
| 
		 After Width: | Height: | Size: 65 KiB  | 
| 
		 After Width: | Height: | Size: 90 KiB  | 
| 
		 After Width: | Height: | Size: 119 KiB  | 
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 16 KiB  | 
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 2.7 KiB  | 
| 
		 After Width: | Height: | Size: 660 B  | 
| 
		 After Width: | Height: | Size: 5.1 KiB  | 
| 
		 After Width: | Height: | Size: 4.2 KiB  | 
| 
		 After Width: | Height: | Size: 296 B  | 
| 
		 After Width: | Height: | Size: 2.1 KiB  | 
| 
		 After Width: | Height: | Size: 1.8 KiB  | 
| 
		 After Width: | Height: | Size: 408 B  | 
| 
		 After Width: | Height: | Size: 3.0 KiB  | 
| 
		 After Width: | Height: | Size: 2.7 KiB  | 
| 
		 After Width: | Height: | Size: 3.9 KiB  | 
| 
		 After Width: | Height: | Size: 1006 B  | 
| 
		 After Width: | Height: | Size: 7.5 KiB  | 
| 
		 After Width: | Height: | Size: 6.4 KiB  | 
| 
		 After Width: | Height: | Size: 6.5 KiB  | 
| 
		 After Width: | Height: | Size: 1.8 KiB  | 
| 
		 After Width: | Height: | Size: 13 KiB  | 
| 
		 After Width: | Height: | Size: 10 KiB  | 
| 
		 After Width: | Height: | Size: 9.2 KiB  | 
| 
		 After Width: | Height: | Size: 2.5 KiB  | 
| 
		 After Width: | Height: | Size: 18 KiB  | 
| 
		 After Width: | Height: | Size: 16 KiB  | 
@@ -0,0 +1,4 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<resources>
 | 
			
		||||
    <color name="ic_launcher_background">#FFFFFF</color>
 | 
			
		||||
</resources>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										29
									
								
								frontend/allowance-planner-v2/android/build.gradle
									
									
									
									
									
										Normal 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
 | 
			
		||||
}
 | 
			
		||||
@@ -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')
 | 
			
		||||
							
								
								
									
										22
									
								
								frontend/allowance-planner-v2/android/gradle.properties
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/android/gradle/wrapper/gradle-wrapper.jar
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										7
									
								
								frontend/allowance-planner-v2/android/gradle/wrapper/gradle-wrapper.properties
									
									
									
									
										vendored
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										252
									
								
								frontend/allowance-planner-v2/android/gradlew
									
									
									
									
										vendored
									
									
										Normal 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" "$@"
 | 
			
		||||
							
								
								
									
										94
									
								
								frontend/allowance-planner-v2/android/gradlew.bat
									
									
									
									
										vendored
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										5
									
								
								frontend/allowance-planner-v2/android/settings.gradle
									
									
									
									
									
										Normal 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'
 | 
			
		||||
							
								
								
									
										16
									
								
								frontend/allowance-planner-v2/android/variables.gradle
									
									
									
									
									
										Normal 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'
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										158
									
								
								frontend/allowance-planner-v2/angular.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,158 @@
 | 
			
		||||
{
 | 
			
		||||
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
 | 
			
		||||
  "version": 1,
 | 
			
		||||
  "newProjectRoot": "projects",
 | 
			
		||||
  "projects": {
 | 
			
		||||
    "app": {
 | 
			
		||||
      "projectType": "application",
 | 
			
		||||
      "schematics": {},
 | 
			
		||||
      "root": "",
 | 
			
		||||
      "sourceRoot": "src",
 | 
			
		||||
      "prefix": "app",
 | 
			
		||||
      "architect": {
 | 
			
		||||
        "build": {
 | 
			
		||||
          "builder": "@angular-devkit/build-angular:browser",
 | 
			
		||||
          "options": {
 | 
			
		||||
            "outputPath": "www",
 | 
			
		||||
            "index": "src/index.html",
 | 
			
		||||
            "main": "src/main.ts",
 | 
			
		||||
            "polyfills": "src/polyfills.ts",
 | 
			
		||||
            "tsConfig": "tsconfig.app.json",
 | 
			
		||||
            "inlineStyleLanguage": "scss",
 | 
			
		||||
            "assets": [
 | 
			
		||||
              {
 | 
			
		||||
                "glob": "**/*",
 | 
			
		||||
                "input": "src/assets",
 | 
			
		||||
                "output": "assets"
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                "glob": "**/*.svg",
 | 
			
		||||
                "input": "node_modules/ionicons/dist/ionicons/svg",
 | 
			
		||||
                "output": "./svg"
 | 
			
		||||
              }
 | 
			
		||||
            ],
 | 
			
		||||
            "styles": [
 | 
			
		||||
              "@angular/material/prebuilt-themes/azure-blue.css",
 | 
			
		||||
              "src/global.scss",
 | 
			
		||||
              "src/theme/variables.scss"
 | 
			
		||||
            ],
 | 
			
		||||
            "scripts": []
 | 
			
		||||
          },
 | 
			
		||||
          "configurations": {
 | 
			
		||||
            "production": {
 | 
			
		||||
              "budgets": [
 | 
			
		||||
                {
 | 
			
		||||
                  "type": "initial",
 | 
			
		||||
                  "maximumWarning": "2mb",
 | 
			
		||||
                  "maximumError": "5mb"
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                  "type": "anyComponentStyle",
 | 
			
		||||
                  "maximumWarning": "2kb",
 | 
			
		||||
                  "maximumError": "4kb"
 | 
			
		||||
                }
 | 
			
		||||
              ],
 | 
			
		||||
              "fileReplacements": [
 | 
			
		||||
                {
 | 
			
		||||
                  "replace": "src/environments/environment.ts",
 | 
			
		||||
                  "with": "src/environments/environment.prod.ts"
 | 
			
		||||
                }
 | 
			
		||||
              ],
 | 
			
		||||
              "outputHashing": "all"
 | 
			
		||||
            },
 | 
			
		||||
            "development": {
 | 
			
		||||
              "buildOptimizer": false,
 | 
			
		||||
              "optimization": false,
 | 
			
		||||
              "vendorChunk": true,
 | 
			
		||||
              "extractLicenses": false,
 | 
			
		||||
              "sourceMap": true,
 | 
			
		||||
              "namedChunks": true
 | 
			
		||||
            },
 | 
			
		||||
            "ci": {
 | 
			
		||||
              "progress": false
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          "defaultConfiguration": "production"
 | 
			
		||||
        },
 | 
			
		||||
        "serve": {
 | 
			
		||||
          "builder": "@angular-devkit/build-angular:dev-server",
 | 
			
		||||
          "configurations": {
 | 
			
		||||
            "production": {
 | 
			
		||||
              "buildTarget": "app:build:production"
 | 
			
		||||
            },
 | 
			
		||||
            "development": {
 | 
			
		||||
              "buildTarget": "app:build:development"
 | 
			
		||||
            },
 | 
			
		||||
            "ci": {
 | 
			
		||||
              "progress": false
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          "defaultConfiguration": "development"
 | 
			
		||||
        },
 | 
			
		||||
        "extract-i18n": {
 | 
			
		||||
          "builder": "@angular-devkit/build-angular:extract-i18n",
 | 
			
		||||
          "options": {
 | 
			
		||||
            "buildTarget": "app:build"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "test": {
 | 
			
		||||
          "builder": "@angular-devkit/build-angular:karma",
 | 
			
		||||
          "options": {
 | 
			
		||||
            "main": "src/test.ts",
 | 
			
		||||
            "polyfills": "src/polyfills.ts",
 | 
			
		||||
            "tsConfig": "tsconfig.spec.json",
 | 
			
		||||
            "karmaConfig": "karma.conf.js",
 | 
			
		||||
            "inlineStyleLanguage": "scss",
 | 
			
		||||
            "assets": [
 | 
			
		||||
              {
 | 
			
		||||
                "glob": "**/*",
 | 
			
		||||
                "input": "src/assets",
 | 
			
		||||
                "output": "assets"
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                "glob": "**/*.svg",
 | 
			
		||||
                "input": "node_modules/ionicons/dist/ionicons/svg",
 | 
			
		||||
                "output": "./svg"
 | 
			
		||||
              }
 | 
			
		||||
            ],
 | 
			
		||||
            "styles": [
 | 
			
		||||
              "@angular/material/prebuilt-themes/azure-blue.css",
 | 
			
		||||
              "src/global.scss",
 | 
			
		||||
              "src/theme/variables.scss"
 | 
			
		||||
            ],
 | 
			
		||||
            "scripts": []
 | 
			
		||||
          },
 | 
			
		||||
          "configurations": {
 | 
			
		||||
            "ci": {
 | 
			
		||||
              "progress": false,
 | 
			
		||||
              "watch": false
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "lint": {
 | 
			
		||||
          "builder": "@angular-eslint/builder:lint",
 | 
			
		||||
          "options": {
 | 
			
		||||
            "lintFilePatterns": [
 | 
			
		||||
              "src/**/*.ts",
 | 
			
		||||
              "src/**/*.html"
 | 
			
		||||
            ]
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "cli": {
 | 
			
		||||
    "schematicCollections": [
 | 
			
		||||
      "@ionic/angular-toolkit"
 | 
			
		||||
    ],
 | 
			
		||||
    "analytics": false
 | 
			
		||||
  },
 | 
			
		||||
  "schematics": {
 | 
			
		||||
    "@ionic/angular-toolkit:component": {
 | 
			
		||||
      "styleext": "scss"
 | 
			
		||||
    },
 | 
			
		||||
    "@ionic/angular-toolkit:page": {
 | 
			
		||||
      "styleext": "scss"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/assets/icon-background.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 38 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/assets/icon-foreground.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 163 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/assets/splash.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 130 KiB  | 
							
								
								
									
										9
									
								
								frontend/allowance-planner-v2/capacitor.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,9 @@
 | 
			
		||||
import type { CapacitorConfig } from '@capacitor/cli';
 | 
			
		||||
 | 
			
		||||
const config: CapacitorConfig = {
 | 
			
		||||
  appId: 'io.ionic.starter',
 | 
			
		||||
  appName: 'Allowance Planner V2',
 | 
			
		||||
  webDir: 'www'
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default config;
 | 
			
		||||
							
								
								
									
										7
									
								
								frontend/allowance-planner-v2/ionic.config.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "allowance-planner-v2",
 | 
			
		||||
  "integrations": {
 | 
			
		||||
    "capacitor": {}
 | 
			
		||||
  },
 | 
			
		||||
  "type": "angular"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										44
									
								
								frontend/allowance-planner-v2/karma.conf.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,44 @@
 | 
			
		||||
// Karma configuration file, see link for more information
 | 
			
		||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
 | 
			
		||||
 | 
			
		||||
module.exports = function (config) {
 | 
			
		||||
  config.set({
 | 
			
		||||
    basePath: '',
 | 
			
		||||
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
 | 
			
		||||
    plugins: [
 | 
			
		||||
      require('karma-jasmine'),
 | 
			
		||||
      require('karma-chrome-launcher'),
 | 
			
		||||
      require('karma-jasmine-html-reporter'),
 | 
			
		||||
      require('karma-coverage'),
 | 
			
		||||
      require('@angular-devkit/build-angular/plugins/karma')
 | 
			
		||||
    ],
 | 
			
		||||
    client: {
 | 
			
		||||
      jasmine: {
 | 
			
		||||
        // you can add configuration options for Jasmine here
 | 
			
		||||
        // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
 | 
			
		||||
        // for example, you can disable the random execution with `random: false`
 | 
			
		||||
        // or set a specific seed with `seed: 4321`
 | 
			
		||||
      },
 | 
			
		||||
      clearContext: false // leave Jasmine Spec Runner output visible in browser
 | 
			
		||||
    },
 | 
			
		||||
    jasmineHtmlReporter: {
 | 
			
		||||
      suppressAll: true // removes the duplicated traces
 | 
			
		||||
    },
 | 
			
		||||
    coverageReporter: {
 | 
			
		||||
      dir: require('path').join(__dirname, './coverage/app'),
 | 
			
		||||
      subdir: '.',
 | 
			
		||||
      reporters: [
 | 
			
		||||
        { type: 'html' },
 | 
			
		||||
        { type: 'text-summary' }
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
    reporters: ['progress', 'kjhtml'],
 | 
			
		||||
    port: 9876,
 | 
			
		||||
    colors: true,
 | 
			
		||||
    logLevel: config.LOG_INFO,
 | 
			
		||||
    autoWatch: true,
 | 
			
		||||
    browsers: ['Chrome'],
 | 
			
		||||
    singleRun: false,
 | 
			
		||||
    restartOnFileChange: true
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										21452
									
								
								frontend/allowance-planner-v2/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										70
									
								
								frontend/allowance-planner-v2/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,70 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "allowance-planner-v2",
 | 
			
		||||
  "version": "0.0.1",
 | 
			
		||||
  "author": "Ionic Framework",
 | 
			
		||||
  "homepage": "https://ionicframework.com/",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "ng": "ng",
 | 
			
		||||
    "start": "ng serve",
 | 
			
		||||
    "build": "ng build",
 | 
			
		||||
    "watch": "ng build --watch --configuration development",
 | 
			
		||||
    "test": "ng test",
 | 
			
		||||
    "lint": "ng lint"
 | 
			
		||||
  },
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@angular/animations": "^19.0.0",
 | 
			
		||||
    "@angular/cdk": "^19.2.15",
 | 
			
		||||
    "@angular/common": "^19.0.0",
 | 
			
		||||
    "@angular/compiler": "^19.0.0",
 | 
			
		||||
    "@angular/core": "^19.0.0",
 | 
			
		||||
    "@angular/forms": "^19.0.0",
 | 
			
		||||
    "@angular/material": "^19.2.15",
 | 
			
		||||
    "@angular/platform-browser": "^19.0.0",
 | 
			
		||||
    "@angular/platform-browser-dynamic": "^19.0.0",
 | 
			
		||||
    "@angular/router": "^19.0.0",
 | 
			
		||||
    "@capacitor/android": "7.2.0",
 | 
			
		||||
    "@capacitor/app": "7.0.1",
 | 
			
		||||
    "@capacitor/core": "7.2.0",
 | 
			
		||||
    "@capacitor/haptics": "7.0.1",
 | 
			
		||||
    "@capacitor/keyboard": "7.0.1",
 | 
			
		||||
    "@capacitor/status-bar": "7.0.1",
 | 
			
		||||
    "@ionic/angular": "^8.0.0",
 | 
			
		||||
    "@ionic/pwa-elements": "^3.3.0",
 | 
			
		||||
    "@ionic/storage-angular": "^4.0.0",
 | 
			
		||||
    "ionicons": "^7.0.0",
 | 
			
		||||
    "rxjs": "~7.8.0",
 | 
			
		||||
    "tslib": "^2.3.0",
 | 
			
		||||
    "zone.js": "~0.15.0"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@angular-devkit/build-angular": "^19.0.0",
 | 
			
		||||
    "@angular-eslint/builder": "^19.0.0",
 | 
			
		||||
    "@angular-eslint/eslint-plugin": "^19.0.0",
 | 
			
		||||
    "@angular-eslint/eslint-plugin-template": "^19.0.0",
 | 
			
		||||
    "@angular-eslint/schematics": "^19.0.0",
 | 
			
		||||
    "@angular-eslint/template-parser": "^19.0.0",
 | 
			
		||||
    "@angular/cli": "^19.0.0",
 | 
			
		||||
    "@angular/compiler-cli": "^19.0.0",
 | 
			
		||||
    "@angular/language-service": "^19.0.0",
 | 
			
		||||
    "@capacitor/assets": "^3.0.5",
 | 
			
		||||
    "@capacitor/cli": "7.2.0",
 | 
			
		||||
    "@ionic/angular-toolkit": "^12.0.0",
 | 
			
		||||
    "@types/jasmine": "~5.1.0",
 | 
			
		||||
    "@typescript-eslint/eslint-plugin": "^8.18.0",
 | 
			
		||||
    "@typescript-eslint/parser": "^8.18.0",
 | 
			
		||||
    "eslint": "^9.16.0",
 | 
			
		||||
    "eslint-plugin-import": "^2.29.1",
 | 
			
		||||
    "eslint-plugin-jsdoc": "^48.2.1",
 | 
			
		||||
    "eslint-plugin-prefer-arrow": "1.2.2",
 | 
			
		||||
    "jasmine-core": "~5.1.0",
 | 
			
		||||
    "jasmine-spec-reporter": "~5.0.0",
 | 
			
		||||
    "karma": "~6.4.0",
 | 
			
		||||
    "karma-chrome-launcher": "~3.2.0",
 | 
			
		||||
    "karma-coverage": "~2.2.0",
 | 
			
		||||
    "karma-jasmine": "~5.1.0",
 | 
			
		||||
    "karma-jasmine-html-reporter": "~2.1.0",
 | 
			
		||||
    "typescript": "~5.6.3"
 | 
			
		||||
  },
 | 
			
		||||
  "description": "An Ionic project"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								frontend/allowance-planner-v2/src/app/app-routing.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,22 @@
 | 
			
		||||
import { CommonModule } from '@angular/common';
 | 
			
		||||
import { NgModule } from '@angular/core';
 | 
			
		||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
 | 
			
		||||
 | 
			
		||||
const routes: Routes = [
 | 
			
		||||
  {
 | 
			
		||||
    path: '',
 | 
			
		||||
    loadChildren: () => import('./pages/user-login/user-login.module').then( m => m.UserLoginPageModule)
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: '',
 | 
			
		||||
    loadChildren: () => import('./pages/tabs/tabs.module').then(m => m.TabsPageModule)
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
@NgModule({
 | 
			
		||||
  imports: [
 | 
			
		||||
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }),
 | 
			
		||||
    CommonModule
 | 
			
		||||
  ],
 | 
			
		||||
  exports: [RouterModule]
 | 
			
		||||
})
 | 
			
		||||
export class AppRoutingModule {}
 | 
			
		||||
							
								
								
									
										3
									
								
								frontend/allowance-planner-v2/src/app/app.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,3 @@
 | 
			
		||||
<ion-app>
 | 
			
		||||
  <ion-router-outlet></ion-router-outlet>
 | 
			
		||||
</ion-app>
 | 
			
		||||