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 {
|
func GetInvestment(db *gorm.DB) gin.H {
|
||||||
var postings []posting.Posting
|
var postings []posting.Posting
|
||||||
result := db.Where("account like ?", "Asset:%").Find(&postings)
|
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 {
|
if result.Error != nil {
|
||||||
log.Fatal(result.Error)
|
log.Fatal(result.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,10 @@ func computeOverviewTimeline(db *gorm.DB, postings []posting.Posting) []Overview
|
||||||
var p posting.Posting
|
var p posting.Posting
|
||||||
var pastPostings []posting.Posting
|
var pastPostings []posting.Posting
|
||||||
|
|
||||||
|
if len(postings) == 0 {
|
||||||
|
return networths
|
||||||
|
}
|
||||||
|
|
||||||
end := time.Now()
|
end := time.Now()
|
||||||
for start := postings[0].Date; start.Before(end); start = start.AddDate(0, 0, 1) {
|
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)) {
|
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) {
|
router.GET("/api/gain", func(c *gin.Context) {
|
||||||
c.JSON(200, GetGain(db))
|
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) {
|
router.GET("/api/allocation", func(c *gin.Context) {
|
||||||
c.JSON(200, GetAllocation(db))
|
c.JSON(200, GetAllocation(db))
|
||||||
})
|
})
|
||||||
|
|
|
@ -2,6 +2,7 @@ package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/google/btree"
|
"github.com/google/btree"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BTreeDescendFirstLessOrEqual[I btree.Item](tree *btree.BTree, item I) I {
|
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
|
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 ledger from "./ledger";
|
||||||
import overview from "./overview";
|
import overview from "./overview";
|
||||||
import gain from "./gain";
|
import gain from "./gain";
|
||||||
|
import income from "./income";
|
||||||
|
|
||||||
const tabs = {
|
const tabs = {
|
||||||
overview: _.once(overview),
|
overview: _.once(overview),
|
||||||
investment: _.once(investment),
|
investment: _.once(investment),
|
||||||
allocation: _.once(allocation),
|
allocation: _.once(allocation),
|
||||||
ledger: _.once(ledger),
|
ledger: _.once(ledger),
|
||||||
gain: _.once(gain)
|
gain: _.once(gain),
|
||||||
|
income: _.once(income)
|
||||||
};
|
};
|
||||||
|
|
||||||
let tippyInstances: Instance[] = [];
|
let tippyInstances: Instance[] = [];
|
||||||
|
|
|
@ -126,13 +126,8 @@ function renderInvestmentTimeline(
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const x = d3
|
const x = d3.scaleBand().range([0, width]).paddingInner(0.1).paddingOuter(0);
|
||||||
.scaleBand()
|
const y = d3.scaleLinear().range([height, 0]);
|
||||||
.rangeRound([0, width])
|
|
||||||
.paddingInner(0.1)
|
|
||||||
.paddingOuter(0);
|
|
||||||
|
|
||||||
const y = d3.scaleLinear().rangeRound([height, 0]);
|
|
||||||
|
|
||||||
const sum = (filter) => (p) =>
|
const sum = (filter) => (p) =>
|
||||||
_.sum(
|
_.sum(
|
||||||
|
|
|
@ -48,6 +48,13 @@ export interface Aggregate {
|
||||||
timestamp: dayjs.Dayjs;
|
timestamp: dayjs.Dayjs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Income {
|
||||||
|
date: string;
|
||||||
|
postings: Posting[];
|
||||||
|
|
||||||
|
timestamp: dayjs.Dayjs;
|
||||||
|
}
|
||||||
|
|
||||||
export function ajax(
|
export function ajax(
|
||||||
route: "/api/investment"
|
route: "/api/investment"
|
||||||
): Promise<{ postings: Posting[] }>;
|
): Promise<{ postings: Posting[] }>;
|
||||||
|
@ -65,6 +72,9 @@ export function ajax(route: "/api/allocation"): Promise<{
|
||||||
aggregates: { [key: string]: Aggregate };
|
aggregates: { [key: string]: Aggregate };
|
||||||
aggregates_timeline: { [key: string]: Aggregate }[];
|
aggregates_timeline: { [key: string]: Aggregate }[];
|
||||||
}>;
|
}>;
|
||||||
|
export function ajax(route: "/api/income"): Promise<{
|
||||||
|
income_timeline: Income[];
|
||||||
|
}>;
|
||||||
export async function ajax(route: string) {
|
export async function ajax(route: string) {
|
||||||
const response = await fetch(route);
|
const response = await fetch(route);
|
||||||
return await response.json();
|
return await response.json();
|
||||||
|
@ -137,6 +147,10 @@ export function secondName(account: string) {
|
||||||
return account.split(":")[1];
|
return account.split(":")[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function restName(account: string) {
|
||||||
|
return _.drop(account.split(":")).join(":");
|
||||||
|
}
|
||||||
|
|
||||||
export function parentName(account: string) {
|
export function parentName(account: string) {
|
||||||
return _.dropRight(account.split(":"), 1).join(":");
|
return _.dropRight(account.split(":"), 1).join(":");
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
<a id="overview" class="navbar-item is-active">Overview</a>
|
<a id="overview" class="navbar-item is-active">Overview</a>
|
||||||
<a id="investment" class="navbar-item">Investment</a>
|
<a id="investment" class="navbar-item">Investment</a>
|
||||||
<a id="gain" class="navbar-item">Gain</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="allocation" class="navbar-item">Allocation</a>
|
||||||
<a id="ledger" class="navbar-item">Ledger</a>
|
<a id="ledger" class="navbar-item">Ledger</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -128,6 +129,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="section tab-allocation">
|
||||||
<div class="container is-fluid">
|
<div class="container is-fluid">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
|
|
|
@ -31,7 +31,7 @@ body {
|
||||||
color: #4a4a4a;
|
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;
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue