feat(dashboard): #82 add chart for sum of account and savings
Some checks failed
Build Docker Image / Build-Docker-Image (push) Has been cancelled

This commit is contained in:
2025-06-19 13:28:49 +02:00
parent 3b3343bdb5
commit c94a9447f2
13 changed files with 241 additions and 173 deletions

1
.gitignore vendored
View File

@@ -31,6 +31,7 @@ secrets/
node_modules/
static/css/tailwind.css
static/js/htmx.min.js
static/js/echarts.min.js
tmp/
mocks/*

View File

@@ -120,7 +120,8 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
dashboardService := service.NewDashboard(d)
render := handler.NewRender()
indexHandler := handler.NewIndex(render, dashboardService, clockService)
indexHandler := handler.NewIndex(render, clockService)
dashboardHandler := handler.NewDashboard(render, dashboardService)
authHandler := handler.NewAuth(authService, render)
accountHandler := handler.NewAccount(accountService, render)
treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render)
@@ -130,6 +131,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
go dailyTaskTimer(ctx, transactionRecurringService, authService)
indexHandler.Handle(router)
dashboardHandler.Handle(router)
accountHandler.Handle(router)
treasureChestHandler.Handle(router)
authHandler.Handle(router)

View File

@@ -0,0 +1,107 @@
package handler
import (
"fmt"
"log/slog"
"net/http"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
"spend-sparrow/internal/template/dashboard"
"spend-sparrow/internal/utils"
"time"
)
type Dashboard interface {
Handle(router *http.ServeMux)
}
type DashboardImpl struct {
r *Render
d *service.Dashboard
}
func NewDashboard(r *Render, d *service.Dashboard) Dashboard {
return DashboardImpl{
r: r,
d: d,
}
}
func (handler DashboardImpl) Handle(router *http.ServeMux) {
router.Handle("GET /dashboard", handler.handleDashboard())
router.Handle("GET /dashboard/dataset", handler.handleDashboardDataset())
}
func (handler DashboardImpl) handleDashboard() 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
}
comp := dashboard.Dashboard()
handler.r.RenderLayoutWithStatus(r, w, comp, user, http.StatusOK)
}
}
func (handler DashboardImpl) handleDashboardDataset() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
series, err := handler.d.MainChart(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
account := ""
savings := ""
for _, entry := range series {
account += fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100)
savings += fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Savings)/100)
}
account = account[:len(account)-1]
savings = savings[:len(savings)-1]
_, err = fmt.Fprintf(w, `
{
"tooltip": {
"trigger": "axis",
"formatter": "<updated by client>"
},
"xAxis": {
"type": "time"
},
"yAxis": {
"axisLabel": {
"formatter": "{value} €"
}
},
"series": [
{
"data": [%s],
"type": "line",
"name": "Account Value"
},
{
"data": [%s],
"type": "line",
"name": "Savings"
}
]
}
`, account, savings)
if err != nil {
slog.InfoContext(r.Context(), "could not write response", "err", err)
}
}
}

View File

@@ -1,16 +1,11 @@
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"
)
@@ -21,14 +16,12 @@ type Index interface {
type IndexImpl struct {
r *Render
d *service.Dashboard
c service.Clock
}
func NewIndex(r *Render, d *service.Dashboard, c service.Clock) Index {
func NewIndex(r *Render, c service.Clock) Index {
return IndexImpl{
r: r,
d: d,
c: c,
}
}
@@ -54,11 +47,8 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
status = http.StatusNotFound
} else {
if user != nil {
var err error
comp, err = handler.dashboard(user, htmx, r)
if err != nil {
slog.ErrorContext(r.Context(), "Failed to get dashboard summary", "err", err)
}
utils.DoRedirect(w, r, "/dashboard")
return
} else {
comp = template.Index()
}
@@ -73,31 +63,6 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
}
}
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 = handler.c.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
}
}
func (handler IndexImpl) handleEmpty() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)

View File

@@ -19,93 +19,59 @@ func NewDashboard(db *sqlx.DB) *Dashboard {
}
}
func (s Dashboard) Summary(ctx context.Context, user *types.User, month time.Time) (*types.DashboardMonthlySummary, error) {
func (s Dashboard) MainChart(
ctx context.Context,
user *types.User,
) ([]types.DashboardMainChartEntry, 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(ctx, "dashboard", nil, err)
transactions := make([]types.Transaction, 0)
err := s.db.SelectContext(ctx, &transactions, `
SELECT *
FROM "transaction"
WHERE user_id = ?
ORDER BY timestamp`, user.Id)
err = db.TransformAndLogDbError(ctx, "dashboard Chart", nil, err)
if err != nil {
return nil, err
}
if value != nil {
summary.Savings = *value
timeEntries := make([]types.DashboardMainChartEntry, 0)
var lastEntry *types.DashboardMainChartEntry
for _, t := range transactions {
if t.Error != nil {
continue
}
newDay := t.Timestamp.Truncate(24 * time.Hour)
if lastEntry == nil {
lastEntry = &types.DashboardMainChartEntry{
Day: newDay,
Value: 0,
Savings: 0,
}
} else if lastEntry.Day != newDay {
timeEntries = append(timeEntries, *lastEntry)
lastEntry = &types.DashboardMainChartEntry{
Day: newDay,
Value: lastEntry.Value,
Savings: lastEntry.Savings,
}
}
if t.AccountId != nil {
lastEntry.Value += t.Value
}
if t.TreasureChestId != nil {
lastEntry.Savings += t.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(ctx, "dashboard", nil, err)
if err != nil {
return nil, err
}
if value != nil {
summary.Income = *value
if lastEntry != nil {
timeEntries = append(timeEntries, *lastEntry)
}
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(ctx, "dashboard", nil, err)
if err != nil {
return nil, err
}
if value != nil {
summary.Expenses = *value
}
summary.Total = summary.Income + summary.Expenses
summary.Month = month
err = s.db.GetContext(ctx, &value, `
SELECT SUM(current_balance)
FROM treasure_chest
WHERE user_id = $1`,
user.Id)
err = db.TransformAndLogDbError(ctx, "dashboard", nil, err)
if err != nil {
return nil, err
}
if value != nil {
summary.SumOfSavings = *value
}
err = s.db.GetContext(ctx, &value, `
SELECT SUM(current_balance)
FROM account
WHERE user_id = $1`,
user.Id)
err = db.TransformAndLogDbError(ctx, "dashboard", nil, err)
if err != nil {
return nil, err
}
if value != nil {
summary.SumOfAccounts = *value
}
return &summary, nil
return timeEntries, nil
}

View File

@@ -1,53 +1,7 @@
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 datetime"
value={ summary.Month.String() }
/>
</form>
<div id="dashboard">
@DashboardData(summary)
</div>
templ Dashboard() {
<div class="mt-10 h-full">
<div id="graph" class="h-96"></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>
<section class="grid grid-cols-[auto_auto_auto_1fr] gap-4">
<span>Total Savings</span>
<span>Total Account Balance</span>
<span>Net</span>
<span></span>
<span>{ types.FormatEuros(summary.SumOfSavings) }</span>
<span>{ types.FormatEuros(summary.SumOfAccounts) }</span>
@balance(summary.SumOfAccounts - summary.SumOfSavings)
</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

@@ -27,6 +27,8 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/toast.js"></script>
<script src="/static/js/time.js"></script>
<script src="/static/js/echarts.min.js"></script>
<script src="/static/js/dashboard.js" defer></script>
</head>
<body class="h-screen flex flex-col" hx-headers='{"Csrf-Token": "CSRF_TOKEN"}'>
// Header
@@ -36,7 +38,7 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str
<span class="text-4xl font-bold font-pirata">SpendSparrow</span>
</a>
if loggedIn {
<a class={ layoutLinkClass(path == "/") } href="/">Dashboard</a>
<a class={ layoutLinkClass(path == "/dashboard") } href="/dashboard">Dashboard</a>
<a class={ layoutLinkClass(path == "/transaction") } href="/transaction">Transaction</a>
<a class={ layoutLinkClass(path == "/treasurechest") } href="/treasurechest">Treasure Chest</a>
<a class={ layoutLinkClass(path == "/account") } href="/account">Account</a>

View File

@@ -1,7 +1,6 @@
package template
templ Index() {
<!-- <div class="h-full flex flex-col items-center justify-center"> -->
<div class="h-full flex flex-col items-center justify-center">
<h1 class="flex gap-2 w-full justify-center">
<img class="w-24" src="/static/favicon.svg" alt="SpendSparrow logo"/>

View File

@@ -16,3 +16,9 @@ type DashboardMonthlySummary struct {
SumOfSavings int64
SumOfAccounts int64
}
type DashboardMainChartEntry struct {
Day time.Time
Value int64
Savings int64
}

28
package-lock.json generated
View File

@@ -8,6 +8,9 @@
"name": "spend-sparrow",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"echarts": "^5.6.0"
},
"devDependencies": {
"@tailwindcss/cli": "4.1.10",
"htmx.org": "2.0.4",
@@ -790,6 +793,16 @@
"node": ">=0.10"
}
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@@ -1281,6 +1294,12 @@
"node": ">=8.0"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -1290,6 +1309,15 @@
"engines": {
"node": ">=18"
}
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}

View File

@@ -1,18 +1,19 @@
{
"name": "spend-sparrow",
"version": "1.0.0",
"description": "Your (almost) independent tech stack to host on a VPC.",
"description": "Personal finance tracking done right",
"main": "index.js",
"scripts": {
"build": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --minify",
"watch": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --watch"
"build": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && cp -f node_modules/echarts/dist/echarts.min.js static/js/echarts.min.js && tailwindcss -i input.css -o static/css/tailwind.css --minify",
"watch": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && cp -f node_modules/echarts/dist/echarts.min.js static/js/echarts.min.js && tailwindcss -i input.css -o static/css/tailwind.css --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@tailwindcss/cli": "4.1.10",
"htmx.org": "2.0.4",
"tailwindcss": "4.1.10",
"@tailwindcss/cli": "4.1.10"
"echarts": "5.6.0"
}
}

38
static/js/dashboard.js Normal file
View File

@@ -0,0 +1,38 @@
// Initialize the echarts instance based on the prepared dom
async function init() {
const element = document.getElementById('graph')
if (element === null) {
return;
}
var myChart = echarts.init(element);
try {
const response = await fetch("/dashboard/dataset");
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const option = await response.json();
option.tooltip.formatter = function (params) {
return new Date(params[0].data[0]).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric' }) +
'<br />' +
'Sum of Accounts: <span class="font-bold">' + params[0].data[1] + '</span> € <br />' +
'Sum of Savings: <span class="font-bold">' + params[1].data[1] + '</span> €'
};
const chart = myChart.setOption(option);
window.addEventListener('resize', function() {
myChart.resize();
});
console.log("initialized charts");
} catch (error) {
console.error(error.message);
}
// Display the chart using the configuration items and data just specified.
}
init();

View File

@@ -4,7 +4,6 @@ htmx.on("htmx:afterSwap", () => {
});
document.addEventListener("DOMContentLoaded", () => {
console.log("DOMContentLoaded");
updateTime(document);
})