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:
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>