taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit e55a5551fee3e4853c381525d70dbe458716ef56
parent f7f07e78de65811c499955b5787224c6df3f9ff4
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Mon, 24 Nov 2025 15:36:41 +0900

Statistics: add better filtering by ranges

Diffstat:
Mpackages/merchant-backoffice-ui/src/paths/instance/statistics/list/RevenueChart.tsx | 184+++++++++++++++++++++++--------------------------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/statistics/list/index.tsx | 481+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 514 insertions(+), 151 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/RevenueChart.tsx b/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/RevenueChart.tsx @@ -19,27 +19,25 @@ * @author Martin Schanzenbach */ -import { AbsoluteTime, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { AbsoluteTime } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { DatePicker } from "../../../../components/picker/DatePicker.js"; -import { dateFormatForSettings, usePreference } from "../../../../hooks/preference.js"; -import { CardTable } from "./Table.js"; -import { WithId } from "../../../../declaration.js"; import { Bar } from "react-chartjs-2"; -import { ChartOptions } from "chart.js"; +import { FormProvider } from "../../../../components/form/FormProvider.js"; +import { InputSelector } from "../../../../components/form/InputSelector.js"; +import { InputNumber } from "../../../../components/form/InputNumber.js"; +import { InputGroup } from "../../../../components/form/InputGroup.js"; export interface RevenueChartProps { currencyString: string, chartData: any; chartLabels: any; chartOptions: any; - onShowRange: (range: string) => void; - activeTimeRange: string; - filterFromDate?: AbsoluteTime; - onSelectDate: (date?: AbsoluteTime) => void; + revenueRanges: string[]; + onShowRange: (rangeCount: number, range?: string) => void; + activeRange: {range?: string, rangeCount: number}; } export function RevenueChart({ @@ -47,139 +45,63 @@ export function RevenueChart({ chartData, chartLabels, chartOptions, + revenueRanges, onShowRange, - activeTimeRange, - filterFromDate, - onSelectDate, + activeRange: activeRange, }: RevenueChartProps): VNode { const { i18n } = useTranslationContext(); - const [settings] = usePreference(); - const dateTooltip = i18n.str`Select date from which to show statistics`; - const [pickDate, setPickDate] = useState(false); + const translateMap = new Map<string, [string, string]>([ + ["hour", [i18n.str`hour`, i18n.str`hours`]], + ["day", [i18n.str`day`, i18n.str`days`]], + ["week", [i18n.str`week`, i18n.str`weeks`]], + ["month", [i18n.str`month`, i18n.str`months`]], + ["quarter", [i18n.str`quarter`, i18n.str`quarters`]], + ["year", [i18n.str`years`, i18n.str`years`]], + ]) + function translateRange(range: {rangeCount: number, range?: string}) : string { + if (!translateMap.has(range.range!)) { + return ""; + } + return (range.rangeCount < 2) ? translateMap.get(range.range!)![0] : translateMap.get(range.range!)![1]; + } + + const form = activeRange; return ( <Fragment> <div class="columns"> <div class="column is-two-thirds"> - <div class="tabs" style={{ overflow: "inherit" }}> - <ul> - <li class={(activeTimeRange === "year") ? "is-active" : ""}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`Show yearly data`} - > - <a onClick={() => {onShowRange("year") }}> - <i18n.Translate>Yearly</i18n.Translate> - </a> - </div> - </li> - <li class={(activeTimeRange === "quarter") ? "is-active" : ""}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`Show quarterly data`} - > - <a onClick={() => {onShowRange("quarter") }}> - <i18n.Translate>Quarterly</i18n.Translate> - </a> - </div> - </li> - <li class={(activeTimeRange === "month") ? "is-active" : ""}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`Show monthly data`} - > - <a onClick={() => {onShowRange("month") }}> - <i18n.Translate>Monthly</i18n.Translate> - </a> - </div> - </li> - <li class={(activeTimeRange === "week") ? "is-active" : ""}> - <div - class="has-tooltip-left" - data-tooltip={i18n.str`Show weekly data`} - > - <a onClick={() => {onShowRange("week") }}> - <i18n.Translate>Weekly</i18n.Translate> - </a> - </div> - </li> - <li class={(activeTimeRange === "day") ? "is-active" : ""}> - <div - class="has-tooltip-left" - data-tooltip={i18n.str`Show daily data`} - > - <a onClick={() => {onShowRange("day") }}> - <i18n.Translate>Daily</i18n.Translate> - </a> - </div> - </li> - <li class={(activeTimeRange === "hour") ? "is-active" : ""}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`Show hourly data`} - > - <a onClick={() => {onShowRange("hour") }}> - <i18n.Translate>Hourly</i18n.Translate> - </a> - </div> - </li> - </ul> - </div> + {i18n.str`Revenue statistics over the past ${activeRange.rangeCount} ${translateRange(activeRange)} for currency '${currencyString}'`} </div> <div class="column "> - <div class="buttons is-right"> - <div class="field has-addons"> - {filterFromDate && ( - <div class="control"> - <a class="button is-fullwidth" onClick={() => onSelectDate(undefined)}> - <span - class="icon" - data-tooltip={i18n.str`Clear date filter`} - > - <i class="mdi mdi-close" /> - </span> - </a> - </div> - )} - <div class="control"> - <span class="has-tooltip-top" data-tooltip={dateTooltip}> - <input - class="input" - type="text" - readonly - value={!filterFromDate || filterFromDate.t_ms === "never" ? "" : format(filterFromDate.t_ms, dateFormatForSettings(settings))} - placeholder={i18n.str`Start from (${dateFormatForSettings(settings)})`} - onClick={() => { - setPickDate(true); - }} - /> - </span> - </div> - <div class="control"> - <span class="has-tooltip-left" data-tooltip={dateTooltip}> - <a - class="button is-fullwidth" - onClick={() => { - setPickDate(true); - }} - > - <span class="icon"> - <i class="mdi mdi-calendar" /> - </span> - </a> - </span> - </div> - </div> - </div> + <FormProvider + object={form} + valueHandler={(updater) => onShowRange(updater(form).rangeCount ?? 0, updater(form).range)} + > + <InputGroup + name="rangeFilter" + label={i18n.str`Filter`} + > + <InputSelector + name="range" + label={i18n.str`Time range`} + values={revenueRanges} + fromStr={(d) => { + const idx = revenueRanges.indexOf(d) + if (idx === -1) return undefined; + return d + }} + tooltip={i18n.str`Select time range to group by`} + /> + <InputNumber + name="rangeCount" + label={translateMap.get(activeRange.range!)![0]} + + /> + </InputGroup> + </FormProvider> </div> </div> - <DatePicker - opened={pickDate} - closeFunction={() => setPickDate(false)} - dateReceiver={(d) => { - onSelectDate(AbsoluteTime.fromMilliseconds(d.getTime())) - }} - /> {(chartData && chartLabels.length > 0) ? ( <Bar data={{labels: chartLabels, datasets: chartData}} options={chartOptions}/> ) : ( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/index.tsx @@ -93,18 +93,447 @@ const chartColors = new Map<string, string>(); chartColors.set("orders-paid", '#525597'); chartColors.set("refunds-granted", '#525597'); +interface Form { + range?: string; + rangeCount: number; +} + +function addTestValues( + ordersStats: Map<string, StatisticsCounter>, + revenueStats: Map<string, StatisticsAmount>, +) { + // FIXME only for testing; + const ordersCreated = { + "intervals": [ + { + "start_time": { + "t_s": 1763155101 + }, + "cumulative_counter": 0 + }, + { + "start_time": { + "t_s": 1763155041 + }, + "cumulative_counter": 19 + }, + { + "start_time": { + "t_s": 1763154901 + }, + "cumulative_counter": 2 + }, + { + "start_time": { + "t_s": 1760153641 + }, + "cumulative_counter": 9 + } + ], + "buckets": [], + "intervals_description": "number of orders created (but not necessarily claimed by wallets)" + }; + const ordersClaimed = { + "intervals": [ + { + "start_time": { + "t_s": 1763155101 + }, + "cumulative_counter": 5 + }, + { + "start_time": { + "t_s": 1763155041 + }, + "cumulative_counter": 19 + }, + { + "start_time": { + "t_s": 1763154901 + }, + "cumulative_counter": 2 + }, + { + "start_time": { + "t_s": 1763154641 + }, + "cumulative_counter": 9 + }, + { + "start_time": { + "t_s": 1760153641 + }, + "cumulative_counter": 3 + } + ], + "buckets": [], + "intervals_description": "number of orders claimed" + }; + const ordersPaid = { + "intervals": [ + { + "start_time": { + "t_s": 1763155101 + }, + "cumulative_counter": 5 + }, + { + "start_time": { + "t_s": 1763155041 + }, + "cumulative_counter": 19 + }, + { + "start_time": { + "t_s": 1763154901 + }, + "cumulative_counter": 2 + }, + { + "start_time": { + "t_s": 1763154641 + }, + "cumulative_counter": 9 + }, + { + "start_time": { + "t_s": 1760153641 + }, + "cumulative_counter": 3 + } + ], + "buckets": [], + "intervals_description": "number of orders paid" + }; + const ordersSettled = { + "intervals": [ + { + "start_time": { + "t_s": 1763155101 + }, + "cumulative_counter": 4 + }, + { + "start_time": { + "t_s": 1763155041 + }, + "cumulative_counter": 19 + }, + { + "start_time": { + "t_s": 1763154901 + }, + "cumulative_counter": 2 + }, + { + "start_time": { + "t_s": 1763154641 + }, + "cumulative_counter": 9 + }, + { + "start_time": { + "t_s": 1760153641 + }, + "cumulative_counter": 3 + } + ], + "buckets": [], + "intervals_description": "number of orders settled" + } + const depositFees: StatisticsAmount = { + "intervals": [], + "buckets": [ + { + "start_time": { + "t_s": 1763157600 + }, + "end_time": { + "t_s": 1763078400 + }, + "range": "hour", + "cumulative_amounts": [ + "EUR:0.11" + ] + }, + { + "start_time": { + "t_s": 1763078400 + }, + "end_time": { + "t_s": 1763164800 + }, + "range": "day", + "cumulative_amounts": [ + "EUR:0.21" + ] + }, + { + "start_time": { + "t_s": 1762732800 + }, + "end_time": { + "t_s": 1763337600 + }, + "range": "week", + "cumulative_amounts": [ + "EUR:0.31" + ] + }, + { + "start_time": { + "t_s": 1761955200 + }, + "end_time": { + "t_s": 1764547200 + }, + "range": "month", + "cumulative_amounts": [ + "EUR:0.51" + ] + }, + { + "start_time": { + "t_s": 1751328000 + }, + "end_time": { + "t_s": 1759276800 + }, + "range": "quarter", + "cumulative_amounts": [ + "EUR:3.10" + ] + }, + { + "start_time": { + "t_s": 1759276800 + }, + "end_time": { + "t_s": 1767225600 + }, + "range": "quarter", + "cumulative_amounts": [ + "EUR:1.11" + ] + }, + { + "start_time": { + "t_s": 1735689600 + }, + "end_time": { + "t_s": 1767225600 + }, + "range": "year", + "cumulative_amounts": [ + "EUR:2.11" + ] + } + ], + "buckets_description": "deposit fees we or our customers paid to the exchange (includes those waived on refunds)" + } + const paymentsReceived: StatisticsAmount = { + "intervals": [], + "buckets": [ + { + "start_time": { + "t_s": 1763146800 + }, + "end_time": { + "t_s": 1763078400 + }, + "range": "hour", + "cumulative_amounts": [ + "EUR:4.89" + ] + }, + { + "start_time": { + "t_s": 1763078400 + }, + "end_time": { + "t_s": 1763164800 + }, + "range": "day", + "cumulative_amounts": [ + "EUR:14.89" + ] + }, + { + "start_time": { + "t_s": 1762732800 + }, + "end_time": { + "t_s": 1763337600 + }, + "range": "week", + "cumulative_amounts": [ + "EUR:27.89" + ] + }, + { + "start_time": { + "t_s": 1761955200 + }, + "end_time": { + "t_s": 1764547200 + }, + "range": "month", + "cumulative_amounts": [ + "EUR:34.89" + ] + }, + { + "start_time": { + "t_s": 1751328000 + }, + "end_time": { + "t_s": 1759276800 + }, + "range": "quarter", + "cumulative_amounts": [ + "EUR:18.99" + ] + }, + { + "start_time": { + "t_s": 1759276800 + }, + "end_time": { + "t_s": 1767225600 + }, + "range": "quarter", + "cumulative_amounts": [ + "EUR:34.89" + ] + }, + { + "start_time": { + "t_s": 1735689600 + }, + "end_time": { + "t_s": 1767225600 + }, + "range": "year", + "cumulative_amounts": [ + "EUR:54.89" + ] + } + ], + "buckets_description": "amount customers paid to us (excluded deposit fees paid by us or customers, wire fees are still deducted by the exchange)" + } + const testRefundsBuckets: StatisticsAmount = { + intervals: [], + buckets: [ + { + "start_time": { + "t_s": 1763146800 + }, + "end_time": { + "t_s": 1763078400 + }, + "range": "hour", + "cumulative_amounts": [ + "EUR:0" + ] + }, + { + "start_time": { + "t_s": 1763078400 + }, + "end_time": { + "t_s": 1763164800 + }, + "range": "day", + "cumulative_amounts": [ + "EUR:2" + ] + }, + { + "start_time": { + "t_s": 1762732800 + }, + "end_time": { + "t_s": 1763337600 + }, + "range": "week", + "cumulative_amounts": [ + "EUR:3" + ] + }, + { + "start_time": { + "t_s": 1761955200 + }, + "end_time": { + "t_s": 1764547200 + }, + "range": "month", + "cumulative_amounts": [ + "EUR:4" + ] + }, + { + "start_time": { + "t_s": 1751328000 + }, + "end_time": { + "t_s": 1759276800 + }, + "range": "quarter", + "cumulative_amounts": [ + "EUR:8" + ] + }, + { + "start_time": { + "t_s": 1759276800 + }, + "end_time": { + "t_s": 1767225600 + }, + "range": "quarter", + "cumulative_amounts": [ + "EUR:6" + ] + }, + { + "start_time": { + "t_s": 1735689600 + }, + "end_time": { + "t_s": 1767225600 + }, + "range": "year", + "cumulative_amounts": [ + "EUR:7", + "KUDOS:23" + ] + } + ], + "buckets_description": "refunds granted by us to our customers" + } + ordersStats.set("orders-created", ordersCreated); + ordersStats.set("orders-claimed", ordersClaimed); + ordersStats.set("orders-paid", ordersPaid); + ordersStats.set("orders-settled", ordersSettled); + revenueStats.set("payments-received-after-deposit-fee", paymentsReceived); + revenueStats.set("total-deposit-fees-paid", depositFees); + revenueStats.set("refunds-granted", testRefundsBuckets); +} + export default function Statistics({ }: Props): VNode { const { i18n } = useTranslationContext(); - const [selectedRange, setSelectedRange] = useState<string>("year"); + const [rangeFilter, setRangeFilter] = useState(new Map<string, Form>()); const [notif, _] = useState<Notification | undefined>(undefined); const ordersStats = new Map<string, StatisticsCounter>(); + const revenueStats = new Map<string, StatisticsAmount>(); const currentDate = new Date(); const lastMonthDate = sub(currentDate, { months: 1 }); const lastMonthAbs: AbsoluteTime = {t_ms: lastMonthDate.getMilliseconds() } as AbsoluteTime; - const [startRevenueFromDate, setStartRevenueFromDate] = useState(new Map<string, AbsoluteTime | undefined>()); const [startOrdersFromDate, setStartOrdersFromDate] = useState<AbsoluteTime | undefined>(lastMonthAbs); + for (let ordersSlug of ["orders-created", "orders-claimed", "orders-paid", "orders-settled"]) { let res = useInstanceStatisticsCounter(ordersSlug); if (!res) return <Loading />; @@ -135,7 +564,6 @@ export default function Statistics({ } ordersStats.set(ordersSlug, res.body); } - const revenueStats = new Map<string, StatisticsAmount>; for (let revenueSlug of ["payments-received-after-deposit-fee", "total-wire-fees-paid", "refunds-granted", @@ -169,6 +597,11 @@ export default function Statistics({ } revenueStats.set(revenueSlug, res.body); } + if (true) { + addTestValues(ordersStats, revenueStats); + console.log(revenueStats); + } + const orderStatsChartOptions = { plugins: { title: { @@ -206,7 +639,7 @@ export default function Statistics({ scales: { x: { type: "time", - time: { unit: selectedRange }, + time: { unit: "quarter" }, ticks: { source: "labels"}, stacked: true, title: { display: true, text: "Time range", align: "end" } @@ -220,8 +653,9 @@ export default function Statistics({ var revenueChartDatasets = new Map<string, ChartDataset[]>(); var orderStatsChartDatasets: ChartDataset[] = []; - const revenueChartLabels: number[] = []; + const revenueChartLabels = new Map<string, number[]>(); const revenueCurrencies: string[] = []; + const revenueRanges: string[] = []; revenueStats.forEach((stat: StatisticsAmount, slug: string) => { var datasetForCurrency = new Map<string, ChartDataset>(); for (let b of stat.buckets) { @@ -235,8 +669,8 @@ export default function Statistics({ revenueCurrencies.push(a.currency); } }); - if (b.range !== selectedRange) { - continue; + if (-1 === revenueRanges.indexOf(b.range)) { + revenueRanges.push(b.range); } if (b.start_time.t_s == "never") { continue; @@ -247,8 +681,15 @@ export default function Statistics({ if (!a) { continue; } + var currRangeFilter = rangeFilter.get(a.currency); + if (!currRangeFilter) { + currRangeFilter = { range: "quarter" , rangeCount: 4}; + rangeFilter.set(a.currency, currRangeFilter); + } + if (currRangeFilter.range !== b.range) { + continue; + } const datasetColor = chartColors.get(slug ?? "") ?? "#eeeeee"; - const startRevenueFromDateCurrency = startRevenueFromDate.get(a.currency); if (!datasetForCurrency.has(a.currency)) { datasetForCurrency.set(a.currency, { label: stat.buckets_description, @@ -258,14 +699,14 @@ export default function Statistics({ }) } const amount = a.value + (a.fraction / Math.pow(10, 8)); - datasetForCurrency.get(a.currency)?.data.push({x: b.start_time.t_s * 1000, y: amount}); - // Do not add label if outside of range - if ((startRevenueFromDateCurrency) && - ("never" !== startRevenueFromDateCurrency.t_ms) && - (b.start_time.t_s * 1000 < startRevenueFromDateCurrency.t_ms)) { - continue; + datasetForCurrency.get(a.currency)!.data.push({x: b.start_time.t_s * 1000, y: amount}); + if (currRangeFilter.rangeCount < datasetForCurrency.get(a.currency)!.data.length) { + datasetForCurrency.get(a.currency)?.data.shift(); // Remove oldest entry + } + if (!revenueChartLabels.has(a.currency)) { + revenueChartLabels.set(a.currency, []); } - revenueChartLabels.push(b.start_time.t_s * 1000); + revenueChartLabels.get(a.currency)?.push(b.start_time.t_s * 1000); } } datasetForCurrency.forEach((value: ChartDataset, key: string) => { @@ -335,15 +776,15 @@ export default function Statistics({ revenueCurrencies.map((c,_) => { const tmpOpt = structuredClone(revenueChartOptions); tmpOpt.scales.y.title.text = c; + tmpOpt.scales.x.time.unit = rangeFilter.get(c)?.range ?? "quarter"; return <RevenueChart currencyString={c} - chartLabels={revenueChartLabels} + chartLabels={revenueChartLabels.get(c)} chartData={revenueChartDatasets.get(c)} chartOptions={tmpOpt as ChartOptions} - activeTimeRange={selectedRange} - onShowRange={(r: string) => {setSelectedRange(r);}} - filterFromDate={startRevenueFromDate.get(c)} - onSelectDate={(d?: AbsoluteTime) => { setStartRevenueFromDate((prevMap) => new Map(prevMap.set(c, d)))}} + activeRange={rangeFilter.get(c)!} + revenueRanges={revenueRanges} + onShowRange={(rangeCount: number, r?: string) => {setRangeFilter((prevMap) => new Map(prevMap.set(c, {range: r ?? "quarter", rangeCount: rangeCount})))}} />}) ) : ( <i>{i18n.str`No revenue statistics yet.`}</i>