diff --git a/backend/src/controllers/transactions.ts b/backend/src/controllers/transactions.ts index a24158e..dcf5e03 100644 --- a/backend/src/controllers/transactions.ts +++ b/backend/src/controllers/transactions.ts @@ -71,3 +71,41 @@ export const deleteTransaction: RequestHandler = async (req, res) => { res.status(500).json({ error: `Internal server error: ${error}` }); } }; + +// Get monthly spending for a specfic user, which is further divided into categories. +export const getMonthlyByCategory: RequestHandler = async (req, res) => { + const { user_id } = req.params; + const { startDate, endDate } = req.query; + const ID_MATCH = /^\d+$/; + const DATE_MATCH = /^\d{4}-\d{2}-\d{2}$/; + + const getQuery = + "SELECT DATE_TRUNC('month', date)::DATE as month, " + + "COALESCE(category_name, 'Total') as category, SUM(amount) FROM transactions " + + "WHERE user_id = $1 AND date::DATE BETWEEN " + + "COALESCE($2, '1970-01-01'::DATE) AND COALESCE($3, CURRENT_DATE) " + + "GROUP BY ROLLUP(month, category_name) ORDER BY month DESC;"; + + // Validate input. + if (!user_id || !user_id.match(ID_MATCH)) { + return res.status(400).json({ error: "Missing or invalid user_id" }); + } + if (startDate && (typeof startDate !== "string" || !startDate.match(DATE_MATCH))) { + return res.status(400).json({ + error: "Invalid date format, should be YYYY-MM-DD", + }); + } + if (endDate && (typeof endDate !== "string" || !endDate.match(DATE_MATCH))) { + return res.status(400).json({ + error: "Invalid date format, should be YYYY-MM-DD", + }); + } + + try { + client.query(getQuery, [user_id, startDate, endDate], (err, result) => { + res.status(200).json(result.rows); + }); + } catch (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..f0ca39a 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, + getMonthlyByCategory, +} 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("/getMonthlyByCategory/:user_id", getMonthlyByCategory); + export default router; diff --git a/frontend/app/(tabs)/index.tsx b/frontend/app/(tabs)/index.tsx index 4842d75..fdbe940 100644 --- a/frontend/app/(tabs)/index.tsx +++ b/frontend/app/(tabs)/index.tsx @@ -1,4 +1,10 @@ -import { View, StyleSheet, Text, ScrollView } from "react-native"; +import { + View, + StyleSheet, + Text, + ScrollView, + TouchableOpacity, +} from "react-native"; import NewTransactionButton from "@/components/NewTransaction/NewTransactionButton"; import TransactionHistory from "@/components/TransactionHistory/TransactionHistory"; import { useEffect, useState, useCallback } from "react"; @@ -6,6 +12,8 @@ import { BACKEND_PORT } from "@env"; import { useAuth } from "@/context/authContext"; import CustomPieChart from "@/components/Graphs/PieChart"; import { useFocusEffect } from "@react-navigation/native"; +import { useWindowDimensions } from "react-native"; +import StackedBarChart from "@/components/Graphs/StackedBarChart"; /* this function is the structure for the home screen which includes a graph, option to add transaction, and recent transaction history. */ @@ -16,6 +24,19 @@ interface Category { max_category_budget: string; user_id: number; } + +interface MonthlyCategory { + month: string; + category: string; + sum: string; +} + +type StackedBarData = Record>; + +const STACKED_BAR_NUM = 6; // Number of bars in the stacked bar chart. +const STACKED_BAR_HEIGHT = 300; +const STACKED_BAR_WIDTH_OFFSET = 150; + export default function Home() { //place holder array for us to map through //passing it through props because I think it will be easier for us to call the API endpoints in the page and pass it through props @@ -23,6 +44,9 @@ export default function Home() { const [updateRecent, setUpdateRecent] = useState(false); const [total, setTotal] = useState(0); const [categories, setCategories] = useState([]); + const [stackedBarData, setStackedBarData] = useState({}); + const [stackedBarIsRelative, setStackedBarIsRelative] = + useState(false); const { userId } = useAuth(); const [username, setUsername] = useState(""); const categoryColors = new Map([ @@ -88,6 +112,39 @@ export default function Home() { .catch((error) => { console.error("API Error:", error); }); + + const current_date = new Date(); + let year = current_date.getFullYear(); + let month = current_date.getMonth() + 1 - STACKED_BAR_NUM; + // Normalize year and month in case of wraparound. + if (month < 1) { + year--; + month += 12; + } + const firstDateOfMonth = + year.toString().padStart(4, "0") + + "-" + + month.toString().padStart(2, "0") + + "-01"; + + fetch( + `http://localhost:${BACKEND_PORT}/transactions/getMonthlyByCategory/${userId}`, + { method: "GET" }, + ) + .then((res) => res.json()) + .then((data: MonthlyCategory[]) => { + setStackedBarData( + data.slice(1).reduce((acc: StackedBarData, row) => { + const monthString = row.month.slice(0, 10); + if (!(monthString in acc)) acc[monthString] = {}; + acc[monthString][row.category] = parseFloat(row.sum); + return acc; + }, {}), + ); + }) + .catch((error) => { + console.error("API Error:", error); + }); }, [updateRecent]), ); @@ -126,6 +183,44 @@ export default function Home() { })} + + + Monthly Spending Breakdown + + + + {pieData.map((category) => { + return ( + + + {category.name} + + ); + })} + + + setStackedBarIsRelative((prev) => !prev)} + style={styles.applyButton} + > + + {stackedBarIsRelative ? "Relative" : "Absolute"} + + + + {/* components for the new transaction button and the list of transaction history. */} @@ -169,6 +264,17 @@ const styles = StyleSheet.create({ shadowRadius: 12, shadowOpacity: 0.4, }, + stackedBarChartContainer: { + width: "100%", + backgroundColor: "white", + borderRadius: 15, + padding: 20, + flexDirection: "column", + justifyContent: "space-between", + gap: 30, + shadowRadius: 12, + shadowOpacity: 0.4, + }, graph: { width: "100%", height: 180, @@ -198,4 +304,15 @@ const styles = StyleSheet.create({ fontSize: 16, color: "black", }, + applyButton: { + backgroundColor: "#4CAF50", + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 5, + marginTop: 5, + }, + buttonText: { + color: "#E6E6E6", + fontWeight: "bold", + }, }); diff --git a/frontend/components/Graphs/StackedBarChart.tsx b/frontend/components/Graphs/StackedBarChart.tsx new file mode 100644 index 0000000..0ab349b --- /dev/null +++ b/frontend/components/Graphs/StackedBarChart.tsx @@ -0,0 +1,416 @@ +import { View, Text, StyleSheet } from "react-native"; +import Svg, { G, Rect, Line } from "react-native-svg"; +import Animated, { + useSharedValue, + useAnimatedProps, + withTiming, + interpolate, + SharedValue, + ReduceMotion, + Easing, +} from "react-native-reanimated"; +import { useEffect } from "react"; + +type StackedBarData = Record>; + +const MONTH_NAMES = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] as const; +// Prefered number of bars. +const PREFERRED_NUM_COLS = [60, 24, 12, 6, 3, 1] as const; +// Denomination for determining highest chart line. For example: +// value=2.5: highest chart line is at 2.5, 25, 250, 2500 or so on. +// log=Math.log10(2.5): for quick comparison with the log of the maximum bar value. +// subdivisions=5: the chart line divides the chart into 5 sections. +const DENOMINATIONS = [ + { + value: 1, + log: 0, + subdivisions: 5, + }, + { + value: 1.2, + log: Math.log10(1.2), + subdivisions: 6, + }, + { + value: 1.5, + log: Math.log10(1.5), + subdivisions: 3, + }, + { + value: 2, + log: Math.log10(2), + subdivisions: 4, + }, + { + value: 2.5, + log: Math.log10(2.5), + subdivisions: 5, + }, + { + value: 3, + log: Math.log10(3), + subdivisions: 6, + }, + { + value: 4, + log: Math.log10(4), + subdivisions: 4, + }, + { + value: 5, + log: Math.log10(5), + subdivisions: 5, + }, + { + value: 6, + log: Math.log10(6), + subdivisions: 6, + }, + { + value: 8, + log: Math.log10(8), + subdivisions: 4, + }, + { + value: 10, + log: 1, + subdivisions: 5, + }, +] as const; +const MIN_COL_WIDTH = 40; // Including gap between columns. +const HALF_GAP_WIDTH = 8; + +const AnimatedG = Animated.createAnimatedComponent(G); + +function range(n: number) { + return [...Array(n).keys()]; +} + +function first( + array: readonly T[], + test: (value: T, index: number) => boolean, +): T { + for (let i = 0; i < array.length; i++) { + if (test(array[i], i)) return array[i]; + } + + return array[array.length - 1]; +} + +class YearAndMonth { + public year: number; + public month: number; + + constructor(year: number, month: number) { + this.year = year; + this.month = month; + } + + clone() { + return new YearAndMonth(this.year, this.month); + } + + addMonth(numMonths: number) { + this.year += Math.trunc(numMonths / 12); + this.month += numMonths % 12; + + if (this.month > 12) { + this.month -= 12; + this.year++; + } else if (this.month < 1) { + this.month += 12; + this.year--; + } + + return this; + } + + toString() { + return ( + this.year.toString().padStart(4, "0") + + "-" + + this.month.toString().padStart(2, "0") + + "-01" + ); + } +} + +class MonthIter implements Iterator, Iterable { + private currentMonth: YearAndMonth; + private numIter: number; + private step: number; + private i = 0; + + constructor(currentMonth: YearAndMonth, numIter: number, step: number) { + this.currentMonth = currentMonth; + this.numIter = numIter; + this.step = step; + } + + next(): IteratorResult { + const monthBeforeUpdate = this.currentMonth.clone(); + + this.currentMonth.addMonth(this.step); + + return this.i++ < this.numIter + ? { + value: monthBeforeUpdate, + done: false, + } + : { value: undefined, done: true }; + } + + *map(callback: (value: YearAndMonth, index: number) => T): Iterable { + let i = 0; + for (const yearAndMonth of this) { + yield callback(yearAndMonth, i++); + } + } + + [Symbol.iterator]() { + return this; + } +} + +function StackedBar(props: { + monthData: Record; + monthString: string; + index: number; + animatedVar: SharedValue; + colors: Map; + barWidth: number; + height: number; + maxYValue: number; + width: number; + columnWidth: number; +}) { + const scale = props.monthData.Total / props.maxYValue; + + const animatedProps = useAnimatedProps(() => ({ + transform: [ + { + translateX: + props.width - props.columnWidth * (props.index + 1) + HALF_GAP_WIDTH, + }, + { + translateY: interpolate( + props.animatedVar.value, + [0, 1], + [(1 - scale) * props.height, 1], + ), + }, + { scaleX: props.barWidth }, + { + scaleY: interpolate( + props.animatedVar.value, + [0, 1], + [scale * props.height, props.height], + ), + }, + ], + })); + + let startY = 1; + return ( + + {Array.from(props.colors) + .filter(([category, _]) => category in props.monthData) + .map(([category, color]) => ({ + key: category, + startY: (startY -= props.monthData[category] / props.monthData.Total), + height: props.monthData[category] / props.monthData.Total, + color, + })) + .map(({ key, startY, height, color }) => ( + + ))} + + ); +} + +export default function StackedBarChart(props: { + width: number; + height: number; + data: StackedBarData; + colors: Map; + numCols: number; + isRelative: boolean; +}) { + const current_date = new Date(); + const current_year = current_date.getFullYear(); + const current_month = current_date.getMonth() + 1; + const currentYearAndMonth = new YearAndMonth(current_year, current_month); + + // Compute max monthly expense for this duration, and the height of the chart line needed. + const maxExpense = Object.values(props.data).reduce( + (max, row) => (row.Total > max ? row.Total : max), + 0, + ); + const logMaxExpense = Math.max(Math.log10(maxExpense), Math.log10(0.05)); + const intPart = Math.floor(logMaxExpense); + const fracPart = logMaxExpense - intPart; + const denomObj = first(DENOMINATIONS, (item) => fracPart < item.log); + const maxYValue = Math.pow(10, intPart) * denomObj.value; + const numSubdivisions = props.isRelative ? 5 : denomObj.subdivisions; + const subdivisionGap = maxYValue / numSubdivisions; + + const numCols = first( + PREFERRED_NUM_COLS, + (value) => props.width / value >= MIN_COL_WIDTH, + ); + const columnWidth = props.width / numCols; + const barWidth = columnWidth - 2 * HALF_GAP_WIDTH; + + const animatedVar = useSharedValue(0); + useEffect(() => { + animatedVar.value = withTiming(+props.isRelative, { + duration: 800, + easing: Easing.inOut(Easing.quad), + reduceMotion: ReduceMotion.System, + }); + }, [props.isRelative]); + + return ( + + + + {range(numSubdivisions + 1) + .map((index) => { + if (props.isRelative) { + return (100 * (1 - index / numSubdivisions)).toFixed(0) + "%"; + } + + const value = maxYValue - index * subdivisionGap; + return value < 1 && value > 0 + ? (value * 100).toFixed(0) + "ยข" + : "$" + value.toFixed(0); + }) + .map((lineLabel) => ( + + {lineLabel} + + ))} + + + + + + + + {range(numSubdivisions).map((index) => { + const gap = (index / numSubdivisions) * props.height; + return ( + + ); + })} + + + {[ + ...new MonthIter(currentYearAndMonth.clone(), numCols, -1).map( + (yearAndMonth, index) => { + const monthString = yearAndMonth.toString(); + return ( + + ); + }, + ), + ]} + + + + + + {[ + ...new MonthIter( + currentYearAndMonth.clone().addMonth(-numCols + 1), + numCols, + 1, + ).map((yearAndMonth) => ( + + {MONTH_NAMES[yearAndMonth.month - 1]} + + )), + ]} + + + + ); +} + +const styles = StyleSheet.create({ + stackedBarChartView: { + justifyContent: "space-between", + flexDirection: "row", + alignItems: "stretch", + gap: 5, + width: "100%", + }, + lineLabelsView: { + minWidth: 50, + flexDirection: "column", + justifyContent: "space-between", + flexGrow: 1, + }, + lineLabelText: { + textAlign: "right", + fontSize: 16, + color: "black", + }, + columnLabelsView: { + flexDirection: "row", + }, + columnLabelText: { + textAlign: "center", + fontSize: 16, + }, +});