Skip to content

Commit

Permalink
feat: human readable locktime duration for fidelity bonds (#450)
Browse files Browse the repository at this point in the history
* feat: add time utils

* feat: update wording

* refactor: simplify api

* Update src/components/fb/utils.test.ts

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>

* Update src/components/fb/ExistingFidelityBond.jsx

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>

* Update src/components/fb/utils.test.ts

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>

* Update src/components/fb/utils.ts

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>

* fix: pass locale

* Update src/i18n/locales/en/translation.json

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>
  • Loading branch information
Daniel and theborakompanioni committed Aug 5, 2022
1 parent c3159a2 commit 9d8e656
Show file tree
Hide file tree
Showing 6 changed files with 232 additions and 19 deletions.
34 changes: 21 additions & 13 deletions src/components/fb/CreateFidelityBond.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}>
Expand Down
15 changes: 14 additions & 1 deletion src/components/fb/ExistingFidelityBond.jsx
Original file line number Diff line number Diff line change
@@ -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}>
Expand All @@ -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">
Expand Down
26 changes: 22 additions & 4 deletions src/components/fb/FidelityBondSteps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} />,
Expand Down Expand Up @@ -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">
Expand All @@ -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">
Expand Down
120 changes: 120 additions & 0 deletions src/components/fb/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
})
54 changes: 54 additions & 0 deletions src/components/fb/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Utxo } from '../../context/WalletContext'

type Milliseconds = number
type Seconds = number
type TimeInterval = number

export type YearsRange = {
min: number
Expand Down Expand Up @@ -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,
}
})()
2 changes: 1 addition & 1 deletion src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit 9d8e656

Please sign in to comment.