switch overview to svelte
This commit is contained in:
parent
f996cd1486
commit
19948a8579
|
@ -0,0 +1,13 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
|
@ -0,0 +1,20 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['svelte3', '@typescript-eslint'],
|
||||
ignorePatterns: ['*.cjs'],
|
||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||
settings: {
|
||||
'svelte3/typescript': () => require('typescript')
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
}
|
||||
};
|
13
.eslintrc.js
13
.eslintrc.js
|
@ -1,13 +0,0 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off"
|
||||
}
|
||||
};
|
|
@ -1,7 +1,16 @@
|
|||
paisa.db
|
||||
paisa.yaml
|
||||
paisa
|
||||
web/static/dist*
|
||||
node_modules
|
||||
web/static
|
||||
example.ledger
|
||||
personal.ledger
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
|
@ -13,8 +13,8 @@ func Listen(db *gorm.DB) {
|
|||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
router := gin.Default()
|
||||
router.GET("/static/*filepath", func(c *gin.Context) {
|
||||
c.FileFromFS(c.Request.URL.Path, http.FS(web.Static))
|
||||
router.GET("/_app/*filepath", func(c *gin.Context) {
|
||||
c.FileFromFS("/static"+c.Request.URL.Path, http.FS(web.Static))
|
||||
})
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(web.Index))
|
||||
|
|
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
|
@ -1,5 +1,19 @@
|
|||
{
|
||||
"name": "paisa",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"build:watch": "vite build --watch",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||
"format": "prettier --plugin-search-dir . --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-static": "^1.0.0",
|
||||
"@types/clusterize.js": "^0.18.1",
|
||||
"@types/d3": "^7.1.0",
|
||||
"@types/jquery": "^3.5.14",
|
||||
|
@ -15,16 +29,26 @@
|
|||
"jquery": "^3.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"sprintf-js": "^1.1.2",
|
||||
"svelte-language-server": "^0.15.0",
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^1.0.0",
|
||||
"@sveltejs/kit": "^1.0.0",
|
||||
"@types/chroma-js": "^2.1.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.22.0",
|
||||
"@typescript-eslint/parser": "^5.22.0",
|
||||
"eslint": "^8.15.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"nodemon": "^2.0.15",
|
||||
"prettier": "2.6.2",
|
||||
"typescript": "^4.6.4"
|
||||
}
|
||||
"prettier": "^2.8.0",
|
||||
"prettier-plugin-svelte": "^2.8.1",
|
||||
"svelte": "^3.54.0",
|
||||
"svelte-check": "^2.9.2",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.9.3",
|
||||
"vite": "^4.0.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
// and what to do when importing types
|
||||
declare namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>₹</text></svg>">
|
||||
<title>Paisa</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,458 @@
|
|||
import $ from "jquery";
|
||||
import * as d3 from "d3";
|
||||
import legend from "d3-svg-legend";
|
||||
import dayjs from "dayjs";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
Aggregate,
|
||||
ajax,
|
||||
AllocationTarget,
|
||||
formatCurrency,
|
||||
formatFloat,
|
||||
lastName,
|
||||
parentName,
|
||||
secondName,
|
||||
textColor,
|
||||
tooltip,
|
||||
skipTicks,
|
||||
generateColorScheme
|
||||
} from "./utils";
|
||||
import COLORS from "./colors";
|
||||
import chroma from "chroma-js";
|
||||
|
||||
export default async function () {
|
||||
const {
|
||||
aggregates: aggregates,
|
||||
aggregates_timeline: aggregatesTimeline,
|
||||
allocation_targets: allocationTargets
|
||||
} = await ajax("/api/allocation");
|
||||
_.each(aggregates, (a) => (a.timestamp = dayjs(a.date)));
|
||||
_.each(aggregatesTimeline, (aggregates) =>
|
||||
_.each(aggregates, (a) => (a.timestamp = dayjs(a.date)))
|
||||
);
|
||||
|
||||
const color = generateColorScheme(_.keys(aggregates));
|
||||
|
||||
renderAllocationTarget(allocationTargets, color);
|
||||
renderAllocation(aggregates, color);
|
||||
renderAllocationTimeline(aggregatesTimeline);
|
||||
}
|
||||
|
||||
function renderAllocationTarget(
|
||||
allocationTargets: AllocationTarget[],
|
||||
color: d3.ScaleOrdinal<string, string>
|
||||
) {
|
||||
const id = "#d3-allocation-target";
|
||||
|
||||
if (_.isEmpty(allocationTargets)) {
|
||||
$(id).closest(".container").hide();
|
||||
return;
|
||||
}
|
||||
allocationTargets = _.sortBy(allocationTargets, (t) => t.name);
|
||||
const BAR_HEIGHT = 25;
|
||||
const svg = d3.select(id),
|
||||
margin = { top: 20, right: 0, bottom: 10, left: 150 },
|
||||
fullWidth = document.getElementById(id.substring(1)).parentElement
|
||||
.clientWidth,
|
||||
width = fullWidth - margin.left - margin.right,
|
||||
height = allocationTargets.length * BAR_HEIGHT * 2,
|
||||
g = svg
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
svg.attr("height", height + margin.top + margin.bottom);
|
||||
|
||||
const keys = ["target", "current"];
|
||||
const colorKeys = ["target", "current", "diff"];
|
||||
const colors = [COLORS.primary, COLORS.secondary, COLORS.diff];
|
||||
|
||||
const y = d3.scaleBand().range([0, height]).paddingInner(0).paddingOuter(0);
|
||||
y.domain(allocationTargets.map((t) => t.name));
|
||||
|
||||
const y1 = d3
|
||||
.scaleBand()
|
||||
.range([0, y.bandwidth()])
|
||||
.domain(keys)
|
||||
.paddingInner(0)
|
||||
.paddingOuter(0.1);
|
||||
|
||||
const z = d3.scaleOrdinal<string>(colors).domain(colorKeys);
|
||||
|
||||
const z1 = d3
|
||||
.scaleThreshold<number, string>()
|
||||
.domain([5, 10, 15])
|
||||
.range([COLORS.gain, COLORS.warn, COLORS.loss, COLORS.loss]);
|
||||
|
||||
const maxX = _.chain(allocationTargets)
|
||||
.flatMap((t) => [t.current, t.target])
|
||||
.max()
|
||||
.value();
|
||||
const targetWidth = 400;
|
||||
const targetMargin = 20;
|
||||
const textGroupWidth = 150;
|
||||
const textGroupMargin = 20;
|
||||
const textGroupZero = targetWidth + targetMargin;
|
||||
|
||||
const x = d3
|
||||
.scaleLinear()
|
||||
.range([textGroupZero + textGroupWidth + textGroupMargin, width]);
|
||||
x.domain([0, maxX]);
|
||||
const x1 = d3.scaleLinear().range([0, targetWidth]).domain([0, maxX]);
|
||||
|
||||
g.append("line")
|
||||
.attr("stroke", "#ddd")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", height)
|
||||
.attr("x2", width)
|
||||
.attr("y2", height);
|
||||
|
||||
g.append("text")
|
||||
.attr("fill", "#4a4a4a")
|
||||
.text("Target")
|
||||
.attr("text-anchor", "end")
|
||||
|
||||
.attr("x", textGroupZero + (textGroupWidth * 1) / 3)
|
||||
.attr("y", -5);
|
||||
|
||||
g.append("text")
|
||||
.attr("fill", "#4a4a4a")
|
||||
.text("Current")
|
||||
.attr("text-anchor", "end")
|
||||
.attr("x", textGroupZero + (textGroupWidth * 2) / 3)
|
||||
.attr("y", -5);
|
||||
|
||||
g.append("text")
|
||||
.attr("fill", "#4a4a4a")
|
||||
.text("Diff")
|
||||
.attr("text-anchor", "end")
|
||||
.attr("x", textGroupZero + textGroupWidth)
|
||||
.attr("y", -5);
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis y")
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x1)
|
||||
.tickSize(-height)
|
||||
.tickFormat(skipTicks(40, x, (n) => formatFloat(n, 0)))
|
||||
);
|
||||
|
||||
g.append("g").attr("class", "axis y dark").call(d3.axisLeft(y));
|
||||
|
||||
const textGroup = g
|
||||
.append("g")
|
||||
.selectAll("g")
|
||||
.data(allocationTargets)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "inline-text");
|
||||
|
||||
textGroup
|
||||
.append("line")
|
||||
.attr("stroke", "#ddd")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", (t) => y(t.name))
|
||||
.attr("x2", width)
|
||||
.attr("y2", (t) => y(t.name));
|
||||
|
||||
textGroup
|
||||
.append("text")
|
||||
.text((t) => formatFloat(t.target))
|
||||
.attr("text-anchor", "end")
|
||||
.attr("dominant-baseline", "middle")
|
||||
.style("fill", z("target"))
|
||||
.attr("x", textGroupZero + (textGroupWidth * 1) / 3)
|
||||
.attr("y", (t) => y(t.name) + y.bandwidth() / 2);
|
||||
|
||||
textGroup
|
||||
.append("text")
|
||||
.text((t) => formatFloat(t.current))
|
||||
.attr("text-anchor", "end")
|
||||
.attr("dominant-baseline", "middle")
|
||||
.style("fill", z("current"))
|
||||
.attr("x", textGroupZero + (textGroupWidth * 2) / 3)
|
||||
.attr("y", (t) => y(t.name) + y.bandwidth() / 2);
|
||||
|
||||
textGroup
|
||||
.append("text")
|
||||
.text((t) => formatFloat(t.current - t.target))
|
||||
.attr("text-anchor", "end")
|
||||
.attr("dominant-baseline", "middle")
|
||||
.style("fill", (t) =>
|
||||
chroma(z1(Math.abs(t.current - t.target)))
|
||||
.darken()
|
||||
.hex()
|
||||
)
|
||||
.attr("x", textGroupZero + (textGroupWidth * 3) / 3)
|
||||
.attr("y", (t) => y(t.name) + y.bandwidth() / 2);
|
||||
|
||||
const groups = g
|
||||
.append("g")
|
||||
.selectAll("g.group")
|
||||
.data(allocationTargets)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "group");
|
||||
|
||||
groups
|
||||
.append("rect")
|
||||
.attr("fill", (d) => z1(Math.abs(d.target - d.current)))
|
||||
.attr("x", (d) => x1(d3.min([d.target, d.current])))
|
||||
.attr("y", (d) => y(d.name) + y.bandwidth() / 4)
|
||||
.attr("height", y.bandwidth() / 2)
|
||||
.attr("width", (d) => x1(Math.abs(d.target - d.current)));
|
||||
|
||||
groups
|
||||
.append("line")
|
||||
.attr("stroke-width", 3)
|
||||
.attr("stroke-linecap", "round")
|
||||
.attr("stroke", z("target"))
|
||||
.attr("x1", (d) => x1(d.target))
|
||||
.attr("x2", (d) => x1(d.target))
|
||||
.attr("y1", (d) => y(d.name) + y.bandwidth() / 8)
|
||||
.attr("y2", (d) => y(d.name) + (y.bandwidth() / 8) * 7);
|
||||
|
||||
const paddingTop = (y1.range()[1] - y1.bandwidth() * 2) / 2;
|
||||
d3.select("#d3-allocation-target-treemap")
|
||||
.append("div")
|
||||
.style("height", height + margin.top + margin.bottom + "px")
|
||||
.style("position", "absolute")
|
||||
.style("width", "100%")
|
||||
.selectAll("div")
|
||||
.data(allocationTargets)
|
||||
.enter()
|
||||
.append("div")
|
||||
.style("position", "absolute")
|
||||
.style("left", margin.left + x(0) + "px")
|
||||
.style("top", (t) => margin.top + y(t.name) + paddingTop + "px")
|
||||
.style("height", y1.bandwidth() * 2 + "px")
|
||||
.style("width", x.range()[1] - x.range()[0] + "px")
|
||||
.append("div")
|
||||
.style("position", "relative")
|
||||
.attr("height", y1.bandwidth() * 2)
|
||||
.each(function (t) {
|
||||
renderPartition(this, t.aggregates, d3.treemap(), color);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAllocation(
|
||||
aggregates: { [key: string]: Aggregate },
|
||||
color: d3.ScaleOrdinal<string, string>
|
||||
) {
|
||||
renderPartition(
|
||||
document.getElementById("d3-allocation-category"),
|
||||
aggregates,
|
||||
d3.partition(),
|
||||
color
|
||||
);
|
||||
renderPartition(
|
||||
document.getElementById("d3-allocation-value"),
|
||||
aggregates,
|
||||
d3.treemap(),
|
||||
color
|
||||
);
|
||||
}
|
||||
|
||||
function renderPartition(
|
||||
element: HTMLElement,
|
||||
aggregates,
|
||||
hierarchy,
|
||||
color: d3.ScaleOrdinal<string, string>
|
||||
) {
|
||||
if (_.isEmpty(aggregates)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const div = d3.select(element),
|
||||
margin = { top: 0, right: 0, bottom: 0, left: 20 },
|
||||
width = element.parentElement.clientWidth - margin.left - margin.right,
|
||||
height = +div.attr("height") - margin.top - margin.bottom;
|
||||
|
||||
const percent = (d) => {
|
||||
return formatFloat((d.value / root.value) * 100) + "%";
|
||||
};
|
||||
|
||||
const stratify = d3
|
||||
.stratify<Aggregate>()
|
||||
.id((d) => d.account)
|
||||
.parentId((d) => parentName(d.account));
|
||||
|
||||
const partition = hierarchy.size([width, height]).round(true);
|
||||
|
||||
const root = stratify(_.sortBy(aggregates, (a) => a.account))
|
||||
.sum((a) => a.market_amount)
|
||||
.sort(function (a, b) {
|
||||
return b.height - a.height || b.value - a.value;
|
||||
});
|
||||
|
||||
partition(root);
|
||||
|
||||
const cell = div
|
||||
.selectAll(".node")
|
||||
.data(root.descendants())
|
||||
.enter()
|
||||
.append("div")
|
||||
.attr("class", "node")
|
||||
.attr("data-tippy-content", (d) => {
|
||||
return tooltip([
|
||||
["Account", [d.id, "has-text-right"]],
|
||||
[
|
||||
"MarketAmount",
|
||||
[formatCurrency(d.value), "has-text-weight-bold has-text-right"]
|
||||
],
|
||||
["Percentage", [percent(d), "has-text-weight-bold has-text-right"]]
|
||||
]);
|
||||
})
|
||||
.style("top", (d: any) => d.y0 + "px")
|
||||
.style("left", (d: any) => d.x0 + "px")
|
||||
.style("width", (d: any) => d.x1 - d.x0 + "px")
|
||||
.style("height", (d: any) => d.y1 - d.y0 + "px")
|
||||
.style("background", (d) => color(d.id))
|
||||
.style("color", (d) => textColor(color(d.id)));
|
||||
|
||||
cell
|
||||
.append("p")
|
||||
.attr("class", "heading has-text-weight-bold")
|
||||
.text((d) => lastName(d.id));
|
||||
|
||||
cell
|
||||
.append("p")
|
||||
.attr("class", "heading has-text-weight-bold")
|
||||
.style("font-size", ".5 rem")
|
||||
.text(percent);
|
||||
}
|
||||
|
||||
function renderAllocationTimeline(
|
||||
aggregatesTimeline: { [key: string]: Aggregate }[]
|
||||
) {
|
||||
const timeline = _.map(aggregatesTimeline, (aggregates) => {
|
||||
return _.chain(aggregates)
|
||||
.values()
|
||||
.filter((a) => a.amount != 0)
|
||||
.groupBy((a) => secondName(a.account))
|
||||
.map((aggregates, group) => {
|
||||
return {
|
||||
date: aggregates[0].date,
|
||||
account: group,
|
||||
amount: _.sum(_.map(aggregates, (a) => a.amount)),
|
||||
market_amount: _.sum(_.map(aggregates, (a) => a.market_amount)),
|
||||
timestamp: aggregates[0].timestamp
|
||||
};
|
||||
})
|
||||
.value();
|
||||
});
|
||||
const assets = _.chain(timeline)
|
||||
.last()
|
||||
.map((a) => a.account)
|
||||
.sort()
|
||||
.value();
|
||||
|
||||
const defaultValues = _.zipObject(
|
||||
assets,
|
||||
_.map(assets, () => 0)
|
||||
);
|
||||
const start = timeline[0][0].timestamp,
|
||||
end = dayjs();
|
||||
|
||||
interface Point {
|
||||
date: dayjs.Dayjs;
|
||||
[key: string]: number | dayjs.Dayjs;
|
||||
}
|
||||
const points: Point[] = [];
|
||||
_.each(timeline, (aggregates) => {
|
||||
const total = _.sum(_.map(aggregates, (a) => a.market_amount));
|
||||
if (total == 0) {
|
||||
return;
|
||||
}
|
||||
const kvs = _.map(aggregates, (a) => [
|
||||
a.account,
|
||||
(a.market_amount / total) * 100
|
||||
]);
|
||||
points.push(
|
||||
_.merge(
|
||||
{
|
||||
date: aggregates[0].timestamp
|
||||
},
|
||||
defaultValues,
|
||||
_.fromPairs(kvs)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const svg = d3.select("#d3-allocation-timeline"),
|
||||
margin = { top: 40, right: 60, bottom: 20, left: 35 },
|
||||
width =
|
||||
document.getElementById("d3-allocation-timeline").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 x = d3.scaleTime().range([0, width]).domain([start, end]),
|
||||
y = d3
|
||||
.scaleLinear()
|
||||
.range([height, 0])
|
||||
.domain([
|
||||
0,
|
||||
d3.max(d3.map(points, (p) => d3.max(_.values(_.omit(p, "date")))))
|
||||
]),
|
||||
z = generateColorScheme(assets);
|
||||
|
||||
const line = (group) =>
|
||||
d3
|
||||
.line<Point>()
|
||||
.curve(d3.curveLinear)
|
||||
.defined((p, i) => p[group] > 0 || points[i + 1]?.[group] > 0)
|
||||
.x((p) => x(p.date))
|
||||
.y((p) => y(p[group]));
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis x")
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.call(d3.axisBottom(x));
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis y")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.tickSize(-width)
|
||||
.tickFormat((y) => `${y}%`)
|
||||
);
|
||||
g.append("g")
|
||||
.attr("class", "axis y")
|
||||
.attr("transform", `translate(${width},0)`)
|
||||
.call(d3.axisRight(y).tickFormat((y) => `${y}%`));
|
||||
|
||||
const layer = g
|
||||
.selectAll(".layer")
|
||||
.data(assets)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "layer");
|
||||
|
||||
layer
|
||||
.append("path")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", (group) => z(group))
|
||||
.attr("stroke-width", "2")
|
||||
.attr("d", (group) => line(group)(points));
|
||||
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "legendOrdinal")
|
||||
.attr("transform", "translate(40,0)");
|
||||
|
||||
const legendOrdinal = legend
|
||||
.legendColor()
|
||||
.shape("rect")
|
||||
.orient("horizontal")
|
||||
.shapePadding(100)
|
||||
.labels(assets)
|
||||
.scale(z);
|
||||
|
||||
svg.select(".legendOrdinal").call(legendOrdinal as any);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
const COLORS = {
|
||||
gain: "#b2df8a",
|
||||
gainText: "#48c78e",
|
||||
loss: "#fb9a99",
|
||||
lossText: "#f14668",
|
||||
warn: "#f4a261",
|
||||
primary: "#1f77b4",
|
||||
secondary: "#17becf",
|
||||
tertiary: "#ff7f0e",
|
||||
diff: "#4a4a4a"
|
||||
};
|
||||
export default COLORS;
|
|
@ -0,0 +1,47 @@
|
|||
import * as d3 from "d3";
|
||||
import COLORS from "./colors";
|
||||
import { ajax, Issue, setHtml } from "./utils";
|
||||
|
||||
export default async function () {
|
||||
const { issues: issues } = await ajax("/api/diagnosis");
|
||||
setHtml(
|
||||
"diagnosis-count",
|
||||
issues.length.toString(),
|
||||
issues.length > 0 ? COLORS.lossText : COLORS.gainText
|
||||
);
|
||||
renderIssues(issues);
|
||||
}
|
||||
|
||||
function renderIssues(issues: Issue[]) {
|
||||
const id = "#d3-diagnosis";
|
||||
const root = d3.select(id);
|
||||
|
||||
`<article class="message is-danger">
|
||||
<div class="message-header">
|
||||
<p>Danger</p>
|
||||
<button class="delete" aria-label="delete"></button>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. <strong>Pellentesque risus mi</strong>, tempus quis placerat ut, porta nec nulla. Vestibulum rhoncus ac ex sit amet fringilla. Nullam gravida purus diam, et dictum <a>felis venenatis</a> efficitur. Aenean ac <em>eleifend lacus</em>, in mollis lectus. Donec sodales, arcu et sollicitudin porttitor, tortor urna tempor ligula, id porttitor mi magna a neque. Donec dui urna, vehicula et sem eget, facilisis sodales sem.
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
const issue = root
|
||||
.selectAll("div")
|
||||
.data(issues)
|
||||
.enter()
|
||||
.append("div")
|
||||
.attr("class", "column is-6")
|
||||
.append("div")
|
||||
.attr("class", (i) => `message is-${i.level}`);
|
||||
|
||||
issue
|
||||
.append("div")
|
||||
.attr("class", "message-header")
|
||||
.html((i) => `<p>${i.summary}</p>`);
|
||||
|
||||
issue
|
||||
.append("div")
|
||||
.attr("class", "message-body")
|
||||
.html((i) => `${i.description} <br/> <br/> ${i.details}`);
|
||||
}
|
|
@ -0,0 +1,650 @@
|
|||
import * as d3 from "d3";
|
||||
import $ from "jquery";
|
||||
import legend from "d3-svg-legend";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import chroma from "chroma-js";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
ajax,
|
||||
forEachMonth,
|
||||
formatFixedWidthFloat,
|
||||
formatCurrency,
|
||||
formatPercentage,
|
||||
formatCurrencyCrude,
|
||||
Posting,
|
||||
restName,
|
||||
secondName,
|
||||
setHtml,
|
||||
skipTicks,
|
||||
tooltip,
|
||||
generateColorScheme
|
||||
} from "./utils";
|
||||
import COLORS from "./colors";
|
||||
|
||||
export default async function () {
|
||||
const {
|
||||
expenses: expenses,
|
||||
month_wise: {
|
||||
expenses: grouped_expenses,
|
||||
incomes: grouped_incomes,
|
||||
investments: grouped_investments,
|
||||
taxes: grouped_taxes
|
||||
}
|
||||
} = await ajax("/api/expense");
|
||||
|
||||
let minDate = dayjs();
|
||||
_.each(expenses, (p) => (p.timestamp = dayjs(p.date)));
|
||||
const parseDate = (group: { [key: string]: Posting[] }) => {
|
||||
_.each(group, (ps) => {
|
||||
_.each(ps, (p) => {
|
||||
p.timestamp = dayjs(p.date);
|
||||
if (p.timestamp.isBefore(minDate)) {
|
||||
minDate = p.timestamp;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
parseDate(grouped_expenses);
|
||||
parseDate(grouped_incomes);
|
||||
parseDate(grouped_investments);
|
||||
parseDate(grouped_taxes);
|
||||
|
||||
const max = dayjs().format("YYYY-MM");
|
||||
const min = minDate.format("YYYY-MM");
|
||||
const input = d3.select<HTMLInputElement, never>("#d3-current-month");
|
||||
input.attr("max", max);
|
||||
input.attr("min", min);
|
||||
|
||||
const { z, groups } = renderMonthlyExpensesTimeline(expenses, input.node());
|
||||
const renderer = renderCurrentExpensesBreakdown(z);
|
||||
|
||||
const state = { month: max, groups: groups };
|
||||
|
||||
$(document).on("onGroupSelected", function (_event, { groups }) {
|
||||
state.groups = groups;
|
||||
changeState();
|
||||
});
|
||||
|
||||
const changeState = () => {
|
||||
const month = state.month;
|
||||
renderCalendar(month, grouped_expenses[month], z, state.groups);
|
||||
renderSelectedMonth(
|
||||
renderer,
|
||||
grouped_expenses[month] || [],
|
||||
grouped_incomes[month] || [],
|
||||
grouped_taxes[month] || [],
|
||||
grouped_investments[month] || []
|
||||
);
|
||||
};
|
||||
|
||||
input.on("input", (event) => {
|
||||
state.month = event.srcElement.value;
|
||||
changeState();
|
||||
});
|
||||
|
||||
input.attr("value", max);
|
||||
changeState();
|
||||
input.node().focus();
|
||||
input.node().select();
|
||||
}
|
||||
|
||||
function renderCalendar(
|
||||
month: string,
|
||||
expenses: Posting[],
|
||||
z: d3.ScaleOrdinal<string, string, never>,
|
||||
groups: string[]
|
||||
) {
|
||||
const id = "#d3-current-month-expense-calendar";
|
||||
const monthStart = dayjs(month, "YYYY-MM");
|
||||
const monthEnd = monthStart.endOf("month");
|
||||
const weekStart = monthStart.startOf("week");
|
||||
const weekEnd = monthEnd.endOf("week");
|
||||
|
||||
const expensesByDay = {};
|
||||
const days: Dayjs[] = [];
|
||||
let d = weekStart;
|
||||
while (d.isSameOrBefore(weekEnd)) {
|
||||
days.push(d);
|
||||
expensesByDay[d.format("YYYY-MM-DD")] = _.filter(
|
||||
expenses,
|
||||
(e) =>
|
||||
e.timestamp.isSame(d, "day") && _.includes(groups, restName(e.account))
|
||||
);
|
||||
|
||||
d = d.add(1, "day");
|
||||
}
|
||||
|
||||
const root = d3.select(id);
|
||||
const dayDivs = root.select("div.days").selectAll("div").data(days);
|
||||
|
||||
const tooltipContent = (d: Dayjs) => {
|
||||
const es = expensesByDay[d.format("YYYY-MM-DD")];
|
||||
if (_.isEmpty(es)) {
|
||||
return null;
|
||||
}
|
||||
return tooltip(
|
||||
es.map((p) => {
|
||||
return [
|
||||
p.timestamp.format("DD MMM YYYY"),
|
||||
[p.payee, "is-clipped"],
|
||||
[formatCurrency(p.amount), "has-text-weight-bold has-text-right"]
|
||||
];
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const dayDiv = dayDivs
|
||||
.join("div")
|
||||
.attr("class", "date p-1")
|
||||
.style("position", "relative")
|
||||
.attr("data-tippy-content", tooltipContent)
|
||||
.style("visibility", (d) =>
|
||||
d.isBefore(monthStart) || d.isAfter(monthEnd) ? "hidden" : "visible"
|
||||
);
|
||||
|
||||
dayDiv
|
||||
.selectAll("span")
|
||||
.data((d) => [d])
|
||||
.join("span")
|
||||
.style("position", "absolute")
|
||||
.text((d) => d.date().toString());
|
||||
|
||||
const width = 35;
|
||||
const height = 35;
|
||||
|
||||
dayDiv
|
||||
.selectAll("svg")
|
||||
.data((d) => [d])
|
||||
.join("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height)
|
||||
.attr("viewBox", [-width / 2, -height / 2, width, height])
|
||||
.attr("style", "max-width: 100%; height: auto; height: intrinsic;")
|
||||
.selectAll("path")
|
||||
.data((d) => {
|
||||
const dayExpenses = expensesByDay[d.format("YYYY-MM-DD")];
|
||||
return d3
|
||||
.pie<Posting>()
|
||||
.value((p) => p.amount)
|
||||
.sort((a, b) => a.account.localeCompare(b.account))(dayExpenses);
|
||||
})
|
||||
.join("path")
|
||||
.attr("fill", function (d) {
|
||||
const category = restName(d.data.account);
|
||||
return z(category);
|
||||
})
|
||||
.attr("d", (arc) => {
|
||||
return d3.arc().innerRadius(13).outerRadius(17)(arc as any);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSelectedMonth(
|
||||
renderer: (ps: Posting[]) => void,
|
||||
expenses: Posting[],
|
||||
incomes: Posting[],
|
||||
taxes: Posting[],
|
||||
investments: Posting[]
|
||||
) {
|
||||
renderer(expenses);
|
||||
setHtml("current-month-income", sumCurrency(incomes, -1), COLORS.gainText);
|
||||
setHtml("current-month-tax", sumCurrency(taxes), COLORS.lossText);
|
||||
setHtml("current-month-expenses", sumCurrency(expenses), COLORS.lossText);
|
||||
setHtml(
|
||||
"current-month-investment",
|
||||
sumCurrency(investments),
|
||||
COLORS.secondary
|
||||
);
|
||||
const savingsRate = sum(investments) / (sum(incomes, -1) - sum(taxes));
|
||||
setHtml(
|
||||
"current-month-savings-rate",
|
||||
formatPercentage(savingsRate),
|
||||
COLORS.secondary
|
||||
);
|
||||
}
|
||||
|
||||
function sum(postings: Posting[], sign = 1) {
|
||||
return sign * _.sumBy(postings, (p) => p.amount);
|
||||
}
|
||||
|
||||
function sumCurrency(postings: Posting[], sign = 1) {
|
||||
return formatCurrency(sign * _.sumBy(postings, (p) => p.amount));
|
||||
}
|
||||
|
||||
function renderMonthlyExpensesTimeline(
|
||||
postings: Posting[],
|
||||
dateSelector: HTMLInputElement
|
||||
) {
|
||||
const id = "#d3-expense-timeline";
|
||||
const timeFormat = "MMM-YYYY";
|
||||
const MAX_BAR_WIDTH = 40;
|
||||
const svg = d3.select(id),
|
||||
margin = { top: 40, right: 30, bottom: 60, 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 groups = _.chain(postings)
|
||||
.map((p) => secondName(p.account))
|
||||
.uniq()
|
||||
.sort()
|
||||
.value();
|
||||
|
||||
const defaultValues = _.zipObject(
|
||||
groups,
|
||||
_.map(groups, () => 0)
|
||||
);
|
||||
|
||||
const start = _.min(_.map(postings, (p) => p.timestamp)),
|
||||
end = dayjs().startOf("month");
|
||||
const ms = _.groupBy(postings, (p) => p.timestamp.format(timeFormat));
|
||||
const ys = _.chain(postings)
|
||||
.groupBy((p) => p.timestamp.format("YYYY"))
|
||||
.map((ps, k) => {
|
||||
const trend = _.chain(ps)
|
||||
.groupBy((p) => secondName(p.account))
|
||||
.map((ps, g) => {
|
||||
let months = 12;
|
||||
if (start.format("YYYY") == k) {
|
||||
months -= start.month();
|
||||
}
|
||||
|
||||
if (end.format("YYYY") == k) {
|
||||
months -= 11 - end.month();
|
||||
}
|
||||
|
||||
return [g, _.sum(_.map(ps, (p) => p.amount)) / months];
|
||||
})
|
||||
.fromPairs()
|
||||
.value();
|
||||
|
||||
return [k, _.merge({}, defaultValues, trend)];
|
||||
})
|
||||
.fromPairs()
|
||||
.value();
|
||||
|
||||
interface Point {
|
||||
month: string;
|
||||
timestamp: Dayjs;
|
||||
[key: string]: number | string | Dayjs;
|
||||
}
|
||||
|
||||
const points: Point[] = [];
|
||||
|
||||
forEachMonth(start, end, (month) => {
|
||||
const postings = ms[month.format(timeFormat)] || [];
|
||||
const values = _.chain(postings)
|
||||
.groupBy((t) => secondName(t.account))
|
||||
.map((postings, key) => [key, _.sum(_.map(postings, (p) => p.amount))])
|
||||
.fromPairs()
|
||||
.value();
|
||||
|
||||
points.push(
|
||||
_.merge(
|
||||
{
|
||||
timestamp: month,
|
||||
month: month.format(timeFormat),
|
||||
postings: postings,
|
||||
trend: {}
|
||||
},
|
||||
defaultValues,
|
||||
values
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const x = d3.scaleBand().range([0, width]).paddingInner(0.1).paddingOuter(0);
|
||||
const y = d3.scaleLinear().range([height, 0]);
|
||||
|
||||
const z = generateColorScheme(groups);
|
||||
|
||||
const tooltipContent = (allowedGroups: string[]) => {
|
||||
return (d) => {
|
||||
return tooltip(
|
||||
_.flatMap(allowedGroups, (key) => {
|
||||
const total = (d.data as any)[key];
|
||||
if (total > 0) {
|
||||
return [
|
||||
[
|
||||
key,
|
||||
[formatCurrency(total), "has-text-weight-bold has-text-right"]
|
||||
]
|
||||
];
|
||||
}
|
||||
return [];
|
||||
})
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const xAxis = g.append("g").attr("class", "axis x");
|
||||
const yAxis = g.append("g").attr("class", "axis y");
|
||||
|
||||
const bars = g.append("g");
|
||||
const line = g
|
||||
.append("path")
|
||||
.attr("stroke", COLORS.primary)
|
||||
.attr("stroke-width", "2px")
|
||||
.attr("stroke-linecap", "round")
|
||||
.attr("stroke-dasharray", "5,5");
|
||||
|
||||
const render = (allowedGroups: string[]) => {
|
||||
const sum = (p) => _.sum(_.map(allowedGroups, (k) => p[k]));
|
||||
x.domain(points.map((p) => p.month));
|
||||
y.domain([0, d3.max(points, sum)]);
|
||||
|
||||
const t = svg.transition().duration(750);
|
||||
xAxis
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.transition(t)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(5)
|
||||
.tickFormat(skipTicks(30, x, (d) => d.toString()))
|
||||
)
|
||||
.selectAll("text")
|
||||
.attr("y", 10)
|
||||
.attr("x", -8)
|
||||
.attr("dy", ".35em")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end");
|
||||
|
||||
yAxis
|
||||
.transition(t)
|
||||
.call(d3.axisLeft(y).tickSize(-width).tickFormat(formatCurrencyCrude));
|
||||
|
||||
line
|
||||
.transition(t)
|
||||
.attr(
|
||||
"d",
|
||||
d3
|
||||
.line<Point>()
|
||||
.curve(d3.curveStepAfter)
|
||||
.x((p) => x(p.month))
|
||||
.y((p) => {
|
||||
const total = _.chain(ys[p.timestamp.format("YYYY")])
|
||||
.pick(allowedGroups)
|
||||
.values()
|
||||
.sum()
|
||||
.value();
|
||||
|
||||
return y(total);
|
||||
})(points)
|
||||
)
|
||||
.attr("fill", "none");
|
||||
|
||||
bars
|
||||
.selectAll("g")
|
||||
.data(
|
||||
d3.stack().offset(d3.stackOffsetDiverging).keys(allowedGroups)(
|
||||
points as { [key: string]: number }[]
|
||||
),
|
||||
(d: any) => d.key
|
||||
)
|
||||
.join(
|
||||
(enter) =>
|
||||
enter.append("g").attr("fill", function (d) {
|
||||
return z(d.key);
|
||||
}),
|
||||
(update) => update.transition(t),
|
||||
(exit) =>
|
||||
exit
|
||||
.selectAll("rect")
|
||||
.transition(t)
|
||||
.attr("y", y.range()[0])
|
||||
.attr("height", 0)
|
||||
.remove()
|
||||
)
|
||||
.selectAll("rect")
|
||||
.data(function (d) {
|
||||
return d;
|
||||
})
|
||||
.join(
|
||||
(enter) =>
|
||||
enter
|
||||
.append("rect")
|
||||
.attr("class", "zoomable")
|
||||
.on("click", (event, data) => {
|
||||
const timestamp: Dayjs = data.data.timestamp as any;
|
||||
dateSelector.value = timestamp.format("YYYY-MM");
|
||||
dateSelector.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
})
|
||||
.attr("data-tippy-content", tooltipContent(allowedGroups))
|
||||
.attr("x", function (d) {
|
||||
return (
|
||||
x((d.data as any).month) +
|
||||
(x.bandwidth() - Math.min(x.bandwidth(), MAX_BAR_WIDTH)) / 2
|
||||
);
|
||||
})
|
||||
.attr("width", Math.min(x.bandwidth(), MAX_BAR_WIDTH))
|
||||
.attr("y", y.range()[0])
|
||||
.transition(t)
|
||||
.attr("y", function (d) {
|
||||
return y(d[1]);
|
||||
})
|
||||
.attr("height", function (d) {
|
||||
return y(d[0]) - y(d[1]);
|
||||
}),
|
||||
(update) =>
|
||||
update
|
||||
.attr("data-tippy-content", tooltipContent(allowedGroups))
|
||||
.transition(t)
|
||||
.attr("y", function (d) {
|
||||
return y(d[1]);
|
||||
})
|
||||
.attr("height", function (d) {
|
||||
return y(d[0]) - y(d[1]);
|
||||
}),
|
||||
(exit) => exit.transition(t).remove()
|
||||
);
|
||||
};
|
||||
|
||||
let selectedGroups = groups;
|
||||
render(selectedGroups);
|
||||
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "legendOrdinal")
|
||||
.attr("transform", "translate(40,0)");
|
||||
|
||||
const legendOrdinal = legend
|
||||
.legendColor()
|
||||
.shape("rect")
|
||||
.orient("horizontal")
|
||||
.shapePadding(100)
|
||||
.labels(groups)
|
||||
.on("cellclick", function () {
|
||||
const group = this.__data__;
|
||||
if (selectedGroups.length == 1 && selectedGroups[0] == group) {
|
||||
selectedGroups = groups;
|
||||
d3.selectAll(".legendOrdinal .cell .label").attr("fill", "#000");
|
||||
} else {
|
||||
selectedGroups = [group];
|
||||
d3.selectAll(".legendOrdinal .cell .label").attr("fill", "#ccc");
|
||||
d3.select(this).selectAll(".label").attr("fill", "#000");
|
||||
}
|
||||
$(document).trigger("onGroupSelected", { groups: selectedGroups });
|
||||
render(selectedGroups);
|
||||
})
|
||||
.scale(z);
|
||||
|
||||
svg.select(".legendOrdinal").call(legendOrdinal as any);
|
||||
return { z: z, groups: groups };
|
||||
}
|
||||
|
||||
function renderCurrentExpensesBreakdown(
|
||||
z: d3.ScaleOrdinal<string, string, never>
|
||||
) {
|
||||
const id = "#d3-current-month-breakdown";
|
||||
const BAR_HEIGHT = 20;
|
||||
const svg = d3.select(id),
|
||||
margin = { top: 10, right: 160, bottom: 20, left: 100 },
|
||||
width =
|
||||
document.getElementById(id.substring(1)).parentElement.clientWidth -
|
||||
margin.left -
|
||||
margin.right,
|
||||
g = svg
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
|
||||
const x = d3.scaleLinear().range([0, width]);
|
||||
const y = d3.scaleBand().paddingInner(0.1).paddingOuter(0);
|
||||
|
||||
const xAxis = g.append("g").attr("class", "axis y");
|
||||
const yAxis = g.append("g").attr("class", "axis y dark");
|
||||
|
||||
const bar = g.append("g");
|
||||
|
||||
return (postings: Posting[]) => {
|
||||
interface Point {
|
||||
category: string;
|
||||
postings: Posting[];
|
||||
total: number;
|
||||
}
|
||||
const categories = _.chain(postings)
|
||||
.groupBy((p) => restName(p.account))
|
||||
.mapValues((ps, category) => {
|
||||
return {
|
||||
category: category,
|
||||
postings: ps,
|
||||
total: _.sumBy(ps, (p) => p.amount)
|
||||
};
|
||||
})
|
||||
.value();
|
||||
const keys = _.chain(categories)
|
||||
.sortBy((c) => c.total)
|
||||
.map((c) => c.category)
|
||||
.value();
|
||||
|
||||
const points = _.values(categories);
|
||||
const total = _.sumBy(points, (p) => p.total);
|
||||
|
||||
const height = BAR_HEIGHT * keys.length;
|
||||
svg.attr("height", height + margin.top + margin.bottom);
|
||||
|
||||
y.domain(keys);
|
||||
x.domain([0, d3.max(points, (p) => p.total)]);
|
||||
y.range([height, 0]);
|
||||
|
||||
const t = svg.transition().duration(750);
|
||||
|
||||
xAxis
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.transition(t)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.tickSize(-height)
|
||||
.tickFormat(skipTicks(60, x, formatCurrencyCrude))
|
||||
);
|
||||
|
||||
yAxis.transition(t).call(d3.axisLeft(y));
|
||||
|
||||
const tooltipContent = (d: Point) => {
|
||||
return tooltip(
|
||||
d.postings.map((p) => {
|
||||
return [
|
||||
p.timestamp.format("DD MMM YYYY"),
|
||||
[p.payee, "is-clipped"],
|
||||
[formatCurrency(p.amount), "has-text-weight-bold has-text-right"]
|
||||
];
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
bar
|
||||
.selectAll("rect")
|
||||
.data(points, (p: any) => p.category)
|
||||
.join(
|
||||
(enter) =>
|
||||
enter
|
||||
.append("rect")
|
||||
.attr("fill", function (d) {
|
||||
return z(d.category);
|
||||
})
|
||||
.attr("data-tippy-content", tooltipContent)
|
||||
.attr("x", x(0))
|
||||
.attr("y", function (d) {
|
||||
return (
|
||||
y(d.category) +
|
||||
(y.bandwidth() - Math.min(y.bandwidth(), BAR_HEIGHT)) / 2
|
||||
);
|
||||
})
|
||||
.attr("width", function (d) {
|
||||
return x(d.total);
|
||||
})
|
||||
.attr("height", y.bandwidth()),
|
||||
|
||||
(update) =>
|
||||
update
|
||||
.attr("fill", function (d) {
|
||||
return z(d.category);
|
||||
})
|
||||
.attr("data-tippy-content", tooltipContent)
|
||||
.transition(t)
|
||||
.attr("x", x(0))
|
||||
.attr("y", function (d) {
|
||||
return (
|
||||
y(d.category) +
|
||||
(y.bandwidth() - Math.min(y.bandwidth(), BAR_HEIGHT)) / 2
|
||||
);
|
||||
})
|
||||
.attr("width", function (d) {
|
||||
return x(d.total);
|
||||
})
|
||||
.attr("height", y.bandwidth()),
|
||||
|
||||
(exit) => exit.remove()
|
||||
);
|
||||
|
||||
bar
|
||||
.selectAll("text")
|
||||
.data(points, (p: any) => p.category)
|
||||
.join(
|
||||
(enter) =>
|
||||
enter
|
||||
.append("text")
|
||||
.attr("text-anchor", "end")
|
||||
.attr("dominant-baseline", "middle")
|
||||
.attr("y", function (d) {
|
||||
return y(d.category) + y.bandwidth() / 2;
|
||||
})
|
||||
.attr("x", width + 135)
|
||||
.style("white-space", "pre")
|
||||
.style("font-size", "13px")
|
||||
.style("font-weight", "bold")
|
||||
.style("fill", function (d) {
|
||||
return chroma(z(d.category)).darken(0.8).hex();
|
||||
})
|
||||
.attr("class", "is-family-monospace")
|
||||
.text(
|
||||
(d) =>
|
||||
`${formatCurrency(d.total)} ${formatFixedWidthFloat(
|
||||
(d.total / total) * 100,
|
||||
6
|
||||
)}%`
|
||||
),
|
||||
(update) =>
|
||||
update
|
||||
.text(
|
||||
(d) =>
|
||||
`${formatCurrency(d.total)} ${formatFixedWidthFloat(
|
||||
(d.total / total) * 100,
|
||||
6
|
||||
)}%`
|
||||
)
|
||||
.transition(t)
|
||||
.attr("y", function (d) {
|
||||
return y(d.category) + y.bandwidth() / 2;
|
||||
}),
|
||||
(exit) => exit.remove()
|
||||
);
|
||||
|
||||
return;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,605 @@
|
|||
import chroma from "chroma-js";
|
||||
import * as d3 from "d3";
|
||||
import legend from "d3-svg-legend";
|
||||
import dayjs from "dayjs";
|
||||
import _ from "lodash";
|
||||
import COLORS from "./colors";
|
||||
import {
|
||||
ajax,
|
||||
formatCurrency,
|
||||
formatCurrencyCrude,
|
||||
formatFloat,
|
||||
Gain,
|
||||
Overview,
|
||||
tooltip,
|
||||
skipTicks,
|
||||
restName
|
||||
} from "./utils";
|
||||
|
||||
export default async function () {
|
||||
const { gain_timeline_breakdown: gains } = await ajax("/api/gain");
|
||||
_.each(gains, (g) =>
|
||||
_.each(g.overview_timeline, (o) => (o.timestamp = dayjs(o.date)))
|
||||
);
|
||||
|
||||
renderLegend();
|
||||
renderOverview(gains);
|
||||
renderPerAccountOverview(gains);
|
||||
}
|
||||
|
||||
const areaKeys = ["gain", "loss"];
|
||||
const colors = [COLORS.gain, COLORS.loss];
|
||||
const areaScale = d3.scaleOrdinal<string>().domain(areaKeys).range(colors);
|
||||
const lineKeys = ["balance", "investment", "withdrawal"];
|
||||
const lineScale = d3
|
||||
.scaleOrdinal<string>()
|
||||
.domain(lineKeys)
|
||||
.range([COLORS.primary, COLORS.secondary, COLORS.tertiary]);
|
||||
|
||||
function renderTable(gain: Gain) {
|
||||
const tbody = d3.select(this);
|
||||
const current = _.last(gain.overview_timeline);
|
||||
tbody.html(function () {
|
||||
return `
|
||||
<tr>
|
||||
<td>Account</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${restName(gain.account)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Investment</td>
|
||||
<td class='has-text-right'>${formatCurrency(current.investment_amount)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Withdrawal</td>
|
||||
<td class='has-text-right'>${formatCurrency(current.withdrawal_amount)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gain</td>
|
||||
<td class='has-text-right'>${formatCurrency(current.gain_amount)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Balance</td>
|
||||
<td class='has-text-right'>${formatCurrency(
|
||||
current.investment_amount + current.gain_amount - current.withdrawal_amount
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>XIRR</td>
|
||||
<td class='has-text-right'>${formatFloat(gain.xirr)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderOverview(gains: Gain[]) {
|
||||
gains = _.sortBy(gains, (g) => g.account);
|
||||
const BAR_HEIGHT = 15;
|
||||
const id = "#d3-gain-overview";
|
||||
const svg = d3.select(id),
|
||||
margin = { top: 5, right: 20, bottom: 30, left: 150 },
|
||||
width =
|
||||
document.getElementById(id.substring(1)).parentElement.clientWidth -
|
||||
margin.left -
|
||||
margin.right,
|
||||
height = gains.length * BAR_HEIGHT * 2,
|
||||
g = svg
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
svg.attr("height", height + margin.top + margin.bottom);
|
||||
|
||||
const y = d3.scaleBand().range([0, height]).paddingInner(0).paddingOuter(0);
|
||||
y.domain(gains.map((g) => restName(g.account)));
|
||||
const y1 = d3
|
||||
.scaleBand()
|
||||
.range([0, y.bandwidth()])
|
||||
.domain(["0", "1"])
|
||||
.paddingInner(0)
|
||||
.paddingOuter(0.1);
|
||||
|
||||
const keys = ["balance", "investment", "withdrawal", "gain", "loss"];
|
||||
const colors = [
|
||||
COLORS.primary,
|
||||
COLORS.secondary,
|
||||
COLORS.tertiary,
|
||||
COLORS.gain,
|
||||
COLORS.loss
|
||||
];
|
||||
const z = d3.scaleOrdinal<string>(colors).domain(keys);
|
||||
|
||||
const getInvestmentAmount = (g: Gain) =>
|
||||
_.last(g.overview_timeline).investment_amount;
|
||||
|
||||
const getGainAmount = (g: Gain) => _.last(g.overview_timeline).gain_amount;
|
||||
const getWithdrawalAmount = (g: Gain) =>
|
||||
_.last(g.overview_timeline).withdrawal_amount;
|
||||
|
||||
const getBalanceAmount = (g: Gain) => {
|
||||
const current = _.last(g.overview_timeline);
|
||||
return (
|
||||
current.investment_amount +
|
||||
current.gain_amount -
|
||||
current.withdrawal_amount
|
||||
);
|
||||
};
|
||||
|
||||
const maxX = _.chain(gains)
|
||||
.map((g) => getInvestmentAmount(g) + _.max([getGainAmount(g), 0]))
|
||||
.max()
|
||||
.value();
|
||||
const xirrWidth = 250;
|
||||
const xirrTextWidth = 40;
|
||||
const xirrMargin = 20;
|
||||
const textGroupWidth = 225;
|
||||
const textGroupZero = xirrWidth + xirrTextWidth + xirrMargin;
|
||||
|
||||
const x = d3.scaleLinear().range([textGroupZero + textGroupWidth, width]);
|
||||
x.domain([0, maxX]);
|
||||
const x1 = d3
|
||||
.scaleLinear()
|
||||
.range([0, xirrWidth])
|
||||
.domain([
|
||||
_.min([_.min(_.map(gains, (g) => g.xirr)), 0]),
|
||||
_.max([0, _.max(_.map(gains, (g) => g.xirr))])
|
||||
]);
|
||||
|
||||
g.append("line")
|
||||
.attr("stroke", "#ddd")
|
||||
.attr("x1", xirrWidth + xirrTextWidth + xirrMargin / 2)
|
||||
.attr("y1", 0)
|
||||
.attr("x2", xirrWidth + xirrTextWidth + xirrMargin / 2)
|
||||
.attr("y2", height);
|
||||
|
||||
g.append("line")
|
||||
.attr("stroke", "#ddd")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", height)
|
||||
.attr("x2", width)
|
||||
.attr("y2", height);
|
||||
|
||||
g.append("text")
|
||||
.attr("fill", "#4a4a4a")
|
||||
.text("XIRR")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("x", xirrWidth / 2)
|
||||
.attr("y", height + 30);
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis y")
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.tickSize(-height)
|
||||
.tickFormat(skipTicks(60, x, formatCurrencyCrude))
|
||||
);
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis y")
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x1)
|
||||
.tickSize(-height)
|
||||
.tickFormat(skipTicks(40, x1, (n) => formatFloat(n, 1)))
|
||||
);
|
||||
|
||||
g.append("g").attr("class", "axis y dark").call(d3.axisLeft(y));
|
||||
|
||||
const textGroup = g
|
||||
.append("g")
|
||||
.selectAll("g")
|
||||
.data(gains)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "inline-text");
|
||||
|
||||
textGroup
|
||||
.append("text")
|
||||
.text((g) => formatCurrency(getInvestmentAmount(g)))
|
||||
.attr("dominant-baseline", "hanging")
|
||||
.attr("text-anchor", "end")
|
||||
.style("fill", (g) =>
|
||||
getInvestmentAmount(g) > 0 ? z("investment") : "none"
|
||||
)
|
||||
.attr("dx", "-3")
|
||||
.attr("dy", "3")
|
||||
.attr("x", textGroupZero + textGroupWidth / 3)
|
||||
.attr("y", (g) => y(restName(g.account)));
|
||||
|
||||
textGroup
|
||||
.append("text")
|
||||
.text((g) => formatCurrency(getGainAmount(g)))
|
||||
.attr("dominant-baseline", "hanging")
|
||||
.attr("text-anchor", "end")
|
||||
.style("fill", (g) =>
|
||||
getGainAmount(g) > 0 ? chroma(z("gain")).darken().hex() : "none"
|
||||
)
|
||||
.attr("dx", "-3")
|
||||
.attr("dy", "3")
|
||||
.attr("x", textGroupZero + (textGroupWidth * 2) / 3)
|
||||
.attr("y", (g) => y(restName(g.account)));
|
||||
|
||||
textGroup
|
||||
.append("text")
|
||||
.text((g) => formatCurrency(getBalanceAmount(g)))
|
||||
.attr("text-anchor", "end")
|
||||
.style("fill", (g) => (getBalanceAmount(g) > 0 ? z("balance") : "none"))
|
||||
.attr("dx", "-3")
|
||||
.attr("dy", "-3")
|
||||
.attr("x", textGroupZero + textGroupWidth / 3)
|
||||
.attr("y", (g) => y(restName(g.account)) + y.bandwidth());
|
||||
|
||||
textGroup
|
||||
.append("text")
|
||||
.text((g) => formatCurrency(getGainAmount(g)))
|
||||
.attr("text-anchor", "end")
|
||||
.style("fill", (g) =>
|
||||
getGainAmount(g) < 0 ? chroma(z("loss")).darken().hex() : "none"
|
||||
)
|
||||
.attr("dx", "-3")
|
||||
.attr("dy", "-3")
|
||||
.attr("x", textGroupZero + (textGroupWidth * 2) / 3)
|
||||
.attr("y", (g) => y(restName(g.account)) + y.bandwidth());
|
||||
|
||||
textGroup
|
||||
.append("text")
|
||||
.text((g) => formatCurrency(getWithdrawalAmount(g)))
|
||||
.attr("text-anchor", "end")
|
||||
.style("fill", (g) =>
|
||||
getWithdrawalAmount(g) > 0 ? z("withdrawal") : "none"
|
||||
)
|
||||
.attr("dx", "-3")
|
||||
.attr("dy", "-3")
|
||||
.attr("x", textGroupZero + textGroupWidth)
|
||||
.attr("y", (g) => y(restName(g.account)) + y.bandwidth());
|
||||
|
||||
textGroup
|
||||
.append("line")
|
||||
.attr("stroke", "#ddd")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", (g) => y(restName(g.account)))
|
||||
.attr("x2", width)
|
||||
.attr("y2", (g) => y(restName(g.account)));
|
||||
|
||||
textGroup
|
||||
.append("text")
|
||||
.text((g) => formatFloat(g.xirr))
|
||||
.attr("text-anchor", "end")
|
||||
.attr("dominant-baseline", "middle")
|
||||
.style("fill", (g) =>
|
||||
g.xirr < 0
|
||||
? chroma(z("loss")).darken().hex()
|
||||
: chroma(z("gain")).darken().hex()
|
||||
)
|
||||
.attr("x", xirrWidth + xirrTextWidth)
|
||||
.attr("y", (g) => y(restName(g.account)) + y.bandwidth() / 2);
|
||||
|
||||
const groups = g
|
||||
.append("g")
|
||||
.selectAll("g.group")
|
||||
.data(gains)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "group")
|
||||
.attr("transform", (g) => "translate(0," + y(restName(g.account)) + ")");
|
||||
|
||||
groups
|
||||
.selectAll("g")
|
||||
.data((g) => [
|
||||
d3.stack().keys(["investment", "gain"])([
|
||||
{
|
||||
i: "0",
|
||||
data: g,
|
||||
investment: getInvestmentAmount(g),
|
||||
gain: _.max([getGainAmount(g), 0])
|
||||
}
|
||||
] as any),
|
||||
d3.stack().keys(["balance", "loss", "withdrawal"])([
|
||||
{
|
||||
i: "1",
|
||||
data: g,
|
||||
balance: getBalanceAmount(g),
|
||||
withdrawal: getWithdrawalAmount(g),
|
||||
loss: Math.abs(_.min([getGainAmount(g), 0]))
|
||||
}
|
||||
] as any)
|
||||
])
|
||||
.enter()
|
||||
.append("g")
|
||||
.selectAll("rect")
|
||||
.data((d) => {
|
||||
return d;
|
||||
})
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("fill", (d) => {
|
||||
return z(d.key);
|
||||
})
|
||||
.attr("x", (d) => x(d[0][0]))
|
||||
.attr("y", (d: any) => y1(d[0].data.i))
|
||||
.attr("height", y1.bandwidth())
|
||||
.attr("width", (d) => x(d[0][1]) - x(d[0][0]));
|
||||
|
||||
const paddingTop = (y1.range()[1] - y1.bandwidth() * 2) / 2;
|
||||
g.append("g")
|
||||
.selectAll("rect")
|
||||
.data(gains)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("fill", (g) => (g.xirr < 0 ? z("loss") : z("gain")))
|
||||
.attr("x", (g) => (g.xirr < 0 ? x1(g.xirr) : x1(0)))
|
||||
.attr("y", (g) => y(restName(g.account)) + paddingTop)
|
||||
.attr("height", y.bandwidth() - paddingTop * 2)
|
||||
.attr("width", (g) => Math.abs(x1(0) - x1(g.xirr)));
|
||||
|
||||
g.append("g")
|
||||
.selectAll("rect")
|
||||
.data(gains)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("fill", "transparent")
|
||||
.attr("data-tippy-content", (g: Gain) => {
|
||||
const current = _.last(g.overview_timeline);
|
||||
return tooltip([
|
||||
["Account", [g.account, "has-text-weight-bold has-text-right"]],
|
||||
[
|
||||
"Investment",
|
||||
[
|
||||
formatCurrency(current.investment_amount),
|
||||
"has-text-weight-bold has-text-right"
|
||||
]
|
||||
],
|
||||
[
|
||||
"Withdrawal",
|
||||
[
|
||||
formatCurrency(current.withdrawal_amount),
|
||||
"has-text-weight-bold has-text-right"
|
||||
]
|
||||
],
|
||||
[
|
||||
"Gain",
|
||||
[
|
||||
formatCurrency(current.gain_amount),
|
||||
"has-text-weight-bold has-text-right"
|
||||
]
|
||||
],
|
||||
[
|
||||
"Balance",
|
||||
[
|
||||
formatCurrency(
|
||||
current.investment_amount +
|
||||
current.gain_amount -
|
||||
current.withdrawal_amount
|
||||
),
|
||||
"has-text-weight-bold has-text-right"
|
||||
]
|
||||
],
|
||||
["XIRR", [formatFloat(g.xirr), "has-text-weight-bold has-text-right"]]
|
||||
]);
|
||||
})
|
||||
.attr("x", 0)
|
||||
.attr("y", (g) => y(restName(g.account)))
|
||||
.attr("height", y.bandwidth())
|
||||
.attr("width", width);
|
||||
}
|
||||
|
||||
function renderPerAccountOverview(gains: Gain[]) {
|
||||
const start = _.min(
|
||||
_.flatMap(gains, (g) => _.map(g.overview_timeline, (o) => o.timestamp))
|
||||
),
|
||||
end = dayjs();
|
||||
|
||||
const divs = d3
|
||||
.select("#d3-gain-timeline-breakdown")
|
||||
.selectAll("div")
|
||||
.data(_.sortBy(gains, (g) => g.account));
|
||||
|
||||
divs.exit().remove();
|
||||
|
||||
const columns = divs.enter().append("div").attr("class", "columns");
|
||||
|
||||
const leftColumn = columns
|
||||
.append("div")
|
||||
.attr("class", "column is-4 is-3-desktop is-2-fullhd");
|
||||
leftColumn
|
||||
.append("table")
|
||||
.attr("class", "table is-narrow is-fullwidth is-size-7")
|
||||
.append("tbody")
|
||||
.each(renderTable);
|
||||
|
||||
const rightColumn = columns.append("div").attr("class", "column");
|
||||
rightColumn
|
||||
.append("svg")
|
||||
.attr("width", "100%")
|
||||
.attr("height", "150")
|
||||
.each(function (gain) {
|
||||
renderOverviewSmall(gain.overview_timeline, this, [start, end]);
|
||||
});
|
||||
}
|
||||
|
||||
function renderOverviewSmall(
|
||||
points: Overview[],
|
||||
element: Element,
|
||||
xDomain: [dayjs.Dayjs, dayjs.Dayjs]
|
||||
) {
|
||||
const svg = d3.select(element),
|
||||
margin = { top: 5, right: 80, bottom: 20, left: 40 },
|
||||
width = element.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 x = d3.scaleTime().range([0, width]).domain(xDomain),
|
||||
y = d3
|
||||
.scaleLinear()
|
||||
.range([height, 0])
|
||||
.domain([
|
||||
0,
|
||||
d3.max<Overview, number>(
|
||||
points,
|
||||
(d) => d.gain_amount + d.investment_amount
|
||||
)
|
||||
]),
|
||||
z = d3.scaleOrdinal<string>(colors).domain(areaKeys);
|
||||
|
||||
const area = (y0, y1) =>
|
||||
d3
|
||||
.area<Overview>()
|
||||
.curve(d3.curveBasis)
|
||||
.x((d) => x(d.timestamp))
|
||||
.y0(y0)
|
||||
.y1(y1);
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis x")
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.call(d3.axisBottom(x));
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis y")
|
||||
.attr("transform", `translate(${width},0)`)
|
||||
.call(
|
||||
d3.axisRight(y).ticks(5).tickPadding(5).tickFormat(formatCurrencyCrude)
|
||||
);
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis y")
|
||||
.call(
|
||||
d3.axisLeft(y).ticks(5).tickSize(-width).tickFormat(formatCurrencyCrude)
|
||||
);
|
||||
|
||||
const layer = g
|
||||
.selectAll(".layer")
|
||||
.data([points])
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "layer");
|
||||
|
||||
const clipAboveID = _.uniqueId("clip-above");
|
||||
layer
|
||||
.append("clipPath")
|
||||
.attr("id", clipAboveID)
|
||||
.append("path")
|
||||
.attr(
|
||||
"d",
|
||||
area(height, (d) => {
|
||||
return y(d.gain_amount + d.investment_amount);
|
||||
})
|
||||
);
|
||||
|
||||
const clipBelowID = _.uniqueId("clip-below");
|
||||
layer
|
||||
.append("clipPath")
|
||||
.attr("id", clipBelowID)
|
||||
.append("path")
|
||||
.attr(
|
||||
"d",
|
||||
area(0, (d) => {
|
||||
return y(d.gain_amount + d.investment_amount);
|
||||
})
|
||||
);
|
||||
|
||||
layer
|
||||
.append("path")
|
||||
.attr(
|
||||
"clip-path",
|
||||
`url(${new URL("#" + clipAboveID, window.location.toString())})`
|
||||
)
|
||||
.style("fill", z("gain"))
|
||||
.style("opacity", "0.8")
|
||||
.attr(
|
||||
"d",
|
||||
area(0, (d) => {
|
||||
return y(d.investment_amount);
|
||||
})
|
||||
);
|
||||
|
||||
layer
|
||||
.append("path")
|
||||
.attr(
|
||||
"clip-path",
|
||||
`url(${new URL("#" + clipBelowID, window.location.toString())})`
|
||||
)
|
||||
.style("fill", z("loss"))
|
||||
.style("opacity", "0.8")
|
||||
.attr(
|
||||
"d",
|
||||
area(height, (d) => {
|
||||
return y(d.investment_amount);
|
||||
})
|
||||
);
|
||||
|
||||
layer
|
||||
.append("path")
|
||||
.style("stroke", lineScale("investment"))
|
||||
.style("fill", "none")
|
||||
.attr(
|
||||
"d",
|
||||
d3
|
||||
.line<Overview>()
|
||||
.curve(d3.curveBasis)
|
||||
.x((d) => x(d.timestamp))
|
||||
.y((d) => y(d.investment_amount))
|
||||
);
|
||||
|
||||
layer
|
||||
.append("path")
|
||||
.style("stroke", lineScale("withdrawal"))
|
||||
.style("fill", "none")
|
||||
.attr(
|
||||
"d",
|
||||
d3
|
||||
.line<Overview>()
|
||||
.curve(d3.curveBasis)
|
||||
.defined((d) => d.withdrawal_amount > 0)
|
||||
.x((d) => x(d.timestamp))
|
||||
.y((d) => y(d.withdrawal_amount))
|
||||
);
|
||||
|
||||
layer
|
||||
.append("path")
|
||||
.style("stroke", lineScale("balance"))
|
||||
.style("fill", "none")
|
||||
.attr(
|
||||
"d",
|
||||
d3
|
||||
.line<Overview>()
|
||||
.curve(d3.curveBasis)
|
||||
.x((d) => x(d.timestamp))
|
||||
.y((d) => y(d.investment_amount + d.gain_amount - d.withdrawal_amount))
|
||||
);
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
const svg = d3.select("#d3-gain-legend");
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "legendOrdinal")
|
||||
.attr("transform", "translate(280,3)");
|
||||
|
||||
const legendOrdinal = legend
|
||||
.legendColor()
|
||||
.shape("rect")
|
||||
.orient("horizontal")
|
||||
.shapePadding(70)
|
||||
.labels(areaKeys)
|
||||
.scale(areaScale);
|
||||
|
||||
svg.select(".legendOrdinal").call(legendOrdinal as any);
|
||||
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "legendLine")
|
||||
.attr("transform", "translate(30,3)");
|
||||
|
||||
const legendLine = legend
|
||||
.legendColor()
|
||||
.shape("rect")
|
||||
.orient("horizontal")
|
||||
.shapePadding(70)
|
||||
.labels(lineKeys)
|
||||
.scale(lineScale);
|
||||
|
||||
svg.select(".legendLine").call(legendLine as any);
|
||||
}
|
|
@ -0,0 +1,322 @@
|
|||
import * as d3 from "d3";
|
||||
import dayjs from "dayjs";
|
||||
import _, { round } from "lodash";
|
||||
import COLORS from "./colors";
|
||||
import {
|
||||
ajax,
|
||||
CapitalGain,
|
||||
formatCurrency,
|
||||
formatFloat,
|
||||
restName,
|
||||
tooltip
|
||||
} from "./utils";
|
||||
|
||||
export default async function () {
|
||||
const { capital_gains: capital_gains } = await ajax("/api/harvest");
|
||||
renderHarvestables(capital_gains);
|
||||
}
|
||||
|
||||
function renderHarvestables(capital_gains: CapitalGain[]) {
|
||||
const id = "#d3-harvestables";
|
||||
const root = d3.select(id);
|
||||
|
||||
const card = root
|
||||
.selectAll("div.column")
|
||||
.data(_.filter(capital_gains, (cg) => cg.harvestable.harvestable_units > 0))
|
||||
.enter()
|
||||
.append("div")
|
||||
.attr("class", "column is-12")
|
||||
.append("div")
|
||||
.attr("class", "card");
|
||||
|
||||
const header = card.append("header").attr("class", "card-header");
|
||||
|
||||
header
|
||||
.append("p")
|
||||
.attr("class", "card-header-title")
|
||||
.text((cg) => restName(cg.account));
|
||||
|
||||
header
|
||||
.append("div")
|
||||
.attr("class", "card-header-icon")
|
||||
.style("flex-grow", "1")
|
||||
.style("cursor", "auto")
|
||||
.append("div")
|
||||
.each(function (cg) {
|
||||
const self = d3.select(this);
|
||||
const [units, amount, taxableGain] = unitsRequiredFromGain(cg, 100000);
|
||||
self.append("span").html("If you redeem ");
|
||||
const unitsSpan = self.append("span").text(formatFloat(units));
|
||||
self.append("span").html(" units you will get ₹");
|
||||
const amountInput = self
|
||||
.append("input")
|
||||
.attr("class", "input is-small adjustable-input")
|
||||
.attr("type", "number")
|
||||
.attr("value", round(amount))
|
||||
.attr("step", "1000")
|
||||
.on("input", (event) => {
|
||||
const [units, amount, taxableGain] = unitsRequiredFromAmount(
|
||||
cg,
|
||||
parseInt(event.srcElement.value)
|
||||
);
|
||||
|
||||
unitsSpan.text(formatFloat(units));
|
||||
(taxableGainInput.node() as HTMLInputElement).value =
|
||||
round(taxableGain).toString();
|
||||
event.srcElement.value = round(amount);
|
||||
});
|
||||
self
|
||||
.append("span")
|
||||
.html(" and your <b>taxable</b> gain would be ₹");
|
||||
const taxableGainInput = self
|
||||
.append("input")
|
||||
.attr("class", "input is-small adjustable-input")
|
||||
.attr("type", "number")
|
||||
.attr("value", round(taxableGain))
|
||||
.attr("step", "1000")
|
||||
.on("input", (event) => {
|
||||
const [units, amount, taxableGain] = unitsRequiredFromGain(
|
||||
cg,
|
||||
parseInt(event.srcElement.value)
|
||||
);
|
||||
unitsSpan.text(formatFloat(units));
|
||||
event.srcElement.value = round(taxableGain);
|
||||
(amountInput.node() as HTMLInputElement).value =
|
||||
round(amount).toString();
|
||||
});
|
||||
});
|
||||
|
||||
header
|
||||
.append("span")
|
||||
.attr("class", "card-header-icon")
|
||||
.text(
|
||||
(cg) =>
|
||||
"price as on " +
|
||||
dayjs(cg.harvestable.current_unit_date).format("DD MMM YYYY")
|
||||
);
|
||||
|
||||
const content = card
|
||||
.append("div")
|
||||
.attr("class", "card-content")
|
||||
.append("div")
|
||||
.attr("class", "content")
|
||||
.append("div")
|
||||
.attr("class", "columns");
|
||||
|
||||
const summary = content.append("div").attr("class", "column is-4");
|
||||
|
||||
summary.append("div").each(renderSingleBar);
|
||||
|
||||
summary.append("div").html((cg) => {
|
||||
const h = cg.harvestable;
|
||||
return `
|
||||
<table class="table is-narrow is-fullwidth">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Balance Units</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatFloat(
|
||||
h.total_units
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Harvestable Units</td>
|
||||
<td class='has-text-right has-text-weight-bold has-text-success'>${formatFloat(
|
||||
h.harvestable_units
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tax Category</td>
|
||||
<td class='has-text-right is-uppercase'>${cg.tax_category}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Current Unit Price</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatFloat(
|
||||
h.current_unit_price
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Unrealized Gain / Loss</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatCurrency(
|
||||
h.unrealized_gain
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Taxable Unrealized Gain / Loss</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatCurrency(
|
||||
h.taxable_unrealized_gain
|
||||
)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
});
|
||||
|
||||
const table = content
|
||||
.append("div")
|
||||
.attr("class", "column is-8")
|
||||
.append("div")
|
||||
.attr("class", "table-container")
|
||||
.style("overflow-y", "auto")
|
||||
.style("max-height", "245px")
|
||||
.append("table")
|
||||
.attr("class", "table");
|
||||
|
||||
table.append("thead").html(`
|
||||
<tr>
|
||||
<th>Purchase Date</th>
|
||||
<th class='has-text-right'>Units</th>
|
||||
<th class='has-text-right'>Purchase Price</th>
|
||||
<th class='has-text-right'>Purchase Unit Price</th>
|
||||
<th class='has-text-right'>Current Price</th>
|
||||
<th class='has-text-right'>Unrealized Gain</th>
|
||||
<th class='has-text-right'>Taxable Unrealized Gain</th>
|
||||
</tr>
|
||||
`);
|
||||
|
||||
table
|
||||
.append("tbody")
|
||||
.selectAll("tr")
|
||||
.data((cg) => {
|
||||
return cg.harvestable.harvest_breakdown;
|
||||
})
|
||||
.enter()
|
||||
.append("tr")
|
||||
.html((breakdown) => {
|
||||
return `
|
||||
<tr>
|
||||
<td style="white-space: nowrap">${dayjs(breakdown.purchase_date).format(
|
||||
"DD MMM YYYY"
|
||||
)}</td>
|
||||
<td class='has-text-right'>${formatFloat(breakdown.units)}</td>
|
||||
<td class='has-text-right'>${formatCurrency(breakdown.purchase_price)}</td>
|
||||
<td class='has-text-right'>${formatFloat(breakdown.purchase_unit_price)}</td>
|
||||
<td class='has-text-right'>${formatCurrency(breakdown.current_price)}</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatCurrency(
|
||||
breakdown.unrealized_gain
|
||||
)}</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatCurrency(
|
||||
breakdown.taxable_unrealized_gain
|
||||
)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
function unitsRequiredFromGain(
|
||||
cg: CapitalGain,
|
||||
taxableGain: number
|
||||
): [number, number, number] {
|
||||
let gain = 0;
|
||||
let amount = 0;
|
||||
let units = 0;
|
||||
const available = _.clone(cg.harvestable.harvest_breakdown);
|
||||
while (taxableGain > gain && available.length > 0) {
|
||||
const breakdown = available.shift();
|
||||
if (breakdown.taxable_unrealized_gain < taxableGain - gain) {
|
||||
gain += breakdown.taxable_unrealized_gain;
|
||||
units += breakdown.units;
|
||||
amount += breakdown.current_price;
|
||||
} else {
|
||||
const u =
|
||||
((taxableGain - gain) * breakdown.units) /
|
||||
breakdown.taxable_unrealized_gain;
|
||||
units += u;
|
||||
amount += u * cg.harvestable.current_unit_price;
|
||||
gain = taxableGain;
|
||||
}
|
||||
}
|
||||
return [units, amount, gain];
|
||||
}
|
||||
|
||||
function unitsRequiredFromAmount(
|
||||
cg: CapitalGain,
|
||||
expectedAmount: number
|
||||
): [number, number, number] {
|
||||
let gain = 0;
|
||||
let amount = 0;
|
||||
let units = 0;
|
||||
const available = _.clone(cg.harvestable.harvest_breakdown);
|
||||
while (expectedAmount > amount && available.length > 0) {
|
||||
const breakdown = available.shift();
|
||||
if (breakdown.current_price < expectedAmount - amount) {
|
||||
gain += breakdown.taxable_unrealized_gain;
|
||||
units += breakdown.units;
|
||||
amount += breakdown.current_price;
|
||||
} else {
|
||||
const u = (expectedAmount - amount) / cg.harvestable.current_unit_price;
|
||||
units += u;
|
||||
amount = expectedAmount;
|
||||
gain += u * (breakdown.taxable_unrealized_gain / breakdown.units);
|
||||
}
|
||||
}
|
||||
return [units, amount, gain];
|
||||
}
|
||||
|
||||
function renderSingleBar(cg: CapitalGain) {
|
||||
const selection = d3.select(this);
|
||||
const svg = selection.append("svg");
|
||||
const harvestable = cg.harvestable;
|
||||
|
||||
const height = 20;
|
||||
const margin = { top: 20, right: 0, bottom: 20, left: 0 },
|
||||
width = selection.node().clientWidth - margin.left - margin.right,
|
||||
g = svg
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
|
||||
svg
|
||||
.attr("width", width + margin.left + margin.right)
|
||||
.attr("height", height + margin.top + margin.bottom);
|
||||
|
||||
const x = d3
|
||||
.scaleLinear()
|
||||
.range([0, width])
|
||||
.domain([0, harvestable.total_units]);
|
||||
|
||||
const non_harvestable_units =
|
||||
harvestable.total_units - harvestable.harvestable_units;
|
||||
|
||||
g.attr("data-tippy-content", () => {
|
||||
return tooltip([
|
||||
[
|
||||
["Type", "has-text-weight-bold"],
|
||||
["Units", "has-text-weight-bold has-text-right"],
|
||||
["Percentage", "has-text-weight-bold has-text-right"]
|
||||
],
|
||||
[
|
||||
"Harvestable",
|
||||
[formatFloat(harvestable.harvestable_units), "has-text-right"],
|
||||
[
|
||||
formatFloat(
|
||||
(harvestable.harvestable_units / harvestable.total_units) * 100
|
||||
),
|
||||
"has-text-right"
|
||||
]
|
||||
],
|
||||
[
|
||||
"Non Harvestable",
|
||||
[formatFloat(non_harvestable_units), "has-text-right"],
|
||||
[
|
||||
formatFloat((non_harvestable_units / harvestable.total_units) * 100),
|
||||
"has-text-right"
|
||||
]
|
||||
]
|
||||
]);
|
||||
});
|
||||
|
||||
g.selectAll("rect")
|
||||
.data([
|
||||
{ start: 0, end: harvestable.harvestable_units, color: COLORS.gainText },
|
||||
{
|
||||
start: harvestable.harvestable_units,
|
||||
end: harvestable.total_units,
|
||||
color: COLORS.tertiary
|
||||
}
|
||||
])
|
||||
.join("rect")
|
||||
.attr("fill", (d) => d.color)
|
||||
.attr("x", (d) => x(d.start))
|
||||
.attr("width", (d) => x(d.end) - x(d.start))
|
||||
.attr("y", 0)
|
||||
.attr("height", height);
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import * as d3 from "d3";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
ajax,
|
||||
Breakdown,
|
||||
depth,
|
||||
formatCurrency,
|
||||
formatFloat,
|
||||
lastName
|
||||
} from "./utils";
|
||||
|
||||
export default async function () {
|
||||
const { breakdowns: breakdowns } = await ajax("/api/ledger");
|
||||
renderBreakdowns(breakdowns);
|
||||
}
|
||||
|
||||
function renderBreakdowns(breakdowns: Breakdown[]) {
|
||||
const tbody = d3.select(".d3-postings-breakdown");
|
||||
const trs = tbody.selectAll("tr").data(Object.values(breakdowns));
|
||||
|
||||
trs.exit().remove();
|
||||
trs
|
||||
.enter()
|
||||
.append("tr")
|
||||
.merge(trs as any)
|
||||
.html((b) => {
|
||||
let changeClass = "";
|
||||
|
||||
const gain = b.market_amount + b.withdrawal_amount - b.investment_amount;
|
||||
if (gain > 0) {
|
||||
changeClass = "has-text-success";
|
||||
} else if (gain < 0) {
|
||||
changeClass = "has-text-danger";
|
||||
}
|
||||
const indent = _.repeat("  ", depth(b.group) - 1);
|
||||
return `
|
||||
<td style='max-width: 200px; overflow: hidden;'>${indent}${lastName(
|
||||
b.group
|
||||
)}</td>
|
||||
<td class='has-text-right'>${
|
||||
b.investment_amount != 0 ? formatCurrency(b.investment_amount) : ""
|
||||
}</td>
|
||||
<td class='has-text-right'>${
|
||||
b.withdrawal_amount != 0 ? formatCurrency(b.withdrawal_amount) : ""
|
||||
}</td>
|
||||
<td class='has-text-right'>${
|
||||
b.balance_units > 0 ? formatFloat(b.balance_units, 4) : ""
|
||||
}</td>
|
||||
<td class='has-text-right'>${
|
||||
b.market_amount != 0 ? formatCurrency(b.market_amount) : ""
|
||||
}</td>
|
||||
<td class='${changeClass} has-text-right'>${
|
||||
b.investment_amount != 0 && gain != 0 ? formatCurrency(gain) : ""
|
||||
}</td>
|
||||
<td class='${changeClass} has-text-right'>${
|
||||
b.xirr > 0.0001 || b.xirr < -0.0001 ? formatFloat(b.xirr) : ""
|
||||
}</td>
|
||||
`;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,210 @@
|
|||
import * as d3 from "d3";
|
||||
import legend from "d3-svg-legend";
|
||||
import dayjs from "dayjs";
|
||||
import _ from "lodash";
|
||||
import COLORS from "./colors";
|
||||
import {
|
||||
ajax,
|
||||
formatCurrency,
|
||||
formatCurrencyCrude,
|
||||
generateColorScheme,
|
||||
Income,
|
||||
Posting,
|
||||
restName,
|
||||
setHtml,
|
||||
skipTicks,
|
||||
tooltip
|
||||
} from "./utils";
|
||||
|
||||
export default async function () {
|
||||
const { income_timeline: incomes, tax_timeline: taxes } = await ajax(
|
||||
"/api/income"
|
||||
);
|
||||
_.each(incomes, (i) => (i.timestamp = dayjs(i.date)));
|
||||
renderMonthlyInvestmentTimeline(incomes);
|
||||
|
||||
const grossIncome = _.sumBy(incomes, (i) =>
|
||||
_.sumBy(i.postings, (p) => -p.amount)
|
||||
);
|
||||
|
||||
const netTax = _.sumBy(taxes, (t) => _.sumBy(t.postings, (p) => p.amount));
|
||||
|
||||
setHtml("gross-income", formatCurrency(grossIncome), COLORS.gainText);
|
||||
setHtml("net-tax", formatCurrency(netTax), COLORS.lossText);
|
||||
}
|
||||
|
||||
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: 60, 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 groupTotal = _.chain(postings)
|
||||
.groupBy((p) => restName(p.account))
|
||||
.map((postings, key) => {
|
||||
const total = _.sumBy(postings, (p) => -p.amount);
|
||||
return [key, `${key} ${formatCurrency(total)}`];
|
||||
})
|
||||
.fromPairs()
|
||||
.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, _.sumBy(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 = generateColorScheme(groupKeys);
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis x")
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(5)
|
||||
.tickFormat(skipTicks(30, x, (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));
|
||||
|
||||
const LEGEND_PADDING = 100;
|
||||
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "legendOrdinal")
|
||||
.attr("transform", `translate(${LEGEND_PADDING / 2},0)`);
|
||||
|
||||
const legendOrdinal = legend
|
||||
.legendColor()
|
||||
.shape("rect")
|
||||
.orient("horizontal")
|
||||
.shapePadding(LEGEND_PADDING)
|
||||
.labels(({ i }) => {
|
||||
return groupTotal[groupKeys[i]];
|
||||
})
|
||||
.scale(z);
|
||||
|
||||
(legendOrdinal as any).labelWrap(75); // type missing
|
||||
|
||||
svg.select(".legendOrdinal").call(legendOrdinal as any);
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
// css
|
||||
|
||||
import "clusterize.js/clusterize.css";
|
||||
import "bulma/css/bulma.css";
|
||||
import "../static/styles/custom.css";
|
||||
|
||||
import { followCursor, Instance, delegate } from "tippy.js";
|
||||
import dayjs from "dayjs";
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||
import $ from "jquery";
|
||||
import _ from "lodash";
|
||||
dayjs.extend(isSameOrBefore);
|
||||
import "tippy.js/dist/tippy.css";
|
||||
import "tippy.js/themes/light.css";
|
||||
|
||||
import allocation from "./allocation";
|
||||
import investment from "./investment";
|
||||
import holding from "./holding";
|
||||
import journal from "./journal";
|
||||
import overview from "./overview";
|
||||
import gain from "./gain";
|
||||
import income from "./income";
|
||||
import expense from "./expense";
|
||||
import price from "./price";
|
||||
import harvest from "./harvest";
|
||||
import scheduleAL from "./schedule_al";
|
||||
import doctor from "./doctor";
|
||||
|
||||
const tabs = {
|
||||
overview: _.once(overview),
|
||||
investment: _.once(investment),
|
||||
allocation: _.once(allocation),
|
||||
holding: _.once(holding),
|
||||
journal: _.once(journal),
|
||||
gain: _.once(gain),
|
||||
income: _.once(income),
|
||||
expense: _.once(expense),
|
||||
price: _.once(price),
|
||||
harvest: _.once(harvest),
|
||||
schedule_al: _.once(scheduleAL),
|
||||
doctor: _.once(doctor)
|
||||
};
|
||||
|
||||
let tippyInstances: Instance[] = [];
|
||||
|
||||
function toggleTab(id: string) {
|
||||
const ids = _.keys(tabs);
|
||||
_.each(ids, (tab) => {
|
||||
$(`section.tab-${tab}`).hide();
|
||||
});
|
||||
$(`section.tab-${id}`).show();
|
||||
$(window).scrollTop(0);
|
||||
tabs[id]().then(function () {
|
||||
tippyInstances.forEach((t) => t.destroy());
|
||||
tippyInstances = delegate(`section.tab-${id}`, {
|
||||
target: "[data-tippy-content]",
|
||||
theme: "light",
|
||||
onShow: (instance) => {
|
||||
const content = instance.reference.getAttribute("data-tippy-content");
|
||||
if (!_.isEmpty(content)) {
|
||||
instance.setContent(content);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
maxWidth: "none",
|
||||
delay: 0,
|
||||
allowHTML: true,
|
||||
followCursor: true,
|
||||
plugins: [followCursor]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$("a.navbar-item").on("click", function () {
|
||||
const id = $(this).attr("id");
|
||||
window.location.hash = id;
|
||||
toggleTab(id);
|
||||
$(".navbar-item, .navbar-link").removeClass("is-active");
|
||||
$(this).addClass("is-active");
|
||||
$(this)
|
||||
.closest(".has-dropdown.navbar-item")
|
||||
.find(".navbar-link")
|
||||
.addClass("is-active");
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!_.isEmpty(window.location.hash)) {
|
||||
$(window.location.hash).trigger("click");
|
||||
} else {
|
||||
$("#overview").trigger("click");
|
||||
}
|
||||
|
||||
$(".navbar-burger").on("click", function () {
|
||||
$(".navbar-burger").toggleClass("is-active");
|
||||
$(".navbar-menu").toggleClass("is-active");
|
||||
});
|
|
@ -0,0 +1,470 @@
|
|||
import * as d3 from "d3";
|
||||
import legend from "d3-svg-legend";
|
||||
import dayjs from "dayjs";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
ajax,
|
||||
forEachMonth,
|
||||
formatCurrency,
|
||||
formatCurrencyCrude,
|
||||
formatFloat,
|
||||
generateColorScheme,
|
||||
Posting,
|
||||
secondName,
|
||||
skipTicks,
|
||||
tooltip,
|
||||
YearlyCard
|
||||
} from "./utils";
|
||||
|
||||
export default async function () {
|
||||
const { assets: assets, yearly_cards: yearlyCards } = await ajax(
|
||||
"/api/investment"
|
||||
);
|
||||
_.each(assets, (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(assets);
|
||||
renderYearlyInvestmentTimeline(yearlyCards);
|
||||
renderYearlyCards(yearlyCards);
|
||||
}
|
||||
|
||||
function financialYear(card: YearlyCard) {
|
||||
return `${card.start_date_timestamp.format(
|
||||
"YYYY"
|
||||
)}-${card.end_date_timestamp.format("YYYY")}`;
|
||||
}
|
||||
|
||||
function renderMonthlyInvestmentTimeline(postings: Posting[]) {
|
||||
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: 60, 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 groups = _.chain(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(postings, (p) => p.timestamp)),
|
||||
end = dayjs().startOf("month");
|
||||
const ts = _.groupBy(postings, (p) => p.timestamp.format(timeFormat));
|
||||
|
||||
const points: {
|
||||
month: string;
|
||||
[key: string]: number | string | dayjs.Dayjs;
|
||||
}[] = [];
|
||||
|
||||
forEachMonth(start, end, (month) => {
|
||||
const postings = ts[month.format(timeFormat)] || [];
|
||||
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(
|
||||
{
|
||||
month: month.format(timeFormat),
|
||||
postings: 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 = generateColorScheme(groups);
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis x")
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(5)
|
||||
.tickFormat(skipTicks(30, x, (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) => [
|
||||
_.drop(p.account.split(":")).join(":"),
|
||||
[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(groups)
|
||||
.scale(z);
|
||||
|
||||
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: financialYear(card),
|
||||
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 = generateColorScheme(groups);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function renderYearlyCards(yearlyCards: YearlyCard[]) {
|
||||
const id = "#d3-yearly-investment-cards";
|
||||
const root = d3.select(id);
|
||||
|
||||
const card = root
|
||||
.selectAll("div.column")
|
||||
.data(_.reverse(yearlyCards))
|
||||
.enter()
|
||||
.append("div")
|
||||
.attr("class", "column is-4")
|
||||
.append("div")
|
||||
.attr("class", "card");
|
||||
|
||||
card
|
||||
.append("header")
|
||||
.attr("class", "card-header")
|
||||
.append("p")
|
||||
.attr("class", "card-header-title")
|
||||
.text((c) => financialYear(c));
|
||||
|
||||
card
|
||||
.append("div")
|
||||
.attr("class", "card-content p-1")
|
||||
.append("div")
|
||||
.attr("class", "content")
|
||||
.html((card) => {
|
||||
return `
|
||||
<table class="table is-narrow is-fullwidth is-size-7">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Gross Salary Income</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatCurrency(
|
||||
card.gross_salary_income
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gross Other Income</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatCurrency(
|
||||
card.gross_other_income
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tax</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatCurrency(
|
||||
card.net_tax
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Net Income</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatCurrency(
|
||||
card.net_income
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Net Expense</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatCurrency(
|
||||
card.net_expense
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Investment</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatCurrency(
|
||||
card.net_investment
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Savings Rate</td>
|
||||
<td class='has-text-right has-text-weight-bold'>${formatFloat(
|
||||
(card.net_investment / card.net_income) * 100
|
||||
)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
import * as d3 from "d3";
|
||||
import dayjs from "dayjs";
|
||||
import _ from "lodash";
|
||||
import Clusturize from "clusterize.js";
|
||||
import { ajax, formatCurrency, formatFloat, Posting } from "./utils";
|
||||
|
||||
export default async function () {
|
||||
const { postings: postings } = await ajax("/api/ledger");
|
||||
_.each(postings, (p) => (p.timestamp = dayjs(p.date)));
|
||||
|
||||
const { rows, clusterTable } = renderTransactions(postings);
|
||||
|
||||
d3.select("input.d3-posting-filter").on(
|
||||
"input",
|
||||
_.debounce((event) => {
|
||||
const text = event.srcElement.value;
|
||||
const filtered = filterTransactions(rows, text);
|
||||
clusterTable.update(_.map(filtered, (r) => r.markup));
|
||||
}, 100)
|
||||
);
|
||||
}
|
||||
|
||||
function renderTransactions(postings: Posting[]) {
|
||||
const rows = _.map(postings, (p) => {
|
||||
const purchase = formatCurrency(p.amount);
|
||||
const date = p.timestamp.format("DD MMM YYYY");
|
||||
|
||||
let market = "",
|
||||
change = "",
|
||||
changePercentage = "",
|
||||
changeClass = "",
|
||||
price = "",
|
||||
units = "";
|
||||
if (p.commodity !== "INR") {
|
||||
units = formatFloat(p.quantity, 4);
|
||||
price = formatCurrency(Math.abs(p.amount / p.quantity), 4);
|
||||
const days = dayjs().diff(p.timestamp, "days");
|
||||
if (p.quantity > 0 && days > 0) {
|
||||
market = formatCurrency(p.market_amount);
|
||||
const changeAmount = p.market_amount - p.amount;
|
||||
if (changeAmount > 0) {
|
||||
changeClass = "has-text-success";
|
||||
} else if (changeAmount < 0) {
|
||||
changeClass = "has-text-danger";
|
||||
}
|
||||
const perYear = 365 / days;
|
||||
changePercentage = formatFloat(
|
||||
(changeAmount / p.amount) * 100 * perYear
|
||||
);
|
||||
change = formatCurrency(changeAmount);
|
||||
}
|
||||
}
|
||||
const markup = `
|
||||
<tr class="${p.timestamp.month() % 2 == 0 ? "has-background-white-ter" : ""}">
|
||||
<td>${date}</td>
|
||||
<td>${p.payee}</td>
|
||||
<td>${p.account}</td>
|
||||
<td class='has-text-right'>${purchase}</td>
|
||||
<td class='has-text-right'>${units}</td>
|
||||
<td class='has-text-right'>${price}</td>
|
||||
<td class='has-text-right'>${market}</td>
|
||||
<td class='${changeClass} has-text-right'>${change}</td>
|
||||
<td class='${changeClass} has-text-right'>${changePercentage}</td>
|
||||
</tr>
|
||||
`;
|
||||
return {
|
||||
date: date,
|
||||
markup: markup,
|
||||
posting: p
|
||||
};
|
||||
});
|
||||
|
||||
const clusterTable = new Clusturize({
|
||||
rows: _.map(rows, (r) => r.markup),
|
||||
scrollId: "d3-postings-container",
|
||||
contentId: "d3-postings",
|
||||
rows_in_block: 100
|
||||
});
|
||||
|
||||
return { rows, clusterTable };
|
||||
}
|
||||
|
||||
function filterTransactions(
|
||||
rows: { date: string; posting: Posting; markup: string }[],
|
||||
filter: string
|
||||
) {
|
||||
let filterRegex = new RegExp(".*", "i");
|
||||
if (filter) {
|
||||
filterRegex = new RegExp(filter, "i");
|
||||
}
|
||||
|
||||
return _.filter(
|
||||
rows,
|
||||
(r) =>
|
||||
filterRegex.test(r.posting.account) ||
|
||||
filterRegex.test(r.posting.payee) ||
|
||||
filterRegex.test(r.date)
|
||||
);
|
||||
}
|
|
@ -0,0 +1,163 @@
|
|||
import * as d3 from "d3";
|
||||
import legend from "d3-svg-legend";
|
||||
import dayjs from "dayjs";
|
||||
import _ from "lodash";
|
||||
import COLORS from "./colors";
|
||||
import { formatCurrencyCrude, type Overview } from "./utils";
|
||||
|
||||
export function renderOverview(points: Overview[], element: Element) {
|
||||
const start = _.min(_.map(points, (p) => p.timestamp)),
|
||||
end = dayjs();
|
||||
|
||||
const svg = d3.select(element),
|
||||
margin = { top: 40, right: 80, bottom: 20, left: 40 },
|
||||
width = element.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 areaKeys = ["gain", "loss"];
|
||||
const colors = [COLORS.gain, COLORS.loss];
|
||||
const areaScale = d3.scaleOrdinal().domain(areaKeys).range(colors);
|
||||
|
||||
const lineKeys = ["networth", "investment"];
|
||||
const labels = ["Net Worth", "Net Investment"];
|
||||
const lineScale = d3
|
||||
.scaleOrdinal<string>()
|
||||
.domain(lineKeys)
|
||||
.range([COLORS.primary, COLORS.secondary]);
|
||||
|
||||
const positions = _.flatMap(points, (p) => [
|
||||
p.gain_amount + p.investment_amount - p.withdrawal_amount,
|
||||
p.investment_amount - p.withdrawal_amount
|
||||
]);
|
||||
positions.push(0);
|
||||
|
||||
const x = d3.scaleTime().range([0, width]).domain([start, end]),
|
||||
y = d3.scaleLinear().range([height, 0]).domain(d3.extent(positions)),
|
||||
z = d3.scaleOrdinal<string>(colors).domain(areaKeys);
|
||||
|
||||
const area = (y0, y1) =>
|
||||
d3
|
||||
.area<Overview>()
|
||||
.curve(d3.curveBasis)
|
||||
.x((d) => x(d.timestamp))
|
||||
.y0(y0)
|
||||
.y1(y1);
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis x")
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.call(d3.axisBottom(x));
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis y")
|
||||
.attr("transform", `translate(${width},0)`)
|
||||
.call(d3.axisRight(y).tickPadding(5).tickFormat(formatCurrencyCrude));
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "axis y")
|
||||
.call(d3.axisLeft(y).tickSize(-width).tickFormat(formatCurrencyCrude));
|
||||
|
||||
const layer = g.selectAll(".layer").data([points]).enter().append("g").attr("class", "layer");
|
||||
|
||||
const clipAboveID = _.uniqueId("clip-above");
|
||||
layer
|
||||
.append("clipPath")
|
||||
.attr("id", clipAboveID)
|
||||
.append("path")
|
||||
.attr(
|
||||
"d",
|
||||
area(height, (d) => {
|
||||
return y(d.gain_amount + d.investment_amount - d.withdrawal_amount);
|
||||
})
|
||||
);
|
||||
|
||||
const clipBelowID = _.uniqueId("clip-below");
|
||||
layer
|
||||
.append("clipPath")
|
||||
.attr("id", clipBelowID)
|
||||
.append("path")
|
||||
.attr(
|
||||
"d",
|
||||
area(0, (d) => {
|
||||
return y(d.gain_amount + d.investment_amount - d.withdrawal_amount);
|
||||
})
|
||||
);
|
||||
|
||||
layer
|
||||
.append("path")
|
||||
.attr("clip-path", `url(${new URL("#" + clipAboveID, window.location.toString())})`)
|
||||
.style("fill", z("gain"))
|
||||
.style("opacity", "0.8")
|
||||
.attr(
|
||||
"d",
|
||||
area(0, (d) => {
|
||||
return y(d.investment_amount - d.withdrawal_amount);
|
||||
})
|
||||
);
|
||||
|
||||
layer
|
||||
.append("path")
|
||||
.attr("clip-path", `url(${new URL("#" + clipBelowID, window.location.toString())})`)
|
||||
.style("fill", z("loss"))
|
||||
.style("opacity", "0.8")
|
||||
.attr(
|
||||
"d",
|
||||
area(height, (d) => {
|
||||
return y(d.investment_amount - d.withdrawal_amount);
|
||||
})
|
||||
);
|
||||
|
||||
layer
|
||||
.append("path")
|
||||
.style("stroke", lineScale("investment"))
|
||||
.style("fill", "none")
|
||||
.attr(
|
||||
"d",
|
||||
d3
|
||||
.line<Overview>()
|
||||
.curve(d3.curveBasis)
|
||||
.x((d) => x(d.timestamp))
|
||||
.y((d) => y(d.investment_amount - d.withdrawal_amount))
|
||||
);
|
||||
|
||||
layer
|
||||
.append("path")
|
||||
.style("stroke", lineScale("networth"))
|
||||
.style("fill", "none")
|
||||
.attr(
|
||||
"d",
|
||||
d3
|
||||
.line<Overview>()
|
||||
.curve(d3.curveBasis)
|
||||
.x((d) => x(d.timestamp))
|
||||
.y((d) => y(d.investment_amount + d.gain_amount - d.withdrawal_amount))
|
||||
);
|
||||
|
||||
svg.append("g").attr("class", "legendOrdinal").attr("transform", "translate(265,3)");
|
||||
|
||||
const legendOrdinal = legend
|
||||
.legendColor()
|
||||
.shape("rect")
|
||||
.orient("horizontal")
|
||||
.shapePadding(50)
|
||||
.labels(areaKeys)
|
||||
.scale(areaScale);
|
||||
|
||||
svg.select(".legendOrdinal").call(legendOrdinal as any);
|
||||
|
||||
svg.append("g").attr("class", "legendLine").attr("transform", "translate(80,3)");
|
||||
|
||||
const legendLine = legend
|
||||
.legendColor()
|
||||
.shape("rect")
|
||||
.orient("horizontal")
|
||||
.shapePadding(70)
|
||||
.labelOffset(22)
|
||||
.shapeHeight(3)
|
||||
.shapeWidth(25)
|
||||
.labels(labels)
|
||||
.scale(lineScale);
|
||||
|
||||
svg.select(".legendLine").call(legendLine as any);
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import * as d3 from "d3";
|
||||
import dayjs from "dayjs";
|
||||
import _ from "lodash";
|
||||
import { ajax, Price, formatCurrency } from "./utils";
|
||||
|
||||
export default async function () {
|
||||
const { prices: prices } = await ajax("/api/price");
|
||||
_.each(prices, (p) => (p.timestamp = dayjs(p.date)));
|
||||
renderPrices(prices);
|
||||
}
|
||||
|
||||
function renderPrices(prices: Price[]) {
|
||||
const tbody = d3.select(".d3-prices");
|
||||
const trs = tbody.selectAll("tr").data(prices);
|
||||
|
||||
trs.exit().remove();
|
||||
trs
|
||||
.enter()
|
||||
.append("tr")
|
||||
.merge(trs as any)
|
||||
.html((p) => {
|
||||
return `
|
||||
<td>${p.commodity_name}</td>
|
||||
<td>${p.commodity_type}</td>
|
||||
<td>${p.commodity_id}</td>
|
||||
<td>${p.timestamp.format("DD MMM YYYY")}</td>
|
||||
<td class='has-text-right'>${formatCurrency(p.value, 4)}</td>
|
||||
`;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import * as d3 from "d3";
|
||||
import dayjs from "dayjs";
|
||||
import { ajax, formatCurrency, ScheduleALEntry, setHtml } from "./utils";
|
||||
|
||||
export default async function () {
|
||||
const { schedule_al_entries: scheduleALEntries, date: date } = await ajax(
|
||||
"/api/schedule_al"
|
||||
);
|
||||
|
||||
setHtml("schedule-al-date", dayjs(date).format("DD MMM YYYY"));
|
||||
renderBreakdowns(scheduleALEntries);
|
||||
}
|
||||
|
||||
function renderBreakdowns(scheduleALEntries: ScheduleALEntry[]) {
|
||||
const tbody = d3.select(".d3-schedule-al");
|
||||
const trs = tbody.selectAll("tr").data(Object.values(scheduleALEntries));
|
||||
|
||||
trs.exit().remove();
|
||||
trs
|
||||
.enter()
|
||||
.append("tr")
|
||||
.merge(trs as any)
|
||||
.html((s) => {
|
||||
return `
|
||||
<td>${s.section.code}</td>
|
||||
<td>${s.section.section}</td>
|
||||
<td>${s.section.details}</td>
|
||||
<td class='has-text-right'>${formatCurrency(s.amount)}</td>
|
||||
`;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,467 @@
|
|||
import chroma from "chroma-js";
|
||||
import dayjs from "dayjs";
|
||||
import { sprintf } from "sprintf-js";
|
||||
import _ from "lodash";
|
||||
import * as d3 from "d3";
|
||||
|
||||
export interface Posting {
|
||||
id: string;
|
||||
date: string;
|
||||
payee: string;
|
||||
account: string;
|
||||
commodity: string;
|
||||
quantity: number;
|
||||
amount: number;
|
||||
market_amount: number;
|
||||
|
||||
timestamp: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
export interface Price {
|
||||
id: string;
|
||||
date: string;
|
||||
commodity_type: string;
|
||||
commodity_id: string;
|
||||
commodity_name: string;
|
||||
value: number;
|
||||
|
||||
timestamp: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
export interface Overview {
|
||||
date: string;
|
||||
investment_amount: number;
|
||||
withdrawal_amount: number;
|
||||
gain_amount: number;
|
||||
|
||||
timestamp: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
export interface Gain {
|
||||
account: string;
|
||||
overview_timeline: Overview[];
|
||||
xirr: number;
|
||||
}
|
||||
|
||||
export interface Breakdown {
|
||||
group: string;
|
||||
investment_amount: number;
|
||||
withdrawal_amount: number;
|
||||
balance_units: number;
|
||||
market_amount: number;
|
||||
xirr: number;
|
||||
}
|
||||
|
||||
export interface Aggregate {
|
||||
date: string;
|
||||
account: string;
|
||||
amount: number;
|
||||
market_amount: number;
|
||||
|
||||
timestamp: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
export interface AllocationTarget {
|
||||
name: string;
|
||||
target: number;
|
||||
current: number;
|
||||
aggregates: { [key: string]: Aggregate };
|
||||
}
|
||||
|
||||
export interface Income {
|
||||
date: string;
|
||||
postings: Posting[];
|
||||
|
||||
timestamp: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
export interface Tax {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
postings: Posting[];
|
||||
}
|
||||
|
||||
export interface YearlyCard {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
postings: Posting[];
|
||||
net_tax: number;
|
||||
gross_salary_income: number;
|
||||
gross_other_income: number;
|
||||
net_income: number;
|
||||
net_investment: number;
|
||||
net_expense: number;
|
||||
|
||||
start_date_timestamp: dayjs.Dayjs;
|
||||
end_date_timestamp: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
export interface FYCapitalGain {
|
||||
gain: number;
|
||||
units: number;
|
||||
purchase_price: number;
|
||||
sell_price: number;
|
||||
}
|
||||
export interface HarvestBreakdown {
|
||||
units: number;
|
||||
purchase_date: string;
|
||||
purchase_price: number;
|
||||
purchase_unit_price: number;
|
||||
current_price: number;
|
||||
unrealized_gain: number;
|
||||
taxable_unrealized_gain: number;
|
||||
}
|
||||
|
||||
export interface Harvestable {
|
||||
total_units: number;
|
||||
harvestable_units: number;
|
||||
unrealized_gain: number;
|
||||
taxable_unrealized_gain: number;
|
||||
current_unit_price: number;
|
||||
grandfather_unit_price: number;
|
||||
current_unit_date: string;
|
||||
harvest_breakdown: HarvestBreakdown[];
|
||||
}
|
||||
|
||||
export interface CapitalGain {
|
||||
account: string;
|
||||
tax_category: string;
|
||||
fy: { [key: string]: FYCapitalGain };
|
||||
harvestable: Harvestable;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
level: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface ScheduleALSection {
|
||||
code: string;
|
||||
section: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface ScheduleALEntry {
|
||||
section: ScheduleALSection;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export function ajax(
|
||||
route: "/api/harvest"
|
||||
): Promise<{ capital_gains: CapitalGain[] }>;
|
||||
export function ajax(route: "/api/schedule_al"): Promise<{
|
||||
schedule_al_entries: ScheduleALEntry[];
|
||||
date: string;
|
||||
}>;
|
||||
export function ajax(route: "/api/diagnosis"): Promise<{ issues: Issue[] }>;
|
||||
export function ajax(
|
||||
route: "/api/investment"
|
||||
): Promise<{ assets: Posting[]; yearly_cards: YearlyCard[] }>;
|
||||
export function ajax(
|
||||
route: "/api/ledger"
|
||||
): Promise<{ postings: Posting[]; breakdowns: Breakdown[] }>;
|
||||
export function ajax(route: "/api/price"): Promise<{ prices: Price[] }>;
|
||||
export function ajax(route: "/api/overview"): Promise<{
|
||||
overview_timeline: Overview[];
|
||||
xirr: number;
|
||||
}>;
|
||||
export function ajax(route: "/api/gain"): Promise<{
|
||||
gain_timeline_breakdown: Gain[];
|
||||
}>;
|
||||
export function ajax(route: "/api/allocation"): Promise<{
|
||||
aggregates: { [key: string]: Aggregate };
|
||||
aggregates_timeline: { [key: string]: Aggregate }[];
|
||||
allocation_targets: AllocationTarget[];
|
||||
}>;
|
||||
export function ajax(route: "/api/income"): Promise<{
|
||||
income_timeline: Income[];
|
||||
tax_timeline: Tax[];
|
||||
}>;
|
||||
export function ajax(route: "/api/expense"): Promise<{
|
||||
expenses: Posting[];
|
||||
month_wise: {
|
||||
expenses: { [key: string]: Posting[] };
|
||||
incomes: { [key: string]: Posting[] };
|
||||
investments: { [key: string]: Posting[] };
|
||||
taxes: { [key: string]: Posting[] };
|
||||
};
|
||||
}>;
|
||||
export async function ajax(route: string) {
|
||||
const response = await fetch(route);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
const obscure = false;
|
||||
|
||||
export function formatCurrency(value: number, precision = 0) {
|
||||
if (obscure) {
|
||||
return "00";
|
||||
}
|
||||
|
||||
// minus 0
|
||||
if (1 / value === -Infinity) {
|
||||
value = 0;
|
||||
}
|
||||
|
||||
return value.toLocaleString("hi", {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCurrencyCrude(value: number) {
|
||||
if (obscure) {
|
||||
return "00";
|
||||
}
|
||||
|
||||
let x = 0,
|
||||
unit = "";
|
||||
if (Math.abs(value) < 100000) {
|
||||
(x = value / 1000), (unit = "K");
|
||||
} else if (Math.abs(value) < 10000000) {
|
||||
(x = value / 100000), (unit = "L");
|
||||
} else {
|
||||
(x = value / 10000000), (unit = "C");
|
||||
}
|
||||
const precision = 2;
|
||||
return sprintf(`%.${precision}f %s`, x, unit);
|
||||
}
|
||||
|
||||
export function formatFloat(value, precision = 2) {
|
||||
if (obscure) {
|
||||
return "00";
|
||||
}
|
||||
return sprintf(`%.${precision}f`, value);
|
||||
}
|
||||
|
||||
export function formatPercentage(value, precision = 0) {
|
||||
if (obscure) {
|
||||
return "00";
|
||||
}
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
value = 0;
|
||||
}
|
||||
|
||||
// minus 0
|
||||
if (1 / value === -Infinity) {
|
||||
value = 0;
|
||||
}
|
||||
|
||||
return value.toLocaleString("hi", {
|
||||
style: "percent",
|
||||
minimumFractionDigits: precision
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFixedWidthFloat(value, width, precision = 2) {
|
||||
if (obscure) {
|
||||
value = 0;
|
||||
}
|
||||
return sprintf(`%${width}.${precision}f`, value);
|
||||
}
|
||||
|
||||
export function forEachMonth(
|
||||
start: dayjs.Dayjs,
|
||||
end: dayjs.Dayjs,
|
||||
cb: (current: dayjs.Dayjs) => any
|
||||
) {
|
||||
let current = start;
|
||||
while (current.isSameOrBefore(end, "month")) {
|
||||
cb(current);
|
||||
current = current.add(1, "month");
|
||||
}
|
||||
}
|
||||
|
||||
export function forEachYear(
|
||||
start: dayjs.Dayjs,
|
||||
end: dayjs.Dayjs,
|
||||
cb: (current: dayjs.Dayjs) => any
|
||||
) {
|
||||
let current = start;
|
||||
while (current.isSameOrBefore(end, "year")) {
|
||||
cb(current);
|
||||
current = current.add(1, "year");
|
||||
}
|
||||
}
|
||||
|
||||
export function lastName(account: string) {
|
||||
return _.last(account.split(":"));
|
||||
}
|
||||
|
||||
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(":");
|
||||
}
|
||||
|
||||
export function depth(account: string) {
|
||||
return account.split(":").length;
|
||||
}
|
||||
|
||||
export function skipTicks<Domain>(
|
||||
minWidth: number,
|
||||
scale: d3.AxisScale<Domain>,
|
||||
cb: (data: d3.AxisDomain, index: number) => string
|
||||
) {
|
||||
const range = scale.range();
|
||||
const width = Math.abs(range[1] - range[0]);
|
||||
const s = scale as any;
|
||||
const points = s.ticks ? s.ticks().length : s.domain().length;
|
||||
return function (data: d3.AxisDomain, index: number) {
|
||||
let skip = Math.round((minWidth * points) / width);
|
||||
skip = Math.max(1, skip);
|
||||
|
||||
return index % skip === 0 ? cb(data, index) : null;
|
||||
};
|
||||
}
|
||||
|
||||
export function generateColorScheme(domain: string[]) {
|
||||
let colors: string[];
|
||||
|
||||
const n = domain.length;
|
||||
if (n <= 12) {
|
||||
colors = {
|
||||
1: ["#7570b3"],
|
||||
2: ["#7fc97f", "#fdc086"],
|
||||
3: ["#66c2a5", "#fc8d62", "#8da0cb"],
|
||||
4: ["#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3"],
|
||||
5: ["#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854"],
|
||||
6: ["#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854", "#ffd92f"],
|
||||
7: [
|
||||
"#8dd3c7",
|
||||
"#ffed6f",
|
||||
"#bebada",
|
||||
"#fb8072",
|
||||
"#80b1d3",
|
||||
"#fdb462",
|
||||
"#b3de69"
|
||||
],
|
||||
8: [
|
||||
"#8dd3c7",
|
||||
"#ffed6f",
|
||||
"#bebada",
|
||||
"#fb8072",
|
||||
"#80b1d3",
|
||||
"#fdb462",
|
||||
"#b3de69",
|
||||
"#fccde5"
|
||||
],
|
||||
9: [
|
||||
"#8dd3c7",
|
||||
"#ffed6f",
|
||||
"#bebada",
|
||||
"#fb8072",
|
||||
"#80b1d3",
|
||||
"#fdb462",
|
||||
"#b3de69",
|
||||
"#fccde5",
|
||||
"#d9d9d9"
|
||||
],
|
||||
10: [
|
||||
"#8dd3c7",
|
||||
"#ffed6f",
|
||||
"#bebada",
|
||||
"#fb8072",
|
||||
"#80b1d3",
|
||||
"#fdb462",
|
||||
"#b3de69",
|
||||
"#fccde5",
|
||||
"#d9d9d9",
|
||||
"#bc80bd"
|
||||
],
|
||||
11: [
|
||||
"#8dd3c7",
|
||||
"#ffed6f",
|
||||
"#bebada",
|
||||
"#fb8072",
|
||||
"#80b1d3",
|
||||
"#fdb462",
|
||||
"#b3de69",
|
||||
"#fccde5",
|
||||
"#d9d9d9",
|
||||
"#bc80bd",
|
||||
"#ccebc5"
|
||||
],
|
||||
12: [
|
||||
"#8dd3c7",
|
||||
"#ffffb3",
|
||||
"#bebada",
|
||||
"#fb8072",
|
||||
"#80b1d3",
|
||||
"#fdb462",
|
||||
"#b3de69",
|
||||
"#fccde5",
|
||||
"#d9d9d9",
|
||||
"#bc80bd",
|
||||
"#ccebc5",
|
||||
"#ffed6f"
|
||||
]
|
||||
}[n];
|
||||
} else {
|
||||
const z = d3
|
||||
.scaleSequential()
|
||||
.domain([0, n - 1])
|
||||
.interpolator(d3.interpolateSinebow);
|
||||
colors = _.map(_.range(0, n), (n) => chroma(z(n)).desaturate(1.5).hex());
|
||||
}
|
||||
|
||||
return d3.scaleOrdinal<string>().domain(domain).range(colors);
|
||||
}
|
||||
|
||||
export function setHtml(selector: string, value: string, color?: string) {
|
||||
const node: HTMLElement = document.querySelector(".d3-" + selector);
|
||||
if (color) {
|
||||
node.style.backgroundColor = color;
|
||||
node.style.padding = "5px";
|
||||
node.style.color = "white";
|
||||
}
|
||||
node.innerHTML = value;
|
||||
}
|
||||
|
||||
export function rainbowScale(keys: string[]) {
|
||||
const x = d3
|
||||
.scaleLinear()
|
||||
.domain([0, _.size(keys) - 1])
|
||||
.range([0, 0.9]);
|
||||
return d3
|
||||
.scaleOrdinal(_.map(keys, (_value, i) => d3.interpolateRainbow(x(i))))
|
||||
.domain(keys);
|
||||
}
|
||||
|
||||
export function textColor(backgroundColor: string) {
|
||||
const color = d3.rgb(backgroundColor);
|
||||
// http://www.w3.org/TR/AERT#color-contrast
|
||||
const brightness = (color.r * 299 + color.g * 587 + color.b) / 1000;
|
||||
if (brightness > 125) {
|
||||
return "black";
|
||||
}
|
||||
return "white";
|
||||
}
|
||||
|
||||
export function tooltip(rows: Array<Array<string | [string, string]>>) {
|
||||
const trs = rows
|
||||
.map((r) => {
|
||||
const cells = r
|
||||
.map((c) => {
|
||||
if (typeof c == "string") {
|
||||
return `<td>${c}</td>`;
|
||||
} else {
|
||||
return `<td class='${c[1]}'>${c[0]}</td>`;
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `<tr>${cells}</tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
return `<table class='table is-narrow is-size-7 popup-table'><tbody>${trs}</tbody></table>`;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export const prerender = true;
|
||||
import 'bulma/css/bulma.css';
|
||||
import '../app.css';
|
|
@ -0,0 +1,44 @@
|
|||
<nav class="navbar is-light" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<span class="navbar-item is-size-4 has-text-weight-medium">₹ Paisa</span>
|
||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
<a id="overview" class="navbar-item is-active">Overview</a>
|
||||
<a id="expense" class="navbar-item">Expense</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">Assets</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a id="investment" class="navbar-item">Investment</a>
|
||||
<a id="gain" class="navbar-item">Gain</a>
|
||||
<a id="allocation" class="navbar-item">Allocation</a>
|
||||
</div>
|
||||
</div>
|
||||
<a id="income" class="navbar-item">Income</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">Ledger</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a id="holding" class="navbar-item">Holding</a>
|
||||
<a id="journal" class="navbar-item">Journal</a>
|
||||
<a id="price" class="navbar-item">Price</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">Tax</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a id="harvest" class="navbar-item">Harvest</a>
|
||||
<a id="schedule_al" class="navbar-item">Schedule AL</a>
|
||||
</div>
|
||||
</div>
|
||||
<a id="doctor" class="navbar-item">Doctor</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<slot></slot>
|
|
@ -0,0 +1,110 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ajax,
|
||||
formatCurrency,
|
||||
formatCurrencyCrude,
|
||||
formatFloat,
|
||||
Overview,
|
||||
setHtml
|
||||
} from "../lib/utils";
|
||||
import COLORS from "../lib/colors";
|
||||
import { renderOverview } from "../lib/overview";
|
||||
import _ from "lodash";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { onMount, tick } from "svelte";
|
||||
|
||||
let networth = 0;
|
||||
let investment = 0;
|
||||
let gain = 0;
|
||||
let xirr = 0;
|
||||
let loaded = false;
|
||||
let svg;
|
||||
|
||||
onMount(async () => {
|
||||
const result = await ajax("/api/overview");
|
||||
const points = result.overview_timeline;
|
||||
|
||||
_.each(points, (n) => (n.timestamp = dayjs(n.date)));
|
||||
|
||||
const current = _.last(points);
|
||||
networth = current.investment_amount + current.gain_amount - current.withdrawal_amount;
|
||||
investment = current.investment_amount - current.withdrawal_amount;
|
||||
gain = current.gain_amount;
|
||||
xirr = result.xirr;
|
||||
loaded = true;
|
||||
|
||||
renderOverview(points, svg);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loaded}
|
||||
<section class="section tab-overview">
|
||||
<div class="container">
|
||||
<nav class="level">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Net worth</p>
|
||||
<p class="title" style="background-color: {COLORS.primary};">
|
||||
{formatCurrency(networth)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Net Investment</p>
|
||||
<p class="title" style="background-color: {COLORS.secondary};">
|
||||
{formatCurrency(investment)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Gain / Loss</p>
|
||||
<p
|
||||
class="title"
|
||||
style="background-color: {gain >= 0 ? COLORS.gainText : COLORS.lossText};"
|
||||
>
|
||||
{formatCurrency(gain)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">XIRR</p>
|
||||
<p class="title has-text-black">{formatFloat(xirr)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="section tab-overview">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<svg id="d3-overview-timeline" width="100%" height="500" bind:this={svg} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Networth Timeline</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div id="d3-overview-timeline-breakdown" class="column is-12" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.level-item p.title {
|
||||
padding: 5px;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,150 @@
|
|||
:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.axis text {
|
||||
font-size: 10px;
|
||||
fill: #aaa;
|
||||
}
|
||||
|
||||
.inline-text text {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.axis.dark text {
|
||||
font-size: 12px;
|
||||
fill: #666;
|
||||
}
|
||||
|
||||
.axis path.domain, .axis line {
|
||||
fill: none;
|
||||
stroke: #ccc;
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
|
||||
.axis .tick line {
|
||||
stroke: #dedede;
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
|
||||
.legendOrdinal .label, .legendLine .label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
font-weight: normal;
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
#d3-investment-timeline .legendOrdinal .label, #d3-yearly-investment-timeline .legendOrdinal .label, #d3-income-timeline .legendOrdinal .label, #d3-breakdown .legendOrdinal .label {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.y.axis line {
|
||||
stroke: #f5f5f5;
|
||||
}
|
||||
|
||||
.y.axis path.domain {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.y.axis.keep-domain path.domain {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#d3-salary-timeline .x.axis > g.tick {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#d3-salary-timeline .x.axis > g.tick:nth-child(3n-1) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.node {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#d3-allocation-category, #d3-allocation-value {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.popup-table td {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.column, .section {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.adjustable-input {
|
||||
border: none;
|
||||
border-bottom: 1px solid #ccc;
|
||||
box-shadow: none;
|
||||
width: 80px;
|
||||
font-size: 1em !important;
|
||||
margin-left: 5px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-right: 0;
|
||||
padding-left: 5px;
|
||||
height: 1.3rem;
|
||||
}
|
||||
|
||||
|
||||
#d3-expense-timeline .cell {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoomable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.d3-calendar .days, .d3-calendar .weekdays {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-around;
|
||||
padding: 0;
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
.d3-calendar .weekdays div {
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 5px;
|
||||
flex: 14.28571%;
|
||||
margin: 0.1rem 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.d3-calendar .days .date {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 14.28571%;
|
||||
margin: 0.1rem 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 1.5rem !important;
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import adapter from "@sveltejs/adapter-static";
|
||||
import { vitePreprocess } from "@sveltejs/kit/vite";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter({ pages: "web/static", assets: "web/static", out: "web/static" })
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
|
@ -1,10 +1,13 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["web/src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.spec.ts"]
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"importsNotUsedAsValues": "preserve"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
|
||||
/** @type {import('vite').UserConfig} */
|
||||
const config = {
|
||||
plugins: [sveltekit()]
|
||||
};
|
||||
|
||||
export default config;
|
|
@ -3,27 +3,16 @@ import legend from "d3-svg-legend";
|
|||
import dayjs from "dayjs";
|
||||
import _ from "lodash";
|
||||
import COLORS from "./colors";
|
||||
import {
|
||||
ajax,
|
||||
formatCurrency,
|
||||
formatCurrencyCrude,
|
||||
formatFloat,
|
||||
Overview,
|
||||
setHtml
|
||||
} from "./utils";
|
||||
import { ajax, formatCurrency, formatCurrencyCrude, formatFloat, Overview, setHtml } from "./utils";
|
||||
|
||||
export default async function () {
|
||||
function init() {
|
||||
const { overview_timeline: points, xirr: xirr } = await ajax("/api/overview");
|
||||
_.each(points, (n) => (n.timestamp = dayjs(n.date)));
|
||||
|
||||
const current = _.last(points);
|
||||
setHtml(
|
||||
"networth",
|
||||
formatCurrency(
|
||||
current.investment_amount +
|
||||
current.gain_amount -
|
||||
current.withdrawal_amount
|
||||
),
|
||||
formatCurrency(current.investment_amount + current.gain_amount - current.withdrawal_amount),
|
||||
COLORS.primary
|
||||
);
|
||||
setHtml(
|
||||
|
@ -41,7 +30,7 @@ export default async function () {
|
|||
renderOverview(points, document.getElementById("d3-overview-timeline"));
|
||||
}
|
||||
|
||||
function renderOverview(points: Overview[], element: Element) {
|
||||
export function renderOverview(points: Overview[], element: Element) {
|
||||
const start = _.min(_.map(points, (p) => p.timestamp)),
|
||||
end = dayjs();
|
||||
|
||||
|
@ -49,9 +38,7 @@ function renderOverview(points: Overview[], element: Element) {
|
|||
margin = { top: 40, right: 80, bottom: 20, left: 40 },
|
||||
width = element.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 + ")");
|
||||
g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
|
||||
const areaKeys = ["gain", "loss"];
|
||||
const colors = [COLORS.gain, COLORS.loss];
|
||||
|
@ -96,12 +83,7 @@ function renderOverview(points: Overview[], element: Element) {
|
|||
.attr("class", "axis y")
|
||||
.call(d3.axisLeft(y).tickSize(-width).tickFormat(formatCurrencyCrude));
|
||||
|
||||
const layer = g
|
||||
.selectAll(".layer")
|
||||
.data([points])
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "layer");
|
||||
const layer = g.selectAll(".layer").data([points]).enter().append("g").attr("class", "layer");
|
||||
|
||||
const clipAboveID = _.uniqueId("clip-above");
|
||||
layer
|
||||
|
@ -129,10 +111,7 @@ function renderOverview(points: Overview[], element: Element) {
|
|||
|
||||
layer
|
||||
.append("path")
|
||||
.attr(
|
||||
"clip-path",
|
||||
`url(${new URL("#" + clipAboveID, window.location.toString())})`
|
||||
)
|
||||
.attr("clip-path", `url(${new URL("#" + clipAboveID, window.location.toString())})`)
|
||||
.style("fill", z("gain"))
|
||||
.style("opacity", "0.8")
|
||||
.attr(
|
||||
|
@ -144,10 +123,7 @@ function renderOverview(points: Overview[], element: Element) {
|
|||
|
||||
layer
|
||||
.append("path")
|
||||
.attr(
|
||||
"clip-path",
|
||||
`url(${new URL("#" + clipBelowID, window.location.toString())})`
|
||||
)
|
||||
.attr("clip-path", `url(${new URL("#" + clipBelowID, window.location.toString())})`)
|
||||
.style("fill", z("loss"))
|
||||
.style("opacity", "0.8")
|
||||
.attr(
|
||||
|
@ -183,10 +159,7 @@ function renderOverview(points: Overview[], element: Element) {
|
|||
.y((d) => y(d.investment_amount + d.gain_amount - d.withdrawal_amount))
|
||||
);
|
||||
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "legendOrdinal")
|
||||
.attr("transform", "translate(265,3)");
|
||||
svg.append("g").attr("class", "legendOrdinal").attr("transform", "translate(265,3)");
|
||||
|
||||
const legendOrdinal = legend
|
||||
.legendColor()
|
||||
|
@ -198,10 +171,7 @@ function renderOverview(points: Overview[], element: Element) {
|
|||
|
||||
svg.select(".legendOrdinal").call(legendOrdinal as any);
|
||||
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "legendLine")
|
||||
.attr("transform", "translate(80,3)");
|
||||
svg.append("g").attr("class", "legendLine").attr("transform", "translate(80,3)");
|
||||
|
||||
const legendLine = legend
|
||||
.legendColor()
|
||||
|
|
|
@ -1,516 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="static/dist.css" type="text/css"/>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>₹</text></svg>">
|
||||
<title>Paisa</title>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-light" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<span class="navbar-item is-size-4 has-text-weight-medium">₹ Paisa</span>
|
||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
<a id="overview" class="navbar-item is-active">Overview</a>
|
||||
<a id="expense" class="navbar-item">Expense</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">Assets</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a id="investment" class="navbar-item">Investment</a>
|
||||
<a id="gain" class="navbar-item">Gain</a>
|
||||
<a id="allocation" class="navbar-item">Allocation</a>
|
||||
</div>
|
||||
</div>
|
||||
<a id="income" class="navbar-item">Income</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">Ledger</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a id="holding" class="navbar-item">Holding</a>
|
||||
<a id="journal" class="navbar-item">Journal</a>
|
||||
<a id="price" class="navbar-item">Price</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">Tax</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a id="harvest" class="navbar-item">Harvest</a>
|
||||
<a id="schedule_al" class="navbar-item">Schedule AL</a>
|
||||
</div>
|
||||
</div>
|
||||
<a id="doctor" class="navbar-item">Doctor</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="section tab-overview">
|
||||
<div class="container">
|
||||
<nav class="level">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Net worth</p>
|
||||
<p class="d3-networth title"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Net Investment</p>
|
||||
<p class="d3-investment title"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Gain / Loss</p>
|
||||
<p class="d3-gains title"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">XIRR</p>
|
||||
<p class="d3-xirr title"></p>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-overview">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<svg id="d3-overview-timeline" width="100%" height="500"></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Networth Timeline</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div id="d3-overview-timeline-breakdown" class="column is-12">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-investment">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<svg id="d3-investment-timeline" width="100%" height="500"></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Monthly Investment Timeline</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-investment">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns is-flex-wrap-wrap">
|
||||
<div class="column is-full-tablet is-half-fullhd">
|
||||
<div class="p-3">
|
||||
<svg id="d3-yearly-investment-timeline" width="100%"></svg>
|
||||
</div>
|
||||
<div class="p-3 has-text-centered">
|
||||
<p class="heading">Financial Year Investment Timeline</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-full-tablet is-half-fullhd">
|
||||
<div class="columns is-flex-wrap-wrap" id="d3-yearly-investment-cards">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-gain">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<svg id="d3-gain-legend" width="100%" height="50"></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<svg id="d3-gain-overview" width="100%"></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Gain Overview</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-gain">
|
||||
<div class="container is-fluid d3-gain-timeline-breakdown">
|
||||
<div class="columns">
|
||||
<div id="d3-gain-timeline-breakdown" class="column is-12">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-income">
|
||||
<div class="container">
|
||||
<nav class="level">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Gross Income</p>
|
||||
<p class="d3-gross-income title"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Net Tax</p>
|
||||
<p class="d3-net-tax title"></p>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</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">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div id="d3-allocation-target-treemap" style="width: 100%; position: relative"></div>
|
||||
<svg id="d3-allocation-target" width="100%"></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Allocation Targets</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-allocation">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div id="d3-allocation-category" width="100%" style="height: 500px" height="500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Allocation by category</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-allocation">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div id="d3-allocation-value" width="100%" style="height: 300px" height="300"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Allocation by value</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-allocation">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<svg id="d3-allocation-timeline" width="100%" height="300"></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Allocation Timeline</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-holding">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<p class="subtitle is-12">
|
||||
Holdings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<table class="table is-narrow is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th class='has-text-right'>Investment Amount</th>
|
||||
<th class='has-text-right'>Withdrawal Amount</th>
|
||||
<th class='has-text-right'>Balance Units</th>
|
||||
<th class='has-text-right'>Market Value</th>
|
||||
<th class='has-text-right'>Change</th>
|
||||
<th class='has-text-right'>XIRR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="d3-postings-breakdown">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-journal">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<nav class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<p class="subtitle is-5">
|
||||
Postings
|
||||
</p>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input class="d3-posting-filter input" style="width: 440px" type="text" placeholder="filter by account or description or date">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12 clusterize-scroll" id="d3-postings-container" style="max-height: calc(100vh - 200px); min-height: calc(100vh - 200px)">
|
||||
<table class="table is-narrow is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Account</th>
|
||||
<th class='has-text-right'>Amount</th>
|
||||
<th class='has-text-right'>Units</th>
|
||||
<th class='has-text-right'>Unit Price</th>
|
||||
<th class='has-text-right'>Market Value</th>
|
||||
<th class='has-text-right'>Change</th>
|
||||
<th class='has-text-right'>CAGR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="d3-postings clusterize-content" id="d3-postings">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-price">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<p class="subtitle is-12">
|
||||
Prices
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<table class="table is-narrow is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Commodity Name</th>
|
||||
<th>Commodity Type</th>
|
||||
<th>Commodity ID</th>
|
||||
<th>Date</th>
|
||||
<th class='has-text-right'>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="d3-prices">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-expense">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns is-flex-wrap-wrap">
|
||||
<div class="column is-3">
|
||||
<div class="columns is-flex-wrap-wrap">
|
||||
<div class="column is-full">
|
||||
<div class="p-3">
|
||||
<nav class="level">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Income</p>
|
||||
<p class="d3-current-month-income title"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Tax</p>
|
||||
<p class="d3-current-month-tax title"></p>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-full">
|
||||
<div class="p-3">
|
||||
<nav class="level">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading"><span>Net Investment</span><span title="Savings Rate" class="tag ml-2 has-text-weight-semibold d3-current-month-savings-rate"></span></p>
|
||||
<p class="d3-current-month-investment title"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Expenses</p>
|
||||
<p class="d3-current-month-expenses title"></p>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="p-3">
|
||||
<div id="d3-current-month-expense-calendar" class="d3-calendar">
|
||||
<div class="has-text-centered py-1">
|
||||
<input style="width: 175px" class="input is-medium is-size-6" required type="month" id="d3-current-month">
|
||||
</div>
|
||||
<div class="weekdays">
|
||||
<div>Sun</div>
|
||||
<div>Mon</div>
|
||||
<div>Tue</div>
|
||||
<div>Wed</div>
|
||||
<div>Thu</div>
|
||||
<div>Fri</div>
|
||||
<div>Sat</div>
|
||||
</div>
|
||||
<div class="days">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-full-tablet is-half-fullhd">
|
||||
<div class="p-3">
|
||||
<svg id="d3-current-month-breakdown" width="100%"></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-expense">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<svg id="d3-expense-timeline" width="100%" height="500"></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Monthly Expenses</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-harvest">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Harvestables</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-flex-wrap-wrap" id="d3-harvestables">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-schedule_al">
|
||||
<div class="container is-fluid">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<p class="subtitle is-12">
|
||||
Schedule AL as on <span class="d3-schedule-al-date has-text-weight-bold"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-12">
|
||||
<table class="table is-narrow is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Section</th>
|
||||
<th>Details</th>
|
||||
<th class='has-text-right'>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="d3-schedule-al">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section tab-doctor">
|
||||
<div class="container is-fluid">
|
||||
<div class="columns">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div>
|
||||
We have found <b class="d3-diagnosis-count"></b> potential issue(s).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-flex-wrap-wrap" id="d3-diagnosis">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script src="static/dist.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -4,7 +4,7 @@ import (
|
|||
"embed"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
//go:embed all:static/*
|
||||
var Static embed.FS
|
||||
|
||||
//go:embed static/index.html
|
||||
|
|
Loading…
Reference in New Issue