Skip to content

Commit

Permalink
support shared JavaScript utility functions (inline in dashboards.yam…
Browse files Browse the repository at this point in the history
…l or in an external file) (#15)

* load optional shared JavaScript helper functions from dashboards.js

Resolves: #14

* improve file ext substitution method

* introduce utils script

* delete unused import

* formatting
  • Loading branch information
andreasgerstmayr committed Mar 11, 2024
1 parent ee2d6a7 commit 2da5679
Show file tree
Hide file tree
Showing 10 changed files with 155 additions and 66 deletions.
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
},
"customizations": {
"vscode": {
"extensions": ["ms-python.python", "Lencerf.beancount"]
"extensions": ["editorconfig.editorconfig", "ms-python.python", "Lencerf.beancount"]
}
},
"postCreateCommand": "make deps",
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The location of the `dashboards.yaml` configuration file can be customized:
}"
```

Please take a look at the example dashboard configuration [dashboards.yaml](example/dashboards.yaml), which uses most of the functionality described below.
Please take a look at the example dashboards configuration [dashboards.yaml](example/dashboards.yaml), which uses most of the functionality described below.

The configuration file can contain multiple dashboards, and a dashboard contains one or more panels.
A panel has a relative width (e.g. `50%` for 2 columns, or `33.3%` for 3 column layouts) and a absolute height.
Expand All @@ -42,6 +42,8 @@ It can contain Jinja template syntax to access the `panel` and `ledger` variable
The query results can be accessed via `panel.queries[i].result`, where `i` is the index of the query in the `queries` field.
Note: Additionally to the Beancount query, Fava's filter bar further filters the available entries of the ledger.

Common code for utility functions can be defined in the dashboards configuration file, either inline in `utils.inline` or in an external file defined in `utils.path`.

**HTML, echarts and d3-sankey panels:**
The `script` field must contain valid JavaScript code.
It must return a valid configuration depending on the panel `type`.
Expand All @@ -52,9 +54,7 @@ The following variables and functions are available:
* `ledger.operatingCurrencies`: configured operating currencies of the ledger
* `ledger.ccy`: shortcut for the first configured operating currency of the ledger
* `ledger.commodities`: declared commodities of the ledger
* `helpers.iterateMonths(dateFirst, dateLast)`: iterate over all months between `dateFirst` and `dateLast`, e.g. `[{year: 2022, month: 1}, {year: 2022, month: 2}, ...]`
* `helpers.iterateYears(dateFirst, dateLast)`: iterate over all years between `dateFirst` and `dateLast`, e.g. `[2022, 2023, ...]`
* `helpers.buildAccountTree(rows, valueFn, [nameFn])`: build an account tree based on the results of a BQL query
* `utils`: the return value of the `utils` code of the dashboard configuration

**Jinja2 panels:**
The `template` field must contain valid Jinja2 template code.
Expand Down
76 changes: 65 additions & 11 deletions example/dashboards.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ dashboards:
currency: ledger.ccy,
maximumFractionDigits: 0,
});
const months = helpers.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`);
const months = utils.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`);

// the beancount query only returns months where there was at least one matching transaction, therefore we group by month
const amounts = {};
Expand Down Expand Up @@ -193,7 +193,7 @@ dashboards:
currency: ledger.ccy,
maximumFractionDigits: 0,
});
const months = helpers.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`);
const months = utils.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`);
const amounts = {};
// the beancount query only returns months where there was at least one matching transaction, therefore we group by month
Expand Down Expand Up @@ -245,7 +245,7 @@ dashboards:
currency: ledger.ccy,
maximumFractionDigits: 0,
});
const months = helpers.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`);
const months = utils.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`);
const amounts = {};
// the beancount query only returns months where there was at least one matching transaction, therefore we group by month
Expand Down Expand Up @@ -306,7 +306,7 @@ dashboards:
currency: ledger.ccy,
maximumFractionDigits: 0,
});
const months = helpers.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`);
const months = utils.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`);
const amounts = {};
// the beancount query only returns months where there was at least one matching transaction, therefore we group by month
Expand Down Expand Up @@ -562,7 +562,7 @@ dashboards:
});
const days = (new Date(ledger.dateLast) - new Date(ledger.dateFirst)) / (1000 * 60 * 60 * 24) + 1;
const divisor = days / (365 / 12);
const accountTree = helpers.buildAccountTree(
const accountTree = utils.buildAccountTree(
panel.queries[0].result,
(row) => -row.value[ledger.ccy] / divisor,
(parts, i) => parts[i],
Expand Down Expand Up @@ -611,7 +611,7 @@ dashboards:
});
const days = (new Date(ledger.dateLast) - new Date(ledger.dateFirst)) / (1000 * 60 * 60 * 24) + 1;
const divisor = days / (365 / 12);
const accountTree = helpers.buildAccountTree(
const accountTree = utils.buildAccountTree(
panel.queries[0].result,
(row) => row.value[ledger.ccy] / divisor,
(parts, i) => parts[i],
Expand Down Expand Up @@ -679,7 +679,7 @@ dashboards:
currency: ledger.ccy,
maximumFractionDigits: 0,
});
const months = helpers.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`);
const months = utils.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`);
const amounts = {};
// the beancount query only returns months where there was at least one matching transaction, therefore we group by month
Expand Down Expand Up @@ -730,7 +730,7 @@ dashboards:
currency: ledger.ccy,
maximumFractionDigits: 0,
});
const years = helpers.iterateYears(ledger.dateFirst, ledger.dateLast);
const years = utils.iterateYears(ledger.dateFirst, ledger.dateLast);
const maxAccounts = 7; // number of accounts to show, sorted by sum

const accountSums = {};
Expand Down Expand Up @@ -824,7 +824,7 @@ dashboards:
currency: ledger.ccy,
maximumFractionDigits: 0,
});
const years = helpers.iterateYears(ledger.dateFirst, ledger.dateLast);
const years = utils.iterateYears(ledger.dateFirst, ledger.dateLast);
const amounts = {};
// the beancount query only returns months where there was at least one matching transaction, therefore we group by year
Expand Down Expand Up @@ -971,7 +971,7 @@ dashboards:
}
}
const accountTree = helpers.buildAccountTree(panel.queries[0].result, (row) => row.value[ledger.ccy]);
const accountTree = utils.buildAccountTree(panel.queries[0].result, (row) => row.value[ledger.ccy]);
addNode(accountTree.children[0]);
addNode(accountTree.children[1]);
Expand Down Expand Up @@ -1052,7 +1052,7 @@ dashboards:
const dateLastMonth = dateLast.getMonth() + 1;
const dateFirstStr = `${dateFirst.getFullYear()}-${dateFirst.getMonth() + 1}-1`;
const dateProjectUntilStr = `${dateLastYear + projectYears}-${dateLastMonth}-1`;
const months = helpers.iterateMonths(dateFirstStr, dateProjectUntilStr).map((m) => `${m.month}/${m.year}`);
const months = utils.iterateMonths(dateFirstStr, dateProjectUntilStr).map((m) => `${m.month}/${m.year}`);
const lastMonthIdx = months.findIndex((m) => m === `${dateLastMonth}/${dateLastYear}`);
const projection = [];
Expand Down Expand Up @@ -1112,3 +1112,57 @@ dashboards:
window.open(link);
},
};
utils:
inline: |
function iterateMonths(dateFirst, dateLast) {
const months = [];
let [year, month] = dateFirst.split("-").map((x) => parseInt(x));
let [lastYear, lastMonth] = dateLast.split("-").map((x) => parseInt(x));
while (year < lastYear || (year === lastYear && month <= lastMonth)) {
months.push({ year, month });
if (month == 12) {
year++;
month = 1;
} else {
month++;
}
}
return months;
}
function iterateYears(dateFirst, dateLast) {
const years = [];
let year = parseInt(dateFirst.split("-")[0]);
let lastYear = parseInt(dateLast.split("-")[0]);
for (; year <= lastYear; year++) {
years.push(year);
}
return years;
}
function buildAccountTree(rows, valueFn, nameFn) {
nameFn = nameFn ?? ((parts, i) => parts.slice(0, i + 1).join(":"));
const accountTree = { children: [] };
for (let row of rows) {
const accountParts = row.account.split(":");
let node = accountTree;
for (let i = 0; i < accountParts.length; i++) {
const account = nameFn(accountParts, i);
let child = node.children.find((c) => c.name == account);
if (!child) {
child = { name: account, children: [], value: 0 };
node.children.push(child);
}
child.value += valueFn(row);
node = child;
}
}
return accountTree;
}
return { iterateMonths, iterateYears, buildAccountTree };
35 changes: 17 additions & 18 deletions frontend/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
import * as echarts from "echarts";
import * as helpers from "./helpers";
import { render_d3sankey } from "./sankey";
import { Dashboard, Ledger, Panel as PanelType } from "./types";
import { Dashboard, Ledger, Panel as PanelType, Utils } from "./types";

class Panel {
static runScript(ledger: Ledger, panel: PanelType) {
// pass 'fava' for backwards compatibility
const scriptFn = new Function("panel", "ledger", "fava", "helpers", panel.script!);
return scriptFn(panel, ledger, ledger, helpers);
static runScript(ledger: Ledger, utils: Utils, panel: PanelType) {
// pass 'fava' and 'helpers' for backwards compatibility
const scriptFn = new Function("panel", "ledger", "fava", "helpers", "utils", panel.script!);
return scriptFn(panel, ledger, ledger, helpers, utils);
}

static html(ledger: Ledger, panel: PanelType, elem: HTMLDivElement) {
static html(ledger: Ledger, utils: Utils, panel: PanelType, elem: HTMLDivElement) {
try {
elem.innerHTML = Panel.runScript(ledger, panel);
elem.innerHTML = Panel.runScript(ledger, utils, panel);
} catch (e) {
elem.innerHTML = e;
}
}

static echarts(ledger: Ledger, panel: PanelType, elem: HTMLDivElement) {
static echarts(ledger: Ledger, utils: Utils, panel: PanelType, elem: HTMLDivElement) {
let options;
try {
options = Panel.runScript(ledger, panel);
options = Panel.runScript(ledger, utils, panel);
} catch (e) {
elem.innerHTML = e;
return;
Expand All @@ -40,10 +40,10 @@ class Panel {
chart.setOption(options);
}

static d3_sankey(ledger: Ledger, panel: PanelType, elem: HTMLDivElement) {
static d3_sankey(ledger: Ledger, utils: Utils, panel: PanelType, elem: HTMLDivElement) {
let options;
try {
options = Panel.runScript(ledger, panel);
options = Panel.runScript(ledger, utils, panel);
} catch (e) {
elem.innerHTML = e;
return;
Expand All @@ -52,22 +52,20 @@ class Panel {
render_d3sankey(elem, options);
}

static jinja2(ledger: Ledger, panel: PanelType, elem: HTMLDivElement) {
static jinja2(ledger: Ledger, utils: Utils, panel: PanelType, elem: HTMLDivElement) {
elem.innerHTML = panel.template!;
}
}

function renderDashboard(ledger: Ledger, dashboard: Dashboard) {
function renderDashboard(ledger: Ledger, dashboard: Dashboard, utils: Utils) {
for (let i = 0; i < dashboard.panels.length; i++) {
const panel = dashboard.panels[i];
if (!panel.type) {
if (!panel.type || !(panel.type in Panel)) {
continue;
}

const elem = document.getElementById(`panel${i}`);
if (panel.type in Panel) {
Panel[panel.type](ledger, panel, elem as HTMLDivElement);
}
Panel[panel.type](ledger, utils, panel, elem as HTMLDivElement);
}
}

Expand All @@ -77,6 +75,7 @@ export default {
if (!boostrapJSON) return;

const bootstrap = JSON.parse(boostrapJSON);
renderDashboard(bootstrap.ledger, bootstrap.dashboard);
const utils = new Function(bootstrap.utils)();
renderDashboard(bootstrap.ledger, bootstrap.dashboard, utils);
},
};
12 changes: 12 additions & 0 deletions frontend/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export const iterateMonths = (dateFirst: string, dateLast: string) => {
console.warn(
"helpers.iterateMonths() is deprecated, please define this function in utils.inline in dashboards.yaml",
);

const months: { year: number; month: number }[] = [];
let [year, month] = dateFirst.split("-").map((x) => parseInt(x));
let [lastYear, lastMonth] = dateLast.split("-").map((x) => parseInt(x));
Expand All @@ -16,6 +20,10 @@ export const iterateMonths = (dateFirst: string, dateLast: string) => {
};

export const iterateYears = (dateFirst: string, dateLast: string) => {
console.warn(
"helpers.iterateMonths() is deprecated, please define this function in utils.inline in dashboards.yaml",
);

const years: number[] = [];
let year = parseInt(dateFirst.split("-")[0]);
let lastYear = parseInt(dateLast.split("-")[0]);
Expand All @@ -37,6 +45,10 @@ export const buildAccountTree = (
valueFn: (row: any) => number,
nameFn: (parts: string[], i: number) => string,
) => {
console.warn(
"helpers.iterateMonths() is deprecated, please define this function in utils.inline in dashboards.yaml",
);

nameFn = nameFn ?? ((parts: string[], i: number) => parts.slice(0, i + 1).join(":"));

const accountTree: { children: AccountTreeNode[] } = { children: [] };
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface Dashboard {
panels: Panel[];
}

export type Utils = { [k: string]: any };

export interface Bootstrap {
ledger: Ledger;
dashboard: Dashboard;
Expand Down
48 changes: 25 additions & 23 deletions scripts/format_js_in_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,36 +18,38 @@ def run_prettier(code, indent):
check=True,
cwd="frontend",
)
formatted = p.stdout.decode()
return (
indent
+ formatted.rstrip().replace("\n", "\n" + indent).replace(indent + "\n", "\n")
+ "\n\n"
)
formatted = p.stdout.decode().rstrip()
intended = indent + formatted.replace("\n", "\n" + indent)
# strip lines with only whitespace
return intended.replace(indent + "\n", "\n") + "\n"


def format_js_in_dashboard(f):
# cannot use YAML parser here, because it won't preserve comments, additional newlines etc.
formatted = ""
script_started = False
current_script = ""

for line in f:
if script_started:
if line == "\n" or line.startswith(" "):
current_script += line
else:
formatted += run_prettier(current_script, " ")
formatted += line
script_started = False
current_script = ""
else:
if line == " script: |\n" or line.startswith(" script: &"):
script_started = True
formatted += line
formatted += line
if line == " script: |\n" or line.startswith(" script: &"):
current_script = ""
for line in f:
if line == "\n" or line.startswith(" "):
current_script += line
else:
formatted += run_prettier(current_script, " ") + "\n" + line
break
elif line == " inline: |\n":
current_script = ""
for line in f:
if line == "\n" or line.startswith(" "):
current_script += line
else:
formatted += run_prettier(current_script, " ") + "\n" + line
break
if current_script:
formatted += run_prettier(current_script, " ")

if script_started:
formatted += run_prettier(current_script, " ")
return formatted.rstrip() + "\n"
return formatted


def main():
Expand Down
4 changes: 2 additions & 2 deletions src/fava_dashboards/FavaDashboards.js

Large diffs are not rendered by default.

Loading

0 comments on commit 2da5679

Please sign in to comment.