diff --git a/.golangci.yaml b/.golangci.yaml index 4df5544..ebd93a4 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -24,6 +24,8 @@ linters: - cyclop - contextcheck - bodyclose # i don't care in the tests, the implementation itself doesn't do http requests + - wsl_v5 + - noinlineerr settings: nestif: min-complexity: 6 diff --git a/internal/default.go b/internal/default.go index 55cb730..6134e82 100644 --- a/internal/default.go +++ b/internal/default.go @@ -121,7 +121,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings * render := handler.NewRender() indexHandler := handler.NewIndex(render, clockService) - dashboardHandler := handler.NewDashboard(render, dashboardService) + dashboardHandler := handler.NewDashboard(render, dashboardService, treasureChestService) authHandler := handler.NewAuth(authService, render) accountHandler := handler.NewAccount(accountService, render) treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render) diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index 4710fa4..22fa96a 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -9,6 +9,8 @@ import ( "spend-sparrow/internal/template/dashboard" "spend-sparrow/internal/utils" "time" + + "github.com/google/uuid" ) type Dashboard interface { @@ -16,14 +18,16 @@ type Dashboard interface { } type DashboardImpl struct { - r *Render - d *service.Dashboard + r *Render + d *service.Dashboard + treasureChest service.TreasureChest } -func NewDashboard(r *Render, d *service.Dashboard) Dashboard { +func NewDashboard(r *Render, d *service.Dashboard, treasureChest service.TreasureChest) Dashboard { return DashboardImpl{ - r: r, - d: d, + r: r, + d: d, + treasureChest: treasureChest, } } @@ -31,6 +35,7 @@ func (handler DashboardImpl) Handle(router *http.ServeMux) { router.Handle("GET /dashboard", handler.handleDashboard()) router.Handle("GET /dashboard/main-chart", handler.handleDashboardMainChart()) router.Handle("GET /dashboard/treasure-chests", handler.handleDashboardTreasureChests()) + router.Handle("GET /dashboard/treasure-chest", handler.handleDashboardTreasureChest()) } func (handler DashboardImpl) handleDashboard() http.HandlerFunc { @@ -43,7 +48,13 @@ func (handler DashboardImpl) handleDashboard() http.HandlerFunc { return } - comp := dashboard.Dashboard() + treasureChests, err := handler.treasureChest.GetAll(r.Context(), user) + if err != nil { + handleError(w, r, err) + return + } + + comp := dashboard.Dashboard(treasureChests) handler.r.RenderLayoutWithStatus(r, w, comp, user, http.StatusOK) } } @@ -70,6 +81,7 @@ func (handler DashboardImpl) handleDashboardMainChart() http.HandlerFunc { 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] @@ -126,8 +138,10 @@ func (handler DashboardImpl) handleDashboardTreasureChests() http.HandlerFunc { w.WriteHeader(http.StatusOK) data := "" + for _, item := range treeList { children := "" + for _, child := range item.Children { if child.Value < 0 { children += fmt.Sprintf(`{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, -child.Value) @@ -135,9 +149,11 @@ func (handler DashboardImpl) handleDashboardTreasureChests() http.HandlerFunc { children += fmt.Sprintf(`{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, child.Value) } } + children = children[:len(children)-1] data += fmt.Sprintf(`{"name":"%s","children":[%s]},`, item.Name, children) } + data = data[:len(data)-1] _, err = fmt.Fprintf(w, ` @@ -159,3 +175,73 @@ func (handler DashboardImpl) handleDashboardTreasureChests() http.HandlerFunc { } } } + +func (handler DashboardImpl) handleDashboardTreasureChest() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + updateSpan(r) + + user := middleware.GetUser(r) + + var treasureChestId *uuid.UUID + + treasureChestStr := r.URL.Query().Get("id") + if treasureChestStr != "" { + id, err := uuid.Parse(treasureChestStr) + if err != nil { + handleError(w, r, fmt.Errorf("could not parse treasure chest: %w", service.ErrBadRequest)) + return + } + + treasureChestId = &id + } + + series, err := handler.d.TreasureChest(r.Context(), user, treasureChestId) + if err != nil { + handleError(w, r, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + value := "" + + for _, entry := range series { + value += fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100) + } + + if len(value) > 0 { + value = value[:len(value)-1] + } + + _, err = fmt.Fprintf(w, ` + { + "aria": { + "enabled": true + }, + "tooltip": { + "trigger": "axis", + "formatter": "" + }, + "xAxis": { + "type": "time" + }, + "yAxis": { + "axisLabel": { + "formatter": "{value} €" + } + }, + "series": [ + { + "data": [%s], + "type": "line", + "name": "Treasure Chest Value" + } + ] + } + `, value) + if err != nil { + slog.InfoContext(r.Context(), "could not write response", "err", err) + } + } +} diff --git a/internal/handler/middleware/cross_site_request_forgery.go b/internal/handler/middleware/cross_site_request_forgery.go index d6cb4ed..2e70ddc 100644 --- a/internal/handler/middleware/cross_site_request_forgery.go +++ b/internal/handler/middleware/cross_site_request_forgery.go @@ -10,6 +10,7 @@ import ( type csrfResponseWriter struct { http.ResponseWriter + csrfToken string } diff --git a/internal/handler/middleware/logger.go b/internal/handler/middleware/logger.go index d2b3930..bb9d54f 100644 --- a/internal/handler/middleware/logger.go +++ b/internal/handler/middleware/logger.go @@ -8,6 +8,7 @@ import ( type WrappedWriter struct { http.ResponseWriter + StatusCode int } diff --git a/internal/service/dashboard.go b/internal/service/dashboard.go index 6f70bae..13235d4 100644 --- a/internal/service/dashboard.go +++ b/internal/service/dashboard.go @@ -6,6 +6,7 @@ import ( "spend-sparrow/internal/types" "time" + "github.com/google/uuid" "github.com/jmoiron/sqlx" ) @@ -39,7 +40,9 @@ func (s Dashboard) MainChart( } timeEntries := make([]types.DashboardMainChartEntry, 0) + var lastEntry *types.DashboardMainChartEntry + for _, t := range transactions { if t.Error != nil { continue @@ -64,6 +67,7 @@ func (s Dashboard) MainChart( if t.AccountId != nil { lastEntry.Value += t.Value } + if t.TreasureChestId != nil { lastEntry.Savings += t.Value } @@ -94,6 +98,7 @@ func (s Dashboard) TreasureChests( treasureChests = sortTreasureChests(treasureChests) result := make([]*types.DashboardTreasureChest, 0) + for _, t := range treasureChests { if t.ParentId == nil { result = append(result, &types.DashboardTreasureChest{ @@ -112,3 +117,59 @@ func (s Dashboard) TreasureChests( return result, nil } + +func (s Dashboard) TreasureChest( + ctx context.Context, + user *types.User, + treausureChestId *uuid.UUID, +) ([]types.DashboardMainChartEntry, error) { + if user == nil { + return nil, ErrUnauthorized + } + + transactions := make([]types.Transaction, 0) + err := s.db.SelectContext(ctx, &transactions, ` + SELECT * + FROM "transaction" + WHERE user_id = ? + AND treasure_chest_id = ? + ORDER BY timestamp`, user.Id, treausureChestId) + err = db.TransformAndLogDbError(ctx, "dashboard Chart", nil, err) + if err != nil { + return nil, err + } + + 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, + } + } else if lastEntry.Day != newDay { + timeEntries = append(timeEntries, *lastEntry) + lastEntry = &types.DashboardMainChartEntry{ + Day: newDay, + Value: lastEntry.Value, + } + } + + if t.TreasureChestId != nil { + lastEntry.Value += t.Value + } + } + + if lastEntry != nil { + timeEntries = append(timeEntries, *lastEntry) + } + + return timeEntries, nil +} diff --git a/internal/template/dashboard/dashboard.templ b/internal/template/dashboard/dashboard.templ index 5e98fb3..28821d5 100644 --- a/internal/template/dashboard/dashboard.templ +++ b/internal/template/dashboard/dashboard.templ @@ -1,8 +1,32 @@ package dashboard -templ Dashboard() { +import "spend-sparrow/internal/types" + +templ Dashboard(treasureChests []*types.TreasureChest) {
-
-
+
+
+
+
+ + +
+
+
} diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 1df405c..809c710 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -7,6 +7,9 @@ async function initMainChart() { } var myChart = echarts.init(element); + window.addEventListener('resize', function() { + myChart.resize(); + }); try { const response = await fetch("/dashboard/main-chart"); @@ -22,10 +25,7 @@ async function initMainChart() { 'Sum of Savings: ' + params[1].data[1] + ' €' }; - const chart = myChart.setOption(option); - window.addEventListener('resize', function() { - myChart.resize(); - }); + myChart.setOption(option); console.log("initialized main-chart"); } catch (error) { @@ -40,6 +40,9 @@ async function initTreasureChests() { } var myChart = echarts.init(element); + window.addEventListener('resize', function() { + myChart.resize(); + }); try { const response = await fetch("/dashboard/treasure-chests"); @@ -48,11 +51,7 @@ async function initTreasureChests() { } const option = await response.json(); - - const chart = myChart.setOption(option); - window.addEventListener('resize', function() { - myChart.resize(); - }); + myChart.setOption(option); console.log("initialized treasure-chests"); } catch (error) { @@ -60,5 +59,42 @@ async function initTreasureChests() { } } +async function initTreasureChest() { + const element = document.getElementById('treasure-chest') + if (element === null) { + return; + } + + var myChart = echarts.init(element); + window.addEventListener('resize', function() { + myChart.resize(); + }); + + + const treasureChestSelect = document.getElementById('treasure-chest-id') + treasureChestSelect.addEventListener("change", async (e) => { + try { + const response = await fetch("/dashboard/treasure-chest?id="+e.target.value); + 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] + ' €' + }; + + myChart.setOption(option); + } catch (error) { + console.error(error.message); + } + }); + + console.log("initialized treasure-chest"); +} + initMainChart(); initTreasureChests(); +initTreasureChest();