├── travis.yml
├── Procfile
├── demo1.gif
├── src
├── index.css
├── elements
│ ├── footer
│ │ ├── styled.js
│ │ └── index.js
│ ├── navbar
│ │ ├── index.js
│ │ └── styled.js
│ ├── App.js
│ ├── line-chart
│ │ ├── x-axis-tick.js
│ │ └── index.js
│ ├── NotFound.js
│ └── styled
│ │ └── index.js
├── reducers
│ ├── index.js
│ └── calculation-data.js
├── components
│ ├── chart
│ │ ├── styled.js
│ │ └── index.js
│ ├── age-form
│ │ ├── __tests__
│ │ │ ├── index.js
│ │ │ └── __snapshots__
│ │ │ │ └── index.js.snap
│ │ ├── styled.js
│ │ └── index.js
│ └── calculator-form
│ │ ├── styled.js
│ │ └── index.js
├── store
│ └── index.js
├── containers
│ └── calculator-container
│ │ ├── styled.js
│ │ └── index.js
├── assets
│ ├── continue.svg
│ └── reset.svg
├── utils
│ └── formatMoney.js
├── index.js
└── registerServiceWorker.js
├── .prettierignore
├── public
├── img
│ └── linkedin.png
├── manifest.json
└── index.html
├── .prettierrc
├── .gitignore
├── package.json
├── server
└── index.js
├── LICENSE
└── README.md
/travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: node ./server/index.js
2 |
--------------------------------------------------------------------------------
/demo1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabrielwr/React-Retirement-Calculator/HEAD/demo1.gif
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: Roboto;
5 | }
6 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | package.json
2 | package-lock.json
3 | node_modules/
4 | src/assets/
5 | public/
--------------------------------------------------------------------------------
/public/img/linkedin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabrielwr/React-Retirement-Calculator/HEAD/public/img/linkedin.png
--------------------------------------------------------------------------------
/src/elements/footer/styled.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Footer = styled.div`
4 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.5);
5 | `;
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": false,
7 | "trailingComma": "none",
8 | "jsxBracketSameLine": true
9 | }
10 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux";
2 |
3 | import calculationData from "./calculation-data";
4 |
5 | const rootReducer = combineReducers({
6 | calculationData
7 | });
8 |
9 | export default rootReducer;
10 |
--------------------------------------------------------------------------------
/src/components/chart/styled.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const ChartWrapper = styled.div`
4 | display: flex;
5 | justify-content: center;
6 | align-items: center;
7 | height: 40vh;
8 | width: 100%;
9 | `;
10 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from "redux";
2 | import { createLogger } from "redux-logger";
3 |
4 | import rootReducer from "../reducers/";
5 |
6 | const store = createStore(rootReducer, applyMiddleware(createLogger()));
7 |
8 | export default store;
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 |
4 | # testing
5 | /coverage
6 |
7 | # production
8 | /build
9 |
10 | # misc
11 | .DS_Store
12 | .env.local
13 | .env.development.local
14 | .env.test.local
15 | .env.production.local
16 |
17 | npm-debug.log*
18 | yarn-debug.log*
19 | yarn-error.log*
20 |
--------------------------------------------------------------------------------
/src/components/age-form/__tests__/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { AgeForm } from "../";
3 | import renderer from "react-test-renderer";
4 |
5 | it("renders correctly", () => {
6 | const tree = renderer
7 | .create(
{pathname}
11 | The router gave me these props:
13 |{JSON.stringify(props, null, 2)}
14 | 15 | Lost? Here's a way home. 16 |
17 | ~ xoxo. 18 |More from me!
11 | 12 | 16 | 17 | 18 | 22 | 23 | 24 | 28 | 29 | 30 | }> 31 |Thanks so much for coming to check this out.
33 |34 | It's a fun little side project that is still very much a work in 35 | progress! 36 |
37 |
13 |
14 | ## Table of Contents
15 |
16 | * [Background](#background)
17 | * [Install](#install)
18 | * [License](#license)
19 |
20 |
21 | ## Background
22 |
23 | React Retirement Calculator is a fun side project that I worked on while working at Fullstack Academy as a teaching fellow and software engineer.
24 |
25 | I originally conceived of the idea for this calculator after a chapter of learning about personal finance. During that time, I had a favorite retirement calculator that just disappeared from the internet one day. So I figured I would try to recreate it and even improve upon it (since it was probably made in the early 2000's...)
26 |
27 | This project is an updated version of a calculator I originally made in vanilla JS, HTML, and CSS.
28 |
29 | The basic premise of this calculator is to show the power of compounding interest over time. This calculator has been a great asset for me when I need a little help with remembering to save money ;).
30 |
31 | ## Install
32 |
33 | To install and locally run RRC clone or download the project to your machine and run the following commands:
34 |
35 | Install all project dependencies:
36 | ```bash
37 | $ npm install
38 | ```
39 | To start the server and app:
40 | ```bash
41 | $ npm start
42 | ```
43 | ## License
44 | MIT (c) Gabriel Rowe
45 |
46 |
--------------------------------------------------------------------------------
/src/reducers/calculation-data.js:
--------------------------------------------------------------------------------
1 | import { assoc } from "ramda";
2 |
3 | /*
4 | # (reducer) calculation-data
5 |
6 | The `calculation-data` reducer describes user-specified data needed for graph calculation
7 |
8 | */
9 | export const actionTypes = {
10 | SET_FINAL_SAVINGS: "CALCULATION_DATA_SET_FINAL_SAVINGS",
11 | SET_GRAPH_DATA: "CALCULATION_DATA_SET_GRAPH_DATA",
12 | SET_INVESTMENT_RETURN_RATE: "CALCULATION_DATA_SET_INVESTMENT_RETURN_RATE",
13 | SET_LIFE_EXPECTANCY: "CALCULATION_DATA_SET_LIFE_EXPECTANCY",
14 | SET_RETIRE_AGE: "CALCULATION_DATA_SET_RETIRE_AGE",
15 | SET_RETIRE_AMT: "CALCULATION_DATA_SET_RETIRE_AMT",
16 | SET_RETIRE_SPENDING: "CALCULATION_DATA_SET_RETIRE_SPENDING",
17 | SET_SALARY: "CALCULATION_DATA_SET_SALARY",
18 | SET_SALARY_INCREASE: "CALCULATION_DATA_SET_SALARY_INCREASE",
19 | SET_SAVINGS_RATE: "CALCULATION_DATA_SET_SAVINGS_RATE",
20 | SET_STARTING_AGE: "CALCULATION_DATA_SET_STARTING_AGE",
21 | SET_STARTING_SAVINGS: "CALCULATION_DATA_SET_STARTING_SAVINGS"
22 | };
23 |
24 | export const actions = {
25 | setFinalSavings(savings) {
26 | return { type: actionTypes.SET_FINAL_SAVINGS, savings };
27 | },
28 | setGraphData(graphData) {
29 | return { type: actionTypes.SET_GRAPH_DATA, graphData };
30 | },
31 | setInvestmentReturnRate(returnRate) {
32 | return { type: actionTypes.SET_INVESTMENT_RETURN_RATE, returnRate };
33 | },
34 | setLifeExpectancy(lifeExpectancy) {
35 | return { type: actionTypes.SET_LIFE_EXPECTANCY, lifeExpectancy };
36 | },
37 | setRetireAge(retireAge) {
38 | return { type: actionTypes.SET_RETIRE_AGE, retireAge };
39 | },
40 | setRetireAmt(retireAmt) {
41 | return { type: actionTypes.SET_RETIRE_AMT, retireAmt };
42 | },
43 | setRetireSpending(retireSpending) {
44 | return { type: actionTypes.SET_RETIRE_SPENDING, retireSpending };
45 | },
46 | setSalary(salary) {
47 | return { type: actionTypes.SET_SALARY, salary };
48 | },
49 | setSalaryIncrease(salaryIncrease) {
50 | return { type: actionTypes.SET_SALARY, salaryIncrease };
51 | },
52 | setSavingsRate(savingsRate) {
53 | return { type: actionTypes.SET_SAVINGS_RATE, savingsRate };
54 | },
55 | setStartingAge(age) {
56 | return { type: actionTypes.SET_STARTING_AGE, age };
57 | },
58 | setStartingSavings(savings) {
59 | return { type: actionTypes.SET_STARTING_SAVINGS, savings };
60 | }
61 | };
62 |
63 | export const INITIAL_STATE = {
64 | finalSavings: 0,
65 | graphData: [],
66 | investmentReturnRate: 4,
67 | lifeExpectancy: 90,
68 | retireAge: 65,
69 | retireAmt: 0,
70 | retireSpending: 40000,
71 | salary: 50000,
72 | salaryIncrease: 3,
73 | savingsRate: 10,
74 | startingAge: 26,
75 | startingSavings: 0
76 | };
77 |
78 | export default (_state = INITIAL_STATE, action) => {
79 | let state = Object.assign({}, _state);
80 |
81 | switch (action.type) {
82 | case actionTypes.SET_FINAL_SAVINGS:
83 | state = assoc("finalSavings", action.savings)(state);
84 | break;
85 |
86 | case actionTypes.SET_GRAPH_DATA:
87 | state = assoc("graphData", action.graphData)(state);
88 | break;
89 |
90 | case actionTypes.SET_INVESTMENT_RETURN_RATE:
91 | state = assoc("investmentReturnRate", action.returnRate)(state);
92 | break;
93 |
94 | case actionTypes.SET_LIFE_EXPECTANCY:
95 | state = assoc("lifeExpectancy", action.lifeExpectancy)(state);
96 | break;
97 |
98 | case actionTypes.SET_RETIRE_AGE:
99 | state = assoc("retireAge", action.retireAge)(state);
100 | break;
101 |
102 | case actionTypes.SET_RETIRE_AMT:
103 | state = assoc("retireAmt", action.retireAmt)(state);
104 | break;
105 |
106 | case actionTypes.SET_RETIRE_SPENDING:
107 | state = assoc("retireSpending", action.retireSpending)(state);
108 | break;
109 |
110 | case actionTypes.SET_SALARY:
111 | state = assoc("salary", action.salary)(state);
112 | break;
113 |
114 | case actionTypes.SET_SALARY_INCREASE:
115 | state = assoc("salaryIncrease", action.salaryIncrease)(state);
116 | break;
117 |
118 | case actionTypes.SET_SAVINGS_RATE:
119 | state = assoc("savingsRate", action.savingsRate)(state);
120 | break;
121 |
122 | case actionTypes.SET_STARTING_AGE:
123 | state = assoc("startingAge", action.age)(state);
124 | break;
125 |
126 | case actionTypes.SET_STARTING_SAVINGS:
127 | state = assoc("startingSavings", action.savings)(state);
128 | break;
129 |
130 | default:
131 | return state;
132 | }
133 |
134 | return state;
135 | };
136 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === "localhost" ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === "[::1]" ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener("load", () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl);
38 |
39 | // Add some additional logging to localhost, pointing developers to the
40 | // service worker/PWA documentation.
41 | navigator.serviceWorker.ready.then(() => {
42 | console.log(
43 | "This web app is being served cache-first by a service " +
44 | "worker. To learn more, visit https://goo.gl/SC7cgQ"
45 | );
46 | });
47 | } else {
48 | // Is not local host. Just register service worker
49 | registerValidSW(swUrl);
50 | }
51 | });
52 | }
53 | }
54 |
55 | function registerValidSW(swUrl) {
56 | navigator.serviceWorker
57 | .register(swUrl)
58 | .then((registration) => {
59 | registration.onupdatefound = () => {
60 | const installingWorker = registration.installing;
61 | installingWorker.onstatechange = () => {
62 | if (installingWorker.state === "installed") {
63 | if (navigator.serviceWorker.controller) {
64 | // At this point, the old content will have been purged and
65 | // the fresh content will have been added to the cache.
66 | // It's the perfect time to display a "New content is
67 | // available; please refresh." message in your web app.
68 | console.log("New content is available; please refresh.");
69 | } else {
70 | // At this point, everything has been precached.
71 | // It's the perfect time to display a
72 | // "Content is cached for offline use." message.
73 | console.log("Content is cached for offline use.");
74 | }
75 | }
76 | };
77 | };
78 | })
79 | .catch((error) => {
80 | console.error("Error during service worker registration:", error);
81 | });
82 | }
83 |
84 | function checkValidServiceWorker(swUrl) {
85 | // Check if the service worker can be found. If it can't reload the page.
86 | fetch(swUrl)
87 | .then((response) => {
88 | // Ensure service worker exists, and that we really are getting a JS file.
89 | if (
90 | response.status === 404 ||
91 | response.headers.get("content-type").indexOf("javascript") === -1
92 | ) {
93 | // No service worker found. Probably a different app. Reload the page.
94 | navigator.serviceWorker.ready.then((registration) => {
95 | registration.unregister().then(() => {
96 | window.location.reload();
97 | });
98 | });
99 | } else {
100 | // Service worker found. Proceed as normal.
101 | registerValidSW(swUrl);
102 | }
103 | })
104 | .catch(() => {
105 | console.log(
106 | "No internet connection found. App is running in offline mode."
107 | );
108 | });
109 | }
110 |
111 | export function unregister() {
112 | if ("serviceWorker" in navigator) {
113 | navigator.serviceWorker.ready.then((registration) => {
114 | registration.unregister();
115 | });
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/containers/calculator-container/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { connect } from "react-redux";
3 | import * as R from "ramda";
4 |
5 | import { actions as calculationDataActions } from "../../reducers/calculation-data";
6 |
7 | import CalculatorForm from "../../components/calculator-form";
8 | import Chart from "../../components/chart";
9 | import AgeForm from "../../components/age-form";
10 |
11 | import { CalculatorContainerWrapper, ContentWrapper } from "./styled";
12 |
13 | import { IconButton, IconImage } from "../../elements/styled";
14 |
15 | import ContinueIcon from "../../assets/continue.svg";
16 |
17 | const {
18 | setFinalSavings,
19 | setGraphData,
20 | setInvestmentReturnRate,
21 | setLifeExpectancy,
22 | setRetireAge,
23 | setRetireAmt,
24 | setRetireSpending,
25 | setSalary,
26 | setSalaryIncrease,
27 | setSavingsRate,
28 | setStartingAge,
29 | setStartingSavings
30 | } = calculationDataActions;
31 |
32 | class CalculatorContainer extends Component {
33 | componentWillMount() {
34 | this.computeData();
35 | }
36 |
37 | computeData() {
38 | const {
39 | startingAge,
40 | salary,
41 | salaryIncrease,
42 | retireAge,
43 | savingsRate,
44 | lifeExpectancy,
45 | startingSavings,
46 | investmentReturnRate,
47 | retireSpending
48 | } = this.props;
49 |
50 | const graphData = [];
51 | const yearsToRetirement = retireAge - startingAge;
52 | const totalYears = lifeExpectancy - startingAge;
53 | let salarySaved = Math.floor((salary / 100) * savingsRate);
54 | let currentAge = startingAge;
55 | let accumulatedSavings = startingSavings;
56 | let isRetired = yearsToRetirement > 0 ? false : true;
57 |
58 | for (let currentYear = 0; currentYear <= totalYears; currentYear++) {
59 | graphData.push({
60 | savings: accumulatedSavings,
61 | age: currentAge
62 | });
63 |
64 | accumulatedSavings += Math.floor(
65 | (accumulatedSavings / 100) * investmentReturnRate
66 | );
67 | if (currentYear === yearsToRetirement) {
68 | this.props.setRetireAmt(accumulatedSavings);
69 | }
70 | if (isRetired) {
71 | accumulatedSavings -= retireSpending;
72 | } else {
73 | salarySaved += Math.floor((salarySaved / 100) * salaryIncrease);
74 | accumulatedSavings += salarySaved;
75 | }
76 |
77 | currentAge++;
78 | }
79 |
80 | this.props.setFinalSavings(accumulatedSavings);
81 | this.props.setGraphData(graphData);
82 | }
83 |
84 | handleStartingAge = (_evt, age) => {
85 | const {
86 | retireAge,
87 | setLifeExpectancy,
88 | lifeExpectancy,
89 | setStartingAge,
90 | setRetireAge
91 | } = this.props;
92 |
93 | if (age >= retireAge) {
94 | setRetireAge(age + 1);
95 | }
96 | if (age >= lifeExpectancy) {
97 | setLifeExpectancy(age + 1);
98 | }
99 | setStartingAge(age);
100 | this.computeData();
101 | };
102 |
103 | handleRetirementAge = (_evt, retireAge) => {
104 | const {
105 | setStartingAge,
106 | setRetireAge,
107 | startingAge,
108 | lifeExpectancy
109 | } = this.props;
110 | if (retireAge <= startingAge) {
111 | setStartingAge(retireAge - 1);
112 | } else if (retireAge >= lifeExpectancy) {
113 | setRetireAge(lifeExpectancy - 1);
114 | }
115 | setRetireAge(retireAge);
116 | this.computeData();
117 | };
118 |
119 | changeHandler = (keyName) => {
120 | return (_evt, updatedValue) => {
121 | this.props[`set${keyName}`](`${updatedValue}`);
122 | this.computeData();
123 | };
124 | };
125 |
126 | render() {
127 | const {
128 | finalSavings,
129 | investmentReturnRate,
130 | retireAge,
131 | retireAmt,
132 | retireSpending,
133 | salary,
134 | salaryIncrease,
135 | savingsRate,
136 | startingSavings,
137 | startingAge
138 | } = this.props;
139 |
140 | const formProps = {
141 | finalSavings,
142 | investmentReturnRate,
143 | retireAge,
144 | retireAmt,
145 | retireSpending,
146 | salary,
147 | salaryIncrease,
148 | savingsRate,
149 | startingSavings,
150 | startingAge,
151 | handleStartingAge: this.handleStartingAge,
152 | handleRetirementAge: this.handleRetirementAge
153 | };
154 |
155 | return (
156 |