diff --git a/backend/src/controllers/transactions.ts b/backend/src/controllers/transactions.ts index a24158e..b9a87b6 100644 --- a/backend/src/controllers/transactions.ts +++ b/backend/src/controllers/transactions.ts @@ -71,3 +71,68 @@ export const deleteTransaction: RequestHandler = async (req, res) => { res.status(500).json({ error: `Internal server error: ${error}` }); } }; + +//get spending trend per category for multi-line chart comparison +export const multiLineTransaction: RequestHandler = async (req, res) => { + try { + const { user_id } = req.params; + const periodParam = req.query.period as string; + const monthsParam = req.query.months as string; + + const period = periodParam || "weekly"; + const months = monthsParam ? parseInt(monthsParam) : 3; + + // Validate input + if (!user_id) { + return res.status(400).json({ error: "Missing user_id" }); + } + if (period !== "weekly" && period !== "daily") { + return res.status(400).json({ error: "Period must be 'weekly' or 'daily'" }); + } + if (months <= 0 || isNaN(months)) { + return res.status(400).json({ error: "Months must be a positive number" }); + } + + // Build query + let getMultiLineTrend: string; + + if (period === "weekly") { + getMultiLineTrend = ` + SELECT + date_trunc('week', date)::date as date, + category_name, + COALESCE(SUM(amount), 0) as total + FROM transactions + WHERE user_id = $1 + AND date >= NOW() - INTERVAL '1 month' * $2 + GROUP BY date_trunc('week', date), category_name + ORDER BY date ASC, category_name ASC; + `; + } else { + getMultiLineTrend = ` + SELECT + date::date as date, + category_name, + COALESCE(SUM(amount), 0) as total + FROM transactions + WHERE user_id = $1 + AND date >= NOW() - INTERVAL '1 month' * $2 + GROUP BY date::date, category_name + ORDER BY date ASC, category_name ASC; + `; + } + + const result = await client.query(getMultiLineTrend, [user_id, months]); + + const formattedRows = result.rows.map((row) => ({ + date: new Date(row.date).toISOString().split("T")[0], // Format as YYYY-MM-DD + category: row.category_name, + total: parseFloat(row.total), + })); + + res.status(200).json(formattedRows); + } catch (error) { + console.error("Error fetching multi-line category trends:", error); + res.status(500).json({ error: `Internal server error: ${error}` }); + } +}; diff --git a/backend/src/routes/transactions.ts b/backend/src/routes/transactions.ts index dfcba3a..139ab05 100644 --- a/backend/src/routes/transactions.ts +++ b/backend/src/routes/transactions.ts @@ -1,5 +1,10 @@ import express from "express"; -import { addTransaction, getTransactions, deleteTransaction } from "../controllers/transactions"; +import { + addTransaction, + getTransactions, + deleteTransaction, + multiLineTransaction, +} from "../controllers/transactions"; const router = express.Router(); @@ -10,4 +15,6 @@ router.get("/getTransactions/:user_id", getTransactions); // DELETE /transactions/:user_id/:transaction_id router.delete("/:user_id/:transaction_id", deleteTransaction); +router.get("/multiTrend/:user_id", multiLineTransaction); + export default router; diff --git a/frontend/app/(tabs)/History.tsx b/frontend/app/(tabs)/History.tsx index e6a6d3e..5c60da8 100644 --- a/frontend/app/(tabs)/History.tsx +++ b/frontend/app/(tabs)/History.tsx @@ -1,12 +1,19 @@ -import { View, StyleSheet, Text, TouchableOpacity } from "react-native"; +import { + View, + StyleSheet, + Text, + TouchableOpacity, + useWindowDimensions, +} from "react-native"; import { Picker } from "@react-native-picker/picker"; import { useCallback, useState } from "react"; import BudgetChart from "@/components/HistoryBudget/BudgetChart"; import FullTransactionHistory from "@/components/TransactionHistory/FullTransactionHistory"; -import { StackRouter, useFocusEffect } from "@react-navigation/native"; +import { useFocusEffect } from "@react-navigation/native"; import { BACKEND_PORT } from "@env"; import { useAuth } from "@/context/authContext"; import { ScrollView } from "react-native-gesture-handler"; +import MultiLineChart from "@/components/Graphs/MultiLineChart"; // Page for showing full Expense History along with the user's budget and how much they spent compared to their budget export default function History() { @@ -25,6 +32,20 @@ export default function History() { ); // YYYY-MM format const [selectedCategory, setSelectedCategory] = useState("Food"); const [showFilterOptions, setShowFilterOptions] = useState(false); + const [multiLineChartData, setMultiLineChartData] = useState< + { date: string; category: string; total: number }[] + >([]); + const [selectedTimeRange, setSelectedTimeRange] = useState("3months"); + const screenWidth = useWindowDimensions().width; + const chartWidth = screenWidth * 0.75; + + // Time range configuration + const timeRangeConfig = { + "1month": { period: "daily", months: 1, label: "1 Month" }, + "3months": { period: "weekly", months: 3, label: "3 Months" }, + "6months": { period: "weekly", months: 6, label: "6 Months" }, + "1year": { period: "weekly", months: 12, label: "1 Year" }, + }; // Get unique categories from transactions const getUniqueCategories = () => { @@ -76,7 +97,27 @@ export default function History() { .catch((error) => { console.error("API Error:", error); }); - }, []), + + const config = + timeRangeConfig[selectedTimeRange as keyof typeof timeRangeConfig]; + fetch( + `http://localhost:${BACKEND_PORT}/transactions/multiTrend/${userId}?period=${config.period}&months=${config.months}`, + { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }, + ) + .then((res) => res.json()) + .then((data) => { + setMultiLineChartData(data); + }) + .catch((error) => { + console.error("API Error:", error); + }); + }, [selectedTimeRange]), ); // Toggle filter options visibility @@ -162,6 +203,30 @@ export default function History() { Budget={budget} /> + + + Category Trends Comparison + + + + Time Range: + setSelectedTimeRange(itemValue)} + style={styles.timeRangePicker} + > + + + + + + + + {/* Sorting Controls */} @@ -424,4 +489,30 @@ const styles = StyleSheet.create({ activeFilterText: { fontWeight: "bold", }, + graphContainer: { + backgroundColor: "#FFFFFF", + padding: 15, + borderRadius: 10, + width: "90%", + alignItems: "center", + marginVertical: 10, + }, + timeRangePickerContainer: { + flexDirection: "row", + alignItems: "center", + marginTop: 15, + width: "100%", + justifyContent: "center", + }, + timeRangeLabel: { + fontSize: 16, + fontWeight: "600", + marginRight: 10, + }, + timeRangePicker: { + height: 50, + width: 150, + backgroundColor: "#E6E6E6", + borderRadius: 5, + }, }); diff --git a/frontend/components/Graphs/MultiLineChart.tsx b/frontend/components/Graphs/MultiLineChart.tsx new file mode 100644 index 0000000..88163ab --- /dev/null +++ b/frontend/components/Graphs/MultiLineChart.tsx @@ -0,0 +1,301 @@ +import React from "react"; +import { View, StyleSheet, Text } from "react-native"; +import Svg, { + Path, + Line, + Text as SvgText, + G, + Circle, + ClipPath, + Defs, +} from "react-native-svg"; + +// GET /transactions/multiTrend/:user_id +// Query Params: ?period=weekly&months=3 +// Response: [ +// { date: "2024-01-01", category: "Food", total: 150.00 }, +// { date: "2024-01-01", category: "Shopping", total: 75.00 }, +// { date: "2024-01-08", category: "Food", total: 200.00 }, +// ... +// Each data point represents the total spending for that category in that time period. + +export default function MultiLineChart(props: { + data: { date: string; category: string; total: number }[]; + width: number; + height: number; +}) { + const padding = 20; + const chartWidth = props.width - 2 * padding; + const chartHeight = props.height - 2 * padding; + + // Group data by category and get all unique dates + const categoryMap = new Map>(); + const dateSet = new Set(); + + props.data.forEach((item) => { + if (!categoryMap.has(item.category)) { + categoryMap.set(item.category, new Map()); + } + categoryMap.get(item.category)!.set(item.date, item.total); + dateSet.add(item.date); + }); + + // Sort dates + const sortedDates = Array.from(dateSet).sort(); + + // Category colors + const colors = [ + "#007AFF", + "#FF3B30", + "#34C759", + "#FF9500", + "#AF52DE", + "#FF2D55", + "#5AC8FA", + ]; + + const categories = Array.from(categoryMap.keys()); + const categoryData = categories.map((category, index) => ({ + category, + color: colors[index % colors.length], + points: sortedDates.map((date) => ({ + date, + total: categoryMap.get(category)?.get(date) || 0, + })), + })); + + // Get all values for min/max calculation + // Ensure minValue is at least 0 so x-axis represents $0 + const allValues = props.data.map((item) => item.total); + const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0; + const minValue = Math.max( + 0, + allValues.length > 0 ? Math.min(...allValues) : 0, + ); + const valueRange = maxValue - minValue || 1; + const verticalMargin = chartHeight * 0.1; // 10% margin top and bottom + + function getPointCoordinates(data: { date: string; total: number }[]) { + if (data.length === 0) return []; + + const xAxisY = props.height - padding; + + return data.map((item, index) => { + const x = padding + (index / (data.length - 1 || 1)) * chartWidth; + // Calculate y position, but ensure it doesn't go below x-axis + let y = + padding + + verticalMargin + + (chartHeight - 2 * verticalMargin) - + ((item.total - minValue) / valueRange) * + (chartHeight - 2 * verticalMargin); + + // Clamp y to not go below x-axis (which represents $0) + y = Math.min(y, xAxisY); + + return { x, y, total: item.total, date: item.date }; + }); + } + + function createLine(data: { date: string; total: number }[]) { + if (data.length === 0) return ""; + + const coordinates = getPointCoordinates(data); + const pathData = coordinates + .map((coord, index) => { + return index === 0 + ? `M ${coord.x} ${coord.y}` + : `L ${coord.x} ${coord.y}`; + }) + .join(" "); + + return pathData; + } + + // Define clip path to prevent lines from going below x-axis + const clipPathId = "chartClip"; + + return ( + + + + + + + + + {/* Y-axis */} + + {/* X-axis */} + + + {/* Y-axis labels */} + {(() => { + const numYLabels = 5; + const yLabelIndices = []; + + for (let i = 0; i < numYLabels; i++) { + yLabelIndices.push(i); + } + + return yLabelIndices.map((i) => { + const value = + maxValue - (i / (numYLabels - 1)) * (maxValue - minValue); + const y = + padding + (i / (numYLabels - 1)) * (props.height - 2 * padding); + + return ( + + ${value.toFixed(0)} + + ); + }); + })()} + + {/* X-axis labels */} + {sortedDates.length > 0 && ( + <> + {(() => { + const numLabels = Math.min(5, sortedDates.length); + const labelIndices = []; + + for (let i = 0; i < numLabels; i++) { + const index = Math.floor( + (i / (numLabels - 1)) * (sortedDates.length - 1), + ); + labelIndices.push(index); + } + + return labelIndices.map((index) => { + const x = + padding + + (index / (sortedDates.length - 1 || 1)) * chartWidth; + const date = new Date(sortedDates[index]); + + return ( + + {date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + + ); + }); + })()} + + )} + + {/* Multiple category lines with clipping */} + + {categoryData.map((cat) => ( + + ))} + + + {/* Data point dots */} + {categoryData.map((cat) => { + const coordinates = getPointCoordinates(cat.points); + return coordinates.map((coord, index) => ( + + )); + })} + + + + {/* Legend */} + + {categoryData.map((cat) => ( + + + {cat.category} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + LineContainer: { + justifyContent: "flex-start", + width: "100%", + alignItems: "center", + }, + legendContainer: { + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "center", + marginTop: 15, + paddingHorizontal: 10, + }, + legendItem: { + flexDirection: "row", + alignItems: "center", + marginHorizontal: 8, + marginVertical: 4, + }, + legendColor: { + width: 16, + height: 16, + borderRadius: 2, + marginRight: 6, + }, + legendText: { + fontSize: 12, + color: "#333", + fontWeight: "500", + }, +});