Created
December 22, 2018 16:04
-
-
Save kimble/fb6e99070d9ae9ffab2994029f8f8a98 to your computer and use it in GitHub Desktop.
Zoomable graph for monner.no investment page
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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