Compare commits
	
		
			31 Commits
		
	
	
		
			2bd03586da
			...
			schedules
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1e463fec55 | |||
| 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 | 
| @@ -1,6 +1,8 @@ | |||||||
| name: Backend Deploy | name: Backend Deploy | ||||||
| on: | on: | ||||||
|   push: [main] |   push: | ||||||
|  |     branches: | ||||||
|  |       - main | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   build: |   build: | ||||||
| @@ -17,9 +19,9 @@ jobs: | |||||||
|       - name: Build |       - name: Build | ||||||
|         run: | |         run: | | ||||||
|           cd backend |           cd backend | ||||||
|           docker build -t gitea.seeseepuff.be/seeseemelk/wolproxy:$(git rev-parse --short HEAD) . |           docker build -t gitea.seeseepuff.be/seeseemelk/allowance-planner:$(git rev-parse --short HEAD) . | ||||||
|  |  | ||||||
|       - name: Push |       - name: Push | ||||||
|         run: | |         run: | | ||||||
|           cd backend |           cd backend | ||||||
|           docker push gitea.seeseepuff.be/seeseemelk/wolproxy:$(git rev-parse --short HEAD) |           docker push gitea.seeseepuff.be/seeseemelk/allowance-planner:$(git rev-parse --short HEAD) | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,3 +1,4 @@ | |||||||
| *.db3 | *.db3 | ||||||
| *.db3-* | *.db3-* | ||||||
|  | *.db3.* | ||||||
| /allowance_planner | /allowance_planner | ||||||
|   | |||||||
| @@ -9,12 +9,13 @@ import ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| const ( | const ( | ||||||
| 	TestAllowanceName = "Test History" | 	TestHistoryName = "Test History" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func startServer(t *testing.T) *httpexpect.Expect { | func startServer(t *testing.T) *httpexpect.Expect { | ||||||
| 	config := ServerConfig{ | 	config := ServerConfig{ | ||||||
| 		Datasource: ":memory:", | 		Datasource: ":memory:", | ||||||
|  | 		//Datasource: "test.db", | ||||||
| 		Addr:    ":0", | 		Addr:    ":0", | ||||||
| 		Started: make(chan bool), | 		Started: make(chan bool), | ||||||
| 	} | 	} | ||||||
| @@ -62,7 +63,7 @@ func TestGetUserAllowance(t *testing.T) { | |||||||
|  |  | ||||||
| 	// Create a new allowance | 	// Create a new allowance | ||||||
| 	requestBody := map[string]interface{}{ | 	requestBody := map[string]interface{}{ | ||||||
| 		"name":   TestAllowanceName, | 		"name":   TestHistoryName, | ||||||
| 		"target": 5000, | 		"target": 5000, | ||||||
| 		"weight": 10, | 		"weight": 10, | ||||||
| 	} | 	} | ||||||
| @@ -73,7 +74,7 @@ func TestGetUserAllowance(t *testing.T) { | |||||||
| 	result.Length().IsEqual(2) | 	result.Length().IsEqual(2) | ||||||
| 	item := result.Value(1).Object() | 	item := result.Value(1).Object() | ||||||
| 	item.Value("id").IsEqual(1) | 	item.Value("id").IsEqual(1) | ||||||
| 	item.Value("name").IsEqual(TestAllowanceName) | 	item.Value("name").IsEqual(TestHistoryName) | ||||||
| 	item.Value("target").IsEqual(5000) | 	item.Value("target").IsEqual(5000) | ||||||
| 	item.Value("weight").IsEqual(10) | 	item.Value("weight").IsEqual(10) | ||||||
| 	item.Value("progress").IsEqual(0) | 	item.Value("progress").IsEqual(0) | ||||||
| @@ -95,7 +96,7 @@ func TestCreateUserAllowance(t *testing.T) { | |||||||
|  |  | ||||||
| 	// Create a new allowance | 	// Create a new allowance | ||||||
| 	requestBody := map[string]interface{}{ | 	requestBody := map[string]interface{}{ | ||||||
| 		"name":   TestAllowanceName, | 		"name":   TestHistoryName, | ||||||
| 		"target": 5000, | 		"target": 5000, | ||||||
| 		"weight": 10, | 		"weight": 10, | ||||||
| 	} | 	} | ||||||
| @@ -120,7 +121,7 @@ func TestCreateUserAllowance(t *testing.T) { | |||||||
|  |  | ||||||
| 	allowance := allowances.Value(1).Object() | 	allowance := allowances.Value(1).Object() | ||||||
| 	allowance.Value("id").IsEqual(allowanceId) | 	allowance.Value("id").IsEqual(allowanceId) | ||||||
| 	allowance.Value("name").IsEqual(TestAllowanceName) | 	allowance.Value("name").IsEqual(TestHistoryName) | ||||||
| 	allowance.Value("target").IsEqual(5000) | 	allowance.Value("target").IsEqual(5000) | ||||||
| 	allowance.Value("weight").IsEqual(10) | 	allowance.Value("weight").IsEqual(10) | ||||||
| 	allowance.Value("progress").IsEqual(0) | 	allowance.Value("progress").IsEqual(0) | ||||||
| @@ -130,7 +131,7 @@ func TestCreateUserAllowanceNoUser(t *testing.T) { | |||||||
| 	e := startServer(t) | 	e := startServer(t) | ||||||
|  |  | ||||||
| 	requestBody := map[string]interface{}{ | 	requestBody := map[string]interface{}{ | ||||||
| 		"name":   TestAllowanceName, | 		"name":   TestHistoryName, | ||||||
| 		"target": 5000, | 		"target": 5000, | ||||||
| 		"weight": 10, | 		"weight": 10, | ||||||
| 	} | 	} | ||||||
| @@ -171,7 +172,7 @@ func TestCreateUserAllowanceBadId(t *testing.T) { | |||||||
| 	e := startServer(t) | 	e := startServer(t) | ||||||
|  |  | ||||||
| 	requestBody := map[string]interface{}{ | 	requestBody := map[string]interface{}{ | ||||||
| 		"name":   TestAllowanceName, | 		"name":   TestHistoryName, | ||||||
| 		"target": 5000, | 		"target": 5000, | ||||||
| 		"weight": 10, | 		"weight": 10, | ||||||
| 	} | 	} | ||||||
| @@ -187,7 +188,7 @@ func TestDeleteUserAllowance(t *testing.T) { | |||||||
|  |  | ||||||
| 	// Create a new allowance to delete | 	// Create a new allowance to delete | ||||||
| 	createRequest := map[string]interface{}{ | 	createRequest := map[string]interface{}{ | ||||||
| 		"name":   TestAllowanceName, | 		"name":   TestHistoryName, | ||||||
| 		"target": 1000, | 		"target": 1000, | ||||||
| 		"weight": 5, | 		"weight": 5, | ||||||
| 	} | 	} | ||||||
| @@ -284,6 +285,54 @@ func TestCreateTask(t *testing.T) { | |||||||
| 	responseWithUser.Value("id").Number().IsEqual(2) | 	responseWithUser.Value("id").Number().IsEqual(2) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func TestCreateScheduleTask(t *testing.T) { | ||||||
|  | 	e := startServer(t) | ||||||
|  |  | ||||||
|  | 	// Create a new task without assigned user | ||||||
|  | 	requestBody := map[string]interface{}{ | ||||||
|  | 		"name":     "Test Task", | ||||||
|  | 		"reward":   100, | ||||||
|  | 		"schedule": "0 */5 * * * *", | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response := e.POST("/tasks"). | ||||||
|  | 		WithJSON(requestBody). | ||||||
|  | 		Expect(). | ||||||
|  | 		Status(201). // Expect Created status | ||||||
|  | 		JSON().Object() | ||||||
|  |  | ||||||
|  | 	requestBody["schedule"] = "every 5 seconds" | ||||||
|  | 	e.POST("/tasks").WithJSON(requestBody).Expect().Status(400) | ||||||
|  |  | ||||||
|  | 	// Verify the response has an ID | ||||||
|  | 	response.ContainsKey("id") | ||||||
|  | 	response.Value("id").Number().IsEqual(1) | ||||||
|  |  | ||||||
|  | 	e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1) | ||||||
|  |  | ||||||
|  | 	// Get task | ||||||
|  | 	result := e.GET("/task/1").Expect().Status(200).JSON().Object() | ||||||
|  | 	result.Value("id").IsEqual(1) | ||||||
|  | 	result.Value("name").IsEqual("Test Task") | ||||||
|  | 	result.Value("schedule").IsEqual("0 */5 * * * *") | ||||||
|  | 	result.Value("reward").IsEqual(100) | ||||||
|  | 	result.Value("assigned").IsNull() | ||||||
|  |  | ||||||
|  | 	// Complete the task | ||||||
|  | 	e.POST("/task/1/complete").Expect().Status(200) | ||||||
|  |  | ||||||
|  | 	// Set expires date to 1 second in the past | ||||||
|  | 	db.db.Query("update tasks set next_run = ? where id = 1").Bind(time.Now().Add(10 * -time.Minute).Unix()).MustExec() | ||||||
|  |  | ||||||
|  | 	// Verify a new task is created | ||||||
|  | 	newTask := e.GET("/task/2").Expect().Status(200).JSON().Object() | ||||||
|  | 	newTask.Value("id").IsEqual(2) | ||||||
|  | 	newTask.Value("name").IsEqual("Test Task") | ||||||
|  | 	newTask.Value("schedule").IsEqual("0 */5 * * * *") | ||||||
|  | 	newTask.Value("reward").IsEqual(100) | ||||||
|  | 	newTask.Value("assigned").IsNull() | ||||||
|  | } | ||||||
|  |  | ||||||
| func TestDeleteTask(t *testing.T) { | func TestDeleteTask(t *testing.T) { | ||||||
| 	e := startServer(t) | 	e := startServer(t) | ||||||
|  |  | ||||||
| @@ -434,37 +483,50 @@ func TestPutTaskInvalidTaskId(t *testing.T) { | |||||||
| 	e.PUT("/task/999").WithJSON(requestBody).Expect().Status(404) | 	e.PUT("/task/999").WithJSON(requestBody).Expect().Status(404) | ||||||
| } | } | ||||||
|  |  | ||||||
| func TestPostAllowance(t *testing.T) { | func TestPostHistory(t *testing.T) { | ||||||
| 	e := startServer(t) | 	e := startServer(t) | ||||||
|  |  | ||||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100}).Expect().Status(200) | 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100, Description: "Add a 100"}).Expect().Status(200) | ||||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20}).Expect().Status(200) | 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20, Description: "Lolol"}).Expect().Status(200) | ||||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10}).Expect().Status(200) | 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10, Description: "Subtracting"}).Expect().Status(200) | ||||||
|  |  | ||||||
| 	response := e.GET("/user/1").Expect().Status(200).JSON().Object() | 	response := e.GET("/user/1").Expect().Status(200).JSON().Object() | ||||||
| 	response.Value("allowance").Number().IsEqual(100 + 20 - 10) | 	response.Value("allowance").Number().IsEqual(100 + 20 - 10) | ||||||
| } | } | ||||||
|  |  | ||||||
| func TestPostAllowanceInvalidUserId(t *testing.T) { | func TestPostHistoryInvalidUserId(t *testing.T) { | ||||||
| 	e := startServer(t) | 	e := startServer(t) | ||||||
|  |  | ||||||
| 	e.POST("/user/999/history").WithJSON(PostHistory{Allowance: 100}).Expect(). | 	e.POST("/user/999/history").WithJSON(PostHistory{Allowance: 100, Description: "Good"}).Expect(). | ||||||
| 		Status(404) | 		Status(404) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func TestPostHistoryInvalidDescription(t *testing.T) { | ||||||
|  | 	e := startServer(t) | ||||||
|  |  | ||||||
|  | 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100}).Expect(). | ||||||
|  | 		Status(400) | ||||||
|  | } | ||||||
|  |  | ||||||
| func TestGetHistory(t *testing.T) { | func TestGetHistory(t *testing.T) { | ||||||
| 	e := startServer(t) | 	e := startServer(t) | ||||||
|  |  | ||||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100}).Expect().Status(200) | 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100, Description: "Add 100"}).Expect().Status(200) | ||||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20}).Expect().Status(200) | 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20, Description: "Add 20"}).Expect().Status(200) | ||||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10}).Expect().Status(200) | 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10, Description: "Subtract 10"}).Expect().Status(200) | ||||||
|  |  | ||||||
| 	response := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | 	response := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||||
| 	response.Length().IsEqual(3) | 	response.Length().IsEqual(3) | ||||||
|  | 	response.Value(0).Object().Length().IsEqual(3) | ||||||
| 	response.Value(0).Object().Value("allowance").Number().IsEqual(100) | 	response.Value(0).Object().Value("allowance").Number().IsEqual(100) | ||||||
| 	response.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | 	response.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||||
|  | 	response.Value(0).Object().Value("description").String().IsEqual("Add 100") | ||||||
|  |  | ||||||
| 	response.Value(1).Object().Value("allowance").Number().IsEqual(20) | 	response.Value(1).Object().Value("allowance").Number().IsEqual(20) | ||||||
|  | 	response.Value(1).Object().Value("description").String().IsEqual("Add 20") | ||||||
|  |  | ||||||
| 	response.Value(2).Object().Value("allowance").Number().IsEqual(-10) | 	response.Value(2).Object().Value("allowance").Number().IsEqual(-10) | ||||||
|  | 	response.Value(2).Object().Value("description").String().IsEqual("Subtract 10") | ||||||
| } | } | ||||||
|  |  | ||||||
| func TestGetUserAllowanceById(t *testing.T) { | func TestGetUserAllowanceById(t *testing.T) { | ||||||
| @@ -472,9 +534,10 @@ func TestGetUserAllowanceById(t *testing.T) { | |||||||
|  |  | ||||||
| 	// Create a new allowance | 	// Create a new allowance | ||||||
| 	requestBody := map[string]interface{}{ | 	requestBody := map[string]interface{}{ | ||||||
| 		"name":   TestAllowanceName, | 		"name":   TestHistoryName, | ||||||
| 		"target": 5000, | 		"target": 5000, | ||||||
| 		"weight": 10, | 		"weight": 10, | ||||||
|  | 		"colour": "#FF5733", | ||||||
| 	} | 	} | ||||||
| 	resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object() | 	resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object() | ||||||
| 	allowanceId := int(resp.Value("id").Number().Raw()) | 	allowanceId := int(resp.Value("id").Number().Raw()) | ||||||
| @@ -482,10 +545,21 @@ func TestGetUserAllowanceById(t *testing.T) { | |||||||
| 	// Retrieve the created allowance by ID | 	// Retrieve the created allowance by ID | ||||||
| 	result := e.GET("/user/1/allowance/" + strconv.Itoa(allowanceId)).Expect().Status(200).JSON().Object() | 	result := e.GET("/user/1/allowance/" + strconv.Itoa(allowanceId)).Expect().Status(200).JSON().Object() | ||||||
| 	result.Value("id").IsEqual(allowanceId) | 	result.Value("id").IsEqual(allowanceId) | ||||||
| 	result.Value("name").IsEqual(TestAllowanceName) | 	result.Value("name").IsEqual(TestHistoryName) | ||||||
| 	result.Value("target").IsEqual(5000) | 	result.Value("target").IsEqual(5000) | ||||||
| 	result.Value("weight").IsEqual(10) | 	result.Value("weight").IsEqual(10) | ||||||
| 	result.Value("progress").IsEqual(0) | 	result.Value("progress").IsEqual(0) | ||||||
|  | 	result.Value("colour").IsEqual("#FF5733") | ||||||
|  |  | ||||||
|  | 	resultArray := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||||
|  | 	resultArray.Length().IsEqual(2) | ||||||
|  | 	result = resultArray.Value(1).Object() | ||||||
|  | 	result.Value("id").IsEqual(allowanceId) | ||||||
|  | 	result.Value("name").IsEqual(TestHistoryName) | ||||||
|  | 	result.Value("target").IsEqual(5000) | ||||||
|  | 	result.Value("weight").IsEqual(10) | ||||||
|  | 	result.Value("progress").IsEqual(0) | ||||||
|  | 	result.Value("colour").IsEqual("#FF5733") | ||||||
| } | } | ||||||
|  |  | ||||||
| func TestGetUserByAllowanceIdInvalidAllowance(t *testing.T) { | func TestGetUserByAllowanceIdInvalidAllowance(t *testing.T) { | ||||||
| @@ -513,7 +587,7 @@ func TestPutAllowanceById(t *testing.T) { | |||||||
|  |  | ||||||
| 	// Create a new allowance | 	// Create a new allowance | ||||||
| 	requestBody := map[string]interface{}{ | 	requestBody := map[string]interface{}{ | ||||||
| 		"name":   TestAllowanceName, | 		"name":   TestHistoryName, | ||||||
| 		"target": 5000, | 		"target": 5000, | ||||||
| 		"weight": 10, | 		"weight": 10, | ||||||
| 		"colour": "#FF5733", | 		"colour": "#FF5733", | ||||||
| @@ -593,6 +667,36 @@ func TestCompleteTask(t *testing.T) { | |||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func TestCompleteTaskWithNoWeights(t *testing.T) { | ||||||
|  | 	e := startServer(t) | ||||||
|  | 	taskId := createTestTaskWithAmount(e, 101) | ||||||
|  |  | ||||||
|  | 	e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1) | ||||||
|  |  | ||||||
|  | 	// Ensure main allowance has no weight | ||||||
|  | 	e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{ | ||||||
|  | 		Weight: 0, | ||||||
|  | 	}).Expect().Status(200) | ||||||
|  |  | ||||||
|  | 	// Complete the task | ||||||
|  | 	e.POST("/task/" + strconv.Itoa(taskId) + "/complete").Expect().Status(200) | ||||||
|  |  | ||||||
|  | 	// Verify the task is marked as completed | ||||||
|  | 	e.GET("/task/" + strconv.Itoa(taskId)).Expect().Status(404) | ||||||
|  |  | ||||||
|  | 	// Verify the allowances are updated for user 1 | ||||||
|  | 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||||
|  | 	allowances.Length().IsEqual(1) | ||||||
|  | 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||||
|  | 	allowances.Value(0).Object().Value("progress").Number().InDelta(101.00, 0.01) | ||||||
|  |  | ||||||
|  | 	// And also for user 2 | ||||||
|  | 	allowances = e.GET("/user/2/allowance").Expect().Status(200).JSON().Array() | ||||||
|  | 	allowances.Length().IsEqual(1) | ||||||
|  | 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||||
|  | 	allowances.Value(0).Object().Value("progress").Number().InDelta(101.00, 0.01) | ||||||
|  | } | ||||||
|  |  | ||||||
| func TestCompleteTaskAllowanceWeightsSumTo0(t *testing.T) { | func TestCompleteTaskAllowanceWeightsSumTo0(t *testing.T) { | ||||||
| 	e := startServer(t) | 	e := startServer(t) | ||||||
| 	taskId := createTestTaskWithAmount(e, 101) | 	taskId := createTestTaskWithAmount(e, 101) | ||||||
| @@ -631,6 +735,11 @@ func TestCompleteAllowance(t *testing.T) { | |||||||
| 	createTestTaskWithAmount(e, 100) | 	createTestTaskWithAmount(e, 100) | ||||||
| 	createTestAllowance(e, "Test Allowance 1", 100, 50) | 	createTestAllowance(e, "Test Allowance 1", 100, 50) | ||||||
|  |  | ||||||
|  | 	// Update base allowance | ||||||
|  | 	e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{ | ||||||
|  | 		Weight: 0, | ||||||
|  | 	}).Expect().Status(200) | ||||||
|  |  | ||||||
| 	// Complete the task | 	// Complete the task | ||||||
| 	e.POST("/task/1/complete").Expect().Status(200) | 	e.POST("/task/1/complete").Expect().Status(200) | ||||||
|  |  | ||||||
| @@ -643,10 +752,15 @@ func TestCompleteAllowance(t *testing.T) { | |||||||
| 	// Verify history is updated | 	// Verify history is updated | ||||||
| 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||||
| 	history.Length().IsEqual(2) | 	history.Length().IsEqual(2) | ||||||
|  | 	history.Value(0).Object().Length().IsEqual(3) | ||||||
| 	history.Value(0).Object().Value("allowance").Number().IsEqual(100) | 	history.Value(0).Object().Value("allowance").Number().IsEqual(100) | ||||||
| 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||||
|  | 	history.Value(0).Object().Value("description").String().IsEqual("Task completed: Test Task") | ||||||
|  |  | ||||||
|  | 	history.Value(1).Object().Length().IsEqual(3) | ||||||
| 	history.Value(1).Object().Value("allowance").Number().IsEqual(-100) | 	history.Value(1).Object().Value("allowance").Number().IsEqual(-100) | ||||||
| 	history.Value(1).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | 	history.Value(1).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||||
|  | 	history.Value(1).Object().Value("description").String().IsEqual("Allowance completed: Test Allowance 1") | ||||||
| } | } | ||||||
|  |  | ||||||
| func TestCompleteAllowanceInvalidUserId(t *testing.T) { | func TestCompleteAllowanceInvalidUserId(t *testing.T) { | ||||||
| @@ -693,6 +807,145 @@ func TestPutBulkAllowance(t *testing.T) { | |||||||
| 	allowances.Value(2).Object().Value("weight").Number().IsEqual(10) | 	allowances.Value(2).Object().Value("weight").Number().IsEqual(10) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func TestAddAllowanceSimple(t *testing.T) { | ||||||
|  | 	e := startServer(t) | ||||||
|  |  | ||||||
|  | 	createTestAllowance(e, "Test Allowance 1", 1000, 1) | ||||||
|  |  | ||||||
|  | 	request := map[string]interface{}{ | ||||||
|  | 		"amount":      10, | ||||||
|  | 		"description": "Added to allowance 1", | ||||||
|  | 	} | ||||||
|  | 	e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200) | ||||||
|  |  | ||||||
|  | 	// Verify the allowance is updated | ||||||
|  | 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||||
|  | 	allowances.Value(1).Object().Value("id").Number().IsEqual(1) | ||||||
|  | 	allowances.Value(1).Object().Value("progress").Number().InDelta(10.0, 0.01) | ||||||
|  |  | ||||||
|  | 	// Verify the history is updated | ||||||
|  | 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||||
|  | 	history.Length().IsEqual(1) | ||||||
|  | 	history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01) | ||||||
|  | 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||||
|  | 	history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestAddAllowanceWithSpillage(t *testing.T) { | ||||||
|  | 	e := startServer(t) | ||||||
|  |  | ||||||
|  | 	createTestAllowance(e, "Test Allowance 1", 5, 1) | ||||||
|  | 	createTestAllowance(e, "Test Allowance 2", 5, 1) | ||||||
|  | 	e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{Weight: 1}).Expect().Status(200) | ||||||
|  |  | ||||||
|  | 	request := map[string]interface{}{ | ||||||
|  | 		"amount":      10, | ||||||
|  | 		"description": "Added to allowance 1", | ||||||
|  | 	} | ||||||
|  | 	e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200) | ||||||
|  |  | ||||||
|  | 	// Verify the allowance is updated | ||||||
|  | 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||||
|  | 	allowances.Value(1).Object().Value("id").Number().IsEqual(1) | ||||||
|  | 	allowances.Value(1).Object().Value("progress").Number().InDelta(5.0, 0.01) | ||||||
|  |  | ||||||
|  | 	allowances.Value(2).Object().Value("id").Number().IsEqual(2) | ||||||
|  | 	allowances.Value(2).Object().Value("progress").Number().InDelta(2.5, 0.01) | ||||||
|  |  | ||||||
|  | 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||||
|  | 	allowances.Value(0).Object().Value("progress").Number().InDelta(2.5, 0.01) | ||||||
|  |  | ||||||
|  | 	// Verify the history is updated | ||||||
|  | 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||||
|  | 	history.Length().IsEqual(1) | ||||||
|  | 	history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01) | ||||||
|  | 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||||
|  | 	history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestAddAllowanceIdZero(t *testing.T) { | ||||||
|  | 	e := startServer(t) | ||||||
|  |  | ||||||
|  | 	createTestAllowance(e, "Test Allowance 1", 1000, 1) | ||||||
|  |  | ||||||
|  | 	request := map[string]interface{}{ | ||||||
|  | 		"amount":      10, | ||||||
|  | 		"description": "Added to allowance 1", | ||||||
|  | 	} | ||||||
|  | 	e.POST("/user/1/allowance/0/add").WithJSON(request).Expect().Status(200) | ||||||
|  |  | ||||||
|  | 	// Verify the allowance is updated | ||||||
|  | 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||||
|  | 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||||
|  | 	allowances.Value(0).Object().Value("progress").Number().InDelta(10.0, 0.01) | ||||||
|  |  | ||||||
|  | 	// Verify the history is updated | ||||||
|  | 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||||
|  | 	history.Length().IsEqual(1) | ||||||
|  | 	history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01) | ||||||
|  | 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||||
|  | 	history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestSubtractAllowanceSimple(t *testing.T) { | ||||||
|  | 	e := startServer(t) | ||||||
|  |  | ||||||
|  | 	createTestAllowance(e, "Test Allowance 1", 1000, 1) | ||||||
|  |  | ||||||
|  | 	request := map[string]interface{}{ | ||||||
|  | 		"amount":      10, | ||||||
|  | 		"description": "Added to allowance 1", | ||||||
|  | 	} | ||||||
|  | 	e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200) | ||||||
|  | 	request["amount"] = -2.5 | ||||||
|  | 	e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200) | ||||||
|  |  | ||||||
|  | 	// Verify the allowance is updated | ||||||
|  | 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||||
|  | 	allowances.Value(1).Object().Value("id").Number().IsEqual(1) | ||||||
|  | 	allowances.Value(1).Object().Value("progress").Number().InDelta(7.5, 0.01) | ||||||
|  |  | ||||||
|  | 	// Verify the history is updated | ||||||
|  | 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||||
|  | 	history.Length().IsEqual(2) | ||||||
|  | 	history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01) | ||||||
|  | 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||||
|  | 	history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||||
|  |  | ||||||
|  | 	history.Value(1).Object().Value("allowance").Number().InDelta(-2.5, 0.01) | ||||||
|  | 	history.Value(1).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||||
|  | 	history.Value(1).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestSubtractllowanceIdZero(t *testing.T) { | ||||||
|  | 	e := startServer(t) | ||||||
|  |  | ||||||
|  | 	createTestAllowance(e, "Test Allowance 1", 1000, 1) | ||||||
|  |  | ||||||
|  | 	request := map[string]interface{}{ | ||||||
|  | 		"amount":      10, | ||||||
|  | 		"description": "Added to allowance 1", | ||||||
|  | 	} | ||||||
|  | 	e.POST("/user/1/allowance/0/add").WithJSON(request).Expect().Status(200) | ||||||
|  | 	request["amount"] = -2.5 | ||||||
|  | 	e.POST("/user/1/allowance/0/add").WithJSON(request).Expect().Status(200) | ||||||
|  |  | ||||||
|  | 	// Verify the allowance is updated | ||||||
|  | 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||||
|  | 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||||
|  | 	allowances.Value(0).Object().Value("progress").Number().InDelta(7.5, 0.01) | ||||||
|  |  | ||||||
|  | 	// Verify the history is updated | ||||||
|  | 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||||
|  | 	history.Length().IsEqual(2) | ||||||
|  | 	history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01) | ||||||
|  | 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||||
|  | 	history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||||
|  |  | ||||||
|  | 	history.Value(1).Object().Value("allowance").Number().InDelta(-2.5, 0.01) | ||||||
|  | 	history.Value(1).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||||
|  | } | ||||||
|  |  | ||||||
| func getDelta(base time.Time, delta float64) (time.Time, time.Time) { | func getDelta(base time.Time, delta float64) (time.Time, time.Time) { | ||||||
| 	start := base.Add(-time.Duration(delta) * time.Second) | 	start := base.Add(-time.Duration(delta) * time.Second) | ||||||
| 	end := base.Add(time.Duration(delta) * time.Second) | 	end := base.Add(time.Duration(delta) * time.Second) | ||||||
|   | |||||||
| @@ -28,3 +28,7 @@ func ConvertStringToColour(colourStr string) (int, error) { | |||||||
| 	} | 	} | ||||||
| 	return colour, nil | 	return colour, nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func ConvertColourToString(colour int) string { | ||||||
|  | 	return fmt.Sprintf("#%06X", colour) | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										265
									
								
								backend/db.go
									
									
									
									
									
								
							
							
						
						| @@ -3,6 +3,7 @@ package main | |||||||
| import ( | import ( | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"github.com/adhocore/gronx" | ||||||
| 	"log" | 	"log" | ||||||
| 	"math" | 	"math" | ||||||
| 	"time" | 	"time" | ||||||
| @@ -84,13 +85,14 @@ func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) { | |||||||
| 	totalAllowance.Progress = float64(progress) / 100.0 | 	totalAllowance.Progress = float64(progress) / 100.0 | ||||||
| 	allowances = append(allowances, totalAllowance) | 	allowances = append(allowances, totalAllowance) | ||||||
|  |  | ||||||
| 	for row := range db.db.Query("select id, name, target, balance, weight from allowances where user_id = ?"). | 	for row := range db.db.Query("select id, name, target, balance, weight, colour from allowances where user_id = ?"). | ||||||
| 		Bind(userId).Range(&err) { | 		Bind(userId).Range(&err) { | ||||||
| 		allowance := Allowance{} | 		allowance := Allowance{} | ||||||
| 		var target, progress int | 		var target, progress, colour int | ||||||
| 		err = row.Scan(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight) | 		err = row.Scan(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight, &colour) | ||||||
| 		allowance.Target = float64(target) / 100.0 | 		allowance.Target = float64(target) / 100.0 | ||||||
| 		allowance.Progress = float64(progress) / 100.0 | 		allowance.Progress = float64(progress) / 100.0 | ||||||
|  | 		allowance.Colour = ConvertColourToString(colour) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, err | 			return nil, err | ||||||
| 		} | 		} | ||||||
| @@ -113,13 +115,14 @@ func (db *Db) GetUserAllowanceById(userId int, allowanceId int) (*Allowance, err | |||||||
| 			return nil, err | 			return nil, err | ||||||
| 		} | 		} | ||||||
| 	} else { | 	} else { | ||||||
| 		var target, progress, colour int64 | 		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 = ?"). | 		err := db.db.Query("select id, name, target, balance, weight, colour from allowances where user_id = ? and id = ?"). | ||||||
| 			Bind(userId, allowanceId). | 			Bind(userId, allowanceId). | ||||||
| 			ScanSingle(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight, &colour) | 			ScanSingle(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight, &colour) | ||||||
| 		allowance.Target = float64(target) / 100.0 | 		allowance.Target = float64(target) / 100.0 | ||||||
| 		allowance.Progress = float64(progress) / 100.0 | 		allowance.Progress = float64(progress) / 100.0 | ||||||
| 		allowance.Colour = fmt.Sprintf("#%06X", colour) | 		allowance.Colour = ConvertColourToString(colour) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, err | 			return nil, err | ||||||
| 		} | 		} | ||||||
| @@ -205,8 +208,9 @@ func (db *Db) CompleteAllowance(userId int, allowanceId int) error { | |||||||
|  |  | ||||||
| 	// Get the cost of the allowance | 	// Get the cost of the allowance | ||||||
| 	var cost int | 	var cost int | ||||||
| 	err = tx.Query("select balance from allowances where id = ? and user_id = ?"). | 	var allowanceName string | ||||||
| 		Bind(allowanceId, userId).ScanSingle(&cost) | 	err = tx.Query("select balance, name from allowances where id = ? and user_id = ?"). | ||||||
|  | 		Bind(allowanceId, userId).ScanSingle(&cost, &allowanceName) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| @@ -219,8 +223,8 @@ func (db *Db) CompleteAllowance(userId int, allowanceId int) error { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Add a history entry | 	// Add a history entry | ||||||
| 	err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). | 	err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)"). | ||||||
| 		Bind(userId, time.Now().Unix(), -cost). | 		Bind(userId, time.Now().Unix(), -cost, fmt.Sprintf("Allowance completed: %s", allowanceName)). | ||||||
| 		Exec() | 		Exec() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| @@ -310,10 +314,20 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) { | |||||||
| 	} | 	} | ||||||
| 	defer tx.MustRollback() | 	defer tx.MustRollback() | ||||||
|  |  | ||||||
|  | 	var nextRun *int64 | ||||||
|  | 	if task.Schedule != nil { | ||||||
|  | 		nextRunTime, err := gronx.NextTick(*task.Schedule, false) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return 0, fmt.Errorf("failed to calculate next run: %w", err) | ||||||
|  | 		} | ||||||
|  | 		nextRunTimeAsInt := nextRunTime.Unix() | ||||||
|  | 		nextRun = &nextRunTimeAsInt | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	// Insert the new task | 	// Insert the new task | ||||||
| 	reward := int(math.Round(task.Reward * 100.0)) | 	reward := int(math.Round(task.Reward * 100.0)) | ||||||
| 	err = tx.Query("insert into tasks (name, reward, assigned) values (?, ?, ?)"). | 	err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) values (?, ?, ?, ?, ?)"). | ||||||
| 		Bind(task.Name, reward, task.Assigned). | 		Bind(task.Name, reward, task.Assigned, task.Schedule, nextRun). | ||||||
| 		Exec() | 		Exec() | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| @@ -337,13 +351,17 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (db *Db) GetTasks() ([]Task, error) { | func (db *Db) GetTasks() ([]Task, error) { | ||||||
| 	tasks := make([]Task, 0) | 	err := db.UpdateScheduledTasks() | ||||||
| 	var err error | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to update scheduled tasks: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	for row := range db.db.Query("select id, name, reward, assigned from tasks").Range(&err) { | 	tasks := make([]Task, 0) | ||||||
|  |  | ||||||
|  | 	for row := range db.db.Query("select id, name, reward, assigned, schedule from tasks where completed is null").Range(&err) { | ||||||
| 		task := Task{} | 		task := Task{} | ||||||
| 		var reward int64 | 		var reward int64 | ||||||
| 		err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned) | 		err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule) | ||||||
| 		task.Reward = float64(reward) / 100.0 | 		task.Reward = float64(reward) / 100.0 | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, err | 			return nil, err | ||||||
| @@ -359,16 +377,78 @@ func (db *Db) GetTasks() ([]Task, error) { | |||||||
| func (db *Db) GetTask(id int) (Task, error) { | func (db *Db) GetTask(id int) (Task, error) { | ||||||
| 	task := Task{} | 	task := Task{} | ||||||
|  |  | ||||||
| 	var reward int64 | 	err := db.UpdateScheduledTasks() | ||||||
| 	err := db.db.Query("select id, name, reward, assigned from tasks where id = ?"). |  | ||||||
| 		Bind(id).ScanSingle(&task.ID, &task.Name, &reward, &task.Assigned) |  | ||||||
| 	task.Reward = float64(reward) / 100.0 |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return Task{}, err | 		return Task{}, fmt.Errorf("failed to update scheduled tasks: %w", err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	var reward int64 | ||||||
|  | 	err = db.db.Query("select id, name, reward, assigned, schedule from tasks where id = ? and completed is null"). | ||||||
|  | 		Bind(id).ScanSingle(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return task, err | ||||||
|  | 	} | ||||||
|  | 	task.Reward = float64(reward) / 100.0 | ||||||
| 	return task, nil | 	return task, nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func (db *Db) UpdateScheduledTasks() error { | ||||||
|  | 	type ScheduledTask struct { | ||||||
|  | 		ID       int | ||||||
|  | 		Schedule string | ||||||
|  | 		Expires  int64 | ||||||
|  | 	} | ||||||
|  | 	tasks := make([]ScheduledTask, 0) | ||||||
|  | 	var err error | ||||||
|  |  | ||||||
|  | 	for row := range db.db.Query("select id, schedule, next_run from tasks where schedule is not null").Range(&err) { | ||||||
|  | 		task := ScheduledTask{} | ||||||
|  | 		err := row.Scan(&task.ID, &task.Schedule, &task.Expires) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 		if time.Now().Unix() >= task.Expires { | ||||||
|  | 			tasks = append(tasks, task) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to fetch scheduled tasks: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	tx, err := db.db.Begin() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	defer tx.MustRollback() | ||||||
|  |  | ||||||
|  | 	for _, task := range tasks { | ||||||
|  | 		nextRun, err := gronx.NextTickAfter(task.Schedule, time.Now(), false) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("failed to calculate next run for task %d: %w", task.ID, err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) select name, reward, assigned, schedule, ? from tasks where id = ?"). | ||||||
|  | 			Bind(nextRun.Unix(), task.ID). | ||||||
|  | 			Exec() | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		err = tx.Query("update tasks set schedule = null where id = ?").Bind(task.ID).Exec() | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		tx.Query("select last_insert_rowid()").MustScanSingle(&task.ID) | ||||||
|  | 		log.Printf("Task %d scheduled for %s", task.ID, nextRun) | ||||||
|  | 	} | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return tx.Commit() | ||||||
|  | } | ||||||
|  |  | ||||||
| func (db *Db) DeleteTask(id int) error { | func (db *Db) DeleteTask(id int) error { | ||||||
| 	tx, err := db.db.Begin() | 	tx, err := db.db.Begin() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| @@ -419,27 +499,52 @@ func (db *Db) CompleteTask(taskId int) error { | |||||||
| 	defer tx.MustRollback() | 	defer tx.MustRollback() | ||||||
|  |  | ||||||
| 	var reward int | 	var reward int | ||||||
| 	err = tx.Query("select reward from tasks where id = ?").Bind(taskId).ScanSingle(&reward) | 	var rewardName string | ||||||
|  | 	err = tx.Query("select reward, name from tasks where id = ?").Bind(taskId).ScanSingle(&reward, &rewardName) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	for userRow := range tx.Query("select id, weight from users").Range(&err) { | 	for userRow := range tx.Query("select id from users").Range(&err) { | ||||||
| 		var userId int | 		var userId int | ||||||
| 		var userWeight float64 | 		err = userRow.Scan(&userId) | ||||||
| 		err = userRow.Scan(&userId, &userWeight) |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		// Add the history entry | 		// Add the history entry | ||||||
| 		err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). | 		err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)"). | ||||||
| 			Bind(userId, time.Now().Unix(), reward). | 			Bind(userId, time.Now().Unix(), reward, fmt.Sprintf("Task completed: %s", rewardName)). | ||||||
| 			Exec() | 			Exec() | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | 		err := db.addDistributedReward(tx, userId, reward) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Remove the task | ||||||
|  | 	err = tx.Query("update tasks set completed=? where id = ?").Bind(time.Now().Unix(), taskId).Exec() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return tx.Commit() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (db *Db) addDistributedReward(tx *mysqlite.Tx, userId int, reward int) error { | ||||||
|  | 	var userWeight float64 | ||||||
|  | 	err := tx.Query("select weight from users where id = ?").Bind(userId).ScanSingle(&userWeight) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	// Calculate the sums of all weights | 	// Calculate the sums of all weights | ||||||
| 	var sumOfWeights float64 | 	var sumOfWeights float64 | ||||||
| 	err = tx.Query("select sum(weight) from allowances where user_id = ? and weight > 0").Bind(userId).ScanSingle(&sumOfWeights) | 	err = tx.Query("select sum(weight) from allowances where user_id = ? and weight > 0").Bind(userId).ScanSingle(&sumOfWeights) | ||||||
| @@ -476,18 +581,7 @@ func (db *Db) CompleteTask(taskId int) error { | |||||||
| 	// Add the remaining reward to the user | 	// Add the remaining reward to the user | ||||||
| 	err = tx.Query("update users set balance = balance + ? where id = ?"). | 	err = tx.Query("update users set balance = balance + ? where id = ?"). | ||||||
| 		Bind(remainingReward, userId).Exec() | 		Bind(remainingReward, userId).Exec() | ||||||
| 		if err != nil { |  | ||||||
| 	return err | 	return err | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Remove the task |  | ||||||
| 	err = tx.Query("delete from tasks where id = ?").Bind(taskId).Exec() |  | ||||||
|  |  | ||||||
| 	return tx.Commit() |  | ||||||
| } | } | ||||||
|  |  | ||||||
| func (db *Db) AddHistory(userId int, allowance *PostHistory) error { | func (db *Db) AddHistory(userId int, allowance *PostHistory) error { | ||||||
| @@ -498,8 +592,8 @@ func (db *Db) AddHistory(userId int, allowance *PostHistory) error { | |||||||
| 	defer tx.MustRollback() | 	defer tx.MustRollback() | ||||||
|  |  | ||||||
| 	amount := int(math.Round(allowance.Allowance * 100.0)) | 	amount := int(math.Round(allowance.Allowance * 100.0)) | ||||||
| 	err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). | 	err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)"). | ||||||
| 		Bind(userId, time.Now().Unix(), amount). | 		Bind(userId, time.Now().Unix(), amount, allowance.Description). | ||||||
| 		Exec() | 		Exec() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| @@ -511,11 +605,11 @@ func (db *Db) GetHistory(userId int) ([]History, error) { | |||||||
| 	history := make([]History, 0) | 	history := make([]History, 0) | ||||||
| 	var err error | 	var err error | ||||||
|  |  | ||||||
| 	for row := range db.db.Query("select amount, `timestamp` from history where user_id = ? order by `timestamp` desc"). | 	for row := range db.db.Query("select amount, `timestamp`, description from history where user_id = ? order by `timestamp` desc"). | ||||||
| 		Bind(userId).Range(&err) { | 		Bind(userId).Range(&err) { | ||||||
| 		allowance := History{} | 		allowance := History{} | ||||||
| 		var timestamp, amount int64 | 		var timestamp, amount int64 | ||||||
| 		err = row.Scan(&amount, ×tamp) | 		err = row.Scan(&amount, ×tamp, &allowance.Description) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, err | 			return nil, err | ||||||
| 		} | 		} | ||||||
| @@ -528,3 +622,92 @@ func (db *Db) GetHistory(userId int) ([]History, error) { | |||||||
| 	} | 	} | ||||||
| 	return history, nil | 	return history, nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func (db *Db) AddAllowanceAmount(userId int, allowanceId int, request AddAllowanceAmountRequest) error { | ||||||
|  | 	tx, err := db.db.Begin() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	defer tx.MustRollback() | ||||||
|  |  | ||||||
|  | 	// Convert amount to integer (cents) | ||||||
|  | 	remainingAmount := int(math.Round(request.Amount * 100)) | ||||||
|  |  | ||||||
|  | 	// Insert history entry | ||||||
|  | 	err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)"). | ||||||
|  | 		Bind(userId, time.Now().Unix(), remainingAmount, request.Description). | ||||||
|  | 		Exec() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if allowanceId == 0 { | ||||||
|  | 		if remainingAmount < 0 { | ||||||
|  | 			var userBalance int | ||||||
|  | 			err = tx.Query("select balance from users where id = ?"). | ||||||
|  | 				Bind(userId).ScanSingle(&userBalance) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 			if remainingAmount > userBalance { | ||||||
|  | 				return fmt.Errorf("cannot remove more than the current balance: %d", userBalance) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		err = tx.Query("update users set balance = balance + ? where id = ?"). | ||||||
|  | 			Bind(remainingAmount, userId).Exec() | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 	} else if remainingAmount < 0 { | ||||||
|  | 		var progress int | ||||||
|  | 		err = tx.Query("select balance from allowances where id = ? and user_id = ?"). | ||||||
|  | 			Bind(allowanceId, userId).ScanSingle(&progress) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if remainingAmount > progress { | ||||||
|  | 			return fmt.Errorf("cannot remove more than the current allowance balance: %d", progress) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). | ||||||
|  | 			Bind(remainingAmount, allowanceId, userId).Exec() | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 	} else { | ||||||
|  | 		// Fetch the target and progress of the specified allowance | ||||||
|  | 		var target, progress int | ||||||
|  | 		err = tx.Query("select target, balance from allowances where id = ? and user_id = ?"). | ||||||
|  | 			Bind(allowanceId, userId).ScanSingle(&target, &progress) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Calculate the amount to add to the current allowance | ||||||
|  | 		toAdd := remainingAmount | ||||||
|  | 		if progress+toAdd > target { | ||||||
|  | 			toAdd = target - progress | ||||||
|  | 		} | ||||||
|  | 		remainingAmount -= toAdd | ||||||
|  |  | ||||||
|  | 		// Update the current allowance | ||||||
|  | 		if toAdd > 0 { | ||||||
|  | 			err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). | ||||||
|  | 				Bind(toAdd, allowanceId, userId).Exec() | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// If there's remaining amount, distribute it to the user's allowances | ||||||
|  | 		if remainingAmount > 0 { | ||||||
|  | 			err = db.addDistributedReward(tx, userId, remainingAmount) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return tx.Commit() | ||||||
|  | } | ||||||
|   | |||||||
| @@ -16,10 +16,12 @@ type UserWithAllowance struct { | |||||||
| type History struct { | type History struct { | ||||||
| 	Allowance   float64   `json:"allowance"` | 	Allowance   float64   `json:"allowance"` | ||||||
| 	Timestamp   time.Time `json:"timestamp"` | 	Timestamp   time.Time `json:"timestamp"` | ||||||
|  | 	Description string    `json:"description"` | ||||||
| } | } | ||||||
|  |  | ||||||
| type PostHistory struct { | type PostHistory struct { | ||||||
| 	Allowance   float64 `json:"allowance"` | 	Allowance   float64 `json:"allowance"` | ||||||
|  | 	Description string  `json:"description"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // Task represents a task in the system. | // Task represents a task in the system. | ||||||
| @@ -27,7 +29,8 @@ type Task struct { | |||||||
| 	ID       int     `json:"id"` | 	ID       int     `json:"id"` | ||||||
| 	Name     string  `json:"name"` | 	Name     string  `json:"name"` | ||||||
| 	Reward   float64 `json:"reward"` | 	Reward   float64 `json:"reward"` | ||||||
| 	Assigned *int    `json:"assigned"` // Pointer to allow null | 	Assigned *int    `json:"assigned"` | ||||||
|  | 	Schedule *string `json:"schedule"` | ||||||
| } | } | ||||||
|  |  | ||||||
| type Allowance struct { | type Allowance struct { | ||||||
| @@ -66,8 +69,14 @@ type CreateTaskRequest struct { | |||||||
| 	Name     string  `json:"name" binding:"required"` | 	Name     string  `json:"name" binding:"required"` | ||||||
| 	Reward   float64 `json:"reward"` | 	Reward   float64 `json:"reward"` | ||||||
| 	Assigned *int    `json:"assigned"` | 	Assigned *int    `json:"assigned"` | ||||||
|  | 	Schedule *string `json:"schedule"` | ||||||
| } | } | ||||||
|  |  | ||||||
| type CreateTaskResponse struct { | type CreateTaskResponse struct { | ||||||
| 	ID int `json:"id"` | 	ID int `json:"id"` | ||||||
| } | } | ||||||
|  |  | ||||||
|  | type AddAllowanceAmountRequest struct { | ||||||
|  | 	Amount      float64 `json:"amount"` | ||||||
|  | 	Description string  `json:"description"` | ||||||
|  | } | ||||||
|   | |||||||
| @@ -6,11 +6,12 @@ require ( | |||||||
| 	gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 | 	gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 | ||||||
| 	github.com/gavv/httpexpect/v2 v2.17.0 | 	github.com/gavv/httpexpect/v2 v2.17.0 | ||||||
| 	github.com/gin-contrib/cors v1.7.5 | 	github.com/gin-contrib/cors v1.7.5 | ||||||
| 	github.com/gin-gonic/gin v1.10.0 | 	github.com/gin-gonic/gin v1.10.1 | ||||||
| ) | ) | ||||||
|  |  | ||||||
| require ( | require ( | ||||||
| 	github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect | 	github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect | ||||||
|  | 	github.com/adhocore/gronx v1.19.6 // indirect | ||||||
| 	github.com/ajg/form v1.5.1 // indirect | 	github.com/ajg/form v1.5.1 // indirect | ||||||
| 	github.com/andybalholm/brotli v1.1.1 // indirect | 	github.com/andybalholm/brotli v1.1.1 // indirect | ||||||
| 	github.com/bytedance/sonic v1.13.2 // indirect | 	github.com/bytedance/sonic v1.13.2 // indirect | ||||||
| @@ -49,7 +50,7 @@ require ( | |||||||
| 	github.com/sergi/go-diff v1.3.1 // indirect | 	github.com/sergi/go-diff v1.3.1 // indirect | ||||||
| 	github.com/stretchr/testify v1.10.0 // indirect | 	github.com/stretchr/testify v1.10.0 // indirect | ||||||
| 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect | 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect | ||||||
| 	github.com/ugorji/go/codec v1.2.12 // indirect | 	github.com/ugorji/go/codec v1.2.14 // indirect | ||||||
| 	github.com/valyala/bytebufferpool v1.0.0 // indirect | 	github.com/valyala/bytebufferpool v1.0.0 // indirect | ||||||
| 	github.com/valyala/fasthttp v1.62.0 // indirect | 	github.com/valyala/fasthttp v1.62.0 // indirect | ||||||
| 	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | 	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | ||||||
| @@ -68,10 +69,10 @@ require ( | |||||||
| 	gopkg.in/fsnotify.v1 v1.4.7 // indirect | 	gopkg.in/fsnotify.v1 v1.4.7 // indirect | ||||||
| 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect | 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect | ||||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||||
| 	modernc.org/libc v1.65.7 // indirect | 	modernc.org/libc v1.65.8 // indirect | ||||||
| 	modernc.org/mathutil v1.7.1 // indirect | 	modernc.org/mathutil v1.7.1 // indirect | ||||||
| 	modernc.org/memory v1.11.0 // indirect | 	modernc.org/memory v1.11.0 // indirect | ||||||
| 	modernc.org/sqlite v1.37.0 // indirect | 	modernc.org/sqlite v1.37.1 // indirect | ||||||
| 	moul.io/http2curl/v2 v2.3.0 // indirect | 	moul.io/http2curl/v2 v2.3.0 // indirect | ||||||
| 	zombiezen.com/go/sqlite v1.4.0 // indirect | 	zombiezen.com/go/sqlite v1.4.2 // indirect | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -2,6 +2,8 @@ gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW | |||||||
| gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= | gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= | ||||||
| github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= | github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= | ||||||
| github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= | github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= | ||||||
|  | github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= | ||||||
|  | github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= | ||||||
| github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= | github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= | ||||||
| github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= | github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= | ||||||
| github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= | github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= | ||||||
| @@ -34,6 +36,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w | |||||||
| github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= | github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= | ||||||
| github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= | ||||||
| github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | ||||||
|  | github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= | ||||||
|  | github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | ||||||
| github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | ||||||
| github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | ||||||
| github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= | ||||||
| @@ -127,6 +131,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS | |||||||
| github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | ||||||
| github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= | ||||||
| github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | ||||||
|  | github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw= | ||||||
|  | github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | ||||||
| github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= | ||||||
| github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= | ||||||
| github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0= | github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0= | ||||||
| @@ -216,6 +222,8 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= | |||||||
| modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | ||||||
| modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= | modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= | ||||||
| modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= | modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= | ||||||
|  | modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q= | ||||||
|  | modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= | ||||||
| modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | ||||||
| modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | ||||||
| modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | ||||||
| @@ -226,6 +234,8 @@ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= | |||||||
| modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= | ||||||
| modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= | modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= | ||||||
| modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= | modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= | ||||||
|  | modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= | ||||||
|  | modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= | ||||||
| modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= | ||||||
| modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= | ||||||
| modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | ||||||
| @@ -235,3 +245,5 @@ moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHc | |||||||
| nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= | ||||||
| zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= | zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= | ||||||
| zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= | zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= | ||||||
|  | zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= | ||||||
|  | zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc= | ||||||
|   | |||||||
| @@ -4,7 +4,9 @@ import ( | |||||||
| 	"context" | 	"context" | ||||||
| 	"embed" | 	"embed" | ||||||
| 	"errors" | 	"errors" | ||||||
|  | 	"fmt" | ||||||
| 	"gitea.seeseepuff.be/seeseemelk/mysqlite" | 	"gitea.seeseepuff.be/seeseemelk/mysqlite" | ||||||
|  | 	"github.com/adhocore/gronx" | ||||||
| 	"log" | 	"log" | ||||||
| 	"net" | 	"net" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| @@ -43,6 +45,11 @@ type ServerConfig struct { | |||||||
| 	Started chan bool | 	Started chan bool | ||||||
| } | } | ||||||
|  |  | ||||||
|  | const DefaultDomain = "localhost:8080" | ||||||
|  |  | ||||||
|  | // The domain that the server is reachable at. | ||||||
|  | var domain = DefaultDomain | ||||||
|  |  | ||||||
| func getUsers(c *gin.Context) { | func getUsers(c *gin.Context) { | ||||||
| 	users, err := db.GetUsers() | 	users, err := db.GetUsers() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| @@ -368,6 +375,56 @@ func completeAllowance(c *gin.Context) { | |||||||
| 	c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance completed successfully"}) | 	c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance completed successfully"}) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func addToAllowance(c *gin.Context) { | ||||||
|  | 	userIdStr := c.Param("userId") | ||||||
|  | 	allowanceIdStr := c.Param("allowanceId") | ||||||
|  |  | ||||||
|  | 	userId, err := strconv.Atoi(userIdStr) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf(ErrInvalidUserID+": %v", err) | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidUserID}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	allowanceId, err := strconv.Atoi(allowanceIdStr) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf("Invalid allowance ID: %v", err) | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid allowance ID"}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	exists, err := db.UserExists(userId) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf(ErrCheckingUserExist, err) | ||||||
|  | 		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if !exists { | ||||||
|  | 		c.JSON(http.StatusNotFound, gin.H{"error": ErrUserNotFound}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var allowanceRequest AddAllowanceAmountRequest | ||||||
|  | 	if err := c.ShouldBindJSON(&allowanceRequest); err != nil { | ||||||
|  | 		log.Printf("Error parsing request body: %v", err) | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	err = db.AddAllowanceAmount(userId, allowanceId, allowanceRequest) | ||||||
|  | 	if errors.Is(err, mysqlite.ErrNoRows) { | ||||||
|  | 		c.JSON(http.StatusNotFound, gin.H{"error": "Allowance not found"}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf("Error completing allowance: %v", err) | ||||||
|  | 		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance completed successfully"}) | ||||||
|  | } | ||||||
|  |  | ||||||
| func createTask(c *gin.Context) { | func createTask(c *gin.Context) { | ||||||
| 	var taskRequest CreateTaskRequest | 	var taskRequest CreateTaskRequest | ||||||
| 	if err := c.ShouldBindJSON(&taskRequest); err != nil { | 	if err := c.ShouldBindJSON(&taskRequest); err != nil { | ||||||
| @@ -381,6 +438,14 @@ func createTask(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	if taskRequest.Schedule != nil { | ||||||
|  | 		valid := gronx.IsValid(*taskRequest.Schedule) | ||||||
|  | 		if !valid { | ||||||
|  | 			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid cron schedule: %s", *taskRequest.Schedule)}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	// If assigned is not nil, check if user exists | 	// If assigned is not nil, check if user exists | ||||||
| 	if taskRequest.Assigned != nil { | 	if taskRequest.Assigned != nil { | ||||||
| 		exists, err := db.UserExists(*taskRequest.Assigned) | 		exists, err := db.UserExists(*taskRequest.Assigned) | ||||||
| @@ -458,6 +523,11 @@ func putTask(c *gin.Context) { | |||||||
| 		c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"}) | 		c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"}) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf("Error getting task: %v", err) | ||||||
|  | 		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	err = db.UpdateTask(taskId, &taskRequest) | 	err = db.UpdateTask(taskId, &taskRequest) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| @@ -539,6 +609,11 @@ func postHistory(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	if historyRequest.Description == "" { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": "Description cannot be empty"}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	exists, err := db.UserExists(userId) | 	exists, err := db.UserExists(userId) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Printf(ErrCheckingUserExist, err) | 		log.Printf(ErrCheckingUserExist, err) | ||||||
| @@ -606,6 +681,7 @@ func start(ctx context.Context, config *ServerConfig) { | |||||||
| 	router.DELETE("/api/user/:userId/allowance/:allowanceId", deleteUserAllowance) | 	router.DELETE("/api/user/:userId/allowance/:allowanceId", deleteUserAllowance) | ||||||
| 	router.PUT("/api/user/:userId/allowance/:allowanceId", putUserAllowance) | 	router.PUT("/api/user/:userId/allowance/:allowanceId", putUserAllowance) | ||||||
| 	router.POST("/api/user/:userId/allowance/:allowanceId/complete", completeAllowance) | 	router.POST("/api/user/:userId/allowance/:allowanceId/complete", completeAllowance) | ||||||
|  | 	router.POST("/api/user/:userId/allowance/:allowanceId/add", addToAllowance) | ||||||
| 	router.POST("/api/tasks", createTask) | 	router.POST("/api/tasks", createTask) | ||||||
| 	router.GET("/api/tasks", getTasks) | 	router.GET("/api/tasks", getTasks) | ||||||
| 	router.GET("/api/task/:taskId", getTask) | 	router.GET("/api/task/:taskId", getTask) | ||||||
| @@ -650,5 +726,10 @@ func main() { | |||||||
| 		config.Datasource = "allowance_planner.db3" | 		config.Datasource = "allowance_planner.db3" | ||||||
| 		log.Printf("Warning: No DB_PATH set, using default of %s", config.Datasource) | 		log.Printf("Warning: No DB_PATH set, using default of %s", config.Datasource) | ||||||
| 	} | 	} | ||||||
|  | 	domain = os.Getenv("DOMAIN") | ||||||
|  | 	if domain == "" { | ||||||
|  | 		domain = DefaultDomain | ||||||
|  | 		log.Printf("Warning: No DOMAIN set, using default of %s", domain) | ||||||
|  | 	} | ||||||
| 	start(context.Background(), &config) | 	start(context.Background(), &config) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ create table users | |||||||
| ( | ( | ||||||
|     id   integer primary key, |     id   integer primary key, | ||||||
|     name text not null, |     name text not null, | ||||||
|     weight real not null default 0.0, |     weight real not null default 10.0, | ||||||
|     balance integer not null default 0 |     balance integer not null default 0 | ||||||
| ) strict; | ) strict; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,2 +1,2 @@ | |||||||
| alter table allowances | alter table allowances | ||||||
| add column colour integer not null; | 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; | ||||||
| @@ -3,6 +3,7 @@ package main | |||||||
| import ( | import ( | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/gin-gonic/gin" | ||||||
|  | 	"log" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"strconv" | 	"strconv" | ||||||
| ) | ) | ||||||
| @@ -26,11 +27,22 @@ func loadWebEndpoints(router *gin.Engine) { | |||||||
| 	router.GET("/completeAllowance", renderCompleteAllowance) | 	router.GET("/completeAllowance", renderCompleteAllowance) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func redirectToPage(c *gin.Context, page string) { | ||||||
|  | 	redirectToPageStatus(c, page, http.StatusSeeOther) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func redirectToPageStatus(c *gin.Context, page string, status int) { | ||||||
|  | 	scheme := c.Request.URL.Scheme | ||||||
|  | 	target := scheme + domain + page | ||||||
|  | 	c.Redirect(status, target) | ||||||
|  | } | ||||||
|  |  | ||||||
| func renderLogin(c *gin.Context) { | func renderLogin(c *gin.Context) { | ||||||
| 	if c.Query("user") != "" { | 	if c.Query("user") != "" { | ||||||
| 		c.SetCookie("user", c.Query("user"), 3600, "/", "localhost", false, true) | 		log.Println("Set cookie for user:", c.Query("user")) | ||||||
|  | 		c.SetCookie("user", c.Query("user"), 3600, "", "", false, true) | ||||||
| 	} | 	} | ||||||
| 	c.Redirect(http.StatusFound, "/") | 	redirectToPage(c, "/") | ||||||
| } | } | ||||||
|  |  | ||||||
| func renderIndex(c *gin.Context) { | func renderIndex(c *gin.Context) { | ||||||
| @@ -59,16 +71,24 @@ func renderCreateTask(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	_, err = db.CreateTask(&CreateTaskRequest{ | 	request := &CreateTaskRequest{ | ||||||
| 		Name:   name, | 		Name:   name, | ||||||
| 		Reward: reward, | 		Reward: reward, | ||||||
| 	}) | 	} | ||||||
|  |  | ||||||
|  | 	schedule := c.PostForm("schedule") | ||||||
|  | 	if schedule != "" { | ||||||
|  | 		request.Schedule = &schedule | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
|  | 	_, err = db.CreateTask(request) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		renderError(c, http.StatusInternalServerError, err) | 		renderError(c, http.StatusInternalServerError, err) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.Redirect(http.StatusFound, "/") | 	redirectToPageStatus(c, "/", http.StatusFound) | ||||||
| } | } | ||||||
|  |  | ||||||
| func renderCompleteTask(c *gin.Context) { | func renderCompleteTask(c *gin.Context) { | ||||||
| @@ -85,7 +105,7 @@ func renderCompleteTask(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.Redirect(http.StatusFound, "/") | 	redirectToPageStatus(c, "/", http.StatusFound) | ||||||
| } | } | ||||||
|  |  | ||||||
| func renderCreateAllowance(c *gin.Context) { | func renderCreateAllowance(c *gin.Context) { | ||||||
| @@ -122,7 +142,7 @@ func renderCreateAllowance(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.Redirect(http.StatusFound, "/") | 	redirectToPageStatus(c, "/", http.StatusFound) | ||||||
| } | } | ||||||
|  |  | ||||||
| func renderCompleteAllowance(c *gin.Context) { | func renderCompleteAllowance(c *gin.Context) { | ||||||
| @@ -144,11 +164,12 @@ func renderCompleteAllowance(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	c.Redirect(http.StatusFound, "/") | 	redirectToPageStatus(c, "/", http.StatusFound) | ||||||
| } | } | ||||||
|  |  | ||||||
| func getCurrentUser(c *gin.Context) *int { | func getCurrentUser(c *gin.Context) *int { | ||||||
| 	currentUserStr, err := c.Cookie("user") | 	currentUserStr, err := c.Cookie("user") | ||||||
|  | 	log.Println("Cookie string:", currentUserStr) | ||||||
| 	if errors.Is(err, http.ErrNoCookie) { | 	if errors.Is(err, http.ErrNoCookie) { | ||||||
| 		renderNoUser(c) | 		renderNoUser(c) | ||||||
| 		return nil | 		return nil | ||||||
| @@ -172,7 +193,7 @@ func getCurrentUser(c *gin.Context) *int { | |||||||
|  |  | ||||||
| func unsetUserCookie(c *gin.Context) { | func unsetUserCookie(c *gin.Context) { | ||||||
| 	c.SetCookie("user", "", -1, "/", "localhost", false, true) | 	c.SetCookie("user", "", -1, "/", "localhost", false, true) | ||||||
| 	c.Redirect(http.StatusFound, "/") | 	redirectToPageStatus(c, "/", http.StatusFound) | ||||||
| } | } | ||||||
|  |  | ||||||
| func renderNoUser(c *gin.Context) { | func renderNoUser(c *gin.Context) { | ||||||
|   | |||||||
| @@ -3,9 +3,11 @@ | |||||||
| <head> | <head> | ||||||
| 	<title>Allowance Planner 2000</title> | 	<title>Allowance Planner 2000</title> | ||||||
| 	<style> | 	<style> | ||||||
|  | 		<!-- | ||||||
| 		tr:hover { | 		tr:hover { | ||||||
| 			background-color: #f0f0f0; | 			background-color: #f0f0f0; | ||||||
| 		} | 		} | ||||||
|  | 		--> | ||||||
| 	</style> | 	</style> | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
| @@ -27,7 +29,7 @@ | |||||||
| 	{{if ne .CurrentUser 0}} | 	{{if ne .CurrentUser 0}} | ||||||
| 		<h2>Allowances</h2> | 		<h2>Allowances</h2> | ||||||
| 		<form action="/createAllowance" method="post"> | 		<form action="/createAllowance" method="post"> | ||||||
| 			<table border="1"> | 			<table border=1> | ||||||
| 				<thead> | 				<thead> | ||||||
| 				<tr> | 				<tr> | ||||||
| 					<th>Name</th> | 					<th>Name</th> | ||||||
| @@ -43,7 +45,7 @@ | |||||||
| 						<td></td> | 						<td></td> | ||||||
| 						<td><label><input type="number" name="target" placeholder="Target"></label></td> | 						<td><label><input type="number" name="target" placeholder="Target"></label></td> | ||||||
| 						<td><label><input type="number" name="weight" placeholder="Weight"></label></td> | 						<td><label><input type="number" name="weight" placeholder="Weight"></label></td> | ||||||
| 						<td><button>Create</button></td> | 						<td><input type="submit" value="Create"></td> | ||||||
| 					</tr> | 					</tr> | ||||||
| 				{{range .Allowances}} | 				{{range .Allowances}} | ||||||
| 					{{if eq .ID 0}} | 					{{if eq .ID 0}} | ||||||
| @@ -79,6 +81,7 @@ | |||||||
| 					<th>Name</th> | 					<th>Name</th> | ||||||
| 					<th>Assigned</th> | 					<th>Assigned</th> | ||||||
| 					<th>Reward</th> | 					<th>Reward</th> | ||||||
|  | 					<th>Schedule</th> | ||||||
| 					<th>Actions</th> | 					<th>Actions</th> | ||||||
| 				</tr> | 				</tr> | ||||||
| 				</thead> | 				</thead> | ||||||
| @@ -94,6 +97,7 @@ | |||||||
| 							{{end}} | 							{{end}} | ||||||
| 						</td> | 						</td> | ||||||
| 						<td>{{.Reward}}</td> | 						<td>{{.Reward}}</td> | ||||||
|  | 						<td>{{.Schedule}}</td> | ||||||
| 						<td> | 						<td> | ||||||
| 							<a href="/completeTask?task={{.ID}}">Mark as completed</a> | 							<a href="/completeTask?task={{.ID}}">Mark as completed</a> | ||||||
| 						</td> | 						</td> | ||||||
| @@ -103,7 +107,8 @@ | |||||||
| 							<td><label><input type="text" name="name" placeholder="Name"></label></td> | 							<td><label><input type="text" name="name" placeholder="Name"></label></td> | ||||||
| 							<td></td> | 							<td></td> | ||||||
| 							<td><label><input type="number" name="reward" placeholder="Reward"></label></td> | 							<td><label><input type="number" name="reward" placeholder="Reward"></label></td> | ||||||
| 							<td><button>Create</button></td> | 							<td><label><input type="text" name="schedule" placeholder="Schedule"></label></td> | ||||||
|  | 							<td><input type="submit" value="Create"></td> | ||||||
| 						</tr> | 						</tr> | ||||||
| 				</tbody> | 				</tbody> | ||||||
| 			</table> | 			</table> | ||||||
|   | |||||||
| @@ -422,7 +422,10 @@ components: | |||||||
|           description: The task name |           description: The task name | ||||||
|         reward: |         reward: | ||||||
|           type: integer |           type: integer | ||||||
|           description: The task reward, in cents |           description: The task reward | ||||||
|  |         schedule: | ||||||
|  |           type: string | ||||||
|  |           description: The schedule of the task, in cron format | ||||||
|         assigned: |         assigned: | ||||||
|           type: integer |           type: integer | ||||||
|           description: The user ID of the user assigned to the task |           description: The user ID of the user assigned to the task | ||||||
|   | |||||||
							
								
								
									
										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' | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/assets/icon-background.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 38 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/assets/icon-foreground.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 163 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/assets/splash.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 130 KiB | 
| @@ -2,7 +2,7 @@ import type { CapacitorConfig } from '@capacitor/cli'; | |||||||
|  |  | ||||||
| const config: CapacitorConfig = { | const config: CapacitorConfig = { | ||||||
|   appId: 'io.ionic.starter', |   appId: 'io.ionic.starter', | ||||||
|   appName: 'allowance-planner-v2', |   appName: 'Allowance Planner V2', | ||||||
|   webDir: 'www' |   webDir: 'www' | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										3478
									
								
								frontend/allowance-planner-v2/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -23,6 +23,7 @@ | |||||||
|     "@angular/platform-browser": "^19.0.0", |     "@angular/platform-browser": "^19.0.0", | ||||||
|     "@angular/platform-browser-dynamic": "^19.0.0", |     "@angular/platform-browser-dynamic": "^19.0.0", | ||||||
|     "@angular/router": "^19.0.0", |     "@angular/router": "^19.0.0", | ||||||
|  |     "@capacitor/android": "7.2.0", | ||||||
|     "@capacitor/app": "7.0.1", |     "@capacitor/app": "7.0.1", | ||||||
|     "@capacitor/core": "7.2.0", |     "@capacitor/core": "7.2.0", | ||||||
|     "@capacitor/haptics": "7.0.1", |     "@capacitor/haptics": "7.0.1", | ||||||
| @@ -46,6 +47,7 @@ | |||||||
|     "@angular/cli": "^19.0.0", |     "@angular/cli": "^19.0.0", | ||||||
|     "@angular/compiler-cli": "^19.0.0", |     "@angular/compiler-cli": "^19.0.0", | ||||||
|     "@angular/language-service": "^19.0.0", |     "@angular/language-service": "^19.0.0", | ||||||
|  |     "@capacitor/assets": "^3.0.5", | ||||||
|     "@capacitor/cli": "7.2.0", |     "@capacitor/cli": "7.2.0", | ||||||
|     "@ionic/angular-toolkit": "^12.0.0", |     "@ionic/angular-toolkit": "^12.0.0", | ||||||
|     "@types/jasmine": "~5.1.0", |     "@types/jasmine": "~5.1.0", | ||||||
|   | |||||||
| @@ -11,7 +11,6 @@ const routes: Routes = [ | |||||||
|     path: '', |     path: '', | ||||||
|     loadChildren: () => import('./pages/tabs/tabs.module').then(m => m.TabsPageModule) |     loadChildren: () => import('./pages/tabs/tabs.module').then(m => m.TabsPageModule) | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
| ]; | ]; | ||||||
| @NgModule({ | @NgModule({ | ||||||
|   imports: [ |   imports: [ | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								frontend/allowance-planner-v2/src/app/models/allowance.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,10 @@ | |||||||
|  | export interface Allowance { | ||||||
|  |     id: number; | ||||||
|  |     name: string; | ||||||
|  |     target: number; | ||||||
|  |     // Current allowance value | ||||||
|  |     progress: number; | ||||||
|  |     // Can be any positive number (backend checks for number relative to each other) | ||||||
|  |     weight: number; | ||||||
|  |     colour: string; | ||||||
|  | } | ||||||
							
								
								
									
										5
									
								
								frontend/allowance-planner-v2/src/app/models/history.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | |||||||
|  | export interface History { | ||||||
|  |     timestamp: string; | ||||||
|  |     allowance: number; | ||||||
|  |     description: string; | ||||||
|  | } | ||||||
| @@ -0,0 +1,17 @@ | |||||||
|  | import { NgModule } from '@angular/core'; | ||||||
|  | import { Routes, RouterModule } from '@angular/router'; | ||||||
|  |  | ||||||
|  | import { AddAllowancePage } from './add-allowance.page'; | ||||||
|  |  | ||||||
|  | const routes: Routes = [ | ||||||
|  |   { | ||||||
|  |     path: '', | ||||||
|  |     component: AddAllowancePage | ||||||
|  |   } | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | @NgModule({ | ||||||
|  |   imports: [RouterModule.forChild(routes)], | ||||||
|  |   exports: [RouterModule], | ||||||
|  | }) | ||||||
|  | export class AddAllowancePageRoutingModule {} | ||||||
| @@ -0,0 +1,23 @@ | |||||||
|  | import { NgModule } from '@angular/core'; | ||||||
|  | import { CommonModule } from '@angular/common'; | ||||||
|  | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | ||||||
|  |  | ||||||
|  | import { IonicModule } from '@ionic/angular'; | ||||||
|  |  | ||||||
|  | import { AddAllowancePageRoutingModule } from './add-allowance-routing.module'; | ||||||
|  |  | ||||||
|  | import { AddAllowancePage } from './add-allowance.page'; | ||||||
|  | import { MatIconModule } from '@angular/material/icon'; | ||||||
|  |  | ||||||
|  | @NgModule({ | ||||||
|  |   imports: [ | ||||||
|  |     CommonModule, | ||||||
|  |     FormsModule, | ||||||
|  |     IonicModule, | ||||||
|  |     AddAllowancePageRoutingModule, | ||||||
|  |     ReactiveFormsModule, | ||||||
|  |     MatIconModule | ||||||
|  |   ], | ||||||
|  |   declarations: [AddAllowancePage] | ||||||
|  | }) | ||||||
|  | export class AddAllowancePageModule {} | ||||||
| @@ -0,0 +1,27 @@ | |||||||
|  | <ion-header [translucent]="true"> | ||||||
|  |   <ion-toolbar> | ||||||
|  |     <div class="toolbar"> | ||||||
|  |       <div class="icon" (click)="navigateBack()"> | ||||||
|  |         <mat-icon>arrow_back</mat-icon> | ||||||
|  |       </div> | ||||||
|  |       <ion-title *ngIf="isAddMode && goalId == 0">Add to Allowance</ion-title> | ||||||
|  |       <ion-title *ngIf="isAddMode && goalId != 0">Add to Goal</ion-title> | ||||||
|  |       <ion-title *ngIf="!isAddMode">Spend Allowance</ion-title> | ||||||
|  |     </div> | ||||||
|  |   </ion-toolbar> | ||||||
|  | </ion-header> | ||||||
|  |  | ||||||
|  | <ion-content [fullscreen]="true"> | ||||||
|  |   <form [formGroup]="form"> | ||||||
|  |     <label>Amount</label> | ||||||
|  |     <input id="amount" type="number" placeholder="0.00" name="price" min="0" value="0" step="0.01" formControlName="amount"/> | ||||||
|  |  | ||||||
|  |     <label>Description</label> | ||||||
|  |     <input id="description" type="text" formControlName="description"/> | ||||||
|  |  | ||||||
|  |     <button type="button" [disabled]="!form.valid" (click)="changeAllowance()"> | ||||||
|  |       <span *ngIf="isAddMode">Add</span> | ||||||
|  |       <span *ngIf="!isAddMode">Spend</span> | ||||||
|  |     </button> | ||||||
|  |   </form> | ||||||
|  | </ion-content> | ||||||
| @@ -0,0 +1,49 @@ | |||||||
|  | .toolbar { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .icon { | ||||||
|  |     margin-left: 5px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | form { | ||||||
|  |     height: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | form, | ||||||
|  | .item { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | input { | ||||||
|  |     border: 1px solid var(--ion-color-primary); | ||||||
|  |     border-radius: 5px; | ||||||
|  |     width: 250px; | ||||||
|  |     height: 40px; | ||||||
|  |     padding-inline: 10px; | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | label { | ||||||
|  |     color: var(--ion-color-primary); | ||||||
|  |     margin-top: 25px; | ||||||
|  |     margin-bottom: 10px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | button { | ||||||
|  |     background-color: var(--ion-color-primary); | ||||||
|  |     border-radius: 5px; | ||||||
|  |     color: white; | ||||||
|  |     padding: 10px; | ||||||
|  |     width: 250px; | ||||||
|  |     margin-top: 100px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | button:disabled, | ||||||
|  | button[disabled]{ | ||||||
|  |     opacity: 0.5; | ||||||
|  | } | ||||||
| @@ -0,0 +1,17 @@ | |||||||
|  | import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||||||
|  | import { AddAllowancePage } from './add-allowance.page'; | ||||||
|  |  | ||||||
|  | describe('AddAllowancePage', () => { | ||||||
|  |   let component: AddAllowancePage; | ||||||
|  |   let fixture: ComponentFixture<AddAllowancePage>; | ||||||
|  |  | ||||||
|  |   beforeEach(() => { | ||||||
|  |     fixture = TestBed.createComponent(AddAllowancePage); | ||||||
|  |     component = fixture.componentInstance; | ||||||
|  |     fixture.detectChanges(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('should create', () => { | ||||||
|  |     expect(component).toBeTruthy(); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -0,0 +1,51 @@ | |||||||
|  | import { Location } from '@angular/common'; | ||||||
|  | import { Component } from '@angular/core'; | ||||||
|  | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; | ||||||
|  | import { ActivatedRoute, Router } from '@angular/router'; | ||||||
|  | import { AllowanceService } from 'src/app/services/allowance.service'; | ||||||
|  |  | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-add-allowance', | ||||||
|  |   templateUrl: './add-allowance.page.html', | ||||||
|  |   styleUrls: ['./add-allowance.page.scss'], | ||||||
|  |   standalone: false, | ||||||
|  | }) | ||||||
|  | export class AddAllowancePage { | ||||||
|  |   public form: FormGroup; | ||||||
|  |   public goalId: number; | ||||||
|  |   public userId: number; | ||||||
|  |   public isAddMode = true; | ||||||
|  | // Marcus' first comment | ||||||
|  | //            b    ........a`.OK  ø¶Ópppppppp--P09OP | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   constructor( | ||||||
|  |     private allowanceService: AllowanceService, | ||||||
|  |     private route: ActivatedRoute, | ||||||
|  |     private formBuilder: FormBuilder, | ||||||
|  |     private router: Router, | ||||||
|  |     private location: Location | ||||||
|  |   ) { | ||||||
|  |     this.userId = this.route.snapshot.params['id']; | ||||||
|  |     this.goalId = this.route.snapshot.params['goalId']; | ||||||
|  |  | ||||||
|  |     this.form = this.formBuilder.group({ | ||||||
|  |       amount: ['', Validators.required], | ||||||
|  |       description: ['', Validators.required] | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   changeAllowance() { | ||||||
|  |     this.allowanceService.addOrSpendAllowance( | ||||||
|  |       this.goalId, | ||||||
|  |       this.userId, | ||||||
|  |       this.form.value.amount, | ||||||
|  |       this.form.value.description | ||||||
|  |     ); | ||||||
|  |     this.router.navigate(['/tabs/allowance', this.userId]); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   navigateBack() { | ||||||
|  |     this.location.back(); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,17 @@ | |||||||
|  | import { NgModule } from '@angular/core'; | ||||||
|  | import { Routes, RouterModule } from '@angular/router'; | ||||||
|  |  | ||||||
|  | import { SpendllowancePage } from './spend-allowance.page'; | ||||||
|  |  | ||||||
|  | const routes: Routes = [ | ||||||
|  |     { | ||||||
|  |         path: '', | ||||||
|  |         component: SpendllowancePage | ||||||
|  |     } | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | @NgModule({ | ||||||
|  |     imports: [RouterModule.forChild(routes)], | ||||||
|  |     exports: [RouterModule], | ||||||
|  | }) | ||||||
|  | export class SpendAllowancePageRoutingModule {} | ||||||
| @@ -0,0 +1,22 @@ | |||||||
|  | import { NgModule } from '@angular/core'; | ||||||
|  | import { CommonModule } from '@angular/common'; | ||||||
|  | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | ||||||
|  |  | ||||||
|  | import { IonicModule } from '@ionic/angular'; | ||||||
|  |  | ||||||
|  | import { SpendAllowancePageRoutingModule } from './spend-allowance-routing.module'; | ||||||
|  | import { SpendllowancePage } from './spend-allowance.page'; | ||||||
|  | import { MatIconModule } from '@angular/material/icon'; | ||||||
|  |  | ||||||
|  | @NgModule({ | ||||||
|  |     imports: [ | ||||||
|  |         CommonModule, | ||||||
|  |         FormsModule, | ||||||
|  |         IonicModule, | ||||||
|  |         SpendAllowancePageRoutingModule, | ||||||
|  |         ReactiveFormsModule, | ||||||
|  |         MatIconModule | ||||||
|  |     ], | ||||||
|  |     declarations: [SpendllowancePage] | ||||||
|  | }) | ||||||
|  | export class SpendAllowancePageModule {} | ||||||
| @@ -0,0 +1,52 @@ | |||||||
|  | import { Location } from '@angular/common'; | ||||||
|  | import { Component } from '@angular/core'; | ||||||
|  | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; | ||||||
|  | import { ActivatedRoute, Router } from '@angular/router'; | ||||||
|  | import { AllowanceService } from 'src/app/services/allowance.service'; | ||||||
|  |  | ||||||
|  | @Component({ | ||||||
|  |     selector: 'app-spend-allowance', | ||||||
|  |     templateUrl: './add-allowance.page.html', | ||||||
|  |     styleUrls: ['./add-allowance.page.scss'], | ||||||
|  |     standalone: false, | ||||||
|  | }) | ||||||
|  | export class SpendllowancePage { | ||||||
|  |     public form: FormGroup; | ||||||
|  |     public goalId: number; | ||||||
|  |     public userId: number; | ||||||
|  |     public isAddMode = false; | ||||||
|  |  | ||||||
|  |     constructor( | ||||||
|  |         private allowanceService: AllowanceService, | ||||||
|  |         private route: ActivatedRoute, | ||||||
|  |         private formBuilder: FormBuilder, | ||||||
|  |         private router: Router, | ||||||
|  |         private location: Location | ||||||
|  |     ) { | ||||||
|  |         this.userId = this.route.snapshot.params['id']; | ||||||
|  |         this.goalId = this.route.snapshot.params['goalId']; | ||||||
|  |  | ||||||
|  |         this.form = this.formBuilder.group({ | ||||||
|  |         amount: ['', Validators.required], | ||||||
|  |         description: ['', Validators.required] | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         this.allowanceService.getAllowanceById(this.goalId, this.userId).subscribe(allowance => { | ||||||
|  |             this.form.controls['amount'].addValidators([Validators.max(allowance.progress)]); | ||||||
|  |         });  | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     changeAllowance() { | ||||||
|  |         this.allowanceService.addOrSpendAllowance( | ||||||
|  |         this.goalId, | ||||||
|  |         this.userId, | ||||||
|  |         -this.form.value.amount, | ||||||
|  |         this.form.value.description | ||||||
|  |         ); | ||||||
|  |         this.router.navigate(['/tabs/allowance', this.userId]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     navigateBack() { | ||||||
|  |         this.location.back(); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -6,6 +6,22 @@ const routes: Routes = [ | |||||||
|   { |   { | ||||||
|     path: ':id', |     path: ':id', | ||||||
|     component: AllowancePage, |     component: AllowancePage, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: ':id/add', | ||||||
|  |     loadChildren: () => import('../edit-allowance/edit-allowance.module').then(m => m.EditAllowancePageModule) | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: ':id/edit/:goalId', | ||||||
|  |     loadChildren: () => import('../edit-allowance/edit-allowance.module').then(m => m.EditAllowancePageModule) | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: ':id/increase/:goalId', | ||||||
|  |     loadChildren: () => import('../add-allowance/add-allowance.module').then(m => m.AddAllowancePageModule) | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: ':id/spend/:goalId', | ||||||
|  |     loadChildren: () => import('../add-allowance/spend-allowance.module').then(m => m.SpendAllowancePageModule) | ||||||
|   } |   } | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,14 +5,22 @@ import { FormsModule } from '@angular/forms'; | |||||||
| import { AllowancePage } from './allowance.page'; | import { AllowancePage } from './allowance.page'; | ||||||
|  |  | ||||||
| import { AllowancePageRoutingModule } from './allowance-routing.module'; | import { AllowancePageRoutingModule } from './allowance-routing.module'; | ||||||
|  | import { AllowanceService } from 'src/app/services/allowance.service'; | ||||||
|  | import { provideHttpClient } from '@angular/common/http'; | ||||||
|  | import { MatIconModule } from '@angular/material/icon'; | ||||||
|  |  | ||||||
| @NgModule({ | @NgModule({ | ||||||
|   imports: [ |   imports: [ | ||||||
|     IonicModule, |     IonicModule, | ||||||
|     CommonModule, |     CommonModule, | ||||||
|     FormsModule, |     FormsModule, | ||||||
|     AllowancePageRoutingModule |     AllowancePageRoutingModule, | ||||||
|  |     MatIconModule | ||||||
|   ], |   ], | ||||||
|   declarations: [AllowancePage] |   declarations: [AllowancePage], | ||||||
|  |   providers: [ | ||||||
|  |     provideHttpClient(), | ||||||
|  |     AllowanceService | ||||||
|  |   ] | ||||||
| }) | }) | ||||||
| export class AllowancePageModule {} | export class AllowancePageModule {} | ||||||
|   | |||||||
| @@ -1,10 +1,72 @@ | |||||||
| <ion-header [translucent]="true" class="ion-no-border"> | <ion-header [translucent]="true" class="ion-no-border"> | ||||||
|   <ion-toolbar> |   <ion-toolbar> | ||||||
|  |     <div class="toolbar"> | ||||||
|       <ion-title> |       <ion-title> | ||||||
|         Allowance |         Allowance | ||||||
|       </ion-title> |       </ion-title> | ||||||
|  |       <button class="top-add-button" (click)="createAllowance()">Add Goal</button> | ||||||
|  |     </div> | ||||||
|   </ion-toolbar> |   </ion-toolbar> | ||||||
| </ion-header> | </ion-header> | ||||||
|  |  | ||||||
| <ion-content> | <ion-content> | ||||||
|  |   <div class="content" *ngIf="allowance$ | async as allowance"> | ||||||
|  |     <div class="bar"> | ||||||
|  |       <div class="distribution">Allowance distribution</div> | ||||||
|  |       <div class="allowance-bar"> | ||||||
|  |         <span | ||||||
|  |           *ngFor="let goal of allowance" | ||||||
|  |           class="partition" | ||||||
|  |           [style.--partition-color]="goal.colour" | ||||||
|  |           [style.width.%]="getPartitionSize(goal, allowance)" | ||||||
|  |         ></span> | ||||||
|  |       </div> | ||||||
|  |       <div class="legend"> | ||||||
|  |         <div class="legend-item" [style.--legend-color]="goal.colour" *ngFor="let goal of allowance"> | ||||||
|  |           <div class="circle"></div> | ||||||
|  |           <div class="title">{{ goal.name }}</div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div | ||||||
|  |       class="goal" | ||||||
|  |       [style.--used-color]="goal.colour" | ||||||
|  |       [ngClass]="{'other-goals': goal.id !== 0}" | ||||||
|  |       *ngFor="let goal of allowance" | ||||||
|  |     > | ||||||
|  |       <div class="main" *ngIf="goal.id === 0; else other_goal"> | ||||||
|  |         <div class="title"> | ||||||
|  |           <div class="name">Main Allowance</div> | ||||||
|  |           <div class="icon" (click)="updateAllowance(goal.id)"> | ||||||
|  |             <mat-icon>settings</mat-icon> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <div class="progress">{{ goal.progress }} SP</div> | ||||||
|  |         <div class="buttons"> | ||||||
|  |           <button class="add-button" (click)="addAllowance(goal.id)">Add</button> | ||||||
|  |           <!-- <button class="move-button">Move</button> --> | ||||||
|  |           <button class="spend-button" (click)="spendAllowance(goal.id)">Spend</button> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <ng-template #other_goal> | ||||||
|  |         <div class="color-wrapper"> | ||||||
|  |           <div> | ||||||
|  |             <div class="title"> | ||||||
|  |               <div class="name">{{ goal.name }}</div> | ||||||
|  |               <div class="icon" (click)="updateAllowance(goal.id)"> | ||||||
|  |                 <mat-icon>settings</mat-icon> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |             <div class="progress">{{ goal.progress }} / {{ goal.target }} SP</div> | ||||||
|  |             <div class="buttons"> | ||||||
|  |               <button class="add-button" (click)="addAllowance(goal.id)">Add</button> | ||||||
|  |               <!-- <button class="move-button">Move</button> --> | ||||||
|  |               <button class="spend-button" [disabled]="!canFinishGoal(goal)" (click)="completeGoal(goal.id)">Finish goal</button> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |           <div class="color" [style.--background]="hexToRgb(goal.colour)" [style.width.%]="getPercentage(goal)"></div> | ||||||
|  |         </div> | ||||||
|  |       </ng-template> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
| </ion-content> | </ion-content> | ||||||
|   | |||||||