From 426e456ba7de4ee000dfbab9150c7784cd31c5bf Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Sat, 24 May 2025 06:11:39 +0200 Subject: [PATCH] Add lite website (#73) Reviewed-on: https://gitea.seeseepuff.be/seeseemelk/allowance_planner_2000/pulls/73 --- backend/.gitignore | 1 + backend/api_test.go | 42 +------- backend/db.go | 10 +- backend/go.sum | 8 -- backend/main.go | 3 + backend/web.go | 228 ++++++++++++++++++++++++++++++++++++++++++++ backend/web.gohtml | 132 +++++++++++++++++++++++++ 7 files changed, 376 insertions(+), 48 deletions(-) create mode 100644 backend/web.go create mode 100644 backend/web.gohtml diff --git a/backend/.gitignore b/backend/.gitignore index 0f580ed..23487f5 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,3 @@ *.db3 *.db3-* +/allowance_planner diff --git a/backend/api_test.go b/backend/api_test.go index f1b6f46..4bf31d4 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -549,12 +549,12 @@ func TestCompleteTask(t *testing.T) { // Create two allowance goals e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ Name: "Test Allowance 1", - Target: 1000, + Target: 100, Weight: 50, }).Expect().Status(201) e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ Name: "Test Allowance 1", - Target: 1000, + Target: 10, Weight: 25, }).Expect().Status(201) @@ -568,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().IsEqual(26) + allowances.Value(0).Object().Value("progress").Number().IsEqual(31) 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().IsEqual(60) 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 allowances = e.GET("/user/2/allowance").Expect().Status(200).JSON().Array() @@ -623,38 +623,6 @@ func TestCompleteTaskInvalidId(t *testing.T) { e.POST("/task/999/complete").Expect().Status(404) } -func TestCompleteTaskAllowanceWeightsSumTo0(t *testing.T) { - e := startServer(t) - taskId := createTestTaskWithAmount(e, 101) - - e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1) - - // Update rest allowance - e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{ - Weight: 0, - }).Expect().Status(200) - // Create two allowance goals - e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ - Name: "Test Allowance 1", - Target: 1000, - Weight: 0, - }).Expect().Status(201) - - // Complete the task - e.POST("/task/" + strconv.Itoa(taskId) + "/complete").Expect().Status(200) - - // Verify the task is marked as completed - e.GET("/task/" + strconv.Itoa(taskId)).Expect().Status(404) - - // Verify the allowances are updated for user 1 - allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() - allowances.Length().IsEqual(2) - allowances.Value(0).Object().Value("id").Number().IsEqual(0) - allowances.Value(0).Object().Value("progress").Number().IsEqual(101) - allowances.Value(1).Object().Value("id").Number().IsEqual(1) - allowances.Value(1).Object().Value("progress").Number().IsEqual(0) -} - func TestCompleteAllowance(t *testing.T) { e := startServer(t) createTestTaskWithAmount(e, 100) diff --git a/backend/db.go b/backend/db.go index fe02ac2..0aa5570 100644 --- a/backend/db.go +++ b/backend/db.go @@ -416,16 +416,20 @@ func (db *Db) CompleteTask(taskId int) error { if sumOfWeights > 0 { // 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) { - var allowanceId int + for allowanceRow := range tx.Query("select id, weight, target, balance from allowances where user_id = ? and weight > 0 order by (target - balance) asc").Bind(userId).Range(&err) { + var allowanceId, allowanceTarget, allowanceBalance int var allowanceWeight float64 - err = allowanceRow.Scan(&allowanceId, &allowanceWeight) + err = allowanceRow.Scan(&allowanceId, &allowanceWeight, &allowanceTarget, &allowanceBalance) if err != nil { return err } // Calculate the amount to add to the allowance amount := int((allowanceWeight / sumOfWeights) * float64(remainingReward)) + if allowanceBalance+amount > allowanceTarget { + // If the amount reaches past the target, set it to the target + amount = allowanceTarget - allowanceBalance + } sumOfWeights -= allowanceWeight err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). Bind(amount, allowanceId, userId).Exec() diff --git a/backend/go.sum b/backend/go.sum index 964d5a6..0a43417 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= 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/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= diff --git a/backend/main.go b/backend/main.go index 49a8a0d..e584eb6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -592,6 +592,9 @@ func start(ctx context.Context, config *ServerConfig) { corsConfig.AllowAllOrigins = true router.Use(cors.New(corsConfig)) + // Web endpoints + loadWebEndpoints(router) + // API endpoints router.GET("/api/users", getUsers) router.GET("/api/user/:userId", getUser) router.POST("/api/user/:userId/history", postHistory) diff --git a/backend/web.go b/backend/web.go new file mode 100644 index 0000000..12abbd0 --- /dev/null +++ b/backend/web.go @@ -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.Atoi(rewardStr) + 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.Atoi(targetStr) + 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(), + }) +} diff --git a/backend/web.gohtml b/backend/web.gohtml new file mode 100644 index 0000000..1ed49ee --- /dev/null +++ b/backend/web.gohtml @@ -0,0 +1,132 @@ +{{- /*gotype: allowance_planner.ViewModel*/}} + + + Allowance Planner 2000 + + + +

Allowance Planner 2000

+ +{{if ne .Error ""}} +

Error

+

{{.Error}}

+{{else}} +

Users

+ {{range .Users}} + {{if eq $.CurrentUser .ID}} + {{.Name}} + {{else}} + {{.Name}} + {{end}} + {{end}} + + {{if ne .CurrentUser 0}} +

Allowances

+
+ + + + + + + + + + + + + + + + + + + {{range .Allowances}} + {{if eq .ID 0}} + + + + + + + {{else}} + + + + + + {{if ge .Progress .Target}} + + {{end}} + + {{end}} + {{end}} + +
NameProgressTargetWeightActions
Total{{.Progress}}{{.Weight}}
{{.Name}} ({{.Progress}}){{.Target}}{{.Weight}} + Mark as completed +
+
+ +

Tasks

+
+ + + + + + + + + + + {{range .Tasks}} + + + + + + + {{end}} + + + + + + + +
NameAssignedRewardActions
{{.Name}} + {{if eq .Assigned nil}} + None + {{else}} + {{.Assigned}} + {{end}} + {{.Reward}} + Mark as completed +
+
+ +

History

+ + + + + + + + + {{range .History}} + + + + + {{end}} + +
TimestampAllowance
{{.Timestamp}}{{.Allowance}}
+ {{end}} +{{end}} + +