├── 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 | 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 | 20 |
23 | 28 | 29 | 30 |
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 | 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 | 31 |
32 | ); 33 | 34 | const NavigationNonAuth = () => ( 35 |
36 | Sign in to save 37 | 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 | 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 | 26 | 29 | 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 | 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 | 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 | 59 | {isAdhoc && } 60 | 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 |