diff --git a/app/.storybook/main.js b/app/.storybook/main.js index 7312a9811..0fe939139 100644 --- a/app/.storybook/main.js +++ b/app/.storybook/main.js @@ -1,5 +1,9 @@ module.exports = { - stories: ['../src/**/*.stories.(ts|tsx|js|jsx|mdx)'], + stories: [ + // display the Loop page as the first story int he list + '../src/__stories__/LoopPage.stories.tsx', + '../src/**/*.stories.(ts|tsx|js|jsx|mdx)', + ], addons: [ '@storybook/preset-create-react-app', '@storybook/addon-actions', diff --git a/app/src/App.tsx b/app/src/App.tsx index 980967122..4e316845b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -2,16 +2,17 @@ import React from 'react'; import './App.scss'; import { createStore, StoreProvider } from 'store'; import { Layout } from 'components/layout'; -import LoopPage from 'components/loop/LoopPage'; +import Pages from 'components/Pages'; import { ThemeProvider } from 'components/theme'; const App = () => { const store = createStore(); + return ( - + diff --git a/app/src/__stories__/HistoryPage.stories.tsx b/app/src/__stories__/HistoryPage.stories.tsx new file mode 100644 index 000000000..e386f9bcc --- /dev/null +++ b/app/src/__stories__/HistoryPage.stories.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { SwapState, SwapType } from 'types/generated/loop_pb'; +import { useStore } from 'store'; +import HistoryPage from 'components/history/HistoryPage'; +import { Layout } from 'components/layout'; + +export default { + title: 'Pages/History', + component: HistoryPage, + parameters: { contained: true }, +}; + +export const Default = () => { + const store = useStore(); + store.swapStore.stopAutoPolling(); + store.swapStore.sortedSwaps.forEach((s, i) => { + if (s.typeName === 'Unknown') s.type = SwapType.LOOP_IN; + if (i === 0) s.state = SwapState.INVOICE_SETTLED; + }); + return ; +}; + +export const InsideLayout = () => { + const store = useStore(); + store.uiStore.page = 'history'; + store.swapStore.stopAutoPolling(); + store.swapStore.sortedSwaps.forEach((s, i) => { + if (s.typeName === 'Unknown') s.type = SwapType.LOOP_IN; + if (i === 0) s.state = SwapState.INVOICE_SETTLED; + }); + return ( + + + + ); +}; diff --git a/app/src/__stories__/Layout.stories.tsx b/app/src/__stories__/Layout.stories.tsx index 5955b0b57..f35ab3cc0 100644 --- a/app/src/__stories__/Layout.stories.tsx +++ b/app/src/__stories__/Layout.stories.tsx @@ -4,7 +4,7 @@ import LoopPage from 'components/loop/LoopPage'; import { Layout } from '../components/layout'; export default { - title: 'Layout', + title: 'Components/Layout', component: Layout, }; diff --git a/app/src/__stories__/LoopPage.stories.tsx b/app/src/__stories__/LoopPage.stories.tsx index c903eaa04..e5bc390d5 100644 --- a/app/src/__stories__/LoopPage.stories.tsx +++ b/app/src/__stories__/LoopPage.stories.tsx @@ -4,7 +4,7 @@ import { Layout } from 'components/layout'; import LoopPage from 'components/loop/LoopPage'; export default { - title: 'Loop Page', + title: 'Pages/Loop', component: LoopPage, parameters: { contained: true }, }; diff --git a/app/src/__tests__/App.spec.tsx b/app/src/__tests__/App.spec.tsx index fee63a185..deda1586a 100644 --- a/app/src/__tests__/App.spec.tsx +++ b/app/src/__tests__/App.spec.tsx @@ -2,8 +2,14 @@ import React from 'react'; import { render } from '@testing-library/react'; import App from '../App'; -it('renders the App', () => { - const { getByText } = render(); - const linkElement = getByText('Node Status'); - expect(linkElement).toBeInTheDocument(); +describe('App Component', () => { + const renderApp = () => { + return render(); + }; + + it('should render the App', () => { + const { getByText } = renderApp(); + const linkElement = getByText('Node Status'); + expect(linkElement).toBeInTheDocument(); + }); }); diff --git a/app/src/__tests__/Pages.spec.tsx b/app/src/__tests__/Pages.spec.tsx new file mode 100644 index 000000000..999cb8df0 --- /dev/null +++ b/app/src/__tests__/Pages.spec.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { renderWithProviders } from 'util/tests'; +import Pages from 'components/Pages'; + +describe('Pages Component', () => { + const render = () => { + return renderWithProviders(); + }; + + it('should display the Loop page by default', () => { + const { getByText, store } = render(); + expect(getByText('Lightning Loop')).toBeInTheDocument(); + expect(store.uiStore.page).toBe('loop'); + }); + + it('should display the History page', () => { + const { getByText, store } = render(); + store.uiStore.goToHistory(); + expect(getByText('Loop History')).toBeInTheDocument(); + expect(store.uiStore.page).toBe('history'); + }); +}); diff --git a/app/src/__tests__/components/history/HistoryPage.spec.tsx b/app/src/__tests__/components/history/HistoryPage.spec.tsx new file mode 100644 index 000000000..1759e2ed5 --- /dev/null +++ b/app/src/__tests__/components/history/HistoryPage.spec.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { renderWithProviders } from 'util/tests'; +import { createStore, Store } from 'store'; +import HistoryPage from 'components/history/HistoryPage'; + +describe('HistoryPage', () => { + let store: Store; + + beforeEach(() => { + store = createStore(); + store.uiStore.goToHistory(); + }); + + const render = () => { + return renderWithProviders(, store); + }; + + it('should display the title', () => { + const { getByText } = render(); + expect(getByText('Loop History')).toBeInTheDocument(); + }); + + it('should display the back link', () => { + const { getByText } = render(); + expect(getByText('Lightning Loop')).toBeInTheDocument(); + }); + + it('should display the back icon', () => { + const { getByText } = render(); + expect(getByText('arrow-left.svg')).toBeInTheDocument(); + }); + + it('should display the export icon', () => { + const { getByText } = render(); + expect(getByText('download.svg')).toBeInTheDocument(); + }); + + it('should display the table headers', () => { + const { getByText } = render(); + expect(getByText('Status')).toBeInTheDocument(); + expect(getByText('Loop Type')).toBeInTheDocument(); + expect(getByText('Amount (sats)')).toBeInTheDocument(); + expect(getByText('Created')).toBeInTheDocument(); + expect(getByText('Updated')).toBeInTheDocument(); + }); + + it('should navigate back to the Loop Page', () => { + const { getByText } = render(); + fireEvent.click(getByText('arrow-left.svg')); + expect(store.uiStore.page).toEqual('loop'); + }); +}); diff --git a/app/src/__tests__/components/history/HistoryRow.spec.tsx b/app/src/__tests__/components/history/HistoryRow.spec.tsx new file mode 100644 index 000000000..fafe638bd --- /dev/null +++ b/app/src/__tests__/components/history/HistoryRow.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { renderWithProviders } from 'util/tests'; +import { loopListSwaps } from 'util/tests/sampleData'; +import { Swap } from 'store/models'; +import HistoryRow from 'components/history/HistoryRow'; + +describe('HistoryRow component', () => { + let swap: Swap; + + beforeEach(async () => { + swap = new Swap(loopListSwaps.swapsList[0]); + }); + + const render = () => { + return renderWithProviders(); + }; + + it('should display the status', () => { + const { getByText } = render(); + expect(getByText(swap.stateLabel)).toBeInTheDocument(); + }); + + it('should display the type', () => { + const { getByText } = render(); + expect(getByText(swap.typeName)).toBeInTheDocument(); + }); + + it('should display the created date', () => { + const { getByText } = render(); + expect(getByText(swap.createdOnLabel)).toBeInTheDocument(); + }); + + it('should display the updated date', () => { + const { getByText } = render(); + expect(getByText(swap.updatedOnLabel)).toBeInTheDocument(); + }); +}); diff --git a/app/src/__tests__/components/layout/Layout.spec.tsx b/app/src/__tests__/components/layout/Layout.spec.tsx index d1f7f60b8..a0020577c 100644 --- a/app/src/__tests__/components/layout/Layout.spec.tsx +++ b/app/src/__tests__/components/layout/Layout.spec.tsx @@ -21,4 +21,23 @@ describe('Layout component', () => { fireEvent.click(getByTitle('menu')); expect(store.settingsStore.sidebarVisible).toBe(true); }); + + it('should navigate to the History page', () => { + const { getByText, store } = render(); + expect(store.uiStore.page).toBe('loop'); + fireEvent.click(getByText('History')); + expect(store.uiStore.page).toBe('history'); + expect(getByText('History').parentElement).toHaveClass('active'); + }); + + it('should navigate back to the Loop page', () => { + const { getByText, store } = render(); + expect(store.uiStore.page).toBe('loop'); + fireEvent.click(getByText('History')); + expect(store.uiStore.page).toBe('history'); + expect(getByText('History').parentElement).toHaveClass('active'); + fireEvent.click(getByText('Lightning Loop')); + expect(store.uiStore.page).toBe('loop'); + expect(getByText('Lightning Loop').parentElement).toHaveClass('active'); + }); }); diff --git a/app/src/assets/icons/clock.svg b/app/src/assets/icons/clock.svg new file mode 100644 index 000000000..ea3f5e507 --- /dev/null +++ b/app/src/assets/icons/clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/assets/icons/download.svg b/app/src/assets/icons/download.svg new file mode 100644 index 000000000..76767a924 --- /dev/null +++ b/app/src/assets/icons/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/components/Pages.tsx b/app/src/components/Pages.tsx new file mode 100644 index 000000000..b9092b084 --- /dev/null +++ b/app/src/components/Pages.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { useStore } from 'store'; +import HistoryPage from './history/HistoryPage'; +import LoopPage from './loop/LoopPage'; + +const Pages: React.FC = () => { + const { uiStore } = useStore(); + + switch (uiStore.page) { + case 'history': + return ; + default: + return ; + } +}; + +export default observer(Pages); diff --git a/app/src/components/common/PageHeader.tsx b/app/src/components/common/PageHeader.tsx new file mode 100644 index 000000000..6bb806291 --- /dev/null +++ b/app/src/components/common/PageHeader.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { styled } from 'components/theme'; +import { ArrowLeft, Icon } from './icons'; +import { PageTitle } from './text'; + +const Styled = { + Wrapper: styled.div` + display: flex; + justify-content: space-between; + `, + Left: styled.span` + flex: 1; + text-align: left; + `, + Center: styled.span` + flex: 1; + text-align: center; + `, + Right: styled.span` + flex: 1; + text-align: right; + `, + BackLink: styled.a` + text-transform: uppercase; + font-size: ${props => props.theme.sizes.s}; + cursor: pointer; + color: ${props => props.theme.colors.whitish}; + + &:hover { + opacity: 80%; + } + `, + BackIcon: styled(ArrowLeft)` + margin-right: 5px; + `, + ActionIcon: styled(Icon)` + margin-left: 50px; + `, +}; + +interface Props { + title: string; + onBackClick?: () => void; + backText?: string; + onHistoryClick?: () => void; + onExportClick?: () => void; +} + +const PageHeader: React.FC = ({ + title, + onBackClick, + backText, + onHistoryClick, + onExportClick, +}) => { + const { Wrapper, Left, Center, Right, BackLink, BackIcon, ActionIcon } = Styled; + return ( + + + {onBackClick && ( + + + {backText} + + )} + +
+ {title} +
+ + {onHistoryClick && } + {onExportClick && } + +
+ ); +}; + +export default observer(PageHeader); diff --git a/app/src/components/common/base.tsx b/app/src/components/common/base.tsx index c17faca56..6ff593806 100644 --- a/app/src/components/common/base.tsx +++ b/app/src/components/common/base.tsx @@ -53,6 +53,23 @@ export const Button = styled.button` } `; +/** + * the react-virtualized list doesn't play nice with the bootstrap row -15px + * margin. We need to manually offset the container and remove the + * padding from the last column to get the alignment correct + */ +export const ListContainer = styled.div` + margin-right: -15px; + + .col:last-child { + padding-right: 0; + } + + *:focus { + outline: none; + } +`; + /** * the input[type=range] element. Vendor-specific rules for pseudo * elements cannot be mixed. As such, there are no shared styles for focus or diff --git a/app/src/components/common/icons.tsx b/app/src/components/common/icons.tsx index 78489b00c..11c25dc20 100644 --- a/app/src/components/common/icons.tsx +++ b/app/src/components/common/icons.tsx @@ -1,3 +1,8 @@ +import React, { ReactNode } from 'react'; +import { ReactComponent as Clock } from 'assets/icons/clock.svg'; +import { ReactComponent as Download } from 'assets/icons/download.svg'; +import { styled } from 'components/theme'; + export { ReactComponent as ArrowRight } from 'assets/icons/arrow-right.svg'; export { ReactComponent as ArrowLeft } from 'assets/icons/arrow-left.svg'; export { ReactComponent as Bolt } from 'assets/icons/bolt.svg'; @@ -10,3 +15,46 @@ export { ReactComponent as Menu } from 'assets/icons/menu.svg'; export { ReactComponent as Minimize } from 'assets/icons/minimize.svg'; export { ReactComponent as Maximize } from 'assets/icons/maximize.svg'; export { ReactComponent as Refresh } from 'assets/icons/refresh-cw.svg'; + +interface IconComponents { + clock: ReactNode; + download: ReactNode; +} + +const components: IconComponents = { + clock: , + download: , +}; + +const Styled = { + Wrapper: styled.span` + display: inline-block; + cursor: pointer; + color: ${props => props.theme.colors.whitish}; + + &:hover { + opacity: 80%; + } + `, + Label: styled.span` + margin-left: 5px; + `, +}; + +interface Props { + icon: keyof IconComponents; + text?: string; + onClick?: () => void; + className?: string; +} + +export const Icon: React.FC = ({ icon, text, onClick, className }) => { + const { Wrapper, Label } = Styled; + + return ( + + {components[icon]} + {text && } + + ); +}; diff --git a/app/src/components/history/HistoryList.tsx b/app/src/components/history/HistoryList.tsx new file mode 100644 index 000000000..d55215a7f --- /dev/null +++ b/app/src/components/history/HistoryList.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { AutoSizer, List, WindowScroller } from 'react-virtualized'; +import { observer, Observer } from 'mobx-react-lite'; +import styled from '@emotion/styled'; +import { useStore } from 'store'; +import { ListContainer } from 'components/common/base'; +import HistoryRow, { HistoryRowHeader, ROW_HEIGHT } from './HistoryRow'; + +const Styled = { + Wrapper: styled.div` + margin: 100px 0; + `, +}; + +const HistoryList: React.FC = () => { + const { swapStore } = useStore(); + + const { Wrapper } = Styled; + return ( + + + + + {({ width }) => ( + + {({ height, isScrolling, onChildScroll, scrollTop }) => ( + + {() => ( + ( + + )} + scrollTop={scrollTop} + width={width} + /> + )} + + )} + + )} + + + + ); +}; + +export default observer(HistoryList); diff --git a/app/src/components/history/HistoryPage.tsx b/app/src/components/history/HistoryPage.tsx new file mode 100644 index 000000000..2d3e1489c --- /dev/null +++ b/app/src/components/history/HistoryPage.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { usePrefixedTranslation } from 'hooks'; +import { useStore } from 'store'; +import PageHeader from 'components/common/PageHeader'; +import { styled } from 'components/theme'; +import HistoryList from './HistoryList'; + +const Styled = { + Wrapper: styled.div` + padding: 40px 0; + `, +}; + +const HistoryPage: React.FC = () => { + const { l } = usePrefixedTranslation('cmps.history.HistoryPage'); + const { uiStore } = useStore(); + + const { Wrapper } = Styled; + return ( + + alert('TODO: Export CSV of Swaps')} + /> + + + ); +}; + +export default observer(HistoryPage); diff --git a/app/src/components/history/HistoryRow.tsx b/app/src/components/history/HistoryRow.tsx new file mode 100644 index 000000000..f442fccbc --- /dev/null +++ b/app/src/components/history/HistoryRow.tsx @@ -0,0 +1,85 @@ +import React, { CSSProperties } from 'react'; +import { observer } from 'mobx-react-lite'; +import { usePrefixedTranslation } from 'hooks'; +import { Swap } from 'store/models'; +import { Column, Row } from 'components/common/grid'; +import { Title } from 'components/common/text'; +import SwapDot from 'components/loop/SwapDot'; +import { styled } from 'components/theme'; + +/** + * the virtualized list requires each row to have a specified + * height. Defining a const here because it is used in multiple places + */ +export const ROW_HEIGHT = 60; + +const Styled = { + Row: styled(Row)` + border-bottom: 0.5px solid ${props => props.theme.colors.darkGray}; + + &:last-child { + border-bottom-width: 0; + } + `, + Column: styled(Column)` + overflow: hidden; + text-overflow: ellipsis; + line-height: ${ROW_HEIGHT}px; + `, + StatusTitle: styled(Title)` + margin-left: 35px; + `, + StatusIcon: styled.span` + display: inline-block; + margin-right: 20px; + `, +}; + +export const HistoryRowHeader: React.FC = () => { + const { l } = usePrefixedTranslation('cmps.history.HistoryRowHeader'); + const { StatusTitle } = Styled; + return ( + + + {l('status')} + + + {l('type')} + + + {l('amount')} (sats) + + + {l('created')} + + + {l('updated')} + + + ); +}; + +interface Props { + swap: Swap; + style?: CSSProperties; +} + +const HistoryRow: React.FC = ({ swap, style }) => { + const { Row, Column, StatusIcon } = Styled; + return ( + + + + + + {swap.stateLabel} + + {swap.typeName} + {swap.amount.toLocaleString()} + {swap.createdOnLabel} + {swap.updatedOnLabel} + + ); +}; + +export default observer(HistoryRow); diff --git a/app/src/components/layout/NavMenu.tsx b/app/src/components/layout/NavMenu.tsx index dc7d00d7c..b9d6d8006 100644 --- a/app/src/components/layout/NavMenu.tsx +++ b/app/src/components/layout/NavMenu.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { observer } from 'mobx-react-lite'; import { usePrefixedTranslation } from 'hooks'; +import { useStore } from 'store'; import { Title } from 'components/common/text'; import { styled } from 'components/theme'; @@ -14,14 +16,16 @@ const Styled = { `, NavItem: styled.li` font-size: ${props => props.theme.sizes.s}; + margin-right: -17px; - a { + span { display: block; height: 50px; line-height: 50px; padding: 0 12px; border-left: 3px solid transparent; color: ${props => props.theme.colors.whitish}; + cursor: pointer; &:hover { text-decoration: none; @@ -29,10 +33,9 @@ const Styled = { } } - &.active a { + &.active span { border-left: 3px solid ${props => props.theme.colors.whitish}; background-color: ${props => props.theme.colors.blue}; - margin-right: -17px; &:hover { border-left: 3px solid ${props => props.theme.colors.pink}; @@ -42,23 +45,26 @@ const Styled = { }; const NavMenu: React.FC = () => { - const { NavTitle, Nav, NavItem } = Styled; - const { l } = usePrefixedTranslation('cmps.layout.NavMenu'); + const { uiStore } = useStore(); + const { NavTitle, Nav, NavItem } = Styled; return ( <> {l('menu')} ); }; -export default NavMenu; +export default observer(NavMenu); diff --git a/app/src/components/loop/LoopPage.tsx b/app/src/components/loop/LoopPage.tsx index 1dcfb526c..6310ca077 100644 --- a/app/src/components/loop/LoopPage.tsx +++ b/app/src/components/loop/LoopPage.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; import { usePrefixedTranslation } from 'hooks'; import { useStore } from 'store'; -import { PageTitle } from 'components/common/text'; +import PageHeader from 'components/common/PageHeader'; import { styled } from 'components/theme'; import ChannelList from './ChannelList'; import LoopActions from './LoopActions'; @@ -29,7 +29,11 @@ const LoopPage: React.FC = () => { ) : ( <> - {l('pageTitle')} + alert('TODO: Export CSV of Channels')} + /> diff --git a/app/src/i18n/locales/en-US.json b/app/src/i18n/locales/en-US.json index 161c7d849..fe84bb37b 100644 --- a/app/src/i18n/locales/en-US.json +++ b/app/src/i18n/locales/en-US.json @@ -1,4 +1,11 @@ { + "cmps.history.HistoryPage.backText": "Lightning Loop", + "cmps.history.HistoryPage.pageTitle": "Loop History", + "cmps.history.HistoryRowHeader.status": "Status", + "cmps.history.HistoryRowHeader.amount": "Amount", + "cmps.history.HistoryRowHeader.type": "Loop Type", + "cmps.history.HistoryRowHeader.created": "Created", + "cmps.history.HistoryRowHeader.updated": "Updated", "cmps.loop.ChannelRowHeader.canReceive": "Can Receive", "cmps.loop.ChannelRowHeader.canSend": "Can Send", "cmps.loop.ChannelRowHeader.upTime": "Up Time %", @@ -26,6 +33,7 @@ "cmps.loop.swap.SwapReviewStep.total": "Invoice Total", "cmps.layout.NavMenu.menu": "Menu", "cmps.layout.NavMenu.loop": "Lightning Loop", + "cmps.layout.NavMenu.history": "History", "cmps.layout.NavMenu.settings": "Settings", "cmps.NodeStatus.title": "Node Status" } diff --git a/app/src/store/models/swap.ts b/app/src/store/models/swap.ts index 9cef42cfa..e2c68d999 100644 --- a/app/src/store/models/swap.ts +++ b/app/src/store/models/swap.ts @@ -80,11 +80,21 @@ export default class Swap { return new Date(this.initiationTime / 1000 / 1000); } + /** The date this swap was created as formatted string */ + @computed get createdOnLabel() { + return `${this.createdOn.toLocaleDateString()} ${this.createdOn.toLocaleTimeString()}`; + } + /** The date this swap was last updated as a JS Date object */ @computed get updatedOn() { return new Date(this.lastUpdateTime / 1000 / 1000); } + /** The date this swap was last updated as formatted string */ + @computed get updatedOnLabel() { + return `${this.updatedOn.toLocaleDateString()} ${this.updatedOn.toLocaleTimeString()}`; + } + /** * Updates this swap model using data provided from the Loop GRPC api * @param loopSwap the swap data diff --git a/app/src/store/stores/uiStore.ts b/app/src/store/stores/uiStore.ts index 5699f36c9..3d9be1d91 100644 --- a/app/src/store/stores/uiStore.ts +++ b/app/src/store/stores/uiStore.ts @@ -1,15 +1,40 @@ import { action, observable } from 'mobx'; import { Store } from 'store'; +type PageName = 'loop' | 'history'; + export default class UiStore { private _store: Store; + /** the current page being displayed */ + @observable page: PageName = 'loop'; + /** indicates if the Processing Loops section is displayed on the Loop page */ @observable processingSwapsVisible = false; constructor(store: Store) { this._store = store; } + /** + * Change to the Loop page + */ + @action.bound + goToLoop() { + this.page = 'loop'; + this._store.log.info('Go to the Loop page'); + } + + /** + * Change to the History page + */ + @action.bound + goToHistory() { + this.page = 'history'; + } + + /** + * Toggle displaying of the Processing Loops section + */ @action.bound toggleProcessingSwaps() { this.processingSwapsVisible = !this.processingSwapsVisible; diff --git a/app/src/util/tests/sampleData.ts b/app/src/util/tests/sampleData.ts index 5ac0f1f89..4413c37a3 100644 --- a/app/src/util/tests/sampleData.ts +++ b/app/src/util/tests/sampleData.ts @@ -117,7 +117,7 @@ export const loopListSwaps: LOOP.ListSwapsResponse.AsObject = { type: (i % 3) as LOOP.SwapStatus.AsObject['type'], state: i % 2 ? LOOP.SwapState.SUCCESS : LOOP.SwapState.FAILED, initiationTime: 1586390353623905000 + i * 100000000000000, - lastUpdateTime: 1586398369729857000, + lastUpdateTime: 1586398369729857000 + i * 200000000000000, htlcAddress: 'bcrt1qzu4077erkr78k52yuf2rwkk6ayr6m3wtazdfz2qqmd7taa5vvy9s5d75gd', costServer: 66, costOnchain: 6812,