├── .eslintrc.json ├── next.config.js ├── public ├── favicon.ico ├── favicon-16px.png ├── favicon-32px.png ├── favicon-96px.png ├── sounds │ └── timer.wav ├── fonts │ └── Intro │ │ ├── IntroDemo-BlackCAPS.eot │ │ ├── IntroDemo-BlackCAPS.ttf │ │ ├── IntroDemo-BlackCAPS.woff │ │ └── IntroDemo-BlackCAPS.woff2 └── vercel.svg ├── utils ├── isBrowser.js ├── findClosest.js ├── useIsomorphicLayoutEffect.js ├── workerInterval.js ├── tailwindUtil.js ├── useClientWidth.js ├── notificationUtil.js ├── constants.js ├── localStorageUtil.js ├── timeUtil.js └── timelineUtil.js ├── postcss.config.js ├── pages ├── presets.js ├── timeline.js ├── api │ └── hello.js ├── _document.js ├── index.js └── _app.js ├── components ├── molecules │ ├── TimelineCreator.js │ ├── cards │ │ ├── NoPresets.js │ │ ├── InputCard.js │ │ ├── TimelineStartEnd.js │ │ ├── Header.js │ │ ├── LongBreaks.js │ │ ├── BlockTime.js │ │ ├── TimelineHasEndedModal.js │ │ ├── EnableNotificationsModal.js │ │ ├── CardsLayout.js │ │ ├── LogoutModal.js │ │ ├── IntervalSize.js │ │ ├── TimelineDuration.js │ │ ├── DeletePresetModal.js │ │ ├── SettingsModal.js │ │ ├── AuthCard.js │ │ ├── AuthModal.js │ │ ├── SavePresetModal.js │ │ ├── TimeInput.js │ │ └── Carousel.js │ ├── PresetList.js │ ├── Modal.js │ ├── Preset.js │ ├── TimelinePreview.js │ ├── NavBar.js │ ├── Timeline.js │ └── MainTimeline.js └── atoms │ ├── SelectItem.js │ ├── Logo.js │ ├── Toggle.js │ ├── RadioButton.js │ ├── ActionButton.js │ ├── Interval.js │ ├── Arrow.js │ ├── ViewMoreLess.js │ ├── Button.js │ ├── Tab.js │ ├── Label.js │ ├── TextInput.js │ ├── Timer.js │ ├── NumberInput.js │ ├── SelectInput.js │ └── Icon.js ├── .env.production ├── .env.development ├── .gitignore ├── package.json ├── context ├── Presets.js ├── Settings.js └── Blueprint.js ├── styles └── globals.css ├── tailwind.config.js ├── README.md └── firebase └── Firebase.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | }; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharpirate/need-to-break/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharpirate/need-to-break/HEAD/public/favicon-16px.png -------------------------------------------------------------------------------- /public/favicon-32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharpirate/need-to-break/HEAD/public/favicon-32px.png -------------------------------------------------------------------------------- /public/favicon-96px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharpirate/need-to-break/HEAD/public/favicon-96px.png -------------------------------------------------------------------------------- /public/sounds/timer.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharpirate/need-to-break/HEAD/public/sounds/timer.wav -------------------------------------------------------------------------------- /utils/isBrowser.js: -------------------------------------------------------------------------------- 1 | const isBrowser = typeof window !== "undefined"; 2 | 3 | export default isBrowser; 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/fonts/Intro/IntroDemo-BlackCAPS.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharpirate/need-to-break/HEAD/public/fonts/Intro/IntroDemo-BlackCAPS.eot -------------------------------------------------------------------------------- /public/fonts/Intro/IntroDemo-BlackCAPS.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharpirate/need-to-break/HEAD/public/fonts/Intro/IntroDemo-BlackCAPS.ttf -------------------------------------------------------------------------------- /public/fonts/Intro/IntroDemo-BlackCAPS.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharpirate/need-to-break/HEAD/public/fonts/Intro/IntroDemo-BlackCAPS.woff -------------------------------------------------------------------------------- /public/fonts/Intro/IntroDemo-BlackCAPS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharpirate/need-to-break/HEAD/public/fonts/Intro/IntroDemo-BlackCAPS.woff2 -------------------------------------------------------------------------------- /pages/presets.js: -------------------------------------------------------------------------------- 1 | import PresetList from "../components/molecules/PresetList"; 2 | 3 | function PresetsPage() { 4 | return ; 5 | } 6 | 7 | export default PresetsPage; 8 | -------------------------------------------------------------------------------- /pages/timeline.js: -------------------------------------------------------------------------------- 1 | import MainTimeline from "../components/molecules/MainTimeline"; 2 | 3 | function TimelinePage() { 4 | return ; 5 | } 6 | 7 | export default TimelinePage; 8 | -------------------------------------------------------------------------------- /pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default function handler(req, res) { 4 | res.status(200).json({ name: "John Doe" }); 5 | } 6 | -------------------------------------------------------------------------------- /utils/findClosest.js: -------------------------------------------------------------------------------- 1 | export default function findClosest(x, numbers) { 2 | return numbers.reduce((prev, current) => { 3 | return Math.abs(current - x) < Math.abs(prev - x) ? current : prev; 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /utils/useIsomorphicLayoutEffect.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from "react"; 2 | import isBrowser from "./isBrowser"; 3 | 4 | const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect; 5 | 6 | export default useIsomorphicLayoutEffect; 7 | -------------------------------------------------------------------------------- /components/molecules/TimelineCreator.js: -------------------------------------------------------------------------------- 1 | import CardsLayout from "./cards/CardsLayout"; 2 | import { cardsLayoutTypes } from "./cards/CardsLayout"; 3 | 4 | function TimelineCreator() { 5 | return ; 6 | } 7 | 8 | export default TimelineCreator; 9 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_FIREBASE_API_KEY= 2 | NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= 3 | NEXT_PUBLIC_FIREBASE_PROJECT_ID= 4 | NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= 5 | NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= 6 | NEXT_PUBLIC_FIREBASE_APP_ID= -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_FIREBASE_API_KEY= 2 | NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= 3 | NEXT_PUBLIC_FIREBASE_PROJECT_ID= 4 | NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= 5 | NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= 6 | NEXT_PUBLIC_FIREBASE_APP_ID= -------------------------------------------------------------------------------- /utils/workerInterval.js: -------------------------------------------------------------------------------- 1 | let intervalId; 2 | 3 | onmessage = ({ data }) => { 4 | if (data.delay) { 5 | postMessage(Date.now()); 6 | 7 | intervalId = setInterval(() => { 8 | postMessage(Date.now()); 9 | }, data.delay); 10 | } else if (data.clear) { 11 | clearInterval(intervalId); 12 | close(); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /utils/tailwindUtil.js: -------------------------------------------------------------------------------- 1 | import resolveConfig from "tailwindcss/resolveConfig"; 2 | import tailwindConfig from "../tailwind.config.js"; 3 | 4 | const config = resolveConfig(tailwindConfig); 5 | 6 | export function isBelowBreakpoint(clientWidth, breakpoint) { 7 | if (clientWidth === 0) { 8 | return null; 9 | } 10 | // remove px from the end and convert to a number 11 | return clientWidth <= parseInt(config.theme.screens[breakpoint].slice(0, -2)); 12 | } 13 | -------------------------------------------------------------------------------- /components/molecules/cards/NoPresets.js: -------------------------------------------------------------------------------- 1 | import { iconTypes } from "../../atoms/Icon"; 2 | import InputCard from "./InputCard"; 3 | import Header from "./Header"; 4 | 5 | function NoPresets() { 6 | return ( 7 | 8 |
13 | 14 | ); 15 | } 16 | 17 | export default NoPresets; 18 | -------------------------------------------------------------------------------- /components/molecules/cards/InputCard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function InputCard({ children, useExtraPadding }) { 4 | const paddingStyle = useExtraPadding 5 | ? "pt-16 pb-32 420:pt-24 pb-48" 6 | : "py-16 420:py-24"; 7 | return ( 8 |
11 |
12 | {children} 13 |
14 |
15 | ); 16 | } 17 | 18 | export default InputCard; 19 | -------------------------------------------------------------------------------- /utils/useClientWidth.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import isBrowser from "./isBrowser"; 3 | 4 | export default function useClientWidth() { 5 | if (!isBrowser) { 6 | return 0; 7 | } 8 | 9 | const [width, setWidth] = useState(window.innerWidth); 10 | 11 | const handleResize = () => { 12 | setWidth(window.innerWidth); 13 | }; 14 | 15 | useEffect(() => { 16 | window.addEventListener("resize", handleResize); 17 | 18 | return () => window.removeEventListener("resize", handleResize); 19 | }, []); 20 | 21 | return width; 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | /TODO.txt 37 | /playground.js 38 | -------------------------------------------------------------------------------- /components/molecules/cards/TimelineStartEnd.js: -------------------------------------------------------------------------------- 1 | import InputCard from "./InputCard"; 2 | import Header from "./Header"; 3 | import { iconTypes } from "../../atoms/Icon"; 4 | import TimeInput from "./TimeInput"; 5 | 6 | function TimelineStartEnd() { 7 | return ( 8 | 9 |
14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | export default TimelineStartEnd; 21 | -------------------------------------------------------------------------------- /components/atoms/SelectItem.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | 3 | function SelectItem({ value, children, focused, handleKeyDown, handleClick }) { 4 | const ref = useRef(null); 5 | 6 | useEffect(() => { 7 | if (focused) { 8 | ref.current.focus(); 9 | } 10 | }, [focused]); 11 | 12 | return ( 13 |
  • handleKeyDown(e, value)} 18 | onClick={() => handleClick(value)} 19 | > 20 | {children} 21 |
  • 22 | ); 23 | } 24 | 25 | export default SelectItem; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "need-to-break", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@vercel/analytics": "^1.0.2", 13 | "firebase": "^9.6.7", 14 | "framer-motion": "^6.2.6", 15 | "lodash.clonedeep": "^4.5.0", 16 | "next": "^12.1.0", 17 | "react": "^17.0.2", 18 | "react-dom": "^17.0.2", 19 | "react-modal": "^3.14.3", 20 | "uuid": "^8.3.2" 21 | }, 22 | "devDependencies": { 23 | "autoprefixer": "^10.4.0", 24 | "eslint": "7.32.0", 25 | "eslint-config-next": "11.1.0", 26 | "postcss": "^8.4.5", 27 | "tailwindcss": "^3.0.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /utils/notificationUtil.js: -------------------------------------------------------------------------------- 1 | import isBrowser from "./isBrowser"; 2 | 3 | const permissions = { 4 | default: "default", 5 | granted: "granted", 6 | denied: "denied", 7 | }; 8 | 9 | const hasNotificationSupport = isBrowser && "Notification" in window; 10 | 11 | export function requestPermission() { 12 | if (hasNotificationSupport) { 13 | Notification.requestPermission(); 14 | } 15 | } 16 | 17 | export function useNotifications() { 18 | if (hasNotificationSupport) { 19 | switch (Notification.permission) { 20 | case permissions.granted: 21 | return true; 22 | case permissions.denied: 23 | return false; 24 | case permissions.default: 25 | default: 26 | return null; 27 | } 28 | } else { 29 | return false; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /components/molecules/cards/Header.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import Icon from "../../atoms/Icon"; 3 | import Label, { labelTypes } from "../../atoms/Label"; 4 | 5 | function Header({ icon, heading, description }) { 6 | return ( 7 |
    8 | 9 |
    10 | 13 | {description ? ( 14 |

    {description}

    15 | ) : null} 16 |
    17 |
    18 | ); 19 | } 20 | 21 | Header.propTypes = { 22 | icon: PropTypes.string, 23 | heading: PropTypes.string, 24 | description: PropTypes.string, 25 | }; 26 | 27 | export default Header; 28 | -------------------------------------------------------------------------------- /components/molecules/PresetList.js: -------------------------------------------------------------------------------- 1 | import Preset from "./Preset"; 2 | import NoPresets from "./cards/NoPresets"; 3 | import { usePresets } from "../../context/Presets"; 4 | import { useAuth } from "../../firebase/Firebase"; 5 | 6 | function PresetList() { 7 | const presets = usePresets(); 8 | const { user, userLoading } = useAuth(); 9 | 10 | if (user && !presets) { 11 | // still loading 12 | return null; 13 | } 14 | 15 | if (!userLoading && !user) { 16 | } 17 | 18 | return presets?.length ? ( 19 |
      20 | {presets.map((preset) => ( 21 |
    • 22 | 23 |
    • 24 | ))} 25 |
    26 | ) : ( 27 | 28 | ); 29 | } 30 | 31 | export default PresetList; 32 | -------------------------------------------------------------------------------- /components/atoms/Logo.js: -------------------------------------------------------------------------------- 1 | function Logo() { 2 | return ( 3 | 9 | 13 | 17 | 21 | 25 | 26 | ); 27 | } 28 | 29 | export default Logo; 30 | -------------------------------------------------------------------------------- /components/molecules/cards/LongBreaks.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import InputCard from "./InputCard"; 3 | import Header from "./Header"; 4 | import Toggle from "../../atoms/Toggle"; 5 | import NumberInput from "../../atoms/NumberInput"; 6 | import { iconTypes } from "../../atoms/Icon"; 7 | 8 | function LongBreaks() { 9 | const [checked, setChecked] = useState(false); 10 | 11 | return ( 12 | 13 |
    18 | 19 |
    20 | setChecked(!checked)} 24 | /> 25 |
    26 | 27 | ); 28 | } 29 | 30 | export default LongBreaks; 31 | -------------------------------------------------------------------------------- /components/molecules/cards/BlockTime.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import InputCard from "./InputCard"; 3 | import Header from "./Header"; 4 | import Toggle from "../../atoms/Toggle"; 5 | import { iconTypes } from "../../atoms/Icon"; 6 | import Carousel from "./Carousel"; 7 | import TimeInput from "./TimeInput"; 8 | 9 | function BlockTime() { 10 | const [checked, setChecked] = useState(false); 11 | 12 | return ( 13 | 14 |
    19 | 20 |
    21 | setChecked(!checked)} 25 | /> 26 |
    27 | 28 | ); 29 | } 30 | 31 | export default BlockTime; 32 | -------------------------------------------------------------------------------- /components/molecules/Modal.js: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | import PropTypes from "prop-types"; 3 | import ReactModal from "react-modal"; 4 | import { TRANSITIONS } from "../../utils/constants"; 5 | 6 | function Modal({ isOpen, handleClose, children }) { 7 | return ( 8 | 15 | 22 | {children} 23 | 24 | 25 | ); 26 | } 27 | 28 | Modal.propTypes = { 29 | isOpen: PropTypes.bool, 30 | handleClose: PropTypes.func, 31 | }; 32 | 33 | export default Modal; 34 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /utils/constants.js: -------------------------------------------------------------------------------- 1 | export const SCALES = [ 2 | { name: "5 min", value: 300 }, 3 | { name: "15 min", value: 900 }, 4 | ]; 5 | 6 | export const TRANSITIONS = { 7 | spring500: { 8 | type: "spring", 9 | duration: 0.5, 10 | bounce: 0, 11 | }, 12 | springBounce500: { 13 | type: "spring", 14 | duration: 0.5, 15 | bounce: 0.25, 16 | }, 17 | }; 18 | 19 | export const ACTION_DELAYS = { 20 | short: 500, 21 | long: 1000, 22 | }; 23 | 24 | export const DIRECTIONS = { 25 | default: "default", 26 | left: "left", 27 | right: "right", 28 | none: "none", 29 | vertical: "vertical", 30 | }; 31 | 32 | // export const PRE_LOGIN_PAGES = [ 33 | // { name: 'Sign Up', url: '/signup' }, 34 | // { name: 'Login', url: '/login' }, 35 | // ]; 36 | 37 | export const APP_PAGES = [ 38 | { name: "Timeline", url: "/timeline" }, 39 | { name: "Presets", url: "/presets" }, 40 | ]; 41 | 42 | // used for reading / writing the timeline in localStorage 43 | export const STORED_KEY = "stored"; 44 | 45 | export const STARTING_KEY = "starting"; 46 | 47 | export const SETTINGS_KEY = "settings"; 48 | -------------------------------------------------------------------------------- /components/atoms/Toggle.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | function Toggle({ handleChange, checked, disabled }) { 3 | function handleEnterKey(e) { 4 | if (e.key === "Enter") { 5 | handleChange(); 6 | } 7 | } 8 | 9 | const disabledStyle = disabled ? "bg-gray-300" : "bg-gray-400"; 10 | 11 | return ( 12 |
    13 | handleEnterKey(e)} 22 | /> 23 | 24 |
    25 | ); 26 | } 27 | 28 | Toggle.propTypes = { 29 | handleChange: PropTypes.func, 30 | checked: PropTypes.bool, 31 | }; 32 | 33 | export default Toggle; 34 | -------------------------------------------------------------------------------- /utils/localStorageUtil.js: -------------------------------------------------------------------------------- 1 | import { STORED_KEY, STARTING_KEY, SETTINGS_KEY } from "./constants"; 2 | 3 | const keys = { 4 | stored: STORED_KEY, 5 | starting: STARTING_KEY, 6 | settings: SETTINGS_KEY, 7 | }; 8 | 9 | export function setStoredLocalStorage(stored) { 10 | localStorage.setItem(keys.stored, JSON.stringify(stored)); 11 | } 12 | 13 | export function getStoredLocalStorage() { 14 | return JSON.parse(localStorage.getItem(keys.stored)); 15 | } 16 | 17 | export function removeStoredLocalStorage() { 18 | localStorage.removeItem(keys.stored); 19 | } 20 | 21 | export function getStartingLocalStorage() { 22 | return Number(localStorage.getItem(keys.starting)); 23 | } 24 | 25 | export function setStartingLocalStorage(starting) { 26 | localStorage.setItem(keys.starting, starting); 27 | } 28 | 29 | export function removeStartingLocalStorage() { 30 | localStorage.removeItem(keys.starting); 31 | } 32 | 33 | export function setSettingsLocalStorage(settings) { 34 | localStorage.setItem(keys.settings, JSON.stringify(settings)); 35 | } 36 | 37 | export function getSettingsLocalStorage() { 38 | return JSON.parse(localStorage.getItem(keys.settings)); 39 | } 40 | -------------------------------------------------------------------------------- /components/molecules/cards/TimelineHasEndedModal.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import InputCard from "./InputCard"; 4 | import Header from "./Header"; 5 | import { iconTypes } from "../../atoms/Icon"; 6 | import Button, { buttonTypes } from "../../atoms/Button"; 7 | import Modal from "../Modal"; 8 | 9 | function TimelineHasEndedModal({ isOpen, setIsOpen }) { 10 | return ( 11 | setIsOpen(false)}> 12 | 13 |
    18 | 19 |
    20 | 26 |
    27 | 28 | 29 | ); 30 | } 31 | 32 | TimelineHasEndedModal.propTypes = { 33 | isOpen: PropTypes.bool, 34 | setIsOpen: PropTypes.func, 35 | }; 36 | 37 | export default TimelineHasEndedModal; 38 | -------------------------------------------------------------------------------- /components/atoms/RadioButton.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | function RadioButton({ name, id, label, value, handleChange, isChecked }) { 5 | return ( 6 |
    7 |
    8 | 17 | 18 |
    19 | 22 |
    23 | ); 24 | } 25 | 26 | RadioButton.propTypes = { 27 | name: PropTypes.string, 28 | id: PropTypes.string, 29 | label: PropTypes.string, 30 | }; 31 | 32 | export default RadioButton; 33 | -------------------------------------------------------------------------------- /context/Presets.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useContext, createContext } from "react"; 3 | import { useDB } from "../firebase/Firebase"; 4 | import { useAuth } from "../firebase/Firebase"; 5 | 6 | const PresetsContext = createContext(); 7 | const FetchPresetsContext = createContext(); 8 | 9 | export const PresetsProvider = ({ children }) => { 10 | const { getPresets } = useDB(); 11 | const [presets, setPresets] = useState(); 12 | const { user } = useAuth(); 13 | 14 | async function fetchPresets() { 15 | const { presets: results } = await getPresets(); 16 | 17 | if (results) { 18 | const processedPresets = results.map((preset) => { 19 | if (!preset.startTime) { 20 | preset.startTime = Date.now(); 21 | } 22 | 23 | return preset; 24 | }); 25 | 26 | setPresets(processedPresets); 27 | } 28 | } 29 | 30 | useEffect(() => { 31 | if (user) { 32 | fetchPresets(); 33 | } 34 | }, [user]); 35 | 36 | return ( 37 | 38 | 39 | {children} 40 | 41 | 42 | ); 43 | }; 44 | 45 | export const usePresets = () => useContext(PresetsContext); 46 | export const useFetchPresets = () => useContext(FetchPresetsContext); 47 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | class MyDocument extends Document { 4 | render() { 5 | return ( 6 | 7 | 8 | 14 | 20 | 26 | 27 | 32 | 36 | 40 | 41 | 42 |
    43 | 44 | 45 | 46 | ); 47 | } 48 | } 49 | 50 | export default MyDocument; 51 | -------------------------------------------------------------------------------- /utils/timeUtil.js: -------------------------------------------------------------------------------- 1 | export function parseTime(time) { 2 | // convert "12:30" to [12, 30] 3 | // convert [12, 30] to "12:30" 4 | return typeof time === "string" 5 | ? time.split(":").map((item) => Number(item)) 6 | : time.map((item) => getTwoDigitTime(item)).join(":"); 7 | } 8 | 9 | export function getTwoDigitTime(time) { 10 | return time < 10 ? `0${time}` : time; 11 | } 12 | 13 | export function timestampToString(timestamp) { 14 | const date = new Date(timestamp); 15 | const hours = getTwoDigitTime(date.getHours()); 16 | const minutes = getTwoDigitTime(date.getMinutes()); 17 | return `${hours}:${minutes}`; 18 | } 19 | 20 | export function parseStartTime(startTime) { 21 | // if it's flexible (timestamp) parse it to string first 22 | const [hours, minutes] = 23 | typeof startTime === "number" 24 | ? parseTime(timestampToString(startTime)) 25 | : parseTime(startTime); 26 | return new Date().setHours(hours, minutes, 0); 27 | } 28 | 29 | export function get12HourTime(time) { 30 | const [hour, min] = typeof time === "string" ? parseTime(time) : time; 31 | 32 | let resultHour; 33 | let suffix; 34 | 35 | if (hour >= 0 && hour <= 11) { 36 | if (hour === 0) { 37 | resultHour = 12; 38 | } else { 39 | resultHour = hour; 40 | } 41 | suffix = "AM"; 42 | } else { 43 | if (hour === 12) { 44 | resultHour = hour; 45 | } else { 46 | resultHour = hour - 12; 47 | } 48 | suffix = "PM"; 49 | } 50 | 51 | return [getTwoDigitTime(resultHour), getTwoDigitTime(min), suffix]; 52 | } 53 | -------------------------------------------------------------------------------- /components/molecules/cards/EnableNotificationsModal.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import InputCard from "./InputCard"; 4 | import Header from "./Header"; 5 | import { iconTypes } from "../../atoms/Icon"; 6 | import Button, { buttonTypes } from "../../atoms/Button"; 7 | import Modal from "../Modal"; 8 | import { requestPermission } from "../../../utils/notificationUtil"; 9 | 10 | function EnableNotificationsModal({ isOpen, setIsOpen }) { 11 | function handleEnable() { 12 | requestPermission(); 13 | setIsOpen(false); 14 | } 15 | 16 | function handleClose() { 17 | setIsOpen(false); 18 | } 19 | 20 | return ( 21 | setIsOpen(false)}> 22 | 23 |
    28 | 29 |
    30 | 33 | 36 |
    37 | 38 | 39 | ); 40 | } 41 | 42 | EnableNotificationsModal.propTypes = { 43 | isOpen: PropTypes.bool, 44 | setIsOpen: PropTypes.func, 45 | presetName: PropTypes.string, 46 | }; 47 | 48 | export default EnableNotificationsModal; 49 | -------------------------------------------------------------------------------- /components/molecules/cards/CardsLayout.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import TimelineStartEnd from "./TimelineStartEnd"; 3 | import IntervalSize from "./IntervalSize"; 4 | import BlockTime from "./BlockTime"; 5 | import TimelineDuration from "./TimelineDuration"; 6 | import LongBreaks from "./LongBreaks"; 7 | import TimelinePreview from "../TimelinePreview"; 8 | import { BlueprintProvider } from "../../../context/Blueprint"; 9 | 10 | const types = { 11 | fullTime: "fullTime", 12 | flexible: "flexible", 13 | }; 14 | 15 | export { types as cardsLayoutTypes }; 16 | 17 | function CardsLayout({ type }) { 18 | return ( 19 | 20 |
      21 |
    • 22 | {type === types.fullTime ? ( 23 | 24 | ) : ( 25 | 26 | )} 27 |
    • 28 | 29 |
    • 30 | 31 |
    • 32 | 33 |
    • 34 | {type === types.fullTime ? : } 35 |
    • 36 |
    37 | 38 |
    39 | 40 |
    41 |
    42 | ); 43 | } 44 | 45 | CardsLayout.propTypes = { 46 | type: PropTypes.string, 47 | }; 48 | 49 | export default CardsLayout; 50 | -------------------------------------------------------------------------------- /components/molecules/cards/LogoutModal.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import InputCard from "./InputCard"; 4 | import Header from "./Header"; 5 | import { iconTypes } from "../../atoms/Icon"; 6 | import Button, { buttonTypes } from "../../atoms/Button"; 7 | import Modal from "../Modal"; 8 | import { useAuth } from "../../../firebase/Firebase"; 9 | import { 10 | removeStartingLocalStorage, 11 | removeStoredLocalStorage, 12 | } from "../../../utils/localStorageUtil"; 13 | function LogoutModal({ isOpen, setIsOpen }) { 14 | const { signOut, user } = useAuth(); 15 | 16 | async function handleLogout() { 17 | await signOut(); 18 | removeStoredLocalStorage(); 19 | removeStartingLocalStorage(); 20 | handleClose(true); 21 | } 22 | 23 | function handleClose() { 24 | setIsOpen(false); 25 | } 26 | 27 | return ( 28 | 29 | 30 |
    35 | 36 |
    37 | 40 | 43 |
    44 | 45 | 46 | ); 47 | } 48 | 49 | LogoutModal.propTypes = { 50 | isOpen: PropTypes.bool, 51 | setIsOpen: PropTypes.func, 52 | presetName: PropTypes.string, 53 | }; 54 | 55 | export default LogoutModal; 56 | -------------------------------------------------------------------------------- /components/atoms/ActionButton.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | const types = { 4 | add: "add", 5 | remove: "remove", 6 | }; 7 | 8 | export { types as actionButtonTypes }; 9 | 10 | function ActionButton({ type, handleClick }) { 11 | return getStyledButton(type, handleClick); 12 | } 13 | 14 | ActionButton.propTypes = { 15 | type: PropTypes.string, 16 | }; 17 | 18 | function getStyledButton(type, handleClick) { 19 | let colorStyle = ""; 20 | let baseStyle = "w-20 h-20 420:w-24 420:h-24"; 21 | 22 | switch (type) { 23 | case types.add: 24 | colorStyle = "fill-primary-500 group-focus-visible:fill-primary-600"; 25 | break; 26 | case types.remove: 27 | colorStyle = "fill-support-error group-focus-visible:fill-blocked-600"; 28 | baseStyle += " rotate-45"; 29 | break; 30 | default: 31 | break; 32 | } 33 | 34 | return ( 35 | 54 | ); 55 | } 56 | 57 | ActionButton.propTypes = { 58 | type: PropTypes.string, 59 | handleClick: PropTypes.func, 60 | }; 61 | 62 | export default ActionButton; 63 | -------------------------------------------------------------------------------- /components/atoms/Interval.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | const types = { 4 | work: "work", 5 | break: "break", 6 | blocked: "blocked", 7 | floating: "floating", 8 | starting: "starting", 9 | end: "end", 10 | }; 11 | 12 | export { types as intervalTypes }; 13 | 14 | function Interval({ type, first, last, duration }) { 15 | return ( 16 |
  • 20 | ); 21 | } 22 | 23 | Interval.propTypes = { 24 | type: PropTypes.string, 25 | first: PropTypes.bool, 26 | last: PropTypes.bool, 27 | duration: PropTypes.number, 28 | }; 29 | 30 | function getStyle(type, first, last) { 31 | let baseStyle = 32 | "block h-full w-32 420:w-40 932:w-auto 932:h-32 1172:h-40 hover:bg-primary-600 hover:cursor-pointer"; 33 | 34 | let colorStyle = ""; 35 | 36 | switch (type) { 37 | case types.work: 38 | colorStyle = "bg-primary-500"; 39 | break; 40 | case types.break: 41 | colorStyle = "bg-primary-400"; 42 | break; 43 | case types.blank: 44 | colorStyle = "bg-primary-300"; 45 | break; 46 | case types.blocked: 47 | colorStyle = "bg-blocked-500"; 48 | break; 49 | case types.floating: 50 | colorStyle = "bg-blocked-400"; 51 | break; 52 | default: 53 | break; 54 | } 55 | 56 | let borderStyle = ""; 57 | 58 | if (first && last) { 59 | borderStyle = 60 | "rounded-t-20 rounded-b-20 932:rounded-0 932:rounded-l-20 932:rounded-r-20"; 61 | } else if (first) { 62 | borderStyle = "rounded-t-20 932:rounded-0 932:rounded-l-20"; 63 | } else if (last) { 64 | borderStyle = "rounded-b-20 932:rounded-0 932:rounded-r-20 "; 65 | } 66 | 67 | return `${baseStyle} ${colorStyle} ${borderStyle}`; 68 | } 69 | 70 | export default Interval; 71 | -------------------------------------------------------------------------------- /components/molecules/cards/IntervalSize.js: -------------------------------------------------------------------------------- 1 | import InputCard from "./InputCard"; 2 | import Header from "./Header"; 3 | import NumberInput from "../../atoms/NumberInput"; 4 | import { iconTypes } from "../../atoms/Icon"; 5 | import { useState, useEffect } from "react"; 6 | import { 7 | useDispatchBlueprint, 8 | blueprintActions, 9 | } from "../../../context/Blueprint"; 10 | 11 | function IntervalSize() { 12 | const [w, setW] = useState(30); 13 | const [b, setB] = useState(10); 14 | const dispatch = useDispatchBlueprint(); 15 | 16 | useEffect(() => { 17 | dispatch({ type: blueprintActions.SET_WORK, value: w * 60 }); 18 | }, [w]); 19 | 20 | useEffect(() => { 21 | dispatch({ type: blueprintActions.SET_BREAK, value: b * 60 }); 22 | }, [b]); 23 | 24 | return ( 25 | 26 |
    31 | 32 |
    33 | setW(value)} 37 | step={5} 38 | min={5} 39 | max={90} 40 | unit="min" 41 | widthStyle="w-64 420:w-78" 42 | bigLabel="Work" 43 | centerBig 44 | /> 45 | 46 | setB(value)} 50 | step={5} 51 | min={5} 52 | max={90} 53 | unit="min" 54 | widthStyle="w-64 420:w-78" 55 | bigLabel="Break" 56 | centerBig 57 | /> 58 |
    59 | 60 | ); 61 | } 62 | 63 | export default IntervalSize; 64 | -------------------------------------------------------------------------------- /components/molecules/cards/TimelineDuration.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import Header from "./Header"; 3 | import { iconTypes } from "../../atoms/Icon"; 4 | import InputCard from "./InputCard"; 5 | import NumberInput from "../../atoms/NumberInput"; 6 | import { 7 | useDispatchBlueprint, 8 | blueprintActions, 9 | } from "../../../context/Blueprint"; 10 | 11 | function TimelineDuration() { 12 | const [hours, setHours] = useState(0); 13 | const [minutes, setMinutes] = useState(0); 14 | const blueprintDispatch = useDispatchBlueprint(); 15 | 16 | useEffect(() => { 17 | const duration = hours * 3600 + minutes * 60; 18 | 19 | blueprintDispatch({ type: blueprintActions.SET_START, value: Date.now() }); 20 | blueprintDispatch({ type: blueprintActions.SET_DURATION, value: duration }); 21 | }, [hours, minutes]); 22 | 23 | return ( 24 | 25 |
    30 | 31 |
    32 | setHours(value)} 36 | step={1} 37 | min={0} 38 | max={12} 39 | unit="hrs" 40 | widthStyle="w-64 420:w-78" 41 | bigLabel="Hours" 42 | centerBig 43 | /> 44 | 45 | setMinutes(value)} 49 | step={15} 50 | min={0} 51 | max={45} 52 | unit="min" 53 | widthStyle="w-64 420:w-78" 54 | bigLabel="Minutes" 55 | centerBig 56 | /> 57 |
    58 | 59 | ); 60 | } 61 | 62 | export default TimelineDuration; 63 | -------------------------------------------------------------------------------- /context/Settings.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useContext, createContext } from "react"; 3 | import { 4 | getSettingsLocalStorage, 5 | setSettingsLocalStorage, 6 | } from "../utils/localStorageUtil"; 7 | import isBrowser from "../utils/isBrowser"; 8 | import { useAuth } from "../firebase/Firebase"; 9 | 10 | const SettingsContext = createContext(); 11 | const SaveSettingsContext = createContext(); 12 | 13 | function getInitialState(user) { 14 | const initialState = { 15 | use12Hour: true, 16 | useSmartRestart: false, 17 | }; 18 | 19 | // prioritize settings from localStorage 20 | if (isBrowser && user) { 21 | const localStorageSettings = getSettingsLocalStorage(); 22 | 23 | if (localStorageSettings) { 24 | return localStorageSettings; 25 | } 26 | } 27 | 28 | // initial state 29 | return initialState; 30 | } 31 | 32 | export const SettingsProvider = ({ children }) => { 33 | const [settings, setSettings] = useState(getInitialState()); 34 | const { user } = useAuth(); 35 | 36 | useEffect(() => { 37 | if (user) { 38 | setSettings(getInitialState(user)); 39 | } 40 | }, [user]); 41 | 42 | function saveSettings(settings) { 43 | setSettings(settings); 44 | 45 | if (user) { 46 | setSettingsLocalStorage(settings); 47 | } 48 | } 49 | 50 | return ( 51 | 52 | 53 | {children} 54 | 55 | 56 | ); 57 | }; 58 | 59 | export const useSettings = () => useContext(SettingsContext); 60 | export const useSaveSettings = () => useContext(SaveSettingsContext); 61 | 62 | export const timeFormats = [ 63 | { name: "AM / PM", value: true }, 64 | { name: "24 hour", value: false }, 65 | ]; 66 | 67 | export const restartTypes = [ 68 | { name: "Instant", value: false }, 69 | { name: "Smart", value: true }, 70 | ]; 71 | -------------------------------------------------------------------------------- /components/atoms/Arrow.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | const types = { 4 | down: "down", 5 | left: "left", 6 | right: "right", 7 | up: "up", 8 | 9 | small: "50", 10 | reg: "100", 11 | 12 | active: "active", 13 | default: "default", 14 | disabled: "disabled", 15 | }; 16 | 17 | export { types as arrowTypes }; 18 | 19 | function Arrow({ type, size, state }) { 20 | return resolveArrow(type, size, state); 21 | } 22 | 23 | Arrow.propTypes = { 24 | type: PropTypes.string, 25 | size: PropTypes.string, 26 | state: PropTypes.string, 27 | }; 28 | 29 | function resolveArrow(type, size, state) { 30 | let sizeStyle = ""; 31 | 32 | switch (size) { 33 | case types.small: 34 | sizeStyle = "w-13 h-13 420:w-16 420:h-16"; 35 | break; 36 | case types.reg: 37 | sizeStyle = "w-16 h-16 420:w-24 420:h-24"; 38 | break; 39 | default: 40 | return null; 41 | } 42 | 43 | let typeStyle = ""; 44 | 45 | switch (type) { 46 | case types.down: 47 | break; 48 | case types.left: 49 | typeStyle = "rotate-90"; 50 | break; 51 | case types.right: 52 | typeStyle = "-rotate-90"; 53 | break; 54 | case types.up: 55 | typeStyle = "rotate-180"; 56 | break; 57 | default: 58 | return null; 59 | } 60 | 61 | let colorStyle = ""; 62 | 63 | switch (state) { 64 | case types.active: 65 | colorStyle = "fill-primary-500 group-focus-visible:fill-primary-600"; 66 | break; 67 | case types.default: 68 | colorStyle = "fill-gray-400"; 69 | break; 70 | case types.disabled: 71 | colorStyle = "fill-gray-300"; 72 | return null; 73 | } 74 | 75 | return ( 76 | 82 | 88 | 89 | ); 90 | } 91 | 92 | export default Arrow; 93 | -------------------------------------------------------------------------------- /components/molecules/cards/DeletePresetModal.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import InputCard from "./InputCard"; 4 | import Header from "./Header"; 5 | import { iconTypes } from "../../atoms/Icon"; 6 | import Button, { buttonTypes } from "../../atoms/Button"; 7 | import Modal from "../Modal"; 8 | import { useDB } from "../../../firebase/Firebase"; 9 | import { useState } from "react"; 10 | import { ACTION_DELAYS } from "../../../utils/constants"; 11 | import { useFetchPresets } from "../../../context/Presets"; 12 | 13 | function DeletePresetModal({ isOpen, setIsOpen, name, id }) { 14 | const [success, setSuccess] = useState(false); 15 | const { deletePreset } = useDB(); 16 | const fetchPresets = useFetchPresets(); 17 | 18 | async function handleDelete() { 19 | const error = await deletePreset(id); 20 | 21 | if (!error) { 22 | setSuccess(true); 23 | 24 | setTimeout(() => { 25 | handleClose(true); 26 | }, ACTION_DELAYS.short); 27 | } 28 | } 29 | 30 | function handleClose(refetch) { 31 | setIsOpen(false); 32 | setSuccess(false); 33 | 34 | if (refetch) { 35 | fetchPresets(); 36 | } 37 | } 38 | 39 | return ( 40 | 41 | 42 |
    47 | 48 |
    49 | 52 | 58 |
    59 | 60 | 61 | ); 62 | } 63 | 64 | DeletePresetModal.propTypes = { 65 | isOpen: PropTypes.bool, 66 | setIsOpen: PropTypes.func, 67 | presetName: PropTypes.string, 68 | }; 69 | 70 | export default DeletePresetModal; 71 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Button, { buttonTypes } from "../components/atoms/Button"; 2 | import { useRouter } from "next/dist/client/router"; 3 | 4 | export default function Home() { 5 | const router = useRouter(); 6 | 7 | return ( 8 |
    9 | {/* Main */} 10 |
    11 |
    12 |

    13 | Take Your Productivity 14 | To The 15 | 16 | Next Level 17 | 18 |

    19 |

    20 | The Smart Way to manage your screen time 21 | While staying Productive 22 |

    23 |
    24 | 25 |
    26 |
    27 | 34 |
    35 | 36 | 42 | 48 |
    49 |
    50 | 51 | {/* Devices Image */} 52 |
    53 |
    54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /components/atoms/ViewMoreLess.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import useClientWidth from "../../utils/useClientWidth"; 4 | import { isBelowBreakpoint } from "../../utils/tailwindUtil"; 5 | import Arrow, { arrowTypes } from "./Arrow"; 6 | import { AnimatePresence, motion } from "framer-motion"; 7 | import { TRANSITIONS } from "../../utils/constants"; 8 | 9 | const variants = { 10 | initial: { 11 | height: 0, 12 | }, 13 | enter: { 14 | height: "auto", 15 | transition: TRANSITIONS.spring500, 16 | }, 17 | exit: { 18 | height: 0, 19 | transition: TRANSITIONS.spring500, 20 | }, 21 | }; 22 | function ViewMoreLess({ 23 | viewMoreText, 24 | viewLessText, 25 | children, 26 | isTimeline, 27 | handleClick, 28 | active, 29 | }) { 30 | const clientWidth = useClientWidth(); 31 | const isMobile = isBelowBreakpoint(clientWidth, "932"); 32 | 33 | const buttonStyle = `${isTimeline ? "932:hidden" : ""}`; 34 | 35 | let content; 36 | 37 | if (!isTimeline || (isTimeline && isMobile)) { 38 | content = active ? ( 39 | 46 | {children} 47 | 48 | ) : null; 49 | } else if (isTimeline && !isMobile) { 50 | content = children; 51 | } 52 | 53 | return ( 54 | <> 55 | 69 | 70 | {content} 71 | 72 | ); 73 | } 74 | 75 | ViewMoreLess.propTypes = { 76 | viewMoreText: PropTypes.string, 77 | viewLessText: PropTypes.string, 78 | isTimeline: PropTypes.bool, 79 | }; 80 | 81 | export default ViewMoreLess; 82 | -------------------------------------------------------------------------------- /components/atoms/Button.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import { motion } from "framer-motion"; 3 | import Link from "next/link"; 4 | 5 | const delay = 150; 6 | 7 | export { delay as buttonDelay }; 8 | 9 | const types = { 10 | primary: "primary", 11 | outline: "outline", 12 | delete: "delete", 13 | success: "success", 14 | callToAction: "callToAction", 15 | }; 16 | 17 | export { types as buttonTypes }; 18 | 19 | function Button({ 20 | type, 21 | children, 22 | disabled, 23 | handleClick, 24 | isSubmit, 25 | styleOverride, 26 | href, 27 | }) { 28 | let style; 29 | 30 | switch (type) { 31 | case types.primary: 32 | style = 33 | "text-white bg-primary-500 disabled:bg-gray-300 focus-visible:bg-primary-600"; 34 | break; 35 | case types.callToAction: 36 | style = "text-primary-600 bg-support-attention focus-visible:bg-white"; 37 | break; 38 | case types.outline: 39 | style = 40 | "text-primary-500 bg-white ring-5/2 ring-inset ring-primary-500 disabled:ring-gray-300 disabled:text-gray-300 focus-visible:ring-primary-600"; 41 | break; 42 | case types.delete: 43 | style = 44 | "text-white bg-blocked-500 disabled:bg-gray-300 focus-visible:bg-blocked-600"; 45 | break; 46 | case types.success: 47 | style = "text-white bg-support-success"; 48 | break; 49 | default: 50 | return null; 51 | } 52 | 53 | if (href) { 54 | return ( 55 | 66 | {children} 67 | 68 | ); 69 | } 70 | 71 | return ( 72 | setTimeout(handleClick, delay)} 76 | disabled={disabled} 77 | className={ 78 | "text-center select-none px-24 420:px-32 py-8 body-med body-small rounded-6 outline-none active:scale-90" + 79 | " " + 80 | style + 81 | " " + 82 | styleOverride 83 | } 84 | whileTap={{ scale: 0.85 }} 85 | > 86 | {children} 87 | 88 | ); 89 | } 90 | 91 | Button.propTypes = { 92 | type: PropTypes.string, 93 | disabled: PropTypes.bool, 94 | handleClick: PropTypes.func, 95 | }; 96 | 97 | export default Button; 98 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | @font-face { 7 | font-family: "IntroDemo"; 8 | src: 9 | url("/fonts/Intro/IntroDemo-BlackCAPS.eot"), 10 | /* IE9 Compat Modes */ url("/fonts/Intro/IntroDemo-BlackCAPS.woff2") 11 | format("woff2"), 12 | /* Super Modern Browsers */ url("/fonts/Intro/IntroDemo-BlackCAPS.woff") 13 | format("woff"), 14 | /* Pretty Modern Browsers */ url("/fonts/Intro/IntroDemo-BlackCAPS.ttf") 15 | format("truetype"); /* Safari, Android, iOS */ 16 | } 17 | } 18 | 19 | @layer utilities { 20 | .carousel { 21 | overflow-y: visible; 22 | overflow-x: clip; 23 | } 24 | 25 | /* Hide scrollbar for Chrome, Safari and Opera */ 26 | .hide-scrollbar::-webkit-scrollbar { 27 | display: none; 28 | } 29 | 30 | /* Hide scrollbar for IE, Edge and Firefox */ 31 | .hide-scrollbar { 32 | -ms-overflow-style: none; /* IE and Edge */ 33 | scrollbar-width: none; /* Firefox */ 34 | } 35 | 36 | .term { 37 | @apply text-gray-600; 38 | } 39 | 40 | .term::after { 41 | content: ":\00a0"; 42 | } 43 | 44 | .custom-scrollbar { 45 | scrollbar-color: #39a2db #e8f0f2; 46 | scrollbar-width: thin; 47 | } 48 | 49 | .custom-scrollbar::-webkit-scrollbar { 50 | height: 8px; /* width of the entire scrollbar */ 51 | } 52 | 53 | .custom-scrollbar::-webkit-scrollbar-track { 54 | background: #e8f0f2; /* color of the tracking area */ 55 | border-radius: 8px; 56 | } 57 | 58 | .custom-scrollbar::-webkit-scrollbar-thumb { 59 | background-color: #39a2db; /* color of the scroll thumb */ 60 | border-radius: 8px; 61 | } 62 | 63 | .body-reg { 64 | @apply font-base font-reg text-13 420:text-16 tracking-2; 65 | } 66 | 67 | .body-med { 68 | @apply font-base font-med text-13 420:text-16 tracking-2; 69 | } 70 | 71 | .mono-med { 72 | @apply font-mono font-med text-13 420:text-16; 73 | } 74 | 75 | .body-bold { 76 | @apply font-base font-bold text-13 420:text-16 tracking-2; 77 | } 78 | 79 | .body-sbold { 80 | @apply font-base font-sbold text-13 420:text-16 tracking-2; 81 | } 82 | 83 | .heading-landing { 84 | @apply font-base font-black text-32 420:text-40 480:text-50 tracking-2; 85 | } 86 | 87 | .subheading-landing { 88 | @apply font-base font-med text-16 420:text-20 tracking-2; 89 | } 90 | 91 | .devices-landing { 92 | background: url("/images/devices.svg") top center/contain no-repeat; 93 | } 94 | 95 | .bg-landing { 96 | background: url("/images/bg-landing.svg") top left/cover no-repeat; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /components/atoms/Tab.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | const types = { 4 | navBar: "navBar", 5 | pagination: "pagination", 6 | }; 7 | 8 | export { types as tabTypes }; 9 | function Tab({ children, type, active, first, last, value, handleClick }) { 10 | return ( 11 |
  • 12 | 18 |
  • 19 | ); 20 | } 21 | 22 | Tab.propTypes = { 23 | type: PropTypes.string, 24 | active: PropTypes.bool, 25 | first: PropTypes.bool, 26 | last: PropTypes.bool, 27 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 28 | handleClick: PropTypes.func, 29 | }; 30 | 31 | function getStyle(type, active, first, last) { 32 | switch (type) { 33 | case types.navBar: 34 | return getNavBarStyle(active, first, last); 35 | case types.pagination: 36 | return getPaginationStyle(active, first, last); 37 | default: 38 | return ""; 39 | } 40 | } 41 | 42 | function getNavBarStyle(active, first, last) { 43 | let baseStyle = 44 | "w-full block outline-none text-center text-white body-med p-6 border-b border-white 420:p-8 420:text-16 732:font-sbold 732:py-8 732:px-32 732:border-t-2 732:border-b-2 732:border-r-2 732:border-primary-500 732:focus-visible:bg-primary-600 732:focus-visible:text-white"; 45 | 46 | let activeStyle = ""; 47 | 48 | if (active) { 49 | activeStyle = "border-opacity-100 732:bg-primary-500 732:text-white"; 50 | } else { 51 | activeStyle = 52 | "border-opacity-0 732:border-opacity-100 732:bg-white 732:text-primary-500"; 53 | } 54 | 55 | let borderStyle = ""; 56 | 57 | if (first) { 58 | borderStyle = "732:border-2 732:rounded-l-20"; 59 | } else if (last) { 60 | borderStyle = "732:border-2 732:border-l-0 732:rounded-r-20"; 61 | } 62 | 63 | return `${baseStyle} ${activeStyle} ${borderStyle}`; 64 | } 65 | 66 | function getPaginationStyle(active, first, last) { 67 | let baseStyle = 68 | "block outline-none text-center body-sbold py-2 420:py-0 px-8 border-t-2 border-b-2 border-r-2 border-primary-500 focus-visible:bg-primary-600 focus-visible:text-white"; 69 | 70 | let activeStyle = ""; 71 | 72 | if (active) { 73 | activeStyle = "bg-primary-500 text-white"; 74 | } else { 75 | activeStyle = "bg-white text-primary-500"; 76 | } 77 | 78 | let borderStyle = ""; 79 | 80 | if (first) { 81 | borderStyle = "border-2 rounded-l-6"; 82 | } else if (last) { 83 | borderStyle = "border-2 border-l-0 rounded-r-6"; 84 | } 85 | 86 | return `${baseStyle} ${activeStyle} ${borderStyle}`; 87 | } 88 | 89 | export default Tab; 90 | -------------------------------------------------------------------------------- /components/atoms/Label.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | const types = { 4 | // size 5 | small: "small", 6 | status: "status", 7 | big: "big", 8 | large: "large", 9 | xlarge: "xlarge", 10 | 11 | // as 12 | h1: "h1", 13 | h2: "h2", 14 | h3: "h3", 15 | h4: "h4", 16 | h5: "h5", 17 | h6: "h6", 18 | label: "label", 19 | 20 | // type 21 | success: "success", 22 | error: "error", 23 | }; 24 | 25 | export { types as labelTypes }; 26 | 27 | function Label({ size, as, children, fieldId, type, center, capitalize }) { 28 | const style = getStyle(size, type, center, capitalize); 29 | 30 | switch (as) { 31 | case types.h1: 32 | return

    {children}

    ; 33 | case types.h2: 34 | return

    {children}

    ; 35 | case types.h3: 36 | return

    {children}

    ; 37 | case types.h4: 38 | return

    {children}

    ; 39 | case types.h5: 40 | return
    {children}
    ; 41 | case types.h6: 42 | return
    {children}
    ; 43 | case types.label: 44 | return ( 45 | 48 | ); 49 | default: 50 | return null; 51 | } 52 | } 53 | 54 | Label.propTypes = { 55 | size: PropTypes.string, 56 | as: PropTypes.string, 57 | fieldId: PropTypes.string, 58 | type: PropTypes.string, 59 | center: PropTypes.bool, 60 | capitalize: PropTypes.bool, 61 | }; 62 | 63 | function getStyle(size, type, center, capitalize) { 64 | const baseStyle = "tracking-2 font-base"; 65 | 66 | let colorStyle = ""; 67 | 68 | switch (type) { 69 | case types.success: 70 | colorStyle = "text-support-success"; 71 | break; 72 | case types.error: 73 | colorStyle = "text-support-error"; 74 | break; 75 | default: 76 | colorStyle = "text-gray-600"; 77 | break; 78 | } 79 | 80 | let sizeStyle = ""; 81 | 82 | switch (size) { 83 | case types.status: 84 | sizeStyle = 85 | "font-med text-10 pt-4 420:text-13 absolute bottom-0 translate-y-full"; 86 | break; 87 | case types.small: 88 | sizeStyle = "font-med text-10 mb-4 420:text-13"; 89 | break; 90 | case types.big: 91 | sizeStyle = "font-bold text-13 420:text-16 mb-6 420:mb-8"; 92 | break; 93 | case types.large: 94 | sizeStyle = "font-bold text-16 420:text-20 mb-6 420:mb-8"; 95 | break; 96 | case types.xlarge: 97 | sizeStyle = "font-bold text-20 420:text-26 mb-6 420:mb-8"; 98 | break; 99 | default: 100 | break; 101 | } 102 | 103 | let centerStyle = ""; 104 | 105 | if (center) { 106 | centerStyle = "text-center"; 107 | } 108 | 109 | const textStyle = capitalize ? "capitalize" : "normal-case"; 110 | 111 | return `${baseStyle} ${centerStyle} ${sizeStyle} ${colorStyle} ${textStyle}`; 112 | } 113 | 114 | export default Label; 115 | -------------------------------------------------------------------------------- /components/atoms/TextInput.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import Label, { labelTypes } from "./Label"; 3 | 4 | const types = { 5 | text: "text", 6 | password: "password", 7 | email: "email", 8 | }; 9 | 10 | export { types as textInputTypes }; 11 | function TextInput({ 12 | name, 13 | type, 14 | bigLabel, 15 | smallLabel, 16 | widthStyle, 17 | centerBig, 18 | centerSmall, 19 | successLabel, 20 | errorLabel, 21 | children, 22 | value, 23 | disabled, 24 | handleChange, 25 | hasSuccess, 26 | }) { 27 | const renderBigLabel = bigLabel ? ( 28 | 36 | ) : null; 37 | 38 | const renderSmallLabel = smallLabel ? ( 39 | 47 | ) : null; 48 | 49 | const renderSuccessLabel = successLabel ? ( 50 | 58 | ) : null; 59 | 60 | const renderErrorLabel = errorLabel ? ( 61 | 69 | ) : null; 70 | 71 | return ( 72 |
    73 | {renderBigLabel} 74 | {renderSmallLabel} 75 | handleChange(e.target.value)} 79 | className={getStyle(Boolean(errorLabel), hasSuccess)} 80 | placeholder={children} 81 | type={type} 82 | id={name} 83 | name={name} 84 | value={value} 85 | spellCheck={false} 86 | /> 87 | {renderSuccessLabel} 88 | {renderErrorLabel} 89 |
    90 | ); 91 | } 92 | 93 | function getStyle(error, success) { 94 | const baseStyle = `appearance-none text-gray-600 placeholder-gray-400 body-reg p-8 ring-2 ring-inset rounded-4`; 95 | const focusStyle = "focus:outline-none focus:ring-primary-500"; 96 | const disabledStyle = 97 | "disabled:bg-white disabled:ring-gray-300 disabled:placeholder-gray-300"; 98 | 99 | let ringColor = ""; 100 | 101 | if (error) { 102 | ringColor = "ring-support-error"; 103 | } else if (success) { 104 | ringColor = "ring-support-success"; 105 | } else { 106 | ringColor = "ring-gray-400"; 107 | } 108 | 109 | return `${baseStyle} ${ringColor} ${focusStyle} ${disabledStyle}`; 110 | } 111 | 112 | TextInput.propTypes = { 113 | name: PropTypes.string, 114 | bigLabel: PropTypes.string, 115 | smallLabel: PropTypes.string, 116 | successLabel: PropTypes.string, 117 | errorLabel: PropTypes.string, 118 | value: PropTypes.string, 119 | type: PropTypes.string, 120 | disabled: PropTypes.bool, 121 | centerBig: PropTypes.bool, 122 | centerSmall: PropTypes.bool, 123 | widthStyle: PropTypes.string, 124 | }; 125 | 126 | export default TextInput; 127 | -------------------------------------------------------------------------------- /components/molecules/cards/SettingsModal.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import InputCard from "./InputCard"; 4 | import Header from "./Header"; 5 | import { iconTypes } from "../../atoms/Icon"; 6 | import Button, { buttonTypes } from "../../atoms/Button"; 7 | import SelectInput from "../../atoms/SelectInput"; 8 | import Modal from "../Modal"; 9 | import { 10 | useSettings, 11 | useSaveSettings, 12 | timeFormats, 13 | restartTypes, 14 | } from "../../../context/Settings"; 15 | import { ACTION_DELAYS } from "../../../utils/constants"; 16 | 17 | function SettingsModal({ isOpen, setIsOpen }) { 18 | const settings = useSettings(); 19 | const saveSettings = useSaveSettings(); 20 | const [localSettings, setLocalSettings] = useState(settings); 21 | const [success, setSuccess] = useState(false); 22 | 23 | function handleApply() { 24 | // save local settings to the context and localStorage 25 | saveSettings(localSettings); 26 | 27 | setSuccess(true); 28 | 29 | setTimeout(() => { 30 | closeModal(); 31 | }, ACTION_DELAYS.short); 32 | } 33 | 34 | function handleCancel() { 35 | // revert local changes to context 36 | setLocalSettings(settings); 37 | closeModal(); 38 | } 39 | 40 | function closeModal() { 41 | setIsOpen(false); 42 | setSuccess(false); 43 | } 44 | 45 | return ( 46 | 47 | 48 |
    49 | 50 |
    e.preventDefault()} 53 | > 54 | {/* Settings */} 55 | 63 | setLocalSettings({ ...localSettings, use12Hour: value }) 64 | } 65 | hasSuccess={success} 66 | /> 67 | 68 | {/* setLocalSettings({ ...localSettings, useSmartRestart: value })} 76 | hasSuccess={success} 77 | /> */} 78 | 79 | {/* Buttons */} 80 |
    81 | 87 | 90 |
    91 | 92 | 93 | 94 | ); 95 | } 96 | 97 | SettingsModal.propTypes = { 98 | isOpen: PropTypes.bool, 99 | setIsOpen: PropTypes.func, 100 | }; 101 | 102 | export default SettingsModal; 103 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "./pages/**/*.{js,ts,jsx,tsx}", 4 | "./components/**/*.{js,ts,jsx,tsx}", 5 | ], 6 | theme: { 7 | extend: { 8 | borderWidth: { 9 | 3: "3px", 10 | }, 11 | scale: { 12 | "-1": "-1", 13 | }, 14 | translate: { 15 | "-3/7": "-42.8571429%", 16 | }, 17 | ringWidth: { 18 | "5/2": "2.5px", 19 | }, 20 | }, 21 | colors: { 22 | transparent: "transparent", 23 | white: "#fff", 24 | black: "#000", 25 | primary: { 26 | 300: "#E8F0F2", 27 | 400: "#A2DBFA", 28 | 500: "#39A2DB", 29 | 600: "#0C6899", 30 | }, 31 | blocked: { 32 | 300: "#F8EEEC", 33 | 400: "#FFA79B", 34 | 500: "#F8644F", 35 | 600: "#DC311A", 36 | }, 37 | support: { 38 | success: "#00B076", 39 | error: "#F8644F", 40 | attention: "#F8D34F", 41 | }, 42 | gray: { 43 | 300: "#D9D9D9", 44 | 400: "#B2B2B2", 45 | 500: "#8C8C8C", 46 | 600: "#666666", 47 | 700: "#404040", 48 | }, 49 | }, 50 | screens: { 51 | 320: "320px", 52 | 360: "360px", 53 | 420: "420px", 54 | 438: "438px", 55 | 480: "480px", 56 | 540: "540px", 57 | 640: "640px", 58 | 732: "732px", 59 | 808: "808px", 60 | 932: "932px", 61 | 1172: "1172px", 62 | 1260: "1260px", 63 | }, 64 | spacing: { 65 | monopad: "26px", // width of 2.5 characters in monospaced font for timeline 66 | 0: "0px", 67 | 1: "1px", 68 | 2: "2px", 69 | 4: "4px", 70 | 6: "6px", 71 | 8: "8px", 72 | 12: "12px", 73 | 13: "13px", 74 | 16: "16px", 75 | 20: "20px", 76 | 24: "24px", 77 | 32: "32px", 78 | 40: "40px", 79 | 48: "48px", 80 | 52: "52px", 81 | 56: "56px", 82 | 64: "64px", 83 | 72: "72px", 84 | 78: "78px", 85 | 80: "80px", 86 | 96: "96px", 87 | 100: "100px", 88 | 120: "120px", 89 | 128: "128px", 90 | 130: "130px", 91 | 160: "160px", 92 | 200: "200px", 93 | 220: "220px", 94 | }, 95 | maxWidth: { 96 | 1600: "1600px", 97 | }, 98 | fontFamily: { 99 | base: ["Inter", "sans-serif"], 100 | mono: ["Azeret Mono", "monospace"], 101 | display: ["IntroDemo"], 102 | }, 103 | fontSize: { 104 | 8: ["8px", { lineHeight: "16px" }], 105 | 10: ["10px", { lineHeight: "16px" }], 106 | 13: ["13px", { lineHeight: "16px" }], 107 | 16: ["16px", { lineHeight: "24px" }], 108 | 20: ["20px", { lineHeight: "24px" }], 109 | 26: ["26px", { lineHeight: "32px" }], 110 | 32: ["32px", { lineHeight: "40px" }], 111 | 40: ["40px", { lineHeight: "48px" }], 112 | 50: ["50px", { lineHeight: "56px" }], 113 | 64: ["64px", { lineHeight: "72px" }], 114 | }, 115 | fontWeight: { 116 | reg: "400", 117 | med: "500", 118 | sbold: "600", 119 | bold: "700", 120 | black: "900", 121 | }, 122 | letterSpacing: { 123 | 2: "0.02em", 124 | }, 125 | borderRadius: { 126 | 0: "0px", 127 | 4: "4px", 128 | 6: "6px", 129 | 8: "8px", 130 | 20: "20px", 131 | }, 132 | }, 133 | plugins: [], 134 | }; 135 | -------------------------------------------------------------------------------- /context/Blueprint.js: -------------------------------------------------------------------------------- 1 | import { useReducer, useContext, createContext } from "react"; 2 | 3 | const BlueprintStateContext = createContext(); 4 | const BlueprintDispatchContext = createContext(); 5 | 6 | const actionTypes = { 7 | SET_DURATION: "SET_DURATION", 8 | SET_WORK: "SET_WORK", 9 | SET_BREAK: "SET_BREAK", 10 | SET_START: "SET_START", 11 | }; 12 | 13 | export { actionTypes as blueprintActions }; 14 | 15 | function reducer(blueprint, action) { 16 | switch (action.type) { 17 | case actionTypes.SET_DURATION: 18 | return { 19 | ...blueprint, 20 | duration: action.value, 21 | }; 22 | case actionTypes.SET_START: 23 | return { 24 | ...blueprint, 25 | startTime: action.value, 26 | }; 27 | case actionTypes.SET_WORK: 28 | return { 29 | ...blueprint, 30 | workDuration: action.value, 31 | }; 32 | case actionTypes.SET_BREAK: 33 | return { 34 | ...blueprint, 35 | breakDuration: action.value, 36 | }; 37 | default: 38 | return blueprint; 39 | } 40 | } 41 | 42 | export const BlueprintProvider = ({ children }) => { 43 | const [state, dispatch] = useReducer(reducer, {}); 44 | return ( 45 | 46 | 47 | {children} 48 | 49 | 50 | ); 51 | }; 52 | 53 | export const useBlueprint = () => useContext(BlueprintStateContext); 54 | export const useDispatchBlueprint = () => useContext(BlueprintDispatchContext); 55 | 56 | export const hours24 = [ 57 | { name: "00", value: 0 }, 58 | { name: "01", value: 1 }, 59 | { name: "02", value: 2 }, 60 | { name: "03", value: 3 }, 61 | { name: "04", value: 4 }, 62 | { name: "05", value: 5 }, 63 | { name: "06", value: 6 }, 64 | { name: "07", value: 7 }, 65 | { name: "08", value: 8 }, 66 | { name: "09", value: 9 }, 67 | { name: "10", value: 10 }, 68 | { name: "11", value: 11 }, 69 | { name: "12", value: 12 }, 70 | { name: "13", value: 13 }, 71 | { name: "14", value: 14 }, 72 | { name: "15", value: 15 }, 73 | { name: "16", value: 16 }, 74 | { name: "17", value: 17 }, 75 | { name: "18", value: 18 }, 76 | { name: "19", value: 19 }, 77 | { name: "20", value: 20 }, 78 | { name: "21", value: 21 }, 79 | { name: "22", value: 22 }, 80 | { name: "23", value: 23 }, 81 | ]; 82 | 83 | export const hours12 = [ 84 | { name: "12 AM", value: 0 }, 85 | { name: "1 AM", value: 1 }, 86 | { name: "2 AM", value: 2 }, 87 | { name: "3 AM", value: 3 }, 88 | { name: "4 AM", value: 4 }, 89 | { name: "5 AM", value: 5 }, 90 | { name: "6 AM", value: 6 }, 91 | { name: "7 AM", value: 7 }, 92 | { name: "8 AM", value: 8 }, 93 | { name: "9 AM", value: 9 }, 94 | { name: "10 AM", value: 10 }, 95 | { name: "11 AM", value: 11 }, 96 | { name: "12 PM", value: 12 }, 97 | { name: "1 PM", value: 13 }, 98 | { name: "2 PM", value: 14 }, 99 | { name: "3 PM", value: 15 }, 100 | { name: "4 PM", value: 16 }, 101 | { name: "5 PM", value: 17 }, 102 | { name: "6 PM", value: 18 }, 103 | { name: "7 PM", value: 19 }, 104 | { name: "8 PM", value: 20 }, 105 | { name: "9 PM", value: 21 }, 106 | { name: "10 PM", value: 22 }, 107 | { name: "11 PM", value: 23 }, 108 | ]; 109 | 110 | export const minutes = [ 111 | { name: "00", value: 0 }, 112 | { name: "15", value: 15 }, 113 | { name: "30", value: 30 }, 114 | { name: "45", value: 45 }, 115 | ]; 116 | -------------------------------------------------------------------------------- /components/molecules/cards/AuthCard.js: -------------------------------------------------------------------------------- 1 | import InputCard from "./InputCard"; 2 | import Header from "./Header"; 3 | import { iconTypes } from "../../atoms/Icon"; 4 | import TextInput, { textInputTypes } from "../../atoms/TextInput"; 5 | import Button, { buttonTypes } from "../../atoms/Button"; 6 | import { useState } from "react"; 7 | import { useAuth, errorTypes } from "../../../firebase/Firebase"; 8 | import { useRouter } from "next/router"; 9 | import { ACTION_DELAYS } from "../../../utils/constants"; 10 | 11 | export const authTypes = { 12 | signUp: "signUp", 13 | login: "login", 14 | }; 15 | 16 | function AuthCard({ type }) { 17 | const [username, setUsername] = useState(""); 18 | const [usernameError, setUsernameError] = useState(null); 19 | const [password, setPassword] = useState(""); 20 | const [passwordError, setPasswordError] = useState(null); 21 | const [success, setSuccess] = useState(false); 22 | const { signUp, signIn } = useAuth(); 23 | const router = useRouter(); 24 | 25 | async function handleSubmit(e) { 26 | e.preventDefault(); 27 | const action = type === authTypes.signUp ? signUp : signIn; 28 | 29 | const { user, error } = await action(username, password); 30 | 31 | if (user) { 32 | setSuccess(true); 33 | 34 | setTimeout(() => { 35 | router.push("/active"); 36 | }, ACTION_DELAYS.long); 37 | } else { 38 | const { msg, type } = error; 39 | 40 | switch (type) { 41 | case errorTypes.username: 42 | setUsernameError(msg); 43 | break; 44 | case errorTypes.password: 45 | setPasswordError(msg); 46 | break; 47 | case errorTypes.unknown: 48 | default: 49 | setUsernameError(msg); 50 | setPasswordError(msg); 51 | break; 52 | } 53 | } 54 | } 55 | 56 | return ( 57 | 58 |
    62 | 63 |
    68 | { 75 | setUsername(value); 76 | setUsernameError(null); 77 | }} 78 | errorLabel={usernameError} 79 | hasSuccess={success} 80 | > 81 | Your Username 82 | 83 | 84 | { 91 | setPassword(value); 92 | setPasswordError(null); 93 | }} 94 | errorLabel={passwordError} 95 | hasSuccess={success} 96 | > 97 | Your Password 98 | 99 | 100 |
    101 | 107 |
    108 |
    109 | 110 | ); 111 | } 112 | 113 | export default AuthCard; 114 | -------------------------------------------------------------------------------- /components/atoms/Timer.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { intervalTypes } from "../atoms/Interval"; 4 | 5 | const radius = 70; 6 | const FULL_ARC = 2 * Math.PI * radius; 7 | 8 | const units = { 9 | SECONDS: "SEC", 10 | MINUTES: "MIN", 11 | HOURS: "HRS", 12 | }; 13 | function Timer({ type, timeLeft, duration }) { 14 | const [timeDisplay, setTimeDisplay] = useState(getTimeDisplay()); 15 | 16 | function getTimeDisplay() { 17 | let number; 18 | let unit; 19 | 20 | if (timeLeft <= 60) { 21 | unit = units.SECONDS; 22 | number = timeLeft; 23 | } else if (timeLeft <= 3600) { 24 | unit = units.MINUTES; 25 | number = Math.ceil(timeLeft / 60); 26 | } else { 27 | unit = units.HOURS; 28 | number = Math.ceil(timeLeft / 3600); 29 | } 30 | 31 | return { 32 | number, 33 | unit, 34 | }; 35 | } 36 | 37 | useEffect(() => { 38 | setTimeDisplay(getTimeDisplay()); 39 | }, [timeLeft]); 40 | 41 | let ringColor = ""; 42 | let primaryColor = ""; 43 | let accentColor = ""; 44 | 45 | switch (type) { 46 | case intervalTypes.starting: 47 | primaryColor = "stroke-gray-400"; 48 | accentColor = "text-gray-600"; 49 | ringColor = "stroke-gray-300"; 50 | break; 51 | case intervalTypes.work: 52 | primaryColor = "stroke-primary-500"; 53 | accentColor = "text-primary-600"; 54 | ringColor = "stroke-primary-300"; 55 | break; 56 | case intervalTypes.break: 57 | primaryColor = "stroke-primary-400"; 58 | accentColor = "text-primary-600"; 59 | ringColor = "stroke-primary-300"; 60 | break; 61 | case intervalTypes.blocked: 62 | primaryColor = "stroke-blocked-500"; 63 | accentColor = "text-blocked-600"; 64 | ringColor = "stroke-blocked-300"; 65 | break; 66 | case intervalTypes.floating: 67 | primaryColor = "stroke-blocked-400"; 68 | accentColor = "text-blocked-600"; 69 | ringColor = "stroke-blocked-300"; 70 | break; 71 | default: 72 | break; 73 | } 74 | 75 | return ( 76 |
    77 | 83 | 91 | 102 | 103 |
    104 | 107 | {timeDisplay.number} 108 | 109 | 112 | {timeDisplay.unit} 113 | 114 |
    115 |
    116 | ); 117 | } 118 | 119 | Timer.propTypes = { 120 | type: PropTypes.string, 121 | timeLeft: PropTypes.number, 122 | duration: PropTypes.number, 123 | }; 124 | 125 | export default Timer; 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![home](https://github.com/bstoynov/need-to-break/assets/20927667/6e25a559-cf37-4daf-b0c3-adcd8cb5b3e0) 2 | 3 | ## Using The App 4 | 5 | ### Working With Timelines 6 | 7 | A Timeline is a sequence of work and break intervals that can help you track your screen time with the help of timers. 8 |

    9 | Go to the Timeline screen by clicking the **Timeline** tab in the top navigation: 10 | 11 | ![timelinescreen](https://github.com/bstoynov/need-to-break/assets/20927667/b9ad42ea-ac12-4ea9-b116-162c5fd20e4b) 12 |

    13 | 14 | Use the **Timeline Duration** and **Interval Size** controls to create your timeline: 15 | 16 | ![timdur](https://github.com/bstoynov/need-to-break/assets/20927667/c6ff66c6-792d-4080-96f3-62f177652004) 17 |

    18 | 19 | The generated Timeline will appear at the bottom of the page and it will be updated automatically every time you make a change. 20 |

    21 | Once you are done making changes, click on the **Start** button to start the Timeline: 22 | 23 | ![starttim](https://github.com/bstoynov/need-to-break/assets/20927667/8b79c4b1-3dd5-4685-aeee-6da9b5bd47ee) 24 |

    25 | 26 | Once you start the Timeline, you will see the active Timeline screen. Here you can keep track of your work and break intervals. 27 |

    28 | Cick on the **Stop Timeline** button if you want to delete the current active Timeline and start over: 29 | 30 | ![stop](https://github.com/bstoynov/need-to-break/assets/20927667/85c3cc90-4a7f-48cf-8487-7b41c3727cf3) 31 |

    32 | 33 | ### Using Presets 34 | 35 | You can save a Timeline to your account for later use. This is called a **Preset**. 36 |

    37 | Create a new Timeline from the Timeline screen and click the **Save** button: 38 | 39 | ![savetim](https://github.com/bstoynov/need-to-break/assets/20927667/2bc7a871-cfe9-4be9-8ec9-071b272d32c2) 40 |

    41 | 42 | You will see a modal prompting you to create an account. 43 |

    44 | Enter your username and password and click the **Sign Up** button: 45 | 46 | ![signupclick](https://github.com/bstoynov/need-to-break/assets/20927667/bbc1b5cf-b594-459a-8ba3-4e10c8deed62) 47 |

    48 | 49 | Once you create your account, click on the **Save** button once again. You should see a new modal appear prompting you to save your timeline. 50 |

    51 | Enter the name of your Preset and click the **Save** button: 52 | 53 | ![savepre](https://github.com/bstoynov/need-to-break/assets/20927667/76ca3342-8119-4ffe-ae93-d306a0dc2c56) 54 |

    55 | 56 | Once you have saved your Preset, you will be redirected to the **Presets** screen where you can manage all of your Presets. 57 |

    58 | To delete a Preset, click on the **Delete** button: 59 | 60 | ![delpre](https://github.com/bstoynov/need-to-break/assets/20927667/e30d9f20-6d8b-4ef9-b0ff-372dd1f40f32) 61 |

    62 | 63 | You will see a modal prompting you to delete the preset. 64 |

    65 | Click on the **Delete** button: 66 | 67 | ![delmodal](https://github.com/bstoynov/need-to-break/assets/20927667/dbf7777a-6e84-4bf0-8b1f-0b97b96ec31b) 68 | 69 | ## Local Setup 70 | 71 | ### 1. Install Packages 72 | 73 | Clone the repo and run `npm install` from the repository's root to install the required package dependencies 74 | 75 | ### 2. Running Locally 76 | 77 | Run `npm run dev` from the repository's root to start the local dev build. 78 | 79 | Go to `localhost:3000` to access the app: 80 | 81 | ![local](https://github.com/bstoynov/need-to-break/assets/20927667/701b9c84-3aa7-436d-bf97-8a7fa712b303) 82 |

    83 | 84 | ### (Optional) Configure Firebase 85 | 86 | If you want to be able to save presets you need to connect the app to Firebase. 87 | 88 | Go to https://firebase.google.com/ and create a new Web app. 89 | 90 | Go to https://console.firebase.google.com/ and find your app's `firebaseConfig`. 91 | 92 | Create a file called `.env.development.local` in the project's root and set your environment variables to match the `firebaseConfig`\ 93 | (You can reference the provided example `.env.development` file): 94 | 95 | ![envlocal](https://github.com/bstoynov/need-to-break/assets/20927667/4e12dae0-cb5e-4baf-9996-f17da9266ab5) 96 |

    97 | 98 | Restart the dev server by running `npm run dev` again. 99 | 100 | You should be able to create an account and save presets. 101 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import NavBar from "../components/molecules/NavBar"; 3 | import { useRouter } from "next/dist/client/router"; 4 | import Modal from "react-modal"; 5 | import Head from "next/head"; 6 | import { SettingsProvider } from "../context/Settings"; 7 | import { AnimatePresence, motion } from "framer-motion"; 8 | import { useEffect, useState } from "react"; 9 | import { APP_PAGES, TRANSITIONS } from "../utils/constants"; 10 | import { DIRECTIONS } from "../utils/constants"; 11 | import { PresetsProvider } from "../context/Presets"; 12 | // import { Analytics } from "@vercel/analytics/react"; 13 | Modal.setAppElement("#__next"); 14 | 15 | const pageVariants = { 16 | initial: (direction) => { 17 | switch (direction) { 18 | case DIRECTIONS.left: 19 | return { 20 | x: "-100vw", 21 | }; 22 | case DIRECTIONS.right: 23 | return { 24 | x: "100vw", 25 | }; 26 | case DIRECTIONS.vertical: 27 | return { 28 | y: "100vh", 29 | }; 30 | case DIRECTIONS.none: 31 | default: 32 | return false; 33 | } 34 | }, 35 | center: { 36 | x: 0, 37 | y: 0, 38 | transition: TRANSITIONS.spring500, 39 | }, 40 | exit: (direction) => { 41 | switch (direction) { 42 | case DIRECTIONS.left: 43 | return { 44 | x: "100vw", 45 | }; 46 | case DIRECTIONS.right: 47 | return { 48 | x: "-100vw", 49 | }; 50 | case DIRECTIONS.vertical: 51 | return { 52 | y: "100vh", 53 | }; 54 | case DIRECTIONS.none: 55 | default: 56 | return false; 57 | } 58 | }, 59 | }; 60 | 61 | export function isAppPage(url) { 62 | return Boolean(APP_PAGES.find((page) => page.url === url)); 63 | } 64 | 65 | function PageHead() { 66 | return ( 67 | 68 | Need To Break 69 | 70 | 71 | ); 72 | } 73 | 74 | function MyApp({ Component, pageProps }) { 75 | const router = useRouter(); 76 | const url = router.pathname; 77 | const [page, setPage] = useState({ 78 | direction: DIRECTIONS.vertical, 79 | url: url, 80 | }); 81 | 82 | useEffect(() => { 83 | if (page.url) { 84 | router.push(page.url); 85 | } 86 | }, [page]); 87 | 88 | function pushPage(targetUrl) { 89 | let direction; 90 | 91 | const currentIndex = APP_PAGES.findIndex((page) => page.url === url); 92 | const nextIndex = APP_PAGES.findIndex((page) => page.url === targetUrl); 93 | 94 | direction = 95 | nextIndex - currentIndex > 0 ? DIRECTIONS.right : DIRECTIONS.left; 96 | 97 | setPage({ 98 | url: targetUrl, 99 | direction, 100 | }); 101 | } 102 | 103 | function renderAppPage() { 104 | return ( 105 | 106 | 107 | 108 |
    109 | 110 |
    111 | 113 | setPage({ direction: DIRECTIONS.vertical }) 114 | } 115 | > 116 | 125 | 126 | {/* */} 127 | 128 | 129 |
    130 |
    131 |
    132 |
    133 | ); 134 | } 135 | 136 | function renderGenericPage() { 137 | return ( 138 | <> 139 | 140 | 141 | 142 | 143 | ); 144 | } 145 | 146 | if (isAppPage(url)) { 147 | return renderAppPage(); 148 | } else { 149 | return renderGenericPage(); 150 | } 151 | } 152 | 153 | export default MyApp; 154 | -------------------------------------------------------------------------------- /components/molecules/cards/AuthModal.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import InputCard from "./InputCard"; 3 | import Header from "./Header"; 4 | import { iconTypes } from "../../atoms/Icon"; 5 | import TextInput, { textInputTypes } from "../../atoms/TextInput"; 6 | import Button, { buttonTypes } from "../../atoms/Button"; 7 | import { useState } from "react"; 8 | import { useAuth, errorTypes } from "../../../firebase/Firebase"; 9 | import { useRouter } from "next/router"; 10 | import { ACTION_DELAYS } from "../../../utils/constants"; 11 | import Modal from "../Modal"; 12 | 13 | export const authTypes = { 14 | signUp: "signUp", 15 | login: "login", 16 | }; 17 | 18 | function AuthModal({ isOpen, setIsOpen }) { 19 | const [username, setUsername] = useState(""); 20 | const [usernameError, setUsernameError] = useState(null); 21 | const [password, setPassword] = useState(""); 22 | const [passwordError, setPasswordError] = useState(null); 23 | const [success, setSuccess] = useState(false); 24 | const [loginSuccess, setLoginSuccess] = useState(false); 25 | const [signUpSuccess, setSignUpSuccess] = useState(false); 26 | const { signUp, signIn } = useAuth(); 27 | 28 | function clearState() { 29 | setUsername(""); 30 | setUsernameError(null); 31 | setPassword(""); 32 | setPasswordError(null); 33 | setSuccess(false); 34 | setSignUpSuccess(false); 35 | setLoginSuccess(false); 36 | } 37 | 38 | async function handleSubmit(type) { 39 | setUsernameError(null); 40 | setPasswordError(null); 41 | 42 | const action = type === authTypes.signUp ? signUp : signIn; 43 | 44 | const { user, error } = await action(username, password); 45 | 46 | if (user) { 47 | setSuccess(true); 48 | 49 | type === authTypes.signUp 50 | ? setSignUpSuccess(true) 51 | : setLoginSuccess(true); 52 | 53 | setTimeout(() => { 54 | handleClose(); 55 | }, ACTION_DELAYS.long); 56 | } else { 57 | const { msg, type } = error; 58 | 59 | switch (type) { 60 | case errorTypes.username: 61 | setUsernameError(msg); 62 | break; 63 | case errorTypes.password: 64 | setPasswordError(msg); 65 | break; 66 | case errorTypes.unknown: 67 | default: 68 | setUsernameError(msg); 69 | setPasswordError(msg); 70 | break; 71 | } 72 | } 73 | } 74 | 75 | function handleClose() { 76 | clearState(); 77 | setIsOpen(false); 78 | } 79 | 80 | return ( 81 | 82 | 83 |
    88 | 89 |
    90 | { 97 | setUsername(value); 98 | setUsernameError(null); 99 | }} 100 | errorLabel={usernameError} 101 | hasSuccess={success} 102 | > 103 | Your username 104 | 105 | 106 | { 113 | setPassword(value); 114 | setPasswordError(null); 115 | }} 116 | errorLabel={passwordError} 117 | hasSuccess={success} 118 | > 119 | Your Password 120 | 121 | 122 |
    123 | 130 | 137 |
    138 |
    139 | 140 | 141 | ); 142 | } 143 | 144 | AuthModal.propTypes = { 145 | isOpen: PropTypes.bool, 146 | setIsOpen: PropTypes.func, 147 | }; 148 | 149 | export default AuthModal; 150 | -------------------------------------------------------------------------------- /utils/timelineUtil.js: -------------------------------------------------------------------------------- 1 | import { intervalTypes } from "../components/atoms/Interval"; 2 | import { SCALES } from "./constants"; 3 | import { parseStartTime, timestampToString } from "./timeUtil"; 4 | import { setStoredLocalStorage } from "./localStorageUtil"; 5 | import { useSettings } from "../context/Settings"; 6 | import { get12HourTime } from "./timeUtil"; 7 | 8 | function generateScales(intervals, totalDuration) { 9 | const scaleMap = {}; 10 | 11 | const scales = SCALES.filter((scale) => totalDuration % scale.value === 0); 12 | 13 | const smallestScale = scales[0].value; 14 | const numberOfBlocks = totalDuration / smallestScale; 15 | 16 | const timeLabels = []; 17 | 18 | for (let i = 0; i <= numberOfBlocks; i++) { 19 | const timestamp = intervals[0].timestamp + i * smallestScale * 1000; 20 | 21 | timeLabels.push(timestampToString(timestamp)); 22 | } 23 | 24 | for (const scale of scales) { 25 | scaleMap[scale.value] = timeLabels.filter( 26 | (timeLabel, i) => i % (scale.value / smallestScale) === 0, 27 | ); 28 | } 29 | 30 | return [scales, scaleMap]; 31 | } 32 | 33 | export function blueprintToStored(blueprint) { 34 | const { workDuration, breakDuration, startTime, duration, startWith } = 35 | blueprint; 36 | 37 | const intervals = []; 38 | let totalDuration = 0; 39 | let i = 0; 40 | 41 | do { 42 | let type; 43 | if (i === 0) { 44 | // use the startWith if exists or default to work 45 | type = startWith || intervalTypes.work; 46 | } else { 47 | type = 48 | intervals[i - 1].type === intervalTypes.work 49 | ? intervalTypes.break 50 | : intervalTypes.work; 51 | } 52 | 53 | const intervalDuration = 54 | type === intervalTypes.work ? workDuration : breakDuration; 55 | totalDuration += intervalDuration; 56 | 57 | const overflow = totalDuration - duration; 58 | let normalizedDuration = intervalDuration; 59 | 60 | if (overflow > 0) { 61 | normalizedDuration -= overflow; 62 | } 63 | 64 | intervals.push({ 65 | type, 66 | duration: normalizedDuration, 67 | }); 68 | 69 | i++; 70 | } while (totalDuration < duration); 71 | 72 | return { 73 | intervals, 74 | startTime, 75 | // we need these only when restarting 76 | workDuration, 77 | breakDuration, 78 | }; 79 | } 80 | 81 | export function storedToTimeline(stored) { 82 | const { intervals, startTime, workDuration, breakDuration } = stored; 83 | 84 | const parsedStartTime = 85 | typeof startTime === "string" ? parseStartTime(startTime) : startTime; 86 | 87 | let totalDuration = 0; 88 | 89 | for (let i = 0; i < intervals.length; i++) { 90 | let timestamp; 91 | 92 | if (i === 0) { 93 | timestamp = parsedStartTime; 94 | } else { 95 | timestamp = intervals[i - 1].timestamp + intervals[i - 1].duration * 1000; 96 | } 97 | 98 | const startLabel = timestampToString(timestamp); 99 | const endLabel = timestampToString( 100 | timestamp + intervals[i].duration * 1000, 101 | ); 102 | 103 | intervals[i].timestamp = timestamp; 104 | intervals[i].startLabel = startLabel; 105 | intervals[i].endLabel = endLabel; 106 | 107 | totalDuration += intervals[i].duration; 108 | } 109 | 110 | const [scales, scaleMap] = generateScales(intervals, totalDuration); 111 | 112 | return { 113 | intervals, 114 | scales, 115 | scaleMap, 116 | startTime: parsedStartTime, 117 | duration: totalDuration, 118 | // we need these only when restarting 119 | workDuration, 120 | breakDuration, 121 | }; 122 | } 123 | 124 | export function blueprintToTimeline(blueprint) { 125 | const stored = blueprintToStored(blueprint); 126 | const timeline = storedToTimeline(stored); 127 | 128 | return timeline; 129 | } 130 | 131 | export function startTimeline(blueprint) { 132 | const stored = blueprintToStored(blueprint); 133 | setStoredLocalStorage(stored); 134 | } 135 | 136 | export function getDetails(blueprint) { 137 | const { use12Hour } = useSettings(); 138 | 139 | const type = 140 | typeof blueprint.startTime === "number" ? "Flexible" : "Full Time"; 141 | 142 | const startTime = isNaN(blueprint.startTime) 143 | ? parseStartTime(blueprint.startTime) 144 | : blueprint.startTime; 145 | const endTime = startTime + blueprint.duration * 1000; 146 | 147 | let duration; 148 | if (use12Hour) { 149 | const [startHour, startMin, startSuffix] = get12HourTime( 150 | timestampToString(startTime), 151 | ); 152 | const [endHour, endMin, endSuffix] = get12HourTime( 153 | timestampToString(endTime), 154 | ); 155 | duration = `${startHour}:${startMin} ${startSuffix} to ${endHour}:${endMin} ${endSuffix}`; 156 | } else { 157 | duration = `${timestampToString(startTime)} to ${timestampToString( 158 | endTime, 159 | )}`; 160 | } 161 | 162 | return { 163 | type, 164 | workDuration: blueprint.workDuration / 60, 165 | breakDuration: blueprint.breakDuration / 60, 166 | duration, 167 | }; 168 | } 169 | -------------------------------------------------------------------------------- /components/molecules/cards/SavePresetModal.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import InputCard from "./InputCard"; 3 | import Header from "./Header"; 4 | import { iconTypes } from "../../atoms/Icon"; 5 | import TextInput, { textInputTypes } from "../../atoms/TextInput"; 6 | import Button, { buttonTypes } from "../../atoms/Button"; 7 | import Label, { labelTypes } from "../../atoms/Label"; 8 | import Modal from "../Modal"; 9 | import { useDB } from "../../../firebase/Firebase"; 10 | import { useState } from "react"; 11 | import { getDetails } from "../../../utils/timelineUtil"; 12 | import { ACTION_DELAYS } from "../../../utils/constants"; 13 | import { useFetchPresets } from "../../../context/Presets"; 14 | import { useRouter } from "next/router"; 15 | 16 | function SavePresetModal({ isOpen, setIsOpen, blueprint }) { 17 | const { savePreset } = useDB(); 18 | const fetchPresets = useFetchPresets(); 19 | const [name, setName] = useState(""); 20 | const [nameError, setNameError] = useState(""); 21 | const [success, setSuccess] = useState(false); 22 | const router = useRouter(); 23 | 24 | function handleClose(refetch) { 25 | setName(""); 26 | setNameError(""); 27 | setSuccess(false); 28 | setIsOpen(false); 29 | 30 | if (refetch) { 31 | fetchPresets(); 32 | } 33 | 34 | router.push("/presets"); 35 | } 36 | 37 | async function handleSave(e) { 38 | e.preventDefault(); 39 | 40 | if (!name) { 41 | return setNameError("Name is required"); 42 | } 43 | 44 | if (blueprint) { 45 | const preset = { 46 | ...blueprint, 47 | name, 48 | }; 49 | 50 | if (typeof preset.startTime === "number") { 51 | // remove timestamp for flexible timeline 52 | delete preset.startTime; 53 | } 54 | 55 | const error = await savePreset(preset); 56 | 57 | if (!error) { 58 | // success 59 | setSuccess(true); 60 | 61 | setTimeout(() => { 62 | handleClose(true); 63 | }, ACTION_DELAYS.short); 64 | } 65 | } 66 | } 67 | 68 | const details = getDetails(blueprint); 69 | 70 | return ( 71 | 72 | 73 |
    78 | 79 |
    84 | {/* Preset Name */} 85 | { 93 | setNameError(""); 94 | setName(value); 95 | }} 96 | errorLabel={nameError} 97 | hasSuccess={success} 98 | > 99 | Preset Name 100 | 101 | 102 | {/* Details */} 103 |
    104 | 107 |
    108 |
    109 |
    Type
    110 |
    {details.type}
    111 |
    112 | 113 |
    114 |
    Timeline
    115 |
    {details.duration}
    116 |
    117 | 118 |
    119 |
    Intervals
    120 |
    121 | W: {details.workDuration} min, B: {details.breakDuration} min 122 |
    123 |
    124 | 125 | {/*
    126 |
    Blocked
    127 |
    128 |
    12:00 to 12:30
    129 |
    17:00 to 17:30
    130 |
    131 |
    */} 132 |
    133 |
    134 | 135 | {/* Buttons */} 136 |
    137 | 143 | 146 |
    147 |
    148 | 149 | 150 | ); 151 | } 152 | 153 | SavePresetModal.propTypes = { 154 | isOpen: PropTypes.bool, 155 | setIsOpen: PropTypes.func, 156 | }; 157 | 158 | export default SavePresetModal; 159 | -------------------------------------------------------------------------------- /components/molecules/Preset.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import ViewMoreLess from "../atoms/ViewMoreLess"; 4 | import Timeline from "./Timeline"; 5 | import Label, { labelTypes } from "../atoms/Label"; 6 | import Button, { buttonTypes } from "../atoms/Button"; 7 | import Icon, { iconTypes } from "../atoms/Icon"; 8 | import DeletePresetModal from "./cards/DeletePresetModal"; 9 | import { blueprintToTimeline, startTimeline } from "../../utils/timelineUtil"; 10 | import { parseStartTime } from "../../utils/timeUtil"; 11 | import { useRouter } from "next/router"; 12 | import { getDetails } from "../../utils/timelineUtil"; 13 | import { useAuth } from "../../firebase/Firebase"; 14 | import TimelineHasEndedModal from "./cards/TimelineHasEndedModal"; 15 | 16 | function Preset({ preset }) { 17 | const [deleteModalIsOpen, setDeleteModalIsOpen] = useState(false); 18 | const [endedModalIsOpen, setEndedModalIsOpen] = useState(false); 19 | const [viewMore, setViewMore] = useState(false); 20 | const [timeline, setTimeline] = useState(); 21 | const router = useRouter(); 22 | const { user } = useAuth(); 23 | 24 | useEffect(() => { 25 | setTimeline(blueprintToTimeline(preset)); 26 | }, []); 27 | 28 | function handleStart() { 29 | if (preset && user) { 30 | const startTime = parseStartTime(preset.startTime); 31 | const endTime = startTime + preset.duration * 1000; 32 | 33 | if (Date.now() <= endTime) { 34 | startTimeline(preset); 35 | router.push("/timeline"); 36 | } else { 37 | setEndedModalIsOpen(true); 38 | } 39 | } 40 | } 41 | 42 | const details = getDetails(preset); 43 | 44 | return timeline ? ( 45 |
    46 | {/* Icon & Name */} 47 |
    48 | 49 | 52 |
    53 | 54 | {/* Info List */} 55 |
      56 |
    • 57 | 60 |

      {details.type}

      61 |
    • 62 | 63 |
    • 64 | 67 |

      {details.duration}

      68 |
    • 69 | 70 |
    • 71 | 74 |

      75 | Work: {details.workDuration} min 76 |
      77 | Break: {details.breakDuration} min 78 |

      79 |
    • 80 | 81 | {/*
    • 82 | 83 |

      84 | 12:00 to 12:30 85 |
      86 | 17:00 to 17:15 87 |

      88 |
    • */} 89 |
    90 | 91 | {/* Timeline & Buttons (Able To Reverse Flex Order) */} 92 |
    93 |
    94 | 97 | 103 |
    104 | 105 | {/* Timeline Block */} 106 | setViewMore(!viewMore)} 112 | > 113 |
    114 | 115 |
    116 |
    117 |
    118 | 119 | {/* Delete Preset Modal */} 120 | 126 | 127 | {/* Timeline Has Ended */} 128 | 132 |
    133 | ) : null; 134 | } 135 | 136 | Preset.propTypes = { 137 | name: PropTypes.string, 138 | }; 139 | 140 | export default Preset; 141 | -------------------------------------------------------------------------------- /components/molecules/cards/TimeInput.js: -------------------------------------------------------------------------------- 1 | import { useReducer, useEffect, useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import Label, { labelTypes } from "../../atoms/Label"; 4 | import SelectInput from "../../atoms/SelectInput"; 5 | import { 6 | useDispatchBlueprint, 7 | blueprintActions, 8 | hours24, 9 | hours12, 10 | minutes, 11 | } from "../../../context/Blueprint"; 12 | import { parseTime } from "../../../utils/timeUtil"; 13 | import { useSettings } from "../../../context/Settings"; 14 | 15 | const actionTypes = { 16 | SET_START_HOUR: "SET_START_HOUR", 17 | SET_END_HOUR: "SET_END_HOUR", 18 | SET_START_MIN: "SET_START_MIN", 19 | SET_END_MIN: "SET_END_MIN", 20 | }; 21 | 22 | const initialState = { 23 | startHour: hours24[0].value, 24 | startMin: minutes[0].value, 25 | endHour: hours24[0].value, 26 | endMin: minutes[0].value, 27 | }; 28 | 29 | function TimeInput({ paddingStyle, disableFocus }) { 30 | const [{ startHour, startMin, endHour, endMin }, dispatch] = useReducer( 31 | reducer, 32 | initialState, 33 | ); 34 | const [hours, setHours] = useState(hours24); 35 | const [widthStyle, setWidthStyle] = useState(); 36 | const blueprintDispatch = useDispatchBlueprint(); 37 | 38 | const { use12Hour } = useSettings(); 39 | 40 | useEffect(() => { 41 | blueprintDispatch({ 42 | type: blueprintActions.SET_START, 43 | value: parseTime([startHour, startMin]), 44 | }); 45 | }, [startHour, startMin]); 46 | 47 | useEffect(() => { 48 | const startDate = new Date(); 49 | startDate.setHours(startHour); 50 | startDate.setMinutes(startMin); 51 | 52 | const endDate = new Date(); 53 | endDate.setHours(endHour); 54 | endDate.setMinutes(endMin); 55 | 56 | // Math rouund is very important because sometimes it would return weird decimal numbers which will evaluate to true and crash the page 57 | const duration = Math.round((endDate - startDate) / 1000); 58 | 59 | if (duration <= 0) { 60 | dispatch({ type: actionTypes.SET_END_HOUR, value: startHour }); 61 | } 62 | 63 | blueprintDispatch({ type: blueprintActions.SET_DURATION, value: duration }); 64 | }, [startHour, startMin, endHour, endMin]); 65 | 66 | useEffect(() => { 67 | if (use12Hour) { 68 | setHours(hours12); 69 | setWidthStyle("w-80 420:w-96"); 70 | } else { 71 | setHours(hours24); 72 | setWidthStyle("w-64 420:w-72"); 73 | } 74 | }, [use12Hour]); 75 | 76 | return ( 77 |
    78 | {/* From */} 79 |
    80 | 83 |
    84 | 91 | dispatch({ type: actionTypes.SET_START_HOUR, value }) 92 | } 93 | /> 94 | : 95 | 102 | dispatch({ type: actionTypes.SET_START_MIN, value }) 103 | } 104 | /> 105 |
    106 |
    107 | 108 | {/* To */} 109 |
    110 | 113 |
    114 | 121 | dispatch({ type: actionTypes.SET_END_HOUR, value }) 122 | } 123 | /> 124 | : 125 | 132 | dispatch({ type: actionTypes.SET_END_MIN, value }) 133 | } 134 | /> 135 |
    136 |
    137 |
    138 | ); 139 | } 140 | 141 | function reducer(state, action) { 142 | switch (action.type) { 143 | case actionTypes.SET_START_HOUR: 144 | return { 145 | ...state, 146 | startHour: action.value, 147 | }; 148 | case actionTypes.SET_END_HOUR: 149 | return { 150 | ...state, 151 | endHour: action.value, 152 | }; 153 | case actionTypes.SET_START_MIN: 154 | return { 155 | ...state, 156 | startMin: action.value, 157 | }; 158 | case actionTypes.SET_END_MIN: 159 | return { 160 | ...state, 161 | endMin: action.value, 162 | }; 163 | default: 164 | return state; 165 | } 166 | } 167 | 168 | TimeInput.propTypes = { 169 | paddingStyle: PropTypes.string, 170 | disableFocus: PropTypes.bool, 171 | }; 172 | 173 | export default TimeInput; 174 | -------------------------------------------------------------------------------- /components/atoms/NumberInput.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | import Label, { labelTypes } from "./Label"; 4 | 5 | const types = { 6 | minus: "minus", 7 | plus: "plus", 8 | }; 9 | 10 | export { types as numberInputTypes }; 11 | 12 | const valueToString = (value, unit) => { 13 | return unit ? `${value} ${unit}` : `${value}`; 14 | }; 15 | function NumberInput({ 16 | name, 17 | value, 18 | handleChange, 19 | step, 20 | min, 21 | max, 22 | unit: unitText, 23 | widthStyle, 24 | bigLabel, 25 | smallLabel, 26 | centerBig, 27 | centerSmall, 28 | }) { 29 | const [unit, setUnit] = useState(unitText); 30 | const [stringValue, setStringValue] = useState(valueToString(value)); 31 | 32 | useEffect(() => { 33 | setStringValue(valueToString(value, unit)); 34 | }, [value, unit]); 35 | 36 | const handleClick = (type, step, min, max) => { 37 | if (type === types.plus) { 38 | if (value + step <= max) { 39 | handleChange(value + step); 40 | } 41 | } else { 42 | if (value - step >= min) { 43 | handleChange(value - step); 44 | } 45 | } 46 | }; 47 | 48 | const handleInput = (e) => { 49 | const newValue = Number(e.target.value); 50 | 51 | if (!isNaN(newValue)) { 52 | setUnit(null); 53 | handleChange(Number(newValue)); 54 | } 55 | }; 56 | 57 | const handleBlur = (min, max) => { 58 | let normalizedValue = value; 59 | 60 | if (value < min) { 61 | normalizedValue = min; 62 | } else if (value > max) { 63 | normalizedValue = max; 64 | } 65 | 66 | setUnit(unitText); 67 | handleChange(normalizedValue); 68 | }; 69 | 70 | const renderBigLabel = bigLabel ? ( 71 | 79 | ) : null; 80 | 81 | const renderSmallLabel = smallLabel ? ( 82 | 90 | ) : null; 91 | 92 | return ( 93 |
    94 | {renderBigLabel} 95 | {renderSmallLabel} 96 |
    97 |
    115 |
    116 | ); 117 | } 118 | 119 | NumberInput.propTypes = { 120 | name: PropTypes.string, 121 | selected: PropTypes.number, 122 | step: PropTypes.number, 123 | min: PropTypes.number, 124 | max: PropTypes.number, 125 | unit: PropTypes.string, 126 | widthStyle: PropTypes.string, 127 | smallLabel: PropTypes.string, 128 | bigLabel: PropTypes.string, 129 | centerBig: PropTypes.bool, 130 | centerSmall: PropTypes.bool, 131 | }; 132 | 133 | function Button({ type, handleClick }) { 134 | const baseStyle = 135 | "flex justify-center items-center align-center w-32 h-32 420:w-40 420:h-40 bg-primary-500 outline-none focus-visible:bg-primary-600"; 136 | 137 | let typeStyle = ""; 138 | switch (type) { 139 | case types.minus: 140 | typeStyle = "rounded-l-6"; 141 | break; 142 | case types.plus: 143 | typeStyle = "rounded-r-6"; 144 | break; 145 | default: 146 | break; 147 | } 148 | 149 | return ( 150 | 153 | ); 154 | } 155 | 156 | Button.propTypes = { 157 | type: PropTypes.string, 158 | handleClick: PropTypes.func, 159 | }; 160 | 161 | function Icon({ type }) { 162 | switch (type) { 163 | case types.plus: 164 | return ( 165 | 171 | 172 | 179 | 180 | ); 181 | case types.minus: 182 | return ( 183 | 189 | 195 | 196 | ); 197 | default: 198 | return null; 199 | } 200 | } 201 | 202 | Icon.propTypes = { 203 | type: PropTypes.string, 204 | }; 205 | 206 | export default NumberInput; 207 | -------------------------------------------------------------------------------- /firebase/Firebase.js: -------------------------------------------------------------------------------- 1 | import { initializeApp, getApps, getApp } from "firebase/app"; 2 | import { 3 | getAuth, 4 | onAuthStateChanged, 5 | createUserWithEmailAndPassword, 6 | signInWithEmailAndPassword, 7 | signOut as signOutFirebase, 8 | } from "firebase/auth"; 9 | import { 10 | addDoc, 11 | collection, 12 | getDocs, 13 | deleteDoc, 14 | doc, 15 | getFirestore, 16 | } from "firebase/firestore"; 17 | import { useEffect, useState } from "react"; 18 | 19 | const firebaseConfig = { 20 | apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, 21 | authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, 22 | projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, 23 | storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, 24 | messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, 25 | appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, 26 | }; 27 | 28 | export const errorTypes = { 29 | password: "password", 30 | username: "username", 31 | unknown: "unknown", 32 | }; 33 | 34 | const errorMap = { 35 | "auth/user-not-found": { 36 | msg: "User not found", 37 | type: errorTypes.username, 38 | }, 39 | "auth/email-already-in-use": { 40 | msg: "Username has already been used", 41 | type: errorTypes.username, 42 | }, 43 | "auth/invalid-email": { 44 | msg: "Invalid username", 45 | type: errorTypes.username, 46 | }, 47 | "auth/missing-email": { 48 | msg: "Missing username", 49 | type: errorTypes.username, 50 | }, 51 | "auth/too-many-requests": { 52 | msg: "Account locked: too many login attempts", 53 | type: errorTypes.username, 54 | }, 55 | "auth/wrong-password": { 56 | msg: "Incorrect password", 57 | type: errorTypes.password, 58 | }, 59 | "auth/weak-password": { 60 | msg: "Should be at least 6 characters", 61 | type: errorTypes.password, 62 | }, 63 | }; 64 | 65 | function getErrorMessage(code) { 66 | return ( 67 | errorMap[code] || { 68 | msg: "There was an error", 69 | type: errorTypes.unknown, 70 | } 71 | ); 72 | } 73 | 74 | function getAppInstance() { 75 | // initialize the app only once 76 | return !getApps().length ? initializeApp(firebaseConfig) : getApp(); 77 | } 78 | 79 | function getAuthInstance() { 80 | const app = getAppInstance(); 81 | return getAuth(app); 82 | } 83 | 84 | function getDBInstance() { 85 | const app = getAppInstance(); 86 | return getFirestore(app); 87 | } 88 | 89 | export function useDB() { 90 | async function savePreset(preset) { 91 | try { 92 | const userId = getAuth().currentUser.uid; 93 | await addDoc( 94 | collection(getDBInstance(), `users/${userId}/presets`), 95 | preset, 96 | ); 97 | } catch (error) { 98 | console.error(error); 99 | return error; 100 | } 101 | } 102 | 103 | async function getPresets() { 104 | try { 105 | const userId = getAuth().currentUser.uid; 106 | const querySnapshot = await getDocs( 107 | collection(getDBInstance(), `users/${userId}/presets`), 108 | ); 109 | const presets = querySnapshot.docs.map((doc) => { 110 | return { 111 | id: doc.id, 112 | ...doc.data(), 113 | }; 114 | }); 115 | 116 | return { 117 | presets, 118 | }; 119 | } catch (error) { 120 | console.error(error); 121 | return { 122 | error, 123 | }; 124 | } 125 | } 126 | 127 | async function deletePreset(id) { 128 | try { 129 | const userId = getAuth().currentUser.uid; 130 | await deleteDoc(doc(getDBInstance(), `users/${userId}/presets`, id)); 131 | } catch (error) { 132 | console.error(error); 133 | return error; 134 | } 135 | } 136 | 137 | return { 138 | savePreset, 139 | deletePreset, 140 | getPresets, 141 | }; 142 | } 143 | 144 | export function useAuth() { 145 | const [user, setUser] = useState(); 146 | const [userLoading, setUserLoading] = useState(true); 147 | 148 | useEffect(() => { 149 | const unsubscribe = onAuthStateChanged(getAuthInstance(), (user) => { 150 | setUser(user || null); 151 | 152 | setUserLoading(false); 153 | }); 154 | 155 | return unsubscribe; 156 | }, []); 157 | 158 | async function signUp(username, password) { 159 | setUserLoading(true); 160 | 161 | try { 162 | const result = await createUserWithEmailAndPassword( 163 | getAuthInstance(), 164 | `${username}@needtobreak.com`, 165 | password, 166 | ); 167 | 168 | return { 169 | user: result.user, 170 | }; 171 | } catch (error) { 172 | console.error(error); 173 | 174 | return { 175 | error: getErrorMessage(error.code), 176 | }; 177 | } 178 | } 179 | 180 | async function signIn(username, password) { 181 | setUserLoading(true); 182 | 183 | try { 184 | const result = await signInWithEmailAndPassword( 185 | getAuthInstance(), 186 | `${username}@needtobreak.com`, 187 | password, 188 | ); 189 | 190 | return { 191 | user: result.user, 192 | }; 193 | } catch (error) { 194 | console.error(error); 195 | 196 | return { 197 | error: getErrorMessage(error.code), 198 | }; 199 | } 200 | } 201 | 202 | async function signOut() { 203 | try { 204 | await signOutFirebase(getAuthInstance()); 205 | } catch (error) { 206 | console.error(error); 207 | return error; 208 | } 209 | } 210 | 211 | return { 212 | user, 213 | userLoading, 214 | signUp, 215 | signIn, 216 | signOut, 217 | }; 218 | } 219 | -------------------------------------------------------------------------------- /components/molecules/TimelinePreview.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import ViewMoreLess from "../atoms/ViewMoreLess"; 4 | import Timeline from "./Timeline"; 5 | import Label, { labelTypes } from "../atoms/Label"; 6 | import Button, { buttonTypes } from "../atoms/Button"; 7 | import Icon, { iconTypes } from "../atoms/Icon"; 8 | import SavePresetModal from "../molecules/cards/SavePresetModal"; 9 | import TimelineHasEndedModal from "./cards/TimelineHasEndedModal"; 10 | import { useBlueprint } from "../../context/Blueprint"; 11 | import { blueprintToTimeline, startTimeline } from "../../utils/timelineUtil"; 12 | import { parseStartTime } from "../../utils/timeUtil"; 13 | import { useRouter } from "next/router"; 14 | import useIsomorphicLayoutEffect from "../../utils/useIsomorphicLayoutEffect"; 15 | import AuthModal from "./cards/AuthModal"; 16 | import { useAuth } from "../../firebase/Firebase"; 17 | function TimelinePreview({ hasFloating }) { 18 | const [saveModalIsOpen, setSaveModalIsOpen] = useState(false); 19 | const [authModalIsOpen, setAuthModalIsOpen] = useState(false); 20 | const [endedModalIsOpen, setEndedModalIsOpen] = useState(false); 21 | const blueprint = useBlueprint(); 22 | const [timeline, setTimeline] = useState(); 23 | const [viewMore, setViewMore] = useState(false); 24 | const router = useRouter(); 25 | const { user } = useAuth(); 26 | 27 | useIsomorphicLayoutEffect(() => { 28 | if (blueprint.duration) { 29 | const timeline = blueprintToTimeline(blueprint); 30 | setTimeline(timeline); 31 | } else { 32 | setTimeline(null); 33 | } 34 | }, [blueprint]); 35 | 36 | function handleStart() { 37 | if (blueprint) { 38 | const startTime = parseStartTime(blueprint.startTime); 39 | const endTime = startTime + blueprint.duration * 1000; 40 | 41 | if (Date.now() <= endTime) { 42 | startTimeline(blueprint); 43 | router.reload(); 44 | } else { 45 | setEndedModalIsOpen(true); 46 | } 47 | } 48 | } 49 | 50 | function handleSave() { 51 | if (user) { 52 | setSaveModalIsOpen(true); 53 | } else { 54 | setAuthModalIsOpen(true); 55 | } 56 | } 57 | 58 | const floatingStyle = hasFloating ? "932:grid-cols-2" : ""; 59 | 60 | return timeline ? ( 61 |
    62 |
    65 | {/* Timeline Preview Block */} 66 | {/*
    67 |
    68 | 69 |
    70 |
    71 | 74 |

    75 | A visual representation of your timeline. You can either generate 76 | it now or save it as a preset for later. 77 |

    78 |
    79 |
    */} 80 | 81 | {/* Floating Time Block (If Any)*/} 82 | {hasFloating ? ( 83 |
    84 |
    85 | 86 |
    87 |
    88 | 91 |

    92 | We have filled the empty blocks around your blocked time with 93 | floating time. This is unmanaged time and it’s up to you to 94 | decide how you spend it. 95 |

    96 |
    97 |
    98 | ) : null} 99 |
    100 | {/* Timeline & Buttons (Able To Reverse Flex Order) */} 101 |
    102 |
    103 | 106 | 109 |
    110 | 111 | {/* Timeline Block */} 112 | setViewMore(!viewMore)} 118 | > 119 |
    120 | 121 |
    122 |
    123 |
    124 | 125 | {/* Save Preset Modal */} 126 | 131 | 132 | {/* Auth Modal */} 133 | 134 | 135 | {/* Timeline Has Ended */} 136 | 140 |
    141 | ) : null; 142 | } 143 | 144 | TimelinePreview.propTypes = { 145 | hasFloating: PropTypes.bool, 146 | }; 147 | 148 | export default TimelinePreview; 149 | -------------------------------------------------------------------------------- /components/molecules/cards/Carousel.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React, { useState } from "react"; 3 | import ActionButton, { actionButtonTypes } from "../../atoms/ActionButton"; 4 | import Arrow, { arrowTypes } from "../../atoms/Arrow"; 5 | import { v4 as uuidv4 } from "uuid"; 6 | 7 | function generateInitialPages(numOfPages) { 8 | const pages = []; 9 | 10 | for (let i = 0; i < numOfPages; i++) { 11 | pages.push({ id: uuidv4() }); 12 | } 13 | 14 | return pages; 15 | } 16 | 17 | function Carousel({ initialPages, renderItem, infinite, pageLimit }) { 18 | const [pages, setPages] = useState(generateInitialPages(initialPages)); 19 | const [currentPage, setCurrentPage] = useState(0); 20 | 21 | const handleArrowClick = (type) => { 22 | if (type === arrowTypes.right) { 23 | if (currentPage === pages.length - 1) { 24 | if (infinite) { 25 | setCurrentPage(0); 26 | } 27 | } else { 28 | setCurrentPage(currentPage + 1); 29 | } 30 | } else { 31 | if (currentPage === 0) { 32 | if (infinite) { 33 | setCurrentPage(pages.length - 1); 34 | } 35 | } else { 36 | setCurrentPage(currentPage - 1); 37 | } 38 | } 39 | }; 40 | 41 | const handleActionClick = (type) => { 42 | if (type === actionButtonTypes.add) { 43 | if (pageLimit && pageLimit === pages.length) { 44 | return; 45 | } else { 46 | setPages([...pages, { id: uuidv4() }]); 47 | setCurrentPage(pages.length); 48 | } 49 | } else { 50 | if (pages.length > 1) { 51 | setPages(pages.filter((page, index) => index !== currentPage)); 52 | 53 | if (currentPage === 0) { 54 | } else { 55 | setCurrentPage(currentPage - 1); 56 | } 57 | } 58 | } 59 | }; 60 | 61 | const handlePageClick = (id) => { 62 | setCurrentPage(pages.findIndex((page) => page.id === id)); 63 | }; 64 | 65 | return ( 66 |
    67 | {/* Body */} 68 |
    69 | 75 | 76 | {/* Placeholder Item */} 77 |
    {renderItem(true)}
    78 | 79 | {/* Items */} 80 |
      86 | {pages.map((page) => { 87 | const disableFocus = 88 | pages.findIndex((item) => item.id === page.id) !== currentPage; 89 | 90 | return
    • {renderItem(disableFocus)}
    • ; 91 | })} 92 |
    93 |
    94 | 95 | {/* Pagination */} 96 | 101 | 102 | {/* Buttons */} 103 |
    104 | 108 | 112 |
    113 |
    114 | ); 115 | } 116 | 117 | Carousel.propTypes = { 118 | initialPages: PropTypes.number, 119 | renderItem: PropTypes.func, 120 | infinite: PropTypes.bool, 121 | pageLimit: PropTypes.number, 122 | }; 123 | 124 | function Pagination({ pages, handleClick, currentPage }) { 125 | return ( 126 |
      127 | {pages.map((page, index) => { 128 | const baseStyle = 129 | "appearance-none block w-6 420:w-8 h-6 420:h-8 rounded-4"; 130 | const bgStyle = 131 | currentPage === index ? "bg-primary-500" : "bg-gray-400"; 132 | 133 | return ( 134 |
    • 135 |
    • 141 | ); 142 | })} 143 |
    144 | ); 145 | } 146 | 147 | Pagination.propTypes = { 148 | pages: PropTypes.array, 149 | handleClick: PropTypes.func, 150 | currentPage: PropTypes.number, 151 | }; 152 | 153 | function Navigation({ handleClick, currentPage, totalPages, infinite }) { 154 | const hideLeft = currentPage === 0 && !infinite; 155 | const hideRight = currentPage === totalPages - 1 && !infinite; 156 | 157 | return ( 158 | <> 159 | {!hideLeft ? ( 160 | 161 | ) : null} 162 | {!hideRight ? ( 163 | 164 | ) : null} 165 | 166 | ); 167 | } 168 | 169 | Navigation.propTypes = { 170 | handleClick: PropTypes.func, 171 | currentPage: PropTypes.number, 172 | totalPages: PropTypes.number, 173 | infinite: PropTypes.bool, 174 | }; 175 | 176 | function ArrowButton({ type, handleClick }) { 177 | const baseStyle = "absolute top-1/2 z-10 group outline-none"; 178 | 179 | let typeStyle = ""; 180 | 181 | if (type === arrowTypes.left) { 182 | typeStyle = "left-0"; 183 | } else { 184 | typeStyle = "right-0"; 185 | } 186 | 187 | return ( 188 | 194 | ); 195 | } 196 | 197 | ArrowButton.propTypes = { 198 | type: PropTypes.string, 199 | handleClick: PropTypes.func, 200 | }; 201 | 202 | export default Carousel; 203 | -------------------------------------------------------------------------------- /components/molecules/NavBar.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import Tab, { tabTypes } from "../atoms/Tab"; 4 | import SettingsModal from "./cards/SettingsModal"; 5 | import LogoutModal from "./cards/LogoutModal"; 6 | 7 | const forTypes = { 8 | desktop: "desktop", 9 | mobile: "mobile", 10 | }; 11 | 12 | const types = { 13 | settings: "settings", 14 | logout: "logout", 15 | }; 16 | 17 | function NavBar({ tabs, active, handlePageChange }) { 18 | const [settingsIsOpen, setSettingsIsOpen] = useState(false); 19 | const [logoutIsOpen, setLogoutIsOpen] = useState(false); 20 | 21 | return ( 22 | 65 | ); 66 | } 67 | 68 | function NavButton({ invisible, forType, handleClick, type }) { 69 | let forStyle = ""; 70 | 71 | switch (forType) { 72 | case forTypes.desktop: 73 | forStyle = "hidden 732:block group outline-none"; 74 | break; 75 | case forTypes.mobile: 76 | forStyle = "block 732:hidden"; 77 | break; 78 | default: 79 | forStyle = "block"; 80 | break; 81 | } 82 | 83 | let icon; 84 | switch (type) { 85 | case types.settings: 86 | icon = ; 87 | break; 88 | case types.logout: 89 | icon = ; 90 | break; 91 | default: 92 | return null; 93 | } 94 | 95 | let invisibleStyle = invisible ? "invisible" : ""; 96 | 97 | return ( 98 | 101 | ); 102 | } 103 | 104 | function SettingsIcon() { 105 | return ( 106 | 112 | 118 | 119 | ); 120 | } 121 | 122 | function LogoutIcon() { 123 | return ( 124 | 130 | 136 | 137 | ); 138 | } 139 | 140 | NavButton.propTypes = { 141 | type: PropTypes.string, 142 | invisible: PropTypes.bool, 143 | handleClick: PropTypes.func, 144 | }; 145 | 146 | export default NavBar; 147 | -------------------------------------------------------------------------------- /components/atoms/SelectInput.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import SelectItem from "./SelectItem"; 4 | import Label, { labelTypes } from "./Label"; 5 | 6 | function Icon({ active, hasSuccess }) { 7 | const wrapperStyle = active ? "rotate-180" : ""; 8 | 9 | let iconStyle; 10 | 11 | if (active) { 12 | iconStyle = "fill-primary-500"; 13 | } else if (hasSuccess) { 14 | iconStyle = "fill-support-success"; 15 | } else { 16 | iconStyle = "fill-gray-400"; 17 | } 18 | 19 | return ( 20 | 21 | 27 | 33 | 34 | 35 | ); 36 | } 37 | 38 | Icon.propTypes = { 39 | active: PropTypes.bool, 40 | }; 41 | 42 | function SelectInput({ 43 | name, 44 | options, 45 | bigLabel, 46 | smallLabel, 47 | centerBig, 48 | centerSmall, 49 | widthStyle, 50 | disableFocus, 51 | selected, 52 | handleSelect, 53 | hasSuccess, 54 | }) { 55 | const [active, setActive] = useState(false); 56 | // manage the focused item by index 57 | const [focused, setFocused] = useState(0); 58 | const [sortedOptions, setSortedOptions] = useState(options); 59 | const ref = useRef(null); 60 | 61 | useEffect(() => { 62 | setSortedOptions(options); 63 | }, [options]); 64 | 65 | useEffect(() => { 66 | const selectedIndex = options.findIndex( 67 | (option) => option.value === selected, 68 | ); 69 | const firstHalf = options.slice(0, selectedIndex); 70 | const secondHalf = options.slice(selectedIndex + 1); 71 | 72 | setSortedOptions([...secondHalf, ...firstHalf]); 73 | }, [selected]); 74 | 75 | useEffect(() => { 76 | if (active) { 77 | // detect outside click when dropdown is open 78 | document.addEventListener("click", () => { 79 | exitMenu(true); 80 | }); 81 | } 82 | }, [active]); 83 | 84 | function handleMenuKeyPress(e) { 85 | if (e.key === "Enter") { 86 | setActive(true); 87 | } 88 | } 89 | 90 | function exitMenu(blur = false) { 91 | setFocused(0); 92 | setActive(false); 93 | 94 | if (ref && ref.current) { 95 | if (blur) { 96 | ref.current.blur(); 97 | } else { 98 | ref.current.focus(); 99 | } 100 | } 101 | } 102 | 103 | function handleItemKeyDown(e, value) { 104 | let newFocusedIndex; 105 | 106 | switch (e.key) { 107 | case "ArrowDown": 108 | newFocusedIndex = 109 | focused + 1 === sortedOptions.length - 1 ? 0 : focused + 1; 110 | setFocused(newFocusedIndex); 111 | break; 112 | case "ArrowUp": 113 | newFocusedIndex = 114 | focused - 1 === -1 ? sortedOptions.length - 2 : focused - 1; 115 | setFocused(newFocusedIndex); 116 | break; 117 | case "Escape": 118 | case "Tab": 119 | exitMenu(); 120 | break; 121 | case "Enter": 122 | handleSelect( 123 | sortedOptions.find((option) => option.value === value).value, 124 | ); 125 | exitMenu(); 126 | default: 127 | break; 128 | } 129 | } 130 | 131 | function handleItemClick(value) { 132 | handleSelect(sortedOptions.find((option) => option.value === value).value); 133 | exitMenu(true); 134 | } 135 | 136 | function getRootItemStyle(active, hasSuccess) { 137 | const baseStyle = 138 | "group relative p-8 border-2 focus:outline-none focus:border-primary-500 hover:border-primary-500"; 139 | const activeStyle = active ? "rounded-t-6" : "rounded-6"; 140 | 141 | let colorStyle = ""; 142 | 143 | if (active) { 144 | colorStyle = "border-primary-500"; 145 | } else if (hasSuccess) { 146 | colorStyle = "border-support-success"; 147 | } else { 148 | colorStyle = "border-gray-400"; 149 | } 150 | 151 | return `${baseStyle} ${activeStyle} ${colorStyle}`; 152 | } 153 | 154 | const renderBigLabel = bigLabel ? ( 155 | 163 | ) : null; 164 | 165 | const renderSmallLabel = smallLabel ? ( 166 | 174 | ) : null; 175 | 176 | return ( 177 |
    178 | {renderBigLabel} 179 | {renderSmallLabel} 180 |
      e.stopPropagation()} 183 | className={`text-left select-none cursor-default ${widthStyle} font-base font-reg text-13 420:text-16 text-gray-600`} 184 | > 185 |
    • (active ? exitMenu(true) : setActive(true))} 189 | onKeyDown={handleMenuKeyPress} 190 | className={getRootItemStyle(active, hasSuccess)} 191 | > 192 | {options.find((option) => option.value === selected).name} 193 | 194 |
    • 195 | 196 | {active ? ( 197 |
    • 198 |
        199 | {sortedOptions.map((option, index) => ( 200 | 207 | {option.name} 208 | 209 | ))} 210 |
      211 |
    • 212 | ) : null} 213 |
    214 |
    215 | ); 216 | } 217 | 218 | SelectInput.propTypes = { 219 | options: PropTypes.array, 220 | bigLabel: PropTypes.string, 221 | smallLabel: PropTypes.string, 222 | name: PropTypes.string, 223 | centerBig: PropTypes.bool, 224 | centerSmall: PropTypes.bool, 225 | widthStyle: PropTypes.string, 226 | disableFocus: PropTypes.bool, 227 | }; 228 | 229 | export default SelectInput; 230 | -------------------------------------------------------------------------------- /components/molecules/Timeline.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react"; 2 | import useIsomorphicLayoutEffect from "../../utils/useIsomorphicLayoutEffect"; 3 | import PropTypes from "prop-types"; 4 | import Interval, { intervalTypes } from "../atoms/Interval"; 5 | import Tab, { tabTypes } from "../atoms/Tab"; 6 | import { isBelowBreakpoint } from "../../utils/tailwindUtil"; 7 | import useClientWidth from "../../utils/useClientWidth"; 8 | import { useSettings } from "../../context/Settings"; 9 | import { get12HourTime } from "../../utils/timeUtil"; 10 | 11 | function Timeline({ timeline, progress }) { 12 | const { scaleMap, scales, intervals } = timeline; 13 | 14 | const [scale, setScale] = useState(scales[scales.length - 1].value); 15 | const [hours, setHours] = useState(scaleMap[scale]); 16 | 17 | const clientWidth = useClientWidth(); 18 | const isMobile = isBelowBreakpoint(clientWidth, "932"); 19 | 20 | const timelineProps = { 21 | scaleMap, 22 | scales, 23 | intervals, 24 | scale, 25 | setScale, 26 | hours, 27 | setHours, 28 | progress, 29 | }; 30 | 31 | useIsomorphicLayoutEffect(() => { 32 | // fix stale state in TimelinePreview 33 | setHours(scaleMap[scale]); 34 | }, [timeline]); 35 | 36 | const regularTimeline = ( 37 |
    38 | 39 |
    40 | ); 41 | 42 | const mobileTimeline = ( 43 |
    44 | 45 |
    46 | ); 47 | 48 | return isMobile === null ? ( 49 | // return both on server side to avoid flickering 50 | <> 51 | {regularTimeline} 52 | {mobileTimeline} 53 | 54 | ) : isMobile === false ? ( 55 | regularTimeline 56 | ) : ( 57 | mobileTimeline 58 | ); 59 | } 60 | 61 | const RegularTimeline = ({ 62 | scaleMap, 63 | scales, 64 | intervals, 65 | scale, 66 | setScale, 67 | hours, 68 | setHours, 69 | progress, 70 | }) => { 71 | const timelineRef = useRef(); 72 | const [scroll, setScroll] = useState(0); 73 | const [scrollTarget, setScrollTarget] = useState({ 74 | value: null, 75 | smooth: null, 76 | }); 77 | 78 | // Listen for scroll event 79 | useIsomorphicLayoutEffect(() => { 80 | const timeline = timelineRef.current; 81 | 82 | if (timeline) { 83 | const { clientWidth, scrollWidth } = timeline; 84 | const scrollableWidth = scrollWidth - clientWidth; 85 | 86 | const handleScroll = (e) => { 87 | const currentScroll = e.target.scrollLeft / scrollableWidth; 88 | 89 | if (scrollTarget.value === currentScroll) { 90 | setScrollTarget({ value: null, smooth: null }); 91 | } 92 | 93 | setScroll(currentScroll); 94 | }; 95 | 96 | timeline.addEventListener("scroll", handleScroll); 97 | 98 | return () => timeline.removeEventListener("scroll", handleScroll); 99 | } 100 | }, [timelineRef, scrollTarget.value]); 101 | 102 | // Programatically scroll to a certain scroll target 103 | useIsomorphicLayoutEffect(() => { 104 | if (scrollTarget.value !== null && scrollTarget.smooth !== null) { 105 | scrollTo(scrollTarget); 106 | } 107 | }, [scrollTarget.value, scrollTarget.smooth]); 108 | 109 | const scrollTo = ({ value, smooth }) => { 110 | const timeline = timelineRef.current; 111 | 112 | if (timeline) { 113 | const { clientWidth, scrollWidth } = timeline; 114 | const scrollableWidth = scrollWidth - clientWidth; 115 | 116 | timeline.scrollTo({ 117 | left: value * scrollableWidth, 118 | behavior: smooth ? "smooth" : "auto", 119 | }); 120 | } 121 | }; 122 | 123 | const handleScaleChange = (newScale) => { 124 | if (newScale !== scale) { 125 | setScrollTarget({ 126 | value: scroll, 127 | smooth: false, 128 | }); 129 | setScale(newScale); 130 | setHours(scaleMap[newScale]); 131 | } 132 | }; 133 | 134 | return ( 135 |
    136 |
    timelineRef.current.focus()} 141 | > 142 |
    149 | 155 | 156 |
      157 | {intervals.map((interval, index) => ( 158 | 165 | ))} 166 |
    167 | 168 |
      169 | {hours.map((hour) => ( 170 | {hour} 171 | ))} 172 |
    173 |
    174 |
    175 | 176 | 181 |
    182 | ); 183 | }; 184 | 185 | function MobileTimeline({ 186 | scaleMap, 187 | scales, 188 | intervals, 189 | scale, 190 | setScale, 191 | hours, 192 | setHours, 193 | progress, 194 | }) { 195 | const handleScaleChange = (newScale) => { 196 | if (newScale !== scale) { 197 | setScale(newScale); 198 | setHours(scaleMap[newScale]); 199 | } 200 | }; 201 | 202 | return ( 203 |
    204 | 209 |
    215 | 221 | 222 |
      223 | {intervals.map((interval, index) => ( 224 | 231 | ))} 232 |
    233 | 234 |
      235 | {hours.map((hour) => ( 236 | {hour} 237 | ))} 238 |
    239 |
    240 |
    241 | ); 242 | } 243 | Timeline.propTypes = { 244 | timeline: PropTypes.object, 245 | progress: PropTypes.number, 246 | }; 247 | 248 | function Hour({ children }) { 249 | const { use12Hour } = useSettings(); 250 | 251 | let item; 252 | 253 | if (use12Hour) { 254 | const [hour, min, suffix] = get12HourTime(children); 255 | 256 | item = ( 257 |
    258 | 259 | {hour}:{min} 260 | 261 | 262 | {suffix} 263 | 264 |
    265 | ); 266 | } else { 267 | item = children; 268 | } 269 | 270 | return
  • {item}
  • ; 271 | } 272 | function Arrow({ type, progress, isMobile }) { 273 | let colorStyle = ""; 274 | 275 | switch (type) { 276 | case intervalTypes.work: 277 | case intervalTypes.break: 278 | colorStyle = "fill-primary-500"; 279 | break; 280 | case intervalTypes.blocked: 281 | case intervalTypes.floating: 282 | colorStyle = "fill-blocked-500"; 283 | break; 284 | default: 285 | break; 286 | } 287 | 288 | const visibleStyle = progress >= 0 ? "visible" : "invisible"; 289 | 290 | return isMobile ? ( 291 |
    294 | 301 | 305 | 306 |
    307 | ) : ( 308 |
    309 | 316 | 320 | 321 |
    322 | ); 323 | } 324 | 325 | Arrow.propTypes = { 326 | type: PropTypes.string, 327 | progress: PropTypes.number, 328 | }; 329 | 330 | function Pages({ pages, currentPage, handlePageChange }) { 331 | return ( 332 |
      333 | {pages.map((page, index) => ( 334 | handlePageChange(page.value)} 342 | > 343 | {page.name} 344 | 345 | ))} 346 |
    347 | ); 348 | } 349 | 350 | function Pagination({ scales, currentScale, handleScaleChange }) { 351 | return ( 352 |
    353 | 358 |
    359 | ); 360 | } 361 | 362 | export default Timeline; 363 | -------------------------------------------------------------------------------- /components/molecules/MainTimeline.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import ViewMoreLess from "../atoms/ViewMoreLess"; 3 | import Timer from "../atoms/Timer"; 4 | import Timeline from "./Timeline"; 5 | import Label, { labelTypes } from "../atoms/Label"; 6 | import RadioButton from "../atoms/RadioButton"; 7 | import Button, { buttonDelay, buttonTypes } from "../atoms/Button"; 8 | import { intervalTypes } from "../atoms/Interval"; 9 | import { blueprintToStored, storedToTimeline } from "../../utils/timelineUtil"; 10 | import { 11 | getStartingLocalStorage, 12 | getStoredLocalStorage, 13 | removeStartingLocalStorage, 14 | removeStoredLocalStorage, 15 | setStartingLocalStorage, 16 | setStoredLocalStorage, 17 | } from "../../utils/localStorageUtil"; 18 | import { timestampToString, get12HourTime } from "../../utils/timeUtil"; 19 | import { useSettings } from "../../context/Settings"; 20 | import { SCALES } from "../../utils/constants"; 21 | import cloneDeep from "lodash.clonedeep"; 22 | import EnableNotificationsModal from "./cards/EnableNotificationsModal"; 23 | import { useNotifications } from "../../utils/notificationUtil"; 24 | import { useAuth } from "../../firebase/Firebase"; 25 | import TimelineCreator from "./TimelineCreator"; 26 | import useIsomorphicLayoutEffect from "../../utils/useIsomorphicLayoutEffect"; 27 | import isBrowser from "../../utils/isBrowser"; 28 | 29 | function MainTimeline() { 30 | const [timeline, setTimeline] = useState(); 31 | const [activeInterval, setActiveInterval] = useState(); 32 | const [timeLeft, setTimeLeft] = useState(); 33 | const [progress, setProgress] = useState(); 34 | const [restartType, setRestartType] = useState(intervalTypes.work); 35 | const [modalIsOpen, setModalIsOpen] = useState(false); 36 | const [audio, setAudio] = useState(null); 37 | const [viewMoreTimeline, setViewMoreTimeline] = useState(false); 38 | const [viewMoreRestart, setViewMoreRestart] = useState(false); 39 | const { use12Hour, useSmartRestart } = useSettings(); 40 | const allowNotifications = useNotifications(); 41 | 42 | function tick(now) { 43 | // get timer time left 44 | const endTimestamp = 45 | activeInterval.timestamp + activeInterval.duration * 1000; 46 | setTimeLeft(Math.round((endTimestamp - now) / 1000)); 47 | 48 | // get timeline progress 49 | const timelineProgress = 50 | (Math.round((now - timeline.startTime) / 1000) / timeline.duration) * 100; 51 | setProgress(timelineProgress); 52 | } 53 | 54 | function createStartingTimer(firstTimestamp) { 55 | const now = Date.now(); 56 | let timestamp = getStartingLocalStorage(); 57 | 58 | if (!timestamp) { 59 | timestamp = now; 60 | setStartingLocalStorage(timestamp); 61 | } 62 | 63 | const type = intervalTypes.starting; 64 | const durationMs = firstTimestamp - timestamp; 65 | const durationSec = Math.round(durationMs / 1000); 66 | const startLabel = timestampToString(timestamp); 67 | const endLabel = timestampToString(timestamp + durationMs); 68 | 69 | return { 70 | type, 71 | duration: durationSec, 72 | timestamp, 73 | startLabel, 74 | endLabel, 75 | }; 76 | } 77 | 78 | function startTimeline() { 79 | const stored = getStoredLocalStorage(); 80 | 81 | if (stored) { 82 | const timeline = storedToTimeline(stored); 83 | setTimeline(timeline); 84 | 85 | if (Date.now() < timeline.startTime) { 86 | // timeline hasn't started yet 87 | setActiveInterval(createStartingTimer(timeline.startTime)); 88 | } else { 89 | const nextIntervalIndex = timeline.intervals.findIndex( 90 | (interval) => interval.timestamp > Date.now() 91 | ); 92 | setActiveInterval(timeline.intervals[nextIntervalIndex - 1]); 93 | } 94 | } 95 | } 96 | 97 | useIsomorphicLayoutEffect(() => { 98 | startTimeline(); 99 | }, []); 100 | 101 | useEffect(() => { 102 | if (activeInterval && timeline) { 103 | const worker = new Worker( 104 | new URL("../../utils/workerInterval.js", import.meta.url) 105 | ); 106 | 107 | // start the interval 108 | worker.postMessage({ delay: 1000 }); 109 | 110 | // call the tick fn with Date.now() from the worker 111 | worker.onmessage = ({ data }) => tick(data); 112 | 113 | return () => { 114 | // end worker 115 | worker.postMessage({ clear: true }); 116 | removeStartingLocalStorage(); 117 | }; 118 | } 119 | }, [activeInterval, timeline]); 120 | 121 | useEffect(() => { 122 | if (timeLeft <= 3 && !audio) { 123 | const audio = new Audio("sounds/timer.wav"); 124 | audio.autoplay = true; 125 | setAudio(audio); 126 | } 127 | 128 | if (timeLeft <= 0) { 129 | setAudio(null); 130 | const oldIndex = timeline.intervals.indexOf(activeInterval); 131 | 132 | let nextInterval; 133 | 134 | // if timeline has ended next interval will be null 135 | if (oldIndex + 1 === timeline.intervals.length) { 136 | nextInterval = null; 137 | } else { 138 | nextInterval = timeline.intervals[oldIndex + 1]; 139 | } 140 | 141 | setActiveInterval(nextInterval); 142 | 143 | if (allowNotifications) { 144 | showNotificationOnIntervalEnd(nextInterval); 145 | } 146 | } 147 | }, [timeLeft]); 148 | 149 | useEffect(() => { 150 | if (allowNotifications === null) { 151 | setTimeout(() => { 152 | setModalIsOpen(true); 153 | }, 3000); 154 | } 155 | }, [allowNotifications]); 156 | 157 | function showNotificationOnIntervalEnd(nextInterval) { 158 | if (!nextInterval) { 159 | new Notification("Timeline Has Ended!"); 160 | } else { 161 | const title = 162 | nextInterval.type === intervalTypes.work ? "Work Time!" : "Break Time!"; 163 | const minutes = nextInterval.duration / 60; 164 | const body = `${minutes} min`; 165 | 166 | new Notification(title, { body: body }); 167 | } 168 | } 169 | 170 | function handleRestartChange(e) { 171 | setRestartType(e.target.value); 172 | } 173 | 174 | function handleStop() { 175 | removeStoredLocalStorage(); 176 | removeStartingLocalStorage(); 177 | setTimeline(null); 178 | } 179 | 180 | function handleRestart(e) { 181 | e.preventDefault(); 182 | 183 | setViewMoreRestart(false); 184 | 185 | let closestBlock; 186 | const now = Date.now(); 187 | 188 | if (useSmartRestart) { 189 | // 5 min 190 | const blockSize = SCALES[0].value; 191 | const numberOfBlocks = timeline.duration / blockSize; 192 | const blockStartTimes = []; 193 | let nearestBlocks; 194 | 195 | for (let i = 0; i <= numberOfBlocks; i++) { 196 | const blockStart = timeline.startTime + i * blockSize * 1000; 197 | 198 | blockStartTimes.push(blockStart); 199 | 200 | if (blockStart >= now) { 201 | // return the last two blocks 202 | nearestBlocks = blockStartTimes.slice(-2); 203 | break; 204 | } else { 205 | // TODO: Handle restarting at the last interval 206 | nearestBlocks = []; 207 | } 208 | } 209 | 210 | const [leftBlock, rightBlock] = nearestBlocks; 211 | const timeToLeftBlock = Math.abs(now - leftBlock); 212 | const timeToRightBlock = Math.abs(now - rightBlock); 213 | closestBlock = 214 | timeToLeftBlock < timeToRightBlock ? leftBlock : rightBlock; 215 | } 216 | 217 | const { workDuration, breakDuration } = timeline; 218 | let intervalEnd; 219 | let startBlock = restartType; 220 | 221 | if (useSmartRestart) { 222 | if (activeInterval.type === restartType) { 223 | const intervalDuration = 224 | restartType === intervalTypes.work ? workDuration : breakDuration; 225 | intervalEnd = closestBlock + intervalDuration * 1000; 226 | // invert start block 227 | startBlock = 228 | restartType === intervalTypes.work 229 | ? intervalTypes.break 230 | : intervalTypes.work; 231 | } else { 232 | intervalEnd = closestBlock; 233 | } 234 | } else { 235 | intervalEnd = now; 236 | } 237 | 238 | // copy the current intervals so they can be mutated without affecting the timeline 239 | const intervalsCopy = cloneDeep(timeline.intervals); 240 | const activeIntervalIndex = intervalsCopy.findIndex( 241 | (interval) => interval.timestamp === activeInterval.timestamp 242 | ); 243 | 244 | // end the current interval now for instant restart or end it at the closest block 245 | intervalsCopy[activeIntervalIndex].duration = Math.round( 246 | (intervalEnd - activeInterval.timestamp) / 1000 247 | ); 248 | 249 | // remove all elements after the current interval and remove startLabel, endLabel and timestamp 250 | const prevIntervals = intervalsCopy 251 | .filter((interval, i) => i <= activeIntervalIndex) 252 | .map((interval) => ({ 253 | type: interval.type, 254 | duration: interval.duration, 255 | })); 256 | 257 | // find the remaining duration of the timeline 258 | const timelineEnd = timeline.startTime + timeline.duration * 1000; 259 | const remainingDuration = Math.round((timelineEnd - intervalEnd) / 1000); 260 | 261 | // generate the new part of the timeline 262 | const nextIntervals = blueprintToStored({ 263 | startTime: intervalEnd, 264 | duration: remainingDuration, 265 | workDuration, 266 | breakDuration, 267 | startWith: startBlock, 268 | }).intervals; 269 | 270 | const newStored = { 271 | intervals: [...prevIntervals, ...nextIntervals], 272 | startTime: timeline.startTime, 273 | workDuration, 274 | breakDuration, 275 | }; 276 | 277 | // tigger a timeline restart 278 | setStoredLocalStorage(newStored); 279 | startTimeline(); 280 | } 281 | 282 | const readyToShow = activeInterval && timeline && timeLeft >= 0; 283 | let timeLabel; 284 | 285 | if (activeInterval) { 286 | let startTime; 287 | let endTime; 288 | 289 | if (use12Hour) { 290 | const [startHour, startMin, startSuffix] = get12HourTime( 291 | activeInterval.startLabel 292 | ); 293 | const [endHour, endMin, endSuffix] = get12HourTime( 294 | activeInterval.endLabel 295 | ); 296 | 297 | startTime = `${startHour}:${startMin} ${startSuffix}`; 298 | endTime = `${endHour}:${endMin} ${endSuffix}`; 299 | } else { 300 | startTime = activeInterval.startLabel; 301 | endTime = activeInterval.endLabel; 302 | } 303 | 304 | timeLabel = 305 | activeInterval.type === intervalTypes.starting 306 | ? endTime 307 | : `${startTime} - ${endTime}`; 308 | } 309 | 310 | function renderNoTimeline() { 311 | if (isBrowser) { 312 | const stored = getStoredLocalStorage(); 313 | 314 | if (!stored) { 315 | return ; 316 | } 317 | } 318 | 319 | return null; 320 | } 321 | 322 | return readyToShow ? ( 323 |
    324 | 328 | 329 | {/* Restart Block */} 330 |
    { 333 | e.preventDefault(); 334 | setTimeout(handleRestart.bind(this, e), buttonDelay); 335 | }} 336 | > 337 | setViewMoreRestart(!viewMoreRestart)} 342 | > 343 |
    344 | 347 |

    348 | Align the timeline to the current moment 349 |
    350 | Select an interval type: 351 |

    352 |
    353 | 361 | 369 |
    370 | 373 |
    374 |
    375 |
    376 | 377 | {/* Timer Block */} 378 |
    379 | 382 |

    {timeLabel}

    383 |
    384 | 385 |
    386 | 391 |
    392 | 393 | {/* Timeline Block */} 394 | setViewMoreTimeline(!viewMoreTimeline)} 400 | > 401 |
    402 | 403 |
    404 |
    405 | 408 |
    409 |
    410 |
    411 | ) : ( 412 | renderNoTimeline() 413 | ); 414 | } 415 | 416 | export default MainTimeline; 417 | -------------------------------------------------------------------------------- /components/atoms/Icon.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | const types = { 4 | sandclock: "sandclock", 5 | coffee: "coffee", 6 | timeline: "timeline", 7 | save: "save", 8 | timer: "timer", 9 | blocked: "blocked", 10 | warning: "warning", 11 | settings: "settings", 12 | padlock: "padlock", 13 | delete: "delete", 14 | user: "user", 15 | logout: "logout", 16 | }; 17 | 18 | export { types as iconTypes }; 19 | 20 | function Icon({ type }) { 21 | return resolveIcon(type); 22 | } 23 | 24 | Icon.propTypes = { 25 | type: PropTypes.string, 26 | }; 27 | 28 | function resolveIcon(type) { 29 | let icon = ""; 30 | 31 | switch (type) { 32 | case types.sandclock: 33 | icon = 34 | "M19.6924 17.8462C19.6924 16.8266 20.5189 16 21.5385 16H42.4616C43.4812 16 44.3078 16.8266 44.3078 17.8462C44.3078 18.8658 43.4812 19.6923 42.4616 19.6923H41.8461V19.6923V21.8976C41.8461 25.1399 40.2499 28.1744 37.5781 30.0112L36.3147 30.8798L34.6853 32L36.3147 33.1202L37.5781 33.9888C40.2499 35.8256 41.8461 38.8601 41.8461 42.1024V44.3076H42.4616C43.4812 44.3076 44.3078 45.1342 44.3078 46.1538C44.3078 47.1734 43.4812 47.9999 42.4616 47.9999H21.5385C20.5189 47.9999 19.6924 47.1734 19.6924 46.1538C19.6924 45.1342 20.5189 44.3076 21.5385 44.3076H22.1538V42.1024C22.1538 38.8601 23.75 35.8256 26.4218 33.9888L27.6852 33.1202L29.3146 32L27.6852 30.8798L26.4218 30.0112C23.75 28.1744 22.1538 25.1399 22.1538 21.8976V19.6923V19.6923H21.5385C20.5189 19.6923 19.6924 18.8658 19.6924 17.8462ZM40 44.3076H24V42.1024C24 39.468 25.2969 37.0025 27.4677 35.5101L30.3605 33.5213C30.8615 33.1769 31.1607 32.6079 31.1607 32C31.1607 31.3921 30.8615 30.8231 30.3605 30.4787L27.4677 28.4899C25.2969 26.9975 24 24.532 24 21.8976V19.6923H40V21.8976C40 24.532 38.703 26.9975 36.5322 28.4899L33.6394 30.4787C33.1385 30.8231 32.8392 31.3921 32.8392 32C32.8392 32.6079 33.1385 33.1769 33.6394 33.5213L36.5322 35.5101C38.703 37.0025 40 39.468 40 42.1024V44.3076ZM27.0769 41.9486C27.0769 39.615 28.2707 37.4436 30.241 36.1932L32 35.0769L33.759 36.1932C35.7293 37.4436 36.9231 39.615 36.9231 41.9486V42.4615H27.0769V41.9486ZM27.5565 26.1032C27.2579 25.9137 27.0769 25.5845 27.0769 25.2308H36.9231C36.9231 25.5845 36.7421 25.9137 36.4434 26.1032L32 28.9231L27.5565 26.1032Z"; 35 | break; 36 | case types.coffee: 37 | icon = 38 | "M31.0992 17.3303C30.9805 17.8008 30.9883 18.629 31.8548 19.8667C33.0532 21.5786 33.361 23.1642 32.9172 24.5167C32.4935 25.8081 31.4831 26.5733 30.6881 26.9021L29.6633 24.4238C29.9282 24.3143 30.2476 24.0509 30.3691 23.6807C30.4705 23.3716 30.5386 22.6628 29.6578 21.4047C28.4451 19.6724 28.1506 18.0548 28.4988 16.6744C28.8333 15.3485 29.7113 14.4466 30.4665 14L31.8316 16.3084C31.5896 16.4515 31.2316 16.8054 31.0992 17.3303ZM26.4883 21.4102C25.8526 20.5022 25.8469 19.8945 25.934 19.5493C26.0311 19.1641 26.2938 18.9044 26.4713 18.7995L25.4698 17.1057C24.9156 17.4334 24.2714 18.0952 24.026 19.068C23.7705 20.0808 23.9866 21.2677 24.8764 22.5387C25.5226 23.4618 25.4727 23.9819 25.3983 24.2087C25.3091 24.4803 25.0748 24.6736 24.8804 24.7539L25.6323 26.5723C26.2156 26.3311 26.957 25.7696 27.2679 24.8221C27.5935 23.8297 27.3677 22.6663 26.4883 21.4102ZM37.3107 21.4102C36.675 20.5022 36.6693 19.8945 36.7563 19.5493C36.8535 19.1641 37.1162 18.9044 37.2937 18.7995L36.2922 17.1057C35.738 17.4334 35.0938 18.0952 34.8484 19.068C34.5929 20.0808 34.809 21.2677 35.6988 22.5387C36.345 23.4618 36.295 23.9819 36.2206 24.2087C36.1315 24.4803 35.8971 24.6736 35.7028 24.7539L36.4547 26.5723C37.038 26.3311 37.7794 25.7696 38.0903 24.8221C38.4159 23.8297 38.1901 22.6663 37.3107 21.4102ZM19.8794 28.9168C20.3239 28.4723 20.9268 28.2225 21.5555 28.2225H40.6765C41.3051 28.2225 41.9081 28.4723 42.3526 28.9168C42.7971 29.3613 43.0469 29.9643 43.0468 30.5929C43.0468 30.6841 43.0467 30.7754 43.0464 30.8669C43.5219 30.6876 44.0309 30.5928 44.5507 30.5928C46.9057 30.5928 48.8148 32.5019 48.8148 34.8569V35.8553C48.8148 38.4143 46.7404 40.4887 44.1814 40.4887C43.2434 40.4887 42.4078 40.228 41.7129 39.7902C41.5425 40.1987 41.3509 40.595 41.1355 40.9766C40.2599 42.5276 39.0005 43.8283 37.2589 44.7167C35.5456 45.5908 33.4961 46.0001 31.116 46.0001C28.7359 46.0001 26.6864 45.5908 24.9731 44.7167C23.2315 43.8283 21.972 42.5276 21.0965 40.9766C19.4269 38.019 19.1851 34.1787 19.1851 30.5929C19.1851 29.9643 19.4349 29.3613 19.8794 28.9168ZM43.0011 32.9401C42.9255 34.6631 42.7386 36.3882 42.3147 37.9949C42.7971 38.4206 43.4365 38.6895 44.1814 38.6895C45.7467 38.6895 47.0156 37.4205 47.0156 35.8553V34.8569C47.0156 33.4956 45.912 32.392 44.5507 32.392C43.9823 32.392 43.4362 32.5883 43.0011 32.9401ZM23.9259 30.5929L38.3061 30.5929L40.6765 30.5929C40.6765 31.4024 40.6627 32.1946 40.6261 32.9633C40.3374 39.0316 38.631 43.6297 31.116 43.6297C23.601 43.6297 21.8946 39.0316 21.6059 32.9633C21.5693 32.1946 21.5555 31.4024 21.5555 30.5929H23.9259Z"; 39 | break; 40 | case types.timeline: 41 | icon = 42 | "M19.2 33.6C20.0837 33.6 20.8 32.8837 20.8 32C20.8 31.1164 20.0837 30.4 19.2 30.4C18.3163 30.4 17.6 31.1164 17.6 32C17.6 32.8837 18.3163 33.6 19.2 33.6ZM19.2 35.2C20.6911 35.2 21.944 34.1802 22.2992 32.8H28.9008C29.256 34.1802 30.5089 35.2 32 35.2C33.4911 35.2 34.744 34.1802 35.0992 32.8H41.7008C42.056 34.1802 43.3089 35.2 44.8 35.2C46.5673 35.2 48 33.7674 48 32C48 30.2327 46.5673 28.8 44.8 28.8C43.309 28.8 42.0561 29.8198 41.7008 31.2H35.0992C34.7439 29.8198 33.4911 28.8 32 28.8C30.5089 28.8 29.2561 29.8198 28.9008 31.2H22.2992C21.9439 29.8198 20.6911 28.8 19.2 28.8C17.4327 28.8 16 30.2327 16 32C16 33.7674 17.4327 35.2 19.2 35.2ZM32 33.6C32.8837 33.6 33.6 32.8837 33.6 32C33.6 31.1164 32.8837 30.4 32 30.4C31.1163 30.4 30.4 31.1164 30.4 32C30.4 32.8837 31.1163 33.6 32 33.6ZM46.4 32C46.4 32.8837 45.6837 33.6 44.8 33.6C43.9164 33.6 43.2 32.8837 43.2 32C43.2 31.1164 43.9164 30.4 44.8 30.4C45.6837 30.4 46.4 31.1164 46.4 32Z"; 43 | break; 44 | case types.save: 45 | icon = 46 | "M20.5714 18.2857H22.8571V25.7143C22.8571 26.3455 23.3688 26.8571 24 26.8571H36C36.6312 26.8571 37.1429 26.3455 37.1429 25.7143V18.4075L43.4286 24.6932V45.7143H20.5714V18.2857ZM36 16H24H20.5714C19.3091 16 18.2857 17.0234 18.2857 18.2857V45.7143C18.2857 46.9767 19.3091 48 20.5714 48H43.4286C44.6909 48 45.7143 46.9767 45.7143 45.7143V23.7464L37.9678 16H36ZM25.1429 24.5714V18.2857H34.8571V24.5714H25.1429ZM25.1429 41.1429V33.7143H39.4286V41.1429H25.1429ZM22.8571 32.5714C22.8571 31.9402 23.3688 31.4286 24 31.4286H40.5714C41.2026 31.4286 41.7143 31.9402 41.7143 32.5714V42.2857C41.7143 42.9169 41.2026 43.4286 40.5714 43.4286H24C23.3688 43.4286 22.8571 42.9169 22.8571 42.2857V32.5714ZM31.4286 19.4286V23.4286L33.7143 23.4286L33.7143 19.4286H31.4286Z"; 47 | break; 48 | case types.timer: 49 | icon = 50 | "M29.7827 15.0001C29.3234 15.0001 28.951 15.3725 28.951 15.8317V17.0776C28.951 17.5369 29.3234 17.9092 29.7827 17.9092H34.7688C35.2281 17.9092 35.6004 17.5369 35.6004 17.0776V15.8317C35.6004 15.3724 35.2281 15.0001 34.7688 15.0001H29.7827ZM33.9381 17.9095H30.6134V20.5053C28.1788 20.8089 25.9486 21.7706 24.1063 23.2067L22.6824 21.7828L20.7723 23.6929L22.1541 25.0747C20.173 27.397 18.977 30.4094 18.977 33.7012C18.977 41.0458 24.931 46.9999 32.2757 46.9999C39.6204 46.9999 45.5744 41.0458 45.5744 33.7012C45.5744 26.9195 40.4982 21.3234 33.9381 20.5054V17.9095ZM43.3567 33.7012C43.3567 39.8211 38.3956 44.7822 32.2757 44.7822C26.1558 44.7822 21.1946 39.8211 21.1946 33.7012C21.1946 27.5813 26.1558 22.6201 32.2757 22.6201C38.3956 22.6201 43.3567 27.5813 43.3567 33.7012ZM28.3968 26.9833C29.5761 26.3024 30.9138 25.944 32.2756 25.944V33.7015L38.9938 37.5803C38.313 38.7596 37.3337 39.7389 36.1544 40.4198C34.9751 41.1007 33.6373 41.4591 32.2756 41.4591C30.9138 41.4591 29.5761 41.1007 28.3968 40.4198C27.2175 39.7389 26.2382 38.7596 25.5573 37.5803C24.8765 36.401 24.518 35.0633 24.518 33.7015C24.518 32.3398 24.8765 31.002 25.5573 29.8227C26.2382 28.6434 27.2175 27.6641 28.3968 26.9833ZM18.6235 23.4541C18.3597 23.1902 18.3597 22.7624 18.6235 22.4985L21.4882 19.6339C21.7521 19.37 22.1799 19.37 22.4437 19.6339L23.1595 20.3497C23.4234 20.6135 23.4234 21.0414 23.1595 21.3052L20.2949 24.1699C20.031 24.4338 19.6032 24.4338 19.3393 24.1699L18.6235 23.4541Z"; 51 | break; 52 | case types.blocked: 53 | icon = 54 | "M48 32C48 40.8366 40.8366 48 32 48C23.1634 48 16 40.8366 16 32C16 23.1634 23.1634 16 32 16C40.8366 16 48 23.1634 48 32ZM37.4186 41.1898C35.8306 42.1282 33.9782 42.6667 32 42.6667C26.1089 42.6667 21.3333 37.8911 21.3333 32C21.3333 30.0219 21.8718 28.1695 22.8102 26.5814L37.4186 41.1898ZM41.1898 37.4186C42.1282 35.8306 42.6666 33.9782 42.6666 32C42.6666 26.109 37.891 21.3334 32 21.3334C30.0218 21.3334 28.1695 21.8718 26.5814 22.8102L41.1898 37.4186Z"; 55 | break; 56 | case types.warning: 57 | icon = 58 | "M32 16C30.2272 16 28.8225 17.4969 28.9351 19.2661L29.873 34.004C29.9444 35.1263 30.8755 36 32 36C33.1245 36 34.0556 35.1263 34.127 34.004L35.0649 19.2661C35.1775 17.4969 33.7729 16 32 16ZM32 48C33.8075 48 35.2727 46.5347 35.2727 44.7273C35.2727 42.9198 33.8075 41.4545 32 41.4545C30.1925 41.4545 28.7273 42.9198 28.7273 44.7273C28.7273 46.5347 30.1925 48 32 48Z"; 59 | break; 60 | case types.settings: 61 | icon = 62 | "M21.5755 27.4537C21.5184 27.5827 21.4637 27.7129 21.4113 27.8444L17.0872 29.4002C16.4349 29.6349 16 30.2535 16 30.9467V33.0531C16 33.7463 16.4349 34.365 17.0872 34.5997L21.3192 36.1223C21.385 36.2987 21.4549 36.473 21.529 36.6451L19.6168 40.7066C19.3215 41.3337 19.4515 42.0787 19.9416 42.5689L21.4311 44.0583C21.9212 44.5485 22.6662 44.6784 23.2934 44.3832L27.2318 42.5289C27.4607 42.636 27.6937 42.7358 27.9305 42.828L29.4002 46.9127C29.6348 47.565 30.2535 47.9999 30.9467 47.9999H33.0531C33.7463 47.9999 34.3649 47.565 34.5996 46.9127L36.0371 42.9175C36.32 42.8146 36.5978 42.7008 36.8698 42.5768L40.7066 44.3832C41.3338 44.6785 42.0788 44.5485 42.5689 44.0584L44.0584 42.5689C44.5485 42.0787 44.6785 41.3338 44.3832 40.7066L42.5768 36.8698C42.7008 36.5978 42.8146 36.32 42.9175 36.0371L46.9127 34.5997C47.565 34.365 47.9999 33.7463 47.9999 33.0531V30.9467C47.9999 30.2535 47.565 29.6349 46.9127 29.4002L42.828 27.9305C42.7358 27.6937 42.636 27.4608 42.5289 27.2319L44.3832 23.2933C44.6785 22.6662 44.5486 21.9212 44.0584 21.431L42.569 19.9416C42.0788 19.4514 41.3338 19.3215 40.7067 19.6167L36.645 21.529C36.4729 21.4549 36.2986 21.385 36.1223 21.3192L34.5996 17.0872C34.3649 16.4349 33.7463 16 33.0531 16H30.9467C30.2535 16 29.6348 16.4349 29.4002 17.0872L27.8443 21.4113C27.7129 21.4637 27.5827 21.5184 27.4537 21.5755L23.2933 19.6167C22.6662 19.3214 21.9212 19.4514 21.431 19.9415L19.9416 21.431C19.4514 21.9212 19.3215 22.6661 19.6168 23.2933L21.5755 27.4537ZM32.025 37.7776C35.2021 37.7776 37.7776 35.2021 37.7776 32.025C37.7776 28.848 35.2021 26.2724 32.025 26.2724C28.848 26.2724 26.2724 28.848 26.2724 32.025C26.2724 35.2021 28.848 37.7776 32.025 37.7776Z"; 63 | break; 64 | case types.padlock: 65 | icon = 66 | "M40.5299 25.5504H40.056V23.181C40.056 18.7318 36.4492 15.125 32 15.125C27.5508 15.125 23.944 18.7318 23.944 23.181V25.5504H23.4702C21.1147 25.5504 19.2052 27.4599 19.2052 29.8153V42.6101C19.2052 44.9655 21.1147 46.875 23.4702 46.875H40.5299C42.8853 46.875 44.7948 44.9655 44.7948 42.6101V29.8153C44.7948 27.4598 42.8853 25.5504 40.5299 25.5504ZM37.2127 23.181V25.5504H26.7873V23.181C26.7873 20.3021 29.1211 17.9683 32 17.9683C34.8789 17.9683 37.2127 20.3021 37.2127 23.181ZM23.4702 28.3937C22.685 28.3937 22.0485 29.0301 22.0485 29.8153V42.6101C22.0485 43.3952 22.685 44.0317 23.4702 44.0317H40.5299C41.315 44.0317 41.9515 43.3952 41.9515 42.6101V29.8153C41.9515 29.0301 41.315 28.3937 40.5299 28.3937H23.4702ZM35 34C35 35.2302 34.2595 36.2874 33.2 36.7503V39.3999C33.2 40.0627 32.6627 40.5999 32 40.5999C31.3373 40.5999 30.8 40.0627 30.8 39.3999V36.7503C29.7405 36.2874 29 35.2302 29 34C29 32.3431 30.3432 31 32 31C33.6569 31 35 32.3431 35 34Z"; 67 | break; 68 | case types.delete: 69 | icon = 70 | "M26.2155 16.9542C26.4147 16.3828 26.9535 16 27.5585 16H36.4413C37.0464 16 37.5852 16.3828 37.7843 16.9542L39.1864 20.9778H40.8889H43.7334H45.8667C46.2594 20.9778 46.5778 21.2961 46.5778 21.6889V23.1111C46.5778 23.5038 46.2594 23.8222 45.8667 23.8222H43.7334V43.7333C43.7334 46.0897 41.8231 48 39.4667 48H25.2445C22.8881 48 20.9778 46.0897 20.9778 43.7333V23.8222H18.1333C17.7406 23.8222 17.4222 23.5038 17.4222 23.1111V21.6889C17.4222 21.2961 17.7406 20.9778 18.1333 20.9778H20.9778H23.8222H24.8134L26.2155 16.9542ZM35.8926 18.9439L36.619 20.9778H27.3809L28.1073 18.9439C28.3094 18.3778 28.8456 18 29.4466 18H34.5532C35.1543 18 35.6904 18.3778 35.8926 18.9439ZM38.1801 23.8222H40.8889V43.7333C40.8889 44.5188 40.2522 45.1555 39.4667 45.1555H25.2445C24.459 45.1555 23.8222 44.5188 23.8222 43.7333V23.8222H25.8197L25.8239 23.8222H38.176L38.1801 23.8222ZM26.6667 28.0889C26.2739 28.0889 25.9555 28.4073 25.9555 28.8V40.8889C25.9555 41.2817 26.2739 41.6 26.6667 41.6H28.0889C28.4816 41.6 28.8 41.2817 28.8 40.8889V28.8C28.8 28.4073 28.4816 28.0889 28.0889 28.0889H26.6667ZM31.6446 28.089C31.2518 28.089 30.9334 28.4073 30.9334 28.8001V40.889C30.9334 41.2817 31.2518 41.6001 31.6446 41.6001H33.0668C33.4595 41.6001 33.7779 41.2817 33.7779 40.889V28.8001C33.7779 28.4073 33.4595 28.089 33.0668 28.089H31.6446ZM35.9112 28.8001C35.9112 28.4073 36.2296 28.089 36.6223 28.089H38.0446C38.4373 28.089 38.7557 28.4073 38.7557 28.8001V40.889C38.7557 41.2817 38.4373 41.6001 38.0446 41.6001H36.6223C36.2296 41.6001 35.9112 41.2817 35.9112 40.889V28.8001Z"; 71 | break; 72 | case types.user: 73 | icon = 74 | "M32.0003 32.3168C36.5058 32.3168 40.1582 28.6644 40.1582 24.1589C40.1582 19.6534 36.5058 16.001 32.0003 16.001C27.4948 16.001 23.8424 19.6534 23.8424 24.1589C23.8424 28.6644 27.4948 32.3168 32.0003 32.3168ZM32 35.4991C24.303 35.4991 17.8509 40.8288 16.1328 47.999H47.8671C46.1491 40.8288 39.6969 35.4991 32 35.4991Z"; 75 | break; 76 | case types.logout: 77 | icon = 78 | "M32 16C30.8954 16 30 16.8954 30 18V32C30 33.1046 30.8954 34 32 34C33.1046 34 34 33.1046 34 32V18C34 16.8954 33.1046 16 32 16ZM23.5177 23.4112C24.2971 22.6286 24.2944 21.3622 23.5118 20.5828C22.7291 19.8034 21.4627 19.8061 20.6833 20.5888C18.4463 22.8352 16.9239 25.6963 16.3071 28.8098C15.6904 31.9232 16.0069 35.1505 17.2168 38.0839C18.4268 41.0173 20.4764 43.5259 23.1077 45.2914C25.7391 47.0571 28.8336 48 32 48C35.1664 48 38.2609 47.0571 40.8923 45.2914C43.5236 43.5259 45.5732 41.0173 46.7832 38.0839C47.9931 35.1505 48.3096 31.9232 47.6929 28.8098C47.0761 25.6963 45.5537 22.8352 43.3167 20.5888C42.5373 19.8061 41.2709 19.8034 40.4882 20.5828C39.7056 21.3622 39.7029 22.6286 40.4823 23.4112C42.1612 25.0972 43.3054 27.2462 43.7691 29.587C44.2328 31.9278 43.9947 34.3541 43.0854 36.5586C42.1761 38.763 40.6369 40.6458 38.6636 41.9699C36.6904 43.2939 34.3715 44 32 44C29.6285 44 27.3096 43.2938 25.3364 41.9699C23.3631 40.6458 21.8239 38.763 20.9146 36.5586C20.0053 34.3541 19.7672 31.9278 20.2309 29.587C20.6946 27.2462 21.8388 25.0972 23.5177 23.4112Z"; 79 | break; 80 | default: 81 | return null; 82 | } 83 | 84 | return ( 85 | 91 | 92 | 98 | 99 | ); 100 | } 101 | 102 | export default Icon; 103 | --------------------------------------------------------------------------------