commit f7f07e78de65811c499955b5787224c6df3f9ff4
parent 4578a18548970ca42a36a9f665277aeb5ee72b3e
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date: Mon, 14 Jul 2025 23:34:56 +0200
Initial try towards UI for statistics
Diffstat:
12 files changed, 1238 insertions(+), 1 deletion(-)
diff --git a/packages/merchant-backoffice-ui/package.json b/packages/merchant-backoffice-ui/package.json
@@ -19,11 +19,14 @@
"dependencies": {
"@gnu-taler/taler-util": "workspace:*",
"@gnu-taler/web-util": "workspace:*",
+ "chart.js": "^4.0.0",
+ "chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "2.29.3",
"history": "4.10.1",
"preact": "10.11.3",
"preact-router": "3.2.1",
"qrcode-generator": "1.4.4",
+ "react-chartjs-2": "^5.3.1",
"swr": "2.2.2"
},
"devDependencies": {
diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx
@@ -85,6 +85,7 @@ import { LoginPage } from "./paths/login/index.js";
import { NewAccount } from "./paths/newAccount/index.js";
import { ResetAccount } from "./paths/resetAccount/index.js";
import { Settings } from "./paths/settings/index.js";
+import Statistics from "./paths/instance/statistics/list/index.js";
import { Notification } from "./utils/types.js";
export enum InstancePaths {
@@ -138,6 +139,7 @@ export enum InstancePaths {
newAccount = "/account/new",
resetAccount = "/account/reset/:id",
+ statistics = "/statistics",
}
export enum AdminPaths {
@@ -697,6 +699,13 @@ export function Routing(_p: Props): VNode {
/>
<Route path={InstancePaths.interface} component={Settings} />
{/**
+ * Statistics pages
+ */}
+ <Route
+ path={InstancePaths.statistics}
+ component={Statistics}
+ />
+ {/**
* Example pages
*/}
<Route path="/loading" component={Loading} />
diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx
@@ -165,6 +165,19 @@ export function Sidebar({ mobile }: Props): VNode {
</HtmlPersonaFlag>
<HtmlPersonaFlag
htmlElement="li"
+ point={UIElement.sidebar_statistics}
+ >
+ <a href={"#/statistics"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-counter" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Statistics</i18n.Translate>
+ </span>
+ </a>
+ </HtmlPersonaFlag>
+ <HtmlPersonaFlag
+ htmlElement="li"
point={UIElement.sidebar_tokenFamilies}
>
<a href={"#/tokenfamilies"} class="has-icon">
@@ -384,7 +397,7 @@ export function Sidebar({ mobile }: Props): VNode {
<i18n.Translate>Log out</i18n.Translate>
</span>
</a>
- </li>
+ </li>
) : undefined}
</ul>
</div>
@@ -425,6 +438,7 @@ function getAvailableForPersona(p: MerchantPersona): ElementMap {
[UIElement.option_paymentTimeoutOnTemplate]: true,
[UIElement.action_useRevenueApi]: true,
[UIElement.option_inventoryTaxes]: true,
+ [UIElement.sidebar_statistics]: true,
[UIElement.sidebar_discounts]: false,
[UIElement.sidebar_subscriptions]: false,
@@ -457,6 +471,7 @@ function getAvailableForPersona(p: MerchantPersona): ElementMap {
[UIElement.option_ageRestriction]: false,
[UIElement.action_useRevenueApi]: false,
[UIElement.option_inventoryTaxes]: false,
+ [UIElement.sidebar_statistics]: false,
};
// case "inperson-vending-with-inventory":
@@ -486,6 +501,7 @@ function getAvailableForPersona(p: MerchantPersona): ElementMap {
[UIElement.option_ageRestriction]: false,
[UIElement.action_useRevenueApi]: false,
[UIElement.option_inventoryTaxes]: false,
+ [UIElement.sidebar_statistics]: false,
};
case "digital-publishing":
return {
@@ -495,6 +511,7 @@ function getAvailableForPersona(p: MerchantPersona): ElementMap {
[UIElement.sidebar_bankAccounts]: true,
[UIElement.sidebar_settings]: true,
[UIElement.sidebar_password]: true,
+ [UIElement.sidebar_statistics]: true,
[UIElement.sidebar_templates]: false,
@@ -524,6 +541,7 @@ function getAvailableForPersona(p: MerchantPersona): ElementMap {
[UIElement.sidebar_bankAccounts]: true,
[UIElement.sidebar_settings]: true,
[UIElement.sidebar_password]: true,
+ [UIElement.sidebar_statistics]: true,
[UIElement.sidebar_templates]: false,
diff --git a/packages/merchant-backoffice-ui/src/hooks/preference.ts b/packages/merchant-backoffice-ui/src/hooks/preference.ts
@@ -38,6 +38,7 @@ export enum UIElement {
sidebar_webhooks,
sidebar_tokenFamilies,
sidebar_subscriptions,
+ sidebar_statistics,
sidebar_discounts,
sidebar_settings,
sidebar_password,
diff --git a/packages/merchant-backoffice-ui/src/hooks/statistics.ts b/packages/merchant-backoffice-ui/src/hooks/statistics.ts
@@ -0,0 +1,87 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 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/>
+ */
+import { useState } from "preact/hooks";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { useSessionContext } from "../context/session.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+
+export interface InstanceTemplateFilter {
+}
+
+export function revalidateInstanceStatisticsCounter() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getStatisticsCounter",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useInstanceStatisticsCounter(slug: string) {
+ const { state, lib } = useSessionContext();
+
+ const [offset] = useState<string | undefined>();
+
+ async function fetcher([token, slug, intervalOrBucket]: [AccessToken, string, string]) {
+ return await lib.instance.getStatisticsCounter(token, slug, {
+ by: "ANY",
+ });
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"getStatisticsCounter">,
+ TalerHttpError
+ >([state.token, slug, "ANY"], fetcher);
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ return data;
+
+}
+export function revalidateInstanceStatisticsAmount() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getStatisticsAmount",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useInstanceStatisticsAmount(slug: string) {
+ const { state, lib } = useSessionContext();
+
+ const [offset] = useState<string | undefined>();
+
+ async function fetcher([token, slug, intervalOrBucket]: [AccessToken, string, string]) {
+ return await lib.instance.getStatisticsAmount(token, slug, {
+ by: "ANY",
+ });
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"getStatisticsAmount">,
+ TalerHttpError
+ >([state.token, slug, "ANY"], fetcher);
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ return data;
+
+}
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
@@ -0,0 +1,117 @@
+/*
+ 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 { 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 { Line } from "react-chartjs-2";
+
+export interface OrdersChartProps {
+ chartData: any;
+ chartLabels: any;
+ chartOptions: any;
+ filterFromDate?: AbsoluteTime;
+ onSelectDate: (date?: AbsoluteTime) => void;
+}
+
+export function OrdersChart({
+ chartData,
+ chartLabels,
+ chartOptions,
+ filterFromDate,
+ onSelectDate,
+}: OrdersChartProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreference();
+ const dateTooltip = i18n.str`Select date from which to show statistics`;
+ const [pickDate, setPickDate] = useState(false);
+
+ return (
+ <Fragment>
+ <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>
+ </div>
+ </div>
+
+ <DatePicker
+ opened={pickDate}
+ closeFunction={() => setPickDate(false)}
+ dateReceiver={(d) => {
+ onSelectDate(AbsoluteTime.fromMilliseconds(d.getTime()))
+ }}
+ />
+ {(chartLabels.length > 0) ? (
+ <Line data={{labels: chartLabels, datasets: chartData}} options={chartOptions}/>
+ ) : (
+ <i>{i18n.str`No order statistics yet.`}</i>
+ )
+ }
+ </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
@@ -0,0 +1,190 @@
+/*
+ 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 { AbsoluteTime, TalerMerchantApi } 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";
+
+export interface RevenueChartProps {
+ currencyString: string,
+ chartData: any;
+ chartLabels: any;
+ chartOptions: any;
+ onShowRange: (range: string) => void;
+ activeTimeRange: string;
+ filterFromDate?: AbsoluteTime;
+ onSelectDate: (date?: AbsoluteTime) => void;
+}
+
+export function RevenueChart({
+ currencyString,
+ chartData,
+ chartLabels,
+ chartOptions,
+ onShowRange,
+ activeTimeRange,
+ filterFromDate,
+ onSelectDate,
+}: RevenueChartProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreference();
+ const dateTooltip = i18n.str`Select date from which to show statistics`;
+ const [pickDate, setPickDate] = useState(false);
+ 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>
+ </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>
+ </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}/>
+ ) : (
+ <i>{i18n.str`No revenue statistics yet.`}</i>
+ )}
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/Table.tsx
@@ -0,0 +1,170 @@
+/*
+ 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 { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import { StatSlug } from "./index.js"
+import { Chart } from "chart.js";
+
+type Entity = StatSlug;
+
+interface Props {
+ counterStatSlugs: Entity[];
+ amountStatSlugs: Entity[];
+ onSelectAmountStat: (e: string) => void;
+ onSelectCounterStat: (e: string) => void;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ counterStatSlugs,
+ amountStatSlugs,
+ onSelectAmountStat,
+ onSelectCounterStat,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-counter" />
+ </span>
+ <i18n.Translate>Available statistics</i18n.Translate>
+ </p>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {amountStatSlugs.length > 0 ? (
+ <Table
+ counterStats={counterStatSlugs}
+ amountStats={amountStatSlugs}
+ onSelectCounterStat={onSelectCounterStat}
+ onSelectAmountStat={onSelectAmountStat}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ <div style="width: 800px;"><canvas id="orderschartcanvas"></canvas></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ amountStats: Entity[];
+ counterStats: Entity[];
+ onSelectAmountStat: (e: string) => void;
+ onSelectCounterStat: (e: string) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+}
+
+function Table({
+ amountStats,
+ counterStats,
+ onSelectAmountStat,
+ onSelectCounterStat
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Description</i18n.Translate>
+ </th>
+ <th>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {amountStats.map((i) => {
+ return (
+ <tr key={i.slug}>
+ <td
+ onClick={(): void => onSelectAmountStat(i.slug)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.text}
+ </td>
+ <td
+ onClick={(): void => onSelectAmountStat(i.slug)}
+ style={{ cursor: "pointer" }}
+ >
+ <span class="mdi mdi-arrow-right"></span>
+ </td>
+ </tr>
+ );
+ })}
+ {counterStats.map((i) => {
+ return (
+ <tr key={i.slug}>
+ <td
+ onClick={(): void => onSelectCounterStat(i.slug)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.text}
+ </td>
+ <td
+ onClick={(): void => onSelectCounterStat(i.slug)}
+ style={{ cursor: "pointer" }}
+ >
+ <span class="mdi mdi-arrow-right"></span>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-magnify mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There are no statistics to list
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
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
@@ -0,0 +1,370 @@
+/*
+ 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 {
+ AbsoluteTime,
+ Amounts,
+ HttpStatusCode,
+ StatisticsAmount,
+ TalerError,
+ assertUnreachable,
+ StatisticsCounter,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ PointElement,
+ LineElement,
+ ChartDataset,
+ Title,
+ TimeScale,
+ Tooltip,
+ Legend,
+ Filler,
+ Point,
+ ChartOptions,
+ LineOptions,
+ ComplexFillTarget,
+} from 'chart.js';
+import 'chartjs-adapter-date-fns';
+import { RevenueChart } from "./RevenueChart.js";
+import { OrdersChart } from "./OrdersChart.js";
+import { useInstanceStatisticsAmount, useInstanceStatisticsCounter } from "../../../../hooks/statistics.js";
+import { sub } from "date-fns";
+interface Props {
+}
+
+ChartJS.register(
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ LineElement,
+ PointElement,
+ BarElement,
+ Title,
+ Filler,
+ Tooltip,
+ Legend
+);
+
+export interface StatSlug {
+ slug: string,
+ text: string
+}
+
+const chartColors = new Map<string, string>();
+ chartColors.set("orders-created", '#b9a5ff');
+ chartColors.set("tokens-issued", '#b9a5ff');
+ chartColors.set("payments-received-after-deposit-fee", '#b9a5ff');
+ chartColors.set("orders-claimed", '#647cda');
+ chartColors.set("total-wire-fees-paid", '#647cda');
+ chartColors.set("total-deposit-fees-paid", '#2830a8');
+ chartColors.set("orders-settled", '#2830a8');
+ chartColors.set("tokens-used", '#2830a8');
+ chartColors.set("orders-paid", '#525597');
+ chartColors.set("refunds-granted", '#525597');
+
+export default function Statistics({
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [selectedRange, setSelectedRange] = useState<string>("year");
+ const [notif, _] = useState<Notification | undefined>(undefined);
+ const ordersStats = new Map<string, StatisticsCounter>();
+
+ 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 />;
+ if (res instanceof TalerError) {
+ return <ErrorLoadingMerchant error={res} />;
+ }
+ if (res.type === "fail") {
+ switch (res.case) {
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />;
+ }
+ case HttpStatusCode.BadGateway: {
+ return <div />;
+ }
+ case HttpStatusCode.ServiceUnavailable: {
+ return <div />;
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <div />;
+ }
+ case HttpStatusCode.NotFound: {
+ return <div />;
+ }
+ default: {
+ assertUnreachable(res);
+ }
+ }
+ }
+ 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",
+ "total-deposit-fees-paid"]) {
+ let res = useInstanceStatisticsAmount(revenueSlug);
+ if (!res) return <Loading />;
+ if (res instanceof TalerError) {
+ return <ErrorLoadingMerchant error={res} />;
+ }
+ if (res.type === "fail") {
+ switch (res.case) {
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />;
+ }
+ case HttpStatusCode.BadGateway: {
+ return <div />;
+ }
+ case HttpStatusCode.ServiceUnavailable: {
+ return <div />;
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <div />;
+ }
+ case HttpStatusCode.NotFound: {
+ return <div />;
+ }
+ default: {
+ assertUnreachable(res);
+ }
+ }
+ }
+ revenueStats.set(revenueSlug, res.body);
+ }
+ const orderStatsChartOptions = {
+ plugins: {
+ title: {
+ display: true,
+ text: 'Orders',
+ },
+ filler: {
+ propagate: true
+ }
+ },
+ responsive: true,
+ scales: {
+ x: {
+ min: startOrdersFromDate?.t_ms,
+ type: "time",
+ time: { unit: "day", displayFormats: { day: "Pp" }},
+ ticks: { source: "data"},
+ stacked: true,
+ title: { display: false }
+ },
+ y: {
+ stacked: true,
+ title: { display: true, text: "# of orders since", align: "end" }
+ },
+ },
+ };
+ const revenueChartOptions = {
+ plugins: {
+ title: {
+ display: true,
+ text: 'Revenue',
+ },
+ },
+ responsive: true,
+ scales: {
+ x: {
+ type: "time",
+ time: { unit: selectedRange },
+ ticks: { source: "labels"},
+ stacked: true,
+ title: { display: true, text: "Time range", align: "end" }
+ },
+ y: {
+ stacked: true,
+ title: { display: true, text: "Euro (€)", align: "end" }
+ },
+ },
+ };
+ var revenueChartDatasets = new Map<string, ChartDataset[]>();
+ var orderStatsChartDatasets: ChartDataset[] = [];
+
+ const revenueChartLabels: number[] = [];
+ const revenueCurrencies: string[] = [];
+ revenueStats.forEach((stat: StatisticsAmount, slug: string) => {
+ var datasetForCurrency = new Map<string, ChartDataset>();
+ for (let b of stat.buckets) {
+ // We collect all currencies that we have stats of before we
+ // filter.
+ // FIXME kind of ugly.
+ // We probably want to build all datasets here and filter later.
+ b.cumulative_amounts.map ((c) => {
+ const a = Amounts.parse(c);
+ if (a && revenueCurrencies.indexOf(a.currency) === -1) {
+ revenueCurrencies.push(a.currency);
+ }
+ });
+ if (b.range !== selectedRange) {
+ continue;
+ }
+ if (b.start_time.t_s == "never") {
+ continue;
+ }
+
+ for (let c of b.cumulative_amounts) {
+ const a = Amounts.parse(c);
+ if (!a) {
+ 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,
+ data: Array<Point>(),
+ backgroundColor: datasetColor,
+ hoverOffset: 4,
+ })
+ }
+ 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;
+ }
+ revenueChartLabels.push(b.start_time.t_s * 1000);
+ }
+ }
+ datasetForCurrency.forEach((value: ChartDataset, key: string) => {
+ if (!revenueChartDatasets.has(key)) {
+ revenueChartDatasets.set(key, []);
+ }
+ revenueChartDatasets.get(key)?.push(value);
+ })
+ });
+ var smallestOrderDate: number = -1;
+ const orderStatsChartLabels: number[] = [];
+ ordersStats.forEach((stat: StatisticsCounter, slug: string) => {
+ const datasetColor = chartColors.get(slug ?? "") ?? "#eeeeee";
+ const dataset: ChartDataset = {
+ label: stat.intervals_description,
+ data: Array<[number,number]>(),
+ backgroundColor: datasetColor,
+ hoverOffset: 4,
+ fill: {
+ target: "-1",
+ below: datasetColor,
+ }
+ }
+ var accum = 0;
+ for (let j = stat.intervals.length - 1; j >= 0; j--) {
+ const interval = stat.intervals[j];
+ if (interval.start_time.t_s == "never") {
+ continue;
+ }
+ accum += interval.cumulative_counter;
+ dataset.data.push({x: interval.start_time.t_s * 1000, y: accum});
+ // Do not add label if outside of range
+ if ((startOrdersFromDate) &&
+ ("never" !== startOrdersFromDate.t_ms) &&
+ (interval.start_time.t_s * 1000 < startOrdersFromDate.t_ms)) {
+ continue;
+ }
+ orderStatsChartLabels.push(interval.start_time.t_s * 1000);
+ if (smallestOrderDate < 0) {
+ smallestOrderDate = interval.start_time.t_s * 1000;
+ }
+ if (smallestOrderDate > interval.start_time.t_s * 1000) {
+ smallestOrderDate = interval.start_time.t_s * 1000;
+ }
+ }
+ orderStatsChartDatasets.push(dataset);
+ });
+ // Hack to make first area to origin work.
+ const ldo = orderStatsChartDatasets[orderStatsChartDatasets.length - 1] as LineOptions;
+ (ldo.fill as ComplexFillTarget).target = "origin";
+ // Set the orders from date to the smallest interval we found
+ if (startOrdersFromDate && smallestOrderDate && (startOrdersFromDate.t_ms != "never") && (startOrdersFromDate.t_ms < smallestOrderDate)) {
+ setStartOrdersFromDate({ t_ms: smallestOrderDate } as AbsoluteTime);
+ }
+ orderStatsChartDatasets = orderStatsChartDatasets.reverse();
+
+ // FIXME: End date picker?
+ // FIXME: Intelligent date range selection?
+ const a: VNode = (
+ <section class="section is-main-section">
+ <NotificationCard notification={notif} />
+
+ <h2>{i18n.str`Revenue statistics`}</h2>
+ <div>
+ {
+ (revenueCurrencies.length > 0) ? (
+ revenueCurrencies.map((c,_) => {
+ const tmpOpt = structuredClone(revenueChartOptions);
+ tmpOpt.scales.y.title.text = c;
+ return <RevenueChart
+ currencyString={c}
+ chartLabels={revenueChartLabels}
+ 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)))}}
+ />})
+ ) : (
+ <i>{i18n.str`No revenue statistics yet.`}</i>
+ )
+ }
+ </div>
+ <hr/>
+ <h2>{i18n.str`Order statistics`}</h2>
+ <OrdersChart
+ chartLabels={orderStatsChartLabels}
+ chartData={orderStatsChartDatasets}
+ chartOptions={orderStatsChartOptions}
+ filterFromDate={startOrdersFromDate}
+ onSelectDate={(d?: AbsoluteTime) => { setStartOrdersFromDate(d)}}
+ />
+ </section>
+ );
+ return a;
+}
+
+
+
+
+
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -63,6 +63,8 @@ import {
codecForStatusGoto,
codecForStatusPaid,
codecForStatusStatusUnpaid,
+ codecForStatisticsAmountResponse,
+ codecForStatisticsCounterResponse,
codecForTalerMerchantConfigResponse,
codecForTansferList,
codecForTemplateDetails,
@@ -3093,4 +3095,78 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp
return opUnknownHttpFailure(resp);
}
}
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get--management-instances-$INSTANCE-statistics-counter-$SLUG
+ */
+ async getStatisticsCounter(
+ token: AccessToken | undefined,
+ statSlug: string,
+ params: TalerMerchantApi.GetStatisticsRequestParams,
+ ) {
+ const url = new URL(`private/statistics-counter/${statSlug}`, this.baseUrl);
+
+ if (params.by) {
+ url.searchParams.set("by", params.by);
+ }
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForStatisticsCounterResponse());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.BadGateway:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.ServiceUnavailable:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownHttpFailure(resp);
+ }
+ } /**
+ * https://docs.taler.net/core/api-merchant.html#get--management-instances-$INSTANCE-statistics-amount-$SLUG
+ */
+ async getStatisticsAmount(
+ token: AccessToken | undefined,
+ statSlug: string,
+ params: TalerMerchantApi.GetStatisticsRequestParams,
+ ) {
+ const url = new URL(`private/statistics-amount/${statSlug}`, this.baseUrl);
+
+ if (params.by) {
+ url.searchParams.set("by", params.by);
+ }
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForStatisticsAmountResponse());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.BadGateway:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.ServiceUnavailable:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownHttpFailure(resp);
+ }
+ }
}
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -1538,6 +1538,14 @@ export interface ListOrdersRequestParams {
order?: "asc" | "dec";
}
+export interface GetStatisticsRequestParams {
+ // Optional. If set to “BUCKET”, only statistics by bucket will
+ // be returned. If set to “INTERVAL”, only statistics kept by
+ // interval will be returned.
+ // If not set or set to “ANY”, both will be returned.
+ by?: string;
+}
+
export interface PayRequest {
// The coins used to make the payment.
coins: CoinPaySig[];
@@ -3375,6 +3383,101 @@ export interface TokenFamilyDetails {
used: Integer;
}
+
+export interface StatisticAmountByBucket {
+
+ // Start time of the bucket (inclusive)
+ start_time: Timestamp;
+
+ // End time of the bucket (exclusive)
+ end_time: Timestamp;
+
+ // Range of the bucket
+ range: string; //StatisticBucketRange;
+
+ // Sum of all amounts falling under the given
+ // SLUG within this timeframe.
+ cumulative_amounts: AmountString[];
+
+}
+
+export interface StatisticAmountByInterval {
+
+ // Start time of the interval.
+ // The interval always ends at the response
+ // generation time.
+ start_time: Timestamp;
+
+ // Sum of all amounts falling under the given
+ // SLUG within this timeframe.
+ cumulative_amounts: AmountString[];
+
+}
+
+export interface StatisticsAmount {
+ // Statistics kept for a particular fixed time window.
+ buckets: StatisticAmountByBucket[];
+
+ // Human-readable bucket statistic description.
+ // Unset if no buckets returned
+ buckets_description?: string;
+
+ // Statistics kept for a particular sliding interval.
+ intervals: StatisticAmountByInterval[];
+
+ // Human-readable interval statistic description.
+ // Unset if no buckets returned
+ intervals_description?: string;
+}
+
+export interface StatisticCounterByBucket {
+
+ // Start time of the bucket (inclusive)
+ start_time: Timestamp;
+
+ // End time of the bucket (exclusive)
+ end_time: Timestamp;
+
+ // Range of the bucket
+ range: string; //StatisticBucketRange;
+
+ // Sum of all counters falling under the given
+ // SLUG within this timeframe.
+ cumulative_counter: number;
+
+}
+
+export interface StatisticCounterByInterval {
+
+ // Start time of the interval.
+ // The interval always ends at the response
+ // generation time.
+ start_time: Timestamp;
+
+ // Sum of all counters falling under the given
+ // SLUG within this timeframe.
+ cumulative_counter: number;
+
+}
+
+
+
+export interface StatisticsCounter {
+ // Statistics kept for a particular fixed time window.
+ buckets: StatisticCounterByBucket[];
+
+ // Human-readable bucket statistic description.
+ // Unset if no buckets returned
+ buckets_description?: string;
+
+ // Statistics kept for a particular sliding interval.
+ intervals: StatisticCounterByInterval[];
+
+ // Human-readable interval statistic description.
+ // Unset if no intervals returned
+ intervals_description?: string;
+}
+
export interface OrderChoice {
// Total price for the choice. The exchange will subtract deposit
// fees from that amount before transferring it to the merchant.
@@ -4598,6 +4701,51 @@ export const codecForTokenFamilySummary = (): Codec<TokenFamilySummary> =>
.property("kind", codecForTokenFamilyKind)
.build("TalerMerchantApi.TokenFamilySummary");
+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("cumulative_amounts", codecForList(codecForAmountString()))
+ .build("TalerMerchantApi.StatisticsAmountBucket");
+
+export const codecForStatisticsAmountInterval = (): Codec<StatisticAmountByInterval> =>
+ buildCodecForObject<StatisticAmountByInterval>()
+ .property("start_time", codecForTimestamp)
+ .property("cumulative_amounts", codecForList(codecForAmountString()))
+ .build("TalerMerchantApi.StatisticsAmountInterval");
+
+export const codecForStatisticsAmountResponse = (): Codec<StatisticsAmount> =>
+ buildCodecForObject<StatisticsAmount>()
+ .property("buckets", codecForList(codecForStatisticsAmountBucket()))
+ .property("buckets_description", codecOptional(codecForString()))
+ .property("intervals", codecForList(codecForStatisticsAmountInterval()))
+ .property("intervals_description", codecOptional(codecForString()))
+ .build("TalerMerchantApi.StatisticsAmountResponse");
+
+export const codecForStatisticsCounterBucket = (): Codec<StatisticCounterByBucket> =>
+ buildCodecForObject<StatisticCounterByBucket>()
+ .property("start_time", codecForTimestamp)
+ .property("end_time", codecForTimestamp)
+ .property("range", codecForString()) // FIXME Bucket range string to be specific
+ .property("cumulative_counter", codecForNumber())
+ .build("TalerMerchantApi.StatisticsCounterBucket");
+
+export const codecForStatisticsCounterInterval = (): Codec<StatisticCounterByInterval> =>
+ buildCodecForObject<StatisticCounterByInterval>()
+ .property("start_time", codecForTimestamp)
+ .property("cumulative_counter", codecForNumber())
+ .build("TalerMerchantApi.StatisticsCounterInterval");
+
+
+export const codecForStatisticsCounterResponse = (): Codec<StatisticsCounter> =>
+ buildCodecForObject<StatisticsCounter>()
+ .property("buckets", codecForList(codecForStatisticsCounterBucket()))
+ .property("buckets_description", codecOptional(codecForString()))
+ .property("intervals", codecForList(codecForStatisticsCounterInterval()))
+ .property("intervals_description", codecOptional(codecForString()))
+ .build("TalerMerchantApi.StatisticsCounterResponse");
+
export const codecForInstancesResponse = (): Codec<InstancesResponse> =>
buildCodecForObject<InstancesResponse>()
.property("instances", codecForList(codecForInstance()))
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -706,6 +706,12 @@ importers:
'@gnu-taler/web-util':
specifier: workspace:*
version: link:../web-util
+ chart.js:
+ specifier: ^4.0.0
+ version: 4.5.0
+ chartjs-adapter-date-fns:
+ specifier: ^3.0.0
+ version: 3.0.0(chart.js@4.5.0)(date-fns@2.29.3)
date-fns:
specifier: 2.29.3
version: 2.29.3
@@ -721,6 +727,9 @@ importers:
qrcode-generator:
specifier: 1.4.4
version: 1.4.4
+ react-chartjs-2:
+ specifier: ^5.3.1
+ version: 5.3.1(chart.js@4.5.0)(react@18.3.1)
swr:
specifier: 2.2.2
version: 2.2.2(react@18.3.1)
@@ -2781,6 +2790,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+ '@kurkle/color@0.3.4':
+ resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
+
'@leichtgewicht/ip-codec@2.0.4':
resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==}
@@ -4236,6 +4248,16 @@ packages:
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+ chart.js@4.5.0:
+ resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
+ engines: {pnpm: '>=8'}
+
+ chartjs-adapter-date-fns@3.0.0:
+ resolution: {integrity: sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==}
+ peerDependencies:
+ chart.js: '>=2.8.0'
+ date-fns: '>=2.0.0'
+
check-error@1.0.2:
resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==}
@@ -4843,9 +4865,11 @@ packages:
deepcopy@1.0.0:
resolution: {integrity: sha512-WJrecobaoqqgQHtvRI2/VCzWoWXPAnFYyAkF/spmL46lZMnd0gW0gLGuyeFVSrqt2B3s0oEEj6i+j2L/2QiS4g==}
+ deprecated: No longer maintained. Use structuredClone instead.
deepcopy@2.1.0:
resolution: {integrity: sha512-8cZeTb1ZKC3bdSCP6XOM1IsTczIO73fdqtwa2B0N15eAz7gmyhQo+mc5gnFuulsgN3vIQYmTgbmQVKalH1dKvQ==}
+ deprecated: No longer maintained. Use structuredClone instead.
deepmerge@2.2.1:
resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==}
@@ -7440,6 +7464,7 @@ packages:
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
+ deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
@@ -8580,6 +8605,12 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
+ react-chartjs-2@5.3.1:
+ resolution: {integrity: sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==}
+ peerDependencies:
+ chart.js: ^4.1.1
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -9173,6 +9204,7 @@ packages:
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
+ deprecated: The work that was done in this beta branch won't be included in future versions
sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
@@ -13003,6 +13035,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.4.15
+ '@kurkle/color@0.3.4': {}
+
'@leichtgewicht/ip-codec@2.0.4': {}
'@linaria/babel-preset@3.0.0-beta.22':
@@ -15000,6 +15034,15 @@ snapshots:
chalk@5.3.0: {}
+ chart.js@4.5.0:
+ dependencies:
+ '@kurkle/color': 0.3.4
+
+ chartjs-adapter-date-fns@3.0.0(chart.js@4.5.0)(date-fns@2.29.3):
+ dependencies:
+ chart.js: 4.5.0
+ date-fns: 2.29.3
+
check-error@1.0.2: {}
cheerio-select@2.1.0:
@@ -20085,6 +20128,11 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
+ react-chartjs-2@5.3.1(chart.js@4.5.0)(react@18.3.1):
+ dependencies:
+ chart.js: 4.5.0
+ react: 18.3.1
+
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0