From 846355059dd811a017e12faa02b22f69c0d9cc9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Qu=E1=BB=91c=20Kh=C3=A1nh?= Date: Thu, 22 Aug 2024 21:56:29 +0700 Subject: [PATCH] feat(mobile): correct budget amount (#243) --- apps/mobile/app/(app)/(tabs)/budgets.tsx | 42 ++++++++++- .../app/(app)/budget/[budgetId]/index.tsx | 35 +++++++-- apps/mobile/components/budget/budget-item.tsx | 72 ++++++++++++------ .../components/budget/budget-statistic.tsx | 4 +- .../components/budget/burndown-chart.tsx | 73 +++++++++++++------ .../components/common/circular-progress.tsx | 11 ++- apps/mobile/stores/budget/hooks.tsx | 13 +++- 7 files changed, 188 insertions(+), 62 deletions(-) diff --git a/apps/mobile/app/(app)/(tabs)/budgets.tsx b/apps/mobile/app/(app)/(tabs)/budgets.tsx index 15e5e689..63d10dfb 100644 --- a/apps/mobile/app/(app)/(tabs)/budgets.tsx +++ b/apps/mobile/app/(app)/(tabs)/budgets.tsx @@ -7,10 +7,13 @@ import { Text } from '@/components/ui/text' import { useColorScheme } from '@/hooks/useColorScheme' import { theme } from '@/lib/theme' import { useBudgetList } from '@/stores/budget/hooks' +import { useTransactionList } from '@/stores/transaction/hooks' +import { dayjsExtended } from '@6pm/utilities' import type { Budget, BudgetPeriodConfig } from '@6pm/validation' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' import { LinearGradient } from 'expo-linear-gradient' +import { groupBy, map } from 'lodash-es' import { Dimensions, SectionList, View } from 'react-native' import Animated, { Extrapolation, @@ -128,11 +131,39 @@ export default function BudgetsScreen() { savingBudgets, investingBudgets, debtBudgets, + totalBudget, isRefetching, isLoading, refetch, } = useBudgetList() + const { transactions } = useTransactionList({ + from: dayjsExtended().startOf('month').toDate(), + to: dayjsExtended().endOf('month').toDate(), + }) + + const totalBudgetUsage = transactions.reduce((acc, t) => { + if (!t.budgetId) { + return acc + } + return acc + t.amountInVnd + }, 0) + + const chartData = map( + groupBy(transactions, (t) => t.date), + (transactions, key) => ({ + day: new Date(key).getDate(), + amount: transactions.reduce((acc, t) => acc + t.amountInVnd, 0), + }), + ) + + const totalRemaining = totalBudget.add(totalBudgetUsage).round() + + const daysInMonth = dayjsExtended().daysInMonth() + const remainingDays = daysInMonth - dayjsExtended().get('date') + const remainingPerDay = totalRemaining.div(remainingDays).round() + const averagePerDay = totalBudget.div(daysInMonth).round() + const sections = [ { key: 'SPENDING', title: t(i18n)`Spending`, data: spendingBudgets }, { key: 'SAVING', title: t(i18n)`Saving`, data: savingBudgets }, @@ -154,13 +185,20 @@ export default function BudgetsScreen() { }} > - + - + acc + t.amountInVnd, 0) + const transactionsGroupByDate = useMemo(() => { const groupedByDay = groupBy(transactions, (transaction) => format(new Date(transaction.date), 'yyyy-MM-dd'), @@ -193,6 +195,25 @@ 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), + }), + ) + return ( - totalRemaining={currentPeriod.amount as any} - // biome-ignore lint/suspicious/noExplicitAny: - remainingPerDay={currentPeriod.amount as any} + totalRemaining={totalRemaining} + remainingPerDay={remainingPerDay} /> - + = ({ budget }) => { orderBy(budget.periodConfigs, 'startDate', 'desc'), ) - // biome-ignore lint/suspicious/noExplicitAny: - const remainingBalance = (latestPeriodConfig?.amount ?? 0) as any + const { totalExpense, totalIncome } = useTransactionList({ + from: latestPeriodConfig?.startDate!, + to: latestPeriodConfig?.endDate!, + budgetId: budget.id, + }) - // biome-ignore lint/suspicious/noExplicitAny: - const amountPerDay = latestPeriodConfig?.amount ?? (0 as any) + const totalBudgetUsage = totalExpense + totalIncome - const usagePercentage = Math.random() * 100 + const remainingBalance = Math.round( + Number(latestPeriodConfig?.amount!) + totalBudgetUsage, + ) + + const usagePercentage = Math.round( + (Math.abs(totalBudgetUsage) / Number(latestPeriodConfig?.amount)) * 100, + ) - const remainingDays = useMemo(() => { + const remainingDuration = useMemo(() => { let periodEndDate: Date | null if (latestPeriodConfig?.type === 'CUSTOM') { periodEndDate = latestPeriodConfig?.endDate @@ -50,22 +62,35 @@ export const BudgetItem: FC = ({ budget }) => { } if (!periodEndDate) { + return null + } + + return intervalToDuration({ + start: new Date(), + end: periodEndDate, + }) + }, [latestPeriodConfig]) + + const remainingDaysText = useMemo(() => { + if (!remainingDuration) { return t(i18n)`Unknown` } - const duration = formatDuration( - intervalToDuration({ - start: new Date(), - end: periodEndDate, - }), - { - format: ['days', 'hours'], - delimiter: ',', - }, - ) + const duration = formatDuration(remainingDuration, { + format: ['days', 'hours'], + delimiter: ',', + }) + + return t(i18n)`${duration.split(',')[0]} left` + }, [remainingDuration, i18n]) + + const remainingDays = + dayjsExtended().daysInMonth() - dayjsExtended().get('date') - return t(i18n)`${duration?.split(',')[0]} left` - }, [latestPeriodConfig, i18n]) + const amountPerDay = remainingBalance / remainingDays + + const isOver = + remainingBalance < 0 || remainingBalance < amountPerDay * remainingDays return ( = ({ budget }) => { - + @@ -115,12 +143,12 @@ export const BudgetItem: FC = ({ budget }) => { numberOfLines={1} className="line-clamp-1 flex-1 text-muted-foreground text-sm" > - {remainingDays} + {remainingDaysText} diff --git a/apps/mobile/components/budget/budget-statistic.tsx b/apps/mobile/components/budget/budget-statistic.tsx index ad95a9b1..9e4bc374 100644 --- a/apps/mobile/components/budget/budget-statistic.tsx +++ b/apps/mobile/components/budget/budget-statistic.tsx @@ -18,13 +18,13 @@ export function BudgetStatistic({ return ( - + {t(i18n)`Left this month`} - + {t(i18n)`Left per day`} diff --git a/apps/mobile/components/budget/burndown-chart.tsx b/apps/mobile/components/budget/burndown-chart.tsx index 19c5eda5..3049b68a 100644 --- a/apps/mobile/components/budget/burndown-chart.tsx +++ b/apps/mobile/components/budget/burndown-chart.tsx @@ -1,10 +1,12 @@ 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 { SpaceMono_700Bold } from '@expo-google-fonts/space-mono' +import { + SpaceMono_400Regular, + SpaceMono_700Bold, +} from '@expo-google-fonts/space-mono' import { DashPathEffect, Group, @@ -21,7 +23,6 @@ import { Scatter, useLinePath, } from 'victory-native' -import { Text } from '../ui/text' function AverageLine({ points }: { points: PointsArray }) { const { colorScheme } = useColorScheme() @@ -90,6 +91,10 @@ function UsageLine({ offset = -52 } + if (lastPoint.y! > 120) { + offset = -52 + } + return lastPoint.y! + offset }, [lastPoint]) @@ -134,22 +139,29 @@ function UsageLine({ ) } -export function BurndownChart() { - const { totalBudget } = useBudgetList() +type BurndownChartProps = { + totalBudget: number + averagePerDay: number + data?: { day: number; amount?: number }[] +} + +export function BurndownChart({ + totalBudget, + averagePerDay, + data = [], +}: BurndownChartProps) { + const font = useFont(SpaceMono_400Regular, 12) + const { colorScheme } = useColorScheme() const defaultCurrency = useDefaultCurrency() const today = dayjsExtended(new Date()).get('date') + 1 - const daysInMonth = dayjsExtended(new Date()).daysInMonth() - - const averagePerDay = totalBudget.div(daysInMonth).toNumber() + const daysInMonth = dayjsExtended().daysInMonth() - const mockUsageData = Array.from({ length: daysInMonth + 1 }, (_, i) => ({ + const chartData = Array.from({ length: daysInMonth + 1 }, (_, i) => ({ day: i, - amount: i === 0 ? 0 : Math.random() * averagePerDay * 2, - })) - - const chartData = mockUsageData.reduce( + amount: i === 0 ? 0 : data.find((d) => d.day === i)?.amount ?? 0, + })).reduce( (acc, usage, index) => { const lastDay = acc[acc.length - 1] return [ @@ -157,7 +169,9 @@ export function BurndownChart() { { ...usage, amount: - index > today ? undefined : (lastDay?.amount || 0) + usage.amount, + index > today + ? undefined + : (lastDay?.amount || 0) + (usage.amount ?? 0), average: averagePerDay * index, }, ] @@ -170,11 +184,10 @@ export function BurndownChart() { (todayRecord?.average || 0) - (todayRecord?.amount || 0), ) + const totalText = `${nFormatter(totalBudget, 0)} ${defaultCurrency}` + return ( - - - {totalBudget.toNumber().toLocaleString()} {defaultCurrency} - + {({ points }) => ( <> + + )} - - {'0.00'} {defaultCurrency} - ) } diff --git a/apps/mobile/components/common/circular-progress.tsx b/apps/mobile/components/common/circular-progress.tsx index 871f03ef..c5dfde16 100644 --- a/apps/mobile/components/common/circular-progress.tsx +++ b/apps/mobile/components/common/circular-progress.tsx @@ -66,13 +66,15 @@ export function CircularProgress({ const length = properties.getTotalLength() const animatedValue = useSharedValue(length) + const chartPercentage = progress > 100 ? 100 : progress + // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { animatedValue.value = withDelay( delay, - withTiming(length - (progress * length) / 100, { duration }), + withTiming(length - (chartPercentage * length) / 100, { duration }), ) - }, [duration, progress]) + }, [duration, chartPercentage]) const animatedProps = useAnimatedProps(() => { return { @@ -127,7 +129,10 @@ export function CircularProgress({ }, ]} > - + {Math.round(progress)}% diff --git a/apps/mobile/stores/budget/hooks.tsx b/apps/mobile/stores/budget/hooks.tsx index 41928869..775c080f 100644 --- a/apps/mobile/stores/budget/hooks.tsx +++ b/apps/mobile/stores/budget/hooks.tsx @@ -38,10 +38,15 @@ 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), - ) + const totalBudget = budgets.reduce((acc, budget) => { + const latestPeriodConfig = first( + orderBy(budget.periodConfigs, 'startDate', 'desc'), + ) + if (!latestPeriodConfig) { + return acc + } + return acc.add(new Decimal(latestPeriodConfig.amount)) + }, new Decimal(0)) return { budgetsDict,