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
38 changes: 38 additions & 0 deletions backend/src/controllers/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` });
}
};
9 changes: 8 additions & 1 deletion backend/src/routes/transactions.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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;
119 changes: 118 additions & 1 deletion frontend/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
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";

Check warning on line 10 in frontend/app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / Frontend check

'useEffect' is defined but never used

Check warning on line 10 in frontend/app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / Frontend check

'useEffect' is defined but never used
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.
*/
Expand All @@ -16,13 +24,29 @@
max_category_budget: string;
user_id: number;
}

interface MonthlyCategory {
month: string;
category: string;
sum: string;
}

type StackedBarData = Record<string, Record<string, number>>;

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
const [ThreeTransactions, setThreeTransactions] = useState([]);
const [updateRecent, setUpdateRecent] = useState(false);
const [total, setTotal] = useState(0);
const [categories, setCategories] = useState<Category[]>([]);
const [stackedBarData, setStackedBarData] = useState<StackedBarData>({});
const [stackedBarIsRelative, setStackedBarIsRelative] =
useState<boolean>(false);
const { userId } = useAuth();
const [username, setUsername] = useState("");
const categoryColors = new Map<string, string>([
Expand Down Expand Up @@ -88,6 +112,39 @@
.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 =

Check warning on line 124 in frontend/app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / Frontend check

'firstDateOfMonth' is assigned a value but never used

Check warning on line 124 in frontend/app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / Frontend check

'firstDateOfMonth' is assigned a value but never used
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<StackedBarData>((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]),
);

Expand Down Expand Up @@ -126,6 +183,44 @@
})}
</View>
</View>
<View style={styles.stackedBarChartContainer}>
<Text style={{ fontSize: 20, fontWeight: "600" }}>
Monthly Spending Breakdown
</Text>
<StackedBarChart
data={stackedBarData}
colors={categoryColors}
numCols={STACKED_BAR_NUM}
width={useWindowDimensions().width - STACKED_BAR_WIDTH_OFFSET}
height={STACKED_BAR_HEIGHT}
isRelative={stackedBarIsRelative}
/>
<View style={styles.legendContainer}>
{pieData.map((category) => {
return (
<View key={category.id} style={styles.legendItem}>
<View
style={[
styles.colorBox,
{ backgroundColor: category.color },
]}
/>
<Text style={styles.legendText}>{category.name}</Text>
</View>
);
})}
</View>
<View>
<TouchableOpacity
onPress={() => setStackedBarIsRelative((prev) => !prev)}
style={styles.applyButton}
>
<Text style={styles.buttonText}>
{stackedBarIsRelative ? "Relative" : "Absolute"}
</Text>
</TouchableOpacity>
</View>
</View>
{/*
components for the new transaction button and the list of transaction history.
*/}
Expand Down Expand Up @@ -169,6 +264,17 @@
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,
Expand Down Expand Up @@ -198,4 +304,15 @@
fontSize: 16,
color: "black",
},
applyButton: {
backgroundColor: "#4CAF50",
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 5,
marginTop: 5,
},
buttonText: {
color: "#E6E6E6",
fontWeight: "bold",
},
});
Loading
Loading