Skip to content

Commit

Permalink
feat(mobile): add category chart and filter by category (#241)
Browse files Browse the repository at this point in the history
  • Loading branch information
bkdev98 committed Aug 21, 2024
1 parent f12ee0b commit 7b7ceee
Show file tree
Hide file tree
Showing 7 changed files with 279 additions and 81 deletions.
12 changes: 10 additions & 2 deletions apps/mobile/app/(app)/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>(undefined)

const timeRange = useMemo(() => {
if (filter !== HomeFilter.All) {
Expand All @@ -53,6 +54,7 @@ export default function HomeScreen() {
const { transactions, isLoading, isRefetching, refetch } = useTransactionList(
{
walletAccountId,
categoryId,
...timeRange,
},
)
Expand Down Expand Up @@ -80,6 +82,7 @@ export default function HomeScreen() {
})
}
setFilter(filter)
setCategoryId(undefined)
}

const transactionsGroupByDate = useMemo(() => {
Expand Down Expand Up @@ -115,11 +118,16 @@ export default function HomeScreen() {
<SectionList
ListHeaderComponent={
filter === HomeFilter.All ? (
<View className="p-6">
<View className="p-6 pb-4">
<WalletStatistics
view={view}
onViewChange={setView}
onViewChange={(selected) => {
setView(selected)
setCategoryId(undefined)
}}
walletAccountId={walletAccountId}
categoryId={categoryId}
onCategoryChange={setCategoryId}
/>
</View>
) : null
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/components/budget/budget-statistic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function BudgetStatistic({
{t(i18n)`Left this month`}
</Text>
</View>
<View className="gap-1">
<View className="items-end gap-1">
<AmountFormat amount={remainingPerDay} />
<Text className="text-right text-muted-foreground">
{t(i18n)`Left per day`}
Expand Down
150 changes: 150 additions & 0 deletions apps/mobile/components/home/category-chart.tsx
Original file line number Diff line number Diff line change
@@ -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<FlatList>(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 (
<View className="w-full">
<View className="flex-row gap-2">
{chartData
.filter((c) => c.percentage >= 5)
.map((c, index) => {
const opacity = 1 - index * 0.2 || 0.1
return (
<TouchableOpacity
activeOpacity={0.8}
key={c.id}
onPress={() => {
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 }}
/>
)
})}
</View>
<FlatList
ref={listRef}
horizontal
data={chartData.filter((c) => c.percentage > 0)}
showsHorizontalScrollIndicator={false}
contentContainerClassName="py-2"
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => <View className="w-1" />}
renderItem={({ item, index }) => {
const opacity = 1 - index * 0.2 || 0.1
return (
<Button
variant={selected === item.id ? 'secondary' : 'ghost'}
size="sm"
className={cn(
'!h-8 border border-primary-foreground',
selected === item.id && 'border-border',
)}
onPress={() => {
onSelect?.(selected === item.id ? undefined : item.id)
listRef.current?.scrollToIndex({
index: index,
viewPosition: 0.5,
animated: true,
})
}}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
{opacity > 0 && (
<View
className={cn('h-3 w-3 rounded bg-primary')}
style={{ opacity }}
/>
)}
<Text>{item.name}</Text>
<Text className="font-normal text-muted-foreground">
{item.percentage}%
</Text>
</Button>
)
}}
/>
</View>
)
}
153 changes: 90 additions & 63 deletions apps/mobile/components/home/wallet-statistics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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()

Expand Down Expand Up @@ -60,7 +65,7 @@ export function WalletStatistics({
}
}, [view])

const { totalExpense, totalIncome } = useTransactionList({
const { totalExpense, totalIncome, transactions } = useTransactionList({
walletAccountId,
...timeRange,
})
Expand Down Expand Up @@ -104,69 +109,91 @@ export function WalletStatistics({
]

return (
<Select
value={options.find((option) => option.value === view) ?? options[0]}
onValueChange={(selected) => {
onViewChange?.(selected?.value as HomeView)
}}
>
<SelectTrigger
hideArrow
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
// className={cn(
// '!h-10 !px-2.5 flex-row items-center gap-2',
// value !== HomeFilter.All && 'border-primary bg-primary',
// )}
className="!border-0 h-auto flex-col items-center gap-3 native:h-auto"
<View className="items-center gap-6">
<Select
value={options.find((option) => option.value === view) ?? options[0]}
onValueChange={(selected) => {
onViewChange?.(selected?.value as HomeView)
}}
>
<View className="self-center border-primary border-b">
<Text className="w-fit self-center text-center leading-tight">
{options.find((option) => option.value === view)?.label}
</Text>
</View>
<AmountFormat
amount={totalValue}
size="xl"
displayNegativeSign
displayPositiveColor
<SelectTrigger
hideArrow
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
// className={cn(
// '!h-10 !px-2.5 flex-row items-center gap-2',
// value !== HomeFilter.All && 'border-primary bg-primary',
// )}
className="!border-0 h-auto flex-col items-center gap-3 native:h-auto"
>
<View className="self-center border-primary border-b">
<Text className="w-fit self-center text-center leading-tight">
{options.find((option) => option.value === view)?.label}
</Text>
</View>
<AmountFormat
amount={totalValue}
size="xl"
displayNegativeSign
displayPositiveColor
/>
</SelectTrigger>
<SelectContent sideOffset={6} align="center">
<SelectGroup className="px-1">
{options.slice(0, 2).map((option) => (
<SelectItem
key={option.value}
value={option.value}
label={option.label}
className="flex-row items-center justify-between"
>
{option.label}
</SelectItem>
))}
<SelectSeparator />
{options.slice(2, 4).map((option) => (
<SelectItem
key={option.value}
value={option.value}
label={option.label}
className="flex-row items-center justify-between"
>
{option.label}
</SelectItem>
))}
<SelectSeparator />
{options.slice(4).map((option) => (
<SelectItem
key={option.value}
value={option.value}
label={option.label}
className="flex-row items-center justify-between"
>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{view !== HomeView.CurrentBalance ? (
<CategoryChart
selected={categoryId}
onSelect={onCategoryChange}
transactions={transactions.filter((t) => {
if (
view === HomeView.SpentThisWeek ||
view === HomeView.SpentThisMonth
) {
return t.amountInVnd < 0
}
if (
view === HomeView.RevenueThisWeek ||
view === HomeView.RevenueThisMonth
) {
return t.amountInVnd > 0
}
})}
/>
</SelectTrigger>
<SelectContent sideOffset={6} align="center">
<SelectGroup className="px-1">
{options.slice(0, 2).map((option) => (
<SelectItem
key={option.value}
value={option.value}
label={option.label}
className="flex-row items-center justify-between"
>
{option.label}
</SelectItem>
))}
<SelectSeparator />
{options.slice(2, 4).map((option) => (
<SelectItem
key={option.value}
value={option.value}
label={option.label}
className="flex-row items-center justify-between"
>
{option.label}
</SelectItem>
))}
<SelectSeparator />
{options.slice(4).map((option) => (
<SelectItem
key={option.value}
value={option.value}
label={option.label}
className="flex-row items-center justify-between"
>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : null}
</View>
)
}
Loading

0 comments on commit 7b7ceee

Please sign in to comment.