Use clusterize.js for general ledger view to avoid pagination

This commit is contained in:
RunasSudo 2024-11-16 14:31:43 +11:00
parent ace8629bc3
commit eb72c3be95
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
4 changed files with 120 additions and 57 deletions

View File

@ -14,11 +14,13 @@
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-sql": "~2", "@tauri-apps/plugin-sql": "~2",
"clusterize.js": "^1.0.0",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "4" "vue-router": "4"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/clusterize.js": "^0.18.3",
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.0.5",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.49", "postcss": "^8.4.49",

View File

@ -20,6 +20,9 @@ importers:
'@tauri-apps/plugin-sql': '@tauri-apps/plugin-sql':
specifier: ~2 specifier: ~2
version: 2.0.1 version: 2.0.1
clusterize.js:
specifier: ^1.0.0
version: 1.0.0
vue: vue:
specifier: ^3.3.4 specifier: ^3.3.4
version: 3.5.12(typescript@5.6.3) version: 3.5.12(typescript@5.6.3)
@ -30,6 +33,9 @@ importers:
'@tauri-apps/cli': '@tauri-apps/cli':
specifier: ^2 specifier: ^2
version: 2.1.0 version: 2.1.0
'@types/clusterize.js':
specifier: ^0.18.3
version: 0.18.3
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^5.0.5 specifier: ^5.0.5
version: 5.2.0(vite@5.4.11)(vue@3.5.12(typescript@5.6.3)) version: 5.2.0(vite@5.4.11)(vue@3.5.12(typescript@5.6.3))
@ -418,6 +424,9 @@ packages:
'@tauri-apps/plugin-sql@2.0.1': '@tauri-apps/plugin-sql@2.0.1':
resolution: {integrity: sha512-SxvRO/qwq/dHHGJ+79Bx4tB/wlfUE44sP1+wpuGOp11fgmfmOaf3nlZAl0P0KX+U3h0rwR/f7PMRQ6Eg408DYQ==} resolution: {integrity: sha512-SxvRO/qwq/dHHGJ+79Bx4tB/wlfUE44sP1+wpuGOp11fgmfmOaf3nlZAl0P0KX+U3h0rwR/f7PMRQ6Eg408DYQ==}
'@types/clusterize.js@0.18.3':
resolution: {integrity: sha512-udptC3aq8hfaXgmt9lC73OuE4RJYt26D2XIj+fTNDs0wuzAgQ6cyDpQOSkWhU65NroISAWhZ3/aovvV88IX7Gw==}
'@types/estree@1.0.6': '@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
@ -546,6 +555,9 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
clusterize.js@1.0.0:
resolution: {integrity: sha512-EEYhO8rOvw9JVaHLgEFdvvg9H6ug/GVl8KgakOoc9hg4FK6xmyYsC4B0Aw/QI6ClPxaGPKBetO+ISvCY8N/uUQ==}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@ -1236,6 +1248,8 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.1.1 '@tauri-apps/api': 2.1.1
'@types/clusterize.js@0.18.3': {}
'@types/estree@1.0.6': {} '@types/estree@1.0.6': {}
'@vitejs/plugin-vue@5.2.0(vite@5.4.11)(vue@3.5.12(typescript@5.6.3))': '@vitejs/plugin-vue@5.2.0(vite@5.4.11)(vue@3.5.12(typescript@5.6.3))':
@ -1395,6 +1409,8 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
clusterize.js@1.0.0: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4

View File

@ -22,52 +22,39 @@
</h1> </h1>
<div class="my-4 flex"> <div class="my-4 flex">
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button> <button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false; renderTable();">Hide commodity detail</button>
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button> <button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true; renderTable();">Show commodity detail</button>
</div> </div>
<table class="min-w-full" ref="table"> <div id="transaction-list" class="max-h-[100vh] overflow-y-scroll wk-aa">
<thead> <table class="min-w-full">
<tr> <thead>
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Date</th> <tr>
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start" colspan="3">Description</th> <th class="py-0.5 pr-1 text-gray-900 font-semibold lg:w-[12ex] text-start">Date</th>
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th> <th class="py-0.5 px-1 text-gray-900 font-semibold text-start" colspan="3">Description</th>
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Cr</th> <template v-if="commodityDetail">
</tr> <th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
</thead> <th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Cr</th>
<tbody id="transaction-list"> </template>
<template v-for="transaction in transactions" :key="transaction.id"> <template v-if="!commodityDetail">
<tr class="border-t border-gray-300"> <th class="py-0.5 px-1 text-gray-900 font-semibold lg:w-[12ex] text-end">Dr</th>
<td class="py-0.5 pr-1 text-gray-900">{{ transaction.dt.split(' ')[0] }}</td> <th class="py-0.5 pl-1 text-gray-900 font-semibold lg:w-[12ex] text-end">Cr</th>
<td class="py-0.5 px-1 text-gray-900" colspan="3">{{ transaction.description }}</td> </template>
<td></td>
<td></td>
</tr> </tr>
<template v-for="posting in transaction.postings" :key="posting.id"> </thead>
<tr> <tbody>
<td></td> <tr>
<td class="py-0.5 px-1 text-gray-900">{{ posting.description }}</td> <td colspan="4">Loading data</td>
<td class="py-0.5 px-1 text-gray-900 text-end"><i>{{ posting.quantity >= 0 ? 'Dr' : 'Cr' }}</i></td> </tr>
<td class="py-0.5 px-1 text-gray-900">{{ posting.account }}</td> </tbody>
<td class="py-0.5 px-1 text-gray-900 text-end"> </table>
{{ posting.quantity >= 0 ? (commodityDetail ? ppWithCommodity(posting.quantity, posting.commodity) : pp(asCost(posting.quantity, posting.commodity))) : '' }}
</td>
<td class="py-0.5 pl-1 text-gray-900 text-end">
{{ posting.quantity < 0 ? (commodityDetail ? ppWithCommodity(-posting.quantity, posting.commodity) : pp(asCost(-posting.quantity, posting.commodity))) : '' }}
</td>
</tr>
</template>
</template>
</tbody>
</table>
<div class="my-4 flex" v-if="transactionsOffset !== null">
<button class="btn-secondary" @click="load()">Load more</button>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import Clusterize from 'clusterize.js';
import { onUnmounted, ref } from 'vue';
import { asCost } from './commodities.ts'; import { asCost } from './commodities.ts';
import { db } from './db.ts'; import { db } from './db.ts';
@ -90,29 +77,18 @@
commodity: string commodity: string
} }
const transactions = ref([] as _Transaction[]); const transactions: _Transaction[] = [];
const transactionsOffset = ref(0 as number | null); let clusterize: Clusterize | null = null;
async function load() { async function load() {
if (transactionsOffset.value === null) {
// No more entries
return;
}
const session = await db.load(); const session = await db.load();
const transactionsRaw: {transaction_id: number, dt: string, transaction_description: string, id: number, description: string, account: string, quantity: number, commodity: string}[] = await session.select('SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity FROM transactions LEFT JOIN postings ON transactions.id = postings.transaction_id ORDER BY dt DESC, transaction_id DESC, postings.id LIMIT 200 OFFSET ?', [transactionsOffset.value]); const transactionsRaw: {transaction_id: number, dt: string, transaction_description: string, id: number, description: string, account: string, quantity: number, commodity: string}[] = await session.select('SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity FROM transactions LEFT JOIN postings ON transactions.id = postings.transaction_id ORDER BY dt DESC, transaction_id DESC, postings.id');
if (transactionsRaw.length === 0) {
// No more entries
transactionsOffset.value = null;
return;
}
// Group postings into transactions // Group postings into transactions
for (const transactionRaw of transactionsRaw) { for (const transactionRaw of transactionsRaw) {
if (transactions.value.length === 0 || transactions.value.at(-1)!.id !== transactionRaw.transaction_id) { if (transactions.length === 0 || transactions.at(-1)!.id !== transactionRaw.transaction_id) {
transactions.value.push({ transactions.push({
id: transactionRaw.transaction_id, id: transactionRaw.transaction_id,
dt: transactionRaw.dt, dt: transactionRaw.dt,
description: transactionRaw.transaction_description, description: transactionRaw.transaction_description,
@ -120,7 +96,7 @@
}); });
} }
transactions.value.at(-1)!.postings.push({ transactions.at(-1)!.postings.push({
id: transactionRaw.id, id: transactionRaw.id,
description: transactionRaw.description, description: transactionRaw.description,
account: transactionRaw.account, account: transactionRaw.account,
@ -129,7 +105,72 @@
}); });
} }
transactionsOffset.value += transactionsRaw.length; renderTable();
} }
function renderTable() {
const rows = [];
for (const transaction of transactions) {
rows.push(
`<tr class="border-t border-gray-300">
<td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ transaction.dt.split(' ')[0] }</td>
<td class="py-0.5 px-1 text-gray-900" colspan="3">${ transaction.description }</td>
<td></td>
<td></td>
</tr>`
);
for (const posting of transaction.postings) {
if (commodityDetail.value) {
rows.push(
`<tr>
<td class=""></td>
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]">${ posting.description || '' }</td>
<td class="py-0.5 px-1 text-gray-900 text-end"><i>${ posting.quantity >= 0 ? 'Dr' : 'Cr' }</i></td>
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]">${ posting.account }</td>
<td class="py-0.5 px-1 text-gray-900 text-end">
${ posting.quantity >= 0 ? ppWithCommodity(posting.quantity, posting.commodity) : '' }
</td>
<td class="py-0.5 pl-1 text-gray-900 text-end">
${ posting.quantity < 0 ? ppWithCommodity(-posting.quantity, posting.commodity) : '' }
</td>
</tr>`
);
} else {
rows.push(
`<tr>
<td class=""></td>
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]">${ posting.description || '' }</td>
<td class="py-0.5 px-1 text-gray-900 text-end"><i>${ posting.quantity >= 0 ? 'Dr' : 'Cr' }</i></td>
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]">${ posting.account }</td>
<td class="py-0.5 px-1 text-gray-900 lg:w-[12ex] text-end">
${ posting.quantity >= 0 ? pp(asCost(posting.quantity, posting.commodity)) : '' }
</td>
<td class="py-0.5 pl-1 text-gray-900 lg:w-[12ex] text-end">
${ posting.quantity < 0 ? pp(asCost(-posting.quantity, posting.commodity)) : '' }
</td>
</tr>`
);
}
}
}
if (clusterize === null) {
clusterize = new Clusterize({
'rows': rows,
scrollElem: document.getElementById('transaction-list')!,
contentElem: document.querySelector('#transaction-list tbody')!
});
} else {
clusterize.update(rows);
}
}
load(); load();
onUnmounted(() => {
if (clusterize !== null) {
clusterize.destroy();
}
});
</script> </script>

View File

@ -36,4 +36,8 @@
.page-heading { .page-heading {
@apply text-xl sm:text-base font-medium text-gray-700 print:text-xl print:text-gray-900; @apply text-xl sm:text-base font-medium text-gray-700 print:text-xl print:text-gray-900;
} }
.wk-aa {
/* When we use overflow: scroll, WebKit automatically disables antialiasing */
-webkit-font-smoothing: antialiased;
}
} }