From 7b7ceee4dad154f424b1295f33a710cda8650dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Qu=E1=BB=91c=20Kh=C3=A1nh?= Date: Thu, 22 Aug 2024 01:47:26 +0700 Subject: [PATCH] feat(mobile): add category chart and filter by category (#241) Resolves https://github.com/get6pm/6pm/issues/36 https://github.com/user-attachments/assets/981d4b5c-6866-49f3-9512-ac650057220a --- apps/mobile/app/(app)/(tabs)/index.tsx | 12 +- .../components/budget/budget-statistic.tsx | 2 +- .../mobile/components/home/category-chart.tsx | 150 +++++++++++++++++ .../components/home/wallet-statistics.tsx | 153 ++++++++++-------- apps/mobile/locales/en/messages.po | 15 +- apps/mobile/locales/vi/messages.po | 15 +- apps/mobile/stores/transaction/hooks.tsx | 13 +- 7 files changed, 279 insertions(+), 81 deletions(-) create mode 100644 apps/mobile/components/home/category-chart.tsx diff --git a/apps/mobile/app/(app)/(tabs)/index.tsx b/apps/mobile/app/(app)/(tabs)/index.tsx index a1781529..c2893286 100644 --- a/apps/mobile/app/(app)/(tabs)/index.tsx +++ b/apps/mobile/app/(app)/(tabs)/index.tsx @@ -39,6 +39,7 @@ export default function HomeScreen() { from: dayjsExtended().subtract(10, 'year').startOf('year').toDate(), to: dayjsExtended().add(10, 'year').endOf('year').toDate(), }) + const [categoryId, setCategoryId] = useState(undefined) const timeRange = useMemo(() => { if (filter !== HomeFilter.All) { @@ -53,6 +54,7 @@ export default function HomeScreen() { const { transactions, isLoading, isRefetching, refetch } = useTransactionList( { walletAccountId, + categoryId, ...timeRange, }, ) @@ -80,6 +82,7 @@ export default function HomeScreen() { }) } setFilter(filter) + setCategoryId(undefined) } const transactionsGroupByDate = useMemo(() => { @@ -115,11 +118,16 @@ export default function HomeScreen() { + { + setView(selected) + setCategoryId(undefined) + }} walletAccountId={walletAccountId} + categoryId={categoryId} + onCategoryChange={setCategoryId} /> ) : null diff --git a/apps/mobile/components/budget/budget-statistic.tsx b/apps/mobile/components/budget/budget-statistic.tsx index daf2aae9..ad95a9b1 100644 --- a/apps/mobile/components/budget/budget-statistic.tsx +++ b/apps/mobile/components/budget/budget-statistic.tsx @@ -23,7 +23,7 @@ export function BudgetStatistic({ {t(i18n)`Left this month`} - + {t(i18n)`Left per day`} diff --git a/apps/mobile/components/home/category-chart.tsx b/apps/mobile/components/home/category-chart.tsx new file mode 100644 index 00000000..d66029cd --- /dev/null +++ b/apps/mobile/components/home/category-chart.tsx @@ -0,0 +1,150 @@ +import { cn } from '@/lib/utils' +import type { TransactionPopulated } from '@6pm/validation' +import { t } from '@lingui/macro' +import { useLingui } from '@lingui/react' +import { useRef } from 'react' +import { FlatList, TouchableOpacity, View } from 'react-native' +import { Button } from '../ui/button' +import { Text } from '../ui/text' + +type ChartCategory = { + id: string + name: string + amountInVnd: number +} + +type CategoryChartProps = { + transactions: TransactionPopulated[] + selected?: string + onSelect?: (categoryId?: string) => void +} + +export const UNCATEGORIZED_ID = 'UNCATEGORIZED' + +export function CategoryChart({ + transactions, + selected, + onSelect, +}: CategoryChartProps) { + const { i18n } = useLingui() + const listRef = useRef(null) + + const categories = transactions.reduce( + (acc, t) => { + if (!t.category) { + return acc.map((c) => + c.id === UNCATEGORIZED_ID + ? { ...c, amountInVnd: c.amountInVnd + t.amountInVnd } + : c, + ) + } + + const foundCategory = acc.find((c) => c.id === t.category?.id) + + if (!foundCategory) { + return acc.concat({ + id: t.category.id, + name: t.category.name, + amountInVnd: t.amountInVnd, + }) + } + + return acc.map((c) => + c.id === foundCategory.id + ? { ...c, amountInVnd: c.amountInVnd + t.amountInVnd } + : c, + ) + }, + [ + { + id: UNCATEGORIZED_ID, + name: t(i18n)`Uncategorized`, + amountInVnd: 0, + }, + ] as ChartCategory[], + ) + + const totalValue = categories.reduce((acc, c) => acc + c.amountInVnd, 0) + + const chartData = categories + .map((c) => ({ + id: c.id, + name: c.name, + percentage: Number(((c.amountInVnd / totalValue) * 100).toFixed(1)), + })) + .sort((a, b) => b.percentage - a.percentage) + + return ( + + + {chartData + .filter((c) => c.percentage >= 5) + .map((c, index) => { + const opacity = 1 - index * 0.2 || 0.1 + return ( + { + onSelect?.(selected === c.id ? undefined : c.id) + listRef.current?.scrollToIndex({ + index: chartData.findIndex((i) => i.id === c.id), + viewPosition: 0.5, + animated: true, + }) + }} + className={cn( + 'h-6 rounded-md bg-primary', + selected && selected !== c.id && '!opacity-10', + selected === c.id && '!opacity-100', + )} + style={{ opacity, flex: c.percentage }} + /> + ) + })} + + c.percentage > 0)} + showsHorizontalScrollIndicator={false} + contentContainerClassName="py-2" + keyExtractor={(item) => item.id} + ItemSeparatorComponent={() => } + renderItem={({ item, index }) => { + const opacity = 1 - index * 0.2 || 0.1 + return ( + + ) + }} + /> + + ) +} diff --git a/apps/mobile/components/home/wallet-statistics.tsx b/apps/mobile/components/home/wallet-statistics.tsx index 04fe7b75..89258f01 100644 --- a/apps/mobile/components/home/wallet-statistics.tsx +++ b/apps/mobile/components/home/wallet-statistics.tsx @@ -14,6 +14,7 @@ import { SelectTrigger, } from '../ui/select' import { Text } from '../ui/text' +import { CategoryChart } from './category-chart' export enum HomeView { SpentThisWeek = 'SPENT_THIS_WEEK', @@ -27,12 +28,16 @@ type WalletStatisticsProps = { view?: HomeView onViewChange?: (view: HomeView) => void walletAccountId?: string + categoryId?: string + onCategoryChange?: (categoryId?: string) => void } export function WalletStatistics({ view = HomeView.SpentThisWeek, onViewChange, walletAccountId, + categoryId, + onCategoryChange, }: WalletStatisticsProps) { const { i18n } = useLingui() @@ -60,7 +65,7 @@ export function WalletStatistics({ } }, [view]) - const { totalExpense, totalIncome } = useTransactionList({ + const { totalExpense, totalIncome, transactions } = useTransactionList({ walletAccountId, ...timeRange, }) @@ -104,69 +109,91 @@ export function WalletStatistics({ ] return ( - option.value === view) ?? options[0]} + onValueChange={(selected) => { + onViewChange?.(selected?.value as HomeView) + }} > - - - {options.find((option) => option.value === view)?.label} - - - + + + {options.find((option) => option.value === view)?.label} + + + + + + + {options.slice(0, 2).map((option) => ( + + {option.label} + + ))} + + {options.slice(2, 4).map((option) => ( + + {option.label} + + ))} + + {options.slice(4).map((option) => ( + + {option.label} + + ))} + + + + {view !== HomeView.CurrentBalance ? ( + { + if ( + view === HomeView.SpentThisWeek || + view === HomeView.SpentThisMonth + ) { + return t.amountInVnd < 0 + } + if ( + view === HomeView.RevenueThisWeek || + view === HomeView.RevenueThisMonth + ) { + return t.amountInVnd > 0 + } + })} /> - - - - {options.slice(0, 2).map((option) => ( - - {option.label} - - ))} - - {options.slice(2, 4).map((option) => ( - - {option.label} - - ))} - - {options.slice(4).map((option) => ( - - {option.label} - - ))} - - - + ) : null} + ) } diff --git a/apps/mobile/locales/en/messages.po b/apps/mobile/locales/en/messages.po index 08203dcd..440f297d 100644 --- a/apps/mobile/locales/en/messages.po +++ b/apps/mobile/locales/en/messages.po @@ -50,7 +50,7 @@ msgstr "" msgid "Account deleted successfully" msgstr "" -#: apps/mobile/app/(app)/(tabs)/index.tsx:169 +#: apps/mobile/app/(app)/(tabs)/index.tsx:177 msgid "Add your first transaction here" msgstr "" @@ -194,7 +194,7 @@ msgstr "" msgid "Copied version to clipboard" msgstr "" -#: apps/mobile/components/home/wallet-statistics.tsx:102 +#: apps/mobile/components/home/wallet-statistics.tsx:107 msgid "Current balance" msgstr "" @@ -401,7 +401,7 @@ msgstr "" msgid "No budget selected" msgstr "" -#: apps/mobile/app/(app)/(tabs)/index.tsx:157 +#: apps/mobile/app/(app)/(tabs)/index.tsx:165 msgid "No transactions" msgstr "" @@ -481,11 +481,11 @@ msgstr "" msgid "Rate 6pm on App Store" msgstr "" -#: apps/mobile/components/home/wallet-statistics.tsx:98 +#: apps/mobile/components/home/wallet-statistics.tsx:103 msgid "Revenue this month" msgstr "" -#: apps/mobile/components/home/wallet-statistics.tsx:94 +#: apps/mobile/components/home/wallet-statistics.tsx:99 msgid "Revenue this week" msgstr "" @@ -589,11 +589,11 @@ msgstr "" msgid "Spending" msgstr "" -#: apps/mobile/components/home/wallet-statistics.tsx:90 +#: apps/mobile/components/home/wallet-statistics.tsx:95 msgid "Spent this month" msgstr "" -#: apps/mobile/components/home/wallet-statistics.tsx:86 +#: apps/mobile/components/home/wallet-statistics.tsx:91 msgid "Spent this week" msgstr "" @@ -657,6 +657,7 @@ msgstr "" msgid "Type" msgstr "" +#: apps/mobile/components/home/category-chart.tsx:61 #: apps/mobile/components/transaction/select-category-field.tsx:91 #: apps/mobile/components/transaction/transaction-item.tsx:29 msgid "Uncategorized" diff --git a/apps/mobile/locales/vi/messages.po b/apps/mobile/locales/vi/messages.po index 93a7c6d7..80ec4858 100644 --- a/apps/mobile/locales/vi/messages.po +++ b/apps/mobile/locales/vi/messages.po @@ -50,7 +50,7 @@ msgstr "" msgid "Account deleted successfully" msgstr "Xóa tài khoản thành công" -#: apps/mobile/app/(app)/(tabs)/index.tsx:169 +#: apps/mobile/app/(app)/(tabs)/index.tsx:177 msgid "Add your first transaction here" msgstr "Thêm giao dịch đầu tiên ở đây" @@ -194,7 +194,7 @@ msgstr "Tiếp tục với Email" msgid "Copied version to clipboard" msgstr "Đã sao chép số phiên bản" -#: apps/mobile/components/home/wallet-statistics.tsx:102 +#: apps/mobile/components/home/wallet-statistics.tsx:107 msgid "Current balance" msgstr "Số dư hiện tại" @@ -401,7 +401,7 @@ msgstr "Tạo danh mục mới" msgid "No budget selected" msgstr "Chọn ngân sách" -#: apps/mobile/app/(app)/(tabs)/index.tsx:157 +#: apps/mobile/app/(app)/(tabs)/index.tsx:165 msgid "No transactions" msgstr "Không có giao dịch" @@ -481,11 +481,11 @@ msgstr "Hàng quý" msgid "Rate 6pm on App Store" msgstr "Đánh giá 6pm trên App Store" -#: apps/mobile/components/home/wallet-statistics.tsx:98 +#: apps/mobile/components/home/wallet-statistics.tsx:103 msgid "Revenue this month" msgstr "Doanh thu tháng này" -#: apps/mobile/components/home/wallet-statistics.tsx:94 +#: apps/mobile/components/home/wallet-statistics.tsx:99 msgid "Revenue this week" msgstr "Doanh thu tuần này" @@ -589,11 +589,11 @@ msgstr "Ngày cụ thể" msgid "Spending" msgstr "Chi tiêu" -#: apps/mobile/components/home/wallet-statistics.tsx:90 +#: apps/mobile/components/home/wallet-statistics.tsx:95 msgid "Spent this month" msgstr "Chi tiêu tháng này" -#: apps/mobile/components/home/wallet-statistics.tsx:86 +#: apps/mobile/components/home/wallet-statistics.tsx:91 msgid "Spent this week" msgstr "Chi tiêu tuần này" @@ -657,6 +657,7 @@ msgstr "ghi chú giao dịch" msgid "Type" msgstr "Loại" +#: apps/mobile/components/home/category-chart.tsx:61 #: apps/mobile/components/transaction/select-category-field.tsx:91 #: apps/mobile/components/transaction/transaction-item.tsx:29 msgid "Uncategorized" diff --git a/apps/mobile/stores/transaction/hooks.tsx b/apps/mobile/stores/transaction/hooks.tsx index fe633175..7a42fbe3 100644 --- a/apps/mobile/stores/transaction/hooks.tsx +++ b/apps/mobile/stores/transaction/hooks.tsx @@ -1,3 +1,4 @@ +import { UNCATEGORIZED_ID } from '@/components/home/category-chart' import { getHonoClient } from '@/lib/client' import { useMeQuery } from '@/queries/auth' import { @@ -19,11 +20,13 @@ export function useTransactionList({ to, walletAccountId, budgetId, + categoryId, }: { from: Date to: Date walletAccountId?: string budgetId?: string + categoryId?: string }) { const transactionsInRangeFromStore = useTransactionStore().transactions.filter( @@ -55,6 +58,14 @@ export function useTransactionList({ return false } + if (categoryId && categoryId === UNCATEGORIZED_ID && !t.categoryId) { + return true + } + + if (categoryId && t.categoryId !== categoryId) { + return false + } + return true }) @@ -74,7 +85,7 @@ export function useTransactionList({ }, 0) return { transactions, transactionDict, totalIncome, totalExpense } - }, [transactionsInRangeFromStore, walletAccountId, budgetId]) + }, [transactionsInRangeFromStore, walletAccountId, budgetId, categoryId]) return { ...query,