Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): [Budget] add burndown chart #169

Merged
merged 1 commit into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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