├── styles
├── utils.module.css
└── global.css
├── public
├── decor
│ ├── hmmm.png
│ ├── quari.png
│ └── monstera.png
└── email
│ └── passwordReset.html
├── next.config.js
├── hooks
├── useRefName.js
├── useWarnError.js
├── index.js
├── useCalendar.js
└── useForm.js
├── pages
├── api
│ ├── auth
│ │ ├── logout.js
│ │ ├── getSession.js
│ │ ├── login.js
│ │ └── index.js
│ ├── server
│ │ ├── resolvers
│ │ │ ├── demo
│ │ │ │ ├── index.js
│ │ │ │ ├── clearSampleData.js
│ │ │ │ └── generateSampleData.js
│ │ │ ├── validation.js
│ │ │ └── mail.js
│ │ ├── index.js
│ │ └── schemas
│ │ │ └── index.js
│ ├── handleError.js
│ ├── handleRequest.js
│ └── index.js
├── 404.js
├── 500.js
├── settings.js
├── account.js
├── register.js
├── habits.js
├── _app.js
├── index.js
├── login.js
├── dashboard.js
└── recover.js
├── components
├── MyData
│ ├── Graph
│ │ ├── graph.module.css
│ │ ├── index.js
│ │ └── graphConfig.js
│ ├── Timeline
│ │ ├── timeline.module.css
│ │ └── index.js
│ ├── myData.module.css
│ ├── Calendar
│ │ ├── calendar.module.css
│ │ └── index.js
│ └── index.js
├── Footer
│ ├── index.js
│ └── footer.module.css
├── ArrowNav
│ ├── arrowNav.module.css
│ └── index.js
├── ErrorPage
│ ├── errorPage.module.css
│ └── index.js
├── Form
│ ├── Submit
│ │ ├── submit.module.css
│ │ └── index.js
│ ├── Switch
│ │ ├── index.js
│ │ └── switch.module.css
│ ├── Button
│ │ ├── index.js
│ │ └── button.module.css
│ ├── index.js
│ ├── Checkbox
│ │ ├── index.js
│ │ └── checkbox.module.css
│ ├── Dropdown
│ │ ├── dropdown.module.css
│ │ └── index.js
│ └── Input
│ │ ├── input.module.css
│ │ └── index.js
├── ViewOptions
│ ├── index.js
│ └── viewOptions.module.css
├── Nav
│ ├── blob.svg
│ ├── index.js
│ └── nav.module.css
├── Tooltip
│ ├── index.js
│ └── tooltip.module.css
├── Loading
│ ├── loading.module.css
│ └── index.js
├── MyAccount
│ ├── index.js
│ ├── DeleteAccount.js
│ ├── AccountDetails.js
│ └── ChangePassword.js
├── MySettings
│ ├── mySettings.module.css
│ └── index.js
├── Modal
│ ├── ModalForm.js
│ ├── index.js
│ ├── modal.module.css
│ └── modalStore.js
├── DataForm
│ └── dataForm.module.css
├── MiniCalendar
│ ├── miniCalendar.module.css
│ └── index.js
├── EmojiPicker
│ ├── index.js
│ └── emojiPicker.module.css
├── DashPanel
│ ├── index.js
│ └── dashPanel.module.css
├── Registration
│ └── index.js
└── MyHabits
│ ├── HabitGrid.js
│ ├── HabitList.js
│ └── myHabits.module.css
├── layouts
├── index.js
├── HomepageLayout
│ ├── index.js
│ └── homepageLayout.module.css
├── DashboardLayout
│ ├── Clock
│ │ ├── clock.module.css
│ │ └── index.js
│ ├── index.js
│ └── dashboardLayout.module.css
└── AppLayout
│ ├── appLayout.module.css
│ └── index.js
├── lib
├── session.js
└── prisma.js
├── .gitignore
├── contexts
├── index.js
├── MobileContext.js
├── ModalContext.js
└── DataContext.js
├── utils
└── index.js
├── package.json
├── prisma
└── schema.prisma
└── README.md
/styles/utils.module.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/decor/hmmm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gnoem/habitat/HEAD/public/decor/hmmm.png
--------------------------------------------------------------------------------
/public/decor/quari.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gnoem/habitat/HEAD/public/decor/quari.png
--------------------------------------------------------------------------------
/public/decor/monstera.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gnoem/habitat/HEAD/public/decor/monstera.png
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | serverRuntimeConfig: {
3 | PROJECT_ROOT: __dirname
4 | }
5 | }
--------------------------------------------------------------------------------
/hooks/useRefName.js:
--------------------------------------------------------------------------------
1 | export const useRefName = (ref) => {
2 | const { current: refName } = ref;
3 | return refName;
4 | }
--------------------------------------------------------------------------------
/pages/api/auth/logout.js:
--------------------------------------------------------------------------------
1 | import withSession from "../../../lib/session";
2 |
3 | export default withSession(async (req, res) => {
4 | await req.session.destroy();
5 | res.status(200).send({});
6 | });
--------------------------------------------------------------------------------
/pages/api/auth/getSession.js:
--------------------------------------------------------------------------------
1 | import withSession from "../../../lib/session";
2 |
3 | export default withSession(async (req, res) => {
4 | const user = req.session.get('user');
5 | res.status(200).send(user);
6 | });
7 |
--------------------------------------------------------------------------------
/components/MyData/Graph/graph.module.css:
--------------------------------------------------------------------------------
1 | .Graph {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: flex-start;
5 | justify-content: center;
6 | }
7 | .Graph > div {
8 | position: relative;
9 | width: 100%;
10 | }
--------------------------------------------------------------------------------
/components/Footer/index.js:
--------------------------------------------------------------------------------
1 | import styles from "./footer.module.css";
2 |
3 | const Footer = ({ children }) => {
4 | return (
5 |
permanently delete your account, along with all data, habits, and settings. this is irreversible ! if you really want to do this, click the button below. (you will be asked to confirm)
20 | `,
31 | to,
32 | subject,
33 | plaintext: 'hi \n this is habitat', // todo better plaintext option
34 | html: htmlToSend
35 | });
36 | return `Sent password recovery email to ${to}`;
37 | }
--------------------------------------------------------------------------------
/components/Form/Checkbox/index.js:
--------------------------------------------------------------------------------
1 | import styles from "./checkbox.module.css";
2 | import { fancyClassName } from "../../../utils";
3 |
4 | const Checkbox = ({ label, name, detailedLabel, checkboxAfter, className, checked, onChange }) => {
5 | const checkboxClassName = () => {
6 | let stringToReturn = '';
7 | if (detailedLabel) stringToReturn += `${styles.detailed}`;
8 | if (checkboxAfter) stringToReturn += ` ${styles.checkboxAfter}`;
9 | if (className) stringToReturn += ` ${fancyClassName({ styles, className })}`;
10 | return stringToReturn;
11 | }
12 | return (
13 |
14 |
20 | {label
21 | ?
{label}
22 | :
23 | {detailedLabel[0]}
24 | {detailedLabel[1]}
25 |
26 | }
27 |
28 | );
29 | }
30 |
31 | export default Checkbox;
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import Router, { useRouter } from "next/router";
3 |
4 | import nprogress from "nprogress";
5 |
6 | import "../styles/global.css";
7 | import { AppContextProvider, ModalContext } from "../contexts";
8 | import { AppLayout, DashboardWrapper, HomepageLayout } from "../layouts";
9 | import Modal from "../components/Modal";
10 |
11 | Router.events.on('routeChangeStart', () => nprogress.start());
12 | Router.events.on('routeChangeComplete', () => nprogress.done());
13 | Router.events.on('routeChangeError', () => nprogress.done());
14 |
15 | const App = ({ Component, pageProps }) => {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | const AppContent = ({ Component, pageProps }) => {
26 | const { modal } = useContext(ModalContext);
27 | const router = useRouter();
28 | const protectedRoutes = ['/dashboard', '/habits', '/account', '/settings'];
29 | const PageWrapper = (protectedRoutes.includes(router.pathname))
30 | ? DashboardWrapper
31 | : HomepageLayout;
32 | return (
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | export default App;
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect } from "react";
2 |
3 | import { faGithub } from "@fortawesome/free-brands-svg-icons";
4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5 |
6 | import { ModalContext } from "../contexts";
7 | import { getQueryParams } from "../utils";
8 | import Footer from "../components/Footer";
9 | import { Button } from "../components/Form";
10 |
11 | const Home = ({ query }) => {
12 | const { createModal } = useContext(ModalContext);
13 | useEffect(() => {
14 | const { demo } = query;
15 | if (demo == null) return;
16 | createModal('demoMessage');
17 | }, []);
18 | return (
19 | <>
20 | habitat is a digital habit tracker designed & created by naomi g.w. as a project for her portfolio, and also as a personal tool to track & organize her life.
21 |
22 | Login
23 | Register
24 |
25 | » read more about this project on github
26 | copyright 2021 naomi g.w. • all rights reserved
27 | >
28 | );
29 | }
30 |
31 | Home.getInitialProps = getQueryParams;
32 |
33 | export default Home;
--------------------------------------------------------------------------------
/layouts/AppLayout/appLayout.module.css:
--------------------------------------------------------------------------------
1 | .AppLayout {
2 | overflow: hidden;
3 | width: 100%;
4 | height: 100%;
5 | position: relative;
6 | z-index: 1;
7 | }
8 | .AppLayout::before, .f, .base {
9 | display: block;
10 | position: fixed;
11 | z-index: -1;
12 | top: 0;
13 | left: 0;
14 | width: 100%;
15 | height: 100%;
16 | transition: 1.3s ease-in-out;
17 | }
18 | .base {
19 | background: #fde8ff;
20 | }
21 | .f {
22 | width: 130%;
23 | height: 130%;
24 | }
25 |
26 | .full {
27 | width: 100%;
28 | height: 100%;
29 | }
30 |
31 | .AppLayout figure {
32 | position: fixed;
33 | bottom: 5rem;
34 | right: 5rem;
35 | width: 500px;
36 | height: auto;
37 | margin: 0;
38 | }
39 | .AppLayout img {
40 | width: auto;
41 | height: 100%;
42 | }
43 |
44 | @media screen and (max-width: 900px) {
45 | .AppLayout {
46 | overflow: auto;
47 | display: flex;
48 | flex-direction: column;
49 | font-size: 0.9rem;
50 | }
51 | .AppLayout figure {
52 | position: static;
53 | width: auto;
54 | height: 20%;
55 | padding: 4rem 0 0 4rem;
56 | padding-bottom: 0;
57 | }
58 | .AppLayout figure img {
59 | width: auto;
60 | height: auto;
61 | max-width: 100%;
62 | max-height: 100%;
63 | }
64 | }
65 |
66 | @media screen and (max-width: 600px) {
67 | .AppLayout figure {
68 | padding: 2rem 0 0 2rem;
69 | }
70 | }
--------------------------------------------------------------------------------
/components/Form/Submit/index.js:
--------------------------------------------------------------------------------
1 | import styles from "./submit.module.css";
2 | import { fancyClassName } from "../../../utils";
3 | import Button from "../Button";
4 | import Loading from "../../Loading";
5 |
6 | const Submit = ({ value, onClick, cancel, onCancel, successPending, successAnimation, className, disabled }) => {
7 | const hideText = !!successPending || !!successAnimation;
8 | return (
9 |
10 |
11 | {value ?? 'submit'}
12 |
13 |
14 | {(cancel !== false) && {cancel ?? 'cancel'} }
15 |
16 | );
17 | }
18 |
19 | const StatusIcon = ({ successPending, successAnimation }) => {
20 | if (!successPending && !successAnimation) return null;
21 | if (successPending) return ;
22 | if (successAnimation) return ;
23 | }
24 |
25 | const SuccessAnimation = ({ status }) => {
26 | return (
27 |
32 | );
33 | }
34 |
35 | export default Submit;
--------------------------------------------------------------------------------
/components/Tooltip/tooltip.module.css:
--------------------------------------------------------------------------------
1 | .TooltipElement {
2 | position: relative;
3 | }
4 | .Tooltip {
5 | position: absolute;
6 | z-index: 10;
7 | color: #fff;
8 | background: #000;
9 | padding: 0.375rem;
10 | font-family: var(--default-font);
11 | font-size: 0.75rem;
12 | opacity: 0;
13 | pointer-events: none;
14 | transition: 0.2s;
15 | }
16 | .Tooltip::before {
17 | content: '';
18 | position: absolute;
19 | display: block;
20 | width: 0.5rem;
21 | height: 0.5rem;
22 | background: inherit;
23 | }
24 | .Tooltip::after {
25 | content: '';
26 | position: absolute;
27 | z-index: -1;
28 | bottom: 0;
29 | left: 0;
30 | width: calc(100% + 0.5rem);
31 | height: 100%;
32 | transform: translate3d(-0.25rem, 0, 0) skew(-12deg);
33 | background: inherit;
34 | }
35 | .TooltipElement:hover .Tooltip {
36 | opacity: 1;
37 | pointer-events: default;
38 | }
39 | .Tooltip.above, .Tooltip.below {
40 | left: 50%;
41 | transform: translate3d(-50%, 0, 0);
42 | margin: 0.5rem 0;
43 | }
44 | .Tooltip.above {
45 | bottom: 100%;
46 | }
47 | .Tooltip.below {
48 | top: 100%;
49 | }
50 | .Tooltip.above::before {
51 | top: 100%;
52 | left: 50%;
53 | transform: translate3d(-50%, -0.25rem, 0) rotate(45deg);
54 | }
55 | .Tooltip.below::before {
56 | bottom: 100%;
57 | left: 50%;
58 | transform: translate3d(-50%, 0.25rem, 0) rotate(45deg);
59 | }
60 | .Tooltip.nowrap {
61 | white-space: nowrap;
62 | }
--------------------------------------------------------------------------------
/components/Modal/ModalForm.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 |
3 | import { ModalContext } from "../../contexts";
4 | import Form, { Submit } from "../Form";
5 |
6 | const ModalForm = (props) => {
7 | const { children, onSuccess, submit } = props;
8 | const { closeModal } = useContext(ModalContext);
9 | const handleSuccess = (result) => {
10 | onSuccess(result);
11 | closeModal();
12 | }
13 | const submitProps = {
14 | onCancel: closeModal
15 | }
16 | const modalSubmit = submit ? React.cloneElement(submit, submitProps) : ;
17 | return (
18 |
21 | );
22 | }
23 |
24 | export const ModalizedForm = ({ originalFormComponent, originalFormProps }) => {
25 | const { closeModal, submit } = useContext(ModalContext);
26 | const { onSuccess } = originalFormProps;
27 | const handleSuccess = () => {
28 | onSuccess?.();
29 | closeModal();
30 | }
31 | const submitProps = {
32 | onCancel: closeModal
33 | }
34 | const modalSubmit = submit ? React.cloneElement(submit, submitProps) : ;
35 | const componentToReturn = React.cloneElement(originalFormComponent, {
36 | onSuccess: handleSuccess,
37 | submit: modalSubmit,
38 | ...originalFormProps
39 | });
40 | return componentToReturn;
41 | }
42 |
43 | export default ModalForm;
--------------------------------------------------------------------------------
/components/DataForm/dataForm.module.css:
--------------------------------------------------------------------------------
1 | .DataForm {
2 | display: flex;
3 | flex-direction: column;
4 | }
5 | .DataFormField {
6 | display: flex;
7 | align-items: center;
8 | justify-content: flex-start;
9 | }
10 | .DataFormField + .DataFormField {
11 | margin-top: 0.5rem;
12 | }
13 | .DataFormField > span:first-child {
14 | font-size: 1.5rem;
15 | margin-right: 0.5rem;
16 | align-self: flex-start;
17 | filter: grayscale();
18 | transition: 0.2s;
19 | }
20 | .DataFormField > span.check:first-child {
21 | filter: none;
22 | }
23 | .DataFormField > span:last-child {
24 | display: flex;
25 | align-items: center;
26 | justify-content: flex-start;
27 | }
28 | .DataForm input {
29 | border: 0;
30 | background: #fff;
31 | }
32 | .DataForm input[type=number] {
33 | width: 4rem;
34 | padding-right: 0;
35 | margin: 0 0.4rem;
36 | line-height: 1.4rem;
37 | }
38 | .DataFormDateInputNav {
39 | margin-left: 1rem;
40 | }
41 | .DataFormDateInputNav > button {
42 | font-size: 1rem;
43 | }
44 | button.deleteEntry {
45 | font-size: 0.5rem;
46 | padding: 0.3rem 0.6rem;
47 | margin-top: 1rem;
48 | align-self: center;
49 | }
50 | button.deleteEntry > span {
51 | display: inline-block;
52 | margin-left: 0.375rem;
53 | font-size: 0.75rem;
54 | }
55 | .noHabits p {
56 | margin: 0.5rem 0 1.5rem;
57 | opacity: 0.6;
58 | }
59 |
60 | @media screen and (max-width: 900px) {
61 | .DataForm input {
62 | background: #f7f7f7;
63 | }
64 | .DataFormField > span:first-child {
65 | margin-right: 0.75rem;
66 | }
67 | }
--------------------------------------------------------------------------------
/components/MiniCalendar/miniCalendar.module.css:
--------------------------------------------------------------------------------
1 | .MiniCalendarWrapper {
2 | width: 100%;
3 | padding: 1rem 0 0;
4 | }
5 | .MiniCalendar {
6 | width: 100%;
7 | display: grid;
8 | grid-template-columns: repeat(7, 1fr);
9 | gap: 0.25rem;
10 | margin-bottom: 1rem;
11 | }
12 | .WeekLabels {
13 | font-family: var(--mono-font);
14 | text-transform: lowercase;
15 | border-bottom: 2px solid #000;
16 | margin-bottom: 0.25rem;
17 | }
18 | .MiniCalendar > div {
19 | text-align: center;
20 | }
21 | .Header {
22 | display: flex;
23 | align-items: center;
24 | justify-content: space-between;
25 | margin-bottom: 1rem;
26 | font-family: var(--mono-font);
27 | font-size: 1.2rem;
28 | font-weight: 300;
29 | text-transform: uppercase;
30 | }
31 | .CalendarDay:not(.filler) {
32 | font-size: 0.75rem;
33 | padding: 0.5rem;
34 | cursor: pointer;
35 | }
36 | .CalendarDay > span {
37 | position: relative;
38 | display: flex;
39 | align-items: center;
40 | justify-content: center;
41 | transition: 0.2s;
42 | }
43 | .CalendarDay > span::before {
44 | content: '';
45 | position: absolute;
46 | z-index: -1;
47 | background: #fff;
48 | width: 1.75rem;
49 | height: 1.75rem;
50 | border-radius: 999px;
51 | transition: 0.2s;
52 | }
53 | .CalendarDay:hover > span {
54 | transform: scale(1.2);
55 | }
56 | .CalendarDay:hover > span::before {
57 | box-shadow: 0.125rem 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
58 | }
59 |
60 | @media screen and (max-width: 900px) {
61 | .MiniCalendarWrapper {
62 | min-height: 75vh;
63 | }
64 | .CalendarDay > span::before {
65 | background: #f7f7f7;
66 | }
67 | }
--------------------------------------------------------------------------------
/components/MyAccount/AccountDetails.js:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 |
3 | import { User } from "../../pages/api";
4 | import { DataContext } from "../../contexts";
5 | import { useForm } from "../../hooks";
6 | import Form, { Input, Submit } from "../Form";
7 |
8 | const AccountDetails = ({ demo, user, isMobile }) => {
9 | const { setUser } = useContext(DataContext);
10 | const { formData, handleFormError, inputProps } = useForm({
11 | id: user.id,
12 | name: user.name ?? '',
13 | email: user.email ?? ''
14 | });
15 | const handleSubmit = () => {
16 | if (demo) {
17 | console.log('cute');
18 | return Promise.reject({ __typename: 'NiceTry' });
19 | }
20 | return User.edit(formData);
21 | }
22 | const handleSuccess = ({ editUser }) => setUser(editUser);
23 | return (
24 | }
27 | title="account details">
28 |
36 |
44 |
45 | );
46 | }
47 |
48 | export default AccountDetails;
--------------------------------------------------------------------------------
/components/Form/Dropdown/dropdown.module.css:
--------------------------------------------------------------------------------
1 | .Dropdown {
2 | position: relative;
3 | display: inline-block;
4 | font-family: var(--mono-font);
5 | cursor: pointer;
6 | }
7 | .Display, .List button {
8 | background: #fff;
9 | line-height: 2rem;
10 | padding: 0 0.75rem;
11 | display: flex;
12 | align-items: center;
13 | justify-content: space-between;
14 | }
15 | .Display::before {
16 | content: '';
17 | display: block;
18 | position: absolute;
19 | z-index: -1;
20 | top: 0;
21 | left: 0;
22 | width: calc(100% + 0.5rem);
23 | height: 100%;
24 | background: inherit;
25 | transform: translate3d(-0.25rem, 0, 0) skew(-12deg);
26 | box-shadow: 0.25rem 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
27 | }
28 | .Display span {
29 | display: block;
30 | margin-right: 3rem;
31 | }
32 | .Display svg {
33 | display: block;
34 | transition: 0.2s;
35 | }
36 | .List {
37 | position: absolute;
38 | z-index: 11;
39 | top: 100%;
40 | left: 0;
41 | width: 100%;
42 | margin-top: 0.25rem;
43 | box-shadow: 0.25rem 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
44 | display: flex;
45 | flex-direction: column;
46 | }
47 | .List button {
48 | max-height: 0;
49 | overflow: hidden;
50 | transition: 0.2s;
51 | }
52 | .List button:hover {
53 | background: #f3f3f3;
54 | }
55 | .List button span {
56 | display: block;
57 | opacity: 0;
58 | transform: scale(0.8);
59 | transition: 0.2s;
60 | }
61 |
62 | .Dropdown.expanded .Display svg {
63 | transform: rotate(-90deg);
64 | }
65 | .Dropdown.expanded .List button {
66 | max-height: 2rem;
67 | }
68 | .Dropdown.expanded .List button span {
69 | opacity: 1;
70 | transform: scale(1);
71 | }
--------------------------------------------------------------------------------
/public/email/passwordReset.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | your habitat account ✨
5 |
6 |
7 |
8 |
9 |
10 |
11 | habitat
12 |
13 |
14 |
hi there!
15 |
i received a request to reset the password for the habitat account associated with this email address. you can reset your password by clicking on the button below:
16 |
17 |
18 | reset password
19 |
20 |
21 |
if you have any questions or concerns, feel free to reply to this email. thanks so much!
22 |
— naomi 🧡
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/layouts/DashboardLayout/Clock/index.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import dayjs from "dayjs";
3 | import styles from "./clock.module.css";
4 |
5 | const Clock = ({ user }) => {
6 | const [time, setTime] = useState(new Date());
7 | useEffect(() => {
8 | const counter = setInterval(() => {
9 | setTime(new Date());
10 | }, 1 * 1000);
11 | return () => clearInterval(counter);
12 | }, []);
13 | const { appearance__showClock, appearance__24hrClock, appearance__showClockSeconds } = user?.settings ?? {
14 | appearance__showClock: true,
15 | appearance__24hrClock: false,
16 | appearance__showClockSeconds: true
17 | }
18 | return (
19 |
20 | {dayjs(time).format('DD')}
21 | {dayjs(time).format('MMMM')}
22 | {appearance__showClock && (
23 |
28 | )}
29 |
30 | );
31 | }
32 |
33 | const Time = ({ time, appearance__24hrClock, appearance__showClockSeconds }) => {
34 | let [hours, minutes, seconds] = [time.getHours(), time.getMinutes(), time.getSeconds()];
35 | const ampm = appearance__24hrClock ? null : (hours >= 12) ? 'pm' : 'am';
36 | hours = appearance__24hrClock ? hours : hours % 12;
37 | hours = hours ? hours : 12; // the hour '0' should be '12'
38 | const addZero = (num) => {
39 | let fixed = num < 10 ? '0' + num : num;
40 | return fixed;
41 | }
42 | minutes = addZero(minutes);
43 | seconds = appearance__showClockSeconds ? `:${addZero(seconds)}` : null;
44 | return (
45 | {hours}:{minutes}{seconds} {ampm}
46 | );
47 | }
48 |
49 | export default Clock;
--------------------------------------------------------------------------------
/components/Modal/index.js:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect, useRef } from "react";
2 |
3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4 | import { faTimes } from "@fortawesome/free-solid-svg-icons";
5 |
6 | import styles from "./modal.module.css";
7 | import { modalStore } from "./modalStore";
8 | import { ModalContext } from "../../contexts";
9 | import { useRefName } from "../../hooks";
10 |
11 | const Modal = ({ keyphrase, options, selfDestruct }) => {
12 | const { closeModal } = useContext(ModalContext);
13 | if (!keyphrase) return null;
14 | const modalProps = {
15 | ...options,
16 | closeModal
17 | }
18 | return (
19 |
20 | {modalStore[keyphrase]?.(modalProps) ?? 'nothing to see here'}
21 |
22 | );
23 | }
24 |
25 | const ModalWrapper = ({ children, selfDestruct, closeModal }) => {
26 | const modalRef = useRef(null);
27 | const modalContentRef = useRef(null);
28 | useEffect(() => {
29 | const { current: modalContent } = modalContentRef;
30 | const handleClick = (e) => {
31 | if (modalContent.contains(e.target)) return;
32 | closeModal();
33 | }
34 | window.addEventListener('click', handleClick);
35 | return () => window.removeEventListener('click', handleClick);
36 | }, []);
37 | return (
38 |
39 |
40 |
41 |
42 |
43 | {children}
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | export default Modal;
--------------------------------------------------------------------------------
/components/MyData/Timeline/timeline.module.css:
--------------------------------------------------------------------------------
1 | .timelineEntries {
2 | display: flex;
3 | flex-direction: column;
4 | animation: timelineEntriesAnimation 0.2s 1 forwards;
5 | }
6 | .DashboardEntry {
7 | display: flex;
8 | flex-direction: row;
9 | }
10 | .DashboardEntry:not(:first-of-type) > * {
11 | padding-top: 1.5rem;
12 | }
13 | .entryTitle {
14 | padding-right: 0.75rem;
15 | margin-right: 1rem;
16 | border-right: 1px solid #000;
17 | display: flex;
18 | flex-direction: column;
19 | white-space: nowrap;
20 | width: 3rem;
21 | }
22 | .entryTitle span {
23 | text-transform: uppercase;
24 | display: block;
25 | text-align: right;
26 | line-height: 1;
27 | }
28 | .entryTitle span.day {
29 | font-family: var(--default-font);
30 | font-weight: bold;
31 | font-size: 1.5rem;
32 | margin-bottom: 0.125rem;
33 | }
34 | .entryTitle span.month {
35 | font-family: var(--mono-font);
36 | font-size: 0.8rem;
37 | }
38 | .entryTitle button {
39 | align-self: flex-end;
40 | text-align: right;
41 | margin-top: 0.5rem;
42 | }
43 | .entryTitle button svg {
44 | opacity: 0.3;
45 | transition: 0.2s;
46 | }
47 | .entryTitle button:hover svg {
48 | opacity: 0.7;
49 | }
50 | .entryBody > div {
51 | display: grid;
52 | grid-template: 'icon label' 100% / 1.9rem 1fr;
53 | align-items: center;
54 | }
55 | .entryBody > div span:first-of-type {
56 | font-size: 1.3rem;
57 | }
58 | .entryBody > div b {
59 | font-weight: 600;
60 | }
61 |
62 | @media screen and (max-width: 900px) {
63 | .entryTitle {
64 | width: 2.5rem;
65 | margin-right: 0.75rem;
66 | }
67 | .entryTitle span.day {
68 | font-size: 1.3rem;
69 | }
70 | .entryBody > div {
71 | font-size: 0.8rem;
72 | grid-template: 'icon label' 100% / 1.625rem 1fr;
73 | }
74 | .entryBody > div span:first-of-type {
75 | font-size: 1.125rem;
76 | }
77 | }
--------------------------------------------------------------------------------
/components/MyData/myData.module.css:
--------------------------------------------------------------------------------
1 | @keyframes jumpToDateAnimation {
2 | from {
3 | opacity: 0;
4 | transform: translate3d(-1rem, 0, 0);
5 | }
6 | to {
7 | opacity: 1;
8 | transform: translate3d(0, 0, 0);
9 | }
10 | }
11 | @keyframes dataAnimation {
12 | from {
13 | opacity: 0;
14 | transform: translate3d(0, 1rem, 0);
15 | }
16 | to {
17 | opacity: 1;
18 | transform: translate3d(0, 0, 0);
19 | }
20 | }
21 |
22 | .MyDataHeader {
23 | margin-bottom: 2rem;
24 | }
25 | .MyDataNav {
26 | display: inline-flex;
27 | align-items: center;
28 | position: relative;
29 | }
30 | button.jumpToCurrentMonth {
31 | font-size: 0.8rem;
32 | line-height: 1.25rem;
33 | margin-left: 0.5rem;
34 | white-space: nowrap;
35 | animation: jumpToDateAnimation 0.2s 1 forwards;
36 | }
37 | button.jumpToCurrentMonth::before {
38 | content: '\00BB';
39 | display: inline-block;
40 | margin-right: 0.5em;
41 | }
42 | button.jumpToCurrentMonth::after {
43 | opacity: 0;
44 | }
45 | button.jumpToCurrentMonth:hover::after {
46 | opacity: 1;
47 | }
48 |
49 | .calendarPeriod {
50 | position: relative;
51 | font-family: var(--mono-font);
52 | font-size: 1.5rem;
53 | font-weight: 300;
54 | line-height: 0.8;
55 | text-transform: uppercase;
56 | margin-top: 0.75rem;
57 | }
58 |
59 | .MyDataViewOptions {
60 | position: absolute;
61 | bottom: 0;
62 | right: 0;
63 | }
64 | div button.currentDataView { /* including div for specificity */
65 | opacity: 1;
66 | }
67 |
68 | .MyDataContent > * {
69 | animation: dataAnimation 0.2s 1 forwards;
70 | }
71 |
72 | .noData {
73 | margin-top: 2rem;
74 | font-size: 1.2rem;
75 | animation: dataAnimation 0.2s 1 forwards;
76 | }
77 | .noData > span {
78 | opacity: 0.4;
79 | }
80 |
81 | @media screen and (max-width: 900px) {
82 | .calendarPeriod {
83 | font-size: 1.2rem;
84 | }
85 | }
--------------------------------------------------------------------------------
/components/EmojiPicker/index.js:
--------------------------------------------------------------------------------
1 | import dynamic from "next/dynamic";
2 | import { useEffect, useRef, useState } from "react";
3 | import { fancyClassName } from "../../utils";
4 |
5 | import styles from "./emojiPicker.module.css";
6 |
7 | const EmojiPickerNoSSRWrapper = dynamic(
8 | () => import("emoji-picker-react"),
9 | {
10 | ssr: false,
11 | loading: () => ,
12 | }
13 | );
14 |
15 | const EmojiPicker = ({ className, formFieldName, setFormData }) => {
16 | const [expanded, setExpanded] = useState(false);
17 | const containerRef = useRef(null);
18 | const toggleExpanded = () => {
19 | if (expanded) setExpanded('closing');
20 | else setExpanded(true);
21 | }
22 | useEffect(() => {
23 | if (!expanded) return;
24 | if (expanded === 'closing') {
25 | setTimeout(() => {
26 | setExpanded(false)
27 | }, 200);
28 | }
29 | const closePicker = (e) => {
30 | if (!containerRef.current?.contains(e.target)) {
31 | setExpanded('closing');
32 | }
33 | }
34 | window.addEventListener('click', closePicker);
35 | return () => window.removeEventListener('click', closePicker);
36 | }, [expanded]);
37 | const handleClick = (_, obj) => {
38 | setExpanded('closing');
39 | setFormData(prevData => ({
40 | ...prevData,
41 | [formFieldName]: obj.emoji
42 | }));
43 | }
44 | return (
45 |
46 |
47 |
48 | {expanded ? 'close' : 'emoji picker'}
49 |
50 |
51 | {expanded && }
52 |
53 | );
54 | }
55 |
56 | export default EmojiPicker;
--------------------------------------------------------------------------------
/pages/login.js:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import { useRouter } from "next/router";
3 |
4 | import { handleRequest, User } from "./api";
5 | import { auth } from "./api/auth";
6 | import { ModalContext } from "../contexts";
7 | import { useForm } from "../hooks";
8 | import Form, { Input, Submit } from "../components/Form";
9 |
10 | const Login = () => {
11 | const router = useRouter();
12 | const { formData, handleFormError, inputProps } = useForm({
13 | email: '',
14 | password: ''
15 | });
16 | const handleSubmit = () => User.login(formData);
17 | const handleSuccess = async ({ login: user }) => {
18 | const { id, demoTokenId } = user;
19 | await handleRequest('/api/auth/login', {
20 | user: { id, demoTokenId }
21 | });
22 | router.push('/dashboard');
23 | }
24 | return (
25 | <>
26 | login
27 |
44 | >
45 | );
46 | }
47 |
48 | const ForgotPassword = () => {
49 | const { createModal } = useContext(ModalContext);
50 | return (
51 | createModal('forgotPassword')}>
55 | forgot your password?
56 |
57 | );
58 | }
59 |
60 | export const getServerSideProps = auth({ shield: false, redirect: '/dashboard' });
61 |
62 | export default Login;
--------------------------------------------------------------------------------
/components/MyAccount/ChangePassword.js:
--------------------------------------------------------------------------------
1 | import { User } from "../../pages/api";
2 | import { useForm } from "../../hooks";
3 | import Form, { Input, Submit } from "../Form";
4 |
5 | const ChangePassword = ({ demo, user, isMobile }) => {
6 | const { formData, handleFormError, inputProps, resetForm } = useForm({
7 | id: user.id,
8 | password: '',
9 | confirmPassword: ''
10 | });
11 | const handleSubmit = () => {
12 | if (demo) {
13 | console.log('cute');
14 | return Promise.reject({ __typename: 'NiceTry' });
15 | }
16 | return User.editPassword(formData);
17 | }
18 | const handleSuccess = () => resetForm();
19 | const passwordIsOk = (() => {
20 | if (!formData.password || !formData.confirmPassword) return false;
21 | if (formData.password.length < 6) return false;
22 | return formData.password === formData.confirmPassword;
23 | })();
24 | const formSubmit = (
25 |
32 | );
33 | return (
34 |
56 | );
57 | }
58 |
59 | export default ChangePassword;
--------------------------------------------------------------------------------
/layouts/DashboardLayout/index.js:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect } from "react";
2 |
3 | import styles from "./dashboardLayout.module.css";
4 | import { DataContext, DataContextProvider } from "../../contexts";
5 | import { fancyClassName } from "../../utils";
6 | import Nav from "../../components/Nav";
7 | import { PageLoading } from "../../components/Loading";
8 | import Clock from "./Clock";
9 |
10 | export const DashboardWrapper = ({ children }) => {
11 | return (
12 |
13 | {children}
14 |
15 | );
16 | }
17 |
18 | const DashboardLayout = ({ children, userId, sidebar, className, dim, dimOnClick }) => {
19 | const { user, getUser, habits, getHabits, entries, getEntries } = useContext(DataContext);
20 | useEffect(() => {
21 | if (user == null) return getUser(userId);
22 | }, [user]);
23 | useEffect(() => {
24 | if (user && !habits) return getHabits();
25 | }, [user, habits]);
26 | useEffect(() => {
27 | if (user && !entries) return getEntries();
28 | }, [user, entries]);
29 | if (!user) return ;
30 | return (
31 |
32 |
33 |
34 |
35 |
36 | {sidebar}
37 |
38 | {children}
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | export const Dim = ({ dim, dimOnClick }) => {
46 | return (
47 |
48 | );
49 | }
50 |
51 | export const Content = ({ children, className }) => {
52 | return (
53 |
54 | {children}
55 |
56 | );
57 | }
58 |
59 | export const Sidebar = ({ children }) => {
60 | if (!children) return null;
61 | return (
62 |
63 | {children}
64 |
65 | );
66 | }
67 |
68 | export default DashboardLayout;
--------------------------------------------------------------------------------
/components/MyData/Calendar/calendar.module.css:
--------------------------------------------------------------------------------
1 | .Calendar {
2 | width: 100%;
3 | display: grid;
4 | grid-template-columns: repeat(7, 1fr);
5 | gap: 1rem;
6 | }
7 | .calendarWeekLabel {
8 | padding: 0 0.5rem;
9 | font-family: var(--mono-font);
10 | font-size: 1.3rem;
11 | font-weight: 300;
12 | color: #000;
13 | line-height: 1;
14 | padding-bottom: 0.25rem;
15 | border-bottom: 0.25rem solid #000;
16 | text-transform: lowercase;
17 | }
18 | .calendarDay {
19 | border: 1px solid #000;
20 | border-width: 1px 0 0 1px;
21 | padding: 0 0.5rem 0.5rem;
22 | min-height: 4rem;
23 | cursor: pointer;
24 | }
25 | .calendarDay > div.title {
26 | font-family: var(--mono-font);
27 | font-weight: 400;
28 | font-size: 1rem;
29 | background: #fff;
30 | display: inline-block;
31 | width: 2rem;
32 | height: 2rem;
33 | border-radius: 999px;
34 | text-align: center;
35 | line-height: 2rem;
36 | transform: translate3d(0, -0.5rem, 0);
37 | box-shadow: 0.25rem 0.25rem 0.5rem rgba(0, 0, 0, 0);
38 | }
39 | .calendarDay > div:last-of-type {
40 | display: flex;
41 | flex-wrap: wrap;
42 | font-size: 1.2rem;
43 | line-height: 1;
44 | margin: 0.25rem -0.25em -0.25em 0;
45 | }
46 | .calendarDay > div:last-of-type > div {
47 | margin: 0 0.25em 0.25em 0;
48 | }
49 | .calendarDay.filler {
50 | border: 0;
51 | background: #000;
52 | opacity: 0.05;
53 | cursor: default;
54 | }
55 | .calendarDay.filler * {
56 | display: none;
57 | }
58 |
59 | @media screen and (max-width: 900px) {
60 | .Calendar {
61 | gap: 0.25rem;
62 | }
63 | .calendarWeekLabel {
64 | overflow: hidden;
65 | font-size: 1rem;
66 | }
67 | .calendarDay {
68 | padding: 0 0.25rem 0.25rem;
69 | min-height: unset;
70 | text-align: center;
71 | }
72 | .calendarDay > div.title {
73 | line-height: 1;
74 | transform: none;
75 | box-shadow: none;
76 | width: auto;
77 | height: auto;
78 | background: none;
79 | }
80 | .calendarDay > div:last-of-type {
81 | font-size: 1rem;
82 | justify-content: center;
83 | margin: 0.25rem -0.5rem 0rem 0;
84 | }
85 | .calendarDay > div:last-of-type > div {
86 | text-align: center;
87 | }
88 | }
--------------------------------------------------------------------------------
/pages/dashboard.js:
--------------------------------------------------------------------------------
1 | import { useContext, useMemo, useState } from "react";
2 |
3 | import dayjs from "dayjs";
4 |
5 | import { auth } from "./api/auth";
6 | import { DataContext, MobileContext } from "../contexts";
7 | import { DashboardLayout } from "../layouts";
8 | import { PageLoading } from "../components/Loading";
9 | import DashPanel from "../components/DashPanel";
10 | import MyData from "../components/MyData";
11 |
12 | const Dashboard = ({ user }) => {
13 | const isMobile = useContext(MobileContext);
14 | const [dashPanel, setDashPanel] = useState(null);
15 | const [calendarPeriod, updateCalendarPeriod] = useState(dayjs().format('YYYY-MM'));
16 | const updateDashPanel = (view, options) => setDashPanel({ view, options });
17 | return (
18 | setDashPanel(null)}
22 | sidebar={ }>
23 |
24 |
25 | );
26 | }
27 |
28 | const DashboardContent = ({ dashPanel, updateDashPanel, calendarPeriod, updateCalendarPeriod }) => {
29 | const { user, habits, entries } = useContext(DataContext);
30 | const entriesToDisplay = useMemo(() => {
31 | if (!entries) return [];
32 | return entries.filter(entry => {
33 | const [currentYear, currentMonth] = calendarPeriod.split('-');
34 | const [entryYear, entryMonth] = entry.date.split('-');
35 | if ((entryYear === currentYear) && (entryMonth === currentMonth)) return entry;
36 | });
37 | }, [entries, calendarPeriod]);
38 | return (
39 | <>
40 | dashboard
41 | {(!habits || !entries) && }
42 | {(habits && entries) && (
43 |
52 | )}
53 | >
54 | );
55 | }
56 |
57 | export const getServerSideProps = auth({ shield: true, redirect: '/login' });
58 |
59 | export default Dashboard;
--------------------------------------------------------------------------------
/components/Form/Input/input.module.css:
--------------------------------------------------------------------------------
1 | .Input + .Input, form > div + .Input {
2 | margin-top: 1rem;
3 | }
4 | form > div > .Input + .Input {
5 | margin-top: 0;
6 | margin-left: 1rem;
7 | }
8 | .Input {
9 | display: flex;
10 | flex-direction: column;
11 | align-items: flex-start;
12 | }
13 | .Input > label {
14 | width: 100%;
15 | display: flex;
16 | justify-content: space-between;
17 | }
18 | .Input > div {
19 | display: inline-block;
20 | position: relative;
21 | }
22 | .Input > div + span {
23 | display: inline-block;
24 | margin-top: 0.3rem;
25 | font-size: 0.7rem;
26 | letter-spacing: 0ch;
27 | }
28 |
29 | .Input.inline {
30 | display: inline-flex;
31 | }
32 | .Input.stretch > div, .Input.stretch > div > input, .Input > div + span {
33 | width: 100%;
34 | }
35 | .Input.alertInside .Alert {
36 | left: unset;
37 | right: 1.25rem;
38 | }
39 |
40 | .Alert {
41 | position: absolute;
42 | z-index: 99;
43 | top: 50%;
44 | left: 100%;
45 | transform: translate3d(0.5rem, -50%, 0);
46 | }
47 | .Alert > div {
48 | width: 1.25rem;
49 | height: 1.25rem;
50 | text-align: center;
51 | font-size: 1rem;
52 | color: #000;
53 | border-radius: 999px;
54 | cursor: help;
55 | }
56 | .Alert > span {
57 | position: absolute;
58 | top: calc(100% + 0.25rem);
59 | right: -0.5rem;
60 | background: #fff;
61 | color: #000;
62 | font-size: 0.75rem;
63 | line-height: 2;
64 | padding: 0 0.5rem;
65 | box-shadow: 0.2rem 0.2rem 1rem rgba(0, 0, 0, 0.1);
66 | white-space: nowrap;
67 | opacity: 0;
68 | transform: scale(0.9);
69 | transform-origin: top right;
70 | pointer-events: none;
71 | transition: 0.2s;
72 | }
73 | .Alert > span > span {
74 | display: block;
75 | transform: skew(12deg);
76 | }
77 | .Alert > div:hover + span {
78 | pointer-events: unset;
79 | transform: scale(1) skew(-12deg);
80 | opacity: 1;
81 | }
82 |
83 | .Input input[type=color] {
84 | width: 2rem;
85 | height: 2rem;
86 | padding: 0;
87 | opacity: 0;
88 | cursor: pointer;
89 | }
90 | .ColorInput {
91 | position: absolute;
92 | top: 0;
93 | left: 0;
94 | width: 2rem;
95 | height: 2rem;
96 | background: #fff;
97 | border: 1px solid #000;
98 | border-radius: 999px;
99 | cursor: pointer;
100 | }
101 |
--------------------------------------------------------------------------------
/pages/api/handleRequest.js:
--------------------------------------------------------------------------------
1 | import { FetchError } from "./handleError";
2 |
3 | export const handleRequest = (...args) => {
4 | return Promise.race([
5 | handleFetch(...args),
6 | new Promise((_, reject) => {
7 | setTimeout(() => reject(new Error('Timeout')), 20000);
8 | })
9 | ]).catch(err => {
10 | if (err.message === 'Timeout') throw new FetchError({ message: 'The connection timed out.' });
11 | console.log(`caught err in handleRequest:`);
12 | console.dir(err);
13 | throw err; // todo add catch block whenever await handleRequest occurs in client
14 | });
15 | }
16 |
17 | const handleFetch = async (route, body) => {
18 | const options = {
19 | method: 'POST',
20 | headers: {
21 | 'Content-Type': 'application/json'
22 | },
23 | body: JSON.stringify(body)
24 | };
25 | const response = await fetch(route, options);
26 | const data = await response.json();
27 | if (!response.ok) {
28 | // 400 or 500 server error:
29 | console.log('throwing err in handleFetch');
30 | // todo create class ServerError - should include response.status, response.statusText, and data.errors (if it exists)
31 | throw new Error(data?.errors ? JSON.stringify(data?.errors) : `fetch response is not ok: ${response.status} ${response.statusText}`);
32 | }
33 | return data;
34 | }
35 |
36 | export const handleQuery = (query = {}, variables = {}) => {
37 | return handleRequest('/api/server', { query, variables }).then(body => {
38 | if (body?.errors) {
39 | // these will be server/graphQL errors
40 | console.warn('body contained errors: ', body.errors);
41 | throw {
42 | caughtErrors: JSON.stringify(body.errors)
43 | }
44 | }
45 | if (body?.data) {
46 | const result = Object.values(body.data)[0];
47 | if (result?.__typename === 'FormErrorReport') throw result;
48 | // maybe better to parse error report here before returning/throwing back to client? todo think about it
49 | return body.data;
50 | }
51 | console.dir('no body.data');
52 | throw body;
53 | }).catch(err => {
54 | // errors might arrive via makeRequest catch block or via then block directly above this
55 | console.log(`caught err in handleQuery, now forwarding to client`);
56 | // passing error to whatever handles error on the client side, e.g. handleError in Form.js:
57 | throw err;
58 | });
59 | }
--------------------------------------------------------------------------------
/components/Form/Dropdown/index.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 |
3 | import { faCaretLeft } from "@fortawesome/free-solid-svg-icons";
4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5 |
6 | import styles from "./dropdown.module.css";
7 | import { useRefName } from "../../../hooks";
8 |
9 | const Dropdown = ({ name, defaultValue, listItems, onChange }) => {
10 | const [displayValue, setDisplayValue] = useState(defaultValue ?? 'Select one...');
11 | const [expanded, setExpanded] = useState(false);
12 | const toggleExpanded = () => setExpanded(state => !state);
13 | const dropdownRef = useRef(null);
14 | useEffect(() => {
15 | const dropdown = useRefName(dropdownRef);
16 | if (!dropdown) return;
17 | dropdown.style.width = dropdown.scrollWidth + 'px';
18 | const closeDropdown = (e) => {
19 | if (!dropdown.contains(e.target)) {
20 | setExpanded(false);
21 | }
22 | }
23 | window.addEventListener('click', closeDropdown);
24 | return () => window.removeEventListener('click', closeDropdown);
25 | }, [dropdownRef]);
26 | return (
27 |
28 | {displayValue}
29 | onChange(name, value),
32 | updateDisplayValue: setDisplayValue,
33 | updateExpanded: setExpanded
34 | }} />
35 |
36 | );
37 | }
38 |
39 | const Display = ({ children, toggleExpanded }) => {
40 | return (
41 |
42 | {children}
43 |
44 |
45 | );
46 | }
47 |
48 | const List = ({ listItems, onChange, updateDisplayValue, updateExpanded }) => {
49 | const listButtons = listItems.map(({ value, display }) => {
50 | const handleClick = () => {
51 | onChange(value);
52 | updateDisplayValue(display);
53 | updateExpanded(false);
54 | }
55 | return (
56 |
60 | {display}
61 |
62 | );
63 | });
64 | return (
65 |
66 | {listButtons}
67 |
68 | );
69 | }
70 |
71 | export default Dropdown;
--------------------------------------------------------------------------------
/pages/api/server/resolvers/demo/generateSampleData.js:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 |
3 | const habitDefs = [
4 | { name: 'Hydration', icon: '💦', color: '#94e4ff', label: 'Drank {oz} of water', complex: true, retired: false, range: '35-60' },
5 | { name: 'Sleep', icon: '💤', color: '#bfa8ff', label: 'Slept {hours}', complex: true, retired: false, range: '5-9' },
6 | { name: 'Cardio', icon: '💓', color: '#ff94d2', label: 'Did cardio', complex: false, retired: false },
7 | { name: 'Plants', icon: '🌱', color: '#80ff8e', label: 'Watered plants', complex: false, retired: false },
8 | { name: 'Sunshine', icon: '☀️', color: '#ffe74d', label: 'Spent time outdoors', complex: false, retired: false }
9 | ]
10 |
11 | const ids = habitDefs.map((_, index) => {
12 | return `demo-${index}`;
13 | });
14 |
15 | // for each entry that will be in entriesArray (28-31 depending on calendar period), create a record for each habit in habitDefs
16 | export const recordsList = (demoTokenId, calendarPeriod) => {
17 | return new Array(dayjs(calendarPeriod).daysInMonth()).fill('').map(() => {
18 | return habitDefs.map((habit, index) => {
19 | const randomAmount = () => {
20 | let [min, max] = habit.range.split('-');
21 | min = parseInt(min);
22 | max = parseInt(max);
23 | const randomInt = Math.floor(Math.random() * (max - min + 1) + min);
24 | return randomInt;
25 | }
26 | const randomBool = () => Math.random() < 0.5; // 50% probability of getting true
27 | return {
28 | habitId: `${ids[index]}-${demoTokenId}`,
29 | amount: habit.complex ? randomAmount() : null,
30 | check: habit.complex ? true : randomBool()
31 | }
32 | });
33 | });
34 | }
35 |
36 | // entries for the whole current month
37 | export const entriesList = (userId, demoTokenId, calendarPeriod) => recordsList(calendarPeriod).map((_, index) => {
38 | const dateForThisEntry = `${calendarPeriod}-${index + 1}`;
39 | return {
40 | date: dayjs(dateForThisEntry).format('YYYY-MM-DD'),
41 | userId,
42 | demoTokenId
43 | }
44 | });
45 |
46 | export const habitsList = (userId, demoTokenId) => habitDefs.map((habit, index) => {
47 | const sanitizedHabit = {...habit};
48 | delete sanitizedHabit.range; // range is only there for data generation, will cause server error if we try to write it to db
49 | return {
50 | id: `${ids[index]}-${demoTokenId}`,
51 | ...sanitizedHabit,
52 | userId,
53 | demoTokenId
54 | }
55 | });
--------------------------------------------------------------------------------
/components/DashPanel/index.js:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect } from "react";
2 |
3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4 | import { faCalendarAlt, faPlus } from "@fortawesome/free-solid-svg-icons";
5 |
6 | import styles from "./dashPanel.module.css";
7 | import DataForm from "../DataForm";
8 | import { DataContext } from "../../contexts";
9 | import MiniCalendar from "../MiniCalendar";
10 |
11 | const DashPanel = ({ dashPanel, updateDashPanel, calendarPeriod, updateCalendarPeriod }) => {
12 | const { habits } = useContext(DataContext);
13 | const { view: panelName, options: dashPanelOptions } = dashPanel ?? {};
14 | const handleNavClick = (newPanelName) => {
15 | if (panelName === newPanelName) updateDashPanel(null);
16 | else updateDashPanel(newPanelName);
17 | }
18 | const isActiveClassName = (name) => {
19 | if (panelName == null) return '';
20 | return panelName === name ? styles.active : styles.inactive;
21 | }
22 | const isActive = (name) => {
23 | if (panelName == null) return false;
24 | return panelName === name;
25 | }
26 | return (
27 |
28 |
29 | handleNavClick('data')}>
30 | Add data
31 |
32 |
33 | handleNavClick('calendar')}>
34 | Jump to...
35 |
36 |
37 |
38 |
46 |
47 | );
48 | }
49 |
50 | const PanelContent = ({ view, habits, dashPanelOptions, updateDashPanel, calendarPeriod, updateCalendarPeriod }) => {
51 | return (
52 |
53 | {(view === 'data') && }
54 | {(view === 'calendar') && }
55 |
56 | );
57 | }
58 |
59 | export default DashPanel;
--------------------------------------------------------------------------------
/components/Form/Input/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from "react";
2 |
3 | import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5 |
6 | import styles from "./input.module.css";
7 | import { fancyClassName } from "../../../utils";
8 | import { useRefName } from "../../../hooks";
9 |
10 | const Input = ({
11 | type,
12 | name,
13 | label,
14 | value,
15 | defaultValue,
16 | placeholder,
17 | disabled,
18 | readOnly,
19 | onChange, onInput, onClick,
20 | note,
21 | alert,
22 | tool,
23 | className,
24 | maxLength,
25 | min
26 | }) => {
27 | const inputRef = useRef(null);
28 | return (
29 |
30 | {label &&
{label} }
31 |
32 | {(type === 'color') &&
}
33 |
47 | {tool}
48 |
49 |
50 | {note &&
{note} }
51 |
52 | );
53 | }
54 |
55 | const Alert = ({ alert, name, inputRef }) => {
56 | const spanRef = useRef(null);
57 | useEffect(() => {
58 | const input = useRefName(inputRef);
59 | const span = useRefName(spanRef);
60 | if (!input || !span) return;
61 | if (span.scrollWidth > input.scrollWidth) {
62 | span.style.width = input.scrollWidth * 0.9 + 'px';
63 | span.style.paddingTop = '0.5rem';
64 | span.style.paddingBottom = '0.5rem';
65 | span.style.lineHeight = '1.2';
66 | span.style.whiteSpace = 'normal';
67 | }
68 | }, [alert, name, inputRef, spanRef]);
69 | if (!alert(name)) return null;
70 | return (
71 |
72 |
73 |
{alert(name).message}
74 |
75 | );
76 | }
77 |
78 | const ColorInput = ({ color }) => {
79 | return (
80 |
81 | );
82 | }
83 |
84 | export default Input;
--------------------------------------------------------------------------------
/components/Modal/modal.module.css:
--------------------------------------------------------------------------------
1 | @keyframes fadeIn {
2 | from {
3 | opacity: 0;
4 | }
5 | to {
6 | opacity: 1;
7 | }
8 | }
9 | @keyframes fadeOut {
10 | from {
11 | opacity: 1;
12 | }
13 | to {
14 | opacity: 0;
15 | }
16 | }
17 | @keyframes scaleIn {
18 | from {
19 | transform: scale(0.8);
20 | }
21 | to {
22 | transform: scale(1);
23 | }
24 | }
25 | @keyframes scaleOut {
26 | from {
27 | transform: scale(1);
28 | }
29 | to {
30 | transform: scale(0.8);
31 | }
32 | }
33 |
34 | .Modal {
35 | position: fixed;
36 | z-index: 999;
37 | top: 0;
38 | left: 0;
39 | width: 100vw;
40 | height: 100vh;
41 | padding: 4rem;
42 | background: rgba(0, 0, 0, 0.5);
43 | animation: fadeIn 0.2s 1 forwards;
44 | }
45 | .modalContainer {
46 | position: relative;
47 | display: grid;
48 | place-items: center;
49 | width: 100%;
50 | height: 100%;
51 | }
52 | .modalContent {
53 | position: relative;
54 | width: 400px;
55 | background: #fff;
56 | animation: fadeIn 0.2s 1 forwards, scaleIn 0.2s 1 forwards;
57 | }
58 | .modalContent > div {
59 | width: 100%;
60 | height: 100%;
61 | max-height: calc(100vh - 4rem);
62 | overflow-x: hidden;
63 | overflow-y: auto;
64 | padding: 2rem;
65 | }
66 | .Modal.goodbye {
67 | animation: fadeOut 0.2s 1 forwards;
68 | }
69 | .Modal.goodbye .modalContent {
70 | animation: fadeOut 0.2s 1 forwards, scaleOut 0.2s 1 forwards;
71 | }
72 | .exit {
73 | position: absolute;
74 | z-index: 150;
75 | top: -0.5rem;
76 | right: -0.5rem;
77 | width: 1.5rem;
78 | height: 1.5rem;
79 | background: #000;
80 | border-radius: 999px;
81 | display: flex;
82 | align-items: center;
83 | justify-content: center;
84 | color: #fff;
85 | }
86 | .exit > * {
87 | display: block;
88 | position: absolute;
89 | top: 50%;
90 | left: 50%;
91 | transform: translate3d(-50%, -50%, 0);
92 | }
93 |
94 | @media screen and (max-width: 900px) {
95 | .Modal {
96 | padding: 1.5rem;
97 | }
98 | .modalContent {
99 | width: 500px;
100 | }
101 | .modalContent > div {
102 | max-height: calc(100vh - 3rem); /* 3rem because of (2 * 1.5rem) padding on .Modal */
103 | }
104 | .Modal div[class^='submit_Submit'] {
105 | justify-content: center;
106 | }
107 | .Modal div[class^='submit_Submit'] button {
108 | padding: 0.5rem 1rem;
109 | }
110 | }
111 |
112 | @media screen and (max-width: 600px) {
113 | .modalContent {
114 | width: 100%;
115 | }
116 | .modalContent > div {
117 | padding: 1.5rem;
118 | }
119 | }
--------------------------------------------------------------------------------
/layouts/DashboardLayout/dashboardLayout.module.css:
--------------------------------------------------------------------------------
1 | .Dashboard {
2 | width: 100%;
3 | height: 100%;
4 | overflow: auto;
5 | font-size: 0.9rem;
6 | }
7 | .Dashboard h1 {
8 | font-size: 3rem;
9 | margin-bottom: 2.5rem;
10 | position: relative;
11 | }
12 | .Dashboard h1::after {
13 | display: inline-block;
14 | position: absolute;
15 | top: 100%;
16 | left: 2rem;
17 | margin-top: 1.5rem;
18 | width: 100px;
19 | height: 0.4rem;
20 | background: #000;
21 | }
22 | .Main {
23 | display: grid;
24 | grid-template: 'nav content sidebar' 100% / calc(2 * var(--dashboard-nav-width)) 1fr 300px;
25 | flex: 0 0 calc(100% - (3 * var(--dashboard-nav-width)));
26 | text-align: left;
27 | overflow: hidden;
28 | }
29 | .Content {
30 | grid-area: content;
31 | margin: 5rem 0;
32 | padding-right: 2rem;
33 | width: 100%;
34 | }
35 | .Sidebar {
36 | position: fixed;
37 | top: 250px;
38 | right: 0;
39 | max-width: var(--dashpanel-sidebar-width);
40 | max-height: calc(100% - 250px);
41 | padding: 0 3rem 3rem 0;
42 | font-size: 0;
43 | }
44 |
45 | .Dashboard > div p {
46 | text-align: inherit;
47 | }
48 | .Dashboard > img {
49 | position: fixed;
50 | bottom: 0;
51 | left: calc(0.5 * var(--Dashboardboard-sidebar-width));
52 | width: auto;
53 | height: 40%;
54 | }
55 |
56 | .Content.Account form {
57 | margin-bottom: 2rem;
58 | }
59 |
60 | .Dim {
61 | position: fixed;
62 | z-index: 250;
63 | top: 0;
64 | left: 0;
65 | width: 100%;
66 | height: 100%;
67 | background: rgba(0, 0, 0, 0.5);
68 | opacity: 0;
69 | transition: 0.2s;
70 | pointer-events: none;
71 | }
72 | .Dim.dimmed {
73 | opacity: 1;
74 | pointer-events: auto;
75 | }
76 |
77 | @media screen and (max-width: 900px) {
78 | .Dashboard h1 {
79 | font-size: 2rem;
80 | margin-bottom: 1.5rem;
81 | }
82 | .Main {
83 | overflow-y: auto;
84 | width: 100%;
85 | height: 100%;
86 | grid-template:
87 | 'content' 1fr
88 | 'sidebar' auto / 100%;
89 | position: relative; /* for Date */
90 | }
91 | .Content {
92 | margin: 0;
93 | margin-top: 5rem;
94 | padding: 3rem;
95 | padding-bottom: 4rem;
96 | }
97 | .Sidebar {
98 | position: fixed;
99 | z-index: 300;
100 | top: unset;
101 | bottom: 0;
102 | right: 0;
103 | width: 100%;
104 | max-width: 100%;
105 | max-height: 75%;
106 | padding: 0;
107 | }
108 | }
109 |
110 | @media screen and (max-width: 600px) {
111 | .Content {
112 | padding: 1.5rem;
113 | padding-bottom: 2.5rem;
114 | }
115 | }
--------------------------------------------------------------------------------
/components/MyData/Calendar/index.js:
--------------------------------------------------------------------------------
1 | import styles from "./calendar.module.css";
2 | import { getUnitFromLabel } from "../../../utils";
3 | import TooltipElement from "../../Tooltip";
4 | import { useCalendar } from "../../../hooks";
5 |
6 | const Calendar = ({ habits, entries, calendarPeriod, updateDashPanel }) => {
7 | const { weekdays, totalDaysInMonth } = useCalendar(calendarPeriod);
8 | return (
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
16 | const CalendarWeekLabels = ({ weekdays }) => {
17 | return weekdays('ddd').map(weekName => (
18 |
19 | {weekName}
20 |
21 | ));
22 | }
23 |
24 | const CalendarDays = ({ habits, entries, totalDaysInMonth, updateDashPanel }) => {
25 | return totalDaysInMonth.map((date, index) => {
26 | const isFiller = date === '';
27 | const { records } = entries.find(entry => entry.date === date) ?? {};
28 | const recordIcons = records?.map(({ habitId, amount, check }) => {
29 | if (!check) return null;
30 | const { name, icon, label, complex } = habits.find(habit => habit.id === habitId);
31 | const unit = complex ? getUnitFromLabel(label) : null;
32 | const recordDetails = (
33 |
34 | {complex
35 | ? <>{name}: {amount} {unit}>
36 | : <>{label}>
37 | }
38 |
39 | );
40 | return (
41 |
45 | {icon}
46 |
47 | );
48 | });
49 | return (
50 |
56 | {isFiller ? null : (
57 |
58 | {date.split('-')[2]}
59 |
60 | )}
61 | {recordIcons}
62 |
63 | );
64 | });
65 | }
66 |
67 | const CalendarDay = ({ children, isFiller, date, updateDashPanel }) => {
68 | const handleClick = () => {
69 | if (isFiller) return;
70 | updateDashPanel('data', { date })
71 | }
72 | return (
73 |
75 | {children}
76 |
77 | );
78 | }
79 |
80 | export default Calendar;
--------------------------------------------------------------------------------
/contexts/DataContext.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { useRouter } from "next/router";
3 |
4 | import { User, Habit, Entry, handleRequest } from "../pages/api";
5 | import { differenceInMinutes } from "../utils";
6 |
7 | export const DataContext = React.createContext(null);
8 |
9 | export const DataContextProvider = ({ children }) => {
10 | const router = useRouter();
11 | const [user, setUser] = useState(null);
12 | const [habits, setHabits] = useState(null);
13 | const [entries, setEntries] = useState(null);
14 | const [demoTokenId, setDemoTokenId] = useState(null);
15 | const [demoGenOption, setDemoGenOption] = useState(true);
16 | const getUser = async (userId = user.id) => {
17 | if (!userId) return console.log('userId is undefined!');
18 | const { demoTokenId: tokenId, createdAt } = await handleRequest('/api/auth/getSession');
19 | setDemoTokenId(tokenId);
20 | const { user } = await User.get({ id: userId, demoTokenId: tokenId });
21 | if ((user == null) || (tokenId && differenceInMinutes(createdAt) > 120)) { // if user does not exist OR if user is demo user and session is older than 2hrs
22 | if (tokenId) User.clearDemoData({ demoTokenId: tokenId });
23 | await handleRequest('/api/auth/logout');
24 | router.push('/');
25 | return;
26 | }
27 | setUser(user);
28 | return user;
29 | }
30 | const getHabits = async () => {
31 | if (!user) return console.log('User not stored');
32 | const { habits } = await Habit.get({ userId: user.id, demoTokenId });
33 | if (user.email === 'demo') {
34 | // check if user has created any of their own habits, in which case they can't use feature to auto generate data
35 | // only demo habits will have ids of the form demo-0, demo-1, demo-2, etc, all others will be a string of letters & numbers
36 | const containsOnlyDemoHabits = habits.every(habit => habit.id.split('-')[0] === 'demo');
37 | if (containsOnlyDemoHabits) setDemoGenOption(true);
38 | else setDemoGenOption(false);
39 | }
40 | setHabits(habits);
41 | return habits;
42 | }
43 | const getEntries = async () => {
44 | if (!user) return console.log('User not stored');
45 | const { entries } = await Entry.get({ userId: user.id, demoTokenId });
46 | setEntries(entries);
47 | return entries;
48 | }
49 | const dataContext = {
50 | user, setUser, getUser,
51 | habits, setHabits, getHabits,
52 | entries, setEntries, getEntries,
53 | demoTokenId, setDemoTokenId,
54 | demoGenOption
55 | }
56 | return (
57 |
58 | {children}
59 |
60 | );
61 | }
--------------------------------------------------------------------------------
/components/Registration/index.js:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import Link from "next/link";
3 |
4 | import { handleRequest, User } from "../../pages/api";
5 | import { useForm } from "../../hooks";
6 | import Footer from "../Footer";
7 | import Form, { Input, Submit } from "../Form";
8 |
9 | export const RegistrationIsOpen = ({ token }) => {
10 | const router = useRouter();
11 | const { formData, handleFormError, inputProps } = useForm({
12 | email: '',
13 | password: '',
14 | token
15 | });
16 | const handleSubmit = () => User.create(formData);
17 | const handleSuccess = async ({ createUser }) => {
18 | await handleRequest('/api/auth/login', { user: createUser });
19 | router.push('/dashboard');
20 | }
21 | return (
22 | <>
23 | create an account by filling out the fields below:
24 | }>
27 |
28 |
29 |
30 | *i promise i will never send you marketing emails or share your data with 3rd parties. i just need your email to verify your account and so that you can reset your password if you forget what it is. thank you!
31 | >
32 | );
33 | }
34 |
35 | export const RegistrationIsClosed = ({ updateToken }) => {
36 | return (
37 | <>
38 | sorry, account registration is closed for the time being!* if i've given you a registration code, you can enter it below:
39 |
40 | *you can still demo the app by logging in with the username demo and password habitat . if you want to join for real, contact me and we'll talk 🧡
41 | >
42 | );
43 | }
44 |
45 | const RegistrationCodeForm = ({ updateToken }) => {
46 | const router = useRouter();
47 | const { formData, handleFormError, inputProps } = useForm({
48 | tokenId: ''
49 | });
50 | const handleSubmit = () => User.validateSignupToken(formData);
51 | const handleSuccess = ({ validateSignupToken }) => {
52 | updateToken(validateSignupToken.id);
53 | }
54 | return (
55 |
62 | );
63 | }
--------------------------------------------------------------------------------
/components/MyData/Timeline/index.js:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faPen } from "@fortawesome/free-solid-svg-icons";
4 |
5 | import styles from "./timeline.module.css";
6 | import { getUnitFromLabel } from "../../../utils";
7 | import TooltipElement from "../../Tooltip";
8 |
9 | const Timeline = ({ habits, entries, calendarPeriod, updateDashPanel }) => {
10 | const timelineEntries = () => {
11 | return (
12 |
13 | {entries.map(entry => )}
14 |
15 | );
16 | }
17 | return (
18 |
19 | {timelineEntries()}
20 |
21 | );
22 | }
23 |
24 | const DashboardEntry = ({ entry, habits, updateDashPanel }) => {
25 | const { date, records } = entry;
26 | const getHabitObject = {
27 | fromId: (id) => {
28 | const index = habits.findIndex(habit => habit.id === id);
29 | return (index !== -1) ? habits[index] : {};
30 | }
31 | }
32 | const entryDate = () => {
33 | const month = dayjs(date).format('MMM');
34 | const day = dayjs(date).format('DD');
35 | const editEntry = () => {
36 | updateDashPanel('data', { date });
37 | }
38 | return (
39 |
40 | {day}
41 | {month}
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 | const recordsList = records?.map(record => {
51 | const habit = getHabitObject.fromId(record.habitId);
52 | return
53 | });
54 | return (
55 |
56 | {entryDate(date)}
57 |
{recordsList}
58 |
59 | );
60 | }
61 |
62 | const EntryRecord = ({ habit, record }) => {
63 | const { amount, check } = record;
64 | const { icon, label, complex } = habit;
65 | if (!check || ((complex && check) && !amount)) return null;
66 | const preparedLabel = () => {
67 | if (!complex) return {label} ;
68 | const pre = label.split('{')[0].trim();
69 | const post = label.split('}')[1].trim();
70 | const unit = getUnitFromLabel(label);
71 | return (
72 | {pre} {amount ?? 'some'} {unit} {post}
73 | );
74 | }
75 | return (
76 |
77 | {icon}
78 | {preparedLabel()}
79 |
80 | );
81 | }
82 |
83 | export default Timeline;
--------------------------------------------------------------------------------
/components/Form/Checkbox/checkbox.module.css:
--------------------------------------------------------------------------------
1 | .Checkbox {
2 | display: inline-flex;
3 | align-items: center;
4 | }
5 | .Checkbox + .Checkbox {
6 | margin-top: 0.5rem;
7 | }
8 | .Checkbox label {
9 | font-family: var(--default-font);
10 | font-weight: normal;
11 | font-size: 0.9rem;
12 | margin-bottom: 0;
13 | text-transform: none;
14 | line-height: 1.3;
15 | }
16 | .checkboxElement {
17 | position: relative;
18 | width: 1rem;
19 | height: 1rem;
20 | margin-right: 0.4rem;
21 | border-radius: 999px;
22 | cursor: pointer;
23 | flex: 0 0 auto;
24 | }
25 | .checkboxElement span.svg {
26 | transform: translate3d(0,0,0) scale(1);
27 | position: absolute;
28 | width: 100%;
29 | height: 100%;
30 | border-radius: 999px;
31 | border: 1px solid #ddd;
32 | background: #fff;
33 | transition: 0.2s ease;
34 | }
35 | .checkboxElement span.svg::before {
36 | content: '';
37 | width: 1rem;
38 | height: 1rem;
39 | margin: -1px; /* to offset 1px border on span.svg */
40 | background: #000;
41 | display: block;
42 | transform: scale(0);
43 | opacity: 1;
44 | border-radius: 999px;
45 | transition-delay: 0.2s;
46 | transform-origin: center;
47 | }
48 | .checkboxElement span.svg svg {
49 | position: absolute;
50 | z-index: 1;
51 | top: 0;
52 | left: 0;
53 | width: 100%;
54 | height: 100%;
55 | fill: none;
56 | stroke: #fff;
57 | stroke-width: 2;
58 | stroke-linecap: round;
59 | stroke-linejoin: round;
60 | stroke-dasharray: 1rem;
61 | stroke-dashoffset: 1rem;
62 | transition: all 0.3s ease;
63 | transition-delay: 0.2s;
64 | transform: translate3d(0,0,0) scale(0.8);
65 | }
66 | .Checkbox input[type=checkbox] {
67 | position: absolute;
68 | z-index: 3;
69 | top: 0;
70 | left: 0;
71 | width: 1rem;
72 | height: 1rem;
73 | margin-bottom: 0;
74 | opacity: 0;
75 | user-select: none;
76 | cursor: pointer;
77 | }
78 | .Checkbox input[type=checkbox]:checked + span.svg {
79 | border: 1px solid #000;
80 | background: #000;
81 | animation: pulse 0.6s ease;
82 | }
83 | .Checkbox input[type=checkbox]:checked + span.svg::before {
84 | transform: scale(2.2);
85 | opacity: 0;
86 | transition: all 0.6s ease;
87 | }
88 | .Checkbox input[type=checkbox]:checked + span.svg svg {
89 | stroke-dashoffset: 0;
90 | }
91 | .checkboxElement:hover span.svg {
92 | border-color: #000;
93 | }
94 |
95 | .Checkbox.detailed {
96 | display: flex;
97 | align-items: flex-start;
98 | justify-content: flex-start;
99 | }
100 | .Checkbox.detailed label {
101 | margin-bottom: 0.2rem;
102 | }
103 | .Checkbox.detailed .label span {
104 | display: block;
105 | font-size: 0.7rem;
106 | line-height: 1.2;
107 | color: #666;
108 | }
109 |
110 | .Checkbox.checkboxAfter {
111 | flex-direction: row-reverse;
112 | }
113 | .Checkbox.checkboxAfter .checkboxElement {
114 | margin-right: 0;
115 | margin-left: 0.4rem;
116 | }
--------------------------------------------------------------------------------
/layouts/AppLayout/index.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import { useRouter } from "next/router";
3 | import Head from "next/head";
4 |
5 | import '@fortawesome/fontawesome-svg-core/styles.css';
6 | // Prevent fontawesome from adding its CSS since we did it manually above:
7 | import { config } from '@fortawesome/fontawesome-svg-core';
8 | config.autoAddCss = false; /* eslint-disable import/first */
9 |
10 | import styles from "./appLayout.module.css";
11 |
12 | const Layout = ({ children }) => {
13 | const { pathname } = useRouter();
14 | return (
15 |
16 |
17 |
18 | {/* does everytime context change mean the background backdrop shifts again? todo look into this more */}
19 | {children}
20 |
21 | );
22 | }
23 |
24 | const Header = () => {
25 | return (
26 |
27 | habitat
28 |
29 |
30 | );
31 | }
32 |
33 | const Backdrop = ({ pathname }) => {
34 | const hue = useRef(0);
35 | const didMountRef = useRef(false);
36 | useEffect(() => {
37 | if (didMountRef.current) {
38 | //hue.current = Math.random() * (135 + 90) - 90;
39 | let [min, max] = [40, -40]
40 | hue.current = Math.random() * (max - min) + min;
41 | } else {
42 | didMountRef.current = true;
43 | }
44 | }, [pathname]);
45 | const startingColors = [
46 | 'rgb(216, 255, 241)',
47 | 'rgb(234, 223, 255)',
48 | 'rgb(252, 255, 223)',
49 | 'rgb(233, 255, 199)',
50 | 'rgb(212, 255, 247)',
51 | 'rgb(255, 224, 245)'
52 | ];
53 | const blobs = startingColors.map(color => {
54 | const translation = {
55 | x: Math.random() * (50 + 50) - 50,
56 | y: Math.random() * (50 + 50) - 50
57 | }
58 | const size = {
59 | x: (Math.random() * (130 - 80) + 80) / 100,
60 | y: (Math.random() * (130 - 80) + 80) / 100
61 | }
62 | return (
63 |
64 | )
65 | });
66 | return (
67 | {blobs}
70 | );
71 | }
72 |
73 | const Blob = ({ initial, hue, translation, size }) => {
74 | const transparentize = (rgb) => {
75 | // gradients that fade to transparent are interpreted/interpolated as ending in 'transparent black' in safari
76 | // this is a workaround
77 | return rgb.replace('rgb', 'rgba').split(')')[0] + ', 0)';
78 | }
79 | return (
80 |
85 | );
86 | }
87 |
88 | export default Layout;
--------------------------------------------------------------------------------
/components/EmojiPicker/emojiPicker.module.css:
--------------------------------------------------------------------------------
1 | .EmojiPicker {
2 | position: absolute;
3 | top: 0;
4 | right: 0;
5 | height: 100%;
6 | z-index: 95;
7 | }
8 | .EmojiPicker.active {
9 | width: 100%;
10 | }
11 | .EmojiPicker > button {
12 | position: absolute;
13 | top: 50%;
14 | right: 0.25rem;
15 | height: calc(100% - 0.5rem);
16 | font-size: 0.75rem;
17 | white-space: nowrap;
18 | text-transform: uppercase;
19 | transform: translate3d(0, -50%, 0);
20 | display: flex;
21 | justify-content: flex-end;
22 | }
23 | .EmojiPicker.active > button {
24 | width: 100%;
25 | }
26 | .EmojiPicker > button > span {
27 | padding: 0 0.5rem;
28 | display: flex;
29 | align-items: center;
30 | height: 100%;
31 | background: #eee;
32 | }
33 | .EmojiPicker > aside, .loading {
34 | top: calc(100% + 0.5rem);
35 | max-width: calc(100% + 3rem);
36 | min-width: 280px;
37 | animation: scaleIn 0.2s 1 forwards;
38 | max-height: 200px;
39 | }
40 | .EmojiPicker > aside {
41 | right: unset;
42 | left: 0;
43 | }
44 | .loading {
45 | position: absolute;
46 | right: 0;
47 | font-size: 0.75rem;
48 | background: #fff;
49 | border: 1px solid #efefef;
50 | padding: 0.5rem;
51 | border-radius: 0.25rem;
52 | box-shadow: 0 5px 10px #efefef;
53 | }
54 | .EmojiPicker.goodbye > aside {
55 | animation: scaleOut 0.2s 1 forwards;
56 | }
57 | .EmojiPicker > aside nav button {
58 | height: 2rem !important;
59 | background-size: 1rem !important;
60 | }
61 | .EmojiPicker > aside input:not([type=radio]) {
62 | height: 2rem;
63 | }
64 | .EmojiPicker > aside input:not([type=radio]) + ul li {
65 | width: 0.5rem;
66 | height: 0.5rem;
67 | border-radius: 999px;
68 | }
69 | .EmojiPicker > aside div[data-name]::before {
70 | padding: 0.5rem;
71 | /* display: flex; */
72 | display: none;
73 | align-items: center;
74 | justify-content: flex-end;
75 | background: #fff;
76 | line-height: 1;
77 | }
78 | .EmojiPicker > aside ul[data-display-name]::before {
79 | font-family: var(--mono-font);
80 | font-size: 0.8rem;
81 | line-height: 2.2;
82 | background: #fff;
83 | }
84 | .EmojiPicker > aside ul[data-display-name] button > img {
85 | width: 1.5rem;
86 | height: 1.5rem;
87 | margin: 2px;
88 | }
89 |
90 | .EmojiPicker.newHabitIcon > button {
91 | opacity: 0;
92 | width: 2rem;
93 | white-space: normal;
94 | }
95 | .EmojiPicker.newHabitIcon > button > span {
96 | width: 100%;
97 | }
98 | .EmojiPicker.newHabitIcon > aside {
99 | left: unset;
100 | right: 0;
101 | margin: 0;
102 | }
103 | .EmojiPicker.newHabitIcon.active > button {
104 | width: 100%;
105 | }
106 | .EmojiPicker.newHabitIcon.active {
107 | width: auto;
108 | }
109 |
110 | @keyframes scaleIn {
111 | from {
112 | opacity: 0;
113 | transform: scale(0.8);
114 | }
115 | to {
116 | opacity: 1;
117 | transform: scale(1);
118 | }
119 | }
120 | @keyframes scaleOut {
121 | from {
122 | opacity: 1;
123 | transform: scale(1);
124 | }
125 | to {
126 | opacity: 0;
127 | transform: scale(0.8);
128 | }
129 | }
--------------------------------------------------------------------------------
/components/Nav/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useRef, useState } from "react";
2 | import { useRouter } from "next/router";
3 |
4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5 | import { faCog, faHome, faList, faSignOutAlt, faUser } from "@fortawesome/free-solid-svg-icons";
6 |
7 | import styles from "./nav.module.css";
8 | import { handleRequest, User } from "../../pages/api";
9 | import { DataContext, MobileContext } from "../../contexts";
10 |
11 | const Nav = () => {
12 | const router = useRouter();
13 | const isMobile = useContext(MobileContext);
14 | const [showingNav, setShowingNav] = useState(false);
15 | const navButtonRef = useRef(null);
16 | const navButtonsRef = useRef(null);
17 | const toggleNav = () => setShowingNav(state => !state);
18 | useEffect(() => {
19 | if (!showingNav) return;
20 | const closeNav = (e) => {
21 | if (!navButtonRef.current || !navButtonsRef.current) return;
22 | if (navButtonRef.current.contains(e.target)) return;
23 | if (navButtonsRef.current.contains(e.target)) return;
24 | setShowingNav(false);
25 | }
26 | window.addEventListener('click', closeNav);
27 | return () => window.removeEventListener('click', closeNav);
28 | }, [showingNav, navButtonRef.current, navButtonsRef.current]);
29 | return (
30 |
31 | {isMobile && (
32 |
35 |
36 |
37 | )}
38 |
39 |
40 | );
41 | }
42 |
43 | const NavButtons = React.forwardRef(({ router, showingNav, updateShowingNav }, ref) => {
44 | const { demoTokenId } = useContext(DataContext);
45 | const handleClick = (e) => {
46 | updateShowingNav(false);
47 | const path = `/${e.currentTarget.name}`;
48 | router.push(path);
49 | }
50 | const isActive = (path) => {
51 | if (router.pathname === path) return styles.active;
52 | return '';
53 | }
54 | const handleLogout = async () => {
55 | if (demoTokenId) User.clearDemoData({ demoTokenId });
56 | await handleRequest('/api/auth/logout');
57 | router.push('/');
58 | }
59 | return (
60 |
61 |
62 |
63 | home
64 |
65 |
66 |
67 | my habits
68 |
69 |
70 |
71 | my account
72 |
73 |
74 |
75 | settings
76 |
77 |
78 |
79 | log out
80 |
81 |
82 | );
83 | });
84 |
85 | export default Nav;
--------------------------------------------------------------------------------
/components/MiniCalendar/index.js:
--------------------------------------------------------------------------------
1 | import styles from "./miniCalendar.module.css";
2 | import { useCalendar } from "../../hooks";
3 | import dayjs from "dayjs";
4 | import ArrowNav from "../ArrowNav";
5 | import { useContext, useState } from "react";
6 | import { MobileContext } from "../../contexts";
7 | import { Button } from "../Form";
8 |
9 | const MiniCalendarWrapper = ({ children, isMobile, miniCalendarPeriod, updateMiniCalendarPeriod }) => {
10 | const currentPeriod = dayjs().format('YYYY-MM');
11 | const jumpToCurrentMonth = () => updateMiniCalendarPeriod(currentPeriod);
12 | return (
13 |
14 |
15 | {children}
16 | {(miniCalendarPeriod === currentPeriod)
17 | ? null
18 | : isMobile
19 | ?
» jump to current month
20 | : » jump to current month }
21 |
22 | );
23 | }
24 |
25 | const Header = ({ miniCalendarPeriod, updateMiniCalendarPeriod }) => {
26 | const nav = (direction) => () => {
27 | const newPeriod = (direction === 'next')
28 | ? (period) => dayjs(period).add(1, 'month').format('YYYY-MM')
29 | : (period) => dayjs(period).subtract(1, 'month').format('YYYY-MM');
30 | updateMiniCalendarPeriod(newPeriod);
31 | }
32 | return (
33 |
34 | {dayjs(miniCalendarPeriod).format('MMMM YYYY')}
35 |
36 |
37 | );
38 | }
39 |
40 | const MiniCalendar = ({ calendarPeriod, updateCalendarPeriod, updateDashPanel }) => {
41 | const isMobile = useContext(MobileContext);
42 | const [miniCalendarPeriod, updateMiniCalendarPeriod] = useState(calendarPeriod);
43 | const { weekdays, totalDaysInMonth } = useCalendar(miniCalendarPeriod);
44 | return (
45 |
46 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
54 | const WeekLabels = ({ weekdays }) => {
55 | return weekdays('dd').map(weekName => (
56 |
57 | {weekName}
58 |
59 | ));
60 | }
61 |
62 | const DaysInMonth = ({ isMobile, totalDaysInMonth, updateCalendarPeriod, updateDashPanel }) => {
63 | return totalDaysInMonth.map((date, index) => {
64 | const handleClick = () => {
65 | updateCalendarPeriod(dayjs(date).format('YYYY-MM'));
66 | updateDashPanel(); // close it first to allow pretty opening animation
67 | setTimeout(() => {
68 | updateDashPanel('data', { date });
69 | }, isMobile ? 0 : 100);
70 | }
71 | const dayNumber = () => {
72 | if (date === '') return null;
73 | const rawDate = date.split('-')[2];
74 | return (parseInt(rawDate) > 9) ? rawDate : parseInt(rawDate).toString();
75 | }
76 | return (
77 |
81 | {(date === '') ? null : {dayNumber()} }
82 |
83 | );
84 | });
85 | }
86 |
87 | export default MiniCalendar;
--------------------------------------------------------------------------------
/pages/recover.js:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect, useState } from "react";
2 | import { useRouter } from "next/router";
3 |
4 | import { User, Token, handleRequest } from "./api";
5 | import { ModalContext, MobileContext } from "../contexts";
6 | import { useForm, useWarnError } from "../hooks";
7 | import { getQueryParams } from "../utils";
8 | import { PageLoading } from "../components/Loading";
9 | import Form, { Input, Submit } from "../components/Form";
10 |
11 | const ResetPassword = ({ query }) => {
12 | const [userId, setUserId] = useState(null);
13 | const [tokenIsValid, setTokenIsValid] = useState(null);
14 | const warnError = useWarnError();
15 | useEffect(() => {
16 | const { token } = query;
17 | if (!token) return setTokenIsValid(false);
18 | Token.validate({ tokenId: token }).then(({ validatePasswordToken }) => {
19 | setUserId(validatePasswordToken.userId);
20 | setTokenIsValid(true);
21 | }).catch(err => {
22 | setTokenIsValid(false);
23 | if (err.__typename !== 'FormErrorReport') {
24 | warnError('somethingWentWrong', err);
25 | }
26 | });
27 | }, []);
28 | const content = () => {
29 | if (tokenIsValid == null) return ;
30 | return tokenIsValid ? : ;
31 | }
32 | return (
33 | <>
34 | reset password
35 | {content()}
36 | >
37 | );
38 | }
39 |
40 | const InvalidToken = () => {
41 | const { createModal } = useContext(ModalContext);
42 | const handleClick = () => {
43 | createModal('forgotPassword');
44 | }
45 | return (
46 | <>
47 | sorry, this password reset link is invalid or expired! to request a new one, click here
48 | >
49 | );
50 | }
51 |
52 | const ValidToken = ({ userId }) => {
53 | const router = useRouter();
54 | const isMobile = useContext(MobileContext);
55 | const { formData, handleFormError, inputProps, resetForm } = useForm({
56 | id: userId,
57 | password: '',
58 | confirmPassword: '',
59 | reset: true
60 | });
61 | const handleSubmit = () => User.editPassword(formData);
62 | const handleSuccess = async ({ editPassword: user }) => {
63 | await handleRequest('/api/auth/login', { user });
64 | router.push('/dashboard');
65 | }
66 | const passwordIsOk = (() => {
67 | if (!formData.password || !formData.confirmPassword) return false;
68 | if (formData.password.length < 6) return false;
69 | return formData.password === formData.confirmPassword;
70 | })();
71 | const formSubmit = (
72 |
78 | );
79 | return (
80 |
100 | );
101 | }
102 |
103 | ResetPassword.getInitialProps = getQueryParams;
104 |
105 |
106 | export default ResetPassword;
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "postgresql"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | previewFeatures = ["orderByRelation"]
9 | }
10 |
11 | model User {
12 | id String @default(cuid()) @id
13 | name String?
14 | email String @unique
15 | password String?
16 | habits Habit[]
17 | entries Entry[]
18 | records Record[]
19 | settings Settings?
20 | @@map(name: "users")
21 | }
22 |
23 | model Settings {
24 | id String @default(cuid()) @id
25 | dashboard__defaultView String? @default("list")
26 | habits__defaultView String? @default("list")
27 | habits__newHabitIcon String? @default("🐛")
28 | appearance__showClock Boolean? @default(true)
29 | appearance__24hrClock Boolean? @default(false)
30 | appearance__showClockSeconds Boolean? @default(true)
31 | user User? @relation(fields: [userId], references: [id])
32 | userId String? @unique
33 | demoToken DemoToken? @relation(fields: [demoTokenId], references: [id])
34 | demoTokenId String? @unique
35 | @@map(name: "settings")
36 | }
37 |
38 | model Habit {
39 | id String @default(cuid()) @id
40 | name String
41 | icon String?
42 | color String?
43 | label String?
44 | complex Boolean?
45 | retired Boolean @default(false)
46 | order Int?
47 | records Record[]
48 | user User? @relation(fields: [userId], references: [id])
49 | userId String?
50 | demoToken DemoToken? @relation(fields: [demoTokenId], references: [id])
51 | demoTokenId String?
52 | @@map(name: "habits")
53 | }
54 |
55 | model Entry {
56 | id String @default(cuid()) @id
57 | date String
58 | records Record[]
59 | user User @relation(fields: [userId], references: [id])
60 | userId String
61 | demoToken DemoToken? @relation(fields: [demoTokenId], references: [id])
62 | demoTokenId String?
63 | @@map(name: "entries")
64 | }
65 |
66 | model Record {
67 | id String @default(cuid()) @id
68 | habit Habit? @relation(fields: [habitId], references: [id])
69 | habitId String?
70 | amount Int?
71 | check Boolean
72 | entry Entry? @relation(fields: [entryId], references: [id])
73 | entryId String?
74 | user User? @relation(fields: [userId], references: [id])
75 | userId String?
76 | demoToken DemoToken? @relation(fields: [demoTokenId], references: [id])
77 | demoTokenId String?
78 | @@map(name: "records")
79 | }
80 |
81 | model SignupToken {
82 | id String @default(cuid()) @id
83 | createdAt DateTime @default(now())
84 | @@map(name: "signupToken")
85 | }
86 |
87 | model PasswordToken {
88 | id String @default(cuid()) @id
89 | userId String? @unique
90 | createdAt DateTime @default(now())
91 | @@map(name: "passwordToken")
92 | }
93 |
94 | model DemoToken {
95 | id String @default(cuid()) @id
96 | habits Habit[]
97 | entries Entry[]
98 | records Record[]
99 | settings Settings?
100 | createdAt DateTime @default(now())
101 | @@map(name: "demoToken")
102 | }
--------------------------------------------------------------------------------
/components/DashPanel/dashPanel.module.css:
--------------------------------------------------------------------------------
1 | .DashPanel {
2 | width: 100%;
3 | height: 100%;
4 | display: grid;
5 | grid-template:
6 | 'nav' auto
7 | 'content' 1fr / 100%;
8 | justify-items: flex-end;
9 | font-size: 0.9rem;
10 | }
11 | .DashPanel > nav {
12 | text-align: right;
13 | display: flex;
14 | flex-direction: column;
15 | align-items: flex-end;
16 | }
17 | .DashPanel > nav button {
18 | display: flex;
19 | flex-direction: row;
20 | align-items: center;
21 | justify-content: flex-end;
22 | max-height: 2rem;
23 | transition: 0.2s;
24 | }
25 | .DashPanel > nav button > span:first-child {
26 | font-size: 1rem;
27 | transition: 0.2s;
28 | position: relative;
29 | z-index: 5;
30 | }
31 | .DashPanel > nav button > span:first-child::before {
32 | content: '';
33 | display: block;
34 | position: absolute;
35 | z-index: -1;
36 | top: 50%;
37 | left: -0.5rem;
38 | width: calc(100% + 1rem);
39 | height: 1rem;
40 | background: #fff;
41 | transform: skew(-12deg);
42 | /* box-shadow: 0 0 1rem rgba(0, 0, 0, 0.1); */
43 | }
44 | .DashPanel > nav button > span:last-child {
45 | width: 2rem;
46 | height: 2rem;
47 | background: #000;
48 | border-radius: 999px;
49 | color: #fff;
50 | font-size: 1rem;
51 | line-height: 2rem;
52 | display: flex;
53 | align-items: center;
54 | justify-content: center;
55 | margin-left: 0.75rem;
56 | transition: 0.2s;
57 | }
58 | .DashPanel > nav button + button {
59 | margin-top: 1rem;
60 | }
61 | .DashPanel > nav button.active {
62 | margin-top: 0;
63 | }
64 | .DashPanel > nav button.active > span:first-child {
65 | font-size: 1.4rem;
66 | font-weight: 300;
67 | }
68 | .DashPanel > nav button.active > span:last-child {
69 | transform: rotate(-45deg);
70 | }
71 | .DashPanel > nav button.inactive {
72 | opacity: 0;
73 | margin-top: 0;
74 | max-height: 0;
75 | padding: 0;
76 | overflow: visible;
77 | pointer-events: none;
78 | }
79 |
80 | .PanelContent {
81 | opacity: 0;
82 | margin-top: 1rem;
83 | transform: scale(0) translate3d(0, 50%, 0);
84 | transform-origin: top right;
85 | max-height: 100%;
86 | transition: 0.2s;
87 | }
88 | .PanelContent.active {
89 | transform: scale(1) translate3d(0, 0, 0);
90 | opacity: 1;
91 | width: var(--dashpanel-sidebar-width);
92 | }
93 | .PanelContent > * {
94 | border-top: 1px solid #000;
95 | padding-top: 1rem;
96 | }
97 |
98 | @media screen and (max-width: 900px) {
99 | .DashPanel {
100 | display: inline-block;
101 | }
102 | .DashPanel > nav {
103 | flex-direction: row-reverse;
104 | justify-content: flex-start;
105 | padding: 0 1rem 1rem 0rem;
106 | }
107 | .DashPanel > nav button > span:first-child {
108 | display: none;
109 | }
110 | .DashPanel > nav button > span:last-child {
111 | margin: 0;
112 | }
113 | .DashPanel > nav button + button,
114 | .DashPanel > nav button + button.active {
115 | margin-top: 0;
116 | margin-right: 0.5rem;
117 | }
118 | .DashPanel > nav button.inactive {
119 | max-width: 0;
120 | transform: scale(0);
121 | overflow: visible;
122 | transition: transform 0.2s, max-width 0s 0.2s;
123 | }
124 | .DashPanel > nav.active {
125 | padding-bottom: 0;
126 | height: 0;
127 | overflow: visible;
128 | }
129 | .DashPanel > nav button.active {
130 | transform: translate3d(0, 1rem, 0);
131 | position: relative;
132 | z-index: 51;
133 | }
134 | .DashPanel > nav button + button.active {
135 | margin-right: 0;
136 | }
137 |
138 | .PanelContent {
139 | width: 100%;
140 | height: 100%;
141 | overflow: hidden;
142 | background: #fff;
143 | transform: translate3d(0, 100%, 0);
144 | margin-top: 0;
145 | max-height: 0;
146 | opacity: 1;
147 | transition: 0.2s;
148 | }
149 | .PanelContent.active {
150 | transform: translate3d(0, 0, 0);
151 | max-height: 75vh;
152 | width: 100%;
153 | }
154 | .PanelContent > div {
155 | padding: 2rem;
156 | }
157 | }
158 |
159 | @media screen and (max-width: 600px) {
160 | .PanelContent > div {
161 | padding: 1.5rem;
162 | }
163 | }
--------------------------------------------------------------------------------
/pages/api/server/schemas/index.js:
--------------------------------------------------------------------------------
1 | import { gql } from "apollo-server-micro";
2 |
3 | export const typeDefs = gql`
4 | type User {
5 | id: String
6 | name: String
7 | email: String
8 | password: String
9 | habits: [Habit]
10 | entries: [Entry]
11 | settings: Settings
12 | demoTokenId: String
13 | }
14 |
15 | type Settings {
16 | userId: String,
17 | dashboard__defaultView: String,
18 | habits__defaultView: String,
19 | habits__newHabitIcon: String,
20 | appearance__showClock: Boolean,
21 | appearance__24hrClock: Boolean,
22 | appearance__showClockSeconds: Boolean
23 | }
24 |
25 | type Habit {
26 | id: String
27 | name: String
28 | icon: String
29 | color: String
30 | label: String
31 | complex: Boolean
32 | retired: Boolean
33 | userId: String
34 | }
35 |
36 | type Entry {
37 | id: String
38 | date: String
39 | records: [Record]
40 | user: User
41 | userId: String
42 | }
43 |
44 | type Record {
45 | id: String
46 | habitId: String
47 | amount: Int
48 | check: Boolean
49 | entryId: String
50 | }
51 |
52 | type FormErrorReport {
53 | errors: [FormError]
54 | }
55 |
56 | type FormError {
57 | message: String,
58 | location: String
59 | }
60 |
61 | type Success {
62 | success: Boolean
63 | }
64 |
65 | input UserInput {
66 | email: String
67 | password: String
68 | }
69 |
70 | input RecordInput {
71 | id: String
72 | habitId: String
73 | amount: Int
74 | check: Boolean
75 | }
76 |
77 | type Token {
78 | id: String
79 | userId: String
80 | }
81 |
82 | union UserResult = User | FormErrorReport
83 |
84 | union HabitResult = Habit | FormErrorReport
85 |
86 | union EntryResult = Entry | FormErrorReport
87 |
88 | union TokenResult = Token | FormErrorReport
89 |
90 | type Query {
91 | users: [User]
92 | login(email: String, password: String): UserResult
93 | user(id: String, demoTokenId: String): User
94 | settings(userId: String, demoTokenId: String): Settings
95 | habits(userId: String, demoTokenId: String): [Habit]
96 | entries(userId: String, demoTokenId: String): [Entry]
97 | records(entryId: String): [Record]
98 | }
99 |
100 | type Mutation {
101 | createUser(email: String, password: String, token: String): UserResult
102 | editUser(id: String, name: String, email: String): UserResult
103 | editPassword(id: String, password: String, confirmPassword: String, reset: Boolean): UserResult
104 | editSettings(
105 | userId: String,
106 | demoTokenId: String,
107 | dashboard__defaultView: String,
108 | habits__defaultView: String,
109 | habits__newHabitIcon: String,
110 | appearance__showClock: Boolean,
111 | appearance__24hrClock: Boolean,
112 | appearance__showClockSeconds: Boolean
113 | ): Settings
114 | deleteAccount(id: String): UserResult
115 | createHabit(
116 | name: String,
117 | icon: String,
118 | color: String,
119 | label: String,
120 | complex: Boolean,
121 | retired: Boolean,
122 | userId: String,
123 | demoTokenId: String
124 | ): HabitResult
125 | editHabit(
126 | id: String,
127 | name: String,
128 | icon: String,
129 | color: String,
130 | label: String,
131 | complex: Boolean,
132 | retired: Boolean
133 | ): HabitResult
134 | deleteHabit(id: String): Habit
135 | rearrangeHabits(array: [String]): Success
136 | createEntry(userId: String, date: String, records: [RecordInput], demoTokenId: String): EntryResult
137 | editEntry(id: String, date: String, records: [RecordInput], demoTokenId: String): EntryResult
138 | deleteEntry(id: String): Entry
139 | generateDemoData(id: String, demoTokenId: String, calendarPeriod: String, alsoHabits: Boolean): Success
140 | clearDemoData(demoTokenId: String): Success
141 | validateSignupToken(tokenId: String): TokenResult
142 | createPasswordToken(email: String): TokenResult
143 | validatePasswordToken(tokenId: String): TokenResult
144 | }
145 | `;
--------------------------------------------------------------------------------
/components/Nav/nav.module.css:
--------------------------------------------------------------------------------
1 | .Nav {
2 | position: fixed;
3 | z-index: 200;
4 | top: 0;
5 | left: 0;
6 | width: var(--dashboard-nav-width);
7 | height: 100%;
8 | }
9 | .Nav nav {
10 | height: 70%;
11 | display: flex;
12 | flex-direction: column;
13 | align-items: center;
14 | justify-content: center;
15 | }
16 | .Nav nav button {
17 | margin: 0;
18 | padding: 0;
19 | border: 1px solid #000;
20 | background: transparent;
21 | font-size: 1.3rem;
22 | color: #000;
23 | width: 3rem;
24 | height: 3rem;
25 | border-radius: 999px;
26 | position: relative;
27 | display: flex;
28 | align-items: center;
29 | justify-content: center;
30 | transition: 0.2s;
31 | }
32 | .Nav nav button + button {
33 | margin-top: 1rem;
34 | }
35 | .Nav nav button:hover, .Nav nav button.active {
36 | background: #000;
37 | color: #fff;
38 | }
39 | .Nav nav button > span {
40 | display: block;
41 | position: absolute;
42 | bottom: 50%;
43 | left: 100%;
44 | font-family: var(--mono-font);
45 | font-size: 0.9rem;
46 | line-height: 1.2rem;
47 | color: #000;
48 | text-align: left;
49 | white-space: nowrap;
50 | margin-left: 1rem;
51 | transform: translate3d(0, 50%, 0) scale(0);
52 | transform-origin: center left;
53 | transition: 0.2s;
54 | }
55 | .Nav nav button:hover > span {
56 | transform: translate3d(0, 50%, 0) scale(1);
57 | }
58 | .Nav nav button.active > span {
59 | transform: translate3d(0, 50%, 0) scale(0);
60 | }
61 | .Nav nav button > span::before {
62 | content: '';
63 | }
64 | .Nav nav button:hover > span::before {
65 | z-index: -1;
66 | position: absolute;
67 | bottom: 0;
68 | left: -0.5rem;
69 | width: calc(100% + 1rem);
70 | height: 1.2rem;
71 | background: #fff;
72 | transform: skew(-12deg);
73 | }
74 |
75 | @media screen and (max-width: 900px) {
76 | .MobileNavButton {
77 | pointer-events: auto;
78 | display: flex;
79 | align-items: center;
80 | justify-content: center;
81 | width: 5rem;
82 | height: 5rem;
83 | overflow: visible;
84 | position: relative;
85 | }
86 | .MobileNavButton::before {
87 | content: '';
88 | position: absolute;
89 | z-index: -1;
90 | display: block;
91 | width: 140%;
92 | height: 140%;
93 | background: url('./blob.svg') center;
94 | background-size: 140% 140%;
95 | transition: 0.5s;
96 | }
97 | .MobileNavButton > span {
98 | width: 50%;
99 | height: 100%;
100 | display: flex;
101 | flex-direction: column;
102 | align-items: center;
103 | justify-content: center;
104 | }
105 | .MobileNavButton > span::before, .MobileNavButton > span::after {
106 | content: '';
107 | display: block;
108 | width: 100%;
109 | height: 2px;
110 | background: #000;
111 | transition: 0.5s;
112 | }
113 | .MobileNavButton > span::after {
114 | transform: translate3d(0, 5px, 0);
115 | }
116 | .MobileNavButton.expanded::before {
117 | transform: scale(4, 9);
118 | }
119 | .MobileNavButton.expanded > span::before {
120 | transform-origin: center;
121 | transform: rotate(45deg);
122 | }
123 | .MobileNavButton.expanded > span::after {
124 | margin: 0;
125 | transform-origin: center;
126 | transform: rotate(-45deg) translate3d(1.5px, -1.5px, 0);
127 | }
128 | .Nav {
129 | height: auto;
130 | pointer-events: none;
131 | }
132 | .Nav nav {
133 | margin-top: 1rem;
134 | margin-left: 2rem;
135 | display: inline-flex;
136 | align-items: flex-start;
137 | width: auto;
138 | height: unset;
139 | transform-origin: top left;
140 | opacity: 0;
141 | transform: scale(0.7);
142 | transition: 0.2s;
143 | pointer-events: none;
144 | }
145 | .Nav nav.active {
146 | opacity: 1;
147 | transform: scale(1);
148 | transition: 0.5s 0.1s;
149 | pointer-events: auto;
150 | }
151 | .Nav nav button {
152 | width: 2.5rem;
153 | height: 2.5rem;
154 | font-size: 1.1rem;
155 | }
156 | .Nav nav button > span,
157 | .Nav nav button.active > span {
158 | line-height: 2.5rem;
159 | transform: translate3d(0, 50%, 0);
160 | margin-left: 0;
161 | padding-left: 0.75rem;
162 | padding-right: 2rem;
163 | }
164 | .Nav nav button + button {
165 | margin-top: 0.5rem;
166 | }
167 | .Nav nav button > span::before {
168 | content: none;
169 | }
170 | }
--------------------------------------------------------------------------------
/components/MyData/Graph/index.js:
--------------------------------------------------------------------------------
1 | import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2 |
3 | import dayjs from "dayjs";
4 | import { Chart } from "chart.js/dist/chart";
5 |
6 | import styles from "./graph.module.css";
7 | import graphConfig from "./graphConfig";
8 | import { useRefName } from "../../../hooks";
9 | import { MobileContext } from "../../../contexts";
10 |
11 | const Graph = ({ habits, entries, calendarPeriod, updateDashPanel }) => {
12 | const [resizing, setResizing] = useState(false);
13 | useEffect(() => {
14 | let timeout;
15 | const doThing = () => {
16 | if (!resizing) setResizing(true);
17 | clearTimeout(timeout);
18 | timeout = setTimeout(() => setResizing(false), 500);
19 | }
20 | window.addEventListener('resize', doThing);
21 | return () => {
22 | window.removeEventListener('resize', doThing);
23 | clearTimeout(timeout);
24 | }
25 | }, [resizing]);
26 | if (resizing) return null;
27 | return (
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
35 | const ChartCanvas = ({ type, habits, entries, calendarPeriod, updateDashPanel, includeDateMarkers }) => {
36 | const [chartInstance, setChartInstance] = useState(null);
37 | const isMobile = useContext(MobileContext);
38 | const chartRef = useRef(null);
39 | const { data, config } = useMemo(() => {
40 | return graphConfig(habits, entries, calendarPeriod, type, isMobile, includeDateMarkers);
41 | }, [entries, isMobile]);
42 | useEffect(() => {
43 | if (!chartInstance) return;
44 | chartInstance.data = data;
45 | chartInstance.options = config.options;
46 | chartInstance.update();
47 | }, [data, config]);
48 | const handleChartClick = useCallback((e) => {
49 | if (!chartInstance) return;
50 | const points = chartInstance.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
51 | if (!points.length) return;
52 | const { index } = points[0];
53 | const date = dayjs(data.labels[index])?.format(`${calendarPeriod}-DD`);
54 | if (date) updateDashPanel('data', { date });
55 | }, [chartInstance, calendarPeriod]);
56 | useEffect(() => {
57 | const myCanvas = useRefName(chartRef);
58 | if (!chartInstance) {
59 | if (!myCanvas) {
60 | setChartInstance(null);
61 | chartInstance.destroy();
62 | return;
63 | }
64 | const ctx = myCanvas.getContext('2d');
65 | const chart = new Chart(ctx, config);
66 | setChartInstance(chart);
67 | }
68 | myCanvas.addEventListener('click', handleChartClick);
69 | return () => myCanvas.removeEventListener('click', handleChartClick);
70 | }, [chartRef, handleChartClick, config]);
71 | const chartHeight = () => {
72 | if (type === 'complex') return 250;
73 | if (habits.length <= 1) return 40;
74 | const num = includeDateMarkers ? 30 : 20;
75 | return habits.length * num;
76 | }
77 | return (
78 |
79 |
80 |
81 | ); // height for simple habits: habitsActiveThisMonth.length * 40
82 | // visibility: hidden on canvas if habitsActiveThisMonth.length is 0
83 | }
84 |
85 | export const SimpleHabits = ({ habits, entries, calendarPeriod, updateDashPanel }) => {
86 | const simpleHabits = habits.filter(habit => !habit.complex);
87 | if (!simpleHabits.length) return null;
88 | const includeDateMarkers = simpleHabits.length === habits.length;
89 | return (
90 |
98 | );
99 | }
100 |
101 | export const ComplexHabits = ({ habits, entries, calendarPeriod, updateDashPanel }) => {
102 | const complexHabits = habits.filter(habit => habit.complex);
103 | if (!complexHabits.length) return null;
104 | return (
105 |
112 | );
113 | }
114 |
115 | export default Graph;
--------------------------------------------------------------------------------
/components/MySettings/index.js:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect } from "react";
2 |
3 | import styles from "./mySettings.module.css";
4 | import { User } from "../../pages/api";
5 | import { DataContext } from "../../contexts";
6 | import { useForm } from "../../hooks";
7 | import Form, { Dropdown, Input, Submit, Switch } from "../Form";
8 | import EmojiPicker from "../EmojiPicker";
9 |
10 | const MySettings = () => {
11 | const { user, getUser, demoTokenId } = useContext(DataContext);
12 | const { formData, setFormData, inputProps, checkboxProps, dropdownProps } = useForm({
13 | userId: user.id,
14 | dashboard__defaultView: user.settings?.dashboard__defaultView ?? 'list',
15 | habits__defaultView: user.settings?.habits__defaultView ?? 'list',
16 | habits__newHabitIcon: user.settings?.habits__newHabitIcon ?? '🐛',
17 | appearance__showClock: user.settings?.appearance__showClock ?? true,
18 | appearance__24hrClock: user.settings?.appearance__24hrClock ?? false,
19 | appearance__showClockSeconds: user.settings?.appearance__showClockSeconds ?? true,
20 | demoTokenId
21 | });
22 | const handleSubmit = async () => User.editSettings(formData);
23 | const handleSuccess = () => getUser();
24 | return (
25 | }>
30 |
31 |
32 | );
33 | }
34 |
35 | const DashboardSettings = ({ formData, setFormData, inputProps, dropdownProps, checkboxProps }) => {
36 | const dashboardViewListItems = [
37 | { value: 'list', display: 'list' },
38 | { value: 'grid', display: 'grid' },
39 | { value: 'graph', display: 'graph' }
40 | ];
41 | const dashboardViewDefaultValue = dashboardViewListItems.find(item => item.value === formData.dashboard__defaultView)?.display;
42 | const habitViewListItems = [
43 | { value: 'list', display: 'list' },
44 | { value: 'grid', display: 'grid' }
45 | ];
46 | const habitViewDefaultValue = dashboardViewListItems.find(item => item.value === formData.habits__defaultView)?.display;
47 | return (
48 |
49 |
dashboard
50 |
51 | default dashboard view
52 |
58 |
59 |
habits
60 |
61 | default habit view
62 |
68 |
69 |
70 | "create new habit" emoji
71 | }
78 | {...inputProps}
79 | />
80 |
81 |
appearance
82 |
clock
83 |
84 | show clock
85 |
90 |
91 |
92 | 24 hr clock
93 |
98 |
99 |
100 | show seconds
101 |
106 |
107 |
108 | );
109 | }
110 |
111 | export default MySettings;
--------------------------------------------------------------------------------
/components/MyData/Graph/graphConfig.js:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 | import { getUnitFromLabel } from "../../../utils";
3 |
4 | const graphConfig = (habits, entries, calendarPeriod, type, isMobile, includeDateMarkers) => {
5 | const daysInMonth = new Array(dayjs(calendarPeriod).daysInMonth()).fill('');
6 | const datesInMonth = daysInMonth.map((_, i) => {
7 | return dayjs(`${calendarPeriod}-${i + 1}`).format('YYYY-MM-DD');
8 | });
9 | const datasets = habits.map(habit => {
10 | const particularEntryRecord = (someEntry, habitId) => {
11 | return someEntry?.records?.find(record => record.habitId === habitId);
12 | }
13 | const activeThisMonth = entries.some(entry => particularEntryRecord(entry, habit.id)?.check);
14 | if (!activeThisMonth) return null;
15 | const data = datesInMonth.map(day => {
16 | const currentEntry = entries.find(entry => entry.date === day);
17 | if (!currentEntry) return habit.complex ? 0 : null;
18 | if (particularEntryRecord(currentEntry, habit.id)?.check) {
19 | return habit.complex
20 | ? particularEntryRecord(currentEntry, habit.id).amount
21 | : isMobile ? habit.icon : habit.name;
22 | }
23 | return habit.complex ? 0 : null;
24 | });
25 | const unit = getUnitFromLabel(habit.label);
26 | const obj = {
27 | label: habit.name,
28 | unit,
29 | data,
30 | borderColor: '#000',
31 | borderWidth: 1,
32 | backgroundColor: habit.color || '#45DAC8',
33 | tension: 0.3
34 | }
35 | return obj;
36 | }).filter(el => el);
37 | const labels = datesInMonth.map(day => dayjs(day).format('MMM DD'));
38 | const yAxisLabels = (type === 'complex')
39 | ? null
40 | : isMobile
41 | ? habits.map(habit => habit.icon)
42 | : habits.map(habit => habit.name);
43 | return {
44 | config: chartSetup(labels, yAxisLabels, datasets, type, isMobile, includeDateMarkers),
45 | data: {
46 | labels,
47 | yAxisLabels,
48 | datasets
49 | }
50 | }
51 | }
52 |
53 | export const chartSetup = (labels, yAxisLabels, datasets, type, isMobile, includeDateMarkers) => ({
54 | type: 'line',
55 | options: {
56 | layout: {
57 | padding: {
58 | right: isMobile ? 0 : 30,
59 | bottom: (isMobile && type === 'complex') ? 32 : 0
60 | }
61 | },
62 | scales: {
63 | x: {
64 | ticks: {
65 | font: {
66 | family: 'Inconsolata, monospace'
67 | },
68 | display: (type === 'complex') || includeDateMarkers,
69 | padding: 5
70 | },
71 | grid: {
72 | drawTicks: (type === 'complex') || (isMobile && type === 'simple' && includeDateMarkers)
73 | }
74 | },
75 | y: {
76 | type: yAxisLabels ? 'category' : 'linear',
77 | labels: yAxisLabels,
78 | ticks: {
79 | font: {
80 | family: 'Inconsolata, monospace',
81 | size: (isMobile && type === 'simple') ? 12 : 10
82 | },
83 | color: '#000'
84 | },
85 | afterSetDimensions: (axes) => {
86 | axes.paddingLeft = isMobile ? 40 : 100;
87 | }
88 | }
89 | },
90 | responsive: true,
91 | maintainAspectRatio: false,
92 | hoverMode: 'index',
93 | plugins: {
94 | legend: {
95 | display: type === 'complex',
96 | position: 'bottom',
97 | labels: {
98 | font: {
99 | family: 'Work Sans, sans-serif',
100 | size: 12
101 | },
102 | boxWidth: 7,
103 | usePointStyle: true,
104 | padding: 20
105 | }
106 | },
107 | tooltip: {
108 | enabled: type === 'complex',
109 | cornerRadius: 0,
110 | titleColor: '#000',
111 | titleFont: {
112 | family: 'Inconsolata, monospace',
113 | weight: 'normal',
114 | size: 14
115 | },
116 | bodyColor: '#000',
117 | bodyFont: {
118 | family: 'Work Sans, sans-serif',
119 | lineHeight: 1
120 | },
121 | backgroundColor: '#fff',
122 | boxWidth: 7,
123 | boxHeight: 7,
124 | usePointStyle: true,
125 | padding: 8,
126 | callbacks: {
127 | afterLabel: (context) => {
128 | const { unit } = context.dataset;
129 | return unit;
130 | }
131 | }
132 | }
133 | }
134 | },
135 | data: {
136 | labels,
137 | datasets
138 | },
139 | });
140 |
141 | export default graphConfig;
142 |
--------------------------------------------------------------------------------
/components/MyHabits/HabitGrid.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useRef, useState } from "react";
2 |
3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4 | import { faPen, faPlus, faTrashAlt } from "@fortawesome/free-solid-svg-icons";
5 |
6 | import styles from "./myHabits.module.css";
7 | import { ModalContext } from "../../contexts";
8 | import { getUnitFromLabel } from "../../utils";
9 | import { Grip, HabitForm, HabitIcon } from ".";
10 |
11 | export const HabitGridItem = React.forwardRef(({ user, index, habits, addingNew, habit, habitItemsStuff }, ref) => {
12 | const { id, name, icon, color, label, complex, retired } = habit ?? {};
13 | const { habitItems, habitItemOrder, updateHabitItemOrder } = habitItemsStuff ?? {};
14 | const [dragging, setDragging] = useState(false);
15 | const { createModal } = useContext(ModalContext);
16 | const manageHabit = () => {
17 | createModal('manageHabit', {
18 | habitForm: ,
19 | habitFormProps: {
20 | title: addingNew ? 'create a new habit' : 'edit this habit',
21 | user,
22 | ...habit
23 | }
24 | });
25 | }
26 | const deleteHabit = () => {
27 | const habit = { id, name };
28 | createModal('deleteHabit', { habit });
29 | }
30 | if (addingNew) return (
31 |
32 |
33 |
34 | {habits.length ? 'Add new' : 'Create your first habit'}
35 |
36 |
37 | );
38 | const orderIndex = { order: index };
39 | return (
40 |
44 |
45 |
46 |
47 | {!retired && ( )}
55 |
56 | );
57 | });
58 |
59 | const HabitGridItemHeader = ({ name, icon }) => {
60 | return (
61 |
62 | {icon}
63 | {name}
64 |
65 | );
66 | }
67 |
68 | const HabitGridItemBody = ({ label, complex, manageHabit, deleteHabit }) => {
69 | const [pre, post] = [label?.split('{')[0], label?.split('}')[1]];
70 | const unit = getUnitFromLabel(label);
71 | return (
72 |
73 | {complex
74 | ?
{pre} __ {unit} {post}
75 | :
{label}
76 | }
77 |
78 |
79 |
80 |
81 |
82 | );
83 | }
84 |
85 | const MakeDraggable = (props) => {
86 | const { habitItems, habitItemOrder } = props;
87 | const hotspotsRef = useRef([]);
88 | useEffect(() => {
89 | const lastHabitItemId = habitItemOrder[habitItemOrder.length - 1]; // lastElement depends on habitItemOrder!!!!!!
90 | const lastElement = habitItems[lastHabitItemId];
91 | const objectToMap = {
92 | ...habitItems,
93 | last_item: lastElement // to add the hotspot after the last item
94 | }
95 | const generateHotspots = ([id, element]) => {
96 | const hotspotDetails = (element) => {
97 | if (!element) return null;
98 | let { top, left, right, bottom } = element.getBoundingClientRect();
99 | const width = 100;
100 | const height = bottom - top;
101 | left = (id === 'last_item') ? right - (width * 0.5) : left - (width * 0.5);
102 | return {
103 | id, top, left, width, height
104 | }
105 | }
106 | return hotspotDetails(element);
107 | }
108 | hotspotsRef.current = Object.entries(objectToMap).map(generateHotspots).filter(el => el);
109 | }, [habitItems, habitItemOrder])
110 | return (
111 |
115 | );
116 | }
117 |
118 | export const NewHabitGridItem = ({ user, habits }) => {
119 | return (
120 |
125 | );
126 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🍊 habitat
2 |
3 | A simple browser app for tracking habits! ✨
4 |
5 | Built with:
6 | - [Next.js](https://nextjs.org/)
7 | - GraphQL using [Apollo Server](https://github.com/apollographql/apollo-server)
8 | - [Prisma](http://prisma.io/) ORM for Postgres
9 | - [Chart.js](https://www.chartjs.org/)
10 | - [next-iron-session](https://github.com/vvo/next-iron-session) for authorization
11 |
12 |
13 |
14 | ## how to use
15 |
16 | Habitat is live at [https://habi.vercel.app/](https://habi.vercel.app/?demo)! Registration is currently closed as I don't want to inadvertently become responsible for a bunch of strangers' habit data, but you can use the username **demo** and password **habitat** to log in with a special account I set up for people interested in demoing the app.
17 |
18 | If you like Habitat and you actually want to use it, feel free to [contact me](mailto:contact@ngw.dev).
19 |
20 | ## overview
21 |
22 | ### general functionality
23 |
24 | - Authenticate users using `next-iron-session`, which creates a signed and encrypted cookie to store session data
25 |
26 | - Create/read/update/delete habit data
27 | * Display habit data for a given calendar period (month and year) on either a timeline, calendar, or line graph
28 | * Add, edit, or delete habit data for a given day
29 | - Create/read/update/delete habits
30 | * Display habits either as a list or on a grid
31 | * Configure habit display information: name, label, icon, and color
32 | * Configure habit type: complex/scalar ("I practiced music for this many hours today") vs. simple ("I practiced music today"/"I didn't practice music today"), active vs. inactive/retired (retired habits will no longer show up when creating/editing records, but existing data for those habits will be preserved)
33 | - Allow users logged on to the [demo account](#how-to-use) to generate a set of sample habits and at least a month's worth of randomized habit data to play around with. I have this set up so that any data (sample or otherwise) created by a demo user is bound to a unique string or `demoTokenId` stored in that user's browser session, so that multiple users can be logged on to the same demo account simultaneously without interfering with anyone else's experience of the app.
34 | - Allow users to set preferences for how the app should behave
35 | * Set a default view type for habit data (timeline, calendar, or line graph) and for habits (list or grid)
36 | * Toggle view options for the clock in the top right corner of the app: display or hide clock, 12- or 24-hour clock, show or hide clock seconds
37 | - Edit account details (name, email address, and password)
38 |
39 | ### api breakdown
40 |
41 | - Apollo Server for GraphQL (in [/pages/api/server](/pages/api/server))
42 | * [index.js](/pages/api/server/index.js): Creates an instance of `ApolloServer`
43 | * [schemas/index.js](/pages/api/server/schemas/index.js): Defines my GraphQL schema
44 | * [resolvers/index.js](/pages/api/server/resolvers/index.js): Defines my resolvers, which tell Apollo Server how to fetch data for each type defined in the schema
45 | - Prisma ORM for Postgres
46 | * [/prisma/schema.prisma](/prisma/schema.prisma): Prisma schema file, the main configuration file for my Prisma setup
47 | * [/lib/prisma.js](/lib/prisma.js): Creates an instance of `PrismaClient`, which is used by my [resolvers](/pages/api/server/resolvers/index.js) to send database queries
48 |
49 | - All non-auth-related API routes are restricted to one file, [/pages/api/index.js](/pages/api/index.js), whose exports are objects that correspond to resource types - User, Habit, and Entry. Each of these objects comes with a set of CRUD functions that use the `handleQuery` fetch wrapper (see [/pages/api/handleRequest.js](/pages/api/handleRequest.js)) to call the GraphQL server with a pre-defined mutation/query string and optional query variables.
50 |
51 | - Auth-related API logic is located inside the [/pages/api/auth](/pages/api/auth) directory:
52 | - [index.js](/pages/api/auth/index.js): Checks if there is user data stored in session. The data returned from this function, fetched at request time via `getServerSideProps`, is used on all protected pages to redirect to the login page if no valid session is found.
53 | - [getSession.js](/pages/api/auth/getSession.js): Gets the session data. The only time this is used is when retrieving the `demoTokenId` unique to each demo account session (this is explained in more detail above, under [general functionality](#general-functionality)).
54 | - [login.js](/pages/api/auth/login.js): Uses `next-iron-session` to create a signed and encrypted cookie to store session data
55 | - [logout.js](/pages/api/auth/logout.js): Destroys the session
56 |
57 | ## notes to self
58 |
59 | - [ ] dash panel idea - 'help'/tutorial section for dashboard and habit pages, togglable in settings
60 | - [ ] character limit on habit names
61 | - [ ] figure out switching from simple -> complex habits - should maybe prompt the user for an amount to put in for entries that record that habit as completed?
--------------------------------------------------------------------------------
/components/MyHabits/HabitList.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useRef, useState } from "react";
2 |
3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4 | import { faAngleDoubleRight } from "@fortawesome/free-solid-svg-icons";
5 |
6 | import styles from "./myHabits.module.css";
7 | import { ModalContext } from "../../contexts";
8 | import { useRefName } from "../../hooks";
9 | import { Grip, HabitForm, HabitIcon } from ".";
10 |
11 | export const HabitListItem = React.forwardRef(({ addingNew, user, index, habit, habitItemsStuff }, ref) => {
12 | const { id, name, icon, color, label, complex, retired } = habit ?? {};
13 | const { habitItems, habitItemOrder, updateHabitItemOrder } = habitItemsStuff ?? {};
14 | const [expanded, setExpanded] = useState(false);
15 | const [dragging, setDragging] = useState(false);
16 | const orderIndex = { order: index };
17 | return (
18 |
22 | setExpanded(state => !state)
27 | }} />
28 | {(!addingNew && !retired) && ( )}
36 |
49 |
50 | );
51 | });
52 |
53 | const MakeDraggable = (props) => {
54 | const { habitItems, habitItemOrder } = props;
55 | const hotspotsRef = useRef([]);
56 | useEffect(() => {
57 | const lastHabitItemId = habitItemOrder[habitItemOrder.length - 1]; // lastElement depends on habitItemOrder!!!!!!
58 | const lastElement = habitItems[lastHabitItemId];
59 | const objectToMap = {
60 | ...habitItems,
61 | last_item: lastElement // to add the hotspot after the last item
62 | }
63 | const generateHotspots = ([id, element]) => {
64 | const hotspotDetails = () => {
65 | if (!element) return null;
66 | let { top, bottom, left, right } = element.getBoundingClientRect();
67 | const width = right - left;
68 | const height = 24;
69 | top = (id === 'last_item') ? bottom : top - height;
70 | return {
71 | id, top, left, width, height
72 | }
73 | }
74 | return hotspotDetails();
75 | }
76 | hotspotsRef.current = Object.entries(objectToMap).map(generateHotspots).filter(el => el);
77 | }, [habitItems, habitItemOrder]);
78 | return (
79 |
83 | );
84 | }
85 |
86 | const HabitListItemHeader = ({ name, icon, retired, toggleExpanded }) => {
87 | return (
88 |
89 | {icon}
90 |
{name}
91 |
92 | );
93 | }
94 |
95 | const HabitListItemBody = ({ addingNew, user, id, name, icon, color, label, complex, retired, expanded, updateExpanded }) => {
96 | const habitBodyRef = useRef(null);
97 | useEffect(() => {
98 | const habitBody = useRefName(habitBodyRef);
99 | if (!habitBody) return;
100 | if (expanded) {
101 | habitBody.style.maxHeight = habitBody.scrollHeight + 'px';
102 | } else {
103 | habitBody.style.maxHeight = '0';
104 | }
105 | }, [expanded]);
106 | return (
107 |
108 |
109 | updateExpanded(false),
120 | resetFormAfter: !!addingNew
121 | }} />
122 | {addingNew || }
123 |
124 |
125 | );
126 | }
127 |
128 | export const NewHabitListItem = ({ habits, user }) => {
129 | return (
130 |
138 | );
139 | }
140 |
141 | const DeleteHabit = ({ id, name }) => {
142 | const { createModal } = useContext(ModalContext);
143 | const confirmDeleteHabit = () => {
144 | const habit = { id, name };
145 | createModal('deleteHabit', { habit });
146 | }
147 | return (
148 |
149 |
150 |
151 | delete this habit
152 |
153 |
154 | );
155 | }
--------------------------------------------------------------------------------
/components/MyData/index.js:
--------------------------------------------------------------------------------
1 | import { useContext, useState } from "react";
2 |
3 | import dayjs from "dayjs";
4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5 | import { faChartLine, faListOl, faTh } from "@fortawesome/free-solid-svg-icons";
6 |
7 | import styles from "./myData.module.css";
8 | import { DataContext, MobileContext, ModalContext } from "../../contexts";
9 | import Timeline from "./Timeline";
10 | import Calendar from "./Calendar";
11 | import Graph from "./Graph";
12 | import ArrowNav from "../ArrowNav";
13 | import ViewOptions from "../ViewOptions";
14 | import { Button, Submit } from "../Form";
15 | import { User } from "../../pages/api";
16 |
17 | const MyData = ({ user, habits, entries, calendarPeriod, updateCalendarPeriod, updateDashPanel }) => {
18 | const [dataView, setDataView] = useState(user.settings?.dashboard__defaultView ?? 'list');
19 | return (
20 |
21 |
27 |
35 |
36 | );
37 | }
38 |
39 | const NoData = ({ user, calendarPeriod }) => {
40 | const { demoTokenId } = useContext(DataContext);
41 | return (
42 |
43 | you haven't added any data for this period
44 | {demoTokenId && }
45 |
46 | );
47 | }
48 |
49 | const MyDataHeader = ({ calendarPeriod, updateCalendarPeriod, dataView, updateDataView }) => {
50 | const isMobile = useContext(MobileContext);
51 | const currentPeriod = dayjs().format('YYYY-MM');
52 | const nav = (direction) => () => {
53 | const newPeriod = direction === 'next'
54 | ? (period) => dayjs(period).add(1, 'month').format('YYYY-MM')
55 | : (period) => dayjs(period).subtract(1, 'month').format('YYYY-MM');
56 | updateCalendarPeriod(newPeriod);
57 | }
58 | return (
59 |
60 |
61 |
62 | {(calendarPeriod !== currentPeriod) && (!isMobile) &&
63 |
updateCalendarPeriod(currentPeriod)}>
67 | jump to current month
68 | }
69 |
70 |
71 | {dayjs(calendarPeriod).format('MMMM YYYY')}
72 |
73 | updateDataView('list')}>
76 |
77 | list
78 |
79 | updateDataView('grid')}>
82 |
83 | grid
84 |
85 | updateDataView('graph')}>
88 |
89 | graph
90 |
91 |
92 |
93 |
94 | );
95 | }
96 |
97 | const MyDataContent = ({ user, habits, entries, calendarPeriod, updateDashPanel, dataView }) => {
98 | if (!entries.length) return ;
99 | const viewShouldInherit = { habits, entries, calendarPeriod, updateDashPanel };
100 | return (
101 |
102 | {(dataView === 'list') && }
103 | {(dataView === 'grid') && }
104 | {(dataView === 'graph') && }
105 |
106 | )
107 | }
108 |
109 | const GenerateDemoData = ({ user, calendarPeriod }) => {
110 | const { demoTokenId, demoGenOption, habits, getHabits, getEntries } = useContext(DataContext);
111 | const isMobile = useContext(MobileContext);
112 | const { createModal } = useContext(ModalContext);
113 | const [successPending, setSuccessPending] = useState(false);
114 | const generateData = () => {
115 | setSuccessPending(true);
116 | return User.generateDemoData({
117 | id: user.id,
118 | demoTokenId,
119 | calendarPeriod,
120 | alsoHabits: !habits?.length
121 | }).then(getHabits).then(getEntries);
122 | }
123 | const explainDemoData = () => {
124 | createModal('generateDemoData', { generateData })
125 | }
126 | const buttonClassName = `mt15 ${isMobile ? 'compact' : ''}`;
127 | if (!demoGenOption) return null;
128 | if (habits.length) return (
129 |
136 | );
137 | return (
138 |
139 |
140 | generate sample data
141 |
142 |
143 | );
144 | }
145 |
146 | export default MyData;
--------------------------------------------------------------------------------
/hooks/useForm.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useWarnError } from ".";
3 |
4 | export const useFormData = (initialState = {}) => {
5 | const [formData, setFormData] = useState(initialState);
6 | const updateFormData = (e) => setFormData(prevState => ({
7 | ...prevState,
8 | [e.target.name]: e.target.value
9 | }));
10 | const updateFormDataCheckbox = (e) => setFormData(prevState => ({
11 | ...prevState,
12 | [e.target.name]: e.target.checked
13 | }));
14 | const updateFormDataDropdown = (key, value) => setFormData(prevState => ({
15 | ...prevState,
16 | [key]: value
17 | }));
18 | const resetForm = () => setFormData(initialState);
19 | return {
20 | formData,
21 | updateFormData,
22 | updateFormDataCheckbox,
23 | updateFormDataDropdown,
24 | setFormData,
25 | resetForm
26 | }
27 | }
28 |
29 | export const useFormError = (initialState = {}) => {
30 | const [formError, setFormError] = useState(initialState);
31 | const parseFormError = (errorReport) => {
32 | /* sample errorReport: [
33 | { location: 'email', message: 'already in use' },
34 | { location: 'password', message: 'must be at least 6 chars' }
35 | ] */
36 | if (!Array.isArray(errorReport)) return console.error(errorReport); // todo better
37 | const errors = errorReport.reduce((obj, error) => {
38 | obj[error.location] = error.message;
39 | return obj;
40 | }, {});
41 | /* errors: {
42 | email: 'already in use',
43 | password: 'must be at least 6 chars'
44 | } */
45 | return errors;
46 | }
47 | const updateFormError = (errorReport) => {
48 | const errors = parseFormError(errorReport);
49 | // now spread error object into formError so that errorAlert on each
50 | // input field can look at formError[inputName] and see if there's an error there
51 | setFormError(errors);
52 | }
53 | const resetFormError = (e) => {
54 | if (!formError?.[e.target.name]) return;
55 | setFormError(prevState => {
56 | if (!prevState?.[e.target.name]) return prevState;
57 | const newState = {...prevState};
58 | delete newState[e.target.name];
59 | return newState;
60 | });
61 | }
62 | const errorAlert = (inputName) => {
63 | if (formError?.[inputName]) return {
64 | type: 'error',
65 | message: formError[inputName]
66 | }
67 | }
68 | return {
69 | updateFormError,
70 | parseFormError,
71 | resetFormError,
72 | errorAlert
73 | }
74 | }
75 |
76 | export const useForm = (initialFormData = {}) => {
77 | const { formData, updateFormData, updateFormDataCheckbox, updateFormDataDropdown, setFormData, resetForm } = useFormData(initialFormData);
78 | const { updateFormError, parseFormError, resetFormError, errorAlert } = useFormError({});
79 | const inputProps = {
80 | onChange: updateFormData,
81 | onInput: resetFormError,
82 | alert: errorAlert
83 | }
84 | const checkboxProps = {
85 | onChange: updateFormDataCheckbox
86 | }
87 | const dropdownProps = {
88 | onChange: updateFormDataDropdown
89 | }
90 | return {
91 | formData,
92 | setFormData,
93 | resetForm,
94 | handleFormError: updateFormError,
95 | parseFormError,
96 | inputProps,
97 | checkboxProps,
98 | dropdownProps
99 | }
100 | }
101 |
102 | export const useFormSubmit = ({ onSubmit, onSuccess, handleFormError, behavior }) => {
103 | const warnError = useWarnError();
104 | const defaultBehavior = {
105 | showLoading: true,
106 | showSuccess: true,
107 | checkmarkStick: true
108 | }
109 | const { showLoading, showSuccess, checkmarkStick } = Object.assign(defaultBehavior, behavior);
110 | const [clickedOnceAlready, setClickedOnceAlready] = useState(false);
111 | const [successPending, setSuccessPending] = useState(false);
112 | const [successAnimation, setSuccessAnimation] = useState(null);
113 | useEffect(() => {
114 | if (successAnimation === 'fade') {
115 | setTimeout(() => {
116 | setSuccessAnimation(null);
117 | }, 500); /* length of checkmark-shrink duration */
118 | }
119 | }, [successAnimation]);
120 | const handleResult = (result) => {
121 | const validateResult = (result) => {
122 | if (!result) throw new Error('no response from server');
123 | if (Object.values(result)[0] == null) throw new Error('response object was null, indicates malformed query or something?')
124 | }
125 | validateResult(result);
126 | if (showSuccess) {
127 | setSuccessAnimation('check');
128 | setSuccessPending(false);
129 | }
130 | handleSuccess(result);
131 | }
132 | const handleSuccess = (result) => {
133 | setTimeout(() => {
134 | onSuccess(result);
135 | if (showSuccess && !checkmarkStick) {
136 | setSuccessAnimation('fade');
137 | setClickedOnceAlready(false);
138 | }
139 | }, showSuccess ? 1400 : 0);
140 | }
141 | const handleError = (err) => {
142 | setSuccessPending(false);
143 | setClickedOnceAlready(false);
144 | if (err === 'handled') return;
145 | const { __typename, errors } = err;
146 | if (__typename === 'FormErrorReport') {
147 | if (handleFormError) handleFormError(errors);
148 | else warnError('unhandledFormError', errors);
149 | return;
150 | }
151 | if (__typename === 'NiceTry') {
152 | return warnError('niceTry');
153 | }
154 | warnError('somethingWentWrong', err);
155 | }
156 | const handleSubmit = (e) => {
157 | e.preventDefault();
158 | if (clickedOnceAlready) return;
159 | setClickedOnceAlready(true);
160 | if (!onSubmit) return console.log('missing onSubmit handler!');
161 | if (showLoading) setSuccessPending(true);
162 | onSubmit().then(handleResult).catch(handleError);
163 | }
164 | return {
165 | handleSubmit,
166 | successPending,
167 | successAnimation
168 | }
169 | }
--------------------------------------------------------------------------------
/styles/global.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=Inconsolata:wght@100;200;300;400;500;600;700;800;900&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Work+Sans:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap');
2 |
3 | @keyframes formErrorDisappear {
4 | from {
5 | opacity: 1;
6 | }
7 | to {
8 | opacity: 0;
9 | }
10 | }
11 | @keyframes check {
12 | 100% {
13 | stroke-dashoffset: 0;
14 | }
15 | }
16 | @keyframes checkmark-shrink {
17 | 100% {
18 | opacity: 0;
19 | transform: translate3d(0, 0, 0) rotate(45deg) scale(0);
20 | }
21 | }
22 | @keyframes dashPanelAnimation {
23 | from {
24 | opacity: 0;
25 | }
26 | to {
27 | opacity: 1;
28 | }
29 | }
30 |
31 | :root {
32 | --header-font: 'Dela Gothic One', sans-serif;
33 | --default-font: 'Work Sans', 'Inter', sans-serif;
34 | --mono-font: 'Inconsolata', monospace;
35 | --link-color: #000;
36 | --link-bg: #000;
37 | --caution: #df436a;
38 | --button-cancel-color: #767578;
39 | --loading-icon-color: #9FF7E7;
40 | --dashboard-nav-width: 7rem;
41 | --dashpanel-sidebar-width: 250px;
42 | }
43 |
44 | * {
45 | box-sizing: border-box;
46 | outline: none;
47 | -webkit-tap-highlight-color: transparent;
48 | }
49 |
50 | html, body, #__next {
51 | width: 100%;
52 | height: 100%;
53 | margin: 0;
54 | }
55 |
56 | #__next > div {
57 | width: 100%;
58 | height: 100%;
59 | }
60 |
61 | body {
62 | font-family: var(--default-font), 'Helvetica Neue', sans-serif;
63 | line-height: 1.4;
64 | -webkit-font-smoothing: antialiased;
65 | -moz-osx-font-smoothing: grayscale;
66 | }
67 | body.dragging {
68 | cursor: grabbing;
69 | }
70 |
71 | p, h1, h2, h3, h4, h5, h6 {
72 | margin-top: 0;
73 | }
74 | h1, h2, h3, h4, h5, h6 {
75 | line-height: 1;
76 | }
77 | h1 {
78 | font-family: var(--header-font);
79 | font-weight: normal;
80 | }
81 | h2 {
82 | font-size: 2rem;
83 | margin-bottom: 1rem;
84 | }
85 | center {
86 | display: block;
87 | text-align: center;
88 | }
89 | code {
90 | font-family: var(--mono-font);
91 | font-size: 0.8rem;
92 | line-height: 1rem;
93 | padding: 0 0.2rem;
94 | letter-spacing: -0.02rem;
95 | }
96 |
97 | @media screen and (max-width: 900px) {
98 | h2 {
99 | font-size: 1.5rem;
100 | }
101 | }
102 |
103 | a, a:visited, button.link {
104 | cursor: pointer;
105 | font-family: var(--mono-font);
106 | font-size: inherit;
107 | line-height: inherit;
108 | color: var(--link-color);
109 | text-decoration: none;
110 | position: relative;
111 | z-index: 2;
112 | display: inline-block;
113 | transition: 0.2s;
114 | }
115 | a::before, button.link::after {
116 | content: '';
117 | position: absolute;
118 | z-index: -1;
119 | bottom: 0;
120 | left: 0;
121 | width: calc(100% + 1em);
122 | max-width: 100%;
123 | height: 100%;
124 | max-height: 0px;
125 | border: 1px solid #000;
126 | border-width: 0 0 1px;
127 | transition: 0.2s;
128 | }
129 | a:hover::before, button.link:hover::after {
130 | border-width: 1px;
131 | max-height: 100%;
132 | max-width: calc(100% + 1em);
133 | transform: skewX(-12deg) translate3d(-0.5em, 1px, 0);
134 | }
135 |
136 | button {
137 | background: 0;
138 | border: 0;
139 | padding: 0;
140 | margin: 0;
141 | cursor: pointer;
142 | }
143 | button, input {
144 | font-family: var(--mono-font);
145 | outline: none;
146 | }
147 | button.cancel::before, button.cancel::after {
148 | border-color: var(--button-cancel-color);
149 | }
150 | button.cancel::before {
151 | background: var(--button-cancel-color);
152 | }
153 | button span[data-ghost=true] {
154 | visibility: hidden;
155 | }
156 | button span[data-ghost=true] + * {
157 | display: inline-flex;
158 | align-items: center;
159 | justify-content: center;
160 | position: absolute;
161 | top: 50%;
162 | left: 50%;
163 | width: auto;
164 | height: 100%;
165 | transform: translate3d(-45%, -50%, 0) scale(0.5);
166 | }
167 | .SuccessAnimation svg {
168 | fill: none;
169 | stroke: #fff;
170 | stroke-width: 1.5px;
171 | stroke-dasharray: 2rem;
172 | stroke-dashoffset: 2rem;
173 | height: 100%;
174 | transform-origin: center;
175 | transform: translate3d(-10%, -30%, 0) rotate(45deg) scale(1);
176 | }
177 | .SuccessAnimation.check svg {
178 | animation: 0.7s check 1 forwards;
179 | }
180 | .SuccessAnimation.fade svg {
181 | stroke-dashoffset: 0;
182 | animation: 0.5s checkmark-shrink 1 forwards;
183 | }
184 |
185 | form .formGrid {
186 | display: flex;
187 | }
188 | form div + .formGrid {
189 | margin-top: 1rem;
190 | }
191 |
192 | div.sbs {
193 | display: flex;
194 | }
195 |
196 | label {
197 | font-family: var(--mono-font);
198 | font-size: 0.8rem;
199 | text-transform: uppercase;
200 | display: block;
201 | margin-bottom: 0.3rem;
202 | }
203 | input {
204 | background: transparent;
205 | border: 1px solid #000;
206 | line-height: 2.5;
207 | font-size: 1rem;
208 | padding: 0 0.7rem;
209 | transition: 0.2s;
210 | }
211 | input:focus {
212 | box-shadow: inset 3px 0 0 #000;
213 | }
214 | input:disabled, button:disabled {
215 | opacity: 0.4;
216 | cursor: not-allowed;
217 | user-select: none;
218 | }
219 | input[type=date] {
220 | cursor: pointer;
221 | user-select: none;
222 | }
223 |
224 | .bb {
225 | font-size: 0.8rem;
226 | }
227 | .mt05 {
228 | margin-top: 0.5rem !important;
229 | }
230 | .mt10 {
231 | margin-top: 1rem !important;
232 | }
233 | .mb10 {
234 | margin-bottom: 1rem !important;
235 | }
236 | .mb20 {
237 | margin-bottom: 2rem !important;
238 | }
239 | .mt15 {
240 | margin-top: 1.5rem !important;
241 | }
242 | .mt25 {
243 | margin-top: 2.5rem !important;
244 | }
245 | .tar {
246 | text-align: right;
247 | }
248 | .jcfs {
249 | justify-content: flex-start !important;
250 | }
251 | .jcc {
252 | justify-content: center;
253 | }
254 | .aic {
255 | align-items: center;
256 | }
257 |
258 | #nprogress {
259 | pointer-events: none;
260 | }
261 | #nprogress .spinner {
262 | display: block;
263 | position: fixed;
264 | z-index: 1346;
265 | bottom: 3rem;
266 | right: 3rem;
267 | }
268 | #nprogress .spinner-icon {
269 | width: 3rem;
270 | height: 3rem;
271 | border: solid 0.125rem transparent;
272 | border-top-color: #000;
273 | border-left-color: #000;
274 | border-radius: 999px;
275 | animation: nprogress-spinner 0.4s linear infinite;
276 | }
277 | .nprogress-custom-parent {
278 | overflow: hidden;
279 | position: relative;
280 | }
281 | .nprogress-custom-parent #nprogress .spinner,
282 | .nprogress-custom-parent #nprogress .bar {
283 | position: absolute;
284 | }
285 |
286 | details.errorReport {
287 | max-width: 100%;
288 | margin-bottom: 1.5rem;
289 | }
290 | details.errorReport summary {
291 | font-weight: 400;
292 | text-transform: uppercase;
293 | font-size: 0.8rem;
294 | color: #333;
295 | outline: none;
296 | }
297 | details.errorReport .errorDetails {
298 | white-space: pre-line;
299 | word-wrap: break-word;
300 | display: block;
301 | padding: 0.5rem;
302 | margin-top: 0.5rem;
303 | border: 1px solid #ddd;
304 | max-height: 6rem;
305 | overflow: auto;
306 | }
307 | details.errorReport .errorDetails code {
308 | line-height: 1rem;
309 | display: block;
310 | }
311 |
312 | /* habit clone for rearranging habits */
313 | *[class$=-clone] {
314 | position: fixed;
315 | z-index: 9999;
316 | top: 0;
317 | left: 0;
318 | line-height: 2.5;
319 | padding: 0 1rem;
320 | display: flex;
321 | align-items: center;
322 | justify-content: center;
323 | }
324 | *[class$=-clone]::before {
325 | content: '';
326 | position: absolute;
327 | z-index: -1;
328 | width: 100%;
329 | height: 100%;
330 | background: #fff;
331 | transform: skew(-12deg);
332 | box-shadow: 0.2rem 0.2rem 0.5rem rgba(0, 0, 0, 0.1);
333 | }
334 |
335 | @media screen and (max-width: 900px) {
336 | #nprogress .spinner {
337 | bottom: 1rem;
338 | right: unset;
339 | left: 1rem;
340 | }
341 | #nprogress .spinner-icon {
342 | width: 1.5rem;
343 | height: 1.5rem;
344 | border-width: 0.0625rem;
345 | }
346 | }
347 |
348 | @keyframes nprogress-spinner {
349 | 0% {
350 | transform: rotate(0deg);
351 | }
352 | 100% {
353 | transform: rotate(360deg);
354 | }
355 | }
--------------------------------------------------------------------------------
/components/MyHabits/myHabits.module.css:
--------------------------------------------------------------------------------
1 | .MyHabitsNav {
2 | position: relative;
3 | display: inline-block;
4 | width: 100%;
5 | }
6 | .HabitViewOptions {
7 | margin-bottom: 2rem;
8 | }
9 | div button.currentHabitView { /* including div for specificity */
10 | opacity: 1;
11 | }
12 |
13 | .retired, .dragging > * {
14 | opacity: 0.5;
15 | transition: 0.2s;
16 | }
17 | .retired:hover {
18 | opacity: 1;
19 | }
20 |
21 | .HabitsContainer h2 {
22 | grid-column: 1 / -1;
23 | }
24 |
25 | .HabitList {
26 | display: inline-flex;
27 | flex-direction: column;
28 | width: 350px;
29 | max-width: 100%;
30 | }
31 | .HabitList h2 {
32 | margin-bottom: 1.5rem;
33 | }
34 | .HabitList + .HabitList {
35 | display: flex;
36 | margin-top: 2rem;
37 | }
38 | .HabitListItem:not(:last-of-type) {
39 | position: relative;
40 | margin-bottom: 1.4rem;
41 | }
42 | .HabitListItemHeader {
43 | position: relative;
44 | cursor: pointer;
45 | display: flex;
46 | align-items: center;
47 | justify-content: flex-start;
48 | }
49 | .HabitListItemHeader h3 {
50 | font-family: var(--mono-font);
51 | font-size: 1.2rem;
52 | font-weight: 400;
53 | letter-spacing: -0.5px;
54 | margin-left: 1rem;
55 | margin-bottom: 0;
56 | }
57 | .HabitIcon {
58 | position: relative;
59 | z-index: 9;
60 | width: 2rem;
61 | height: 2rem;
62 | display: flex;
63 | align-items: center;
64 | justify-content: center;
65 | font-size: 2.5rem;
66 | }
67 | .HabitListItem .HabitIcon::after {
68 | content: '+';
69 | color: #000;
70 | font-size: 0.8rem;
71 | line-height: 0.75rem;
72 | position: absolute;
73 | bottom: 0;
74 | left: 75%;
75 | width: 0.75rem;
76 | height: 0.75rem;
77 | border-radius: 999px;
78 | display: flex;
79 | align-items: center;
80 | justify-content: center;
81 | background: #fff;
82 | box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.5);
83 | }
84 | .HabitListItem.expanded .HabitIcon::after {
85 | content: '-'
86 | }
87 | .HabitListItemBody {
88 | font-size: 0.8rem;
89 | margin-left: 0.5rem;
90 | width: calc(100% - 0.5rem);
91 | border-left: 1px solid #000;
92 | background: #fff;
93 | max-height: 0;
94 | overflow: hidden;
95 | transform-origin: top left;
96 | opacity: 0;
97 | transform: scaleX(0);
98 | transition: max-height 0.2s, opacity 0.2s, transform 0.2s, border-radius 0.5s;
99 | margin-top: 0.5rem;
100 | border-radius: 0 0 4rem 0;
101 | }
102 | .HabitListItem.expanded .HabitListItemBody {
103 | opacity: 1;
104 | transform: scaleX(1);
105 | border-radius: 0;
106 | }
107 | .HabitListItemBody > div {
108 | padding: 1rem 1rem 1.5rem;
109 | }
110 | .HabitListItemBody label {
111 | font-size: 0.8rem;
112 | }
113 | .HabitListItemBody input {
114 | border: 0;
115 | background: #f7f7f7;
116 | font-size: 0.9rem;
117 | }
118 | .HabitListItem .grip {
119 | top: 0;
120 | right: 0;
121 | height: 2rem;
122 | align-items: center;
123 | }
124 | .HabitListItem[data-hotspot=true]::before, .HabitListItem[data-hotspot=after]::before {
125 | content: '';
126 | position: absolute;
127 | bottom: calc(100% + 0.875rem);
128 | left: 0;
129 | margin-bottom: -1px;
130 | width: 100%;
131 | height: 2px;
132 | background: #000;
133 | }
134 | .HabitListItem[data-hotspot=after]::before {
135 | bottom: unset;
136 | top: calc(100% + 0.5rem);
137 | }
138 |
139 | .HabitGrid {
140 | display: flex;
141 | flex-flow: row wrap;
142 | margin: 1rem -1.5rem 0 0;
143 | }
144 | .HabitGrid h2 {
145 | grid-column: 1 / -1;
146 | flex: 1 0 100%;
147 | margin: 2rem 0;
148 | }
149 | .HabitGridItem {
150 | flex: 1 0 0;
151 | display: flex;
152 | flex-direction: column;
153 | position: relative;
154 | margin: 0 1.5rem 1.5rem 0;
155 | padding: 1rem;
156 | background: #fff;
157 | }
158 | .HabitGridColorIndicator {
159 | position: absolute;
160 | display: block;
161 | top: 0;
162 | left: 0;
163 | width: 100%;
164 | height: 0.5rem;
165 | margin-bottom: 0.5rem;
166 | opacity: 0.5;
167 | }
168 | .HabitGridItemHeader .HabitIcon {
169 | margin-top: -2rem;
170 | }
171 | .HabitGridItemHeader .HabitIcon + span {
172 | display: block;
173 | margin: 0.75rem 0 0.5rem;
174 | font-weight: 600;
175 | font-size: 1.2rem;
176 | padding-right: 1rem;
177 | }
178 | .HabitGridItemBody {
179 | flex: 1 0 auto;
180 | display: flex;
181 | flex-direction: column;
182 | justify-content: space-between;
183 | }
184 | .HabitGridItemBody > span {
185 | font-family: var(--mono-font);
186 | font-size: 0.8rem;
187 | margin-bottom: 0.75rem;
188 | }
189 | .HabitGridItemBody > div {
190 | display: flex;
191 | align-items: center;
192 | justify-content: flex-start;
193 | }
194 | .HabitGridItemBody > div button {
195 | opacity: 0.3;
196 | }
197 | .HabitGridItemBody > div button + button {
198 | margin-left: 0.5rem;
199 | }
200 | .NewHabitGridItem {
201 | grid-column: 1 / -1;
202 | flex: 1 0 100%;
203 | }
204 | .NewHabitGridItem > button {
205 | display: flex;
206 | align-items: center;
207 | }
208 | .NewHabitGridItem > button > div {
209 | width: 2rem;
210 | height: 2rem;
211 | background: #000;
212 | color: #fff;
213 | border-radius: 999px;
214 | font-size: 1.2rem;
215 | display: flex;
216 | align-items: center;
217 | justify-content: center;
218 | }
219 | .NewHabitGridItem > button > span {
220 | display: block;
221 | margin-left: 0.75rem;
222 | font-size: 1rem;
223 | position: relative;
224 | }
225 | .NewHabitGridItem > button > span::before {
226 | content: '';
227 | display: block;
228 | z-index: -1;
229 | position: absolute;
230 | top: 50%;
231 | left: -0.5rem;
232 | width: calc(100% + 1rem);
233 | height: calc(100% + 0.5rem);
234 | background: #fff;
235 | transform: skew(-12deg) translate3d(0, -50%, 0);
236 | }
237 | .HabitGridItem .grip {
238 | top: 1rem;
239 | right: 0.5rem;
240 | align-items: flex-end;
241 | }
242 | .HabitGridItem[data-hotspot=true]::before, .HabitGridItem[data-hotspot=after]::before {
243 | content: '';
244 | position: absolute;
245 | top: 0;
246 | right: calc(100% + 0.75rem); /* half of 1.5rem margin between grid items */
247 | margin-right: -1px;
248 | width: 2px;
249 | height: 100%;
250 | background: #000;
251 | }
252 | .HabitGridItem[data-hotspot=after]::before {
253 | right: unset;
254 | left: calc(100% + 0.75rem);
255 | margin-left: -1px;
256 | }
257 |
258 | .grip {
259 | position: absolute;
260 | display: inline-flex;
261 | cursor: grab;
262 | opacity: 0.2;
263 | transition: 0.2s;
264 | touch-action: none;
265 | }
266 | .grip:hover {
267 | opacity: 0.5;
268 | }
269 | .grip:active {
270 | cursor: grabbing;
271 | }
272 |
273 | .displayOptions {
274 | margin-top: 1rem;
275 | display: flex;
276 | }
277 | .displayOptions > div:first-child {
278 | flex: 1 1 auto;
279 | }
280 | .displayOptions > div:last-child {
281 | flex: 0 1 auto;
282 | }
283 |
284 | .DeleteHabit {
285 | margin-top: 1rem;
286 | text-align: center;
287 | }
288 | .DeleteHabit button {
289 | font-size: 0.5rem;
290 | }
291 | .DeleteHabit button > span {
292 | display: inline-block;
293 | margin-left: 0.25rem;
294 | font-size: 0.75rem;
295 | }
296 |
297 | @media screen and (max-width: 900px) {
298 | .HabitIcon {
299 | width: 1.75rem;
300 | height: 1.75rem;
301 | font-size: 2rem;
302 | }
303 | .HabitList {
304 | max-width: 100%;
305 | }
306 | .HabitList + .HabitList {
307 | margin-top: 1.5rem;
308 | }
309 | .HabitListItem {
310 | margin-bottom: 1rem;
311 | }
312 | .HabitListItemHeader h3 {
313 | font-size: 1rem;
314 | }
315 | .HabitGrid {
316 | display: grid;
317 | grid-template-columns: repeat(auto-fit, 33%);
318 | margin: 0 -1rem 0 0;
319 | }
320 | .HabitGrid h2 {
321 | margin-bottom: 1.5rem;
322 | }
323 | .HabitGridItem {
324 | margin: 0 1rem 1rem 0;
325 | }
326 | .HabitGridItem[data-hotspot=true]::before {
327 | right: calc(100% + 0.5rem); /* half of 1rem margin between grid items */
328 | }
329 | .HabitGridItemHeader .HabitIcon {
330 | margin-top: -1.5rem;
331 | }
332 | .HabitGridItemHeader .HabitIcon + span {
333 | line-height: 1;
334 | }
335 | .HabitGridItemBody > span {
336 | line-height: 1.2;
337 | }
338 | .HabitGridItemBody button {
339 | font-size: 0.9rem;
340 | }
341 | }
342 |
343 | @media screen and (max-width: 600px) {
344 | .HabitGrid {
345 | grid-template-columns: repeat(auto-fit, 50%);
346 | }
347 | .HabitGridItemBody button {
348 | font-size: 0.8rem;
349 | }
350 | }
--------------------------------------------------------------------------------
/components/Modal/modalStore.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState } from "react";
2 | import { useRouter } from "next/router";
3 |
4 | import { User, Entry, Habit, Token, handleRequest } from "../../pages/api";
5 | import { DataContext } from "../../contexts";
6 | import { useForm } from "../../hooks";
7 | import Form, { Submit, Button, Input } from "../Form";
8 | import ModalForm, { ModalizedForm } from "./ModalForm";
9 |
10 | export const modalStore = {
11 | 'demoMessage': (props) => ,
12 | 'generateDemoData': (props) => ,
13 | 'somethingWentWrong': (props) => ,
14 | 'unhandledFormError': (props) => ,
15 | 'niceTry': (props) => ,
16 | 'forgotPassword': (props) => ,
17 | 'warnOverwriteEntry': (props) => ,
18 | 'manageHabit': (props) => ,
19 | 'deleteHabit': (props) => ,
20 | 'deleteEntry': (props) => ,
21 | 'deleteAccount': (props) => ,
22 | }
23 |
24 | const DemoMessage = ({ closeModal }) => {
25 | const router = useRouter();
26 | const handleClick = () => {
27 | router.push('/');
28 | closeModal();
29 | }
30 | return (
31 | <>
32 | hi there!
33 | welcome to habitat! i've set up a special account for people interesting in demoing the app. sign in with the username demo & password habitat and you'll have the option to generate sample data to play around with, which will be cleared on logout or automatically after 2 hours.
34 | got it
35 | >
36 | );
37 | }
38 |
39 | const GenerateDemoData = ({ generateData, closeModal }) => {
40 | const [successPending, setSuccessPending] = useState(false);
41 | const handleClick = () => {
42 | setSuccessPending(true);
43 | generateData().then(closeModal);
44 | }
45 | return (
46 | <>
47 | what's this?
48 | for your convenience, this will generate a set of sample habits and a month's worth of randomized habit data for you to play around with. to wipe this account and start fresh, log out and then log back in with the same credentials (username demo & password habitat ). have fun!
49 |
55 | >
56 | );
57 | }
58 |
59 | const SomethingWentWrong = ({ error, closeModal }) => {
60 | const handleClick = () => {
61 | closeModal();
62 | window.location.reload();
63 | }
64 | return (
65 |
66 |
something went wrong
67 |
an unknown error occurred, please reload the page and try again
68 |
69 | View details
70 |
71 | {error?.caughtErrors ?? "nothing to see here, sorry :("}
72 |
73 |
74 |
close & reload
75 |
76 | );
77 | }
78 |
79 | const UnhandledFormError = ({ error, closeModal }) => {
80 | const { parseFormError } = useForm();
81 | const printErrors = () => {
82 | return Object.entries(parseFormError(error)).map(([key, value]) => {
83 | return {key} : {value}
84 | });
85 | }
86 | return (
87 |
88 |
oopsies
89 |
found errors on the following fields:
90 |
91 |
92 | try again
93 |
94 |
95 | );
96 | }
97 |
98 | const NiceTry = ({ closeModal }) => {
99 | return (
100 | <>
101 | nice try
102 | you think im going to let my app be defeated by some nerd who knows how to use dev tools? try again sweetie
103 | close
104 | >
105 | );
106 | }
107 |
108 | const ForgotPassword = ({ closeModal }) => {
109 | const [success, setSuccess] = useState(false);
110 | const { formData, handleFormError, inputProps } = useForm();
111 | const handleSubmit = async () => Token.createPasswordToken(formData);
112 | const handleSuccess = () => {
113 | setTimeout(() => {
114 | setSuccess(true);
115 | }, 200);
116 | }
117 | if (success) return (
118 | <>
119 | success!
120 | an email containing a link to reset your password has been sent to {formData.email} . be sure to check your spam folder if you can't find the email in your regular inbox.
121 | close
122 | >
123 | );
124 | return (
125 |
132 | );
133 | }
134 |
135 | const WarnOverwriteEntry = ({ date, handleSubmit, handleSuccess }) => {
136 | return (
137 | }>
142 | an entry for {date} already exists. if you continue, it will be overwritten.
143 |
144 | );
145 | }
146 |
147 | const ManageHabit = ({ habitForm, habitFormProps }) => {
148 | return (
149 |
153 | );
154 | }
155 |
156 | const DeleteHabit = ({ habit }) => {
157 | const { getHabits, getEntries } = useContext(DataContext);
158 | const handleSubmit = async () => Habit.delete({ id: habit.id });
159 | const handleSuccess = () => {
160 | getHabits();
161 | getEntries(); // in case any entries were left empty by deleting this habit and all its records, since those entries will have been deleted too
162 | }
163 | return (
164 | }>
169 | are you sure you want to delete the habit {habit.name} and all data associated with it? this action cannot be undone.
170 |
171 | );
172 | }
173 |
174 | const DeleteEntry = ({ entry }) => {
175 | const { getEntries } = useContext(DataContext);
176 | const handleSubmit = async () => Entry.delete({ id: entry.id });
177 | return (
178 | }>
183 | are you sure you want to delete this entry? this action cannot be undone.
184 |
185 | );
186 | }
187 |
188 | const DeleteAccount = ({ user, closeModal }) => {
189 | const router = useRouter();
190 | const [success, setSuccess] = useState(false);
191 | const handleSubmit = async () => User.deleteAccount({ id: user.id });
192 | const handleSuccess = () => setSuccess(true);
193 | const handleSuccessClick = () => {
194 | closeModal();
195 | const logout = async () => {
196 | await handleRequest('/api/auth/logout');
197 | router.push('/');
198 | }
199 | setTimeout(logout, 200);
200 | }
201 | if (success) return (
202 | <>
203 | success!
204 | all data associated with this account has been deleted. sorry to see you go!
205 | close
206 | >
207 | );
208 | return (
209 | }>
214 | are you absolutely sure you want to delete your account and all data associated with it? this action cannot be undone.
215 |
216 | );
217 | }
--------------------------------------------------------------------------------
/pages/api/index.js:
--------------------------------------------------------------------------------
1 | import { handleRequest, handleQuery } from "./handleRequest";
2 |
3 | export const User = {
4 | get: async (params) => await handleQuery(queries.getUser, params),
5 | create: async (formData) => await handleQuery(mutations.createUser, formData),
6 | edit: async (formData) => await handleQuery(mutations.editUser, formData),
7 | editPassword: async (formData) => await handleQuery(mutations.editPassword, formData),
8 | editSettings: async (formData) => await handleQuery(mutations.editSettings, formData),
9 | login: async (params) => await handleQuery(queries.loginUser, params),
10 | deleteAccount: async (data) => await handleQuery(mutations.deleteAccount, data),
11 | generateDemoData: async (id) => await handleQuery(mutations.generateDemoData, id),
12 | clearDemoData: async ({ demoTokenId }) => await handleQuery(mutations.clearDemoData, { demoTokenId }),
13 | validateSignupToken: async ({ tokenId }) => await handleQuery(mutations.validateSignupToken, { tokenId })
14 | }
15 |
16 | export const Habit = {
17 | get: async (params) => await handleQuery(queries.getHabits, params),
18 | create: async (formData) => await handleQuery(mutations.createHabit, formData),
19 | edit: async (formData) => await handleQuery(mutations.editHabit, formData),
20 | delete: async (formData) => await handleQuery(mutations.deleteHabit, formData),
21 | rearrange: async (data) => await handleQuery(mutations.rearrangeHabits, data)
22 | }
23 |
24 | export const Entry = {
25 | get: async (params) => await handleQuery(queries.getEntries, params),
26 | create: async (formData) => await handleQuery(mutations.createEntry, formData),
27 | edit: async (formData) => await handleQuery(mutations.editEntry, formData),
28 | delete: async (formData) => await handleQuery(mutations.deleteEntry, formData),
29 | }
30 |
31 | export const Token = {
32 | validate: async ({ tokenId }) => await handleQuery(mutations.validatePasswordToken, { tokenId }),
33 | createPasswordToken: async (formData) => await handleQuery(mutations.createPasswordToken, formData)
34 | }
35 |
36 | const queries = {
37 | getUser: `
38 | query($id: String, $demoTokenId: String) {
39 | user(id: $id, demoTokenId: $demoTokenId) {
40 | id
41 | name
42 | email
43 | settings {
44 | dashboard__defaultView
45 | habits__defaultView
46 | habits__newHabitIcon
47 | appearance__showClock
48 | appearance__24hrClock
49 | appearance__showClockSeconds
50 | }
51 | }
52 | }
53 | `,
54 | getHabits: `
55 | query($userId: String, $demoTokenId: String) {
56 | habits(userId: $userId, demoTokenId: $demoTokenId) {
57 | id
58 | name
59 | icon
60 | color
61 | label
62 | complex
63 | retired
64 | }
65 | }
66 | `,
67 | getEntries: `
68 | query($userId: String, $demoTokenId: String) {
69 | entries(userId: $userId, demoTokenId: $demoTokenId) {
70 | id
71 | date
72 | records {
73 | id
74 | habitId
75 | amount
76 | check
77 | }
78 | }
79 | }
80 | `,
81 | loginUser: `
82 | query($email: String, $password: String) {
83 | login(email: $email, password: $password) {
84 | ... on FormErrorReport {
85 | __typename
86 | errors {
87 | location
88 | message
89 | }
90 | }
91 | ... on User {
92 | id
93 | name
94 | email
95 | settings {
96 | dashboard__defaultView
97 | habits__defaultView
98 | habits__newHabitIcon
99 | appearance__showClock
100 | appearance__24hrClock
101 | appearance__showClockSeconds
102 | }
103 | demoTokenId
104 | }
105 | }
106 | }
107 | `
108 | }
109 |
110 | const mutations = {
111 | validateSignupToken: `
112 | mutation($tokenId: String) {
113 | validateSignupToken(tokenId: $tokenId) {
114 | ... on FormErrorReport {
115 | __typename
116 | errors {
117 | location
118 | message
119 | }
120 | }
121 | ... on Token {
122 | id
123 | }
124 | }
125 | }
126 | `,
127 | createUser: `
128 | mutation($email: String, $password: String, $token: String) {
129 | createUser(email: $email, password: $password, token: $token) {
130 | ... on FormErrorReport {
131 | __typename
132 | errors {
133 | location
134 | message
135 | }
136 | }
137 | ... on User {
138 | id
139 | name
140 | email
141 | settings {
142 | dashboard__defaultView
143 | habits__defaultView
144 | habits__newHabitIcon
145 | appearance__showClock
146 | appearance__24hrClock
147 | appearance__showClockSeconds
148 | }
149 | }
150 | }
151 | }
152 | `,
153 | editUser: `
154 | mutation($id: String, $name: String, $email: String) {
155 | editUser(id: $id, name: $name, email: $email) {
156 | ... on FormErrorReport {
157 | __typename
158 | errors {
159 | location
160 | message
161 | }
162 | }
163 | ... on User {
164 | id
165 | name
166 | email
167 | }
168 | }
169 | }
170 | `,
171 | editPassword: `
172 | mutation($id: String, $password: String, $confirmPassword: String, $reset: Boolean) {
173 | editPassword(id: $id, password: $password, confirmPassword: $confirmPassword, reset: $reset) {
174 | ... on FormErrorReport {
175 | __typename
176 | errors {
177 | location
178 | message
179 | }
180 | }
181 | ... on User {
182 | id
183 | name
184 | email
185 | }
186 | }
187 | }
188 | `,
189 | deleteAccount: `
190 | mutation($id: String) {
191 | deleteAccount(id: $id) {
192 | ... on FormErrorReport {
193 | __typename
194 | errors {
195 | location
196 | message
197 | }
198 | }
199 | ... on User {
200 | id
201 | name
202 | email
203 | }
204 | }
205 | }
206 | `,
207 | editSettings: `
208 | mutation(
209 | $userId: String,
210 | $demoTokenId: String,
211 | $dashboard__defaultView: String,
212 | $habits__defaultView: String,
213 | $habits__newHabitIcon: String,
214 | $appearance__showClock: Boolean,
215 | $appearance__24hrClock: Boolean,
216 | $appearance__showClockSeconds: Boolean
217 | ) {
218 | editSettings(
219 | userId: $userId,
220 | demoTokenId: $demoTokenId,
221 | dashboard__defaultView: $dashboard__defaultView,
222 | habits__defaultView: $habits__defaultView,
223 | habits__newHabitIcon: $habits__newHabitIcon,
224 | appearance__showClock: $appearance__showClock,
225 | appearance__24hrClock: $appearance__24hrClock,
226 | appearance__showClockSeconds: $appearance__showClockSeconds
227 | ) {
228 | dashboard__defaultView
229 | habits__defaultView
230 | habits__newHabitIcon
231 | appearance__showClock
232 | appearance__24hrClock
233 | appearance__showClockSeconds
234 | }
235 | }
236 | `,
237 | createHabit: `
238 | mutation(
239 | $name: String,
240 | $icon: String,
241 | $color: String,
242 | $label: String,
243 | $complex: Boolean,
244 | $retired: Boolean,
245 | $userId: String,
246 | $demoTokenId: String
247 | ) {
248 | createHabit(
249 | name: $name,
250 | icon: $icon,
251 | color: $color,
252 | label: $label,
253 | complex: $complex,
254 | retired: $retired,
255 | userId: $userId,
256 | demoTokenId: $demoTokenId
257 | ) {
258 | ... on FormErrorReport {
259 | __typename
260 | errors {
261 | location
262 | message
263 | }
264 | }
265 | ... on Habit {
266 | id
267 | name
268 | icon
269 | color
270 | label
271 | complex
272 | }
273 | }
274 | }
275 | `,
276 | editHabit: `
277 | mutation($id: String, $name: String, $icon: String, $color: String, $label: String, $complex: Boolean, $retired: Boolean) {
278 | editHabit(id: $id, name: $name, icon: $icon, color: $color, label: $label, complex: $complex, retired: $retired) {
279 | ... on FormErrorReport {
280 | __typename
281 | errors {
282 | location
283 | message
284 | }
285 | }
286 | ... on Habit {
287 | id
288 | name
289 | icon
290 | color
291 | label
292 | complex
293 | }
294 | }
295 | }
296 | `,
297 | deleteHabit: `
298 | mutation($id: String) {
299 | deleteHabit(id: $id) {
300 | id
301 | name
302 | icon
303 | color
304 | label
305 | complex
306 | retired
307 | }
308 | }
309 | `,
310 | rearrangeHabits: `
311 | mutation($array: [String]) {
312 | rearrangeHabits(array: $array) {
313 | success
314 | }
315 | }
316 | `,
317 | createEntry: `
318 | mutation($userId: String, $date: String, $records: [RecordInput], $demoTokenId: String) {
319 | createEntry(userId: $userId, date: $date, records: $records, demoTokenId: $demoTokenId) {
320 | ... on FormErrorReport {
321 | __typename
322 | errors {
323 | location
324 | message
325 | }
326 | }
327 | ... on Entry {
328 | id
329 | date
330 | records {
331 | habitId
332 | amount
333 | check
334 | }
335 | }
336 | }
337 | }
338 | `,
339 | editEntry: `
340 | mutation($id: String, $date: String, $records: [RecordInput], $demoTokenId: String) {
341 | editEntry(id: $id, date: $date, records: $records, demoTokenId: $demoTokenId) {
342 | ... on FormErrorReport {
343 | __typename
344 | errors {
345 | location
346 | message
347 | }
348 | }
349 | ... on Entry {
350 | id
351 | date
352 | records {
353 | habitId
354 | amount
355 | check
356 | }
357 | }
358 | }
359 | }
360 | `,
361 | deleteEntry: `
362 | mutation($id: String) {
363 | deleteEntry(id: $id) {
364 | id
365 | date
366 | }
367 | }
368 | `,
369 | generateDemoData: `
370 | mutation($id: String, $demoTokenId: String, $calendarPeriod: String, $alsoHabits: Boolean) {
371 | generateDemoData(id: $id, demoTokenId: $demoTokenId, calendarPeriod: $calendarPeriod, alsoHabits: $alsoHabits) {
372 | success
373 | }
374 | }
375 | `,
376 | clearDemoData: `
377 | mutation($demoTokenId: String) {
378 | clearDemoData(demoTokenId: $demoTokenId) {
379 | success
380 | }
381 | }
382 | `,
383 | createPasswordToken: `
384 | mutation($email: String) {
385 | createPasswordToken(email: $email) {
386 | ... on FormErrorReport {
387 | __typename
388 | errors {
389 | location
390 | message
391 | }
392 | }
393 | ... on Token {
394 | id
395 | }
396 | }
397 | }
398 | `,
399 | validatePasswordToken: `
400 | mutation($tokenId: String) {
401 | validatePasswordToken(tokenId: $tokenId) {
402 | ... on FormErrorReport {
403 | __typename
404 | errors {
405 | location
406 | message
407 | }
408 | }
409 | ... on Token {
410 | id
411 | userId
412 | }
413 | }
414 | }
415 | `,
416 | }
417 |
418 | export { handleRequest }
--------------------------------------------------------------------------------