Skip to content

Instantly share code, notes, and snippets.

@kimble
Created December 22, 2018 16:04
Show Gist options
  • Save kimble/fb6e99070d9ae9ffab2994029f8f8a98 to your computer and use it in GitHub Desktop.
Save kimble/fb6e99070d9ae9ffab2994029f8f8a98 to your computer and use it in GitHub Desktop.
Zoomable graph for monner.no investment page
// ==UserScript==
// @name Monner
// @namespace http://tampermonkey.net/
// @version 0.2
// @description Add a zoomable graph to monners investment page
// @author Kim A. Betti
// @match https://www.monner.no/investeringer
// @require https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js
// @grant none
// ==/UserScript==
// Nice for development to avoid making too many requests
const useCache = false;
const getJSON = async (uri) => {
return fetch(uri)
.then(function(response) {
return response.json();
})
.catch(err => {
console.error("Error while fetching: " + uri, err);
});
};
const formattedMoneyToOre = (formatted) => {
const f = formatted.indexOf(',') > 0 ? 1 : 100;
return parseInt(formatted.replace(/[^\d]/g, ''), 10) * f;
};
const formattedDateToDate = (formatted) => {
const p = formatted.split(".");
return new Date(parseInt(p[2]), parseInt(p[1])-1, parseInt(p[0]));
};
const fetchAllPaymentDocuments = async () => {
console.log("Fetching documents from web");
const summary = await getJSON('https://www.monner.no/api/investorsInvestments?investorId=null');
const allInvestments = [...summary.loanData.investments, ...summary.loanData.closedInvestments];
const paymentDocumentsUris = allInvestments.map(inv => "/api/backpayment/?id=" + inv.coreInfo.id);
return await Promise.all(allInvestments.map(inv => {
return getJSON("/api/backpayment/?id=" + inv.coreInfo.id)
.then(pay => {
return {
investment: inv,
payments: pay
};
});
}));
};
const getAllPaymentDocuments = async () => {
const cachedDocs = sessionStorage.getItem("docs");
if (useCache && cachedDocs !== null) {
console.log("Returning documents from cache");
return JSON.parse(cachedDocs);
}
else {
const docs = await fetchAllPaymentDocuments();
sessionStorage.setItem("docs", JSON.stringify(docs));
return docs;
}
};
/**
* Convert the stuff fetched from monner.no
* to something we can work with..
*/
const mapMonnerDocument = (doc) => {
return {
business: doc.investment.coreInfo.businessName,
title: doc.investment.coreInfo.title,
date: formattedDateToDate(doc.investment.dispursmentDate),
loan: formattedMoneyToOre(doc.investment.myLoanAmount),
plan: doc.payments.backpayments.map((bp) => {
return {
date: formattedDateToDate(bp.date),
payed: bp.receiptDocumentLink != null,
amount: formattedMoneyToOre(bp.amount),
principal: formattedMoneyToOre(bp.principal),
interests: formattedMoneyToOre(bp.interests),
fees: formattedMoneyToOre(bp.fees)
};
})
};
};
const createPaymentSummary = (payments) => {
return {
amount: d3.sum(payments, (p) => p.amount),
principal: d3.sum(payments, (p) => p.principal),
interests: d3.sum(payments, (p) => p.interests),
fees: d3.sum(payments, (p) => p.fees)
};
};
const createDateInterval = (startDate, endDate) => {
let dates = [],
currentDate = startDate,
addDays = function(days) {
var date = new Date(this.valueOf());
date.setDate(date.getDate() + days);
return date;
};
while (currentDate <= endDate) {
dates.push(currentDate);
currentDate = addDays.call(currentDate, 1);
}
return dates;
};
const createCumulativeSummary = (events) => {
const cumulative = [];
const zero = () => {
return { payed: 0, remaining: 0 };
};
let state = {
date: null,
interests: 0,
amount: 0,
fees: 0
};
const copyOfState = () => Object.assign({}, state);
const firstDate = events[0].date;
const lastDate = events[events.length-1].date;
const firstEvent = copyOfState();
firstEvent.date = firstDate;
cumulative.push(firstEvent);
createDateInterval(firstDate, lastDate).forEach(date => {
const eventsForDay = events.filter(e => e.date.getTime() === date.getTime());
const newState = copyOfState();
newState.date = date;
eventsForDay.forEach(e => {
switch (e.type) {
case "new-loan":
newState.amount += e.loan;
break;
case "payback":
newState.amount -= e.principal;
newState.interests += e.interests;
newState.fees += e.fees;
break;
}
});
cumulative.push(newState);
state = newState;
});
return cumulative;
};
const createEvents = (data) => {
const events = [];
let id = 0;
data.forEach(d => {
events.push({
id: ++id,
type: 'new-loan',
title: 'Nytt lån',
subTitle: d.business + " - " + d.title,
date: d.date,
business: d.business,
loanTitle: d.title,
loan: d.loan
});
d.plan.forEach((p, i) => {
events.push({
id: ++id,
type: 'payback',
title: 'Tilbakebetaling',
subTitle: 'Fra ' + d.business,
date: p.date,
payed: p.payed,
business: d.business,
loanTitle: d.title,
amount: p.amount,
interests: p.interests,
principal: p.principal,
fees: p.fees
});
if (i === d.plan.length -1) {
events.push({
id: ++id,
type: 'loan-payed',
title: 'Lån tilbakebetalt',
subTitle: d.business,
date: p.date,
business: d.business,
loanTitle: d.title,
loan: d.loan
});
}
});
});
events.sort((a, b) => {
return a.date>b.date ? 1 : a.date<b.date ? -1 : 0;
});
return events;
};
/**
* Extract useful summary information from our data
*/
const createDataSummary = (data) => {
const allPayments = [];
data.map(d => d.plan).forEach(p => allPayments.push(...p));
allPayments.sort((a, b) => {
return a.date>b.date ? 1 : a.date<b.date ? -1 : 0;
});
const paymentsGroupedByDate = [];
let currentDate = null;
allPayments.forEach(p => {
if (currentDate == null || currentDate.date.getTime() != p.date.getTime()) {
currentDate = {
date: p.date,
payments: []
};
paymentsGroupedByDate.push(currentDate);
}
currentDate.payments.push(p);
});
paymentsGroupedByDate.forEach(atDate => {
atDate.summary = createPaymentSummary(atDate.payments);
});
const firstInvestment = d3.min(data, (d) => d.date);
const lastPayment = d3.max(allPayments, (p) => p.date);
const payedPayments = allPayments.filter(p => p.payed);
const remainingPayments = allPayments.filter(p => !p.payed);
return {
firstInvestment: firstInvestment,
lastPayment: lastPayment,
sortedPayments: allPayments,
paymentsGroupedByDate, paymentsGroupedByDate,
maxAmountForDay: d3.max(paymentsGroupedByDate, (d) => d.summary.amount),
total: createPaymentSummary(allPayments),
payed: createPaymentSummary(payedPayments),
remaining: createPaymentSummary(remainingPayments)
}
};
const insertContainer = () => {
const summary = document.querySelectorAll(".investments-summary")[0];
const container = document.createElement("div");
container.style.marginBottom = "3em";
summary.after(container);
return container;
};
const translate = (x, y) => "translate(" + x + ", " + y + ")";
const formatOrerAsReadableKroner = (orer) => {
const kr = orer / 100;
return kr.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ") + " kr";
};
const createEventTablePrinter = (container) => {
const div = document.createElement("div");
const table = document.createElement("table");
div.appendChild(table);
container.appendChild(div);
div.classList.add("investments-summary");
div.style.width = "1000px";
const body = d3.select(table).append("tbody");
const foot = d3.select(table).append("tfoot").append("tr");
const sumLoan = foot.append("th").attr("class", "entry");
const sumPrincipal = foot.append("th").attr("class", "entry");
const sumInterests = foot.append("th").attr("class", "entry");
const sumPayed = foot.append("th").attr("class", "entry");
const sumFees = foot.append("th").attr("class", "entry");
return (events) => {
sumLoan.html("<div class=type>Investert</div><div class=value>" + formatOrerAsReadableKroner(d3.sum(events.filter((d) => d.type === "new-loan"), (e) => e.loan)) + "</div>");
const paybackEvents = events.filter((d) => d.type === "payback");
const interests = d3.sum(paybackEvents, (e) => e.interests);
const principal = d3.sum(paybackEvents, (e) => e.principal);
const payed = interests + principal;
const fees = d3.sum(paybackEvents, (e) => e.fees);
sumInterests.html("<div class=type>Renter</div><div class=value>" + formatOrerAsReadableKroner(interests) + "</div>");
sumPrincipal.html("<div class=type>Avdrag</div><div class=value>" + formatOrerAsReadableKroner(principal) + "</div>");
sumPayed.html("<div class=type>Utbetalt</div><div class=value>" + formatOrerAsReadableKroner(payed) + "</div>");
sumFees.html("<div class=type>Gebyr</div><div class=value>"+ formatOrerAsReadableKroner(fees) + "</div>");
};
};
const createChart = (container, data, summary, events, cumulative, tablePrinter) => {
const m = {
top: 20,
right: 20,
bottom: 20,
left: 60
};
const dim = {
w: 1000,
h: 600
};
const today = new Date();
const x = d3.scaleTime()
.domain([ summary.firstInvestment, summary.lastPayment ])
.range([0, dim.w - m.left - m.right]);
const y = d3.scaleLinear()
.domain([ d3.max(cumulative, (d) => d.amount), 0 ])
.range([0, dim.h - m.top - m.bottom]);
const zoom = d3.zoom()
.scaleExtent([1, 40])
.translateExtent([[-100, -100], [dim.w + 90, dim.h + 100]])
.on("zoom", zoomed);
const createLine = (accessor) => {
return d3.line()
.x(d => x(d.date))
.y(d => y(accessor(d)));
};
const svg = d3.select(container)
.append("svg")
.attr("width", dim.w)
.attr("height", dim.h);
// X-axis
const xAxis = d3.axisBottom(x).ticks(10);
const gX = svg.append("g")
.attr("transform", translate(m.left, dim.h - m.top))
.call(xAxis);
// Y-axis
const yAxis = d3.axisRight(y)
.ticks(10)
.tickSize(dim.w-m.left-m.right)
.tickFormat((d) => (d / 100000) + " k");
function customYAxis(g) {
const gY = g.call(yAxis);
g.select(".domain").remove();
g.selectAll(".tick:not(:last-of-type) line").attr("stroke", "#bbb").attr("stroke-dasharray", "2,2");
g.selectAll(".tick text").attr("x", -30).attr("dy", 4);
return gY;
}
const gY = svg.append("g")
.attr("transform", translate(m.left, m.top))
.call(customYAxis);
const graphs = svg.append("g");
// Today
const todayLine = svg.append("g")
.attr("transform", translate(m.left, m.top/2))
.append("line")
.style("stroke", "#555")
.style("stroke-width", "1.5")
.style("stroke-dasharray", "6,1")
.attr("x1", x(today))
.attr("x2", x(today))
.attr("y1", 0)
.attr("y2", y(0) + (m.top/2));
// Path - Amount
const amountPath = graphs.append("g")
.attr("transform", translate(m.left, m.top))
.append("path")
.data([cumulative])
.style("fill", "none")
.style("stroke", "steelblue")
.style("stroke-width", "2")
.style("shape-rendering", "geometricPrecision")
.attr("d", createLine((d) => d.amount));
// Path - Interests
const interestsPath = graphs.append("g")
.attr("transform", translate(m.left, m.top))
.append("path")
.data([cumulative])
.style("fill", "none")
.style("stroke", "darkseagreen")
.style("stroke-width", "2")
.attr("d", createLine((d) => d.interests));
// Path - Fees
const feesPath = graphs.append("g")
.attr("transform", translate(m.left, m.top))
.append("path")
.data([cumulative])
.style("fill", "none")
.style("stroke", "indianred")
.style("stroke-width", "1")
.attr("d", createLine((d) => d.fees));
const createNewLineFunction = (x, accessor) => {
const r = x.range();
return d3.line()
.defined((d) => {
const dx = x(d.date);
return dx < r[1] && dx > r[0];
})
.x(d => x(d.date))
.y(d => y(accessor(d)));
};
const updateLine = (path, x, dataAccessor) => {
const newFunction = createNewLineFunction(x, dataAccessor);
path.attr("d", newFunction);
};
function zoomed() {
const t = d3.event.transform;
// Update x-axis
const updatedX = t.rescaleX(x);
xAxis.scale(updatedX);
gX.call(xAxis);
// Update lines
updateLine(amountPath, updatedX, (d) => d.amount);
updateLine(interestsPath, updatedX, (d) => d.interests);
updateLine(feesPath, updatedX, (d) => d.fees);
// Update today
todayLine.attr("x1", updatedX(today)).attr("x2", updatedX(today));
// Relevant events
const updatedDomain = updatedX.domain();
const relevantEvents = events.filter(e => e.date >= updatedDomain[0] && e.date <= updatedDomain[1]);
tablePrinter(relevantEvents);
}
svg.call(zoom);
};
(async function() {
'use strict';
const documents = await getAllPaymentDocuments();
const data = documents.map(d => mapMonnerDocument(d))
const events = createEvents(data);
const summary = createDataSummary(data);
const cumulative = createCumulativeSummary(events);
console.log("Investments: ", documents);
console.log("Data: ", data);
console.log("Summary: ", summary);
console.log("Events: ", events);
console.log("Cumulative: ", cumulative);
const container = insertContainer();
const tablePrinter = createEventTablePrinter(container);
createChart(container, data, summary, events, cumulative, tablePrinter);
tablePrinter(events);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment