add income page

This commit is contained in:
Anantha Kumaran 2022-05-14 20:24:34 +05:30
parent f7d38b3985
commit 34a742066d
11 changed files with 285 additions and 10 deletions

47
internal/server/income.go Normal file
View File

@ -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
}

View File

@ -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}
}

View File

@ -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)) {

View File

@ -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))
})

View File

@ -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())
}

183
web/src/income.ts Normal file
View File

@ -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);
}

View File

@ -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[] = [];

View File

@ -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(

View File

@ -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(":");
}

View File

@ -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">

View File

@ -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;
}