From c3d112de3736f78c915154b2da03f937bf2ae6c1 Mon Sep 17 00:00:00 2001 From: ghzhost Date: Wed, 2 Sep 2026 09:46:56 +0000 Subject: [PATCH] fix(charts): prevent cross-year day bucket collisions in History and Dashboard (#29, #47) - Extract shared chart aggregation utilities to src/utils/chart.ts (getHistoryChartData and getActivityChartData). - Key day buckets using normalized UTC YYYY-MM-DD keys so transactions on the same month/day in different years never collide. - Keep locale-formatted display strings ('MMM D') for chart axes and tooltips. - Add comprehensive test suite in src/utils/chart.test.ts validating cross-year separation and metric aggregations. - Fixes #29 and resolves #47 duplication. --- src/pages/Dashboard.tsx | 29 +--------- src/pages/History.tsx | 30 +--------- src/utils/chart.test.ts | 123 ++++++++++++++++++++++++++++++++++++++++ src/utils/chart.ts | 105 ++++++++++++++++++++++++++++++++++ 4 files changed, 234 insertions(+), 53 deletions(-) create mode 100644 src/utils/chart.test.ts create mode 100644 src/utils/chart.ts diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index e916ecb..dc5879f 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -19,37 +19,14 @@ import { } from 'recharts' import { useRecentTransactions } from '@/hooks/useTransactions' +import { getActivityChartData } from '@/utils/chart' + // ─── Activity chart ─────────────────────────────────────────────────────────── function ActivityChart() { const { data } = useRecentTransactions(50) const transactions = data?.transactions ?? [] - - // Group by day (last 7 days) - const days: Record = {} - const now = Date.now() - for (let i = 6; i >= 0; i--) { - const d = new Date(now - i * 86_400_000) - const key = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - days[key] = { sent: 0, received: 0 } - } - - transactions.forEach((tx) => { - const key = new Date(tx.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }) - if (days[key]) { - if (tx.direction === 'sent') days[key].sent += parseFloat(tx.amount || '0') - else days[key].received += parseFloat(tx.amount || '0') - } - }) - - const chartData = Object.entries(days).map(([date, vals]) => ({ - date, - sent: parseFloat(vals.sent.toFixed(4)), - received: parseFloat(vals.received.toFixed(4)), - })) + const chartData = getActivityChartData(transactions) return ( diff --git a/src/pages/History.tsx b/src/pages/History.tsx index f11a95b..cc0bb74 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -18,38 +18,14 @@ import { Card as CardComp } from '@/components/ui/Card' import { TrendingUp, TrendingDown, DollarSign, Activity } from 'lucide-react' import { formatAmount } from '@/lib/stellar' +import { getHistoryChartData } from '@/utils/chart' + // ─── Chart panel ───────────────────────────────────────────────────────────── function HistoryChart() { const { data } = useRecentTransactions(100) const transactions = data?.transactions ?? [] - - const days: Record = {} - const now = Date.now() - for (let i = 29; i >= 0; i--) { - const d = new Date(now - i * 86_400_000) - const key = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - days[key] = { sent: 0, received: 0, fees: 0 } - } - - transactions.forEach((tx) => { - const key = new Date(tx.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }) - if (days[key]) { - if (tx.direction === 'sent') days[key].sent += parseFloat(tx.amount || '0') - else days[key].received += parseFloat(tx.amount || '0') - days[key].fees += parseFloat(tx.fee || '0') / 10_000_000 - } - }) - - const chartData = Object.entries(days).map(([date, v]) => ({ - date, - Sent: +v.sent.toFixed(4), - Received: +v.received.toFixed(4), - Fees: +v.fees.toFixed(7), - })) + const chartData = getHistoryChartData(transactions) return ( diff --git a/src/utils/chart.test.ts b/src/utils/chart.test.ts new file mode 100644 index 0000000..dd9749d --- /dev/null +++ b/src/utils/chart.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { getHistoryChartData, getActivityChartData, toUtcDateKey } from './chart' +import type { Transaction } from '@/types' + +function makeTx(overrides: Partial = {}): Transaction { + return { + id: 'tx-1', + hash: 'hash-1', + createdAt: new Date().toISOString(), + type: 'payment', + status: 'success', + sourceAccount: 'GBXYZSOURCE...', + destinationAccount: 'GBXYZDEST...', + direction: 'sent', + amount: '10.5', + assetCode: 'XLM', + assetIssuer: null, + counterparty: 'GBXYZDEST...', + fee: '100', + ledger: 1000, + memo: '', + ...overrides, + } +} + +describe('chart aggregation utilities', () => { + describe('toUtcDateKey', () => { + it('formats a date cleanly to YYYY-MM-DD in UTC', () => { + const d = new Date('2026-09-02T12:00:00Z') + expect(toUtcDateKey(d)).toBe('2026-09-02') + }) + }) + + describe('getHistoryChartData (#29)', () => { + it('generates 30 day buckets', () => { + const chartData = getHistoryChartData([], new Date('2026-09-02T00:00:00Z').getTime()) + expect(chartData).toHaveLength(30) + }) + + it('does not merge transactions on the same month/day across different years', () => { + const refTime = new Date('2026-09-02T00:00:00Z').getTime() + + const txCurrentYear = makeTx({ + id: 'tx-2026', + createdAt: '2026-08-15T10:00:00Z', + direction: 'sent', + amount: '50.0', + fee: '1000', + }) + + const txPreviousYear = makeTx({ + id: 'tx-2025', + createdAt: '2025-08-15T10:00:00Z', + direction: 'sent', + amount: '100.0', + fee: '2000', + }) + + const chartData = getHistoryChartData([txCurrentYear, txPreviousYear], refTime) + const aug15Bucket = chartData.find((b) => b.date === 'Aug 15') + + expect(aug15Bucket).toBeDefined() + // Should only contain the 2026 transaction amount (50.0), NOT 150.0 + expect(aug15Bucket!.Sent).toBe(50.0) + }) + + it('correctly aggregates sent, received, and fees for in-window dates', () => { + const refTime = new Date('2026-09-02T00:00:00Z').getTime() + + const sentTx = makeTx({ + id: 'tx-1', + createdAt: '2026-09-01T12:00:00Z', + direction: 'sent', + amount: '12.5000', + fee: '1000', + }) + + const receivedTx = makeTx({ + id: 'tx-2', + createdAt: '2026-09-01T14:00:00Z', + direction: 'received', + amount: '25.2500', + fee: '500', + }) + + const chartData = getHistoryChartData([sentTx, receivedTx], refTime) + const sep1Bucket = chartData.find((b) => b.date === 'Sep 1') + + expect(sep1Bucket).toBeDefined() + expect(sep1Bucket!.Sent).toBe(12.5) + expect(sep1Bucket!.Received).toBe(25.25) + expect(sep1Bucket!.Fees).toBe(0.00015) // (1000 + 500) / 10,000,000 + }) + }) + + describe('getActivityChartData', () => { + it('generates 7 day buckets and aggregates sent/received amounts correctly', () => { + const refTime = new Date('2026-09-02T00:00:00Z').getTime() + + const tx1 = makeTx({ + id: 'tx-1', + createdAt: '2026-09-01T12:00:00Z', + direction: 'sent', + amount: '30.0', + }) + + const tx2 = makeTx({ + id: 'tx-2', + createdAt: '2026-09-01T16:00:00Z', + direction: 'received', + amount: '45.5', + }) + + const chartData = getActivityChartData([tx1, tx2], refTime) + expect(chartData).toHaveLength(7) + + const sep1Bucket = chartData.find((b) => b.date === 'Sep 1') + expect(sep1Bucket).toBeDefined() + expect(sep1Bucket!.sent).toBe(30.0) + expect(sep1Bucket!.received).toBe(45.5) + }) + }) +}) diff --git a/src/utils/chart.ts b/src/utils/chart.ts new file mode 100644 index 0000000..5c2d84b --- /dev/null +++ b/src/utils/chart.ts @@ -0,0 +1,105 @@ +import type { Transaction } from '@/types' + +export interface HistoryDayBucket { + date: string + Sent: number + Received: number + Fees: number +} + +export interface ActivityDayBucket { + date: string + sent: number + received: number +} + +/** + * Format a Date into ISO YYYY-MM-DD string using UTC to prevent timezone drift. + */ +export function toUtcDateKey(d: Date): string { + const year = d.getUTCFullYear() + const month = String(d.getUTCMonth() + 1).padStart(2, '0') + const day = String(d.getUTCDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +/** + * Format a Date for chart tick / tooltip display (e.g. "Jul 15"). + */ +export function formatDayDisplay(d: Date): string { + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }) +} + +/** + * Aggregate 30 days of transaction volume for HistoryChart. + * Buckets by unambiguous YYYY-MM-DD key so transactions on the same month/day + * across year boundaries or outside the 30-day window never collide or corrupt sums. + */ +export function getHistoryChartData(transactions: Transaction[], now: number = Date.now()): HistoryDayBucket[] { + const bucketMap = new Map() + + for (let i = 29; i >= 0; i--) { + const d = new Date(now - i * 86_400_000) + const key = toUtcDateKey(d) + const label = formatDayDisplay(d) + bucketMap.set(key, { label, sent: 0, received: 0, fees: 0 }) + } + + transactions.forEach((tx) => { + const txDate = new Date(tx.createdAt) + const key = toUtcDateKey(txDate) + const bucket = bucketMap.get(key) + if (bucket) { + if (tx.direction === 'sent') { + bucket.sent += parseFloat(tx.amount || '0') + } else { + bucket.received += parseFloat(tx.amount || '0') + } + bucket.fees += parseFloat(tx.fee || '0') / 10_000_000 + } + }) + + return Array.from(bucketMap.values()).map((v) => ({ + date: v.label, + Sent: +v.sent.toFixed(4), + Received: +v.received.toFixed(4), + Fees: +v.fees.toFixed(7), + })) +} + +/** + * Aggregate 7 days of transaction activity for ActivityChart on Dashboard. + */ +export function getActivityChartData(transactions: Transaction[], now: number = Date.now()): ActivityDayBucket[] { + const bucketMap = new Map() + + for (let i = 6; i >= 0; i--) { + const d = new Date(now - i * 86_400_000) + const key = toUtcDateKey(d) + const label = formatDayDisplay(d) + bucketMap.set(key, { label, sent: 0, received: 0 }) + } + + transactions.forEach((tx) => { + const txDate = new Date(tx.createdAt) + const key = toUtcDateKey(txDate) + const bucket = bucketMap.get(key) + if (bucket) { + if (tx.direction === 'sent') { + bucket.sent += parseFloat(tx.amount || '0') + } else { + bucket.received += parseFloat(tx.amount || '0') + } + } + }) + + return Array.from(bucketMap.values()).map((v) => ({ + date: v.label, + sent: parseFloat(v.sent.toFixed(4)), + received: parseFloat(v.received.toFixed(4)), + })) +}