feat(dashboard): #163 first summary
Some checks failed
Build Docker Image / Build-Docker-Image (push) Has been cancelled

This commit is contained in:
2025-06-15 22:02:45 +02:00
parent 487ea82c34
commit ff5ed3ec14
9 changed files with 112 additions and 124 deletions

View File

@@ -1,65 +0,0 @@
package handler
import (
"fmt"
"net/http"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
t "spend-sparrow/internal/template/dashboard"
"spend-sparrow/internal/utils"
"time"
)
type Dashboard interface {
Handle(router *http.ServeMux)
}
type DashboardImpl struct {
s service.Dashboard
r *Render
}
func NewDashboard(s service.Dashboard, r *Render) Dashboard {
return DashboardImpl{
s: s,
r: r,
}
}
func (h DashboardImpl) Handle(r *http.ServeMux) {
r.Handle("GET /transaction", h.handleDashboardPage())
}
func (h DashboardImpl) handleDashboardPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
var month time.Time
var err error
monthStr := r.URL.Query().Get("month")
if monthStr == "" {
month, err = time.Parse("2006-01-02", r.FormValue("timestamp"))
if err != nil {
handleError(w, r, fmt.Errorf("could not parse timestamp: %w", service.ErrBadRequest))
return
}
} else {
month = time.Now()
}
summary, err := h.s.Summary(r.Context(), user, month)
comp := t.Dashboard(summary)
if utils.IsHtmx(r) {
h.r.Render(r, w, comp)
} else {
h.r.RenderLayout(r, w, comp, user)
}
}
}

View File

@@ -9,6 +9,7 @@ import (
"spend-sparrow/internal/template" "spend-sparrow/internal/template"
"spend-sparrow/internal/template/dashboard" "spend-sparrow/internal/template/dashboard"
"spend-sparrow/internal/types" "spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"time" "time"
"github.com/a-h/templ" "github.com/a-h/templ"
@@ -41,6 +42,8 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
user := middleware.GetUser(r) user := middleware.GetUser(r)
htmx := utils.IsHtmx(r)
var comp templ.Component var comp templ.Component
var status int var status int
@@ -50,7 +53,7 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
} else { } else {
if user != nil { if user != nil {
var err error var err error
comp, err = handler.dashboard(user, r) comp, err = handler.dashboard(user, htmx, r)
if err != nil { if err != nil {
slog.Error("Failed to get dashboard summary", "err", err) slog.Error("Failed to get dashboard summary", "err", err)
} }
@@ -60,17 +63,21 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
status = http.StatusOK status = http.StatusOK
} }
handler.r.RenderLayoutWithStatus(r, w, comp, user, status) if htmx {
handler.r.RenderWithStatus(r, w, comp, status)
} else {
handler.r.RenderLayoutWithStatus(r, w, comp, user, status)
}
} }
} }
func (handler IndexImpl) dashboard(user *types.User, r *http.Request) (templ.Component, error) { func (handler IndexImpl) dashboard(user *types.User, htmx bool, r *http.Request) (templ.Component, error) {
var month time.Time var month time.Time
var err error var err error
monthStr := r.URL.Query().Get("month") monthStr := r.URL.Query().Get("month")
if monthStr != "" { if monthStr != "" {
month, err = time.Parse("2006-01-02", r.FormValue("timestamp")) month, err = time.Parse("2006-01-02", monthStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse timestamp: %w", service.ErrBadRequest) return nil, fmt.Errorf("could not parse timestamp: %w", service.ErrBadRequest)
} }
@@ -83,8 +90,11 @@ func (handler IndexImpl) dashboard(user *types.User, r *http.Request) (templ.Com
return nil, err return nil, err
} }
comp := dashboard.Dashboard(summary) if htmx {
return comp, nil return dashboard.DashboardData(summary), nil
} else {
return dashboard.Dashboard(summary), nil
}
} }
func (handler IndexImpl) handleEmpty() http.HandlerFunc { func (handler IndexImpl) handleEmpty() http.HandlerFunc {

View File

@@ -62,21 +62,24 @@ func (s Dashboard) Summary(ctx context.Context, user *types.User, month time.Tim
summary.Income = *value summary.Income = *value
} }
// err = s.db.GetContext(ctx, &summary.Expenses, ` err = s.db.GetContext(ctx, &value, `
// SELECT SUM(value) SELECT SUM(value)
// FROM "transaction" FROM "transaction"
// WHERE user_id = $1 WHERE user_id = $1
// AND account_id IS NOT NULL AND account_id IS NOT NULL
// AND treasure_chest_id IS NOT NULL AND treasure_chest_id IS NOT NULL
// AND error IS NULL AND error IS NULL
// AND date(timestamp, 'start of month') = date($2, 'start of month')`, AND date(timestamp, 'start of month') = date($2, 'start of month')`,
// user.Id, month) user.Id, month)
// err = db.TransformAndLogDbError("dashboard", nil, err) err = db.TransformAndLogDbError("dashboard", nil, err)
// if err != nil { if err != nil {
// return nil, err return nil, err
// } }
if value != nil {
summary.Expenses = *value
}
summary.Total = summary.Income - summary.Expenses summary.Total = summary.Income + summary.Expenses
summary.Month = month summary.Month = month
slog.Info("Dashboard summary", "summary", summary) slog.Info("Dashboard summary", "summary", summary)

View File

@@ -1,6 +1,5 @@
package account package account
import "fmt"
import "spend-sparrow/internal/template/svg" import "spend-sparrow/internal/template/svg"
import "spend-sparrow/internal/types" import "spend-sparrow/internal/types"
@@ -81,9 +80,9 @@ templ AccountItem(account *types.Account) {
<div class="text-xl flex justify-end gap-4"> <div class="text-xl flex justify-end gap-4">
<p class="mr-auto">{ account.Name }</p> <p class="mr-auto">{ account.Name }</p>
if account.CurrentBalance < 0 { if account.CurrentBalance < 0 {
<p class="mr-20 text-red-700">{ displayBalance(account.CurrentBalance) }</p> <p class="mr-20 text-red-700">{ types.FormatEuros(account.CurrentBalance) }</p>
} else { } else {
<p class="mr-20 text-green-700">{ displayBalance(account.CurrentBalance) }</p> <p class="mr-20 text-green-700">{ types.FormatEuros(account.CurrentBalance) }</p>
} }
<a <a
href={ templ.URL("/transaction?account-id=" + account.Id.String()) } href={ templ.URL("/transaction?account-id=" + account.Id.String()) }
@@ -121,9 +120,3 @@ templ AccountItem(account *types.Account) {
</div> </div>
</div> </div>
} }
func displayBalance(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f €", euros)
}

View File

@@ -4,18 +4,41 @@ import "spend-sparrow/internal/types"
templ Dashboard(summary *types.DashboardMonthlySummary) { templ Dashboard(summary *types.DashboardMonthlySummary) {
<div class="mt-10"> <div class="mt-10">
<label for="month">Select Month:</label> <form hx-get="/" hx-target="#dashboard" hx-trigger="change" hx-push-url="true">
<input name="month" type="date" class="input"/> <label for="month">Select Month:</label>
<input
name="month"
type="date"
class="input"
value={ summary.Month.String() }
/>
</form>
<div id="dashboard">
@DashboardData(summary)
</div>
</div>
}
templ DashboardData(summary *types.DashboardMonthlySummary) {
<div class="mt-10">
<section class="grid grid-cols-[auto_auto_auto_auto_1fr] gap-4"> <section class="grid grid-cols-[auto_auto_auto_auto_1fr] gap-4">
<span>Savings</span> <span>Savings</span>
<span>Income</span> <span>Income</span>
<span>Expenses</span> <span>Expenses</span>
<span>Total</span> <span>Total</span>
<span></span> <span></span>
<span>{ summary.Savings }</span> <span>{ types.FormatEuros(summary.Savings) }</span>
<span>{ summary.Income }</span> @balance(summary.Income)
<span>{ summary.Expenses }</span> @balance(summary.Expenses)
<span>{ summary.Total }</span> @balance(summary.Total)
</section> </section>
</div> </div>
} }
templ balance(balance int64) {
if balance < 0 {
<span class="text-red-700">{ types.FormatEuros(balance) }</span>
} else {
<span class="text-green-700">{ types.FormatEuros(balance) }</span>
}
}

View File

@@ -103,7 +103,7 @@ templ EditTransaction(transaction *types.Transaction, accounts []*types.Account,
if transaction.TreasureChestId != nil { if transaction.TreasureChestId != nil {
treasureChestId = transaction.TreasureChestId.String() treasureChestId = transaction.TreasureChestId.String()
} }
value = displayBalance(transaction.Value) value = formatFloat(transaction.Value)
id = transaction.Id.String() id = transaction.Id.String()
cancelUrl = "/transaction/" + id cancelUrl = "/transaction/" + id
@@ -250,9 +250,9 @@ templ TransactionItem(transaction *types.Transaction, accounts, treasureChests m
</p> </p>
</div> </div>
if transaction.Value < 0 { if transaction.Value < 0 {
<p class="mr-8 min-w-22 text-right text-red-700">{ displayBalance(transaction.Value)+" €" }</p> <p class="mr-8 min-w-22 text-right text-red-700">{ types.FormatEuros(transaction.Value) }</p>
} else { } else {
<p class="mr-8 w-22 text-right text-green-700">{ displayBalance(transaction.Value)+" €" }</p> <p class="mr-8 w-22 text-right text-green-700">{ types.FormatEuros(transaction.Value) }</p>
} }
<button <button
hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" } hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" }
@@ -280,11 +280,8 @@ templ TransactionItem(transaction *types.Transaction, accounts, treasureChests m
</div> </div>
} }
func displayBalance(balance int64) string { func formatFloat(balance int64) string {
euros := float64(balance) / 100 euros := float64(balance) / 100
return fmt.Sprintf("%.2f", euros) return fmt.Sprintf("%.2f", euros)
} }
func calculateReferences() {
}

View File

@@ -53,9 +53,9 @@ templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring,
Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s) Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s)
</p> </p>
if transactionRecurring.Value < 0 { if transactionRecurring.Value < 0 {
<p class="text-right text-red-700">{ displayBalance(transactionRecurring.Value)+" €" }</p> <p class="text-right text-red-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
} else { } else {
<p class="text-right text-green-700">{ displayBalance(transactionRecurring.Value)+" €" }</p> <p class="text-right text-green-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
} }
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@@ -104,7 +104,7 @@ templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring,
} }
party = transactionRecurring.Party party = transactionRecurring.Party
description = transactionRecurring.Description description = transactionRecurring.Description
value = displayBalance(transactionRecurring.Value) value = formatFloat(transactionRecurring.Value)
id = transactionRecurring.Id.String() id = transactionRecurring.Id.String()
} }
@@ -201,11 +201,8 @@ templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring,
</div> </div>
} }
func displayBalance(balance int64) string { func formatFloat(balance int64) string {
euros := float64(balance) / 100 euros := float64(balance) / 100
return fmt.Sprintf("%.2f", euros) return fmt.Sprintf("%.2f", euros)
} }
func calculateReferences() {
}

View File

@@ -1,6 +1,5 @@
package treasurechest package treasurechest
import "fmt"
import "spend-sparrow/internal/template/svg" import "spend-sparrow/internal/template/svg"
import "spend-sparrow/internal/types" import "spend-sparrow/internal/types"
import "github.com/google/uuid" import "github.com/google/uuid"
@@ -130,14 +129,14 @@ templ TreasureChestItem(treasureChest *types.TreasureChest, monthlySums map[uuid
<p class="mr-auto">{ treasureChest.Name }</p> <p class="mr-auto">{ treasureChest.Name }</p>
<p class="mr-20 text-gray-600"> <p class="mr-20 text-gray-600">
if treasureChest.ParentId != nil { if treasureChest.ParentId != nil {
+ { displayBalance(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm">&nbsp;per month</span> + { types.FormatEuros(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm">&nbsp;per month</span>
} }
</p> </p>
if treasureChest.ParentId != nil { if treasureChest.ParentId != nil {
if treasureChest.CurrentBalance < 0 { if treasureChest.CurrentBalance < 0 {
<p class="mr-20 min-w-20 text-right text-red-700">{ displayBalance(treasureChest.CurrentBalance) }</p> <p class="mr-20 min-w-20 text-right text-red-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
} else { } else {
<p class="mr-20 min-w-20 text-right text-green-700">{ displayBalance(treasureChest.CurrentBalance) }</p> <p class="mr-20 min-w-20 text-right text-green-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
} }
} }
<a <a
@@ -187,9 +186,3 @@ func filterNoChildNoSelf(nodes []*types.TreasureChest, selfId string) []*types.T
return result return result
} }
func displayBalance(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f €", euros)
}

37
internal/types/format.go Normal file
View File

@@ -0,0 +1,37 @@
package types
import (
"fmt"
"strings"
)
func FormatEuros(balance int64) string {
prefix := ""
if balance < 0 {
prefix = "- "
balance = -balance
}
n := float64(balance) / 100
s := fmt.Sprintf("%.2f", n) // "1234567.89"
parts := strings.Split(s, ".")
intPart := parts[0]
fracPart := parts[1]
var result strings.Builder
numberOfSeperators := len(intPart) % 3
if numberOfSeperators == 0 {
result.WriteString(intPart)
} else {
for i := range intPart {
if i > 0 && (i-numberOfSeperators)%3 == 0 {
result.WriteString(",")
}
result.WriteByte(intPart[i])
}
}
return prefix + result.String() + "." + fracPart + " €"
}