show investment at financial year granularity
This commit is contained in:
parent
2a570a1898
commit
74ebc7f503
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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[] }>;
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue