feat(dashboard): #163 first summary
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m6s

This commit was merged in pull request #181.
This commit is contained in:
2025-06-08 15:35:21 +02:00
parent 935019c1c4
commit 6b8059889d
15 changed files with 280 additions and 67 deletions

View File

@@ -116,9 +116,10 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
treasureChestService := service.NewTreasureChest(d, randomService, clockService) treasureChestService := service.NewTreasureChest(d, randomService, clockService)
transactionService := service.NewTransaction(d, randomService, clockService) transactionService := service.NewTransaction(d, randomService, clockService)
transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService, transactionService) transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService, transactionService)
dashboardService := service.NewDashboard(d)
render := handler.NewRender() render := handler.NewRender()
indexHandler := handler.NewIndex(render) indexHandler := handler.NewIndex(render, dashboardService)
authHandler := handler.NewAuth(authService, render) authHandler := handler.NewAuth(authService, render)
accountHandler := handler.NewAccount(accountService, render) accountHandler := handler.NewAccount(accountService, render)
treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render) treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render)

View File

@@ -1,9 +1,16 @@
package handler package handler
import ( import (
"fmt"
"log/slog"
"net/http" "net/http"
"spend-sparrow/internal/handler/middleware" "spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
"spend-sparrow/internal/template" "spend-sparrow/internal/template"
"spend-sparrow/internal/template/dashboard"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"time"
"github.com/a-h/templ" "github.com/a-h/templ"
) )
@@ -13,12 +20,14 @@ type Index interface {
} }
type IndexImpl struct { type IndexImpl struct {
render *Render r *Render
d *service.Dashboard
} }
func NewIndex(render *Render) Index { func NewIndex(r *Render, d *service.Dashboard) Index {
return IndexImpl{ return IndexImpl{
render: render, r: r,
d: d,
} }
} }
@@ -33,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
@@ -41,14 +52,47 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
status = http.StatusNotFound status = http.StatusNotFound
} else { } else {
if user != nil { if user != nil {
comp = template.Dashboard() var err error
comp, err = handler.dashboard(user, htmx, r)
if err != nil {
slog.Error("Failed to get dashboard summary", "err", err)
}
} else { } else {
comp = template.Index() comp = template.Index()
} }
status = http.StatusOK status = http.StatusOK
} }
handler.render.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, htmx bool, r *http.Request) (templ.Component, error) {
var month time.Time
var err error
monthStr := r.URL.Query().Get("month")
if monthStr != "" {
month, err = time.Parse("2006-01-02", monthStr)
if err != nil {
return nil, fmt.Errorf("could not parse timestamp: %w", service.ErrBadRequest)
}
} else {
month = time.Now()
}
summary, err := handler.d.Summary(r.Context(), user, month)
if err != nil {
return nil, err
}
if htmx {
return dashboard.DashboardData(summary), nil
} else {
return dashboard.Dashboard(summary), nil
} }
} }

View File

@@ -14,8 +14,6 @@ import (
"github.com/a-h/templ" "github.com/a-h/templ"
"github.com/google/uuid" "github.com/google/uuid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
) )
type Transaction interface { type Transaction interface {
@@ -56,9 +54,6 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
return return
} }
currentSpan := trace.SpanFromContext(r.Context())
currentSpan.SetAttributes(attribute.String("", "test"))
filter := types.TransactionItemsFilter{ filter := types.TransactionItemsFilter{
AccountId: r.URL.Query().Get("account-id"), AccountId: r.URL.Query().Get("account-id"),
TreasureChestId: r.URL.Query().Get("treasure-chest-id"), TreasureChestId: r.URL.Query().Get("treasure-chest-id"),

View File

@@ -0,0 +1,88 @@
package service
import (
"context"
"log/slog"
"spend-sparrow/internal/db"
"spend-sparrow/internal/types"
"time"
"github.com/jmoiron/sqlx"
)
type Dashboard struct {
db *sqlx.DB
}
func NewDashboard(db *sqlx.DB) *Dashboard {
return &Dashboard{
db: db,
}
}
func (s Dashboard) Summary(ctx context.Context, user *types.User, month time.Time) (*types.DashboardMonthlySummary, error) {
if user == nil {
return nil, ErrUnauthorized
}
var summary types.DashboardMonthlySummary
var value *int64
err := s.db.GetContext(ctx, &value, `
SELECT SUM(value)
FROM "transaction"
WHERE user_id = $1
AND treasure_chest_id IS NOT NULL
AND account_id IS NULL
AND error IS NULL
AND date(timestamp, 'start of month') = date($2, 'start of month')`,
user.Id, month)
err = db.TransformAndLogDbError("dashboard", nil, err)
if err != nil {
return nil, err
}
if value != nil {
summary.Savings = *value
}
err = s.db.GetContext(ctx, &value, `
SELECT SUM(value)
FROM "transaction"
WHERE user_id = $1
AND account_id IS NOT NULL
AND treasure_chest_id IS NULL
AND error IS NULL
AND date(timestamp, 'start of month') = date($2, 'start of month')`,
user.Id, month)
err = db.TransformAndLogDbError("dashboard", nil, err)
if err != nil {
return nil, err
}
if value != nil {
summary.Income = *value
}
err = s.db.GetContext(ctx, &value, `
SELECT SUM(value)
FROM "transaction"
WHERE user_id = $1
AND account_id IS NOT NULL
AND treasure_chest_id IS NOT NULL
AND error IS NULL
AND date(timestamp, 'start of month') = date($2, 'start of month')`,
user.Id, month)
err = db.TransformAndLogDbError("dashboard", nil, err)
if err != nil {
return nil, err
}
if value != nil {
summary.Expenses = *value
}
summary.Total = summary.Income + summary.Expenses
summary.Month = month
slog.Info("Dashboard summary", "summary", summary)
return &summary, nil
}

View File

@@ -512,27 +512,20 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
return &transaction, nil return &transaction, nil
} }
func (s TransactionImpl) updateErrors(transaction *types.Transaction) { func (s TransactionImpl) updateErrors(t *types.Transaction) {
errorStr := "" errorStr := ""
switch { switch {
case transaction.Value < 0: case (t.AccountId != nil && t.TreasureChestId != nil && t.Value > 0) ||
if transaction.TreasureChestId == nil { (t.AccountId == nil && t.TreasureChestId == nil):
errorStr = "no treasure chest specified" errorStr = "either an account or a treasure chest needs to be specified"
} case t.Value == 0:
case transaction.Value > 0:
if transaction.AccountId == nil && transaction.TreasureChestId == nil {
errorStr = "either an account or a treasure chest needs to be specified"
} else if transaction.AccountId != nil && transaction.TreasureChestId != nil {
errorStr = "positive amounts can only be applied to either an account or a treasure chest"
}
default:
errorStr = "\"value\" needs to be specified" errorStr = "\"value\" needs to be specified"
} }
if errorStr == "" { if errorStr == "" {
transaction.Error = nil t.Error = nil
} else { } else {
transaction.Error = &errorStr t.Error = &errorStr
} }
} }

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

@@ -1,9 +0,0 @@
package template
templ Dashboard() {
<div>
<h1 class="text-8xl">
Dashboard
</h1>
</div>
}

View File

@@ -0,0 +1,44 @@
package dashboard
import "spend-sparrow/internal/types"
templ Dashboard(summary *types.DashboardMonthlySummary) {
<div class="mt-10">
<form hx-get="/" hx-target="#dashboard" hx-trigger="change" hx-push-url="true">
<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">
<span>Savings</span>
<span>Income</span>
<span>Expenses</span>
<span>Total</span>
<span></span>
<span>{ types.FormatEuros(summary.Savings) }</span>
@balance(summary.Income)
@balance(summary.Expenses)
@balance(summary.Total)
</section>
</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

@@ -0,0 +1,2 @@
package dashboard

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)
}

View File

@@ -0,0 +1,26 @@
package types
import "time"
// Account | TreasureChest | Value | Description
// --------|---------------|-------|----------------
// Y | Y | + | Invalid
// Y | Y | - | Expense
// Y | N | + | Deposit
// Y | N | - | Withdrawal (for moving between accounts)
// N | Y | + | Saving
// N | Y | - | Withdrawal (for moving between treasure chests)
// N | N | + | Invalid
// N | N | - | Invalid
type DashboardMonthlySummary struct {
Month time.Time
// Sum of all Transactions with TreasureChests and no Accounts
Savings int64
// Sum of all Transactions with Accounts and no TreasureChests
Income int64
// Sum of all Transactions with Accounts and TreasureChests
Expenses int64
// Income - Expenses
Total int64
}

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

@@ -0,0 +1,36 @@
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 + " €"
}

View File

@@ -13,6 +13,19 @@ import (
// //
// If it becomes necessary to precalculate snapshots for performance reasons, this can be done in the future. // If it becomes necessary to precalculate snapshots for performance reasons, this can be done in the future.
// But the transaction should always be the source of truth. // But the transaction should always be the source of truth.
//
// There are the following constallations and their explanation:
//
// Account | TreasureChest | Value | Description
// --------|---------------|-------|----------------
// Y | Y | + | Invalid
// Y | Y | - | Expense
// Y | N | + | Deposit
// Y | N | - | Withdrawal (for moving between accounts)
// N | Y | + | Saving
// N | Y | - | Withdrawal (for moving between treasure chests)
// N | N | + | Invalid
// N | N | - | Invalid
type Transaction struct { type Transaction struct {
Id uuid.UUID `db:"id"` Id uuid.UUID `db:"id"`
UserId uuid.UUID `db:"user_id"` UserId uuid.UUID `db:"user_id"`