feat: phase 1 dashboard redesign — disposable income + smart panels
Replaces the old KPI/chart dashboard with a focused layout that answers the three key questions immediately: - Hero block: "Available to spend" = income − fixed costs − spent so far, with a progress bar showing % of disposable used vs month elapsed - Bank math panel: detects recurring fixed expenses (Housing, Utilities, Subscriptions, Investments) from last 3 months and shows the minimum bank balance needed right now including a 2-week safety buffer - Savings rate card with month-over-month delta - Portfolio snapshot card with total value and P&L - Stocks at a glance panel: per-holding value and P&L inline - Budget health: thin bars per category, red when over limit - Recent activity: last 5 transactions with category color dots Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
be76117ce7
commit
1d3aa764cb
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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -204,7 +204,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now()
|
||||
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
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{})
|
||||
if err != nil {
|
||||
@ -217,21 +217,22 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
slog.Error("get categories", "err", err)
|
||||
}
|
||||
catColors := make(map[string]string)
|
||||
catBudgets := make(map[string]int64)
|
||||
catNames := make(map[string]string)
|
||||
for _, c := range cats {
|
||||
catNames[c.Name] = c.Name
|
||||
catColors[c.Name] = c.Color
|
||||
if c.BudgetCents > 0 {
|
||||
catBudgets[c.Name] = c.BudgetCents
|
||||
}
|
||||
}
|
||||
|
||||
thisMonth := &PeriodSummary{
|
||||
TotalCents: 0,
|
||||
ByCategory: make(map[string]int64),
|
||||
CategoryNames: catNames,
|
||||
}
|
||||
lastMonth := &PeriodSummary{
|
||||
TotalCents: 0,
|
||||
ByCategory: make(map[string]int64),
|
||||
CategoryNames: catNames,
|
||||
}
|
||||
thisMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames}
|
||||
lastMonth := &PeriodSummary{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
|
||||
|
||||
var recent []Transaction
|
||||
var balPoints []BalancePoint
|
||||
@ -239,25 +240,36 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
var balDates []string
|
||||
|
||||
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.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.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)
|
||||
}
|
||||
|
||||
day := t.Date.Format("2006-01-02")
|
||||
balByDate[day] += t.AmountCents
|
||||
balDates = appendIfMissing(balDates, day)
|
||||
}
|
||||
|
||||
for d := range balByDate {
|
||||
balDates = append(balDates, d)
|
||||
}
|
||||
sortStrings(balDates)
|
||||
running := int64(0)
|
||||
for _, d := range balDates {
|
||||
@ -269,41 +281,142 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
balPoints = balPoints[len(balPoints)-90:]
|
||||
}
|
||||
|
||||
// compute income vs expense split for this month
|
||||
// income / expense split
|
||||
thisMonthIncome := int64(0)
|
||||
thisMonthExpense := int64(0)
|
||||
for _, amt := range thisMonth.ByCategory {
|
||||
if amt > 0 {
|
||||
thisMonthIncome += amt
|
||||
} 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
|
||||
catBudgets := make(map[string]int64)
|
||||
catColors := make(map[string]string)
|
||||
for _, c := range cats {
|
||||
if c.BudgetCents > 0 {
|
||||
catBudgets[c.Name] = c.BudgetCents
|
||||
}
|
||||
catColors[c.Name] = c.Color
|
||||
availableToSpend := disposableIncome - variableSpent
|
||||
if availableToSpend < 0 {
|
||||
availableToSpend = 0
|
||||
}
|
||||
|
||||
render(w, dashboardTmpl, map[string]interface{}{
|
||||
"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,
|
||||
// month progress
|
||||
daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
|
||||
monthProgressPct := int(float64(now.Day()) / float64(daysInMonth) * 100)
|
||||
|
||||
// % of disposable already spent
|
||||
monthSpentPct := 0
|
||||
if disposableIncome > 0 {
|
||||
monthSpentPct = int(float64(variableSpent) / float64(disposableIncome) * 100)
|
||||
if monthSpentPct > 100 {
|
||||
monthSpentPct = 100
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (best-effort, ignore errors)
|
||||
var portfolioValueCents, portfolioPCLCents int64
|
||||
var portfolioHoldings []Holding
|
||||
if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 {
|
||||
if prices, err := fetchPricesByISIN(uniqueISINs(trades)); err == nil {
|
||||
pr := aggregatePortfolio(computeHoldings(trades, prices))
|
||||
portfolioValueCents = pr.TotalVal
|
||||
portfolioPCLCents = pr.TotalPCL
|
||||
portfolioHoldings = pr.Holdings
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1227,3 +1340,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
func sortStrings(s []string) {
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
UserID string
|
||||
Email string
|
||||
@ -114,6 +127,28 @@ type DashboardData struct {
|
||||
LastMonth *PeriodSummary
|
||||
RecentTxns []Transaction
|
||||
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
|
||||
}
|
||||
|
||||
type PeriodSummary struct {
|
||||
|
||||
@ -1,68 +1,160 @@
|
||||
{{define "content"}}
|
||||
{{$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;">
|
||||
<h1>Dashboard</h1>
|
||||
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
||||
</div>
|
||||
|
||||
<!-- KPI cards -->
|
||||
<div class="grid">
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>Net This Month</h2>
|
||||
<div class="value {{if lt $d.ThisMonth.TotalCents 0}}negative{{else}}positive{{end}} animate-counter"
|
||||
data-target="{{$d.ThisMonth.TotalCents}}" data-prefix="€">€0.00</div>
|
||||
<!-- HERO: available to spend -->
|
||||
<div class="card animate-on-scroll" style="margin-bottom:16px; padding:24px 28px;">
|
||||
<div style="font-size:12px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:10px; display:flex; align-items:center; gap:8px;">
|
||||
Available to spend this month
|
||||
<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;">
|
||||
income − fixed costs − spent so far
|
||||
</span>
|
||||
</div>
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>Income</h2>
|
||||
<div class="value positive animate-counter"
|
||||
data-target="{{$d.ThisMonthIncome}}" data-prefix="€">€0.00</div>
|
||||
</div>
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>Expenses</h2>
|
||||
<div class="value negative animate-counter"
|
||||
data-target="{{$d.ThisMonthExpense}}" data-prefix="€">€0.00</div>
|
||||
</div>
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>vs Last Month</h2>
|
||||
<div class="value {{if lt $change 0}}negative{{else}}positive{{end}} animate-counter"
|
||||
data-target="{{$change}}" data-prefix="€">€0.00</div>
|
||||
|
||||
<div style="display:flex; align-items:baseline; gap:20px; flex-wrap:wrap; margin-bottom:16px;">
|
||||
<div class="animate-counter {{if lt $d.AvailableToSpend 0}}negative{{else}}positive{{end}}"
|
||||
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>
|
||||
|
||||
<!-- Charts -->
|
||||
<div style="background:var(--bg3); border-radius:99px; height:6px; overflow:hidden; margin-bottom:6px;">
|
||||
<div style="height:100%; border-radius:99px; width:{{$d.MonthSpentPct}}%;
|
||||
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 style="display:flex; justify-content:space-between;">
|
||||
<span style="font-size:11px; color:var(--text3);">Month progress: {{$d.MonthProgressPct}}%</span>
|
||||
<span style="font-size:11px; color:var(--text3);">Spent: {{$d.MonthSpentPct}}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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.PortfolioValueCents}}
|
||||
<div class="value animate-counter" data-target="{{$d.PortfolioValueCents}}" data-prefix="€" style="color:var(--text);">€0.00</div>
|
||||
<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}}
|
||||
<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);">{{.ISIN}} · {{printf "%.2f" .SharesOwned}} shares</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<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>
|
||||
</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</span>
|
||||
<span class="animate-counter" style="font-size:15px; font-weight:600; {{if ge $d.PortfolioPCLCents 0}}color:var(--green){{else}}color:var(--red){{end}};"
|
||||
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="card animate-on-scroll">
|
||||
<h2>Spending by Category — This Month</h2>
|
||||
{{if $d.ThisMonth.ByCategory}}
|
||||
<div style="position:relative; padding-top:8px;">
|
||||
<canvas id="thisMonthChart" height="220"></canvas>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state" style="padding:32px;">No spending data this month.</div>
|
||||
{{end}}
|
||||
</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 style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||
<h2>Budget health</h2>
|
||||
<a href="/categories" style="font-size:12px; color:var(--text3);">→ categories</a>
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; gap:16px;">
|
||||
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||
{{range $cat, $budget := $d.CategoryBudgets}}
|
||||
{{$spent := index $d.ThisMonth.ByCategory $cat}}
|
||||
{{$spentAbs := centsAbs $spent}}
|
||||
@ -70,20 +162,19 @@
|
||||
{{$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}}
|
||||
<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: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 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:8px; height:7px; overflow:hidden;">
|
||||
<div style="height:100%; border-radius:8px; width:{{$pct}}%; transition:width 1s ease;
|
||||
<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}};
|
||||
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>
|
||||
transition:width 1s ease;"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -91,127 +182,37 @@
|
||||
</div>
|
||||
{{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 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>
|
||||
<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>
|
||||
{{if $d.RecentTxns}}
|
||||
<div style="display:flex; flex-direction:column;">
|
||||
{{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;">
|
||||
<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)}}
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted" style="padding:36px;">
|
||||
<div class="empty-state" style="padding:24px;">
|
||||
No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</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>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user