Compare commits
	
		
			5 Commits
		
	
	
		
			a08d194cde
			...
			19/post-al
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| b2f532fa22 | |||
| b56738653d | |||
| 5d803bb01c | |||
| 2620d6ee47 | |||
| 74536bd49d | 
| @@ -1,24 +0,0 @@ | ||||
| name: Backend Build and Test | ||||
| on: [push] | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|     runs-on: standard-latest | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|  | ||||
|       - name: Setup Go | ||||
|         uses: actions/setup-go@v5 | ||||
|         with: | ||||
|           go-version: '>=1.24' | ||||
|  | ||||
|       - name: Build | ||||
|         run: | | ||||
|           cd backend | ||||
|           go build . | ||||
|  | ||||
|       - name: Test | ||||
|         run: | | ||||
|           cd backend | ||||
|           go test . -v | ||||
| @@ -1,27 +0,0 @@ | ||||
| name: Backend Deploy | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|     runs-on: standard-latest | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|  | ||||
|       - name: Login | ||||
|         with: | ||||
|           package_rw: ${{ secrets.PACKAGE_RW }} | ||||
|         run: docker login gitea.seeseepuff.be -u seeseemelk -p ${{ secrets.PACKAGE_RW }} | ||||
|  | ||||
|       - name: Build | ||||
|         run: | | ||||
|           cd backend | ||||
|           docker build -t gitea.seeseepuff.be/seeseemelk/allowance-planner:$(git rev-parse --short HEAD) . | ||||
|  | ||||
|       - name: Push | ||||
|         run: | | ||||
|           cd backend | ||||
|           docker push gitea.seeseepuff.be/seeseemelk/allowance-planner:$(git rev-parse --short HEAD) | ||||
| @@ -2,15 +2,8 @@ | ||||
| An improved Allowance Planner app. | ||||
|  | ||||
| ## Running backend | ||||
| In order to run the backend, go to the `backend` directory and run: | ||||
| In order to run the backend, go to the `backend directory and run: | ||||
|  | ||||
| ```bash | ||||
| $ go run . | ||||
| ``` | ||||
|  | ||||
| ## Running frontend | ||||
| In order to run the frontend, go to the `allowance-planner-v2` directory in the `frontend` directory and run: | ||||
|  | ||||
| ```bash | ||||
| $ ionic serve | ||||
| ``` | ||||
|   | ||||
							
								
								
									
										1
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,3 +1,2 @@ | ||||
| *.db3 | ||||
| *.db3-* | ||||
| /allowance_planner | ||||
|   | ||||
| @@ -1,14 +0,0 @@ | ||||
| FROM golang:1.24.2-alpine3.21 | ||||
|  | ||||
| WORKDIR /app | ||||
| COPY go.mod go.sum ./ | ||||
| RUN go mod download | ||||
|  | ||||
| COPY migrations ./migrations/ | ||||
| COPY *.go ./ | ||||
| COPY *.gohtml ./ | ||||
| RUN go build -o /allowance_planner | ||||
|  | ||||
| EXPOSE 8080 | ||||
| ENV GIN_MODE=release | ||||
| CMD ["/allowance_planner"] | ||||
										
											Binary file not shown.
										
									
								
							| @@ -9,7 +9,7 @@ import ( | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	TestHistoryName = "Test History" | ||||
| 	TestAllowanceName = "Test History" | ||||
| ) | ||||
|  | ||||
| func startServer(t *testing.T) *httpexpect.Expect { | ||||
| @@ -62,7 +62,7 @@ func TestGetUserAllowance(t *testing.T) { | ||||
|  | ||||
| 	// Create a new allowance | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestHistoryName, | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 	} | ||||
| @@ -73,7 +73,7 @@ func TestGetUserAllowance(t *testing.T) { | ||||
| 	result.Length().IsEqual(2) | ||||
| 	item := result.Value(1).Object() | ||||
| 	item.Value("id").IsEqual(1) | ||||
| 	item.Value("name").IsEqual(TestHistoryName) | ||||
| 	item.Value("name").IsEqual(TestAllowanceName) | ||||
| 	item.Value("target").IsEqual(5000) | ||||
| 	item.Value("weight").IsEqual(10) | ||||
| 	item.Value("progress").IsEqual(0) | ||||
| @@ -95,7 +95,7 @@ func TestCreateUserAllowance(t *testing.T) { | ||||
|  | ||||
| 	// Create a new allowance | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestHistoryName, | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 	} | ||||
| @@ -120,7 +120,7 @@ func TestCreateUserAllowance(t *testing.T) { | ||||
|  | ||||
| 	allowance := allowances.Value(1).Object() | ||||
| 	allowance.Value("id").IsEqual(allowanceId) | ||||
| 	allowance.Value("name").IsEqual(TestHistoryName) | ||||
| 	allowance.Value("name").IsEqual(TestAllowanceName) | ||||
| 	allowance.Value("target").IsEqual(5000) | ||||
| 	allowance.Value("weight").IsEqual(10) | ||||
| 	allowance.Value("progress").IsEqual(0) | ||||
| @@ -130,7 +130,7 @@ func TestCreateUserAllowanceNoUser(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestHistoryName, | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 	} | ||||
| @@ -171,7 +171,7 @@ func TestCreateUserAllowanceBadId(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestHistoryName, | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 	} | ||||
| @@ -187,7 +187,7 @@ func TestDeleteUserAllowance(t *testing.T) { | ||||
|  | ||||
| 	// Create a new allowance to delete | ||||
| 	createRequest := map[string]interface{}{ | ||||
| 		"name":   TestHistoryName, | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"target": 1000, | ||||
| 		"weight": 5, | ||||
| 	} | ||||
| @@ -434,7 +434,7 @@ func TestPutTaskInvalidTaskId(t *testing.T) { | ||||
| 	e.PUT("/task/999").WithJSON(requestBody).Expect().Status(404) | ||||
| } | ||||
|  | ||||
| func TestPostHistory(t *testing.T) { | ||||
| func TestPostAllowance(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100}).Expect().Status(200) | ||||
| @@ -445,7 +445,7 @@ func TestPostHistory(t *testing.T) { | ||||
| 	response.Value("allowance").Number().IsEqual(100 + 20 - 10) | ||||
| } | ||||
|  | ||||
| func TestPostHistoryInvalidUserId(t *testing.T) { | ||||
| func TestPostAllowanceInvalidUserId(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	e.POST("/user/999/history").WithJSON(PostHistory{Allowance: 100}).Expect(). | ||||
| @@ -472,10 +472,9 @@ func TestGetUserAllowanceById(t *testing.T) { | ||||
|  | ||||
| 	// Create a new allowance | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestHistoryName, | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 		"colour": "#FF5733", | ||||
| 	} | ||||
| 	resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object() | ||||
| 	allowanceId := int(resp.Value("id").Number().Raw()) | ||||
| @@ -483,21 +482,10 @@ func TestGetUserAllowanceById(t *testing.T) { | ||||
| 	// Retrieve the created allowance by ID | ||||
| 	result := e.GET("/user/1/allowance/" + strconv.Itoa(allowanceId)).Expect().Status(200).JSON().Object() | ||||
| 	result.Value("id").IsEqual(allowanceId) | ||||
| 	result.Value("name").IsEqual(TestHistoryName) | ||||
| 	result.Value("name").IsEqual(TestAllowanceName) | ||||
| 	result.Value("target").IsEqual(5000) | ||||
| 	result.Value("weight").IsEqual(10) | ||||
| 	result.Value("progress").IsEqual(0) | ||||
| 	result.Value("colour").IsEqual("#FF5733") | ||||
|  | ||||
| 	resultArray := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||
| 	resultArray.Length().IsEqual(2) | ||||
| 	result = resultArray.Value(1).Object() | ||||
| 	result.Value("id").IsEqual(allowanceId) | ||||
| 	result.Value("name").IsEqual(TestHistoryName) | ||||
| 	result.Value("target").IsEqual(5000) | ||||
| 	result.Value("weight").IsEqual(10) | ||||
| 	result.Value("progress").IsEqual(0) | ||||
| 	result.Value("colour").IsEqual("#FF5733") | ||||
| } | ||||
|  | ||||
| func TestGetUserByAllowanceIdInvalidAllowance(t *testing.T) { | ||||
| @@ -525,10 +513,9 @@ func TestPutAllowanceById(t *testing.T) { | ||||
|  | ||||
| 	// Create a new allowance | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestHistoryName, | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 		"colour": "#FF5733", | ||||
| 	} | ||||
| 	resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object() | ||||
| 	allowanceId := int(resp.Value("id").Number().Raw()) | ||||
| @@ -538,7 +525,6 @@ func TestPutAllowanceById(t *testing.T) { | ||||
| 		"name":   "Updated Allowance", | ||||
| 		"target": 6000, | ||||
| 		"weight": 15, | ||||
| 		"colour": "#3357FF", | ||||
| 	} | ||||
| 	e.PUT("/user/1/allowance/" + strconv.Itoa(allowanceId)).WithJSON(updateRequest).Expect().Status(200) | ||||
|  | ||||
| @@ -548,7 +534,6 @@ func TestPutAllowanceById(t *testing.T) { | ||||
| 	result.Value("name").IsEqual("Updated Allowance") | ||||
| 	result.Value("target").IsEqual(6000) | ||||
| 	result.Value("weight").IsEqual(15) | ||||
| 	result.Value("colour").IsEqual("#3357FF") | ||||
| } | ||||
|  | ||||
| func TestCompleteTask(t *testing.T) { | ||||
| @@ -564,12 +549,12 @@ func TestCompleteTask(t *testing.T) { | ||||
| 	// Create two allowance goals | ||||
| 	e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ | ||||
| 		Name:   "Test Allowance 1", | ||||
| 		Target: 100, | ||||
| 		Target: 1000, | ||||
| 		Weight: 50, | ||||
| 	}).Expect().Status(201) | ||||
| 	e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ | ||||
| 		Name:   "Test Allowance 1", | ||||
| 		Target: 10, | ||||
| 		Target: 1000, | ||||
| 		Weight: 25, | ||||
| 	}).Expect().Status(201) | ||||
|  | ||||
| @@ -583,11 +568,11 @@ func TestCompleteTask(t *testing.T) { | ||||
| 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||
| 	allowances.Length().IsEqual(3) | ||||
| 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||
| 	allowances.Value(0).Object().Value("progress").Number().InDelta(30.34, 0.01) | ||||
| 	allowances.Value(0).Object().Value("progress").Number().IsEqual(26) | ||||
| 	allowances.Value(1).Object().Value("id").Number().IsEqual(1) | ||||
| 	allowances.Value(1).Object().Value("progress").Number().InDelta(60.66, 0.01) | ||||
| 	allowances.Value(1).Object().Value("progress").Number().IsEqual(50) | ||||
| 	allowances.Value(2).Object().Value("id").Number().IsEqual(2) | ||||
| 	allowances.Value(2).Object().Value("progress").Number().IsEqual(10) | ||||
| 	allowances.Value(2).Object().Value("progress").Number().IsEqual(25) | ||||
|  | ||||
| 	// And also for user 2 | ||||
| 	allowances = e.GET("/user/2/allowance").Expect().Status(200).JSON().Array() | ||||
| @@ -605,36 +590,6 @@ func TestCompleteTask(t *testing.T) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestCompleteTaskWithNoWeights(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
| 	taskId := createTestTaskWithAmount(e, 101) | ||||
|  | ||||
| 	e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1) | ||||
|  | ||||
| 	// Ensure main allowance has no weight | ||||
| 	e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{ | ||||
| 		Weight: 0, | ||||
| 	}).Expect().Status(200) | ||||
|  | ||||
| 	// Complete the task | ||||
| 	e.POST("/task/" + strconv.Itoa(taskId) + "/complete").Expect().Status(200) | ||||
|  | ||||
| 	// Verify the task is marked as completed | ||||
| 	e.GET("/task/" + strconv.Itoa(taskId)).Expect().Status(404) | ||||
|  | ||||
| 	// Verify the allowances are updated for user 1 | ||||
| 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||
| 	allowances.Length().IsEqual(1) | ||||
| 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||
| 	allowances.Value(0).Object().Value("progress").Number().InDelta(101.00, 0.01) | ||||
|  | ||||
| 	// And also for user 2 | ||||
| 	allowances = e.GET("/user/2/allowance").Expect().Status(200).JSON().Array() | ||||
| 	allowances.Length().IsEqual(1) | ||||
| 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||
| 	allowances.Value(0).Object().Value("progress").Number().InDelta(101.00, 0.01) | ||||
| } | ||||
|  | ||||
| func TestCompleteTaskAllowanceWeightsSumTo0(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
| 	taskId := createTestTaskWithAmount(e, 101) | ||||
| @@ -673,11 +628,6 @@ func TestCompleteAllowance(t *testing.T) { | ||||
| 	createTestTaskWithAmount(e, 100) | ||||
| 	createTestAllowance(e, "Test Allowance 1", 100, 50) | ||||
|  | ||||
| 	// Update base allowance | ||||
| 	e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{ | ||||
| 		Weight: 0, | ||||
| 	}).Expect().Status(200) | ||||
|  | ||||
| 	// Complete the task | ||||
| 	e.POST("/task/1/complete").Expect().Status(200) | ||||
|  | ||||
| @@ -746,7 +696,7 @@ func getDelta(base time.Time, delta float64) (time.Time, time.Time) { | ||||
| 	return start, end | ||||
| } | ||||
|  | ||||
| func createTestAllowance(e *httpexpect.Expect, name string, target float64, weight float64) { | ||||
| func createTestAllowance(e *httpexpect.Expect, name string, target int, weight float64) { | ||||
| 	e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ | ||||
| 		Name:   name, | ||||
| 		Target: target, | ||||
|   | ||||
| @@ -1,34 +0,0 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| ) | ||||
|  | ||||
| func ConvertStringToColour(colourStr string) (int, error) { | ||||
| 	if len(colourStr) == 0 { | ||||
| 		return 0xFF0000, nil // Default colour if no string is provided | ||||
| 	} | ||||
| 	if colourStr[0] == '#' { | ||||
| 		colourStr = colourStr[1:] | ||||
| 	} | ||||
| 	if len(colourStr) != 6 && len(colourStr) != 3 { | ||||
| 		return 0, errors.New("colour must be a valid hex string") | ||||
| 	} | ||||
| 	var colour int | ||||
| 	_, err := fmt.Sscanf(colourStr, "%x", &colour) | ||||
| 	if err != nil { | ||||
| 		return 0, fmt.Errorf("invalid colour format: %v", err) | ||||
| 	} | ||||
| 	if len(colourStr) == 3 { | ||||
| 		r := (colour & 0xF00) >> 8 | ||||
| 		g := (colour & 0x0F0) >> 4 | ||||
| 		b := (colour & 0x00F) >> 0 | ||||
| 		colour = (r << 16 << 4) | (g << 8 << 4) | (b << 0 << 4) | ||||
| 	} | ||||
| 	return colour, nil | ||||
| } | ||||
|  | ||||
| func ConvertColourToString(colour int) string { | ||||
| 	return fmt.Sprintf("#%06X", colour) | ||||
| } | ||||
| @@ -1,30 +0,0 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"testing" | ||||
| ) | ||||
|  | ||||
| func TestConvertStringToColourWithSign(t *testing.T) { | ||||
| 	colour, err := ConvertStringToColour("#123456") | ||||
| 	require.NoError(t, err) | ||||
| 	require.Equal(t, 0x123456, colour) | ||||
| } | ||||
|  | ||||
| func TestConvertStringToColourWithoutSign(t *testing.T) { | ||||
| 	colour, err := ConvertStringToColour("123456") | ||||
| 	require.NoError(t, err) | ||||
| 	require.Equal(t, 0x123456, colour) | ||||
| } | ||||
|  | ||||
| func TestConvertStringToColourWithSignThreeDigits(t *testing.T) { | ||||
| 	colour, err := ConvertStringToColour("#ABC") | ||||
| 	require.NoError(t, err) | ||||
| 	require.Equal(t, 0xA0B0C0, colour) | ||||
| } | ||||
|  | ||||
| func TestConvertStringToColourWithoutSignThreeDigits(t *testing.T) { | ||||
| 	colour, err := ConvertStringToColour("ABC") | ||||
| 	require.NoError(t, err) | ||||
| 	require.Equal(t, 0xA0B0C0, colour) | ||||
| } | ||||
| @@ -3,7 +3,6 @@ package main | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"log" | ||||
| 	"math" | ||||
| 	"time" | ||||
|  | ||||
| 	"gitea.seeseepuff.be/seeseemelk/mysqlite" | ||||
| @@ -50,10 +49,8 @@ func (db *Db) GetUsers() ([]User, error) { | ||||
| func (db *Db) GetUser(id int) (*UserWithAllowance, error) { | ||||
| 	user := &UserWithAllowance{} | ||||
|  | ||||
| 	var allowance int | ||||
| 	err := db.db.Query("select u.id, u.name, (select ifnull(sum(h.amount), 0) from history h where h.user_id = u.id) from users u where u.id = ?"). | ||||
| 		Bind(id).ScanSingle(&user.ID, &user.Name, &allowance) | ||||
| 	user.Allowance = float64(allowance) / 100.0 | ||||
| 		Bind(id).ScanSingle(&user.ID, &user.Name, &user.Allowance) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| @@ -73,24 +70,18 @@ func (db *Db) UserExists(userId int) (bool, error) { | ||||
| func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) { | ||||
| 	allowances := make([]Allowance, 0) | ||||
| 	var err error | ||||
| 	var progress int64 | ||||
|  | ||||
| 	totalAllowance := Allowance{} | ||||
| 	err = db.db.Query("select balance, weight from users where id = ?").Bind(userId).ScanSingle(&progress, &totalAllowance.Weight) | ||||
| 	err = db.db.Query("select balance, weight from users where id = ?").Bind(userId).ScanSingle(&totalAllowance.Progress, &totalAllowance.Weight) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	totalAllowance.Progress = float64(progress) / 100.0 | ||||
| 	allowances = append(allowances, totalAllowance) | ||||
|  | ||||
| 	for row := range db.db.Query("select id, name, target, balance, weight, colour from allowances where user_id = ?"). | ||||
| 	for row := range db.db.Query("select id, name, target, balance, weight from allowances where user_id = ?"). | ||||
| 		Bind(userId).Range(&err) { | ||||
| 		allowance := Allowance{} | ||||
| 		var target, progress, colour int | ||||
| 		err = row.Scan(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight, &colour) | ||||
| 		allowance.Target = float64(target) / 100.0 | ||||
| 		allowance.Progress = float64(progress) / 100.0 | ||||
| 		allowance.Colour = ConvertColourToString(colour) | ||||
| 		err = row.Scan(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| @@ -105,22 +96,15 @@ func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) { | ||||
| func (db *Db) GetUserAllowanceById(userId int, allowanceId int) (*Allowance, error) { | ||||
| 	allowance := &Allowance{} | ||||
| 	if allowanceId == 0 { | ||||
| 		var progress int64 | ||||
| 		err := db.db.Query("select balance, weight from users where id = ?"). | ||||
| 			Bind(userId).ScanSingle(&progress, &allowance.Weight) | ||||
| 		allowance.Progress = float64(progress) / 100.0 | ||||
| 			Bind(userId).ScanSingle(&allowance.Progress, &allowance.Weight) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 	} else { | ||||
| 		var target, progress int64 | ||||
| 		var colour int | ||||
| 		err := db.db.Query("select id, name, target, balance, weight, colour from allowances where user_id = ? and id = ?"). | ||||
| 		err := db.db.Query("select id, name, target, balance, weight from allowances where user_id = ? and id = ?"). | ||||
| 			Bind(userId, allowanceId). | ||||
| 			ScanSingle(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight, &colour) | ||||
| 		allowance.Target = float64(target) / 100.0 | ||||
| 		allowance.Progress = float64(progress) / 100.0 | ||||
| 		allowance.Colour = ConvertColourToString(colour) | ||||
| 			ScanSingle(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| @@ -144,15 +128,9 @@ func (db *Db) CreateAllowance(userId int, allowance *CreateAllowanceRequest) (in | ||||
| 	} | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	// Convert string colour to a valid hex format | ||||
| 	colour, err := ConvertStringToColour(allowance.Colour) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
|  | ||||
| 	// Insert the new allowance | ||||
| 	err = tx.Query("insert into allowances (user_id, name, target, weight, colour) values (?, ?, ?, ?, ?)"). | ||||
| 		Bind(userId, allowance.Name, int(math.Round(allowance.Target*100.0)), allowance.Weight, colour). | ||||
| 	err = tx.Query("insert into allowances (user_id, name, target, weight) values (?, ?, ?, ?)"). | ||||
| 		Bind(userId, allowance.Name, allowance.Target, allowance.Weight). | ||||
| 		Exec() | ||||
|  | ||||
| 	if err != nil { | ||||
| @@ -264,14 +242,8 @@ func (db *Db) UpdateAllowance(userId int, allowanceId int, allowance *UpdateAllo | ||||
| 	} | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	colour, err := ConvertStringToColour(allowance.Colour) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	target := int(math.Round(allowance.Target * 100.0)) | ||||
| 	err = tx.Query("update allowances set name=?, target=?, weight=?, colour=? where id = ? and user_id = ?"). | ||||
| 		Bind(allowance.Name, target, allowance.Weight, colour, allowanceId, userId). | ||||
| 	err = tx.Query("update allowances set name=?, target=?, weight=? where id = ? and user_id = ?"). | ||||
| 		Bind(allowance.Name, allowance.Target, allowance.Weight, allowanceId, userId). | ||||
| 		Exec() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| @@ -312,9 +284,8 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) { | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	// Insert the new task | ||||
| 	reward := int(math.Round(task.Reward * 100.0)) | ||||
| 	err = tx.Query("insert into tasks (name, reward, assigned) values (?, ?, ?)"). | ||||
| 		Bind(task.Name, reward, task.Assigned). | ||||
| 		Bind(task.Name, task.Reward, task.Assigned). | ||||
| 		Exec() | ||||
|  | ||||
| 	if err != nil { | ||||
| @@ -343,9 +314,7 @@ func (db *Db) GetTasks() ([]Task, error) { | ||||
|  | ||||
| 	for row := range db.db.Query("select id, name, reward, assigned from tasks").Range(&err) { | ||||
| 		task := Task{} | ||||
| 		var reward int64 | ||||
| 		err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned) | ||||
| 		task.Reward = float64(reward) / 100.0 | ||||
| 		err = row.Scan(&task.ID, &task.Name, &task.Reward, &task.Assigned) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| @@ -360,10 +329,8 @@ func (db *Db) GetTasks() ([]Task, error) { | ||||
| func (db *Db) GetTask(id int) (Task, error) { | ||||
| 	task := Task{} | ||||
|  | ||||
| 	var reward int64 | ||||
| 	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 | ||||
| 		Bind(id).ScanSingle(&task.ID, &task.Name, &task.Reward, &task.Assigned) | ||||
| 	if err != nil { | ||||
| 		return Task{}, err | ||||
| 	} | ||||
| @@ -402,9 +369,8 @@ func (db *Db) UpdateTask(id int, task *CreateTaskRequest) error { | ||||
| 	} | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	reward := int(math.Round(task.Reward * 100.0)) | ||||
| 	err = tx.Query("update tasks set name=?, reward=?, assigned=? where id = ?"). | ||||
| 		Bind(task.Name, reward, task.Assigned, id). | ||||
| 		Bind(task.Name, task.Reward, task.Assigned, id). | ||||
| 		Exec() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| @@ -450,20 +416,16 @@ func (db *Db) CompleteTask(taskId int) error { | ||||
|  | ||||
| 		if sumOfWeights > 0 { | ||||
| 			// Distribute the reward to the allowances | ||||
| 			for allowanceRow := range tx.Query("select id, weight, target, balance from allowances where user_id = ? and weight > 0 order by (target - balance) asc").Bind(userId).Range(&err) { | ||||
| 				var allowanceId, allowanceTarget, allowanceBalance int | ||||
| 			for allowanceRow := range tx.Query("select id, weight from allowances where user_id = ? and weight > 0").Bind(userId).Range(&err) { | ||||
| 				var allowanceId int | ||||
| 				var allowanceWeight float64 | ||||
| 				err = allowanceRow.Scan(&allowanceId, &allowanceWeight, &allowanceTarget, &allowanceBalance) | ||||
| 				err = allowanceRow.Scan(&allowanceId, &allowanceWeight) | ||||
| 				if err != nil { | ||||
| 					return err | ||||
| 				} | ||||
|  | ||||
| 				// Calculate the amount to add to the allowance | ||||
| 				amount := int((allowanceWeight / sumOfWeights) * float64(remainingReward)) | ||||
| 				if allowanceBalance+amount > allowanceTarget { | ||||
| 					// If the amount reaches past the target, set it to the target | ||||
| 					amount = allowanceTarget - allowanceBalance | ||||
| 				} | ||||
| 				sumOfWeights -= allowanceWeight | ||||
| 				err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). | ||||
| 					Bind(amount, allowanceId, userId).Exec() | ||||
| @@ -498,9 +460,8 @@ func (db *Db) AddHistory(userId int, allowance *PostHistory) error { | ||||
| 	} | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	amount := int(math.Round(allowance.Allowance * 100.0)) | ||||
| 	err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). | ||||
| 		Bind(userId, time.Now().Unix(), amount). | ||||
| 		Bind(userId, time.Now().Unix(), allowance.Allowance). | ||||
| 		Exec() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| @@ -515,12 +476,11 @@ func (db *Db) GetHistory(userId int) ([]History, error) { | ||||
| 	for row := range db.db.Query("select amount, `timestamp` from history where user_id = ? order by `timestamp` desc"). | ||||
| 		Bind(userId).Range(&err) { | ||||
| 		allowance := History{} | ||||
| 		var timestamp, amount int64 | ||||
| 		err = row.Scan(&amount, ×tamp) | ||||
| 		var timestamp int64 | ||||
| 		err = row.Scan(&allowance.Allowance, ×tamp) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		allowance.Allowance = float64(amount) / 100.0 | ||||
| 		allowance.Timestamp = time.Unix(timestamp, 0) | ||||
| 		history = append(history, allowance) | ||||
| 	} | ||||
|   | ||||
| @@ -8,49 +8,46 @@ type User struct { | ||||
| } | ||||
|  | ||||
| type UserWithAllowance struct { | ||||
| 	ID        int     `json:"id"` | ||||
| 	Name      string  `json:"name"` | ||||
| 	Allowance float64 `json:"allowance"` | ||||
| 	ID        int    `json:"id"` | ||||
| 	Name      string `json:"name"` | ||||
| 	Allowance int    `json:"allowance"` | ||||
| } | ||||
|  | ||||
| type History struct { | ||||
| 	Allowance float64   `json:"allowance"` | ||||
| 	Allowance int       `json:"allowance"` | ||||
| 	Timestamp time.Time `json:"timestamp"` | ||||
| } | ||||
|  | ||||
| type PostHistory struct { | ||||
| 	Allowance float64 `json:"allowance"` | ||||
| 	Allowance int `json:"allowance"` | ||||
| } | ||||
|  | ||||
| // Task represents a task in the system. | ||||
| type Task struct { | ||||
| 	ID       int     `json:"id"` | ||||
| 	Name     string  `json:"name"` | ||||
| 	Reward   float64 `json:"reward"` | ||||
| 	Assigned *int    `json:"assigned"` // Pointer to allow null | ||||
| 	ID       int    `json:"id"` | ||||
| 	Name     string `json:"name"` | ||||
| 	Reward   int    `json:"reward"` | ||||
| 	Assigned *int   `json:"assigned"` // Pointer to allow null | ||||
| } | ||||
|  | ||||
| type Allowance struct { | ||||
| 	ID       int     `json:"id"` | ||||
| 	Name     string  `json:"name"` | ||||
| 	Target   float64 `json:"target"` | ||||
| 	Progress float64 `json:"progress"` | ||||
| 	Target   int     `json:"target"` | ||||
| 	Progress int     `json:"progress"` | ||||
| 	Weight   float64 `json:"weight"` | ||||
| 	Colour   string  `json:"colour"` | ||||
| } | ||||
|  | ||||
| type CreateAllowanceRequest struct { | ||||
| 	Name   string  `json:"name"` | ||||
| 	Target float64 `json:"target"` | ||||
| 	Target int     `json:"target"` | ||||
| 	Weight float64 `json:"weight"` | ||||
| 	Colour string  `json:"colour"` | ||||
| } | ||||
|  | ||||
| type UpdateAllowanceRequest struct { | ||||
| 	Name   string  `json:"name"` | ||||
| 	Target float64 `json:"target"` | ||||
| 	Target int     `json:"target"` | ||||
| 	Weight float64 `json:"weight"` | ||||
| 	Colour string  `json:"colour"` | ||||
| } | ||||
|  | ||||
| type BulkUpdateAllowanceRequest struct { | ||||
| @@ -63,9 +60,9 @@ type CreateGoalResponse struct { | ||||
| } | ||||
|  | ||||
| type CreateTaskRequest struct { | ||||
| 	Name     string  `json:"name" binding:"required"` | ||||
| 	Reward   float64 `json:"reward"` | ||||
| 	Assigned *int    `json:"assigned"` | ||||
| 	Name     string `json:"name" binding:"required"` | ||||
| 	Reward   int    `json:"reward"` | ||||
| 	Assigned *int   `json:"assigned"` | ||||
| } | ||||
|  | ||||
| type CreateTaskResponse struct { | ||||
|   | ||||
| @@ -1,3 +1,7 @@ | ||||
| gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0 h1:kl0VFgvm52UKxJhZpf1hvucxZdOoXY50g/VmzsWH+/8= | ||||
| gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= | ||||
| gitea.seeseepuff.be/seeseemelk/mysqlite v0.13.0 h1:nqSXu5i5fHB1rrx/kfi8Phn/J6eFa2yh02FiGc9U1yg= | ||||
| gitea.seeseepuff.be/seeseemelk/mysqlite v0.13.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= | ||||
| gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW2VzJfItVk4t8sw= | ||||
| gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= | ||||
| github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= | ||||
| @@ -214,10 +218,14 @@ modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= | ||||
| modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= | ||||
| modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= | ||||
| modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | ||||
| modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE= | ||||
| modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA= | ||||
| modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= | ||||
| modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= | ||||
| modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | ||||
| modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | ||||
| modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= | ||||
| modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= | ||||
| modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | ||||
| modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= | ||||
| modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= | ||||
|   | ||||
| @@ -587,14 +587,9 @@ func start(ctx context.Context, config *ServerConfig) { | ||||
| 	defer db.db.MustClose() | ||||
|  | ||||
| 	router := gin.Default() | ||||
|  | ||||
| 	corsConfig := cors.DefaultConfig() | ||||
| 	corsConfig.AllowAllOrigins = true | ||||
| 	router.Use(cors.New(corsConfig)) | ||||
|  | ||||
| 	// Web endpoints | ||||
| 	loadWebEndpoints(router) | ||||
| 	// API endpoints | ||||
| 	router.Use(cors.New(cors.Config{ | ||||
| 		AllowOrigins: []string{"*"}, | ||||
| 	})) | ||||
| 	router.GET("/api/users", getUsers) | ||||
| 	router.GET("/api/user/:userId", getUser) | ||||
| 	router.POST("/api/user/:userId/history", postHistory) | ||||
|   | ||||
| @@ -2,7 +2,7 @@ create table users | ||||
| ( | ||||
|     id   integer primary key, | ||||
|     name text not null, | ||||
|     weight real not null default 10.0, | ||||
|     weight real not null default 0.0, | ||||
|     balance integer not null default 0 | ||||
| ) strict; | ||||
|  | ||||
|   | ||||
| @@ -1,2 +0,0 @@ | ||||
| alter table allowances | ||||
| add column colour integer; | ||||
							
								
								
									
										228
									
								
								backend/web.go
									
									
									
									
									
								
							
							
						
						
									
										228
									
								
								backend/web.go
									
									
									
									
									
								
							| @@ -1,228 +0,0 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| ) | ||||
|  | ||||
| type ViewModel struct { | ||||
| 	Users       []User | ||||
| 	CurrentUser int | ||||
| 	Allowances  []Allowance | ||||
| 	Tasks       []Task | ||||
| 	History     []History | ||||
| 	Error       string | ||||
| } | ||||
|  | ||||
| func loadWebEndpoints(router *gin.Engine) { | ||||
| 	router.LoadHTMLFiles("web.gohtml") | ||||
| 	router.GET("/", renderIndex) | ||||
| 	router.GET("/login", renderLogin) | ||||
| 	router.POST("/createTask", renderCreateTask) | ||||
| 	router.GET("/completeTask", renderCompleteTask) | ||||
| 	router.POST("/createAllowance", renderCreateAllowance) | ||||
| 	router.GET("/completeAllowance", renderCompleteAllowance) | ||||
| } | ||||
|  | ||||
| func renderLogin(c *gin.Context) { | ||||
| 	if c.Query("user") != "" { | ||||
| 		c.SetCookie("user", c.Query("user"), 3600, "/", "localhost", false, true) | ||||
| 	} | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| } | ||||
|  | ||||
| func renderIndex(c *gin.Context) { | ||||
| 	currentUser := getCurrentUser(c) | ||||
| 	if currentUser == nil { | ||||
| 		return | ||||
| 	} | ||||
| 	renderWithUser(c, *currentUser) | ||||
| } | ||||
|  | ||||
| func renderCreateTask(c *gin.Context) { | ||||
| 	currentUser := getCurrentUser(c) | ||||
| 	if currentUser == nil { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	name := c.PostForm("name") | ||||
| 	rewardStr := c.PostForm("reward") | ||||
| 	reward, err := strconv.ParseFloat(rewardStr, 64) | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusBadRequest, err) | ||||
| 		return | ||||
| 	} | ||||
| 	if name == "" || reward <= 0 { | ||||
| 		renderError(c, http.StatusBadRequest, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	_, err = db.CreateTask(&CreateTaskRequest{ | ||||
| 		Name:   name, | ||||
| 		Reward: reward, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| } | ||||
|  | ||||
| func renderCompleteTask(c *gin.Context) { | ||||
| 	taskIDStr := c.Query("task") | ||||
| 	taskID, err := strconv.Atoi(taskIDStr) | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusBadRequest, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	err = db.CompleteTask(taskID) | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| } | ||||
|  | ||||
| func renderCreateAllowance(c *gin.Context) { | ||||
| 	currentUser := getCurrentUser(c) | ||||
| 	if currentUser == nil { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	name := c.PostForm("name") | ||||
| 	targetStr := c.PostForm("target") | ||||
| 	target, err := strconv.ParseFloat(targetStr, 64) | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusBadRequest, err) | ||||
| 		return | ||||
| 	} | ||||
| 	weightStr := c.PostForm("weight") | ||||
| 	weight, err := strconv.ParseFloat(weightStr, 64) | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusBadRequest, err) | ||||
| 		return | ||||
| 	} | ||||
| 	if name == "" || target <= 0 || weight <= 0 { | ||||
| 		renderError(c, http.StatusBadRequest, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	_, err = db.CreateAllowance(*currentUser, &CreateAllowanceRequest{ | ||||
| 		Name:   name, | ||||
| 		Target: target, | ||||
| 		Weight: weight, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| } | ||||
|  | ||||
| func renderCompleteAllowance(c *gin.Context) { | ||||
| 	currentUser := getCurrentUser(c) | ||||
| 	if currentUser == nil { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	allowanceIDStr := c.Query("allowance") | ||||
| 	allowanceID, err := strconv.Atoi(allowanceIDStr) | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusBadRequest, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	err = db.CompleteAllowance(*currentUser, allowanceID) | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| } | ||||
|  | ||||
| func getCurrentUser(c *gin.Context) *int { | ||||
| 	currentUserStr, err := c.Cookie("user") | ||||
| 	if errors.Is(err, http.ErrNoCookie) { | ||||
| 		renderNoUser(c) | ||||
| 		return nil | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		unsetUserCookie(c) | ||||
| 		return nil | ||||
| 	} | ||||
| 	currentUser, err := strconv.Atoi(currentUserStr) | ||||
| 	if err != nil { | ||||
| 		unsetUserCookie(c) | ||||
| 		return nil | ||||
| 	} | ||||
| 	userExists, err := db.UserExists(currentUser) | ||||
| 	if !userExists || err != nil { | ||||
| 		unsetUserCookie(c) | ||||
| 		return nil | ||||
| 	} | ||||
| 	return ¤tUser | ||||
| } | ||||
|  | ||||
| func unsetUserCookie(c *gin.Context) { | ||||
| 	c.SetCookie("user", "", -1, "/", "localhost", false, true) | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| } | ||||
|  | ||||
| func renderNoUser(c *gin.Context) { | ||||
| 	users, err := db.GetUsers() | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.HTML(http.StatusOK, "web.gohtml", ViewModel{ | ||||
| 		Users: users, | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func renderWithUser(c *gin.Context, currentUser int) { | ||||
| 	users, err := db.GetUsers() | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	allowances, err := db.GetUserAllowances(currentUser) | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	tasks, err := db.GetTasks() | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	history, err := db.GetHistory(currentUser) | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.HTML(http.StatusOK, "web.gohtml", ViewModel{ | ||||
| 		Users:       users, | ||||
| 		CurrentUser: currentUser, | ||||
| 		Allowances:  allowances, | ||||
| 		Tasks:       tasks, | ||||
| 		History:     history, | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func renderError(c *gin.Context, statusCode int, err error) { | ||||
| 	c.HTML(statusCode, "web.gohtml", ViewModel{ | ||||
| 		Error: err.Error(), | ||||
| 	}) | ||||
| } | ||||
| @@ -1,132 +0,0 @@ | ||||
| {{- /*gotype: allowance_planner.ViewModel*/}} | ||||
| <html lang="en"> | ||||
| <head> | ||||
| 	<title>Allowance Planner 2000</title> | ||||
| 	<style> | ||||
| 		tr:hover { | ||||
| 			background-color: #f0f0f0; | ||||
| 		} | ||||
| 	</style> | ||||
| </head> | ||||
| <body> | ||||
| <h1>Allowance Planner 2000</h1> | ||||
|  | ||||
| {{if ne .Error ""}} | ||||
| 	<h2>Error</h2> | ||||
| 	<p>{{.Error}}</p> | ||||
| {{else}} | ||||
| 	<h2>Users</h2> | ||||
| 	{{range .Users}} | ||||
| 		{{if eq $.CurrentUser .ID}} | ||||
| 			<strong>{{.Name}}</strong> | ||||
| 		{{else}} | ||||
| 			<a href="/login?user={{.ID}}">{{.Name}}</a> | ||||
| 		{{end}} | ||||
| 	{{end}} | ||||
|  | ||||
| 	{{if ne .CurrentUser 0}} | ||||
| 		<h2>Allowances</h2> | ||||
| 		<form action="/createAllowance" method="post"> | ||||
| 			<table border="1"> | ||||
| 				<thead> | ||||
| 				<tr> | ||||
| 					<th>Name</th> | ||||
| 					<th>Progress</th> | ||||
| 					<th>Target</th> | ||||
| 					<th>Weight</th> | ||||
| 					<th>Actions</th> | ||||
| 				</tr> | ||||
| 				</thead> | ||||
| 				<tbody> | ||||
| 					<tr> | ||||
| 						<td><label><input type="text" name="name" placeholder="Name"></label></td> | ||||
| 						<td></td> | ||||
| 						<td><label><input type="number" name="target" placeholder="Target"></label></td> | ||||
| 						<td><label><input type="number" name="weight" placeholder="Weight"></label></td> | ||||
| 						<td><button>Create</button></td> | ||||
| 					</tr> | ||||
| 				{{range .Allowances}} | ||||
| 					{{if eq .ID 0}} | ||||
| 						<tr> | ||||
| 							<td>Total</td> | ||||
| 							<td>{{.Progress}}</td> | ||||
| 							<td></td> | ||||
| 							<td>{{.Weight}}</td> | ||||
| 						</tr> | ||||
| 					{{else}} | ||||
| 						<tr> | ||||
| 							<td>{{.Name}}</td> | ||||
| 							<td><progress max="{{.Target}}" value="{{.Progress}}"></progress> ({{.Progress}})</td> | ||||
| 							<td>{{.Target}}</td> | ||||
| 							<td>{{.Weight}}</td> | ||||
|                             {{if ge .Progress .Target}} | ||||
| 								<td> | ||||
| 									<a href="/completeAllowance?allowance={{.ID}}">Mark as completed</a> | ||||
| 								</td> | ||||
|                             {{end}} | ||||
| 						</tr> | ||||
| 					{{end}} | ||||
| 				{{end}} | ||||
| 				</tbody> | ||||
| 			</table> | ||||
| 		</form> | ||||
|  | ||||
| 		<h2>Tasks</h2> | ||||
| 		<form method="post" action="/createTask"> | ||||
| 			<table border="1"> | ||||
| 				<thead> | ||||
| 				<tr> | ||||
| 					<th>Name</th> | ||||
| 					<th>Assigned</th> | ||||
| 					<th>Reward</th> | ||||
| 					<th>Actions</th> | ||||
| 				</tr> | ||||
| 				</thead> | ||||
| 				<tbody> | ||||
| 				{{range .Tasks}} | ||||
| 					<tr> | ||||
| 						<td>{{.Name}}</td> | ||||
| 						<td> | ||||
| 							{{if eq .Assigned nil}} | ||||
| 								None | ||||
| 							{{else}} | ||||
| 								{{.Assigned}} | ||||
| 							{{end}} | ||||
| 						</td> | ||||
| 						<td>{{.Reward}}</td> | ||||
| 						<td> | ||||
| 							<a href="/completeTask?task={{.ID}}">Mark as completed</a> | ||||
| 						</td> | ||||
| 					</tr> | ||||
| 				{{end}} | ||||
| 						<tr> | ||||
| 							<td><label><input type="text" name="name" placeholder="Name"></label></td> | ||||
| 							<td></td> | ||||
| 							<td><label><input type="number" name="reward" placeholder="Reward"></label></td> | ||||
| 							<td><button>Create</button></td> | ||||
| 						</tr> | ||||
| 				</tbody> | ||||
| 			</table> | ||||
| 		</form> | ||||
|  | ||||
| 		<h2>History</h2> | ||||
| 		<table border="1"> | ||||
| 			<thead> | ||||
| 			<tr> | ||||
| 				<th>Timestamp</th> | ||||
| 				<th>Allowance</th> | ||||
| 			</tr> | ||||
| 			</thead> | ||||
| 			<tbody> | ||||
| 			{{range .History}} | ||||
| 				<tr> | ||||
| 					<td>{{.Timestamp}}</td> | ||||
| 					<td>{{.Allowance}}</td> | ||||
| 				</tr> | ||||
| 			{{end}} | ||||
| 			</tbody> | ||||
| 		</table> | ||||
| 	{{end}} | ||||
| {{end}} | ||||
| </body> | ||||
| </html> | ||||
| @@ -11,7 +11,6 @@ const routes: Routes = [ | ||||
|     path: '', | ||||
|     loadChildren: () => import('./pages/tabs/tabs.module').then(m => m.TabsPageModule) | ||||
|   }, | ||||
|  | ||||
| ]; | ||||
| @NgModule({ | ||||
|   imports: [ | ||||
|   | ||||
| @@ -3,12 +3,11 @@ import { BrowserModule } from '@angular/platform-browser'; | ||||
| import { RouteReuseStrategy } from '@angular/router'; | ||||
|  | ||||
| import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; | ||||
| import { Drivers } from '@ionic/storage'; | ||||
| import { Drivers, Storage } from '@ionic/storage'; | ||||
| import { IonicStorageModule } from '@ionic/storage-angular'; | ||||
|  | ||||
| import { AppRoutingModule } from './app-routing.module'; | ||||
| import { AppComponent } from './app.component'; | ||||
| import { ReactiveFormsModule } from '@angular/forms'; | ||||
|  | ||||
| @NgModule({ | ||||
|   declarations: [AppComponent], | ||||
| @@ -16,7 +15,6 @@ import { ReactiveFormsModule } from '@angular/forms'; | ||||
|     BrowserModule, | ||||
|     IonicModule.forRoot(), | ||||
|     AppRoutingModule, | ||||
|     ReactiveFormsModule, | ||||
|     IonicStorageModule.forRoot({ | ||||
|       name: '__mydb', | ||||
|       driverOrder: [Drivers.IndexedDB, Drivers.LocalStorage] | ||||
|   | ||||
| @@ -2,5 +2,5 @@ export interface Task { | ||||
|     id: number; | ||||
|     name: string; | ||||
|     reward: number; | ||||
|     assigned: number | null; | ||||
|     assigned: number; | ||||
| } | ||||
| @@ -1,17 +0,0 @@ | ||||
| import { NgModule } from '@angular/core'; | ||||
| import { Routes, RouterModule } from '@angular/router'; | ||||
|  | ||||
| import { EditTaskPage } from './edit-task.page'; | ||||
|  | ||||
| const routes: Routes = [ | ||||
|   { | ||||
|     path: '', | ||||
|     component: EditTaskPage, | ||||
|   } | ||||
| ]; | ||||
|  | ||||
| @NgModule({ | ||||
|   imports: [RouterModule.forChild(routes)], | ||||
|   exports: [RouterModule], | ||||
| }) | ||||
| export class EditTaskPageRoutingModule {} | ||||
| @@ -1,21 +0,0 @@ | ||||
| import { NgModule } from '@angular/core'; | ||||
| import { CommonModule } from '@angular/common'; | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | ||||
|  | ||||
| import { IonicModule } from '@ionic/angular'; | ||||
|  | ||||
| import { EditTaskPageRoutingModule } from './edit-task-routing.module'; | ||||
|  | ||||
| import { EditTaskPage } from './edit-task.page'; | ||||
|  | ||||
| @NgModule({ | ||||
|   imports: [ | ||||
|     CommonModule, | ||||
|     FormsModule, | ||||
|     IonicModule, | ||||
|     EditTaskPageRoutingModule, | ||||
|     ReactiveFormsModule | ||||
|   ], | ||||
|   declarations: [EditTaskPage] | ||||
| }) | ||||
| export class EditTaskPageModule {} | ||||
| @@ -1,33 +0,0 @@ | ||||
| <ion-header [translucent]="true"> | ||||
|   <ion-toolbar> | ||||
|     <div class="toolbar"> | ||||
|       <ion-title *ngIf="isAddMode">Create Task</ion-title> | ||||
|       <ion-title *ngIf="!isAddMode">Edit Task</ion-title> | ||||
|       <button | ||||
|         *ngIf="!isAddMode" | ||||
|         class="remove-button" | ||||
|         (click)="deleteTask()" | ||||
|       >Delete task</button> | ||||
|     </div> | ||||
|   </ion-toolbar> | ||||
| </ion-header> | ||||
|  | ||||
| <ion-content [fullscreen]="true"> | ||||
|   <form [formGroup]="form"> | ||||
|     <label>Task Name</label> | ||||
|     <input id="name" type="text" formControlName="name"/> | ||||
|  | ||||
|     <label>Reward</label> | ||||
|     <input id="name" type="number" formControlName="reward"/> | ||||
|  | ||||
|     <label>Assigned</label> | ||||
|     <select formControlName="assigned"> | ||||
|       <option *ngFor="let user of users" [value]="user.id">{{ user.name }}</option> | ||||
|     </select> | ||||
|  | ||||
|     <button type="button" [disabled]="!form.valid" (click)="submit()"> | ||||
|       <span *ngIf="isAddMode">Add Task</span> | ||||
|       <span *ngIf="!isAddMode">Update Task</span> | ||||
|     </button> | ||||
|   </form> | ||||
| </ion-content> | ||||
| @@ -1,45 +0,0 @@ | ||||
| .toolbar { | ||||
|     display: flex; | ||||
| } | ||||
|  | ||||
| .remove-button { | ||||
|     background-color: var(--ion-color-primary); | ||||
|     margin-right: 15px; | ||||
|     width: 85px; | ||||
|     margin-bottom: 0; | ||||
| } | ||||
|  | ||||
| form { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     height: 100%; | ||||
| } | ||||
|  | ||||
| label { | ||||
|     color: var(--ion-color-primary); | ||||
|     margin-top: 25px; | ||||
|     margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| input, | ||||
| select { | ||||
|     border: 1px solid var(--ion-color-primary); | ||||
|     border-radius: 5px; | ||||
|     width: 250px; | ||||
| } | ||||
|  | ||||
| button { | ||||
|     background-color: var(--ion-color-primary); | ||||
|     border-radius: 5px; | ||||
|     color: white; | ||||
|     padding: 10px; | ||||
|     width: 250px; | ||||
|     margin-top: auto; | ||||
|     margin-bottom: 50px; | ||||
| } | ||||
|  | ||||
| button:disabled, | ||||
| button[disabled]{ | ||||
|     opacity: 0.5; | ||||
| } | ||||
| @@ -1,17 +0,0 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||||
| import { EditTaskPage } from './edit-task.page'; | ||||
|  | ||||
| describe('EditTaskPage', () => { | ||||
|   let component: EditTaskPage; | ||||
|   let fixture: ComponentFixture<EditTaskPage>; | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     fixture = TestBed.createComponent(EditTaskPage); | ||||
|     component = fixture.componentInstance; | ||||
|     fixture.detectChanges(); | ||||
|   }); | ||||
|  | ||||
|   it('should create', () => { | ||||
|     expect(component).toBeTruthy(); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,80 +0,0 @@ | ||||
| import { Location } from '@angular/common'; | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup, Validators } from '@angular/forms'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { User } from 'src/app/models/user'; | ||||
| import { TaskService } from 'src/app/services/task.service'; | ||||
| import { UserService } from 'src/app/services/user.service'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-edit-task', | ||||
|   templateUrl: './edit-task.page.html', | ||||
|   styleUrls: ['./edit-task.page.scss'], | ||||
|   standalone: false, | ||||
| }) | ||||
| export class EditTaskPage implements OnInit { | ||||
|   form: FormGroup; | ||||
|   id: number; | ||||
|   isAddMode: boolean; | ||||
|   users: Array<User> = [{id: 0, name: 'unassigned'}]; | ||||
|  | ||||
|   constructor( | ||||
|     private route: ActivatedRoute, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private taskService: TaskService, | ||||
|     private userService: UserService, | ||||
|     private router: Router | ||||
|   ) { | ||||
|     this.id = this.route.snapshot.params['id']; | ||||
|     this.isAddMode = !this.id; | ||||
|  | ||||
|     this.form = this.formBuilder.group({ | ||||
|       name: ['', Validators.required], | ||||
|       reward: ['', [Validators.required, Validators.pattern("^[0-9]*$")]], | ||||
|       assigned: [0, Validators.required] | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   ngOnInit() { | ||||
|     this.userService.getUserList().subscribe(users => { | ||||
|       this.users.push(...users); | ||||
|     }); | ||||
|  | ||||
|     if (!this.isAddMode) { | ||||
|       this.taskService.getTaskById(this.id).subscribe(task => { | ||||
|         this.form.setValue({ | ||||
|           name: task.name, | ||||
|           reward: task.reward, | ||||
|           assigned: task.assigned !== null ? task.assigned : 0 | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   submit() { | ||||
|     const formValue = this.form.value; | ||||
|     let assigned: number | null = Number(formValue.assigned); | ||||
|     if (assigned === 0) { | ||||
|       assigned = null; | ||||
|     } | ||||
|  | ||||
|     const task = { | ||||
|       name: formValue.name, | ||||
|       reward: formValue.reward, | ||||
|       assigned | ||||
|     } | ||||
|  | ||||
|     if (this.isAddMode) { | ||||
|       this.taskService.createTask(task); | ||||
|     } else { | ||||
|       this.taskService.updateTask(this.id, task); | ||||
|     } | ||||
|  | ||||
|     this.router.navigate(['/tabs/tasks']); | ||||
|   } | ||||
|  | ||||
|   deleteTask() { | ||||
|     this.taskService.deleteTask(this.id); | ||||
|     this.router.navigate(['/tabs/tasks']); | ||||
|   } | ||||
| } | ||||
| @@ -17,7 +17,7 @@ const routes: Routes = [ | ||||
|       }, | ||||
|       { | ||||
|         path: 'tasks', | ||||
|         loadChildren: () => import('../tasks/tasks.module').then(m => m.TasksPageModule), | ||||
|         loadChildren: () => import('../tasks/tasks.module').then(m => m.TasksPageModule) | ||||
|       }, | ||||
|       { | ||||
|         path: '', | ||||
|   | ||||
| @@ -6,9 +6,7 @@ const routes: Routes = [ | ||||
|   { | ||||
|     path: '', | ||||
|     component: TasksPage, | ||||
|   }, | ||||
|   { path: 'add', loadChildren: () => import('../edit-task/edit-task.module').then(m => m.EditTaskPageModule) }, | ||||
|   { path: 'edit/:id', loadChildren: () => import('../edit-task/edit-task.module').then(m => m.EditTaskPageModule) } | ||||
|   } | ||||
| ]; | ||||
|  | ||||
| @NgModule({ | ||||
|   | ||||
| @@ -1,30 +1,23 @@ | ||||
| <ion-header [translucent]="true" class="ion-no-border"> | ||||
|   <ion-toolbar> | ||||
|     <div class="toolbar"> | ||||
|       <ion-title> | ||||
|         Tasks | ||||
|       </ion-title> | ||||
|       <button class="add-button" (click)="createTask()">Add task</button> | ||||
|     </div> | ||||
|     <ion-title> | ||||
|       Tasks | ||||
|     </ion-title> | ||||
|   </ion-toolbar> | ||||
| </ion-header> | ||||
|  | ||||
| <ion-content> | ||||
|   <div class="content"> | ||||
|     <div class="icon"> | ||||
|       <mat-icon>filter_alt</mat-icon> | ||||
|     </div> | ||||
|     <div class="list"> | ||||
|       <div class="task" *ngFor="let task of tasks$ | async"> | ||||
|         <button (click)="completeTask(task.id)">Done</button> | ||||
|         <div (click)="updateTask(task.id)" class="item"> | ||||
|           <div class="name">{{ task.name }}</div> | ||||
|           <div | ||||
|             class="reward" | ||||
|             [ngClass]="{ 'negative': task.reward < 0 }" | ||||
|           >{{ task.reward.toFixed(2) }} SP</div> | ||||
|         </div> | ||||
|       </div> | ||||
|   <div class="icon"> | ||||
|     <mat-icon>filter_alt</mat-icon> | ||||
|   </div> | ||||
|   <div class="list"> | ||||
|     <div class="task" *ngFor="let task of tasks"> | ||||
|       <button>Done</button> | ||||
|       <div class="name">{{ task.name }}</div> | ||||
|       <div | ||||
|         class="reward" | ||||
|         [ngClass]="{ 'negative': task.reward < 0 }" | ||||
|       >{{ task.reward.toFixed(2) }} SP</div> | ||||
|     </div> | ||||
|   </div> | ||||
| </ion-content> | ||||
|   | ||||
| @@ -1,13 +1,3 @@ | ||||
| .toolbar { | ||||
|     display: flex; | ||||
| } | ||||
|  | ||||
| .content { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     height: 100%; | ||||
| } | ||||
|  | ||||
| .icon { | ||||
|     padding: 5px; | ||||
|     display: flex; | ||||
| @@ -33,13 +23,6 @@ mat-icon { | ||||
|     padding: 5px; | ||||
| } | ||||
|  | ||||
| .item { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     align-items: center; | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| .name { | ||||
|     margin-left: 10px; | ||||
|     color: var(--font-color); | ||||
| @@ -61,10 +44,4 @@ button { | ||||
|     border-radius: 10px; | ||||
|     color: white; | ||||
|     background: var(--confirm-button-color); | ||||
| } | ||||
|  | ||||
| .add-button { | ||||
|     background-color: var(--ion-color-primary); | ||||
|     margin-right: 15px; | ||||
|     width: 75px; | ||||
| } | ||||
| @@ -1,50 +1,24 @@ | ||||
| import { ChangeDetectionStrategy, Component } from '@angular/core'; | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { TaskService } from 'src/app/services/task.service'; | ||||
| import { Task } from 'src/app/models/task'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { BehaviorSubject } from 'rxjs'; | ||||
| import { ViewWillEnter } from '@ionic/angular'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-tasks', | ||||
|   templateUrl: 'tasks.page.html', | ||||
|   styleUrls: ['tasks.page.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
|   standalone: false, | ||||
| }) | ||||
| export class TasksPage implements ViewWillEnter { | ||||
|   public tasks$: BehaviorSubject<Array<Task>> = new BehaviorSubject<Array<Task>>([]); | ||||
| export class TasksPage implements OnInit { | ||||
|   public tasks: Array<Task> = []; | ||||
|  | ||||
|   constructor( | ||||
|     private taskService: TaskService, | ||||
|     private router: Router, | ||||
|     private route: ActivatedRoute | ||||
|   ) { | ||||
|     this.getTasks(); | ||||
|     private taskService: TaskService | ||||
|   ) {} | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.taskService.getTaskList().subscribe(tasks => { | ||||
|       this.tasks = tasks; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   ionViewWillEnter(): void { | ||||
|     this.getTasks(); | ||||
|   } | ||||
|  | ||||
|   getTasks() { | ||||
|     setTimeout(() => { | ||||
|       this.taskService.getTaskList().subscribe(tasks => { | ||||
|         this.tasks$.next(tasks); | ||||
|       }); | ||||
|     }, 10); | ||||
|   } | ||||
|  | ||||
|   createTask() { | ||||
|     this.router.navigate(['add'], { relativeTo: this.route }); | ||||
|   } | ||||
|  | ||||
|   updateTask(id: number) { | ||||
|     this.router.navigate(['edit', id], { relativeTo: this.route }); | ||||
|   } | ||||
|  | ||||
|   completeTask(id: number) { | ||||
|     this.taskService.completeTask(id); | ||||
|     this.getTasks(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -7,31 +7,10 @@ import { Task } from '../models/task'; | ||||
|     providedIn: 'root' | ||||
| }) | ||||
| export class TaskService { | ||||
|     private url = 'http://localhost:8080/api'; | ||||
|  | ||||
|     private url = 'http://localhost:8080/api' | ||||
|     constructor(private http: HttpClient) {} | ||||
|  | ||||
|     getTaskList(): Observable<Array<Task>> { | ||||
|         return this.http.get<Task[]>(`${this.url}/tasks`); | ||||
|     } | ||||
|  | ||||
|     getTaskById(taskId: number): Observable<Task> { | ||||
|         return this.http.get<Task>(`${this.url}/task/${taskId}`); | ||||
|     } | ||||
|  | ||||
|     createTask(task: Partial<Task>) { | ||||
|         this.http.post(`${this.url}/tasks`, task).subscribe(); | ||||
|     } | ||||
|  | ||||
|     updateTask(id: number, task: Partial<Task>) { | ||||
|         this.http.put(`${this.url}/task/${id}`, task).subscribe(); | ||||
|     } | ||||
|  | ||||
|     completeTask(id: number) { | ||||
|         this.http.post(`${this.url}/task/${id}/complete`, {}).subscribe(); | ||||
|     } | ||||
|  | ||||
|     deleteTask(id: number) { | ||||
|         this.http.delete(`${this.url}/task/${id}`).subscribe(); | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user