feat(dashboard): #82 add chart for sum of account and savings
Some checks failed
Build Docker Image / Build-Docker-Image (push) Failing after 3m40s
Some checks failed
Build Docker Image / Build-Docker-Image (push) Failing after 3m40s
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,6 +31,7 @@ secrets/
|
||||
node_modules/
|
||||
static/css/tailwind.css
|
||||
static/js/htmx.min.js
|
||||
static/js/echarts.min.js
|
||||
tmp/
|
||||
|
||||
mocks/*
|
||||
|
||||
@@ -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)
|
||||
|
||||
107
internal/handler/dashboard.go
Normal file
107
internal/handler/dashboard.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
transactions := make([]types.Transaction, 0)
|
||||
err := s.db.SelectContext(ctx, &transactions, `
|
||||
SELECT *
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
28
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
38
static/js/dashboard.js
Normal 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();
|
||||
@@ -4,7 +4,6 @@ htmx.on("htmx:afterSwap", () => {
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
console.log("DOMContentLoaded");
|
||||
updateTime(document);
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user