Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tools: Add Activity Export Feature #2785

Merged
merged 11 commits into from
Feb 15, 2025
7 changes: 7 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ import AddContact from './views/Settings/AddContact';
import ContactDetails from './views/ContactDetails';

import PendingHTLCs from './views/PendingHTLCs';
import ActivityExportOptions from './views/ActivityExportOptions';

// POS
import Order from './views/Order';
Expand Down Expand Up @@ -938,6 +939,12 @@ export default class App extends React.PureComponent {
Sweepremoteclosed
}
/>
<Stack.Screen
name="ActivityExportOptions" // @ts-ignore:next-line
component={
ActivityExportOptions
}
/>
</Stack.Navigator>
</NavigationContainer>
</>
Expand Down
1 change: 1 addition & 0 deletions assets/images/SVG/info.svg
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you see if we can use the text character here instead? we use it in the KeyValue component

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure. pushed the changes

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1017,8 +1017,20 @@
"views.ActivityFilter.ampInvoices": "AMP invoices",
"views.ActivityToCsv.title": "Download Activity",
"views.ActivityToCsv.csvDownloaded": "CSV file has been downloaded",
"views.ActivityToCsv.csvDownloadFailed": "Failed to download CSV file",
"views.ActivityToCsv.textInputPlaceholder": "File name (optional)",
"views.ActivityToCsv.downloadButton": "Download CSV",
"views.activityExport.title": "Export CSV Data",
"views.activityExport.noData": "No data to export",
"views.activityExport.exportPayments": "Export Payments",
"views.activityExport.exportInvoices": "Export Invoices",
"views.activityExport.exportTransactions": "Export Transactions",
"views.activityExport.fromDate": "From Date (Start Date)",
"views.activityExport.toDate": "To Date (End Date)",
"views.activityExport.dateRange": "Select Date Range:",
"views.activityExport.downloadCompleteData": "Download Complete Data",
"views.activityExport.explainerAndroid": "Downloaded CSV files can be found in Files > Downloads.",
"views.activityExport.explaineriOS": "Downloaded CSV files can be found in the Files app under the ZEUS folder.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good to see the Android and iOS breakdown here

"views.Routing.RoutingEvent.sourceChannel": "Source Channel",
"views.Routing.RoutingEvent.destinationChannel": "Destination Channel",
"views.Olympians.title": "Olympians",
Expand Down
200 changes: 200 additions & 0 deletions utils/ActivityCsvUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import {
getFormattedDateTime,
convertActivityToCsv,
saveCsvFile,
CSV_KEYS
} from '.././utils/ActivityCsvUtils';
import RNFS from 'react-native-fs';
import { Platform } from 'react-native';

jest.mock('react-native-fs', () => ({
DownloadDirectoryPath: '/mock/download/path',
DocumentDirectoryPath: '/mock/document/path',
writeFile: jest.fn()
}));

jest.mock('react-native', () => ({
Platform: { OS: 'android' }
}));

describe('activityCsvUtils', () => {
describe('getFormattedDateTime', () => {
it('returns a properly formatted timestamp', () => {
const result = getFormattedDateTime();
expect(result).toMatch(/^\d{8}_\d{6}$/); // Example: 20250212_140719
});
});

describe('convertActivityToCsv', () => {
it('correctly formats Invoice CSV data', async () => {
const mockInvoices = [
{
getAmount: 1500,
getPaymentRequest: 'inv_req123',
getRHash: 'hash_inv1',
getMemo: 'Test Memo',
getNote: 'Test Note',
getCreationDate: '2024-02-10',
formattedTimeUntilExpiry: '30 min'
},
{
getAmount: 3000,
getPaymentRequest: 'inv_req456',
getRHash: 'hash_inv2',
getMemo: '',
getNote: '',
getCreationDate: '2024-02-11',
formattedTimeUntilExpiry: '1 hour'
}
];

const result = await convertActivityToCsv(
mockInvoices,
CSV_KEYS.invoice
);
expect(result).toContain(
'"1500","inv_req123","hash_inv1","Test Memo","Test Note","2024-02-10","30 min"'
);
expect(result).toContain(
'"3000","inv_req456","hash_inv2","","","2024-02-11","1 hour"'
);
});

it('correctly formats Payment CSV data', async () => {
const mockPayments = [
{
getDestination: 'dest123',
getPaymentRequest: 'pay_req123',
paymentHash: 'hash_pay1',
getAmount: 800,
getMemo: 'Payment Memo',
getNote: 'Payment Note',
getDate: '2024-02-09'
},
{
getDestination: 'dest456',
getPaymentRequest: 'pay_req456',
paymentHash: 'hash_pay2',
getAmount: 1600,
getMemo: '',
getNote: '',
getDate: '2024-02-08'
}
];

const result = await convertActivityToCsv(
mockPayments,
CSV_KEYS.payment
);
expect(result).toContain(
'"dest123","pay_req123","hash_pay1","800","Payment Memo","Payment Note","2024-02-09"'
);
expect(result).toContain(
'"dest456","pay_req456","hash_pay2","1600","","","2024-02-08"'
);
});

it('correctly formats Transaction CSV data', async () => {
const mockTransactions = [
{
tx: 'txhash1',
getAmount: 2000,
getFee: 50,
getNote: 'Tx Note1',
getDate: '2024-02-07'
},
{
tx: 'txhash2',
getAmount: 5000,
getFee: 100,
getNote: '',
getDate: '2024-02-06'
}
];

const result = await convertActivityToCsv(
mockTransactions,
CSV_KEYS.transaction
);
expect(result).toContain(
'"txhash1","2000","50","Tx Note1","2024-02-07"'
);
expect(result).toContain('"txhash2","5000","100","","2024-02-06"');
});

it('handles missing fields for Invoice CSV', async () => {
const mockInvoices = [{ getAmount: 1500 }];
const result = await convertActivityToCsv(
mockInvoices,
CSV_KEYS.invoice
);
expect(result).toContain('"1500","","","","","",""');
});

it('handles missing fields for Payment CSV', async () => {
const mockPayments = [{ getDestination: 'dest123' }];
const result = await convertActivityToCsv(
mockPayments,
CSV_KEYS.payment
);
expect(result).toContain('"dest123","","","","","",""');
});

it('handles missing fields for Transaction CSV', async () => {
const mockTransactions = [{ tx: 'txhash1', getAmount: 2000 }];
const result = await convertActivityToCsv(
mockTransactions,
CSV_KEYS.transaction
);
expect(result).toContain('"txhash1","2000","","",""');
});
});

describe('saveCsvFile', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('writes the CSV file to the correct path on Android', async () => {
(Platform.OS as any) = 'android';
(RNFS.writeFile as jest.Mock).mockResolvedValue(undefined);

await saveCsvFile('test.csv', 'mock,csv,data');

expect(RNFS.writeFile).toHaveBeenCalledWith(
'/mock/download/path/test.csv',
'mock,csv,data',
'utf8'
);
});

it('writes the CSV file to the correct path on iOS', async () => {
(Platform.OS as any) = 'ios';
(RNFS.writeFile as jest.Mock).mockResolvedValue(undefined);

await saveCsvFile('test.csv', 'mock,csv,data');

expect(RNFS.writeFile).toHaveBeenCalledWith(
'/mock/document/path/test.csv',
'mock,csv,data',
'utf8'
);
});

it('throws an error when file writing fails (but suppresses console error)', async () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});

(RNFS.writeFile as jest.Mock).mockRejectedValue(
new Error('File write failed')
);

await expect(
saveCsvFile('test.csv', 'mock,csv,data')
).rejects.toThrow('File write failed');

consoleErrorSpy.mockRestore();
});
});
});
83 changes: 83 additions & 0 deletions utils/ActivityCsvUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import RNFS from 'react-native-fs';
import { Platform } from 'react-native';

// Keys for CSV export.
export const CSV_KEYS = {
invoice: [
{ label: 'Amount Paid (sat)', value: 'getAmount' },
{ label: 'Payment Request', value: 'getPaymentRequest' },
{ label: 'Payment Hash', value: 'getRHash' },
{ label: 'Memo', value: 'getMemo' },
{ label: 'Note', value: 'getNote' },
{ label: 'Creation Date', value: 'getCreationDate' },
{ label: 'Expiry', value: 'formattedTimeUntilExpiry' }
],
payment: [
{ label: 'Destination', value: 'getDestination' },
{ label: 'Payment Request', value: 'getPaymentRequest' },
{ label: 'Payment Hash', value: 'paymentHash' },
{ label: 'Amount Paid (sat)', value: 'getAmount' },
{ label: 'Memo', value: 'getMemo' },
{ label: 'Note', value: 'getNote' },
{ label: 'Creation Date', value: 'getDate' }
],
transaction: [
{ label: 'Transaction Hash', value: 'tx' },
{ label: 'Amount (sat)', value: 'getAmount' },
{ label: 'Total Fees (sat)', value: 'getFee' },
{ label: 'Note', value: 'getNote' },
{ label: 'Timestamp', value: 'getDate' }
]
};

// Generates a formatted timestamp string for file naming.
export const getFormattedDateTime = (): string => {
const now = new Date();
const year = now.getFullYear();
const month = (now.getMonth() + 1).toString().padStart(2, '0');
const day = now.getDate().toString().padStart(2, '0');
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const seconds = now.getSeconds().toString().padStart(2, '0');
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
};

// Converts activity data into a CSV string.
export const convertActivityToCsv = async (
data: Array<any>,
keysToInclude: Array<{ label: string; value: string }>
): Promise<string> => {
if (!data || data.length === 0) return '';

try {
const header = keysToInclude.map((field) => field.label).join(',');
const rows = data
.map((item) =>
keysToInclude
.map((field) => `"${item[field.value] || ''}"`)
.join(',')
)
.join('\n');

return `${header}\n${rows}`;
} catch (err) {
console.error(err);
return '';
}
};

//Saves CSV file to the device.
export const saveCsvFile = async (fileName: string, csvData: string) => {
try {
const filePath =
Platform.OS === 'android'
? `${RNFS.DownloadDirectoryPath}/${fileName}`
: `${RNFS.DocumentDirectoryPath}/${fileName}`;

console.log(`Saving file to: ${filePath}`);
await RNFS.writeFile(filePath, csvData, 'utf8');
} catch (err) {
console.error('Failed to save CSV file:', err);
throw err;
}
};
Loading