feat: phase 5 — what-if simulator + savings rate history
Adds /simulator page with live sliders for income change, one-off expenses, and fixed cost shifts. Goal timelines update in real time showing months gained/lost. Includes a savings rate bar chart from historical monthly data. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
42f5b0df4d
commit
39282ff550
@ -117,6 +117,7 @@ var (
|
||||
sharingTmpl = parseTmpl("templates/base.html", "templates/sharing.html")
|
||||
goalsTmpl = parseTmpl("templates/base.html", "templates/goals.html")
|
||||
networthTmpl = parseTmpl("templates/base.html", "templates/networth.html")
|
||||
simulatorTmpl = parseTmpl("templates/base.html", "templates/simulator.html")
|
||||
)
|
||||
|
||||
type authInfo struct {
|
||||
@ -1545,6 +1546,136 @@ func parseFloat(s string) float64 {
|
||||
return f
|
||||
}
|
||||
|
||||
func (h *Handler) Simulator(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
|
||||
txns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
||||
goals, _ := h.store.getGoals(ctx, a.UserID)
|
||||
|
||||
now := time.Now()
|
||||
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
|
||||
// this month income + fixed costs
|
||||
thisMonthIncome := int64(0)
|
||||
totalFixed := int64(0)
|
||||
fixedByMonth := make(map[string]map[int]int64)
|
||||
threeAgo := thisStart.AddDate(0, -3, 0)
|
||||
monthlySavings := make(map[string]struct{ income, saved int64 })
|
||||
|
||||
for _, t := range txns {
|
||||
mk := t.Date.Format("2006-01")
|
||||
if t.Date.Before(thisStart) {
|
||||
// savings history: accumulate per month
|
||||
ms := monthlySavings[mk]
|
||||
if t.AmountCents > 0 {
|
||||
ms.income += t.AmountCents
|
||||
}
|
||||
ms.saved += t.AmountCents
|
||||
monthlySavings[mk] = ms
|
||||
|
||||
// fixed category detection over last 3 months
|
||||
if !t.Date.Before(threeAgo) && 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
|
||||
}
|
||||
} else {
|
||||
if t.AmountCents > 0 {
|
||||
thisMonthIncome += t.AmountCents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// avg monthly fixed from last 3 months
|
||||
for _, byMo := range fixedByMonth {
|
||||
total := int64(0)
|
||||
for _, v := range byMo {
|
||||
total += v
|
||||
}
|
||||
totalFixed += total / int64(len(byMo))
|
||||
}
|
||||
|
||||
// committed goal monthly totals
|
||||
goalsCents := int64(0)
|
||||
var simGoals []SimulatorGoal
|
||||
for _, g := range goals {
|
||||
remaining := g.TargetCents - g.SavedCents
|
||||
if remaining <= 0 {
|
||||
continue
|
||||
}
|
||||
ml := int64(monthsBetween(now, g.Deadline))
|
||||
if ml < 1 {
|
||||
ml = 1
|
||||
}
|
||||
monthly := remaining / ml
|
||||
if g.Committed {
|
||||
goalsCents += monthly
|
||||
}
|
||||
simGoals = append(simGoals, SimulatorGoal{
|
||||
Name: g.Name,
|
||||
MonthlyCents: monthly,
|
||||
MonthsLeft: ml,
|
||||
Committed: g.Committed,
|
||||
})
|
||||
}
|
||||
|
||||
disposable := thisMonthIncome - totalFixed - goalsCents
|
||||
|
||||
// average monthly savings over last 3 complete months
|
||||
var avgSavings int64
|
||||
count := 0
|
||||
for _, ms := range monthlySavings {
|
||||
if ms.income > 0 && ms.saved > 0 {
|
||||
avgSavings += ms.saved
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
avgSavings /= int64(count)
|
||||
}
|
||||
|
||||
// savings rate history — sorted months
|
||||
var monthKeys []string
|
||||
for mk := range monthlySavings {
|
||||
monthKeys = append(monthKeys, mk)
|
||||
}
|
||||
sortStrings(monthKeys)
|
||||
var history []SavingsPoint
|
||||
for _, mk := range monthKeys {
|
||||
ms := monthlySavings[mk]
|
||||
if ms.income <= 0 {
|
||||
continue
|
||||
}
|
||||
rate := int(float64(ms.saved) / float64(ms.income) * 100)
|
||||
if rate < -100 {
|
||||
rate = -100
|
||||
}
|
||||
history = append(history, SavingsPoint{
|
||||
Month: mk,
|
||||
IncomeCents: ms.income,
|
||||
SavedCents: ms.saved,
|
||||
RatePct: rate,
|
||||
})
|
||||
}
|
||||
|
||||
render(w, simulatorTmpl, &SimulatorData{
|
||||
UserID: a.UserID,
|
||||
Email: a.Email,
|
||||
Title: "What If",
|
||||
Route: "simulator",
|
||||
IncomeCents: thisMonthIncome,
|
||||
FixedCents: totalFixed,
|
||||
GoalsCents: goalsCents,
|
||||
DisposableCents: disposable,
|
||||
AvgSavingsCents: avgSavings,
|
||||
Goals: simGoals,
|
||||
SavingsHistory: history,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
@ -1653,6 +1784,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /goals", h.Goals)
|
||||
mux.HandleFunc("POST /goals", h.Goals)
|
||||
mux.HandleFunc("GET /networth", h.NetWorth)
|
||||
mux.HandleFunc("GET /simulator", h.Simulator)
|
||||
mux.HandleFunc("GET /sharing", h.Sharing)
|
||||
mux.HandleFunc("POST /sharing", h.Sharing)
|
||||
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)
|
||||
|
||||
@ -219,6 +219,38 @@ type SharingUser struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
type SimulatorGoal struct {
|
||||
Name string
|
||||
MonthlyCents int64
|
||||
MonthsLeft int64
|
||||
Committed bool
|
||||
}
|
||||
|
||||
type SimulatorData struct {
|
||||
UserID string
|
||||
Email string
|
||||
Title string
|
||||
Route string
|
||||
|
||||
// current state passed to JS
|
||||
IncomeCents int64
|
||||
FixedCents int64 // recurring fixed costs (no goals)
|
||||
GoalsCents int64 // committed goal contributions
|
||||
DisposableCents int64 // income − fixed − goals
|
||||
AvgSavingsCents int64 // 3-month avg monthly savings
|
||||
Goals []SimulatorGoal
|
||||
|
||||
// savings rate history: one point per past month
|
||||
SavingsHistory []SavingsPoint
|
||||
}
|
||||
|
||||
type SavingsPoint struct {
|
||||
Month string
|
||||
IncomeCents int64
|
||||
SavedCents int64
|
||||
RatePct int
|
||||
}
|
||||
|
||||
type NetWorthPoint struct {
|
||||
Month string // "2025-01"
|
||||
AssetCents int64
|
||||
|
||||
@ -401,6 +401,7 @@
|
||||
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
|
||||
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
|
||||
<a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">Net Worth</a>
|
||||
<a href="/simulator" class="{{if eq .Route "simulator"}}active{{end}}">What If</a>
|
||||
<a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a>
|
||||
<div class="nav-spacer"></div>
|
||||
<span class="nav-email">{{.Email}}</span>
|
||||
|
||||
295
apps/finance/services/api/main/templates/simulator.html
Normal file
295
apps/finance/services/api/main/templates/simulator.html
Normal file
@ -0,0 +1,295 @@
|
||||
{{define "content"}}
|
||||
{{$d := .}}
|
||||
|
||||
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
||||
<h1>What If…</h1>
|
||||
<span class="text-muted">adjust sliders to see the ripple effect</span>
|
||||
</div>
|
||||
|
||||
<!-- Live output cards -->
|
||||
<div class="grid" style="margin-bottom:16px;">
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>Disposable income</h2>
|
||||
<div id="out-disposable" class="value" style="color:var(--text);">€0.00</div>
|
||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">after fixed costs & goals</p>
|
||||
</div>
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>Monthly savings</h2>
|
||||
<div id="out-savings" class="value positive">€0.00</div>
|
||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">income − all committed spend</p>
|
||||
</div>
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>Savings rate</h2>
|
||||
<div id="out-rate" class="value positive">0%</div>
|
||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">savings ÷ income</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls + goal impact side by side -->
|
||||
<div class="grid-2" style="margin-bottom:16px;">
|
||||
|
||||
<!-- Sliders -->
|
||||
<div class="card animate-on-scroll">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||
<h2>Adjustments</h2>
|
||||
<button onclick="resetAll()" class="btn btn-outline btn-sm">Reset</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; flex-direction:column; gap:24px;">
|
||||
|
||||
<!-- Income change -->
|
||||
<div>
|
||||
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||||
<label style="font-size:13px; color:var(--text2); font-weight:500;">Income change</label>
|
||||
<span id="lbl-income" style="font-size:13px; font-weight:600; color:var(--accent);">+0%</span>
|
||||
</div>
|
||||
<input type="range" id="sl-income" min="-50" max="100" value="0" step="1"
|
||||
oninput="recalc()" style="width:100%; accent-color:var(--accent);">
|
||||
<div style="display:flex; justify-content:space-between; font-size:11px; color:var(--text3); margin-top:4px;">
|
||||
<span>−50%</span><span>+100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- One-off expense -->
|
||||
<div>
|
||||
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||||
<label style="font-size:13px; color:var(--text2); font-weight:500;">One-off expense</label>
|
||||
<span id="lbl-oneoff" style="font-size:13px; font-weight:600; color:var(--red);">€0</span>
|
||||
</div>
|
||||
<input type="range" id="sl-oneoff" min="0" max="10000" value="0" step="50"
|
||||
oninput="recalc()" style="width:100%; accent-color:var(--red);">
|
||||
<div style="display:flex; justify-content:space-between; font-size:11px; color:var(--text3); margin-top:4px;">
|
||||
<span>€0</span><span>€10 000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed costs change -->
|
||||
<div>
|
||||
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||||
<label style="font-size:13px; color:var(--text2); font-weight:500;">Fixed costs change</label>
|
||||
<span id="lbl-fixed" style="font-size:13px; font-weight:600; color:var(--text2);">+€0/mo</span>
|
||||
</div>
|
||||
<input type="range" id="sl-fixed" min="-500" max="1000" value="0" step="10"
|
||||
oninput="recalc()" style="width:100%; accent-color:var(--accent);">
|
||||
<div style="display:flex; justify-content:space-between; font-size:11px; color:var(--text3); margin-top:4px;">
|
||||
<span>−€500</span><span>+€1 000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New goal -->
|
||||
<div style="border-top:1px solid var(--border); padding-top:20px;">
|
||||
<label style="font-size:13px; color:var(--text2); font-weight:500; display:block; margin-bottom:12px;">
|
||||
Hypothetical new goal
|
||||
</label>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:10px;">
|
||||
<div>
|
||||
<label style="font-size:11px; color:var(--text3); display:block; margin-bottom:4px;">Amount (€)</label>
|
||||
<input type="number" id="ng-amount" min="0" step="100" placeholder="5 000"
|
||||
oninput="recalc()"
|
||||
style="width:100%; padding:8px 10px; background:var(--bg3); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:13px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px; color:var(--text3); display:block; margin-bottom:4px;">Months to save</label>
|
||||
<input type="number" id="ng-months" min="1" max="120" step="1" placeholder="12"
|
||||
oninput="recalc()"
|
||||
style="width:100%; padding:8px 10px; background:var(--bg3); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:13px;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:12px; color:var(--text3);">
|
||||
Would need <span id="ng-monthly" style="font-weight:600; color:var(--accent);">€0/mo</span>
|
||||
— leaving <span id="ng-after" style="font-weight:600;">€0/mo</span> disposable
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goal timeline impact -->
|
||||
<div class="card animate-on-scroll">
|
||||
<h2 style="margin-bottom:16px;">Goal timeline impact</h2>
|
||||
{{if $d.Goals}}
|
||||
<div id="goal-impact-list" style="display:flex; flex-direction:column; gap:10px;">
|
||||
{{range $d.Goals}}
|
||||
<div class="goal-row" data-monthly="{{.MonthlyCents}}" data-months="{{.MonthsLeft}}" data-committed="{{.Committed}}">
|
||||
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:4px;">
|
||||
<span style="font-size:13px; font-weight:500; color:var(--text);">
|
||||
{{.Name}}
|
||||
{{if .Committed}}<span style="font-size:10px; color:var(--green); margin-left:5px;">committed</span>{{end}}
|
||||
</span>
|
||||
<span class="goal-months" style="font-size:13px; font-weight:600; color:var(--text2);">{{.MonthsLeft}}mo</span>
|
||||
</div>
|
||||
<div style="background:var(--bg3); border-radius:99px; height:4px; overflow:hidden;">
|
||||
<div class="goal-bar" style="height:100%; border-radius:99px; width:0%; background:var(--accent); transition:width 0.4s ease;"></div>
|
||||
</div>
|
||||
<div class="goal-feasibility" style="font-size:11px; color:var(--text3); margin-top:3px;"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state" style="padding:24px;">
|
||||
<p>No goals yet. <a href="/goals" style="color:var(--accent);">Add some →</a></p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Savings rate history chart -->
|
||||
{{if $d.SavingsHistory}}
|
||||
<div class="card animate-on-scroll">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||
<h2>Savings rate history</h2>
|
||||
<span style="font-size:11px; color:var(--text3);">past months</span>
|
||||
</div>
|
||||
<canvas id="savings-chart" height="200"></canvas>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
// ── State seeded from server ──────────────────────────────────────────────
|
||||
const BASE = {
|
||||
income: {{$d.IncomeCents}},
|
||||
fixed: {{$d.FixedCents}},
|
||||
goals: {{$d.GoalsCents}},
|
||||
disposable: {{$d.DisposableCents}},
|
||||
avgSavings: {{$d.AvgSavingsCents}},
|
||||
};
|
||||
|
||||
function fmt(cents) {
|
||||
const abs = Math.abs(cents);
|
||||
const sign = cents < 0 ? '−' : '';
|
||||
return sign + '€' + (abs / 100).toLocaleString('pt-PT', {minimumFractionDigits:2, maximumFractionDigits:2});
|
||||
}
|
||||
|
||||
function recalc() {
|
||||
const incomePct = parseFloat(document.getElementById('sl-income').value) / 100;
|
||||
const oneoff = parseFloat(document.getElementById('sl-oneoff').value) * 100; // convert € to cents
|
||||
const fixedDelta = parseFloat(document.getElementById('sl-fixed').value) * 100;
|
||||
|
||||
const newIncome = Math.round(BASE.income * (1 + incomePct));
|
||||
const newFixed = BASE.fixed + fixedDelta;
|
||||
const newDisp = newIncome - newFixed - BASE.goals - oneoff;
|
||||
const newSavings = newIncome - newFixed - BASE.goals;
|
||||
const newRate = newIncome > 0 ? Math.round(newSavings / newIncome * 100) : 0;
|
||||
|
||||
// labels
|
||||
document.getElementById('lbl-income').textContent = (incomePct >= 0 ? '+' : '') + Math.round(incomePct * 100) + '%';
|
||||
document.getElementById('lbl-income').style.color = incomePct >= 0 ? 'var(--green)' : 'var(--red)';
|
||||
document.getElementById('lbl-oneoff').textContent = '€' + (oneoff/100).toLocaleString('pt-PT');
|
||||
document.getElementById('lbl-fixed').textContent = (fixedDelta >= 0 ? '+' : '−') + '€' + (Math.abs(fixedDelta)/100).toLocaleString('pt-PT') + '/mo';
|
||||
|
||||
// output cards
|
||||
setCard('out-disposable', newDisp);
|
||||
setCard('out-savings', newSavings);
|
||||
document.getElementById('out-rate').textContent = newRate + '%';
|
||||
document.getElementById('out-rate').className = 'value ' + (newRate >= 0 ? 'positive' : 'negative');
|
||||
|
||||
// new goal impact
|
||||
const ngAmt = parseFloat(document.getElementById('ng-amount').value || '0') * 100;
|
||||
const ngMonths = parseFloat(document.getElementById('ng-months').value || '0');
|
||||
const ngMonthly = ngMonths > 0 ? Math.round(ngAmt / ngMonths) : 0;
|
||||
const ngAfter = newDisp - ngMonthly;
|
||||
document.getElementById('ng-monthly').textContent = '€' + (ngMonthly/100).toLocaleString('pt-PT', {minimumFractionDigits:2});
|
||||
const ngAfterEl = document.getElementById('ng-after');
|
||||
ngAfterEl.textContent = fmt(ngAfter) + '/mo';
|
||||
ngAfterEl.style.color = ngAfter >= 0 ? 'var(--green)' : 'var(--red)';
|
||||
|
||||
// goal rows
|
||||
document.querySelectorAll('.goal-row').forEach(row => {
|
||||
const monthlyCents = parseInt(row.dataset.monthly);
|
||||
const originalMonths = parseInt(row.dataset.months);
|
||||
const committed = row.dataset.committed === 'true';
|
||||
|
||||
// at the new savings rate, how many months to hit the same target?
|
||||
const target = monthlyCents * originalMonths;
|
||||
const effectiveSavings = newSavings > 0 ? newSavings : 0;
|
||||
const newMonths = effectiveSavings > 0 ? Math.ceil(target / effectiveSavings) : 9999;
|
||||
const feasible = newMonths <= originalMonths;
|
||||
|
||||
row.querySelector('.goal-months').textContent = newMonths > 999 ? '—' : newMonths + 'mo';
|
||||
row.querySelector('.goal-months').style.color = feasible ? 'var(--green)' : 'var(--red)';
|
||||
|
||||
const pct = Math.min(100, Math.round(originalMonths / Math.max(newMonths, 1) * 100));
|
||||
row.querySelector('.goal-bar').style.width = pct + '%';
|
||||
row.querySelector('.goal-bar').style.background = feasible ? 'var(--green)' : 'var(--red)';
|
||||
|
||||
const diff = newMonths - originalMonths;
|
||||
const feasEl = row.querySelector('.goal-feasibility');
|
||||
if (newMonths > 999) {
|
||||
feasEl.textContent = 'Not achievable at this savings level';
|
||||
feasEl.style.color = 'var(--red)';
|
||||
} else if (feasible) {
|
||||
feasEl.textContent = diff < 0 ? Math.abs(diff) + ' months faster ✓' : 'On track ✓';
|
||||
feasEl.style.color = 'var(--green)';
|
||||
} else {
|
||||
feasEl.textContent = diff + ' months over deadline';
|
||||
feasEl.style.color = 'var(--red)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setCard(id, cents) {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = fmt(cents);
|
||||
el.className = 'value ' + (cents >= 0 ? 'positive' : 'negative');
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
document.getElementById('sl-income').value = 0;
|
||||
document.getElementById('sl-oneoff').value = 0;
|
||||
document.getElementById('sl-fixed').value = 0;
|
||||
document.getElementById('ng-amount').value = '';
|
||||
document.getElementById('ng-months').value = '';
|
||||
recalc();
|
||||
}
|
||||
|
||||
// run on load
|
||||
recalc();
|
||||
|
||||
{{if $d.SavingsHistory}}
|
||||
// ── Savings rate chart ────────────────────────────────────────────────────
|
||||
(function() {
|
||||
const labels = [{{range $d.SavingsHistory}}"{{.Month}}",{{end}}];
|
||||
const rates = [{{range $d.SavingsHistory}}{{.RatePct}},{{end}}];
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
const ctx = document.getElementById('savings-chart').getContext('2d');
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Savings rate %',
|
||||
data: rates,
|
||||
backgroundColor: rates.map(r => r >= 0
|
||||
? (isDark ? 'rgba(74,222,128,0.5)' : 'rgba(22,163,74,0.5)')
|
||||
: (isDark ? 'rgba(248,113,113,0.5)' : 'rgba(220,38,38,0.5)')),
|
||||
borderColor: rates.map(r => r >= 0
|
||||
? (isDark ? '#4ade80' : '#16a34a')
|
||||
: (isDark ? '#f87171' : '#dc2626')),
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: ctx => ctx.parsed.y + '%' } }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.05)' },
|
||||
ticks: { color: isDark ? '#5c6585' : '#9fa8c7', maxTicksLimit: 12 }
|
||||
},
|
||||
y: {
|
||||
grid: { color: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.05)' },
|
||||
ticks: { color: isDark ? '#5c6585' : '#9fa8c7', callback: v => v + '%' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
{{end}}
|
||||
</script>
|
||||
{{end}}
|
||||
Loading…
x
Reference in New Issue
Block a user