21 Commits

Author SHA1 Message Date
bf3ed6bfea Set default allowance to 10
Some checks failed
Backend Build and Test / build (push) Has been cancelled
2025-05-25 15:07:04 +02:00
67dbcd053d Set default total allowance to 10
All checks were successful
Backend Build and Test / build (push) Successful in 2m5s
2025-05-25 14:58:32 +02:00
f9fb956efd Fix colour not being sent properly by backend (#81)
All checks were successful
Backend Deploy / build (push) Successful in 2m46s
Backend Build and Test / build (push) Successful in 3m0s
Reviewed-on: #81
2025-05-25 14:36:17 +02:00
5a233073c7 Do deploy on main branch (#80)
All checks were successful
Backend Build and Test / build (push) Successful in 3m21s
Backend Deploy / build (push) Successful in 4m3s
Reviewed-on: #80
2025-05-25 14:24:25 +02:00
cd23e72882 Allow null colour (#79)
Some checks failed
Backend Build and Test / build (push) Has been cancelled
Reviewed-on: #79
2025-05-25 14:22:58 +02:00
a82040720a Add gitea workflow (#78)
All checks were successful
Backend Build and Test / build (push) Successful in 2m9s
Reviewed-on: #78
2025-05-25 14:20:38 +02:00
a8e3332723 Add support for colour attribute on allowances in backend (#77)
Closes #76

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

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

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

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

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

View File

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

View File

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

View File

@@ -2,8 +2,15 @@
An improved Allowance Planner app. An improved Allowance Planner app.
## Running backend ## Running backend
In order to run the backend, go to the `backend directory and run: In order to run the backend, go to the `backend` directory and run:
```bash ```bash
$ go run . $ go run .
``` ```
## Running frontend
In order to run the frontend, go to the `allowance-planner-v2` directory in the `frontend` directory and run:
```bash
$ ionic serve
```

1
backend/.gitignore vendored
View File

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

14
backend/Dockerfile Normal file
View File

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

Binary file not shown.

View File

@@ -9,7 +9,7 @@ import (
) )
const ( const (
TestAllowanceName = "Test History" TestHistoryName = "Test History"
) )
func startServer(t *testing.T) *httpexpect.Expect { func startServer(t *testing.T) *httpexpect.Expect {
@@ -62,7 +62,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 +73,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 +95,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 +120,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 +130,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 +171,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 +187,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,
} }
@@ -434,7 +434,7 @@ 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}).Expect().Status(200)
@@ -445,7 +445,7 @@ func TestPostAllowance(t *testing.T) {
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}).Expect().
@@ -472,9 +472,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 +483,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,9 +525,10 @@ func TestPutAllowanceById(t *testing.T) {
// Create a new allowance // Create a new allowance
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"name": TestAllowanceName, "name": TestHistoryName,
"target": 5000, "target": 5000,
"weight": 10, "weight": 10,
"colour": "#FF5733",
} }
resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object() resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object()
allowanceId := int(resp.Value("id").Number().Raw()) allowanceId := int(resp.Value("id").Number().Raw())
@@ -525,6 +538,7 @@ func TestPutAllowanceById(t *testing.T) {
"name": "Updated Allowance", "name": "Updated Allowance",
"target": 6000, "target": 6000,
"weight": 15, "weight": 15,
"colour": "#3357FF",
} }
e.PUT("/user/1/allowance/" + strconv.Itoa(allowanceId)).WithJSON(updateRequest).Expect().Status(200) e.PUT("/user/1/allowance/" + strconv.Itoa(allowanceId)).WithJSON(updateRequest).Expect().Status(200)
@@ -534,6 +548,7 @@ func TestPutAllowanceById(t *testing.T) {
result.Value("name").IsEqual("Updated Allowance") result.Value("name").IsEqual("Updated Allowance")
result.Value("target").IsEqual(6000) result.Value("target").IsEqual(6000)
result.Value("weight").IsEqual(15) result.Value("weight").IsEqual(15)
result.Value("colour").IsEqual("#3357FF")
} }
func TestCompleteTask(t *testing.T) { func TestCompleteTask(t *testing.T) {
@@ -549,12 +564,12 @@ func TestCompleteTask(t *testing.T) {
// Create two allowance goals // Create two allowance goals
e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{
Name: "Test Allowance 1", Name: "Test Allowance 1",
Target: 1000, Target: 100,
Weight: 50, Weight: 50,
}).Expect().Status(201) }).Expect().Status(201)
e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{
Name: "Test Allowance 1", Name: "Test Allowance 1",
Target: 1000, Target: 10,
Weight: 25, Weight: 25,
}).Expect().Status(201) }).Expect().Status(201)
@@ -568,11 +583,11 @@ func TestCompleteTask(t *testing.T) {
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Length().IsEqual(3) allowances.Length().IsEqual(3)
allowances.Value(0).Object().Value("id").Number().IsEqual(0) allowances.Value(0).Object().Value("id").Number().IsEqual(0)
allowances.Value(0).Object().Value("progress").Number().IsEqual(26) allowances.Value(0).Object().Value("progress").Number().InDelta(30.34, 0.01)
allowances.Value(1).Object().Value("id").Number().IsEqual(1) allowances.Value(1).Object().Value("id").Number().IsEqual(1)
allowances.Value(1).Object().Value("progress").Number().IsEqual(50) allowances.Value(1).Object().Value("progress").Number().InDelta(60.66, 0.01)
allowances.Value(2).Object().Value("id").Number().IsEqual(2) allowances.Value(2).Object().Value("id").Number().IsEqual(2)
allowances.Value(2).Object().Value("progress").Number().IsEqual(25) allowances.Value(2).Object().Value("progress").Number().IsEqual(10)
// And also for user 2 // And also for user 2
allowances = e.GET("/user/2/allowance").Expect().Status(200).JSON().Array() allowances = e.GET("/user/2/allowance").Expect().Status(200).JSON().Array()
@@ -590,6 +605,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)
@@ -628,6 +673,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)
@@ -696,7 +746,7 @@ func getDelta(base time.Time, delta float64) (time.Time, time.Time) {
return start, end return start, end
} }
func createTestAllowance(e *httpexpect.Expect, name string, target int, weight float64) { func createTestAllowance(e *httpexpect.Expect, name string, target float64, weight float64) {
e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{
Name: name, Name: name,
Target: target, Target: target,

34
backend/colour.go Normal file
View File

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

30
backend/colour_test.go Normal file
View File

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

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"errors" "errors"
"log" "log"
"math"
"time" "time"
"gitea.seeseepuff.be/seeseemelk/mysqlite" "gitea.seeseepuff.be/seeseemelk/mysqlite"
@@ -49,8 +50,10 @@ func (db *Db) GetUsers() ([]User, error) {
func (db *Db) GetUser(id int) (*UserWithAllowance, error) { func (db *Db) GetUser(id int) (*UserWithAllowance, error) {
user := &UserWithAllowance{} user := &UserWithAllowance{}
var allowance int
err := db.db.Query("select u.id, u.name, (select ifnull(sum(h.amount), 0) from history h where h.user_id = u.id) from users u where u.id = ?"). err := db.db.Query("select u.id, u.name, (select ifnull(sum(h.amount), 0) from history h where h.user_id = u.id) from users u where u.id = ?").
Bind(id).ScanSingle(&user.ID, &user.Name, &user.Allowance) Bind(id).ScanSingle(&user.ID, &user.Name, &allowance)
user.Allowance = float64(allowance) / 100.0
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -70,18 +73,24 @@ func (db *Db) UserExists(userId int) (bool, error) {
func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) { func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) {
allowances := make([]Allowance, 0) allowances := make([]Allowance, 0)
var err error var err error
var progress int64
totalAllowance := Allowance{} totalAllowance := Allowance{}
err = db.db.Query("select balance, weight from users where id = ?").Bind(userId).ScanSingle(&totalAllowance.Progress, &totalAllowance.Weight) err = db.db.Query("select balance, weight from users where id = ?").Bind(userId).ScanSingle(&progress, &totalAllowance.Weight)
if err != nil { if err != nil {
return nil, err return nil, err
} }
totalAllowance.Progress = float64(progress) / 100.0
allowances = append(allowances, totalAllowance) allowances = append(allowances, totalAllowance)
for row := range db.db.Query("select id, name, target, balance, weight from allowances where user_id = ?"). for row := range db.db.Query("select id, name, target, balance, weight, colour from allowances where user_id = ?").
Bind(userId).Range(&err) { Bind(userId).Range(&err) {
allowance := Allowance{} allowance := Allowance{}
err = row.Scan(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight) var target, progress, colour int
err = row.Scan(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight, &colour)
allowance.Target = float64(target) / 100.0
allowance.Progress = float64(progress) / 100.0
allowance.Colour = ConvertColourToString(colour)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -96,15 +105,22 @@ func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) {
func (db *Db) GetUserAllowanceById(userId int, allowanceId int) (*Allowance, error) { func (db *Db) GetUserAllowanceById(userId int, allowanceId int) (*Allowance, error) {
allowance := &Allowance{} allowance := &Allowance{}
if allowanceId == 0 { if allowanceId == 0 {
var progress int64
err := db.db.Query("select balance, weight from users where id = ?"). err := db.db.Query("select balance, weight from users where id = ?").
Bind(userId).ScanSingle(&allowance.Progress, &allowance.Weight) Bind(userId).ScanSingle(&progress, &allowance.Weight)
allowance.Progress = float64(progress) / 100.0
if err != nil { if err != nil {
return nil, err return nil, err
} }
} else { } else {
err := db.db.Query("select id, name, target, balance, weight from allowances where user_id = ? and id = ?"). var target, progress int64
var colour int
err := db.db.Query("select id, name, target, balance, weight, colour from allowances where user_id = ? and id = ?").
Bind(userId, allowanceId). Bind(userId, allowanceId).
ScanSingle(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight) ScanSingle(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight, &colour)
allowance.Target = float64(target) / 100.0
allowance.Progress = float64(progress) / 100.0
allowance.Colour = ConvertColourToString(colour)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -128,9 +144,15 @@ func (db *Db) CreateAllowance(userId int, allowance *CreateAllowanceRequest) (in
} }
defer tx.MustRollback() defer tx.MustRollback()
// Convert string colour to a valid hex format
colour, err := ConvertStringToColour(allowance.Colour)
if err != nil {
return 0, err
}
// Insert the new allowance // Insert the new allowance
err = tx.Query("insert into allowances (user_id, name, target, weight) values (?, ?, ?, ?)"). err = tx.Query("insert into allowances (user_id, name, target, weight, colour) values (?, ?, ?, ?, ?)").
Bind(userId, allowance.Name, allowance.Target, allowance.Weight). Bind(userId, allowance.Name, int(math.Round(allowance.Target*100.0)), allowance.Weight, colour).
Exec() Exec()
if err != nil { if err != nil {
@@ -242,8 +264,14 @@ func (db *Db) UpdateAllowance(userId int, allowanceId int, allowance *UpdateAllo
} }
defer tx.MustRollback() defer tx.MustRollback()
err = tx.Query("update allowances set name=?, target=?, weight=? where id = ? and user_id = ?"). colour, err := ConvertStringToColour(allowance.Colour)
Bind(allowance.Name, allowance.Target, allowance.Weight, allowanceId, userId). if err != nil {
return err
}
target := int(math.Round(allowance.Target * 100.0))
err = tx.Query("update allowances set name=?, target=?, weight=?, colour=? where id = ? and user_id = ?").
Bind(allowance.Name, target, allowance.Weight, colour, allowanceId, userId).
Exec() Exec()
if err != nil { if err != nil {
return err return err
@@ -284,8 +312,9 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) {
defer tx.MustRollback() defer tx.MustRollback()
// Insert the new task // Insert the new task
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) values (?, ?, ?)").
Bind(task.Name, task.Reward, task.Assigned). Bind(task.Name, reward, task.Assigned).
Exec() Exec()
if err != nil { if err != nil {
@@ -314,7 +343,9 @@ func (db *Db) GetTasks() ([]Task, error) {
for row := range db.db.Query("select id, name, reward, assigned from tasks").Range(&err) { for row := range db.db.Query("select id, name, reward, assigned from tasks").Range(&err) {
task := Task{} task := Task{}
err = row.Scan(&task.ID, &task.Name, &task.Reward, &task.Assigned) var reward int64
err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned)
task.Reward = float64(reward) / 100.0
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -329,8 +360,10 @@ 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.db.Query("select id, name, reward, assigned from tasks where id = ?"). err := db.db.Query("select id, name, reward, assigned from tasks where id = ?").
Bind(id).ScanSingle(&task.ID, &task.Name, &task.Reward, &task.Assigned) 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{}, err
} }
@@ -369,8 +402,9 @@ func (db *Db) UpdateTask(id int, task *CreateTaskRequest) error {
} }
defer tx.MustRollback() defer tx.MustRollback()
reward := int(math.Round(task.Reward * 100.0))
err = tx.Query("update tasks set name=?, reward=?, assigned=? where id = ?"). err = tx.Query("update tasks set name=?, reward=?, assigned=? where id = ?").
Bind(task.Name, task.Reward, task.Assigned, id). Bind(task.Name, reward, task.Assigned, id).
Exec() Exec()
if err != nil { if err != nil {
return err return err
@@ -416,16 +450,20 @@ func (db *Db) CompleteTask(taskId int) error {
if sumOfWeights > 0 { if sumOfWeights > 0 {
// Distribute the reward to the allowances // Distribute the reward to the allowances
for allowanceRow := range tx.Query("select id, weight from allowances where user_id = ? and weight > 0").Bind(userId).Range(&err) { for allowanceRow := range tx.Query("select id, weight, target, balance from allowances where user_id = ? and weight > 0 order by (target - balance) asc").Bind(userId).Range(&err) {
var allowanceId int var allowanceId, allowanceTarget, allowanceBalance int
var allowanceWeight float64 var allowanceWeight float64
err = allowanceRow.Scan(&allowanceId, &allowanceWeight) err = allowanceRow.Scan(&allowanceId, &allowanceWeight, &allowanceTarget, &allowanceBalance)
if err != nil { if err != nil {
return err return err
} }
// Calculate the amount to add to the allowance // Calculate the amount to add to the allowance
amount := int((allowanceWeight / sumOfWeights) * float64(remainingReward)) amount := int((allowanceWeight / sumOfWeights) * float64(remainingReward))
if allowanceBalance+amount > allowanceTarget {
// If the amount reaches past the target, set it to the target
amount = allowanceTarget - allowanceBalance
}
sumOfWeights -= allowanceWeight sumOfWeights -= allowanceWeight
err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?").
Bind(amount, allowanceId, userId).Exec() Bind(amount, allowanceId, userId).Exec()
@@ -460,8 +498,9 @@ func (db *Db) AddHistory(userId int, allowance *PostHistory) error {
} }
defer tx.MustRollback() defer tx.MustRollback()
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) values (?, ?, ?)").
Bind(userId, time.Now().Unix(), allowance.Allowance). Bind(userId, time.Now().Unix(), amount).
Exec() Exec()
if err != nil { if err != nil {
return err return err
@@ -476,11 +515,12 @@ 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"). for row := range db.db.Query("select amount, `timestamp` from history where user_id = ? order by `timestamp` desc").
Bind(userId).Range(&err) { Bind(userId).Range(&err) {
allowance := History{} allowance := History{}
var timestamp int64 var timestamp, amount int64
err = row.Scan(&allowance.Allowance, &timestamp) err = row.Scan(&amount, &timestamp)
if err != nil { if err != nil {
return nil, err return nil, err
} }
allowance.Allowance = float64(amount) / 100.0
allowance.Timestamp = time.Unix(timestamp, 0) allowance.Timestamp = time.Unix(timestamp, 0)
history = append(history, allowance) history = append(history, allowance)
} }

View File

@@ -8,46 +8,49 @@ type User struct {
} }
type UserWithAllowance struct { type UserWithAllowance struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Allowance int `json:"allowance"` Allowance float64 `json:"allowance"`
} }
type History struct { type History struct {
Allowance int `json:"allowance"` Allowance float64 `json:"allowance"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
} }
type PostHistory struct { type PostHistory struct {
Allowance int `json:"allowance"` Allowance float64 `json:"allowance"`
} }
// Task represents a task in the system. // Task represents a task in the system.
type Task struct { type Task struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Reward int `json:"reward"` Reward float64 `json:"reward"`
Assigned *int `json:"assigned"` // Pointer to allow null Assigned *int `json:"assigned"` // Pointer to allow null
} }
type Allowance struct { type Allowance struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Target int `json:"target"` Target float64 `json:"target"`
Progress int `json:"progress"` Progress float64 `json:"progress"`
Weight float64 `json:"weight"` Weight float64 `json:"weight"`
Colour string `json:"colour"`
} }
type CreateAllowanceRequest struct { type CreateAllowanceRequest struct {
Name string `json:"name"` Name string `json:"name"`
Target int `json:"target"` Target float64 `json:"target"`
Weight float64 `json:"weight"` Weight float64 `json:"weight"`
Colour string `json:"colour"`
} }
type UpdateAllowanceRequest struct { type UpdateAllowanceRequest struct {
Name string `json:"name"` Name string `json:"name"`
Target int `json:"target"` Target float64 `json:"target"`
Weight float64 `json:"weight"` Weight float64 `json:"weight"`
Colour string `json:"colour"`
} }
type BulkUpdateAllowanceRequest struct { type BulkUpdateAllowanceRequest struct {
@@ -60,9 +63,9 @@ type CreateGoalResponse struct {
} }
type CreateTaskRequest struct { type CreateTaskRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Reward int `json:"reward"` Reward float64 `json:"reward"`
Assigned *int `json:"assigned"` Assigned *int `json:"assigned"`
} }
type CreateTaskResponse struct { type CreateTaskResponse struct {

View File

@@ -1,7 +1,3 @@
gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0 h1:kl0VFgvm52UKxJhZpf1hvucxZdOoXY50g/VmzsWH+/8=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.13.0 h1:nqSXu5i5fHB1rrx/kfi8Phn/J6eFa2yh02FiGc9U1yg=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.13.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW2VzJfItVk4t8sw= gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW2VzJfItVk4t8sw=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
@@ -218,14 +214,10 @@ modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE=
modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=

View File

@@ -587,9 +587,14 @@ func start(ctx context.Context, config *ServerConfig) {
defer db.db.MustClose() defer db.db.MustClose()
router := gin.Default() router := gin.Default()
router.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, corsConfig := cors.DefaultConfig()
})) corsConfig.AllowAllOrigins = true
router.Use(cors.New(corsConfig))
// Web endpoints
loadWebEndpoints(router)
// API endpoints
router.GET("/api/users", getUsers) router.GET("/api/users", getUsers)
router.GET("/api/user/:userId", getUser) router.GET("/api/user/:userId", getUser)
router.POST("/api/user/:userId/history", postHistory) router.POST("/api/user/:userId/history", postHistory)

View File

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

View File

@@ -0,0 +1,2 @@
alter table allowances
add column colour integer;

View File

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

228
backend/web.go Normal file
View File

@@ -0,0 +1,228 @@
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 &currentUser
}
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(),
})
}

132
backend/web.gohtml Normal file
View File

@@ -0,0 +1,132 @@
{{- /*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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
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 {}

View File

@@ -0,0 +1,33 @@
<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>

View File

@@ -0,0 +1,45 @@
.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;
}

View File

@@ -0,0 +1,17 @@
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();
});
});

View File

@@ -0,0 +1,80 @@
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']);
}
}

View File

@@ -17,7 +17,7 @@ const routes: Routes = [
}, },
{ {
path: 'tasks', path: 'tasks',
loadChildren: () => import('../tasks/tasks.module').then(m => m.TasksPageModule) loadChildren: () => import('../tasks/tasks.module').then(m => m.TasksPageModule),
}, },
{ {
path: '', path: '',

View File

@@ -6,7 +6,9 @@ const routes: Routes = [
{ {
path: '', path: '',
component: TasksPage, 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({ @NgModule({

View File

@@ -1,23 +1,30 @@
<ion-header [translucent]="true" class="ion-no-border"> <ion-header [translucent]="true" class="ion-no-border">
<ion-toolbar> <ion-toolbar>
<ion-title> <div class="toolbar">
Tasks <ion-title>
</ion-title> Tasks
</ion-title>
<button class="add-button" (click)="createTask()">Add task</button>
</div>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content> <ion-content>
<div class="icon"> <div class="content">
<mat-icon>filter_alt</mat-icon> <div class="icon">
</div> <mat-icon>filter_alt</mat-icon>
<div class="list"> </div>
<div class="task" *ngFor="let task of tasks"> <div class="list">
<button>Done</button> <div class="task" *ngFor="let task of tasks$ | async">
<div class="name">{{ task.name }}</div> <button (click)="completeTask(task.id)">Done</button>
<div <div (click)="updateTask(task.id)" class="item">
class="reward" <div class="name">{{ task.name }}</div>
[ngClass]="{ 'negative': task.reward < 0 }" <div
>{{ task.reward.toFixed(2) }} SP</div> class="reward"
[ngClass]="{ 'negative': task.reward < 0 }"
>{{ task.reward.toFixed(2) }} SP</div>
</div>
</div>
</div> </div>
</div> </div>
</ion-content> </ion-content>

View File

@@ -1,3 +1,13 @@
.toolbar {
display: flex;
}
.content {
display: flex;
flex-direction: column;
height: 100%;
}
.icon { .icon {
padding: 5px; padding: 5px;
display: flex; display: flex;
@@ -23,6 +33,13 @@ mat-icon {
padding: 5px; padding: 5px;
} }
.item {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
}
.name { .name {
margin-left: 10px; margin-left: 10px;
color: var(--font-color); color: var(--font-color);
@@ -44,4 +61,10 @@ button {
border-radius: 10px; border-radius: 10px;
color: white; color: white;
background: var(--confirm-button-color); background: var(--confirm-button-color);
}
.add-button {
background-color: var(--ion-color-primary);
margin-right: 15px;
width: 75px;
} }

View File

@@ -1,24 +1,50 @@
import { Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { TaskService } from 'src/app/services/task.service'; import { TaskService } from 'src/app/services/task.service';
import { Task } from 'src/app/models/task'; import { Task } from 'src/app/models/task';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { ViewWillEnter } from '@ionic/angular';
@Component({ @Component({
selector: 'app-tasks', selector: 'app-tasks',
templateUrl: 'tasks.page.html', templateUrl: 'tasks.page.html',
styleUrls: ['tasks.page.scss'], styleUrls: ['tasks.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false, standalone: false,
}) })
export class TasksPage implements OnInit { export class TasksPage implements ViewWillEnter {
public tasks: Array<Task> = []; public tasks$: BehaviorSubject<Array<Task>> = new BehaviorSubject<Array<Task>>([]);
constructor( constructor(
private taskService: TaskService private taskService: TaskService,
) {} private router: Router,
private route: ActivatedRoute
ngOnInit(): void { ) {
this.taskService.getTaskList().subscribe(tasks => { this.getTasks();
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();
}
} }

View File

@@ -7,10 +7,31 @@ import { Task } from '../models/task';
providedIn: 'root' providedIn: 'root'
}) })
export class TaskService { export class TaskService {
private url = 'http://localhost:8080/api' private url = 'http://localhost:8080/api';
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
getTaskList(): Observable<Array<Task>> { getTaskList(): Observable<Array<Task>> {
return this.http.get<Task[]>(`${this.url}/tasks`); 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();
}
} }