├── .netlify └── state.json ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── robots.txt ├── manifest.json └── index.html ├── fitlife-screenshot.png ├── src ├── App.js ├── components │ ├── Logo.jsx │ ├── Divider.jsx │ ├── Input.jsx │ ├── NavBar.jsx │ ├── Modal.jsx │ ├── Icon.jsx │ ├── Button.jsx │ ├── WorkoutChart.jsx │ ├── CalorieChart.jsx │ ├── WorkoutTimer.jsx │ ├── WorkoutScheme.jsx │ └── SelectExercise.jsx ├── index.jsx ├── layouts │ ├── SignInLayout.jsx │ └── DashboardLayout.jsx ├── index.css ├── constants │ └── index.js ├── contexts │ ├── workout │ │ ├── WorkoutContext.js │ │ └── WorkoutReducer.js │ └── auth │ │ └── AuthContext.js ├── firebase.js ├── helpers │ └── index.js ├── pages │ ├── Workout.jsx │ ├── SignUp.jsx │ ├── Profile.jsx │ ├── SignIn.jsx │ └── Dashboard.jsx ├── hooks │ ├── useTimer.js │ └── useWorkoutDb.js └── router.js ├── craco.config.js ├── README.md ├── .gitignore ├── tailwind.config.js └── package.json /.netlify/state.json: -------------------------------------------------------------------------------- 1 | { 2 | "siteId": "a1e45b44-8a8b-47ad-bcd7-f8abc7f03cad" 3 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanderdebr/FitLife/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanderdebr/FitLife/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanderdebr/FitLife/HEAD/public/logo512.png -------------------------------------------------------------------------------- /fitlife-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanderdebr/FitLife/HEAD/fitlife-screenshot.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Router from "./router"; 3 | 4 | function App() { 5 | return ; 6 | } 7 | 8 | export default App; 9 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | style: { 3 | postcss: { 4 | plugins: [require("tailwindcss"), require("autoprefixer")], 5 | }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FitLife 2 | ### Clean and simple Workout Tracker 3 | 4 | ![Screenshot](fitlife-screenshot.png) 5 | 6 | https://fitlife-app.netlify.app/ 7 | 8 | Made with React and Firebase. 9 | -------------------------------------------------------------------------------- /src/components/Logo.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Logo() { 4 | return
FitLife.
; 5 | } 6 | 7 | export default Logo; 8 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById("root") 11 | ); 12 | -------------------------------------------------------------------------------- /src/components/Divider.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Divider({ text }) { 4 | return ( 5 |
6 |
7 |
{text}
8 |
9 |
10 | ); 11 | } 12 | 13 | export default Divider; 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/layouts/SignInLayout.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Logo from "../components/Logo"; 3 | 4 | function SignInLayout({ children }) { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 | {children} 12 |
13 |
14 | ); 15 | } 16 | 17 | export default SignInLayout; 18 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"], 3 | darkMode: false, 4 | theme: { 5 | extend: { 6 | backgroundImage: (theme) => ({ 7 | "signin-image": 8 | "url('https://source.unsplash.com/xB4ExGcUai0/1600x900')", 9 | }), 10 | colors: { 11 | primary: "#182277", 12 | primaryDark: "#10185e", 13 | secondary: "#FFB7E4", 14 | secondaryDark: "#de8cbf", 15 | }, 16 | }, 17 | fontFamily: { 18 | sans: ['"Inter"', "sans-serif"], 19 | }, 20 | }, 21 | variants: { 22 | extend: {}, 23 | }, 24 | plugins: [], 25 | }; 26 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | body { 7 | @apply overflow-x-hidden; 8 | } 9 | 10 | ::selection { 11 | @apply bg-secondary; 12 | } 13 | 14 | a { 15 | @apply font-semibold; 16 | } 17 | 18 | h1, 19 | h2, 20 | h3, 21 | h5, 22 | h5, 23 | h6 { 24 | @apply font-semibold text-primary; 25 | } 26 | 27 | p, 28 | label, 29 | input { 30 | @apply text-primary; 31 | } 32 | 33 | input, 34 | button { 35 | @apply rounded; 36 | } 37 | } 38 | 39 | input::-webkit-outer-spin-button, 40 | input::-webkit-inner-spin-button { 41 | -webkit-appearance: none; 42 | margin: 0; 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Input.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Input({ 4 | name, 5 | type, 6 | placeholder, 7 | label, 8 | value, 9 | center, 10 | handleChange, 11 | }) { 12 | return ( 13 |
14 | {label && } 15 | 26 |
27 | ); 28 | } 29 | 30 | export default Input; 31 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | /*-------------------------- 2 | Calories burned per hour 3 | --------------------------- */ 4 | export const CALORIES_PER_HOUR = 500; 5 | 6 | /*-------------------------- 7 | Icon properties 8 | --------------------------- */ 9 | export const ICON_PROPS = { 10 | size: "20", 11 | }; 12 | 13 | /*-------------------------- 14 | Navbar links 15 | --------------------------- */ 16 | export const NAV_LINKS = [ 17 | { 18 | label: "Dashboard", 19 | to: "/", 20 | icon: "dashboard", 21 | }, 22 | { 23 | label: "Workout", 24 | to: "/workout", 25 | icon: "fitness", 26 | }, 27 | { 28 | label: "Profile", 29 | to: "/profile", 30 | icon: "user", 31 | }, 32 | ]; 33 | 34 | /*-------------------------- 35 | Default set 36 | --------------------------- */ 37 | export const DEFAULT_SET = { 38 | weight: 45, 39 | reps: 0, 40 | isFinished: false, 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/NavBar.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router-dom"; 3 | import Icon from "./Icon"; 4 | 5 | function NavBar({ links }) { 6 | return ( 7 | 25 | ); 26 | } 27 | 28 | export default NavBar; 29 | -------------------------------------------------------------------------------- /src/components/Modal.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Modal({ children }) { 4 | return ( 5 |
11 |
12 | 16 | 17 | 23 | 24 |
25 | {children} 26 |
27 |
28 |
29 | ); 30 | } 31 | 32 | export default Modal; 33 | -------------------------------------------------------------------------------- /src/contexts/workout/WorkoutContext.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useReducer } from "react"; 2 | import { initializer, rootReducer } from "./WorkoutReducer"; 3 | 4 | const { createContext } = require("react"); 5 | 6 | const WorkoutStateContext = createContext(); 7 | const WorkoutDispatchContext = createContext(); 8 | 9 | export const useWorkoutState = () => useContext(WorkoutStateContext); 10 | export const useWorkoutDispatch = () => useContext(WorkoutDispatchContext); 11 | 12 | export const WorkoutProvider = ({ children }) => { 13 | const [workoutState, dispatch] = useReducer(rootReducer, initializer); 14 | 15 | // Persist workout state on workout update 16 | useEffect(() => { 17 | localStorage.setItem("workout", JSON.stringify(workoutState)); 18 | }, [workoutState]); 19 | 20 | return ( 21 | 22 | 23 | {children} 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/Icon.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ImPause, ImPlay2 } from "react-icons/im"; 3 | import { ICON_PROPS } from "../constants"; 4 | import { RiDashboard3Line, RiUser3Line } from "react-icons/ri"; 5 | import { IoMdFitness, IoIosRemoveCircleOutline } from "react-icons/io"; 6 | import { 7 | AiOutlineLoading3Quarters, 8 | AiOutlineCheckCircle, 9 | AiOutlinePlusCircle, 10 | } from "react-icons/ai"; 11 | import { HiOutlineMenuAlt3 } from "react-icons/hi"; 12 | 13 | const icons = { 14 | loading: AiOutlineLoading3Quarters, 15 | check: AiOutlineCheckCircle, 16 | play: ImPlay2, 17 | pause: ImPause, 18 | dashboard: RiDashboard3Line, 19 | fitness: IoMdFitness, 20 | user: RiUser3Line, 21 | remove: IoIosRemoveCircleOutline, 22 | plus: AiOutlinePlusCircle, 23 | menu: HiOutlineMenuAlt3, 24 | }; 25 | 26 | function Icon({ type }) { 27 | const IconComponent = icons[type]; 28 | 29 | return ( 30 | 34 | ); 35 | } 36 | 37 | export default Icon; 38 | -------------------------------------------------------------------------------- /src/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | import "firebase/auth"; 3 | import "firebase/firestore"; 4 | 5 | const firebaseSettings = { 6 | apiKey: process.env.REACT_APP_FIREBASE_API_KEY, 7 | authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN, 8 | databaseURL: process.env.REACT_APP_FIREBASE_DATABASE_URL, 9 | projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID, 10 | storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET, 11 | messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID, 12 | appId: process.env.REACT_APP_FIREBASE_APP_ID, 13 | }; 14 | 15 | let app = !firebase.apps.length 16 | ? firebase.initializeApp(firebaseSettings) 17 | : firebase.app(); 18 | 19 | const firestore = app.firestore(); 20 | 21 | export const database = { 22 | exercises: firestore.collection("exercises"), 23 | workouts: firestore.collection("workouts"), 24 | getCurrentTimestamp: firebase.firestore.FieldValue.serverTimestamp, 25 | }; 26 | 27 | export const auth = app.auth(); 28 | 29 | export const googleProvider = new firebase.auth.GoogleAuthProvider(); 30 | 31 | export default app; 32 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | /*-------------------------- 2 | Format nav links 3 | --------------------------- */ 4 | export function getActiveNavLink(links, currentPath) { 5 | return links.map((link) => ({ 6 | ...link, 7 | isActive: currentPath === link.to, 8 | })); 9 | } 10 | 11 | /*-------------------------- 12 | Add padding to a number 13 | --------------------------- */ 14 | export function padNum(val) { 15 | let valStr = val + ""; 16 | return valStr.length < 2 ? "0" + valStr : valStr; 17 | } 18 | 19 | /*-------------------------- 20 | Format document from Firestore 21 | --------------------------- */ 22 | export function formatDocument(doc) { 23 | return { 24 | id: doc.id, 25 | ...doc.data(), 26 | }; 27 | } 28 | 29 | /*-------------------------- 30 | Local storage helper function 31 | --------------------------- */ 32 | export function persist(type, key, value) { 33 | if (type === "get") { 34 | if (localStorage.getItem(key)) { 35 | return JSON.parse(localStorage.getItem(key)); 36 | } 37 | } 38 | if (type === "set") { 39 | localStorage.setItem(key, JSON.stringify(value)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/pages/Workout.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Modal from "../components/Modal"; 3 | import SelectExercise from "../components/SelectExercise"; 4 | import WorkoutScheme from "../components/WorkoutScheme"; 5 | import WorkoutTimer from "../components/WorkoutTimer"; 6 | 7 | function Workout() { 8 | const [showModal, setShowModal] = useState(false); 9 | 10 | const toggleModal = () => { 11 | setShowModal(!showModal); 12 | }; 13 | 14 | return ( 15 | <> 16 | {showModal && ( 17 | 18 | 19 | 20 | )} 21 |
22 |
23 |

Workout

24 | 25 |
26 |
27 |
28 | 29 |
30 |
31 |
32 | 33 | ); 34 | } 35 | 36 | export default Workout; 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fitlife", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "craco start", 7 | "build": "craco build", 8 | "test": "craco test", 9 | "eject": "react-scripts eject" 10 | }, 11 | "eslintConfig": { 12 | "extends": [ 13 | "react-app", 14 | "react-app/jest" 15 | ] 16 | }, 17 | "browserslist": { 18 | "production": [ 19 | ">0.2%", 20 | "not dead", 21 | "not op_mini all" 22 | ], 23 | "development": [ 24 | "last 1 chrome version", 25 | "last 1 firefox version", 26 | "last 1 safari version" 27 | ] 28 | }, 29 | "dependencies": { 30 | "@craco/craco": "^6.1.1", 31 | "@testing-library/jest-dom": "^5.11.4", 32 | "@testing-library/react": "^11.1.0", 33 | "@testing-library/user-event": "^12.1.10", 34 | "date-fns": "^2.20.2", 35 | "firebase": "^8.3.2", 36 | "immer": "^9.0.1", 37 | "react": "^17.0.2", 38 | "react-dom": "^17.0.2", 39 | "react-icons": "^4.2.0", 40 | "react-router-dom": "^5.2.0", 41 | "react-scripts": "4.0.3", 42 | "recharts": "^2.0.9", 43 | "uuid": "^8.3.2", 44 | "web-vitals": "^1.0.1" 45 | }, 46 | "devDependencies": { 47 | "@tailwindcss/postcss7-compat": "^2.0.4", 48 | "autoprefixer": "^9.8.6", 49 | "postcss": "^7.0.35", 50 | "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/hooks/useTimer.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { persist } from "../helpers"; 3 | 4 | function useTimer() { 5 | const countRef = useRef(); 6 | const [isActive, setIsActive] = useState(false); 7 | const [isPaused, setIsPaused] = useState(false); 8 | const [secondsPassed, setSecondsPassed] = useState( 9 | persist("get", "timer") || 0 10 | ); 11 | 12 | useEffect(() => { 13 | const persistedSeconds = persist("get", "timer"); 14 | if (persistedSeconds > 0) { 15 | startTimer(); 16 | setSecondsPassed(persistedSeconds); 17 | } 18 | }, []); 19 | 20 | useEffect(() => { 21 | persist("set", "timer", secondsPassed); 22 | }, [secondsPassed]); 23 | 24 | const startTimer = () => { 25 | setIsActive(true); 26 | countRef.current = setInterval(() => { 27 | setSecondsPassed((seconds) => seconds + 1); 28 | }, 1000); 29 | }; 30 | 31 | const stopTimer = () => { 32 | setIsActive(false); 33 | setIsPaused(false); 34 | setSecondsPassed(0); 35 | clearInterval(countRef.current); 36 | }; 37 | 38 | const pauseTimer = () => { 39 | setIsPaused(true); 40 | clearInterval(countRef.current); 41 | }; 42 | 43 | const resumeTimer = () => { 44 | setIsPaused(false); 45 | startTimer(); 46 | }; 47 | 48 | return { 49 | secondsPassed, 50 | isActive, 51 | isPaused, 52 | startTimer, 53 | stopTimer, 54 | pauseTimer, 55 | resumeTimer, 56 | }; 57 | } 58 | 59 | export default useTimer; 60 | -------------------------------------------------------------------------------- /src/contexts/auth/AuthContext.js: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, useEffect } from "react"; 2 | import { auth, googleProvider } from "../../firebase"; 3 | 4 | const AuthContext = createContext(); 5 | 6 | export function useAuth() { 7 | return useContext(AuthContext); 8 | } 9 | 10 | export function AuthProvider({ children }) { 11 | const [user, setUser] = useState(null); 12 | const [loading, setLoading] = useState(true); 13 | 14 | useEffect(() => { 15 | const unsubscribe = auth.onAuthStateChanged((user) => { 16 | setUser(user); 17 | setLoading(false); 18 | }); 19 | 20 | return unsubscribe; 21 | }, []); 22 | 23 | function signIn(email, password) { 24 | return auth.signInWithEmailAndPassword(email, password); 25 | } 26 | 27 | function signInWithGoogle() { 28 | return auth.signInWithPopup(googleProvider); 29 | } 30 | 31 | function signUp(email, password) { 32 | return auth.createUserWithEmailAndPassword(email, password); 33 | } 34 | 35 | function signOut() { 36 | return auth.signOut(); 37 | } 38 | 39 | function resetPassword(email) { 40 | return auth.sendPasswordResetEmail(email); 41 | } 42 | 43 | function updateEmail(email) { 44 | return user.updateEmail(email); 45 | } 46 | 47 | function updatePassword(password) { 48 | return user.updatePassword(password); 49 | } 50 | 51 | const value = { 52 | user, 53 | signIn, 54 | signInWithGoogle, 55 | signUp, 56 | signOut, 57 | resetPassword, 58 | updateEmail, 59 | updatePassword, 60 | }; 61 | 62 | return ( 63 | 64 | {!loading && children} 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/Button.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Icon from "./Icon"; 3 | 4 | function Button({ 5 | value, 6 | type, 7 | action, 8 | variant, 9 | loading = false, 10 | fullWidth, 11 | icon, 12 | }) { 13 | let variantClass; 14 | 15 | switch (variant) { 16 | case "primary": 17 | variantClass = 18 | "bg-primary text-white hover:bg-primaryDark focus:ring-primaryDark"; 19 | break; 20 | case "secondary": 21 | variantClass = 22 | "bg-secondary text-white hover:bg-secondaryDark focus:ring-secondaryDark"; 23 | break; 24 | case "frame": 25 | variantClass = 26 | "bg-white text-primary border border-primary hover:bg-gray-100 focus:ring-primary"; 27 | break; 28 | case "green": 29 | variantClass = 30 | "bg-green-100 text-green-600 hover:bg-green-200 focus:ring-bg-green-200"; 31 | break; 32 | case "red": 33 | variantClass = 34 | "bg-red-100 text-red-600 hover:bg-red-200 focus:ring-bg-green-200"; 35 | break; 36 | default: 37 | variantClass = 38 | "bg-gray-100 text-primary hover:bg-gray-300 focus:ring-gray-300"; 39 | break; 40 | } 41 | 42 | return ( 43 | 53 | ); 54 | } 55 | 56 | export default Button; 57 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 22 | 23 | 32 | React App 33 | 34 | 35 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom"; 3 | import { AuthProvider, useAuth } from "./contexts/auth/AuthContext"; 4 | import { WorkoutProvider } from "./contexts/workout/WorkoutContext"; 5 | import DashboardLayout from "./layouts/DashboardLayout"; 6 | import SignInLayout from "./layouts/SignInLayout"; 7 | import Dashboard from "./pages/Dashboard"; 8 | import Workout from "./pages/Workout"; 9 | import Profile from "./pages/Profile"; 10 | import SignIn from "./pages/SignIn"; 11 | import SignUp from "./pages/SignUp"; 12 | 13 | function Router() { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 28 | 34 | 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | 47 | function RouteWrapper({ page: Page, layout: Layout, privateRoute, ...rest }) { 48 | const { user } = useAuth(); 49 | 50 | return ( 51 | 54 | privateRoute && !user ? ( 55 | 56 | ) : ( 57 | 58 | 59 | 60 | ) 61 | } 62 | /> 63 | ); 64 | } 65 | 66 | export default Router; 67 | -------------------------------------------------------------------------------- /src/contexts/workout/WorkoutReducer.js: -------------------------------------------------------------------------------- 1 | import produce from "immer"; 2 | import { DEFAULT_SET } from "../../constants"; 3 | import { persist } from "../../helpers"; 4 | 5 | const ACTIONS = { 6 | START_WORKOUT: "START_WORKOUT", 7 | DISCARD_WORKOUT: "DISCARD_WORKOUT", 8 | UPDATE_WEIGHT: "UPDATE_WEIGHT", 9 | UPDATE_REPS: "UPDATE_REPS", 10 | ADD_EXERCISE: "ADD_EXERCISE", 11 | ADD_SET: "ADD_SET", 12 | REMOVE_SET: "REMOVE_SET", 13 | REMOVE_EXERCISE: "REMOVE_EXERCISE", 14 | TOGGLE_FINISHED: "TOGGLE_FINISHED", 15 | }; 16 | 17 | const initialState = { 18 | exercises: {}, 19 | workoutInProgress: false, 20 | }; 21 | 22 | export const initializer = persist("get", "workout") 23 | ? persist("get", "workout") 24 | : initialState; 25 | 26 | export const rootReducer = produce((draft, { type, payload }) => { 27 | switch (type) { 28 | case ACTIONS.START_WORKOUT: 29 | draft.workoutInProgress = true; 30 | break; 31 | case ACTIONS.DISCARD_WORKOUT: 32 | draft.exercises = {}; 33 | draft.workoutInProgress = false; 34 | break; 35 | case ACTIONS.UPDATE_WEIGHT: 36 | draft.exercises[payload.exerciseId].sets[payload.setId].weight = 37 | payload.newWeight; 38 | break; 39 | case ACTIONS.UPDATE_REPS: 40 | draft.exercises[payload.exerciseId].sets[payload.setId].reps = 41 | payload.newReps; 42 | break; 43 | case ACTIONS.ADD_EXERCISE: 44 | draft.exercises[payload.exerciseId] = payload.exercise; 45 | break; 46 | case ACTIONS.ADD_SET: 47 | draft.exercises[payload.exerciseId].sets[payload.setId] = DEFAULT_SET; 48 | break; 49 | case ACTIONS.REMOVE_SET: 50 | delete draft.exercises[payload.exerciseId].sets[payload.setId]; 51 | break; 52 | case ACTIONS.REMOVE_EXERCISE: 53 | delete draft.exercises[payload.exerciseId]; 54 | break; 55 | case ACTIONS.TOGGLE_FINISHED: 56 | draft.exercises[payload.exerciseId].sets[ 57 | payload.setId 58 | ].isFinished = !draft.exercises[payload.exerciseId].sets[payload.setId] 59 | .isFinished; 60 | break; 61 | default: 62 | throw new Error(`Unhandled action type: ${type}`); 63 | } 64 | }); 65 | -------------------------------------------------------------------------------- /src/hooks/useWorkoutDb.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer } from "react"; 2 | import { useAuth } from "../contexts/auth/AuthContext"; 3 | import { database } from "../firebase"; 4 | import { formatDocument } from "../helpers"; 5 | 6 | const ACTIONS = { 7 | FETCHING_EXERCISES: "FETCHING_EXERCISES", 8 | FETCHING_WORKOUTS: "FETCHING_WORKOUTS", 9 | SET_EXERCISES: "SET_EXERCISES", 10 | SET_WORKOUTS: "SET_WORKOUTS", 11 | }; 12 | 13 | const initialState = { 14 | isFetchingExercises: false, 15 | isFetchingWorkouts: false, 16 | exercises: [], 17 | workouts: [], 18 | }; 19 | 20 | function reducer(state, { type, payload }) { 21 | switch (type) { 22 | case ACTIONS.FETCHING_EXERCISES: 23 | return { 24 | ...state, 25 | isFetchingExercises: true, 26 | }; 27 | case ACTIONS.FETCHING_WORKOUTS: 28 | return { 29 | ...state, 30 | isFetchingWorkouts: true, 31 | }; 32 | case ACTIONS.SET_EXERCISES: 33 | return { 34 | ...state, 35 | exercises: payload, 36 | isFetchingExercises: false, 37 | }; 38 | case ACTIONS.SET_WORKOUTS: 39 | return { 40 | ...state, 41 | workouts: payload, 42 | isFetchingWorkouts: false, 43 | }; 44 | default: 45 | throw new Error(`Unhandled action type: ${type}`); 46 | } 47 | } 48 | 49 | function useWorkoutDb() { 50 | const [workoutDbState, dispatch] = useReducer(reducer, initialState); 51 | 52 | const { user } = useAuth(); 53 | 54 | useEffect(() => { 55 | dispatch({ type: ACTIONS.FETCHING_EXERCISES }); 56 | 57 | return database.exercises 58 | .where("userId", "==", user.uid) 59 | .onSnapshot((snapshot) => { 60 | dispatch({ 61 | type: ACTIONS.SET_EXERCISES, 62 | payload: snapshot.docs.map(formatDocument), 63 | }); 64 | }); 65 | }, [user]); 66 | 67 | useEffect(() => { 68 | dispatch({ type: ACTIONS.FETCHING_WORKOUTS }); 69 | 70 | return database.workouts 71 | .where("userId", "==", user.uid) 72 | .onSnapshot((snapshot) => { 73 | dispatch({ 74 | type: ACTIONS.SET_WORKOUTS, 75 | payload: snapshot.docs.map(formatDocument), 76 | }); 77 | }); 78 | }, [user]); 79 | 80 | return workoutDbState; 81 | } 82 | 83 | export default useWorkoutDb; 84 | -------------------------------------------------------------------------------- /src/pages/SignUp.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Link, useHistory } from "react-router-dom"; 3 | import Button from "../components/Button"; 4 | import Divider from "../components/Divider"; 5 | import Input from "../components/Input"; 6 | import { useAuth } from "../contexts/auth/AuthContext"; 7 | 8 | function SignUp() { 9 | const { signUp } = useAuth(); 10 | const history = useHistory(); 11 | 12 | const initialValues = { email: "", password: "" }; 13 | 14 | const [values, setValues] = useState(initialValues); 15 | const [loading, setLoading] = useState(false); 16 | const [error, setError] = useState(null); 17 | 18 | const handleChange = (e) => { 19 | const { name, value } = e.target; 20 | setValues({ ...values, [name]: value }); 21 | }; 22 | 23 | const handleSubmit = async (e) => { 24 | e.preventDefault(); 25 | 26 | const { email, password } = values; 27 | if (!email || !password) { 28 | return setError("Please fill in all fields"); 29 | } 30 | 31 | try { 32 | setLoading(true); 33 | await signUp(email, password); 34 | 35 | history.push("/"); 36 | } catch (error) { 37 | setError(error.message); 38 | } 39 | 40 | setLoading(false); 41 | }; 42 | 43 | return ( 44 |
45 |

Sign Up

46 |
47 | 54 | 61 | {error &&
{error}
} 62 |
76 | ); 77 | } 78 | 79 | export default SignUp; 80 | -------------------------------------------------------------------------------- /src/pages/Profile.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Button from "../components/Button"; 3 | import Input from "../components/Input"; 4 | import { useAuth } from "../contexts/auth/AuthContext"; 5 | 6 | function Profile() { 7 | const { user, updateEmail } = useAuth(); 8 | 9 | const [loading, setLoading] = useState(false); 10 | const [message, setMessage] = useState(""); 11 | const [error, setError] = useState(""); 12 | 13 | const [values, setValues] = useState({ 14 | name: user.displayName, 15 | email: user.email, 16 | }); 17 | 18 | const handleChange = (e) => { 19 | const { name, value } = e.target; 20 | setValues((values) => ({ ...values, [name]: value })); 21 | }; 22 | 23 | const handleSubmit = async (e) => { 24 | e.preventDefault(); 25 | 26 | const { email } = values; 27 | if (!email) { 28 | return setError("Please fill in all fields"); 29 | } 30 | 31 | try { 32 | setLoading(true); 33 | await updateEmail(email); 34 | setMessage("Succesfully updated"); 35 | } catch (error) { 36 | setError(error.message); 37 | } 38 | 39 | setLoading(false); 40 | }; 41 | 42 | return ( 43 | <> 44 |
45 |
46 |

Profile

47 |
48 |
49 |
50 |
51 | 58 |
59 | {message && message} 60 |
61 | {error &&
{error}
} 62 |
72 |
73 |
74 | 75 | ); 76 | } 77 | 78 | export default Profile; 79 | -------------------------------------------------------------------------------- /src/components/WorkoutChart.jsx: -------------------------------------------------------------------------------- 1 | import { format, subMonths } from "date-fns"; 2 | import React, { useEffect, useState } from "react"; 3 | import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts"; 4 | import useWorkoutDb from "../hooks/useWorkoutDb"; 5 | 6 | function WorkoutChart() { 7 | const [data, setData] = useState([]); 8 | 9 | const { isFetchingWorkouts, workouts } = useWorkoutDb(); 10 | 11 | useEffect(() => { 12 | let lastMonths = []; 13 | 14 | const addEmptyMonths = () => { 15 | const today = new Date(); 16 | 17 | for (let i = 2; i >= 0; i--) { 18 | const month = format(subMonths(today, i), "LLL"); 19 | lastMonths.push(month); 20 | setData((data) => [...data, { month, amount: 0 }]); 21 | } 22 | }; 23 | 24 | const addWorkoutsPerMonth = () => { 25 | for (const { createdAt } of workouts) { 26 | const month = format(new Date(createdAt.seconds * 1000), "LLL"); 27 | const index = lastMonths.indexOf(month); 28 | if (index !== -1) { 29 | setData((data) => { 30 | data[index].amount++; 31 | return data; 32 | }); 33 | } 34 | } 35 | }; 36 | 37 | setData([]); 38 | addEmptyMonths(); 39 | 40 | if (!isFetchingWorkouts && workouts.length) { 41 | addWorkoutsPerMonth(); 42 | } 43 | }, [isFetchingWorkouts, workouts]); 44 | 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 62 | 63 | 71 | 72 | 73 | ); 74 | } 75 | 76 | export default WorkoutChart; 77 | -------------------------------------------------------------------------------- /src/layouts/DashboardLayout.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useLocation } from "react-router"; 3 | import Logo from "../components/Logo"; 4 | import NavBar from "../components/NavBar"; 5 | import { NAV_LINKS } from "../constants"; 6 | import { useAuth } from "../contexts/auth/AuthContext"; 7 | import { getActiveNavLink } from "../helpers"; 8 | import Button from "../components/Button"; 9 | 10 | function DashboardLayout({ children }) { 11 | const { signOut } = useAuth(); 12 | const { pathname } = useLocation(); 13 | 14 | const [showMobileMenu, setShowMobileMenu] = useState(false); 15 | 16 | const handleMenu = () => { 17 | setShowMobileMenu((showMobileMenu) => !showMobileMenu); 18 | }; 19 | 20 | useEffect(() => { 21 | setShowMobileMenu(false); 22 | }, [pathname]); 23 | 24 | return ( 25 |
26 | 38 | {showMobileMenu && ( 39 |
43 | 44 |
45 | Sign Out 46 |
47 |
48 | )} 49 |
50 | 51 |
52 |
59 |
60 |
61 | {children} 62 |
63 |
64 | ); 65 | } 66 | 67 | export default DashboardLayout; 68 | -------------------------------------------------------------------------------- /src/components/CalorieChart.jsx: -------------------------------------------------------------------------------- 1 | import { format, subDays } from "date-fns"; 2 | import React, { useEffect, useState } from "react"; 3 | import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts"; 4 | import useWorkoutDb from "../hooks/useWorkoutDb"; 5 | import { CALORIES_PER_HOUR } from "../constants"; 6 | 7 | function CalorieChart() { 8 | const [data, setData] = useState([]); 9 | 10 | const { isFetchingWorkouts, workouts } = useWorkoutDb(); 11 | 12 | useEffect(() => { 13 | let lastDays = []; 14 | 15 | const addEmptyDays = () => { 16 | const today = new Date(); 17 | 18 | for (let i = 6; i >= 0; i--) { 19 | const day = format(subDays(today, i), "E"); 20 | lastDays.push(day); 21 | setData((data) => [...data, { day, calories: 0 }]); 22 | } 23 | }; 24 | 25 | const addCaloriesPerDay = () => { 26 | for (const { createdAt, secondsPassed } of workouts) { 27 | const day = format(new Date(createdAt.seconds * 1000), "E"); 28 | const index = lastDays.indexOf(day); 29 | if (index !== -1) { 30 | const calories = CALORIES_PER_HOUR * (secondsPassed / 3600); 31 | 32 | setData((data) => { 33 | data[index].calories = data[index].calories + parseInt(calories); 34 | return data; 35 | }); 36 | } 37 | } 38 | }; 39 | 40 | setData([]); 41 | addEmptyDays(); 42 | 43 | if (!isFetchingWorkouts && workouts.length) { 44 | addCaloriesPerDay(); 45 | } 46 | }, [isFetchingWorkouts, workouts]); 47 | 48 | return ( 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 65 | 66 | 74 | 75 | 76 | ); 77 | } 78 | 79 | export default CalorieChart; 80 | -------------------------------------------------------------------------------- /src/pages/SignIn.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Link, useHistory } from "react-router-dom"; 3 | import Button from "../components/Button"; 4 | import Divider from "../components/Divider"; 5 | import Input from "../components/Input"; 6 | import { useAuth } from "../contexts/auth/AuthContext"; 7 | 8 | function SignIn() { 9 | const { signIn, signInWithGoogle, resetPassword } = useAuth(); 10 | const history = useHistory(); 11 | 12 | const initialValues = { email: "", password: "" }; 13 | 14 | const [values, setValues] = useState(initialValues); 15 | const [loading, setLoading] = useState(false); 16 | const [googleLoading, setGoogleLoading] = useState(false); 17 | const [error, setError] = useState(null); 18 | const [message, setMessage] = useState(null); 19 | 20 | const handleChange = (e) => { 21 | const { name, value } = e.target; 22 | setValues({ ...values, [name]: value }); 23 | }; 24 | 25 | const handleSubmit = async (e) => { 26 | e.preventDefault(); 27 | 28 | const { email, password } = values; 29 | if (!email || !password) { 30 | return setError("Please fill in all fields"); 31 | } 32 | 33 | try { 34 | setLoading(true); 35 | await signIn(email, password); 36 | history.push("/"); 37 | } catch (error) { 38 | setError(error.message); 39 | } 40 | 41 | setLoading(false); 42 | }; 43 | 44 | const handleGoogleSignIn = async () => { 45 | try { 46 | setGoogleLoading(true); 47 | await signInWithGoogle(); 48 | history.push("/"); 49 | } catch (error) { 50 | setError(error.message); 51 | } 52 | 53 | setGoogleLoading(false); 54 | }; 55 | 56 | const handlePassword = async () => { 57 | setMessage(null); 58 | setError(null); 59 | 60 | const { email } = values; 61 | 62 | if (!email) { 63 | return setError("Please enter an email first"); 64 | } 65 | 66 | try { 67 | setLoading(true); 68 | await resetPassword(email); 69 | setMessage("Successfully sent email reset link"); 70 | } catch (error) { 71 | setError(error.message); 72 | } 73 | 74 | setLoading(false); 75 | }; 76 | 77 | return ( 78 |
79 |

Sign In

80 |
81 | 89 |
90 | 98 |
102 | Forgot password? 103 |
104 |
105 | {message &&
{message}
} 106 | {error &&
{error}
} 107 |
129 | ); 130 | } 131 | 132 | export default SignIn; 133 | -------------------------------------------------------------------------------- /src/pages/Dashboard.jsx: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns"; 2 | import React, { useEffect, useState } from "react"; 3 | import { Link } from "react-router-dom"; 4 | import Button from "../components/Button"; 5 | import CalorieChart from "../components/CalorieChart"; 6 | import WorkoutChart from "../components/WorkoutChart"; 7 | import { CALORIES_PER_HOUR } from "../constants"; 8 | import useWorkoutDb from "../hooks/useWorkoutDb"; 9 | 10 | function Dashboard() { 11 | const { isFetchingWorkouts, workouts } = useWorkoutDb(); 12 | 13 | const initialData = { 14 | today: 0, 15 | week: 0, 16 | month: 0, 17 | }; 18 | 19 | const [calories, setCalories] = useState(initialData); 20 | 21 | useEffect(() => { 22 | setCalories(initialData); 23 | 24 | const today = new Date(); 25 | const dayOfYear = format(today, "d"); 26 | const weekNum = format(today, "w"); 27 | const monthNum = format(today, "L"); 28 | 29 | const calcCalories = () => { 30 | for (const { createdAt, secondsPassed } of workouts) { 31 | const formattedDate = new Date(createdAt.seconds * 1000); 32 | const day = format(formattedDate, "d"); 33 | const week = format(formattedDate, "w"); 34 | const month = format(formattedDate, "L"); 35 | 36 | const newCalories = CALORIES_PER_HOUR * (secondsPassed / 3600); 37 | 38 | if (dayOfYear === day) { 39 | setCalories((calories) => ({ 40 | ...calories, 41 | today: calories.today + newCalories, 42 | })); 43 | } 44 | if (weekNum === week) { 45 | setCalories((calories) => ({ 46 | ...calories, 47 | week: calories.week + newCalories, 48 | })); 49 | } 50 | if (monthNum === month) { 51 | setCalories((calories) => ({ 52 | ...calories, 53 | month: calories.month + newCalories, 54 | })); 55 | } 56 | } 57 | }; 58 | 59 | if (!isFetchingWorkouts && workouts.length) { 60 | calcCalories(); 61 | } 62 | }, [isFetchingWorkouts, workouts]); 63 | 64 | return ( 65 |
66 |
67 |

Dashboard

68 | 69 |
72 |
73 |
74 |
75 |

Workouts

76 |
77 |
TOTAL
78 |

79 | {isFetchingWorkouts ? 0 : workouts.length} 80 |

81 |
82 |
83 | 84 |
85 |
86 |
87 |

Calories

88 |
89 |
TODAY
90 |

91 | {isFetchingWorkouts ? 0 : parseInt(calories.today)} 92 |

93 |
94 |
95 |
THIS WEEK
96 |

97 | {isFetchingWorkouts ? 0 : parseInt(calories.week)} 98 |

99 |
100 |
101 |
THIS MONTH
102 |

103 | {isFetchingWorkouts ? 0 : parseInt(calories.month)} 104 |

105 |
106 |
107 |
108 | 109 |
110 |
111 |
112 |
113 | ); 114 | } 115 | 116 | export default Dashboard; 117 | -------------------------------------------------------------------------------- /src/components/WorkoutTimer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { useAuth } from "../contexts/auth/AuthContext"; 3 | import { 4 | useWorkoutDispatch, 5 | useWorkoutState, 6 | } from "../contexts/workout/WorkoutContext"; 7 | import { database } from "../firebase"; 8 | import { padNum, persist } from "../helpers"; 9 | import useTimer from "../hooks/useTimer"; 10 | import Button from "./Button"; 11 | 12 | function WorkoutTimer({ toggleModal }) { 13 | const [message, setMessage] = useState(""); 14 | const [loading, setLoading] = useState(false); 15 | 16 | const minutesRef = useRef(); 17 | const secondsRef = useRef(); 18 | 19 | const dispatch = useWorkoutDispatch(); 20 | 21 | const { exercises, workoutInProgress } = useWorkoutState(); 22 | 23 | const { user } = useAuth(); 24 | 25 | const { 26 | secondsPassed, 27 | isActive, 28 | isPaused, 29 | startTimer, 30 | stopTimer, 31 | pauseTimer, 32 | resumeTimer, 33 | } = useTimer(); 34 | 35 | useEffect(() => { 36 | secondsRef.current.innerHTML = padNum(secondsPassed % 60); 37 | minutesRef.current.innerHTML = padNum(parseInt(secondsPassed / 60)); 38 | }, [secondsPassed]); 39 | 40 | const handleStart = () => { 41 | startTimer(); 42 | dispatch({ 43 | type: "START_WORKOUT", 44 | }); 45 | }; 46 | 47 | const handleDiscard = () => { 48 | stopTimer(); 49 | persist("set", "timer", 0); 50 | 51 | dispatch({ 52 | type: "DISCARD_WORKOUT", 53 | }); 54 | }; 55 | 56 | const newMessage = (msg) => { 57 | setMessage(msg); 58 | setTimeout(() => setMessage(""), 5000); 59 | }; 60 | 61 | const handleSave = async () => { 62 | newMessage(""); 63 | setLoading(true); 64 | 65 | try { 66 | await database.workouts.add({ 67 | workout: JSON.stringify(exercises), 68 | secondsPassed, 69 | userId: user.uid, 70 | createdAt: database.getCurrentTimestamp(), 71 | }); 72 | 73 | newMessage("Saved succesfully"); 74 | handleDiscard(); 75 | } catch (err) { 76 | newMessage(err.message); 77 | } 78 | 79 | setLoading(false); 80 | }; 81 | 82 | const finishedSets = Object.values(exercises).filter( 83 | (exercise) => 84 | Object.values(exercise.sets).filter((set) => set.isFinished === true) 85 | .length > 0 86 | ); 87 | 88 | const showSave = finishedSets.length > 0; 89 | 90 | return ( 91 |
92 | {workoutInProgress ? ( 93 | 104 | ) : ( 105 |
122 | ); 123 | } 124 | 125 | function ActiveWorkoutTimer({ 126 | showSave, 127 | handleSave, 128 | handleDiscard, 129 | loading, 130 | toggleModal, 131 | isPaused, 132 | resumeTimer, 133 | pauseTimer, 134 | }) { 135 | return ( 136 | <> 137 |