switch overview to svelte

This commit is contained in:
Anantha Kumaran 2022-12-24 18:35:07 +05:30
parent f996cd1486
commit 19948a8579
38 changed files with 11822 additions and 5835 deletions

13
.eslintignore Normal file
View File

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

20
.eslintrc.cjs Normal file
View File

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

View File

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

13
.gitignore vendored
View File

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

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

13
.prettierignore Normal file
View File

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

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

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

12881
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

9
src/app.d.ts vendored Normal file
View File

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

13
src/app.html Normal file
View File

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

458
src/lib/allocation.ts Normal file
View File

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

12
src/lib/colors.ts Normal file
View File

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

47
src/lib/doctor.ts Normal file
View File

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

650
src/lib/expense.ts Normal file
View File

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

605
src/lib/gain.ts Normal file
View File

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

322
src/lib/harvest.ts Normal file
View File

@ -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&nbsp;");
const unitsSpan = self.append("span").text(formatFloat(units));
self.append("span").html("&nbsp;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("&nbsp; 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);
}

60
src/lib/holding.ts Normal file
View File

@ -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("&emsp;&emsp;", 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>
`;
});
}

210
src/lib/income.ts Normal file
View File

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

97
src/lib/index.ts Normal file
View File

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

470
src/lib/investment.ts Normal file
View File

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

99
src/lib/journal.ts Normal file
View File

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

163
src/lib/overview.ts Normal file
View File

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

30
src/lib/price.ts Normal file
View File

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

31
src/lib/schedule_al.ts Normal file
View File

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

467
src/lib/utils.ts Normal file
View File

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

3
src/routes/+layout.js Normal file
View File

@ -0,0 +1,3 @@
export const prerender = true;
import 'bulma/css/bulma.css';
import '../app.css';

44
src/routes/+layout.svelte Normal file
View File

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

110
src/routes/+page.svelte Normal file
View File

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

150
static/styles/custom.css Normal file
View File

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

15
svelte.config.js Normal file
View File

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

View File

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

8
vite.config.js Normal file
View File

@ -0,0 +1,8 @@
import { sveltekit } from "@sveltejs/kit/vite";
/** @type {import('vite').UserConfig} */
const config = {
plugins: [sveltekit()]
};
export default config;

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import (
"embed"
)
//go:embed static/*
//go:embed all:static/*
var Static embed.FS
//go:embed static/index.html