├── CNAME
├── src
├── css
│ └── tailwind.src.css
├── constants
│ ├── routes.js
│ ├── misc.js
│ ├── sample.js
│ └── options.js
├── setupTests.js
├── components
│ ├── Firebase
│ │ ├── index.js
│ │ ├── context.js
│ │ └── firebase.js
│ ├── Label.js
│ ├── Inputs
│ │ ├── Input.js
│ │ ├── Text.js
│ │ ├── Button.js
│ │ ├── Currency.js
│ │ ├── Slider.js
│ │ ├── Radio.js
│ │ └── Select.js
│ ├── App.js
│ ├── GlobalStyle.js
│ ├── TransitionGroup.js
│ ├── withLeaveWarning.js
│ ├── Chart.js
│ ├── withAuthentication.js
│ ├── Footer.js
│ ├── Split.js
│ ├── Header.js
│ ├── Save.js
│ ├── Report
│ │ ├── index.js
│ │ └── Row.js
│ ├── Incomes.js
│ ├── Home.js
│ ├── SignIn.js
│ └── Expenses.js
├── reducers
│ ├── index.js
│ ├── incomes.js
│ ├── split.js
│ ├── expenses.js
│ └── session.js
├── actions
│ ├── incomes.js
│ ├── split.js
│ ├── session.js
│ ├── expenses.js
│ ├── shared.js
│ └── shared.test.js
├── store.js
├── middleware
│ └── session.js
├── tailwind.js
├── index.js
├── images
│ ├── mark.svg
│ └── logo.svg
├── helpers.js
└── serviceWorker.js
├── .firebaserc
├── public
├── robots.txt
├── favicon.ico
├── favicon-16x16.png
├── favicon-32x32.png
├── mstile-150x150.png
├── apple-touch-icon.png
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── browserconfig.xml
├── site.webmanifest
├── index.html
└── safari-pinned-tab.svg
├── babel-plugin-macros.config.js
├── database.rules.json
├── firebase.json
├── .gitignore
├── README.md
├── LICENSE
├── package.json
└── TERMS.md
/CNAME:
--------------------------------------------------------------------------------
1 | pages.budgetduo.com
--------------------------------------------------------------------------------
/src/css/tailwind.src.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 |
--------------------------------------------------------------------------------
/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": "budget-duo"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hursey013/budget-duo/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/src/constants/routes.js:
--------------------------------------------------------------------------------
1 | export const HOME = '/';
2 | export const SIGN_IN = '/signin';
3 |
--------------------------------------------------------------------------------
/src/constants/misc.js:
--------------------------------------------------------------------------------
1 | export const MAX_INCOME = 250000;
2 | export const DEFAULT_SPLIT = 'income';
3 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hursey013/budget-duo/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hursey013/budget-duo/HEAD/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hursey013/budget-duo/HEAD/public/mstile-150x150.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hursey013/budget-duo/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hursey013/budget-duo/HEAD/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hursey013/budget-duo/HEAD/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | import { configure } from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 |
4 | configure({ adapter: new Adapter() });
5 |
--------------------------------------------------------------------------------
/babel-plugin-macros.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | tailwind: {
3 | config: './src/tailwind.js',
4 | styled: 'styled-components/macro'
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/database.rules.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "users": {
4 | "$uid": {
5 | ".read": "$uid === auth.uid",
6 | ".write": "$uid === auth.uid"
7 | }
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/Firebase/index.js:
--------------------------------------------------------------------------------
1 | import FirebaseContext, { withFirebase } from './context';
2 | import Firebase from './firebase';
3 |
4 | export default Firebase;
5 | export { FirebaseContext, withFirebase };
6 |
--------------------------------------------------------------------------------
/src/components/Label.js:
--------------------------------------------------------------------------------
1 | import tw from 'tailwind.macro';
2 | import styled from 'styled-components/macro';
3 |
4 | const Label = styled.div`
5 | ${tw`text-teal uppercase text-xs font-bold`}
6 | `;
7 |
8 | export default Label;
9 |
--------------------------------------------------------------------------------
/src/components/Inputs/Input.js:
--------------------------------------------------------------------------------
1 | import tw from 'tailwind.macro';
2 | import styled from 'styled-components/macro';
3 |
4 | const Input = styled.input`
5 | ${tw`w-full bg-white shadow rounded leading-tight appearance-none p-3`}
6 | `;
7 |
8 | export default Input;
9 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import split from './split';
3 | import incomes from './incomes';
4 | import expenses from './expenses';
5 | import session from './session';
6 |
7 | export default combineReducers({ incomes, expenses, split, session });
8 |
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #00aba9
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/actions/incomes.js:
--------------------------------------------------------------------------------
1 | export const RECEIVE_INCOMES = 'RECEIVE_INCOMES';
2 | export const UPDATE_INCOME = 'UPDATE_INCOME';
3 |
4 | export const receiveIncomes = (incomes = {}) => ({
5 | type: RECEIVE_INCOMES,
6 | incomes
7 | });
8 |
9 | export const updateIncome = (key, value = 0) => ({
10 | type: UPDATE_INCOME,
11 | payload: { key, value }
12 | });
13 |
--------------------------------------------------------------------------------
/src/components/Inputs/Text.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import Input from './Input';
5 |
6 | const Text = props => ;
7 |
8 | Text.propTypes = {
9 | name: PropTypes.string,
10 | onChange: PropTypes.func.isRequired,
11 | value: PropTypes.string
12 | };
13 |
14 | export default Text;
15 |
--------------------------------------------------------------------------------
/src/components/Firebase/context.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const FirebaseContext = React.createContext(null);
4 |
5 | export const withFirebase = Component => props => (
6 |
7 | {firebase => }
8 |
9 | );
10 |
11 | export default FirebaseContext;
12 |
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "hosting": {
3 | "public": "build",
4 | "ignore": [
5 | "firebase.json",
6 | "**/.*",
7 | "**/node_modules/**"
8 | ],
9 | "rewrites": [
10 | {
11 | "source": "**",
12 | "destination": "/index.html"
13 | }
14 | ]
15 | },
16 | "database": {
17 | "rules": "database.rules.json"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/actions/split.js:
--------------------------------------------------------------------------------
1 | export const RECEIVE_SPLIT = 'RECEIVE_SPLIT';
2 | export const UPDATE_SPLIT = 'UPDATE_SPLIT';
3 | export const RESET_SPLIT = 'RESET_SPLIT';
4 |
5 | export const receiveSplit = split => ({
6 | type: RECEIVE_SPLIT,
7 | split
8 | });
9 |
10 | export const updateSplit = split => ({
11 | type: UPDATE_SPLIT,
12 | split
13 | });
14 |
15 | export const resetSplit = () => ({
16 | type: RESET_SPLIT
17 | });
18 |
--------------------------------------------------------------------------------
/src/constants/sample.js:
--------------------------------------------------------------------------------
1 | export const SAMPLE = {
2 | expenses: [
3 | { name: 'Electricity', value: 100 },
4 | { name: 'Internet', value: 45 },
5 | { name: 'Gym', value: 65 },
6 | { name: 'Netflix', value: 10.99 },
7 | { name: 'Rent', value: 1200 },
8 | { name: 'Savings', value: 200 }
9 | ],
10 | incomes: [
11 | { name: 'You', value: 35000 },
12 | { name: 'Partner', value: 65000 }
13 | ]
14 | };
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | .env
27 | .firebase
28 | /src/tailwind.css
29 |
--------------------------------------------------------------------------------
/src/reducers/incomes.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_INCOMES, UPDATE_INCOME } from '../actions/incomes';
2 |
3 | export default function incomes(state = {}, action) {
4 | switch (action.type) {
5 | case RECEIVE_INCOMES:
6 | return action.incomes;
7 | case UPDATE_INCOME:
8 | return {
9 | ...state,
10 | [action.payload.key]: {
11 | ...state[action.payload.key],
12 | value: action.payload.value
13 | }
14 | };
15 | default:
16 | return state;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/reducers/split.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_SPLIT, UPDATE_SPLIT, RESET_SPLIT } from '../actions/split';
2 | import { DEFAULT_SPLIT } from '../constants/misc';
3 |
4 | const initialState = DEFAULT_SPLIT;
5 |
6 | export default function split(state = initialState, action) {
7 | switch (action.type) {
8 | case RECEIVE_SPLIT:
9 | return action.split;
10 | case UPDATE_SPLIT:
11 | return action.split;
12 | case RESET_SPLIT:
13 | return initialState;
14 | default:
15 | return state;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "BudgetDuo",
3 | "short_name": "BudgetDuo",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
3 |
4 | import * as ROUTES from '../constants/routes';
5 | import Home from './Home';
6 | import SignIn from './SignIn';
7 |
8 | const App = () => (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 |
18 | export default App;
19 |
--------------------------------------------------------------------------------
/src/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux';
2 | import thunk from 'redux-thunk';
3 |
4 | import rootReducer from './reducers';
5 | import { session } from './middleware/session';
6 |
7 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
8 | const middleware = [thunk, session];
9 |
10 | export default function configureStore(preloadedState) {
11 | return createStore(
12 | rootReducer,
13 | preloadedState,
14 | composeEnhancers(applyMiddleware(...middleware))
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/GlobalStyle.js:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components';
2 | import tw from 'tailwind.macro';
3 |
4 | const GlobalStyle = createGlobalStyle`
5 | body {
6 | background: ${props => props.theme.colors.teal.dark};
7 | background: linear-gradient(
8 | 145deg,
9 | ${props => props.theme.colors.teal.dark} 0%,
10 | ${props => props.theme.colors.blue} 100%
11 | );
12 |
13 | ${tw`bg-fixed bg-no-repeat font-sans font-normal leading-normal text-grey h-full`}
14 | }
15 | `;
16 |
17 | export default GlobalStyle;
18 |
--------------------------------------------------------------------------------
/src/middleware/session.js:
--------------------------------------------------------------------------------
1 | import { UPDATE_SPLIT } from '../actions/split';
2 | import { UPDATE_INCOME } from '../actions/incomes';
3 | import { UPDATE_EXPENSE, DELETE_EXPENSE } from '../actions/expenses';
4 | import { isDirty } from '../actions/session';
5 |
6 | export const session = ({ dispatch, getState }) => next => action => {
7 | const actions = [UPDATE_SPLIT, UPDATE_INCOME, UPDATE_EXPENSE, DELETE_EXPENSE];
8 |
9 | if (actions.includes(action.type) && !getState().session.isDirty) {
10 | dispatch(isDirty(true));
11 | }
12 |
13 | return next(action);
14 | };
15 |
--------------------------------------------------------------------------------
/src/tailwind.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | theme: {
3 | colors: {
4 | black: '#000000',
5 | blue: '#041d59',
6 | grey: {
7 | light: '#f1f5f8',
8 | default: '#6e7d95'
9 | },
10 | teal: {
11 | light: '#00ebcb',
12 | default: '#2ad9c2',
13 | dark: '#00a1a7'
14 | },
15 | transparent: 'transparent',
16 | white: '#ffffff'
17 | },
18 | extend: {},
19 | container: {
20 | center: true,
21 | padding: '1rem'
22 | }
23 | },
24 | variants: {},
25 | plugins: []
26 | };
27 |
--------------------------------------------------------------------------------
/src/actions/session.js:
--------------------------------------------------------------------------------
1 | export const AUTH_USER_SET = 'AUTH_USER_SET';
2 | export const IS_DIRTY = 'IS_DIRTY';
3 | export const IS_REDIRECT = 'IS_REDIRECT';
4 | export const IS_SAVING = 'IS_SAVING';
5 |
6 | export const authUserSet = authUser => ({
7 | type: AUTH_USER_SET,
8 | authUser
9 | });
10 |
11 | export const isDirty = isDirty => ({
12 | type: IS_DIRTY,
13 | isDirty
14 | });
15 |
16 | export const isRedirect = isRedirect => ({
17 | type: IS_REDIRECT,
18 | isRedirect
19 | });
20 |
21 | export const isSaving = isSaving => ({
22 | type: IS_SAVING,
23 | isSaving
24 | });
25 |
--------------------------------------------------------------------------------
/src/components/Inputs/Button.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import tw from 'tailwind.macro';
3 | import styled from 'styled-components/macro';
4 |
5 | const Button = styled.button`
6 | ${tw`rounded py-2 px-4 bg-teal no-underline text-blue border-2 border-transparent hover:bg-teal-light`}
7 |
8 | ${props =>
9 | props.outline &&
10 | tw`text-teal border-2 border-teal bg-transparent flex items-center hover:border-teal-light hover:text-teal-light font-bold hover:bg-transparent`}
11 | `;
12 |
13 | Button.propTypes = {
14 | outline: PropTypes.bool
15 | };
16 |
17 | export default Button;
18 |
--------------------------------------------------------------------------------
/src/actions/expenses.js:
--------------------------------------------------------------------------------
1 | export const RECEIVE_EXPENSES = 'RECEIVE_EXPENSES';
2 | export const ADD_EXPENSE = 'ADD_EXPENSE';
3 | export const UPDATE_EXPENSE = 'UPDATE_EXPENSE';
4 | export const DELETE_EXPENSE = 'DELETE_EXPENSE';
5 |
6 | export const receiveExpenses = (expenses = {}) => ({
7 | type: RECEIVE_EXPENSES,
8 | expenses
9 | });
10 |
11 | export const addExpense = ({ key, name = '', payer }) => ({
12 | type: ADD_EXPENSE,
13 | payload: { key, name, payer }
14 | });
15 |
16 | export const updateExpense = (key, value) => ({
17 | type: UPDATE_EXPENSE,
18 | payload: { key, value }
19 | });
20 |
21 | export const deleteExpense = key => ({
22 | type: DELETE_EXPENSE,
23 | key
24 | });
25 |
--------------------------------------------------------------------------------
/src/reducers/expenses.js:
--------------------------------------------------------------------------------
1 | import {
2 | RECEIVE_EXPENSES,
3 | ADD_EXPENSE,
4 | UPDATE_EXPENSE,
5 | DELETE_EXPENSE
6 | } from '../actions/expenses';
7 |
8 | export default function expenses(state = {}, action) {
9 | switch (action.type) {
10 | case RECEIVE_EXPENSES:
11 | return action.expenses;
12 | case ADD_EXPENSE:
13 | const { key, ...rest } = action.payload;
14 | return {
15 | ...state,
16 | [key]: rest
17 | };
18 | case UPDATE_EXPENSE:
19 | return {
20 | ...state,
21 | [action.payload.key]: {
22 | ...state[action.payload.key],
23 | ...action.payload.value
24 | }
25 | };
26 | case DELETE_EXPENSE:
27 | const { [action.key]: _, ...result } = state;
28 | return result;
29 | default:
30 | return state;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/reducers/session.js:
--------------------------------------------------------------------------------
1 | import {
2 | AUTH_USER_SET,
3 | IS_REDIRECT,
4 | IS_DIRTY,
5 | IS_SAVING
6 | } from '../actions/session';
7 |
8 | const initialState = {
9 | isRedirect: false,
10 | isDirty: false,
11 | isSaving: false
12 | };
13 |
14 | export default function session(state = initialState, action) {
15 | switch (action.type) {
16 | case AUTH_USER_SET:
17 | return {
18 | ...state,
19 | authUser: action.authUser
20 | };
21 | case IS_REDIRECT:
22 | return {
23 | ...state,
24 | isRedirect: action.isRedirect
25 | };
26 | case IS_DIRTY:
27 | return {
28 | ...state,
29 | isDirty: action.isDirty
30 | };
31 | case IS_SAVING:
32 | return {
33 | ...state,
34 | isSaving: action.isSaving
35 | };
36 | default:
37 | return state;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { ThemeProvider } from 'styled-components';
4 | import { theme } from './tailwind.js';
5 | import { Provider } from 'react-redux';
6 |
7 | import './tailwind.css';
8 | import configureStore from './store';
9 | import * as serviceWorker from './serviceWorker';
10 | import Firebase, { FirebaseContext } from './components/Firebase';
11 | import GlobalStyle from './components/GlobalStyle';
12 | import App from './components/App';
13 |
14 | const rootElement = document.getElementById('root');
15 |
16 | ReactDOM.render(
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | ,
25 | rootElement
26 | );
27 |
28 | serviceWorker.unregister();
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # budget-duo
2 |
3 | Calculate how much you and your partner should contribute towards shared expenses.
4 |
5 | To run locally, clone the repository and run:
6 |
7 | ```
8 | npm install
9 | ```
10 |
11 | Once all of the dependencies are installed, you can start the development server with:
12 |
13 | ```
14 | npm start
15 | ```
16 |
17 | To run any included tests, use:
18 |
19 | ```
20 | npm test
21 | ```
22 |
23 | This project uses Google Firebase's [Realtime Database](https://firebase.google.com/products/realtime-database/) and [Authentication](https://firebase.google.com/products/auth/) so you'll need to provide a `.env` file with the following variables:
24 |
25 | ```
26 | REACT_APP_API_KEY=XXXXxxxx
27 | REACT_APP_AUTH_DOMAIN=xxxxXXXX.firebaseapp.com
28 | REACT_APP_DATABASE_URL=https://xxxXXXX.firebaseio.com
29 | REACT_APP_PROJECT_ID=xxxxXXXX
30 | REACT_APP_STORAGE_BUCKET=xxxxXXXX.appspot.com
31 | REACT_APP_MESSAGING_SENDER_ID=xxxxXXXX
32 | ```
33 |
--------------------------------------------------------------------------------
/src/components/TransitionGroup.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components/macro';
4 | import { CSSTransitionGroup } from 'react-transition-group';
5 |
6 | const Group = styled(CSSTransitionGroup)`
7 | .enter {
8 | opacity: 0.01;
9 | }
10 |
11 | .enter.enter-active {
12 | opacity: 1;
13 | transition: opacity 500ms ease-in;
14 | }
15 |
16 | .leave {
17 | opacity: 1;
18 | }
19 |
20 | .leave.leave-active {
21 | opacity: 0.01;
22 | transition: opacity 300ms ease-in;
23 | }
24 | `;
25 |
26 | const TransitionGroup = ({ children }) => {
27 | return (
28 |
37 | {children}
38 |
39 | );
40 | };
41 |
42 | TransitionGroup.propTypes = {
43 | children: PropTypes.node
44 | };
45 |
46 | export default TransitionGroup;
47 |
--------------------------------------------------------------------------------
/src/components/Inputs/Currency.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import 'styled-components/macro';
5 | import NumberFormat from 'react-number-format';
6 |
7 | import Input from './Input';
8 |
9 | const Currency = ({ onValueChange, value }) => {
10 | return (
11 |
12 |
15 | $
16 |
17 |
28 |
29 | );
30 | };
31 |
32 | Currency.propTypes = {
33 | onValueChange: PropTypes.func.isRequired,
34 | value: PropTypes.number
35 | };
36 |
37 | export default Currency;
38 |
--------------------------------------------------------------------------------
/src/components/withLeaveWarning.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 |
5 | function withLeaveWarning(Component) {
6 | class WithLeaveWarning extends React.Component {
7 | componentDidMount() {
8 | window.addEventListener('beforeunload', this.beforeunload);
9 | }
10 |
11 | componentWillUnmount() {
12 | window.removeEventListener('beforeunload', this.beforeunload);
13 | }
14 |
15 | beforeunload = e => {
16 | const { isDirty } = this.props;
17 |
18 | if (isDirty) {
19 | e.preventDefault();
20 | e.returnValue = true;
21 | }
22 | };
23 |
24 | render() {
25 | return ;
26 | }
27 | }
28 |
29 | WithLeaveWarning.propTypes = {
30 | isDirty: PropTypes.bool.isRequired
31 | };
32 |
33 | const mapStateToProps = ({ session }) => ({
34 | isDirty: session.isDirty
35 | });
36 |
37 | return connect(mapStateToProps)(WithLeaveWarning);
38 | }
39 |
40 | export default withLeaveWarning;
41 |
--------------------------------------------------------------------------------
/src/constants/options.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import tw from 'tailwind.macro';
3 | import 'styled-components/macro';
4 |
5 | export const OPTIONS = [
6 | {
7 | name: 'Based on income',
8 | value: 'income',
9 | description:
10 | 'The amount you and your partner owe for shared expenses is proportional to the total income you each contribute to your household.'
11 | },
12 | {
13 | name: '50/50 split',
14 | value: 'half',
15 | description: (
16 | <>
17 | Your shared expenses are split in half for you and your partner.{' '}
18 |
19 | When using this method your income is irrelevant.
20 |
21 | >
22 | )
23 | },
24 | {
25 | name: 'Grab bag method',
26 | value: 'adhoc',
27 | description: (
28 | <>
29 | Designate who will be responsible for each bill using the dropdown menu
30 | next to each expense.{' '}
31 |
32 | When using this method your income is irrelevant.
33 |
34 | >
35 | )
36 | }
37 | ];
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Brian Hurst
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/components/Firebase/firebase.js:
--------------------------------------------------------------------------------
1 | import app from 'firebase/app';
2 | import 'firebase/auth';
3 | import 'firebase/database';
4 |
5 | const config = {
6 | apiKey: process.env.REACT_APP_API_KEY,
7 | authDomain: process.env.REACT_APP_AUTH_DOMAIN,
8 | databaseURL: process.env.REACT_APP_DATABASE_URL,
9 | projectId: process.env.REACT_APP_PROJECT_ID,
10 | storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
11 | messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
12 | appId: process.env.REACT_APP_APP_ID,
13 | measurementId: process.env.REACT_APP_MEASUREMENT_ID
14 | };
15 |
16 | class Firebase {
17 | constructor() {
18 | app.initializeApp(config);
19 |
20 | this.auth = app.auth;
21 | this.authFn = app.auth();
22 | this.db = app.database;
23 | this.dbFn = app.database();
24 | }
25 |
26 | user = uid => this.dbFn.ref(`users/${uid}`);
27 | key = () => this.dbFn.ref().push().key;
28 | timestamp = () => this.db.ServerValue.TIMESTAMP;
29 | signOut = () => this.authFn.signOut();
30 | onAuthStateChanged = authUser => this.authFn.onAuthStateChanged(authUser);
31 | }
32 |
33 | export default Firebase;
34 |
--------------------------------------------------------------------------------
/src/components/Inputs/Slider.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import 'styled-components/macro';
5 | import { Range } from 'react-range';
6 |
7 | import { MAX_INCOME } from '../../constants/misc';
8 |
9 | const Slider = ({ onChange, disabled, value }) => (
10 | MAX_INCOME ? MAX_INCOME : value]}
15 | onChange={onChange}
16 | disabled={disabled}
17 | renderTrack={({ props, children }) => (
18 |
25 | {children}
26 |
27 | )}
28 | renderThumb={({ props }) => (
29 |
36 | )}
37 | />
38 | );
39 |
40 | Slider.propTypes = {
41 | onChange: PropTypes.func.isRequired,
42 | value: PropTypes.number
43 | };
44 |
45 | export default Slider;
46 |
--------------------------------------------------------------------------------
/src/components/Inputs/Radio.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import styled from 'styled-components/macro';
5 |
6 | const StyledRadio = styled.div`
7 | input[type='radio']:checked + label > span {
8 | ${tw`bg-teal`}
9 | box-shadow: 0px 0px 0px 4px white inset;
10 | }
11 | `;
12 |
13 | const Radio = ({ checked, option, onChange }) => (
14 |
15 |
23 |
24 |
27 |
28 |
{option.name}
29 | {checked &&
{option.description}
}
30 |
31 |
32 |
33 | );
34 |
35 | Radio.propTypes = {
36 | checked: PropTypes.bool.isRequired,
37 | option: PropTypes.object.isRequired,
38 | onChange: PropTypes.func.isRequired
39 | };
40 |
41 | export default Radio;
42 |
--------------------------------------------------------------------------------
/src/components/Chart.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { withTheme } from 'styled-components';
5 | import PieChart from 'react-minimal-pie-chart';
6 |
7 | import { calculatePercentage } from '../helpers';
8 |
9 | const Chart = ({ incomes, expenses, split, theme }) => (
10 | ({
14 | title: incomes[key].name,
15 | value: calculatePercentage(
16 | key,
17 | incomes[key].value,
18 | incomes,
19 | expenses,
20 | split
21 | ),
22 | color: Object.values(theme.colors.teal).slice(1)[index]
23 | }))}
24 | lineWidth={45}
25 | paddingAngle={2}
26 | startAngle={-90}
27 | animate
28 | />
29 | );
30 |
31 | Chart.propTypes = {
32 | incomes: PropTypes.object.isRequired,
33 | expenses: PropTypes.object.isRequired,
34 | split: PropTypes.string.isRequired,
35 | theme: PropTypes.object.isRequired
36 | };
37 |
38 | const mapStateToProps = ({ incomes, expenses, split }) => ({
39 | incomes,
40 | expenses,
41 | split
42 | });
43 |
44 | export default connect(mapStateToProps)(withTheme(Chart));
45 |
--------------------------------------------------------------------------------
/src/components/withAuthentication.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 |
5 | import { withFirebase } from './Firebase';
6 | import { authUserSet } from '../actions/session';
7 |
8 | const withAuthentication = Component => {
9 | class WithAuthentication extends React.Component {
10 | componentDidMount() {
11 | const { firebase, onSetAuthUser } = this.props;
12 |
13 | this.listener = firebase.onAuthStateChanged(authUser => {
14 | if (authUser) {
15 | onSetAuthUser(authUser);
16 | } else {
17 | onSetAuthUser(null);
18 | }
19 | });
20 | }
21 |
22 | componentWillUnmount() {
23 | this.listener();
24 | }
25 |
26 | render() {
27 | return ;
28 | }
29 | }
30 |
31 | WithAuthentication.propTypes = {
32 | firebase: PropTypes.object.isRequired,
33 | onSetAuthUser: PropTypes.func.isRequired
34 | };
35 |
36 | const mapDispatchToProps = dispatch => ({
37 | onSetAuthUser: authUser => {
38 | dispatch(authUserSet(authUser));
39 | }
40 | });
41 |
42 | return connect(null, mapDispatchToProps)(withFirebase(WithAuthentication));
43 | };
44 |
45 | export default withAuthentication;
46 |
--------------------------------------------------------------------------------
/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import tw from 'tailwind.macro';
3 | import styled from 'styled-components/macro';
4 |
5 | import { ReactComponent as Logo } from '../images/mark.svg';
6 |
7 | const List = styled.ul`
8 | ${tw`list-reset`}
9 |
10 | li {
11 | ${tw`inline-block`}
12 |
13 | &:not(:last-child):after {
14 | content: '|';
15 | ${tw`px-2`}
16 | }
17 | }
18 | `;
19 |
20 | const Footer = () => (
21 |
50 | );
51 |
52 | export default Footer;
53 |
--------------------------------------------------------------------------------
/src/components/Inputs/Select.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import 'styled-components/macro';
5 |
6 | const Select = ({ name, onChange, value, options }) => (
7 |
8 |
14 | {options.map(option => (
15 |
16 | {option.name}
17 |
18 | ))}
19 |
20 |
31 |
32 | );
33 |
34 | Select.propTypes = {
35 | name: PropTypes.string,
36 | onChange: PropTypes.func.isRequired,
37 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
38 | options: PropTypes.arrayOf(
39 | PropTypes.shape({
40 | name: PropTypes.string.isRequired,
41 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
42 | .isRequired
43 | })
44 | ).isRequired
45 | };
46 |
47 | export default Select;
48 |
--------------------------------------------------------------------------------
/src/components/Split.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import 'styled-components/macro';
5 | import { connect } from 'react-redux';
6 |
7 | import { OPTIONS } from '../constants/options';
8 | import { updateSplit } from '../actions/split';
9 | import Radio from './Inputs/Radio';
10 | import Chart from './Chart';
11 |
12 | const Split = ({ split, onUpdateSplit }) => {
13 | const handleUpdate = event => {
14 | onUpdateSplit(event.target.value);
15 | };
16 |
17 | return (
18 | <>
19 | Divide our expenses
20 |
21 |
22 | {OPTIONS.map(option => (
23 |
24 |
29 |
30 | ))}
31 |
32 |
33 |
34 |
35 |
36 | >
37 | );
38 | };
39 |
40 | Split.propTypes = {
41 | split: PropTypes.string.isRequired,
42 | onUpdateSplit: PropTypes.func.isRequired
43 | };
44 |
45 | const mapDispatchToProps = dispatch => ({
46 | onUpdateSplit: value => dispatch(updateSplit(value))
47 | });
48 |
49 | const mapStateToProps = ({ split }) => ({
50 | split
51 | });
52 |
53 | export default connect(mapStateToProps, mapDispatchToProps)(Split);
54 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
18 |
24 |
25 |
30 |
31 |
32 | BudgetDuo
33 |
34 |
35 |
36 |
40 |
44 |
45 |
49 |
50 |
51 | You need to enable JavaScript to run this app.
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/images/mark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | logo green-ish copy
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/helpers.js:
--------------------------------------------------------------------------------
1 | import Dinero from 'dinero.js';
2 |
3 | const toDinero = (amount, factor = Math.pow(10, 2)) => {
4 | return Dinero({ amount: Math.round(amount * factor) });
5 | };
6 |
7 | export const toPercentage = number => {
8 | return number.toLocaleString('en', {
9 | style: 'percent'
10 | });
11 | };
12 |
13 | export const totalValues = values => {
14 | const obj = Object.keys(values)
15 | .filter(key => values[key].value)
16 | .reduce((prev, key) => {
17 | return prev.add(toDinero(values[key].value));
18 | }, Dinero());
19 | return obj;
20 | };
21 |
22 | export const arrayToFirebaseObject = (array, firebase) => {
23 | return array.reduce((obj, item) => {
24 | const key = firebase.key();
25 | return {
26 | ...obj,
27 | [key]: item
28 | };
29 | }, {});
30 | };
31 |
32 | const filterExpensesByPayer = (expenses, key) => {
33 | return Object.keys(expenses)
34 | .filter(k => expenses[k].payer === key)
35 | .reduce((obj, k) => {
36 | return {
37 | ...obj,
38 | [k]: expenses[k]
39 | };
40 | }, {});
41 | };
42 |
43 | export const calculatePercentage = (key, income, incomes, expenses, split) => {
44 | switch (split) {
45 | case 'half':
46 | return 0.5;
47 | case 'adhoc':
48 | return (
49 | totalValues(filterExpensesByPayer(expenses, key)).getAmount() /
50 | totalValues(expenses).getAmount() || 0
51 | );
52 | default:
53 | return (
54 | toDinero(income).getAmount() / totalValues(incomes).getAmount() || 0
55 | );
56 | }
57 | };
58 |
59 | export const calculateAmount = (key, income, incomes, expenses, split) => {
60 | const totalExpenses = totalValues(expenses);
61 |
62 | switch (split) {
63 | case 'half':
64 | return totalExpenses.divide(2);
65 | case 'adhoc':
66 | return totalValues(filterExpensesByPayer(expenses, key));
67 | default:
68 | return totalExpenses.multiply(
69 | toDinero(income).getAmount() / totalValues(incomes).getAmount() || 0
70 | );
71 | }
72 | };
73 |
--------------------------------------------------------------------------------
/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import { Link } from 'react-router-dom';
5 | import { connect } from 'react-redux';
6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
7 | import { faSignOutAlt } from '@fortawesome/free-solid-svg-icons';
8 |
9 | import { withFirebase } from './Firebase';
10 | import * as ROUTES from '../constants/routes';
11 | import { ReactComponent as Logo } from '../images/logo.svg';
12 | import Button from './Inputs/Button';
13 | import Save from './Save';
14 | import TransitionGroup from './TransitionGroup';
15 |
16 | const Text = tw.span`hidden lg:inline-block mr-2 italic text-white`;
17 |
18 | const NavigationAuth = ({ firebase, isDirty }) => (
19 |
20 |
21 | {isDirty ? You have unsaved changes! : null}
22 |
23 |
24 |
25 |
29 | Sign out
30 |
31 |
32 | );
33 |
34 | const NavigationNonAuth = () => (
35 |
36 | Sign in to save
37 |
38 | Sign in
39 |
40 |
41 | );
42 |
43 | const Header = ({ session, firebase }) => (
44 |
45 |
46 |
47 |
48 |
49 |
50 | {session.authUser ? (
51 |
52 | ) : (
53 |
54 | )}
55 |
56 | );
57 |
58 | Header.propTypes = {
59 | session: PropTypes.object.isRequired,
60 | firebase: PropTypes.object.isRequired
61 | };
62 |
63 | const mapStateToProps = ({ session }) => ({
64 | session
65 | });
66 |
67 | export default connect(mapStateToProps)(withFirebase(Header));
68 |
--------------------------------------------------------------------------------
/src/actions/shared.js:
--------------------------------------------------------------------------------
1 | import { SAMPLE } from '../constants/sample';
2 | import { receiveIncomes } from './incomes';
3 | import { receiveExpenses } from './expenses';
4 | import { receiveSplit, resetSplit } from './split';
5 | import { arrayToFirebaseObject } from '../helpers';
6 |
7 | export const handleFirebaseData = (uid, firebase) => {
8 | return dispatch => {
9 | return firebase.user(uid).once('value', snapshot => {
10 | const { incomes, expenses, split, lastSaved } = snapshot.val();
11 |
12 | dispatch(split ? receiveSplit(split) : resetSplit());
13 | dispatch(
14 | receiveIncomes(lastSaved ? incomes : convertLegacyIncomes(incomes))
15 | );
16 | dispatch(
17 | receiveExpenses(
18 | lastSaved ? expenses : convertLegacyExpenses(expenses, incomes)
19 | )
20 | );
21 | });
22 | };
23 | };
24 |
25 | export const handleSampleData = firebase => (dispatch, getState) => {
26 | dispatch(resetSplit());
27 | dispatch(receiveIncomes(arrayToFirebaseObject(SAMPLE.incomes, firebase)));
28 | dispatch(
29 | receiveExpenses(
30 | arrayToFirebaseObject(
31 | SAMPLE.expenses.map(expense => ({
32 | ...expense,
33 | payer: Object.keys(getState().incomes)[0]
34 | })),
35 | firebase
36 | )
37 | )
38 | );
39 | };
40 |
41 | export const convertLegacyIncomes = incomes => {
42 | return Object.keys(incomes).reduce((obj, k) => {
43 | return {
44 | ...obj,
45 | [k]: {
46 | name: incomes[k].name,
47 | value: incomes[k].value || +incomes[k].amount || null
48 | }
49 | };
50 | }, {});
51 | };
52 |
53 | export const convertLegacyExpenses = (expenses, incomes) => {
54 | return Object.keys(expenses).reduce((obj, k) => {
55 | return {
56 | ...obj,
57 | [k]: {
58 | name: expenses[k].name || expenses[k].item || '',
59 | payer: Object.keys(incomes).includes(expenses[k].payer)
60 | ? expenses[k].payer
61 | : expenses[k].split == 1 // eslint-disable-line eqeqeq
62 | ? Object.keys(incomes)[1]
63 | : Object.keys(incomes)[0],
64 | value: expenses[k].value || +expenses[k].cost || null
65 | }
66 | };
67 | }, {});
68 | };
69 |
--------------------------------------------------------------------------------
/src/components/Save.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import 'styled-components/macro';
5 | import { connect } from 'react-redux';
6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
7 | import { faSave } from '@fortawesome/free-regular-svg-icons';
8 | import { ThreeBounce } from 'styled-spinkit';
9 | import { withTheme } from 'styled-components';
10 |
11 | import { withFirebase } from './Firebase';
12 | import { isDirty, isSaving } from '../actions/session';
13 | import Button from './Inputs/Button';
14 |
15 | const Save = ({ firebase, state, onDirty, onSaving, theme }) => {
16 | const { expenses, incomes, split, session } = state;
17 |
18 | const handleSave = () => {
19 | onSaving(true);
20 |
21 | firebase
22 | .user(session.authUser.uid)
23 | .set({
24 | expenses,
25 | incomes,
26 | split,
27 | lastSaved: firebase.timestamp()
28 | })
29 | .then(onDirty(false))
30 | .then(
31 | setTimeout(function() {
32 | onSaving(false);
33 | }, 500)
34 | );
35 | };
36 |
37 | return (
38 |
39 | {session.isSaving ? (
40 |
41 | ) : (
42 | <>
43 |
47 | Save
48 | >
49 | )}
50 |
51 | );
52 | };
53 |
54 | Save.propTypes = {
55 | state: PropTypes.object.isRequired,
56 | firebase: PropTypes.object.isRequired,
57 | onDirty: PropTypes.func.isRequired,
58 | onSaving: PropTypes.func.isRequired,
59 | theme: PropTypes.object.isRequired
60 | };
61 |
62 | const mapDispatchToProps = dispatch => ({
63 | onDirty: value => dispatch(isDirty(value)),
64 | onSaving: value => dispatch(isSaving(value))
65 | });
66 |
67 | const mapStateToProps = state => ({
68 | state
69 | });
70 |
71 | export default connect(
72 | mapStateToProps,
73 | mapDispatchToProps
74 | )(withFirebase(withTheme(Save)));
75 |
--------------------------------------------------------------------------------
/src/components/Report/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import 'styled-components/macro';
5 | import { connect } from 'react-redux';
6 | import { withTheme } from 'styled-components';
7 |
8 | import {
9 | calculatePercentage,
10 | calculateAmount,
11 | totalValues
12 | } from '../../helpers';
13 |
14 | import Label from '../Label';
15 | import Row from './Row';
16 |
17 | const Report = ({ incomes, expenses, split, theme }) => (
18 | <>
19 |
20 | Great! Here's your contribution breakdown:
21 |
22 |
25 | Individual
26 |
27 | % of total
28 |
29 | Amount due
30 |
31 | {Object.keys(incomes)
32 | .sort()
33 | .map((key, index) => (
34 |
53 | ))}
54 | Total:}
58 | percentage={1}
59 | amount={totalValues(expenses)}
60 | />
61 | >
62 | );
63 |
64 | Report.propTypes = {
65 | incomes: PropTypes.object.isRequired,
66 | expenses: PropTypes.object.isRequired,
67 | split: PropTypes.string.isRequired,
68 | theme: PropTypes.object.isRequired
69 | };
70 |
71 | const mapStateToProps = ({ incomes, expenses, split }) => ({
72 | incomes,
73 | expenses,
74 | split
75 | });
76 |
77 | export default connect(mapStateToProps)(withTheme(Report));
78 |
--------------------------------------------------------------------------------
/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.11, written by Peter Selinger 2001-2013
9 |
10 |
12 |
23 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/components/Incomes.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import { connect } from 'react-redux';
5 |
6 | import { updateIncome } from '../actions/incomes';
7 | import { withFirebase } from './Firebase';
8 |
9 | import Label from './Label';
10 | import Slider from './Inputs/Slider';
11 | import Currency from './Inputs/Currency';
12 |
13 | const Row = tw.div`flex items-center`;
14 |
15 | const Incomes = ({ incomes, split, onUpdateIncome }) => {
16 | const handleUpdate = (key, value = 0) => {
17 | onUpdateIncome(key, value);
18 | };
19 |
20 | return (
21 | <>
22 | Annual income
23 | {Object.keys(incomes)
24 | .sort()
25 | .map(key => (
26 |
27 |
28 |
29 | {incomes[key].name}
30 |
31 |
32 | Gross income
33 |
34 |
35 |
36 |
37 | handleUpdate(key, values[0])}
39 | value={incomes[key].value}
40 | />
41 |
42 |
43 | handleUpdate(key, values.floatValue)}
45 | value={incomes[key].value}
46 | />
47 |
48 |
49 |
50 | ))}
51 | >
52 | );
53 | };
54 |
55 | Incomes.propTypes = {
56 | incomes: PropTypes.object.isRequired,
57 | firebase: PropTypes.object.isRequired,
58 | onUpdateIncome: PropTypes.func.isRequired
59 | };
60 |
61 | const mapDispatchToProps = dispatch => ({
62 | onUpdateIncome: (key, value) => dispatch(updateIncome(key, value))
63 | });
64 |
65 | const mapStateToProps = ({ incomes, split }) => ({
66 | incomes,
67 | split
68 | });
69 |
70 | export default connect(
71 | mapStateToProps,
72 | mapDispatchToProps
73 | )(withFirebase(Incomes));
74 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "budget-duo",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@fortawesome/fontawesome-svg-core": "^1.2.28",
7 | "@fortawesome/free-brands-svg-icons": "^5.13.0",
8 | "@fortawesome/free-regular-svg-icons": "^5.13.0",
9 | "@fortawesome/free-solid-svg-icons": "^5.13.0",
10 | "@fortawesome/react-fontawesome": "^0.1.9",
11 | "dinero.js": "^1.8.1",
12 | "firebase": "^7.12.0",
13 | "react": "^16.13.1",
14 | "react-dom": "^16.13.1",
15 | "react-firebaseui": "^4.1.0",
16 | "react-minimal-pie-chart": "^5.0.2",
17 | "react-number-format": "^4.4.1",
18 | "react-range": "^1.5.3",
19 | "react-redux": "^7.2.0",
20 | "react-router-dom": "^5.1.2",
21 | "react-scripts": "^4.0.3",
22 | "react-transition-group": "^1.2.1",
23 | "redux": "^4.0.5",
24 | "redux-thunk": "^2.3.0",
25 | "styled-components": "^4.4.1",
26 | "styled-spinkit": "^1.1.0",
27 | "tailwind.macro": "^1.0.0-alpha.10",
28 | "tailwindcss": "^1.2.0"
29 | },
30 | "devDependencies": {
31 | "enzyme": "^3.11.0",
32 | "enzyme-adapter-react-16": "^1.15.2",
33 | "eslint-config-prettier": "^6.10.1",
34 | "eslint-plugin-prettier": "^3.1.2",
35 | "husky": "^3.1.0",
36 | "lint-staged": "^9.5.0",
37 | "prettier": "^1.19.1",
38 | "react-test-renderer": "^16.13.1",
39 | "redux-devtools": "^3.5.0"
40 | },
41 | "scripts": {
42 | "prestart": "npm run tailwind:css",
43 | "start": "react-scripts start",
44 | "prebuild": "npm run tailwind:css",
45 | "build": "react-scripts build",
46 | "test": "react-scripts test",
47 | "eject": "react-scripts eject",
48 | "tailwind:css": "tailwind build src/css/tailwind.src.css -c src/tailwind.js -o src/tailwind.css"
49 | },
50 | "prettier": {
51 | "printWidth": 80,
52 | "singleQuote": true,
53 | "trailingComma": "none"
54 | },
55 | "husky": {
56 | "hooks": {
57 | "pre-commit": "lint-staged"
58 | }
59 | },
60 | "lint-staged": {
61 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
62 | "prettier --write",
63 | "git add"
64 | ]
65 | },
66 | "eslintConfig": {
67 | "extends": [
68 | "react-app",
69 | "plugin:prettier/recommended"
70 | ]
71 | },
72 | "browserslist": {
73 | "production": [
74 | ">0.2%",
75 | "not dead",
76 | "not op_mini all"
77 | ],
78 | "development": [
79 | "last 1 chrome version",
80 | "last 1 firefox version",
81 | "last 1 safari version"
82 | ]
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/components/Report/Row.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import 'styled-components/macro';
5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
6 | import { faPlus, faMinus } from '@fortawesome/free-solid-svg-icons';
7 |
8 | import { toPercentage } from '../../helpers';
9 |
10 | class Row extends Component {
11 | state = {
12 | expanded: this.props.expanded || false
13 | };
14 |
15 | handleClick = () => {
16 | this.setState(state => ({ expanded: !state.expanded }));
17 | };
18 |
19 | toPaymentInteval = (amount, interval) => {
20 | return amount
21 | .multiply(12)
22 | .divide(interval)
23 | .toFormat('$0,0.00');
24 | };
25 |
26 | render() {
27 | const { className, amount, percentage, name, color } = this.props;
28 | const { expanded } = this.state;
29 |
30 | return (
31 |
36 |
37 | {color && (
38 |
42 | )}
43 | {name}
44 |
45 |
46 | {toPercentage(percentage)}
47 |
48 |
49 | {amount.toFormat('$0,0.00')}
50 | /mo
51 |
52 |
53 |
57 |
58 | {expanded && (
59 |
60 |
61 | {this.toPaymentInteval(amount, 26)} / bi-weekly
62 |
63 |
64 | {this.toPaymentInteval(amount, 24)} / bi-monthly
65 |
66 |
67 | {this.toPaymentInteval(amount, 1)} / annually
68 |
69 |
70 | )}
71 |
72 | );
73 | }
74 | }
75 |
76 | Row.propTypes = {
77 | className: PropTypes.string,
78 | expanded: PropTypes.bool,
79 | color: PropTypes.string,
80 | amount: PropTypes.object.isRequired,
81 | percentage: PropTypes.number.isRequired,
82 | name: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired
83 | };
84 |
85 | export default Row;
86 |
--------------------------------------------------------------------------------
/src/components/Home.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import { connect } from 'react-redux';
5 |
6 | import { withFirebase } from './Firebase';
7 | import withAuthentication from './withAuthentication';
8 | import { handleSampleData, handleFirebaseData } from '../actions/shared';
9 | import withLeaveWarning from './withLeaveWarning';
10 | import Header from './Header';
11 | import Incomes from './Incomes';
12 | import Expenses from './Expenses';
13 | import Split from './Split';
14 | import Report from './Report';
15 | import Footer from './Footer';
16 |
17 | const Column = tw.div`w-full lg:w-1/2 px-8 mb-8 lg:mb-0`;
18 |
19 | class Home extends Component {
20 | componentDidUpdate(prevProps) {
21 | const {
22 | authUser,
23 | firebase,
24 | onHandleFirebaseData,
25 | onHandleSampleData
26 | } = this.props;
27 |
28 | if (authUser !== prevProps.authUser)
29 | authUser
30 | ? onHandleFirebaseData(authUser.uid, firebase)
31 | : onHandleSampleData(firebase);
32 | }
33 |
34 | render() {
35 | return (
36 |
37 |
38 |
39 |
40 |
41 | What's your share?
42 |
43 |
44 | Calculate how much you and your partner should contribute towards
45 | shared household expenses.
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | What now?
56 |
57 | Talk with your partner about which method of splitting expenses
58 | works best for you. Once you've chosen a method, consider creating
59 | a shared checking account, depositing your contributions from each
60 | paycheck, and paying bills using your new account for complete
61 | automation.
62 |
63 |
64 |
65 |
66 |
67 | );
68 | }
69 | }
70 |
71 | Home.propTypes = {
72 | authUser: PropTypes.object,
73 | firebase: PropTypes.object.isRequired
74 | };
75 |
76 | const mapDispatchToProps = dispatch => ({
77 | onHandleFirebaseData: (uid, firebase) =>
78 | dispatch(handleFirebaseData(uid, firebase)),
79 | onHandleSampleData: firebase => dispatch(handleSampleData(firebase))
80 | });
81 |
82 | const mapStateToProps = ({ session }) => ({
83 | authUser: session.authUser
84 | });
85 |
86 | export default connect(
87 | mapStateToProps,
88 | mapDispatchToProps
89 | )(withFirebase(withAuthentication(withLeaveWarning(Home))));
90 |
--------------------------------------------------------------------------------
/src/components/SignIn.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import styled from 'styled-components/macro';
5 | import { connect } from 'react-redux';
6 | import { Link, withRouter } from 'react-router-dom';
7 | import StyledFirebaseAuth from 'react-firebaseui/StyledFirebaseAuth';
8 | import * as firebaseui from 'firebaseui';
9 | import { WaveLoading } from 'styled-spinkit';
10 |
11 | import { withFirebase } from './Firebase';
12 | import { isRedirect } from '../actions/session';
13 | import { ReactComponent as Logo } from '../images/logo.svg';
14 |
15 | const AuthContainer = styled.div`
16 | ${tw`w-full`}
17 |
18 | .firebaseui-id-page-callback {
19 | min-height: ${props => (props.isRedirect ? 0 : '200px')};
20 | }
21 |
22 | .firebaseui-callback-indicator-container {
23 | height: ${props => (props.isRedirect ? 0 : '120px')};
24 |
25 | .firebaseui-busy-indicator {
26 | ${tw`hidden`}
27 | }
28 | }
29 | `;
30 |
31 | class SignIn extends Component {
32 | componentDidUpdate() {
33 | const { state } = this.props;
34 | const { expenses, incomes, split, session } = state;
35 |
36 | if (!session.isRedirect && Object.keys(incomes).length < 1) {
37 | this.props.history.push('/');
38 | }
39 |
40 | if (!session.isRedirect) {
41 | localStorage.setItem(
42 | 'budgetDuoState',
43 | JSON.stringify({
44 | incomes,
45 | expenses,
46 | split
47 | })
48 | );
49 | }
50 | }
51 |
52 | componentWillUnmount() {
53 | const { onRedirect } = this.props;
54 |
55 | onRedirect(false);
56 | }
57 |
58 | uiConfig = {
59 | signInOptions: [
60 | this.props.firebase.auth.GoogleAuthProvider.PROVIDER_ID,
61 | this.props.firebase.auth.TwitterAuthProvider.PROVIDER_ID,
62 | {
63 | provider: this.props.firebase.auth.EmailAuthProvider.PROVIDER_ID,
64 | requireDisplayName: false
65 | }
66 | ],
67 | credentialHelper: firebaseui.auth.CredentialHelper.NONE,
68 | callbacks: {
69 | signInSuccessWithAuthResult: authResult => {
70 | const user = authResult.user;
71 | const isNewUser = authResult.additionalUserInfo.isNewUser;
72 |
73 | isNewUser &&
74 | this.props.firebase.user(user.uid).set({
75 | ...JSON.parse(localStorage.getItem('budgetDuoState')),
76 | lastSaved: this.props.firebase.timestamp()
77 | });
78 |
79 | this.props.history.push('/');
80 | }
81 | }
82 | };
83 |
84 | render() {
85 | const { state, firebase, onRedirect } = this.props;
86 |
87 | return (
88 |
89 |
90 |
91 |
92 |
93 |
94 | {state.session.isRedirect &&
}
95 |
96 | onRedirect(ui.isPendingRedirect())}
100 | />
101 |
102 |
103 | );
104 | }
105 | }
106 |
107 | SignIn.propTypes = {
108 | state: PropTypes.object.isRequired,
109 | firebase: PropTypes.object.isRequired,
110 | onRedirect: PropTypes.func.isRequired
111 | };
112 |
113 | const mapDispatchToProps = dispatch => ({
114 | onRedirect: value => dispatch(isRedirect(value))
115 | });
116 |
117 | const mapStateToProps = state => ({
118 | state
119 | });
120 |
121 | export default connect(
122 | mapStateToProps,
123 | mapDispatchToProps
124 | )(withFirebase(withRouter(SignIn)));
125 |
--------------------------------------------------------------------------------
/TERMS.md:
--------------------------------------------------------------------------------
1 | # Terms of Service
2 |
3 | ## 1\. Terms
4 |
5 | By accessing the website at [https://budgetduo.com](https://budgetduo.com), you are agreeing to be bound by these terms of service, all applicable laws and regulations, and agree that you are responsible for compliance with any applicable local laws. If you do not agree with any of these terms, you are prohibited from using or accessing this site. The materials contained in this website are protected by applicable copyright and trademark law.
6 |
7 | ## 2\. Disclaimer
8 |
9 | 1. The materials on BudgetDuo's website are provided on an 'as is' basis. BudgetDuo makes no warranties, expressed or implied, and hereby disclaims and negates all other warranties including, without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.
10 | 2. Further, BudgetDuo does not warrant or make any representations concerning the accuracy, likely results, or reliability of the use of the materials on its website or otherwise relating to such materials or on any sites linked to this site.
11 |
12 | ## 3\. Limitations
13 |
14 | In no event shall BudgetDuo or its suppliers be liable for any damages (including, without limitation, damages for loss of data or profit, or due to business interruption) arising out of the use or inability to use the materials on BudgetDuo's website, even if BudgetDuo or a BudgetDuo authorized representative has been notified orally or in writing of the possibility of such damage. Because some jurisdictions do not allow limitations on implied warranties, or limitations of liability for consequential or incidental damages, these limitations may not apply to you.
15 |
16 | ## 4\. Accuracy of materials
17 |
18 | The materials appearing on BudgetDuo's website could include technical, typographical, or photographic errors. BudgetDuo does not warrant that any of the materials on its website are accurate, complete or current. BudgetDuo may make changes to the materials contained on its website at any time without notice. However BudgetDuo does not make any commitment to update the materials.
19 |
20 | ## 5\. Links
21 |
22 | BudgetDuo has not reviewed all of the sites linked to its website and is not responsible for the contents of any such linked site. The inclusion of any link does not imply endorsement by BudgetDuo of the site. Use of any such linked website is at the user's own risk.
23 |
24 | ## 6\. Modifications
25 |
26 | BudgetDuo may revise these terms of service for its website at any time without notice. By using this website you are agreeing to be bound by the then current version of these terms of service.
27 |
28 | ## 7\. Governing Law
29 |
30 | These terms and conditions are governed by and construed in accordance with the laws of Virginia and you irrevocably submit to the exclusive jurisdiction of the courts in that State or location.
31 |
32 | ## Privacy Policy
33 |
34 | Your privacy is important to us. It is BudgetDuo's policy to respect your privacy regarding any information we may collect from you across our website, [https://budgetduo.com](https://budgetduo.com), and other sites we own and operate.
35 |
36 | We only ask for personal information when we truly need it to provide a service to you. We collect it by fair and lawful means, with your knowledge and consent. We also let you know why we’re collecting it and how it will be used.
37 |
38 | We only retain collected information for as long as necessary to provide you with your requested service. What data we store, we’ll protect within commercially acceptable means to prevent loss and theft, as well as unauthorised access, disclosure, copying, use or modification.
39 |
40 | We don’t share any personally identifying information publicly or with third-parties, except when required to by law.
41 |
42 | Our website may link to external sites that are not operated by us. Please be aware that we have no control over the content and practices of these sites, and cannot accept responsibility or liability for their respective privacy policies.
43 |
44 | You are free to refuse our request for your personal information, with the understanding that we may be unable to provide you with some of your desired services.
45 |
46 | Your continued use of our website will be regarded as acceptance of our practices around privacy and personal information. If you have any questions about how we handle user data and personal information, feel free to contact us.
47 |
48 | This policy is effective as of 25 May 2018.
49 |
--------------------------------------------------------------------------------
/src/actions/shared.test.js:
--------------------------------------------------------------------------------
1 | import { convertLegacyExpenses, convertLegacyIncomes } from './shared';
2 |
3 | const sample = {
4 | expenses: {
5 | '-LCeMf-6iddBAqgs3MSN': {
6 | cost: '86.00',
7 | item: 'Gym'
8 | },
9 | '-LCeMf-6iddBAqgs3MSP': {
10 | cost: '1848.32',
11 | item: 'Mortgage'
12 | },
13 | '-LCeMf-6iddBAqgs3MSQ': {
14 | cost: '200.00',
15 | item: 'Savings'
16 | }
17 | },
18 | incomes: {
19 | '-LCeMf-6iddBAqgs3MSK': {
20 | amount: '109980.00',
21 | name: 'You'
22 | },
23 | '-LCeMf-6iddBAqgs3MSL': {
24 | amount: '72100.00',
25 | name: 'Partner'
26 | }
27 | },
28 | split: 'income'
29 | };
30 |
31 | describe('convertLegacyExpenses()', () => {
32 | it('updates keys', () => {
33 | expect(
34 | convertLegacyExpenses(sample.expenses, sample.incomes)
35 | ).toStrictEqual({
36 | '-LCeMf-6iddBAqgs3MSN': {
37 | name: 'Gym',
38 | payer: '-LCeMf-6iddBAqgs3MSK',
39 | value: 86
40 | },
41 | '-LCeMf-6iddBAqgs3MSP': {
42 | name: 'Mortgage',
43 | payer: '-LCeMf-6iddBAqgs3MSK',
44 | value: 1848.32
45 | },
46 | '-LCeMf-6iddBAqgs3MSQ': {
47 | name: 'Savings',
48 | payer: '-LCeMf-6iddBAqgs3MSK',
49 | value: 200
50 | }
51 | });
52 | });
53 |
54 | it('handles different split values', () => {
55 | const object = {
56 | ...sample,
57 | expenses: {
58 | '-LCeMf-6iddBAqgs3MSN': {
59 | cost: '86.00',
60 | item: 'Gym',
61 | split: 1
62 | },
63 | '-LCeMf-6iddBAqgs3MSP': {
64 | cost: '1848.32',
65 | item: 'Mortgage',
66 | split: 0
67 | },
68 | '-LCeMf-6iddBAqgs3MSQ': {
69 | cost: '200.00',
70 | item: 'Savings'
71 | },
72 | '-LCeMf-6iddBAqgs3MSR': {
73 | cost: '49.99',
74 | item: 'Internet',
75 | split: '1'
76 | },
77 | '-LCeMf-6iddBAqgs3MSS': {
78 | cost: '75.00',
79 | item: 'Utilities',
80 | split: '0'
81 | }
82 | }
83 | };
84 |
85 | expect(
86 | convertLegacyExpenses(object.expenses, object.incomes)
87 | ).toStrictEqual({
88 | '-LCeMf-6iddBAqgs3MSN': {
89 | name: 'Gym',
90 | payer: '-LCeMf-6iddBAqgs3MSL',
91 | value: 86
92 | },
93 | '-LCeMf-6iddBAqgs3MSP': {
94 | name: 'Mortgage',
95 | payer: '-LCeMf-6iddBAqgs3MSK',
96 | value: 1848.32
97 | },
98 | '-LCeMf-6iddBAqgs3MSQ': {
99 | name: 'Savings',
100 | payer: '-LCeMf-6iddBAqgs3MSK',
101 | value: 200
102 | },
103 | '-LCeMf-6iddBAqgs3MSR': {
104 | name: 'Internet',
105 | payer: '-LCeMf-6iddBAqgs3MSL',
106 | value: 49.99
107 | },
108 | '-LCeMf-6iddBAqgs3MSS': {
109 | name: 'Utilities',
110 | payer: '-LCeMf-6iddBAqgs3MSK',
111 | value: 75
112 | }
113 | });
114 | });
115 |
116 | it('does not modify an object that has already been updated', () => {
117 | const object = {
118 | ...sample,
119 | expenses: {
120 | '-LCeMf-6iddBAqgs3MSN': {
121 | name: 'Gym',
122 | payer: '-LCeMf-6iddBAqgs3MSL',
123 | value: 86
124 | },
125 | '-LCeMf-6iddBAqgs3MSP': {
126 | name: 'Mortgage',
127 | payer: '-LCeMf-6iddBAqgs3MSK',
128 | value: 1848.32
129 | },
130 | '-LCeMf-6iddBAqgs3MSQ': {
131 | name: 'Savings',
132 | payer: '-LCeMf-6iddBAqgs3MSK',
133 | value: 200
134 | }
135 | }
136 | };
137 |
138 | expect(
139 | convertLegacyExpenses(object.expenses, object.incomes)
140 | ).toStrictEqual(object.expenses);
141 | });
142 |
143 | describe('convertLegacyIncomes()', () => {
144 | it('updates keys', () => {
145 | expect(convertLegacyIncomes(sample.incomes)).toStrictEqual({
146 | '-LCeMf-6iddBAqgs3MSK': {
147 | name: 'You',
148 | value: 109980
149 | },
150 | '-LCeMf-6iddBAqgs3MSL': {
151 | name: 'Partner',
152 | value: 72100
153 | }
154 | });
155 | });
156 |
157 | it('does not modify an object that has already been updated', () => {
158 | const object = {
159 | ...sample,
160 | incomes: {
161 | '-LCeMf-6iddBAqgs3MSK': {
162 | name: 'You',
163 | value: 109980
164 | },
165 | '-LCeMf-6iddBAqgs3MSL': {
166 | name: 'Partner',
167 | value: 72100
168 | }
169 | }
170 | };
171 |
172 | expect(convertLegacyIncomes(object.incomes)).toStrictEqual(
173 | object.incomes
174 | );
175 | });
176 | });
177 | });
178 |
--------------------------------------------------------------------------------
/src/components/Expenses.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import tw from 'tailwind.macro';
4 | import { connect } from 'react-redux';
5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
6 | import { faPlus } from '@fortawesome/free-solid-svg-icons';
7 | import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
8 | import TransitionGroup from './TransitionGroup';
9 |
10 | import { withFirebase } from './Firebase';
11 | import { addExpense, updateExpense, deleteExpense } from '../actions/expenses';
12 | import Button from './Inputs/Button';
13 | import Label from './Label';
14 | import Text from './Inputs/Text';
15 | import Currency from './Inputs/Currency';
16 | import Select from './Inputs/Select';
17 |
18 | const Row = tw.div`flex items-center`;
19 |
20 | const Expenses = ({
21 | incomes,
22 | expenses,
23 | split,
24 | firebase,
25 | onAddExpense,
26 | onUpdateExpense,
27 | onDeleteExpense
28 | }) => {
29 | const handleAdd = () => {
30 | const key = firebase.key();
31 | const payer = Object.keys(incomes).sort()[0];
32 |
33 | onAddExpense({ key, payer });
34 | };
35 |
36 | const handleUpdate = (key, e) => {
37 | onUpdateExpense(key, {
38 | [e.target.name]: e.target.value
39 | });
40 | };
41 |
42 | const handleValueUpdate = (key, value) => {
43 | onUpdateExpense(key, { value });
44 | };
45 |
46 | const handleDelete = key => {
47 | onDeleteExpense(key);
48 | };
49 |
50 | const isAdhoc = split === 'adhoc';
51 |
52 | return (
53 | <>
54 | Monthly expenses
55 |
56 |
57 | Description
58 |
59 | {isAdhoc && Payer }
60 | Cost
61 |
62 |
63 | {Object.keys(expenses)
64 | .sort()
65 | .map(key => (
66 |
67 |
68 | handleUpdate(key, e)}
71 | value={expenses[key].name}
72 | />
73 |
74 | {isAdhoc && (
75 |
76 | ({
81 | name: incomes[key].name,
82 | value: key
83 | }))}
84 | onChange={e => handleUpdate(key, e)}
85 | value={expenses[key].payer}
86 | />
87 |
88 | )}
89 |
90 |
92 | handleValueUpdate(key, values.floatValue)
93 | }
94 | value={expenses[key].value}
95 | />
96 |
97 |
98 | {Object.keys(expenses).length > 1 && (
99 | handleDelete(key)}
102 | >
103 |
107 |
108 | )}
109 |
110 |
111 | ))}
112 |
113 |
114 |
118 | Add expense
119 |
120 | >
121 | );
122 | };
123 |
124 | Expenses.propTypes = {
125 | expenses: PropTypes.object.isRequired,
126 | incomes: PropTypes.object.isRequired,
127 | split: PropTypes.string.isRequired,
128 | firebase: PropTypes.object.isRequired,
129 | onAddExpense: PropTypes.func.isRequired,
130 | onUpdateExpense: PropTypes.func.isRequired,
131 | onDeleteExpense: PropTypes.func.isRequired
132 | };
133 |
134 | const mapDispatchToProps = dispatch => ({
135 | onAddExpense: obj => dispatch(addExpense(obj)),
136 | onUpdateExpense: (key, obj) => {
137 | dispatch(updateExpense(key, obj));
138 | },
139 | onDeleteExpense: key => dispatch(deleteExpense(key))
140 | });
141 |
142 | const mapStateToProps = ({ incomes, expenses, split }) => ({
143 | incomes,
144 | expenses,
145 | split
146 | });
147 |
148 | export default connect(
149 | mapStateToProps,
150 | mapDispatchToProps
151 | )(withFirebase(Expenses));
152 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.1/8 is considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl)
104 | .then(response => {
105 | // Ensure service worker exists, and that we really are getting a JS file.
106 | const contentType = response.headers.get('content-type');
107 | if (
108 | response.status === 404 ||
109 | (contentType != null && contentType.indexOf('javascript') === -1)
110 | ) {
111 | // No service worker found. Probably a different app. Reload the page.
112 | navigator.serviceWorker.ready.then(registration => {
113 | registration.unregister().then(() => {
114 | window.location.reload();
115 | });
116 | });
117 | } else {
118 | // Service worker found. Proceed as normal.
119 | registerValidSW(swUrl, config);
120 | }
121 | })
122 | .catch(() => {
123 | console.log(
124 | 'No internet connection found. App is running in offline mode.'
125 | );
126 | });
127 | }
128 |
129 | export function unregister() {
130 | if ('serviceWorker' in navigator) {
131 | navigator.serviceWorker.ready.then(registration => {
132 | registration.unregister();
133 | });
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/images/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Page 1
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------