Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 3 additions & 26 deletions src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { sent: number; received: number }> = {}
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 (
<Card>
Expand Down
30 changes: 3 additions & 27 deletions src/pages/History.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { sent: number; received: number; fees: number }> = {}
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 (
<CardComp className="mb-5">
Expand Down
123 changes: 123 additions & 0 deletions src/utils/chart.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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)
})
})
})
105 changes: 105 additions & 0 deletions src/utils/chart.ts
Original file line number Diff line number Diff line change
@@ -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<string, { label: string; sent: number; received: number; fees: number }>()

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<string, { label: string; sent: number; received: number }>()

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)),
}))
}