taler-typescript-core

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

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:
Mpackages/merchant-backoffice-ui/package.json | 3+++
Mpackages/merchant-backoffice-ui/src/Routing.tsx | 9+++++++++
Mpackages/merchant-backoffice-ui/src/components/menu/SideBar.tsx | 20+++++++++++++++++++-
Mpackages/merchant-backoffice-ui/src/hooks/preference.ts | 1+
Apackages/merchant-backoffice-ui/src/hooks/statistics.ts | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/statistics/list/OrdersChart.tsx | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/statistics/list/RevenueChart.tsx | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/statistics/list/Table.tsx | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/statistics/list/index.tsx | 370+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/merchant.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-merchant.ts | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpnpm-lock.yaml | 48++++++++++++++++++++++++++++++++++++++++++++++++
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