From 28e1a732e02e004561bc7fe48419bd79f64d2e25 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Mon, 14 Sep 2026 16:41:21 -0300 Subject: [PATCH] feat(analysis): add monthly pattern analysis feature with chart and API integration --- .../quotes/handlers/getMonthlyPattern.ts | 122 +++++++ .../src/modules/quotes/quotes.routes.ts | 2 + .../src/features/analysis/AnalysisPage.tsx | 40 ++- .../analysis/components/MonthPatternChart.tsx | 293 +++++++++++++++++ apps/frontend/src/lib/api.ts | 25 ++ apps/frontend/src/lib/queries.ts | 13 +- pnpm-lock.yaml | 311 ++++++++++++++++++ 7 files changed, 794 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/modules/quotes/handlers/getMonthlyPattern.ts create mode 100644 apps/frontend/src/features/analysis/components/MonthPatternChart.tsx create mode 100644 pnpm-lock.yaml diff --git a/apps/backend/src/modules/quotes/handlers/getMonthlyPattern.ts b/apps/backend/src/modules/quotes/handlers/getMonthlyPattern.ts new file mode 100644 index 0000000..b19e4a9 --- /dev/null +++ b/apps/backend/src/modules/quotes/handlers/getMonthlyPattern.ts @@ -0,0 +1,122 @@ +import type { Context } from "hono"; +import { cache } from "../../../lib/cache"; +import { prisma } from "../../../lib/prisma"; + +const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const; +const ONE_HOUR = 60 * 60 * 1000; + +export async function getMonthlyPattern(c: Context) { + const rawType = c.req.param("type")?.toUpperCase(); + + if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) { + return c.json( + { + error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`, + }, + 400, + ); + } + + const cacheKey = `quotes:monthly-pattern:${rawType}`; + const cached = cache.get(cacheKey); + if (cached) return c.json(cached); + + const rows = await prisma.$queryRaw< + Array<{ + day: number; + months: number; + avgBuy: number; + avgSell: number; + avgBuyPosition: number | null; + avgSellPosition: number | null; + minBuyDays: number; + maxBuyDays: number; + minSellDays: number; + maxSellDays: number; + }> + >` + WITH monthly AS ( + SELECT + DATE_TRUNC('month', "timeStamp")::date AS month, + MIN(buy::numeric) AS min_buy, + MAX(buy::numeric) AS max_buy, + MIN(sell::numeric) AS min_sell, + MAX(sell::numeric) AS max_sell + FROM "quotes_history" + WHERE "type" = ${rawType}::"QuoteType" + AND "timeStamp" < DATE_TRUNC('month', NOW()) + GROUP BY DATE_TRUNC('month', "timeStamp") + ), + daily AS ( + SELECT + DATE_TRUNC('month', "timeStamp")::date AS month, + EXTRACT(DAY FROM "timeStamp")::int AS day, + AVG(buy::numeric) AS avg_buy, + AVG(sell::numeric) AS avg_sell, + MIN(buy::numeric) AS day_min_buy, + MAX(buy::numeric) AS day_max_buy, + MIN(sell::numeric) AS day_min_sell, + MAX(sell::numeric) AS day_max_sell + FROM "quotes_history" + WHERE "type" = ${rawType}::"QuoteType" + AND "timeStamp" < DATE_TRUNC('month', NOW()) + GROUP BY + DATE_TRUNC('month', "timeStamp"), + EXTRACT(DAY FROM "timeStamp") + ) + SELECT + d.day, + COUNT(*)::int AS months, + AVG(d.avg_buy)::float8 AS "avgBuy", + AVG(d.avg_sell)::float8 AS "avgSell", + AVG( + CASE WHEN m.max_buy > m.min_buy + THEN (d.avg_buy - m.min_buy) / (m.max_buy - m.min_buy) + END + )::float8 AS "avgBuyPosition", + AVG( + CASE WHEN m.max_sell > m.min_sell + THEN (d.avg_sell - m.min_sell) / (m.max_sell - m.min_sell) + END + )::float8 AS "avgSellPosition", + COUNT(*) FILTER (WHERE d.day_min_buy = m.min_buy)::int AS "minBuyDays", + COUNT(*) FILTER (WHERE d.day_max_buy = m.max_buy)::int AS "maxBuyDays", + COUNT(*) FILTER (WHERE d.day_min_sell = m.min_sell)::int AS "minSellDays", + COUNT(*) FILTER (WHERE d.day_max_sell = m.max_sell)::int AS "maxSellDays" + FROM daily d + JOIN monthly m ON m.month = d.month + GROUP BY d.day + ORDER BY d.day ASC + `; + + const totalMonths = await prisma.$queryRaw>` + SELECT COUNT(*)::int AS count + FROM ( + SELECT DATE_TRUNC('month', "timeStamp")::date AS month + FROM "quotes_history" + WHERE "type" = ${rawType}::"QuoteType" + AND "timeStamp" < DATE_TRUNC('month', NOW()) + GROUP BY DATE_TRUNC('month', "timeStamp") + ) AS months + `; + + const response = { + type: rawType, + totalMonths: totalMonths[0]?.count ?? 0, + days: rows.map((r) => ({ + day: r.day, + months: r.months, + avgBuy: r.avgBuy, + avgSell: r.avgSell, + avgBuyPosition: r.avgBuyPosition, + avgSellPosition: r.avgSellPosition, + minBuyDays: r.minBuyDays, + maxBuyDays: r.maxBuyDays, + minSellDays: r.minSellDays, + maxSellDays: r.maxSellDays, + })), + }; + + cache.set(cacheKey, response, ONE_HOUR); + return c.json(response); +} diff --git a/apps/backend/src/modules/quotes/quotes.routes.ts b/apps/backend/src/modules/quotes/quotes.routes.ts index 92c450a..462ab00 100644 --- a/apps/backend/src/modules/quotes/quotes.routes.ts +++ b/apps/backend/src/modules/quotes/quotes.routes.ts @@ -5,6 +5,7 @@ import { getDailyMinMax } from "./handlers/getDailyMinMax"; import { getDailyQuotes } from "./handlers/getDailyQuotes"; import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax"; import { getMonthlyLast } from "./handlers/getMonthlyLast"; +import { getMonthlyPattern } from "./handlers/getMonthlyPattern"; import { getPeakHours } from "./handlers/getPeakHours"; import { getQuoteHistory } from "./handlers/getQuoteHistory"; import { quoteEvents } from "./handlers/quoteEvents"; @@ -17,6 +18,7 @@ app.get("/:type/history", getQuoteHistory); app.get("/:type/daily", getDailyQuotes); app.get("/:type/min-max", getDailyMinMax); app.get("/:type/peak-hours", getPeakHours); +app.get("/:type/monthly-pattern", getMonthlyPattern); app.get("/:type/historical/min-max", getHistoricalMinMax); app.get("/fetch", fetchQuotes); app.get("/events", quoteEvents); diff --git a/apps/frontend/src/features/analysis/AnalysisPage.tsx b/apps/frontend/src/features/analysis/AnalysisPage.tsx index 183f7f6..0f4716b 100644 --- a/apps/frontend/src/features/analysis/AnalysisPage.tsx +++ b/apps/frontend/src/features/analysis/AnalysisPage.tsx @@ -17,10 +17,12 @@ import { useDailyQuotes, useHistoricalMinMax, useMonthlyLast, + useMonthlyPattern, usePeakHours, useQuotes, } from "@/lib/queries"; import { MonthlyVariationTable } from "./components/MonthlyVariationTable"; +import { MonthPatternChart } from "./components/MonthPatternChart"; import { PeakHoursChart } from "./components/PeakHoursChart"; const CURRENCIES = ["BELO", "BLUE", "BNA"] as const; @@ -104,6 +106,9 @@ export function AnalysisPage({ currency }: { currency: string }) { const { data: peakHoursData, isLoading: peakHoursLoading } = usePeakHours("BELO"); + const { data: monthlyPatternData, isLoading: monthlyPatternLoading } = + useMonthlyPattern("BELO"); + const chartData = useMemo(() => { if (!daily) return []; return daily.map((d) => ({ @@ -598,16 +603,31 @@ export function AnalysisPage({ currency }: { currency: string }) { {currency === "BELO" && ( -
-

- Análisis de pico horario -

-

- Frecuencia con la que cada hora del día fue el momento de mayor - precio de compra dentro de cada día. -

- -
+ <> +
+

+ Análisis de pico horario +

+

+ Frecuencia con la que cada hora del día fue el momento de mayor + precio de compra dentro de cada día. +

+ +
+ +
+

Patrón mensual

+

+ Posición del precio dentro de cada mes (0% = mínimo del mes, 100% + = máximo), promediada entre los meses completos disponibles. Se + excluye la tendencia inflacionaria para comparar momentos del mes. +

+ +
+ )} ); diff --git a/apps/frontend/src/features/analysis/components/MonthPatternChart.tsx b/apps/frontend/src/features/analysis/components/MonthPatternChart.tsx new file mode 100644 index 0000000..531c1e9 --- /dev/null +++ b/apps/frontend/src/features/analysis/components/MonthPatternChart.tsx @@ -0,0 +1,293 @@ +import { useMemo, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { MonthlyPatternResponse } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +const priceFormatter = new Intl.NumberFormat("es-AR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +type Mode = "sell" | "buy"; + +type ChartEntry = { + day: number; + months: number; + position: number; + avgPrice: number; + recommended: boolean; +}; + +export function MonthPatternChart({ + data, + isLoading, +}: { + data: MonthlyPatternResponse | undefined; + isLoading: boolean; +}) { + const [mode, setMode] = useState("sell"); + + const { chartData, totalMonths, bestSellDay, bestBuyDay, minCoverage } = + useMemo(() => { + const totalMonths = data?.totalMonths ?? 0; + const allDays = data?.days ?? []; + const byDay = new Map(allDays.map((d) => [d.day, d])); + const minCoverage = Math.max(3, Math.ceil(totalMonths * 0.5)); + + const eligible = allDays.filter((d) => d.months >= minCoverage); + + let bestSellDay: number | null = null; + let bestSellPosition = -Infinity; + for (const d of eligible) { + const pos = d.avgBuyPosition ?? null; + if (pos !== null && pos > bestSellPosition) { + bestSellPosition = pos; + bestSellDay = d.day; + } + } + + let bestBuyDay: number | null = null; + let bestBuyPosition = Infinity; + for (const d of eligible) { + const pos = d.avgSellPosition ?? null; + if (pos !== null && pos < bestBuyPosition) { + bestBuyPosition = pos; + bestBuyDay = d.day; + } + } + + const recommended = mode === "sell" ? bestSellDay : bestBuyDay; + + const chartData: ChartEntry[] = Array.from({ length: 31 }, (_, i) => { + const day = i + 1; + const found = byDay.get(day); + const position = + mode === "sell" + ? (found?.avgBuyPosition ?? 0) + : (found?.avgSellPosition ?? 0); + return { + day, + months: found?.months ?? 0, + position, + avgPrice: + mode === "sell" ? (found?.avgBuy ?? 0) : (found?.avgSell ?? 0), + recommended: recommended !== null && day === recommended, + }; + }); + + return { + chartData, + totalMonths, + bestSellDay, + bestBuyDay, + minCoverage, + }; + }, [data, mode]); + + if (isLoading) { + return ( +
+ Cargando datos del patrón mensual... +
+ ); + } + + if (totalMonths === 0 || chartData.every((d) => d.months === 0)) { + return ( +
+ Sin suficiente historial. Se necesitan al menos 2 meses completos de + datos. +
+ ); + } + + return ( +
+
+
+

+ Mejor día para vender USDC +

+

+ {bestSellDay !== null ? `Día ${bestSellDay}` : "Sin datos"} +

+

+ Precio de compra más alto del mes +

+
+
+

+ Mejor día para comprar USDC +

+

+ {bestBuyDay !== null ? `Día ${bestBuyDay}` : "Sin datos"} +

+

+ Precio de venta más bajo del mes +

+
+
+

Meses analizados

+

{totalMonths}

+

+ Mínimo de cobertura por día: {minCoverage} meses +

+
+
+ +
+
+ + +
+

+ Posición promedio dentro del mes (0% = mínimo, 100% = máximo) +

+
+ +
+ + + + + `${Math.round(v * 100)}%`} + label={{ + value: "Posición en el mes", + angle: -90, + position: "insideLeft", + offset: 10, + style: { fontSize: 11, fill: "hsl(var(--muted-foreground))" }, + }} + /> + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as ChartEntry; + return ( +
+

Día {d.day}

+
+

+ Posición:{" "} + + {Math.round(d.position * 100)}% + +

+ {d.avgPrice > 0 && ( +

+ Precio promedio:{" "} + + ${priceFormatter.format(d.avgPrice)} + +

+ )} +

+ Con datos en:{" "} + + {d.months} meses + +

+ {d.recommended && ( +

+ Día recomendado +

+ )} +
+
+ ); + }} + /> + + {chartData.map((entry) => ( + + ))} + +
+
+
+ +
+
+ + + Mejor día para vender USDC + +
+
+ + + Mejor día para comprar USDC + +
+
+
+ ); +} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 0a4ba95..81bd7b1 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -58,6 +58,25 @@ export type PeakHoursResponse = { peaks: PeakHour[]; }; +export type MonthPatternDay = { + day: number; + months: number; + avgBuy: number; + avgSell: number; + avgBuyPosition: number | null; + avgSellPosition: number | null; + minBuyDays: number; + maxBuyDays: number; + minSellDays: number; + maxSellDays: number; +}; + +export type MonthlyPatternResponse = { + type: string; + totalMonths: number; + days: MonthPatternDay[]; +}; + async function fetcher(url: string): Promise { const res = await fetch(url, { credentials: "include" }); if (!res.ok) { @@ -126,6 +145,12 @@ export function getPeakHours(type: string): Promise { return fetcher(`/api/quotes/${type}/peak-hours`); } +export function getMonthlyPattern( + type: string, +): Promise { + return fetcher(`/api/quotes/${type}/monthly-pattern`); +} + export type MonthlyLast = { month: string; type: "BELO" | "BLUE" | "BNA"; diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts index 9572b45..fdaac17 100644 --- a/apps/frontend/src/lib/queries.ts +++ b/apps/frontend/src/lib/queries.ts @@ -26,6 +26,7 @@ import { getHistoricalMinMax, getInflation, getMonthlyLast, + getMonthlyPattern, getMonthlyPayedTotal, getMonthlyTotals, getPeakHours, @@ -64,8 +65,8 @@ export const quoteKeys = { ["quotes", type, "daily", startDate, endDate] as const, historicalMinMax: (type: string) => ["quotes", type, "historicalMinMax"] as const, - peakHours: (type: string) => - ["quotes", type, "peakHours"] as const, + peakHours: (type: string) => ["quotes", type, "peakHours"] as const, + monthlyPattern: (type: string) => ["quotes", type, "monthlyPattern"] as const, }; export function useQuotes() { @@ -143,6 +144,14 @@ export function usePeakHours(type: string) { }); } +export function useMonthlyPattern(type: string) { + return useQuery({ + queryKey: quoteKeys.monthlyPattern(type), + queryFn: () => getMonthlyPattern(type), + staleTime: 30_000, + }); +} + export function useMonthlyLast() { return useQuery({ queryKey: ["quotes", "monthlyLast"] as const, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..a9a65a5 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,311 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@noble/ciphers': + specifier: ^2.2.0 + version: 2.4.0 + devDependencies: + '@biomejs/biome': + specifier: ^2.4.16 + version: 2.5.10 + concurrently: + specifier: ^9.1.0 + version: 9.2.4 + +packages: + + '@biomejs/biome@2.5.10': + resolution: {integrity: sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.10': + resolution: {integrity: sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.10': + resolution: {integrity: sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.10': + resolution: {integrity: sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.10': + resolution: {integrity: sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.10': + resolution: {integrity: sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.10': + resolution: {integrity: sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.10': + resolution: {integrity: sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.10': + resolution: {integrity: sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concurrently@9.2.4: + resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==} + engines: {node: '>=18'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + engines: {node: '>= 0.4'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + +snapshots: + + '@biomejs/biome@2.5.10': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.10 + '@biomejs/cli-darwin-x64': 2.5.10 + '@biomejs/cli-linux-arm64': 2.5.10 + '@biomejs/cli-linux-arm64-musl': 2.5.10 + '@biomejs/cli-linux-x64': 2.5.10 + '@biomejs/cli-linux-x64-musl': 2.5.10 + '@biomejs/cli-win32-arm64': 2.5.10 + '@biomejs/cli-win32-x64': 2.5.10 + + '@biomejs/cli-darwin-arm64@2.5.10': + optional: true + + '@biomejs/cli-darwin-x64@2.5.10': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.10': + optional: true + + '@biomejs/cli-linux-arm64@2.5.10': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.10': + optional: true + + '@biomejs/cli-linux-x64@2.5.10': + optional: true + + '@biomejs/cli-win32-arm64@2.5.10': + optional: true + + '@biomejs/cli-win32-x64@2.5.10': + optional: true + + '@noble/ciphers@2.4.0': {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concurrently@9.2.4: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.9.0 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + emoji-regex@8.0.0: {} + + escalade@3.2.0: {} + + get-caller-file@2.0.5: {} + + has-flag@4.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + require-directory@2.1.1: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + shell-quote@1.9.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1