Skip to content

Commit

Permalink
feat(mobile): refactor and correct budget stats (#247)
Browse files Browse the repository at this point in the history
Add new useBudgetPeriodStats hooks.
  • Loading branch information
bkdev98 committed Sep 1, 2024
1 parent 31c0730 commit 2df82a7
Show file tree
Hide file tree
Showing 14 changed files with 2,521 additions and 2,698 deletions.
8 changes: 4 additions & 4 deletions apps/mobile/app/(app)/(tabs)/budgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,16 +153,16 @@ export default function BudgetsScreen() {
groupBy(transactions, (t) => t.date),
(transactions, key) => ({
day: new Date(key).getDate(),
amount: transactions.reduce((acc, t) => acc + t.amountInVnd, 0),
amount: transactions.reduce((acc, t) => acc - t.amountInVnd, 0),
}),
)

const totalRemaining = totalBudget.add(totalBudgetUsage).round()
const totalRemaining = totalBudget.add(totalBudgetUsage)

const daysInMonth = dayjsExtended().daysInMonth()
const remainingDays = daysInMonth - dayjsExtended().get('date')
const remainingPerDay = totalRemaining.div(remainingDays).round()
const averagePerDay = totalBudget.div(daysInMonth).round()
const remainingPerDay = totalRemaining.div(remainingDays)
const averagePerDay = totalBudget.div(daysInMonth)

const sections = [
{ key: 'SPENDING', title: t(i18n)`Spending`, data: spendingBudgets },
Expand Down
72 changes: 25 additions & 47 deletions apps/mobile/app/(app)/budget/[budgetId]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,14 @@ import { BudgetStatistic } from '@/components/budget/budget-statistic'
import { BurndownChart } from '@/components/budget/burndown-chart'
import { PeriodControl } from '@/components/budget/period-control'
import { AmountFormat } from '@/components/common/amount-format'
import { ListSkeleton } from '@/components/common/list-skeleton'
import { TransactionItem } from '@/components/transaction/transaction-item'
import { Button } from '@/components/ui/button'
import { Text } from '@/components/ui/text'
import { useColorScheme } from '@/hooks/useColorScheme'
import { formatDateShort } from '@/lib/date'
import { theme } from '@/lib/theme'
import { useBudget } from '@/stores/budget/hooks'
import { useTransactionList } from '@/stores/transaction/hooks'
import { dayjsExtended } from '@6pm/utilities'
import { useBudget, useBudgetPeriodStats } from '@/stores/budget/hooks'
import type { TransactionPopulated } from '@6pm/validation'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { format } from 'date-fns'
import { LinearGradient } from 'expo-linear-gradient'
import { Link, useLocalSearchParams, useNavigation } from 'expo-router'
Expand Down Expand Up @@ -44,7 +39,6 @@ const AnimatedSectionList = Animated.createAnimatedComponent(
export default function BudgetDetailScreen() {
const navigation = useNavigation()
const { colorScheme } = useColorScheme()
const { i18n } = useLingui()
const { bottom } = useSafeAreaInsets()
const headerAnimation = useSharedValue(0)
const scrollY = useSharedValue(0)
Expand All @@ -53,19 +47,18 @@ export default function BudgetDetailScreen() {
const { budgetId } = useLocalSearchParams<{ budgetId: string }>()
const { budget } = useBudget(budgetId!)
const periodConfigs = sortBy(budget?.periodConfigs, (pc) => pc.startDate)
const [currentPeriodIndex, setCurrentPeriodIndex] = useState<number>(0)
const currentPeriod = periodConfigs[currentPeriodIndex]

const { transactions, isLoading, isRefetching, refetch } = useTransactionList(
{
budgetId,
from:
currentPeriod?.startDate || dayjsExtended().startOf('month').toDate(),
to: currentPeriod?.endDate || dayjsExtended().endOf('month').toDate(),
},
const [currentPeriodIndex, setCurrentPeriodIndex] = useState<number>(
periodConfigs.length - 1,
)
const currentPeriod = periodConfigs[currentPeriodIndex]

const totalUsage = transactions.reduce((acc, t) => acc + t.amountInVnd, 0)
const {
budgetAmount,
transactions,
remainingAmount,
remainingAmountPerDays,
averageAmountPerDay,
} = useBudgetPeriodStats(currentPeriod!)

const transactionsGroupByDate = useMemo(() => {
const groupedByDay = groupBy(transactions, (transaction) =>
Expand Down Expand Up @@ -93,7 +86,7 @@ export default function BudgetDetailScreen() {
<Link
href={{
pathname: '/budget/[budgetId]/edit',
params: { budgetId: budget?.id },
params: { budgetId: budget?.id! },
}}
asChild
push
Expand Down Expand Up @@ -195,22 +188,11 @@ export default function BudgetDetailScreen() {
)
}

const totalRemaining = Math.round(
Number(currentPeriod.amount ?? 0) + totalUsage,
)
const remainingDays =
dayjsExtended().daysInMonth() - dayjsExtended().get('date')
const remainingPerDay = Math.round(totalRemaining / remainingDays)

const averagePerDay = Math.round(
Number(currentPeriod.amount) / dayjsExtended().daysInMonth(),
)

const chartData = map(
groupBy(transactions, (t) => t.date),
(transactions, key) => ({
day: new Date(key).getDate(),
amount: transactions.reduce((acc, t) => acc + t.amountInVnd, 0),
amount: transactions.reduce((acc, t) => acc - t.amountInVnd, 0),
}),
)

Expand All @@ -234,18 +216,24 @@ export default function BudgetDetailScreen() {
>
<Animated.View className="gap-6 px-6 py-6" style={summaryStyle}>
<BudgetStatistic
totalRemaining={totalRemaining}
remainingPerDay={remainingPerDay}
totalRemaining={remainingAmount.toNumber()}
remainingPerDay={remainingAmountPerDays.toNumber()}
/>
</Animated.View>
<Animated.View
className="px-6 pb-5"
style={[{ flexGrow: 0 }, chartStyle]}
>
<BurndownChart
totalBudget={Number(currentPeriod?.amount)}
averagePerDay={Math.abs(averagePerDay)}
totalBudget={budgetAmount.toNumber()}
averagePerDay={averageAmountPerDay.toNumber()}
data={chartData}
anchorDay={
new Date() > new Date(currentPeriod?.startDate!) &&
new Date() < new Date(currentPeriod?.endDate!)
? new Date().getDate()
: new Date(currentPeriod?.endDate!).getDate()
}
/>
</Animated.View>
</View>
Expand All @@ -254,8 +242,8 @@ export default function BudgetDetailScreen() {
showsVerticalScrollIndicator={false}
ListHeaderComponent={<Animated.View style={dummyHeaderStyle} />}
contentContainerStyle={{ paddingBottom: bottom + 32 }}
refreshing={isRefetching}
onRefresh={refetch}
// refreshing={isRefetching}
// onRefresh={refetch}
sections={transactionsGroupByDate}
keyExtractor={(item) => item.id}
renderItem={({ item: transaction }) => (
Expand All @@ -272,16 +260,6 @@ export default function BudgetDetailScreen() {
/>
</View>
)}
ListFooterComponent={
isLoading || isRefetching ? <ListSkeleton /> : null
}
ListEmptyComponent={
!isLoading && !isRefetching ? (
<Text className="mx-auto my-2 text-center text-muted-foreground">{t(
i18n,
)`No transactions found`}</Text>
) : null
}
/>
<LinearGradient
colors={[
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/components/auth/auth-email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function AuthEmail() {
identifier: emailAddress,
})

const emailCodeFactor = supportedFirstFactors.find(
const emailCodeFactor = supportedFirstFactors?.find(
(i) => i.strategy === 'email_code',
)
if (emailCodeFactor) {
Expand Down
92 changes: 18 additions & 74 deletions apps/mobile/components/budget/budget-item.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { formatDuration, intervalToDuration } from 'date-fns'
import { Link } from 'expo-router'
import { type FC, useMemo } from 'react'
import type { FC } from 'react'
import { Pressable, View } from 'react-native'

import type { BudgetItem as BudgetItemData } from '@/stores/budget/store'
import { useTransactionList } from '@/stores/transaction/hooks'
import {
calculateBudgetPeriodStartEndDates,
dayjsExtended,
} from '@6pm/utilities'
getLatestPeriodConfig,
useBudgetPeriodStats,
} from '@/stores/budget/hooks'
import type { BudgetItem as BudgetItemData } from '@/stores/budget/store'
import { useUser } from '@clerk/clerk-expo'
import { first, orderBy } from 'lodash-es'
import { ChevronRightIcon } from 'lucide-react-native'
import { AmountFormat } from '../common/amount-format'
import { CircularProgress } from '../common/circular-progress'
Expand All @@ -29,68 +26,15 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
const { i18n } = useLingui()
const { user } = useUser()

const latestPeriodConfig = first(
orderBy(budget.periodConfigs, 'startDate', 'desc'),
)

const { totalExpense, totalIncome } = useTransactionList({
from: latestPeriodConfig?.startDate!,
to: latestPeriodConfig?.endDate!,
budgetId: budget.id,
})

const totalBudgetUsage = totalExpense + totalIncome

const remainingBalance = Math.round(
Number(latestPeriodConfig?.amount!) + totalBudgetUsage,
)

const usagePercentage = Math.round(
(Math.abs(totalBudgetUsage) / Number(latestPeriodConfig?.amount)) * 100,
)

const remainingDuration = useMemo(() => {
let periodEndDate: Date | null
if (latestPeriodConfig?.type === 'CUSTOM') {
periodEndDate = latestPeriodConfig?.endDate
} else {
const { endDate } = calculateBudgetPeriodStartEndDates({
anchorDate: new Date(),
type: latestPeriodConfig?.type ?? 'MONTHLY',
})
periodEndDate = endDate
}

if (!periodEndDate) {
return null
}

return intervalToDuration({
start: new Date(),
end: periodEndDate,
})
}, [latestPeriodConfig])

const remainingDaysText = useMemo(() => {
if (!remainingDuration) {
return t(i18n)`Unknown`
}

const duration = formatDuration(remainingDuration, {
format: ['days', 'hours'],
delimiter: ',',
})

return t(i18n)`${duration.split(',')[0]} left`
}, [remainingDuration, i18n])

const remainingDays =
dayjsExtended().daysInMonth() - dayjsExtended().get('date')

const amountPerDay = remainingBalance / remainingDays
const latestPeriodConfig = getLatestPeriodConfig(budget.periodConfigs)

const isOver =
remainingBalance < 0 || remainingBalance < amountPerDay * remainingDays
const {
remainingAmount,
usagePercentage,
remainingAmountPerDays,
remainingDays,
isExceeded,
} = useBudgetPeriodStats(latestPeriodConfig!)

return (
<Link
Expand Down Expand Up @@ -118,15 +62,15 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
/>
<Badge variant="outline" className="rounded-full">
<Text className="text-sm capitalize">
{budget.periodConfigs[0].type}
{latestPeriodConfig?.type}
</Text>
</Badge>
</View>
</View>
<View className="flex-row items-center gap-2">
<CircularProgress
progress={usagePercentage}
strokeColor={isOver ? '#ef4444' : undefined}
strokeColor={isExceeded ? '#ef4444' : undefined}
/>
<ChevronRightIcon className="size-6 text-foreground" />
</View>
Expand All @@ -135,20 +79,20 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
<View className="flex-row items-center justify-between gap-6">
<View className="flex-1 gap-1">
<AmountFormat
amount={remainingBalance}
amount={remainingAmount?.toNumber() ?? 0}
displayNegativeSign
className="text-xl"
/>
<Text
numberOfLines={1}
className="line-clamp-1 flex-1 text-muted-foreground text-sm"
>
{remainingDaysText}
{t(i18n)`${remainingDays} days left`}
</Text>
</View>
<View className="justify-end gap-1">
<AmountFormat
amount={Math.round(amountPerDay)}
amount={remainingAmountPerDays?.toNumber() ?? 0}
displayNegativeSign
className="text-xl"
/>
Expand Down
13 changes: 6 additions & 7 deletions apps/mobile/components/budget/burndown-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,24 +143,23 @@ type BurndownChartProps = {
totalBudget: number
averagePerDay: number
data?: { day: number; amount?: number }[]
anchorDay?: number
}

export function BurndownChart({
totalBudget,
averagePerDay,
data = [],
anchorDay = new Date().getDate(),
}: BurndownChartProps) {
const font = useFont(SpaceMono_400Regular, 12)
const { colorScheme } = useColorScheme()
const defaultCurrency = useDefaultCurrency()

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

const daysInMonth = dayjsExtended().daysInMonth()
const daysInMonth = dayjsExtended(anchorDay).daysInMonth()

const chartData = Array.from({ length: daysInMonth + 1 }, (_, i) => ({
day: i,
amount: i === 0 ? 0 : data.find((d) => d.day === i)?.amount ?? 0,
amount: data.find((d) => d.day === i)?.amount ?? 0,
})).reduce(
(acc, usage, index) => {
const lastDay = acc[acc.length - 1]
Expand All @@ -169,7 +168,7 @@ export function BurndownChart({
{
...usage,
amount:
index > today
index > anchorDay
? undefined
: (lastDay?.amount || 0) + (usage.amount ?? 0),
average: averagePerDay * index,
Expand All @@ -179,7 +178,7 @@ export function BurndownChart({
[] as { day: number; amount?: number; average: number }[],
)

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

0 comments on commit 2df82a7

Please sign in to comment.