Merge pull request #7 from GoncaloRodri/feature/phase1-dashboard
feat: phase 1 dashboard — disposable income & smart panels
This commit is contained in:
commit
809fc01d48
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "finance-api",
|
||||||
|
"runtimeExecutable": "go",
|
||||||
|
"runtimeArgs": ["run", "./apps/finance/services/api/main/"],
|
||||||
|
"port": 8080
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
Makefile
10
Makefile
@ -4,8 +4,6 @@ SHELL := /bin/zsh
|
|||||||
|
|
||||||
K3D_SCRIPT := infrastructure/k3d/k3d.sh
|
K3D_SCRIPT := infrastructure/k3d/k3d.sh
|
||||||
TERRAFORM := terraform
|
TERRAFORM := terraform
|
||||||
SHA := $(shell git rev-parse --short HEAD)
|
|
||||||
|
|
||||||
.PHONY: up
|
.PHONY: up
|
||||||
up: ## Create the k3d dev cluster
|
up: ## Create the k3d dev cluster
|
||||||
$(K3D_SCRIPT) homelab
|
$(K3D_SCRIPT) homelab
|
||||||
@ -22,15 +20,15 @@ SERVICES := $(shell find apps -name Makefile -path "*/services/*" -exec dirname
|
|||||||
|
|
||||||
.PHONY: deploy-finance
|
.PHONY: deploy-finance
|
||||||
deploy-finance: ## Build and deploy the finance API
|
deploy-finance: ## Build and deploy the finance API
|
||||||
$(MAKE) -C apps/finance/services/api build-deploy IMAGE_TAG=$(SHA)
|
$(MAKE) -C apps/finance/services/api build-deploy
|
||||||
|
|
||||||
.PHONY: deploy-auth-users
|
.PHONY: deploy-auth-users
|
||||||
deploy-auth-users: ## Build and deploy the auth users service
|
deploy-auth-users: ## Build and deploy the auth users service
|
||||||
$(MAKE) -C apps/auth/services/users build-deploy IMAGE_TAG=$(SHA)
|
$(MAKE) -C apps/auth/services/users build-deploy
|
||||||
|
|
||||||
.PHONY: deploy-auth-gateway
|
.PHONY: deploy-auth-gateway
|
||||||
deploy-auth-gateway: ## Build and deploy the auth gateway service
|
deploy-auth-gateway: ## Build and deploy the auth gateway service
|
||||||
$(MAKE) -C apps/auth/services/gateway build-deploy IMAGE_TAG=$(SHA)
|
$(MAKE) -C apps/auth/services/gateway build-deploy
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: ## Run all tests
|
test: ## Run all tests
|
||||||
@ -40,7 +38,7 @@ test: ## Run all tests
|
|||||||
deploy-all: ## Build, load, deploy, and restart every service
|
deploy-all: ## Build, load, deploy, and restart every service
|
||||||
@for dir in $(SERVICES); do \
|
@for dir in $(SERVICES); do \
|
||||||
echo "\033[36m>>> $$dir\033[0m"; \
|
echo "\033[36m>>> $$dir\033[0m"; \
|
||||||
$(MAKE) -C "$$dir" build-deploy IMAGE_TAG=$(SHA) || true; \
|
$(MAKE) -C "$$dir" build-deploy || true; \
|
||||||
done
|
done
|
||||||
|
|
||||||
.PHONY: restart-all
|
.PHONY: restart-all
|
||||||
|
|||||||
@ -17,7 +17,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: gateway
|
- name: gateway
|
||||||
image: homelab/auth-gateway:latest
|
image: homelab/gateway:latest
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
|
|||||||
@ -17,7 +17,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: users
|
- name: users
|
||||||
image: homelab/auth-users:latest
|
image: homelab/users:latest
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
|
|||||||
@ -17,7 +17,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: api
|
- name: api
|
||||||
image: homelab/finance-api:latest
|
image: homelab/api:latest
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
|
|||||||
@ -204,7 +204,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||||
lastStart := thisStart.AddDate(0, -1, 0)
|
lastStart := thisStart.AddDate(0, -1, 0)
|
||||||
lastEnd := thisStart.Add(-time.Nanosecond)
|
threeMonthsAgo := thisStart.AddDate(0, -3, 0)
|
||||||
|
|
||||||
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -217,21 +217,23 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("get categories", "err", err)
|
slog.Error("get categories", "err", err)
|
||||||
}
|
}
|
||||||
|
catColors := make(map[string]string)
|
||||||
|
catBudgets := make(map[string]int64)
|
||||||
catNames := make(map[string]string)
|
catNames := make(map[string]string)
|
||||||
for _, c := range cats {
|
for _, c := range cats {
|
||||||
catNames[c.Name] = c.Name
|
catNames[c.Name] = c.Name
|
||||||
|
catColors[c.Name] = c.Color
|
||||||
|
// exclude fixed categories from budget health — they're committed costs, not variable spend
|
||||||
|
if c.BudgetCents > 0 && !FixedCategories[c.Name] {
|
||||||
|
catBudgets[c.Name] = c.BudgetCents
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
thisMonth := &PeriodSummary{
|
thisMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames}
|
||||||
TotalCents: 0,
|
lastMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames}
|
||||||
ByCategory: make(map[string]int64),
|
|
||||||
CategoryNames: catNames,
|
// fixed spending by category over the last 3 months (for recurring detection)
|
||||||
}
|
fixedByMonth := make(map[string]map[int]int64) // category -> month-offset -> total
|
||||||
lastMonth := &PeriodSummary{
|
|
||||||
TotalCents: 0,
|
|
||||||
ByCategory: make(map[string]int64),
|
|
||||||
CategoryNames: catNames,
|
|
||||||
}
|
|
||||||
|
|
||||||
var recent []Transaction
|
var recent []Transaction
|
||||||
var balPoints []BalancePoint
|
var balPoints []BalancePoint
|
||||||
@ -239,25 +241,36 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
var balDates []string
|
var balDates []string
|
||||||
|
|
||||||
for _, t := range txns {
|
for _, t := range txns {
|
||||||
if t.Date.After(thisStart) || t.Date.Equal(thisStart) {
|
isThisMonth := !t.Date.Before(thisStart)
|
||||||
|
isLastMonth := !t.Date.Before(lastStart) && t.Date.Before(thisStart)
|
||||||
|
isRecent3 := !t.Date.Before(threeMonthsAgo) && t.Date.Before(thisStart)
|
||||||
|
|
||||||
|
if isThisMonth {
|
||||||
thisMonth.TotalCents += t.AmountCents
|
thisMonth.TotalCents += t.AmountCents
|
||||||
thisMonth.ByCategory[t.Category] += t.AmountCents
|
thisMonth.ByCategory[t.Category] += t.AmountCents
|
||||||
} else if t.Date.After(lastStart) && t.Date.Before(lastEnd.Add(24*time.Hour)) {
|
} else if isLastMonth {
|
||||||
lastMonth.TotalCents += t.AmountCents
|
lastMonth.TotalCents += t.AmountCents
|
||||||
lastMonth.ByCategory[t.Category] += t.AmountCents
|
lastMonth.ByCategory[t.Category] += t.AmountCents
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(recent) < 10 {
|
// accumulate fixed category spending over last 3 months
|
||||||
|
if isRecent3 && FixedCategories[t.Category] && t.AmountCents < 0 {
|
||||||
|
mo := int(t.Date.Month())
|
||||||
|
if fixedByMonth[t.Category] == nil {
|
||||||
|
fixedByMonth[t.Category] = make(map[int]int64)
|
||||||
|
}
|
||||||
|
fixedByMonth[t.Category][mo] += -t.AmountCents
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recent) < 5 {
|
||||||
recent = append(recent, t)
|
recent = append(recent, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
day := t.Date.Format("2006-01-02")
|
day := t.Date.Format("2006-01-02")
|
||||||
balByDate[day] += t.AmountCents
|
balByDate[day] += t.AmountCents
|
||||||
|
balDates = appendIfMissing(balDates, day)
|
||||||
}
|
}
|
||||||
|
|
||||||
for d := range balByDate {
|
|
||||||
balDates = append(balDates, d)
|
|
||||||
}
|
|
||||||
sortStrings(balDates)
|
sortStrings(balDates)
|
||||||
running := int64(0)
|
running := int64(0)
|
||||||
for _, d := range balDates {
|
for _, d := range balDates {
|
||||||
@ -269,41 +282,158 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
balPoints = balPoints[len(balPoints)-90:]
|
balPoints = balPoints[len(balPoints)-90:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// compute income vs expense split for this month
|
// income / expense split
|
||||||
thisMonthIncome := int64(0)
|
thisMonthIncome := int64(0)
|
||||||
thisMonthExpense := int64(0)
|
thisMonthExpense := int64(0)
|
||||||
for _, amt := range thisMonth.ByCategory {
|
for _, amt := range thisMonth.ByCategory {
|
||||||
if amt > 0 {
|
if amt > 0 {
|
||||||
thisMonthIncome += amt
|
thisMonthIncome += amt
|
||||||
} else {
|
} else {
|
||||||
thisMonthExpense += amt
|
thisMonthExpense += -amt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastMonthIncome := int64(0)
|
||||||
|
lastMonthSavings := int64(0)
|
||||||
|
for _, amt := range lastMonth.ByCategory {
|
||||||
|
if amt > 0 {
|
||||||
|
lastMonthIncome += amt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastMonthSavings = lastMonth.TotalCents
|
||||||
|
if lastMonthSavings < 0 {
|
||||||
|
lastMonthSavings = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// detect recurring fixed expenses (average over last 3 months)
|
||||||
|
var recurringExpenses []RecurringExpense
|
||||||
|
totalFixedCents := int64(0)
|
||||||
|
for cat, byMonth := range fixedByMonth {
|
||||||
|
total := int64(0)
|
||||||
|
for _, v := range byMonth {
|
||||||
|
total += v
|
||||||
|
}
|
||||||
|
avg := total / int64(len(byMonth))
|
||||||
|
recurringExpenses = append(recurringExpenses, RecurringExpense{Category: cat, MonthlyCents: avg})
|
||||||
|
totalFixedCents += avg
|
||||||
|
}
|
||||||
|
sort.Slice(recurringExpenses, func(i, j int) bool {
|
||||||
|
return recurringExpenses[i].MonthlyCents > recurringExpenses[j].MonthlyCents
|
||||||
|
})
|
||||||
|
|
||||||
|
// disposable income = income - fixed recurring
|
||||||
|
disposableIncome := thisMonthIncome - totalFixedCents
|
||||||
|
|
||||||
|
// variable spend so far this month (non-fixed categories, expenses only)
|
||||||
|
variableSpent := int64(0)
|
||||||
|
for cat, amt := range thisMonth.ByCategory {
|
||||||
|
if !FixedCategories[cat] && amt < 0 {
|
||||||
|
variableSpent += -amt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// budget data: map category name -> budget cents
|
availableToSpend := disposableIncome - variableSpent
|
||||||
catBudgets := make(map[string]int64)
|
if availableToSpend < 0 {
|
||||||
catColors := make(map[string]string)
|
availableToSpend = 0
|
||||||
for _, c := range cats {
|
|
||||||
if c.BudgetCents > 0 {
|
|
||||||
catBudgets[c.Name] = c.BudgetCents
|
|
||||||
}
|
|
||||||
catColors[c.Name] = c.Color
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render(w, dashboardTmpl, map[string]interface{}{
|
// month progress
|
||||||
"UserID": a.UserID,
|
daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
|
||||||
"Email": a.Email,
|
monthProgressPct := int(float64(now.Day()) / float64(daysInMonth) * 100)
|
||||||
"Title": "Dashboard",
|
|
||||||
"Route": "dashboard",
|
// % of disposable already spent
|
||||||
"IsOwner": true,
|
monthSpentPct := 0
|
||||||
"ThisMonth": thisMonth,
|
if disposableIncome > 0 {
|
||||||
"LastMonth": lastMonth,
|
monthSpentPct = int(float64(variableSpent) / float64(disposableIncome) * 100)
|
||||||
"RecentTxns": recent,
|
if monthSpentPct > 100 {
|
||||||
"BalanceTrend": balPoints,
|
monthSpentPct = 100
|
||||||
"ThisMonthIncome": thisMonthIncome,
|
}
|
||||||
"ThisMonthExpense": thisMonthExpense,
|
}
|
||||||
"CategoryBudgets": catBudgets,
|
|
||||||
"CategoryColors": catColors,
|
// safety buffer = 2 weeks of average daily variable spend over last month
|
||||||
|
lastMonthVariableSpent := int64(0)
|
||||||
|
for cat, amt := range lastMonth.ByCategory {
|
||||||
|
if !FixedCategories[cat] && amt < 0 {
|
||||||
|
lastMonthVariableSpent += -amt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
safetyBuffer := lastMonthVariableSpent / 2
|
||||||
|
|
||||||
|
// bank should be = upcoming fixed costs (not yet paid this month) + safety buffer
|
||||||
|
fixedPaidThisMonth := int64(0)
|
||||||
|
for cat, amt := range thisMonth.ByCategory {
|
||||||
|
if FixedCategories[cat] && amt < 0 {
|
||||||
|
fixedPaidThisMonth += -amt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bankShouldBe := (totalFixedCents - fixedPaidThisMonth) + safetyBuffer
|
||||||
|
|
||||||
|
// savings rate
|
||||||
|
savingsRatePct := 0
|
||||||
|
if thisMonthIncome > 0 {
|
||||||
|
saved := thisMonthIncome - thisMonthExpense
|
||||||
|
if saved > 0 {
|
||||||
|
savingsRatePct = int(float64(saved) / float64(thisMonthIncome) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastMonthSavingsRatePct := 0
|
||||||
|
if lastMonthIncome > 0 && lastMonthSavings > 0 {
|
||||||
|
lastMonthSavingsRatePct = int(float64(lastMonthSavings) / float64(lastMonthIncome) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// portfolio snapshot — degrade to cost basis if live prices are unavailable
|
||||||
|
var portfolioValueCents, portfolioPCLCents int64
|
||||||
|
var portfolioHoldings []Holding
|
||||||
|
var portfolioPricesAvailable bool
|
||||||
|
if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 {
|
||||||
|
prices, _ := fetchPricesByISIN(uniqueISINs(trades))
|
||||||
|
holdings := computeHoldings(trades, prices)
|
||||||
|
pr := aggregatePortfolio(holdings)
|
||||||
|
portfolioHoldings = pr.Holdings
|
||||||
|
|
||||||
|
// check whether any prices came back
|
||||||
|
for _, p := range prices {
|
||||||
|
if p > 0 {
|
||||||
|
portfolioPricesAvailable = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if portfolioPricesAvailable {
|
||||||
|
portfolioValueCents = pr.TotalVal
|
||||||
|
portfolioPCLCents = pr.TotalPCL
|
||||||
|
} else {
|
||||||
|
// fall back to cost basis so the card is still useful
|
||||||
|
portfolioValueCents = pr.TotalCost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(w, dashboardTmpl, &DashboardData{
|
||||||
|
UserID: a.UserID,
|
||||||
|
Email: a.Email,
|
||||||
|
Title: "Dashboard",
|
||||||
|
Route: "dashboard",
|
||||||
|
IsOwner: true,
|
||||||
|
ThisMonth: thisMonth,
|
||||||
|
LastMonth: lastMonth,
|
||||||
|
RecentTxns: recent,
|
||||||
|
BalanceTrend: balPoints,
|
||||||
|
ThisMonthIncome: thisMonthIncome,
|
||||||
|
ThisMonthExpense: thisMonthExpense,
|
||||||
|
CategoryBudgets: catBudgets,
|
||||||
|
CategoryColors: catColors,
|
||||||
|
AvailableToSpend: availableToSpend,
|
||||||
|
DisposableIncome: disposableIncome,
|
||||||
|
MonthProgressPct: monthProgressPct,
|
||||||
|
MonthSpentPct: monthSpentPct,
|
||||||
|
RecurringExpenses: recurringExpenses,
|
||||||
|
BankShouldBe: bankShouldBe,
|
||||||
|
SafetyBufferCents: safetyBuffer,
|
||||||
|
SavingsRatePct: savingsRatePct,
|
||||||
|
LastMonthSavingsRatePct: lastMonthSavingsRatePct,
|
||||||
|
PortfolioValueCents: portfolioValueCents,
|
||||||
|
PortfolioPCLCents: portfolioPCLCents,
|
||||||
|
PortfolioHoldings: portfolioHoldings,
|
||||||
|
PortfolioPricesAvailable: portfolioPricesAvailable,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1227,3 +1357,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
func sortStrings(s []string) {
|
func sortStrings(s []string) {
|
||||||
sort.Strings(s)
|
sort.Strings(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendIfMissing(s []string, v string) []string {
|
||||||
|
for _, x := range s {
|
||||||
|
if x == v {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(s, v)
|
||||||
|
}
|
||||||
|
|||||||
@ -104,6 +104,19 @@ type CSVImportPreview struct {
|
|||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FixedCategories are treated as recurring committed costs, not variable spend.
|
||||||
|
var FixedCategories = map[string]bool{
|
||||||
|
"Housing": true,
|
||||||
|
"Utilities": true,
|
||||||
|
"Subscriptions": true,
|
||||||
|
"Investments": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecurringExpense struct {
|
||||||
|
Category string
|
||||||
|
MonthlyCents int64
|
||||||
|
}
|
||||||
|
|
||||||
type DashboardData struct {
|
type DashboardData struct {
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
@ -114,6 +127,29 @@ type DashboardData struct {
|
|||||||
LastMonth *PeriodSummary
|
LastMonth *PeriodSummary
|
||||||
RecentTxns []Transaction
|
RecentTxns []Transaction
|
||||||
BalanceTrend []BalancePoint
|
BalanceTrend []BalancePoint
|
||||||
|
|
||||||
|
// Phase 1 fields
|
||||||
|
ThisMonthIncome int64
|
||||||
|
ThisMonthExpense int64
|
||||||
|
CategoryBudgets map[string]int64
|
||||||
|
CategoryColors map[string]string
|
||||||
|
|
||||||
|
AvailableToSpend int64 // income − fixed − variable budgets spent so far
|
||||||
|
DisposableIncome int64 // income − fixed recurring costs
|
||||||
|
MonthProgressPct int // % of month elapsed
|
||||||
|
MonthSpentPct int // % of disposable already spent
|
||||||
|
|
||||||
|
RecurringExpenses []RecurringExpense
|
||||||
|
BankShouldBe int64 // sum of upcoming fixed costs + safety buffer
|
||||||
|
SafetyBufferCents int64
|
||||||
|
|
||||||
|
SavingsRatePct int // savings / income * 100 this month
|
||||||
|
LastMonthSavingsRatePct int
|
||||||
|
|
||||||
|
PortfolioValueCents int64
|
||||||
|
PortfolioPCLCents int64
|
||||||
|
PortfolioHoldings []Holding
|
||||||
|
PortfolioPricesAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeriodSummary struct {
|
type PeriodSummary struct {
|
||||||
|
|||||||
@ -1,217 +1,227 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
{{$change := sub $d.ThisMonth.TotalCents $d.LastMonth.TotalCents}}
|
|
||||||
|
|
||||||
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KPI cards -->
|
<!-- HERO: available to spend -->
|
||||||
<div class="grid">
|
<div class="card animate-on-scroll" style="margin-bottom:16px; padding:24px 28px;">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div style="font-size:12px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:10px; display:flex; align-items:center; gap:8px;">
|
||||||
<h2>Net This Month</h2>
|
Available to spend this month
|
||||||
<div class="value {{if lt $d.ThisMonth.TotalCents 0}}negative{{else}}positive{{end}} animate-counter"
|
<span style="font-size:11px; background:var(--bg3); color:var(--text3); padding:2px 8px; border-radius:99px; font-weight:400; text-transform:none; letter-spacing:0;">
|
||||||
data-target="{{$d.ThisMonth.TotalCents}}" data-prefix="€">€0.00</div>
|
income − fixed costs − spent so far
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
|
||||||
<h2>Income</h2>
|
<div style="display:flex; align-items:baseline; gap:20px; flex-wrap:wrap; margin-bottom:16px;">
|
||||||
<div class="value positive animate-counter"
|
<div class="animate-counter {{if lt $d.AvailableToSpend 0}}negative{{else}}positive{{end}}"
|
||||||
data-target="{{$d.ThisMonthIncome}}" data-prefix="€">€0.00</div>
|
style="font-size:42px; font-weight:500; letter-spacing:-1.5px; line-height:1;"
|
||||||
|
data-target="{{$d.AvailableToSpend}}" data-prefix="€">€0.00</div>
|
||||||
|
<div style="font-size:13px; color:var(--text2);">
|
||||||
|
of <span style="color:var(--text); font-weight:500;" class="animate-counter" data-target="{{$d.DisposableIncome}}" data-prefix="€">€0.00</span> disposable
|
||||||
|
· <span style="color:var(--text2);">{{$d.MonthSpentPct}}% used</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
|
||||||
<h2>Expenses</h2>
|
<div style="background:var(--bg3); border-radius:99px; height:6px; overflow:hidden; margin-bottom:6px;">
|
||||||
<div class="value negative animate-counter"
|
<div style="height:100%; border-radius:99px; width:{{$d.MonthSpentPct}}%;
|
||||||
data-target="{{$d.ThisMonthExpense}}" data-prefix="€">€0.00</div>
|
background:{{if gt $d.MonthSpentPct 90}}var(--red){{else if gt $d.MonthSpentPct 70}}#f59e0b{{else}}var(--green){{end}};
|
||||||
|
transition:width 1s ease;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div style="display:flex; justify-content:space-between;">
|
||||||
<h2>vs Last Month</h2>
|
<span style="font-size:11px; color:var(--text3);">Month progress: {{$d.MonthProgressPct}}%</span>
|
||||||
<div class="value {{if lt $change 0}}negative{{else}}positive{{end}} animate-counter"
|
<span style="font-size:11px; color:var(--text3);">Spent: {{$d.MonthSpentPct}}%</span>
|
||||||
data-target="{{$change}}" data-prefix="€">€0.00</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Charts -->
|
<!-- 3 diagnostic cards -->
|
||||||
|
<div class="grid" style="margin-bottom:16px;">
|
||||||
|
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Bank balance should be</h2>
|
||||||
|
<div class="value animate-counter" data-target="{{$d.BankShouldBe}}" data-prefix="€" style="color:var(--text);">€0.00</div>
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">upcoming fixed + safety buffer</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Savings rate</h2>
|
||||||
|
<div class="value {{if gt $d.SavingsRatePct 0}}positive{{else}}negative{{end}}">{{$d.SavingsRatePct}}%</div>
|
||||||
|
{{if $d.LastMonthSavingsRatePct}}
|
||||||
|
<p style="font-size:12px; margin-top:6px; {{if gt $d.SavingsRatePct $d.LastMonthSavingsRatePct}}color:var(--green){{else}}color:var(--red){{end}};">
|
||||||
|
{{if gt $d.SavingsRatePct $d.LastMonthSavingsRatePct}}↑{{else}}↓{{end}} vs last month ({{$d.LastMonthSavingsRatePct}}%)
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Portfolio today</h2>
|
||||||
|
{{if $d.PortfolioHoldings}}
|
||||||
|
<div class="value animate-counter" data-target="{{$d.PortfolioValueCents}}" data-prefix="€" style="color:var(--text);">€0.00</div>
|
||||||
|
{{if $d.PortfolioPricesAvailable}}
|
||||||
|
<p style="font-size:12px; margin-top:6px; {{if ge $d.PortfolioPCLCents 0}}color:var(--green){{else}}color:var(--red){{end}};">
|
||||||
|
{{if ge $d.PortfolioPCLCents 0}}+{{else}}−{{end}}€{{cents (centsAbs $d.PortfolioPCLCents)}} total P&L
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">cost basis · prices unavailable</p>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<div class="value" style="color:var(--text3); font-size:18px;">No trades yet</div>
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;"><a href="/import" style="color:var(--accent);">Import trades →</a></p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bank math + stocks -->
|
||||||
|
<div class="grid-2" style="margin-bottom:16px;">
|
||||||
|
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
|
<h2>What should be in your bank</h2>
|
||||||
|
<span style="font-size:11px; color:var(--text3);">right now</span>
|
||||||
|
</div>
|
||||||
|
{{if $d.RecurringExpenses}}
|
||||||
|
<div style="display:flex; flex-direction:column;">
|
||||||
|
{{range $d.RecurringExpenses}}
|
||||||
|
{{$color := index $d.CategoryColors .Category}}
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid var(--border);">
|
||||||
|
<span style="font-size:13px; color:var(--text2); display:flex; align-items:center; gap:7px;">
|
||||||
|
{{if $color}}<span style="width:7px;height:7px;border-radius:50%;background:{{$color}};display:inline-block;"></span>{{end}}
|
||||||
|
{{.Category}}
|
||||||
|
</span>
|
||||||
|
<span style="font-size:13px; font-weight:500; color:var(--red);">− €{{cents .MonthlyCents}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if $d.SafetyBufferCents}}
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid var(--border);">
|
||||||
|
<span style="font-size:13px; color:var(--text2);">Safety buffer (2 weeks)</span>
|
||||||
|
<span style="font-size:13px; font-weight:500; color:var(--red);">− €{{cents $d.SafetyBufferCents}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
|
||||||
|
<span style="font-size:13px; font-weight:500; color:var(--text);">Minimum recommended</span>
|
||||||
|
<span class="animate-counter positive" style="font-size:16px; font-weight:600;"
|
||||||
|
data-target="{{$d.BankShouldBe}}" data-prefix="€">€0.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state" style="padding:24px;">
|
||||||
|
<p>No recurring expenses detected yet.<br>Import a few months of transactions.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
|
<h2>Stocks at a glance</h2>
|
||||||
|
<a href="/portfolio" style="font-size:12px; color:var(--text3);">→ portfolio</a>
|
||||||
|
</div>
|
||||||
|
{{if $d.PortfolioHoldings}}
|
||||||
|
<div style="display:flex; flex-direction:column;">
|
||||||
|
{{range $d.PortfolioHoldings}}
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border);">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:13px; font-weight:600; color:var(--text);">{{.Name}}</div>
|
||||||
|
<div style="font-size:11px; color:var(--text3);">{{printf "%.4f" .SharesOwned}} shares</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
{{if $d.PortfolioPricesAvailable}}
|
||||||
|
<div style="font-size:13px; font-weight:500; color:var(--text);">€{{cents .CurrentValueCents}}</div>
|
||||||
|
<div style="font-size:12px; {{if ge .UnrealizedPCLCents 0}}color:var(--green){{else}}color:var(--red){{end}};">
|
||||||
|
{{pctSign .UnrealizedPCLPct}}{{printf "%.1f" .UnrealizedPCLPct}}%
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div style="font-size:13px; font-weight:500; color:var(--text);">€{{cents .TotalCostCents}}</div>
|
||||||
|
<div style="font-size:11px; color:var(--text3);">cost basis</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
|
||||||
|
<span style="font-size:13px; font-weight:500; color:var(--text);">Total{{if not $d.PortfolioPricesAvailable}} invested{{end}}</span>
|
||||||
|
<span class="animate-counter" style="font-size:15px; font-weight:600; color:var(--text);"
|
||||||
|
data-target="{{$d.PortfolioValueCents}}" data-prefix="€">€0.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state" style="padding:24px;">
|
||||||
|
<p>No holdings yet.<br><a href="/import" style="color:var(--accent);">Import trades →</a></p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget health + recent activity -->
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
|
|
||||||
|
{{if $d.CategoryBudgets}}
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2>Spending by Category — This Month</h2>
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
{{if $d.ThisMonth.ByCategory}}
|
<h2>Budget health</h2>
|
||||||
<div style="position:relative; padding-top:8px;">
|
<a href="/categories" style="font-size:12px; color:var(--text3);">→ categories</a>
|
||||||
<canvas id="thisMonthChart" height="220"></canvas>
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||||
|
{{range $cat, $budget := $d.CategoryBudgets}}
|
||||||
|
{{$spent := index $d.ThisMonth.ByCategory $cat}}
|
||||||
|
{{$spentAbs := centsAbs $spent}}
|
||||||
|
{{$color := index $d.CategoryColors $cat}}
|
||||||
|
{{$over := isOver $spentAbs $budget}}
|
||||||
|
{{$pct := clampPct $spentAbs $budget}}
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
|
||||||
|
<span style="font-size:12px; color:var(--text2); display:flex; align-items:center; gap:6px;">
|
||||||
|
{{if $color}}<span style="width:7px;height:7px;border-radius:50%;background:{{$color}};display:inline-block;"></span>{{end}}
|
||||||
|
{{$cat}}
|
||||||
|
</span>
|
||||||
|
<span style="font-size:11px; {{if $over}}color:var(--red); font-weight:600;{{else}}color:var(--text3);{{end}}">
|
||||||
|
{{$pct}}%{{if $over}} ⚠{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--bg3); border-radius:99px; height:5px; overflow:hidden;">
|
||||||
|
<div style="height:100%; border-radius:99px; width:{{$pct}}%;
|
||||||
|
background:{{if $over}}var(--red){{else if $color}}{{$color}}{{else}}var(--accent){{end}};
|
||||||
|
transition:width 1s ease;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
|
<h2>Recent activity</h2>
|
||||||
|
<a href="/transactions" style="font-size:12px; color:var(--text3);">→ all transactions</a>
|
||||||
|
</div>
|
||||||
|
{{if $d.RecentTxns}}
|
||||||
|
<div style="display:flex; flex-direction:column;">
|
||||||
|
{{range $d.RecentTxns}}
|
||||||
|
{{$color := index $d.CategoryColors .Category}}
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border);">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; min-width:0;">
|
||||||
|
<div style="width:8px; height:8px; border-radius:50%; flex-shrink:0;
|
||||||
|
background:{{if $color}}{{$color}}{{else}}var(--text3){{end}};"></div>
|
||||||
|
<div style="min-width:0;">
|
||||||
|
<div style="font-size:13px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:180px;">{{.Description}}</div>
|
||||||
|
<div style="font-size:11px; color:var(--text3);">{{dateShort .Date}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px; font-weight:500; white-space:nowrap; margin-left:12px;
|
||||||
|
{{if lt .AmountCents 0}}color:var(--red){{else}}color:var(--green){{end}};">
|
||||||
|
{{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state" style="padding:32px;">No spending data this month.</div>
|
<div class="empty-state" style="padding:24px;">
|
||||||
{{end}}
|
No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a>
|
||||||
</div>
|
|
||||||
<div class="card animate-on-scroll">
|
|
||||||
<h2>Balance Trend — 90 Days</h2>
|
|
||||||
{{if $d.BalanceTrend}}
|
|
||||||
<div style="position:relative; padding-top:8px;">
|
|
||||||
<canvas id="balanceChart" height="220"></canvas>
|
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<div class="empty-state" style="padding:32px;">No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a></div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Budget progress -->
|
|
||||||
{{if $d.CategoryBudgets}}
|
|
||||||
<div class="card animate-on-scroll">
|
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:18px;">
|
|
||||||
<h2>Budget vs Actual — This Month</h2>
|
|
||||||
<a href="/categories" class="btn btn-outline btn-sm">Manage budgets</a>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex; flex-direction:column; gap:16px;">
|
|
||||||
{{range $cat, $budget := $d.CategoryBudgets}}
|
|
||||||
{{$spent := index $d.ThisMonth.ByCategory $cat}}
|
|
||||||
{{$spentAbs := centsAbs $spent}}
|
|
||||||
{{$color := index $d.CategoryColors $cat}}
|
|
||||||
{{$over := isOver $spentAbs $budget}}
|
|
||||||
{{$pct := clampPct $spentAbs $budget}}
|
|
||||||
<div>
|
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:7px;">
|
|
||||||
<span style="font-size:13.5px; font-weight:500; display:flex; align-items:center; gap:7px;">
|
|
||||||
{{if $color}}<span style="width:9px;height:9px;border-radius:50%;background:{{$color}};display:inline-block;box-shadow:0 0 6px {{$color}}66;"></span>{{end}}
|
|
||||||
{{$cat}}
|
|
||||||
</span>
|
|
||||||
<span style="font-size:12.5px; {{if $over}}color:var(--red);font-weight:600;{{else}}color:var(--text2);{{end}}">
|
|
||||||
€{{cents $spentAbs}} / €{{cents $budget}}
|
|
||||||
{{if $over}} ⚠ over budget{{end}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style="background:var(--bg3); border-radius:8px; height:7px; overflow:hidden;">
|
|
||||||
<div style="height:100%; border-radius:8px; width:{{$pct}}%; transition:width 1s ease;
|
|
||||||
background:{{if $over}}var(--red){{else if $color}}{{$color}}{{else}}var(--accent){{end}};
|
|
||||||
box-shadow:{{if $over}}0 0 8px var(--red){{else if $color}}0 0 6px {{$color}}88{{else}}0 0 6px var(--accent-glow){{end}};"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Recent transactions -->
|
|
||||||
<div class="card animate-on-scroll">
|
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:14px;">
|
|
||||||
<h2>Recent Transactions</h2>
|
|
||||||
<a href="/transactions" class="btn btn-outline btn-sm">View all</a>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th class="text-right">Amount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{range $d.RecentTxns}}
|
|
||||||
{{$color := index $d.CategoryColors .Category}}
|
|
||||||
<tr>
|
|
||||||
<td style="white-space:nowrap; color:var(--text2);">{{dateShort .Date}}</td>
|
|
||||||
<td style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge" style="
|
|
||||||
background:{{if $color}}{{$color}}18{{else}}var(--bg3){{end}};
|
|
||||||
color:{{if $color}}{{$color}}{{else}}var(--text2){{end}};
|
|
||||||
border:1px solid {{if $color}}{{$color}}33{{else}}var(--border2){{end}};">
|
|
||||||
{{if $color}}<span class="category-dot" style="background:{{$color}};box-shadow:0 0 4px {{$color}}88;"></span>{{end}}
|
|
||||||
{{.Category}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="font-weight:600; white-space:nowrap;">
|
|
||||||
{{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="text-center text-muted" style="padding:36px;">
|
|
||||||
No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
|
|
||||||
const isDark = () => document.documentElement.getAttribute('data-theme') === 'dark';
|
|
||||||
|
|
||||||
function gridColor() { return isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'; }
|
|
||||||
function textColor() { return isDark() ? '#5c6585' : '#8a92b0'; }
|
|
||||||
|
|
||||||
{{if $d.ThisMonth.ByCategory}}
|
|
||||||
const catLabels = {{jsonKeys $d.ThisMonth.ByCategory}};
|
|
||||||
const catData = {{jsonVals $d.ThisMonth.ByCategory}};
|
|
||||||
const resolvedColors = catLabels.map(k => catColors[k] || '#6979f8');
|
|
||||||
|
|
||||||
new Chart(document.getElementById('thisMonthChart'), {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: catLabels,
|
|
||||||
datasets: [{
|
|
||||||
data: catData.map(v => Math.abs(v) / 100),
|
|
||||||
backgroundColor: resolvedColors.map(c => c + '99'),
|
|
||||||
borderColor: resolvedColors,
|
|
||||||
borderWidth: 1.5,
|
|
||||||
borderRadius: 6,
|
|
||||||
borderSkipped: false,
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
animation: { duration: 900, easing: 'easeOutQuart' },
|
|
||||||
plugins: { legend: { display: false } },
|
|
||||||
scales: {
|
|
||||||
y: { beginAtZero: true, grid: { color: gridColor() }, ticks: { color: textColor(), callback: v => '€' + v } },
|
|
||||||
x: { grid: { display: false }, ticks: { color: textColor() } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if $d.BalanceTrend}}
|
|
||||||
const ctx = document.getElementById('balanceChart').getContext('2d');
|
|
||||||
const grad = ctx.createLinearGradient(0, 0, 0, 260);
|
|
||||||
grad.addColorStop(0, 'rgba(105,121,248,0.30)');
|
|
||||||
grad.addColorStop(0.6, 'rgba(105,121,248,0.08)');
|
|
||||||
grad.addColorStop(1, 'rgba(105,121,248,0.00)');
|
|
||||||
|
|
||||||
new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: [{{range $d.BalanceTrend}}"{{dateShort .Date}}",{{end}}],
|
|
||||||
datasets: [{
|
|
||||||
label: 'Balance (€)',
|
|
||||||
data: [{{range $d.BalanceTrend}}{{div .Cents 100}},{{end}}],
|
|
||||||
borderColor: '#6979f8',
|
|
||||||
backgroundColor: grad,
|
|
||||||
fill: true,
|
|
||||||
tension: 0.42,
|
|
||||||
pointRadius: 0,
|
|
||||||
pointHoverRadius: 5,
|
|
||||||
pointBackgroundColor: '#fff',
|
|
||||||
pointBorderColor: '#6979f8',
|
|
||||||
pointBorderWidth: 2,
|
|
||||||
borderWidth: 2.5,
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
animation: { duration: 1100, easing: 'easeOutQuart' },
|
|
||||||
plugins: { legend: { display: false } },
|
|
||||||
scales: {
|
|
||||||
y: { beginAtZero: false, grid: { color: gridColor() }, ticks: { color: textColor(), callback: v => '€' + v } },
|
|
||||||
x: { grid: { display: false }, ticks: { color: textColor(), maxTicksLimit: 7 } }
|
|
||||||
},
|
|
||||||
interaction: { intersect: false, mode: 'index' }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
{{end}}
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user