Skip to content

Commit

Permalink
feat(mobile): [Budget] add burndown chart (#169)
Browse files Browse the repository at this point in the history
  • Loading branch information
bkdev98 authored Jul 26, 2024
1 parent 000e33a commit 56a29db
Show file tree
Hide file tree
Showing 7 changed files with 354 additions and 11 deletions.
159 changes: 158 additions & 1 deletion apps/mobile/components/budget/burndown-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,162 @@
import { useColorScheme } from '@/hooks/useColorScheme'
import { theme } from '@/lib/theme'
import { useBudgetList } from '@/stores/budget/hooks'
import { useDefaultCurrency } from '@/stores/user-settings/hooks'
import { nFormatter } from '@6pm/currency'
import { dayjsExtended } from '@6pm/utilities'
import {
DashPathEffect,
Group,
Path,
RoundedRect,
Text as SkiaText,
useFont,
} from '@shopify/react-native-skia'
import { View } from 'react-native'
import {
CartesianChart,
type PointsArray,
Scatter,
useLinePath,
} from 'victory-native'
import { Text } from '../ui/text'

function AverageLine({ points }: { points: PointsArray }) {
const { colorScheme } = useColorScheme()
const { path } = useLinePath(points, { curveType: 'linear' })
return (
<Path
path={path}
style="stroke"
opacity={0.3}
strokeWidth={2.5}
color={theme[colorScheme].mutedForeground}
strokeCap="round"
>
<DashPathEffect intervals={[6, 6]} />
</Path>
)
}

const LETTER_WIDTH = 10

function UsageLine({
points,
diffAmount,
}: { points: PointsArray; diffAmount: number }) {
const { colorScheme } = useColorScheme()
const { path } = useLinePath(points, { curveType: 'cardinal' })
const font = useFont(require('../../assets/fonts/SpaceMono-Regular.ttf'), 16)

const lastPoint = points.filter((i) => !!i.y).pop()

const diffText =
diffAmount > 0
? `${nFormatter(Math.abs(diffAmount), 0)} less`
: `${nFormatter(Math.abs(diffAmount), 0)} over`

return (
<>
<Path
path={path}
style="stroke"
strokeWidth={3}
color={theme[colorScheme].primary}
strokeCap="round"
/>
{lastPoint && (
<Group transform={[{ translateX: 6 }]}>
<Scatter
points={[lastPoint]}
color={theme[colorScheme].primary}
shape="circle"
style="stroke"
strokeWidth={3}
radius={6}
/>
<RoundedRect
x={lastPoint.x - (Number(lastPoint.xValue) > 20 ? 16 : 6)}
y={lastPoint.y! + (Number(lastPoint.xValue) > 15 ? 16 : -52)}
width={diffText.length * LETTER_WIDTH + 12}
height={34}
r={8}
color={diffAmount > 0 ? '#16a34a' : '#ef4444'}
/>
<SkiaText
x={lastPoint.x - (Number(lastPoint.xValue) > 20 ? 10 : 0)}
y={lastPoint.y! + (Number(lastPoint.xValue) > 15 ? 38 : -30)}
font={font}
text={diffText}
color="white"
/>
</Group>
)}
</>
)
}

export function BurndownChart() {
return <View className="bg-gray-100 rounded-lg h-[187px] w-full" />
const { totalBudget } = useBudgetList()
const defaultCurrency = useDefaultCurrency()

const today = dayjsExtended(new Date()).get('date') + 1

const daysInMonth = dayjsExtended(new Date()).daysInMonth()

const averagePerDay = totalBudget.div(daysInMonth).toNumber()

const mockUsageData = Array.from({ length: daysInMonth + 1 }, (_, i) => ({
day: i,
amount: i === 0 ? 0 : Math.random() * averagePerDay * 2,
}))

const chartData = mockUsageData.reduce(
(acc, usage, index) => {
const lastDay = acc[acc.length - 1]
return [
...acc,
{
...usage,
amount:
index > today ? undefined : (lastDay?.amount || 0) + usage.amount,
average: averagePerDay * index,
},
]
},
[] as { day: number; amount?: number; average: number }[],
)

const todayRecord = chartData.find((i) => i.day === today)
const diffAmount = Math.round(
(todayRecord?.average || 0) - (todayRecord?.amount || 0),
)

return (
<View className="bg-muted rounded-lg h-[187px] w-full">
<Text className="text-sm font-medium text-end self-end m-3 mb-0 text-muted-foreground">
{totalBudget.toNumber().toLocaleString()} {defaultCurrency}
</Text>
<CartesianChart
data={chartData}
xKey="day"
yKeys={['amount', 'average']}
domainPadding={{
left: 14,
right: 14,
bottom: 8,
top: 0,
}}
>
{({ points }) => (
<>
<AverageLine points={points.average} />
<UsageLine points={points.amount} diffAmount={diffAmount} />
</>
)}
</CartesianChart>
<Text className="text-sm font-medium m-3 mt-0 text-muted-foreground">
{'0.00'} {defaultCurrency}
</Text>
</View>
)
}
20 changes: 10 additions & 10 deletions apps/mobile/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
export const theme = {
light: {
primary: 'hsl(240 6% 10%)',
background: 'hsl(0 0% 100%)',
foreground: 'hsl(240 6% 10%)',
muted: 'hsl(210 40% 96.1%)',
mutedForeground: 'hsl(240 4% 46%)',
primary: 'hsl(240, 6%, 10%)',
background: 'hsl(0, 0%, 100%)',
foreground: 'hsl(240, 6%, 10%)',
muted: 'hsl(210, 40%, 96.1%)',
mutedForeground: 'hsl(240, 4%, 46%)',
},
dark: {
primary: 'hsl(0 0% 98%)',
background: 'hsl(240 10% 3.9%)',
foreground: 'hsl(213 31% 91%)',
muted: 'hsl(240 3.7% 15.9%)',
mutedForeground: 'hsl(240 5% 64.9%)',
primary: 'hsl(0, 0%, 98%)',
background: 'hsl(240, 10%, 3.9%)',
foreground: 'hsl(213, 31%, 91%)',
muted: 'hsl(240, 3.7%, 15.9%)',
mutedForeground: 'hsl(240, 5%, 64.9%)',
},
}
2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@rn-primitives/select": "^1.0.3",
"@rn-primitives/slot": "^1.0.3",
"@rn-primitives/types": "^1.0.3",
"@shopify/react-native-skia": "1.2.3",
"@tanstack/query-async-storage-persister": "^5.51.1",
"@tanstack/react-query": "^5.40.1",
"@tanstack/react-query-persist-client": "^5.51.1",
Expand Down Expand Up @@ -90,6 +91,7 @@
"svg-path-properties": "^1.3.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"victory-native": "^41.0.2",
"zod": "^3.23.8",
"zustand": "^4.5.4"
},
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/stores/budget/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const useBudgetList = () => {
savingBudgets,
investingBudgets,
debtBudgets,
totalBudget,
} = useMemo(() => {
const budgetsDict = keyBy(budgets, 'id')
const spendingBudgets = budgets.filter(
Expand All @@ -35,12 +36,18 @@ export const useBudgetList = () => {
)
const debtBudgets = budgets.filter((budget) => budget.type === 'DEBT')

const totalBudget = budgets.reduce(
(acc, budget) => acc.add(new Decimal(budget.periodConfigs[0].amount)),
new Decimal(0),
)

return {
budgetsDict,
spendingBudgets,
savingBudgets,
investingBudgets,
debtBudgets,
totalBudget,
}
}, [budgets])

Expand All @@ -52,6 +59,7 @@ export const useBudgetList = () => {
savingBudgets,
investingBudgets,
debtBudgets,
totalBudget,
}
}

Expand Down
19 changes: 19 additions & 0 deletions packages/currency/src/formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function nFormatter(num: number, digits: number) {
const lookup = [
{ value: 1, symbol: '' },
{ value: 1e3, symbol: 'k' },
{ value: 1e6, symbol: 'M' },
{ value: 1e9, symbol: 'G' },
{ value: 1e12, symbol: 'T' },
{ value: 1e15, symbol: 'P' },
{ value: 1e18, symbol: 'E' },
]
const regexp = /\.0+$|(?<=\.[0-9]*[1-9])0+$/
const item = lookup
.slice()
.reverse()
.find((item) => num >= item.value)
return item
? (num / item.value).toFixed(digits).replace(regexp, '').concat(item.symbol)
: '0'
}
2 changes: 2 additions & 0 deletions packages/currency/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import currencies from './currencies.json'

export * from './formatter'

export { currencies }
Loading

0 comments on commit 56a29db

Please sign in to comment.