├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── continuous-integration.yaml ├── .gitignore ├── .python-version ├── Dockerfile.test ├── LICENSE.md ├── Makefile ├── README.md ├── example ├── dashboards.yaml ├── example.beancount └── portfolio.beancount ├── frontend ├── .gitignore ├── .nvmrc ├── .prettierrc.json ├── jest-puppeteer.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src │ ├── extension.ts │ ├── helpers.ts │ ├── panels.ts │ ├── sankey.ts │ └── types.ts ├── tests │ └── e2e │ │ ├── __image_snapshots__ │ │ ├── dashboard_assets.png │ │ ├── dashboard_income_and_expenses.png │ │ ├── dashboard_overview.png │ │ ├── dashboard_projection.png │ │ ├── dashboard_sankey.png │ │ └── dashboard_travelling.png │ │ ├── __snapshots__ │ │ └── dashboards.test.ts.snap │ │ └── dashboards.test.ts └── tsconfig.json ├── mise.toml ├── pyproject.toml ├── scripts └── format_js_in_dashboard.py ├── src └── fava_dashboards │ ├── FavaDashboards.js │ ├── __init__.py │ └── templates │ ├── FavaDashboards.html │ └── style.css └── uv.lock /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 2 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 3 | RUN apt-get update && apt-get install -y \ 4 | zsh git make \ 5 | npm 6 | 7 | # chromium dependencies 8 | RUN apt-get install -y fonts-noto-color-emoji libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libxdamage1 libpango-1.0-0 libcairo2 libasound2t64 9 | 10 | ENV LC_ALL="en_US.UTF-8" 11 | USER ubuntu 12 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "dockerfile": "Dockerfile" 4 | }, 5 | "customizations": { 6 | "vscode": { 7 | "extensions": ["editorconfig.editorconfig", "ms-python.python", "Lencerf.beancount"] 8 | } 9 | }, 10 | "postCreateCommand": "make deps", 11 | "forwardPorts": [5000] 12 | } 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv 2 | frontend/node_modules 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 120 11 | 12 | [*.{html,yaml}] 13 | indent_size = 2 14 | 15 | [*.ts] 16 | indent_size = 2 17 | 18 | [Makefile] 19 | indent_style = tab 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | groups: 8 | github-actions: 9 | patterns: 10 | - "*" 11 | 12 | - package-ecosystem: docker 13 | directory: /.devcontainer 14 | schedule: 15 | interval: weekly 16 | 17 | #- package-ecosystem: npm 18 | # directory: /frontend 19 | # schedule: 20 | # interval: weekly 21 | # groups: 22 | # npm: 23 | # patterns: 24 | # - "*" 25 | 26 | - package-ecosystem: pip 27 | directory: / 28 | schedule: 29 | interval: weekly 30 | groups: 31 | pip: 32 | patterns: 33 | - "*" 34 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Build and run dev container task 18 | uses: devcontainers/ci@v0.3 19 | with: 20 | runCmd: make ci 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # python generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # venv 10 | .venv 11 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 2 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 3 | RUN apt-get update && apt-get install -y \ 4 | git make npm 5 | 6 | # Chromium dependencies 7 | RUN apt-get install -y fonts-noto-color-emoji libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libxdamage1 libpango-1.0-0 libcairo2 libasound2t64 8 | 9 | ENV LC_ALL="en_US.UTF-8" 10 | USER ubuntu 11 | 12 | WORKDIR /usr/src/app 13 | RUN mkdir frontend 14 | 15 | # Python dependencies 16 | RUN --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 17 | --mount=type=bind,source=uv.lock,target=uv.lock \ 18 | --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000 \ 19 | uv sync --frozen --no-install-project 20 | 21 | # Node.js dependencies 22 | RUN --mount=type=bind,source=frontend/package.json,target=frontend/package.json \ 23 | --mount=type=bind,source=frontend/package-lock.json,target=frontend/package-lock.json \ 24 | --mount=type=cache,target=/home/ubuntu/.npm,uid=1000 \ 25 | cd frontend && npm ci && npx puppeteer browsers install chrome 26 | 27 | COPY --chown=ubuntu:ubuntu . . 28 | RUN make build 29 | CMD ["make", "run"] 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Andreas Gerstmayr 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: run 2 | 3 | ## Dependencies 4 | deps-js: 5 | cd frontend; npm install && npx puppeteer browsers install chrome 6 | 7 | deps-js-update: 8 | cd frontend; npx npm-check-updates -i 9 | 10 | deps-py: 11 | uv sync 12 | 13 | deps-py-update: 14 | uv pip list --outdated 15 | uv lock --upgrade 16 | 17 | deps: deps-js deps-py 18 | 19 | ## Build and Test 20 | build-js: 21 | cd frontend; npm run build 22 | 23 | build: build-js 24 | 25 | watch-js: 26 | cd frontend; npm run watch 27 | 28 | test-js: 29 | cd frontend; LANG=en npm run test 30 | 31 | test-js-update: 32 | cd frontend; LANG=en npm run test -- -u 33 | 34 | test: test-js 35 | 36 | ## Utils 37 | run: 38 | cd example; uv run fava example.beancount 39 | 40 | dev: 41 | npx concurrently --names fava,esbuild "cd example; PYTHONUNBUFFERED=1 uv run fava --debug example.beancount" "cd frontend; npm run watch" 42 | 43 | lint: 44 | cd frontend; npx tsc --noEmit 45 | uv run mypy src/fava_dashboards scripts/format_js_in_dashboard.py 46 | uv run pylint src/fava_dashboards scripts/format_js_in_dashboard.py 47 | 48 | format: 49 | cd frontend; npx prettier -w . ../src/fava_dashboards/templates/*.css 50 | -uv run ruff check --fix 51 | uv run ruff format . 52 | find example -name '*.beancount' -exec uv run bean-format -c 59 -o "{}" "{}" \; 53 | ./scripts/format_js_in_dashboard.py example/dashboards.yaml 54 | 55 | ci: 56 | make lint 57 | make build 58 | make run & 59 | make test 60 | make format 61 | git diff --exit-code 62 | 63 | ## Container 64 | container-run: container-stop 65 | docker build -t fava-dashboards-test -f Dockerfile.test . 66 | docker run -d --name fava-dashboards-test fava-dashboards-test 67 | 68 | container-stop: 69 | docker rm -f fava-dashboards-test 70 | 71 | container-test: container-run 72 | docker exec fava-dashboards-test make test 73 | make container-stop 74 | 75 | container-test-js-update: container-run 76 | docker exec fava-dashboards-test make test-js-update 77 | docker cp fava-dashboards-test:/usr/src/app/frontend/tests/e2e/__snapshots__ ./frontend/tests/e2e 78 | docker cp fava-dashboards-test:/usr/src/app/frontend/tests/e2e/__image_snapshots__ ./frontend/tests/e2e 79 | make container-stop 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fava Dashboards 2 | [![Continuous Integration](https://github.com/andreasgerstmayr/fava-dashboards/actions/workflows/continuous-integration.yaml/badge.svg)](https://github.com/andreasgerstmayr/fava-dashboards/actions/workflows/continuous-integration.yaml) 3 | [![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/andreasgerstmayr/fava-dashboards) 4 | 5 | fava-dashboards allows creating custom dashboards in [Fava](https://github.com/beancount/fava). 6 | 7 | Example dashboards with random data: 8 | [![Overview](frontend/tests/e2e/__image_snapshots__/dashboard_overview.png)](frontend/tests/e2e/__image_snapshots__/dashboard_overview.png) 9 | [![Assets](frontend/tests/e2e/__image_snapshots__/dashboard_assets.png)](frontend/tests/e2e/__image_snapshots__/dashboard_assets.png) 10 | [![Income and Expenses](frontend/tests/e2e/__image_snapshots__/dashboard_income_and_expenses.png)](frontend/tests/e2e/__image_snapshots__/dashboard_income_and_expenses.png) 11 | [![Travelling](frontend/tests/e2e/__image_snapshots__/dashboard_travelling.png)](frontend/tests/e2e/__image_snapshots__/dashboard_travelling.png) 12 | [![Sankey](frontend/tests/e2e/__image_snapshots__/dashboard_sankey.png)](frontend/tests/e2e/__image_snapshots__/dashboard_sankey.png) 13 | [![Projection](frontend/tests/e2e/__image_snapshots__/dashboard_projection.png)](frontend/tests/e2e/__image_snapshots__/dashboard_projection.png) 14 | 15 | ## Installation 16 | ``` 17 | pip install git+https://github.com/andreasgerstmayr/fava-dashboards.git 18 | ``` 19 | 20 | Enable this plugin in Fava by adding the following lines to your ledger: 21 | ``` 22 | 2010-01-01 custom "fava-extension" "fava_dashboards" 23 | ``` 24 | 25 | ## Configuration 26 | The plugin looks by default for a `dashboards.yaml` file in the directory of the Beancount ledger (e.g. if you run `fava personal.beancount`, the `dashboards.yaml` file should be in the same directory as `personal.beancount`). 27 | The location of the `dashboards.yaml` configuration file can be customized: 28 | ``` 29 | 2010-01-01 custom "fava-extension" "fava_dashboards" "{ 30 | 'config': '/path/to/dashboards.yaml' 31 | }" 32 | ``` 33 | 34 | Please take a look at the example dashboards configuration [dashboards.yaml](example/dashboards.yaml), which uses most of the functionality described below. 35 | 36 | The configuration file can contain multiple dashboards, and a dashboard contains one or more panels. 37 | A panel has a relative width (e.g. `50%` for 2 columns, or `33.3%` for 3 column layouts) and a absolute height. 38 | 39 | The `queries` field contains one or multiple queries. 40 | The Beancount query must be stored in the `bql` field of the respective query. 41 | It can contain Jinja template syntax to access the `panel` and `ledger` variables described below (example: use `{{ledger.ccy}}` to access the first configured operating currency). 42 | Note that Jinja will replace some protected HTML characters with escapes. 43 | For example, a `>` inside a Jinja variable will be turned into `>`. 44 | This can cause problems because `>` *is* a valid Beancount query operator, but `>` is not. 45 | To skip replacing protected HTML characters, pass the [Jinja safe filter](https://jinja.palletsprojects.com/en/3.0.x/templates/#jinja-filters.safe) to your variable invokation (for example, `{{ panel.foo|safe }}`. 46 | 47 | The query results can be accessed via `panel.queries[i].result`, where `i` is the index of the query in the `queries` field. 48 | Note: Additionally to the Beancount query, Fava's filter bar further filters the available entries of the ledger. 49 | 50 | 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`. 51 | 52 | **HTML, echarts and d3-sankey panels:** 53 | The `script` field must contain valid JavaScript code. 54 | It must return a valid configuration depending on the panel `type`. 55 | The following variables and functions are available: 56 | * `ext`: the Fava [`ExtensionContext`](https://github.com/beancount/fava/blob/main/frontend/src/extensions.ts) 57 | * `ext.api.get("query", {bql: "SELECT ..."}`: executes the specified BQL query 58 | * `panel`: the current (augmented) panel definition. The results of the BQL queries can be accessed with `panel.queries[i].result`. 59 | * `ledger.dateFirst`: start date of the current date filter, or first transaction date of the ledger 60 | * `ledger.dateLast`: end date of the current date filter, or last transaction date of the ledger 61 | * `ledger.filterFirst`: start date of the current date filter, or null if no date filter is set 62 | * `ledger.filterLast`: end date of the current date filter, or null if no date filter is set 63 | * `ledger.operatingCurrencies`: configured operating currencies of the ledger 64 | * `ledger.ccy`: shortcut for the first configured operating currency of the ledger 65 | * `ledger.accounts`: declared accounts of the ledger 66 | * `ledger.commodities`: declared commodities of the ledger 67 | * `helpers.urlFor(url)`: add current Fava filter parameters to url 68 | * `utils`: the return value of the `utils` code of the dashboard configuration 69 | 70 | **Jinja2 panels:** 71 | The `template` field must contain valid Jinja2 template code. 72 | The following variables are available: 73 | * `panel`: see above 74 | * `ledger`: see above 75 | * `favaledger`: a reference to the `FavaLedger` object 76 | 77 | ### Common Panel Properties 78 | * `title`: title of the panel. Default: unset 79 | * `width`: width of the panel. Default: 100% 80 | * `height`: height of the panel. Default: 400px 81 | * `link`: optional link target of the panel header. 82 | * `queries`: a list of dicts with a `bql` attribute. 83 | * `type`: panel type. Must be one of `html`, `echarts`, `d3_sankey` or `jinja2`. 84 | 85 | ### HTML panel 86 | The `script` code of HTML panels must return valid HTML. 87 | The HTML code will be rendered in the panel. 88 | 89 | ### ECharts panel 90 | The `script` code of [Apache ECharts](https://echarts.apache.org) panels must return valid [Apache ECharts](https://echarts.apache.org) chart options. 91 | Please take a look at the [ECharts examples](https://echarts.apache.org/examples) to get familiar with the available chart types and options. 92 | 93 | ### d3-sankey panel 94 | The `script` code of d3-sankey panels must return valid d3-sankey chart options. 95 | Please take a look at the example dashboard configuration [dashboards.yaml](example/dashboards.yaml). 96 | 97 | ### Jinja2 panel 98 | The `template` field of Jinja2 panels must contain valid Jinja2 template code. 99 | The rendered template will be shown in the panel. 100 | 101 | ## View Example Ledger 102 | `cd example; fava example.beancount` 103 | 104 | ## Why no React/Svelte/X? 105 | The main reason is simplicity. 106 | This project is small enough to use plain HTML/CSS/JS and Jinja2 templates only, and doesn't warrant using a modern and ever-changing web development toolchain. 107 | Currently it requires only two external dependencies: pyyaml and echarts. 108 | 109 | ## Articles 110 | * [Dashboards with Beancount and Fava](https://www.andreasgerstmayr.at/2023/03/12/dashboards-with-beancount-and-fava.html) 111 | 112 | ## Related Projects 113 | * [Fava Portfolio Returns](https://github.com/andreasgerstmayr/fava-portfolio-returns) 114 | * [Fava Investor](https://github.com/redstreet/fava_investor) 115 | * [Fava Classy Portfolio](https://github.com/seltzered/fava-classy-portfolio) 116 | 117 | ## Acknowledgements 118 | Thanks to Martin Blais and all contributors of [Beancount](https://github.com/beancount/beancount), 119 | Jakob Schnitzer, Dominik Aumayr and all contributors of [Fava](https://github.com/beancount/fava), 120 | and to all contributors of [Apache ECharts](https://echarts.apache.org), [D3.js](https://d3js.org) and [d3-sankey](https://github.com/d3/d3-sankey). 121 | -------------------------------------------------------------------------------- /example/dashboards.yaml: -------------------------------------------------------------------------------- 1 | dashboards: 2 | - name: Overview 3 | panels: 4 | - title: Assets 💰 5 | width: 50% 6 | height: 80px 7 | link: ../../balance_sheet/ 8 | queries: 9 | - bql: SELECT CONVERT(SUM(position), '{{ledger.ccy}}') AS value WHERE account ~ '^Assets:' 10 | type: html 11 | script: | 12 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 13 | const value = panel.queries[0].result[0]?.value[ledger.ccy] ?? 0; 14 | const valueFmt = currencyFormatter(value); 15 | return `
${valueFmt}
`; 16 | 17 | - title: Liabilities 💳 18 | width: 50% 19 | height: 80px 20 | link: ../../balance_sheet/ 21 | queries: 22 | - bql: SELECT CONVERT(SUM(position), '{{ledger.ccy}}') AS value WHERE account ~ '^Liabilities:' 23 | type: html 24 | script: | 25 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 26 | const value = panel.queries[0].result[0]?.value[ledger.ccy] ?? -0; 27 | const valueFmt = currencyFormatter(-value); 28 | return `
${valueFmt}
`; 29 | 30 | - title: Income/Expenses 💸 31 | height: 520px 32 | link: ../../income_statement/ 33 | queries: 34 | - name: Income 35 | stack: income 36 | bql: | 37 | SELECT year, month, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 38 | WHERE account ~ '^Income:' 39 | GROUP BY year, month 40 | link: ../../account/Income/?time={time} 41 | - name: Housing 42 | stack: expenses 43 | bql: | 44 | SELECT year, month, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 45 | WHERE account ~ '^Expenses:Housing:' AND NOT 'travel' IN tags 46 | GROUP BY year, month 47 | link: ../../account/Expenses:Housing/?filter=-#travel&time={time} 48 | - name: Food 49 | stack: expenses 50 | bql: | 51 | SELECT year, month, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 52 | WHERE account ~ '^Expenses:Food:' AND NOT 'travel' IN tags 53 | GROUP BY year, month 54 | link: ../../account/Expenses:Food/?filter=-#travel&time={time} 55 | - name: Shopping 56 | stack: expenses 57 | bql: | 58 | SELECT year, month, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 59 | WHERE account ~ '^Expenses:Shopping:' AND NOT 'travel' IN tags 60 | GROUP BY year, month 61 | link: ../../account/Expenses:Shopping/?filter=-#travel&time={time} 62 | - name: Travel 63 | stack: expenses 64 | bql: | 65 | SELECT year, month, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 66 | WHERE account ~ '^Expenses:' AND 'travel' IN tags 67 | GROUP BY year, month 68 | link: ../../account/Expenses/?filter=#travel&time={time} 69 | - name: Other 70 | stack: expenses 71 | bql: | 72 | SELECT year, month, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 73 | WHERE account ~ '^Expenses:' AND NOT account ~ '^Expenses:(Housing|Food|Shopping):' AND NOT 'travel' IN tags 74 | GROUP BY year, month 75 | link: ../../account/Expenses/?filter=all(-account:"^Expenses:(Housing|Food|Shopping)") -#travel&time={time} 76 | type: echarts 77 | script: | 78 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 79 | const months = utils.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`); 80 | 81 | // the beancount query only returns months where there was at least one matching transaction, therefore we group by month 82 | const amounts = {}; 83 | for (let query of panel.queries) { 84 | amounts[query.name] = {}; 85 | for (let row of query.result) { 86 | const value = row.value[ledger.ccy] ?? 0; 87 | amounts[query.name][`${row.month}/${row.year}`] = query.stack == "income" ? -value : value; 88 | } 89 | } 90 | 91 | return { 92 | tooltip: { 93 | valueFormatter: currencyFormatter, 94 | }, 95 | legend: { 96 | top: "bottom", 97 | }, 98 | xAxis: { 99 | data: months, 100 | }, 101 | yAxis: { 102 | axisLabel: { 103 | formatter: currencyFormatter, 104 | }, 105 | }, 106 | series: panel.queries.map((query) => ({ 107 | type: "bar", 108 | name: query.name, 109 | stack: query.stack, 110 | data: months.map((month) => amounts[query.name][month] ?? 0), 111 | })), 112 | onClick: (event) => { 113 | const query = panel.queries.find((q) => q.name === event.seriesName); 114 | if (query) { 115 | const [month, year] = event.name.split("/"); 116 | const link = query.link.replace("{time}", `${year}-${month.padStart(2, "0")}`); 117 | window.open(helpers.urlFor(link)); 118 | } 119 | }, 120 | }; 121 | 122 | - name: Assets 123 | panels: 124 | - title: Assets 🏦 125 | width: 50% 126 | queries: 127 | - bql: | 128 | SELECT currency, CONVERT(SUM(position), '{{ledger.ccy}}') as market_value 129 | WHERE account_sortkey(account) ~ '^[01]' 130 | GROUP BY currency 131 | ORDER BY market_value 132 | link: ../../account/{account}/?time={time} 133 | type: echarts 134 | script: | 135 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 136 | 137 | const data = panel.queries[0].result 138 | .filter((row) => row.market_value[ledger.ccy]) 139 | .map((row) => ({ name: row.currency, value: row.market_value[ledger.ccy] })); 140 | 141 | return { 142 | tooltip: { 143 | formatter: (params) => 144 | `${params.marker} ${ 145 | ledger.commodities[params.name]?.meta.name ?? params.name 146 | } ${currencyFormatter( 147 | params.value, 148 | )} (${params.percent.toFixed(0)}%)`, 149 | }, 150 | series: [ 151 | { 152 | type: "pie", 153 | data, 154 | }, 155 | ], 156 | }; 157 | 158 | - title: Net Worth 💰 159 | width: 50% 160 | link: ../../income_statement/ 161 | queries: 162 | - bql: | 163 | SELECT year, month, 164 | CONVERT(LAST(balance), '{{ledger.ccy}}', DATE_TRUNC('month', FIRST(date)) + INTERVAL('1 month') - INTERVAL('1 day')) AS value 165 | WHERE account_sortkey(account) ~ '^[01]' 166 | GROUP BY year, month 167 | link: ../../balance_sheet/?time={time} 168 | type: echarts 169 | script: | 170 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 171 | const months = utils.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`); 172 | const amounts = {}; 173 | 174 | // the beancount query only returns months where there was at least one matching transaction, therefore we group by month 175 | for (let row of panel.queries[0].result) { 176 | amounts[`${row.month}/${row.year}`] = row.value[ledger.ccy]; 177 | } 178 | 179 | return { 180 | tooltip: { 181 | trigger: "axis", 182 | valueFormatter: currencyFormatter, 183 | }, 184 | xAxis: { 185 | data: months, 186 | }, 187 | yAxis: { 188 | axisLabel: { 189 | formatter: currencyFormatter, 190 | }, 191 | }, 192 | series: [ 193 | { 194 | type: "line", 195 | smooth: true, 196 | connectNulls: true, 197 | data: months.map((month) => amounts[month]), 198 | }, 199 | ], 200 | onClick: (event) => { 201 | const [month, year] = event.name.split("/"); 202 | const link = panel.queries[0].link.replace("{time}", `${year}-${month.padStart(2, "0")}`); 203 | window.open(helpers.urlFor(link)); 204 | }, 205 | }; 206 | 207 | - title: Portfolio 📈 208 | width: 50% 209 | queries: 210 | - bql: &portfolio_bql | 211 | SELECT year, month, 212 | CONVERT(LAST(balance), '{{ledger.ccy}}', DATE_TRUNC('month', FIRST(date)) + INTERVAL('1 month') - INTERVAL('1 day')) AS market_value, 213 | CONVERT(COST(LAST(balance)), '{{ledger.ccy}}', DATE_TRUNC('month', FIRST(date)) + INTERVAL('1 month') - INTERVAL('1 day')) AS book_value 214 | WHERE account ~ '^Assets:' AND currency != '{{ledger.ccy}}' 215 | GROUP BY year, month 216 | link: ../../balance_sheet/?time={time} 217 | type: echarts 218 | script: | 219 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 220 | const months = utils.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`); 221 | const amounts = {}; 222 | 223 | // the beancount query only returns months where there was at least one matching transaction, therefore we group by month 224 | for (let row of panel.queries[0].result) { 225 | amounts[`${row.month}/${row.year}`] = { 226 | market_value: row.market_value[ledger.ccy], 227 | book_value: row.book_value[ledger.ccy], 228 | }; 229 | } 230 | 231 | return { 232 | tooltip: { 233 | trigger: "axis", 234 | valueFormatter: currencyFormatter, 235 | }, 236 | legend: { 237 | top: "bottom", 238 | }, 239 | xAxis: { 240 | data: months, 241 | }, 242 | yAxis: { 243 | axisLabel: { 244 | formatter: currencyFormatter, 245 | }, 246 | }, 247 | series: [ 248 | { 249 | type: "line", 250 | name: "Market Value", 251 | smooth: true, 252 | connectNulls: true, 253 | data: months.map((month) => amounts[month]?.market_value), 254 | }, 255 | { 256 | type: "line", 257 | name: "Book Value", 258 | smooth: true, 259 | connectNulls: true, 260 | data: months.map((month) => amounts[month]?.book_value), 261 | }, 262 | ], 263 | onClick: (event) => { 264 | const [month, year] = event.name.split("/"); 265 | const link = panel.queries[0].link.replace("{time}", `${year}-${month.padStart(2, "0")}`); 266 | window.open(helpers.urlFor(link)); 267 | }, 268 | }; 269 | 270 | - title: Portfolio Gains ✨ 271 | width: 50% 272 | queries: 273 | - bql: *portfolio_bql 274 | type: echarts 275 | script: | 276 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 277 | const months = utils.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`); 278 | const amounts = {}; 279 | 280 | // the beancount query only returns months where there was at least one matching transaction, therefore we group by month 281 | for (let row of panel.queries[0].result) { 282 | amounts[`${row.month}/${row.year}`] = row.market_value[ledger.ccy] - row.book_value[ledger.ccy]; 283 | } 284 | 285 | return { 286 | tooltip: { 287 | trigger: "axis", 288 | valueFormatter: currencyFormatter, 289 | }, 290 | xAxis: { 291 | data: months, 292 | }, 293 | yAxis: { 294 | axisLabel: { 295 | formatter: currencyFormatter, 296 | }, 297 | }, 298 | series: [ 299 | { 300 | type: "line", 301 | smooth: true, 302 | connectNulls: true, 303 | data: months.map((month) => amounts[month]), 304 | }, 305 | ], 306 | }; 307 | 308 | - title: Asset Classes Year-over-Year 🏦 309 | type: echarts 310 | script: | 311 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 312 | const years = utils.iterateYears(ledger.dateFirst, ledger.dateLast); 313 | 314 | // This chart requires the balances grouped by year and currency. 315 | // Unfortunately the `balance` column does not support GROUP BY 316 | // (see https://groups.google.com/g/beancount/c/TfZJswxuIDA/m/psc2BkrBAAAJ) 317 | // therefore we need to run a separate query per year. 318 | const filterParams = Object.fromEntries(new URL(window.location.href).searchParams); 319 | const queries = await Promise.all( 320 | years.map((year) => 321 | ext.api.get("query", { 322 | bql: `SELECT currency, 323 | CONVERT(SUM(position), '${ledger.ccy}', ${year}-12-31) as market_value 324 | FROM CLOSE ON ${year + 1}-01-01 325 | WHERE account_sortkey(account) ~ '^[01]' 326 | GROUP BY currency`, 327 | ...filterParams, 328 | }), 329 | ), 330 | ); 331 | 332 | const amounts = {}; 333 | const balances = {}; 334 | for (let i = 0; i < years.length; i++) { 335 | const year = years[i]; 336 | const query = queries[i]; 337 | 338 | amounts[year] = {}; 339 | for (let row of query.data.result) { 340 | if (!row.market_value[ledger.ccy]) continue; 341 | 342 | const value = row.market_value[ledger.ccy]; 343 | const assetClass = ledger.commodities[row.currency]?.meta.asset_class ?? "unknown"; 344 | amounts[year][assetClass] = (amounts[year][assetClass] ?? 0) + value; 345 | balances[assetClass] = (balances[assetClass] ?? 0) + value; 346 | } 347 | } 348 | 349 | const assetClasses = Object.entries(balances) 350 | .sort(([, a], [, b]) => b - a) 351 | .map(([name]) => name); 352 | 353 | return { 354 | tooltip: { 355 | formatter: (params) => { 356 | const sum = Object.values(amounts[params.name]).reduce((prev, cur) => prev + cur, 0); 357 | return `${params.marker} ${params.seriesName} ${currencyFormatter( 358 | params.value, 359 | )} (${((params.value / sum) * 100).toFixed(0)}%)`; 360 | }, 361 | }, 362 | legend: { 363 | top: "bottom", 364 | }, 365 | xAxis: { 366 | data: years, 367 | }, 368 | yAxis: { 369 | axisLabel: { 370 | formatter: currencyFormatter, 371 | }, 372 | }, 373 | series: assetClasses.map((assetClass) => ({ 374 | type: "bar", 375 | name: assetClass, 376 | stack: "assets", 377 | data: years.map((year) => amounts[year][assetClass] ?? 0), 378 | })), 379 | }; 380 | 381 | - title: Asset Classes 🏦 382 | width: 50% 383 | queries: 384 | - bql: &assets_bql | 385 | SELECT currency, CONVERT(SUM(position), '{{ledger.ccy}}') as market_value 386 | WHERE account_sortkey(account) ~ '^[01]' 387 | GROUP BY currency 388 | ORDER BY market_value 389 | type: echarts 390 | script: &asset_classes | 391 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 392 | 393 | let totalValue = 0; 394 | const assetClasses = {}; 395 | for (let row of panel.queries[0].result) { 396 | if (!row.market_value[ledger.ccy]) continue; 397 | 398 | const ccy = row.currency; 399 | const value = row.market_value[ledger.ccy]; 400 | const assetName = ledger.commodities[ccy]?.meta.name ?? ccy; 401 | const assetClass = ledger.commodities[ccy]?.meta.asset_class ?? "unknown"; 402 | if (!(assetClass in assetClasses)) { 403 | assetClasses[assetClass] = { name: assetClass, children: [] }; 404 | } 405 | assetClasses[assetClass].children.push({ name: assetName, value }); 406 | totalValue += value; 407 | } 408 | 409 | return { 410 | tooltip: { 411 | formatter: (params) => 412 | `${params.marker} ${params.name} ${currencyFormatter( 413 | params.value, 414 | )} (${((params.value / totalValue) * 100).toFixed(0)}%)`, 415 | }, 416 | series: [ 417 | { 418 | type: "sunburst", 419 | radius: "100%", 420 | label: { 421 | minAngle: 3, 422 | width: 170, 423 | overflow: "truncate", 424 | }, 425 | labelLayout: { 426 | hideOverlap: true, 427 | }, 428 | data: Object.values(assetClasses), 429 | }, 430 | ], 431 | }; 432 | 433 | - title: Investment Classes 🏦 434 | width: 50% 435 | queries: 436 | - bql: &investments_bql | 437 | SELECT currency, CONVERT(SUM(position), '{{ledger.ccy}}') as market_value 438 | WHERE account_sortkey(account) ~ '^[01]' AND currency != '{{ledger.ccy}}' 439 | GROUP BY currency 440 | ORDER BY market_value 441 | type: echarts 442 | script: *asset_classes 443 | 444 | - title: Assets Allocation 🏦 445 | width: 50% 446 | queries: 447 | - bql: *assets_bql 448 | type: echarts 449 | script: &assets_allocation | 450 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 451 | 452 | let totalValue = 0; 453 | const root = { children: [] }; 454 | for (let row of panel.queries[0].result) { 455 | if (!row.market_value[ledger.ccy]) continue; 456 | 457 | const allocations = Object.entries(ledger.commodities[row.currency]?.meta ?? {}).filter(([k, v]) => 458 | k.startsWith("asset_allocation_"), 459 | ); 460 | if (allocations.length === 0) { 461 | allocations.push(["asset_allocation_Unknown", 100]); 462 | } 463 | 464 | for (let [allocation, percentage] of allocations) { 465 | const parts = allocation.substr("asset_allocation_".length).split("_"); 466 | let node = root; 467 | for (const part of parts) { 468 | let child = node.children.find((c) => c.name == part); 469 | if (!child) { 470 | child = { name: part, children: [] }; 471 | node.children.push(child); 472 | } 473 | node = child; 474 | } 475 | 476 | const value = (percentage / 100) * row.market_value[ledger.ccy]; 477 | node.value = (node.value ?? 0) + value; 478 | totalValue += value; 479 | } 480 | } 481 | 482 | return { 483 | tooltip: { 484 | formatter: (params) => 485 | `${params.marker} ${params.name} ${currencyFormatter( 486 | params.value, 487 | )} (${((params.value / totalValue) * 100).toFixed(0)}%)`, 488 | }, 489 | series: [ 490 | { 491 | type: "sunburst", 492 | radius: "100%", 493 | label: { 494 | rotate: "tangential", 495 | minAngle: 20, 496 | }, 497 | labelLayout: { 498 | hideOverlap: true, 499 | }, 500 | data: root.children, 501 | }, 502 | ], 503 | }; 504 | 505 | - title: Investments Allocation 🏦 506 | width: 50% 507 | queries: 508 | - bql: *investments_bql 509 | type: echarts 510 | script: *assets_allocation 511 | 512 | - name: Income and Expenses 513 | panels: 514 | - title: Avg. Income per Month 💰 515 | width: 33.3% 516 | height: 80px 517 | link: ../../account/Income/?r=changes 518 | queries: 519 | - bql: SELECT CONVERT(SUM(position), '{{ledger.ccy}}') AS value WHERE account ~ '^Income:' 520 | type: html 521 | script: | 522 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 523 | const days = (new Date(ledger.dateLast) - new Date(ledger.dateFirst)) / (1000 * 60 * 60 * 24) + 1; 524 | const months = days / (365 / 12); 525 | const value = panel.queries[0].result[0]?.value[ledger.ccy] ?? -0; 526 | const valueFmt = currencyFormatter(-value / months); 527 | return `
${valueFmt}
`; 528 | 529 | - title: Avg. Expenses per Month 💸 530 | width: 33.3% 531 | height: 80px 532 | link: ../../account/Expenses/?r=changes 533 | queries: 534 | - bql: SELECT CONVERT(SUM(position), '{{ledger.ccy}}') AS value WHERE account ~ '^Expenses:' 535 | type: html 536 | script: | 537 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 538 | const days = (new Date(ledger.dateLast) - new Date(ledger.dateFirst)) / (1000 * 60 * 60 * 24) + 1; 539 | const months = days / (365 / 12); 540 | const value = panel.queries[0].result[0]?.value[ledger.ccy] ?? 0; 541 | const valueFmt = currencyFormatter(value / months); 542 | return `
${valueFmt}
`; 543 | 544 | - title: Avg. Savings per Month ✨ 545 | width: 33.3% 546 | height: 80px 547 | link: ../../income_statement/ 548 | queries: 549 | - bql: SELECT CONVERT(SUM(position), '{{ledger.ccy}}') AS value WHERE account ~ '^Income:' 550 | - bql: SELECT CONVERT(SUM(position), '{{ledger.ccy}}') AS value WHERE account ~ '^Expenses:' 551 | type: html 552 | script: | 553 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 554 | const percentFormat = new Intl.NumberFormat(undefined, { 555 | style: "percent", 556 | maximumFractionDigits: 0, 557 | }); 558 | const days = (new Date(ledger.dateLast) - new Date(ledger.dateFirst)) / (1000 * 60 * 60 * 24) + 1; 559 | const months = days / (365 / 12); 560 | const income = -(panel.queries[0].result[0]?.value[ledger.ccy] ?? -0); 561 | const expenses = panel.queries[1].result[0]?.value[ledger.ccy] ?? 0; 562 | const rate = (income - expenses) / months; 563 | const ratePercent = income === 0 ? 0 : 1 - expenses / income; 564 | const value = `${currencyFormatter(rate)} (${percentFormat.format(ratePercent)})`; 565 | return `
${value}
`; 566 | 567 | - title: Savings Heatmap 💰 568 | link: ../../income_statement/ 569 | queries: 570 | - bql: | 571 | SELECT year, month, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 572 | WHERE account ~ '^(Income|Expenses):' 573 | GROUP BY year, month 574 | link: ../../income_statement/?time={time} 575 | type: echarts 576 | script: | 577 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 578 | const monthFormatter = new Intl.DateTimeFormat(undefined, { month: "short" }).format; 579 | const years = utils.iterateYears(ledger.dateFirst, ledger.dateLast); 580 | 581 | // the beancount query only returns months where there was at least one matching transaction, therefore we group by month 582 | const monthly = {}; 583 | const yearly = {}; 584 | for (let row of panel.queries[0].result) { 585 | const savings = -row.value[ledger.ccy]; 586 | monthly[`${row.year}-${row.month}`] = savings; 587 | yearly[row.year] = (yearly[row.year] ?? 0) + savings; 588 | } 589 | 590 | const data = []; 591 | for (const year of years) { 592 | data.push([`${year}`, yearly[year] ?? 0]); 593 | for (let month = 1; month <= 12; month++) { 594 | data.push([`${year}-${month}`, monthly[`${year}-${month}`] ?? 0]); 595 | } 596 | } 597 | const max = Math.max(...data.map(([label, val]) => (label.includes("-") ? Math.abs(val) : 0))); 598 | const maxRounded = Math.round(max * 100) / 100; 599 | 600 | return { 601 | tooltip: { 602 | position: "top", 603 | valueFormatter: currencyFormatter, 604 | }, 605 | grid: { 606 | top: 30, 607 | height: Math.min(50 * years.length, 280), 608 | bottom: 100, // space for visualMap 609 | }, 610 | xAxis: { 611 | type: "category", 612 | }, 613 | yAxis: { 614 | type: "category", 615 | }, 616 | visualMap: { 617 | min: -maxRounded, 618 | max: maxRounded, 619 | calculable: true, // show handles 620 | orient: "horizontal", 621 | left: "center", 622 | bottom: 0, // place visualMap at bottom of chart 623 | itemHeight: 400, // width 624 | inRange: { 625 | color: ["#af3d3d", "#fff", "#3daf46"], 626 | }, 627 | formatter: currencyFormatter, 628 | }, 629 | series: [ 630 | { 631 | type: "heatmap", 632 | data: data.map(([label, value]) => { 633 | if (!label.includes("-")) { 634 | return ["Entire Year", label, value]; 635 | } 636 | 637 | const [year, month] = label.split("-"); 638 | const monthLocale = monthFormatter(new Date(parseInt(year), parseInt(month) - 1, 1)); 639 | return [monthLocale, year, value]; 640 | }), 641 | label: { 642 | show: true, 643 | formatter: (params) => currencyFormatter(params.data[2]), 644 | }, 645 | emphasis: { 646 | itemStyle: { 647 | shadowBlur: 10, 648 | shadowColor: "rgba(0, 0, 0, 0.5)", 649 | }, 650 | }, 651 | }, 652 | ], 653 | onClick: (event) => { 654 | let time = data[event.dataIndex][0]; 655 | if (time.includes("-")) { 656 | const [year, month] = time.split("-"); 657 | time = `${year}-${month.padStart(2, "0")}`; 658 | } 659 | const link = panel.queries[0].link.replace("{time}", time); 660 | window.open(helpers.urlFor(link)); 661 | }, 662 | }; 663 | 664 | - title: Income Categories (per month) 💰 665 | width: 50% 666 | link: ../../account/Income/?r=changes 667 | queries: 668 | - bql: | 669 | SELECT root(account, 4) AS account, CONVERT(SUM(position), '{{ledger.ccy}}') AS value 670 | WHERE account ~ '^Income:' 671 | GROUP BY account 672 | link: ../../account/{account}/?r=changes 673 | type: echarts 674 | script: | 675 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 676 | const days = (new Date(ledger.dateLast) - new Date(ledger.dateFirst)) / (1000 * 60 * 60 * 24) + 1; 677 | const divisor = days / (365 / 12); 678 | const accountTree = utils.buildAccountTree( 679 | panel.queries[0].result, 680 | (row) => -(row.value[ledger.ccy] ?? 0) / divisor, 681 | (parts, i) => parts[i], 682 | ); 683 | // use click event on desktop, dblclick on mobile 684 | const clickEvt = window.screen.width < 800 ? "onDblClick" : "onClick"; 685 | 686 | return { 687 | tooltip: { 688 | valueFormatter: currencyFormatter, 689 | }, 690 | series: [ 691 | { 692 | type: "sunburst", 693 | radius: "100%", 694 | label: { 695 | minAngle: 20, 696 | }, 697 | nodeClick: false, 698 | data: accountTree.children[0]?.children ?? [], 699 | }, 700 | ], 701 | [clickEvt]: (event) => { 702 | const account = "Income" + event.treePathInfo.map((i) => i.name).join(":"); 703 | const link = panel.queries[0].link.replace("{account}", account); 704 | window.open(helpers.urlFor(link)); 705 | }, 706 | }; 707 | 708 | - title: Expenses Categories (per month) 💸 709 | width: 50% 710 | link: ../../account/Expenses/?r=changes 711 | queries: 712 | - bql: | 713 | SELECT root(account, 3) AS account, CONVERT(SUM(position), '{{ledger.ccy}}') AS value 714 | WHERE account ~ '^Expenses:' 715 | GROUP BY account 716 | link: ../../account/{account}/?r=changes 717 | type: echarts 718 | script: | 719 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 720 | const days = (new Date(ledger.dateLast) - new Date(ledger.dateFirst)) / (1000 * 60 * 60 * 24) + 1; 721 | const divisor = days / (365 / 12); 722 | const accountTree = utils.buildAccountTree( 723 | panel.queries[0].result, 724 | (row) => (row.value[ledger.ccy] ?? 0) / divisor, 725 | (parts, i) => parts[i], 726 | ); 727 | // use click event on desktop, dblclick on mobile 728 | const clickEvt = window.screen.width < 800 ? "onDblClick" : "onClick"; 729 | 730 | return { 731 | tooltip: { 732 | valueFormatter: currencyFormatter, 733 | }, 734 | series: [ 735 | { 736 | type: "sunburst", 737 | radius: "100%", 738 | label: { 739 | minAngle: 20, 740 | }, 741 | nodeClick: false, 742 | data: accountTree.children[0]?.children ?? [], 743 | }, 744 | ], 745 | [clickEvt]: (event) => { 746 | const account = "Expenses" + event.treePathInfo.map((i) => i.name).join(":"); 747 | const link = panel.queries[0].link.replace("{account}", account); 748 | window.open(helpers.urlFor(link)); 749 | }, 750 | }; 751 | 752 | - title: Recurring, Regular and Irregular Expenses 🔁 753 | width: 50% 754 | link: ../../income_statement/ 755 | queries: 756 | - name: Recurring 757 | bql: | 758 | SELECT year, month, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 759 | WHERE account ~ '^Expenses:' AND 'recurring' IN tags 760 | GROUP BY year, month 761 | link: ../../account/Expenses/?filter=#recurring&time={time} 762 | - name: Regular 763 | bql: | 764 | SELECT year, month, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 765 | WHERE account ~ '^Expenses:' AND NOT 'recurring' IN tags AND NOT 'irregular' IN tags 766 | GROUP BY year, month 767 | link: ../../account/Expenses/?filter=-#recurring -#irregular&time={time} 768 | - name: Irregular 769 | bql: | 770 | SELECT year, month, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 771 | WHERE account ~ '^Expenses:' AND 'irregular' IN tags 772 | GROUP BY year, month 773 | link: ../../account/Expenses/?filter=#irregular&time={time} 774 | type: echarts 775 | script: | 776 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 777 | const months = utils.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`); 778 | 779 | // the beancount query only returns months where there was at least one matching transaction, therefore we group by month 780 | const amounts = {}; 781 | for (let query of panel.queries) { 782 | amounts[query.name] = {}; 783 | for (let row of query.result) { 784 | amounts[query.name][`${row.month}/${row.year}`] = row.value[ledger.ccy]; 785 | } 786 | } 787 | 788 | return { 789 | tooltip: { 790 | valueFormatter: currencyFormatter, 791 | }, 792 | legend: { 793 | top: "bottom", 794 | }, 795 | xAxis: { 796 | axisLabel: { 797 | formatter: currencyFormatter, 798 | }, 799 | }, 800 | yAxis: { 801 | data: months, 802 | }, 803 | series: panel.queries.map((query) => ({ 804 | type: "bar", 805 | name: query.name, 806 | stack: "expenses", 807 | data: months.map((month) => amounts[query.name][month] ?? 0), 808 | })), 809 | onClick: (event) => { 810 | const query = panel.queries.find((q) => q.name === event.seriesName); 811 | if (query) { 812 | const [month, year] = event.name.split("/"); 813 | const link = query.link.replace("{time}", `${year}-${month.padStart(2, "0")}`); 814 | window.open(helpers.urlFor(link)); 815 | } 816 | }, 817 | }; 818 | 819 | - title: Food Expenses 🥐 820 | width: 50% 821 | link: ../../account/Expenses:Food/ 822 | queries: 823 | - bql: | 824 | SELECT year, month, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 825 | WHERE account ~ '^Expenses:Food:' 826 | GROUP BY year, month 827 | link: ../../account/Expenses:Food/?time={time} 828 | type: echarts 829 | script: | 830 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 831 | const months = utils.iterateMonths(ledger.dateFirst, ledger.dateLast).map((m) => `${m.month}/${m.year}`); 832 | const amounts = {}; 833 | 834 | // the beancount query only returns months where there was at least one matching transaction, therefore we group by month 835 | for (let row of panel.queries[0].result) { 836 | amounts[`${row.month}/${row.year}`] = row.value[ledger.ccy]; 837 | } 838 | 839 | return { 840 | tooltip: { 841 | valueFormatter: currencyFormatter, 842 | }, 843 | xAxis: { 844 | data: months, 845 | }, 846 | yAxis: { 847 | axisLabel: { 848 | formatter: currencyFormatter, 849 | }, 850 | }, 851 | series: [ 852 | { 853 | type: "line", 854 | smooth: true, 855 | data: months.map((month) => amounts[month] ?? 0), 856 | }, 857 | ], 858 | onClick: (event) => { 859 | const [month, year] = event.name.split("/"); 860 | const link = panel.queries[0].link.replace("{time}", `${year}-${month.padStart(2, "0")}`); 861 | window.open(helpers.urlFor(link)); 862 | }, 863 | }; 864 | 865 | - title: Income Year-Over-Year 💰 866 | width: 50% 867 | height: 700px 868 | queries: 869 | - bql: | 870 | SELECT year, root(account, 3) AS account, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 871 | WHERE account ~ "^Income:" 872 | GROUP BY account, year 873 | link: ../../account/{account}/?time={time} 874 | type: echarts 875 | script: &year_over_year | 876 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 877 | const years = utils.iterateYears(ledger.dateFirst, ledger.dateLast); 878 | const maxAccounts = 7; // number of accounts to show, sorted by sum 879 | 880 | const accountSums = {}; 881 | const amounts = {}; 882 | for (let row of panel.queries[0].result) { 883 | if (!(row.account in accountSums)) { 884 | accountSums[row.account] = 0; 885 | } 886 | const value = row.account.startsWith("Income:") ? -row.value[ledger.ccy] : row.value[ledger.ccy]; 887 | amounts[`${row.year}/${row.account}`] = value; 888 | accountSums[row.account] += value; 889 | } 890 | 891 | const accounts = Object.entries(accountSums) 892 | .sort(([, a], [, b]) => b - a) 893 | .map(([name]) => name) 894 | .slice(0, maxAccounts) 895 | .reverse(); 896 | return { 897 | legend: { 898 | top: "bottom", 899 | }, 900 | tooltip: { 901 | formatter: "{a}", 902 | }, 903 | xAxis: { 904 | axisLabel: { 905 | formatter: currencyFormatter, 906 | }, 907 | }, 908 | yAxis: { 909 | data: accounts.map((account) => account.split(":").slice(1).join(":")), 910 | }, 911 | grid: { 912 | containLabel: true, 913 | left: 0, 914 | }, 915 | series: years.map((year) => ({ 916 | type: "bar", 917 | name: year, 918 | data: accounts.map((account) => amounts[`${year}/${account}`] ?? 0), 919 | label: { 920 | show: true, 921 | position: "right", 922 | formatter: (params) => currencyFormatter(params.value), 923 | }, 924 | })), 925 | onClick: (event) => { 926 | const link = panel.queries[0].link 927 | .replace("{account}", accounts[event.dataIndex]) 928 | .replace("{time}", event.seriesName); 929 | window.open(helpers.urlFor(link)); 930 | }, 931 | }; 932 | 933 | - title: Expenses Year-Over-Year 💸 934 | width: 50% 935 | height: 700px 936 | queries: 937 | - bql: | 938 | SELECT year, root(account, 2) AS account, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 939 | WHERE account ~ "^Expenses:" 940 | GROUP BY account, year 941 | link: ../../account/{account}/?time={time} 942 | type: echarts 943 | script: *year_over_year 944 | 945 | - title: Top 10 biggest expenses 946 | queries: 947 | - bql: SELECT date, payee, narration, position WHERE account ~ "^Expenses:" ORDER BY position DESC LIMIT 10 948 | type: jinja2 949 | template: | 950 | {% import "_query_table.html" as querytable %} 951 | {{ querytable.querytable(favaledger, None, panel.queries[0].result_types, panel.queries[0].result) }} 952 | 953 | - name: Travelling 954 | panels: 955 | - title: Travel Costs per Year 📅 956 | # Note: Holidays over New Year's Eve are counted in both years aliquot. 957 | link: ../../income_statement/?filter=#travel 958 | queries: 959 | - bql: | 960 | SELECT year, CONVERT(SUM(position), '{{ledger.ccy}}', LAST(date)) AS value 961 | WHERE account ~ '^Expenses:' AND 'travel' IN tags 962 | GROUP BY year 963 | link: ../../account/Expenses/?filter=#travel&time={time} 964 | type: echarts 965 | script: | 966 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 967 | const years = utils.iterateYears(ledger.dateFirst, ledger.dateLast); 968 | const amounts = {}; 969 | 970 | // the beancount query only returns months where there was at least one matching transaction, therefore we group by year 971 | for (let row of panel.queries[0].result) { 972 | amounts[row.year] = row.value[ledger.ccy]; 973 | } 974 | 975 | return { 976 | tooltip: { 977 | valueFormatter: currencyFormatter, 978 | }, 979 | xAxis: { 980 | data: years, 981 | }, 982 | yAxis: { 983 | axisLabel: { 984 | formatter: currencyFormatter, 985 | }, 986 | }, 987 | series: [ 988 | { 989 | type: "line", 990 | smooth: true, 991 | data: years.map((year) => amounts[year] ?? 0), 992 | }, 993 | ], 994 | onClick: (event) => { 995 | const link = panel.queries[0].link.replace("{time}", event.name); 996 | window.open(helpers.urlFor(link)); 997 | }, 998 | }; 999 | 1000 | - title: Destinations ✈️ 1001 | height: 300px 1002 | link: ../../income_statement/?filter=#travel 1003 | queries: 1004 | - bql: | 1005 | SELECT tags, CONVERT(position, '{{ledger.ccy}}', date) AS value 1006 | WHERE account ~ '^Expenses:' AND 'travel' IN tags 1007 | ORDER BY date ASC 1008 | link: ../../account/Expenses/?filter=#{travel} 1009 | type: echarts 1010 | script: | 1011 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 1012 | let travels = []; 1013 | const amounts = {}; 1014 | 1015 | for (let row of panel.queries[0].result) { 1016 | const tag = row.tags.find((tag) => tag.match(/\-\d{4}/)); 1017 | if (!(tag in amounts)) { 1018 | travels.push(tag); 1019 | amounts[tag] = 0; 1020 | } 1021 | amounts[tag] += row.value.number; 1022 | } 1023 | 1024 | travels = travels.reverse(); 1025 | return { 1026 | tooltip: { 1027 | valueFormatter: currencyFormatter, 1028 | }, 1029 | grid: { 1030 | containLabel: true, 1031 | left: 0, 1032 | }, 1033 | xAxis: { 1034 | type: "value", 1035 | axisLabel: { 1036 | formatter: currencyFormatter, 1037 | }, 1038 | }, 1039 | yAxis: { 1040 | type: "category", 1041 | data: travels, 1042 | }, 1043 | series: [ 1044 | { 1045 | type: "bar", 1046 | data: travels.map((travel) => amounts[travel]), 1047 | label: { 1048 | show: true, 1049 | position: "right", 1050 | formatter: (params) => currencyFormatter(params.value), 1051 | }, 1052 | }, 1053 | ], 1054 | onClick: (event) => { 1055 | const link = panel.queries[0].link.replace("{travel}", event.name); 1056 | window.open(helpers.urlFor(link)); 1057 | }, 1058 | }; 1059 | 1060 | - name: Sankey 1061 | panels: 1062 | - title: Sankey (per month) 💸 1063 | height: 800px 1064 | link: ../../income_statement/ 1065 | queries: 1066 | - bql: | 1067 | SELECT account, CONVERT(SUM(position), '{{ledger.ccy}}') AS value 1068 | WHERE account ~ '^(Income|Expenses):' 1069 | GROUP BY account 1070 | link: ../../account/{account}/ 1071 | type: d3_sankey 1072 | script: | 1073 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 1074 | const days = (new Date(ledger.dateLast) - new Date(ledger.dateFirst)) / (1000 * 60 * 60 * 24) + 1; 1075 | const divisor = days / (365 / 12); // monthly 1076 | const valueThreshold = 10; // skip nodes below this value 1077 | 1078 | const nodes = [{ name: "Income" }]; 1079 | const links = []; 1080 | function addNode(root) { 1081 | for (let node of root.children) { 1082 | let label = node.name.split(":").pop(); 1083 | 1084 | // skip over pass-through accounts 1085 | while (node.children.length === 1) { 1086 | node = node.children[0]; 1087 | label += ":" + node.name.split(":").pop(); 1088 | } 1089 | 1090 | // skip nodes below the threshold 1091 | if (Math.abs(node.value / divisor) < valueThreshold) continue; 1092 | 1093 | nodes.push({ name: node.name, label }); 1094 | if (node.name.startsWith("Income:")) { 1095 | links.push({ source: node.name, target: root.name, value: -node.value / divisor }); 1096 | } else { 1097 | links.push({ 1098 | source: root.name == "Expenses" ? "Income" : root.name, 1099 | target: node.name, 1100 | value: node.value / divisor, 1101 | }); 1102 | } 1103 | addNode(node); 1104 | } 1105 | } 1106 | 1107 | const accountTree = utils.buildAccountTree(panel.queries[0].result, (row) => row.value[ledger.ccy] ?? 0); 1108 | if (accountTree.children.length !== 2) { 1109 | throw Error("No Income/Expense accounts found."); 1110 | } 1111 | addNode(accountTree.children[0]); 1112 | addNode(accountTree.children[1]); 1113 | 1114 | const savings = 1115 | accountTree.children[0].name === "Income" 1116 | ? -accountTree.children[0].value - accountTree.children[1].value 1117 | : -accountTree.children[1].value - accountTree.children[0].value; 1118 | if (savings > 0) { 1119 | nodes.push({ name: "Savings" }); 1120 | links.push({ source: "Income", target: "Savings", value: savings / divisor }); 1121 | } 1122 | 1123 | return { 1124 | align: "left", 1125 | valueFormatter: currencyFormatter, 1126 | data: { 1127 | nodes, 1128 | links, 1129 | }, 1130 | onClick: (event, node) => { 1131 | if (node.name === "Savings") return; 1132 | const link = panel.queries[0].link.replace("{account}", node.name); 1133 | window.open(helpers.urlFor(link)); 1134 | }, 1135 | }; 1136 | 1137 | - name: Projection 1138 | panels: 1139 | - title: Net Worth 💰 1140 | link: ../../income_statement/ 1141 | queries: 1142 | - bql: | 1143 | SELECT year, month, 1144 | CONVERT(LAST(balance), '{{ledger.ccy}}', DATE_TRUNC('month', FIRST(date)) + INTERVAL('1 month') - INTERVAL('1 day')) AS value 1145 | WHERE account_sortkey(account) ~ '^[01]' 1146 | GROUP BY year, month 1147 | link: ../../balance_sheet/?time={time} 1148 | # ignore onetime income and expenses, for example winning the lottery or wedding expenses 1149 | - bql: | 1150 | SELECT year, month, 1151 | CONVERT(LAST(balance), '{{ledger.ccy}}', DATE_TRUNC('month', FIRST(date)) + INTERVAL('1 month') - INTERVAL('1 day')) AS value 1152 | WHERE account_sortkey(account) ~ '^[01]' AND NOT 'wedding' IN tags AND NOT 'weddinggift' IN tags 1153 | GROUP BY year, month 1154 | type: echarts 1155 | script: | 1156 | const currencyFormatter = utils.currencyFormatter(ledger.ccy); 1157 | const projectYears = 2; // number of years to project 1158 | 1159 | // the beancount query only returns months where there was at least one matching transaction, therefore we group by month 1160 | const amounts = {}; 1161 | const amountsEx = {}; 1162 | for (let row of panel.queries[0].result) { 1163 | amounts[`${row.month}/${row.year}`] = row.value[ledger.ccy] ?? 0; 1164 | } 1165 | for (let row of panel.queries[1].result) { 1166 | amountsEx[`${row.month}/${row.year}`] = row.value[ledger.ccy] ?? 0; 1167 | } 1168 | 1169 | const results = panel.queries[0].result; 1170 | const resultsEx = panel.queries[1].result; 1171 | const resultsExLast = resultsEx[resultsEx.length - 1]; 1172 | 1173 | const finalAmount = results[results.length - 1].value[ledger.ccy] ?? 0; 1174 | const dateFirst = new Date(resultsEx[0].year, resultsEx[0].month - 1, 1); 1175 | const dateLast = new Date(new Date(resultsExLast.year, resultsExLast.month, 1).getTime() - 1); 1176 | const days = (dateLast - dateFirst) / (1000 * 60 * 60 * 24) + 1; 1177 | const totalDiff = (resultsExLast.value[ledger.ccy] ?? 0) - (resultsEx[0].value[ledger.ccy] ?? 0); 1178 | const monthlyDiff = (totalDiff / days) * (365 / 12); 1179 | 1180 | const dateLastYear = dateLast.getFullYear(); 1181 | const dateLastMonth = dateLast.getMonth() + 1; 1182 | const dateFirstStr = `${dateFirst.getFullYear()}-${dateFirst.getMonth() + 1}-1`; 1183 | const dateProjectUntilStr = `${dateLastYear + projectYears}-${dateLastMonth}-1`; 1184 | const months = utils.iterateMonths(dateFirstStr, dateProjectUntilStr).map((m) => `${m.month}/${m.year}`); 1185 | const lastMonthIdx = months.findIndex((m) => m === `${dateLastMonth}/${dateLastYear}`); 1186 | 1187 | const projection = {}; 1188 | let sum = finalAmount; 1189 | for (let i = lastMonthIdx; i < months.length; i++) { 1190 | projection[months[i]] = sum; 1191 | sum += monthlyDiff; 1192 | } 1193 | 1194 | return { 1195 | tooltip: { 1196 | trigger: "axis", 1197 | valueFormatter: currencyFormatter, 1198 | }, 1199 | legend: { 1200 | top: "bottom", 1201 | }, 1202 | xAxis: { 1203 | data: months, 1204 | }, 1205 | yAxis: { 1206 | axisLabel: { 1207 | formatter: currencyFormatter, 1208 | }, 1209 | }, 1210 | series: [ 1211 | { 1212 | type: "line", 1213 | name: "Net Worth", 1214 | smooth: true, 1215 | connectNulls: true, 1216 | showSymbol: false, 1217 | data: months.map((month) => amounts[month]), 1218 | }, 1219 | { 1220 | type: "line", 1221 | name: "Excluding onetime txns", 1222 | smooth: true, 1223 | connectNulls: true, 1224 | showSymbol: false, 1225 | data: months.map((month) => amountsEx[month]), 1226 | }, 1227 | { 1228 | type: "line", 1229 | name: "Projection", 1230 | lineStyle: { 1231 | type: "dashed", 1232 | }, 1233 | showSymbol: false, 1234 | data: months.map((month) => projection[month]), 1235 | }, 1236 | ], 1237 | onClick: (event) => { 1238 | if (event.seriesName === "Projection") return; 1239 | const [month, year] = event.name.split("/"); 1240 | const link = panel.queries[0].link.replace("{time}", `${year}-${month.padStart(2, "0")}`); 1241 | window.open(helpers.urlFor(link)); 1242 | }, 1243 | }; 1244 | 1245 | utils: 1246 | inline: | 1247 | function iterateMonths(dateFirst, dateLast) { 1248 | const months = []; 1249 | let [year, month] = dateFirst.split("-").map((x) => parseInt(x)); 1250 | let [lastYear, lastMonth] = dateLast.split("-").map((x) => parseInt(x)); 1251 | 1252 | while (year < lastYear || (year === lastYear && month <= lastMonth)) { 1253 | months.push({ year, month }); 1254 | if (month == 12) { 1255 | year++; 1256 | month = 1; 1257 | } else { 1258 | month++; 1259 | } 1260 | } 1261 | return months; 1262 | } 1263 | 1264 | function iterateYears(dateFirst, dateLast) { 1265 | const years = []; 1266 | let year = parseInt(dateFirst.split("-")[0]); 1267 | let lastYear = parseInt(dateLast.split("-")[0]); 1268 | 1269 | for (; year <= lastYear; year++) { 1270 | years.push(year); 1271 | } 1272 | return years; 1273 | } 1274 | 1275 | function buildAccountTree(rows, valueFn, nameFn) { 1276 | nameFn = nameFn ?? ((parts, i) => parts.slice(0, i + 1).join(":")); 1277 | 1278 | const accountTree = { children: [] }; 1279 | for (let row of rows) { 1280 | const accountParts = row.account.split(":"); 1281 | let node = accountTree; 1282 | for (let i = 0; i < accountParts.length; i++) { 1283 | const account = nameFn(accountParts, i); 1284 | let child = node.children.find((c) => c.name == account); 1285 | if (!child) { 1286 | child = { name: account, children: [], value: 0 }; 1287 | node.children.push(child); 1288 | } 1289 | 1290 | child.value += valueFn(row); 1291 | node = child; 1292 | } 1293 | } 1294 | return accountTree; 1295 | } 1296 | 1297 | function currencyFormatter(currency) { 1298 | const currencyFormat = new Intl.NumberFormat(undefined, { 1299 | style: "currency", 1300 | currency, 1301 | maximumFractionDigits: 0, 1302 | }); 1303 | return (val) => (val !== undefined ? currencyFormat.format(val) : ""); 1304 | } 1305 | 1306 | return { 1307 | iterateMonths, 1308 | iterateYears, 1309 | buildAccountTree, 1310 | currencyFormatter, 1311 | }; 1312 | -------------------------------------------------------------------------------- /example/portfolio.beancount: -------------------------------------------------------------------------------- 1 | option "operating_currency" "USD" 2 | plugin "beancount.plugins.implicit_prices" 3 | 1980-05-12 custom "fava-extension" "fava_dashboards" 4 | 5 | 6 | 2020-01-01 open Income:US:Hooli 7 | 2020-01-01 open Assets:US:GLD 8 | 2020-01-01 open Assets:US:Bank 9 | 2020-01-01 open Assets:US:Cash 10 | 11 | 2020-01-01 * "Salary" 12 | Assets:US:Bank 1000 USD 13 | Income:US:Hooli 14 | 15 | ; 2020-01 16 | ; 1000 USD 17 | ; net worth: 1000 USD, cost: 0 USD, market: 0 USD 18 | 19 | 2020-02-01 * "Buy GLD" 20 | Assets:US:GLD 100 GLD {1 USD} 21 | Assets:US:Bank 22 | 23 | ; 2020-02 24 | ; 900 USD 25 | ; 100 GLD (cost: 100 USD, worth: 100 USD) 26 | ; net worth: 1000 USD, cost: 100 USD, market: 100 USD 27 | 28 | 2020-03-15 * "Buy GLD" 29 | Assets:US:GLD 100 GLD {2 USD} 30 | Assets:US:Bank 31 | 32 | ; 2020-03 33 | ; 700 USD 34 | ; 200 GLD (cost: 300 USD, worth: 400 USD) 35 | ; net worth: 1100 USD, cost: 300 USD, market: 400 USD 36 | 37 | ; 2020-04 38 | ; 700 USD 39 | ; 200 GLD (cost: 300 USD, worth: 400 USD) 40 | ; net worth: 1100 USD, cost: 300 USD, market: 400 USD 41 | 42 | 2020-05-01 * "Buy GLD" 43 | Assets:US:GLD 100 GLD {3 USD} 44 | Assets:US:Bank 45 | 46 | ; 2020-05 47 | ; 400 USD 48 | ; 300 GLD (cost: 600 USD, worth: 900 USD) 49 | ; net worth: 1300 USD, cost: 600 USD, market: 900 USD 50 | 51 | 2020-06-01 * "Convert 200 USD to EUR" 52 | Assets:US:Bank -200 USD @ 0.5 EUR 53 | Assets:US:Cash 54 | 55 | ; 2020-06 56 | ; 200 USD 57 | ; 300 GLD (cost: 600 USD, worth: 900 USD) 58 | ; 100 EUR (cost: 200 USD, worth: 200 USD) 59 | ; net worth: 1300 USD, cost: 800 USD, market: 1100 USD 60 | 61 | 2020-07-01 * "Convert 200 USD to EUR" 62 | Assets:US:Bank -200 USD @ 0.5 EUR 63 | Assets:US:Cash 64 | 65 | 2020-07-15 price GLD 4 USD 66 | 67 | ; 2020-07 68 | ; 0 USD 69 | ; 300 GLD (cost: 600 USD, worth: 1200 USD) 70 | ; 200 EUR (cost: 400 USD, worth: 400 USD) 71 | ; net worth: 1600 USD, cost: 1000 USD, market: 1600 USD 72 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | __diff_output__ 3 | -------------------------------------------------------------------------------- /frontend/.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-organize-imports"] 3 | } 4 | -------------------------------------------------------------------------------- /frontend/jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | launch: { 3 | defaultViewport: { 4 | width: 1680, 5 | height: 1000, 6 | }, 7 | headless: true, 8 | // chrome sandbox does not work inside container 9 | args: ["--no-sandbox"], 10 | 11 | // debug 12 | // dumpio: true, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | module.exports = { 3 | preset: "jest-puppeteer", 4 | snapshotSerializers: ["jest-serializer-html"], 5 | transform: { 6 | "^.+.tsx?$": ["ts-jest", {}], 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fava-dashboards", 3 | "scripts": { 4 | "build": "esbuild src/extension.ts --bundle --format=esm --outfile=../src/fava_dashboards/FavaDashboards.js --minify", 5 | "watch": "esbuild src/extension.ts --bundle --format=esm --outfile=../src/fava_dashboards/FavaDashboards.js --watch", 6 | "test": "jest" 7 | }, 8 | "devDependencies": { 9 | "@types/d3": "^7.4.3", 10 | "@types/d3-sankey": "^0.12.4", 11 | "@types/jest": "^29.5.14", 12 | "@types/jest-image-snapshot": "^6.4.0", 13 | "esbuild": "^0.25.0", 14 | "jest": "^29.7.0", 15 | "jest-image-snapshot": "^6.4.0", 16 | "jest-puppeteer": "^11.0.0", 17 | "jest-serializer-html": "^7.1.0", 18 | "prettier": "^3.4.2", 19 | "prettier-plugin-organize-imports": "^4.1.0", 20 | "puppeteer": "^24.2.0", 21 | "ts-jest": "^29.2.5", 22 | "typescript": "^5.7.3" 23 | }, 24 | "dependencies": { 25 | "d3": "^7.9.0", 26 | "d3-sankey": "^0.12.3", 27 | "echarts": "^5.6.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as helpers from "./helpers"; 2 | import { urlFor } from "./helpers"; 3 | import * as Panels from "./panels"; 4 | import { Bootstrap, Dashboard, Ledger, Utils } from "./types"; 5 | 6 | function renderDashboard(ext: any, dashboard: Dashboard, ledger: Ledger, utils: Utils) { 7 | // add Fava filter parameters to panel links 8 | for (const elem of document.querySelectorAll(".panel a")) { 9 | const link = elem as HTMLAnchorElement; 10 | if (!(link.getAttribute("href") ?? "").includes("://")) { 11 | link.href = urlFor(link.href); 12 | } 13 | } 14 | 15 | // render panel 16 | for (let i = 0; i < dashboard.panels.length; i++) { 17 | const panel = dashboard.panels[i]; 18 | if (!panel.type || !(panel.type in Panels)) { 19 | continue; 20 | } 21 | 22 | const elem = document.getElementById(`panel${i}`) as HTMLDivElement; 23 | const ctx = { ext, ledger, utils, helpers, panel }; 24 | Panels[panel.type](ctx, elem); 25 | } 26 | } 27 | 28 | export default { 29 | onExtensionPageLoad(ext: any) { 30 | const boostrapJSON = (document.querySelector("#favaDashboardsBootstrap") as HTMLScriptElement)?.text; 31 | if (!boostrapJSON) return; 32 | 33 | const bootstrap: Bootstrap = JSON.parse(boostrapJSON); 34 | const utils: Utils = new Function(bootstrap.utils)(); 35 | renderDashboard(ext, bootstrap.dashboard, bootstrap.ledger, utils); 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated 3 | */ 4 | export const iterateMonths = (dateFirst: string, dateLast: string) => { 5 | console.warn("helpers.iterateMonths() is deprecated, please define this function in utils.inline in dashboards.yaml"); 6 | 7 | const months: { year: number; month: number }[] = []; 8 | let [year, month] = dateFirst.split("-").map((x) => parseInt(x)); 9 | let [lastYear, lastMonth] = dateLast.split("-").map((x) => parseInt(x)); 10 | 11 | while (year < lastYear || (year === lastYear && month <= lastMonth)) { 12 | months.push({ year, month }); 13 | if (month == 12) { 14 | year++; 15 | month = 1; 16 | } else { 17 | month++; 18 | } 19 | } 20 | return months; 21 | }; 22 | 23 | /** 24 | * @deprecated 25 | */ 26 | export const iterateYears = (dateFirst: string, dateLast: string) => { 27 | console.warn("helpers.iterateMonths() is deprecated, please define this function in utils.inline in dashboards.yaml"); 28 | 29 | const years: number[] = []; 30 | let year = parseInt(dateFirst.split("-")[0]); 31 | let lastYear = parseInt(dateLast.split("-")[0]); 32 | 33 | for (; year <= lastYear; year++) { 34 | years.push(year); 35 | } 36 | return years; 37 | }; 38 | 39 | interface AccountTreeNode { 40 | name: string; 41 | children: AccountTreeNode[]; 42 | value: number; 43 | } 44 | 45 | /** 46 | * @deprecated 47 | */ 48 | export const buildAccountTree = ( 49 | rows: any[], 50 | valueFn: (row: any) => number, 51 | nameFn: (parts: string[], i: number) => string, 52 | ) => { 53 | console.warn("helpers.iterateMonths() is deprecated, please define this function in utils.inline in dashboards.yaml"); 54 | 55 | nameFn = nameFn ?? ((parts: string[], i: number) => parts.slice(0, i + 1).join(":")); 56 | 57 | const accountTree: { children: AccountTreeNode[] } = { children: [] }; 58 | for (let row of rows) { 59 | const accountParts = row.account.split(":"); 60 | let node = accountTree; 61 | for (let i = 0; i < accountParts.length; i++) { 62 | const account = nameFn(accountParts, i); 63 | let child = node.children.find((c) => c.name == account); 64 | if (!child) { 65 | child = { name: account, children: [], value: 0 }; 66 | node.children.push(child); 67 | } 68 | 69 | child.value += valueFn(row); 70 | node = child; 71 | } 72 | } 73 | return accountTree; 74 | }; 75 | 76 | // https://github.com/beancount/fava/blob/fb59849ccd4535f808d295594e6db7d8a5d249a6/frontend/src/stores/url.ts#L5-L12 77 | const urlSyncedParams = ["account", "charts", "conversion", "filter", "interval", "time"]; 78 | 79 | /** 80 | * add current Fava filter parameters to url 81 | */ 82 | export const urlFor = (url: string) => { 83 | url = url.replaceAll("#", "%23"); 84 | 85 | const currentURL = new URL(window.location.href); 86 | const newURL = new URL(url, window.location.href); 87 | 88 | for (const param of urlSyncedParams) { 89 | if (currentURL.searchParams.has(param) && !newURL.searchParams.has(param)) { 90 | newURL.searchParams.set(param, currentURL.searchParams.get(param)!); 91 | } 92 | } 93 | 94 | return newURL.toString(); 95 | }; 96 | -------------------------------------------------------------------------------- /frontend/src/panels.ts: -------------------------------------------------------------------------------- 1 | import * as echartslib from "echarts"; 2 | import { render_d3sankey } from "./sankey"; 3 | import { PanelCtx } from "./types"; 4 | 5 | function runFunction(src: string, args: Record): Promise { 6 | const AsyncFunction = async function () {}.constructor; 7 | const params = Object.entries(args); 8 | const fn = AsyncFunction( 9 | params.map(([k, _]) => k), 10 | src, 11 | ); 12 | return fn(...params.map(([_, v]) => v)); 13 | } 14 | 15 | function runScript(ctx: PanelCtx) { 16 | return runFunction(ctx.panel.script!, { 17 | ...ctx, 18 | // pass 'fava' for backwards compatibility 19 | fava: ctx.ledger, 20 | }); 21 | } 22 | 23 | export async function html(ctx: PanelCtx, elem: HTMLDivElement) { 24 | try { 25 | elem.innerHTML = await runScript(ctx); 26 | } catch (e: any) { 27 | elem.innerHTML = e; 28 | } 29 | } 30 | 31 | export async function echarts(ctx: PanelCtx, elem: HTMLDivElement) { 32 | let options: echartslib.EChartsOption; 33 | try { 34 | options = await runScript(ctx); 35 | } catch (e: any) { 36 | elem.innerHTML = e; 37 | return; 38 | } 39 | 40 | // use SVG renderer during HTML e2e tests, to compare snapshots 41 | const renderer = window.navigator.userAgent === "puppeteer-html" ? "svg" : undefined; 42 | const isDarkMode = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; 43 | const theme = isDarkMode ? "dark" : undefined; 44 | const chart = echartslib.init(elem, theme, { renderer }); 45 | if (options.onClick) { 46 | chart.on("click", (options as any).onClick); 47 | delete options.onClick; 48 | } 49 | if (options.onDblClick) { 50 | chart.on("dblclick", (options as any).onDblClick); 51 | delete options.onDblClick; 52 | } 53 | if (theme === "dark" && options.backgroundColor === undefined) { 54 | options.backgroundColor = "transparent"; 55 | } 56 | if (window.navigator.userAgent.includes("puppeteer")) { 57 | // disable animations during e2e tests 58 | options.animation = false; 59 | } 60 | chart.setOption(options); 61 | } 62 | 63 | export async function d3_sankey(ctx: PanelCtx, elem: HTMLDivElement) { 64 | let options: any; 65 | try { 66 | options = await runScript(ctx); 67 | } catch (e: any) { 68 | elem.innerHTML = e; 69 | return; 70 | } 71 | 72 | render_d3sankey(elem, options); 73 | } 74 | 75 | export async function jinja2(ctx: PanelCtx, elem: HTMLDivElement) { 76 | elem.innerHTML = ctx.panel.template ?? ""; 77 | } 78 | -------------------------------------------------------------------------------- /frontend/src/sankey.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018–2020 Observable, Inc. 3 | * Copyright 2018 Mike Bostock 4 | * Copyright 2020 Fabian Iwand 5 | * Copyright 2022 Andreas Gerstmayr 6 | * 7 | * https://observablehq.com/@d3/sankey 8 | * https://gist.github.com/mootari/8d3eeb938fafbdf43cda77fe23642d00 9 | */ 10 | import * as d3Base from "d3"; 11 | import * as d3Sankey from "d3-sankey"; 12 | import { SankeyExtraProperties } from "d3-sankey"; 13 | const d3 = Object.assign(d3Base, d3Sankey); 14 | 15 | interface SankeyNodeProperties extends SankeyExtraProperties { 16 | name: string; 17 | } 18 | 19 | export function render_d3sankey(elem, options) { 20 | const data = options.data; 21 | const align = options.align ?? "left"; 22 | const valueFormat = options.valueFormatter ?? ((x) => x); 23 | const width = elem.clientWidth; 24 | const height = elem.clientHeight; 25 | const edgeColor: string = "path"; 26 | 27 | const color = (() => { 28 | const color = d3.scaleOrdinal(d3.schemeCategory10); 29 | return (d) => color(d.category === undefined ? d.name : d.category); 30 | })(); 31 | 32 | const sankey = (() => { 33 | const sankey = d3 34 | .sankey() 35 | .nodeId((d) => d.name) 36 | .nodeAlign(d3[`sankey${align[0].toUpperCase()}${align.slice(1)}`]) 37 | .nodeWidth(15) 38 | .nodePadding(10) 39 | .extent([ 40 | [1, 5], 41 | [width - 1, height - 5], 42 | ]); 43 | return ({ nodes, links }) => 44 | sankey({ 45 | nodes: nodes.map((d) => Object.assign({}, d)), 46 | links: links.map((d) => Object.assign({}, d)), 47 | }); 48 | })(); 49 | 50 | const chart = (() => { 51 | const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]); 52 | 53 | const { nodes, links } = sankey(data); 54 | 55 | // node rectangle 56 | svg 57 | .append("g") 58 | .attr("stroke", "#000") 59 | .selectAll("rect") 60 | .data(nodes) 61 | .join("rect") 62 | .attr("x", (d) => d.x0!) 63 | .attr("y", (d) => d.y0!) 64 | .attr("height", (d) => d.y1! - d.y0!) 65 | .attr("width", (d) => d.x1! - d.x0!) 66 | .attr("fill", color) 67 | .append("title") 68 | .text((d) => `${d.name}: ${valueFormat(d.value)}`); 69 | 70 | const link = svg 71 | .append("g") 72 | .attr("fill", "none") 73 | .attr("stroke-opacity", 0.5) 74 | .selectAll("g") 75 | .data(links) 76 | .join("g") 77 | .style("mix-blend-mode", "multiply"); 78 | 79 | if (edgeColor === "path") { 80 | const gradient = link 81 | .append("linearGradient") 82 | .attr("id", (d, i) => (d.uid = `link-${i}`)) 83 | .attr("gradientUnits", "userSpaceOnUse") 84 | .attr("x1", (d) => (d.source as SankeyExtraProperties).x1) 85 | .attr("x2", (d) => (d.target as SankeyExtraProperties).x0); 86 | 87 | gradient 88 | .append("stop") 89 | .attr("offset", "0%") 90 | .attr("stop-color", (d) => color(d.source)); 91 | 92 | gradient 93 | .append("stop") 94 | .attr("offset", "100%") 95 | .attr("stop-color", (d) => color(d.target)); 96 | } 97 | 98 | link 99 | .append("path") 100 | .attr("d", d3.sankeyLinkHorizontal()) 101 | .attr("stroke", (d) => 102 | edgeColor === "none" 103 | ? "#aaa" 104 | : edgeColor === "path" 105 | ? `url(#${d.uid})` 106 | : edgeColor === "input" 107 | ? color(d.source) 108 | : color(d.target), 109 | ) 110 | .attr("stroke-width", (d) => Math.max(1, d.width!)); 111 | 112 | link 113 | .append("title") 114 | .text( 115 | (d) => 116 | `${(d.source as SankeyNodeProperties).name} → ${(d.target as SankeyNodeProperties).name}: ${valueFormat( 117 | d.value, 118 | )}`, 119 | ); 120 | 121 | // node 122 | const node = svg 123 | .append("g") 124 | .attr("font-family", "sans-serif") 125 | .attr("font-size", 10) 126 | .selectAll("text") 127 | .data(nodes) 128 | .join("text") 129 | .attr("x", (d) => (d.x0! < width / 2 ? d.x1! + 6 : d.x0! - 6)) 130 | .attr("y", (d) => (d.y1! + d.y0!) / 2) 131 | .attr("dy", "0.35em") 132 | .attr("text-anchor", (d) => (d.x0! < width / 2 ? "start" : "end")) 133 | .text((d) => `${d.label ?? d.name} ${valueFormat(d.value)}`); 134 | if (options.onClick) { 135 | node.on("click", options.onClick); 136 | } 137 | 138 | return svg.node(); 139 | })(); 140 | 141 | elem.replaceChildren(chart); 142 | } 143 | -------------------------------------------------------------------------------- /frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | import * as helpers from "./helpers"; 2 | 3 | export interface Bootstrap { 4 | dashboard: Dashboard; 5 | ledger: Ledger; 6 | utils: string; 7 | } 8 | 9 | export interface Dashboard { 10 | name: string; 11 | panels: Panel[]; 12 | } 13 | 14 | export interface PanelCtx { 15 | /** Fava [`ExtensionContext`](https://github.com/beancount/fava/blob/main/frontend/src/extensions.ts) */ 16 | ext: any; 17 | 18 | /** metadata of the Beancount ledger */ 19 | ledger: Ledger; 20 | 21 | /** various helper functions */ 22 | helpers: typeof helpers; 23 | 24 | /** return value of the `utils` code of the dashboard configuration */ 25 | utils: Utils; 26 | 27 | /** current (augmented) panel definition. The results of the BQL queries can be accessed with `panel.queries[i].result`. */ 28 | panel: Panel; 29 | } 30 | 31 | interface Panel { 32 | title?: string; 33 | width?: string; 34 | height?: string; 35 | type: "html" | "jinja2" | "echarts" | "d3_sankey"; 36 | script?: string; 37 | template?: string; 38 | } 39 | 40 | export interface Ledger { 41 | /** first date in the current date filter */ 42 | dateFirst: string; 43 | 44 | /** last date in the current date filter */ 45 | dateLast: string; 46 | 47 | /** configured operating currencies of the ledger */ 48 | operatingCurrencies: string[]; 49 | 50 | /** shortcut for the first configured operating currency of the ledger */ 51 | ccy: string; 52 | 53 | /** declared accounts of the ledger */ 54 | accounts: any[]; 55 | 56 | /** declared commodities of the ledger */ 57 | commodities: any[]; 58 | } 59 | 60 | export type Utils = { 61 | [k: string]: any; 62 | }; 63 | -------------------------------------------------------------------------------- /frontend/tests/e2e/__image_snapshots__/dashboard_assets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasgerstmayr/fava-dashboards/0923c493c2c296ff37705f3659d4d4977c2f2b56/frontend/tests/e2e/__image_snapshots__/dashboard_assets.png -------------------------------------------------------------------------------- /frontend/tests/e2e/__image_snapshots__/dashboard_income_and_expenses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasgerstmayr/fava-dashboards/0923c493c2c296ff37705f3659d4d4977c2f2b56/frontend/tests/e2e/__image_snapshots__/dashboard_income_and_expenses.png -------------------------------------------------------------------------------- /frontend/tests/e2e/__image_snapshots__/dashboard_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasgerstmayr/fava-dashboards/0923c493c2c296ff37705f3659d4d4977c2f2b56/frontend/tests/e2e/__image_snapshots__/dashboard_overview.png -------------------------------------------------------------------------------- /frontend/tests/e2e/__image_snapshots__/dashboard_projection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasgerstmayr/fava-dashboards/0923c493c2c296ff37705f3659d4d4977c2f2b56/frontend/tests/e2e/__image_snapshots__/dashboard_projection.png -------------------------------------------------------------------------------- /frontend/tests/e2e/__image_snapshots__/dashboard_sankey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasgerstmayr/fava-dashboards/0923c493c2c296ff37705f3659d4d4977c2f2b56/frontend/tests/e2e/__image_snapshots__/dashboard_sankey.png -------------------------------------------------------------------------------- /frontend/tests/e2e/__image_snapshots__/dashboard_travelling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasgerstmayr/fava-dashboards/0923c493c2c296ff37705f3659d4d4977c2f2b56/frontend/tests/e2e/__image_snapshots__/dashboard_travelling.png -------------------------------------------------------------------------------- /frontend/tests/e2e/dashboards.test.ts: -------------------------------------------------------------------------------- 1 | import { toMatchImageSnapshot } from "jest-image-snapshot"; 2 | import "jest-puppeteer"; 3 | 4 | const BASE_URL = "http://127.0.0.1:5000/beancount/extension/FavaDashboards/"; 5 | const dashboards = [ 6 | { name: "Overview", url: "" }, 7 | { name: "Assets", url: "?dashboard=1" }, 8 | { name: "Income and Expenses", url: "?dashboard=2" }, 9 | { name: "Travelling", url: "?dashboard=3" }, 10 | { name: "Sankey", url: "?dashboard=4" }, 11 | { name: "Projection", url: "?dashboard=5" }, 12 | ]; 13 | 14 | function customSnapshotIdentifier(p: { currentTestName: string }) { 15 | return p.currentTestName.replace(": PNG Snapshot Tests", "").replaceAll(" ", "_").toLowerCase(); 16 | } 17 | 18 | expect.extend({ toMatchImageSnapshot }); 19 | 20 | describe("Dashboard: PNG Snapshot Tests", () => { 21 | beforeAll(async () => { 22 | await page.setUserAgent("puppeteer-png"); 23 | }); 24 | 25 | it.each(dashboards)("$name", async ({ url }) => { 26 | await page.goto(`${BASE_URL}${url}`); 27 | await page.evaluate(() => { 28 | // full page screenshot doesn't work due to sticky sidebar 29 | document.body.style.height = "inherit"; 30 | }); 31 | await page.waitForNetworkIdle(); 32 | 33 | const screenshot = await page.screenshot({ fullPage: true }); 34 | expect(Buffer.from(screenshot)).toMatchImageSnapshot({ customSnapshotIdentifier }); 35 | }); 36 | }); 37 | 38 | describe("Dashboard: HTML Snapshot Tests", () => { 39 | beforeAll(async () => { 40 | await page.setUserAgent("puppeteer-html"); 41 | }); 42 | 43 | it.each(dashboards)("$name", async ({ url }) => { 44 | await page.goto(`${BASE_URL}${url}`); 45 | await page.waitForNetworkIdle(); 46 | 47 | let html = await page.$eval("#dashboard", (element) => element.innerHTML); 48 | // remove nondeterministic rendering 49 | html = html.replaceAll(/_echarts_instance_="ec_[0-9]+"/g, ""); 50 | expect(html).toMatchSnapshot(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "moduleResolution": "node", 5 | "allowSyntheticDefaultImports": true, 6 | "skipLibCheck": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "22" 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fava-dashboards" 3 | dynamic = ["version"] 4 | description = "Custom Dashboards for Beancount in Fava" 5 | authors = [ 6 | { name = "Andreas Gerstmayr", email = "andreas@gerstmayr.me" } 7 | ] 8 | dependencies = [ 9 | "fava>=1.26.1", 10 | "pyyaml>=6.0.1", 11 | "beanquery>=0.1.0", 12 | ] 13 | readme = "README.md" 14 | requires-python = ">= 3.8" 15 | license = {text = "MIT License"} 16 | 17 | [project.urls] 18 | Source = "https://github.com/andreasgerstmayr/fava-dashboards" 19 | 20 | [build-system] 21 | requires = ["hatchling", "hatch-vcs"] 22 | build-backend = "hatchling.build" 23 | 24 | [dependency-groups] 25 | dev = [ 26 | "mypy>=1.9.0", 27 | "pylint>=3.1.0", 28 | "ruff>=0.8.1", 29 | "types-PyYAML>=6", 30 | ] 31 | 32 | [tool.hatch.metadata] 33 | allow-direct-references = true 34 | 35 | [tool.hatch.version] 36 | source = "vcs" 37 | 38 | [tool.hatch.build.targets.wheel] 39 | packages = ["src/fava_dashboards"] 40 | 41 | [tool.pylint.'messages control'] 42 | disable = [ 43 | "missing-module-docstring", 44 | "missing-class-docstring", 45 | "missing-function-docstring", 46 | "no-else-return", 47 | "line-too-long", 48 | ] 49 | 50 | [[tool.mypy.overrides]] 51 | module = "beanquery.query" 52 | ignore_missing_imports = true 53 | 54 | [tool.ruff] 55 | line-length = 120 56 | 57 | [tool.ruff.lint] 58 | extend-select = ["I"] 59 | 60 | [tool.ruff.lint.isort] 61 | force-single-line = true 62 | -------------------------------------------------------------------------------- /scripts/format_js_in_dashboard.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import subprocess 4 | 5 | 6 | def run_prettier(code, indent): 7 | code = code.replace("\n" + indent, "\n") 8 | p = subprocess.run( 9 | [ 10 | "npx", 11 | "prettier", 12 | "--stdin-filepath", 13 | "script.js", 14 | "--tab-width", 15 | "2", 16 | ], 17 | input=code.encode(), 18 | capture_output=True, 19 | check=True, 20 | cwd="frontend", 21 | ) 22 | formatted = p.stdout.decode().rstrip() 23 | intended = indent + formatted.replace("\n", "\n" + indent) 24 | # strip lines with only whitespace 25 | return intended.replace(indent + "\n", "\n") + "\n" 26 | 27 | 28 | def format_js_in_dashboard(f): 29 | # cannot use YAML parser here, because it won't preserve comments, additional newlines etc. 30 | formatted = "" 31 | 32 | for line in f: 33 | formatted += line 34 | if line == " script: |\n" or line.startswith(" script: &"): 35 | current_script = "" 36 | for line in f: 37 | if line == "\n" or line.startswith(" "): 38 | current_script += line 39 | else: 40 | formatted += run_prettier(current_script, " ") + "\n" + line 41 | break 42 | elif line == " inline: |\n": 43 | current_script = "" 44 | for line in f: 45 | if line == "\n" or line.startswith(" "): 46 | current_script += line 47 | else: 48 | formatted += run_prettier(current_script, " ") + "\n" + line 49 | break 50 | if current_script: 51 | formatted += run_prettier(current_script, " ") 52 | 53 | return formatted 54 | 55 | 56 | def main(): 57 | parser = argparse.ArgumentParser() 58 | parser.add_argument("dashboard") 59 | args = parser.parse_args() 60 | 61 | with open(args.dashboard, encoding="utf-8") as f: 62 | formatted = format_js_in_dashboard(f) 63 | with open(args.dashboard, "w", encoding="utf-8") as f: 64 | f.write(formatted) 65 | 66 | 67 | if __name__ == "__main__": 68 | main() 69 | -------------------------------------------------------------------------------- /src/fava_dashboards/__init__.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from collections import namedtuple 3 | from dataclasses import dataclass 4 | from typing import Any 5 | from typing import Dict 6 | from typing import List 7 | 8 | import yaml 9 | from beancount.core.inventory import Inventory 10 | from beanquery.query import run_query 11 | from fava.application import render_template_string 12 | from fava.beans.abc import Directive 13 | from fava.beans.abc import Price 14 | from fava.beans.abc import Transaction 15 | from fava.context import g 16 | from fava.core import FavaLedger 17 | from fava.core.conversion import simple_units 18 | from fava.ext import FavaExtensionBase 19 | from fava.ext import extension_endpoint 20 | from fava.helpers import FavaAPIError 21 | from flask import Response 22 | from flask import jsonify 23 | from flask import request 24 | 25 | ExtConfig = namedtuple("ExtConfig", ["dashboards_path"]) 26 | 27 | 28 | @dataclass(frozen=True) 29 | class PanelCtx: 30 | ledger: Dict[str, Any] 31 | favaledger: FavaLedger 32 | panel: Dict[str, Any] 33 | 34 | 35 | class FavaDashboards(FavaExtensionBase): 36 | report_title = "Dashboards" 37 | has_js_module = True 38 | 39 | def read_ext_config(self) -> ExtConfig: 40 | cfg = self.config if isinstance(self.config, dict) else {} 41 | return ExtConfig(dashboards_path=self.ledger.join_path(cfg.get("config", "dashboards.yaml"))) 42 | 43 | @staticmethod 44 | def read_dashboards_yaml(path: str): 45 | try: 46 | with open(path, encoding="utf-8") as f: 47 | return yaml.safe_load(f) 48 | except Exception as ex: 49 | raise FavaAPIError(f"cannot read configuration file {path}: {ex}") from ex 50 | 51 | def read_dashboards_utils(self, dashboards_yaml): 52 | utils = dashboards_yaml.get("utils", {}) 53 | if "inline" in utils: 54 | return utils["inline"] 55 | elif "path" in utils: 56 | path = self.ledger.join_path(utils["path"]) 57 | try: 58 | with open(path, encoding="utf-8") as f: 59 | return f.read() 60 | except Exception as ex: 61 | raise FavaAPIError(f"cannot read utils file {path}: {ex}") from ex 62 | else: 63 | return "" 64 | 65 | @staticmethod 66 | def render_template(ctx: PanelCtx, source: str) -> str: 67 | try: 68 | return render_template_string( 69 | source, 70 | # pass 'fava' for backwards compatibility 71 | fava=ctx.ledger, 72 | **ctx.__dict__, 73 | ) 74 | except Exception as ex: 75 | raise FavaAPIError(f"failed to render template {source}: {ex}") from ex 76 | 77 | def exec_query(self, query): 78 | # property added in https://github.com/beancount/fava/commit/0f43df25f0c2cab491f9b74a4ef1efe6dfcb7930 79 | entries = ( 80 | g.filtered.entries_with_all_prices if hasattr(g.filtered, "entries_with_all_prices") else g.filtered.entries 81 | ) 82 | try: 83 | rtypes, rrows = run_query(entries, self.ledger.options, query) 84 | except Exception as ex: 85 | raise FavaAPIError(f"failed to execute query {query}: {ex}") from ex 86 | 87 | # convert to legacy beancount.query format for backwards compat 88 | result_row = namedtuple("ResultRow", [col.name for col in rtypes]) 89 | rtypes = [(t.name, t.datatype) for t in rtypes] 90 | rrows = [result_row(*row) for row in rrows] 91 | 92 | return rtypes, rrows 93 | 94 | def process_queries(self, ctx: PanelCtx): 95 | for query in ctx.panel.get("queries", []): 96 | if "bql" in query: 97 | bql = self.render_template(ctx, query["bql"]) 98 | query["result_types"], query["result"] = self.exec_query(bql) 99 | 100 | def process_jinja2(self, ctx: PanelCtx): 101 | if ctx.panel.get("type") != "jinja2": 102 | return 103 | 104 | template = ctx.panel.get("template", "") 105 | ctx.panel["template"] = self.render_template(ctx, template) 106 | 107 | @staticmethod 108 | def sanitize_query_result(result): 109 | for i, row in enumerate(result): 110 | for k, v in row._asdict().items(): 111 | if isinstance(v, Inventory): 112 | result[i] = result[i]._replace(**{k: simple_units(v)}) 113 | 114 | def sanitize_panel(self, ctx): 115 | """replace or remove fields which are not JSON serializable""" 116 | for query in ctx.panel.get("queries", []): 117 | if "result" in query: 118 | self.sanitize_query_result(query["result"]) 119 | 120 | if "result_types" in query: 121 | del query["result_types"] 122 | 123 | def process_panel(self, ctx: PanelCtx): 124 | self.process_queries(ctx) 125 | self.process_jinja2(ctx) 126 | self.sanitize_panel(ctx) 127 | 128 | def get_ledger_duration(self, entries: List[Directive]): 129 | date_first = None 130 | date_last = None 131 | for entry in entries: 132 | if isinstance(entry, Transaction): 133 | date_first = entry.date 134 | break 135 | for entry in reversed(entries): 136 | if isinstance(entry, (Transaction, Price)): 137 | date_last = entry.date 138 | break 139 | if not date_first or not date_last: 140 | raise FavaAPIError("no transaction found") 141 | return (date_first, date_last) 142 | 143 | def get_ledger(self): 144 | operating_currencies = self.ledger.options["operating_currency"] 145 | if len(operating_currencies) == 0: 146 | raise FavaAPIError("no operating currency specified in the ledger") 147 | 148 | if g.filtered.date_range: 149 | filter_first = g.filtered.date_range.begin 150 | filter_last = g.filtered.date_range.end - datetime.timedelta(days=1) 151 | 152 | # Adjust the dates in case the date filter is set to e.g. 2023-2024, 153 | # however the ledger only contains data up to summer 2024. 154 | # Without this, all averages in the dashboard are off, 155 | # because of a wrong number of days between dateFirst and dateLast. 156 | ledger_date_first, ledger_date_last = self.get_ledger_duration(self.ledger.all_entries) 157 | 158 | if filter_last < ledger_date_first or filter_first > ledger_date_last: 159 | date_first = filter_first 160 | date_last = filter_last 161 | else: 162 | # use min/max only if there is some overlap between filter and ledger dates 163 | date_first = max(filter_first, ledger_date_first) 164 | date_last = min(filter_last, ledger_date_last) 165 | else: 166 | # No time filter applied. 167 | filter_first = filter_last = None 168 | # Use filtered ledger here, as another filter (e.g. tag filter) could be applied. 169 | date_first, date_last = self.get_ledger_duration(g.filtered.entries) 170 | 171 | commodities = {c.currency: c for c in self.ledger.all_entries_by_type.Commodity} 172 | accounts = self.ledger.accounts 173 | return { 174 | "dateFirst": date_first, 175 | "dateLast": date_last, 176 | "filterFirst": filter_first, 177 | "filterLast": filter_last, 178 | "operatingCurrencies": operating_currencies, 179 | "ccy": operating_currencies[0], 180 | "accounts": accounts, 181 | "commodities": commodities, 182 | } 183 | 184 | def bootstrap(self, dashboard_id): 185 | ext_config = self.read_ext_config() 186 | ledger = self.get_ledger() 187 | 188 | dashboards_yaml = self.read_dashboards_yaml(ext_config.dashboards_path) 189 | dashboards = dashboards_yaml.get("dashboards", []) 190 | if not 0 <= dashboard_id < len(dashboards): 191 | raise FavaAPIError(f"invalid dashboard ID: {dashboard_id}") 192 | 193 | for panel in dashboards[dashboard_id].get("panels", []): 194 | ctx = PanelCtx(ledger=ledger, favaledger=self.ledger, panel=panel) 195 | try: 196 | self.process_panel(ctx) 197 | except Exception as ex: 198 | raise FavaAPIError(f'error processing panel "{panel.get("title", "")}": {ex}') from ex 199 | 200 | utils = self.read_dashboards_utils(dashboards_yaml) 201 | return { 202 | "dashboards": dashboards, 203 | "ledger": ledger, 204 | "utils": utils, 205 | } 206 | 207 | @extension_endpoint("query") # type: ignore 208 | def api_query(self) -> Response: 209 | bql = request.args.get("bql") 210 | 211 | try: 212 | _, result = self.exec_query(bql) 213 | except Exception as ex: # pylint: disable=broad-exception-caught 214 | return jsonify({"success": False, "error": str(ex)}) 215 | 216 | self.sanitize_query_result(result) 217 | return jsonify({"success": True, "data": {"result": result}}) 218 | -------------------------------------------------------------------------------- /src/fava_dashboards/templates/FavaDashboards.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% set dashboard_id = request.args.get('dashboard', '0') | int %} 4 | {% set bootstrap = extension.bootstrap(dashboard_id) %} 5 | 12 | 13 |
14 | {% for dashboard in bootstrap.dashboards %} 15 |

16 | {%- if dashboard_id == loop.index0 -%} 17 | {{ dashboard.name }} 18 | {%- else -%} 19 | {{ dashboard.name }} 20 | {%- endif -%} 21 |

22 | {% endfor %} 23 |
24 | 25 |
26 | {% for panel in bootstrap.dashboards[dashboard_id].panels %} 27 |
28 | {% if panel.title %} 29 |

30 | {%- if panel.link %}{% endif -%} 31 | {{ panel.title }} 32 | {%- if panel.link %}{% endif -%} 33 |

34 | {% endif %} 35 |
36 |
37 | {% endfor %} 38 |
39 | -------------------------------------------------------------------------------- /src/fava_dashboards/templates/style.css: -------------------------------------------------------------------------------- 1 | #dashboard { 2 | display: flex; 3 | flex-wrap: wrap; 4 | justify-content: center; 5 | } 6 | #dashboard .panel { 7 | padding: 20px; 8 | min-width: 300px; 9 | } 10 | #dashboard .panel h2 { 11 | text-align: center; 12 | } 13 | #dashboard a { 14 | color: #333; /* --heading-color from fava */ 15 | } 16 | 17 | /* set panel min-width to 100% on screens < 802px (navbar + padding + 2*300px) */ 18 | @media (max-width: 802px) { 19 | #dashboard .panel { 20 | min-width: 100%; 21 | } 22 | } 23 | 24 | @media (prefers-color-scheme: dark) { 25 | #dashboard a { 26 | color: #d7dce2; /* --heading-color from fava */ 27 | } 28 | } 29 | --------------------------------------------------------------------------------