commit a6e54d9ea77db3a019c9c3b09f014c6fdc827774
parent e4ef72cfac5cb9789f379c2e13f1276b1350fa97
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date: Mon, 24 Nov 2025 22:22:19 +0900
statistics: lots of UX, add tables for orders as well
Diffstat:
5 files changed, 738 insertions(+), 591 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/OrdersChart.tsx b/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/OrdersChart.tsx
@@ -27,12 +27,13 @@ import { useState } from "preact/hooks";
import { DatePicker } from "../../../../components/picker/DatePicker.js";
import { dateFormatForSettings, usePreference } from "../../../../hooks/preference.js";
import { Line } from "react-chartjs-2";
+import { ChartDataset, Point } from "chart.js";
const TALER_SCREEN_ID = 58;
export interface OrdersChartProps {
- chartData: any;
- chartLabels: any;
+ chartData: ChartDataset[];
+ chartLabels: number[];
chartOptions: any;
filterFromDate?: AbsoluteTime;
onSelectDate: (date?: AbsoluteTime) => void;
@@ -49,14 +50,43 @@ export function OrdersChart({
const [settings] = usePreference();
const dateTooltip = i18n.str`Select date from which to show statistics`;
const [pickDate, setPickDate] = useState(false);
-
+ const [showTable, setShowTable] = useState<boolean>(false);
+ function getCountForLabelAndDataset(label: number, ds: ChartDataset) : number {
+ for (let d of (ds.data as Array<Point>)) {
+ if (d.x === label) {
+ return d.y;
+ }
+ }
+ return 0;
+ }
return (
<Fragment>
- <div>
- <section class="section is-size-4 hero is-hero-bar">
- {i18n.str`Order statistics`}
- </section>
- <div class="column ">
+ <div class="tabs" style={{ overflow: "inherit" }}>
+ <ul>
+ <li class={(!showTable) ? "is-active" : ""}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`Show chart`}
+ >
+ <a onClick={() => {setShowTable(false) }}>
+ <i18n.Translate>Orders chart</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={(showTable) ? "is-active" : ""}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`Show table`}
+ >
+ <a onClick={() => {setShowTable(true) }}>
+ <i18n.Translate>Orders table</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ </ul>
+ </div>
+ <div class="columns">
+ <div class="column">
<div class="buttons is-right">
<div class="field has-addons">
{filterFromDate && (
@@ -111,12 +141,72 @@ export function OrdersChart({
onSelectDate(AbsoluteTime.fromMilliseconds(d.getTime()))
}}
/>
- {(chartLabels.length > 0) ? (
- <Line data={{labels: chartLabels, datasets: chartData}} options={chartOptions}/>
- ) : (
- <i>{i18n.str`No order statistics yet.`}</i>
- )
- }
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-shopping" />
+ </span>
+ {i18n.str`Orders statistics`}
+ </p>
+ </header>
+ <div class="card-content">
+ {(chartData && chartLabels.length > 0) ? (
+ (!showTable) ? (
+ <Line data={{labels: chartLabels, datasets: chartData as any}} options={chartOptions}/>
+ ) : (
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Since</i18n.Translate>
+ </th>
+ {chartData!.map((d) => {
+ return (
+ <Fragment key={d.label}>
+ <th>
+ {d.label}
+ </th>
+ </Fragment>
+ )})}
+ </tr>
+ </thead>
+ <tbody>
+ {chartLabels.map((l) => {
+ return (
+ <Fragment key={l}>
+ <tr key="info">
+ <td>
+ {new Date(l).toLocaleString()}
+ </td>
+ {chartData?.map((d) => {
+ return (
+ <Fragment key={d.label}>
+ <td>
+ {getCountForLabelAndDataset(l, d)}
+ </td>
+ </Fragment>
+ );})}
+ </tr>
+ </Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+
+ )
+ ) : (
+ <i>{i18n.str`No order statistics yet.`}</i>
+ )
+ }
+ </div>
+ </div>
</Fragment>
);
}
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,7 +19,7 @@
* @author Martin Schanzenbach
*/
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
+import { AmountJson, Amounts, StatisticBucketRange } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { ChartDataset, Point } from "chart.js";
import { format } from "date-fns";
@@ -33,24 +33,28 @@ import { InputSelector } from "../../../../components/form/InputSelector.js";
const TALER_SCREEN_ID = 59;
+export interface RevenueChartFilter {
+ rangeCount: number,
+ range: string,
+ currency: string,
+}
+
export interface RevenueChartProps {
- currencyString: string,
chartData?: ChartDataset[];
chartLabels: number[];
chartOptions: any;
- revenueRanges: string[];
- onShowRange: (rangeCount: number, range?: string) => void;
- activeRange: {range?: string, rangeCount: number};
+ availableCurrencies: string[];
+ activeFilter: {range: string, currency: string, rangeCount: number};
+ onUpdateFilter: (filter: RevenueChartFilter) => void;
}
export function RevenueChart({
- currencyString,
chartData,
chartLabels,
chartOptions,
- revenueRanges,
- onShowRange,
- activeRange: activeRange,
+ onUpdateFilter,
+ availableCurrencies,
+ activeFilter: activeFilter,
}: RevenueChartProps): VNode {
const { i18n } = useTranslationContext();
const [showTable, setShowTable] = useState<boolean>(false);
@@ -68,12 +72,13 @@ export function RevenueChart({
}
return (range.rangeCount < 2) ? translateMap.get(range.range!)![0] : translateMap.get(range.range!)![1];
};
+ const revenueRanges = Object.values(StatisticBucketRange);
const formatMap = new Map<string, string>([
- ["hour", "ha"],
- ["day", 'MMM d'],
- ["week", 'PP'],
- ["month", 'MMM yyyy'],
- ["quarter", 'qqq - yyyy'],
+ ["hour", 'yyyy MMMM d ha'],
+ ["day", 'yyyy MMM d'],
+ ["week", 'yyyy MMM w'],
+ ["month", 'yyyy MMM'],
+ ["quarter", 'yyyy qqq'],
["year", 'yyyy'],
]);
function getRevenueForLabelAndDataset(label: number, ds: ChartDataset) : string {
@@ -85,42 +90,9 @@ export function RevenueChart({
}
return "-";
}
- const form = activeRange;
+ const form = activeFilter;
return (
<Fragment>
- <section class="section is-size-4 hero is-hero-bar">
- {i18n.str`Revenue statistics over the past ${activeRange.rangeCount} ${translateRange(activeRange)} for currency '${currencyString}'`}
- </section>
- <div class="columns">
- <div class="column ">
- <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>
<div class="tabs" style={{ overflow: "inherit" }}>
<ul>
<li class={(!showTable) ? "is-active" : ""}>
@@ -129,7 +101,7 @@ export function RevenueChart({
data-tooltip={i18n.str`Show chart`}
>
<a onClick={() => {setShowTable(false) }}>
- <i18n.Translate>Chart</i18n.Translate>
+ <i18n.Translate>Revenue chart</i18n.Translate>
</a>
</div>
</li>
@@ -139,76 +111,126 @@ export function RevenueChart({
data-tooltip={i18n.str`Show table`}
>
<a onClick={() => {setShowTable(true) }}>
- <i18n.Translate>Table</i18n.Translate>
+ <i18n.Translate>Revenue table</i18n.Translate>
</a>
</div>
</li>
</ul>
</div>
+ <div class="columns">
+ <div class="column">
+ <div class="buttons is-right">
+ <FormProvider
+ object={form}
+ valueHandler={(updater) => onUpdateFilter(
+ {
+ rangeCount: updater(form).rangeCount ?? activeFilter.rangeCount,
+ range: updater(form).range ?? activeFilter.range,
+ currency: updater(form).currency ?? activeFilter.currency
+ }
+ )}
+ >
+ <InputGroup
+ name="rangeFilter"
+ label={i18n.str`Revenue statistics filter`}
+ >
+ <InputSelector
+ name="range"
+ label={i18n.str`Time range`}
+ values={revenueRanges}
+ fromStr={(d) => {
+ const idx = revenueRanges.indexOf(d as StatisticBucketRange)
+ if (idx === -1) return undefined;
+ return d
+ }}
+ tooltip={i18n.str`Select time range to group dataset by`}
+ />
+ <InputNumber
+ name="rangeCount"
+ label={translateMap.get(activeFilter.range)![0]}
+ tooltip={i18n.str`Select the number of ranges to include in data set`}
+
+ />
+ <InputSelector
+ name="currency"
+ label={i18n.str`Currency`}
+ values={availableCurrencies}
+ fromStr={(c) => {
+ const idx = availableCurrencies.indexOf(c)
+ if (idx === -1) return undefined;
+ return c
+ }}
+ tooltip={i18n.str`Select the currency to show statistics for`}
+ />
+ </InputGroup>
+ </FormProvider>
+ </div>
+ </div>
+ </div>
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-shopping" />
+ </span>
+ {i18n.str`Revenue statistics over the past ${activeFilter.rangeCount} ${translateRange(activeFilter)} for currency '${activeFilter.currency}'`}
+ </p>
+ </header>
+ <div class="card-content">
{(chartData && chartLabels.length > 0) ? (
(!showTable) ? (
- <Bar data={{labels: chartLabels, datasets: chartData as any}} options={chartOptions}/>
+ <Bar data={{labels: chartLabels, datasets: chartData as any}} options={chartOptions}/>
) : (
- <div class="card has-table">
- <header class="card-header">
- <p class="card-header-title">
- <span class="icon">
- <i class="mdi mdi-shopping" />
- </span>
- <i18n.Translate>Table</i18n.Translate>
- </p>
- </header>
- <div class="card-content">
- <div class="b-table has-pagination">
- <div class="table-wrapper has-mobile-cards">
- <div class="table-container">
- <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
- <thead>
- <tr>
- <th>
- <i18n.Translate>Start time</i18n.Translate>
- </th>
- {chartData!.map((d) => {
- return (
- <Fragment key={d.label}>
- <th>
- {d.label}
- </th>
- </Fragment>
- )})}
- </tr>
- </thead>
- <tbody>
- {chartLabels.map((l) => {
- return (
- <Fragment key={l}>
- <tr key="info">
- <td>
- {format(new Date(l), formatMap.get(activeRange.range!)!)}
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Start time</i18n.Translate>
+ </th>
+ {chartData!.map((d) => {
+ return (
+ <Fragment key={d.label}>
+ <th>
+ {d.label}
+ </th>
+ </Fragment>
+ )})}
+ </tr>
+ </thead>
+ <tbody>
+ {chartLabels.map((l) => {
+ return (
+ <Fragment key={l}>
+ <tr key="info">
+ <td>
+ {format(new Date(l), formatMap.get(activeFilter.range)!)}
+ </td>
+ {chartData?.map((d) => {
+ return (
+ <Fragment key={d.label}>
+ <td>
+ {getRevenueForLabelAndDataset(l, d)}
</td>
- {chartData?.map((d) => {
- return (
- <Fragment key={d.label}>
- <td>
- {getRevenueForLabelAndDataset(l, d)}
- </td>
- </Fragment>
- );})}
- </tr>
- </Fragment>
- );
- })}
- </tbody>
- </table>
- </div>
+ </Fragment>
+ );})}
+ </tr>
+ </Fragment>
+ );
+ })}
+ </tbody>
+ </table>
</div>
</div>
</div>
- </div>
)
) : (
<i>{i18n.str`No revenue statistics yet.`}</i>
)}
+ </div>
+ </div>
</Fragment>
);
}
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
@@ -27,8 +27,8 @@ import {
TalerError,
assertUnreachable,
StatisticsCounter,
- Amount,
AmountJson,
+ StatisticBucketRange,
} from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
@@ -58,10 +58,11 @@ import {
ComplexFillTarget,
} from 'chart.js';
import 'chartjs-adapter-date-fns';
-import { RevenueChart } from "./RevenueChart.js";
+import { RevenueChart, RevenueChartFilter } from "./RevenueChart.js";
import { OrdersChart } from "./OrdersChart.js";
import { useInstanceStatisticsAmount, useInstanceStatisticsCounter } from "../../../../hooks/statistics.js";
import { sub } from "date-fns";
+import { addTestValues } from "./testing.js";
interface Props {
}
@@ -95,438 +96,8 @@ 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 [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>();
@@ -535,6 +106,11 @@ export default function Statistics({
const lastMonthDate = sub(currentDate, { months: 1 });
const lastMonthAbs: AbsoluteTime = {t_ms: lastMonthDate.getMilliseconds() } as AbsoluteTime;
const [startOrdersFromDate, setStartOrdersFromDate] = useState<AbsoluteTime | undefined>(lastMonthAbs);
+ const [revenueChartFilter, setRevenueChartFilter] = useState<RevenueChartFilter>({
+ range: StatisticBucketRange.Quarter,
+ rangeCount: 4,
+ currency: "", // FIXME get this from merchant?
+ });
for (let ordersSlug of ["orders-created", "orders-claimed", "orders-paid", "orders-settled"]) {
let res = useInstanceStatisticsCounter(ordersSlug);
@@ -599,10 +175,10 @@ export default function Statistics({
}
revenueStats.set(revenueSlug, res.body);
}
- if (false) {
- addTestValues(ordersStats, revenueStats);
- console.log(revenueStats);
- }
+ //if (true) {
+ // addTestValues(ordersStats, revenueStats);
+ // console.log(revenueStats);
+ //}
const orderStatsChartOptions = {
plugins: {
@@ -657,7 +233,6 @@ export default function Statistics({
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) {
@@ -671,9 +246,6 @@ export default function Statistics({
revenueCurrencies.push(a.currency);
}
});
- if (-1 === revenueRanges.indexOf(b.range)) {
- revenueRanges.push(b.range);
- }
if (b.start_time.t_s == "never") {
continue;
}
@@ -683,12 +255,11 @@ 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 unset, set to the first currency we find.
+ if ("" === revenueChartFilter.currency) {
+ revenueChartFilter.currency = a.currency;
}
- if (currRangeFilter.range !== b.range) {
+ if (revenueChartFilter.range !== b.range) {
continue;
}
const datasetColor = chartColors.get(slug ?? "") ?? "#eeeeee";
@@ -703,7 +274,7 @@ export default function Statistics({
const amount = a.value + (a.fraction / Math.pow(10, 8));
const timeRangeStart = b.start_time.t_s * 1000;
datasetForCurrency.get(a.currency)!.data.push({x: timeRangeStart, y: amount, ...a});
- if (currRangeFilter.rangeCount < datasetForCurrency.get(a.currency)!.data.length) {
+ if (revenueChartFilter.rangeCount < datasetForCurrency.get(a.currency)!.data.length) {
datasetForCurrency.get(a.currency)?.data.shift(); // Remove oldest entry
revenueChartLabels.get(a.currency)?.shift(); // Remove oldest entry
}
@@ -729,7 +300,7 @@ export default function Statistics({
const datasetColor = chartColors.get(slug ?? "") ?? "#eeeeee";
const dataset: ChartDataset = {
label: stat.intervals_description,
- data: Array<[number,number]>(),
+ data: Array<Point>(),
backgroundColor: datasetColor,
hoverOffset: 4,
fill: {
@@ -746,21 +317,25 @@ export default function Statistics({
accum += interval.cumulative_counter;
dataset.data.push({x: interval.start_time.t_s * 1000, y: accum});
// Do not add label if outside of range
+ const intervalStart = interval.start_time.t_s * 1000;
if ((startOrdersFromDate) &&
("never" !== startOrdersFromDate.t_ms) &&
- (interval.start_time.t_s * 1000 < startOrdersFromDate.t_ms)) {
+ (intervalStart < startOrdersFromDate.t_ms)) {
continue;
}
- orderStatsChartLabels.push(interval.start_time.t_s * 1000);
+ if (-1 === orderStatsChartLabels.indexOf(intervalStart)) {
+ orderStatsChartLabels.push(intervalStart);
+ }
if (smallestOrderDate < 0) {
- smallestOrderDate = interval.start_time.t_s * 1000;
+ smallestOrderDate = intervalStart;
}
- if (smallestOrderDate > interval.start_time.t_s * 1000) {
- smallestOrderDate = interval.start_time.t_s * 1000;
+ if (smallestOrderDate > intervalStart) {
+ smallestOrderDate = intervalStart;
}
}
orderStatsChartDatasets.push(dataset);
});
+ orderStatsChartLabels.sort();
// Hack to make first area to origin work.
const ldo = orderStatsChartDatasets[orderStatsChartDatasets.length - 1] as LineOptions;
(ldo.fill as ComplexFillTarget).target = "origin";
@@ -772,34 +347,25 @@ export default function Statistics({
// FIXME: End date picker?
// FIXME: Intelligent date range selection?
+ revenueChartOptions.scales.y.title.text = revenueChartFilter.currency;
+ revenueChartOptions.scales.x.time.unit = revenueChartFilter.range;
const a: VNode = (
<section class="section is-main-section">
<NotificationCard notification={notif} />
<div>
- {
- (revenueCurrencies.length > 0) ? (
- 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.get(c) ?? []}
- chartData={revenueChartDatasets.get(c)}
- chartOptions={tmpOpt as ChartOptions}
- 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>
- )
- }
+ <RevenueChart
+ chartLabels={revenueChartLabels.get(revenueChartFilter.currency)?.sort().reverse() ?? []}
+ chartData={revenueChartDatasets.get(revenueChartFilter.currency)}
+ chartOptions={revenueChartOptions as ChartOptions}
+ activeFilter={revenueChartFilter}
+ availableCurrencies={revenueCurrencies}
+ onUpdateFilter={(filter: RevenueChartFilter) => setRevenueChartFilter(filter)}
+ />
</div>
<hr/>
<OrdersChart
- chartLabels={orderStatsChartLabels}
+ chartLabels={orderStatsChartLabels.reverse()}
chartData={orderStatsChartDatasets}
chartOptions={orderStatsChartOptions}
filterFromDate={startOrdersFromDate}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/testing.ts b/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/testing.ts
@@ -0,0 +1,449 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2025 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Martin Schanzenbach
+ */
+
+import {
+ StatisticBucketRange,
+ StatisticsAmount,
+ StatisticsCounter,
+} from "@gnu-taler/taler-util";
+
+export 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": StatisticBucketRange.Hour,
+ "cumulative_amounts": [
+ "EUR:0.11"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1763078400
+ },
+ "end_time": {
+ "t_s": 1763164800
+ },
+ "range": StatisticBucketRange.Day,
+ "cumulative_amounts": [
+ "EUR:0.21"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1762732800
+ },
+ "end_time": {
+ "t_s": 1763337600
+ },
+ "range": StatisticBucketRange.Week,
+ "cumulative_amounts": [
+ "EUR:0.31"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1761955200
+ },
+ "end_time": {
+ "t_s": 1764547200
+ },
+ "range": StatisticBucketRange.Month,
+ "cumulative_amounts": [
+ "EUR:0.51"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1751328000
+ },
+ "end_time": {
+ "t_s": 1759276800
+ },
+ "range": StatisticBucketRange.Quarter,
+ "cumulative_amounts": [
+ "EUR:3.10"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1759276800
+ },
+ "end_time": {
+ "t_s": 1767225600
+ },
+ "range": StatisticBucketRange.Quarter,
+ "cumulative_amounts": [
+ "EUR:1.11"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1735689600
+ },
+ "end_time": {
+ "t_s": 1767225600
+ },
+ "range": StatisticBucketRange.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": StatisticBucketRange.Hour,
+ "cumulative_amounts": [
+ "EUR:4.89"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1763078400
+ },
+ "end_time": {
+ "t_s": 1763164800
+ },
+ "range": StatisticBucketRange.Day,
+ "cumulative_amounts": [
+ "EUR:14.89"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1762732800
+ },
+ "end_time": {
+ "t_s": 1763337600
+ },
+ "range": StatisticBucketRange.Week,
+ "cumulative_amounts": [
+ "EUR:27.89"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1761955200
+ },
+ "end_time": {
+ "t_s": 1764547200
+ },
+ "range": StatisticBucketRange.Month,
+ "cumulative_amounts": [
+ "EUR:34.89"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1751328000
+ },
+ "end_time": {
+ "t_s": 1759276800
+ },
+ "range": StatisticBucketRange.Quarter,
+ "cumulative_amounts": [
+ "EUR:18.99"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1759276800
+ },
+ "end_time": {
+ "t_s": 1767225600
+ },
+ "range": StatisticBucketRange.Quarter,
+ "cumulative_amounts": [
+ "EUR:34.89"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1735689600
+ },
+ "end_time": {
+ "t_s": 1767225600
+ },
+ "range": StatisticBucketRange.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": StatisticBucketRange.Hour,
+ "cumulative_amounts": [
+ "EUR:0"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1763078400
+ },
+ "end_time": {
+ "t_s": 1763164800
+ },
+ "range": StatisticBucketRange.Day,
+ "cumulative_amounts": [
+ "EUR:2"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1762732800
+ },
+ "end_time": {
+ "t_s": 1763337600
+ },
+ "range": StatisticBucketRange.Week,
+ "cumulative_amounts": [
+ "EUR:3"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1761955200
+ },
+ "end_time": {
+ "t_s": 1764547200
+ },
+ "range": StatisticBucketRange.Month,
+ "cumulative_amounts": [
+ "EUR:4"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1751328000
+ },
+ "end_time": {
+ "t_s": 1759276800
+ },
+ "range": StatisticBucketRange.Quarter,
+ "cumulative_amounts": [
+ "EUR:8"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1759276800
+ },
+ "end_time": {
+ "t_s": 1767225600
+ },
+ "range": StatisticBucketRange.Quarter,
+ "cumulative_amounts": [
+ "EUR:6"
+ ]
+ },
+ {
+ "start_time": {
+ "t_s": 1735689600
+ },
+ "end_time": {
+ "t_s": 1767225600
+ },
+ "range": StatisticBucketRange.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);
+}
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -3436,6 +3436,15 @@ export interface TokenFamilyDetails {
used: Integer;
}
+export enum StatisticBucketRange {
+ Hour = "hour",
+ Day = "day",
+ Week = "week",
+ Month = "month",
+ Quarter = "quarter",
+ Year = "year",
+}
+
export interface StatisticAmountByBucket {
@@ -3446,7 +3455,7 @@ export interface StatisticAmountByBucket {
end_time: Timestamp;
// Range of the bucket
- range: string; //StatisticBucketRange;
+ range: StatisticBucketRange;
// Sum of all amounts falling under the given
// SLUG within this timeframe.
@@ -4754,11 +4763,22 @@ export const codecForTokenFamilySummary = (): Codec<TokenFamilySummary> =>
.property("kind", codecForTokenFamilyKind)
.build("TalerMerchantApi.TokenFamilySummary");
+export const codecForStatisticBucketRange = codecForEither(
+ codecForConstString(StatisticBucketRange.Day),
+ codecForConstString(StatisticBucketRange.Hour),
+ codecForConstString(StatisticBucketRange.Day),
+ codecForConstString(StatisticBucketRange.Week),
+ codecForConstString(StatisticBucketRange.Month),
+ codecForConstString(StatisticBucketRange.Quarter),
+ codecForConstString(StatisticBucketRange.Year),
+);
+
+
export const codecForStatisticsAmountBucket = (): Codec<StatisticAmountByBucket> =>
buildCodecForObject<StatisticAmountByBucket>()
.property("start_time", codecForTimestamp)
.property("end_time", codecForTimestamp)
- .property("range", codecForString()) // FIXME Bucket range string to be specific
+ .property("range", codecForStatisticBucketRange) // FIXME Bucket range string to be specific
.property("cumulative_amounts", codecForList(codecForAmountString()))
.build("TalerMerchantApi.StatisticsAmountBucket");