diff --git a/README.md b/README.md index 870512b..eaa10f5 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,11 @@ We welcome contributions from the UCSD community and beyond! Please see our [CON 1. Fork the repository 2. Create a feature branch: `git checkout -b feature/amazing-feature` 3. Make your changes and test thoroughly -4. Run linting: `npm run lint-check` -5. Commit your changes: `git commit -m 'Add amazing feature'` -6. Push to your branch: `git push origin feature/amazing-feature` -7. Open a Pull Request +4. Format with Prettier in backend: `npm run format`, +5. Run linting: `npm run lint-check` +6. Commit your changes: `git commit -m 'Add amazing feature'` +7. Push to your branch: `git push origin feature/amazing-feature` +8. Open a Pull Request ## 📊 Project Structure diff --git a/backend/src/db/database.sql b/backend/src/db/database.sql index 5042aee..a388337 100644 --- a/backend/src/db/database.sql +++ b/backend/src/db/database.sql @@ -1,99 +1,100 @@ -- Create Users Table CREATE TABLE users ( - id SERIAL PRIMARY KEY, -- User ID (Auto Increment) - email VARCHAR(255) UNIQUE NOT NULL, -- Email (Unique) - username VARCHAR(100) UNIQUE NOT NULL, -- Username (Unique) - profile_picture TEXT, -- Profile Picture (Optional) - total_budget DECIMAL(10,2) DEFAULT 0.00, -- Total Budget - total_expense DECIMAL(10,2) DEFAULT 0.00 -- Total Expenses + id SERIAL PRIMARY KEY, + -- User ID (Auto Increment) + email VARCHAR(255) UNIQUE NOT NULL, + -- Email (Unique) + username VARCHAR(100) UNIQUE NOT NULL, + -- Username (Unique) + profile_picture TEXT, + -- Profile Picture (Optional) + total_budget DECIMAL(10, 2) DEFAULT 0.00, + -- Total Budget + total_expense DECIMAL(10, 2) DEFAULT 0.00 -- Total Expenses ); - -- Create Categories Table CREATE TABLE categories ( - id SERIAL PRIMARY KEY, -- Category ID (Auto Increment) - user_id INT REFERENCES users(id) ON DELETE CASCADE, -- User ID (FK) - category_name VARCHAR(100) NOT NULL, -- Category Name - max_category_budget DECIMAL(10,2) DEFAULT 0.00, -- Max Category Budget - category_expense DECIMAL(10,2) DEFAULT 0.00 -- Category Expense + id SERIAL PRIMARY KEY, + -- Category ID (Auto Increment) + user_id INT REFERENCES users(id) ON DELETE CASCADE, + -- User ID (FK) + category_name VARCHAR(100) NOT NULL, + -- Category Name + max_category_budget DECIMAL(10, 2) DEFAULT 0.00, + -- Max Category Budget + category_expense DECIMAL(10, 2) DEFAULT 0.00 -- Category Expense ); - -- Create Transactions Table CREATE TABLE transactions ( - id SERIAL PRIMARY KEY, -- Transaction ID (Auto Increment) - user_id INT REFERENCES users(id) ON DELETE CASCADE, -- User ID (FK) - item_name VARCHAR(255) NOT NULL, -- Item Name - amount DECIMAL(10,2) NOT NULL, -- Amount of the Transaction - category_name VARCHAR(100) NOT NULL, -- Category Name - date TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- Date of Transaction + id SERIAL PRIMARY KEY, + -- Transaction ID (Auto Increment) + user_id INT REFERENCES users(id) ON DELETE CASCADE, + -- User ID (FK) + item_name VARCHAR(255) NOT NULL, + -- Item Name + amount DECIMAL(10, 2) NOT NULL, + -- Amount of the Transaction + category_name VARCHAR(100) NOT NULL, + -- Category Name + date TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- Date of Transaction ); - -- Create Goals Table CREATE TABLE goals ( - id SERIAL PRIMARY KEY, -- Goal ID (Auto Increment) - user_id INT REFERENCES users(id) ON DELETE CASCADE, -- User ID (FK) - title VARCHAR(100) NOT NULL, -- Goal Title - details TEXT, -- Goal Details (optional) - target_date DATE -- Target Date (optional) + id SERIAL PRIMARY KEY, + -- Goal ID (Auto Increment) + user_id INT REFERENCES users(id) ON DELETE CASCADE, + -- User ID (FK) + title VARCHAR(100) NOT NULL, + -- Goal Title + details TEXT, + -- Goal Details (optional) + target_date DATE -- Target Date (optional) ); - -CREATE OR REPLACE FUNCTION create_default_categories() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO categories (user_id, category_name, max_category_budget, category_expense) - VALUES - (NEW.id, 'Food', 0.00, 0.00), - (NEW.id, 'Shopping', 0.00, 0.00), - (NEW.id, 'Transportation', 0.00, 0.00), - (NEW.id, 'Subscriptions', 0.00, 0.00), - (NEW.id, 'Other', 0.00, 0.00); - RETURN NEW; +CREATE OR REPLACE FUNCTION create_default_categories() RETURNS TRIGGER AS $$ BEGIN +INSERT INTO categories ( + user_id, + category_name, + max_category_budget, + category_expense + ) +VALUES (NEW.id, 'Food', 0.00, 0.00), + (NEW.id, 'Shopping', 0.00, 0.00), + (NEW.id, 'Transportation', 0.00, 0.00), + (NEW.id, 'Subscriptions', 0.00, 0.00), + (NEW.id, 'Other', 0.00, 0.00); +RETURN NEW; END; $$ LANGUAGE plpgsql; - CREATE TRIGGER trigger_create_default_categories -AFTER INSERT ON users -FOR EACH ROW -EXECUTE FUNCTION create_default_categories(); - -CREATE OR REPLACE FUNCTION recalculate_all_categories_expense(userid integer) -RETURNS void LANGUAGE plpgsql AS $$ -BEGIN - UPDATE categories - SET category_expense = COALESCE(( +AFTER +INSERT ON users FOR EACH ROW EXECUTE FUNCTION create_default_categories(); +CREATE OR REPLACE FUNCTION recalculate_all_categories_expense(userid integer) RETURNS void LANGUAGE plpgsql AS $$ BEGIN +UPDATE categories +SET category_expense = COALESCE( + ( SELECT SUM(amount) FROM transactions - WHERE user_id = categories.user_id AND category_name = categories.category_name - ), 0); + WHERE user_id = categories.user_id + AND category_name = categories.category_name + ), + 0 + ); END; $$; - -CREATE OR REPLACE FUNCTION trigger_recalculate_all_on_insert() -RETURNS trigger LANGUAGE plpgsql AS $$ -BEGIN - PERFORM recalculate_all_categories_expense(NEW.user_id); - PERFORM recalculate_all_categories_expense(NEW.user_id); - RETURN NEW; +CREATE OR REPLACE FUNCTION trigger_recalculate_all_on_insert() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM recalculate_all_categories_expense(NEW.user_id); +PERFORM recalculate_all_categories_expense(NEW.user_id); +RETURN NEW; END; $$; - -CREATE OR REPLACE FUNCTION trigger_recalculate_all_on_delete() -RETURNS trigger LANGUAGE plpgsql AS $$ -BEGIN - PERFORM recalculate_all_categories_expense(OLD.user_id); - PERFORM recalculate_all_categories_expense(NEW.user_id); - RETURN OLD; +CREATE OR REPLACE FUNCTION trigger_recalculate_all_on_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM recalculate_all_categories_expense(OLD.user_id); +PERFORM recalculate_all_categories_expense(NEW.user_id); +RETURN OLD; END; $$; - DROP TRIGGER IF EXISTS recalc_expense_after_insert ON transactions; DROP TRIGGER IF EXISTS recalc_expense_after_delete ON transactions; - CREATE TRIGGER recalc_expense_after_insert -AFTER INSERT ON transactions -FOR EACH ROW -EXECUTE FUNCTION trigger_recalculate_all_on_insert(); - +AFTER +INSERT ON transactions FOR EACH ROW EXECUTE FUNCTION trigger_recalculate_all_on_insert(); CREATE TRIGGER recalc_expense_after_delete -AFTER DELETE ON transactions -FOR EACH ROW -EXECUTE FUNCTION trigger_recalculate_all_on_delete(); +AFTER DELETE ON transactions FOR EACH ROW EXECUTE FUNCTION trigger_recalculate_all_on_delete(); \ No newline at end of file diff --git a/backend/src/googleAuth.ts b/backend/src/googleAuth.ts index 32329a8..cb2aaec 100644 --- a/backend/src/googleAuth.ts +++ b/backend/src/googleAuth.ts @@ -2,6 +2,7 @@ import passport from "passport"; import { Strategy as GoogleStrategy } from "passport-google-oauth20"; import db from "src/db/db"; import env from "src/util/validateEnv"; // Importing environment variables +// login to postregress psql -U postgres interface UserProfile { id: string; @@ -32,7 +33,9 @@ passport.use( } // Generate a random username (for new users) - const randomUsername = `user_${Math.floor(Math.random() * 1000000)}`; + console.log("Google Profile:", profile); + //const randomUsername = `user_${Math.floor(Math.random() * 1000000)}`; + const username = profile.displayName; // ADDED REAL USERNAME FROM GOOGLE PROFILE second merge request const photos = profile.photos; try { @@ -51,11 +54,7 @@ passport.use( INSERT INTO users (email, username, profile_picture) VALUES ($1, $2, $3) RETURNING id `; - const insertResult = await db.query(insertUserQuery, [ - email, - randomUsername, - profilePicture, - ]); + const insertResult = await db.query(insertUserQuery, [email, username, profilePicture]); profile.id = insertResult.rows[0].id; return done(null, profile); diff --git a/frontend/app/(tabs)/index.tsx b/frontend/app/(tabs)/index.tsx index 4842d75..3f9d276 100644 --- a/frontend/app/(tabs)/index.tsx +++ b/frontend/app/(tabs)/index.tsx @@ -1,11 +1,14 @@ import { View, StyleSheet, Text, ScrollView } from "react-native"; +import { Picker } from "@react-native-picker/picker"; import NewTransactionButton from "@/components/NewTransaction/NewTransactionButton"; import TransactionHistory from "@/components/TransactionHistory/TransactionHistory"; -import { useEffect, useState, useCallback } from "react"; +import { useState, useCallback } from "react"; import { BACKEND_PORT } from "@env"; import { useAuth } from "@/context/authContext"; import CustomPieChart from "@/components/Graphs/PieChart"; import { useFocusEffect } from "@react-navigation/native"; +import AreaChart from "@/components/Graphs/AreaChart"; +import { GraphType } from "@/utils/FrontendTypes"; /* this function is the structure for the home screen which includes a graph, option to add transaction, and recent transaction history. */ @@ -20,8 +23,10 @@ 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 [allTransactions, setAllTransactions] = useState([]); const [updateRecent, setUpdateRecent] = useState(false); const [total, setTotal] = useState(0); + const [totalBudget, setTotalBudget] = useState(0); // total budget not tied to categories const [categories, setCategories] = useState([]); const { userId } = useAuth(); const [username, setUsername] = useState(""); @@ -33,6 +38,9 @@ export default function Home() { ["Others", "#2b2d42"], //black ]); + // only two graph types for now: pie and area + const [graphType, setGraphType] = useState("pie"); + useFocusEffect( useCallback(() => { fetch( @@ -49,8 +57,9 @@ export default function Home() { return res.json(); }) .then((data) => { - console.log(data); + console.log("transaction data", data); setThreeTransactions(data.slice(0, 5)); + setAllTransactions(data); }) .catch((error) => { console.error("API Error:", error); @@ -63,7 +72,9 @@ export default function Home() { return res.json(); }) .then((data) => { + console.log("user data", data); setUsername(data.username); + setTotalBudget(data.total_budget); }) .catch((error) => { console.error("API Error:", error); @@ -76,6 +87,7 @@ export default function Home() { return res.json(); }) .then((data) => { + console.log("category data", data); setCategories(data); setTotal( data.reduce( @@ -91,12 +103,13 @@ export default function Home() { }, [updateRecent]), ); - const pieData = categories.map((category) => ({ + const categoryData = categories.map((category) => ({ value: parseFloat(category.category_expense), color: categoryColors.get(category.category_name) || "#cccccc", name: category.category_name, id: category.id, })); + console.log(categories); return ( <> @@ -105,13 +118,38 @@ export default function Home() { Hello {username} - - Total Spending - + + + Total Spending + + + + setGraphType(itemValue) + } + style={styles.graphTypePicker} + dropdownIconColor="#00629B" + > + + + + + {/* */} - + {graphType === "pie" && ( + + )} + {graphType === "area" && ( + + )} + - {pieData.map((category) => { + {categoryData.map((category) => { return ( { + const [selectedCategory, setSelectedCategory] = useState( + null, + ); + const [selectedMonth, setSelectedMonth] = useState( + new Date().toISOString().substring(0, 7), + ); + const [selectedYear, setSelectedYear] = useState( + new Date().getFullYear().toString(), + ); + const [useTotalOrCategory, setUseTotalOrCategory] = useState< + "total" | "category" + >("total"); + + const chartWidth = 280; + const chartHeight = 180; + const padding = 20; + const graphWidth = chartWidth - padding * 2; + const graphHeight = chartHeight - padding * 2; + + // Calculate cumulative spending data from transactions + const chartData = useMemo(() => { + if (!props.transactions || props.transactions.length === 0) { + // If no transactions, return empty or flat line + return []; + } + + // Filter transactions by selected month/year + const filteredTransactions = props.transactions.filter((transaction) => { + const transactionDate = new Date(transaction.date); + const transactionMonth = transactionDate.toISOString().substring(0, 7); + const transactionYear = transactionDate.getFullYear().toString(); + + // Filter by category if in category mode + if (useTotalOrCategory === "category") { + if (!selectedCategory) { + return false; + } + const matchesCategory = + transaction.category_name === selectedCategory.category_name; + const matchesMonth = transactionMonth === selectedMonth; + const matchesYear = transactionYear === selectedYear; + return matchesCategory && matchesMonth && matchesYear; + } else { + // Total mode - filter by month/year only + const matchesMonth = transactionMonth === selectedMonth; + const matchesYear = transactionYear === selectedYear; + return matchesMonth && matchesYear; + } + }); + + if (filteredTransactions.length === 0) return []; + + // Sort transactions by date + const sortedTransactions = [...filteredTransactions].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ); + + // Get the date range for the selected month + const [year, month] = selectedMonth.split("-").map(Number); + const daysInMonth = new Date(year, month, 0).getDate(); + + // Create daily cumulative data + const dailyData: { date: Date; cumulative: number }[] = []; + let cumulative = 0; + + // Process each day of the month + for (let day = 1; day <= daysInMonth; day++) { + const currentDate = new Date(year, month - 1, day); + + // Add transactions for this day + const dayTransactions = sortedTransactions.filter((t) => { + const tDate = new Date(t.date); + return ( + tDate.getDate() === day && + tDate.getMonth() === month - 1 && + tDate.getFullYear() === year + ); + }); + + // Add amounts for this day + dayTransactions.forEach((t) => { + cumulative += parseFloat(t.amount); + }); + + dailyData.push({ + date: currentDate, + cumulative: cumulative, + }); + } + + // Get budget for scaling + const budget = + useTotalOrCategory === "total" + ? parseFloat(String(props.totalBudget)) || 1 + : selectedCategory + ? parseFloat(selectedCategory.max_category_budget) || 1 + : 1; + + const maxCumulative = Math.max( + ...dailyData.map((d) => d.cumulative), + budget, + ); + + // Convert to chart coordinates + return dailyData.map((data, index) => { + const x = (index / (daysInMonth - 1)) * graphWidth; + const y = graphHeight - (data.cumulative / maxCumulative) * graphHeight; + return { + x, + y, + value: data.cumulative, + date: data.date, + }; + }); + }, [ + useTotalOrCategory, + selectedCategory, + selectedMonth, + selectedYear, + props.transactions, + props.categories, + props.totalBudget, + graphWidth, + graphHeight, + ]); + + // Create area path + const areaPath = useMemo(() => { + if (chartData.length === 0) return ""; + if (chartData.length === 1) { + // Single point - draw a line from bottom to the point + const point = chartData[0]; + const bottomY = graphHeight + padding; + return `M ${point.x + padding} ${bottomY} L ${point.x + padding} ${point.y + padding} L ${point.x + padding} ${bottomY} Z`; + } + const points = chartData.map((d) => `${d.x + padding},${d.y + padding}`); + const firstX = chartData[0].x + padding; + const lastX = chartData[chartData.length - 1].x + padding; + const bottomY = graphHeight + padding; + + return `M ${firstX} ${bottomY} L ${points.join(" L ")} L ${lastX} ${bottomY} Z`; + }, [chartData, padding, graphHeight]); + + const currentValue = useMemo(() => { + if (chartData.length === 0) { + // Fallback to category totals if no transaction data + if (useTotalOrCategory === "total") { + return props.categories.reduce( + (sum, cat) => sum + parseFloat(cat.category_expense), + 0, + ); + } else { + return selectedCategory + ? parseFloat(selectedCategory.category_expense) + : 0; + } + } + // Return the last cumulative value (total for the month) + return chartData[chartData.length - 1]?.value || 0; + }, [chartData, useTotalOrCategory, selectedCategory, props.categories]); + + const currentBudget = useMemo(() => { + if (useTotalOrCategory === "total") { + return parseFloat(String(props.totalBudget)); + } else { + return selectedCategory + ? parseFloat(selectedCategory.max_category_budget) + : 0; + } + }, [useTotalOrCategory, selectedCategory, props.totalBudget]); + + return ( + + {/* Controls */} + + {/* Combined Category/Total Picker */} + + { + if (itemValue === "total") { + setUseTotalOrCategory("total"); + setSelectedCategory(null); + } else { + setUseTotalOrCategory("category"); + const category = props.categories.find( + (c) => c.category_name === itemValue, + ); + setSelectedCategory(category || null); + } + }} + style={styles.categoryPicker} + > + + {props.categories.map((cat) => ( + + ))} + + + + {/* Month/Year Selectors */} + + + Month: + { + // Ensure the month uses the currently selected year + const [year, month] = monthStr.split("-"); + const newMonthStr = `${selectedYear}-${month}`; + setSelectedMonth(newMonthStr); + }} + style={styles.datePicker} + > + {Array.from({ length: 12 }, (_, i) => { + const year = parseInt(selectedYear); + const month = new Date(year, i, 1); + const monthStr = month.toISOString().substring(0, 7); + return ( + + ); + })} + + + + Year: + { + setSelectedYear(year); + // Update month to use new year but keep same month number + const currentMonth = selectedMonth.split("-")[1]; + setSelectedMonth(`${year}-${currentMonth}`); + }} + style={styles.datePicker} + > + {Array.from({ length: 3 }, (_, i) => { + const year = new Date().getFullYear() - 2 + i; + return ( + + ); + })} + + + + + + {/* Chart */} + + + + {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const y = padding + graphHeight - ratio * graphHeight; + return ( + + ); + })} + + {/* Area */} + {areaPath && areaPath !== "" && chartData.length > 0 && ( + + )} + + {/* No data message */} + {chartData.length === 0 && ( + + No data for this period + + )} + + + + {/* Budget/Spending Info - Moved outside chart */} + {chartData.length > 0 && ( + + + Spent: + ${currentValue.toFixed(2)} + + + Budget: + ${currentBudget.toFixed(2)} + + + Remaining: + currentBudget && styles.infoValueOver, + ]} + > + ${(currentBudget - currentValue).toFixed(2)} + + + + )} + + + ); +}; + +export default AreaChart; + +const styles = StyleSheet.create({ + AreaContainer: { + justifyContent: "flex-start", + width: "100%", + alignItems: "center", + gap: 15, + }, + controlsContainer: { + width: "100%", + gap: 10, + }, + pickerContainer: { + width: "100%", + }, + categoryPicker: { + width: "100%", + height: 45, + backgroundColor: "#F5F5F5", + borderRadius: 8, + borderWidth: 1, + borderColor: "#D0D0D0", + }, + chartContainer: { + alignItems: "center", + justifyContent: "center", + width: "100%", + }, + infoContainer: { + marginTop: 15, + width: "100%", + flexDirection: "row", + justifyContent: "space-around", + paddingVertical: 10, + backgroundColor: "#F8F8F8", + borderRadius: 8, + paddingHorizontal: 10, + }, + infoRow: { + flexDirection: "column", + alignItems: "center", + gap: 4, + }, + infoLabel: { + fontSize: 11, + color: "#666", + fontWeight: "500", + }, + infoValue: { + fontSize: 16, + color: "#333", + fontWeight: "bold", + }, + infoValueOver: { + color: "#D32F2F", + }, + dateRow: { + flexDirection: "row", + gap: 10, + width: "100%", + justifyContent: "space-between", + }, + datePickerContainer: { + flex: 1, + gap: 5, + }, + dateLabel: { + fontSize: 12, + color: "#666", + fontWeight: "500", + }, + datePicker: { + height: 40, + backgroundColor: "#F5F5F5", + borderRadius: 8, + borderWidth: 1, + borderColor: "#D0D0D0", + }, +}); diff --git a/frontend/utils/FrontendTypes.ts b/frontend/utils/FrontendTypes.ts new file mode 100644 index 0000000..099f5b1 --- /dev/null +++ b/frontend/utils/FrontendTypes.ts @@ -0,0 +1,9 @@ +export type CategoryType = { + category_expense: string; + category_name: string; + id: number; + max_category_budget: string; + user_id: number; +}; + +export type GraphType = "pie" | "area";