show investment at financial year granularity

This commit is contained in:
Anantha Kumaran 2022-08-09 12:20:29 +05:30
parent 2a570a1898
commit 74ebc7f503
4 changed files with 239 additions and 47 deletions

View File

@ -3,12 +3,20 @@ package server
import (
"github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/ananthakumaran/paisa/internal/service"
"github.com/ananthakumaran/paisa/internal/utils"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"time"
)
type YearlyCard struct {
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Postings []posting.Posting `json:"postings"`
}
func GetInvestment(db *gorm.DB) gin.H {
var postings []posting.Posting
result := db.Where("account like ?", "Asset:%").Find(&postings)
@ -17,5 +25,28 @@ func GetInvestment(db *gorm.DB) gin.H {
}
postings = lo.Filter(postings, func(p posting.Posting, _ int) bool { return !service.IsInterest(db, p) })
return gin.H{"postings": postings}
return gin.H{"postings": postings, "yearly_cards": computeYearlyCard(postings)}
}
func computeYearlyCard(postings []posting.Posting) []YearlyCard {
var yearlyCards []YearlyCard = make([]YearlyCard, 0)
if len(postings) == 0 {
return yearlyCards
}
var p posting.Posting
end := time.Now()
for start := utils.BeginningOfFinancialYear(postings[0].Date); start.Before(end); start = start.AddDate(1, 0, 0) {
yearEnd := utils.EndOfFinancialYear(start)
var currentMonthPostings []posting.Posting = make([]posting.Posting, 0)
for len(postings) > 0 && (postings[0].Date.Before(yearEnd) || postings[0].Date.Equal(start)) {
p, postings = postings[0], postings[1:]
currentMonthPostings = append(currentMonthPostings, p)
}
yearlyCards = append(yearlyCards, YearlyCard{StartDate: start, EndDate: yearEnd, Postings: currentMonthPostings})
}
return yearlyCards
}

View File

@ -5,56 +5,34 @@ import _ from "lodash";
import {
ajax,
forEachMonth,
forEachYear,
formatCurrency,
formatCurrencyCrude,
Posting,
secondName,
skipTicks,
tooltip
tooltip,
YearlyCard
} from "./utils";
export default async function () {
const { postings: postings } = await ajax("/api/investment");
const { postings: postings, yearly_cards: yearlyCards } = await ajax(
"/api/investment"
);
_.each(postings, (p) => (p.timestamp = dayjs(p.date)));
_.each(yearlyCards, (c) => {
c.start_date_timestamp = dayjs(c.start_date);
c.end_date_timestamp = dayjs(c.end_date);
});
renderMonthlyInvestmentTimeline(postings);
renderYearlyInvestmentTimeline(postings);
renderYearlyInvestmentTimeline(yearlyCards);
}
function renderMonthlyInvestmentTimeline(postings: Posting[]) {
renderInvestmentTimeline(
postings,
"#d3-investment-timeline",
"MMM-YYYY",
true,
forEachMonth
);
}
function renderYearlyInvestmentTimeline(postings: Posting[]) {
renderInvestmentTimeline(
postings,
"#d3-yearly-investment-timeline",
"YYYY",
false,
forEachYear
);
}
function renderInvestmentTimeline(
postings: Posting[],
id: string,
timeFormat: string,
showTooltip: boolean,
iterator: (
start: dayjs.Dayjs,
end: dayjs.Dayjs,
cb: (current: dayjs.Dayjs) => any
) => any
) {
const id = "#d3-investment-timeline";
const timeFormat = "MMM-YYYY";
const MAX_BAR_WIDTH = 40;
const svg = d3.select(id),
margin = { top: 40, right: 30, bottom: 80, left: 40 },
margin = { top: 40, right: 30, bottom: 60, left: 40 },
width =
document.getElementById(id.substring(1)).parentElement.clientWidth -
margin.left -
@ -81,14 +59,13 @@ function renderInvestmentTimeline(
const ts = _.groupBy(postings, (p) => p.timestamp.format(timeFormat));
const points: {
date: dayjs.Dayjs;
month: string;
[key: string]: number | string | dayjs.Dayjs;
}[] = [];
iterator(start, end, (month) => {
postings = ts[month.format(timeFormat)] || [];
const values = _.chain(ts[month.format(timeFormat)] || [])
forEachMonth(start, end, (month) => {
const postings = ts[month.format(timeFormat)] || [];
const values = _.chain(postings)
.groupBy((t) => secondName(t.account))
.flatMap((postings, key) => [
[
@ -117,7 +94,6 @@ function renderInvestmentTimeline(
_.merge(
{
month: month.format(timeFormat),
date: month,
postings: postings
},
defaultValues,
@ -189,9 +165,6 @@ function renderInvestmentTimeline(
.enter()
.append("rect")
.attr("data-tippy-content", (d) => {
if (!showTooltip) {
return null;
}
const postings: Posting[] = (d.data as any).postings;
return tooltip(
_.sortBy(
@ -232,3 +205,182 @@ function renderInvestmentTimeline(
svg.select(".legendOrdinal").call(legendOrdinal as any);
}
function renderYearlyInvestmentTimeline(yearlyCards: YearlyCard[]) {
const id = "#d3-yearly-investment-timeline";
const BAR_HEIGHT = 20;
const svg = d3.select(id),
margin = { top: 40, right: 20, bottom: 20, left: 80 },
width =
document.getElementById(id.substring(1)).parentElement.clientWidth -
margin.left -
margin.right,
g = svg
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
const groups = _.chain(yearlyCards)
.flatMap((c) => c.postings)
.map((p) => secondName(p.account))
.uniq()
.sort()
.value();
const groupKeys = _.flatMap(groups, (g) => [g + "-credit", g + "-debit"]);
const defaultValues = _.zipObject(
groupKeys,
_.map(groupKeys, () => 0)
);
const start = _.min(_.map(yearlyCards, (c) => c.start_date_timestamp)),
end = _.max(_.map(yearlyCards, (c) => c.end_date_timestamp));
const height = BAR_HEIGHT * (end.year() - start.year());
svg.attr("height", height + margin.top + margin.bottom);
const points: {
year: string;
[key: string]: number | string | dayjs.Dayjs;
}[] = [];
_.each(yearlyCards, (card) => {
const postings = card.postings;
const values = _.chain(postings)
.groupBy((t) => secondName(t.account))
.flatMap((postings, key) => [
[
key + "-credit",
_.sum(
_.filter(
_.map(postings, (p) => p.amount),
(a) => a >= 0
)
)
],
[
key + "-debit",
_.sum(
_.filter(
_.map(postings, (p) => p.amount),
(a) => a < 0
)
)
]
])
.fromPairs()
.value();
points.push(
_.merge(
{
year: `${card.start_date_timestamp.format(
"YYYY"
)}-${card.end_date_timestamp.format("YYYY")}`,
postings: postings
},
defaultValues,
values
)
);
});
const x = d3.scaleLinear().range([0, width]);
const y = d3.scaleBand().range([height, 0]).paddingInner(0.1).paddingOuter(0);
const sum = (filter) => (p) =>
_.sum(
_.filter(
_.map(groupKeys, (k) => p[k]),
filter
)
);
y.domain(points.map((p) => p.year));
x.domain([
d3.min(
points,
sum((a) => a < 0)
),
d3.max(
points,
sum((a) => a > 0)
)
]);
const z = d3.scaleOrdinal<string>().range(d3.schemeCategory10);
g.append("g")
.attr("class", "axis y")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x).tickSize(-height).tickFormat(formatCurrencyCrude));
g.append("g").attr("class", "axis y dark").call(d3.axisLeft(y));
g.append("g")
.selectAll("g")
.data(
d3.stack().offset(d3.stackOffsetDiverging).keys(groupKeys)(
points as { [key: string]: number }[]
)
)
.enter()
.append("g")
.attr("fill", function (d) {
return z(d.key.split("-")[0]);
})
.selectAll("rect")
.data(function (d) {
return d;
})
.enter()
.append("rect")
.attr("data-tippy-content", (d) => {
return tooltip(
_.sortBy(
groupKeys.flatMap((k) => {
const total = d.data[k];
if (total == 0) {
return [];
}
return [
[
k.replace("-credit", "").replace("-debit", ""),
[
formatCurrency(d.data[k]),
"has-text-weight-bold has-text-right"
]
]
];
}),
(r) => r[0]
)
);
})
.attr("x", function (d) {
return x(d[0]);
})
.attr("y", function (d) {
return (
y((d.data as any).year) +
(y.bandwidth() - Math.min(y.bandwidth(), BAR_HEIGHT)) / 2
);
})
.attr("width", function (d) {
return x(d[1]) - x(d[0]);
})
.attr("height", y.bandwidth());
svg
.append("g")
.attr("class", "legendOrdinal")
.attr("transform", "translate(40,0)");
const legendOrdinal = legend
.legendColor()
.shape("rect")
.orient("horizontal")
.shapePadding(100)
.labels(groups)
.scale(z);
svg.select(".legendOrdinal").call(legendOrdinal as any);
}

View File

@ -69,9 +69,18 @@ export interface Tax {
postings: Posting[];
}
export interface YearlyCard {
start_date: string;
end_date: string;
postings: Posting[];
start_date_timestamp: dayjs.Dayjs;
end_date_timestamp: dayjs.Dayjs;
}
export function ajax(
route: "/api/investment"
): Promise<{ postings: Posting[] }>;
): Promise<{ postings: Posting[]; yearly_cards: YearlyCard[] }>;
export function ajax(
route: "/api/ledger"
): Promise<{ postings: Posting[]; breakdowns: Breakdown[] }>;

View File

@ -108,13 +108,13 @@
<div class="container is-fluid">
<div class="columns">
<div class="column is-6">
<svg id="d3-yearly-investment-timeline" width="100%" height="300"></svg>
<svg id="d3-yearly-investment-timeline" width="100%"></svg>
</div>
</div>
<div class="columns">
<div class="column is-6 has-text-centered">
<div>
<p class="heading">Yearly Investment Timeline</p>
<p class="heading">Financial Year Investment Timeline</p>
</div>
</div>
</div>