Repository: joinmarket-webui/jam
Pull request #451 (2022-08-04 22:24:13)

feat: add sorting and filtering to earn report
Adds ~~pagination,~~ sorting and filtering to Earnings Report overlay.

Was not quite sure how to best style the pagination. Not that happy with the result. Suggestions welcome!
Also, any ideas for improved wording of the table headings?

## :camera_flash: 
![image](https://user-images.githubusercontent.com/3358649/182963019-84f35684-e88a-4b39-a7ef-bf5d4f38c581.png)


dnlggr: > Was not quite sure how to best style the pagination. Not that happy with the result. Suggestions welcome!

We can also omit it for now if you're not happy with it. I feel like it's not super essential to have.
dnlggr: Pagination is nice (maybe needs a bit of a cleaner UI to feel less cluttered) but I feel like it'd be more interesting for the orderbook than for the earn report since the orderbook will be hundreds of entries long by default.
theborakompanioni: > Pagination is nice (maybe needs a bit of a cleaner UI to feel less cluttered) but I feel like it'd be more interesting for the orderbook than for the earn report since the orderbook will be hundreds of entries long by default.

Very valid point. Any immediate ideas or recommendations on the styling? Or should it be added and adapted in an upcoming version?
dnlggr: The easiest would probably be to come up with a UI and add it in one go to both tables? 🤔 

Patch:
diff --git a/src/components/EarnReport.module.css b/src/components/EarnReport.module.css
index 1005fe5..3a433d3 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.tsx b/src/components/EarnReport.tsx
index 33285e0..f5fb6c3 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: <Sprite symbol="caret-right" width="20" height="20" />,
+        iconUp: <Sprite symbol="caret-up" width="20" height="20" />,
+        iconDown: <Sprite symbol="caret-down" width="20" height="20" />,
+      },
+      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 ? (
-        <rb.Alert variant="info">{t('earn.alert_empty_report')}</rb.Alert>
-      ) : (
-        <div>
-          <rb.Table striped bordered hover variant={settings.theme} responsive>
-            <thead>
-              <tr>
-                {headers.map((name, index) => (
-                  <th key={`header_${index}`}>{reportHeadingMap[name]?.heading || name}</th>
-                ))}
-              </tr>
-            </thead>
-            <tbody>
-              {visibleLines.map((line, trIndex) => (
-                <tr key={`tr_${trIndex}`}>
-                  {line.map((val, tdIndex) => (
-                    <td key={`td_${tdIndex}`}>
-                      {headers[tdIndex] && reportHeadingMap[headers[tdIndex]]?.format === 'balance' ? (
-                        <Balance valueString={val} convertToUnit={settings.unit} showBalance={settings.showBalance} />
-                      ) : (
-                        val
-                      )}
-                    </td>
-                  ))}
-                </tr>
-              ))}
-            </tbody>
-          </rb.Table>
-          <div className="my-1 d-flex justify-content-end">
-            <small>
-              {t('earn.text_report_length', {
-                visibleLines: visibleLines.length,
-                linesWithoutHeader: linesWithoutHeader.length,
+      <Table data={tableData} theme={tableTheme} sort={tableSort} layout={{ custom: true, horizontalScroll: true }}>
+        {(tableList) => (
+          <>
+            <Header>
+              <HeaderRow>
+                <HeaderCellSort sortKey={SORT_KEYS.timestamp}>{t('earn.report.heading_timestamp')}</HeaderCellSort>
+                <HeaderCellSort sortKey={SORT_KEYS.earnedAmountInSats}>
+                  {t('earn.report.heading_earned')}
+                </HeaderCellSort>
+                <HeaderCellSort sortKey={SORT_KEYS.cjTotalAmountInSats}>
+                  {t('earn.report.heading_cj_amount')}
+                </HeaderCellSort>
+                <HeaderCellSort sortKey={SORT_KEYS.inputCount}>{t('earn.report.heading_input_count')}</HeaderCellSort>
+                <HeaderCellSort sortKey={SORT_KEYS.inputAmountInSats}>
+                  {t('earn.report.heading_input_value')}
+                </HeaderCellSort>
+                <HeaderCellSort sortKey={SORT_KEYS.feeInSats}>{t('earn.report.heading_cj_fee')}</HeaderCellSort>
+                <HeaderCell>{t('earn.report.heading_notes')}</HeaderCell>
+              </HeaderRow>
+            </Header>
+            <Body>
+              {tableList.map((item) => {
+                const entry = toEarnReportEntry(item)
+                return (
+                  <Row key={item.id} item={item}>
+                    <Cell>{entry.timestamp.toLocaleString()}</Cell>
+                    <Cell>
+                      <Balance
+                        valueString={entry.earnedAmount?.toString() || ''}
+                        convertToUnit={settings.unit}
+                        showBalance={true}
+                      />
+                    </Cell>
+                    <Cell>
+                      <Balance
+                        valueString={entry.cjTotalAmount?.toString() || ''}
+                        convertToUnit={settings.unit}
+                        showBalance={true}
+                      />
+                    </Cell>
+                    <Cell>{entry.inputCount}</Cell>
+                    <Cell>
+                      <Balance
+                        valueString={entry.inputAmount?.toString() || ''}
+                        convertToUnit={settings.unit}
+                        showBalance={true}
+                      />
+                    </Cell>
+                    <Cell>
+                      <Balance
+                        valueString={entry.fee?.toString() || ''}
+                        convertToUnit={settings.unit}
+                        showBalance={true}
+                      />
+                    </Cell>
+                    <Cell>{entry.notes}</Cell>
+                  </Row>
+                )
               })}
-            </small>
+            </Body>
+          </>
+        )}
+      </Table>
+    </>
+  )
+}
+
+interface EarnReportProps {
+  entries: EarnReportEntry[]
+  refresh: (signal: AbortSignal) => Promise<void>
+}
+
+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 (
+    <div className={styles.earnReportContainer}>
+      <div className={styles.titleBar}>
+        <div className="d-flex justify-content-center align-items-center gap-2">
+          <rb.Button
+            className={styles.refreshButton}
+            variant={settings.theme}
+            onClick={() => {
+              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 ? (
+              <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" />
+            ) : (
+              <Sprite symbol="refresh" width="24" height="24" />
+            )}
+          </rb.Button>
+          <div className="small">
+            {search === '' ? (
+              <>
+                {t('earn.report.text_report_summary', {
+                  count: entries.length,
+                })}
+              </>
+            ) : (
+              <>
+                {t('earn.report.text_report_summary_filtered', {
+                  count: tableData.nodes.length,
+                })}
+              </>
+            )}
           </div>
         </div>
-      )}
-    </>
+        <div>
+          <rb.Form.Group controlId="search">
+            <rb.Form.Label className="m-0 pe-2 d-none">{t('earn.report.label_search')}</rb.Form.Label>
+            <rb.Form.Control
+              name="search"
+              placeholder={t('earn.report.placeholder_search')}
+              value={search}
+              disabled={isLoadingRefresh}
+              onChange={(e) => setSearch(e.target.value)}
+            />
+          </rb.Form.Group>
+        </div>
+      </div>
+      <div className="px-md-3 pb-2">
+        {entries.length === 0 ? (
+          <rb.Alert variant="info">{t('earn.alert_empty_report')}</rb.Alert>
+        ) : (
+          <EarnReportTable tableData={tableData} />
+        )}
+      </div>
+    </div>
   )
 }
 
-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<string[] | null>(null)
+  const [entries, setEntries] = useState<EarnReportEntry[] | null>(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 (
-              <rb.Placeholder key={index} as="div" animation="wave">
-                <rb.Placeholder xs={12} className={styles['report-line-placeholder']} />
-              </rb.Placeholder>
-            )
-          })
-      ) : (
-        <>
-          {alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
-          {yieldgenReportLines && (
-            <rb.Row>
-              <rb.Col>
-                <div className={styles.earnReportContainer}>
-                  <YieldgenReportTable lines={yieldgenReportLines} />
-                </div>
-              </rb.Col>
-            </rb.Row>
-          )}
-        </>
-      )}
-    </>
-  )
-}
-
-export function EarnReportOverlay({ show, onHide }: rb.OffcanvasProps) {
-  const { t } = useTranslation()
+  }, [show, refresh])
 
   return (
     <rb.Offcanvas
@@ -198,7 +406,28 @@ export function EarnReportOverlay({ show, onHide }: rb.OffcanvasProps) {
       </rb.Offcanvas.Header>
       <rb.Offcanvas.Body>
         <rb.Container fluid="md" className="py-4 py-sm-5">
-          <EarnReport />
+          {!isInitialized && isLoading ? (
+            Array(5)
+              .fill('')
+              .map((_, index) => {
+                return (
+                  <rb.Placeholder key={index} as="div" animation="wave">
+                    <rb.Placeholder xs={12} className={styles['report-line-placeholder']} />
+                  </rb.Placeholder>
+                )
+              })
+          ) : (
+            <>
+              {alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
+              {entries && (
+                <rb.Row>
+                  <rb.Col>
+                    <EarnReport entries={entries} refresh={refresh} />
+                  </rb.Col>
+                </rb.Row>
+              )}
+            </>
+          )}
         </rb.Container>
       </rb.Offcanvas.Body>
     </rb.Offcanvas>
diff --git a/src/components/fb/CreateFidelityBond.jsx b/src/components/fb/CreateFidelityBond.jsx
index b25112a..c3ec908 100644
--- a/src/components/fb/CreateFidelityBond.jsx
+++ b/src/components/fb/CreateFidelityBond.jsx
@@ -35,7 +35,7 @@ const steps = {
 
 const CreateFidelityBond = ({ otherFidelityBondExists, accountBalances, totalBalance, wallet, walletInfo, onDone }) => {
   const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
-  const { t } = useTranslation()
+  const { t, i18n } = useTranslation()
 
   const [isExpanded, setIsExpanded] = useState(false)
   const [isLoading, setIsLoading] = useState(false)
@@ -510,18 +510,26 @@ const CreateFidelityBond = ({ otherFidelityBondExists, accountBalances, totalBal
   return (
     <div className={styles.container}>
       {alert && <Alert {...alert} className="mt-0" onDismissed={() => setAlert(null)} />}
-      <ConfirmModal
-        isShown={showConfirmInputsModal}
-        title={t('earn.fidelity_bond.confirm_modal.title')}
-        onCancel={() => setShowConfirmInputsModal(false)}
-        onConfirm={() => {
-          setStep(steps.createFidelityBond)
-          setShowConfirmInputsModal(false)
-          directSweepToFidelityBond(selectedJar, timelockedAddress)
-        }}
-      >
-        {t('earn.fidelity_bond.confirm_modal.body', { date: new Date(lockDate).toUTCString() })}
-      </ConfirmModal>
+      {lockDate && (
+        <ConfirmModal
+          isShown={showConfirmInputsModal}
+          title={t('earn.fidelity_bond.confirm_modal.title')}
+          onCancel={() => setShowConfirmInputsModal(false)}
+          onConfirm={() => {
+            setStep(steps.createFidelityBond)
+            setShowConfirmInputsModal(false)
+            directSweepToFidelityBond(selectedJar, timelockedAddress)
+          }}
+        >
+          {t('earn.fidelity_bond.confirm_modal.body', {
+            date: new Date(lockDate).toUTCString(),
+            humanReadableDuration: fb.time.humanReadableDuration({
+              to: fb.lockdate.toTimestamp(lockDate),
+              locale: i18n.resolvedLanguage || i18n.language,
+            }),
+          })}
+        </ConfirmModal>
+      )}
       <div className={styles.header} onClick={() => setIsExpanded(!isExpanded)}>
         <div className="d-flex justify-content-between align-items-center">
           <div className={styles.title}>
diff --git a/src/components/fb/ExistingFidelityBond.jsx b/src/components/fb/ExistingFidelityBond.jsx
index 3b98a91..e70dbc2 100644
--- a/src/components/fb/ExistingFidelityBond.jsx
+++ b/src/components/fb/ExistingFidelityBond.jsx
@@ -1,12 +1,15 @@
 import React from 'react'
+import { useTranslation } from 'react-i18next'
 import { useSettings } from '../../context/SettingsContext'
 import Sprite from '../Sprite'
 import Balance from '../Balance'
 import { CopyButton } from '../CopyButton'
+import * as fb from './utils'
 import styles from './ExistingFidelityBond.module.css'
 
 const ExistingFidelityBond = ({ utxo }) => {
   const settings = useSettings()
+  const { i18n } = useTranslation()
 
   return (
     <div className={styles.container}>
@@ -28,7 +31,14 @@ const ExistingFidelityBond = ({ utxo }) => {
             <Sprite symbol="clock" width="18" height="18" className={styles.icon} />
             <div className="d-flex flex-column">
               <div className={styles.label}>Locked until</div>
-              <div className={styles.content}>{utxo.locktime}</div>
+              <div className={styles.content}>
+                {utxo.locktime} (
+                {fb.time.humanReadableDuration({
+                  to: new Date(utxo.locktime).getTime(),
+                  locale: i18n.resolvedLanguage || i18n.language,
+                })}
+                )
+              </div>
             </div>
           </div>
           <div className="d-flex align-items-center gap-2">
diff --git a/src/components/fb/FidelityBondSteps.tsx b/src/components/fb/FidelityBondSteps.tsx
index b2c96c2..ff04f78 100644
--- a/src/components/fb/FidelityBondSteps.tsx
+++ b/src/components/fb/FidelityBondSteps.tsx
@@ -272,13 +272,22 @@ const UtxoSummary = ({ title, icon, utxos }: { title: string; icon: React.ReactE
 
 const ReviewInputs = ({ lockDate, jar, utxos, selectedUtxos, timelockedAddress }: ReviewInputsProps) => {
   const settings = useSettings()
-  const { t } = useTranslation()
+  const { t, i18n } = useTranslation()
 
   const confirmationItems = [
     {
       icon: <Sprite symbol="clock" width="18" height="18" className={styles.confirmationStepIcon} />,
       label: t('earn.fidelity_bond.review_inputs.label_lock_date'),
-      content: <>{new Date(lockDate).toUTCString()}</>,
+      content: (
+        <>
+          {new Date(lockDate).toUTCString()}(
+          {fb.time.humanReadableDuration({
+            to: fb.lockdate.toTimestamp(lockDate),
+            locale: i18n.resolvedLanguage || i18n.language,
+          })}
+          )
+        </>
+      ),
     },
     {
       icon: <Sprite symbol="jar-open-fill-50" width="18" height="18" className={styles.confirmationStepIcon} />,
@@ -339,7 +348,7 @@ const ReviewInputs = ({ lockDate, jar, utxos, selectedUtxos, timelockedAddress }
 }
 
 const CreatedFidelityBond = ({ fbUtxo, frozenUtxos }: CreatedFidelityBondProps) => {
-  const { t } = useTranslation()
+  const { t, i18n } = useTranslation()
 
   return (
     <div className="d-flex flex-column gap-3">
@@ -354,7 +363,16 @@ const CreatedFidelityBond = ({ fbUtxo, frozenUtxos }: CreatedFidelityBondProps)
                 <div className={styles.confirmationStepLabel}>
                   {t('earn.fidelity_bond.create_fidelity_bond.label_lock_date')}
                 </div>
-                <div className={styles.confirmationStepContent}>{fbUtxo.locktime}</div>
+                {fbUtxo.locktime && (
+                  <div className={styles.confirmationStepContent}>
+                    {fbUtxo.locktime} (
+                    {fb.time.humanReadableDuration({
+                      to: new Date(fbUtxo.locktime).getTime(),
+                      locale: i18n.resolvedLanguage || i18n.language,
+                    })}
+                    )
+                  </div>
+                )}
               </div>
             </div>
             <div className="d-flex align-items-center gap-2">
diff --git a/src/components/fb/utils.ts b/src/components/fb/utils.ts
index cc18309..304b523 100644
--- a/src/components/fb/utils.ts
+++ b/src/components/fb/utils.ts
@@ -3,6 +3,7 @@ import { Utxo } from '../../context/WalletContext'
 
 type Milliseconds = number
 type Seconds = number
+type TimeInterval = number
 
 export type YearsRange = {
   min: number
@@ -105,3 +106,56 @@ export const utxo = (() => {
 
   return { isEqual, isInList, utxosToFreeze, allAreFrozen, isLocked }
 })()
+
+export const time = (() => {
+  type Unit = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second'
+
+  // These values don't need to be exact.
+  // They are only used to approximate a human readable
+  // representation of a time interval--e.g. "in 2 months".
+  const UNIT_MILLIS: { [key in Unit]: Milliseconds } = {
+    year: 24 * 60 * 60 * 1_000 * 365,
+    month: (24 * 60 * 60 * 1_000 * 365) / 12, // ~30.42 days
+    day: 24 * 60 * 60 * 1_000,
+    hour: 60 * 60 * 1_000,
+    minute: 60 * 1_000,
+    second: 1_000,
+  }
+
+  const humanReadableDuration = ({
+    from = Date.now(),
+    to,
+    locale = 'en',
+  }: {
+    from?: Milliseconds
+    to: Milliseconds
+    locale?: string
+  }) => humanReadableTimeInterval(timeInterval({ from, to }), locale)
+
+  const timeInterval = ({ from = Date.now(), to }: { from?: Milliseconds; to: Milliseconds }): TimeInterval => {
+    return to - from
+  }
+
+  const humanReadableTimeInterval = (timeInterval: TimeInterval, locale: string = 'en') => {
+    const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'always', style: 'long' })
+
+    const sortedUnits = (Object.keys(UNIT_MILLIS) as Unit[])
+      .sort((lhs, rhs) => UNIT_MILLIS[lhs] - UNIT_MILLIS[rhs])
+      .reverse()
+
+    for (const unit of sortedUnits) {
+      const limit = UNIT_MILLIS[unit]
+
+      if (Math.abs(timeInterval) > limit) {
+        return rtf.format(Math.round(timeInterval / limit), unit)
+      }
+    }
+
+    return rtf.format(Math.round(timeInterval / UNIT_MILLIS['second']), 'second')
+  }
+
+  return {
+    timeInterval,
+    humanReadableDuration,
+  }
+})()
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 9f27196..da88e3b 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",
@@ -331,7 +336,7 @@
       },
       "confirm_modal": {
         "title": "Create fidelity bond and time-lock funds?",
-        "body": "It will be impossible to spend time-locked funds until the fidelity bond expires. Date of expiration is {{date}}"
+        "body": "It will be impossible to spend time-locked funds until the fidelity bond expires. Date of expiration is {{ humanReadableDuration }} on {{date}}."
       },
       "title": "Configure Fidelity Bond",
       "title_fidelity_bond_exists": "Configure Additional Bond",
diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts
index 0ac094e..1966efc 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,


Test patch:
diff --git a/src/components/EarnReport.test.tsx b/src/components/EarnReport.test.tsx
new file mode 100644
index 0000000..5350982
--- /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/fb/utils.test.ts b/src/components/fb/utils.test.ts
index a336b21..a544c94 100644
--- a/src/components/fb/utils.test.ts
+++ b/src/components/fb/utils.test.ts
@@ -196,4 +196,124 @@ describe('utils', () => {
       })
     })
   })
+
+  describe('time', () => {
+    const now = Date.UTC(2009, 0, 3)
+
+    const oneWeek = Date.UTC(1970, 0, 8)
+    const oneDay = Date.UTC(1970, 0, 2)
+
+    const oneDayFromNow = now + oneDay
+    const oneWeekFromNow = now + oneWeek
+    const fourWeeksFromNow = now + 4 * oneWeek
+    const oneMonthFromNow = now + Date.UTC(1970, 1)
+    const twoMonthsFromNow = now + Date.UTC(1970, 2)
+    const oneAndAHalfYearFromNow = now + Date.UTC(1971, 6)
+    const twoYearsFromNow = now + Date.UTC(1972, 0)
+
+    describe('timeInterval', () => {
+      it('should work for dates in the future', () => {
+        expect(fb.time.timeInterval({ from: now, to: oneDayFromNow })).toBe(oneDay)
+        expect(fb.time.timeInterval({ from: now, to: oneWeekFromNow })).toBe(oneWeek)
+        expect(fb.time.timeInterval({ from: now, to: fourWeeksFromNow })).toBe(4 * oneWeek)
+      })
+
+      it('should work for dates in the past', () => {
+        expect(fb.time.timeInterval({ from: oneDayFromNow, to: now })).toBe(-oneDay)
+        expect(fb.time.timeInterval({ from: oneWeekFromNow, to: now })).toBe(-oneWeek)
+        expect(fb.time.timeInterval({ from: fourWeeksFromNow, to: now })).toBe(-4 * oneWeek)
+      })
+
+      it('should work for equal dates', () => {
+        expect(fb.time.timeInterval({ from: now, to: now })).toBe(0)
+      })
+    })
+
+    describe('humanReadableDuration', () => {
+      expect(fb.time.humanReadableDuration({ to: now, from: now - 1 })).toBe('in 0 seconds')
+      expect(fb.time.humanReadableDuration({ to: now, from: now })).toBe('in 0 seconds')
+      expect(fb.time.humanReadableDuration({ to: now, from: now + 1 })).toBe('0 seconds ago')
+
+      expect(fb.time.humanReadableDuration({ to: now, from: now + 499 })).toBe('0 seconds ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: now + 500 })).toBe('0 seconds ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: now + 501 })).toBe('1 second ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: now + 1_000 })).toBe('1 second ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: now + 59_499 })).toBe('59 seconds ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: now + 59_500 })).toBe('59 seconds ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: now + 59_501 })).toBe('60 seconds ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: now + 89_999 })).toBe('1 minute ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: now + 90_000 })).toBe('1 minute ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: now + 90_001 })).toBe('2 minutes ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: oneDayFromNow - 1 })).toBe('24 hours ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: oneDayFromNow })).toBe('24 hours ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: oneDayFromNow + 1 })).toBe('1 day ago')
+
+      expect(fb.time.humanReadableDuration({ to: now, from: oneWeekFromNow })).toBe('7 days ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: fourWeeksFromNow })).toBe('28 days ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: oneMonthFromNow })).toBe('1 month ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: twoMonthsFromNow })).toBe('2 months ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: oneAndAHalfYearFromNow })).toBe('1 year ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: twoYearsFromNow })).toBe('2 years ago')
+      expect(fb.time.humanReadableDuration({ to: now, from: Date.UTC(2022, 1, 18) })).toBe('13 years ago')
+
+      expect(fb.time.humanReadableDuration({ to: now - 1, from: now })).toBe('0 seconds ago')
+      expect(fb.time.humanReadableDuration({ to: now + 1, from: now })).toBe('in 0 seconds')
+
+      expect(fb.time.humanReadableDuration({ to: now + 499, from: now })).toBe('in 0 seconds')
+      expect(fb.time.humanReadableDuration({ to: now + 500, from: now })).toBe('in 1 second')
+      expect(fb.time.humanReadableDuration({ to: now + 501, from: now })).toBe('in 1 second')
+      expect(fb.time.humanReadableDuration({ to: now + 1000, from: now })).toBe('in 1 second')
+      expect(fb.time.humanReadableDuration({ to: now + 59_499, from: now })).toBe('in 59 seconds')
+      expect(fb.time.humanReadableDuration({ to: now + 59_500, from: now })).toBe('in 60 seconds')
+      expect(fb.time.humanReadableDuration({ to: now + 59_501, from: now })).toBe('in 60 seconds')
+      expect(fb.time.humanReadableDuration({ to: now + 89_999, from: now })).toBe('in 1 minute')
+      expect(fb.time.humanReadableDuration({ to: now + 90_000, from: now })).toBe('in 2 minutes')
+      expect(fb.time.humanReadableDuration({ to: now + 90_001, from: now })).toBe('in 2 minutes')
+      expect(fb.time.humanReadableDuration({ to: oneDayFromNow - 1, from: now })).toBe('in 24 hours')
+      expect(fb.time.humanReadableDuration({ to: oneDayFromNow, from: now })).toBe('in 24 hours')
+      expect(fb.time.humanReadableDuration({ to: oneDayFromNow + 1, from: now })).toBe('in 1 day')
+
+      expect(fb.time.humanReadableDuration({ to: oneWeekFromNow, from: now })).toBe('in 7 days')
+      expect(fb.time.humanReadableDuration({ to: fourWeeksFromNow, from: now })).toBe('in 28 days')
+      expect(fb.time.humanReadableDuration({ to: oneMonthFromNow, from: now })).toBe('in 1 month')
+      expect(fb.time.humanReadableDuration({ to: twoMonthsFromNow, from: now })).toBe('in 2 months')
+      expect(fb.time.humanReadableDuration({ to: oneAndAHalfYearFromNow, from: now })).toBe('in 1 year')
+      expect(fb.time.humanReadableDuration({ to: twoYearsFromNow, from: now })).toBe('in 2 years')
+      expect(fb.time.humanReadableDuration({ to: Date.UTC(2022, 1, 18), from: now })).toBe('in 13 years')
+    })
+
+    // Not every month of the year has the same amount of days:
+    // Demonstrate and verify that month handling is sane.
+    // Also show the edge cases for month having 30 or less days.
+    it('should display elapsed time for month values in a sane way', () => {
+      const feb01 = Date.UTC(2009, 1, 1)
+      expect(fb.time.humanReadableDuration({ to: feb01, from: feb01 + Date.UTC(1970, 0, 31) })).toBe('30 days ago')
+      expect(fb.time.humanReadableDuration({ to: feb01, from: feb01 + Date.UTC(1970, 1, 1) })).toBe('1 month ago')
+      expect(fb.time.humanReadableDuration({ to: feb01, from: feb01 + Date.UTC(1971, 0, 1) })).toBe('12 months ago')
+      expect(fb.time.humanReadableDuration({ to: feb01, from: feb01 + Date.UTC(1971, 0, 2) })).toBe('1 year ago')
+
+      expect(fb.time.humanReadableDuration({ to: feb01 + Date.UTC(1970, 0, 31), from: feb01 })).toBe('in 30 days')
+      expect(fb.time.humanReadableDuration({ to: feb01 + Date.UTC(1970, 1, 1), from: feb01 })).toBe('in 1 month')
+      expect(fb.time.humanReadableDuration({ to: feb01 + Date.UTC(1971, 0, 1), from: feb01 })).toBe('in 12 months')
+      expect(fb.time.humanReadableDuration({ to: feb01 + Date.UTC(1971, 0, 2), from: feb01 })).toBe('in 1 year')
+
+      const mar03 = Date.UTC(2009, 2, 3)
+      expect(fb.time.humanReadableDuration({ to: feb01, from: mar03 })).toBe('30 days ago')
+      expect(fb.time.humanReadableDuration({ to: mar03, from: feb01 })).toBe('in 30 days')
+
+      const mar04 = Date.UTC(2009, 2, 4)
+      expect(fb.time.humanReadableDuration({ to: feb01, from: mar04 })).toBe('1 month ago')
+      expect(fb.time.humanReadableDuration({ to: mar04, from: feb01 })).toBe('in 1 month')
+    })
+
+    it('should be able to display localized versions', () => {
+      expect(fb.time.humanReadableDuration({ to: now, from: now, locale: 'es' })).toBe('dentro de 0 segundos')
+      expect(fb.time.humanReadableDuration({ to: now, from: now, locale: 'fr' })).toBe('dans 0 seconde')
+      expect(fb.time.humanReadableDuration({ to: now, from: now, locale: 'hi' })).toBe('0 सेकंड में')
+      expect(fb.time.humanReadableDuration({ to: now, from: now, locale: 'it' })).toBe('tra 0 secondi')
+      expect(fb.time.humanReadableDuration({ to: now, from: now, locale: 'zh' })).toBe('0秒钟后')
+      // fallback to english
+      expect(fb.time.humanReadableDuration({ to: now, from: now, locale: 'xx' })).toBe('in 0 seconds')
+    })
+  })
 })