Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add budget history graph #11

Merged
merged 5 commits into from
Jan 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion budget/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ func (s *Store) getYearWithTime(getTime func() time.Time, year int) (Budget, err
err = s.bucket.Iter(&budget, func(string) bool {
if budget.Year() < year && (closestBudget == nil || budget.Year() > closestBudget.Year()) {
closestBudget = budget
return closestBudget.Year() != year-1
}
return closestBudget.Year() != year-1
return true
})
if err != nil {
return nil, err
Expand Down
20 changes: 15 additions & 5 deletions server/budgets.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ func getStartEndTimes(startQuery, endQuery string, minStart func(end time.Time)
return
}

func endOfMonth(end time.Time) time.Time {
return time.Date(end.Year(), end.Month()+1, 0, 0, 0, 0, 0, time.UTC)
}

func startOfMonth(end time.Time) time.Time {
return time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC)
}
Expand All @@ -57,6 +61,10 @@ func twelveMonthsTotal(end time.Time) time.Time {
return startOfMonth(end).AddDate(0, -11, 0)
}

func addMonths(t time.Time, months int) time.Time {
return time.Date(t.Year(), t.Month()+time.Month(months), 1, 0, 0, 0, 0, time.UTC)
}

func getEverythingElseSum(accounts budget.Accounts, ldg *ledger.Ledger, start, end time.Time) decimal.Decimal {
leftOverAccounts := ldg.LeftOverAccountBalances(start, end, everythingElseAccounts(accounts)...)
var balance decimal.Decimal
Expand Down Expand Up @@ -84,7 +92,7 @@ func getBudgets(db plaindb.DB, ldg *ledger.Ledger) gin.HandlerFunc {

allMonthlyBudgets := make([]budget.Accounts, 0, 12)
for current := start; current.Before(end); current = current.AddDate(0, 1, 0) {
month, err := store.Month(start.Year(), start.Month())
month, err := store.Month(current.Year(), current.Month())
if err != nil {
abortWithClientError(c, http.StatusInternalServerError, err)
return
Expand All @@ -110,7 +118,9 @@ func getBudgets(db plaindb.DB, ldg *ledger.Ledger) gin.HandlerFunc {

func calculateBudgetBalances(allMonthlyBudgets []budget.Accounts, ldg *ledger.Ledger, start, end time.Time) ([][]monthlyBudget, error) {
budgetResults := make([][]monthlyBudget, 0, 12)
for _, accounts := range allMonthlyBudgets {
for monthOffset, accounts := range allMonthlyBudgets {
monthStart := addMonths(start, monthOffset)
monthEnd := endOfMonth(monthStart)
foundEverythingElse := false
monthResults := make([]monthlyBudget, 0, len(accounts)+1)
for account, budgetAmt := range accounts {
Expand All @@ -119,12 +129,12 @@ func calculateBudgetBalances(allMonthlyBudgets []budget.Accounts, ldg *ledger.Le
switch strings.ToLower(account) {
case everythingElseBudget:
foundEverythingElse = true
balance = getEverythingElseSum(accounts, ldg, start, end)
balance = getEverythingElseSum(accounts, ldg, monthStart, monthEnd)
default:
return nil, errors.Errorf("Invalid builtin account: %s", account)
}
} else {
balance = ldg.AccountBalance(account, start, end)
balance = ldg.AccountBalance(account, monthStart, monthEnd)
}
if strings.HasPrefix(account, model.RevenueAccount+":") || account == model.RevenueAccount {
balance = balance.Neg()
Expand All @@ -139,7 +149,7 @@ func calculateBudgetBalances(allMonthlyBudgets []budget.Accounts, ldg *ledger.Le
if !foundEverythingElse {
monthResults = append(monthResults, monthlyBudget{
Account: everythingElseBudget,
Balance: getEverythingElseSum(accounts, ldg, start, end),
Balance: getEverythingElseSum(accounts, ldg, monthStart, monthEnd),
})
}
budgetResults = append(budgetResults, monthResults)
Expand Down
31 changes: 29 additions & 2 deletions web/src/Budgets.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import './Budgets.css';
import Amount from './Amount';
import BudgetsHistory from './BudgetsHistory';
import Button from 'react-bootstrap/Button';
import Crumb from './Breadcrumb';
import React from 'react';
Expand Down Expand Up @@ -62,6 +63,10 @@ function lastOfMonth(date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0))
}

function someMonthsAgo(date, months = 12) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - (months - 1), 1))
}

function fetchEverythingElseDetails(start, end) {
return API.get('/v1/getEverythingElseBudget', { params: { start, end } })
.then(res => {
Expand All @@ -82,6 +87,7 @@ function parseAllBudgets(budgets) {
}

export default function Budgets({ match }) {
const [allBudgets, setAllBudgets] = React.useState(null)
const [budgets, setBudgets] = React.useState(null)
const [timeProgress, setTimeProgress] = React.useState(null)
const [start, setStart] = React.useState(firstOfMonth(new Date()))
Expand All @@ -97,15 +103,28 @@ export default function Budgets({ match }) {
.then(res => res.data.Budgets),
fetchEverythingElseDetails(start, end),
]).then(([budgets, everythingElseDetails]) => {
setBudgets(parseAllBudgets(budgets)[0])
const all = parseAllBudgets(budgets)
setBudgets(all[all.length-1])
const now = new Date()
const progress = (now.getTime() - start.getTime()) / (end.getTime() - start.getTime())
setTimeProgress(Math.min(1, progress))
setEverythingElse(everythingElseDetails)
setControlsEnabled(firstOfMonth(now).getTime() === start.getTime())
setControlsEnabled(firstOfMonth(now).getTime() === start.getTime() && lastOfMonth(now).getTime() === end.getTime())
})
}, [start, end])

React.useEffect(() => {
const now = new Date()
const start = someMonthsAgo(now, 12), end = lastOfMonth(now)
API.get('/v1/getBudgets', { params: { start, end } })
.then(res => {
setAllBudgets(Object.assign(
{ Budgets: parseAllBudgets(res.data.Budgets) },
res.data,
))
})
}, [])

if (budgets === null) {
return <em>Loading...</em>
}
Expand Down Expand Up @@ -172,6 +191,14 @@ export default function Budgets({ match }) {
return (
<div className="budgets">
<Crumb title="Budgets" match={match} />
<BudgetsHistory
budgets={allBudgets}
date={start}
setMonth={start => {
setStart(start)
setEnd(lastOfMonth(start))
}}
/>
<h2>
<UTCDatePicker
dateFormat="MMM yyyy"
Expand Down
50 changes: 50 additions & 0 deletions web/src/BudgetsHistory.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
.budgets-history {
display: flex;
flex-direction: column;
flex-wrap: wrap;
justify-content: space-between;
height: 7em; /* approximately enough to allow only 2 rows */
}

.budgets-history .budget {
align-items: stretch;
padding: 0;
margin: 0;
font-size: 0.85em;
min-height: 3.8em;
max-height: 3.8em;
}

.budgets-history .budget-delta {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: baseline;

padding: 0.25em 0.5em;
font-weight: 600;
border-radius: 0.25em;
color: white;
background-color: #0A0;
}

.budgets-history .budget-over .budget-delta {
background-color: #C00;
}

.budgets-history .budget-trend {
margin-right: 0.3em;
}

.budgets-history .budget-date {
text-align: left;
margin: 0 0.5em;
}

.budgets-history button {
transition: opacity 0.1s;
}

.budgets-history button:not(.active):not(:hover) {
opacity: 0.7;
}
66 changes: 66 additions & 0 deletions web/src/BudgetsHistory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';
import './BudgetsHistory.css';

import Amount from './Amount';
import Button from 'react-bootstrap/Button';


const monthFormat = new Intl.DateTimeFormat('default', { year: 'numeric', month: 'short', timeZone: 'UTC' });

function addMonths(date, months) {
const dateCopy = new Date(date)
dateCopy.setUTCMonth(dateCopy.getUTCMonth() + months)
return dateCopy
}

export default function ({ budgets: rawBudgets, date, setMonth }) {
if (!date) {
throw Error("date is required")
}
if (!setMonth) {
throw Error("setMonth is required")
}
if (!rawBudgets) {
return null
}
const start = new Date(rawBudgets.Start)
const budgets =
rawBudgets.Budgets.map(month =>
month
.filter(b => !b.Account.startsWith("revenues:") && b.Account !== "revenues")
.map(b => b.Balance - b.Budget)
.reduce((a, b) => a + b))
return (
<div className="budgets-history">
{budgets.map((delta, i) => {
const month = addMonths(start, i)
const active = date.getTime() === month.getTime()
return (
<Button
key={i}
className={"budget " + (delta < 0 ? "budget-under" : "budget-over")}
onClick={() => setMonth(month)}
active={active}
variant="light"
>
<div className="budget-delta">
<div className="budget-trend">{delta < 0 ? '▼' : '▲'}</div>
<Amount prefix="$" amount={Math.abs(delta)} />
</div>
<div className="budget-date">{monthFormat.format(month)}</div>
</Button>
)
}
).reduce((arr, item, index) => {
// alternates which month goes where so the column layout appears to be in order by row
// NOTE: currently fixed at exactly 2 rows
index *= 2
if (index >= budgets.length) {
index = (index + 1) % budgets.length
}
arr[index] = item
return arr
}, new Array(budgets.length))}
</div>
)
}