add income page
This commit is contained in:
parent
f7d38b3985
commit
34a742066d
|
@ -0,0 +1,47 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ananthakumaran/paisa/internal/model/posting"
|
||||
"github.com/ananthakumaran/paisa/internal/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Income struct {
|
||||
Date time.Time `json:"date"`
|
||||
Postings []posting.Posting `json:"postings"`
|
||||
}
|
||||
|
||||
func GetIncome(db *gorm.DB) gin.H {
|
||||
var postings []posting.Posting
|
||||
result := db.Where("account like ?", "Income:%").Order("date ASC").Find(&postings)
|
||||
if result.Error != nil {
|
||||
log.Fatal(result.Error)
|
||||
}
|
||||
return gin.H{"income_timeline": computeIncomeTimeline(postings)}
|
||||
}
|
||||
|
||||
func computeIncomeTimeline(postings []posting.Posting) []Income {
|
||||
var incomes []Income = make([]Income, 0)
|
||||
|
||||
if len(postings) == 0 {
|
||||
return incomes
|
||||
}
|
||||
|
||||
var p posting.Posting
|
||||
end := time.Now()
|
||||
for start := utils.BeginningOfMonth(postings[0].Date); start.Before(end); start = start.AddDate(0, 1, 0) {
|
||||
var currentMonthPostings []posting.Posting = make([]posting.Posting, 0)
|
||||
for len(postings) > 0 && (postings[0].Date.Before(utils.EndOfMonth(start)) || postings[0].Date.Equal(start)) {
|
||||
p, postings = postings[0], postings[1:]
|
||||
currentMonthPostings = append(currentMonthPostings, p)
|
||||
}
|
||||
|
||||
incomes = append(incomes, Income{Date: start, Postings: currentMonthPostings})
|
||||
|
||||
}
|
||||
return incomes
|
||||
}
|
|
@ -12,9 +12,10 @@ import (
|
|||
func GetInvestment(db *gorm.DB) gin.H {
|
||||
var postings []posting.Posting
|
||||
result := db.Where("account like ?", "Asset:%").Find(&postings)
|
||||
postings = lo.Filter(postings, func(p posting.Posting, _ int) bool { return !service.IsInterest(db, p) })
|
||||
if result.Error != nil {
|
||||
log.Fatal(result.Error)
|
||||
}
|
||||
|
||||
postings = lo.Filter(postings, func(p posting.Posting, _ int) bool { return !service.IsInterest(db, p) })
|
||||
return gin.H{"postings": postings}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,10 @@ func computeOverviewTimeline(db *gorm.DB, postings []posting.Posting) []Overview
|
|||
var p posting.Posting
|
||||
var pastPostings []posting.Posting
|
||||
|
||||
if len(postings) == 0 {
|
||||
return networths
|
||||
}
|
||||
|
||||
end := time.Now()
|
||||
for start := postings[0].Date; start.Before(end); start = start.AddDate(0, 0, 1) {
|
||||
for len(postings) > 0 && (postings[0].Date.Before(start) || postings[0].Date.Equal(start)) {
|
||||
|
|
|
@ -29,6 +29,9 @@ func Listen(db *gorm.DB) {
|
|||
router.GET("/api/gain", func(c *gin.Context) {
|
||||
c.JSON(200, GetGain(db))
|
||||
})
|
||||
router.GET("/api/income", func(c *gin.Context) {
|
||||
c.JSON(200, GetIncome(db))
|
||||
})
|
||||
router.GET("/api/allocation", func(c *gin.Context) {
|
||||
c.JSON(200, GetAllocation(db))
|
||||
})
|
||||
|
|
|
@ -2,6 +2,7 @@ package utils
|
|||
|
||||
import (
|
||||
"github.com/google/btree"
|
||||
"time"
|
||||
)
|
||||
|
||||
func BTreeDescendFirstLessOrEqual[I btree.Item](tree *btree.BTree, item I) I {
|
||||
|
@ -13,3 +14,11 @@ func BTreeDescendFirstLessOrEqual[I btree.Item](tree *btree.BTree, item I) I {
|
|||
|
||||
return hit
|
||||
}
|
||||
|
||||
func BeginningOfMonth(date time.Time) time.Time {
|
||||
return date.AddDate(0, 0, -date.Day()+1)
|
||||
}
|
||||
|
||||
func EndOfMonth(date time.Time) time.Time {
|
||||
return date.AddDate(0, 1, -date.Day())
|
||||
}
|
||||
|
|
|
@ -0,0 +1,183 @@
|
|||
import * as d3 from "d3";
|
||||
import legend from "d3-svg-legend";
|
||||
import dayjs from "dayjs";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
ajax,
|
||||
formatCurrency,
|
||||
formatCurrencyCrude,
|
||||
Income,
|
||||
Posting,
|
||||
restName,
|
||||
skipTicks,
|
||||
tooltip
|
||||
} from "./utils";
|
||||
|
||||
export default async function () {
|
||||
const { income_timeline: incomes } = await ajax("/api/income");
|
||||
_.each(incomes, (i) => (i.timestamp = dayjs(i.date)));
|
||||
renderMonthlyInvestmentTimeline(incomes);
|
||||
}
|
||||
|
||||
function renderMonthlyInvestmentTimeline(incomes: Income[]) {
|
||||
renderIncomeTimeline(incomes, "#d3-income-timeline", "MMM-YYYY");
|
||||
}
|
||||
|
||||
function renderIncomeTimeline(
|
||||
incomes: Income[],
|
||||
id: string,
|
||||
timeFormat: string
|
||||
) {
|
||||
const MAX_BAR_WIDTH = 40;
|
||||
const svg = d3.select(id),
|
||||
margin = { top: 40, right: 30, bottom: 80, left: 40 },
|
||||
width =
|
||||
document.getElementById(id.substring(1)).parentElement.clientWidth -
|
||||
margin.left -
|
||||
margin.right,
|
||||
height = +svg.attr("height") - margin.top - margin.bottom,
|
||||
g = svg
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
|
||||
const postings = _.flatMap(incomes, (i) => i.postings);
|
||||
const groupKeys = _.chain(postings)
|
||||
.map((p) => restName(p.account))
|
||||
.uniq()
|
||||
.sort()
|
||||
.value();
|
||||
|
||||
const defaultValues = _.zipObject(
|
||||
groupKeys,
|
||||
_.map(groupKeys, () => 0)
|
||||
);
|
||||
|
||||
let points: {
|
||||
date: dayjs.Dayjs;
|
||||
month: string;
|
||||
[key: string]: number | string | dayjs.Dayjs;
|
||||
}[] = [];
|
||||
|
||||
points = _.map(incomes, (i) => {
|
||||
const values = _.chain(i.postings)
|
||||
.groupBy((p) => restName(p.account))
|
||||
.flatMap((postings, key) => [
|
||||
[key, _.sum(_.map(postings, (p) => -p.amount))]
|
||||
])
|
||||
.fromPairs()
|
||||
.value();
|
||||
|
||||
return _.merge(
|
||||
{
|
||||
month: i.timestamp.format(timeFormat),
|
||||
date: i.timestamp,
|
||||
postings: i.postings
|
||||
},
|
||||
defaultValues,
|
||||
values
|
||||
);
|
||||
});
|
||||
|
||||
const x = d3.scaleBand().range([0, width]).paddingInner(0.1).paddingOuter(0);
|
||||
const y = d3.scaleLinear().range([height, 0]);
|
||||
|
||||
const sum = (filter) => (p) =>
|
||||
_.sum(
|
||||
_.filter(
|
||||
_.map(groupKeys, (k) => p[k]),
|
||||
filter
|
||||
)
|
||||
);
|
||||
x.domain(points.map((p) => p.month));
|
||||
y.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 x")
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(5)
|
||||
.tickFormat(skipTicks(30, width, points.length, (d) => d.toString()))
|
||||
)
|
||||
.selectAll("text")
|
||||
.attr("y", 10)
|
||||
.attr("x", -8)
|
||||
.attr("dy", ".35em")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end");
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis y")
|
||||
.call(d3.axisLeft(y).tickSize(-width).tickFormat(formatCurrencyCrude));
|
||||
|
||||
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) => {
|
||||
const postings: Posting[] = (d.data as any).postings;
|
||||
return tooltip(
|
||||
_.sortBy(
|
||||
postings.map((p) => [
|
||||
restName(p.account),
|
||||
[formatCurrency(-p.amount), "has-text-weight-bold has-text-right"]
|
||||
]),
|
||||
(r) => r[0]
|
||||
)
|
||||
);
|
||||
})
|
||||
.attr("x", function (d) {
|
||||
return (
|
||||
x((d.data as any).month) +
|
||||
(x.bandwidth() - Math.min(x.bandwidth(), MAX_BAR_WIDTH)) / 2
|
||||
);
|
||||
})
|
||||
.attr("y", function (d) {
|
||||
return y(d[1]);
|
||||
})
|
||||
.attr("height", function (d) {
|
||||
return y(d[0]) - y(d[1]);
|
||||
})
|
||||
.attr("width", Math.min(x.bandwidth(), MAX_BAR_WIDTH));
|
||||
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "legendOrdinal")
|
||||
.attr("transform", "translate(40,0)");
|
||||
|
||||
const legendOrdinal = legend
|
||||
.legendColor()
|
||||
.shape("rect")
|
||||
.orient("horizontal")
|
||||
.shapePadding(100)
|
||||
.labels(groupKeys)
|
||||
.scale(z);
|
||||
|
||||
svg.select(".legendOrdinal").call(legendOrdinal as any);
|
||||
}
|
|
@ -12,13 +12,15 @@ import investment from "./investment";
|
|||
import ledger from "./ledger";
|
||||
import overview from "./overview";
|
||||
import gain from "./gain";
|
||||
import income from "./income";
|
||||
|
||||
const tabs = {
|
||||
overview: _.once(overview),
|
||||
investment: _.once(investment),
|
||||
allocation: _.once(allocation),
|
||||
ledger: _.once(ledger),
|
||||
gain: _.once(gain)
|
||||
gain: _.once(gain),
|
||||
income: _.once(income)
|
||||
};
|
||||
|
||||
let tippyInstances: Instance[] = [];
|
||||
|
|
|
@ -126,13 +126,8 @@ function renderInvestmentTimeline(
|
|||
);
|
||||
});
|
||||
|
||||
const x = d3
|
||||
.scaleBand()
|
||||
.rangeRound([0, width])
|
||||
.paddingInner(0.1)
|
||||
.paddingOuter(0);
|
||||
|
||||
const y = d3.scaleLinear().rangeRound([height, 0]);
|
||||
const x = d3.scaleBand().range([0, width]).paddingInner(0.1).paddingOuter(0);
|
||||
const y = d3.scaleLinear().range([height, 0]);
|
||||
|
||||
const sum = (filter) => (p) =>
|
||||
_.sum(
|
||||
|
|
|
@ -48,6 +48,13 @@ export interface Aggregate {
|
|||
timestamp: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
export interface Income {
|
||||
date: string;
|
||||
postings: Posting[];
|
||||
|
||||
timestamp: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
export function ajax(
|
||||
route: "/api/investment"
|
||||
): Promise<{ postings: Posting[] }>;
|
||||
|
@ -65,6 +72,9 @@ export function ajax(route: "/api/allocation"): Promise<{
|
|||
aggregates: { [key: string]: Aggregate };
|
||||
aggregates_timeline: { [key: string]: Aggregate }[];
|
||||
}>;
|
||||
export function ajax(route: "/api/income"): Promise<{
|
||||
income_timeline: Income[];
|
||||
}>;
|
||||
export async function ajax(route: string) {
|
||||
const response = await fetch(route);
|
||||
return await response.json();
|
||||
|
@ -137,6 +147,10 @@ export function secondName(account: string) {
|
|||
return account.split(":")[1];
|
||||
}
|
||||
|
||||
export function restName(account: string) {
|
||||
return _.drop(account.split(":")).join(":");
|
||||
}
|
||||
|
||||
export function parentName(account: string) {
|
||||
return _.dropRight(account.split(":"), 1).join(":");
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
<a id="overview" class="navbar-item is-active">Overview</a>
|
||||
<a id="investment" class="navbar-item">Investment</a>
|
||||
<a id="gain" class="navbar-item">Gain</a>
|
||||
<a id="income" class="navbar-item">Income</a>
|
||||
<a id="allocation" class="navbar-item">Allocation</a>
|
||||
<a id="ledger" class="navbar-item">Ledger</a>
|
||||
</div>
|
||||
|
@ -128,6 +129,22 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-income">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<svg id="d3-income-timeline" width="100%" height="500"></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Monthly Income Timeline</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-allocation">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
|
|
|
@ -31,7 +31,7 @@ body {
|
|||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
#d3-investment-timeline .legendOrdinal .label, #d3-breakdown .legendOrdinal .label {
|
||||
#d3-investment-timeline .legendOrdinal .label, #d3-yearly-investment-timeline .legendOrdinal .label, #d3-income-timeline .legendOrdinal .label, #d3-breakdown .legendOrdinal .label {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue