First functional lite web thingy

This commit is contained in:
Sebastiaan de Schaetzen 2025-05-24 06:09:44 +02:00
parent f4c8ae33dd
commit 08edc867a7
4 changed files with 242 additions and 144 deletions

View File

@ -549,12 +549,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 +568,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().IsEqual(31)
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().IsEqual(60)
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()

View File

@ -416,16 +416,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()

View File

@ -13,6 +13,7 @@ type ViewModel struct {
Allowances []Allowance Allowances []Allowance
Tasks []Task Tasks []Task
History []History History []History
Error string
} }
func loadWebEndpoints(router *gin.Engine) { func loadWebEndpoints(router *gin.Engine) {
@ -20,6 +21,9 @@ func loadWebEndpoints(router *gin.Engine) {
router.GET("/", renderIndex) router.GET("/", renderIndex)
router.GET("/login", renderLogin) router.GET("/login", renderLogin)
router.POST("/createTask", renderCreateTask) router.POST("/createTask", renderCreateTask)
router.GET("/completeTask", renderCompleteTask)
router.POST("/createAllowance", renderCreateAllowance)
router.GET("/completeAllowance", renderCompleteAllowance)
} }
func renderLogin(c *gin.Context) { func renderLogin(c *gin.Context) {
@ -30,49 +34,16 @@ func renderLogin(c *gin.Context) {
} }
func renderIndex(c *gin.Context) { func renderIndex(c *gin.Context) {
currentUserStr, err := c.Cookie("user") currentUser := getCurrentUser(c)
if errors.Is(err, http.ErrNoCookie) { if currentUser == nil {
renderNoUser(c)
return return
} }
renderWithUser(c, *currentUser)
if err != nil {
unsetUserCookie(c)
return
}
currentUser, err := strconv.Atoi(currentUserStr)
if err != nil {
unsetUserCookie(c)
return
}
userExists, err := db.UserExists(currentUser)
if !userExists || err != nil {
unsetUserCookie(c)
return
}
renderWithUser(c, currentUser)
} }
func renderCreateTask(c *gin.Context) { func renderCreateTask(c *gin.Context) {
currentUserStr, err := c.Cookie("user") currentUser := getCurrentUser(c)
if errors.Is(err, http.ErrNoCookie) { if currentUser == nil {
c.HTML(http.StatusBadRequest, "error.gohtml", gin.H{
"error": "User not logged in",
})
return
}
if err != nil {
unsetUserCookie(c)
return
}
currentUser, err := strconv.Atoi(currentUserStr)
if err != nil {
unsetUserCookie(c)
return
}
userExists, err := db.UserExists(currentUser)
if !userExists || err != nil {
unsetUserCookie(c)
return return
} }
@ -80,15 +51,11 @@ func renderCreateTask(c *gin.Context) {
rewardStr := c.PostForm("reward") rewardStr := c.PostForm("reward")
reward, err := strconv.Atoi(rewardStr) reward, err := strconv.Atoi(rewardStr)
if err != nil { if err != nil {
c.HTML(http.StatusBadRequest, "error.gohtml", gin.H{ renderError(c, http.StatusBadRequest, err)
"error": "Invalid reward value",
})
return return
} }
if name == "" || reward <= 0 { if name == "" || reward <= 0 {
c.HTML(http.StatusBadRequest, "error.gohtml", gin.H{ renderError(c, http.StatusBadRequest, err)
"error": "Name and reward must be provided",
})
return return
} }
@ -97,15 +64,112 @@ func renderCreateTask(c *gin.Context) {
Reward: reward, Reward: reward,
}) })
if err != nil { if err != nil {
c.HTML(http.StatusInternalServerError, "error.gohtml", gin.H{ renderError(c, http.StatusInternalServerError, err)
"error": err.Error(),
})
return return
} }
c.Redirect(http.StatusFound, "/") 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 &currentUser
}
func unsetUserCookie(c *gin.Context) { func unsetUserCookie(c *gin.Context) {
c.SetCookie("user", "", -1, "/", "localhost", false, true) c.SetCookie("user", "", -1, "/", "localhost", false, true)
c.Redirect(http.StatusFound, "/") c.Redirect(http.StatusFound, "/")
@ -114,9 +178,7 @@ func unsetUserCookie(c *gin.Context) {
func renderNoUser(c *gin.Context) { func renderNoUser(c *gin.Context) {
users, err := db.GetUsers() users, err := db.GetUsers()
if err != nil { if err != nil {
c.HTML(http.StatusInternalServerError, "error.gohtml", gin.H{ renderError(c, http.StatusInternalServerError, err)
"error": err.Error(),
})
return return
} }
@ -128,33 +190,25 @@ func renderNoUser(c *gin.Context) {
func renderWithUser(c *gin.Context, currentUser int) { func renderWithUser(c *gin.Context, currentUser int) {
users, err := db.GetUsers() users, err := db.GetUsers()
if err != nil { if err != nil {
c.HTML(http.StatusInternalServerError, "error.gohtml", gin.H{ renderError(c, http.StatusInternalServerError, err)
"error": err.Error(),
})
return return
} }
allowances, err := db.GetUserAllowances(currentUser) allowances, err := db.GetUserAllowances(currentUser)
if err != nil { if err != nil {
c.HTML(http.StatusInternalServerError, "error.gohtml", gin.H{ renderError(c, http.StatusInternalServerError, err)
"error": err.Error(),
})
return return
} }
tasks, err := db.GetTasks() tasks, err := db.GetTasks()
if err != nil { if err != nil {
c.HTML(http.StatusInternalServerError, "error.gohtml", gin.H{ renderError(c, http.StatusInternalServerError, err)
"error": err.Error(),
})
return return
} }
history, err := db.GetHistory(currentUser) history, err := db.GetHistory(currentUser)
if err != nil { if err != nil {
c.HTML(http.StatusInternalServerError, "error.gohtml", gin.H{ renderError(c, http.StatusInternalServerError, err)
"error": err.Error(),
})
return return
} }
@ -166,3 +220,9 @@ func renderWithUser(c *gin.Context, currentUser int) {
History: history, History: history,
}) })
} }
func renderError(c *gin.Context, statusCode int, err error) {
c.HTML(statusCode, "web.gohtml", ViewModel{
Error: err.Error(),
})
}

View File

@ -2,97 +2,131 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>Allowance Planner 2000</title> <title>Allowance Planner 2000</title>
<style>
tr:hover {
background-color: #f0f0f0;
}
</style>
</head> </head>
<body> <body>
<h1>Allowance Planner 2000</h1> <h1>Allowance Planner 2000</h1>
<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}} {{if ne .Error ""}}
<h2>Allowances</h2> <h2>Error</h2>
<table border="1"> <p>{{.Error}}</p>
<thead> {{else}}
<tr> <h2>Users</h2>
<th>Name</th> {{range .Users}}
<th>Progress</th> {{if eq $.CurrentUser .ID}}
<th>Target</th> <strong>{{.Name}}</strong>
<th>Weight</th> {{else}}
</tr> <a href="/login?user={{.ID}}">{{.Name}}</a>
</thead>
<tbody>
{{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}}</td>
<td>{{.Target}}</td>
<td>{{.Weight}}</td>
</tr>
{{end}}
{{end}} {{end}}
</tbody> {{end}}
</table>
<h2>Tasks</h2> {{if ne .CurrentUser 0}}
<form method="post" action="/createTask"> <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"> <table border="1">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Timestamp</th>
<th>Assigned</th> <th>Allowance</th>
<th>Reward</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range .Tasks}} {{range .History}}
<tr> <tr>
<td>{{.Name}}</td> <td>{{.Timestamp}}</td>
<td>{{.Assigned}}</td> <td>{{.Allowance}}</td>
<td>{{.Reward}}</td>
</tr> </tr>
{{end}} {{end}}
<tr>
<td><label><input type="text" placeholder="Name"></label></td>
<td></td>
<td>
<label><input type="number" placeholder="Reward"></label>
<button>Create</button>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</form> {{end}}
<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> </body>
</html> </html>