├── src ├── app │ ├── index.js │ ├── authenticated-app │ │ ├── index.js │ │ ├── layout.js │ │ ├── main-content.js │ │ ├── drawer-context.js │ │ ├── navbar.js │ │ ├── drawer.js │ │ └── authenticated-app.js │ ├── unathenticated-app │ │ ├── index.js │ │ ├── main-content.js │ │ ├── app-title.js │ │ ├── footer.js │ │ ├── navbar.js │ │ ├── layout.js │ │ └── unauthenticated-app.js │ └── app.js ├── components │ ├── performance-panel │ │ ├── index.js │ │ ├── helpers.js │ │ └── performance-panel.js │ ├── copyright.js │ ├── sign-in-as-guest-button.js │ ├── heatmap-calendar.js │ ├── bar-chart.js │ ├── github-repo-link.js │ ├── auth-providers-list.js │ ├── habit-row.js │ ├── account-tab.js │ ├── line-chart.js │ ├── checkbox-group.js │ ├── checkmark.js │ ├── mobile-menu.js │ ├── lib.js │ ├── locale-select.js │ ├── week-picker.js │ ├── performance-tab.js │ ├── week-bar-chart.js │ ├── habit-list.js │ ├── form.js │ └── habits-table.js ├── images │ ├── hero.jpg │ ├── landscape.jpg │ ├── diagram-placeholder.png │ ├── hello-darkness.svg │ ├── towing.svg │ └── progress-data.svg ├── translations │ ├── index.js │ └── use-translation.js ├── localization │ ├── index.js │ ├── use-locale.js │ ├── locales.js │ └── locale-context.js ├── theme │ ├── index.js │ ├── theme-context.js │ ├── colors.js │ └── theme.js ├── data │ ├── constants.js │ ├── constraints.js │ ├── auth-providers.js │ └── guest.js ├── setupTests.js ├── utils │ ├── test-utils.js │ ├── misc.js │ ├── __tests__ │ │ └── misc.test.js │ ├── chart-helpers.js │ └── hooks.js ├── reportWebVitals.js ├── index.js ├── context │ ├── firebase-context.js │ ├── index.js │ ├── snackbar-context.js │ ├── dialog-context.js │ ├── user-context.js │ └── auth-context.js ├── icons │ ├── poland.svg │ ├── spain.svg │ ├── united-states.svg │ └── united-kingdom.svg ├── api │ ├── firebase.js │ ├── user-data.js │ ├── appearance.js │ └── habits.js └── screens │ ├── manage-habits.js │ ├── not-found.js │ ├── not-found-habit.js │ ├── no-habits.js │ ├── reset-password.js │ ├── landing.js │ ├── user-settings.js │ ├── add-habit.js │ ├── sign-in.js │ ├── sign-up.js │ ├── edit-habit.js │ └── dashboard.js ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── robots.txt ├── manifest.json └── index.html ├── screenshots ├── landing.png ├── sign-in.png ├── sign-up.png ├── add-habit.png ├── dashboard.png ├── settings.png ├── layout-theme.png └── manage-habits.png ├── jsconfig.json ├── .env ├── firebase.json ├── .env.local.example ├── .gitignore ├── LICENSE ├── package.json └── README.md /src/app/index.js: -------------------------------------------------------------------------------- 1 | export { App } from './app'; -------------------------------------------------------------------------------- /src/app/authenticated-app/index.js: -------------------------------------------------------------------------------- 1 | export { AuthenticatedApp } from './authenticated-app'; -------------------------------------------------------------------------------- /src/app/unathenticated-app/index.js: -------------------------------------------------------------------------------- 1 | export { UnathenticatedApp } from './unauthenticated-app'; -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/components/performance-panel/index.js: -------------------------------------------------------------------------------- 1 | export { PerformancePanel } from './performance-panel'; -------------------------------------------------------------------------------- /src/images/hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/src/images/hero.jpg -------------------------------------------------------------------------------- /screenshots/landing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/screenshots/landing.png -------------------------------------------------------------------------------- /screenshots/sign-in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/screenshots/sign-in.png -------------------------------------------------------------------------------- /screenshots/sign-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/screenshots/sign-up.png -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src" 4 | }, 5 | "include": ["src"] 6 | } 7 | -------------------------------------------------------------------------------- /screenshots/add-habit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/screenshots/add-habit.png -------------------------------------------------------------------------------- /screenshots/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/screenshots/dashboard.png -------------------------------------------------------------------------------- /screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/screenshots/settings.png -------------------------------------------------------------------------------- /src/images/landscape.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/src/images/landscape.jpg -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_THEME_PRIMARY_COLOR=blue 2 | REACT_APP_THEME_SECONDARY_COLOR=yellow 3 | REACT_APP_THEME_DARK=dark 4 | -------------------------------------------------------------------------------- /screenshots/layout-theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/screenshots/layout-theme.png -------------------------------------------------------------------------------- /screenshots/manage-habits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/screenshots/manage-habits.png -------------------------------------------------------------------------------- /src/images/diagram-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitek94/habit-tracker-app/HEAD/src/images/diagram-placeholder.png -------------------------------------------------------------------------------- /src/translations/index.js: -------------------------------------------------------------------------------- 1 | export { translations } from './translations'; 2 | export { useTranslation } from './use-translation'; 3 | -------------------------------------------------------------------------------- /src/localization/index.js: -------------------------------------------------------------------------------- 1 | export { LocaleProvider } from './locale-context'; 2 | export { locales } from './locales'; 3 | export { useLocale } from './use-locale'; -------------------------------------------------------------------------------- /src/theme/index.js: -------------------------------------------------------------------------------- 1 | export { colors, getColor } from './colors'; 2 | export { defaultTheme, defaultThemeConstants, createTheme, isDefaultTheme } from './theme'; 3 | export { ThemeProvider } from './theme-context'; 4 | -------------------------------------------------------------------------------- /src/data/constants.js: -------------------------------------------------------------------------------- 1 | export const DATE_FORMAT = 'YYYY-MM-DD'; 2 | 3 | export const COMPLETED = 'completed'; 4 | 5 | export const FAILED = 'failed'; 6 | 7 | export const EMPTY = 'empty'; 8 | 9 | export const CHECKMARK_VALUES = [EMPTY, COMPLETED, FAILED]; 10 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /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 | } 17 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | # Select your App from the dropdown 2 | # Go to Dashboard, then Project settings 3 | REACT_APP_API_KEY= 4 | REACT_APP_AUTH_DOMAIN= 5 | REACT_APP_DATABASE_URL= 6 | REACT_APP_PROJECT_ID= 7 | REACT_APP_STORAGE_BUCKET= 8 | REACT_APP_MESSAGING_SENDER_ID= 9 | REACT_APP_APP_ID= 10 | REACT_APP_MEASUREMENT_ID= 11 | -------------------------------------------------------------------------------- /src/components/copyright.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Typography } from '@material-ui/core'; 3 | 4 | function Copyright(props) { 5 | return ( 6 | 7 | Copyright © {new Date().getFullYear()} Maciek Sitkowski 8 | 9 | ); 10 | } 11 | 12 | export { Copyright }; 13 | -------------------------------------------------------------------------------- /src/utils/test-utils.js: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | function AllTheProviders({ children }) { 4 | return children; 5 | } 6 | 7 | function customRender(ui, options) { 8 | render(ui, { wrapper: AllTheProviders, ...options }); 9 | } 10 | 11 | // Reexport everything 12 | export * from '@testing-library/react'; 13 | 14 | // Override render method 15 | export { customRender as render }; 16 | -------------------------------------------------------------------------------- /src/app/authenticated-app/layout.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box } from '@material-ui/core'; 3 | import { DrawerProvider } from './drawer-context'; 4 | 5 | /** 6 | * Layout 7 | */ 8 | function Layout({ children }) { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | 16 | export { Layout }; 17 | -------------------------------------------------------------------------------- /src/localization/use-locale.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { MuiPickersAdapterContext } from '@material-ui/lab/LocalizationProvider'; 3 | 4 | function useLocale() { 5 | const context = React.useContext(MuiPickersAdapterContext); 6 | if (context === undefined) { 7 | throw new Error(`useLocale must be used within a LocaleProvider`); 8 | } 9 | return context.locale; 10 | } 11 | 12 | export { useLocale }; 13 | -------------------------------------------------------------------------------- /src/utils/misc.js: -------------------------------------------------------------------------------- 1 | export function descendingComparator(a, b, orderBy) { 2 | if (b[orderBy] < a[orderBy]) { 3 | return -1; 4 | } 5 | if (b[orderBy] > a[orderBy]) { 6 | return 1; 7 | } 8 | return 0; 9 | } 10 | 11 | export function getComparator(order, orderBy) { 12 | return order === 'desc' 13 | ? (a, b) => descendingComparator(a, b, orderBy) 14 | : (a, b) => -descendingComparator(a, b, orderBy); 15 | } 16 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # firebase 7 | src/api/firebase-config.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # firebase 16 | *.cache 17 | .firebaserc 18 | 19 | # misc 20 | .DS_Store 21 | .env.development 22 | .env.production 23 | .env.local 24 | .env.development.local 25 | .env.test.local 26 | .env.production.local 27 | 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # IDE 33 | .idea 34 | -------------------------------------------------------------------------------- /src/translations/use-translation.js: -------------------------------------------------------------------------------- 1 | import { useLocale } from '../localization/use-locale'; 2 | import { translations } from './translations'; 3 | 4 | export function useTranslation(source = translations) { 5 | const { code } = useLocale(); 6 | 7 | return (key) => { 8 | if (!source[key]) { 9 | throw new Error(`There is no translation for ${key} provided`); 10 | } 11 | 12 | // Return translation if exists otherwise default to English 13 | return source[key][code] ?? source[key].en; 14 | } 15 | } -------------------------------------------------------------------------------- /src/app/unathenticated-app/main-content.js: -------------------------------------------------------------------------------- 1 | import { Box } from '@material-ui/core'; 2 | 3 | /** 4 | * Main content wrapper 5 | */ 6 | function MainContent({ children }) { 7 | return ( 8 | 19 | {children} 20 | 21 | ); 22 | } 23 | 24 | export { MainContent }; 25 | -------------------------------------------------------------------------------- /src/app/app.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useAuth } from 'context/auth-context'; 4 | import { UnathenticatedApp } from './unathenticated-app'; 5 | import { AuthenticatedAppProviders } from 'context'; 6 | import { AuthenticatedApp } from './authenticated-app'; 7 | 8 | function App() { 9 | const { user } = useAuth(); 10 | 11 | return user ? ( 12 | 13 | 14 | 15 | ) : ( 16 | 17 | ); 18 | } 19 | 20 | export { App }; 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/app/unathenticated-app/app-title.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, Button, Typography } from '@material-ui/core'; 3 | import { Link as RouterLink } from 'react-router-dom'; 4 | 5 | /** 6 | * Displays app name as a link to `/` route 7 | */ 8 | function AppTitle() { 9 | return ( 10 | 16 | 19 | 20 | ); 21 | } 22 | 23 | export { AppTitle }; 24 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Habit Tracker 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { App } from 'app'; 4 | import { AppProviders } from 'context'; 5 | import reportWebVitals from './reportWebVitals'; 6 | import 'fontsource-roboto/300.css'; 7 | import 'fontsource-roboto/400.css'; 8 | import 'fontsource-roboto/500.css'; 9 | import 'fontsource-roboto/700.css'; 10 | 11 | render( 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ); 17 | 18 | // If you want to start measuring performance in your app, pass a function 19 | // to log results (for example: reportWebVitals(console.log)) 20 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 21 | reportWebVitals(); 22 | -------------------------------------------------------------------------------- /src/context/firebase-context.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import firebase from 'api/firebase'; 3 | 4 | // Context 5 | const FirebaseContext = React.createContext(null); 6 | FirebaseContext.displayName = 'FirebaseContext'; 7 | 8 | // Provider 9 | const FirebaseProvider = ({ children }) => { 10 | return ( 11 | 12 | {children} 13 | 14 | ); 15 | }; 16 | 17 | // Hook 18 | function useFirebase() { 19 | const context = React.useContext(FirebaseContext); 20 | 21 | if (context === undefined) { 22 | throw new Error('useFirebase must be used within FirebaseProvider'); 23 | } 24 | 25 | return context; 26 | } 27 | 28 | export { FirebaseProvider, useFirebase }; 29 | -------------------------------------------------------------------------------- /src/components/sign-in-as-guest-button.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Incognito as IncognitoIcon } from 'mdi-material-ui'; 3 | import { AuthProviderButton } from './auth-providers-list'; 4 | import { useTheme } from '@material-ui/core'; 5 | import { blueGrey } from '@material-ui/core/colors'; 6 | 7 | function SignInAsGuestButton({ label, ...props }) { 8 | const { palette } = useTheme(); 9 | 10 | return ( 11 | } 14 | variant={palette.mode === 'dark' ? 'contained' : 'outlined'} 15 | fullWidth 16 | {...props} 17 | > 18 | {label} 19 | 20 | ) 21 | } 22 | 23 | export { SignInAsGuestButton } -------------------------------------------------------------------------------- /src/app/authenticated-app/main-content.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, Toolbar } from '@material-ui/core'; 3 | 4 | function MainContent({ children }) { 5 | return ( 6 | 14 | {/* Toolbar spacer */} 15 | 16 | 17 | {/* Content */} 18 | 27 | {children} 28 | 29 | 30 | ); 31 | } 32 | 33 | export { MainContent }; 34 | -------------------------------------------------------------------------------- /src/icons/poland.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/app/unathenticated-app/footer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box } from '@material-ui/core'; 3 | import { useMatch } from 'react-router'; 4 | 5 | const landing = { 6 | color: 'common.white', 7 | bgColor: 'transparent', 8 | opacity: 0.7, 9 | }; 10 | const authentication = { 11 | color: { xs: 'text.secondary', sm: 'common.white' }, 12 | bgcolor: { xs: 'background.paper', sm: 'transparent' }, 13 | opacity: { sm: 0.7 }, 14 | }; 15 | 16 | /** 17 | * It changes `color` and `bgcolor` when the screen size is `xs` 18 | */ 19 | function Footer({ children }) { 20 | const matches = useMatch('/'); 21 | 22 | const style = matches ? landing : authentication; 23 | 24 | return ( 25 | 32 | {children} 33 | 34 | ); 35 | } 36 | 37 | export { Footer }; 38 | -------------------------------------------------------------------------------- /src/api/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | 3 | import 'firebase/auth'; 4 | import 'firebase/database'; 5 | import 'firebase/firestore'; 6 | 7 | const firebaseConfig = { 8 | apiKey: process.env.REACT_APP_API_KEY, 9 | authDomain: process.env.REACT_APP_AUTH_DOMAIN, 10 | databaseURL: process.env.REACT_APP_DATABASE_URL, 11 | projectId: process.env.REACT_APP_PROJECT_ID, 12 | storageBucket: process.env.REACT_APP_STORAGE_BUCKET, 13 | messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID, 14 | appId: process.env.REACT_APP_APP_ID, 15 | measurementId: process.env.REACT_APP_MEASUREMENT_ID, 16 | }; 17 | 18 | if (!firebase.apps.length) { 19 | firebase.initializeApp(firebaseConfig); 20 | } else { 21 | firebase.app(); 22 | } 23 | 24 | const app = { 25 | firebase, 26 | auth: firebase.auth(), 27 | firestore: firebase.firestore(), 28 | db: firebase.database(), 29 | }; 30 | 31 | export default app; 32 | -------------------------------------------------------------------------------- /src/app/unathenticated-app/navbar.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { AppBar, Box, Button, Toolbar } from '@material-ui/core'; 3 | import { Link as RouterLink } from 'react-router-dom'; 4 | 5 | /** 6 | * Navbar wrapper 7 | */ 8 | function Navbar({ children }) { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | 16 | /** 17 | * Uses `margin-rigth: auto` to push other elements to the right. 18 | */ 19 | function NavbarStartItem({ children }) { 20 | return {children}; 21 | } 22 | 23 | /** 24 | * `MuiButton` with `RouterLink` as component 25 | */ 26 | function NavbarRouterLink(props) { 27 | return ( 28 | 73 | 74 | 75 | 76 | ); 77 | } 78 | 79 | export { AccountTab }; 80 | -------------------------------------------------------------------------------- /src/components/line-chart.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ResponsiveLine } from '@nivo/line'; 3 | const curveOptions = ['linear', 'monotoneX', 'step', 'stepBefore', 'stepAfter'] 4 | export function LineChart({ data, height }) { 5 | return ( 6 |
7 | -.0%", 35 | tickValues: [-1, -.75, -.5, -.25, 0, .25, .5, .75, 1], 36 | tickSize: 5, 37 | tickPadding: 5, 38 | tickRotation: 0, 39 | }} 40 | useMesh={true} 41 | crosshairType="cross" 42 | lineWidth={4} 43 | enableArea={true} 44 | areaOpacity={0.5} 45 | enablePoints={false} 46 | enableGridX={false} 47 | enableGridY={false} 48 | /> 49 |
50 | ); 51 | } 52 | 53 | // 84 | -------------------------------------------------------------------------------- /src/utils/chart-helpers.js: -------------------------------------------------------------------------------- 1 | import { COMPLETED, FAILED } from 'data/constants'; 2 | 3 | /* 4 | The data that comes from database is an object where 5 | each key is a habit. 6 | 7 | data = 8 | { 9 | "habit-id-1": 10 | { 11 | "2020-10-11": -1, 12 | "2020-10-12": 1, 13 | "2020-10-13": -1, 14 | }, 15 | "habit-id-2": 16 | { 17 | "2020-10-11": -1, 18 | "2020-10-12": 1, 19 | "2020-10-13": -1, 20 | } 21 | } 22 | 23 | Each habit is an object where keys are dates, and values 24 | are completion state of the checkmark 25 | */ 26 | 27 | 28 | 29 | 30 | /** 31 | * Aggregates all the habits checkmarks 32 | * 33 | */ 34 | function aggregate(habits) { 35 | let obj = {}; 36 | 37 | // Iterate over each habit 38 | Object.values(habits).forEach((checkmarks) => { 39 | // Iterate over each habit's checkmarks 40 | Object.entries(checkmarks).forEach(([date, value]) => { 41 | if (obj[date] === undefined) { 42 | // If there is no date in helper object yet, add it 43 | obj[date] = [value]; 44 | } else { 45 | // The date already exists so concat the value 46 | obj[date] = obj[date].concat(value); 47 | } 48 | }); 49 | }); 50 | 51 | return obj; 52 | } 53 | 54 | /** 55 | * Prepares the checkmarks to be used in heatmap chart 56 | * 57 | */ 58 | function heatmapDataFrom(checkmarks) { 59 | return Object.entries(checkmarks).map(([date, values]) => { 60 | const avg = values.reduce((sum, val) => sum + val, 0) / values.length; 61 | 62 | return { 63 | day: date, 64 | value: avg, 65 | }; 66 | }); 67 | } 68 | 69 | /** 70 | * Prepares the checkmarks to be used in bar chart 71 | */ 72 | function barchartDataFrom(checkmarks, dates) { 73 | return dates.map((date) => { 74 | let values = checkmarks[date] || null; 75 | 76 | return { 77 | date, 78 | completed: values ? countValues(values, COMPLETED) : null, 79 | failed: values ? -countValues(values, FAILED) : null, 80 | }; 81 | }); 82 | } 83 | 84 | /** 85 | * Counts the occurences of given value in the array 86 | * 87 | * @param {Number[]} arr source array of numbers 88 | * @param {Number} value a value to count 89 | * 90 | */ 91 | function countValues(arr, value) { 92 | return arr.reduce((acc, cur) => (cur === value ? acc + 1 : acc), 0); 93 | } 94 | 95 | export { barchartDataFrom, heatmapDataFrom, aggregate }; 96 | -------------------------------------------------------------------------------- /src/data/guest.js: -------------------------------------------------------------------------------- 1 | import { eachDayOfInterval, format, getDay, parseISO, sub } from 'date-fns'; 2 | import { COMPLETED, FAILED } from './constants'; 3 | 4 | const habits = [ 5 | { 6 | id: 'reading', 7 | description: 'Read a book for at least 30 min', 8 | frequency: [1, 2, 3, 4, 5], 9 | name: 'Reading', 10 | }, 11 | { 12 | id: 'get-up', 13 | description: "Don't hit that snooze button!", 14 | frequency: [1, 2, 4, 3, 5, 6], 15 | name: 'Get up at 6 am', 16 | }, 17 | { 18 | id: 'running', 19 | description: 'Run at least 10km', 20 | frequency: [1, 3, 5], 21 | name: 'Running', 22 | }, 23 | { 24 | id: 'meditation', 25 | description: 'Calm your mind before going to sleep', 26 | frequency: [0, 1, 2, 3, 4, 5, 6], 27 | name: 'Meditation', 28 | }, 29 | { 30 | id: 'guitar', 31 | description: 'Practice playing the guitar', 32 | frequency: [2, 4, 6], 33 | name: 'Guitar', 34 | }, 35 | ]; 36 | 37 | /** 38 | * Generates an array of checkmarks with random value. 39 | * 40 | * @param {string[]} dates - The collection of dates to use. 41 | * @param {habit[]} habits - The collection of habits to use. 42 | */ 43 | function generateRandomCheckmarks(dates, habits) { 44 | const checkmarks = []; 45 | dates.forEach((date) => { 46 | habits.forEach((habit) => { 47 | if (habit.frequency.includes(getDay(parseISO(date)))) { 48 | checkmarks.push({ 49 | id: `${habit.id}-${date}`, 50 | habitId: habit.id, 51 | date, 52 | value: Math.random() > .25 ? COMPLETED : FAILED, 53 | }); 54 | } 55 | }); 56 | }); 57 | 58 | return checkmarks; 59 | } 60 | 61 | const end = new Date(); 62 | const start = sub(end, { days: 14 }); 63 | 64 | const dates = eachDayOfInterval({ start, end }).map((d) => 65 | format(d, 'yyyy-MM-dd') 66 | ); 67 | 68 | // Random checkmarks for last 14 days 69 | const checkmarks = generateRandomCheckmarks(dates, habits); 70 | 71 | // Converts array of objects to an object using each object `id` as key 72 | function convertToObj(array) { 73 | return array.reduce((acc, { id, ...obj }) => { 74 | acc[id] = obj; 75 | return acc; 76 | }, {}); 77 | } 78 | 79 | // Convert the data so it can be used in the database. 80 | const dbHabits = convertToObj(habits); 81 | const dbCheckmarks = convertToObj(checkmarks); 82 | 83 | export { habits, dbHabits, checkmarks, dbCheckmarks }; 84 | -------------------------------------------------------------------------------- /src/icons/united-states.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/api/user-data.js: -------------------------------------------------------------------------------- 1 | import { useFirebase } from 'context/firebase-context'; 2 | import { useAuth } from 'context/auth-context'; 3 | import { useQueryClient } from 'react-query'; 4 | 5 | /** 6 | * Use update performance goal hook 7 | * 8 | * @returns a function that updates user's performance goal in the database. 9 | * The function takes as an argument a new performance goal value. 10 | */ 11 | export function useUpdatePerformanceGoal() { 12 | const { db } = useFirebase(); 13 | const { user } = useAuth(); 14 | 15 | return (newPerformanceGoal) => { 16 | return db.ref(`users/${user.uid}/performanceGoal`).set(newPerformanceGoal); 17 | }; 18 | } 19 | 20 | /** 21 | * Use update locale code hook 22 | * 23 | * @returns a function that updates user's locale code in the database. 24 | * The function takes as an argument new locale code. 25 | */ 26 | export function useUpdateLocaleCode() { 27 | const { user } = useAuth(); 28 | const { db } = useFirebase(); 29 | 30 | return (newLocaleCode) => { 31 | return db.ref(`users/${user.uid}/locale/code`).set(newLocaleCode); 32 | }; 33 | } 34 | 35 | /** 36 | * Use delete user data hook 37 | * 38 | * @returns a function that deletes user's data. It deletes user's data, 39 | * habits and checkmarks. 40 | */ 41 | export function useDeleteUserData() { 42 | const { user } = useAuth(); 43 | const { db } = useFirebase(); 44 | const queryClient = useQueryClient(); 45 | 46 | return () => { 47 | const updates = {}; 48 | 49 | updates[`habits/${user.uid}`] = null; 50 | updates[`checkmarks/${user.uid}`] = null; 51 | updates[`users/${user.uid}`] = null; 52 | 53 | return db.ref().update(updates).then(() => { 54 | queryClient.invalidateQueries(); 55 | }); 56 | }; 57 | } 58 | 59 | /** 60 | * Returns a function that updates the user data in the database. 61 | */ 62 | export function useUpdateUserData() { 63 | const { user } = useAuth(); 64 | const { db } = useFirebase(); 65 | 66 | /** 67 | * Updates the user data in the database. 68 | * 69 | * @param { {checkmarks: string[]} } 70 | */ 71 | const updateUserData = ({ checkmarks, habits, settings }) => { 72 | const updates = {}; 73 | 74 | if (checkmarks) updates[`checkmarks/${user.uid}`] = checkmarks; 75 | if (habits) updates[`habits/${user.uid}`] = habits; 76 | if (settings) updates[`users/${user.uid}`] = settings; 77 | 78 | return db.ref().update(updates); 79 | }; 80 | 81 | return updateUserData; 82 | } 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "habit-tracker", 3 | "version": "1.0.0-alpha.3", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.1.1", 7 | "@emotion/styled": "^11.0.0", 8 | "@hookform/resolvers": "^1.0.0", 9 | "@material-ui/core": "^5.0.0-alpha.17", 10 | "@material-ui/icons": "^4.11.2", 11 | "@material-ui/lab": "^5.0.0-alpha.17", 12 | "@material-ui/styles": "^4.11.1", 13 | "@nivo/bar": "^0.64.0", 14 | "@nivo/calendar": "^0.64.0", 15 | "@nivo/line": "^0.64.0", 16 | "@nivo/pie": "^0.65.1", 17 | "@testing-library/jest-dom": "^5.11.4", 18 | "@testing-library/react": "^11.1.0", 19 | "@testing-library/user-event": "^12.1.10", 20 | "camelcase": "^6.2.0", 21 | "clsx": "^1.1.1", 22 | "date-fns": "^2.16.1", 23 | "firebase": "^8.0.0", 24 | "fontsource-roboto": "^3.1.5", 25 | "history": "^5.0.0", 26 | "lodash": "^4.17.20", 27 | "mdi-material-ui": "^6.20.0", 28 | "react": "^17.0.1", 29 | "react-dom": "^17.0.1", 30 | "react-error-boundary": "^3.0.2", 31 | "react-hook-form": "^6.10.1", 32 | "react-icons": "^4.1.0", 33 | "react-query": "^3.2.0", 34 | "react-router": "^6.0.0-beta.0", 35 | "react-router-dom": "^6.0.0-beta.0", 36 | "react-scripts": "4.0.0", 37 | "react-swipeable-views": "^0.13.9", 38 | "web-vitals": "^0.2.4", 39 | "yup": "^0.29.3" 40 | }, 41 | "scripts": { 42 | "start": "react-scripts start", 43 | "build": "react-scripts build", 44 | "test": "react-scripts test", 45 | "eject": "react-scripts eject" 46 | }, 47 | "jest": { 48 | "collectCoverageFrom": [ 49 | "src/**/*.{js,jsx,ts,tsx}", 50 | "!/node_modules/", 51 | "!/path/to/dir/" 52 | ], 53 | "coverageThreshold": { 54 | "global": { 55 | "branches": 90, 56 | "functions": 90, 57 | "lines": 90, 58 | "statements": 90 59 | } 60 | }, 61 | "coverageReporters": [ 62 | "html", 63 | "text" 64 | ] 65 | }, 66 | "eslintConfig": { 67 | "extends": [ 68 | "react-app", 69 | "react-app/jest" 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 | "devDependencies": { 85 | "@material-ui/codemod": "^5.0.0-alpha.17" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/components/checkbox-group.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Controller } from 'react-hook-form'; 4 | import { 5 | Checkbox, 6 | FormControl, 7 | FormControlLabel, 8 | FormGroup, 9 | FormLabel, 10 | makeStyles, 11 | } from '@material-ui/core'; 12 | 13 | // Styles 14 | const useStyles = makeStyles({ 15 | row: { 16 | justifyContent: 'space-around', 17 | }, 18 | label: { 19 | // 14px is a value taken from .MuiOutlinedInput-input 20 | // so that the label is aligned equally to other labels 21 | padding: '0 14px', 22 | }, 23 | 24 | disableMargin: { 25 | marginLeft: 0, 26 | marginRight: 0, 27 | }, 28 | }); 29 | 30 | function CheckboxGroup({ control, name, getValues, error, label, values }) { 31 | const classes = useStyles(); 32 | 33 | const handleCheckbox = clickedValue => { 34 | const checkedValues = getValues()[name]; 35 | 36 | const newValues = checkedValues?.includes(clickedValue) 37 | ? checkedValues?.filter(v => v !== clickedValue) 38 | : [...(checkedValues ?? []), clickedValue]; 39 | 40 | return newValues; 41 | }; 42 | 43 | return ( 44 | 45 | 46 | {label} 47 | 48 | 49 | 53 | values.map((v, i) => ( 54 | props.onChange(handleCheckbox(i))} 58 | checked={props.value.includes(i)} 59 | /> 60 | } 61 | key={v} 62 | label={v.slice(0, 3)} 63 | labelPlacement="bottom" 64 | className={classes.disableMargin} 65 | /> 66 | )) 67 | } 68 | /> 69 | 70 | 71 | ); 72 | } 73 | 74 | CheckboxGroup.propTypes = { 75 | // React Hook Form 76 | control: PropTypes.object.isRequired, 77 | name: PropTypes.string.isRequired, 78 | getValues: PropTypes.func.isRequired, 79 | 80 | // Array of values that is mapped over to render checkboxes 81 | values: PropTypes.array.isRequired, 82 | 83 | // Properties 84 | error: PropTypes.bool, 85 | label: PropTypes.string.isRequired, 86 | }; 87 | 88 | export { CheckboxGroup }; 89 | -------------------------------------------------------------------------------- /src/components/checkmark.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { IconButton } from '@material-ui/core'; 3 | import { 4 | CheckBox as CompletedCheckmarkIcon, 5 | CheckBoxOutlineBlank as EmptyCheckmarkIcon, 6 | IndeterminateCheckBox as FailedCheckmarkIcon, 7 | } from '@material-ui/icons'; 8 | import { useUpdateCheckmarkInDbMutate } from 'api/checkmarks'; 9 | import { COMPLETED, EMPTY, FAILED } from 'data/constants'; 10 | import { debounce } from 'lodash'; 11 | 12 | const variants = { 13 | completed: { 14 | icon: , 15 | label: 'completed', 16 | color: 'primary', 17 | }, 18 | failed: { 19 | icon: , 20 | label: 'failed', 21 | color: 'secondary', 22 | }, 23 | empty: { 24 | icon: , 25 | label: 'empty', 26 | color: 'default', 27 | }, 28 | }; 29 | 30 | function Checkmark({ id, initialValue, habitId, date, disabled }) { 31 | const [value, setValue] = React.useState(initialValue); 32 | 33 | React.useEffect(() => { 34 | setValue(initialValue); 35 | }, [initialValue]); 36 | 37 | const updateCheckmarkInDbMutate = useUpdateCheckmarkInDbMutate(); 38 | 39 | // Debounced update function 40 | const debouncedUpdate = React.useRef( 41 | debounce(({ id, newValue }) => { 42 | updateCheckmarkInDbMutate({ 43 | checkmarkId: id, 44 | value: newValue, 45 | habitId, 46 | date, 47 | }); 48 | }, 200) 49 | ).current; 50 | 51 | // Handles clicking on checkmark 52 | const handleClick = () => { 53 | const newValue = getNewValue(value); 54 | 55 | // Update the value locally, so that the icon changes 56 | setValue(newValue); 57 | // Update is debounced so when user is clicking very fast on the checkmark 58 | // only the last call will be invoked to hit the database. 59 | debouncedUpdate({ id, newValue }); 60 | }; 61 | 62 | const { icon, label, color } = variants[value]; 63 | 64 | return ( 65 | 71 | {icon} 72 | 73 | ); 74 | } 75 | 76 | function getNewValue(currentValue) { 77 | const values = [COMPLETED, FAILED, EMPTY]; 78 | 79 | return values[(values.indexOf(currentValue) + 1) % values.length]; 80 | } 81 | 82 | Checkmark.propTypes = { 83 | // initialValue: PropTypes.oneOf(CHECKMARK_VALUES).isRequired, 84 | // onNewValue: PropTypes.func.isRequired, 85 | // disabled: PropTypes.bool, 86 | }; 87 | 88 | export { Checkmark }; 89 | -------------------------------------------------------------------------------- /src/utils/hooks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Code by Kent C. Dodds 3 | * 4 | * https://github.com/kentcdodds/bookshelf/blob/main/src/utils/hooks.js 5 | */ 6 | 7 | import * as React from 'react'; 8 | 9 | function useSafeDispatch(dispatch) { 10 | const mounted = React.useRef(false); 11 | 12 | React.useLayoutEffect(() => { 13 | mounted.current = true; 14 | 15 | return () => (mounted.current = false); 16 | }, []); 17 | 18 | return React.useCallback( 19 | (...args) => (mounted.current ? dispatch(...args) : void 0), 20 | [dispatch] 21 | ); 22 | } 23 | 24 | // Example usage: 25 | // const {data, error, status, run} = useAsync() 26 | // React.useEffect(() => { 27 | // run(fetchPokemon(pokemonName)) 28 | // }, [pokemonName, run]) 29 | const defaultInitialState = { status: 'idle', data: null, error: null }; 30 | function useAsync(initialState) { 31 | const initialStateRef = React.useRef({ 32 | ...defaultInitialState, 33 | ...initialState, 34 | }); 35 | const [{ status, data, error }, setState] = React.useReducer( 36 | (state, action) => ({ ...state, ...action }), 37 | initialStateRef.current 38 | ); 39 | 40 | const safeSetState = useSafeDispatch(setState); 41 | 42 | const setData = React.useCallback( 43 | data => safeSetState({ data, status: 'resolved' }), 44 | [safeSetState] 45 | ); 46 | const setError = React.useCallback( 47 | error => safeSetState({ error, status: 'rejected' }), 48 | [safeSetState] 49 | ); 50 | const reset = React.useCallback(() => safeSetState(initialStateRef.current), [ 51 | safeSetState, 52 | ]); 53 | 54 | const run = React.useCallback( 55 | promise => { 56 | if (!promise || !promise.then) { 57 | throw new Error( 58 | `The argument passed to useAsync().run must be a promise. Maybe a function that's passed isn't returning anything?` 59 | ); 60 | } 61 | safeSetState({ status: 'pending' }); 62 | return promise.then( 63 | data => { 64 | setData(data); 65 | return data; 66 | }, 67 | error => { 68 | setError(error); 69 | return Promise.reject(error); 70 | } 71 | ); 72 | }, 73 | [safeSetState, setData, setError] 74 | ); 75 | 76 | return { 77 | // using the same names that react-query uses for convenience 78 | isIdle: status === 'idle', 79 | isLoading: status === 'pending', 80 | isError: status === 'rejected', 81 | isSuccess: status === 'resolved', 82 | 83 | setData, 84 | setError, 85 | error, 86 | status, 87 | data, 88 | run, 89 | reset, 90 | }; 91 | } 92 | 93 | export { useAsync }; 94 | -------------------------------------------------------------------------------- /src/components/mobile-menu.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { IconButton, Menu, MenuItem } from '@material-ui/core'; 3 | import { MoreVert as MoreVertIcon } from '@material-ui/icons'; 4 | 5 | const MobileMenuContext = React.createContext(); 6 | 7 | /** 8 | * Provides a functionality for mobile menu. 9 | */ 10 | function MobileMenuProvider({ children }) { 11 | const [anchorEl, setAnchorEl] = React.useState(null); 12 | const isMenuOpen = Boolean(anchorEl); 13 | 14 | const onMenuClick = (event) => { 15 | setAnchorEl(event.currentTarget); 16 | }; 17 | 18 | const onClose = () => { 19 | setAnchorEl(null); 20 | }; 21 | 22 | const context = { 23 | anchorEl, 24 | isMenuOpen, 25 | onMenuClick, 26 | onClose, 27 | }; 28 | 29 | return ( 30 | 31 | {children} 32 | 33 | ); 34 | } 35 | 36 | /** 37 | * Convienient access to `MobileMenuContext`. 38 | */ 39 | function useMobileMenu() { 40 | const context = React.useContext(MobileMenuContext); 41 | 42 | if (context === undefined) { 43 | throw new Error('useMobileMenu must be used within MobileMenuProvider'); 44 | } 45 | 46 | return context; 47 | } 48 | 49 | /** 50 | * Mobile menu wrapper 51 | */ 52 | function MobileMenu({ children }) { 53 | const { anchorEl, isMenuOpen, onClose } = useMobileMenu(); 54 | 55 | return ( 56 | 63 | {children} 64 | 65 | ); 66 | } 67 | 68 | /** 69 | * Mobile menu item 70 | */ 71 | const MobileMenuItem = React.forwardRef(function MobileMenuItem(props, ref) { 72 | const { children, ...other } = props; 73 | 74 | const { onClose } = useMobileMenu(); 75 | 76 | return ( 77 | 82 | {children} 83 | 84 | ); 85 | }); 86 | 87 | /** 88 | * Toggles `` visibility. 89 | * 90 | * Is hidden on screens above `xs` 91 | */ 92 | function MobileMenuToggler() { 93 | const { onMenuClick } = useMobileMenu(); 94 | 95 | return ( 96 | 103 | 104 | 105 | ); 106 | } 107 | 108 | export { MobileMenuProvider, MobileMenu, MobileMenuItem, MobileMenuToggler }; 109 | -------------------------------------------------------------------------------- /src/screens/reset-password.js: -------------------------------------------------------------------------------- 1 | import { yupResolver } from '@hookform/resolvers/yup'; 2 | import { useForm } from 'react-hook-form'; 3 | import { TextField } from '@material-ui/core'; 4 | import { useAuth } from 'context/auth-context'; 5 | import { useSnackbar } from 'context/snackbar-context'; 6 | import { resetPasswordSchema } from 'data/constraints'; 7 | import { useAsync } from 'utils/hooks'; 8 | import { 9 | Form, 10 | FormBody, 11 | FormButton, 12 | FormErrorText, 13 | FormHeader, 14 | FormLink, 15 | FormPrimaryText, 16 | FormSecondaryText, 17 | } from 'components/form'; 18 | import { useTranslation } from 'translations'; 19 | 20 | /** 21 | * Reset Password Screen 22 | * 23 | * Here the user can reset their password by entering their email address. 24 | */ 25 | function ResetPasswordScreen() { 26 | const t = useTranslation(); 27 | const { resetPassword } = useAuth(); 28 | const { openSnackbar } = useSnackbar(); 29 | const { isLoading, isError: isAuthError, error: authError, run } = useAsync(); 30 | 31 | const { register, handleSubmit, errors, reset } = useForm({ 32 | resolver: yupResolver(resetPasswordSchema), 33 | }); 34 | 35 | const onSubmit = ({ email }) => { 36 | run( 37 | resetPassword({ email }).then(() => { 38 | openSnackbar('success', `Sent password reset email to ${email}`); 39 | }) 40 | ); 41 | reset(); 42 | }; 43 | 44 | const errorMessages = Object.values(errors); 45 | const isError = isAuthError || errorMessages.length !== 0; 46 | const errorMessage = authError?.message || errorMessages[0]?.message; 47 | 48 | return ( 49 |
50 | 51 | {t('resetPassword')} 52 | {t('resetPasswordDescription')} 53 | {isError ? errorMessage : ' '} 54 | 55 | 56 | 57 | 68 | 69 | {t('resetPasswordButton')} 70 | 71 | 72 | 73 | {t('hasAccountQuestion')}{' '} 74 | {t('signIn')} 75 | 76 | 77 |
78 | ); 79 | } 80 | 81 | export { ResetPasswordScreen }; 82 | -------------------------------------------------------------------------------- /src/components/lib.js: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress, Typography } from '@material-ui/core'; 2 | import { ReactComponent as BugFixingSvg } from 'images/bug-fixing.svg'; 3 | import PropTypes from 'prop-types'; 4 | 5 | function ErrorMessage({ error, ...sx }) { 6 | return ( 7 | 14 | There was an error 15 | 24 | {error.message} 25 | 26 | 27 | ); 28 | } 29 | 30 | // Full page spinner 31 | function FullPageSpinner() { 32 | return ( 33 | 41 | 42 | 43 | ); 44 | } 45 | 46 | // Full page error fallback 47 | function FullPageErrorFallback({ error }) { 48 | return ( 49 | 59 | 67 | 68 | 69 | 70 | 75 | 76 | Uh oh... There's a problem. Try refreshing the app. 77 | 78 | 79 | 80 | 85 | {error.message} 86 | 87 | 88 | ); 89 | } 90 | 91 | function ErrorFallback({ error }) { 92 | return ( 93 | 103 | ); 104 | } 105 | 106 | FullPageErrorFallback.propTypes = { 107 | error: PropTypes.object.isRequired, 108 | }; 109 | 110 | export { ErrorFallback, FullPageSpinner, FullPageErrorFallback }; 111 | -------------------------------------------------------------------------------- /src/theme/colors.js: -------------------------------------------------------------------------------- 1 | import camelCase from 'camelcase'; 2 | 3 | import { 4 | red, 5 | pink, 6 | purple, 7 | deepPurple, 8 | indigo, 9 | blue, 10 | lightBlue, 11 | cyan, 12 | teal, 13 | green, 14 | lightGreen, 15 | lime, 16 | yellow, 17 | amber, 18 | orange, 19 | deepOrange, 20 | brown, 21 | grey as gray, 22 | blueGrey as blueGray, 23 | } from "@material-ui/core/colors"; 24 | 25 | export function getColor(colorId) { 26 | if (!colorId) { 27 | return null; 28 | } 29 | 30 | colorId = camelCase(colorId); 31 | 32 | return colors[colorId]; 33 | }; 34 | 35 | export const colors = { 36 | red: { 37 | id: "red", 38 | name: "Red", 39 | import: red, 40 | }, 41 | 42 | pink: { 43 | id: "pink", 44 | name: "Pink", 45 | import: pink, 46 | }, 47 | 48 | purple: { 49 | id: "purple", 50 | name: "Purple", 51 | import: purple, 52 | }, 53 | 54 | deepPurple: { 55 | id: "deep-purple", 56 | name: "Deep Purple", 57 | import: deepPurple, 58 | }, 59 | 60 | indigo: { 61 | id: "indigo", 62 | name: "Indigo", 63 | import: indigo, 64 | }, 65 | 66 | blue: { 67 | id: "blue", 68 | name: "Blue", 69 | import: blue, 70 | }, 71 | 72 | lightBlue: { 73 | id: "light-blue", 74 | name: "Light Blue", 75 | import: lightBlue, 76 | }, 77 | 78 | cyan: { 79 | id: "cyan", 80 | name: "Cyan", 81 | import: cyan, 82 | }, 83 | 84 | teal: { 85 | id: "teal", 86 | name: "Teal", 87 | import: teal, 88 | }, 89 | 90 | green: { 91 | id: "green", 92 | name: "Green", 93 | import: green, 94 | }, 95 | 96 | lightGreen: { 97 | id: "light-green", 98 | name: "Light Green", 99 | import: lightGreen, 100 | }, 101 | 102 | lime: { 103 | id: "lime", 104 | name: "Lime", 105 | import: lime, 106 | }, 107 | 108 | yellow: { 109 | id: "yellow", 110 | name: "Yellow", 111 | import: yellow, 112 | }, 113 | 114 | amber: { 115 | id: "amber", 116 | name: "Amber", 117 | import: amber, 118 | }, 119 | 120 | orange: { 121 | id: "orange", 122 | name: "Orange", 123 | import: orange, 124 | }, 125 | 126 | deepOrange: { 127 | id: "deep-orange", 128 | name: "Deep Orange", 129 | import: deepOrange, 130 | }, 131 | 132 | brown: { 133 | id: "brown", 134 | name: "Brown", 135 | import: brown, 136 | }, 137 | 138 | gray: { 139 | id: "gray", 140 | name: "Gray", 141 | import: gray, 142 | }, 143 | 144 | blueGray: { 145 | id: "blue-gray", 146 | name: "Blue Gray", 147 | import: blueGray, 148 | }, 149 | }; -------------------------------------------------------------------------------- /src/icons/united-kingdom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 24 | 26 | 28 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/components/locale-select.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { locales, useLocale } from 'localization'; 4 | import { 5 | Menu, 6 | MenuItem, 7 | ListItemIcon, 8 | SvgIcon, 9 | Typography, 10 | IconButton, 11 | Tooltip, 12 | } from '@material-ui/core'; 13 | import { useTranslation } from 'translations'; 14 | import { MobileMenuItem } from 'components/mobile-menu'; 15 | import TranslateIcon from '@material-ui/icons/Translate'; 16 | 17 | function LocaleSelect({ variant = 'icon', onLocaleClick = () => {} }) { 18 | const { code: selectedCode, setLocaleByCode } = useLocale(); 19 | const t = useTranslation(); 20 | 21 | // Open/close menu 22 | const [anchorEl, setAnchorEl] = React.useState(null); 23 | const openMenu = (event) => { 24 | setAnchorEl(event.currentTarget); 25 | }; 26 | const closeMenu = () => { 27 | setAnchorEl(null); 28 | }; 29 | 30 | const handleMenuItemClick = (clickedLocaleCode) => { 31 | onLocaleClick(clickedLocaleCode); 32 | setLocaleByCode(clickedLocaleCode); 33 | closeMenu(); 34 | }; 35 | 36 | return ( 37 | <> 38 | {variant === 'icon' && ( 39 | 40 | 47 | 48 | 49 | 50 | )} 51 | 52 | {variant === 'item' && ( 53 | 54 | 59 | 60 | 61 |

{t('selectLanguage')}

62 |
63 | )} 64 | 65 | 72 | {locales.map(({ code, label, icon }) => ( 73 | handleMenuItemClick(code)} 77 | > 78 | 79 | {icon} 80 | 81 | {label} 82 | 83 | ))} 84 | 85 | 86 | ); 87 | } 88 | 89 | LocaleSelect.propTypes = { 90 | variant: PropTypes.oneOf(['icon', 'item']), 91 | onLocaleClick: PropTypes.func, 92 | }; 93 | 94 | export { LocaleSelect }; 95 | -------------------------------------------------------------------------------- /src/api/appearance.js: -------------------------------------------------------------------------------- 1 | import { useFirebase } from 'context/firebase-context'; 2 | import { useAuth } from 'context/auth-context'; 3 | import { getColor } from 'theme'; 4 | 5 | /** 6 | * Use update theme hook 7 | * 8 | * @returns a function that updates user's theme in the database. 9 | * The function takes as an argument new theme object. 10 | */ 11 | export function useUpdateTheme() { 12 | const { db } = useFirebase(); 13 | const { user } = useAuth(); 14 | 15 | return (newTheme) => { 16 | let primaryColor = newTheme.primaryColor; 17 | let secondaryColor = newTheme.secondaryColor; 18 | let dark = newTheme.dark; 19 | 20 | primaryColor = getColor(primaryColor); 21 | secondaryColor = getColor(secondaryColor); 22 | 23 | return db.ref(`users/${user.uid}/theme`).set({ 24 | primaryColor: primaryColor.id, 25 | secondaryColor: secondaryColor.id, 26 | dark: dark, 27 | }); 28 | }; 29 | } 30 | 31 | /** 32 | * Use update primary color hook 33 | * 34 | * @returns a function that updates user's primary color in the database. 35 | * The function takes as an argument new primary color object. 36 | */ 37 | export function useUpdatePrimaryColor() { 38 | const { db } = useFirebase(); 39 | const { user } = useAuth(); 40 | 41 | return (newPrimaryColor) => { 42 | newPrimaryColor = getColor(newPrimaryColor); 43 | 44 | return db 45 | .ref(`users/${user.uid}/theme/primaryColor`) 46 | .set(newPrimaryColor.id); 47 | }; 48 | } 49 | 50 | /** 51 | * Use update secondary color hook 52 | * 53 | * @returns a function that updates user's secondary color in the database. 54 | * The function takes as an argument new secondary color object. 55 | */ 56 | export function useUpdateSecondaryColor() { 57 | const { db } = useFirebase(); 58 | const { user } = useAuth(); 59 | 60 | return (newSecondaryColor) => { 61 | newSecondaryColor = getColor(newSecondaryColor); 62 | 63 | return db 64 | .ref(`users/${user.uid}/theme/secondaryColor`) 65 | .set(newSecondaryColor.id); 66 | }; 67 | } 68 | 69 | /** 70 | * Use update dark mode hook 71 | * 72 | * @returns a function that updates user's dark mode settings in the database. 73 | * The function takes as an argument new dark mode value (boolean). 74 | */ 75 | export function useUpdateDarkMode() { 76 | const { db } = useFirebase(); 77 | const { user } = useAuth(); 78 | 79 | return (darkMode) => { 80 | return db.ref(`users/${user.uid}/theme/dark`).set(darkMode); 81 | }; 82 | } 83 | 84 | /** 85 | * Use remove theme hook 86 | * 87 | * @returns a function that removes user's theme settings in the database. 88 | */ 89 | export function useRemoveTheme() { 90 | const { db } = useFirebase(); 91 | const { user } = useAuth(); 92 | 93 | return () => { 94 | return db.ref(`users/${user.uid}/theme`).remove(); 95 | }; 96 | } 97 | 98 | -------------------------------------------------------------------------------- /src/app/authenticated-app/drawer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link as RouterLink, useMatch } from 'react-router-dom'; 3 | import { 4 | Drawer as MuiDrawer, 5 | Hidden, 6 | ListItem, 7 | ListItemIcon, 8 | ListItemText, 9 | makeStyles, 10 | } from '@material-ui/core'; 11 | import { useDrawer } from './drawer-context'; 12 | 13 | const DRAWER_WIDTH = 240; 14 | 15 | const useStyles = makeStyles((theme) => ({ 16 | drawer: { 17 | width: DRAWER_WIDTH, 18 | flexShrink: 0, 19 | }, 20 | drawerPaper: { 21 | width: DRAWER_WIDTH, 22 | }, 23 | drawerItem: { 24 | paddingLeft: theme.spacing(3), 25 | }, 26 | })); 27 | 28 | function Drawer({ children }) { 29 | const classes = useStyles(); 30 | 31 | const { isDrawerOpen, closeDrawer, onDrawerToggle } = useDrawer(); 32 | 33 | return ( 34 | <> 35 | {/* Small screens */} 36 | 37 | 54 | {children} 55 | 56 | 57 | 58 | {/* Desktop screens */} 59 | 60 | 67 | {children} 68 | 69 | 70 | 71 | ); 72 | } 73 | 74 | // Sidebar link 75 | function DrawerLink({ icon, children, ...rest }) { 76 | const classes = useStyles(); 77 | const match = useMatch(rest.to); 78 | 79 | return ( 80 | 87 | {icon} 88 | {children} 89 | 90 | ); 91 | } 92 | 93 | // Sidebar button 94 | function DrawerButton({ icon, children, ...rest }) { 95 | const classes = useStyles(); 96 | 97 | return ( 98 | 99 | {icon} 100 | {children} 101 | 102 | ); 103 | } 104 | 105 | export { Drawer, DrawerLink, DrawerButton }; 106 | -------------------------------------------------------------------------------- /src/theme/theme.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core'; 2 | import { getColor } from './colors'; 3 | 4 | // Get constant variables 5 | const defaultPrimaryColor = getColor(process.env.REACT_APP_THEME_PRIMARY_COLOR); 6 | const defaultSecondaryColor = getColor( 7 | process.env.REACT_APP_THEME_SECONDARY_COLOR 8 | ); 9 | const defaultDark = process.env.REACT_APP_THEME_DARK === 'true'; 10 | 11 | /** 12 | * Default theme constants 13 | * 14 | * For primary and secondary color their id and for dark a boolean. 15 | */ 16 | export const defaultThemeConstants = { 17 | primaryColor: defaultPrimaryColor.id, 18 | secondaryColor: defaultSecondaryColor.id, 19 | dark: defaultDark, 20 | }; 21 | 22 | /** 23 | * Create default theme 24 | * 25 | * Creates a theme using `createMuiTheme` that uses default values for 26 | * primary and secondary color. 27 | * 28 | * @param {boolean} darkMode specify if dark mode should be used 29 | */ 30 | export function createDefaultTheme(mode) { 31 | return createMuiTheme({ 32 | palette: { 33 | primary: defaultPrimaryColor.import, 34 | secondary: defaultSecondaryColor.import, 35 | mode, 36 | }, 37 | 38 | // Extend default mui theme with additional properties 39 | ...defaultThemeConstants, 40 | }); 41 | } 42 | 43 | /** 44 | * Default theme 45 | */ 46 | export const defaultTheme = createDefaultTheme(defaultDark === true ? 'dark' : 'light'); 47 | 48 | 49 | /** 50 | * Is default theme? 51 | * 52 | * Checks whether passed `theme` matches `defaultTheme`. 53 | * 54 | * @returns {boolean} true/false 55 | */ 56 | export function isDefaultTheme(theme) { 57 | if (!theme) { 58 | return false; 59 | } 60 | 61 | if ( 62 | theme.primaryColor.id === defaultPrimaryColor.id && 63 | theme.secondaryColor.id === defaultSecondaryColor.id && 64 | theme.dark === defaultDark 65 | ) { 66 | return true; 67 | } 68 | 69 | return false; 70 | } 71 | 72 | /** 73 | * Create theme 74 | * 75 | * @returns extended material-ui theme. Additional properties: 76 | * - `primaryColor` object 77 | * - `secondaryColor` object 78 | * - `dark` boolean 79 | */ 80 | export function createTheme(theme) { 81 | if (!theme) { 82 | return null; 83 | } 84 | 85 | let primaryColor = theme.primaryColor; 86 | let secondaryColor = theme.secondaryColor; 87 | let dark = theme.dark; 88 | 89 | if (!primaryColor || !secondaryColor) { 90 | return null; 91 | } 92 | 93 | primaryColor = getColor(primaryColor); 94 | secondaryColor = getColor(secondaryColor); 95 | 96 | if (!primaryColor || !secondaryColor) { 97 | return null; 98 | } 99 | 100 | theme = createMuiTheme({ 101 | palette: { 102 | primary: primaryColor.import, 103 | secondary: secondaryColor.import, 104 | mode: dark ? 'dark' : 'light', 105 | }, 106 | 107 | // Additional properties 108 | primaryColor: primaryColor, 109 | secondaryColor: secondaryColor, 110 | dark: dark, 111 | }); 112 | 113 | return theme; 114 | } 115 | -------------------------------------------------------------------------------- /src/components/week-picker.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import clsx from 'clsx'; 3 | import { TextField, makeStyles } from '@material-ui/core'; 4 | import { StaticDatePicker, PickersDay } from '@material-ui/lab'; 5 | import { useLocale } from 'localization'; 6 | import { 7 | add, 8 | endOfWeek, 9 | endOfMonth, 10 | isSameDay, 11 | isWithinInterval, 12 | isThisYear, 13 | startOfMonth, 14 | startOfWeek, 15 | } from 'date-fns'; 16 | 17 | const useStyles = makeStyles((theme) => ({ 18 | day: { 19 | width: 40, 20 | }, 21 | 22 | // Highlighting 23 | highlight: { 24 | borderRadius: 0, 25 | backgroundColor: theme.palette.primary.main, 26 | color: theme.palette.common.white, 27 | '&:hover, &:focus': { 28 | backgroundColor: theme.palette.primary.dark, 29 | }, 30 | }, 31 | firstHighlight: { 32 | borderTopLeftRadius: '50%', 33 | borderBottomLeftRadius: '50%', 34 | }, 35 | endHighlight: { 36 | borderTopRightRadius: '50%', 37 | borderBottomRightRadius: '50%', 38 | }, 39 | })); 40 | 41 | const MIN_DATE = new Date('2006-04-09'); 42 | const MAX_DATE = add(new Date(), { years: 10 }); 43 | 44 | function WeekPicker({ selectedDate, onChange }) { 45 | const classes = useStyles(); 46 | const locale = useLocale(); 47 | 48 | const renderWeekPickerDay = ( 49 | date, 50 | _selectedDates, 51 | PickersDayComponentProps 52 | ) => { 53 | if (!selectedDate) { 54 | return ; 55 | } 56 | const weekStart = startOfWeek(selectedDate, { locale }); 57 | const weekEnd = endOfWeek(selectedDate, { locale }); 58 | const monthStart = startOfMonth(date); 59 | const monthEnd = endOfMonth(date); 60 | 61 | const isWeekFirstDay = isSameDay(date, weekStart); 62 | const isWeekLastDay = isSameDay(date, weekEnd); 63 | const isMonthFirstDay = isSameDay(date, monthStart); 64 | const isMonthLastDay = isSameDay(date, monthEnd); 65 | 66 | const dayIsBetween = isWithinInterval(date, { 67 | start: weekStart, 68 | end: weekEnd, 69 | }); 70 | 71 | const isCurrentYear = isThisYear(date); 72 | 73 | return ( 74 | 86 | ); 87 | }; 88 | 89 | return ( 90 | } 101 | /> 102 | ); 103 | } 104 | 105 | export { WeekPicker }; 106 | -------------------------------------------------------------------------------- /src/screens/landing.js: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Fab, 4 | hexToRgb, 5 | alpha, 6 | Typography, 7 | useTheme, 8 | } from '@material-ui/core'; 9 | import { Link as RouterLink } from 'react-router-dom'; 10 | import { FaQuoteLeft } from 'react-icons/fa'; 11 | import { useTranslation } from 'translations'; 12 | import hero from 'images/hero.jpg'; 13 | 14 | function LandingScreen() { 15 | const t = useTranslation(); 16 | 17 | return ( 18 | 19 | 20 | 21 | {t('landingQuoteFirstLine')} 22 |
23 | {t('landingQuoteSecondLine')} 24 |
25 | 26 | — John Dryden 27 |
28 | 29 | {t('getStarted')} 30 |
31 | ); 32 | } 33 | 34 | export function FullPageImageBackground({ children }) { 35 | const { light, dark } = useTheme().palette.primary; 36 | 37 | const lightRgb = hexToRgb(light); 38 | const darkRgb = hexToRgb(dark); 39 | 40 | return ( 41 | 60 | {children} 61 | 62 | ); 63 | } 64 | 65 | function QuoteBox({ children }) { 66 | return ( 67 | 74 | {children} 75 | 76 | ); 77 | } 78 | 79 | function Quote({ children }) { 80 | return ( 81 | 90 | 91 | {children} 92 | 93 | 94 | ); 95 | } 96 | 97 | function Author({ children }) { 98 | return ( 99 | 106 | 107 | {children} 108 | 109 | 110 | ); 111 | } 112 | 113 | function GetStartedButton(props) { 114 | return ( 115 | 123 | 124 | 125 | ); 126 | } 127 | 128 | export { LandingScreen }; 129 | -------------------------------------------------------------------------------- /src/context/dialog-context.js: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, useRef } from 'react'; 2 | 3 | import React from 'react'; 4 | import { 5 | Button, 6 | Dialog, 7 | DialogActions, 8 | DialogContent, 9 | DialogContentText, 10 | DialogTitle, 11 | } from '@material-ui/core'; 12 | 13 | import { useTranslation } from 'translations'; 14 | 15 | // Translations 16 | const translations = { 17 | cancelButton: { 18 | pl: 'Anuluj', 19 | es: 'Cancelar', 20 | en: 'Cancel', 21 | }, 22 | }; 23 | 24 | // Context 25 | const DialogContext = createContext(); 26 | 27 | // Provider 28 | const DialogProvider = ({ children }) => { 29 | const t = useTranslation(translations); 30 | const [dialogs, setDialogs] = useState([]); 31 | 32 | const openDialog = (props) => { 33 | const dialog = { ...props, open: true }; 34 | 35 | setDialogs((dialogs) => [...dialogs, dialog]); 36 | }; 37 | 38 | const closeDialog = () => { 39 | setDialogs((dialogs) => { 40 | const latestDialog = dialogs.pop(); 41 | 42 | if (!latestDialog) return dialogs; 43 | if (latestDialog.onClose) latestDialog.onClose(); 44 | 45 | return [...dialogs].concat({ ...latestDialog, open: false }); 46 | }); 47 | }; 48 | 49 | const contextValue = useRef({ openDialog, closeDialog }); 50 | 51 | return ( 52 | 53 | {children} 54 | {dialogs.map( 55 | ( 56 | { 57 | open, 58 | title, 59 | description, 60 | confirmText, 61 | onConfirm, 62 | color = 'primary', 63 | }, 64 | i 65 | ) => ( 66 | 73 | {title} 74 | 75 | 76 | 77 | {description} 78 | 79 | 80 | 81 | 82 | 85 | 97 | 98 | 99 | ) 100 | )} 101 | 102 | ); 103 | }; 104 | 105 | // Hook 106 | function useDialog() { 107 | const context = useContext(DialogContext); 108 | 109 | if (context === undefined) { 110 | throw new Error('useDialog must be used within DialogProvider'); 111 | } 112 | 113 | return context; 114 | } 115 | 116 | export { DialogProvider, useDialog }; 117 | -------------------------------------------------------------------------------- /src/components/performance-tab.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useUser } from 'context/user-context'; 3 | import { 4 | Box, 5 | FormControl, 6 | InputLabel, 7 | List, 8 | ListItem, 9 | ListItemIcon, 10 | MenuItem, 11 | Select, 12 | useMediaQuery, 13 | useTheme, 14 | } from '@material-ui/core'; 15 | import { TrackChanges as TrackChangesIcon } from '@material-ui/icons'; 16 | import { useUpdatePerformanceGoal } from 'api/user-data'; 17 | import { useTranslation } from 'translations'; 18 | 19 | // Create array of available values [5, 10, ..., 100] 20 | const performanceGoalValues = Array.from(Array(20)).map((_, i) => { 21 | const value = i * 5 + 5; 22 | return { 23 | value, 24 | label: `${value}%`, 25 | }; 26 | }); 27 | 28 | /** 29 | * Performance Tab 30 | * 31 | * User can update performance related settings. For now the only setting 32 | * that can be changed is performance goal. 33 | */ 34 | function PerformanceTab() { 35 | const { performanceGoal } = useUser(); 36 | const t = useTranslation(); 37 | 38 | const updatePerformanceGoal = useUpdatePerformanceGoal(); 39 | 40 | const handlePerformanceGoalChange = (event) => { 41 | // Update user's performance goal in the database 42 | updatePerformanceGoal(event.target.value); 43 | }; 44 | 45 | // Media queries 46 | const theme = useTheme(); 47 | const isXs = useMediaQuery(theme.breakpoints.only('xs')); 48 | 49 | return ( 50 | 51 | {/* Performance gaol */} 52 | 53 | 54 | {!isXs && ( 55 | 56 | 57 | 58 | )} 59 | 60 | 61 | 62 | {t('dailyGoal')} 63 | 64 | 65 | {/* Mobile devices */} 66 | {isXs && ( 67 | 81 | )} 82 | 83 | {/* Up mobile devices */} 84 | {!isXs && ( 85 | 98 | )} 99 | 100 | 101 | 102 | 103 | ); 104 | } 105 | 106 | export { PerformanceTab }; 107 | -------------------------------------------------------------------------------- /src/screens/user-settings.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import SwipeableViews from 'react-swipeable-views'; 3 | import { Box, Paper, Tab, Tabs } from '@material-ui/core'; 4 | import { AccountTab } from 'components/account-tab'; 5 | import { AppearanceTab } from 'components/appearance-tab'; 6 | import { PerformanceTab } from 'components/performance-tab'; 7 | import { 8 | AccountCircle as AccountCircleIcon, 9 | Equalizer as EqualizerIcon, 10 | Palette as PaletteIcon, 11 | } from '@material-ui/icons'; 12 | import { useTranslation } from 'translations'; 13 | 14 | // Translations 15 | const translations = { 16 | account: { 17 | pl: 'Konto', 18 | es: 'Cuenta', 19 | en: 'Account', 20 | }, 21 | performance: { 22 | pl: 'Wyniki', 23 | es: 'Resultados', 24 | en: 'Performance', 25 | }, 26 | appearance: { 27 | pl: 'Motyw', 28 | es: 'Tema', 29 | en: 'Appearance', 30 | }, 31 | }; 32 | 33 | // Available tabs 34 | const tabs = [ 35 | { 36 | key: 'account', 37 | icon: , 38 | }, 39 | 40 | { 41 | key: 'performance', 42 | icon: , 43 | }, 44 | 45 | { 46 | key: 'appearance', 47 | icon: , 48 | }, 49 | ]; 50 | 51 | /** 52 | * User Settings Screen 53 | * 54 | * The settings are displayed as swipeable tabs. The settings that are available 55 | * are: account, performance, appearance. 56 | */ 57 | export default function UserSettingsScreen() { 58 | const t = useTranslation(translations); 59 | 60 | // Selected tab 61 | const [selectedTab, setSelectedTab] = React.useState(0); 62 | const handleTabChange = (event, newValue) => { 63 | setSelectedTab(newValue); 64 | }; 65 | 66 | // SwipeableViews index change handler 67 | const handleIndexChange = (index) => { 68 | setSelectedTab(index); 69 | }; 70 | 71 | return ( 72 | 80 | 84 | {/* Tabs */} 85 | 92 | {tabs.map(({ key, icon }, index) => { 93 | return ; 94 | })} 95 | 96 | 97 | {/* Views */} 98 | 99 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | ); 118 | } 119 | 120 | export { UserSettingsScreen }; 121 | -------------------------------------------------------------------------------- /src/components/week-bar-chart.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ResponsiveBar } from '@nivo/bar'; 3 | import { useTheme } from '@material-ui/core'; 4 | import { countBy } from 'lodash'; 5 | import { format, parseISO } from 'date-fns'; 6 | import { COMPLETED, FAILED } from 'data/constants'; 7 | import { useLocale } from 'localization'; 8 | 9 | function WeekBarChart({ checkmarks, dates, goal }) { 10 | // Style 11 | const { palette } = useTheme(); 12 | const { primary, secondary, getContrastText, text } = palette; 13 | 14 | // Data 15 | const data = dates.map((date) => { 16 | // Values for the current date 17 | const values = checkmarks 18 | .filter((checkmark) => checkmark.date === date) 19 | .map((checkmark) => checkmark.value); 20 | 21 | // There are no checkmarks for this date 22 | if (!values.length) { 23 | return { 24 | date, 25 | completed: null, 26 | failed: null, 27 | }; 28 | } 29 | 30 | // Count completed and failed values 31 | const counts = countBy(values); 32 | 33 | const avg = (value) => Math.round((value / values.length) * 100); 34 | 35 | const completed = avg(counts[COMPLETED]) || null; 36 | const failed = -avg(counts[FAILED]) || null; 37 | 38 | // Return object in the shape accepted by bar chart 39 | return { 40 | date, 41 | completed, 42 | failed, 43 | }; 44 | }); 45 | 46 | const locale = useLocale(); 47 | 48 | // Label formats 49 | const xValueFormat = (date) => format(parseISO(date), 'd-MMM', { locale }); 50 | 51 | // Check if value exists to prevent funny outputs like `null%` 52 | const yValueFormat = (v) => (v ? v + '%' : ''); 53 | 54 | return ( 55 | 108 | ); 109 | } 110 | 111 | export { WeekBarChart }; 112 | -------------------------------------------------------------------------------- /src/context/user-context.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FullPageSpinner, FullPageErrorFallback } from 'components/lib'; 3 | import { useFirebase } from './firebase-context'; 4 | import { useAsync } from 'utils/hooks'; 5 | import { useAuth } from './auth-context'; 6 | import { defaultLocale } from 'localization/locales'; 7 | import { createTheme, defaultThemeConstants } from 'theme'; 8 | import { useTheme } from '@material-ui/core'; 9 | import { useLocale } from 'localization'; 10 | 11 | const UserContext = React.createContext(); 12 | UserContext.displayName = 'UserContext'; 13 | 14 | // Default user data object 15 | const defaultUserData = { 16 | theme: defaultThemeConstants, 17 | performanceGoal: 75, 18 | locale: { 19 | code: defaultLocale.code, 20 | }, 21 | }; 22 | 23 | /** 24 | * User Data Provider 25 | * 26 | * Provides user data object, which is updated whenever user data 27 | * changes in the database. 28 | */ 29 | function UserProvider({ children }) { 30 | const { 31 | data: userData, 32 | setData: setUserData, 33 | status, 34 | error, 35 | isLoading, 36 | isIdle, 37 | isError, 38 | isSuccess, 39 | } = useAsync({ 40 | data: defaultUserData, 41 | }); 42 | 43 | const { db } = useFirebase(); 44 | const { user } = useAuth(); 45 | 46 | /** 47 | * User data snasphot listener 48 | */ 49 | React.useEffect(() => { 50 | const userDataRef = db.ref(`users/${user.uid}`); 51 | 52 | // Set up snapshot listener 53 | userDataRef.on('value', (snapshot) => { 54 | // Check if user has a data point in the database 55 | const userHasData = snapshot.exists(); 56 | 57 | if (userHasData) { 58 | // Merge any user data with the default data 59 | setUserData({ 60 | ...defaultUserData, 61 | ...snapshot.val(), 62 | }); 63 | } else { 64 | setUserData(defaultUserData); 65 | } 66 | }); 67 | 68 | // Detach snapshot listener 69 | return () => userDataRef.off(); 70 | }, [db, user, setUserData]); 71 | 72 | const { theme } = userData; 73 | const { setTheme } = useTheme(); 74 | 75 | /** 76 | * Watch for theme changes in user data 77 | */ 78 | React.useEffect(() => { 79 | if (theme) { 80 | setTheme(createTheme(theme)); 81 | } 82 | }, [theme, setTheme]); 83 | 84 | const { locale } = userData; 85 | const { setLocaleByCode } = useLocale(); 86 | 87 | /** 88 | * Watch for locale changes in user data 89 | */ 90 | React.useEffect(() => { 91 | if (locale) { 92 | setLocaleByCode(locale.code); 93 | } 94 | }, [locale, setLocaleByCode]); 95 | 96 | // User data is loading 97 | if (isLoading || isIdle) { 98 | return ; 99 | } 100 | 101 | // Error when loading user data 102 | if (isError) { 103 | return ; 104 | } 105 | 106 | // User data successfully loaded 107 | if (isSuccess) { 108 | return ( 109 | 110 | {children} 111 | 112 | ); 113 | } 114 | 115 | throw new Error(`Unhandled status: ${status}`); 116 | } 117 | 118 | function useUser() { 119 | const context = React.useContext(UserContext); 120 | if (context === undefined) { 121 | throw new Error(`useUser must be used within a UserProvider`); 122 | } 123 | return context; 124 | } 125 | 126 | export { UserProvider, useUser }; 127 | -------------------------------------------------------------------------------- /src/screens/add-habit.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useForm } from 'react-hook-form'; 3 | import { yupResolver } from '@hookform/resolvers/yup'; 4 | import { habitSchema } from 'data/constraints'; 5 | import { TextField } from '@material-ui/core'; 6 | import { CheckboxGroup } from 'components/checkbox-group'; 7 | import { FullPageSpinner } from 'components/lib'; 8 | import { useSnackbar } from 'context/snackbar-context'; 9 | import { useAddHabitMutation, useHabitsQuery } from 'api/habits'; 10 | import { 11 | Form, 12 | FormBody, 13 | FormButton, 14 | FormErrorText, 15 | FormHeader, 16 | FormPrimaryText, 17 | } from 'components/form'; 18 | import { useLocale } from 'localization'; 19 | import { useTranslation } from 'translations'; 20 | 21 | // Initial habit 22 | const initialHabit = { 23 | name: '', 24 | description: '', 25 | frequency: [], 26 | }; 27 | 28 | function AddHabitScreen() { 29 | const t = useTranslation(); 30 | const { weekdays } = useLocale(); 31 | const { openSnackbar } = useSnackbar(); 32 | 33 | const { data: habits, isLoading } = useHabitsQuery(); 34 | const addHabitMutation = useAddHabitMutation(); 35 | 36 | // Form 37 | const { control, register, handleSubmit, errors, getValues, reset } = useForm( 38 | { 39 | defaultValues: initialHabit, 40 | resolver: yupResolver(habitSchema), 41 | } 42 | ); 43 | 44 | // Submit form 45 | const onSubmit = (form) => { 46 | const { name, description, frequency } = form; 47 | // Habit's position is based on the number of habits 48 | const position = habits.length; 49 | 50 | addHabitMutation.mutate( 51 | { name, description, frequency, position }, 52 | { 53 | onSuccess: () => openSnackbar('success', t('habitAdded')), 54 | } 55 | ); 56 | reset(initialHabit); 57 | }; 58 | 59 | // Is loading habits 60 | if (isLoading) { 61 | return ; 62 | } 63 | 64 | // Get array of errors from the form 65 | const formErrors = Object.values(errors); 66 | 67 | const errorText = addHabitMutation.isError 68 | ? // If there is an error when adding the habit it display it first 69 | addHabitMutation.error.message 70 | : // Otherwise display first form error if any 71 | formErrors[0]?.message; 72 | 73 | // Disable form actions when the habit is being added 74 | const disableActions = addHabitMutation.isLoading; 75 | 76 | return ( 77 |
78 | 79 | {t('createNewHabit')} 80 | {errorText || ' '} 81 | 82 | 83 | 84 | 93 | 94 | 103 | 104 | 112 | 113 | 114 | {t('createHabit')} 115 | 116 | 117 |
118 | ); 119 | } 120 | 121 | export { AddHabitScreen }; 122 | -------------------------------------------------------------------------------- /src/context/auth-context.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useFirebase } from './firebase-context'; 3 | import { useAsync } from 'utils/hooks'; 4 | import { FullPageSpinner, FullPageErrorFallback } from 'components/lib'; 5 | import { useNavigate } from 'react-router'; 6 | import { useTheme } from '@material-ui/core'; 7 | 8 | const AuthContext = React.createContext(); 9 | AuthContext.displayName = 'AuthContext'; 10 | 11 | function AuthProvider(props) { 12 | const { 13 | data: user, 14 | status, 15 | error, 16 | isLoading, 17 | isIdle, 18 | isError, 19 | isSuccess, 20 | setData, 21 | } = useAsync(); 22 | 23 | const { firebase, auth } = useFirebase(); 24 | const { resetTheme } = useTheme(); 25 | const navigate = useNavigate(); 26 | 27 | // Auth state change observer 28 | React.useEffect(() => { 29 | const unsubscribe = auth.onAuthStateChanged((user) => { 30 | if (user) { 31 | setData(user); 32 | navigate('dashboard'); 33 | } else { 34 | setData(null); 35 | } 36 | }); 37 | 38 | return () => { 39 | unsubscribe(); 40 | }; 41 | }, [auth, setData, navigate]); 42 | 43 | // Sign in (email, password) 44 | const signIn = React.useCallback( 45 | ({ email, password }) => { 46 | return auth.signInWithEmailAndPassword(email, password); 47 | }, 48 | [auth] 49 | ); 50 | 51 | // Sign in with Auth Provider 52 | const signInWithAuthProvider = React.useCallback( 53 | ({ id, scopes }) => { 54 | const authProvider = new firebase.auth.OAuthProvider(id); 55 | 56 | if (scopes) { 57 | scopes.forEach((scope) => { 58 | authProvider.addScope(scope); 59 | }); 60 | } 61 | 62 | return auth.signInWithPopup(authProvider); 63 | }, 64 | [firebase, auth] 65 | ); 66 | 67 | // Sign in as guest 68 | const signInAsGuest = React.useCallback(() => { 69 | return auth.signInAnonymously(); 70 | }, [auth]); 71 | 72 | // Sign up (email, password) 73 | const signUp = React.useCallback( 74 | ({ email, password }) => { 75 | return auth.createUserWithEmailAndPassword(email, password); 76 | }, 77 | [auth] 78 | ); 79 | 80 | // Sign out 81 | const signOut = React.useCallback(() => { 82 | return auth.signOut().then(() => { 83 | resetTheme(); 84 | }); 85 | }, [auth, resetTheme]); 86 | 87 | // Reset password 88 | const resetPassword = React.useCallback( 89 | ({ email }) => { 90 | return auth.sendPasswordResetEmail(email); 91 | }, 92 | [auth] 93 | ); 94 | 95 | const deleteAccount = React.useCallback(() => { 96 | return auth.currentUser.delete(); 97 | }, [auth]); 98 | 99 | // Context value 100 | const value = React.useMemo( 101 | () => ({ 102 | user, 103 | signIn, 104 | signInWithAuthProvider, 105 | signInAsGuest, 106 | signUp, 107 | signOut, 108 | resetPassword, 109 | deleteAccount, 110 | }), 111 | [ 112 | user, 113 | signIn, 114 | signInWithAuthProvider, 115 | signInAsGuest, 116 | signUp, 117 | signOut, 118 | resetPassword, 119 | deleteAccount, 120 | ] 121 | ); 122 | 123 | if (isLoading || isIdle) { 124 | return ; 125 | } 126 | 127 | if (isError) { 128 | return ; 129 | } 130 | 131 | if (isSuccess) { 132 | return ; 133 | } 134 | 135 | throw new Error(`Unhandled status: ${status}`); 136 | } 137 | 138 | function useAuth() { 139 | const context = React.useContext(AuthContext); 140 | if (context === undefined) { 141 | throw new Error(`useAuth must be used within a AuthProvider`); 142 | } 143 | return context; 144 | } 145 | 146 | export { AuthProvider, useAuth }; 147 | -------------------------------------------------------------------------------- /src/screens/sign-in.js: -------------------------------------------------------------------------------- 1 | import { yupResolver } from '@hookform/resolvers/yup'; 2 | import { TextField } from '@material-ui/core'; 3 | import { AuthProviderList } from 'components/auth-providers-list'; 4 | import { 5 | Form, 6 | FormBody, 7 | FormButton, 8 | FormDivider, 9 | FormErrorText, 10 | FormHeader, 11 | FormLink, 12 | FormListContainer, 13 | FormPrimaryText, 14 | FormSecondaryText, 15 | } from 'components/form'; 16 | import { useAuth } from 'context/auth-context'; 17 | import { signInSchema } from 'data/constraints'; 18 | import { useTranslation, translations } from 'translations'; 19 | import { useForm } from 'react-hook-form'; 20 | import { useAsync } from 'utils/hooks'; 21 | import { SignInAsGuestButton } from 'components/sign-in-as-guest-button'; 22 | 23 | function SignInScreen() { 24 | const t = useTranslation(translations); 25 | 26 | const { signIn, signInWithAuthProvider, signInAsGuest } = useAuth(); 27 | const { isLoading, isError: isAuthError, error: authError, run } = useAsync(); 28 | 29 | const { register, handleSubmit, errors, reset } = useForm({ 30 | resolver: yupResolver(signInSchema), 31 | reValidateMode: 'onSubmit', 32 | }); 33 | 34 | // Form submit 35 | const onSubmit = ({ email, password }) => { 36 | run(signIn({ email, password })); 37 | reset(); 38 | }; 39 | 40 | // Auth provider click 41 | const handleAuthProviderClick = (event, provider) => { 42 | // Prevents the form from submitting and triggering the form errors 43 | event.preventDefault(); 44 | 45 | run(signInWithAuthProvider(provider)); 46 | }; 47 | 48 | // Sign in as Guest click 49 | const handleSignInAsGuestClick = () => { 50 | run(signInAsGuest()); 51 | }; 52 | 53 | const errorMessages = Object.values(errors); 54 | const isError = isAuthError || errorMessages.length !== 0; 55 | const errorMessage = authError?.message || errorMessages[0]?.message; 56 | 57 | return ( 58 |
59 | 60 | {t('signIn')} 61 | 62 | {t('noAccountQuestion')}{' '} 63 | {t('signUp')} 64 | 65 | {isError ? errorMessage : ' '} 66 | 67 | 68 | 69 | 70 | 75 | 76 | 81 | 82 | 83 | 84 | 85 | 96 | 97 | 109 | 110 | 111 | {t('signIn')} 112 | 113 | 114 | 115 | {t('forgotPassword')} 116 | 117 | 118 |
119 | ); 120 | } 121 | 122 | export { SignInScreen }; 123 | -------------------------------------------------------------------------------- /src/images/hello-darkness.svg: -------------------------------------------------------------------------------- 1 | void -------------------------------------------------------------------------------- /src/screens/sign-up.js: -------------------------------------------------------------------------------- 1 | import { yupResolver } from '@hookform/resolvers/yup'; 2 | import { TextField } from '@material-ui/core'; 3 | import { 4 | Form, 5 | FormBody, 6 | FormButton, 7 | FormDivider, 8 | FormErrorText, 9 | FormHeader, 10 | FormLink, 11 | FormListContainer, 12 | FormPrimaryText, 13 | FormSecondaryText, 14 | } from 'components/form'; 15 | import { useAuth } from 'context/auth-context'; 16 | import { signUpSchema } from 'data/constraints'; 17 | import { useForm } from 'react-hook-form'; 18 | import { useAsync } from 'utils/hooks'; 19 | import { AuthProviderList } from 'components/auth-providers-list'; 20 | import { useTranslation } from 'translations'; 21 | import { SignInAsGuestButton } from 'components/sign-in-as-guest-button'; 22 | 23 | function SignUpScreen() { 24 | const t = useTranslation(); 25 | 26 | const { signUp, signInWithAuthProvider, signInAsGuest } = useAuth(); 27 | const { isLoading, isError: isAuthError, error: authError, run } = useAsync(); 28 | 29 | const { register, handleSubmit, errors, reset } = useForm({ 30 | resolver: yupResolver(signUpSchema), 31 | }); 32 | 33 | // Form submit 34 | const onSubmit = ({ email, password }) => { 35 | run(signUp({ email, password })); 36 | reset(); 37 | }; 38 | 39 | // Auth provider click 40 | const handleAuthProviderClick = (event, provider) => { 41 | // Prevents the form from submitting and triggering the form errors 42 | event.preventDefault(); 43 | 44 | run(signInWithAuthProvider(provider)); 45 | }; 46 | 47 | // Sign up as Guest click 48 | const handleSignInAsGuestClick = () => { 49 | run(signInAsGuest()); 50 | }; 51 | 52 | const errorMessages = Object.values(errors); 53 | const isError = isAuthError || errorMessages.length !== 0; 54 | const errorMessage = authError?.message || errorMessages[0]?.message; 55 | 56 | return ( 57 |
58 | 59 | {t('signUp')} 60 | 61 | {t('hasAccountQuestion')} {t('signIn')} 62 | 63 | {isError ? errorMessage : ' '} 64 | 65 | 66 | 67 | 68 | 69 | 74 | 79 | 80 | 81 | 82 | 83 | 94 | 95 | 107 | 119 | 120 | 121 | {t('signUp')} 122 | 123 | 124 |
125 | ); 126 | } 127 | 128 | export { SignUpScreen }; 129 | -------------------------------------------------------------------------------- /src/components/habit-list.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link as RouterLink } from 'react-router-dom'; 3 | import { 4 | Box, 5 | Chip, 6 | IconButton, 7 | List, 8 | ListItem, 9 | // ListItemIcon, 10 | ListItemText, 11 | Tooltip, 12 | useMediaQuery, 13 | useTheme, 14 | } from '@material-ui/core'; 15 | import { 16 | Delete as DeleteIcon, 17 | Edit as EditIcon, 18 | // Folder as FolderIcon, 19 | } from '@material-ui/icons'; 20 | 21 | import { useDialog } from 'context/dialog-context'; 22 | import { useSnackbar } from 'context/snackbar-context'; 23 | import { useDeleteHabitMutationMutation } from 'api/habits'; 24 | import { useLocale } from 'localization'; 25 | import { useTranslation } from 'translations'; 26 | 27 | function HabitListItem({ habit }) { 28 | const t = useTranslation(); 29 | const { weekdays } = useLocale(); 30 | 31 | const { id, name, description, frequency } = habit; 32 | 33 | const { openSnackbar } = useSnackbar(); 34 | const { openDialog } = useDialog(); 35 | 36 | const deleteHabitMutation = useDeleteHabitMutationMutation(); 37 | 38 | const handleDeleteClick = () => { 39 | // Open the dialog to ask the user if they're sure to delete the habit 40 | openDialog({ 41 | title: `${t('deleteHabitQuestion')} "${name}"?`, 42 | description: t('deleteHabitWarning'), 43 | confirmText: t('deleteHabitConfirmation'), 44 | onConfirm: () => { 45 | deleteHabitMutation.mutate(id, { 46 | onSuccess: () => openSnackbar('success', t('habitDeleted')), 47 | onError: (error) => openSnackbar('error', error.message), 48 | }); 49 | }, 50 | color: 'secondary', 51 | }); 52 | }; 53 | 54 | // Disable buttons when the habit is being deleted 55 | const disableActions = deleteHabitMutation.isLoading; 56 | 57 | const theme = useTheme(); 58 | const isXs = useMediaQuery(theme.breakpoints.only('xs')); 59 | 60 | // Name and description 61 | const text = ; 62 | 63 | // Frequency 64 | const frequencyChips = ( 65 | 71 | {weekdays.map((day, i) => ( 72 | 79 | 84 | 85 | ))} 86 | 87 | ); 88 | 89 | return ( 90 | 91 | {/* TODO: Let user choose icon */} 92 | {/* 93 | 94 | */} 95 | 96 | {/* On small screens display frequency below the text */} 97 | {isXs && ( 98 | 99 | {text} 100 | {frequencyChips} 101 | 102 | )} 103 | 104 | {/* Small screens and up display frequency next to the text */} 105 | {!isXs && ( 106 | <> 107 | {text} 108 | {frequencyChips} 109 | 110 | )} 111 | 112 | {/* Edit link */} 113 | 114 | 121 | 122 | 123 | 124 | 125 | {/* Delete button */} 126 | 127 | 133 | 134 | 135 | 136 | 137 | ); 138 | } 139 | 140 | function HabitList({ habits }) { 141 | return ( 142 | 143 | {habits.map((habit) => ( 144 | 145 | ))} 146 | 147 | ); 148 | } 149 | 150 | export { HabitList }; 151 | -------------------------------------------------------------------------------- /src/screens/edit-habit.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useForm } from 'react-hook-form'; 3 | import { useNavigate, useParams } from 'react-router'; 4 | import { yupResolver } from '@hookform/resolvers/yup'; 5 | import { TextField } from '@material-ui/core'; 6 | import { CheckboxGroup } from 'components/checkbox-group'; 7 | import { FullPageSpinner } from 'components/lib'; 8 | import { useSnackbar } from 'context/snackbar-context'; 9 | import { habitSchema } from 'data/constraints'; 10 | import { useHabitQuery, useUpdateHabitMutation } from 'api/habits'; 11 | import { useLocale } from 'localization'; 12 | import { NotFoundHabitScreen } from './not-found-habit'; 13 | import { 14 | Form, 15 | FormBody, 16 | FormButton, 17 | FormErrorText, 18 | FormHeader, 19 | FormPrimaryText, 20 | } from 'components/form'; 21 | import { useTranslation } from 'translations'; 22 | 23 | // Default habit values 24 | const defaultHabit = { 25 | name: '', 26 | description: '', 27 | frequency: [], 28 | }; 29 | 30 | function EditHabitScreen() { 31 | const navigate = useNavigate(); 32 | 33 | const t = useTranslation(); 34 | const { weekdays } = useLocale(); 35 | const { habitId } = useParams(); 36 | const { openSnackbar } = useSnackbar(); 37 | 38 | const { data: habit, error: habitError, isFetching } = useHabitQuery(habitId); 39 | const updateHabitMutation = useUpdateHabitMutation(); 40 | 41 | // Form 42 | const { 43 | control, 44 | register, 45 | handleSubmit, 46 | errors, 47 | getValues, 48 | setValue, 49 | reset, 50 | } = useForm({ 51 | defaultValues: defaultHabit, 52 | resolver: yupResolver(habitSchema), 53 | }); 54 | 55 | // Save edited habit 56 | const onSubmit = (form) => { 57 | updateHabitMutation.mutate( 58 | { id: habitId, ...form }, 59 | { 60 | onSuccess: () => { 61 | openSnackbar('success', t('habitSaved')); 62 | navigate('/manage-habits'); 63 | }, 64 | } 65 | ); 66 | reset(defaultHabit); 67 | }; 68 | 69 | // Set initial values of the form 70 | React.useEffect(() => { 71 | if (habit) { 72 | const { name, description, frequency } = habit; 73 | 74 | setValue('name', name); 75 | setValue('description', description); 76 | setValue('frequency', frequency); 77 | } 78 | }, [habit, setValue, habitId]); 79 | 80 | // Get array of errors from the form 81 | const formErrors = Object.values(errors); 82 | 83 | const errorText = habitError 84 | ? // If there is an error with fetching the habit it display it first 85 | habitError.message 86 | : // Otherwise display first form error if any 87 | formErrors[0]?.message; 88 | 89 | if (isFetching) { 90 | return ; 91 | } 92 | 93 | // There is no data corresponding with the habit id 94 | if (!habit) { 95 | return ; 96 | } 97 | 98 | // Disable form actions when the habit is updating 99 | const disableActions = updateHabitMutation.isLoading; 100 | 101 | return ( 102 |
103 | 104 | {t('editHabit')} 105 | {errorText || ' '} 106 | 107 | 108 | 109 | 118 | 119 | 128 | 129 | 137 | 138 | 139 | {t('saveHabit')} 140 | 141 | 142 |
143 | ); 144 | } 145 | 146 | export { EditHabitScreen }; 147 | -------------------------------------------------------------------------------- /src/components/form.js: -------------------------------------------------------------------------------- 1 | import { LoadingButton } from '@material-ui/lab'; 2 | import { Link as RouterLink } from 'react-router-dom'; 3 | import { 4 | Divider, 5 | Typography, 6 | FormHelperText, 7 | Box, 8 | Paper, 9 | Link, 10 | useTheme, 11 | } from '@material-ui/core'; 12 | import { useTranslation } from 'translations'; 13 | 14 | /** 15 | * Main form wrapper component 16 | */ 17 | function Form({ children, ...props }) { 18 | return ( 19 | 35 | 36 | {children} 37 | 38 | 39 | ); 40 | } 41 | 42 | /** 43 | * Form header 44 | */ 45 | function FormHeader({ children }) { 46 | return ( 47 | 52 | {children} 53 | 54 | ); 55 | } 56 | 57 | /** 58 | * Form body 59 | */ 60 | function FormBody({ children }) { 61 | return ( 62 | *:not(:last-child)': { 65 | mb: 2, 66 | }, 67 | }} 68 | > 69 | {children} 70 | 71 | ); 72 | } 73 | 74 | /** 75 | * Applies margin bottom to its children except for the last one 76 | */ 77 | function FormListContainer({ children }) { 78 | return ( 79 | *:not(:last-child)': { 82 | mb: 1, 83 | }, 84 | }} 85 | > 86 | {children} 87 | 88 | ); 89 | } 90 | 91 | /** 92 | * Form primary text 93 | */ 94 | function FormPrimaryText({ children }) { 95 | return ( 96 | 97 | {children} 98 | 99 | ); 100 | } 101 | 102 | /** 103 | * Form secondary text 104 | */ 105 | function FormSecondaryText({ children, ...props }) { 106 | return ( 107 | 108 | 113 | {children} 114 | 115 | 116 | ); 117 | } 118 | 119 | /** 120 | * Combined Material-ui Link and RouterLink. 121 | */ 122 | function FormLink(props) { 123 | const { palette } = useTheme(); 124 | 125 | const isDarkMode = palette.mode === 'dark'; 126 | 127 | return ( 128 | 133 | ); 134 | } 135 | 136 | /** 137 | * Uses `MuiLoadingButton` 138 | */ 139 | function FormButton(props) { 140 | return ; 141 | } 142 | 143 | /** 144 | * Form divider 145 | * 146 | * Uses two horizontal dividers and "or" in between. 147 | */ 148 | function FormDivider() { 149 | const t = useTranslation(); 150 | 151 | return ( 152 | 158 | 159 | 160 | 161 | 169 | {t('or')} 170 | 171 | 172 | 173 | 174 | 175 | ); 176 | } 177 | 178 | /** 179 | * Form error text 180 | */ 181 | function FormErrorText({ children }) { 182 | return ( 183 | 189 | {children} 190 | 191 | ); 192 | } 193 | 194 | export { 195 | Form, 196 | FormBody, 197 | FormHeader, 198 | FormListContainer, 199 | FormPrimaryText, 200 | FormSecondaryText, 201 | FormErrorText, 202 | FormButton, 203 | FormDivider, 204 | FormLink, 205 | }; 206 | -------------------------------------------------------------------------------- /src/components/habits-table.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { format, isToday, parseISO } from 'date-fns'; 4 | import { getComparator } from 'utils/misc'; 5 | import { HabitRow } from './habit-row'; 6 | import { useLocale } from 'localization'; 7 | import { useTranslation } from 'translations'; 8 | import { 9 | makeStyles, 10 | Table, 11 | TableBody, 12 | TableCell, 13 | TableContainer, 14 | TableHead, 15 | TableRow, 16 | TableSortLabel, 17 | Typography, 18 | } from '@material-ui/core'; 19 | 20 | // Styles 21 | const useStyles = makeStyles((theme) => ({ 22 | visuallyHidden: { 23 | border: 0, 24 | clip: 'rect(0 0 0 0)', 25 | height: 1, 26 | margin: -1, 27 | overflow: 'hidden', 28 | padding: 0, 29 | position: 'absolute', 30 | top: 20, 31 | width: 1, 32 | }, 33 | positionPadding: { 34 | padding: '0 0 0 12px', 35 | }, 36 | highlight: { 37 | color: theme.palette.common.white, 38 | backgroundColor: theme.palette.primary.main, 39 | }, 40 | noWrap: { 41 | whiteSpace: 'nowrap', 42 | }, 43 | })); 44 | 45 | // Sortable Table 46 | function HabitsTable({ habits, checkmarks, dates }) { 47 | const locale = useLocale(); 48 | const t = useTranslation(); 49 | const classes = useStyles(); 50 | 51 | const [order, setOrder] = React.useState('desc'); 52 | const [orderBy, setOrderBy] = React.useState('position'); 53 | 54 | // Handles sorting 55 | const handleRequestSort = (property) => { 56 | const isAsc = orderBy === property && order === 'asc'; 57 | setOrder(isAsc ? 'desc' : 'asc'); 58 | setOrderBy(property); 59 | }; 60 | 61 | // Cells with option to sort the habits 62 | const sortableCells = [ 63 | // { id: 'position', label: 'Nº', align: 'center' }, 64 | { id: 'name', label: t('habit'), align: 'left' }, 65 | ]; 66 | 67 | // Currently selected date range cells 68 | const datesCells = dates.map((d) => { 69 | const date = parseISO(d); 70 | 71 | return { 72 | date, 73 | label: format(date, 'd-MMM', { locale }), 74 | }; 75 | }); 76 | 77 | const sortedHabits = habits.slice().sort(getComparator(order, orderBy)); 78 | 79 | return ( 80 | 81 | 82 | {/* Table head */} 83 | 84 | 85 | {/* Sortable cells */} 86 | {sortableCells.map(({ id, label, align }) => ( 87 | 93 | handleRequestSort(id)} 97 | className={classes.noWrap} 98 | > 99 | {label} 100 | {orderBy === id ? ( 101 | 102 | {order === 'desc' 103 | ? 'sorted descending' 104 | : 'sorted ascending'} 105 | 106 | ) : null} 107 | 108 | 109 | ))} 110 | 111 | {/* Date cells */} 112 | {datesCells.map(({ date, label }) => ( 113 | 114 | {isToday(date) ? ( 115 | {label} 116 | ) : ( 117 | label 118 | )} 119 | 120 | ))} 121 | 122 | 123 | 124 | {/* Table body */} 125 | 126 | {sortedHabits.map((habit) => { 127 | const habitCheckmarks = checkmarks.filter( 128 | (d) => d.habitId === habit.id 129 | ); 130 | 131 | return ( 132 | 138 | ); 139 | })} 140 | 141 |
142 |
143 | ); 144 | } 145 | 146 | HabitsTable.propTypes = { 147 | habits: PropTypes.array.isRequired, 148 | dates: PropTypes.array.isRequired, 149 | }; 150 | 151 | export { HabitsTable }; 152 | -------------------------------------------------------------------------------- /src/components/performance-panel/performance-panel.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, Divider, Grid, Typography, useTheme } from '@material-ui/core'; 3 | import { Done as DoneIcon } from '@material-ui/icons'; 4 | import { Pie } from '@nivo/pie'; 5 | import { calculateScore, createPieChartData } from './helpers'; 6 | import { useTranslation } from 'translations'; 7 | import { getWeek, isThisWeek, isToday, parseISO } from 'date-fns'; 8 | 9 | function PerformancePanel({ checkmarks, goal }) { 10 | const t = useTranslation(); 11 | 12 | const todayValues = checkmarks 13 | .filter((c) => isToday(parseISO(c.date))) 14 | .map((c) => c.value); 15 | 16 | const thisWeekValues = checkmarks 17 | .filter((c) => isThisWeek(parseISO(c.date))) 18 | .map((c) => c.value); 19 | 20 | const lastWeekValues = checkmarks 21 | .filter((c) => getWeek(parseISO(c.date)) === getWeek(new Date()) - 1) 22 | .map((c) => c.value); 23 | 24 | const dataList = [ 25 | { label: t('lastWeek'), data: createPieChartData(lastWeekValues) }, 26 | { label: t('thisWeek'), data: createPieChartData(thisWeekValues) }, 27 | { label: t('today'), data: createPieChartData(todayValues) }, 28 | ]; 29 | 30 | // Calculate all time user score 31 | const allTimeValues = checkmarks.map((d) => d.value); 32 | const allTimeScore = calculateScore(allTimeValues); 33 | 34 | return ( 35 | <> 36 | {/* Title */} 37 | 38 | {t('yourPerformance')} 39 | 40 | 41 | 42 | 43 | {/* Pie charts */} 44 | 45 | {dataList.map(({ label, data }) => { 46 | const completedValue = data[0].value; 47 | const hasReachedGoal = completedValue >= goal; 48 | 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {/* User has reached the goal */} 58 | {hasReachedGoal ? ( 59 | 60 | ) : ( 61 | 62 | {t('goal')}: 63 |
64 | {goal}% 65 |
66 | )} 67 |
68 |
69 | 70 | 71 |
72 | ); 73 | })} 74 |
75 | 76 | 77 | 78 | {/* Bottom text */} 79 | 80 | {t('overallPerformance')}: {allTimeScore}% 81 | 82 | 83 | ); 84 | } 85 | 86 | function FixedHeightDivider() { 87 | return ( 88 | 95 | 96 | 97 | ); 98 | } 99 | 100 | function Label({ children, ...props }) { 101 | return ( 102 | 103 | {children} 104 | 105 | ); 106 | } 107 | 108 | function GoalLabel({ children }) { 109 | return ( 110 | 119 | 125 | {children} 126 | 127 | 128 | ); 129 | } 130 | 131 | const CHART_SIZE = 95; 132 | 133 | function CenteredBox({ children }) { 134 | return ( 135 | 147 | {children} 148 | 149 | ); 150 | } 151 | 152 | function ChartContainer({ children }) { 153 | return ( 154 | 161 | {children} 162 | 163 | ); 164 | } 165 | 166 | function PieChart({ data }) { 167 | const { 168 | palette: { primary, grey }, 169 | } = useTheme(); 170 | 171 | return ( 172 | 182 | ); 183 | } 184 | 185 | export { PerformancePanel }; 186 | -------------------------------------------------------------------------------- /src/app/unathenticated-app/unauthenticated-app.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link as RouterLink, Navigate, Route, Routes } from 'react-router-dom'; 3 | import { 4 | Button, 5 | ButtonGroup, 6 | Hidden, 7 | IconButton, 8 | Toolbar, 9 | Tooltip, 10 | useTheme, 11 | } from '@material-ui/core'; 12 | import { 13 | AccountCircle as AccountCircleIcon, 14 | Brightness4 as MoonIcon, 15 | Brightness7 as SunIcon, 16 | GitHub as GitHubIcon, 17 | PersonAdd as PersonAddIcon, 18 | } from '@material-ui/icons'; 19 | import { 20 | MobileMenu, 21 | MobileMenuItem, 22 | MobileMenuProvider, 23 | MobileMenuToggler, 24 | } from 'components/mobile-menu'; 25 | 26 | import { Copyright } from 'components/copyright'; 27 | import { LocaleSelect } from 'components/locale-select'; 28 | import { LandingScreen } from 'screens/landing'; 29 | import { ResetPasswordScreen } from 'screens/reset-password'; 30 | import { SignInScreen } from 'screens/sign-in'; 31 | import { SignUpScreen } from 'screens/sign-up'; 32 | import { useTranslation } from 'translations'; 33 | import { AppTitle } from './app-title'; 34 | import { Footer } from './footer'; 35 | import { Layout } from './layout'; 36 | import { MainContent } from './main-content'; 37 | import { Navbar, NavbarStartItem } from './navbar'; 38 | 39 | const githubProps = { 40 | target: '_blank', 41 | rel: 'noopener noreferrer', 42 | href: process.env.REACT_APP_GITHUB_LINK, 43 | }; 44 | 45 | const iconButtonProps = { 46 | edge: 'start', 47 | color: 'inherit', 48 | }; 49 | 50 | /** 51 | * Version of the app when user is not authenticated. 52 | */ 53 | function UnathenticatedApp() { 54 | const t = useTranslation(); 55 | const { palette, toggleDarkMode } = useTheme(); 56 | 57 | // Dark mode 58 | const darkModeLabel = t('darkModeSwitch'); 59 | const darkModeIcon = palette.mode === 'light' ? : ; 60 | 61 | // Github 62 | const githubLabel = t('githubRepo'); 63 | 64 | return ( 65 | 66 | {/* Navbar */} 67 | 68 | 69 | 70 | 71 | 72 | 73 | {/* Screens larger than `xs` */} 74 | 75 | {/* Locale select */} 76 | 77 | 78 | {/* Dark mode switch */} 79 | 80 | 85 | {darkModeIcon} 86 | 87 | 88 | 89 | {/* Github repo link */} 90 | 91 | 96 | 97 | 98 | 99 | 100 | {/* Sign in/up */} 101 | 102 | 105 | 108 | 109 | 110 | 111 | 112 | {/* Toggle mobile menu */} 113 | 114 | 115 | 116 | 117 | {/* Sign in */} 118 | 119 | 120 | 121 | 122 |

{t('signIn')}

123 |
124 | 125 | {/* Sign up */} 126 | 127 | 128 | 129 | 130 |

{t('signUp')}

131 |
132 | 133 | {/* Github repo link */} 134 | 135 | 136 | 137 | 138 |

{githubLabel}

139 |
140 | 141 | {/* Locale select */} 142 | 143 | 144 | {/* Dark mode switch */} 145 | 146 | {darkModeIcon} 147 |

{t('darkModeSwitch')}

148 |
149 |
150 |
151 | 152 | {/* Main content */} 153 | 154 | {/* Offset for navbar */} 155 | 156 | 157 | 158 | } /> 159 | } /> 160 | } /> 161 | } /> 162 | } /> 163 | 164 | 165 | 166 | {/* Footer */} 167 |
168 | 169 |
170 |
171 | ); 172 | } 173 | 174 | export { UnathenticatedApp }; 175 | -------------------------------------------------------------------------------- /src/images/towing.svg: -------------------------------------------------------------------------------- 1 | towing -------------------------------------------------------------------------------- /src/app/authenticated-app/authenticated-app.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useQueryClient } from 'react-query'; 3 | import { ErrorBoundary } from 'react-error-boundary'; 4 | import { Route, Routes } from 'react-router-dom'; 5 | import { Divider, List, Toolbar, Typography } from '@material-ui/core'; 6 | import { 7 | Add as AddIcon, 8 | Dashboard as DashboardIcon, 9 | ExitToApp as ExitIcon, 10 | List as ListIcon, 11 | Settings as SettingsIcon, 12 | } from '@material-ui/icons'; 13 | 14 | import * as guestData from 'data/guest'; 15 | 16 | import { AddHabitScreen } from 'screens/add-habit'; 17 | import { DashboardScreen } from 'screens/dashboard'; 18 | import { EditHabitScreen } from 'screens/edit-habit'; 19 | import { ManageHabitsScreen } from 'screens/manage-habits'; 20 | import { NotFoundScreen } from 'screens/not-found'; 21 | import { UserSettingsScreen } from 'screens/user-settings'; 22 | 23 | import { FullPageErrorFallback, ErrorFallback } from 'components/lib'; 24 | import { LocaleSelect } from 'components/locale-select'; 25 | import { GithubRepoLink } from 'components/github-repo-link'; 26 | 27 | import { useTranslation } from 'translations'; 28 | import { useAuth } from 'context/auth-context'; 29 | import { useDialog } from 'context/dialog-context'; 30 | import { 31 | useUpdateLocaleCode, 32 | useUpdateUserData, 33 | useDeleteUserData, 34 | } from 'api/user-data'; 35 | 36 | import { Drawer, DrawerButton, DrawerLink } from './drawer'; 37 | import { Layout } from './layout'; 38 | import { Navbar } from './navbar'; 39 | import { MainContent } from './main-content'; 40 | 41 | /** 42 | * Authenticated App 43 | */ 44 | function AuthenticatedApp() { 45 | const queryClient = useQueryClient(); 46 | const { user, signOut } = useAuth(); 47 | const { openDialog } = useDialog(); 48 | const t = useTranslation(); 49 | 50 | const deleteUserData = useDeleteUserData(); 51 | const updateUserData = useUpdateUserData(); 52 | 53 | /** 54 | * Initializing the data when user is Anonymous 55 | * 56 | * When user logs in as guest we provide him with some dummy data. This data 57 | * is immediately stored in the cache and updated in user's data point in the database. 58 | * 59 | * This will be handy when creating an account by linking this anonymous account. 60 | */ 61 | React.useEffect(() => { 62 | /** 63 | * Check if user is anonymous and if they have habits and checkmarks in the cache. 64 | * If they've already have the data in cache it means that it has already been initialized 65 | * and there is no need to do it again. 66 | */ 67 | if (user.isAnonymous) { 68 | const { habits, dbHabits, checkmarks, dbCheckmarks } = guestData; 69 | 70 | // Set data in the cache 71 | queryClient.setQueryData('habits', habits); 72 | queryClient.setQueryData('checkmarks', checkmarks); 73 | 74 | // Set data in the database 75 | updateUserData({ 76 | habits: dbHabits, 77 | checkmarks: dbCheckmarks, 78 | }); 79 | } 80 | // Run ONLY on initial mount. 81 | // eslint-disable-next-line react-hooks/exhaustive-deps 82 | }, []); 83 | 84 | // Logout click handler 85 | const handleLogoutClick = () => { 86 | openDialog({ 87 | title: t('signOutQuestion'), 88 | description: t('signOutDescription'), 89 | confirmText: t('signOutConfirm'), 90 | onConfirm: async () => { 91 | try { 92 | // When signing out and user is anonymous, delete their data 93 | if (user.isAnonymous) { 94 | await deleteUserData(); 95 | await signOut(); 96 | } else { 97 | await signOut(); 98 | } 99 | } catch (error) { 100 | console.log(error, error.message); 101 | } 102 | }, 103 | color: 'secondary', 104 | }); 105 | }; 106 | 107 | const updateLocaleCode = useUpdateLocaleCode(); 108 | 109 | // When locale is clicked, user's data in the database is updated 110 | const handleLocaleClick = (clickedLocaleCode) => { 111 | updateLocaleCode(clickedLocaleCode); 112 | }; 113 | 114 | return ( 115 | 116 | 117 | {/* Navbar */} 118 | 119 | 120 | 121 | 122 | 123 | {/* Drawer */} 124 | 125 | 126 | 127 | Habit tracker 128 | 129 | 130 | 131 | 132 | }> 133 | Dashboard 134 | 135 | 136 | }> 137 | {t('addHabit')} 138 | 139 | 140 | }> 141 | {t('manageHabits')} 142 | 143 | 144 | 145 | 146 | }> 147 | {t('settings')} 148 | 149 | }> 150 | {t('signOut')} 151 | 152 | 153 | 154 | 155 | {/* Content */} 156 | 157 | 158 | 159 | } /> 160 | } /> 161 | } 164 | /> 165 | } /> 166 | } /> 167 | } /> 168 | 169 | 170 | 171 | 172 | 173 | ); 174 | } 175 | 176 | export { AuthenticatedApp }; 177 | -------------------------------------------------------------------------------- /src/images/progress-data.svg: -------------------------------------------------------------------------------- 1 | progress_data -------------------------------------------------------------------------------- /src/screens/dashboard.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useCheckmarksQuery } from 'api/checkmarks'; 3 | import { useHabitsQuery } from 'api/habits'; 4 | import { NoHabitsScreen } from 'screens/no-habits'; 5 | import { useUser } from 'context/user-context'; 6 | import { useLocale } from 'localization'; 7 | import { WeekBarChart } from 'components/week-bar-chart'; 8 | import { HabitsTable } from 'components/habits-table'; 9 | import { FullPageErrorFallback, FullPageSpinner } from 'components/lib'; 10 | import { PerformancePanel } from 'components/performance-panel'; 11 | import { WeekPicker } from 'components/week-picker'; 12 | import { Box, Container, Grid, Hidden, Paper } from '@material-ui/core'; 13 | import { 14 | eachDayOfInterval, 15 | endOfWeek, 16 | lightFormat, 17 | startOfWeek, 18 | } from 'date-fns'; 19 | 20 | /** 21 | * Dashboard Screen 22 | * 23 | * In the dashboard there are: 24 | * - Habits table - user can change the completion state of the habits. 25 | * - User Scores - shows the user's performance for last week, current week and today. 26 | * - Week Picker - user can pick the week which will update the table. 27 | * 28 | * ### TODO: All time performance chart or something like that. 29 | */ 30 | function DashboardScreen() { 31 | const locale = useLocale(); 32 | 33 | // Habits data 34 | const { 35 | data: habits, 36 | error: habitsError, 37 | isLoading: isLoadingHabits, 38 | } = useHabitsQuery(); 39 | // Checkmarks data 40 | const { data: checkmarks, error: checkmarksError } = useCheckmarksQuery(); 41 | 42 | const { performanceGoal } = useUser(); 43 | 44 | // Date 45 | const [selectedDate, setSelectedDate] = React.useState(new Date()); 46 | const start = startOfWeek(selectedDate, { locale }); 47 | const end = endOfWeek(selectedDate, { locale }); 48 | 49 | // Get dates that are currently selected 50 | const selectedDates = eachDayOfInterval({ start, end }).map((date) => 51 | lightFormat(date, 'yyyy-MM-dd') 52 | ); 53 | 54 | // Filter checkmarks for the selected dates 55 | const selectedDatesCheckmarks = checkmarks.filter((checkmark) => 56 | selectedDates.includes(checkmark.date) 57 | ); 58 | 59 | // Loading habits data 60 | if (isLoadingHabits) { 61 | return ; 62 | } 63 | 64 | const error = habitsError || checkmarksError; 65 | 66 | /** 67 | * Temporary fix 68 | * 69 | * Cancelled query is throwing `CancelledError`. In V3 cancellation will not throw an error anymore. 70 | * https://github.com/tannerlinsley/react-query/discussions/1179 71 | */ 72 | const isCancelledError = 73 | checkmarksError && checkmarksError.hasOwnProperty('silent'); 74 | 75 | // Ignore cancelled errors 76 | if (error && !isCancelledError) { 77 | return ; 78 | } 79 | 80 | // There are no habits 81 | if (!habits.length) { 82 | return ; 83 | } 84 | 85 | // Bar chart 86 | const barChart = ( 87 | 88 | 94 | 95 | ); 96 | 97 | // Week picker 98 | const weekPicker = ( 99 | 100 | setSelectedDate(newDate)} 103 | /> 104 | 105 | ); 106 | 107 | // Habits and checkmarks table 108 | const habitsTable = ( 109 | 110 | 115 | 116 | ); 117 | 118 | // Performance panel 119 | const performancePanel = ( 120 | 121 | 122 | 123 | ); 124 | 125 | // Render 126 | return ( 127 | 128 | 129 | {/* extra small screens */} 130 | 131 | 132 | 133 | 134 | {weekPicker} 135 | 136 | 137 | {habitsTable} 138 | 139 | 140 | {performancePanel} 141 | 142 | 143 | {barChart} 144 | 145 | 146 | 147 | 148 | 149 | {/* small screens */} 150 | 151 | 152 | 153 | 154 | {performancePanel} 155 | 156 | 157 | {weekPicker} 158 | 159 | 160 | {habitsTable} 161 | 162 | 163 | {barChart} 164 | 165 | 166 | 167 | 168 | 169 | {/* medium screens and up */} 170 | 171 | 172 | 173 | 174 | {barChart} 175 | 176 | 177 | {performancePanel} 178 | 179 | 180 | {weekPicker} 181 | 182 | 183 | {habitsTable} 184 | 185 | 186 | 187 | 188 | 189 | 190 | ); 191 | } 192 | 193 | function LargePaper({ children }) { 194 | return ( 195 | 204 | {children} 205 | 206 | ); 207 | } 208 | 209 | function SmallPaper({ children }) { 210 | return ( 211 | 222 | {children} 223 | 224 | ); 225 | } 226 | 227 | export { DashboardScreen }; 228 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Habit Tracker 2 | 3 | ## Table of Contents 4 | 5 | * [About](#about) 6 | * [Features and Stack](#features-and-stack) 7 | * [Screenshots](#screenshots) 8 | * [Getting Started](#getting-started) 9 | * [Challenges](#challenges) 10 | * [Create React App](#bootstrapped-with-create-react-app) 11 | 12 | ## About 13 | 14 | **Habit Tracker** is my final project for [CS50's Introduction to Computer Science Course](https://www.edx.org/course/cs50s-introduction-to-computer-science). 15 | What can I say, **Habit Tracker** keeps track of your habits :fireworks: :ok_hand: :tada: 16 | 17 | ### Video presentation 18 | 19 | A short video (2 minutes in length) where I present the project. 20 | 21 | [![Video presentation](https://i.gyazo.com/e642a79e194b30fa3deaa050e0c4b0f5.png)](https://www.youtube.com/watch?v=zIr_d1ZsIGQ) 22 | 23 | ## Features and Stack 24 | 25 | ### Features 26 | * create an account with using email and password 27 | * authenticate using Facebook, GitHub or Google 28 | * login as a guest 29 | * add, edit and delete habits 30 | * mark the habits as `completed`, `failed` or `skipped` 31 | * weekly performance is visualized in a bar chart 32 | * brief summary of performance for last week, current week, current day and all time 33 | * customize the app by changing `primary` and `secondary` color 34 | * toggle dark mode 35 | * choose your language: `ES | EN | PL` 36 | 37 | ### Stack 38 | 39 | * React 40 | * React Query 41 | * React Router 42 | * React Hook Form 43 | * Material UI 44 | * Firebase 45 | * Authentication 46 | * Realtime Database 47 | 48 | ## Screenshots 49 | 50 | * Landing Page 51 | 52 | ![Landing Page](screenshots/landing.png) 53 | 54 | * Sign up using **Facebook**, **GitHub**, **Google** or create a new account using your email address. 55 | 56 | ![Sign in](screenshots/sign-up.png) 57 | 58 | * Create new habit 59 | 60 | ![Create habit](screenshots/add-habit.png) 61 | 62 | * Manage your habits - preview, edit or delete your habits 63 | 64 | ![Manage habits](screenshots/manage-habits.png) 65 | 66 | * Keep track of your habits in the Dashboard 67 | 68 | ![Dashboard](screenshots/dashboard.png) 69 | 70 | * Change your settings 71 | 72 | ![Settings](screenshots/settings.png) 73 | 74 | * Customize the app the way you want 75 | 76 | ![Custom theme](screenshots/layout-theme.png) 77 | 78 | ## Getting started 79 | 80 | Below you'll find the instructions for setting up the project locally and a walkthrough video, where I'm following these instructions. 81 | 82 | [![local-dev-thumbnail](https://user-images.githubusercontent.com/58401630/159131748-9181af46-22c3-4648-ae18-e82572f4843c.png)](https://youtu.be/-Iv88vi71gM) 83 | 84 | ### Clone repo and install dependencies 85 | 86 | ```bash 87 | # Clone the repo 88 | git clone https://github.com/sitek94/habit-tracker-app.git 89 | 90 | # Install dependencies 91 | cd habit-tracker-app 92 | yarn 93 | ``` 94 | 95 | ### Connect Firebase 96 | 97 | While you’re waiting for the dependencies to install, you can set up the Firebase. 98 | 99 | 1. Login to [Firebase](https://console.firebase.google.com/) 100 | 2. Create project 101 | 3. Create Realtime Database 102 | 1. In step 2, check “Start in **test mode”** 103 | 4. Authentication > Sign-in method > Sign-in providers, and add the following: 104 | 1. Email/Password 105 | 2. Google 106 | 3. Anonymous 107 | 4. (Optional): If you want to add Facebook and/or GitHub, you’ll have to get Client IDs and secrets from these services 108 | 5. Go to Project Overview and add a web app 109 | 6. You don’t need to run `npm install firebase`, it’s already installed 110 | 7. You should see a `firebaseConfig` similar to this: 111 | 112 | ```bash 113 | const firebaseConfig = { 114 | apiKey: "", 115 | authDomain: "", 116 | databaseURL: "", 117 | projectId: "", 118 | storageBucket: "", 119 | messagingSenderId: "", 120 | appId: "", 121 | measurementId: "", 122 | }; 123 | ``` 124 | 125 | 8. Create `.env.local` file, by duplicating `.env.local.example`, and use config above to fill it out 126 | 127 | ### Start the app 128 | 129 | ```bash 130 | # Start development server 131 | yarn start 132 | ``` 133 | The app should be running at: [http://localhost:3000](http://localhost:3000/) 134 | 135 | ## Challenges 136 | 137 | I learned a lot while building the project and for sure I'm going to learn a lot more while maintaining it. 138 | That's why I want to keep track of the challenges that I've had along the way so that I can reference them in the future. 139 | 140 | ### Database and data structure 141 | 142 | How should I store habit's completion state for each day? Should each habit have an array with the dates 143 | when it was performed or should I store dates and each date would keep track of the habits that where performed on that day? 144 | 145 | I tried to structure the data so that it is saved and retrieved as easily as possible. To do so I've been following 146 | [Firebase guidelines](https://firebase.google.com/docs/database/web/structure-data) and in the end came up with the following data structure: 147 | 148 | ```json 149 | { 150 | "habits": { 151 | "user-one": { 152 | "habit-one": { 153 | "name": "Reading", 154 | "description": "Read a book for at least 30 min in the morning", 155 | "frequency": [0,1,2,3,4] 156 | } 157 | } 158 | }, 159 | "checkmarks": { 160 | "user-one": { 161 | "checkmark-id": { 162 | "habitId": "habit-one", 163 | "date": "2020-11-11", 164 | "value": "completed" 165 | } 166 | } 167 | }, 168 | "users": { 169 | "user-one": { 170 | "locale": { 171 | "code": "en-GB" 172 | }, 173 | "theme": { 174 | "primaryColor": "blue", 175 | "secondaryColor": "red", 176 | "dark": true 177 | }, 178 | "performanceGoal": 80 179 | } 180 | } 181 | } 182 | ``` 183 | 184 | ### Authentication Layer 185 | 186 | For quite some time I was using Private and Public routes to prevent an authenticated user from accessing the parts of the app available only for logged in user. 187 | It was fine, but I wanted to use a different layout for authenticated users (additional sidebar on the left). 188 | 189 | I found the perfect solution in a [blog post by Kent C. Dodds](https://kentcdodds.com/blog/authentication-in-react-applications): 190 | 191 | ```jsx 192 | function App() { 193 | const user = useUser(); 194 | return user ? : ; 195 | } 196 | ``` 197 | 198 | ### Localization and language 199 | 200 | I've never before implemented this in an app, and I really wanted to give it a try. My main goal was to give the user an option to change their locale and language. 201 | Although this goal was achieved, the solution is far from ideal. First, I think that it would be better to split these two layers. For example in YouTube one 202 | can open settings and change either Language or Location. 203 | 204 | ## Bootstrapped with Create React App 205 | 206 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 207 | 208 | For the detailed description of available scripts see [CRA Documentation](https://create-react-app.dev/docs/available-scripts) 209 | -------------------------------------------------------------------------------- /src/api/habits.js: -------------------------------------------------------------------------------- 1 | import { useAuth } from 'context/auth-context'; 2 | import { useFirebase } from 'context/firebase-context'; 3 | import { useQueryClient, useQuery, useMutation } from 'react-query'; 4 | 5 | /** 6 | * Use add habit mutation 7 | * 8 | * @returns a react-query mutation that adds a new habit to the database. 9 | * The mutation takes as an argument a new habit object. 10 | */ 11 | export function useAddHabitMutation() { 12 | const { db } = useFirebase(); 13 | const { user } = useAuth(); 14 | const queryClient = useQueryClient(); 15 | 16 | const addHabitMutation = useMutation( 17 | (habit) => { 18 | const { name, description, frequency, position } = habit; 19 | 20 | // Get database ref for the new habit 21 | const newHabitRef = db.ref(`habits/${user.uid}`).push(); 22 | 23 | // Set the habit in the database 24 | return newHabitRef.set({ 25 | position, 26 | name, 27 | description, 28 | frequency, 29 | createdAt: new Date().toISOString(), 30 | }); 31 | }, 32 | { 33 | onSuccess: () => queryClient.invalidateQueries('habits'), 34 | } 35 | ); 36 | 37 | return addHabitMutation; 38 | } 39 | 40 | /** 41 | * Use delete habit hook 42 | * 43 | * @returns a react-query mutation that deletes the habit and all the checkmarks 44 | * associated with it from the database. 45 | * 46 | * The mutation takes as an argument habit id. 47 | */ 48 | export function useDeleteHabitMutationMutation() { 49 | const { db } = useFirebase(); 50 | const { user } = useAuth(); 51 | const queryClient = useQueryClient(); 52 | 53 | const deleteHabitMutation = useMutation( 54 | async (habitId) => { 55 | // When deleting the habit we have to delete both the habit 56 | // and all the habit's checkmarks 57 | return ( 58 | db 59 | // Find all the the habit's checkmarks 60 | .ref(`checkmarks/${user.uid}`) 61 | .orderByChild('habitId') 62 | .equalTo(habitId) 63 | .once('value') 64 | .then((checkmarks) => { 65 | const updates = {}; 66 | 67 | // For each checkmark create an update that removes it 68 | checkmarks.forEach((checkmark) => { 69 | updates[`checkmarks/${user.uid}/${checkmark.key}`] = null; 70 | }); 71 | 72 | // Remove habit update 73 | updates[`habits/${user.uid}/${habitId}`] = null; 74 | 75 | // Make the updates 76 | return db 77 | .ref() 78 | .update(updates) 79 | .then(() => habitId); 80 | }) 81 | ); 82 | }, 83 | { 84 | // When mutate is called: 85 | onMutate: async (habitId) => { 86 | // Cancel any outgoing refetches (so they don't overwrite our optimistic update) 87 | await queryClient.cancelQueries('habits'); 88 | await queryClient.cancelQueries('checkmarks'); 89 | 90 | // Snapshot previous values 91 | const previousHabits = queryClient.getQueryData('habits'); 92 | const previousCheckmarks = queryClient.getQueryData('checkmarks'); 93 | 94 | // Optimistically remove the habit from queryClient 95 | queryClient.setQueryData('habits', (old) => 96 | old.filter((habit) => habit.id !== habitId) 97 | ); 98 | 99 | // Optimistically remove the habit's checkmarks from queryClient 100 | queryClient.setQueryData('checkmarks', (old) => 101 | old.filter((checkmark) => checkmark.habitId !== habitId) 102 | ); 103 | 104 | // Return a context object with the snapshotted value 105 | return { previousHabits, previousCheckmarks }; 106 | }, 107 | // If the mutation fails, use the context returned from onMutate to roll back 108 | onError: (error, habitId, context) => { 109 | queryClient.setQueryData('habits', context.previousHabits); 110 | queryClient.setQueryData('checkmarks', context.previousCheckmarks); 111 | }, 112 | // Always refetch after error or success: 113 | onSettled: () => { 114 | queryClient.invalidateQueries('habits'); 115 | queryClient.invalidateQueries('checkmarks'); 116 | }, 117 | } 118 | ); 119 | 120 | return deleteHabitMutation; 121 | } 122 | 123 | /** 124 | * Use fetch habit by id hook 125 | * 126 | * @returns a function that fetches a habit by id from the database. 127 | */ 128 | export function useFetchHabit() { 129 | const { db } = useFirebase(); 130 | const { user } = useAuth(); 131 | 132 | /** 133 | * Fetch habit 134 | * 135 | * @param {string} id - ID of the habit to fetch 136 | */ 137 | const fetchHabit = (id) => { 138 | // Get habit database ref 139 | const habitRef = db.ref(`habits/${user.uid}/${id}`); 140 | 141 | // Get habit value 142 | return habitRef.once('value').then((snapshot) => { 143 | // Check if the habit's data exists 144 | if (snapshot.exists()) { 145 | // Return habit id and the rest of the values 146 | return { 147 | id, 148 | ...snapshot.val(), 149 | }; 150 | } else { 151 | // If there is no data return `null` 152 | return null; 153 | } 154 | }); 155 | }; 156 | 157 | return fetchHabit; 158 | } 159 | 160 | /** 161 | * Use habit by id hook 162 | * 163 | * @param {string} habitId 164 | * 165 | * @returns a react-query query that fetches the habit by id, using `fetchHabit` 166 | */ 167 | export function useHabitQuery(id) { 168 | const fetchHabit = useFetchHabit(); 169 | 170 | const habitQuery = useQuery(id && ['habit', id], () => fetchHabit(id), { 171 | enabled: id !== null, 172 | }); 173 | 174 | return habitQuery; 175 | } 176 | 177 | /** 178 | * Use habits hook 179 | * 180 | * @returns react-query query for that fetches all the user's habits from 181 | * the database. 182 | */ 183 | export function useHabitsQuery() { 184 | const { db } = useFirebase(); 185 | const { user } = useAuth(); 186 | 187 | const habitsQuery = useQuery('habits', () => { 188 | // Get all the user's habits from the database 189 | return db 190 | .ref(`habits/${user.uid}`) 191 | .once('value') 192 | .then((snapshot) => { 193 | let fetchedHabits = []; 194 | 195 | if (snapshot.exists()) { 196 | // Iterate over each habit to get its ID and values 197 | snapshot.forEach((childSnapshot) => { 198 | fetchedHabits.push({ 199 | id: childSnapshot.key, 200 | ...childSnapshot.val(), 201 | }); 202 | }); 203 | } 204 | 205 | return fetchedHabits; 206 | }); 207 | }); 208 | 209 | return habitsQuery; 210 | } 211 | 212 | /** 213 | * Use update habit hook 214 | * 215 | * @returns a react-query mutation that updates the habit in the database. 216 | * The mutation takes as an argument habit object. 217 | */ 218 | export function useUpdateHabitMutation() { 219 | const { db } = useFirebase(); 220 | const { user } = useAuth(); 221 | const queryClient = useQueryClient(); 222 | 223 | const updateHabitMutation = useMutation( 224 | (habit) => { 225 | const { id, name, description, frequency } = habit; 226 | 227 | // Get checkmark database ref 228 | const habitRef = db.ref(`habits/${user.uid}/${id}`); 229 | 230 | // Update the habit in the database 231 | return ( 232 | habitRef 233 | .update({ 234 | name, 235 | description, 236 | frequency, 237 | }) 238 | // Return the habit object so it can be used in `onMutate`, etc. 239 | .then(() => habit) 240 | ); 241 | }, 242 | { 243 | // When mutate is called: 244 | onMutate: (habit) => { 245 | const previousHabit = queryClient.getQueryData(['habit', habit.id]); 246 | 247 | // Snapshot previous values 248 | queryClient.setQueryData(['habit', habit.id], (old) => ({ 249 | ...old, 250 | ...habit, 251 | })); 252 | 253 | // Return a context object with the snapshotted value 254 | return { previousHabit }; 255 | }, 256 | // If the mutation fails, use the context returned from onMutate to roll back 257 | onError: (error, newCheckmark, context) => { 258 | queryClient.setQueryData('habits', context.previousCheckmarks); 259 | }, 260 | // Always refetch after error or success: 261 | onSuccess: async (habit) => { 262 | queryClient.refetchQueries('habits'); 263 | await queryClient.refetchQueries(['habit', habit.id]); 264 | }, 265 | } 266 | ); 267 | 268 | return updateHabitMutation; 269 | } 270 | --------------------------------------------------------------------------------