├── 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 |
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 |
34 | );
35 | }
36 |
37 | export { Navbar, NavbarStartItem, NavbarRouterLink };
38 |
--------------------------------------------------------------------------------
/src/screens/manage-habits.js:
--------------------------------------------------------------------------------
1 | import { Box } from '@material-ui/core';
2 | import { HabitList } from 'components/habit-list';
3 | import { FullPageSpinner } from 'components/lib';
4 | import { useHabitsQuery } from 'api/habits';
5 | import { NoHabitsScreen } from 'screens/no-habits';
6 |
7 | /**
8 | * Manage Habits Screen
9 | *
10 | * Here user can see all their habits, navigate to 'Edit Habit Screen`
11 | * and delete habits.
12 | *
13 | * ### TODO: Add arrows (or other mechanism) to change a habit position.
14 | */
15 | function ManageHabitsScreen() {
16 | const { data: habits, isLoading } = useHabitsQuery();
17 |
18 | if (isLoading) return ;
19 |
20 | if (!habits.length) return ;
21 |
22 | return (
23 |
29 |
30 |
31 | );
32 | }
33 |
34 | export { ManageHabitsScreen };
35 |
--------------------------------------------------------------------------------
/src/components/heatmap-calendar.js:
--------------------------------------------------------------------------------
1 | import { ResponsiveCalendar } from '@nivo/calendar';
2 |
3 | function HeatmapCalendar({ data }) {
4 | return (
5 |
31 | );
32 | }
33 |
34 | export { HeatmapCalendar };
35 |
--------------------------------------------------------------------------------
/src/localization/locales.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { enGB, enUS, es, pl } from 'date-fns/locale';
3 | import { ReactComponent as PolandFlagSvg } from 'icons/poland.svg';
4 | import { ReactComponent as SpainFlagSvg } from 'icons/spain.svg';
5 | import { ReactComponent as UnitedKingdomFlagSvg } from 'icons/united-kingdom.svg';
6 | import { ReactComponent as UnitedStatesFlagSvg } from 'icons/united-states.svg';
7 |
8 | // Defaukt locale
9 | const defaultLocale = enUS;
10 |
11 | // Available locales
12 | const locales = [
13 | { code: 'es', label: 'Español', icon: , import: es },
14 | {
15 | code: 'en-US',
16 | label: 'English US',
17 | icon: ,
18 | import: enUS,
19 | },
20 | {
21 | code: 'en-GB',
22 | label: 'English GB',
23 | icon: ,
24 | import: enGB,
25 | },
26 | { code: 'pl', label: 'Polski', icon: , import: pl },
27 | ];
28 |
29 | export { defaultLocale, locales };
30 |
--------------------------------------------------------------------------------
/src/app/authenticated-app/drawer-context.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | // Context
4 | const DrawerContext = React.createContext();
5 |
6 | // Provider
7 | function DrawerProvider({ children }) {
8 | const [isDrawerOpen, setIsDrawerOpen] = React.useState(false);
9 |
10 | // Close drawer
11 | const closeDrawer = () => {
12 | setIsDrawerOpen(false);
13 | };
14 |
15 | // Toggle drawer
16 | const onDrawerToggle = () => {
17 | setIsDrawerOpen(!isDrawerOpen);
18 | };
19 |
20 | const context = {
21 | isDrawerOpen,
22 | closeDrawer,
23 | onDrawerToggle,
24 | };
25 |
26 | return (
27 | {children}
28 | );
29 | }
30 |
31 | // Hook
32 | function useDrawer() {
33 | const context = React.useContext(DrawerContext);
34 |
35 | if (context === 'undefined') {
36 | throw new Error(`useDrawer must be used within a DrawerProvider`);
37 | }
38 |
39 | return context;
40 | }
41 |
42 | export { DrawerProvider, useDrawer };
43 |
44 |
--------------------------------------------------------------------------------
/src/components/bar-chart.js:
--------------------------------------------------------------------------------
1 | import { ResponsiveBar } from '@nivo/bar';
2 |
3 | export function BarChart({ data, keys, indexBy, maxValue }) {
4 | return (
5 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Maciek Sitkowski
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/icons/spain.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
42 |
--------------------------------------------------------------------------------
/src/app/authenticated-app/navbar.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { AppBar, IconButton, makeStyles, Toolbar } from '@material-ui/core';
3 | import { Menu as MenuIcon } from '@material-ui/icons';
4 | import { useDrawer } from './drawer-context';
5 |
6 | const useStyles = makeStyles((theme) => ({
7 | toolbar: {
8 | justifyContent: 'flex-end',
9 | },
10 | menuButton: {
11 | // Push other menu elements to the right
12 | marginRight: 'auto',
13 | [theme.breakpoints.up('lg')]: {
14 | display: 'none',
15 | },
16 | },
17 | }));
18 |
19 | function Navbar({ children }) {
20 | const classes = useStyles();
21 |
22 | const { onDrawerToggle } = useDrawer();
23 |
24 | return (
25 |
26 |
27 |
34 |
35 |
36 | {children}
37 |
38 |
39 | );
40 | }
41 |
42 | export { Navbar };
43 |
--------------------------------------------------------------------------------
/src/components/performance-panel/helpers.js:
--------------------------------------------------------------------------------
1 | import { COMPLETED } from 'data/constants';
2 |
3 | /**
4 | * Checkmark object
5 | * @typedef Checkmark
6 | *
7 | * @property {string} id
8 | * @property {string} date
9 | * @property {string} habitId
10 | * @property {number} value
11 | */
12 |
13 | /**
14 | * Calculate score
15 | *
16 | * Calculates the score for an array of checkmark values.
17 | * The perfomance is the count of COMPLETED checkmarks divided
18 | * by the total count.
19 | *
20 | * @param {number[]} values
21 | *
22 | * @returns {number} floored score between 0 and 100
23 | */
24 | export function calculateScore(values) {
25 | if (!values.length) return 0;
26 |
27 | const completedCount = values.reduce((sum, value) => {
28 | return value === COMPLETED ? sum + 1 : sum;
29 | }, 0);
30 |
31 | return Math.floor((completedCount / values.length) * 100);
32 | }
33 |
34 | /**
35 | * Create pie chart data
36 | *
37 | * @returns an array of data for pie chart
38 | */
39 | export function createPieChartData(values) {
40 | const score = calculateScore(values);
41 |
42 | return [
43 | {
44 | id: 'value',
45 | value: score,
46 | },
47 | {
48 | id: 'empty',
49 | value: 100 - score,
50 | },
51 | ];
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/github-repo-link.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { IconButton, Link, MenuItem, Tooltip } from '@material-ui/core';
4 | import { GitHub as GitHubIcon } from '@material-ui/icons';
5 | import { useTranslation } from 'translations';
6 |
7 | /**
8 | * Github Repo Link
9 | */
10 |
11 | const commonProps = {
12 | target: '_blank',
13 | rel: 'noopener noreferrer',
14 | href: 'https://github.com/sitek94/habit-tracker',
15 | };
16 |
17 | function GithubRepoLink({ variant = 'icon', ...props }) {
18 | const t = useTranslation();
19 |
20 | if (variant === 'icon') {
21 | return (
22 |
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | if (variant === 'item') {
31 | return (
32 |
38 | );
39 | }
40 | }
41 |
42 | GithubRepoLink.propTypes = {
43 | variant: PropTypes.oneOf(['icon', 'item']),
44 | };
45 |
46 | export { GithubRepoLink };
47 |
--------------------------------------------------------------------------------
/src/utils/__tests__/misc.test.js:
--------------------------------------------------------------------------------
1 | import { descendingComparator, getComparator } from '../misc';
2 |
3 | const achilles = { name: 'Achilles', rating: 100 };
4 | const zorro = { name: 'Zorro', rating: 0 };
5 | const mario = { name: 'Mario', rating: 0 };
6 |
7 | // Descending comparator
8 | describe('descending comparator', () => {
9 | it(`returns -1 when 'a' prop is greater than 'b' prop`, () => {
10 | expect(descendingComparator(achilles, zorro, 'rating')).toBe(-1);
11 | });
12 |
13 | it(`returns 1 when 'a' prop is lestt than 'b' prop`, () => {
14 | expect(descendingComparator(zorro, achilles, 'rating')).toBe(1);
15 | });
16 |
17 | it(`returns 0 when 'a' prop is equal to 'b' prop`, () => {
18 | expect(descendingComparator(zorro, mario, 'rating')).toBe(0);
19 | });
20 | });
21 |
22 | // Comparator getter
23 | describe('getComparator', () => {
24 | it('returns descending comparator when called with desc', () => {
25 | const descendingComparator = getComparator('desc', 'rating');
26 |
27 | expect(descendingComparator(achilles, zorro, 'rating')).toBe(-1);
28 | });
29 |
30 | it('returns ascending comparator when called with value other than desc', () => {
31 | const ascendingComparator = getComparator('sth', 'rating');
32 |
33 | expect(ascendingComparator(achilles, zorro, 'rating')).toBe(1);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/theme/theme-context.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import {
3 | createMuiTheme,
4 | CssBaseline,
5 | MuiThemeProvider,
6 | } from '@material-ui/core';
7 | import { createDefaultTheme, defaultTheme } from './theme';
8 |
9 | /**
10 | * Theme provider
11 | */
12 | function ThemeProvider({ children }) {
13 | const [theme, setTheme] = React.useState(defaultTheme);
14 |
15 | const toggleDarkMode = React.useCallback(() => {
16 | const { mode, primary, secondary } = theme.palette;
17 |
18 | setTheme(
19 | createMuiTheme({
20 | palette: {
21 | primary,
22 | secondary,
23 | mode: mode === 'light' ? 'dark' : 'light',
24 | },
25 | })
26 | );
27 | }, [theme]);
28 |
29 | const resetTheme = React.useCallback(
30 | () => {
31 | const { mode } = theme.palette;
32 | setTheme(createDefaultTheme(mode))
33 | }, [theme]
34 | )
35 |
36 | const themeValue = {
37 | // Theme object has to be spread here so that it properties can be accessed directly.
38 | ...theme,
39 |
40 | // Additional properties
41 | setTheme,
42 | toggleDarkMode,
43 | resetTheme,
44 | };
45 |
46 | return (
47 |
48 |
49 | {children}
50 |
51 | );
52 | }
53 |
54 | export { ThemeProvider };
55 |
--------------------------------------------------------------------------------
/src/app/unathenticated-app/layout.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { alpha, Box, hexToRgb, useTheme } from '@material-ui/core';
3 | import hero from 'images/hero.jpg';
4 |
5 | /**
6 | * Layout with background image
7 | */
8 | function Layout({ children }) {
9 | return (
10 |
11 |
20 | {children}
21 |
22 |
23 | );
24 | }
25 |
26 | /**
27 | * Clones child component and adds a background image styles.
28 | */
29 | function BackgroundImage({ children }) {
30 | const { light, dark } = useTheme().palette.primary;
31 |
32 | const lightRgb = hexToRgb(light);
33 | const darkRgb = hexToRgb(dark);
34 |
35 | return (
36 |
50 | {children}
51 |
52 | );
53 | }
54 |
55 | export { Layout };
56 |
--------------------------------------------------------------------------------
/src/data/constraints.js:
--------------------------------------------------------------------------------
1 | import { object, string, array, ref } from 'yup';
2 |
3 | /**
4 | * HABIT
5 | */
6 | const habit = {
7 | name: string().required('Title is required.'),
8 | description: string().notRequired(),
9 | frequency: array().required('You have to select at least one day.'),
10 | };
11 |
12 | // Habit schema
13 | const habitSchema = object().shape({
14 | name: habit.name,
15 | description: habit.description,
16 | frequency: habit.frequency,
17 | });
18 |
19 | /**
20 | * USER
21 | */
22 | const user = {
23 | email: string()
24 | .email('Email address is invalid')
25 | .required('Email address is required.'),
26 |
27 | password: string()
28 | .min(6, 'Password must be at least 6 characters.')
29 | .required('You have to enter the password.'),
30 |
31 | passwordConfirmation: string()
32 | .min(6, 'Password must be at least 6 characters.')
33 | .oneOf([ref('password'), null], `Passwords don't match.`)
34 | .required('You have to enter the password confirmation.'),
35 | };
36 |
37 | // Sign in schema
38 | const signInSchema = object().shape({
39 | email: user.email,
40 | password: user.password,
41 | });
42 |
43 | // Sign up schema
44 | const signUpSchema = object().shape({
45 | email: user.email,
46 | password: user.password,
47 | passwordConfirmation: user.passwordConfirmation,
48 | });
49 |
50 | // Reset password schema
51 | const resetPasswordSchema = object().shape({
52 | email: user.email,
53 | });
54 |
55 | export { habitSchema, signInSchema, signUpSchema, resetPasswordSchema };
56 |
--------------------------------------------------------------------------------
/src/context/index.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { BrowserRouter as Router } from 'react-router-dom';
3 | import { QueryClientProvider, QueryClient } from 'react-query'
4 | // import { ReactQueryDevtools } from 'react-query/devtools';
5 | import { ThemeProvider } from 'theme';
6 | import { LocaleProvider } from 'localization';
7 | import { AuthProvider } from './auth-context';
8 | import { FirebaseProvider } from './firebase-context';
9 | import { SnackbarProvider } from './snackbar-context';
10 | import { DialogProvider } from './dialog-context';
11 | import { UserProvider } from './user-context';
12 |
13 | const queryClient = new QueryClient();
14 |
15 | /**
16 | * Shared context across all app
17 | */
18 | function AppProviders({ children }) {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 |
32 |
33 | {/* */}
34 |
35 |
36 | );
37 | }
38 |
39 | /**
40 | * Context used only when the user is authenticated
41 | */
42 | function AuthenticatedAppProviders({ children }) {
43 | return {children};
44 | }
45 |
46 | export { AppProviders, AuthenticatedAppProviders };
47 |
--------------------------------------------------------------------------------
/src/data/auth-providers.js:
--------------------------------------------------------------------------------
1 | // import { Apple as AppleIcon } from "mdi-material-ui";
2 | import { Facebook as FacebookIcon } from 'mdi-material-ui';
3 | import { Github as GitHubIcon } from 'mdi-material-ui';
4 | import { Google as GoogleIcon } from 'mdi-material-ui';
5 | // import { Microsoft as MicrosoftIcon } from "mdi-material-ui";
6 | // import { Twitter as TwitterIcon } from "mdi-material-ui";
7 | // import { Yahoo as YahooIcon } from "mdi-material-ui";
8 |
9 | /**
10 | * I created all the color objects using Material Design Color Tool
11 | *
12 | * https://material.io/resources/color/#!/
13 | */
14 | const authProviders = [
15 | // {
16 | // id: "apple.com",
17 | // color: "#000000",
18 | // icon: ,
19 | // name: "Apple",
20 | // },
21 | {
22 | id: 'facebook.com',
23 | color: '#3c5a99',
24 | icon: ,
25 | name: 'Facebook',
26 | },
27 | {
28 | id: 'github.com',
29 | color: '#24292e',
30 | icon: ,
31 | name: 'GitHub',
32 | // scopes: ["repo"],
33 | },
34 | {
35 | id: 'google.com',
36 | color: '#de5246',
37 | icon: ,
38 | name: 'Google',
39 | },
40 | // {
41 | // id: "microsoft.com",
42 | // color: "#f65314",
43 | // icon: ,
44 | // name: "Microsoft",
45 | // },
46 | // {
47 | // id: "twitter.com",
48 | // color: "#1da1f2",
49 | // icon: ,
50 | // name: "Twitter",
51 | // },
52 | // {
53 | // id: "yahoo.com",
54 | // color: "#410093",
55 | // icon: ,
56 | // name: "Yahoo",
57 | // },
58 | ];
59 |
60 | export default authProviders;
61 |
--------------------------------------------------------------------------------
/src/screens/not-found.js:
--------------------------------------------------------------------------------
1 | import { Link as RouterLink } from 'react-router-dom';
2 | import { Fab, Box, Typography } from '@material-ui/core';
3 | import { Home as HomeIcon } from '@material-ui/icons';
4 | import { ReactComponent as HelloDarkness } from 'images/hello-darkness.svg';
5 | import { useTranslation } from 'translations';
6 |
7 | /**
8 | * Not Found Screen
9 | *
10 | * This screen is displayed when the user tries to go the page that doesn't exist.
11 | *
12 | * There is a big button that takes the user back to the 'Dashboard Screen'.
13 | */
14 | function NotFoundScreen() {
15 | const t = useTranslation();
16 |
17 | return (
18 |
27 |
35 |
36 |
37 |
38 |
43 | {t('notFoundMessage')}
44 |
45 |
51 |
57 |
58 |
59 | {t('goBack')}
60 |
61 |
62 | );
63 | }
64 |
65 | export { NotFoundScreen };
66 |
--------------------------------------------------------------------------------
/src/context/snackbar-context.js:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useRef, useState } from 'react';
2 | import { Snackbar } from '@material-ui/core';
3 | import { Alert } from '@material-ui/core';
4 |
5 | // Context
6 | const SnackbarContext = createContext();
7 |
8 | // Provider
9 | const SnackbarProvider = ({ children }) => {
10 | const [isOpen, setIsOpen] = useState(false);
11 | const [message, setMessage] = useState('');
12 | const [severity, setSeverity] = useState('info');
13 |
14 | const openSnackbar = (severity, message) => {
15 | setIsOpen(true);
16 | setSeverity(severity);
17 | setMessage(message);
18 | };
19 |
20 | const closeSnackbar = () => {
21 | setIsOpen(false);
22 | };
23 |
24 | const handleClose = (event, reason) => {
25 | if (reason === 'clickaway') {
26 | return;
27 | }
28 |
29 | closeSnackbar();
30 | };
31 |
32 | const contextValue = useRef({ openSnackbar });
33 |
34 | return (
35 |
36 | {children}
37 |
38 |
44 | {message}
45 |
46 |
47 |
48 | );
49 | };
50 |
51 | // Hook
52 | function useSnackbar() {
53 | const context = useContext(SnackbarContext);
54 |
55 | if (context === undefined) {
56 | throw new Error('useSnackbar must be used within SnackbarProvider');
57 | }
58 |
59 | return context;
60 | }
61 |
62 | export { SnackbarProvider, useSnackbar };
63 |
--------------------------------------------------------------------------------
/src/screens/not-found-habit.js:
--------------------------------------------------------------------------------
1 | import { Link as RouterLink } from 'react-router-dom';
2 | import { Fab, Box, Typography } from '@material-ui/core';
3 | import { List as ListIcon } from '@material-ui/icons';
4 | import { ReactComponent as TowingSvg } from 'images/towing.svg';
5 | import { useTranslation } from 'translations';
6 |
7 | /**
8 | * Not Found Habit Screen
9 | *
10 | * This screen is displayed when the user tries to manually go to the
11 | * habit route that doesn't exist.
12 | *
13 | * There is a big button that navigates the user back to the 'Dashboard Screen'
14 | */
15 | function NotFoundHabitScreen() {
16 | const t = useTranslation();
17 |
18 | return (
19 |
28 |
36 |
37 |
38 |
39 |
44 | {t('habitNotFound')}
45 |
46 |
52 |
58 |
59 |
60 | {t('habitList')}
61 |
62 |
63 | );
64 | }
65 |
66 | export { NotFoundHabitScreen };
67 |
--------------------------------------------------------------------------------
/src/localization/locale-context.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import AdapterDateFns from '@material-ui/lab/AdapterDateFns';
3 | import LocalizationProvider from '@material-ui/lab/LocalizationProvider';
4 | import { addDays, format, startOfWeek } from 'date-fns';
5 | import { defaultLocale, locales } from './locales';
6 |
7 | /**
8 | * Locale provider
9 | */
10 | function LocaleProvider({ children }) {
11 | const [locale, setLocale] = React.useState(defaultLocale);
12 |
13 | /**
14 | * Updates locale by locale's code
15 | */
16 | const setLocaleByCode = React.useCallback(
17 | (newLocaleCode) => {
18 | // Find locale by its code
19 | const newLocale = locales.find((locale) => locale.code === newLocaleCode);
20 |
21 | if (newLocale) {
22 | // Update locale with newLocale's imported object
23 | setLocale(newLocale.import);
24 | } else {
25 | throw new Error(`Unhandled locale code provided: ${newLocaleCode}`);
26 | }
27 | }, []);
28 |
29 | /**
30 | * Array of weekdays, starting on the first day of the week
31 | * as specified in the locale
32 | */
33 | const weekdays = React.useMemo(() => {
34 | const firstDayOfWeek = startOfWeek(new Date(), { locale });
35 |
36 | return Array.from(Array(7)).map((_, i) =>
37 | format(addDays(firstDayOfWeek, i), 'eeee', { locale })
38 | );
39 | }, [locale]);
40 |
41 | const localeValue = {
42 | // Theme object has to be spread here so that it properties can be accessed directly.
43 | ...locale,
44 |
45 | // Additional properties
46 | weekdays,
47 | setLocaleByCode,
48 | }
49 |
50 | return (
51 |
52 | {children}
53 |
54 | );
55 | }
56 |
57 | export { LocaleProvider };
58 |
--------------------------------------------------------------------------------
/src/screens/no-habits.js:
--------------------------------------------------------------------------------
1 | import { Add as AddIcon } from '@material-ui/icons';
2 | import { ReactComponent as EmptyBox } from 'images/empty-box.svg';
3 | import { Link as RouterLink } from 'react-router-dom';
4 | import { Fab, Box, Typography } from '@material-ui/core';
5 | import { useTranslation } from 'translations';
6 |
7 | /**
8 | * No Habits Screen
9 | *
10 | * This screen is used to inform the user that they don't have any habits.
11 | * It is used for example in 'Manage Habits Screen'.
12 | *
13 | * There is a big button that navigates the user to 'Add Habit Screen'
14 | */
15 | function NoHabitsScreen() {
16 | const t = useTranslation();
17 |
18 | return (
19 |
28 |
36 |
37 |
38 |
39 |
45 |
46 | {t('noHabitsTitle')}
47 |
48 |
49 | {t('noHabitsDescription')}
50 |
51 |
52 |
58 |
64 |
65 |
66 | {t('addHabit')}
67 |
68 |
69 | );
70 | }
71 |
72 | export { NoHabitsScreen };
73 |
--------------------------------------------------------------------------------
/src/components/auth-providers-list.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Button } from '@material-ui/core';
4 | import {
5 | createMuiTheme,
6 | makeStyles,
7 | ThemeProvider,
8 | useTheme,
9 | } from '@material-ui/core/styles';
10 | import authProviders from 'data/auth-providers';
11 |
12 | const useStyles = makeStyles((theme) => ({
13 | // Button
14 | root: {
15 | justifyContent: 'flex-start',
16 | textTransform: 'none',
17 | },
18 | icon: {
19 | padding: theme.spacing(0, 2),
20 | },
21 | }));
22 |
23 | function AuthProviderButton({ providerColor, ...props }) {
24 | const classes = useStyles();
25 |
26 | // Create a theme with primary color set to corresponding auth provider color
27 | const providerTheme = React.useMemo(
28 | () =>
29 | createMuiTheme({
30 | palette: {
31 | primary: {
32 | main: providerColor,
33 | },
34 | },
35 | }),
36 | [providerColor]
37 | );
38 |
39 | return (
40 |
41 |
48 |
49 | );
50 | }
51 |
52 | function AuthProviderList({ text, disabled, onAuthProviderClick }) {
53 | const { palette } = useTheme();
54 |
55 | return authProviders.map(({ id, name, color, icon, scopes }) => (
56 | onAuthProviderClick(event, { id, scopes })}
60 | disabled={disabled}
61 | startIcon={icon}
62 | variant={palette.mode === 'dark' ? 'contained' : 'outlined'}
63 | fullWidth
64 | >
65 | {text} {name}
66 |
67 | ));
68 | }
69 |
70 | AuthProviderList.defaultProps = {
71 | disabled: false,
72 | };
73 |
74 | AuthProviderList.propTypes = {
75 | // Properties
76 | text: PropTypes.string,
77 | disabled: PropTypes.bool,
78 |
79 | // Events
80 | onAuthProviderClick: PropTypes.func.isRequired,
81 | };
82 |
83 | export { AuthProviderList, AuthProviderButton };
84 |
--------------------------------------------------------------------------------
/src/components/habit-row.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Checkmark } from 'components/checkmark';
4 | import { makeStyles, TableCell, TableRow, Typography } from '@material-ui/core';
5 | import { getDay, parseISO } from 'date-fns';
6 | import { EMPTY } from 'data/constants';
7 |
8 | // Styles
9 | const useStyles = makeStyles((theme) => ({
10 | // A trick to set width of the table cell to its content
11 | minWidth: {
12 | width: '1%',
13 | whiteSpace: 'nowrap',
14 | },
15 | }));
16 |
17 | // Habit row
18 | function HabitRow({ habit, dates, checkmarks }) {
19 | const classes = useStyles();
20 |
21 | const { id, name, frequency /* position */ } = habit;
22 |
23 | return (
24 |
25 | {/* Position */}
26 | {/*
27 | {position + 1}
28 | */}
29 |
30 | {/* Name */}
31 |
37 | {name}
38 |
39 |
40 | {/* Dates */}
41 | {dates.map((date) => {
42 | const dateObj = parseISO(date);
43 |
44 | // Checkmark is disabled if the date is not tracked
45 | const disabled = !frequency.includes(getDay(dateObj));
46 |
47 | // Find checkmark
48 | const checkmark = checkmarks.find((d) => d.date === date);
49 |
50 | return (
51 |
52 |
59 |
60 | );
61 | })}
62 |
63 | );
64 | }
65 |
66 | HabitRow.propTypes = {
67 | habit: PropTypes.shape({
68 | id: PropTypes.string.isRequired,
69 | name: PropTypes.string.isRequired,
70 | frequency: PropTypes.arrayOf(PropTypes.number).isRequired,
71 | }).isRequired,
72 | dates: PropTypes.arrayOf(PropTypes.string).isRequired,
73 | };
74 |
75 | export { HabitRow };
76 |
--------------------------------------------------------------------------------
/src/components/account-tab.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { DeleteForever as DeleteForeverIcon } from '@material-ui/icons';
3 | import { useDeleteUserData } from 'api/user-data';
4 | import { useAuth } from 'context/auth-context';
5 | import { useDialog } from 'context/dialog-context';
6 | import { useSnackbar } from 'context/snackbar-context';
7 | import { useTranslation } from 'translations';
8 | import {
9 | Button,
10 | Hidden,
11 | List,
12 | ListItem,
13 | ListItemIcon,
14 | ListItemSecondaryAction,
15 | ListItemText,
16 | } from '@material-ui/core';
17 |
18 | /**
19 | * Account Tab
20 | *
21 | * A tab where user can change account settings and delete the account.
22 | */
23 | function AccountTab({ disabled }) {
24 | const { deleteAccount } = useAuth();
25 | const { openDialog } = useDialog();
26 | const { openSnackbar } = useSnackbar();
27 | const t = useTranslation();
28 |
29 | const deleteUserData = useDeleteUserData();
30 |
31 | const handleDeleteAccountClick = () => {
32 | openDialog({
33 | title: t('deleteAccountQuestion'),
34 | description: t('deleteAccountWarning'),
35 | confirmText: t('deleteAccount'),
36 | onConfirm: async () => {
37 | try {
38 | await deleteAccount();
39 | await deleteUserData();
40 |
41 | openSnackbar('success', t('accountDeleted'));
42 | } catch (error) {
43 | openSnackbar('error', error.message);
44 | }
45 | },
46 | color: 'secondary',
47 | });
48 | };
49 |
50 | return (
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
63 |
64 |
65 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
119 | );
120 | }
121 |
122 | export { SignInScreen };
123 |
--------------------------------------------------------------------------------
/src/images/hello-darkness.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
170 |
171 | );
172 | }
173 |
174 | export { UnathenticatedApp };
175 |
--------------------------------------------------------------------------------
/src/images/towing.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | [](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 | 
53 |
54 | * Sign up using **Facebook**, **GitHub**, **Google** or create a new account using your email address.
55 |
56 | 
57 |
58 | * Create new habit
59 |
60 | 
61 |
62 | * Manage your habits - preview, edit or delete your habits
63 |
64 | 
65 |
66 | * Keep track of your habits in the Dashboard
67 |
68 | 
69 |
70 | * Change your settings
71 |
72 | 
73 |
74 | * Customize the app the way you want
75 |
76 | 
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 | [](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 |
--------------------------------------------------------------------------------