diff --git a/frontend/src/components/TipHistory.jsx b/frontend/src/components/TipHistory.jsx
index eced1889..38b3833c 100644
--- a/frontend/src/components/TipHistory.jsx
+++ b/frontend/src/components/TipHistory.jsx
@@ -9,6 +9,8 @@ import ShareTip from './ShareTip';
import { useDemoMode } from '../context/DemoContext';
import RefundRequest from './RefundRequest';
import RefundApproval from './RefundApproval';
+import TipHistoryExport from './TipHistoryExport';
+import { Download } from 'lucide-react';
const CATEGORY_LABELS = {
0: 'General', 1: 'Content Creation', 2: 'Open Source',
@@ -60,6 +62,7 @@ export default function TipHistory({ userAddress, addToast }) {
const [tab, setTab] = useState('all');
const [categoryFilter, setCategoryFilter] = useState('all');
const [loadingMore, setLoadingMore] = useState(false);
+ const [showExportModal, setShowExportModal] = useState(false);
const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`;
const demoWalletAddress = demoEnabled ? getDemoData().mockWalletAddress : null;
@@ -255,6 +258,15 @@ export default function TipHistory({ userAddress, addToast }) {
{lastTipsRefresh && {lastTipsRefresh.toLocaleTimeString()}}
+
@@ -363,6 +375,14 @@ export default function TipHistory({ userAddress, addToast }) {
)}
)}
+
+ {showExportModal && (
+ setShowExportModal(false)}
+ userAddress={userAddress || demoWalletAddress}
+ />
+ )}
);
}
diff --git a/frontend/src/components/TipHistoryExport.jsx b/frontend/src/components/TipHistoryExport.jsx
new file mode 100644
index 00000000..7ed3e35c
--- /dev/null
+++ b/frontend/src/components/TipHistoryExport.jsx
@@ -0,0 +1,155 @@
+import { useState } from 'react';
+import { Download, X, Calendar } from 'lucide-react';
+import { generateTipHistoryCSV, downloadCSV, filterTipsByDateRange } from '../lib/csvExport';
+
+export default function TipHistoryExport({ tips, onClose, userAddress }) {
+ const [startDate, setStartDate] = useState('');
+ const [endDate, setEndDate] = useState('');
+ const [filterError, setFilterError] = useState('');
+
+ const handleExport = () => {
+ setFilterError('');
+
+ let filteredTips = tips;
+
+ if (startDate || endDate) {
+ const start = startDate ? new Date(startDate) : null;
+ const end = endDate ? new Date(endDate) : null;
+
+ if (start && end && start > end) {
+ setFilterError('Start date must be before end date');
+ return;
+ }
+
+ filteredTips = filterTipsByDateRange(tips, start, end);
+
+ if (filteredTips.length === 0) {
+ setFilterError('No tips found in the selected date range');
+ return;
+ }
+ }
+
+ const csvContent = generateTipHistoryCSV(filteredTips);
+ const timestamp = Date.now();
+ const dateRange = startDate || endDate
+ ? `_${startDate || 'start'}_to_${endDate || 'end'}`
+ : '';
+ const filename = `tip-history_${userAddress.slice(0, 8)}${dateRange}_${timestamp}.csv`;
+
+ downloadCSV(csvContent, filename);
+ onClose();
+ };
+
+ const handleClearDates = () => {
+ setStartDate('');
+ setEndDate('');
+ setFilterError('');
+ };
+
+ return (
+
+
+
+
Export Tip History
+
+
+
+
+
+ Export your tip history as a CSV file for accounting or tax purposes.
+
+
+
+
+
+ Date Range Filter (Optional)
+
+
+
+
+
+ {
+ setStartDate(e.target.value);
+ setFilterError('');
+ }}
+ className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-amber-500"
+ />
+
+
+
+
+ {
+ setEndDate(e.target.value);
+ setFilterError('');
+ }}
+ className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-amber-500"
+ />
+
+
+ {(startDate || endDate) && (
+
+ )}
+
+
+
+ {filterError && (
+
+ {filterError}
+
+ )}
+
+
+
CSV will include:
+
+ - Transaction ID
+ - Direction (sent/received)
+ - Sender and recipient addresses
+ - Amount in STX and microSTX
+ - Message and category
+ - Timestamp and formatted date
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/lib/csvExport.js b/frontend/src/lib/csvExport.js
new file mode 100644
index 00000000..14860da3
--- /dev/null
+++ b/frontend/src/lib/csvExport.js
@@ -0,0 +1,91 @@
+import { formatSTX } from './utils';
+
+const CATEGORY_LABELS = {
+ 0: 'General',
+ 1: 'Content Creation',
+ 2: 'Open Source',
+ 3: 'Community Help',
+ 4: 'Appreciation',
+ 5: 'Education',
+ 6: 'Bug Bounty',
+};
+
+function escapeCSVField(field) {
+ if (field === null || field === undefined) return '';
+ const str = String(field);
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+}
+
+function formatTimestamp(timestamp) {
+ if (!timestamp) return '';
+ const date = new Date(timestamp * 1000);
+ return date.toISOString();
+}
+
+export function generateTipHistoryCSV(tips) {
+ const headers = [
+ 'Transaction ID',
+ 'Direction',
+ 'Sender',
+ 'Recipient',
+ 'Amount (STX)',
+ 'Amount (microSTX)',
+ 'Message',
+ 'Category',
+ 'Timestamp',
+ 'Date',
+ ];
+
+ const rows = tips.map(tip => [
+ escapeCSVField(tip.txId),
+ escapeCSVField(tip.direction),
+ escapeCSVField(tip.sender),
+ escapeCSVField(tip.recipient),
+ escapeCSVField(formatSTX(tip.amount, 6)),
+ escapeCSVField(tip.amount),
+ escapeCSVField(tip.message || ''),
+ escapeCSVField(tip.category !== null ? CATEGORY_LABELS[tip.category] || tip.category : ''),
+ escapeCSVField(tip.timestamp || ''),
+ escapeCSVField(formatTimestamp(tip.timestamp)),
+ ]);
+
+ const csvContent = [
+ headers.join(','),
+ ...rows.map(row => row.join(',')),
+ ].join('\n');
+
+ return csvContent;
+}
+
+export function downloadCSV(csvContent, filename) {
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+}
+
+export function filterTipsByDateRange(tips, startDate, endDate) {
+ if (!startDate && !endDate) return tips;
+
+ return tips.filter(tip => {
+ if (!tip.timestamp) return false;
+ const tipDate = new Date(tip.timestamp * 1000);
+
+ if (startDate && tipDate < startDate) return false;
+ if (endDate) {
+ const endOfDay = new Date(endDate);
+ endOfDay.setHours(23, 59, 59, 999);
+ if (tipDate > endOfDay) return false;
+ }
+
+ return true;
+ });
+}
diff --git a/frontend/src/test/TipHistoryExport.test.jsx b/frontend/src/test/TipHistoryExport.test.jsx
new file mode 100644
index 00000000..36dadbee
--- /dev/null
+++ b/frontend/src/test/TipHistoryExport.test.jsx
@@ -0,0 +1,361 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import TipHistoryExport from '../components/TipHistoryExport';
+import * as csvExport from '../lib/csvExport';
+
+vi.mock('../lib/csvExport', () => ({
+ generateTipHistoryCSV: vi.fn(),
+ downloadCSV: vi.fn(),
+ filterTipsByDateRange: vi.fn((tips) => tips),
+}));
+
+describe('TipHistoryExport', () => {
+ const mockTips = [
+ {
+ txId: '0x123',
+ direction: 'sent',
+ sender: 'SP123',
+ recipient: 'SP456',
+ amount: '1000000',
+ message: 'Test tip',
+ category: 0,
+ timestamp: 1640000000,
+ },
+ {
+ txId: '0x456',
+ direction: 'received',
+ sender: 'SP789',
+ recipient: 'SP123',
+ amount: '2000000',
+ message: 'Another tip',
+ category: 1,
+ timestamp: 1640100000,
+ },
+ ];
+
+ const mockOnClose = vi.fn();
+ const mockUserAddress = 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7';
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ csvExport.generateTipHistoryCSV.mockReturnValue('mock,csv,content');
+ csvExport.filterTipsByDateRange.mockImplementation((tips) => tips);
+ });
+
+ it('should render export modal', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Export Tip History')).toBeInTheDocument();
+ expect(screen.getByText(/Export your tip history as a CSV file/)).toBeInTheDocument();
+ });
+
+ it('should display date range inputs', () => {
+ render(
+
+ );
+
+ expect(screen.getByLabelText('Start Date')).toBeInTheDocument();
+ expect(screen.getByLabelText('End Date')).toBeInTheDocument();
+ });
+
+ it('should display CSV content information', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('CSV will include:')).toBeInTheDocument();
+ expect(screen.getByText(/Transaction ID/)).toBeInTheDocument();
+ expect(screen.getByText(/Direction/)).toBeInTheDocument();
+ expect(screen.getByText(/Sender and recipient addresses/)).toBeInTheDocument();
+ });
+
+ it('should call onClose when cancel button is clicked', () => {
+ render(
+
+ );
+
+ const cancelButton = screen.getByText('Cancel');
+ fireEvent.click(cancelButton);
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call onClose when X button is clicked', () => {
+ render(
+
+ );
+
+ const closeButton = screen.getByLabelText('Close export dialog');
+ fireEvent.click(closeButton);
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should export CSV without date filter', async () => {
+ render(
+
+ );
+
+ const exportButton = screen.getByText('Export CSV');
+ fireEvent.click(exportButton);
+
+ await waitFor(() => {
+ expect(csvExport.generateTipHistoryCSV).toHaveBeenCalledWith(mockTips);
+ expect(csvExport.downloadCSV).toHaveBeenCalled();
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+ });
+
+ it('should generate filename with user address', async () => {
+ render(
+
+ );
+
+ const exportButton = screen.getByText('Export CSV');
+ fireEvent.click(exportButton);
+
+ await waitFor(() => {
+ const downloadCall = csvExport.downloadCSV.mock.calls[0];
+ const filename = downloadCall[1];
+ expect(filename).toContain('tip-history_SP2J6ZY4');
+ expect(filename).toContain('.csv');
+ });
+ });
+
+ it('should update start date input', () => {
+ render(
+
+ );
+
+ const startDateInput = screen.getByLabelText('Start Date');
+ fireEvent.change(startDateInput, { target: { value: '2024-01-01' } });
+
+ expect(startDateInput.value).toBe('2024-01-01');
+ });
+
+ it('should update end date input', () => {
+ render(
+
+ );
+
+ const endDateInput = screen.getByLabelText('End Date');
+ fireEvent.change(endDateInput, { target: { value: '2024-12-31' } });
+
+ expect(endDateInput.value).toBe('2024-12-31');
+ });
+
+ it('should show clear dates button when dates are set', () => {
+ render(
+
+ );
+
+ const startDateInput = screen.getByLabelText('Start Date');
+ fireEvent.change(startDateInput, { target: { value: '2024-01-01' } });
+
+ expect(screen.getByText('Clear date filters')).toBeInTheDocument();
+ });
+
+ it('should clear dates when clear button is clicked', () => {
+ render(
+
+ );
+
+ const startDateInput = screen.getByLabelText('Start Date');
+ fireEvent.change(startDateInput, { target: { value: '2024-01-01' } });
+
+ const clearButton = screen.getByText('Clear date filters');
+ fireEvent.click(clearButton);
+
+ expect(startDateInput.value).toBe('');
+ });
+
+ it('should show error when start date is after end date', async () => {
+ render(
+
+ );
+
+ const startDateInput = screen.getByLabelText('Start Date');
+ const endDateInput = screen.getByLabelText('End Date');
+
+ fireEvent.change(startDateInput, { target: { value: '2024-12-31' } });
+ fireEvent.change(endDateInput, { target: { value: '2024-01-01' } });
+
+ const exportButton = screen.getByText('Export CSV');
+ fireEvent.click(exportButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Start date must be before end date')).toBeInTheDocument();
+ });
+
+ expect(csvExport.downloadCSV).not.toHaveBeenCalled();
+ expect(mockOnClose).not.toHaveBeenCalled();
+ });
+
+ it('should show error when no tips match date range', async () => {
+ csvExport.filterTipsByDateRange.mockReturnValue([]);
+
+ render(
+
+ );
+
+ const startDateInput = screen.getByLabelText('Start Date');
+ fireEvent.change(startDateInput, { target: { value: '2025-01-01' } });
+
+ const exportButton = screen.getByText('Export CSV');
+ fireEvent.click(exportButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('No tips found in the selected date range')).toBeInTheDocument();
+ });
+
+ expect(csvExport.downloadCSV).not.toHaveBeenCalled();
+ expect(mockOnClose).not.toHaveBeenCalled();
+ });
+
+ it('should filter tips by date range before export', async () => {
+ const filteredTips = [mockTips[0]];
+ csvExport.filterTipsByDateRange.mockReturnValue(filteredTips);
+
+ render(
+
+ );
+
+ const startDateInput = screen.getByLabelText('Start Date');
+ const endDateInput = screen.getByLabelText('End Date');
+
+ fireEvent.change(startDateInput, { target: { value: '2024-01-01' } });
+ fireEvent.change(endDateInput, { target: { value: '2024-01-31' } });
+
+ const exportButton = screen.getByText('Export CSV');
+ fireEvent.click(exportButton);
+
+ await waitFor(() => {
+ expect(csvExport.filterTipsByDateRange).toHaveBeenCalled();
+ expect(csvExport.generateTipHistoryCSV).toHaveBeenCalledWith(filteredTips);
+ });
+ });
+
+ it('should include date range in filename when dates are set', async () => {
+ render(
+
+ );
+
+ const startDateInput = screen.getByLabelText('Start Date');
+ const endDateInput = screen.getByLabelText('End Date');
+
+ fireEvent.change(startDateInput, { target: { value: '2024-01-01' } });
+ fireEvent.change(endDateInput, { target: { value: '2024-12-31' } });
+
+ const exportButton = screen.getByText('Export CSV');
+ fireEvent.click(exportButton);
+
+ await waitFor(() => {
+ const downloadCall = csvExport.downloadCSV.mock.calls[0];
+ const filename = downloadCall[1];
+ expect(filename).toContain('2024-01-01');
+ expect(filename).toContain('2024-12-31');
+ });
+ });
+
+ it('should clear error when date is changed', async () => {
+ render(
+
+ );
+
+ const startDateInput = screen.getByLabelText('Start Date');
+ const endDateInput = screen.getByLabelText('End Date');
+
+ fireEvent.change(startDateInput, { target: { value: '2024-12-31' } });
+ fireEvent.change(endDateInput, { target: { value: '2024-01-01' } });
+
+ const exportButton = screen.getByText('Export CSV');
+ fireEvent.click(exportButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Start date must be before end date')).toBeInTheDocument();
+ });
+
+ fireEvent.change(startDateInput, { target: { value: '2024-01-01' } });
+
+ expect(screen.queryByText('Start date must be before end date')).not.toBeInTheDocument();
+ });
+
+ it('should handle empty tips array', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Export Tip History')).toBeInTheDocument();
+ expect(screen.getByText('Export CSV')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/test/csvExport.test.js b/frontend/src/test/csvExport.test.js
new file mode 100644
index 00000000..f70f895a
--- /dev/null
+++ b/frontend/src/test/csvExport.test.js
@@ -0,0 +1,324 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { generateTipHistoryCSV, downloadCSV, filterTipsByDateRange } from '../lib/csvExport';
+
+describe('csvExport', () => {
+ describe('generateTipHistoryCSV', () => {
+ it('should generate CSV with correct headers', () => {
+ const tips = [];
+ const csv = generateTipHistoryCSV(tips);
+ const lines = csv.split('\n');
+
+ expect(lines[0]).toBe('Transaction ID,Direction,Sender,Recipient,Amount (STX),Amount (microSTX),Message,Category,Timestamp,Date');
+ });
+
+ it('should generate CSV rows for tip data', () => {
+ const tips = [
+ {
+ txId: '0x123abc',
+ direction: 'sent',
+ sender: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
+ recipient: 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE',
+ amount: '1000000',
+ message: 'Great work',
+ category: 1,
+ timestamp: 1640000000,
+ },
+ ];
+
+ const csv = generateTipHistoryCSV(tips);
+ const lines = csv.split('\n');
+
+ expect(lines.length).toBe(2);
+ expect(lines[1]).toContain('0x123abc');
+ expect(lines[1]).toContain('sent');
+ expect(lines[1]).toContain('1.000000');
+ expect(lines[1]).toContain('1000000');
+ expect(lines[1]).toContain('Great work');
+ expect(lines[1]).toContain('Content Creation');
+ });
+
+ it('should escape CSV fields with commas', () => {
+ const tips = [
+ {
+ txId: '0x123',
+ direction: 'sent',
+ sender: 'SP123',
+ recipient: 'SP456',
+ amount: '1000000',
+ message: 'Hello, world',
+ category: 0,
+ timestamp: 1640000000,
+ },
+ ];
+
+ const csv = generateTipHistoryCSV(tips);
+ expect(csv).toContain('"Hello, world"');
+ });
+
+ it('should escape CSV fields with quotes', () => {
+ const tips = [
+ {
+ txId: '0x123',
+ direction: 'sent',
+ sender: 'SP123',
+ recipient: 'SP456',
+ amount: '1000000',
+ message: 'He said "hello"',
+ category: 0,
+ timestamp: 1640000000,
+ },
+ ];
+
+ const csv = generateTipHistoryCSV(tips);
+ expect(csv).toContain('"He said ""hello"""');
+ });
+
+ it('should handle empty messages', () => {
+ const tips = [
+ {
+ txId: '0x123',
+ direction: 'sent',
+ sender: 'SP123',
+ recipient: 'SP456',
+ amount: '1000000',
+ message: '',
+ category: 0,
+ timestamp: 1640000000,
+ },
+ ];
+
+ const csv = generateTipHistoryCSV(tips);
+ const lines = csv.split('\n');
+ expect(lines[1].split(',')[6]).toBe('');
+ });
+
+ it('should handle null category', () => {
+ const tips = [
+ {
+ txId: '0x123',
+ direction: 'sent',
+ sender: 'SP123',
+ recipient: 'SP456',
+ amount: '1000000',
+ message: 'Test',
+ category: null,
+ timestamp: 1640000000,
+ },
+ ];
+
+ const csv = generateTipHistoryCSV(tips);
+ const lines = csv.split('\n');
+ expect(lines[1].split(',')[7]).toBe('');
+ });
+
+ it('should format timestamp as ISO date', () => {
+ const tips = [
+ {
+ txId: '0x123',
+ direction: 'sent',
+ sender: 'SP123',
+ recipient: 'SP456',
+ amount: '1000000',
+ message: 'Test',
+ category: 0,
+ timestamp: 1640000000,
+ },
+ ];
+
+ const csv = generateTipHistoryCSV(tips);
+ expect(csv).toContain('2021-12-20');
+ });
+
+ it('should handle multiple tips', () => {
+ const tips = [
+ {
+ txId: '0x123',
+ direction: 'sent',
+ sender: 'SP123',
+ recipient: 'SP456',
+ amount: '1000000',
+ message: 'First',
+ category: 0,
+ timestamp: 1640000000,
+ },
+ {
+ txId: '0x456',
+ direction: 'received',
+ sender: 'SP789',
+ recipient: 'SP123',
+ amount: '2000000',
+ message: 'Second',
+ category: 1,
+ timestamp: 1640100000,
+ },
+ ];
+
+ const csv = generateTipHistoryCSV(tips);
+ const lines = csv.split('\n');
+
+ expect(lines.length).toBe(3);
+ expect(lines[1]).toContain('0x123');
+ expect(lines[2]).toContain('0x456');
+ });
+
+ it('should handle large amounts correctly', () => {
+ const tips = [
+ {
+ txId: '0x123',
+ direction: 'sent',
+ sender: 'SP123',
+ recipient: 'SP456',
+ amount: '100000000',
+ message: 'Large tip',
+ category: 0,
+ timestamp: 1640000000,
+ },
+ ];
+
+ const csv = generateTipHistoryCSV(tips);
+ expect(csv).toContain('100.000000');
+ expect(csv).toContain('100000000');
+ });
+ });
+
+ describe('downloadCSV', () => {
+ let mockLink;
+
+ beforeEach(() => {
+ mockLink = {
+ href: '',
+ download: '',
+ click: vi.fn(),
+ };
+
+ vi.spyOn(document, 'createElement').mockReturnValue(mockLink);
+ vi.spyOn(document.body, 'appendChild').mockImplementation(() => {});
+ vi.spyOn(document.body, 'removeChild').mockImplementation(() => {});
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should create and download CSV file', () => {
+ const csvContent = 'header1,header2\nvalue1,value2';
+ const filename = 'test.csv';
+
+ downloadCSV(csvContent, filename);
+
+ expect(document.createElement).toHaveBeenCalledWith('a');
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
+ });
+
+ it('should set correct filename', () => {
+ const csvContent = 'test';
+ const filename = 'my-export.csv';
+
+ downloadCSV(csvContent, filename);
+
+ expect(mockLink.download).toBe(filename);
+ });
+
+ it('should trigger download', () => {
+ const csvContent = 'test';
+ const filename = 'test.csv';
+
+ downloadCSV(csvContent, filename);
+
+ expect(mockLink.click).toHaveBeenCalled();
+ });
+ });
+
+ describe('filterTipsByDateRange', () => {
+ const tips = [
+ {
+ txId: '0x1',
+ timestamp: new Date('2024-01-15').getTime() / 1000,
+ },
+ {
+ txId: '0x2',
+ timestamp: new Date('2024-02-15').getTime() / 1000,
+ },
+ {
+ txId: '0x3',
+ timestamp: new Date('2024-03-15').getTime() / 1000,
+ },
+ {
+ txId: '0x4',
+ timestamp: null,
+ },
+ ];
+
+ it('should return all tips when no date range specified', () => {
+ const filtered = filterTipsByDateRange(tips, null, null);
+ expect(filtered.length).toBe(4);
+ });
+
+ it('should filter by start date only', () => {
+ const startDate = new Date('2024-02-01');
+ const filtered = filterTipsByDateRange(tips, startDate, null);
+
+ expect(filtered.length).toBe(2);
+ expect(filtered[0].txId).toBe('0x2');
+ expect(filtered[1].txId).toBe('0x3');
+ });
+
+ it('should filter by end date only', () => {
+ const endDate = new Date('2024-02-20');
+ const filtered = filterTipsByDateRange(tips, null, endDate);
+
+ expect(filtered.length).toBe(2);
+ expect(filtered[0].txId).toBe('0x1');
+ expect(filtered[1].txId).toBe('0x2');
+ });
+
+ it('should filter by both start and end date', () => {
+ const startDate = new Date('2024-02-01');
+ const endDate = new Date('2024-02-28');
+ const filtered = filterTipsByDateRange(tips, startDate, endDate);
+
+ expect(filtered.length).toBe(1);
+ expect(filtered[0].txId).toBe('0x2');
+ });
+
+ it('should exclude tips without timestamp', () => {
+ const startDate = new Date('2024-01-01');
+ const filtered = filterTipsByDateRange(tips, startDate, null);
+
+ expect(filtered.every(tip => tip.timestamp !== null)).toBe(true);
+ });
+
+ it('should include tips on the start date', () => {
+ const startDate = new Date('2024-01-15');
+ const filtered = filterTipsByDateRange(tips, startDate, null);
+
+ expect(filtered.some(tip => tip.txId === '0x1')).toBe(true);
+ });
+
+ it('should include tips on the end date', () => {
+ const endDate = new Date('2024-03-15');
+ const filtered = filterTipsByDateRange(tips, null, endDate);
+
+ expect(filtered.some(tip => tip.txId === '0x3')).toBe(true);
+ });
+
+ it('should handle same start and end date', () => {
+ const date = new Date('2024-02-15');
+ const filtered = filterTipsByDateRange(tips, date, date);
+
+ expect(filtered.length).toBe(1);
+ expect(filtered[0].txId).toBe('0x2');
+ });
+
+ it('should return empty array when no tips match range', () => {
+ const startDate = new Date('2025-01-01');
+ const endDate = new Date('2025-12-31');
+ const filtered = filterTipsByDateRange(tips, startDate, endDate);
+
+ expect(filtered.length).toBe(0);
+ });
+ });
+});