refactor: lnbits-wallet-charts and clean up paymentFiltering (#3526)

This commit is contained in:
dni ⚡ 2025-11-20 15:01:37 +01:00 committed by GitHub
parent 3e0d0d9896
commit faac56c14c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 314 additions and 357 deletions

View file

@ -46,7 +46,7 @@
<div class="col-auto">
<div class="text-h3 q-my-none full-width">
<strong
v-text="walletFormatBalance(this.g.wallet.sat)"
v-text="formatBalance(this.g.wallet.sat)"
class="text-no-wrap"
:style="{fontSize: 'clamp(0.75rem, 10vw, 3rem)', display: 'inline-block', maxWidth: '100%'}"
></strong>
@ -179,10 +179,9 @@
<q-card class="wallet-card">
<q-card-section>
<lnbits-payment-list
@filter-changed="handleFilterChange"
:update="updatePayments"
:mobile-simple="g.mobileSimple"
:expand-details="expandDetails"
:payment-filter="paymentFilter"
></lnbits-payment-list>
</q-card-section>
</q-card>
@ -501,8 +500,7 @@
<div class="col-md-4 col-sm-12">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartConfig.showBalance"
v-model="chartConfig.showBalanceChart"
:label="$t('payments_balance_chart')"
>
</q-checkbox>
@ -511,8 +509,7 @@
<div class="col-md-4 col-sm-12">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartConfig.showBalanceInOut"
v-model="chartConfig.showBalanceInOutChart"
:label="$t('payments_balance_in_out_chart')"
>
</q-checkbox>
@ -520,8 +517,7 @@
<div class="col-md-4 col-sm-12">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartConfig.showPaymentCountInOut"
v-model="chartConfig.showPaymentInOutChart"
:label="$t('payments_count_in_out_chart')"
>
</q-checkbox>
@ -550,37 +546,10 @@
</a>
</q-card-section>
</q-card>
<div
v-show="chartDataPointCount"
class="col-12 col-md-5 q-gutter-y-md"
>
<q-card v-if="chartConfig.showBalance">
<q-card-section class="q-pa-none">
<div style="height: 200px" class="q-pa-sm">
<canvas ref="walletBalanceChart"></canvas>
</div>
</q-card-section>
</q-card>
<q-card v-if="chartConfig.showBalanceInOut">
<q-card-section class="q-pa-none">
<div style="height: 200px" class="q-pa-sm">
<canvas ref="walletBalanceInOut"></canvas>
</div>
</q-card-section>
</q-card>
<q-card v-if="chartConfig.showPaymentCountInOut">
<q-card-section class="q-pa-none">
<div style="height: 200px" class="q-pa-sm">
<canvas ref="walletPaymentsInOut"></canvas>
</div>
</q-card-section>
</q-card>
</div>
<div v-if="hasChartActive && !chartDataPointCount">
<q-card>
<q-card-section> No chart data available</q-card-section>
</q-card>
</div>
<lnbits-wallet-charts
:payment-filter="paymentFilter"
:chart-config="chartConfig"
></lnbits-wallet-charts>
</div>
</div>
@ -864,7 +833,7 @@
<div class="column content-center text-center q-mb-md">
<div v-if="!isFiatPriority">
<h4 class="q-my-none text-bold">
<span v-text="walletFormatBalance(parse.invoice.sat)"></span>
<span v-text="formatBalance(parse.invoice.sat)"></span>
</h4>
</div>
<div v-else>
@ -886,9 +855,7 @@
<div v-if="g.fiatTracking">
<div v-if="isFiatPriority">
<h5 class="q-my-none text-bold">
<span
v-text="walletFormatBalance(parse.invoice.sat)"
></span>
<span v-text="formatBalance(parse.invoice.sat)"></span>
</h5>
</div>
<div v-else style="opacity: 0.75">

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
window.app.component('lnbits-payment-list', {
template: '#lnbits-payment-list',
props: ['update', 'lazy', 'wallet'],
props: ['update', 'lazy', 'wallet', 'paymentFilter'],
mixins: [window.windowMixin],
data() {
return {
@ -31,9 +31,6 @@ window.app.component('lnbits-payment-list', {
rowsNumber: 10
},
search: '',
filter: {
'status[ne]': 'failed'
},
loading: false
},
searchDate: {from: null, to: null},
@ -156,24 +153,27 @@ window.app.component('lnbits-payment-list', {
}
}
if (this.searchDate.from) {
this.paymentsTable.filter['time[ge]'] =
this.searchDate.from + 'T00:00:00'
this.paymentFilter['time[ge]'] = this.searchDate.from + 'T00:00:00'
}
if (this.searchDate.to) {
this.paymentsTable.filter['time[le]'] = this.searchDate.to + 'T23:59:59'
this.paymentFilter['time[le]'] = this.searchDate.to + 'T23:59:59'
}
this.fetchPayments()
},
clearDateSeach() {
this.searchDate = {from: null, to: null}
delete this.paymentsTable.filter['time[ge]']
delete this.paymentsTable.filter['time[le]']
delete this.paymentFilter['time[ge]']
delete this.paymentFilter['time[le]']
this.fetchPayments()
},
fetchPayments(props) {
this.$emit('filter-changed', {...this.paymentsTable.filter})
const params = LNbits.utils.prepareFilterQuery(this.paymentsTable, props)
const params = LNbits.utils.prepareFilterQuery(
this.paymentsTable,
props,
this.paymentFilter
)
this.paymentsTable.loading = true
return LNbits.api
.getPayments(this.currentWallet, params)
.then(response => {
@ -326,34 +326,34 @@ window.app.component('lnbits-payment-list', {
},
handleFilterChanged() {
const {success, pending, failed, incoming, outgoing} = this.searchStatus
delete this.paymentsTable.filter['status[ne]']
delete this.paymentsTable.filter['status[eq]']
let paymentFilter = this.paymentFilter || {}
delete paymentFilter['status[ne]']
delete paymentFilter['status[eq]']
if (success && pending && failed) {
// No status filter
} else if (success && pending) {
this.paymentsTable.filter['status[ne]'] = 'failed'
paymentFilter['status[ne]'] = 'failed'
} else if (success && failed) {
this.paymentsTable.filter['status[ne]'] = 'pending'
paymentFilter['status[ne]'] = 'pending'
} else if (failed && pending) {
this.paymentsTable.filter['status[ne]'] = 'success'
} else if (success) {
this.paymentsTable.filter['status[eq]'] = 'success'
} else if (pending) {
this.paymentsTable.filter['status[eq]'] = 'pending'
} else if (failed) {
this.paymentsTable.filter['status[eq]'] = 'failed'
paymentFilter['status[ne]'] = 'success'
} else if (success && !pending && !failed) {
paymentFilter['status[eq]'] = 'success'
} else if (pending && !success && !failed) {
paymentFilter['status[eq]'] = 'pending'
} else if (failed && !success && !pending) {
paymentFilter['status[eq]'] = 'failed'
}
delete this.paymentsTable.filter['amount[ge]']
delete this.paymentsTable.filter['amount[le]']
if (incoming && outgoing) {
delete paymentFilter['amount[ge]']
delete paymentFilter['amount[le]']
if ((incoming && outgoing) || (!incoming && !outgoing)) {
// do nothing
} else if (incoming) {
this.paymentsTable.filter['amount[ge]'] = 0
} else if (outgoing) {
this.paymentsTable.filter['amount[le]'] = 0
} else if (incoming && !outgoing) {
paymentFilter['amount[ge]'] = 0
} else if (outgoing && !incoming) {
paymentFilter['amount[le]'] = 0
}
this.paymentFilter = paymentFilter
}
},
watch: {

View file

@ -0,0 +1,226 @@
window.app.component('lnbits-wallet-charts', {
template: '#lnbits-wallet-charts',
mixins: [window.windowMixin],
props: ['paymentFilter', 'chartConfig'],
data() {
return {
debounceTimeoutValue: 1337,
debounceTimeout: null,
chartData: [],
chartDataPointCount: 0,
walletBalanceChart: null,
walletBalanceInOut: null,
walletPaymentInOut: null,
colorPrimary: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('primary'),
0.3
),
colorSecondary: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('secondary'),
0.3
),
barOptions: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true
},
y: {
stacked: true
}
}
}
}
},
watch: {
paymentFilter: {
deep: true,
handler() {
this.changeCharts()
}
},
chartConfig: {
deep: true,
handler(val) {
this.$q.localStorage.setItem('lnbits.wallets.chartConfig', val)
this.changeCharts()
}
}
},
methods: {
// Debounce chart drawing and data fetching, if its called multiple times in quick succession
// (e.g. when changing filters) chart.js will error because of a race condition trying to
// destroy and redraw charts at the same time
changeCharts() {
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout)
}
this.debounceTimeout = setTimeout(async () => {
await this.fetchChartData()
this.drawCharts()
}, this.debounceTimeoutValue)
},
filterChartData() {
const timeFrom = this.paymentFilter['time[ge]'] + 'T00:00:00'
const timeTo = this.paymentFilter['time[le]'] + 'T23:59:59'
let totalBalance = 0
let data = this.chartData.map(p => {
if (this.paymentFilter['amount[ge]'] !== undefined) {
totalBalance += p.balance_in
return {...p, balance: totalBalance, balance_out: 0, count_out: 0}
}
if (this.paymentFilter['amount[le]'] !== undefined) {
totalBalance -= p.balance_out
return {...p, balance: totalBalance, balance_in: 0, count_in: 0}
}
return {...p}
})
data = data.filter(p => {
if (this.paymentFilter['time[ge]'] && this.paymentFilter['time[le]']) {
return p.date >= timeFrom && p.date <= timeTo
}
if (this.paymentFilter['time[ge]']) {
return p.date >= timeFrom
}
if (this.paymentFilter['time[le]']) {
return p.date <= timeTo
}
return true
})
const labels = data.map(s =>
new Date(s.date).toLocaleString('default', {
month: 'short',
day: 'numeric'
})
)
this.chartDataPointCount = data.length
return {data, labels}
},
drawBalanceInOutChart(data, labels) {
if (this.walletBalanceInOut) {
this.walletBalanceInOut.destroy()
}
this.walletBalanceInOut = new Chart(
this.$refs.walletBalanceInOut.getContext('2d'),
{
type: 'bar',
options: this.barOptions,
data: {
labels,
datasets: [
{
label: 'Balance In',
borderRadius: 5,
data: data.map(s => s.balance_in),
backgroundColor: this.colorPrimary
},
{
label: 'Balance Out',
borderRadius: 5,
data: data.map(s => s.balance_out),
backgroundColor: this.colorSecondary
}
]
}
}
)
},
drawPaymentInOut(data, labels) {
if (this.walletPaymentInOut) {
this.walletPaymentInOut.destroy()
}
this.walletPaymentInOut = new Chart(
this.$refs.walletPaymentInOut.getContext('2d'),
{
type: 'bar',
options: this.barOptions,
data: {
labels,
datasets: [
{
label: 'Payments In',
data: data.map(s => s.count_in),
backgroundColor: this.colorPrimary
},
{
label: 'Payments Out',
data: data.map(s => -s.count_out),
backgroundColor: this.colorSecondary
}
]
}
}
)
},
drawBalanceChart(data, labels) {
const ref = this.$refs.walletBalanceChart
if (this.walletBalanceChart) {
this.walletBalanceChart.destroy()
}
this.walletBalanceChart = new Chart(ref.getContext('2d'), {
type: 'line',
options: {
responsive: true,
maintainAspectRatio: false
},
data: {
labels,
datasets: [
{
label: 'Balance',
data: data.map(s => s.balance),
pointStyle: false,
backgroundColor: this.colorPrimary,
borderColor: this.colorPrimary,
borderWidth: 2,
fill: true,
tension: 0.7,
fill: 1
},
{
label: 'Fees',
data: data.map(s => s.fee),
pointStyle: false,
backgroundColor: this.colorSecondary,
borderColor: this.colorSecondary,
borderWidth: 1,
fill: true,
tension: 0.7,
fill: 1
}
]
}
})
},
drawCharts() {
const {data, labels} = this.filterChartData()
if (this.chartConfig.showBalanceChart) {
this.drawBalanceChart(data, labels)
}
if (this.chartConfig.showBalanceInOutChart) {
this.drawBalanceInOutChart(data, labels)
}
if (this.chartConfig.showPaymentInOutChart) {
this.drawPaymentInOut(data, labels)
}
},
async fetchChartData() {
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/payments/stats/daily?wallet_id=${this.g.wallet.id}`
)
this.chartData = data
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
}
},
async created() {
await this.fetchChartData()
this.drawCharts()
}
})

View file

@ -87,8 +87,8 @@ window._lnbitsUtils = {
return data
}
},
prepareFilterQuery(tableConfig, props) {
tableConfig.filter = tableConfig.filter || {}
prepareFilterQuery(tableConfig, props, filter) {
tableConfig.filter = filter || tableConfig.filter || {}
if (props) {
tableConfig.pagination = props.pagination
Object.assign(tableConfig.filter, props.filter)

View file

@ -109,23 +109,21 @@ window.WalletPageLogic = {
name: null,
currency: null
},
walletBalanceChart: null,
inkeyHidden: true,
adminkeyHidden: true,
walletIdHidden: true,
hasNfc: false,
nfcReaderAbortController: null,
isFiatPriority: false,
formattedFiatAmount: 0,
formattedExchange: null,
chartData: [],
chartDataPointCount: 0,
chartConfig: {
showBalance: true,
showBalanceInOut: true,
showPaymentCountInOut: true
paymentFilter: {
'status[ne]': 'failed'
},
paymentsFilter: {}
chartConfig: Quasar.LocalStorage.getItem(
'lnbits.wallets.chartConfig'
) || {
showPaymentInOutChart: true,
showBalanceChart: true,
showBalanceInOutChart: true
}
}
},
computed: {
@ -164,16 +162,6 @@ window.WalletPageLogic = {
},
formattedSatAmount() {
return LNbits.utils.formatMsat(this.receive.amountMsat) + ' sat'
},
wallet() {
return this.g.wallet
},
hasChartActive() {
return (
this.chartConfig.showBalance ||
this.chartConfig.showBalanceInOut ||
this.chartConfig.showPaymentCountInOut
)
}
},
methods: {
@ -820,256 +808,6 @@ window.WalletPageLogic = {
this.g.fiatTracking = false
}
},
walletFormatBalance(amount) {
if (LNBITS_DENOMINATION != 'sats') {
return LNbits.utils.formatCurrency(amount / 100, LNBITS_DENOMINATION)
} else {
return LNbits.utils.formatSat(amount) + ' sats'
}
},
handleFilterChange(value = {}) {
if (
this.paymentsFilter['time[ge]'] !== value['time[ge]'] ||
this.paymentsFilter['time[le]'] !== value['time[le]'] ||
this.paymentsFilter['amount[ge]'] !== value['amount[ge]'] ||
this.paymentsFilter['amount[le]'] !== value['amount[le]']
) {
this.refreshCharts()
}
this.paymentsFilter = value
},
async fetchChartData() {
if (this.g.mobileSimple) {
this.chartConfig = {}
return
}
if (!this.hasChartActive) {
return
}
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/payments/stats/daily?wallet_id=${this.g.wallet.id}`
)
this.chartData = data
this.refreshCharts()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
filterChartData(data) {
const timeFrom = this.paymentsFilter['time[ge]'] + 'T00:00:00'
const timeTo = this.paymentsFilter['time[le]'] + 'T23:59:59'
let totalBalance = 0
data = data.map(p => {
if (this.paymentsFilter['amount[ge]'] !== undefined) {
totalBalance += p.balance_in
return {...p, balance: totalBalance, balance_out: 0, count_out: 0}
}
if (this.paymentsFilter['amount[le]'] !== undefined) {
totalBalance -= p.balance_out
return {...p, balance: totalBalance, balance_in: 0, count_in: 0}
}
return {...p}
})
data = data.filter(p => {
if (
this.paymentsFilter['time[ge]'] &&
this.paymentsFilter['time[le]']
) {
return p.date >= timeFrom && p.date <= timeTo
}
if (this.paymentsFilter['time[ge]']) {
return p.date >= timeFrom
}
if (this.paymentsFilter['time[le]']) {
return p.date <= timeTo
}
return true
})
const labels = data.map(s =>
new Date(s.date).toLocaleString('default', {
month: 'short',
day: 'numeric'
})
)
this.chartDataPointCount = data.length
return {data, labels}
},
refreshCharts() {
const originalChartConfig = this.chartConfig || {}
this.chartConfig = {}
setTimeout(() => {
const chartConfig =
this.$q.localStorage.getItem('lnbits.wallets.chartConfig') ||
originalChartConfig
this.chartConfig = {...originalChartConfig, ...chartConfig}
}, 10)
setTimeout(() => {
this.drawCharts(this.chartData)
}, 100)
},
drawCharts(allData) {
try {
const {data, labels} = this.filterChartData(allData)
if (this.chartConfig.showBalance) {
if (this.walletBalanceChart) {
this.walletBalanceChart.destroy()
}
this.walletBalanceChart = new Chart(
this.$refs.walletBalanceChart.getContext('2d'),
{
type: 'line',
options: {
responsive: true,
maintainAspectRatio: false
},
data: {
labels,
datasets: [
{
label: 'Balance',
data: data.map(s => s.balance),
pointStyle: false,
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('primary'),
0.3
),
borderColor: Quasar.colors.getPaletteColor('primary'),
borderWidth: 2,
fill: true,
tension: 0.7,
fill: 1
},
{
label: 'Fees',
data: data.map(s => s.fee),
pointStyle: false,
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('secondary'),
0.3
),
borderColor: Quasar.colors.getPaletteColor('secondary'),
borderWidth: 1,
fill: true,
tension: 0.7,
fill: 1
}
]
}
}
)
}
if (this.chartConfig.showBalanceInOut) {
if (this.walletBalanceInOut) {
this.walletBalanceInOut.destroy()
}
this.walletBalanceInOut = new Chart(
this.$refs.walletBalanceInOut.getContext('2d'),
{
type: 'bar',
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true
},
y: {
stacked: true
}
}
},
data: {
labels,
datasets: [
{
label: 'Balance In',
borderRadius: 5,
data: data.map(s => s.balance_in),
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('primary'),
0.3
)
},
{
label: 'Balance Out',
borderRadius: 5,
data: data.map(s => s.balance_out),
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('secondary'),
0.3
)
}
]
}
}
)
}
if (this.chartConfig.showPaymentCountInOut) {
if (this.walletPaymentsInOut) {
this.walletPaymentsInOut.destroy()
}
this.walletPaymentsInOut = new Chart(
this.$refs.walletPaymentsInOut.getContext('2d'),
{
type: 'bar',
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true
},
y: {
stacked: true
}
}
},
data: {
labels,
datasets: [
{
label: 'Payments In',
data: data.map(s => s.count_in),
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('primary'),
0.3
)
},
{
label: 'Payments Out',
data: data.map(s => -s.count_out),
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('secondary'),
0.3
)
}
]
}
}
)
}
} catch (error) {
console.warn(error)
}
},
saveChartsPreferences() {
this.$q.localStorage.set('lnbits.wallets.chartConfig', this.chartConfig)
this.refreshCharts()
},
updatePaylinks() {
LNbits.api
.request(
@ -1122,11 +860,6 @@ window.WalletPageLogic = {
this.parse.show = true
}
this.createdTasks()
try {
this.fetchChartData()
} catch (error) {
console.warn(`Chart creation failed: ${error}`)
}
},
watch: {
'g.updatePayments'(newVal, oldVal) {

View file

@ -67,6 +67,7 @@
"js/components/admin/lnbits-admin-site-customisation.js",
"js/components/admin/lnbits-admin-assets-config.js",
"js/components/admin/lnbits-admin-audit.js",
"js/components/lnbits-wallet-charts.js",
"js/components/lnbits-wallet-api-docs.js",
"js/components/lnbits-wallet-new.js",
"js/components/lnbits-wallet-share.js",

View file

@ -21,7 +21,8 @@ include('components/lnbits-language-dropdown.vue') %} {%
include('components/lnbits-payment-list.vue') %} {%
include('components/lnbits-wallet-new.vue') %} {%
include('components/lnbits-wallet-api-docs.vue') %} {%
include('components/lnbits-wallet-share.vue') %}
include('components/lnbits-wallet-share.vue') %} {%
include('components/lnbits-wallet-charts.vue') %}
<template id="lnbits-manage">
<q-list v-if="g.user" dense class="lnbits-drawer__q-list">

View file

@ -171,7 +171,7 @@
:row-key="paymentTableRowKey"
:columns="paymentsTable.columns"
:no-data-label="$t('no_transactions')"
:filter="paymentsTable.filter"
:filter="paymentFilter"
:loading="paymentsTable.loading"
:hide-header="g.mobileSimple"
:hide-bottom="g.mobileSimple"

View file

@ -0,0 +1,28 @@
<template id="lnbits-wallet-charts">
<div
:style="!chartDataPointCount ? 'display:none;' : ''"
class="col-12 col-md-5 q-gutter-y-md"
>
<q-card :style="chartConfig.showBalanceChart ? '' : 'display: none;'">
<q-card-section class="q-pa-none">
<div style="height: 200px" class="q-pa-sm">
<canvas ref="walletBalanceChart"></canvas>
</div>
</q-card-section>
</q-card>
<q-card :style="chartConfig.showBalanceInOutChart ? '' : 'display: none;'">
<q-card-section class="q-pa-none">
<div style="height: 200px" class="q-pa-sm">
<canvas ref="walletBalanceInOut"></canvas>
</div>
</q-card-section>
</q-card>
<q-card :style="chartConfig.showPaymentInOutChart ? '' : 'display: none;'">
<q-card-section class="q-pa-none">
<div style="height: 200px" class="q-pa-sm">
<canvas ref="walletPaymentInOut"></canvas>
</div>
</q-card-section>
</q-card>
</div>
</template>

View file

@ -119,6 +119,7 @@
"js/components/admin/lnbits-admin-site-customisation.js",
"js/components/admin/lnbits-admin-assets-config.js",
"js/components/admin/lnbits-admin-audit.js",
"js/components/lnbits-wallet-charts.js",
"js/components/lnbits-wallet-api-docs.js",
"js/components/lnbits-wallet-new.js",
"js/components/lnbits-wallet-share.js",