This commit is contained in:
@@ -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)
|
||||||
|
|||||||
65
internal/handler/dashboard.go
Normal file
65
internal/handler/dashboard.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
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/template/dashboard"
|
||||||
|
"spend-sparrow/internal/types"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
)
|
)
|
||||||
@@ -14,12 +19,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,17 +49,44 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
|
|||||||
status = http.StatusNotFound
|
status = http.StatusNotFound
|
||||||
} else {
|
} else {
|
||||||
if user != nil {
|
if user != nil {
|
||||||
comp = dashboard.Dashboard()
|
var err error
|
||||||
|
comp, err = handler.dashboard(user, 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)
|
handler.r.RenderLayoutWithStatus(r, w, comp, user, status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (handler IndexImpl) dashboard(user *types.User, 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", r.FormValue("timestamp"))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := dashboard.Dashboard(summary)
|
||||||
|
return comp, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (handler IndexImpl) handleEmpty() http.HandlerFunc {
|
func (handler IndexImpl) handleEmpty() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
updateSpan(r)
|
updateSpan(r)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"spend-sparrow/internal/db"
|
||||||
"spend-sparrow/internal/types"
|
"spend-sparrow/internal/types"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,50 +14,72 @@ type Dashboard struct {
|
|||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboard(db *sqlx.DB) TreasureChest {
|
func NewDashboard(db *sqlx.DB) *Dashboard {
|
||||||
return TreasureChestImpl{
|
return &Dashboard{
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Dashboard) Summary(ctx context.Context, user *types.User, month time.Time) (*types.DashboardSummary, error) {
|
func (s Dashboard) Summary(ctx context.Context, user *types.User, month time.Time) (*types.DashboardMonthlySummary, error) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
var summary types.DashboardSummary
|
var summary types.DashboardMonthlySummary
|
||||||
|
|
||||||
s.db.SelectContext(ctx, &summary.Expenses, `
|
var value *int64
|
||||||
SELECT
|
err := s.db.GetContext(ctx, &value, `
|
||||||
|
SELECT SUM(value)
|
||||||
FROM "transaction"
|
FROM "transaction"
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND value < 0
|
|
||||||
AND account_id IS NOT NULL
|
|
||||||
AND error IS NULL
|
|
||||||
AND date_trunc('month', date) = date_trund('month', $2)`,
|
|
||||||
user.Id, month)
|
|
||||||
|
|
||||||
s.db.SelectContext(ctx, &summary.Income, `
|
|
||||||
SELECT
|
|
||||||
FROM "transaction"
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND value > 0
|
|
||||||
AND account_id IS NOT NULL
|
|
||||||
AND treasure_chest_id IS NULL
|
|
||||||
AND error IS NULL
|
|
||||||
AND date_trunc('month', date) = date_trund('month', $2)`,
|
|
||||||
user.Id, month)
|
|
||||||
|
|
||||||
s.db.SelectContext(ctx, &summary.Savings, `
|
|
||||||
SELECT
|
|
||||||
FROM "transaction"
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND value > 0
|
|
||||||
AND treasure_chest_id IS NOT NULL
|
AND treasure_chest_id IS NOT NULL
|
||||||
AND account_id IS NULL
|
AND account_id IS NULL
|
||||||
AND error IS NULL
|
AND error IS NULL
|
||||||
AND date_trunc('month', date) = date_trund('month', $2)`,
|
AND date(timestamp, 'start of month') = date($2, 'start of month')`,
|
||||||
user.Id, 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, &summary.Expenses, `
|
||||||
|
// 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
|
||||||
|
// }
|
||||||
|
|
||||||
|
summary.Total = summary.Income - summary.Expenses
|
||||||
|
summary.Month = month
|
||||||
|
|
||||||
|
slog.Info("Dashboard summary", "summary", summary)
|
||||||
|
|
||||||
return &summary, nil
|
return &summary, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
package dashboard
|
package dashboard
|
||||||
|
|
||||||
templ Dashboard() {
|
import "spend-sparrow/internal/types"
|
||||||
|
|
||||||
|
templ Dashboard(summary *types.DashboardMonthlySummary) {
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
<label for="month">Select Month:</label>
|
<label for="month">Select Month:</label>
|
||||||
<input name="month" type="date" class="input"/>
|
<input name="month" type="date" class="input"/>
|
||||||
@summaryCard()
|
<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>{ summary.Savings }</span>
|
||||||
|
<span>{ summary.Income }</span>
|
||||||
|
<span>{ summary.Expenses }</span>
|
||||||
|
<span>{ summary.Total }</span>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ summaryCard() {
|
|
||||||
<section class="grid grid-cols-[auto_auto_auto_auto_1fr] gap-4">
|
|
||||||
<span>Einnahmen</span>
|
|
||||||
<span>Gespart</span>
|
|
||||||
<span>Ausgaben</span>
|
|
||||||
<span>Gesamt</span>
|
|
||||||
<span></span>
|
|
||||||
<span>4,005.15 €</span>
|
|
||||||
<span>4,005.15 €</span>
|
|
||||||
<span>3,805.15 €</span>
|
|
||||||
<span>200€</span>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
type DashboardSummary struct {
|
import "time"
|
||||||
Income int64
|
|
||||||
|
// 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
|
Savings int64
|
||||||
|
// Sum of all Transactions with Accounts and no TreasureChests
|
||||||
|
Income int64
|
||||||
|
// Sum of all Transactions with Accounts and TreasureChests
|
||||||
Expenses int64
|
Expenses int64
|
||||||
|
// Income - Expenses
|
||||||
Total int64
|
Total int64
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user