diff --git a/api/home_charts.go b/api/home_charts.go new file mode 100644 index 000000000..3c4d27270 --- /dev/null +++ b/api/home_charts.go @@ -0,0 +1,87 @@ +package api + +import ( + "context" + "errors" + "slices" + + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" +) + +func getTransactionTimestampMs(tx db.Transaction) int64 { + if tx.SettledAt != nil { + return tx.SettledAt.UnixMilli() + } + return tx.UpdatedAt.UnixMilli() +} + +func getNetSat(tx db.Transaction) int64 { + amountSat := int64(tx.AmountMsat / 1000) + feeSat := int64(tx.FeeMsat / 1000) + + switch tx.Type { + case constants.TRANSACTION_TYPE_INCOMING: + return amountSat + case constants.TRANSACTION_TYPE_OUTGOING: + return -(amountSat + feeSat) + default: + return 0 + } +} + +func (api *api) GetHomeChartsData(ctx context.Context, from uint64) (*HomeChartsResponse, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, errors.New("LNClient not started") + } + + transactions, _, err := api.svc.GetTransactionsService().ListTransactions( + ctx, + from, + 0, + 0, + 0, + false, + false, + nil, + lnClient, + nil, + false, + ) + if err != nil { + return nil, err + } + + points := make([]HomeChartsTxPoint, 0, len(transactions)) + hasIncomingDeposit := false + var netFlowsSat int64 + + for _, tx := range transactions { + netSat := getNetSat(tx) + if tx.Type == constants.TRANSACTION_TYPE_INCOMING && tx.AmountMsat > 0 { + hasIncomingDeposit = true + } + netFlowsSat += netSat + points = append(points, HomeChartsTxPoint{ + Timestamp: getTransactionTimestampMs(tx), + NetSat: netSat, + }) + } + + slices.SortFunc(points, func(a, b HomeChartsTxPoint) int { + if a.Timestamp < b.Timestamp { + return -1 + } + if a.Timestamp > b.Timestamp { + return 1 + } + return 0 + }) + + return &HomeChartsResponse{ + TxPoints: points, + HasIncomingDeposit: hasIncomingDeposit, + NetFlowsSat: netFlowsSat, + }, nil +} diff --git a/api/models.go b/api/models.go index b4c77accf..d6169c976 100644 --- a/api/models.go +++ b/api/models.go @@ -42,6 +42,7 @@ type API interface { SignMessage(ctx context.Context, message string) (*SignMessageResponse, error) RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error) GetBalances(ctx context.Context) (*BalancesResponse, error) + GetHomeChartsData(ctx context.Context, from uint64) (*HomeChartsResponse, error) ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}) (*SendPaymentResponse, error) @@ -364,6 +365,17 @@ type ListTransactionsResponse struct { Transactions []Transaction `json:"transactions"` } +type HomeChartsTxPoint struct { + Timestamp int64 `json:"timestamp"` + NetSat int64 `json:"netSat"` +} + +type HomeChartsResponse struct { + TxPoints []HomeChartsTxPoint `json:"txPoints"` + HasIncomingDeposit bool `json:"hasIncomingDeposit"` + NetFlowsSat int64 `json:"netFlowsSat"` +} + // TODO: camelCase type Transaction struct { Type string `json:"type"` diff --git a/frontend/package.json b/frontend/package.json index f292e4c57..1e4f3b909 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,6 +56,7 @@ "react-lottie": "^1.2.4", "react-qr-code": "^2.0.12", "react-router-dom": "^6.21.0", + "recharts": "^3.8.0", "sonner": "^2.0.7", "swr": "^2.3.6", "tailwind-merge": "^3.4.1", diff --git a/frontend/src/components/home/widgets/HomeTopChartsRow.tsx b/frontend/src/components/home/widgets/HomeTopChartsRow.tsx new file mode 100644 index 000000000..b705260f4 --- /dev/null +++ b/frontend/src/components/home/widgets/HomeTopChartsRow.tsx @@ -0,0 +1,477 @@ +import { ChevronDownIcon, ChevronUpIcon, MinusIcon } from "lucide-react"; +import React from "react"; +import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts"; +import useSWR from "swr"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "src/components/ui/card"; +import { useBalances } from "src/hooks/useBalances"; +import { useBitcoinRate } from "src/hooks/useBitcoinRate"; +import { useInfo } from "src/hooks/useInfo"; +import { cn } from "src/lib/utils"; +import { formatBitcoinAmount } from "src/utils/bitcoinFormatting"; +import { request } from "src/utils/request"; + +const DAYS = 7; +const WINDOW_MS = DAYS * 24 * 60 * 60 * 1000; +const CHART_REFRESH_MS = 5_000; + +type Point = { + day: string; + value: number; +}; + +type HomeChartsResponse = { + txPoints: Array<{ + timestamp: number; + netSat: number; + }>; + hasIncomingDeposit: boolean; + netFlowsSat: number; +}; + +function startOfDay(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function endOfDay(date: Date) { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 23, + 59, + 59, + 999 + ); +} + +function dayKey(date: Date) { + return startOfDay(date).toISOString().slice(0, 10); +} + +function buildDaySeries() { + const today = startOfDay(new Date()); + const dates: Date[] = []; + for (let i = DAYS - 1; i >= 0; i--) { + const d = new Date(today); + d.setDate(today.getDate() - i); + dates.push(d); + } + return dates; +} + +function satFromMsat(msat = 0) { + return msat / 1000; +} + +function msatFromSat(sat = 0) { + return sat * 1000; +} + +function formatBitcoinBySettings( + valueSat: number, + displayFormat: "sats" | "bip177" +) { + return formatBitcoinAmount(Math.round(valueSat) * 1000, displayFormat, true); +} + +function formatSignedPercent(value: number) { + const sign = value > 0 ? "+" : value < 0 ? "-" : ""; + return `${sign}${Math.abs(value).toFixed(2)}%`; +} + +function getChangeClass(delta: number) { + if (delta > 0) { + return "text-positive-foreground"; + } + if (delta < 0) { + return "text-orange-500"; + } + return "text-muted-foreground"; +} + +function parsePricePoints(input: unknown, currencyCode: string) { + const points: Array<{ ts: number; price: number }> = []; + const upper = currencyCode.toUpperCase(); + const lower = currencyCode.toLowerCase(); + const normalizeTimestamp = (raw: unknown) => { + let ts = + typeof raw === "number" + ? raw + : typeof raw === "string" + ? Number(raw) + : NaN; + if (!Number.isFinite(ts)) { + return NaN; + } + if (ts > 1e14) { + ts = Math.floor(ts / 1000); + } + if (ts > 0 && ts < 1e12) { + ts *= 1000; + } + return ts; + }; + + const visit = (value: unknown) => { + if (!value) { + return; + } + if (Array.isArray(value)) { + if (value.length >= 2) { + const maybeTs = normalizeTimestamp(value[0]); + const maybePrice = + typeof value[1] === "number" + ? value[1] + : typeof value[1] === "string" + ? Number(value[1]) + : NaN; + if ( + Number.isFinite(maybeTs) && + Number.isFinite(maybePrice) && + maybePrice > 0 + ) { + points.push({ ts: maybeTs, price: maybePrice }); + return; + } + } + value.forEach(visit); + return; + } + if (typeof value !== "object") { + return; + } + const item = value as Record; + const rawTs = + item.time ?? item.timestamp ?? item.date ?? item.t ?? item.createdAt; + const ts = normalizeTimestamp(rawTs); + const parsedDateTs = typeof rawTs === "string" ? Date.parse(rawTs) : NaN; + const finalTs = Number.isFinite(ts) ? ts : parsedDateTs; + + const rawPrice = + item[upper] ?? item[lower] ?? item.price ?? item.usd ?? item.USD; + const price = + typeof rawPrice === "number" + ? rawPrice + : typeof rawPrice === "string" + ? Number(rawPrice) + : NaN; + + if (Number.isFinite(finalTs) && Number.isFinite(price) && price > 0) { + points.push({ ts: finalTs, price }); + } + Object.values(item).forEach(visit); + }; + + visit(input); + points.sort((a, b) => a.ts - b.ts); + return points; +} + +function getDayClosePrice( + points: Array<{ ts: number; price: number }>, + dayEndTs: number +) { + const pointsForDay = points.filter((point) => point.ts <= dayEndTs); + const dayClose = + pointsForDay.length > 0 + ? pointsForDay[pointsForDay.length - 1].price + : points[points.length - 1]?.price; + return dayClose && dayClose > 0 ? dayClose : undefined; +} + +function buildPriceSeries( + dailyRawPayloads: unknown[] | undefined, + currencyCode: string, + fallbackPrice?: number +) { + const days = buildDaySeries(); + let lastKnown = fallbackPrice ?? 0; + + const series: Point[] = days.map((date, idx) => { + const points = parsePricePoints(dailyRawPayloads?.[idx], currencyCode); + const dayClose = getDayClosePrice(points, endOfDay(date).getTime()); + if (dayClose && dayClose > 0) { + lastKnown = dayClose; + } + + return { + day: dayKey(date).slice(5), + value: lastKnown, + }; + }); + + if (!series.some((point) => point.value > 0) && fallbackPrice) { + return days.map((date) => ({ + day: dayKey(date).slice(5), + value: fallbackPrice, + })); + } + + return series; +} + +function MetricCard({ + title, + value, + changeLabel, + changeValue, + data, +}: { + title: string; + value: string; + changeLabel: string; + changeValue: number; + data: Point[]; +}) { + const valueColor = getChangeClass(changeValue); + const stroke = changeValue >= 0 ? "#10b981" : "#f97316"; + const gradientId = `${title.toLowerCase().replace(/\s+/g, "-")}-sparkline-gradient`; + const IndicatorIcon = + changeValue > 0 + ? ChevronUpIcon + : changeValue < 0 + ? ChevronDownIcon + : MinusIcon; + + return ( + + + {title} + + +
+

+ {value} +

+ + + {changeLabel} + 7d + +
+
+ + + + + + + + + + + + + +
+
+
+ ); +} + +export function HomeTopChartsRow() { + const { data: balances } = useBalances(); + const { data: info } = useInfo(); + const { data: bitcoinRate } = useBitcoinRate(); + const priceHistoryCurrency = + (info?.currency || "USD").toUpperCase() === "SATS" + ? "USD" + : info?.currency || "USD"; + const daySnapshots = buildDaySeries(); + const historicalPriceEndpoints = daySnapshots.map((day) => { + const ts = Math.floor(endOfDay(day).getTime() / 1000); + const mempoolEndpoint = `/v1/historical-price?currency=${encodeURIComponent( + priceHistoryCurrency + )}×tamp=${ts}`; + return `/api/mempool?endpoint=${encodeURIComponent(mempoolEndpoint)}`; + }); + const { data: dailyPricePayloads } = useSWR( + info ? ["home-bitcoin-price-daily", ...historicalPriceEndpoints] : null, + async () => { + const responses = await Promise.all( + historicalPriceEndpoints.map(async (endpoint) => { + try { + const response = await request(endpoint); + return response ?? {}; + } catch { + return {}; + } + }) + ); + return responses; + }, + { refreshInterval: CHART_REFRESH_MS, refreshWhenHidden: true } + ); + const [nowTs, setNowTs] = React.useState(null); + + React.useEffect(() => { + setNowTs(Date.now()); + }, []); + const windowStart = (nowTs ?? 0) - WINDOW_MS; + const fromSeconds = Math.floor(windowStart / 1000); + const { data: homeChartsData } = useSWR( + info && nowTs !== null ? ["home-charts-data", fromSeconds] : null, + async () => { + const response = await request( + `/api/home/charts?from=${fromSeconds}` + ); + if (!response) { + throw new Error("Missing home chart payload"); + } + return response; + }, + { refreshInterval: CHART_REFRESH_MS, refreshWhenHidden: true } + ); + + if (!balances || !homeChartsData || !info || nowTs === null) { + return null; + } + + const totalBalanceSat = satFromMsat( + msatFromSat(balances.onchain.total) + balances.lightning.totalSpendable + ); + + if (totalBalanceSat <= 0 && !homeChartsData.hasIncomingDeposit) { + return null; + } + + const txPoints = homeChartsData.txPoints || []; + const netFlows7d = homeChartsData.netFlowsSat || 0; + const startingBalance = totalBalanceSat - netFlows7d; + + const totalBalanceSeries: Point[] = (() => { + let running = startingBalance; + const series: Point[] = [ + { + day: new Date(windowStart).toISOString().slice(11, 16), + value: running, + }, + ]; + for (const point of txPoints) { + running += point.netSat; + series.push({ + day: new Date(point.timestamp).toISOString().slice(11, 16), + value: running, + }); + } + if (series.length === 1 || running !== totalBalanceSat) { + series.push({ + day: new Date(nowTs).toISOString().slice(11, 16), + value: totalBalanceSat, + }); + } + return series; + })(); + + const netFlowSeries: Point[] = (() => { + let running = 0; + const series: Point[] = [ + { day: new Date(windowStart).toISOString().slice(11, 16), value: 0 }, + ]; + for (const point of txPoints) { + running += point.netSat; + series.push({ + day: new Date(point.timestamp).toISOString().slice(11, 16), + value: running, + }); + } + if (series.length === 1 || running !== netFlows7d) { + series.push({ + day: new Date(nowTs).toISOString().slice(11, 16), + value: netFlows7d, + }); + } + return series; + })(); + + const chartCurrency = priceHistoryCurrency; + const priceSeries = buildPriceSeries( + dailyPricePayloads, + chartCurrency, + bitcoinRate?.rate_float + ); + const dayCloses = daySnapshots + .map((day, idx) => { + const points = parsePricePoints(dailyPricePayloads?.[idx], chartCurrency); + return getDayClosePrice(points, endOfDay(day).getTime()); + }) + .filter((value): value is number => typeof value === "number"); + const firstPrice = dayCloses[0] || bitcoinRate?.rate_float || 0; + const lastPrice = + dayCloses[dayCloses.length - 1] || bitcoinRate?.rate_float || 0; + const hasPriceChangeData = + dayCloses.length >= 2 && firstPrice > 0 && lastPrice > 0; + const priceChangePct = hasPriceChangeData + ? ((lastPrice - firstPrice) / firstPrice) * 100 + : 0; + + const currency = chartCurrency.toUpperCase(); + const displayFormat = info.bitcoinDisplayFormat || "sats"; + const currentPrice = + priceSeries[priceSeries.length - 1]?.value || bitcoinRate?.rate_float || 0; + const netFlowChangePct = + totalBalanceSat > 0 ? (netFlows7d / totalBalanceSat) * 100 : 0; + const totalBalanceChangePct = + startingBalance > 0 + ? ((totalBalanceSat - startingBalance) / startingBalance) * 100 + : netFlowChangePct; + const priceLabel = new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency === "SATS" ? "USD" : currency, + maximumFractionDigits: 2, + }).format(currentPrice); + + return ( +
+ + + +
+ ); +} diff --git a/frontend/src/screens/Home.tsx b/frontend/src/screens/Home.tsx index e62e3480a..2e8ffbfcf 100644 --- a/frontend/src/screens/Home.tsx +++ b/frontend/src/screens/Home.tsx @@ -24,6 +24,7 @@ import zapplanner from "src/assets/suggested-apps/zapplanner.png"; import { AppOfTheDayWidget } from "src/components/home/widgets/AppOfTheDayWidget"; import { BlockHeightWidget } from "src/components/home/widgets/BlockHeightWidget"; import { ForwardsWidget } from "src/components/home/widgets/ForwardsWidget"; +import { HomeTopChartsRow } from "src/components/home/widgets/HomeTopChartsRow"; import { LatestUsedAppsWidget } from "src/components/home/widgets/LatestUsedAppsWidget"; import { LightningMessageboardWidget } from "src/components/home/widgets/LightningMessageboardWidget"; import { NodeStatusWidget } from "src/components/home/widgets/NodeStatusWidget"; @@ -65,6 +66,7 @@ function Home() { title={getGreeting(albyMe?.name)} contentRight={} /> +
{/* LEFT */}
diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 49b783720..146312640 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1934,6 +1934,18 @@ resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb" integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== +"@reduxjs/toolkit@^1.9.0 || 2.x.x": + version "2.11.2" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz#582225acea567329ca6848583e7dd72580d38e82" + integrity sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ== + dependencies: + "@standard-schema/spec" "^1.0.0" + "@standard-schema/utils" "^0.3.0" + immer "^11.0.0" + redux "^5.0.1" + redux-thunk "^3.1.0" + reselect "^5.1.0" + "@remix-run/router@1.23.0": version "1.23.0" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.23.0.tgz#35390d0e7779626c026b11376da6789eb8389242" @@ -2132,6 +2144,16 @@ resolved "https://registry.yarnpkg.com/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz#5af724b826f1ab4d7f2826d31d3efccec124102b" integrity sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA== +"@standard-schema/spec@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@standard-schema/utils@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b" + integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== + "@stepperize/core@1.2.7": version "1.2.7" resolved "https://registry.yarnpkg.com/@stepperize/core/-/core-1.2.7.tgz#b26d07787c44468be823eb7d3684d2e1812efeb1" @@ -2378,6 +2400,57 @@ dependencies: tslib "^2.4.0" +"@types/d3-array@^3.0.3": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" + integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw== + +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-ease@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" + integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== + +"@types/d3-interpolate@^3.0.1": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a" + integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg== + +"@types/d3-scale@^4.0.2": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" + integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== + dependencies: + "@types/d3-time" "*" + +"@types/d3-shape@^3.1.0": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" + integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time@*", "@types/d3-time@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f" + integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g== + +"@types/d3-timer@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" + integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== + "@types/esrecurse@^4.3.1": version "4.3.1" resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" @@ -2447,6 +2520,11 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@types/use-sync-external-store@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" + integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== + "@typescript-eslint/eslint-plugin@8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz#b1ce606d87221daec571e293009675992f0aae76" @@ -3023,6 +3101,77 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== +"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6: + version "3.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" + integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== + dependencies: + internmap "1 - 2" + +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +d3-ease@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +"d3-format@1 - 3": + version "3.1.2" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.2.tgz#01fdb46b58beb1f55b10b42ad70b6e344d5eb2ae" + integrity sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg== + +"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +d3-path@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" + integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== + +d3-scale@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== + dependencies: + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" + +d3-shape@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" + integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== + dependencies: + d3-path "^3.1.0" + +"d3-time-format@2 - 4": + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" + +"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" + integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== + dependencies: + d3-array "2 - 3" + +d3-timer@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + data-view-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" @@ -3079,6 +3228,11 @@ debug@^4.4.3: dependencies: ms "^2.1.3" +decimal.js-light@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" + integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== + deep-is@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" @@ -3316,6 +3470,11 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" +es-toolkit@^1.39.3: + version "1.45.1" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.45.1.tgz#21b28b2bd43178fd4c9c937c445d5bcaccce907b" + integrity sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw== + esbuild@^0.21.3: version "0.21.5" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" @@ -3858,6 +4017,16 @@ ignore@^7.0.5: resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== +immer@^10.1.1: + version "10.2.0" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.2.0.tgz#88a4ce06a1af64172d254b70f7cb04df51c871b1" + integrity sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw== + +immer@^11.0.0: + version "11.1.4" + resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.4.tgz#37aee86890b134a8f1a2fadd44361fb86c6ae67e" + integrity sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw== + import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" @@ -3890,6 +4059,11 @@ internal-slot@^1.1.0: hasown "^2.0.2" side-channel "^1.1.0" +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + interpret@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" @@ -4867,6 +5041,14 @@ react-qr-code@^2.0.12: prop-types "^15.8.1" qr.js "0.0.0" +"react-redux@8.x.x || 9.x.x": + version "9.2.0" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5" + integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g== + dependencies: + "@types/use-sync-external-store" "^0.0.6" + use-sync-external-store "^1.4.0" + react-remove-scroll-bar@^2.3.7: version "2.3.8" resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223" @@ -4916,6 +5098,23 @@ react@18.3.1: dependencies: loose-envify "^1.1.0" +recharts@^3.8.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.8.0.tgz#461025818cbb858e7ff2e5820b67c6143e9b418d" + integrity sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ== + dependencies: + "@reduxjs/toolkit" "^1.9.0 || 2.x.x" + clsx "^2.1.1" + decimal.js-light "^2.5.1" + es-toolkit "^1.39.3" + eventemitter3 "^5.0.1" + immer "^10.1.1" + react-redux "8.x.x || 9.x.x" + reselect "5.1.1" + tiny-invariant "^1.3.3" + use-sync-external-store "^1.2.2" + victory-vendor "^37.0.2" + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -4923,6 +5122,16 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +redux-thunk@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" + integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== + +redux@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" + integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== + reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: version "1.0.10" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" @@ -5000,6 +5209,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +reselect@5.1.1, reselect@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" + integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -5568,6 +5782,11 @@ terser@^5.17.4: commander "^2.20.0" source-map-support "~0.5.20" +tiny-invariant@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" + integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== + tinyexec@^1.0.0, tinyexec@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.4.tgz#6c60864fe1d01331b2f17c6890f535d7e5385408" @@ -5789,6 +6008,26 @@ vaul@^1.1.2: dependencies: "@radix-ui/react-dialog" "^1.1.1" +victory-vendor@^37.0.2: + version "37.3.6" + resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-37.3.6.tgz#401ac4b029a0b3d33e0cba8e8a1d765c487254da" + integrity sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ== + dependencies: + "@types/d3-array" "^3.0.3" + "@types/d3-ease" "^3.0.0" + "@types/d3-interpolate" "^3.0.1" + "@types/d3-scale" "^4.0.2" + "@types/d3-shape" "^3.1.0" + "@types/d3-time" "^3.0.0" + "@types/d3-timer" "^3.0.0" + d3-array "^3.1.6" + d3-ease "^3.0.1" + d3-interpolate "^3.0.1" + d3-scale "^4.0.2" + d3-shape "^3.1.0" + d3-time "^3.0.0" + d3-timer "^3.0.1" + vite-plugin-pwa@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz#3c7de17d4eed662f273095a0ac52f7a98d0cde36" diff --git a/http/home_charts_handlers.go b/http/home_charts_handlers.go new file mode 100644 index 000000000..36a34cdb7 --- /dev/null +++ b/http/home_charts_handlers.go @@ -0,0 +1,33 @@ +package http + +import ( + "net/http" + "strconv" + "time" + + "github.com/labstack/echo/v4" +) + +const defaultHomeChartsDays = 7 + +func (httpSvc *HttpService) homeChartsHandler(c echo.Context) error { + from := uint64(time.Now().Add(-defaultHomeChartsDays * 24 * time.Hour).Unix()) + if fromRaw := c.QueryParam("from"); fromRaw != "" { + parsed, err := strconv.ParseUint(fromRaw, 10, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: "invalid query parameter: from", + }) + } + from = parsed + } + + response, err := httpSvc.api.GetHomeChartsData(c.Request().Context(), from) + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: err.Error(), + }) + } + + return c.JSON(http.StatusOK, response) +} diff --git a/http/http_service.go b/http/http_service.go index 7d798bd5d..4843a9cef 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -139,6 +139,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) { readOnlyApiGroup.GET("/wallet/capabilities", httpSvc.capabilitiesHandler) readOnlyApiGroup.GET("/transactions", httpSvc.listTransactionsHandler) readOnlyApiGroup.GET("/transactions/:paymentHash", httpSvc.lookupTransactionHandler) + readOnlyApiGroup.GET("/home/charts", httpSvc.homeChartsHandler) readOnlyApiGroup.GET("/balances", httpSvc.balancesHandler) readOnlyApiGroup.GET("/mempool", httpSvc.mempoolApiHandler) readOnlyApiGroup.GET("/log/:type", httpSvc.getLogOutputHandler)