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

} -

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

}