Carson Wolber summer 2024 Meta University Capstone Project
Technical Challenge #1:
-
EconMetrics offers the ability to set and track goals. As those goals are met / missed, users are reminded of their goals.
- goal setting is handled in the
GoalPagefolder, the ability to tracking new goals specifically is in this program flow:<button className='AddButton' onClick={addGoal}>Add Goal</button> - https://github.com/CWMetaUCapstone/EconMetrics/blob/e87f96ff40c9694587a0d7d77ee2a357a1ff9b0e/frontend/src/GoalPage/AddableGoal.jsx#L11C2-L21C6
EconMetrics/backend/flask_routes/app.py
Lines 479 to 493 in e87f96f
@app.route('/followgoal/<userId>/<goalId>', methods=['POST']) def follow_goal(userId, goalId): try: user = User.query.get(userId) goal = Goals.query.get(goalId) if goal not in user.goals: user.goals.append(goal) db.session.commit() return jsonify({'message': 'goal followed!'}) except Exception as e: db.session.rollback() print(f"error at follow_goal: {str(e)}") return jsonify({'error at follow_goal': str(e)}), 500
- notifications are done using
flask_mailon the backend and they're set to run once a week along with pulling new transaction dataEconMetrics/backend/flask_routes/app.py
Lines 778 to 814 in e87f96f
""" helper to send users notifications that update on the state of the goals they're currently tracking following a sync to grab users most recent transaction data from plaid """ def send_goal_update_msg(userId): user = User.query.get(userId) message_body = 'Here\'s your breakdown on goal progress since last week: \n' + format_updates(userId) msg = Message('Your EconMetrics Goal Progress', sender='econmetrics.goalupdates@gmail.com', recipients=[user.email], body= message_body ) try: mail.send(msg) return jsonify({'message': 'email sent!'}) except Exception as e: print(f"error at send_goal_update_msg: {str(e)}") return jsonify({'failed to send email': str(e)}) """ helper to format the list of updates for each goal a user is tracking when tranactions update and message is sent. User's receive emails that for each goal they're tracking, show how their spending on that category has changed between the past two most recent transaction syncs with the context of the goal amount """ def format_updates(userId): update_msg = '' user = User.query.get(userId) goals = user.goals new_transaction = Transactions.query.filter_by(userId=user.id).order_by(Transactions.time.desc()).first() # this gets last weeks transaction (the one before newest / second row) by simply skipping first row and getting the next row last_transaction = Transactions.query.filter_by(userId=user.id).order_by(Transactions.time.desc()).offset(1).limit(1).first() for goal in goals: category = goal.category update_msg += 'category: ' + category + ' targeted change in expenditure: ' + str(goal.target) + ' this weeks percent: '+ str(getattr(new_transaction, category)) + ' last week\'s percent: ' + str(getattr(last_transaction, category)) + '\n' return update_msg
- goal setting is handled in the
-
After goals are set, EconMetrics provides users with personalized plans to meet their goals based on user preferences, other users who have accomplished similar goals, and market conditions.
- this goal is encapsulated in the
personalization.jsfile here: https://github.com/CWMetaUCapstone/EconMetrics/blob/e87f96ff40c9694587a0d7d77ee2a357a1ff9b0e/frontend/HelperFuncs/personalization.js
- this goal is encapsulated in the
Technical Challenge #2:
- Users of EconMetrics want to be able to visualize their financial information and their information compared to other users.
This goal is mostly encapsulated in the components in the Graph folder here: https://github.com/CWMetaUCapstone/EconMetrics/tree/e87f96ff40c9694587a0d7d77ee2a357a1ff9b0e/frontend/src/ProfilePage/Graphs
There is also a pie plot PNG showing off a users expenditure in a different visual:
EconMetrics/backend/data_handling/data_processing.py
Lines 92 to 116 in e87f96f
""" helper function to generate a pie chart representation of user's transaction data at a sub-category level plots are saved in the frontend/public folder for use by react. Each file name uses userId so each user has a stored unique chart """ def create_pie_plot(transaction_data, userId, transactionId): try: # extract the 'name' and 'percent' fields by normailizing the the user_transaction_data JSON and and create a nx2 data frame for these fields rows = [] for category in transaction_data.values(): temp_df = pd.json_normalize(category, record_path='details') rows.append(temp_df) data = pd.concat(rows, ignore_index=True) labels = data['name'].tolist() percents = data['percent'].tolist() fig, ax = plt.subplots() ax.pie(percents, labels=labels, autopct='%.2f%%') root = '../frontend/public' filename = f'pie_chart_{userId}_{transactionId}.png' directory = os.path.join(root, filename) plt.savefig(directory, transparent=True) plt.close() except Exception as e: print(f"error occurred at create_pie_plot: {str(e)}") - They also want to be able to customize/filter what information is pooled into the data visualized.
- there are instances of this goal in both folders. for the
TimeChartcomponent this is done by regulating thedataprop using a react-select propEconMetrics/frontend/src/ProfilePage/Profile.jsx
Lines 348 to 350 in e87f96f
<div className='TimeChart'> <TimeChart data={overTimeChartData} onSaveSvg={saveOverTimeSvgToLocalStorage}/> </div> EconMetrics/frontend/src/ProfilePage/Profile.jsx
Lines 336 to 345 in e87f96f
<Select placeholder="Graph…" closeMenuOnSelect={true} components={animatedComponents} options={selectData} isClearable={true} className='graphSelector' isMulti onChange={handleSelectChange} value={selectedOptions} EconMetrics/frontend/src/ProfilePage/Profile.jsx
Lines 227 to 230 in e87f96f
const handleSelectChange = (options) => { setSelectedOptions(options); localStorage.setItem('selectedOptions', JSON.stringify(options)); };
- in
CompBoxPlotthis done both directly in the graph by clicking on different parts of the graph:EconMetrics/frontend/src/ProfilePage/Graphs/CompBoxPlot.jsx
Lines 244 to 261 in e87f96f
// if a circle is clicked, the corresponding userId is propagated onto profile page to show details about that user svg.selectAll("circle.point") .on("click", (event, d) => { const userId = d.id OnClickedUserId(userId) }) // users can reset the click state by clicking on the graph itself (not a circle or box) svg.on("click", (event) => { const target = event.target if(target.className.baseVal !== 'point'){ OnClickedUserId(0) } if(target.className.baseVal !== 'box-plot'){ OnBoxClick({}) } }) EconMetrics/frontend/src/ProfilePage/Graphs/CompBoxPlot.jsx
Lines 229 to 242 in e87f96f
// if the user hovers over a dot, all dots corresponding to the user associated with that dot are highlighted svg.on("mouseover", (event) => { const target = event.target if(target.className.baseVal === 'point'){ const userId = target.getAttribute("data-user-id"); svg.selectAll("circle.point") .attr("opacity", function(){ return this.getAttribute("data-user-id") === userId ? 1 : 0.2 }) } }).on("mouseout", () => { svg.selectAll("circle.point") .attr("opacity", 1) });
- as well as giving the user some external control over data visualization using two different
CheckboxcomponentsEconMetrics/frontend/src/ProfilePage/Graphs/CompBoxPlot.jsx
Lines 127 to 227 in e87f96f
// conditionally append box plots based on user input through the corresponding checkbox if(showBoxPlots){ // the placement of this if statement in the program allows dots to be placed on top of the box so their mouse effects can still be reached const boxWidth = chartConfig['scatter-range'] - chartConfig['scatter-range'] / 2 + margin.left; const boxOffset = (bandwidth - boxWidth) / 2; const boxPlots = categoryGroups.selectAll("rect.box-plot") .data(d => [d]) .join("g") .attr("transform", `translate(${boxOffset}, 0)`) boxPlots.append("rect") .attr("class", "box-plot") .attr("y", d => yAxis(d.quartiles[2])) .attr("height", d => yAxis(d.quartiles[0]) - yAxis(d.quartiles[2])) .attr("width", boxWidth) .attr("fill", "#ddd") .attr("opacity", 0.6) .on("click", (event, d) => { // if user data is being shown all other inputs are blocked if(!showUserData){ OnBoxClick(d) } }) boxPlots.append("line") .attr("class", "whisker") .attr("y1", d => yAxis(d.range[1])) .attr("y2", d => yAxis(d.range[0])) .attr("x1", (bandwidth - (bandwidth * chartConfig['box-width-factor']))/2 + chartConfig['scatter-range']/2) .attr("x2", (bandwidth - (bandwidth * chartConfig['box-width-factor']))/2 + chartConfig['scatter-range']/2) .attr("stroke", "black") boxPlots.append("line") .attr("class", "median") .attr("y1", d => yAxis(d.quartiles[1])) .attr("y2", d => yAxis(d.quartiles[1])) .attr("x1", 0) .attr("x2", boxWidth) .attr("stroke", "black") boxPlots.append("line") .attr("class", "cap") .attr("y1", d => yAxis(d.range[1])) .attr("y2", d => yAxis(d.range[1])) .attr("x1", boxWidth * .2) .attr("x2", boxWidth * .8) .attr("stroke", "black") boxPlots.append("line") .attr("class", "cap") .attr("y1", d => yAxis(d.range[0])) .attr("y2", d => yAxis(d.range[0])) .attr("x1", boxWidth * .2) .attr("x2", boxWidth * .8) .attr("stroke", "black") } const similarDots = categoryGroups.selectAll("circle.point") .data(d => d.entries) .join("circle") .attr("class", "point") // the x-axis value for dots are randomized within the range of the parent category to give more of a scattered look to improve clarity .attr("cx", () => Math.random() * chartConfig['scatter-range'] - chartConfig['scatter-range'] / 2 + margin.left) .attr("cy", d => yAxis(d.value)) .attr("fill", d => colorRules(d.id)) .attr("r", chartConfig.dot_radius * 1.5) .attr("data-category", d => d.category) .attr("data-user-id", d => d.id) /* if [showUserData] is enabled, plot the data for user whose profile we're on, highlight these points by graying out other dots and disabling their pointer effects */ if(showUserData){ categoryGroups.selectAll("circle.user-point") /* because userData field in [categoryValues] is passed as the direct userData object, categories are not seperated in the same way as in entries so we need to map out each category to get the category name and associated percent */ .data(d => { return d.userData.map(user => ({ total_percent: user[d.category].total_percent, category: d.category })); }) .join("circle") .attr("class", "user-point") .attr("cx", () => Math.random() * chartConfig['scatter-range'] - chartConfig['scatter-range'] / 2 + margin.left) .attr("cy", d => yAxis(d.total_percent)) .attr("fill", "steelblue") .attr("r", chartConfig.dot_radius * 2) .attr("data-category", d => d.category) similarDots .attr("fill", "#ddd") .style("pointer-events", "none") } EconMetrics/frontend/src/ProfilePage/Profile.jsx
Lines 364 to 385 in e87f96f
<div className='Checkboxes'> <FormControlLabel control={ <Checkbox color='success' checked={yourStatsCheckbox} onChange={handleYourStatsBoxChange} /> } label="Show Your Stats" /> <FormControlLabel control={ <Checkbox color='success' checked={boxPlotCheckbox} onClick={handleBoxPlotChange} /> } label="Show Box Plots" /> </div>
- there are instances of this goal in both folders. for the
- In order for this visualization to be performant and responsive, we will have to implement a caching system
- both the svg images themselve and the state of the external checkbox and react select components are cached using localStorage
EconMetrics/frontend/src/ProfilePage/Profile.jsx
Lines 223 to 243 in e87f96f
/* when a new graph selection is made / deleted, set select options to match the value of selectedOptions, tracked through the [options] parameter. The [selectedOptions] item in localStorage is also set to match this change */ const handleSelectChange = (options) => { setSelectedOptions(options); localStorage.setItem('selectedOptions', JSON.stringify(options)); }; const saveOverTimeSvgToLocalStorage = (svgElement) => { // svg is saved in local storage as a src url const overTimeSVGData = new XMLSerializer().serializeToString(svgElement); localStorage.setItem('overTimeSVG', overTimeSVGData); setOverTimeChartSVG(overTimeSVGData); }; const saveBoxPlotSvgToLocalStorage = (svgElement) => { const boxSVGData = new XMLSerializer().serializeToString(svgElement) localStorage.setItem('boxPlotSVG', boxSVGData) setBoxPlotSVG(boxSVGData) }; EconMetrics/frontend/src/ProfilePage/Profile.jsx
Lines 173 to 181 in e87f96f
useEffect(() => { const boxDataString = boxPlotCheckbox.toString(); localStorage.setItem('boxPlotCheckbox', boxDataString); }, [boxPlotCheckbox]); useEffect(() => { const yourStatsDataString = yourStatsCheckbox.toString(); localStorage.setItem('yourStatsCheckbox', yourStatsDataString); }, [yourStatsCheckbox]) - Additionally the matplotlib pie plot is cached as a file in the project
EconMetrics/frontend/src/ProfilePage/Profile.jsx
Lines 123 to 148 in e87f96f
useEffect(() => { // retrieve and set svg src and selected options from localStorage if there's data saved const savedOptions = localStorage.getItem('selectedOptions'); const savedOverTimeSVG = localStorage.getItem('overTimeSVG'); const savedBoxPlotSVG = localStorage.getItem('boxPlotSVG') const storedYourStatsCheckbox = localStorage.getItem('yourStatsCheckbox'); const storedBoxPlotCheckbox = localStorage.getItem('boxPlotCheckbox'); if (storedBoxPlotCheckbox) { // boolean data is stored as a string in local storage so we convert back to bool setBoxPlotCheckbox(storedBoxPlotCheckbox === 'true'); } if (storedYourStatsCheckbox) { setYourStatsCheckbox(storedYourStatsCheckbox === 'true') } if (savedOptions) { setSelectedOptions(JSON.parse(savedOptions)); } if (savedBoxPlotSVG) { const imgSrc = `data:image/svg+xml;base64,${btoa((encodeURIComponent(savedBoxPlotSVG)))}`; setBoxPlotSVG(imgSrc); } if (savedOverTimeSVG) { const imgSrc = `data:image/svg+xml;base64,${btoa((encodeURIComponent(savedOverTimeSVG)))}`; setOverTimeChartSVG(imgSrc); } }, []); EconMetrics/backend/data_handling/data_processing.py
Lines 92 to 116 in e87f96f
""" helper function to generate a pie chart representation of user's transaction data at a sub-category level plots are saved in the frontend/public folder for use by react. Each file name uses userId so each user has a stored unique chart """ def create_pie_plot(transaction_data, userId, transactionId): try: # extract the 'name' and 'percent' fields by normailizing the the user_transaction_data JSON and and create a nx2 data frame for these fields rows = [] for category in transaction_data.values(): temp_df = pd.json_normalize(category, record_path='details') rows.append(temp_df) data = pd.concat(rows, ignore_index=True) labels = data['name'].tolist() percents = data['percent'].tolist() fig, ax = plt.subplots() ax.pie(percents, labels=labels, autopct='%.2f%%') root = '../frontend/public' filename = f'pie_chart_{userId}_{transactionId}.png' directory = os.path.join(root, filename) plt.savefig(directory, transparent=True) plt.close() except Exception as e: print(f"error occurred at create_pie_plot: {str(e)}") EconMetrics/frontend/src/ProfilePage/Profile.jsx
Lines 117 to 121 in e87f96f
useEffect(() => { if(mostRecentTransId != 0){ setPieSrc(`../../public/pie_chart_${userId}_${mostRecentTransId}.png`); } }, [mostRecentTransId]); - https://github.com/CWMetaUCapstone/EconMetrics/blob/e87f96ff40c9694587a0d7d77ee2a357a1ff9b0e/frontend/src/ProfilePage/Profile.jsx#L332C20-L334
- both the svg images themselve and the state of the external checkbox and react select components are cached using localStorage
- My app uses Postgres with Prisma to track four different tables as well as having an intermediary table to allow for a many to many relationship because
UserandGoals. EconMetrics/backend/flask_routes/app.py
Lines 78 to 205 in e87f96f
# Database Models class User(db.Model): __tablename__ = 'User' id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(120), unique=True, nullable=False) password = db.Column(db.String(120), nullable=False) city = db.Column(db.String(120), nullable=True) postal = db.Column(db.String(120), nullable=True) state = db.Column(db.String(120), nullable=True) salary = db.Column(db.String(120), nullable=True) roommates = db.Column(db.Integer, nullable=True) children = db.Column(db.Integer, nullable=True) job = db.Column(db.String(120), nullable=True) token = db.Column(db.String(120), nullable=True, unique=True) transactions = relationship("Transactions", back_populates="user") goals = relationship("Goals", secondary='user_goals', back_populates="users") similarusers = relationship("SimilarUsers", back_populates="user") class Transactions(db.Model): __tablename__ = 'Transactions' id = db.Column(db.Integer, primary_key=True) userId = db.Column(db.Integer, ForeignKey('User.id')) user = relationship("User", back_populates="transactions") rent = db.Column(db.Numeric(13, 2), nullable=True) utilities = db.Column(db.Numeric(13, 2), nullable=True) housing = db.Column(db.Numeric(13, 2), nullable=True) loans = db.Column(db.Numeric(13, 2), nullable=True) student_loans = db.Column(db.Numeric(13, 2), nullable=True) car_loans_and_lease = db.Column(db.Numeric(13, 2), nullable=True) credit_card_payments = db.Column(db.Numeric(13, 2), nullable=True) other_loans = db.Column(db.Numeric(13, 2), nullable=True) entertainment = db.Column(db.Numeric(13, 2), nullable=True) streaming_services = db.Column(db.Numeric(13, 2), nullable=True) other_entertainment = db.Column(db.Numeric(13, 2), nullable=True) food = db.Column(db.Numeric(13, 2), nullable=True) restaurants = db.Column(db.Numeric(13, 2), nullable=True) groceries = db.Column(db.Numeric(13, 2), nullable=True) medical_care = db.Column(db.Numeric(13, 2), nullable=True) transportation = db.Column(db.Numeric(13, 2), nullable=True) gas = db.Column(db.Numeric(13, 2), nullable=True) parking = db.Column(db.Numeric(13, 2), nullable=True) ride_share = db.Column(db.Numeric(13, 2), nullable=True) public_transit = db.Column(db.Numeric(13, 2), nullable=True) other_transportation = db.Column(db.Numeric(13, 2), nullable=True) merchandise = db.Column(db.Numeric(13, 2), nullable=True) retail = db.Column(db.Numeric(13, 2), nullable=True) apparel = db.Column(db.Numeric(13, 2), nullable=True) e_commerce = db.Column(db.Numeric(13, 2), nullable=True) electronics = db.Column(db.Numeric(13, 2), nullable=True) pet_supplies = db.Column(db.Numeric(13, 2), nullable=True) super_stores = db.Column(db.Numeric(13, 2), nullable=True) other_merchandise = db.Column(db.Numeric(13, 2), nullable=True) other_expenses = db.Column(db.Numeric(13, 2), nullable=True) gym_membership = db.Column(db.Numeric(13, 2), nullable=True) financial_planning = db.Column(db.Numeric(13, 2), nullable=True) legal_services = db.Column(db.Numeric(13, 2), nullable=True) insurance = db.Column(db.Numeric(13, 2), nullable=True) tax_payments = db.Column(db.Numeric(13, 2), nullable=True) travel = db.Column(db.Numeric(13, 2), nullable=True) investment_and_saving = db.Column(db.Numeric(13, 2), nullable=True) investment = db.Column(db.Numeric(13, 2), nullable=True) savings_account = db.Column(db.Numeric(13, 2), nullable=True) time = db.Column(db.DateTime, server_default=func.now()) transaction_date = db.Column(db.Integer, nullable=True) class Goals(db.Model): __tablename__ = 'Goals' id = db.Column(db.Integer, primary_key=True) users = relationship("User", secondary='user_goals', back_populates="goals") category = db.Column(db.String) target = db.Column(db.Integer) createdAt = db.Column(db.DateTime, server_default=func.now()) class SimilarUsers(db.Model): __tablename__ = 'SimilarUsers' id = db.Column(db.Integer, primary_key=True) user = relationship("User", back_populates="similarusers") userId = db.Column(db.Integer, ForeignKey('User.id')) similarId = db.Column(db.Integer, unique=True) rent = db.Column(db.Numeric(13, 2), nullable=True) utilities = db.Column(db.Numeric(13, 2), nullable=True) housing = db.Column(db.Numeric(13, 2), nullable=True) loans = db.Column(db.Numeric(13, 2), nullable=True) student_loans = db.Column(db.Numeric(13, 2), nullable=True) car_loans_and_lease = db.Column(db.Numeric(13, 2), nullable=True) credit_card_payments = db.Column(db.Numeric(13, 2), nullable=True) other_loans = db.Column(db.Numeric(13, 2), nullable=True) entertainment = db.Column(db.Numeric(13, 2), nullable=True) streaming_services = db.Column(db.Numeric(13, 2), nullable=True) other_entertainment = db.Column(db.Numeric(13, 2), nullable=True) food = db.Column(db.Numeric(13, 2), nullable=True) restaurants = db.Column(db.Numeric(13, 2), nullable=True) groceries = db.Column(db.Numeric(13, 2), nullable=True) medical_care = db.Column(db.Numeric(13, 2), nullable=True) transportation = db.Column(db.Numeric(13, 2), nullable=True) gas = db.Column(db.Numeric(13, 2), nullable=True) parking = db.Column(db.Numeric(13, 2), nullable=True) ride_share = db.Column(db.Numeric(13, 2), nullable=True) public_transit = db.Column(db.Numeric(13, 2), nullable=True) other_transportation = db.Column(db.Numeric(13, 2), nullable=True) merchandise = db.Column(db.Numeric(13, 2), nullable=True) retail = db.Column(db.Numeric(13, 2), nullable=True) apparel = db.Column(db.Numeric(13, 2), nullable=True) e_commerce = db.Column(db.Numeric(13, 2), nullable=True) electronics = db.Column(db.Numeric(13, 2), nullable=True) pet_supplies = db.Column(db.Numeric(13, 2), nullable=True) super_stores = db.Column(db.Numeric(13, 2), nullable=True) other_merchandise = db.Column(db.Numeric(13, 2), nullable=True) other_expenses = db.Column(db.Numeric(13, 2), nullable=True) gym_membership = db.Column(db.Numeric(13, 2), nullable=True) financial_planning = db.Column(db.Numeric(13, 2), nullable=True) legal_services = db.Column(db.Numeric(13, 2), nullable=True) insurance = db.Column(db.Numeric(13, 2), nullable=True) tax_payments = db.Column(db.Numeric(13, 2), nullable=True) travel = db.Column(db.Numeric(13, 2), nullable=True) investment_and_saving = db.Column(db.Numeric(13, 2), nullable=True) investment = db.Column(db.Numeric(13, 2), nullable=True) savings_account = db.Column(db.Numeric(13, 2), nullable=True) # the user_goals table serves as an intermediary between users and goals to facilitate the many-to-many relationship user_goals = db.Table('user_goals', db.Column('id', db.Integer, primary_key=True), db.Column('user_id', db.Integer, db.ForeignKey('User.id')), db.Column('goal_id', db.Integer, db.ForeignKey('Goals.id')), )
Your app integrates with at least one API (that you didn’t learn about in CodePath) – free APIs only
- my app uses three APIs, Plaid for getting a users bank account info, Back4APP for a dataset of BLS recognized occupations, and Mapbox for their address autocomplete feature.
EconMetrics/backend/flask_routes/app.py
Lines 52 to 61 in e87f96f
# Plaid Client Initialization def create_plaid_client(): configuration = Configuration() configuration.host = "https://sandbox.plaid.com" configuration.api_key['clientId'] = os.getenv('PLAID_CLIENT_ID') configuration.api_key['secret'] = os.getenv('PLAID_SECRET') configuration.ssl_ca_cert = certifi.where() api_client = ApiClient(configuration) return plaid_api.PlaidApi(api_client) EconMetrics/backend/flask_routes/app.py
Lines 295 to 318 in e87f96f
@app.route('/api/exchange_public_token/<userId>', methods=['POST']) def exchange_public_token(userId): public_token = request.json['public_token'] try: exchange_request = ItemPublicTokenExchangeRequest( public_token=public_token ) exchange_response = plaid_client.item_public_token_exchange(exchange_request) access_token = exchange_response['access_token'].encode('utf-8') item_id = exchange_response['item_id'] user = User.query.get(userId) # store the user's access token in user table, an access token provides repeated access to user's plaid-data # this token is encrypted for security # Encrypt the token key = os.getenv('ENCRYPTION_KEY') cipher = Fernet(key) encrypted_token = cipher.encrypt(access_token) user.token = encrypted_token.decode('utf-8') db.session.commit() return jsonify({'message': 'Access token exchanged successfully'}) except Exception as e: print(f"error at exchange_public_token: {str(e)}") return jsonify({'error at exchange_public_token': str(e)}), 500 EconMetrics/backend/flask_routes/app.py
Lines 815 to 842 in e87f96f
""" this function handles the transaction sync endpoint for updating user transactions this is designed as a helper so it can be used both by the flask route and in schedular cron job """ def transactions_sync(userId): try: user = User.query.get(userId) # decrypt the access token from the database to use it encrypted_token = user.token key = os.getenv('ENCRYPTION_KEY') cipher = Fernet(key) decrypted_token = cipher.decrypt(encrypted_token.encode('utf-8')).decode('utf-8') request = TransactionsSyncRequest(access_token=decrypted_token) transactions = plaid_client.transactions_sync(request) # transaction object is by default non-serialable hence why we transform it to a dictionary transactions_data = {"transactions": []} for transaction in transactions.added: transactions_data["transactions"].append(transaction.to_dict()) clean_data = clean_transaction_data(transactions_data) user_transaction_data = aggregate_user_data(clean_data) db_status = save_transaction(userId, user_transaction_data) return jsonify({'message': db_status, 'data': user_transaction_data}) except Exception as e: print(f"error at transactions_sync: {str(e)}") return jsonify({'error': 'error at transactions_sync'}), 500 - https://github.com/CWMetaUCapstone/EconMetrics/blob/e87f96ff40c9694587a0d7d77ee2a357a1ff9b0e/frontend/src/ProfilePage/FormPages/PlaidLink.jsx
EconMetrics/frontend/src/ProfilePage/FormPages/PageOne.jsx
Lines 96 to 104 in e87f96f
<AddressAutofill accessToken={import.meta.env.VITE_MAPBOX_KEY} onSelect={handleAddressSelect}> <input type="text" placeholder='Address' autoComplete="street-address"required/> <label>City</label> <input type="text" name="city" autoComplete='address-level2' onChange={handleInputChange} readOnly /> <label>State</label> <input type="text" name="state" autoComplete='address-level1' onChange={handleInputChange} readOnly /> <label>Postal Code</label> <input type="text" name="postal" autoComplete='postal-code' onChange={handleInputChange} readOnly /> </AddressAutofill> EconMetrics/frontend/HelperFuncs/utils.js
Lines 95 to 124 in e87f96f
/* helper function to fetch list of jobs matching [searchTerm] from the back4app BLS occupations list endpoint */ export const fetchJobTitles = async (searchTerm) => { const where = encodeURIComponent(JSON.stringify({ "title": { "$regex": searchTerm, "$options": "i" } })); const headers = { 'X-Parse-Application-Id': import.meta.env.VITE_API_BLS_ID, 'X-Parse-REST-API-Key': import.meta.env.VITE_API_BLS_KEY }; return fetch(`https://parseapi.back4app.com/classes/Occupations_Job?limit=10&keys=title&where=${where}`, { headers: headers }) .then(response => response.json()) .then(data => { if (data.results) { // map the fetched options into the value-label form react-select uses const jobOptions = data.results.map(job => ({ value: job.title, label: job.title })); return(jobOptions) } else { console.error('No results found for jobOptions'); } }) .catch(error => console.error('Error fetching jobs:', error)); }
- Yes you can, once a user is logged in there's button to log out available on the topbar, conversly, if a user is not signed in there's a button to log in available on the non logged in top bar
<button className='signinbtn' onClick={() => navigate('/signin')}>Sign In</button> - https://github.com/CWMetaUCapstone/EconMetrics/blob/e87f96ff40c9694587a0d7d77ee2a357a1ff9b0e/frontend/src/HomePage/SignHandlers/SignIn.jsx
<button className='LogOutBtn' onClick={() => navigate(`/search/${menloPark}` , {replace: true})}>Log Out</button>
- yes, this is managed across a few components and endpoints as a user initially creates an account with email and password and is then routed to a page that allows them to continue filling out profile data and connect their account to their bank through Plaid
- https://github.com/CWMetaUCapstone/EconMetrics/blob/e87f96ff40c9694587a0d7d77ee2a357a1ff9b0e/frontend/src/HomePage/SignHandlers/SignUp.jsx
- https://github.com/CWMetaUCapstone/EconMetrics/blob/e87f96ff40c9694587a0d7d77ee2a357a1ff9b0e/frontend/src/ProfilePage/FormPages/PageOne.jsx
- https://github.com/CWMetaUCapstone/EconMetrics/blob/e87f96ff40c9694587a0d7d77ee2a357a1ff9b0e/frontend/src/ProfilePage/FormPages/PageTwo.jsx
yes my app uses a total of seven differnt routes to allows users to search if they're logged in or out and navigate the various pages of my project
EconMetrics/frontend/src/App.jsx
Lines 14 to 27 in e87f96f
<BrowserRouter> <Routes> <Route path="/" element={ <Navigate replace to={`/search/${menloPark}`}/> }/> <Route path="/profile/:userId" element={<Profile />}/> <Route path="/signup" element={<SignUp/>}/> <Route path="/signin" element={<SignIn/>}/> <Route path="/createprofile/:userId" element={<CreateAccount/>}/> <Route path="/search/:searchTerm" element={<SearchResults/>}/> <Route path="/profile/:userId/search/:searchTerm" element={<SearchResults/>}/> <Route path = "/goals/:userId" element={<Goals/>}/> </Routes> </BrowserRouter>
yes, I used two different tool tips to help give users more context on their transactions
EconMetrics/frontend/src/ProfilePage/Profile.jsx
Lines 305 to 314 in e87f96f
<h3>Your Transactions Breakdown</h3> <div className='TransactionToolTip'> <h3>How do I read my transaction breakdown?</h3> <span className='TransactionToolTipText'> Your transaction breakdown is based on your weekly expenditure (starting from Monday) and is framed in terms of percentages of how much you spent. So if you see x.xx under "resturants", that means of the money you spent last week, x.xx% of it was spending at resturants. For other two columns "Average of Similar Users" shows you the median perentage of expenditure spent on that category by user's who we think you're comparable to. Lastly the Difference column shows you your percent minus the median of similar users so you can see how exactly your habits compare to those similar to you. </span> </div> EconMetrics/frontend/src/ProfilePage/Profile.jsx
Lines 351 to 363 in e87f96f
<div className='ToolTip'> <h3>What is a box plot?</h3> {/* source: https://en.wikipedia.org/wiki/Box_plot */} <span className='ToolTipText'> A box plot is a method in descriptive statistics of displaying the spread of a set of numerical values in terms of quantiles. The height of the box itself represents the middle 50% of values, so half the cumulative value of points in the plot will be within the box's area and the upper and lower quarters of cumulative point value lay above and below the box respectively. The horizontal line within the box represents the median value for the data set. The two horizontal lines outside of the box represent are the minimum and maximum, or the threshold values past which data points are considered outliers. These lines are found using an equation that considers the intraquartile range (the height of the box) times 1.5 +/- the 75th or 25th percentiles respectively. Lastly, the two vertical lines, or "whiskers" describe the spread of the 25th and 75th percentiles </span> </div>
Your app demonstrates at least one component with complex visual styling (done by you, from scratch)
- yes, I think the chart components I made for my second TC are a glaring example, but another non TC realted component whose styling I'm very proud of is the Search bar with the animated dropdown for seeing results with an emoji pattern matched to the goal category
EconMetrics/frontend/src/HomePage/Top/Search.jsx
Lines 54 to 87 in e87f96f
<div className='searchContainer'> <div className='searchbar'> <div className='icon'> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="16" fill="#F7F9FB" className="bi bi-search" viewBox="0 0 16 16"> <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/> </svg> </div> <input type="text" value={query} placeholder='Search by City, Job Title, or Salary' className='input' onFocus={() => setShowResults(true)} onBlur={() => setTimeout(() => { setShowResults(false); setSearchResults(defaultSearch); }, 150)} onChange={(e) => setQuery(e.target.value)} /> </div> <div className={`results ${showResults ? 'show' : ''}`}> <ul> {searchResults.map((result, index) => ( <li key={index} onClick={(e) => { e.stopPropagation(); searchRouterHelper(result)}}> <div className='listContent'> {result.category === 'salary' && <p>💲</p>} {result.category === 'city' && <p>🏢</p>} {result.category === 'job' && <p>📄</p>} {result.label} </div> </li> ))} </ul> </div> EconMetrics/frontend/src/HomePage/Top/Search.css
Lines 41 to 107 in e87f96f
.results { position: absolute; top: 100%; left: 0; right: 0; background-color: #F7F9FB; border: 1px solid #ccc; z-index: 1; visibility: hidden; opacity: 0; transition: visibility 0s, opacity 0.3s linear; display: flex; align-items: center; font-family: "Inter Tight", sans-serif; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .results.show { visibility: visible; opacity: 1; animation: fadeInFromLeft 0.3s ease-out forwards; } .results ul { list-style-type: none; padding: 0; margin: 0; align-items: center; } li { padding: 8px; height: 4vh; width: 302px; border-radius: 5px; } .results li:hover { cursor: pointer; background-color: #e1e1e1; } .results li:first-child { margin-top: 2%; } .results li:last-child { margin-bottom: 2%; } .results p { font-size: 15pt; display: inline; margin-right: 6%; margin-left: 2%; } @keyframes fadeInFromLeft { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }
- yes I use loading states both after connecting account to plaid and when the large amount of data based on your profile must be fetched in the profile hub component. i did these using the
react-spinnerlibrary EconMetrics/frontend/src/ProfilePage/FormPages/PlaidLink.jsx
Lines 64 to 85 in e87f96f
const { open } = usePlaidLink(config); return ( <> {loading ? ( <div className="LoadOverlay"> <Oval height="80" width="80" color="#4fa94d" /> </div> ) : ( <div> <button className='plaid-btn' onClick={() => token && open()}> Connect Bank Account > </button> </div> )} </> ); } EconMetrics/frontend/src/ProfilePage/Profile.jsx
Lines 261 to 429 in e87f96f
return ( <> {loading ? ( <div className="LoadOverlay"> <Oval height="80" width="80" color="#4fa94d" /> </div> ) : ( <> <div className='TopBar'> <ProfileTopBar/> </div> <div className='ProfileInfo'> <div className='ProfileContent'> <h2> Profile Info </h2> <div className='ProfileSide'> <div className='ProfilePart'> <p>City: {profileData.city}, {profileData.state} </p> </div> <div className='ProfilePart'> <p>Salary Range: {profileData.salary}</p> </div> <div className='ProfilePart'> <p>Roommates: {profileData.roommates}</p> </div> </div> <div className='ProfileSide'> <div className='ProfilePart'> <p>Children: {profileData.children}</p> </div> <div className='ProfilePart'> <p>Job: {profileData.job}</p> </div> </div> <button className='EditProfileBtn' onClick={showModal}>Edit Profile</button> </div> </div> <div className='TransactionInfo'> <div className='TransactionContent'> <h2>Transactions Info</h2> <div className='TransactionsTitle'> <h3>Your Transactions Breakdown</h3> <div className='TransactionToolTip'> <h3>How do I read my transaction breakdown?</h3> <span className='TransactionToolTipText'> Your transaction breakdown is based on your weekly expenditure (starting from Monday) and is framed in terms of percentages of how much you spent. So if you see x.xx under "resturants", that means of the money you spent last week, x.xx% of it was spending at resturants. For other two columns "Average of Similar Users" shows you the median perentage of expenditure spent on that category by user's who we think you're comparable to. Lastly the Difference column shows you your percent minus the median of similar users so you can see how exactly your habits compare to those similar to you. </span> </div> </div> <div className='ag-theme-alpine' style={{ height: 400, width: 800}}> <AgGridReact rowData={rows} columnDefs={columnDefs} autoGroupColumnDef={autoGroupColumnDef} treeData={true} getDataPath={getDataPath} groupDefaultExpanded={0} /> </div> <div className='TransactionsTitle'> <h3>Visualized Data</h3> </div> <div className='piePlotTitle'> <h4>Your Expenditure Breakdown</h4> </div> <div className='piePlot'> <img src={pieSrc}/> </div> <div className='graphSelect'> <Select placeholder="Graph…" closeMenuOnSelect={true} components={animatedComponents} options={selectData} isClearable={true} className='graphSelector' isMulti onChange={handleSelectChange} value={selectedOptions} /> </div> <div className='TimeChart'> <TimeChart data={overTimeChartData} onSaveSvg={saveOverTimeSvgToLocalStorage}/> </div> <div className='ToolTip'> <h3>What is a box plot?</h3> {/* source: https://en.wikipedia.org/wiki/Box_plot */} <span className='ToolTipText'> A box plot is a method in descriptive statistics of displaying the spread of a set of numerical values in terms of quantiles. The height of the box itself represents the middle 50% of values, so half the cumulative value of points in the plot will be within the box's area and the upper and lower quarters of cumulative point value lay above and below the box respectively. The horizontal line within the box represents the median value for the data set. The two horizontal lines outside of the box represent are the minimum and maximum, or the threshold values past which data points are considered outliers. These lines are found using an equation that considers the intraquartile range (the height of the box) times 1.5 +/- the 75th or 25th percentiles respectively. Lastly, the two vertical lines, or "whiskers" describe the spread of the 25th and 75th percentiles </span> </div> <div className='Checkboxes'> <FormControlLabel control={ <Checkbox color='success' checked={yourStatsCheckbox} onChange={handleYourStatsBoxChange} /> } label="Show Your Stats" /> <FormControlLabel control={ <Checkbox color='success' checked={boxPlotCheckbox} onClick={handleBoxPlotChange} /> } label="Show Box Plots" /> </div> <div className='BoxPlot'> <CompBoxPlot userData={transactions} similarUserData={similarUsers} OnClickedUserId={setClickedUserId} onSaveSvg={saveBoxPlotSvgToLocalStorage} showBoxPlots={boxPlotCheckbox} showUserData={yourStatsCheckbox} OnBoxClick={setClickedBoxDetails}/> </div> <EditProfileModal view={showEditProfileModal} closeView={closeModal} profileData={profileData}/> { clickedUserId !== 0 ? ( <div className='ClickedDetails'> <p>City: {clickedUserDetails.city}</p> <p>Salary: {clickedUserDetails.salary}</p> <p>Job: {clickedUserDetails.job}</p> <p>Children: {clickedUserDetails.children}</p> <p>Roomamtes: {clickedUserDetails.roommates}</p> </div> ) : ( <div></div> ) } { Object.keys(clickedBoxDetails).length > 0 ? ( <div className='ClickedDetails'> <p>25th Percentile: {clickedBoxDetails.quartiles[0].toFixed(2)} </p> <p>Median: {clickedBoxDetails.quartiles[1].toFixed(2)}</p> <p>75th Percentile: {clickedBoxDetails.quartiles[2].toFixed(2)}</p> <p>Upper Outlier Range: {clickedBoxDetails.range[1].toFixed(2)}</p> <p>Lower Outlier Range: {clickedBoxDetails.range[0].toFixed(2)}</p> </div> ) : ( <div></div> ) } </div> </div> </> )} </> ) }
https://docs.google.com/document/d/1x0Bo--S4QJQHxFEerv1K9MrlYiCxGJDZhKDZ9Rg-dz0/edit?usp=sharing