Repository: joinmarket-webui/jam (ts)

Issue:
Show total Fidelity Bond duration in addition to expiration date
Now:

> ... the fidelity bond expires on Tue, 01 Nov 2022 00:00:00 GMT

Better:

> ... the fidelity bond expires on Tuesday, Nov 1st (in 15 weeks and 6 days).


Pull request description:
feat: human readable locktime duration for fidelity bonds
Resolves #397.

Mostly used existing code from the old [fidelity bond branch](https://github.com/joinmarket-webui/jam/blob/fidelity-bonds-coin-control-backup/src/components/fidelity_bond/fb_utils.ts#L33). 👴 

~Moved out the time interval computation to an extra function but I don't really like it anymore since it gets super long at the call site.~ Also added a small addition to make sure to loop over the time units in the correct order, since it's [not so clear if](https://stackoverflow.com/questions/30076219/does-es6-introduce-a-well-defined-order-of-enumeration-for-object-properties) looping over `Object.keys()` always guarantees the order. So I figured manually sorting wouldn't hurt.

The tests are the same as on the old branch. I hope it works like this ~--might refactor the `fb.time` API tomorrow to make callsites a bit cleaner. 🧹~

## 📸 
<img width="591" alt="Screenshot 2022-08-04 at 18 36 45" src="https://user-images.githubusercontent.com/10026790/182907927-75c1bd9d-68d3-4849-ad1e-d8e648b2a7cd.png">
<img width="767" alt="Screenshot 2022-08-04 at 18 36 49" src="https://user-images.githubusercontent.com/10026790/182907936-7d44fb17-1a9f-406f-9d52-e803c0add88e.png">
<img width="572" alt="Screenshot 2022-08-04 at 18 44 27" src="https://user-images.githubusercontent.com/10026790/182907940-a91d75cf-267d-4563-a088-4ce8975f1523.png">



Patch:
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..48f9dd1 100644
--- a/src/components/fb/ExistingFidelityBond.jsx
+++ b/src/components/fb/ExistingFidelityBond.jsx
@@ -1,12 +1,18 @@
 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()
+
+  console.log(i18n.resolvedLanguage)
+  console.log(i18n.language)
 
   return (
     <div className={styles.container}>
@@ -28,7 +34,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..e1ed5bf 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -331,7 +331,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",


Test patch:
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')
+    })
+  })
 })