From 7ef0ce34556062a3742329be796a07a72b751643 Mon Sep 17 00:00:00 2001 From: Nhat Tran Date: Thu, 13 Nov 2025 14:06:28 -0800 Subject: [PATCH 1/5] feat: Helper script to populate database with random transactions --- backend/src/db/populate-transactions.ts | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 backend/src/db/populate-transactions.ts diff --git a/backend/src/db/populate-transactions.ts b/backend/src/db/populate-transactions.ts new file mode 100644 index 0000000..c1d76f3 --- /dev/null +++ b/backend/src/db/populate-transactions.ts @@ -0,0 +1,35 @@ +import client from "../db/db"; // Import PostgreSQL client. + +// TUNABLE CONSTANTS +// The number of days between the earliest possible generated transaction and today. +const MAX_DAY_BEFORE = 365; +const MAX_AMOUNT = 15; // The maximum amount for a transaction. +const ITEM_NAME_LENGTH = 6; // The number of characters for item names. +const TRANSACTIONS_NUMBER = 120; // The number of transactions to populate. +const USER_ID = 3; // The user_id of the transactions to populate. + +// CONSTANTS +const CATEGORIES = ["Food", "Shopping", "Subscriptions", "Transportation", "Other"] as const; +const QUERY = + "INSERT INTO transactions(user_id, item_name, amount, category_name, date) " + + "VALUES ($1, $2, $3, $4, LOCALTIMESTAMP - $5::INTERVAL)"; + +/** + * Returns a random integer in the range [0, end). + * + * @param end the end of the range of possible values, exclusive + * @return a random integer in the given range + */ +function randInt(end: number): number { + return Math.floor(Math.random() * end); +} + +for (let i = 0; i < TRANSACTIONS_NUMBER; i++) { + const item_name = randInt(Math.pow(16, ITEM_NAME_LENGTH)) + .toString(16) + .padStart(ITEM_NAME_LENGTH, "0"); // Random hex string. + const amount = randInt(MAX_AMOUNT * 100) / 100; // Value round to cents + const category_name = CATEGORIES[randInt(CATEGORIES.length)]; + const day_before = randInt(MAX_DAY_BEFORE) + " days"; + client.query(QUERY, [USER_ID, item_name, amount, category_name, day_before]); +} From 1c909b2aa084ef6a962c7fc0b60e7fcb35f06e87 Mon Sep 17 00:00:00 2001 From: Nhat Tran Date: Fri, 14 Nov 2025 09:54:51 -0800 Subject: [PATCH 2/5] feat: Backend for retrieving monthly expense, further divided into categories --- backend/src/controllers/transactions.ts | 38 +++++++++++++++++++++++++ backend/src/routes/transactions.ts | 9 +++++- 2 files changed, 46 insertions(+), 1 deletion(-) 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; From 34cf058d5de7f492afb93c91b9afd4220dd9090d Mon Sep 17 00:00:00 2001 From: Nhat Tran Date: Sat, 15 Nov 2025 10:45:44 -0800 Subject: [PATCH 3/5] feat: Responsive stacked bar chart with both relative and absolute display mode --- frontend/app/(tabs)/index.tsx | 119 +++++- .../components/Graphs/StackedBarChart.tsx | 360 ++++++++++++++++++ 2 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 frontend/components/Graphs/StackedBarChart.tsx 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..1eeca9b --- /dev/null +++ b/frontend/components/Graphs/StackedBarChart.tsx @@ -0,0 +1,360 @@ +import { View, Text, StyleSheet } from "react-native"; +import Svg, { G, Rect, Line } from "react-native-svg"; + +type StackedBarData = Record>; + +const MONTH_NAMES = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] as const; +const PREFERRED_NUM_COLS = [60, 24, 12, 6, 3, 1] as const; +// Denomination for determining chart line. +const DENOMINATIONS = [ + { + value: 2, + log: Math.log10(2), + subdivisions: 4, + }, + { + value: 5, + log: Math.log10(5), + subdivisions: 5, + }, + { + value: 10, + log: 1, + subdivisions: 5, + }, +] as const; +const MIN_COL_WIDTH = 40; // Including gap between columns. +const HALF_GAP_WIDTH = 8; + +function hash(input: string) { + let hash = 0; + for (const char of input) { + hash = (hash << 5) - hash + char.charCodeAt(0); + hash |= 0; + } + return hash; +} + +function range(n: number) { + return [...Array(n).keys()]; +} + +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; + } +} + +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); + + let maxYValue = 100; + let numSubdivisions = 5; + if (!props.isRelative) { + // Compute max monthly expense for this duration, and the height of the chart line needed. + const maxExpense = Object.values(props.data).reduce( + (acc, row) => (row.Total > acc ? row.Total : acc), + 0, + ); + const logMaxExpense = Math.max(Math.log10(maxExpense), 0); + const intPart = Math.floor(logMaxExpense); + const fracPart = logMaxExpense % 1; + maxYValue = Math.pow(10, intPart); + for (const item of DENOMINATIONS) { + if (fracPart <= item.log) { + maxYValue *= item.value; + numSubdivisions = item.subdivisions; + break; + } + } + } + const subdivisionGap = maxYValue / numSubdivisions; + + let numCols = 1; + for (const val of PREFERRED_NUM_COLS) { + if (props.width / val >= MIN_COL_WIDTH) { + numCols = val; + break; + } + } + const columnWidth = props.width / numCols; + const barWidth = columnWidth - 2 * HALF_GAP_WIDTH; + let startY = props.height; + + function createSection( + key: number, + monthData: Record, + category: string, + color: string, + ) { + const sectionHeight = + category in monthData + ? (monthData[category] / monthData.Total) * props.height + : 0; + + return ( + + ); + } + + function createStackedBar( + monthData: Record | undefined, + monthString: string, + index: number, + ) { + startY = props.height; + const key = hash(monthString); + + if (monthData === undefined) { + return ; + } + + const heightScale = monthData.Total / maxYValue; + + return ( + + {Array.from(props.colors).map((row) => + createSection(hash(monthString + row[0]), monthData, row[0], row[1]), + )} + + ); + } + + return ( + + + + {range(numSubdivisions + 1).map((index) => { + const labelValue = maxYValue - index * subdivisionGap; + let lineLabel: string = labelValue.toFixed(0); + if (props.isRelative) { + lineLabel = labelValue.toFixed(0) + "%"; + } else if (labelValue < 1 && labelValue > 0) { + lineLabel = (labelValue * 100).toFixed(0) + "¢"; + } else { + lineLabel = "$" + labelValue.toFixed(0); + } + + return ( + + {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 createStackedBar( + props.data[monthString], + monthString, + index, + ); + }, + ), + ]} + + + + + + {[ + ...new MonthIter( + currentYearAndMonth.clone().addMonth(-numCols + 1), + numCols, + 1, + ).map((yearAndMonth) => { + console.log(yearAndMonth); + return ( + + {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, + }, + columns: { + transformOrigin: "bottom left", + }, + svgView: { + paddingTop: 8, + paddingBottom: 8, + }, +}); From b494038b2b4c23623d007d411a3aa5e41c59c3da Mon Sep 17 00:00:00 2001 From: Nhat Tran Date: Sat, 15 Nov 2025 15:16:09 -0800 Subject: [PATCH 4/5] Revert "feat: Helper script to populate database with random transactions" This reverts commit 7ef0ce34556062a3742329be796a07a72b751643. Move helper script into another branch. --- backend/src/db/populate-transactions.ts | 35 ------------------------- 1 file changed, 35 deletions(-) delete mode 100644 backend/src/db/populate-transactions.ts diff --git a/backend/src/db/populate-transactions.ts b/backend/src/db/populate-transactions.ts deleted file mode 100644 index c1d76f3..0000000 --- a/backend/src/db/populate-transactions.ts +++ /dev/null @@ -1,35 +0,0 @@ -import client from "../db/db"; // Import PostgreSQL client. - -// TUNABLE CONSTANTS -// The number of days between the earliest possible generated transaction and today. -const MAX_DAY_BEFORE = 365; -const MAX_AMOUNT = 15; // The maximum amount for a transaction. -const ITEM_NAME_LENGTH = 6; // The number of characters for item names. -const TRANSACTIONS_NUMBER = 120; // The number of transactions to populate. -const USER_ID = 3; // The user_id of the transactions to populate. - -// CONSTANTS -const CATEGORIES = ["Food", "Shopping", "Subscriptions", "Transportation", "Other"] as const; -const QUERY = - "INSERT INTO transactions(user_id, item_name, amount, category_name, date) " + - "VALUES ($1, $2, $3, $4, LOCALTIMESTAMP - $5::INTERVAL)"; - -/** - * Returns a random integer in the range [0, end). - * - * @param end the end of the range of possible values, exclusive - * @return a random integer in the given range - */ -function randInt(end: number): number { - return Math.floor(Math.random() * end); -} - -for (let i = 0; i < TRANSACTIONS_NUMBER; i++) { - const item_name = randInt(Math.pow(16, ITEM_NAME_LENGTH)) - .toString(16) - .padStart(ITEM_NAME_LENGTH, "0"); // Random hex string. - const amount = randInt(MAX_AMOUNT * 100) / 100; // Value round to cents - const category_name = CATEGORIES[randInt(CATEGORIES.length)]; - const day_before = randInt(MAX_DAY_BEFORE) + " days"; - client.query(QUERY, [USER_ID, item_name, amount, category_name, day_before]); -} From d4ebb5737beb37d3282a6bc5aabac388b9afe39f Mon Sep 17 00:00:00 2001 From: Nhat Tran Date: Mon, 17 Nov 2025 16:20:39 -0800 Subject: [PATCH 5/5] feat: Transition animation between absolute and relative stacked bar chart modes --- .../components/Graphs/StackedBarChart.tsx | 320 ++++++++++-------- 1 file changed, 188 insertions(+), 132 deletions(-) diff --git a/frontend/components/Graphs/StackedBarChart.tsx b/frontend/components/Graphs/StackedBarChart.tsx index 1eeca9b..0ab349b 100644 --- a/frontend/components/Graphs/StackedBarChart.tsx +++ b/frontend/components/Graphs/StackedBarChart.tsx @@ -1,5 +1,15 @@ 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>; @@ -17,19 +27,63 @@ const MONTH_NAMES = [ "Nov", "Dec", ] as const; +// Prefered number of bars. const PREFERRED_NUM_COLS = [60, 24, 12, 6, 3, 1] as const; -// Denomination for determining chart line. +// 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, @@ -39,19 +93,23 @@ const DENOMINATIONS = [ const MIN_COL_WIDTH = 40; // Including gap between columns. const HALF_GAP_WIDTH = 8; -function hash(input: string) { - let hash = 0; - for (const char of input) { - hash = (hash << 5) - hash + char.charCodeAt(0); - hash |= 0; - } - return hash; -} +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; @@ -127,6 +185,69 @@ class MonthIter implements Iterator, Iterable { } } +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; @@ -140,120 +261,55 @@ export default function StackedBarChart(props: { const current_month = current_date.getMonth() + 1; const currentYearAndMonth = new YearAndMonth(current_year, current_month); - let maxYValue = 100; - let numSubdivisions = 5; - if (!props.isRelative) { - // Compute max monthly expense for this duration, and the height of the chart line needed. - const maxExpense = Object.values(props.data).reduce( - (acc, row) => (row.Total > acc ? row.Total : acc), - 0, - ); - const logMaxExpense = Math.max(Math.log10(maxExpense), 0); - const intPart = Math.floor(logMaxExpense); - const fracPart = logMaxExpense % 1; - maxYValue = Math.pow(10, intPart); - for (const item of DENOMINATIONS) { - if (fracPart <= item.log) { - maxYValue *= item.value; - numSubdivisions = item.subdivisions; - break; - } - } - } + // 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; - let numCols = 1; - for (const val of PREFERRED_NUM_COLS) { - if (props.width / val >= MIN_COL_WIDTH) { - numCols = val; - break; - } - } + 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; - let startY = props.height; - - function createSection( - key: number, - monthData: Record, - category: string, - color: string, - ) { - const sectionHeight = - category in monthData - ? (monthData[category] / monthData.Total) * props.height - : 0; - - return ( - - ); - } - - function createStackedBar( - monthData: Record | undefined, - monthString: string, - index: number, - ) { - startY = props.height; - const key = hash(monthString); - if (monthData === undefined) { - return ; - } - - const heightScale = monthData.Total / maxYValue; - - return ( - - {Array.from(props.colors).map((row) => - createSection(hash(monthString + row[0]), monthData, row[0], row[1]), - )} - - ); - } + 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) => { - const labelValue = maxYValue - index * subdivisionGap; - let lineLabel: string = labelValue.toFixed(0); - if (props.isRelative) { - lineLabel = labelValue.toFixed(0) + "%"; - } else if (labelValue < 1 && labelValue > 0) { - lineLabel = (labelValue * 100).toFixed(0) + "¢"; - } else { - lineLabel = "$" + labelValue.toFixed(0); - } + {range(numSubdivisions + 1) + .map((index) => { + if (props.isRelative) { + return (100 * (1 - index / numSubdivisions)).toFixed(0) + "%"; + } - return ( - + const value = maxYValue - index * subdivisionGap; + return value < 1 && value > 0 + ? (value * 100).toFixed(0) + "¢" + : "$" + value.toFixed(0); + }) + .map((lineLabel) => ( + {lineLabel} - ); - })} + ))} @@ -281,10 +337,20 @@ export default function StackedBarChart(props: { ...new MonthIter(currentYearAndMonth.clone(), numCols, -1).map( (yearAndMonth, index) => { const monthString = yearAndMonth.toString(); - return createStackedBar( - props.data[monthString], - monthString, - index, + return ( + ); }, ), @@ -306,17 +372,14 @@ export default function StackedBarChart(props: { currentYearAndMonth.clone().addMonth(-numCols + 1), numCols, 1, - ).map((yearAndMonth) => { - console.log(yearAndMonth); - return ( - - {MONTH_NAMES[yearAndMonth.month - 1]} - - ); - }), + ).map((yearAndMonth) => ( + + {MONTH_NAMES[yearAndMonth.month - 1]} + + )), ]} @@ -350,11 +413,4 @@ const styles = StyleSheet.create({ textAlign: "center", fontSize: 16, }, - columns: { - transformOrigin: "bottom left", - }, - svgView: { - paddingTop: 8, - paddingBottom: 8, - }, });