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): add category chart and filter by category #241

Merged
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
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