diff --git a/libs/analytics/src/FinancialStatement.stories.tsx b/libs/analytics/src/FinancialStatement.stories.tsx new file mode 100644 index 000000000..82c0d479e --- /dev/null +++ b/libs/analytics/src/FinancialStatement.stories.tsx @@ -0,0 +1,65 @@ +import { faker } from '@faker-js/faker'; + +import { FinancialStatement } from './FinancialStatement'; + +import type { Meta, StoryObj } from '@storybook/react'; + +faker.seed(4548); + +function array(n: number, initial: T, fn: (last: T) => T) { + let last: T = initial; + return new Array(n).fill(0).map((_, index) => { + if (index === 0) return last; + return (last = fn(last)); + }); +} + +function randomValues(min = 100000, max = 30000000) { + return array(2, faker.number.int({ min, max }), (last) => + faker.number.int({ min: last * 0.75, max: last * 1.25 }), + ); +} + +const meta: Meta = { + component: FinancialStatement, + title: 'Analytics/FinancialStatement', + args: { + dataLastUpdated: 123456789, + columns: ['31 August 2023', '1 week ago'], + data: { + Assets: { + Vault: { + ETH: [125000, 0], + WETH: [125000, 1], + stETH: [0, 125000], + rETH: [1, 125000], + frxETH: [0, 0], + }, + Curve: { + ETH: randomValues(), + OETH: randomValues(), + }, + 'Frax Staking': { + ETH: randomValues(), + OETH: randomValues(), + }, + 'Morpho Aave': { + WETH: randomValues(), + }, + Dripper: { + WETH: randomValues(20000, 50000), + }, + }, + Liabilities: { + 'Token supply': { + OETH: randomValues(10000000, 1000000000), + }, + }, + }, + }, + render: (args) => , +}; + +export default meta; + +export const Default: StoryObj = {}; diff --git a/libs/analytics/src/FinancialStatement.tsx b/libs/analytics/src/FinancialStatement.tsx new file mode 100644 index 000000000..326ce2ff4 --- /dev/null +++ b/libs/analytics/src/FinancialStatement.tsx @@ -0,0 +1,231 @@ +import { Box, Paper, Stack, useMediaQuery, useTheme } from '@mui/material'; +import { useIntl } from 'react-intl'; + +import * as colors from './colors'; + +const calculateChange = (from: number, to: number) => { + if (from === 0 && to === 0) return 0; + const change = -(1 - to / from); + return Math[change > 0 ? 'floor' : 'ceil'](change * 10000) / 100; +}; + +export const FinancialStatement = (props: { + dataLastUpdated: number; + columns: string[]; + data: Record>>; +}) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const columnWeight = props.columns.length + 2; + return ( + theme.palette.text.primary} + fontFamily={'Inter'} + fontSize={{ xs: '.7rem', sm: '.875rem' }} + > + + theme.palette.primary.contrastText} + sx={{ backgroundColor: (theme) => theme.palette.grey[800] }} + fontSize={{ xs: '.875rem', sm: '1.125rem' }} + px={{ xs: 1, sm: 2, md: 4 }} + py={{ xs: 2, sm: 3, md: 4 }} + > + + {props.columns.map((column) => ( + + {column} + + ))} + + {isMobile ? 'Diff' : 'Difference'} + + + + {Object.entries(props.data).map(([title, data]) => ( + + ))} + + ); +}; + +const Table = (props: { + title: string; + data: Record>; +}) => { + const totals = Object.values(props.data).reduce((totals, section) => { + for (const asset of Object.values(section)) { + for (let i = 0; i < asset.length; i++) { + totals[i] = (totals[i] ?? 0) + asset[i]; + } + } + return totals; + }, [] as number[]); + const columnWeight = totals.length + 2; + + return ( + + + {/* Body */} + + {Object.entries(props.data).map(([title, data]) => ( +
+ ))} + + + {/* Total */} + theme.palette.primary.contrastText} + sx={{ backgroundColor: (theme) => theme.palette.grey[800] }} + > + + TOTAL {props.title.toUpperCase()} + + {totals.map((value, index) => ( + + ))} + + + + + ); +}; + +const Section = (props: { title: string; data: Record }) => { + return ( + theme.palette.grey['700'], + }} + px={{ xs: 1, sm: 2, md: 4 }} + gap={{ xs: 1, sm: 2, md: 4 }} + pt={{ xs: 1, sm: 2, md: 4 }} + > + theme.palette.primary.contrastText} + > + {props.title} + + + {Object.entries(props.data).map(([title, data]) => ( + + ))} + + + ); +}; + +const Asset = (props: { title: string; data: number[] }) => { + const columnWeight = props.data.length + 2; + return ( + + + + {props.title} + + {props.data.map((value, index) => ( + + ))} + + + + ); +}; + +export const DataColumn = ({ + value, + columnWeight, +}: { + value: number; + columnWeight: number; +}) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const intl = useIntl(); + return ( + theme.palette.primary.contrastText} + ml={1} + > + theme.palette.text.primary} + pr={{ xs: 0.1, sm: 0.15, md: 0.2 }} + > + {'$'} + + {intl.formatNumber(value, { + notation: isMobile ? 'compact' : 'standard', + maximumFractionDigits: isMobile ? 1 : 2, + })} + + ); +}; + +export const ChangeColumn = ({ + values, + columnWeight, +}: { + values: number[]; + columnWeight: number; +}) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const intl = useIntl(); + const change = calculateChange( + values[values.length - 2], + values[values.length - 1], + ); + return ( + + change > 0 + ? colors.positive + : change < 0 + ? colors.negative + : theme.palette.text.primary + } + > + {isFinite(change) && change > 0 && '+'} + {!isNaN(change) && + isFinite(change) && + `${intl.formatNumber(change, { + notation: isMobile ? 'compact' : 'standard', + maximumFractionDigits: isMobile ? 1 : 0, + })}%`} + + ); +}; diff --git a/libs/analytics/src/colors.ts b/libs/analytics/src/colors.ts new file mode 100644 index 000000000..39e02390f --- /dev/null +++ b/libs/analytics/src/colors.ts @@ -0,0 +1,2 @@ +export const positive = '#4EBE96'; +export const negative = '#D44E66';