Skip to content
Open
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
147 changes: 129 additions & 18 deletions frontend/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { ScrollView, XStack, YStack } from "tamagui";
import { useState, useCallback } from "react";
import { useWindowDimensions } from "react-native";
import { BACKEND_PORT } from "@env";
import { useAuth } from "@/context/authContext";
import { useFocusEffect } from "@react-navigation/native";
import { Screen } from "@/components/primitives/Screen";
import { AppText } from "@/components/primitives/AppText";
import { Card } from "@/components/primitives/Card";
import { SectionTitle } from "@/components/primitives/SectionTitle";
import { SegmentedControl } from "@/components/primitives/SegmentedControl";
import { QuickActionsSection } from "@/components/Home/QuickActionsSection";
import { WeeklySpendingSection } from "@/components/Home/WeeklySpendingSection";
import NewTransactionButton from "@/components/NewTransaction/NewTransactionButton";
import TransactionHistory from "@/components/TransactionHistory/TransactionHistory";
import CustomPieChart from "@/components/Graphs/PieChart";
import CustomLineChart from "@/components/Graphs/LineChart";
import CustomBarChart from "@/components/Graphs/BarChart";

interface Category {
id: number;
Expand All @@ -29,6 +33,19 @@ interface Transaction {
date: string;
}

type ChartType = "pie" | "line" | "bar";
type Range = "1M" | "3M" | "6M" | "1Y";

const RANGE_CONFIG: Record<
Range,
{ period: "daily" | "weekly"; months: number }
> = {
"1M": { period: "daily", months: 1 },
"3M": { period: "weekly", months: 3 },
"6M": { period: "weekly", months: 6 },
"1Y": { period: "weekly", months: 12 },
};

const categoryColors = new Map<string, string>([
["Food", "#b8b8ff"],
["Shopping", "#fff3b0"],
Expand All @@ -45,7 +62,18 @@ export default function Home() {
const [categories, setCategories] = useState<Category[]>([]);
const [username, setUsername] = useState("");
const [forceOpenTransaction, setForceOpenTransaction] = useState(false);

const [chartType, setChartType] = useState<ChartType>("pie");
const [range, setRange] = useState<Range>("3M");
const [lineData, setLineData] = useState<{ date: string; total: number }[]>(
[]
);
const [barData, setBarData] = useState<{ name: string; value: number }[]>([]);

const { userId } = useAuth();
const screenWidth = useWindowDimensions().width;
// page px $4 (16) * 2 + card padding $4 (16) * 2 = 64
const chartCardWidth = screenWidth - 64;

useFocusEffect(
useCallback(() => {
Expand All @@ -57,7 +85,7 @@ export default function Home() {
Accept: "application/json",
"Content-Type": "application/json",
},
},
}
)
.then((res) => res.json())
.then((data) => {
Expand Down Expand Up @@ -89,14 +117,48 @@ export default function Home() {
data.reduce(
(sum: number, category: { category_expense: string }) =>
sum + parseFloat(category.category_expense),
0,
),
0
)
);
})
.catch((error) => {
console.error("API Error:", error);
});
}, [updateRecent]),

if (chartType === "line") {
const { period, months } = RANGE_CONFIG[range];
fetch(
`http://localhost:${BACKEND_PORT}/transactions/spendingTrend/${userId}?period=${period}&months=${months}`,
{ method: "GET" }
)
.then((res) => res.json())
.then((data) => setLineData(data))
.catch((error) => {
console.error("API Error:", error);
});
}

if (chartType === "bar") {
const { months } = RANGE_CONFIG[range];
fetch(
`http://localhost:${BACKEND_PORT}/transactions/monthly/${userId}`,
{
method: "GET",
}
)
.then((res) => res.json())
.then((data: { month: string; total: number | string }[]) => {
const mapped = data.map((d) => ({
name: d.month,
value: parseFloat(String(d.total)),
}));
setBarData(mapped.slice(-months));
})
.catch((error) => {
console.error("API Error:", error);
});
}
}, [updateRecent, chartType, range])
);

const pieData = categories.map((category) => ({
Expand All @@ -116,20 +178,69 @@ export default function Home() {

<Card elevated>
<SectionTitle title="Total Spending" />
<CustomPieChart data={pieData} size={250} total={total} />
<XStack flexWrap="wrap" gap="$2" marginTop="$2">
{pieData.map((category) => (
<XStack key={category.id} alignItems="center" gap="$2">
<YStack
width={16}
height={16}
borderRadius="$1"
backgroundColor={category.color}
/>
<AppText variant="caption">{category.name}</AppText>
</XStack>
))}
</XStack>

<SegmentedControl
value={chartType}
onValueChange={setChartType}
options={[
{ label: "Pie", value: "pie" },
{ label: "Line", value: "line" },
{ label: "Bar", value: "bar" },
]}
/>

{chartType !== "pie" && (
<YStack marginTop="$3">
<SegmentedControl
value={range}
onValueChange={setRange}
options={[
{ label: "1M", value: "1M" },
{ label: "3M", value: "3M" },
{ label: "6M", value: "6M" },
{ label: "1Y", value: "1Y" },
]}
/>
</YStack>
)}

<YStack marginTop="$3" alignItems="center">
{chartType === "pie" && (
<CustomPieChart data={pieData} size={250} total={total} />
)}
{chartType === "line" && (
<CustomLineChart
data={lineData}
width={chartCardWidth}
height={260}
total={lineData.reduce((sum, d) => sum + d.total, 0)}
/>
)}
{chartType === "bar" && (
<CustomBarChart
data={barData}
width={chartCardWidth}
height={260}
total={barData.reduce((sum, d) => sum + d.value, 0)}
/>
)}
</YStack>

{chartType === "pie" && (
<XStack flexWrap="wrap" gap="$2" marginTop="$2">
{pieData.map((category) => (
<XStack key={category.id} alignItems="center" gap="$2">
<YStack
width={16}
height={16}
borderRadius="$1"
backgroundColor={category.color}
/>
<AppText variant="caption">{category.name}</AppText>
</XStack>
))}
</XStack>
)}
</Card>

<Card>
Expand Down
16 changes: 14 additions & 2 deletions frontend/app/demo.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import { ScrollView } from "react-native";
import { Screen } from "../components/primitives/Screen";
import { AppText } from "../components/primitives/AppText";
Expand All @@ -10,7 +10,10 @@ import { SegmentedControl } from "../components/primitives/SegmentedControl";
import { YStack, XStack, Circle } from "tamagui";
import { Ionicons } from "@expo/vector-icons";

type DemoPeriod = "1D" | "1W" | "1M" | "1Y";

export default function DemoScreen() {
const [demoPeriod, setDemoPeriod] = useState<DemoPeriod>("1W");
return (
<Screen>
<ScrollView contentContainerStyle={{ padding: 20 }}>
Expand Down Expand Up @@ -101,7 +104,16 @@ export default function DemoScreen() {
<AppText variant="title" fontSize="$6">
Segmented Control Example
</AppText>
<SegmentedControl defaultValue="1W" />
<SegmentedControl
value={demoPeriod}
onValueChange={setDemoPeriod}
options={[
{ label: "1D", value: "1D" },
{ label: "1W", value: "1W" },
{ label: "1M", value: "1M" },
{ label: "1Y", value: "1Y" },
]}
/>
</YStack>
</YStack>
</ScrollView>
Expand Down
107 changes: 45 additions & 62 deletions frontend/components/Graphs/BarChart.tsx
Original file line number Diff line number Diff line change
@@ -1,90 +1,84 @@
import React from "react";
import { View, Text, StyleSheet } from "react-native";
import Svg, { Rect, Text as SvgText } from "react-native-svg";
import { YStack, useTheme } from "tamagui";
import { AppText } from "@/components/primitives/AppText";

interface BarChartDatum {
name: string;
value: number;
}

export default function BarChart({
data,
size,
width,
height,
total,
}: {
data: any[];
size: number;
data: BarChartDatum[];
width: number;
height: number;
total: number;
}) {
const chartHeight = size;
const chartWidth = size * 1.2;
const theme = useTheme();
const primary = theme.primary?.val ?? "#395773";
const mutedColor = theme.textMuted?.val ?? "#7B8A96";
const textColor = theme.color?.val ?? "#1C252E";

const barSpacing = 25; // consistent spacing
const barWidth = (chartWidth - barSpacing * (data.length + 1)) / data.length;
const topPadding = 28;
const bottomPadding = 32;
const barSpacing = 16;
const count = Math.max(data.length, 1);
const barWidth = (width - barSpacing * (count + 1)) / count;

const maxValue = Math.max(...data.map((d: any) => d.value), 1);
const maxValue = Math.max(...data.map((d) => d.value), 1);
const drawableHeight = height - topPadding - bottomPadding;

// Convert "YYYY-MM" to "Mon"
const formatMonth = (monthStr: string) => {
const [year, month] = monthStr.split("-").map(Number);
const date = new Date(year, month - 1);
return date.toLocaleString("default", { month: "short" });
};

// Pastel colors for each month
const monthColors: Record<string, string> = {
"01": "#FFD1DC", // Jan
"02": "#FFE4B5", // Feb
"03": "#BFFCC6", // Mar
"04": "#C1F0F6", // Apr
"05": "#D8B4E2", // May
"06": "#FFFACD", // Jun
"07": "#FFB347", // Jul
"08": "#AEC6CF", // Aug
"09": "#FF6961", // Sep
"10": "#77DD77", // Oct
"11": "#CBAACB", // Nov
"12": "#FDFD96", // Dec
};

return (
<View style={styles.container}>
<Svg height={chartHeight} width={chartWidth}>
{data.map((item: any, index: number) => {
<YStack width="100%" alignItems="center" gap="$2">
<Svg height={height} width={width}>
{data.map((item, index) => {
const x = barSpacing + index * (barWidth + barSpacing);
const barHeight = (item.value / maxValue) * (chartHeight - 90);
const y = chartHeight - barHeight - 50; // padding from bottom

// Assign color based on month if color not already set
const month = item.name.split("-")[1];
const fillColor = item.color || monthColors[month] || "#ccc";
const barHeight = (item.value / maxValue) * drawableHeight;
const y = topPadding + (drawableHeight - barHeight);

return (
<React.Fragment key={index}>
{/* Value label */}
<React.Fragment key={`${item.name}-${index}`}>
<SvgText
x={x + barWidth / 2}
y={y - 10}
fontSize="14"
fill="#333"
y={y - 8}
fontSize={11}
fill={textColor}
textAnchor="middle"
fontFamily="Inter, Helvetica, Arial, sans-serif"
fontWeight="600"
>
{item.value}
${item.value.toFixed(0)}
</SvgText>

{/* Bar */}
<Rect
x={x}
y={y}
width={barWidth}
height={barHeight}
fill={fillColor}
fill={primary}
rx={8}
ry={8}
/>

{/* Category label */}
<SvgText
x={x + barWidth / 2}
y={chartHeight - 20}
fontSize="14"
fill="#000"
y={height - 10}
fontSize={11}
fill={mutedColor}
textAnchor="middle"
fontFamily="Inter, Helvetica, Arial, sans-serif"
fontWeight="600"
>
{formatMonth(item.name)}
</SvgText>
Expand All @@ -93,20 +87,9 @@ export default function BarChart({
})}
</Svg>

<Text style={styles.totalText}>Total: ${total.toFixed(2)}</Text>
</View>
<AppText variant="title" fontSize="$7">
${total.toFixed(2)}
</AppText>
</YStack>
);
}

const styles = StyleSheet.create({
container: {
width: "100%",
alignItems: "center",
paddingVertical: 10,
},
totalText: {
marginTop: 10,
fontSize: 18,
fontWeight: "600",
},
});
Loading
Loading