taler-typescript-core

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

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:
Mpackages/merchant-backoffice-ui/src/paths/instance/statistics/list/OrdersChart.tsx | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/merchant-backoffice-ui/src/paths/instance/statistics/list/RevenueChart.tsx | 228+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/statistics/list/index.tsx | 510++++++-------------------------------------------------------------------------
Apackages/merchant-backoffice-ui/src/paths/instance/statistics/list/testing.ts | 449+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-merchant.ts | 24++++++++++++++++++++++--
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");