From bd8fd974f565ca525ac94aa9e1f51f7592ce7f6e Mon Sep 17 00:00:00 2001 From: Thebora Kompanioni Date: Fri, 5 Aug 2022 13:56:19 +0200 Subject: [PATCH] feat: add sorting and filtering to earn report (#451) * feat: add sorting and filtering to earn report * dev: remove conf duration from earn table * fix: smaller header for utxo input count * ui: add pagination to earn report * refactor: rename itemsPerPage to pageSizes * review: improved header wording * review: align input count column contents * review: remove pagination from Earn Report component * test: add happy path tests for Earn Report parsing * test: show handling unexpected/malformed data in Earn Report * review: rearrange Earn Report columns; move Earned column to front --- src/components/EarnReport.module.css | 44 ++- src/components/EarnReport.test.tsx | 89 +++++ src/components/EarnReport.tsx | 507 +++++++++++++++++++-------- src/i18n/locales/en/translation.json | 11 +- src/libs/JmWalletApi.ts | 22 ++ 5 files changed, 530 insertions(+), 143 deletions(-) create mode 100644 src/components/EarnReport.test.tsx diff --git a/src/components/EarnReport.module.css b/src/components/EarnReport.module.css index 1005fe579..3a433d322 100644 --- a/src/components/EarnReport.module.css +++ b/src/components/EarnReport.module.css @@ -4,12 +4,54 @@ } .overlayContainer .earnReportContainer { + display: flex; + flex-direction: column; + gap: 2.5rem; background-color: var(--bs-body-bg); } @media only screen and (min-width: 768px) { .overlayContainer .earnReportContainer { - border-radius: 0.5rem; padding: 2rem; + border-radius: 0.5rem; + } +} + +.overlayContainer .earnReportContainer > .titleBar { + min-height: 3.6rem; + display: flex; + justify-content: space-between; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + padding: 0 0 0.8rem 0; + background-color: var(--bs-gray-100); +} + +@media only screen and (min-width: 768px) { + .overlayContainer .earnReportContainer > .titleBar { + align-items: center; + padding: 0.8rem 1rem; + border-radius: 0.6rem; } } + +@media only screen and (min-width: 768px) { + .overlayContainer .earnReportContainer > .titleBar { + flex-direction: row; + } +} + +:root[data-theme='dark'] .overlayContainer .earnReportContainer > .titleBar { + background-color: var(--bs-gray-800); +} + +.overlayContainer .earnReportContainer > .titleBar .refreshButton { + display: flex; + justify-content: center; + align-items: center; + width: 2rem; + height: 2rem; + padding: 0.1rem; + border: none; +} diff --git a/src/components/EarnReport.test.tsx b/src/components/EarnReport.test.tsx new file mode 100644 index 000000000..53509828c --- /dev/null +++ b/src/components/EarnReport.test.tsx @@ -0,0 +1,89 @@ +import { yieldgenReportToEarnReportEntries } from './EarnReport' + +const EXPECTED_HEADER_LINE = + 'timestamp,cj amount/satoshi,my input count,my input value/satoshi,cjfee/satoshi,earned/satoshi,confirm time/min,notes\n' + +describe('Earn Report', () => { + it('should parse empty data correctly', () => { + const entries = yieldgenReportToEarnReportEntries([]) + expect(entries.length).toBe(0) + }) + + it('should parse data only containing headers correctly', () => { + const entries = yieldgenReportToEarnReportEntries([EXPECTED_HEADER_LINE]) + + expect(entries.length).toBe(0) + }) + + it('should parse expected data structure correctly', () => { + const exampleData = [ + EXPECTED_HEADER_LINE, + '2008/10/31 02:42:54,,,,,,,Connected\n', + '2009/01/03 02:54:42,14999989490,4,20000005630,250,250,0.42,\n', + '2009/01/03 03:03:32,10000000000,3,15000016390,250,250,0.8,\n', + '2009/01/03 03:04:47,4999981140,1,5000016640,250,250,0,\n', + '2009/01/03 03:06:07,1132600000,1,2500000000,250,250,13.37,\n', + '2009/01/03 03:07:27,8867393010,2,10000000000,250,250,42,\n', + '2009/01/03 03:08:52,1132595980,1,1367400250,250,250,0.17,\n', + ] + + const entries = yieldgenReportToEarnReportEntries(exampleData) + + expect(entries.length).toBe(7) + + const firstEntry = entries[0] + expect(firstEntry.timestamp.toUTCString()).toBe('Fri, 31 Oct 2008 02:42:54 GMT') + expect(firstEntry.cjTotalAmount).toBe(null) + expect(firstEntry.inputCount).toBe(null) + expect(firstEntry.inputAmount).toBe(null) + expect(firstEntry.fee).toBe(null) + expect(firstEntry.earnedAmount).toBe(null) + expect(firstEntry.confirmationDuration).toBe(null) + expect(firstEntry.notes).toBe('Connected\n') + + const lastEntry = entries[entries.length - 1] + expect(lastEntry.timestamp.toUTCString()).toBe('Sat, 03 Jan 2009 03:08:52 GMT') + expect(lastEntry.cjTotalAmount).toBe(1132595980) + expect(lastEntry.inputCount).toBe(1) + expect(lastEntry.inputAmount).toBe(1367400250) + expect(lastEntry.fee).toBe(250) + expect(lastEntry.earnedAmount).toBe(250) + expect(lastEntry.confirmationDuration).toBe(0.17) + expect(lastEntry.notes).toBe('\n') + }) + + it('should handle unexpected/malformed data in a sane way', () => { + const unexpectedHeader = EXPECTED_HEADER_LINE + ',foo,bar' + const emptyLine = '' // should be skipped + const onlyNewLine = '\n' // should be skipped + const shortLine = '2009/01/03 04:04:04,,,' // should be skipped + const longLine = '2009/01/03 05:05:05,,,,,,,,,,,,,,,,,,,,,,,' // should be parsed + const malformedLine = 'this,is,a,malformed,line,with,some,unexpected,data' // should be parsed + + const exampleData = [unexpectedHeader, emptyLine, onlyNewLine, shortLine, longLine, malformedLine] + + const entries = yieldgenReportToEarnReportEntries(exampleData) + + expect(entries.length).toBe(2) + + const firstEntry = entries[0] + expect(firstEntry.timestamp.toUTCString()).toBe('Sat, 03 Jan 2009 05:05:05 GMT') + expect(firstEntry.cjTotalAmount).toBe(null) + expect(firstEntry.inputCount).toBe(null) + expect(firstEntry.inputAmount).toBe(null) + expect(firstEntry.fee).toBe(null) + expect(firstEntry.earnedAmount).toBe(null) + expect(firstEntry.confirmationDuration).toBe(null) + expect(firstEntry.notes).toBe(null) + + const secondEntry = entries[1] + expect(secondEntry.timestamp.toUTCString()).toBe('Invalid Date') + expect(secondEntry.cjTotalAmount).toBe(NaN) + expect(secondEntry.inputCount).toBe(NaN) + expect(secondEntry.inputAmount).toBe(NaN) + expect(secondEntry.fee).toBe(NaN) + expect(secondEntry.earnedAmount).toBe(NaN) + expect(secondEntry.confirmationDuration).toBe(NaN) + expect(secondEntry.notes).toBe('unexpected') + }) +}) diff --git a/src/components/EarnReport.tsx b/src/components/EarnReport.tsx index 33285e071..f5fb6c3bf 100644 --- a/src/components/EarnReport.tsx +++ b/src/components/EarnReport.tsx @@ -1,4 +1,8 @@ -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table' +import { useSort, HeaderCellSort, SortToggleType } from '@table-library/react-table-library/sort' +import * as TableTypes from '@table-library/react-table-library/types/table' +import { useTheme } from '@table-library/react-table-library/theme' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' import * as Api from '../libs/JmWalletApi' @@ -8,172 +12,376 @@ import Balance from './Balance' import Sprite from './Sprite' import styles from './EarnReport.module.css' -interface YielgenReportTableProps { - lines: string[] - maxAmountOfRows?: number +const SORT_KEYS = { + timestamp: 'TIMESTAMP', + cjTotalAmountInSats: 'CJ_TOTAL_AMOUNT_IN_SATS', + inputCount: 'INPUT_COUNT', + inputAmountInSats: 'INPUT_AMOUNT_IN_SATS', + feeInSats: 'FEE_IN_SATS', + earnedAmountInSats: 'EARNED_AMOUNT_IN_SATS', } -const YieldgenReportTable = ({ lines, maxAmountOfRows = 15 }: YielgenReportTableProps) => { - const { t } = useTranslation() - const settings = useSettings() +const TABLE_THEME = { + Table: ` + --data-table-library_grid-template-columns: 2fr 2fr 2fr 1fr 2fr 2fr 2fr; + font-size: 0.9rem; + `, + BaseCell: ` + &:nth-of-type(2) button { + display: flex; + justify-content: end; + } + &:nth-of-type(3) button { + display: flex; + justify-content: end; + } + &:nth-of-type(4) button { + display: flex; + justify-content: end; + } + &:nth-of-type(5) button { + display: flex; + justify-content: end; + } + &:nth-of-type(6) button { + display: flex; + justify-content: end; + } + `, + Cell: ` + &:nth-of-type(2) { + text-align: right; + } + &:nth-of-type(3) { + text-align: right; + } + &:nth-of-type(4) { + text-align: right; + } + &:nth-of-type(5) { + text-align: right; + } + &:nth-of-type(6) { + text-align: right; + } + `, +} - const reportHeadingMap: { [name: string]: { heading: string; format?: string } } = { - timestamp: { - heading: t('earn.report.heading_timestamp'), - }, - 'cj amount/satoshi': { - heading: t('earn.report.heading_cj_amount'), - format: 'balance', - }, - 'my input count': { - heading: t('earn.report.heading_input_count'), - }, - 'my input value/satoshi': { - heading: t('earn.report.heading_input_value'), - format: 'balance', - }, - 'cjfee/satoshi': { - heading: t('earn.report.heading_cj_fee'), - format: 'balance', - }, - 'earned/satoshi': { - heading: t('earn.report.heading_earned'), - format: 'balance', - }, - 'confirm time/min': { - heading: t('earn.report.heading_confirm_time'), - }, - 'notes\n': { - heading: t('earn.report.heading_notes'), - }, +type Minutes = number + +interface EarnReportEntry { + timestamp: Date + cjTotalAmount: Api.AmountSats | null + inputCount: number | null + inputAmount: Api.AmountSats | null + fee: Api.AmountSats | null + earnedAmount: Api.AmountSats | null + confirmationDuration: Minutes | null + notes: string | null +} + +// in the form of yyyy/MM/dd HH:mm:ss - e.g 2009/01/03 02:54:42 +type RawYielgenTimestamp = string + +const parseYieldgenTimestamp = (val: RawYielgenTimestamp) => { + // adding the timezone manually will display the date in the users timezone correctly + return new Date(Date.parse(`${val} GMT`)) +} + +const yieldgenReportLineToEarnReportEntry = (line: string): EarnReportEntry | null => { + if (!line.includes(',')) return null + + const values = line.split(',') + + // be defensive here - we cannot handle lines with unexpected values + if (values.length < 8) return null + + return { + timestamp: parseYieldgenTimestamp(values[0]), + cjTotalAmount: values[1] !== '' ? parseInt(values[1], 10) : null, + inputCount: values[2] !== '' ? parseInt(values[2], 10) : null, + inputAmount: values[3] !== '' ? parseInt(values[3], 10) : null, + fee: values[4] !== '' ? parseInt(values[4], 10) : null, + earnedAmount: values[5] !== '' ? parseInt(values[5], 10) : null, + confirmationDuration: values[6] !== '' ? parseFloat(values[6]) : null, + notes: values[7] !== '' ? values[7] : null, } +} - const empty = !lines || lines.length < 2 - const headers = empty ? [] : lines[0].split(',') +type YieldgenReportLinesWithHeader = string[] - const linesWithoutHeader = empty - ? [] - : lines - .slice(1, lines.length) - .map((line) => line.split(',')) - .reverse() +// exported for tests only +export const yieldgenReportToEarnReportEntries = (lines: YieldgenReportLinesWithHeader) => { + const empty = lines.length < 2 // report is "empty" if it just contains the header line + const linesWithoutHeader = empty ? [] : lines.slice(1, lines.length) + + return linesWithoutHeader + .map((line) => yieldgenReportLineToEarnReportEntry(line)) + .filter((entry) => entry !== null) + .map((entry) => entry!) +} - const visibleLines = linesWithoutHeader.slice(0, maxAmountOfRows) +// `TableNode` is known to have same properties as `EarnReportEntry`, hence prefer casting over object destructuring +const toEarnReportEntry = (tableNode: TableTypes.TableNode) => tableNode as unknown as EarnReportEntry + +interface EarnReportTableProps { + tableData: TableTypes.Data +} + +const EarnReportTable = ({ tableData }: EarnReportTableProps) => { + const { t } = useTranslation() + const settings = useSettings() + + const tableTheme = useTheme(TABLE_THEME) + + const tableSort = useSort( + tableData, + { + state: { + sortKey: SORT_KEYS.timestamp, + reverse: true, + }, + }, + { + sortIcon: { + margin: '4px', + iconDefault: , + iconUp: , + iconDown: , + }, + sortToggleType: SortToggleType.AlternateWithReset, + sortFns: { + [SORT_KEYS.timestamp]: (array) => array.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()), + [SORT_KEYS.earnedAmountInSats]: (array) => array.sort((a, b) => +a.earnedAmount - +b.earnedAmount), + [SORT_KEYS.cjTotalAmountInSats]: (array) => array.sort((a, b) => +a.cjTotalAmount - +b.cjTotalAmount), + [SORT_KEYS.inputCount]: (array) => array.sort((a, b) => +a.inputCount - +b.inputCount), + [SORT_KEYS.inputAmountInSats]: (array) => array.sort((a, b) => +a.inputAmount - +b.inputAmount), + [SORT_KEYS.feeInSats]: (array) => array.sort((a, b) => +a.fee - +b.fee), + }, + } + ) return ( <> - {empty ? ( - {t('earn.alert_empty_report')} - ) : ( -
- - - - {headers.map((name, index) => ( - {reportHeadingMap[name]?.heading || name} - ))} - - - - {visibleLines.map((line, trIndex) => ( - - {line.map((val, tdIndex) => ( - - {headers[tdIndex] && reportHeadingMap[headers[tdIndex]]?.format === 'balance' ? ( - - ) : ( - val - )} - - ))} - - ))} - - -
- - {t('earn.text_report_length', { - visibleLines: visibleLines.length, - linesWithoutHeader: linesWithoutHeader.length, + + {(tableList) => ( + <> +
+ + {t('earn.report.heading_timestamp')} + + {t('earn.report.heading_earned')} + + + {t('earn.report.heading_cj_amount')} + + {t('earn.report.heading_input_count')} + + {t('earn.report.heading_input_value')} + + {t('earn.report.heading_cj_fee')} + {t('earn.report.heading_notes')} + +
+ + {tableList.map((item) => { + const entry = toEarnReportEntry(item) + return ( + + {entry.timestamp.toLocaleString()} + + + + + + + {entry.inputCount} + + + + + + + {entry.notes} + + ) })} - + + + )} +
+ + ) +} + +interface EarnReportProps { + entries: EarnReportEntry[] + refresh: (signal: AbortSignal) => Promise +} + +export function EarnReport({ entries, refresh }: EarnReportProps) { + const { t } = useTranslation() + const settings = useSettings() + const [search, setSearch] = useState('') + const [isLoadingRefresh, setIsLoadingRefresh] = useState(false) + + const tableData: TableTypes.Data = useMemo(() => { + const searchVal = search.replace('.', '').toLowerCase() + const filteredEntries = + searchVal === '' + ? entries + : entries.filter((entry) => { + return ( + entry.timestamp.toLocaleString().toLowerCase().includes(searchVal) || + entry.cjTotalAmount?.toString().includes(searchVal) || + entry.inputCount?.toString().includes(searchVal) || + entry.inputAmount?.toString().includes(searchVal) || + entry.fee?.toString().includes(searchVal) || + entry.earnedAmount?.toString().includes(searchVal) || + entry.inputCount?.toString().includes(searchVal) || + entry.confirmationDuration?.toString().includes(searchVal) || + entry.notes?.toLowerCase().includes(searchVal) + ) + }) + const nodes = filteredEntries.map((entry, index) => ({ + ...entry, + id: `${index}`, + })) + + return { nodes } + }, [entries, search]) + + return ( +
+
+
+ { + if (isLoadingRefresh) return + + setIsLoadingRefresh(true) + + const abortCtrl = new AbortController() + refresh(abortCtrl.signal).finally(() => { + // as refreshing is fast most of the time, add a short delay to avoid flickering + setTimeout(() => setIsLoadingRefresh(false), 250) + }) + }} + > + {isLoadingRefresh ? ( + +
+ {search === '' ? ( + <> + {t('earn.report.text_report_summary', { + count: entries.length, + })} + + ) : ( + <> + {t('earn.report.text_report_summary_filtered', { + count: tableData.nodes.length, + })} + + )}
- )} - +
+ + {t('earn.report.label_search')} + setSearch(e.target.value)} + /> + +
+
+
+ {entries.length === 0 ? ( + {t('earn.alert_empty_report')} + ) : ( + + )} +
+
) } -export function EarnReport() { +export function EarnReportOverlay({ show, onHide }: rb.OffcanvasProps) { const { t } = useTranslation() const [alert, setAlert] = useState<(rb.AlertProps & { message: string }) | null>(null) const [isInitialized, setIsInitialized] = useState(false) const [isLoading, setIsLoading] = useState(true) - const [yieldgenReportLines, setYieldgenReportLines] = useState(null) + const [entries, setEntries] = useState(null) + + const refresh = useCallback( + (signal: AbortSignal) => { + return Api.getYieldgenReport({ signal }) + .then((res) => { + if (res.ok) return res.json() + // 404 is returned till the maker is started at least once + if (res.status === 404) return { yigen_data: [] } + return Api.Helper.throwError(res) + }) + .then((data) => data.yigen_data as YieldgenReportLinesWithHeader) + .then((linesWithHeader) => yieldgenReportToEarnReportEntries(linesWithHeader)) + .then((earnReportEntries) => { + if (signal.aborted) return + setAlert(null) + setEntries(earnReportEntries) + }) + .catch((e) => { + if (signal.aborted) return + const message = t('earn.error_loading_report_failed', { + reason: e.message || 'Unknown reason', + }) + setAlert({ variant: 'danger', message }) + }) + }, + [t] + ) useEffect(() => { - setIsLoading(true) + if (!show) return const abortCtrl = new AbortController() - Api.getYieldgenReport({ signal: abortCtrl.signal }) - .then((res) => { - if (res.ok) return res.json() - // 404 is returned till the maker is started at least once - if (res.status === 404) return { yigen_data: [] } - return Api.Helper.throwError(res, t('earn.error_loading_report_failed')) - }) - .then((data) => { - if (abortCtrl.signal.aborted) return - setYieldgenReportLines(data.yigen_data) - }) - .catch((e) => { - if (abortCtrl.signal.aborted) return - setAlert({ variant: 'danger', message: e.message }) - }) - .finally(() => { - if (abortCtrl.signal.aborted) return - setIsLoading(false) - setIsInitialized(true) - }) + setIsLoading(true) + refresh(abortCtrl.signal).finally(() => { + if (abortCtrl.signal.aborted) return + setIsLoading(false) + setIsInitialized(true) + }) return () => { abortCtrl.abort() } - }, [t]) - - return ( - <> - {!isInitialized && isLoading ? ( - Array(5) - .fill('') - .map((_, index) => { - return ( - - - - ) - }) - ) : ( - <> - {alert && {alert.message}} - {yieldgenReportLines && ( - - -
- -
-
-
- )} - - )} - - ) -} - -export function EarnReportOverlay({ show, onHide }: rb.OffcanvasProps) { - const { t } = useTranslation() + }, [show, refresh]) return ( - + {!isInitialized && isLoading ? ( + Array(5) + .fill('') + .map((_, index) => { + return ( + + + + ) + }) + ) : ( + <> + {alert && {alert.message}} + {entries && ( + + + + + + )} + + )} diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index e1ed5bf00..da88e3bfa 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -249,13 +249,18 @@ "button_show_orderbook": "Show orderbook", "report": { "title": "Earnings Report", + "text_report_summary_one": "{{ count }} entry", + "text_report_summary_other": "{{ count }} entries", + "text_report_summary_filtered_one": "found {{ count }} entry", + "text_report_summary_filtered_other": "found {{ count }} entries", + "label_search": "Search", + "placeholder_search": "Search", "heading_timestamp": "Timestamp", "heading_cj_amount": "Transaction Amount", - "heading_input_count": "No. of UTXOs input by me", - "heading_input_value": "Amount input by me", + "heading_input_count": "My Inputs", + "heading_input_value": "My Input Amount", "heading_cj_fee": "CoinJoin Fee", "heading_earned": "Earned", - "heading_confirm_time": "Confirmation Time (mins)", "heading_notes": "Notes" }, "title_fidelity_bonds": "Create a Fidelity Bond", diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts index 0ac094e43..1966efc6e 100644 --- a/src/libs/JmWalletApi.ts +++ b/src/libs/JmWalletApi.ts @@ -259,6 +259,28 @@ const postCoinjoin = async ({ token, signal, walletName }: WalletRequestContext, }) } +/** + * Fetch the contents of JM's yigen-statement.csv file. + * + * @param signal AbortSignal + * @returns object with prop `yigen_data` representing contents of yigen-statement.csv as array of strings + * + * e.g. + * ```json + * { + * "yigen_data": [ + * "timestamp,cj amount/satoshi,my input count,my input value/satoshi,cjfee/satoshi,earned/satoshi,confirm time/min,notes\n", + * "2009/01/03 02:54:42,,,,,,,Connected\n", + * "2009/01/09 02:55:13,14999992400,4,20000000000,250,250,60.17,\n", + * "2009/01/09 03:02:48,11093696866,3,15000007850,250,250,12.17,\n", + * "2009/02/01 17:31:03,3906287184,1,5000000000,250,250,30.08,\n", + * "2009/02/04 16:22:18,9687053174,2,10000000000,250,250,0.0,\n", + * "2009/02/12 22:01:57,1406636022,1,2500000000,250,250,4.08,\n", + * "2009/03/07 09:38:12,9687049154,2,10000000000,250,250,0.0,\n" + * ] + * } + * ``` + */ const getYieldgenReport = async ({ signal }: ApiRequestContext) => { return await fetch(`${basePath()}/v1/wallet/yieldgen/report`, { signal,