From 2b6cc1120b771b781d83f8f98cb8acbb986f9427 Mon Sep 17 00:00:00 2001 From: Zuliyat Date: Tue, 6 Oct 2026 21:39:36 +0100 Subject: [PATCH] fix: address Parallax issue #53 --- src/lib/receiptsCsv.d.ts | 13 +++++++++++++ src/lib/receiptsCsv.js | 33 ++++++++++++++++++++++++++++++++ src/pages/contributor.tsx | 38 ++++++++++++++++++++++++++++++++++--- tests/receipts-csv.test.mjs | 27 ++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 src/lib/receiptsCsv.d.ts create mode 100644 src/lib/receiptsCsv.js create mode 100644 tests/receipts-csv.test.mjs diff --git a/src/lib/receiptsCsv.d.ts b/src/lib/receiptsCsv.d.ts new file mode 100644 index 0000000..1b8244a --- /dev/null +++ b/src/lib/receiptsCsv.d.ts @@ -0,0 +1,13 @@ +export interface ReceiptCsvRow { + id: string; + date: string; + repository: string; + issueNumber: string; + issueTitle: string; + pullRequest: string; + amount: number; + asset: string; + payoutAddress: string; +} + +export function receiptsToCsv(rows: readonly ReceiptCsvRow[]): string; diff --git a/src/lib/receiptsCsv.js b/src/lib/receiptsCsv.js new file mode 100644 index 0000000..2bc067c --- /dev/null +++ b/src/lib/receiptsCsv.js @@ -0,0 +1,33 @@ +const COLUMNS = [ + 'receipt id', + 'date', + 'repository', + 'issue number', + 'issue title', + 'pull request', + 'amount', + 'asset', + 'payout address', +]; + +function cell(value) { + let text = value == null ? '' : String(value); + if (/^[\s]*[=+@-]/.test(text)) text = `'${text}`; + return `"${text.replaceAll('"', '""')}"`; +} + +/** Build a spreadsheet-safe CSV document from enriched receipt rows. */ +export function receiptsToCsv(rows) { + const records = [COLUMNS, ...rows.map(row => [ + row.id, + row.date, + row.repository, + row.issueNumber, + row.issueTitle, + row.pullRequest, + row.amount, + row.asset, + row.payoutAddress, + ])]; + return `${records.map(record => record.map(cell).join(',')).join('\r\n')}\r\n`; +} diff --git a/src/pages/contributor.tsx b/src/pages/contributor.tsx index 116f3a4..547e97c 100644 --- a/src/pages/contributor.tsx +++ b/src/pages/contributor.tsx @@ -1,11 +1,12 @@ import { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { ArrowRight, GitPullRequest } from 'lucide-react'; +import { ArrowRight, Download, GitPullRequest } from 'lucide-react'; import { dateLabel, formatMoney, initialState, isOwnApplication, isStellarAddress, repoName, shortAddress, statusTone, } from '../lib/model'; import { PLATFORM } from '../lib/platform'; import { useApp } from '../lib/store'; +import { receiptsToCsv } from '../lib/receiptsCsv.js'; import { Chip, Empty, Item, ModalHost, Page, PageHead, Stagger } from '../components/ui'; export function ContributorWork() { @@ -88,11 +89,36 @@ export function ContributorWork() { } export function ContributorReceipts() { - const { state } = useApp(); + const { state, notify } = useApp(); const me = state.session.contributor; const mine = state.receipts.filter(r => r.contributor === me); const total = mine.reduce((sum, r) => sum + r.amount, 0); + const exportCsv = () => { + const csv = receiptsToCsv(mine.map(receipt => { + const issue = state.issues.find(item => item.id === receipt.issueId); + const repo = state.repos.find(item => item.id === receipt.repoId); + return { + id: receipt.id, + date: receipt.paidAt, + repository: repo ? repoName(repo) : '', + issueNumber: receipt.issueId, + issueTitle: issue?.title ?? `Issue #${receipt.issueId}`, + pullRequest: receipt.pr, + amount: receipt.amount, + asset: PLATFORM.asset, + payoutAddress: receipt.address ?? '', + }; + })); + const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); + const link = document.createElement('a'); + link.href = url; + link.download = 'parallax-receipts.csv'; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + notify('Receipts exported as CSV.'); + }; + return ( @@ -114,7 +140,13 @@ export function ContributorReceipts() {
-

Ledger

+
+

Ledger

+ + +
{mine.length ? ( {mine.map(r => { diff --git a/tests/receipts-csv.test.mjs b/tests/receipts-csv.test.mjs new file mode 100644 index 0000000..2fb3ce4 --- /dev/null +++ b/tests/receipts-csv.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { receiptsToCsv } from '../src/lib/receiptsCsv.js'; + +test('writes the required receipt columns and safely escapes punctuation', () => { + const csv = receiptsToCsv([{ + id: 'rcpt-1', + date: '2026-10-06', + repository: 'stellar, sdk', + issueNumber: '842', + issueTitle: 'Fix "quoted"\nissue', + pullRequest: 'https://github.com/stellar/sdk/pull/2', + amount: 450, + asset: 'USDC', + payoutAddress: 'GADDRESS', + }]); + + assert.equal( + csv, + '"receipt id","date","repository","issue number","issue title","pull request","amount","asset","payout address"\r\n' + + '"rcpt-1","2026-10-06","stellar, sdk","842","Fix ""quoted""\nissue","https://github.com/stellar/sdk/pull/2","450","USDC","GADDRESS"\r\n', + ); +}); + +test('returns headers without a data row for an empty ledger', () => { + assert.equal(receiptsToCsv([]).split('\r\n').length, 2); +});