From d3c7a96ada6a843d1c0cc2a2760da0ccd09bd347 Mon Sep 17 00:00:00 2001 From: Tim Wundenberg Date: Thu, 19 Jun 2025 13:28:49 +0200 Subject: [PATCH] feat(dashboard): #82 add chart for sum of account and savings --- .gitignore | 1 + internal/default.go | 4 +- internal/handler/dashboard.go | 107 +++++++++++++++++ internal/handler/root_and_404.go | 45 +------- internal/service/dashboard.go | 122 +++++++------------- internal/template/dashboard/dashboard.templ | 52 +-------- internal/template/layout.templ | 4 +- internal/template/root.templ | 1 - internal/types/dashboard.go | 6 + package-lock.json | 28 +++++ package.json | 9 +- static/js/dashboard.js | 38 ++++++ static/js/time.js | 1 - 13 files changed, 242 insertions(+), 176 deletions(-) create mode 100644 internal/handler/dashboard.go create mode 100644 static/js/dashboard.js diff --git a/.gitignore b/.gitignore index b1092fc..1995697 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ secrets/ node_modules/ static/css/tailwind.css static/js/htmx.min.js +static/js/echarts.min.js tmp/ mocks/* diff --git a/internal/default.go b/internal/default.go index 285bf84..55cb730 100644 --- a/internal/default.go +++ b/internal/default.go @@ -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) diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go new file mode 100644 index 0000000..4d6440d --- /dev/null +++ b/internal/handler/dashboard.go @@ -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": "" + }, + "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) + } + } +} diff --git a/internal/handler/root_and_404.go b/internal/handler/root_and_404.go index d4be02f..83b3365 100644 --- a/internal/handler/root_and_404.go +++ b/internal/handler/root_and_404.go @@ -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,35 +63,8 @@ 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) - - // Return nothing + // do nothing } } diff --git a/internal/service/dashboard.go b/internal/service/dashboard.go index 1a79df3..4726434 100644 --- a/internal/service/dashboard.go +++ b/internal/service/dashboard.go @@ -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 } diff --git a/internal/template/dashboard/dashboard.templ b/internal/template/dashboard/dashboard.templ index 7767d2d..362cf96 100644 --- a/internal/template/dashboard/dashboard.templ +++ b/internal/template/dashboard/dashboard.templ @@ -1,53 +1,7 @@ package dashboard -import "spend-sparrow/internal/types" - -templ Dashboard(summary *types.DashboardMonthlySummary) { -
-
- - -
-
- @DashboardData(summary) -
+templ Dashboard() { +
+
} - -templ DashboardData(summary *types.DashboardMonthlySummary) { -
-
- Savings - Income - Expenses - Total - - { types.FormatEuros(summary.Savings) } - @balance(summary.Income) - @balance(summary.Expenses) - @balance(summary.Total) -
-
- Total Savings - Total Account Balance - Net - - { types.FormatEuros(summary.SumOfSavings) } - { types.FormatEuros(summary.SumOfAccounts) } - @balance(summary.SumOfAccounts - summary.SumOfSavings) -
-
-} - -templ balance(balance int64) { - if balance < 0 { - { types.FormatEuros(balance) } - } else { - { types.FormatEuros(balance) } - } -} diff --git a/internal/template/layout.templ b/internal/template/layout.templ index bd3321c..52edb1c 100644 --- a/internal/template/layout.templ +++ b/internal/template/layout.templ @@ -27,6 +27,8 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str + + // Header @@ -36,7 +38,7 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str SpendSparrow if loggedIn { - Dashboard + Dashboard Transaction Treasure Chest Account diff --git a/internal/template/root.templ b/internal/template/root.templ index 03aea32..ea56545 100644 --- a/internal/template/root.templ +++ b/internal/template/root.templ @@ -1,7 +1,6 @@ package template templ Index() { -

SpendSparrow logo diff --git a/internal/types/dashboard.go b/internal/types/dashboard.go index dfc5595..013ee93 100644 --- a/internal/types/dashboard.go +++ b/internal/types/dashboard.go @@ -16,3 +16,9 @@ type DashboardMonthlySummary struct { SumOfSavings int64 SumOfAccounts int64 } + +type DashboardMainChartEntry struct { + Day time.Time + Value int64 + Savings int64 +} diff --git a/package-lock.json b/package-lock.json index 5c61e5a..94029b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index f0cd73e..ab8b317 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..a53d666 --- /dev/null +++ b/static/js/dashboard.js @@ -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' }) + + '
' + + 'Sum of Accounts: ' + params[0].data[1] + '
' + + 'Sum of Savings: ' + params[1].data[1] + ' €' + }; + + 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(); diff --git a/static/js/time.js b/static/js/time.js index 1adeebd..d4f41c7 100644 --- a/static/js/time.js +++ b/static/js/time.js @@ -4,7 +4,6 @@ htmx.on("htmx:afterSwap", () => { }); document.addEventListener("DOMContentLoaded", () => { - console.log("DOMContentLoaded"); updateTime(document); })