From 6b8059889daf28950f6755117e06f700bc57d037 Mon Sep 17 00:00:00 2001
From: Tim Wundenberg
Date: Sun, 8 Jun 2025 15:35:21 +0200
Subject: [PATCH] feat(dashboard): #163 first summary
---
internal/default.go | 3 +-
internal/handler/root_and_404.go | 54 ++++++++++--
internal/handler/transaction.go | 5 --
internal/service/dashboard.go | 88 +++++++++++++++++++
internal/service/transaction.go | 21 ++---
internal/template/account/account.templ | 11 +--
internal/template/dashboard.templ | 9 --
internal/template/dashboard/dashboard.templ | 44 ++++++++++
internal/template/dashboard/default.go | 2 +
.../template/transaction/transaction.templ | 11 +--
.../transaction_recurring.templ | 11 +--
.../treasurechest/treasure_chest.templ | 13 +--
internal/types/dashboard.go | 26 ++++++
internal/types/format.go | 36 ++++++++
internal/types/transaction.go | 13 +++
15 files changed, 280 insertions(+), 67 deletions(-)
create mode 100644 internal/service/dashboard.go
delete mode 100644 internal/template/dashboard.templ
create mode 100644 internal/template/dashboard/dashboard.templ
create mode 100644 internal/template/dashboard/default.go
create mode 100644 internal/types/dashboard.go
create mode 100644 internal/types/format.go
diff --git a/internal/default.go b/internal/default.go
index 13ed5db..de0a339 100644
--- a/internal/default.go
+++ b/internal/default.go
@@ -116,9 +116,10 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
treasureChestService := service.NewTreasureChest(d, randomService, clockService)
transactionService := service.NewTransaction(d, randomService, clockService)
transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService, transactionService)
+ dashboardService := service.NewDashboard(d)
render := handler.NewRender()
- indexHandler := handler.NewIndex(render)
+ indexHandler := handler.NewIndex(render, dashboardService)
authHandler := handler.NewAuth(authService, render)
accountHandler := handler.NewAccount(accountService, render)
treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render)
diff --git a/internal/handler/root_and_404.go b/internal/handler/root_and_404.go
index d346101..2430f6e 100644
--- a/internal/handler/root_and_404.go
+++ b/internal/handler/root_and_404.go
@@ -1,9 +1,16 @@
package handler
import (
+ "fmt"
+ "log/slog"
"net/http"
"spend-sparrow/internal/handler/middleware"
+ "spend-sparrow/internal/service"
"spend-sparrow/internal/template"
+ "spend-sparrow/internal/template/dashboard"
+ "spend-sparrow/internal/types"
+ "spend-sparrow/internal/utils"
+ "time"
"github.com/a-h/templ"
)
@@ -13,12 +20,14 @@ type Index interface {
}
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{
- render: render,
+ r: r,
+ d: d,
}
}
@@ -33,6 +42,8 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
user := middleware.GetUser(r)
+ htmx := utils.IsHtmx(r)
+
var comp templ.Component
var status int
@@ -41,14 +52,47 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
status = http.StatusNotFound
} else {
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 {
comp = template.Index()
}
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
}
}
diff --git a/internal/handler/transaction.go b/internal/handler/transaction.go
index 9c28028..4caf686 100644
--- a/internal/handler/transaction.go
+++ b/internal/handler/transaction.go
@@ -14,8 +14,6 @@ import (
"github.com/a-h/templ"
"github.com/google/uuid"
- "go.opentelemetry.io/otel/attribute"
- "go.opentelemetry.io/otel/trace"
)
type Transaction interface {
@@ -56,9 +54,6 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
return
}
- currentSpan := trace.SpanFromContext(r.Context())
- currentSpan.SetAttributes(attribute.String("", "test"))
-
filter := types.TransactionItemsFilter{
AccountId: r.URL.Query().Get("account-id"),
TreasureChestId: r.URL.Query().Get("treasure-chest-id"),
diff --git a/internal/service/dashboard.go b/internal/service/dashboard.go
new file mode 100644
index 0000000..ec896e9
--- /dev/null
+++ b/internal/service/dashboard.go
@@ -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
+}
diff --git a/internal/service/transaction.go b/internal/service/transaction.go
index d47d9da..cabf5ed 100644
--- a/internal/service/transaction.go
+++ b/internal/service/transaction.go
@@ -512,27 +512,20 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
return &transaction, nil
}
-func (s TransactionImpl) updateErrors(transaction *types.Transaction) {
+func (s TransactionImpl) updateErrors(t *types.Transaction) {
errorStr := ""
switch {
- case transaction.Value < 0:
- if transaction.TreasureChestId == nil {
- errorStr = "no treasure chest specified"
- }
- 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:
+ case (t.AccountId != nil && t.TreasureChestId != nil && t.Value > 0) ||
+ (t.AccountId == nil && t.TreasureChestId == nil):
+ errorStr = "either an account or a treasure chest needs to be specified"
+ case t.Value == 0:
errorStr = "\"value\" needs to be specified"
}
if errorStr == "" {
- transaction.Error = nil
+ t.Error = nil
} else {
- transaction.Error = &errorStr
+ t.Error = &errorStr
}
}
diff --git a/internal/template/account/account.templ b/internal/template/account/account.templ
index f0706ae..3a3006f 100644
--- a/internal/template/account/account.templ
+++ b/internal/template/account/account.templ
@@ -1,6 +1,5 @@
package account
-import "fmt"
import "spend-sparrow/internal/template/svg"
import "spend-sparrow/internal/types"
@@ -81,9 +80,9 @@ templ AccountItem(account *types.Account) {
{ account.Name }
if account.CurrentBalance < 0 {
-
{ displayBalance(account.CurrentBalance) }
+
{ types.FormatEuros(account.CurrentBalance) }
} else {
-
{ displayBalance(account.CurrentBalance) }
+
{ types.FormatEuros(account.CurrentBalance) }
}
}
-
-func displayBalance(balance int64) string {
-
- euros := float64(balance) / 100
- return fmt.Sprintf("%.2f €", euros)
-}
diff --git a/internal/template/dashboard.templ b/internal/template/dashboard.templ
deleted file mode 100644
index 54ef258..0000000
--- a/internal/template/dashboard.templ
+++ /dev/null
@@ -1,9 +0,0 @@
-package template
-
-templ Dashboard() {
-
-
- Dashboard
-
-
-}
diff --git a/internal/template/dashboard/dashboard.templ b/internal/template/dashboard/dashboard.templ
new file mode 100644
index 0000000..3997ec7
--- /dev/null
+++ b/internal/template/dashboard/dashboard.templ
@@ -0,0 +1,44 @@
+package dashboard
+
+import "spend-sparrow/internal/types"
+
+templ Dashboard(summary *types.DashboardMonthlySummary) {
+
+
+
+ @DashboardData(summary)
+
+
+}
+
+templ DashboardData(summary *types.DashboardMonthlySummary) {
+
+
+ Savings
+ Income
+ Expenses
+ Total
+
+ { types.FormatEuros(summary.Savings) }
+ @balance(summary.Income)
+ @balance(summary.Expenses)
+ @balance(summary.Total)
+
+
+}
+
+templ balance(balance int64) {
+ if balance < 0 {
+ { types.FormatEuros(balance) }
+ } else {
+ { types.FormatEuros(balance) }
+ }
+}
diff --git a/internal/template/dashboard/default.go b/internal/template/dashboard/default.go
new file mode 100644
index 0000000..7d2270a
--- /dev/null
+++ b/internal/template/dashboard/default.go
@@ -0,0 +1,2 @@
+package dashboard
+
diff --git a/internal/template/transaction/transaction.templ b/internal/template/transaction/transaction.templ
index 1c72c5a..8197799 100644
--- a/internal/template/transaction/transaction.templ
+++ b/internal/template/transaction/transaction.templ
@@ -103,7 +103,7 @@ templ EditTransaction(transaction *types.Transaction, accounts []*types.Account,
if transaction.TreasureChestId != nil {
treasureChestId = transaction.TreasureChestId.String()
}
- value = displayBalance(transaction.Value)
+ value = formatFloat(transaction.Value)
id = transaction.Id.String()
cancelUrl = "/transaction/" + id
@@ -250,9 +250,9 @@ templ TransactionItem(transaction *types.Transaction, accounts, treasureChests m
if transaction.Value < 0 {
- { displayBalance(transaction.Value)+" €" }
+ { types.FormatEuros(transaction.Value) }
} else {
- { displayBalance(transaction.Value)+" €" }
+ { types.FormatEuros(transaction.Value) }
}