From 34a742066d81c652485011bd465c6d4ec47854a9 Mon Sep 17 00:00:00 2001 From: Anantha Kumaran Date: Sat, 14 May 2022 20:24:34 +0530 Subject: [PATCH] add income page --- internal/server/income.go | 47 +++++++++ internal/server/investment.go | 3 +- internal/server/overview.go | 4 + internal/server/server.go | 3 + internal/utils/utils.go | 9 ++ web/src/income.ts | 183 ++++++++++++++++++++++++++++++++++ web/src/index.ts | 4 +- web/src/investment.ts | 9 +- web/src/utils.ts | 14 +++ web/static/index.html | 17 ++++ web/static/styles/custom.css | 2 +- 11 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 internal/server/income.go create mode 100644 web/src/income.ts diff --git a/internal/server/income.go b/internal/server/income.go new file mode 100644 index 0000000..a033c25 --- /dev/null +++ b/internal/server/income.go @@ -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 +} diff --git a/internal/server/investment.go b/internal/server/investment.go index 81c7bad..cdf2fa2 100644 --- a/internal/server/investment.go +++ b/internal/server/investment.go @@ -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} } diff --git a/internal/server/overview.go b/internal/server/overview.go index 428e059..7564c20 100644 --- a/internal/server/overview.go +++ b/internal/server/overview.go @@ -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)) { diff --git a/internal/server/server.go b/internal/server/server.go index fd8b799..d87772c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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)) }) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 8bc3462..3baacf3 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -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()) +} diff --git a/web/src/income.ts b/web/src/income.ts new file mode 100644 index 0000000..6da8809 --- /dev/null +++ b/web/src/income.ts @@ -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().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); +} diff --git a/web/src/index.ts b/web/src/index.ts index 94444cb..089eee9 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -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[] = []; diff --git a/web/src/investment.ts b/web/src/investment.ts index 932a3bf..8cf348d 100644 --- a/web/src/investment.ts +++ b/web/src/investment.ts @@ -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( diff --git a/web/src/utils.ts b/web/src/utils.ts index 19fe4b2..1153046 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -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(":"); } diff --git a/web/static/index.html b/web/static/index.html index ccbda68..cbb124c 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -24,6 +24,7 @@ Overview Investment Gain + Income Allocation Ledger @@ -128,6 +129,22 @@ +
+
+
+
+ +
+
+
+
+
+

Monthly Income Timeline

+
+
+
+
+
diff --git a/web/static/styles/custom.css b/web/static/styles/custom.css index 183a72c..436e31b 100644 --- a/web/static/styles/custom.css +++ b/web/static/styles/custom.css @@ -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; }