├── .nvmrc
├── .npmrc
├── codecov.yml
├── .czrc
├── .env.local.example
├── .env.production
├── .env.test
├── .prettierrc.json
├── features
├── tasks
│ ├── components
│ │ ├── Board
│ │ │ ├── index.js
│ │ │ └── Board.js
│ │ ├── Column
│ │ │ ├── index.js
│ │ │ └── Column.js
│ │ ├── DraggableTask
│ │ │ ├── index.js
│ │ │ ├── handlers.js
│ │ │ └── DraggableTask.js
│ │ ├── DeleteTaskModal.js
│ │ └── EditTaskModal.js
│ ├── constants.js
│ ├── hooks
│ │ ├── useEditTaskModal.js
│ │ ├── useDeleteConfirmation.js
│ │ ├── useTask.js
│ │ ├── useTasks.js
│ │ └── useDeleteConfirmation.test.js
│ ├── containers
│ │ └── EditTask.js
│ ├── helpers.js
│ ├── handlers.js
│ └── helpers.test.js
├── common
│ ├── components
│ │ ├── MainLayout
│ │ │ ├── index.js
│ │ │ ├── __snapshots__
│ │ │ │ └── MainLayout.test.js.snap
│ │ │ ├── MainLayout.test.js
│ │ │ └── MainLayout.js
│ │ ├── UserHeader
│ │ │ ├── index.js
│ │ │ ├── UserHeader.module.css
│ │ │ ├── UserHeader.js
│ │ │ ├── __snapshots__
│ │ │ │ └── UserHeader.test.js.snap
│ │ │ └── UserHeader.test.js
│ │ ├── NavigationMenu
│ │ │ ├── index.js
│ │ │ ├── NavigationMenu.test.js
│ │ │ ├── __snapshots__
│ │ │ │ └── NavigationMenu.test.js.snap
│ │ │ └── NavigationMenu.js
│ │ └── ToggleColorScheme
│ │ │ ├── index.js
│ │ │ ├── handlers.js
│ │ │ ├── __snapshots__
│ │ │ └── ToggleColorScheme.test.js.snap
│ │ │ ├── ToggleColorScheme.js
│ │ │ ├── ToggleColorScheme.test.js
│ │ │ ├── helpers.js
│ │ │ ├── handlers.test.js
│ │ │ └── helpers.test.js
│ └── hooks
│ │ ├── useDialog.js
│ │ ├── useToggle.js
│ │ ├── useLocalData.js
│ │ ├── useInterval.js
│ │ ├── useBreakpoints.js
│ │ ├── useTime.js
│ │ └── useColorScheme.js
├── focusSession
│ ├── api.js
│ ├── hooks
│ │ ├── useBreaktimeConfirmation.js
│ │ ├── useBreaktimeTimer.js
│ │ ├── useChronometer.js
│ │ ├── useFocusSessions.js
│ │ ├── useFocusSession.js
│ │ ├── useBreaktimeConfirmation.test.js
│ │ └── useFocusSessions.test.js
│ ├── components
│ │ ├── FocusSessionFooter.js
│ │ ├── __snapshots__
│ │ │ ├── FocusSessionFooter.test.js.snap
│ │ │ └── BreaktimeConfirmation.test.js.snap
│ │ ├── BreaktimeConfirmation.test.js
│ │ ├── FocusSessionFooter.test.js
│ │ ├── PauseTimer.js
│ │ ├── BreaktimeTimer.js
│ │ ├── BreaktimeConfirmation.js
│ │ └── Chronometer.js
│ ├── helpers.js
│ ├── handlers.js
│ ├── helpers.test.js
│ ├── handlers.test.js
│ └── containers
│ │ └── FocusSession.js
├── planning
│ ├── api.js
│ ├── components
│ │ ├── __snapshots__
│ │ │ ├── AddTaskButton.test.js.snap
│ │ │ └── PlanningFooter.test.js.snap
│ │ ├── AddTaskButton.test.js
│ │ ├── AddTaskButton.js
│ │ ├── PlanningFooter.js
│ │ ├── PlanningOnboarding.js
│ │ └── PlanningFooter.test.js
│ └── containers
│ │ └── Planning.js
└── retrospective
│ ├── components
│ └── RetrospectiveFooter.js
│ └── containers
│ └── Retrospective.js
├── .commitlintrc.json
├── public
├── favicon.ico
├── vercel.svg
└── images
│ ├── yoga-pause.svg
│ └── forest-pause.svg
├── .husky
├── pre-commit
└── commit-msg
├── pages
├── api
│ ├── auth
│ │ └── [...auth0].js
│ ├── test
│ │ ├── focus-sessions.js
│ │ └── tasks.js
│ └── local
│ │ ├── tasks
│ │ ├── [id]
│ │ │ ├── index.js
│ │ │ ├── reset.js
│ │ │ └── complete.js
│ │ └── index.js
│ │ ├── [...entity].js
│ │ └── focus-sessions
│ │ ├── active.js
│ │ ├── resume.js
│ │ ├── pause.js
│ │ ├── index.js
│ │ └── finish.js
├── retrospective.js
├── planning.js
├── focus-session.js
├── form.js
├── _app.js
└── home.js
├── utils
├── httpCodes.js
├── testUtils
│ ├── svgrMock.js
│ ├── dateNowMock.js
│ ├── dummyRender.test.js
│ └── dummyRender.js
├── isObject.js
├── time.js
├── buildLocalApiUrl.js
├── isEmpty.js
├── formatMilliseconds.js
├── fetchJsonServer.js
├── timeAgo.js
├── buildLocalApiUrl.test.js
├── timeAgo.test.js
├── isObject.test.js
├── isEmpty.test.js
└── formatMilliseconds.test.js
├── .lintstagedrc.json
├── api
├── index.js
├── focusSessions.js
├── request.js
└── tasks.js
├── next.config.js
├── config
├── redirects.js
├── index.js
└── webpack.js
├── db.json.example
├── .editorconfig
├── tests
└── integration
│ └── planning.integration.test.js
├── .stylelintrc.json
├── .env.development
├── .gitignore
├── jest.config.js
├── .eslintrc.json
├── jest.setup.js
├── .github
└── workflows
│ ├── review.yml
│ └── release.yml
├── styles
└── globals.scss
├── README.md
└── package.json
/.nvmrc:
--------------------------------------------------------------------------------
1 | v15
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | save-exact=true
2 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | github_checks:
2 | annotations: false
3 |
--------------------------------------------------------------------------------
/.czrc:
--------------------------------------------------------------------------------
1 | {
2 | "path": "cz-conventional-changelog"
3 | }
4 |
--------------------------------------------------------------------------------
/.env.local.example:
--------------------------------------------------------------------------------
1 | # AUTH0
2 | AUTH0_SECRET=
3 | AUTH0_CLIENT_SECRET=
4 |
--------------------------------------------------------------------------------
/.env.production:
--------------------------------------------------------------------------------
1 | # URLS
2 | NEXT_PUBLIC_API_URL=http://localhost:8080
3 |
--------------------------------------------------------------------------------
/.env.test:
--------------------------------------------------------------------------------
1 | # URLS
2 | NEXT_PUBLIC_API_URL=http://localhost:3000/api/test
3 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true
4 | }
5 |
--------------------------------------------------------------------------------
/features/tasks/components/Board/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Board'
2 |
--------------------------------------------------------------------------------
/.commitlintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@commitlint/config-conventional"]
3 | }
4 |
--------------------------------------------------------------------------------
/features/tasks/components/Column/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Column'
2 |
--------------------------------------------------------------------------------
/features/common/components/MainLayout/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './MainLayout'
2 |
--------------------------------------------------------------------------------
/features/common/components/UserHeader/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './UserHeader'
2 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glrodasz/cero-web/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/features/common/components/NavigationMenu/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './NavigationMenu'
2 |
--------------------------------------------------------------------------------
/features/tasks/components/DraggableTask/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './DraggableTask'
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | yarn lint-staged
5 |
--------------------------------------------------------------------------------
/features/common/components/ToggleColorScheme/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './ToggleColorScheme'
2 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | yarn commitlint --edit $1
5 |
--------------------------------------------------------------------------------
/features/focusSession/api.js:
--------------------------------------------------------------------------------
1 | export { tasks as tasksApi, focusSessions as focusSessionsApi } from '../../api'
2 |
--------------------------------------------------------------------------------
/features/planning/api.js:
--------------------------------------------------------------------------------
1 | export { tasks as tasksApi, focusSessions as focusSessionsApi } from '../../api'
2 |
--------------------------------------------------------------------------------
/pages/api/auth/[...auth0].js:
--------------------------------------------------------------------------------
1 | import { handleAuth } from '@auth0/nextjs-auth0'
2 |
3 | export default handleAuth()
4 |
--------------------------------------------------------------------------------
/utils/httpCodes.js:
--------------------------------------------------------------------------------
1 | const httpCodes = {
2 | // 3xx Redirection
3 | FOUND: 302,
4 | }
5 |
6 | export default httpCodes
7 |
--------------------------------------------------------------------------------
/utils/testUtils/svgrMock.js:
--------------------------------------------------------------------------------
1 | export const ReactComponent = 'div'
2 |
3 | const SvgrURL = 'SvgrURL'
4 | export default SvgrURL
5 |
--------------------------------------------------------------------------------
/.lintstagedrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "*.js": "yarn lint:js:fix",
3 | "*.json": "yarn lint:json:fix",
4 | "*.css": "yarn lint:css:fix"
5 | }
6 |
--------------------------------------------------------------------------------
/utils/isObject.js:
--------------------------------------------------------------------------------
1 | const isObject = (obj) => {
2 | return Object.prototype.toString.call(obj) === '[object Object]'
3 | }
4 |
5 | export default isObject
6 |
--------------------------------------------------------------------------------
/utils/testUtils/dateNowMock.js:
--------------------------------------------------------------------------------
1 | const dateNowMock = () =>
2 | jest.fn(() => new Date('1970-01-01T02:00:00.000Z').getTime())
3 |
4 | export default dateNowMock
5 |
--------------------------------------------------------------------------------
/features/tasks/constants.js:
--------------------------------------------------------------------------------
1 | export const IN_PROGRESS_COLUMN_ID = 'in-progress'
2 | export const PENDING_COLUMN_ID = 'pending'
3 | export const COMPLETED_COLUMN_ID = 'completed'
4 |
--------------------------------------------------------------------------------
/api/index.js:
--------------------------------------------------------------------------------
1 | import Tasks from './tasks'
2 | import FocusSession from './focusSessions'
3 |
4 | export const tasks = new Tasks('tasks')
5 | export const focusSessions = new FocusSession('focus-sessions')
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const webpackConfig = require('./config/webpack')
2 | const redirectsConfig = require('./config/redirects')
3 |
4 | module.exports = {
5 | ...redirectsConfig,
6 | ...webpackConfig,
7 | }
8 |
--------------------------------------------------------------------------------
/features/common/components/UserHeader/UserHeader.module.css:
--------------------------------------------------------------------------------
1 | .user-header {
2 | display: flex;
3 | }
4 |
5 | .text {
6 | display: flex;
7 | flex-direction: column;
8 | align-items: flex-start;
9 | }
10 |
--------------------------------------------------------------------------------
/features/focusSession/hooks/useBreaktimeConfirmation.js:
--------------------------------------------------------------------------------
1 | import useDialog from '../../common/hooks/useDialog'
2 |
3 | const useBreaktimeConfirmation = () => {
4 | return useDialog()
5 | }
6 |
7 | export default useBreaktimeConfirmation
8 |
--------------------------------------------------------------------------------
/pages/api/test/focus-sessions.js:
--------------------------------------------------------------------------------
1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2 |
3 | export default (req, res) => {
4 | res.statusCode = 200
5 | // FIXME: Mock filter active sessionss
6 | res.json([])
7 | }
8 |
--------------------------------------------------------------------------------
/features/common/hooks/useDialog.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | const useDialog = () => {
4 | const [showDialog, setShowDialog] = useState(false)
5 |
6 | return { showDialog, setShowDialog }
7 | }
8 |
9 | export default useDialog
10 |
--------------------------------------------------------------------------------
/features/common/components/ToggleColorScheme/handlers.js:
--------------------------------------------------------------------------------
1 | import { persistColorScheme } from './helpers'
2 |
3 | export const handleClick =
4 | ({ isDarkMode, setIsDarkMode }) =>
5 | () => {
6 | persistColorScheme({ isDarkMode: !isDarkMode, setIsDarkMode })
7 | }
8 |
--------------------------------------------------------------------------------
/config/redirects.js:
--------------------------------------------------------------------------------
1 | const redirectConfig = {
2 | async redirects() {
3 | return [
4 | {
5 | source: '/',
6 | destination: '/planning',
7 | permanent: true,
8 | },
9 | ]
10 | },
11 | }
12 |
13 | module.exports = redirectConfig
14 |
--------------------------------------------------------------------------------
/db.json.example:
--------------------------------------------------------------------------------
1 | {
2 | "tasks": [
3 | {
4 | "description": "This is my first task",
5 | "priority": 0,
6 | "status": "in-progress",
7 | "id": 1
8 | }
9 | ],
10 | "focus-sessions": [],
11 | "profile": {
12 | "name": "typicode"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/config/index.js:
--------------------------------------------------------------------------------
1 | export const API_URL = process.env.NEXT_PUBLIC_API_URL
2 | export const MAXIMUN_IN_PRIORITY_TASKS = Number(
3 | process.env.NEXT_PUBLIC_MAXIMUN_IN_PRIORITY_TASKS
4 | )
5 | export const MAXIMUM_BACKLOG_QUANTITY = Number(
6 | process.env.NEXT_PUBLIC_MAXIMUM_BACKLOG_QUANTITY
7 | )
8 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://github.com/airbnb/javascript/blob/master/.editorconfig
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 | end_of_line = lf
11 |
12 | [CHANGELOG.md]
13 | indent_size = false
14 |
--------------------------------------------------------------------------------
/features/common/hooks/useToggle.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | const useToggle = (initialState = false) => {
4 | const [isOn, setIsOn] = useState(initialState)
5 |
6 | const toggle = () => setIsOn((prevState) => !prevState)
7 |
8 | return { isOn, toggle }
9 | }
10 |
11 | export default useToggle
12 |
--------------------------------------------------------------------------------
/features/focusSession/hooks/useBreaktimeTimer.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import useDialog from '../../common/hooks/useDialog'
3 |
4 | const useBreaktimeTimer = () => {
5 | const [time, setTime] = useState(null)
6 |
7 | return { ...useDialog(), time, setTime }
8 | }
9 |
10 | export default useBreaktimeTimer
11 |
--------------------------------------------------------------------------------
/features/tasks/hooks/useEditTaskModal.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import useDialog from '../../common/hooks/useDialog'
3 |
4 | const useEditTaskModal = () => {
5 | const [taskId, setTaskId] = useState(null)
6 |
7 | return { ...useDialog(), taskId, setTaskId }
8 | }
9 |
10 | export default useEditTaskModal
11 |
--------------------------------------------------------------------------------
/pages/retrospective.js:
--------------------------------------------------------------------------------
1 | import { withPageAuthRequired } from '@auth0/nextjs-auth0'
2 | import RetrospectiveContainer from '../features/retrospective/containers/Retrospective'
3 |
4 | export const getServerSideProps = withPageAuthRequired()
5 |
6 | export default function Retrospective() {
7 | return
8 | }
9 |
--------------------------------------------------------------------------------
/features/tasks/hooks/useDeleteConfirmation.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import useDialog from '../../common/hooks/useDialog'
3 |
4 | const useDeleteConfirmation = () => {
5 | const [taskId, setTaskId] = useState(null)
6 |
7 | return { ...useDialog(), taskId, setTaskId }
8 | }
9 |
10 | export default useDeleteConfirmation
11 |
--------------------------------------------------------------------------------
/pages/api/local/tasks/[id]/index.js:
--------------------------------------------------------------------------------
1 | import buildLocalApiUrl from '../../../../../utils/buildLocalApiUrl'
2 | import fetchJsonServer from '../../../../../utils/fetchJsonServer'
3 |
4 | export default function handler(req, res) {
5 | const { url, options } = buildLocalApiUrl(req)
6 | fetchJsonServer({ resource: 'task', url, options, res })
7 | }
8 |
--------------------------------------------------------------------------------
/tests/integration/planning.integration.test.js:
--------------------------------------------------------------------------------
1 | // import { Selector } from 'testcafe'
2 |
3 | fixture`Planning Testing`.page`http://localhost:3000/planning`
4 |
5 | test('when a new task is added it should send a POST request', async (t) => {
6 | await t
7 | .click('#planning-add-button')
8 | .typeText('#planning-add-button-input', 'John Smith')
9 | })
10 |
--------------------------------------------------------------------------------
/features/common/hooks/useLocalData.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 |
3 | const useLocalData = (fetchedData) => {
4 | const [localData, setLocalData] = useState(fetchedData)
5 |
6 | useEffect(() => {
7 | setLocalData(fetchedData)
8 | }, [fetchedData])
9 |
10 | return { localData, setLocalData }
11 | }
12 |
13 | export default useLocalData
14 |
--------------------------------------------------------------------------------
/pages/api/local/[...entity].js:
--------------------------------------------------------------------------------
1 | import buildLocalApiUrl from '../../../utils/buildLocalApiUrl'
2 | import fetchJsonServer from '../../../utils/fetchJsonServer'
3 |
4 | export default async function handler(req, res) {
5 | let { url, options } = buildLocalApiUrl(req)
6 | const [resource] = req.query.entity
7 |
8 | fetchJsonServer({ resource, url, options, res })
9 | }
10 |
--------------------------------------------------------------------------------
/.stylelintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "stylelint-config-recommended",
4 | "stylelint-config-idiomatic-order"
5 | ],
6 | "rules": {
7 | "selector-pseudo-class-no-unknown": [
8 | true,
9 | {
10 | "ignorePseudoClasses": ["global"]
11 | }
12 | ],
13 | "at-rule-no-unknown": [
14 | true,
15 | {
16 | "ignoreAtRules": ["mixin", "include"]
17 | }
18 | ]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/features/tasks/components/DraggableTask/handlers.js:
--------------------------------------------------------------------------------
1 | export const handleCompleteTask =
2 | ({ id, onCompleteTask }) =>
3 | ({ isChecked }) => {
4 | onCompleteTask({ id, isChecked })
5 | }
6 |
7 | export const handleDeleteTask =
8 | ({ id, onDeleteTask }) =>
9 | () => {
10 | onDeleteTask({ id })
11 | }
12 |
13 | export const handleEditTask =
14 | ({ id, onEditTask }) =>
15 | () => {
16 | onEditTask({ id })
17 | }
18 |
--------------------------------------------------------------------------------
/.env.development:
--------------------------------------------------------------------------------
1 | # CONFIG
2 | NEXT_PUBLIC_MAXIMUN_IN_PRIORITY_TASKS=2
3 | NEXT_PUBLIC_MAXIMUM_BACKLOG_QUANTITY=4
4 |
5 | # URLS
6 | NEXT_PUBLIC_BASE_URL=http://localhost:3000
7 | NEXT_PUBLIC_API_URL=http://localhost:3000/api/local
8 |
9 | # JSON SERVER
10 | JSON_SERVER_URL=http://localhost:3001
11 |
12 | # AUTH0
13 | AUTH0_BASE_URL=$NEXT_PUBLIC_BASE_URL
14 | AUTH0_ISSUER_BASE_URL=https://cero-dev.eu.auth0.com
15 | AUTH0_CLIENT_ID=UZZS9FCtzyO052aIcKmicXfNEkY5X9Ra
16 |
--------------------------------------------------------------------------------
/features/planning/components/__snapshots__/AddTaskButton.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`[ features / planning / components / AddTaskButton ] when \`AddTaskButton\` is mounted should render 1`] = `
4 |
5 |
6 | [Spacer.Vertical {"size":"md"} /]
7 | [Button {"id":"","focusHelpText":"Presiona enter","blurHelpText":"Clic para continuar"}]Toca para agregar la tarea[/Button]
8 |
9 | `;
10 |
--------------------------------------------------------------------------------
/utils/time.js:
--------------------------------------------------------------------------------
1 | const time = {
2 | FIVE_MINUTES_IN_MS: 300000,
3 | TEN_MINUTES_IN_MS: 600000,
4 | FIFTY_MINUTES_IN_MS: 900000,
5 | ONE_SECOND_IN_MS: 1000,
6 | ONE_HOUR_IN_MS: 3600000,
7 | HALF_HOUR_IN_MS: 1800000,
8 | TWO_HOUR_IN_MS: 7200000,
9 | ONE_DAY_IN_SECONDS: 86400,
10 | ONE_DAY_IN_MS: 86400000,
11 | ONE_HOUR_IN_SECONDS: 3600,
12 | ONE_MINUTE_IN_SECONDS: 60,
13 | ONE_MINUTE_IN_MS: 60000,
14 | ONE_SECOND: 1,
15 | }
16 |
17 | export default time
18 |
--------------------------------------------------------------------------------
/features/common/components/ToggleColorScheme/__snapshots__/ToggleColorScheme.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`[ features / common / components / ToggleColorScheme ] when \`ToggleColorScheme\` is mounted should render 1`] = `
4 |
5 |
8 |
9 | [Check {"isChecked":false} /]
10 | [Heading]Dark Mode[/Heading]
11 |
12 |
13 | `;
14 |
--------------------------------------------------------------------------------
/utils/buildLocalApiUrl.js:
--------------------------------------------------------------------------------
1 | const API_BASENAME = '/api/local/'
2 |
3 | const buildLocalApiUrl = (req, fetchOptions = {}) => {
4 | const url = `${req.url.replace(API_BASENAME, '')}`
5 | // https://fetch.spec.whatwg.org/#methods
6 | const normalizedMethod = req.method.toUpperCase()
7 |
8 | const options = {
9 | method: normalizedMethod,
10 | body: normalizedMethod !== 'GET' ? req.body : undefined,
11 | ...fetchOptions,
12 | }
13 |
14 | return { url, options }
15 | }
16 |
17 | export default buildLocalApiUrl
18 |
--------------------------------------------------------------------------------
/features/focusSession/components/FocusSessionFooter.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Spacer, Button } from '@glrodasz/components'
3 |
4 | const FocusSessionFooter = ({ onClickEndSession }) => {
5 | return (
6 | <>
7 |
8 |
9 | Finalizar tu sesión
10 |
11 | >
12 | )
13 | }
14 |
15 | FocusSessionFooter.propTypes = {
16 | onClickEndSession: PropTypes.func.isRequired,
17 | }
18 |
19 | export default FocusSessionFooter
20 |
--------------------------------------------------------------------------------
/features/focusSession/components/__snapshots__/FocusSessionFooter.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`[ features / focusSession / components / FocusSessionFooter ] when \`FocusSessionFooter\` is mounted should render 1`] = `
4 |
5 |
6 | [Spacer.Vertical {"size":"lg"} /]
7 |
10 |
13 | Finalizar tu sesión
14 |
15 |
16 |
17 | `;
18 |
--------------------------------------------------------------------------------
/features/common/components/MainLayout/__snapshots__/MainLayout.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`[ features / common / components / MainLayout ] when \`MainLayout\` is mounted should render 1`] = `
4 |
5 |
8 |
13 |
16 | content-component
17 |
18 |
19 |
20 | `;
21 |
--------------------------------------------------------------------------------
/features/focusSession/helpers.js:
--------------------------------------------------------------------------------
1 | import time from '../../utils/time'
2 |
3 | export const getBarWidth = (
4 | currentTime,
5 | filledBarTime = time.ONE_HOUR_IN_MS
6 | ) => {
7 | const timeRatio = currentTime % filledBarTime
8 | return (timeRatio * 100) / filledBarTime
9 | }
10 |
11 | export const getChronometerStartTime = ({ startTime, pauseStartTime }) => {
12 | const nowTime = Date.now()
13 |
14 | if (startTime && pauseStartTime) {
15 | return nowTime - (startTime + nowTime - pauseStartTime)
16 | }
17 |
18 | if (startTime) {
19 | return nowTime - startTime
20 | }
21 |
22 | return 0
23 | }
24 |
--------------------------------------------------------------------------------
/features/common/hooks/useInterval.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react'
2 |
3 | const useInterval = (callback, delay) => {
4 | const [intervalId, setIntervalId] = useState(null)
5 | const savedCallback = useRef()
6 |
7 | useEffect(() => {
8 | savedCallback.current = callback
9 | }, [callback])
10 |
11 | useEffect(() => {
12 | const tick = () => savedCallback.current()
13 |
14 | if (delay !== null) {
15 | setIntervalId(setInterval(tick, delay))
16 | return () => clearInterval(intervalId)
17 | } else {
18 | clearInterval(intervalId)
19 | }
20 | }, [delay])
21 | }
22 |
23 | export default useInterval
24 |
--------------------------------------------------------------------------------
/features/common/components/MainLayout/MainLayout.test.js:
--------------------------------------------------------------------------------
1 | import MainLayout from './MainLayout'
2 | import { render } from '@testing-library/react'
3 |
4 | describe('[ features / common / components / MainLayout ]', () => {
5 | describe('when `MainLayout` is mounted', () => {
6 | it('should render', () => {
7 | // Arrange
8 | const props = {
9 | menu: 'menu-component',
10 | content: 'content-component',
11 | isPlayground: true,
12 | }
13 |
14 | // Act
15 | const { asFragment } = render( )
16 |
17 | // Assert
18 | expect(asFragment()).toMatchSnapshot()
19 | })
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/.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 | # Optional eslint cache
37 | .eslintcache
38 |
39 | # npm
40 | package-lock.json
41 |
42 | # storybook
43 | storybook-static
44 |
45 | # JSON Server
46 | db.json
47 |
--------------------------------------------------------------------------------
/utils/isEmpty.js:
--------------------------------------------------------------------------------
1 | import isObject from './isObject'
2 |
3 | const NOT_EMPTIABLE_TYPES = [
4 | 'number',
5 | 'undefined',
6 | 'symbol',
7 | 'boolean',
8 | 'bigint',
9 | 'function',
10 | ]
11 |
12 | const isEmpty = (value) => {
13 | if (value === null || NOT_EMPTIABLE_TYPES.includes(typeof value)) {
14 | throw new Error('The value is not an object, an array or a string')
15 | }
16 |
17 | if (isObject(value)) {
18 | return (
19 | Object.keys(value).length === 0 &&
20 | Object.keys(Object.getPrototypeOf(value)).length === 0
21 | )
22 | }
23 |
24 | if (Array.isArray(value) || typeof value === 'string') {
25 | return value.length === 0
26 | }
27 | }
28 | export default isEmpty
29 |
--------------------------------------------------------------------------------
/features/common/components/ToggleColorScheme/ToggleColorScheme.js:
--------------------------------------------------------------------------------
1 | import { Check, Heading } from '@glrodasz/components'
2 |
3 | import { handleClick } from './handlers'
4 | import useColorScheme from '../../hooks/useColorScheme'
5 |
6 | const ToggleColorScheme = () => {
7 | const { isDarkMode, setIsDarkMode } = useColorScheme()
8 |
9 | return (
10 |
19 | Dark Mode
20 |
21 | )
22 | }
23 |
24 | export default ToggleColorScheme
25 |
--------------------------------------------------------------------------------
/utils/formatMilliseconds.js:
--------------------------------------------------------------------------------
1 | const formatMilliseconds = (milliseconds = 0) => {
2 | if (typeof milliseconds !== 'number') {
3 | throw new Error('milliseconds is not a number')
4 | }
5 |
6 | const seconds = milliseconds / 1000
7 | const minutes = Math.floor(seconds / 60)
8 | const hours = Math.floor(minutes / 60)
9 |
10 | const currentHours = String(hours).padStart(2, '0')
11 | const currentMinutes = String(minutes % 60).padStart(2, '0')
12 | const currentSeconds = String(parseInt(seconds % 60)).padStart(2, '0')
13 |
14 | return currentHours !== '00'
15 | ? `${currentHours}:${currentMinutes}:${currentSeconds}`
16 | : `${currentMinutes}:${currentSeconds}`
17 | }
18 |
19 | export default formatMilliseconds
20 |
--------------------------------------------------------------------------------
/api/focusSessions.js:
--------------------------------------------------------------------------------
1 | import Request from './request'
2 |
3 | class FocusSession extends Request {
4 | create() {
5 | return this.fetch('focus-sessions', {
6 | method: 'post',
7 | })
8 | }
9 |
10 | finish() {
11 | return this.fetch(`focus-sessions/finish`, {
12 | method: 'patch',
13 | })
14 | }
15 |
16 | getActive() {
17 | return this.fetch('focus-sessions/active')
18 | }
19 |
20 | pause({ time } = {}) {
21 | return this.fetch(`focus-sessions/pause`, {
22 | method: 'patch',
23 | body: { time },
24 | })
25 | }
26 |
27 | resume() {
28 | return this.fetch(`focus-sessions/resume`, {
29 | method: 'patch',
30 | })
31 | }
32 | }
33 |
34 | export default FocusSession
35 |
--------------------------------------------------------------------------------
/features/planning/components/__snapshots__/PlanningFooter.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`[ features / planning / components / PlanningFooter ] when \`tasksLength\` is greater or equal to one should return the footer 1`] = `
4 |
5 |
6 | [Spacer.Vertical {"size":"lg"} /]
7 | [Paragraph {"size":"sm"}]Basados en la matriz de Eisenhower priorizamos tus tareas evitando listas de pendientes saturadas.[/Paragraph]
8 | [Spacer.Vertical {"size":"sm"} /]
9 |
12 |
15 | Empieza ahora
16 |
17 |
18 |
19 | `;
20 |
--------------------------------------------------------------------------------
/utils/fetchJsonServer.js:
--------------------------------------------------------------------------------
1 | import Request from '../api/request'
2 | const JSON_SERVER_URL = process.env.JSON_SERVER_URL
3 |
4 | const fetchJsonServer = async ({
5 | resource,
6 | url,
7 | options,
8 | res,
9 | singular = false,
10 | }) => {
11 | try {
12 | const request = new Request(resource, JSON_SERVER_URL)
13 | let result = await request.fetch(url, options)
14 |
15 | if (singular && Array.isArray(result)) {
16 | result = result[0] ?? {}
17 | }
18 |
19 | return res ? res.status(200).json(result) : Promise.resolve(result)
20 | } catch (error) {
21 | return res
22 | ? res.status(500).json({ error: error.message })
23 | : Promise.reject(error)
24 | }
25 | }
26 |
27 | export default fetchJsonServer
28 |
--------------------------------------------------------------------------------
/features/focusSession/hooks/useChronometer.js:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect } from 'react'
2 | import { getChronometerStartTime } from '../helpers'
3 | import useTime from '../../common/hooks/useTime'
4 |
5 | const useChronometer = ({ startTime, pauseStartTime, isPaused }) => {
6 | const totalStartTime = useCallback(
7 | () =>
8 | getChronometerStartTime({
9 | startTime,
10 | pauseStartTime,
11 | }),
12 | [startTime, pauseStartTime]
13 | )
14 |
15 | const { currentTime, clearTime, resumeTime } = useTime({
16 | startTime: totalStartTime,
17 | })
18 |
19 | useEffect(() => {
20 | if (isPaused) {
21 | clearTime()
22 | }
23 | }, [isPaused])
24 |
25 | return { currentTime, clearTime, resumeTime }
26 | }
27 |
28 | export default useChronometer
29 |
--------------------------------------------------------------------------------
/features/planning/components/AddTaskButton.test.js:
--------------------------------------------------------------------------------
1 | import AddTaskButton from './AddTaskButton'
2 | import { render } from '@testing-library/react'
3 |
4 | jest.mock('@glrodasz/components', () => {
5 | const { dummyRender } = require('../../../utils/testUtils/dummyRender')
6 | return {
7 | Spacer: { Vertical: dummyRender('Spacer.Vertical') },
8 | AddButton: dummyRender('Button'),
9 | }
10 | })
11 |
12 | describe('[ features / planning / components / AddTaskButton ]', () => {
13 | describe('when `AddTaskButton` is mounted', () => {
14 | it('should render', () => {
15 | const props = {
16 | isShown: true,
17 | onAddTask: () => {},
18 | }
19 | const { asFragment } = render( )
20 | expect(asFragment()).toMatchSnapshot()
21 | })
22 | })
23 | })
24 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | collectCoverageFrom: [
3 | '{features,api}/**/!(index|constants).js',
4 | '{helpers,scripts}/*.js',
5 | ],
6 | coverageThreshold: {
7 | global: {
8 | branches: 60,
9 | functions: 60,
10 | lines: 60,
11 | statements: 60,
12 | },
13 | },
14 | moduleNameMapper: {
15 | '\\.css$': 'identity-obj-proxy',
16 | '\\.svg$': '/utils/testUtils/svgrMock.js',
17 | },
18 | setupFilesAfterEnv: ['./jest.setup.js'],
19 | testEnvironment: 'jsdom',
20 | testMatch: [
21 | '**/__tests__/**/*.[jt]s?(x)',
22 | '**/?!(*.integration.)+(spec|test).[jt]s?(x)',
23 | ],
24 | transform: {
25 | '^.+\\.jsx?$': ['babel-jest', { presets: ['next/babel'] }],
26 | },
27 | transformIgnorePatterns: ['node_modules/(?!@glrodasz/components)'],
28 | }
29 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "commonjs": true,
5 | "es6": true,
6 | "jest": true,
7 | "node": true
8 | },
9 | "extends": [
10 | "eslint:recommended",
11 | "plugin:react/recommended",
12 | "next",
13 | "plugin:prettier/recommended",
14 | "plugin:testcafe/recommended"
15 | ],
16 | "globals": {
17 | "Atomics": "readonly",
18 | "SharedArrayBuffer": "readonly"
19 | },
20 | "parserOptions": {
21 | "ecmaVersion": 2020,
22 | "sourceType": "module",
23 | "ecmaFeatures": {
24 | "jsx": true
25 | }
26 | },
27 | "plugins": ["react"],
28 | "settings": {
29 | "react": {
30 | "version": "detect"
31 | }
32 | },
33 | "rules": {
34 | "react/react-in-jsx-scope": "off",
35 | "react/forbid-prop-types": "warn"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/api/request.js:
--------------------------------------------------------------------------------
1 | import { API_URL } from '../config'
2 |
3 | class Request {
4 | constructor(resource, baseUrl) {
5 | this.resource = resource
6 | this.baseUrl = baseUrl
7 | }
8 |
9 | fetch(resource = this.resource, options = {}) {
10 | const method = options.method ? options.method.toUpperCase() : 'GET'
11 | const requestOptions = { ...options, method }
12 |
13 | requestOptions.headers = new Headers({
14 | 'Content-Type': 'application/json',
15 | ...options.headers,
16 | })
17 |
18 | if (options.body) {
19 | requestOptions.body = JSON.stringify(options.body)
20 | }
21 |
22 | const baseUrl = this.baseUrl ?? API_URL
23 |
24 | return fetch(`${baseUrl}/${resource}`, requestOptions).then((data) =>
25 | data.json()
26 | )
27 | }
28 | }
29 |
30 | export default Request
31 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | // https://console.spec.whatwg.org/#loglevel-severity
2 | const CONSOLE_LEVELS = ['debug', 'log', 'info', 'warn', 'error']
3 |
4 | // Notice that by default the levels will be ["error"]
5 | const allowedConsoleLevels = CONSOLE_LEVELS.slice(
6 | CONSOLE_LEVELS.indexOf(process.env.CONSOLE_LEVEL)
7 | )
8 |
9 | global.console = CONSOLE_LEVELS.reduce((levels, level) => {
10 | return allowedConsoleLevels.includes(level)
11 | ? { ...levels, [level]: console[level] }
12 | : { ...levels, [level]: jest.fn() }
13 | }, {})
14 |
15 | // https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#updates-to-server-rendering-apis
16 | // TODO: dummyRender.js needs to be updated to use the Suspense SSR Architectue instead of renderToStaticMarkup
17 | const { TextEncoder } = require('util')
18 | global.TextEncoder = TextEncoder
19 |
--------------------------------------------------------------------------------
/features/retrospective/components/RetrospectiveFooter.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Spacer, Button } from '@glrodasz/components'
3 |
4 | const RetrospectiveFooter = ({
5 | onClickRegisterSession,
6 | onClickSkipRegisterSession,
7 | }) => {
8 | return (
9 | <>
10 |
11 |
12 | Registrar sesión
13 |
14 |
15 |
16 | No registrar esta sesión
17 |
18 | >
19 | )
20 | }
21 |
22 | RetrospectiveFooter.propTypes = {
23 | onClickRegisterSession: PropTypes.func.isRequired,
24 | onClickSkipRegisterSession: PropTypes.func.isRequired,
25 | }
26 |
27 | export default RetrospectiveFooter
28 |
--------------------------------------------------------------------------------
/features/common/components/ToggleColorScheme/ToggleColorScheme.test.js:
--------------------------------------------------------------------------------
1 | import ToggleColorScheme from './ToggleColorScheme'
2 | import { render } from '@testing-library/react'
3 |
4 | jest.mock('@glrodasz/components', () => {
5 | const { dummyRender } = require('../../../../utils/testUtils/dummyRender')
6 | return {
7 | Check: dummyRender('Check'),
8 | Heading: dummyRender('Heading'),
9 | }
10 | })
11 |
12 | jest.mock('../../hooks/useColorScheme', () => () => ({
13 | isDarkMode: false,
14 | setIsDarkMode: () => {},
15 | }))
16 |
17 | describe('[ features / common / components / ToggleColorScheme ]', () => {
18 | describe('when `ToggleColorScheme` is mounted', () => {
19 | it('should render', () => {
20 | const { asFragment } = render( )
21 | expect(asFragment()).toMatchSnapshot()
22 | })
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/features/planning/components/AddTaskButton.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Spacer, AddButton } from '@glrodasz/components'
3 |
4 | const AddTaskButton = ({ id, onAddTask, isShown }) => {
5 | if (isShown) {
6 | return (
7 | <>
8 |
9 |
15 | Toca para agregar la tarea
16 |
17 | >
18 | )
19 | }
20 |
21 | return null
22 | }
23 |
24 | AddTaskButton.defaultProps = {
25 | id: '',
26 | }
27 |
28 | AddTaskButton.propTypes = {
29 | id: PropTypes.string,
30 | onAddTask: PropTypes.func.isRequired,
31 | isShown: PropTypes.bool,
32 | }
33 |
34 | export default AddTaskButton
35 |
--------------------------------------------------------------------------------
/config/webpack.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const transpileModules = require('next-transpile-modules')
3 |
4 | const customConfig = {
5 | webpack: (config, options) => {
6 | // resolve unique version of react
7 | // https://github.com/martpie/next-transpile-modules#i-have-trouble-with-duplicated-dependencies-or-the-invalid-hook-call-error-in-react
8 | if (options.isServer) {
9 | config.externals = ['react', ...config.externals]
10 | }
11 |
12 | config.resolve.alias['react'] = path.resolve(
13 | __dirname,
14 | '../node_modules/react'
15 | )
16 |
17 | // Load SVG as inline React component
18 | config.module.rules.push({
19 | test: /\.svg$/i,
20 | issuer: /\.[jt]sx?$/,
21 | use: ['@svgr/webpack'],
22 | })
23 |
24 | return config
25 | },
26 | }
27 |
28 | module.exports = transpileModules(['@glrodasz/components'])(customConfig)
29 |
--------------------------------------------------------------------------------
/features/planning/components/PlanningFooter.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Spacer, Paragraph, Button } from '@glrodasz/components'
3 |
4 | const PlanningFooter = ({ tasksLength, onClickStartSession }) => {
5 | if (!!tasksLength >= 1) {
6 | return (
7 | <>
8 |
9 |
10 | Basados en la matriz de Eisenhower priorizamos tus tareas evitando
11 | listas de pendientes saturadas.
12 |
13 |
14 |
15 | Empieza ahora
16 |
17 | >
18 | )
19 | }
20 |
21 | return null
22 | }
23 |
24 | PlanningFooter.propTypes = {
25 | onClickStartSession: PropTypes.func.isRequired,
26 | tasksLength: PropTypes.number,
27 | }
28 |
29 | export default PlanningFooter
30 |
--------------------------------------------------------------------------------
/features/focusSession/hooks/useFocusSessions.js:
--------------------------------------------------------------------------------
1 | import { focusSessionsApi } from '../../planning/api'
2 | import { useMutation, useQueryClient } from '@tanstack/react-query'
3 |
4 | const QUERY_KEY = 'focus-sessions'
5 |
6 | export const createMutation = (params) => focusSessionsApi.create(params)
7 | export const finishMutation = (params) => focusSessionsApi.finish(params)
8 |
9 | const useFocusSessions = () => {
10 | const queryClient = useQueryClient()
11 |
12 | const { mutateAsync: create } = useMutation(createMutation, {
13 | onSuccess: () => {
14 | queryClient.invalidateQueries(QUERY_KEY)
15 | },
16 | })
17 |
18 | const { mutateAsync: finish } = useMutation(finishMutation, {
19 | onSuccess: () => {
20 | queryClient.invalidateQueries(QUERY_KEY)
21 | },
22 | })
23 |
24 | return {
25 | api: {
26 | create,
27 | finish,
28 | },
29 | }
30 | }
31 |
32 | export default useFocusSessions
33 |
--------------------------------------------------------------------------------
/features/planning/components/PlanningOnboarding.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Spacer, Heading } from '@glrodasz/components'
3 |
4 | const PlanningOnboarding = ({ tasksLength, children }) => {
5 | return (
6 | <>
7 | {tasksLength == 0 && (
8 | <>
9 |
10 |
11 | ¿Cuál es la primera tarea en la que trabajarás hoy?
12 |
13 | >
14 | )}
15 | {children}
16 | {tasksLength === 1 && (
17 | <>
18 |
19 |
20 | Continúa listando las demás tareas de tu día...
21 |
22 | >
23 | )}
24 | >
25 | )
26 | }
27 |
28 | PlanningOnboarding.propTypes = {
29 | children: PropTypes.node.isRequired,
30 | tasksLength: PropTypes.number,
31 | }
32 |
33 | export default PlanningOnboarding
34 |
--------------------------------------------------------------------------------
/features/common/components/NavigationMenu/NavigationMenu.test.js:
--------------------------------------------------------------------------------
1 | import NavigationMenu from './NavigationMenu'
2 | import { render } from '@testing-library/react'
3 |
4 | jest.mock('next/router', () => ({
5 | useRouter: () => ({
6 | pathname: '/home',
7 | }),
8 | }))
9 |
10 | jest.mock('../../hooks/useBreakpoints', () => () => ({
11 | isDesktop: true,
12 | }))
13 |
14 | jest.mock('@glrodasz/components', () => {
15 | const { dummyRender } = require('../../../../utils/testUtils/dummyRender')
16 | return {
17 | IconLabel: dummyRender('IconLabel'),
18 | }
19 | })
20 |
21 | describe('[ features / common / components / NavigationMenu ]', () => {
22 | describe('when `NavigationMenu` is mounted', () => {
23 | it('should render', () => {
24 | // Arrange
25 | const props = {}
26 |
27 | // Act
28 | const { asFragment } = render( )
29 |
30 | // Assert
31 | expect(asFragment()).toMatchSnapshot()
32 | })
33 | })
34 | })
35 |
--------------------------------------------------------------------------------
/features/common/components/UserHeader/UserHeader.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import { Avatar, Spacer, Heading, Paragraph } from '@glrodasz/components'
4 |
5 | import styles from './UserHeader.module.css'
6 |
7 | const UserHeader = ({ avatar, title, text, isPrimary }) => {
8 | return (
9 |
10 |
11 |
12 |
13 |
{title}
14 | {isPrimary ? (
15 |
{text}
16 | ) : (
17 |
{text}
18 | )}
19 |
20 |
21 | )
22 | }
23 |
24 | UserHeader.propTypes = {
25 | avatar: PropTypes.string.isRequired,
26 | title: PropTypes.string.isRequired,
27 | text: PropTypes.string.isRequired,
28 | isPrimary: PropTypes.bool,
29 | }
30 |
31 | export default UserHeader
32 |
--------------------------------------------------------------------------------
/features/tasks/hooks/useTask.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { tasksApi } from '../../planning/api'
3 | import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
4 |
5 | const QUERY_KEY = 'task'
6 |
7 | const useTask = ({ id }) => {
8 | const queryClient = useQueryClient()
9 |
10 | const {
11 | isLoading,
12 | error,
13 | data: serverData,
14 | } = useQuery([QUERY_KEY, id], () => tasksApi.getById({ id }))
15 |
16 | const { mutateAsync: update } = useMutation(
17 | (params) => tasksApi.update(params),
18 | {
19 | onSuccess: () => {
20 | queryClient.invalidateQueries(QUERY_KEY)
21 | },
22 | }
23 | )
24 |
25 | const [localData, setLocalData] = useState(serverData)
26 |
27 | useEffect(() => {
28 | setLocalData(serverData)
29 | }, [serverData])
30 |
31 | return {
32 | isLoading,
33 | error,
34 | data: localData,
35 | setLocalData,
36 | api: {
37 | update,
38 | },
39 | }
40 | }
41 |
42 | export default useTask
43 |
--------------------------------------------------------------------------------
/features/focusSession/components/__snapshots__/BreaktimeConfirmation.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`[ features / focusSession / components / BreaktimeConfirmation ] when \`BreaktimeConfirmation\` is mounted should render 1`] = `
4 |
5 |
6 | [Modal {"isCentered":true}]
7 | [CenteredContent]
8 | [Picture {"src":"/images/couch-pause.svg","width":200} /]
9 | [Spacer.Vertical {"size":"md"} /]
10 | [Heading {"size":"xl","color":"tertiary","isCentered":true}]Tomate un tiempo para refrescarte[/Heading]
11 | [Spacer.Vertical {"size":"sm"} /]
12 | [Paragraph {"color":"inverted","isCentered":true}]Siempre hay que celebrar los pequeños triunfos, por eso te invitamos a tomar un descanso para despejar tu mente.[/Paragraph]
13 | [Spacer.Vertical {"size":"lg"} /][div style="display:flex;gap:0 20px;width:100%"]
14 | [Button {"isMuted":true}]5 min[/Button]
15 | [Button {"isMuted":true}]10 min[/Button]
16 | [Button {"isMuted":true}]15 min[/Button][/div][/CenteredContent][/Modal]
17 |
18 | `;
19 |
--------------------------------------------------------------------------------
/utils/testUtils/dummyRender.test.js:
--------------------------------------------------------------------------------
1 | import { dummyRender } from './dummyRender'
2 |
3 | describe('[ utils / testUtils / dummyRender ]', () => {
4 | describe('when the component is childless', () => {
5 | it('should render without children', () => {
6 | // Arrange
7 | const name = 'Childless'
8 | const props = { foo: 'bar', foobar: true }
9 |
10 | // Act
11 | const result = dummyRender(name)(props)
12 | const expected = '\n[Childless {"foo":"bar","foobar":true} /]'
13 |
14 | // Assert
15 | expect(result).toBe(expected)
16 | })
17 | })
18 |
19 | describe('when the component has children', () => {
20 | it('should render with children', () => {
21 | // Arrange
22 | const name = 'WithChildren'
23 | const props = { foo: 'bar', children: 'children' }
24 |
25 | // Act
26 | const result = dummyRender(name)(props)
27 | const expected = '\n[WithChildren {"foo":"bar"}]children[/WithChildren]'
28 |
29 | // Assert
30 | expect(result).toBe(expected)
31 | })
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/api/tasks.js:
--------------------------------------------------------------------------------
1 | import Request from './request'
2 |
3 | class Task extends Request {
4 | getAll() {
5 | return this.fetch()
6 | }
7 |
8 | getById({ id }) {
9 | return id && this.fetch(`tasks/${id}`)
10 | }
11 |
12 | create({ description }) {
13 | return this.fetch('tasks', {
14 | method: 'post',
15 | body: { description },
16 | })
17 | }
18 |
19 | updateStatus({ id, isChecked }) {
20 | const status = isChecked ? 'complete' : 'reset'
21 | return this.fetch(`tasks/${id}/${status}`, { method: 'patch' })
22 | }
23 |
24 | updatePriorities({ tasks }) {
25 | return Promise.all(
26 | tasks.map(({ id, priority, status }) =>
27 | this.fetch(`tasks/${id}`, {
28 | method: 'patch',
29 | body: { priority, status },
30 | })
31 | )
32 | )
33 | }
34 |
35 | delete({ id }) {
36 | return this.fetch(`tasks/${id}`, { method: 'delete' })
37 | }
38 |
39 | update({ id, task }) {
40 | return this.fetch(`tasks/${id}`, { method: 'patch', body: task })
41 | }
42 | }
43 |
44 | export default Task
45 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
3 |
4 |
--------------------------------------------------------------------------------
/features/common/components/UserHeader/__snapshots__/UserHeader.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`[ features / common / components / UserHeader ] when \`UserHeader\` is mounted should render 1`] = `
4 |
5 |
19 |
20 | `;
21 |
22 | exports[`[ features / common / components / UserHeader ] when \`UserHeader\` is mounted with \`isPrimary\` as \`true\` should render 1`] = `
23 |
24 |
38 |
39 | `;
40 |
--------------------------------------------------------------------------------
/features/focusSession/components/BreaktimeConfirmation.test.js:
--------------------------------------------------------------------------------
1 | import BreaktimeConfirmation from './BreaktimeConfirmation'
2 | import { render } from '@testing-library/react'
3 |
4 | jest.mock('@glrodasz/components', () => {
5 | const { dummyRender } = require('../../../utils/testUtils/dummyRender')
6 | return {
7 | Modal: dummyRender('Modal'),
8 | CenteredContent: dummyRender('CenteredContent'),
9 | Picture: dummyRender('Picture'),
10 | Heading: dummyRender('Heading'),
11 | Spacer: { Vertical: dummyRender('Spacer.Vertical') },
12 | Paragraph: dummyRender('Paragraph'),
13 | Button: dummyRender('Button'),
14 | }
15 | })
16 |
17 | describe('[ features / focusSession / components / BreaktimeConfirmation ]', () => {
18 | describe('when `BreaktimeConfirmation` is mounted', () => {
19 | it('should render', () => {
20 | const props = {
21 | onClose: () => {},
22 | onChoose: () => {},
23 | }
24 | const { asFragment } = render( )
25 | expect(asFragment()).toMatchSnapshot()
26 | })
27 | })
28 | })
29 |
--------------------------------------------------------------------------------
/pages/planning.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { resetServerContext } from 'react-beautiful-dnd'
3 | import { withPageAuthRequired } from '@auth0/nextjs-auth0'
4 |
5 | import PlanningContainer from '../features/planning/containers/Planning'
6 | import { tasksApi, focusSessionsApi } from '../features/planning/api'
7 | import isEmpty from '../utils/isEmpty'
8 | import httpCodes from '../utils/httpCodes'
9 |
10 | export const getServerSideProps = withPageAuthRequired({
11 | getServerSideProps: async ({ res }) => {
12 | resetServerContext()
13 |
14 | const activeFocusSession = await focusSessionsApi.getActive()
15 |
16 | if (!isEmpty(activeFocusSession)) {
17 | res.statusCode = httpCodes.FOUND
18 | res.setHeader('Location', '/focus-session')
19 | return { props: {} }
20 | }
21 |
22 | const tasks = await tasksApi.getAll()
23 | return { props: { tasks } }
24 | },
25 | })
26 |
27 | function Planning({ tasks }) {
28 | return
29 | }
30 |
31 | Planning.propTypes = {
32 | tasks: PropTypes.array,
33 | }
34 |
35 | export default Planning
36 |
--------------------------------------------------------------------------------
/features/common/components/ToggleColorScheme/helpers.js:
--------------------------------------------------------------------------------
1 | export const persistColorScheme = ({ isDarkMode, setIsDarkMode }) => {
2 | const colorScheme = isDarkMode ? 'dark' : 'light'
3 | document.querySelector('html').dataset.colorScheme = colorScheme
4 | localStorage.setItem('prefers-color-scheme', colorScheme)
5 | setIsDarkMode && setIsDarkMode(isDarkMode)
6 | }
7 |
8 | export const loadAndListenColorScheme = ({
9 | setIsDarkMode,
10 | __persistColorScheme = persistColorScheme,
11 | } = {}) => {
12 | const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
13 | darkModeMediaQuery?.addListener((event) => {
14 | const isDarkMode = event.matches
15 | __persistColorScheme({ isDarkMode, setIsDarkMode })
16 | })
17 |
18 | const localStorageColorScheme = localStorage.getItem('prefers-color-scheme')
19 |
20 | if (localStorageColorScheme) {
21 | const isDarkMode = localStorageColorScheme === 'dark'
22 | __persistColorScheme({ isDarkMode, setIsDarkMode })
23 | } else {
24 | const isDarkMode = darkModeMediaQuery?.matches
25 | __persistColorScheme({ isDarkMode, setIsDarkMode })
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/utils/timeAgo.js:
--------------------------------------------------------------------------------
1 | import time from './time'
2 |
3 | const DEAFULT_LOCALE = 'es-CO'
4 |
5 | const TIME_UNITS_IN_SECONDS = {
6 | day: time.ONE_DAY_IN_SECONDS,
7 | hour: time.ONE_HOUR_IN_SECONDS,
8 | minute: time.ONE_MINUTE_IN_SECONDS,
9 | second: time.ONE_SECOND,
10 | }
11 |
12 | const getSecondsDiff = (timestamp) =>
13 | (Date.now() - timestamp) / time.ONE_SECOND_IN_MS
14 |
15 | const getUnitAndValueTime = (secondsElapsed) => {
16 | const entries = Object.entries(TIME_UNITS_IN_SECONDS)
17 |
18 | for (const [unit, unitInSeconds] of entries) {
19 | const match = secondsElapsed >= unitInSeconds || unit === 'second'
20 |
21 | if (match) {
22 | const value = Math.floor(secondsElapsed / unitInSeconds)
23 | return { value, unit }
24 | }
25 | }
26 | }
27 |
28 | const timeAgo = (timestamp, locale = DEAFULT_LOCALE) => {
29 | const relativeTimeFormat = new Intl.RelativeTimeFormat(locale)
30 |
31 | const secondsElapsed = getSecondsDiff(timestamp)
32 | const { value, unit } = getUnitAndValueTime(secondsElapsed)
33 |
34 | return relativeTimeFormat.format(value * -1, unit)
35 | }
36 |
37 | export default timeAgo
38 |
--------------------------------------------------------------------------------
/features/tasks/components/DeleteTaskModal.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import {
3 | Modal,
4 | CenteredContent,
5 | Heading,
6 | Button,
7 | Spacer,
8 | Paragraph,
9 | } from '@glrodasz/components'
10 |
11 | const DeleteTaskModal = ({ onClickCancel, onClickConfirm }) => {
12 | return (
13 |
14 |
15 |
16 | ¿Estás seguro de querer eliminar esta tarea?
17 |
18 |
19 | La tarea se eliminará de manera permanente.
20 |
21 |
22 | No, Regresar
23 |
24 |
25 |
26 | Sí, eliminar
27 |
28 |
29 |
30 | )
31 | }
32 |
33 | DeleteTaskModal.propTypes = {
34 | onClickCancel: PropTypes.func.isRequired,
35 | onClickConfirm: PropTypes.func.isRequired,
36 | }
37 |
38 | export default DeleteTaskModal
39 |
--------------------------------------------------------------------------------
/features/common/components/NavigationMenu/__snapshots__/NavigationMenu.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`[ features / common / components / NavigationMenu ] when \`NavigationMenu\` is mounted should render 1`] = `
4 |
5 |
33 |
34 | `;
35 |
--------------------------------------------------------------------------------
/.github/workflows/review.yml:
--------------------------------------------------------------------------------
1 | name: Code Review
2 | on:
3 | pull_request:
4 | branches:
5 | - master
6 | jobs:
7 | build:
8 | name: Build, Lint, and Test
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout code
12 | uses: actions/checkout@v2
13 | - name: Setup Node.js v16
14 | uses: actions/setup-node@v1
15 | with:
16 | node-version: 16
17 | - name: Install dependencies
18 | run: yarn install --frozen-lockfile
19 | - name: Lint code
20 | run: yarn lint
21 | - name: Run tests
22 | run: yarn test
23 | - name: Build project
24 | run: yarn build
25 | coverage:
26 | name: Coverage
27 | runs-on: ubuntu-latest
28 | steps:
29 | - name: Checkout code
30 | uses: actions/checkout@v2
31 | - name: Setup Node.js v16
32 | uses: actions/setup-node@v1
33 | with:
34 | node-version: 16
35 | - name: Install dependencies
36 | run: yarn install --frozen-lockfile
37 | - name: Run coverage tests
38 | run: yarn test:coverage
39 | - name: Upload coverage
40 | uses: codecov/codecov-action@v1
41 |
--------------------------------------------------------------------------------
/features/common/hooks/useBreakpoints.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | const queries = [
4 | {
5 | name: 'desktop',
6 | media: '(min-width: 992px)',
7 | },
8 | ]
9 |
10 | const useBreakpoints = () => {
11 | const [breakpoints, setBreakpoints] = useState({
12 | isDesktop: false,
13 | isMobile: false,
14 | })
15 |
16 | useEffect(() => {
17 | function handleResize() {
18 | const mediaQueries = queries.map((query) => {
19 | const mediaQuery = window.matchMedia(query.media)
20 | return {
21 | name: query.name,
22 | media: mediaQuery.media,
23 | matches: mediaQuery.matches,
24 | }
25 | })
26 |
27 | const matchedQuery = mediaQueries.find((mediaQuery) => mediaQuery.matches)
28 |
29 | if (matchedQuery?.name === 'desktop') {
30 | setBreakpoints({ isDesktop: true, isMobile: false })
31 | } else {
32 | setBreakpoints({ isDesktop: false, isMobile: true })
33 | }
34 | }
35 |
36 | window.addEventListener('resize', handleResize)
37 | handleResize()
38 |
39 | return () => window.removeEventListener('resize', handleResize)
40 | }, [])
41 |
42 | return breakpoints
43 | }
44 |
45 | export default useBreakpoints
46 |
--------------------------------------------------------------------------------
/features/common/hooks/useTime.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react'
2 | import time from '../../../utils/time'
3 | import useInterval from './useInterval'
4 |
5 | const tick =
6 | ({ currentTime, setCurrentTime, isTimer }) =>
7 | () => {
8 | const modifier = isTimer
9 | ? time.ONE_SECOND_IN_MS * -1
10 | : time.ONE_SECOND_IN_MS
11 | setCurrentTime(currentTime + modifier)
12 | }
13 |
14 | const useTime = (
15 | { isTimer = false, startTime = 0, endTime = null, callback = () => {} } = {
16 | startTime: 0,
17 | }
18 | ) => {
19 | const [currentTime, setCurrentTime] = useState(startTime)
20 | const [delay, setDelay] = useState(time.ONE_SECOND_IN_MS)
21 |
22 | useInterval(tick({ currentTime, setCurrentTime, isTimer }), delay)
23 |
24 | const clearTime = useCallback(() => setDelay(null))
25 | const resumeTime = useCallback(() => setDelay(time.ONE_SECOND_IN_MS))
26 |
27 | useEffect(() => {
28 | if ((isTimer && currentTime <= 0) || (endTime && currentTime >= endTime)) {
29 | callback()
30 | }
31 | }, [currentTime])
32 |
33 | useEffect(() => {
34 | setCurrentTime(startTime)
35 | }, [startTime])
36 |
37 | return { currentTime, clearTime, resumeTime }
38 | }
39 |
40 | export default useTime
41 |
--------------------------------------------------------------------------------
/pages/focus-session.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { withPageAuthRequired } from '@auth0/nextjs-auth0'
3 | import FocusSessionContainer from '../features/focusSession/containers/FocusSession'
4 | import { resetServerContext } from 'react-beautiful-dnd'
5 | import isEmpty from '../utils/isEmpty'
6 | import httpCodes from '../utils/httpCodes'
7 |
8 | import { tasksApi, focusSessionsApi } from '../features/planning/api'
9 |
10 | export const getServerSideProps = withPageAuthRequired({
11 | getServerSideProps: async ({ res }) => {
12 | resetServerContext()
13 |
14 | const tasks = await tasksApi.getAll()
15 | const activeFocusSession = await focusSessionsApi.getActive()
16 |
17 | if (isEmpty(activeFocusSession)) {
18 | res.statusCode = httpCodes.FOUND
19 | res.setHeader('Location', '/planning')
20 |
21 | return { props: {} }
22 | }
23 |
24 | return { props: { tasks, activeFocusSession } }
25 | },
26 | })
27 |
28 | const FocusSession = ({ tasks, activeFocusSession }) => {
29 | return
30 | }
31 |
32 | FocusSession.propTypes = {
33 | tasks: PropTypes.array,
34 | activeFocusSession: PropTypes.object,
35 | }
36 |
37 | export default FocusSession
38 |
--------------------------------------------------------------------------------
/utils/testUtils/dummyRender.js:
--------------------------------------------------------------------------------
1 | import { renderToStaticMarkup } from 'react-dom/server'
2 | import isEmpty from '../isEmpty'
3 |
4 | const EMPTY_SPACE = ' '
5 | const NEW_LINE = '\n'
6 |
7 | const objectStringify = (obj) =>
8 | !isEmpty(obj) ? `${EMPTY_SPACE}${JSON.stringify(obj)}` : ''
9 |
10 | const cleanMarkup = (markup) =>
11 | markup
12 | .replace(/"/g, '"')
13 | .replace(/"/g, '"')
14 | .replace(/</g, '[')
15 | .replace(/>/g, ']')
16 |
17 | const renderStatic = (children) => {
18 | const isAnString = typeof children === 'string'
19 | return !isAnString
20 | ? cleanMarkup(`${renderToStaticMarkup(children)}`)
21 | : children
22 | }
23 |
24 | const dummyComponent = ({ name, props, children }) => {
25 | const stringifiedProps = objectStringify(props)
26 | const closeTag = children ? `]${children}[/${name}]` : ' /]'
27 |
28 | return `${NEW_LINE}[${name}${stringifiedProps}${closeTag}`
29 | }
30 |
31 | export const dummyRender = (name) => (props) => {
32 | if (props.children) {
33 | const { children, ...rest } = props
34 | return dummyComponent({
35 | name,
36 | props: rest,
37 | children: `${renderStatic(children)}`,
38 | })
39 | }
40 |
41 | return `${dummyComponent({ name, props })}`
42 | }
43 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | branches:
5 | - master
6 | jobs:
7 | build:
8 | name: Build, Lint, and Test
9 | if: "!contains(github.event.head_commit.message, 'skip ci')"
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v2
14 | - name: Setup Node.js v16
15 | uses: actions/setup-node@v1
16 | with:
17 | node-version: 16
18 | - name: Install dependencies
19 | run: yarn install --frozen-lockfile
20 | - name: Lint code
21 | run: yarn lint
22 | - name: Run tests
23 | run: yarn test
24 | - name: Build project
25 | run: yarn build
26 | coverage:
27 | name: Coverage
28 | if: "!contains(github.event.head_commit.message, 'skip ci')"
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout code
32 | uses: actions/checkout@v2
33 | - name: Setup Node.js v16
34 | uses: actions/setup-node@v1
35 | with:
36 | node-version: 16
37 | - name: Install dependencies
38 | run: yarn install --frozen-lockfile
39 | - name: Run coverage tests
40 | run: yarn test:coverage
41 | - name: Upload coverage
42 | uses: codecov/codecov-action@v1
43 |
--------------------------------------------------------------------------------
/features/common/components/UserHeader/UserHeader.test.js:
--------------------------------------------------------------------------------
1 | import UserHeader from './UserHeader'
2 | import { render } from '@testing-library/react'
3 |
4 | jest.mock('@glrodasz/components', () => {
5 | const { dummyRender } = require('../../../../utils/testUtils/dummyRender')
6 |
7 | return {
8 | Avatar: dummyRender('Avatar'),
9 | Spacer: { Horizontal: dummyRender('Space.Horizontal') },
10 | Heading: dummyRender('Heading'),
11 | Paragraph: dummyRender('Paragraph'),
12 | }
13 | })
14 |
15 | describe('[ features / common / components / UserHeader ]', () => {
16 | describe('when `UserHeader` is mounted', () => {
17 | it('should render', () => {
18 | const props = {
19 | avatar: 'avatar',
20 | title: 'title',
21 | text: 'text',
22 | }
23 | const { asFragment } = render( )
24 | expect(asFragment()).toMatchSnapshot()
25 | })
26 | })
27 |
28 | describe('when `UserHeader` is mounted with `isPrimary` as `true`', () => {
29 | it('should render', () => {
30 | const props = {
31 | avatar: 'avatar',
32 | title: 'title',
33 | text: 'text',
34 | isPrimary: true,
35 | }
36 | const { asFragment } = render( )
37 | expect(asFragment()).toMatchSnapshot()
38 | })
39 | })
40 | })
41 |
--------------------------------------------------------------------------------
/features/common/hooks/useColorScheme.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export const persistColorScheme = ({ isDarkMode, setIsDarkMode }) => {
4 | const colorScheme = isDarkMode ? 'dark' : 'light'
5 | document.querySelector('html').dataset.colorScheme = colorScheme
6 | localStorage.setItem('prefers-color-scheme', colorScheme)
7 | setIsDarkMode && setIsDarkMode(isDarkMode)
8 | }
9 | const useColorScheme = (__persistColorScheme = persistColorScheme) => {
10 | const [isDarkMode, setIsDarkMode] = useState(false)
11 |
12 | useEffect(() => {
13 | const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
14 | darkModeMediaQuery?.addListener((event) => {
15 | const isDarkMode = event.matches
16 | __persistColorScheme({ isDarkMode, setIsDarkMode })
17 | })
18 |
19 | const localStorageColorScheme = localStorage.getItem('prefers-color-scheme')
20 |
21 | if (localStorageColorScheme) {
22 | const isDarkMode = localStorageColorScheme === 'dark'
23 | __persistColorScheme({ isDarkMode, setIsDarkMode })
24 | } else {
25 | const isDarkMode = darkModeMediaQuery?.matches
26 | __persistColorScheme({ isDarkMode, setIsDarkMode })
27 | }
28 | }, [])
29 |
30 | return {
31 | isDarkMode,
32 | setIsDarkMode,
33 | }
34 | }
35 |
36 | export default useColorScheme
37 |
--------------------------------------------------------------------------------
/features/focusSession/hooks/useFocusSession.js:
--------------------------------------------------------------------------------
1 | import { focusSessionsApi } from '../api'
2 | import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3 | import useLocalData from '../../common/hooks/useLocalData'
4 |
5 | const QUERY_KEY = 'focus-session'
6 |
7 | export const pauseMutation = (params) => focusSessionsApi.pause(params)
8 | export const resumeMutation = () => focusSessionsApi.resume()
9 |
10 | const useFocusSession = ({ initialData, onResume }) => {
11 | const queryClient = useQueryClient()
12 |
13 | const {
14 | isLoading,
15 | error,
16 | data: fetchedData,
17 | } = useQuery([QUERY_KEY], () => focusSessionsApi.getActive(), { initialData })
18 |
19 | const { mutateAsync: pause } = useMutation(pauseMutation, {
20 | onSuccess: () => {
21 | queryClient.invalidateQueries(QUERY_KEY)
22 | },
23 | })
24 |
25 | const { mutateAsync: resume } = useMutation(resumeMutation, {
26 | onSuccess: () => {
27 | queryClient.invalidateQueries(QUERY_KEY)
28 | onResume?.()
29 | },
30 | })
31 |
32 | const { localData, setLocalData } = useLocalData(fetchedData)
33 |
34 | return {
35 | isLoading,
36 | error,
37 | data: localData,
38 | setLocalData,
39 | api: {
40 | pause,
41 | resume,
42 | },
43 | }
44 | }
45 |
46 | export default useFocusSession
47 |
--------------------------------------------------------------------------------
/features/common/components/ToggleColorScheme/handlers.test.js:
--------------------------------------------------------------------------------
1 | import { handleClick } from './handlers'
2 |
3 | import { persistColorScheme } from './helpers'
4 | jest.mock('./helpers', () => ({
5 | persistColorScheme: jest.fn(),
6 | }))
7 |
8 | describe('[ features / common / ToggleColorSheme / handlers ]', () => {
9 | describe('#handleClick', () => {
10 | describe('when `handleClick` is called', () => {
11 | it('should return a function', () => {
12 | // Arrange
13 | const params = {}
14 |
15 | // Act
16 | const result = typeof handleClick(params)
17 | const expected = 'function'
18 |
19 | // Assert
20 | expect(result).toBe(expected)
21 | })
22 | })
23 |
24 | describe('when `handleClick` returned function is called', () => {
25 | it('should called `persistColorScheme` with parameters', () => {
26 | // Arrange
27 | const setIsDarkModeMock = () => {}
28 | const params = {
29 | isDarkMode: true,
30 | setIsDarkMode: setIsDarkModeMock,
31 | }
32 |
33 | // Act
34 | handleClick(params)()
35 |
36 | // Assert
37 | expect(persistColorScheme).toHaveBeenCalledWith({
38 | isDarkMode: false,
39 | setIsDarkMode: setIsDarkModeMock,
40 | })
41 | })
42 | })
43 | })
44 | })
45 |
--------------------------------------------------------------------------------
/utils/buildLocalApiUrl.test.js:
--------------------------------------------------------------------------------
1 | import buildLocalApiUrl from './buildLocalApiUrl'
2 |
3 | describe('[ utils / buildLocalApiUrl ]', () => {
4 | describe("when `req.method` is `'get'`", () => {
5 | it('should return `{ url, options } with an `undefined` `body``', () => {
6 | // Arrange
7 | const req = {
8 | url: '/api/local/myurl',
9 | method: 'get',
10 | body: 'body',
11 | }
12 | const fetchOptions = {}
13 |
14 | // Act
15 | const result = buildLocalApiUrl(req, fetchOptions)
16 | const expected = {
17 | url: 'myurl',
18 | options: {
19 | method: 'GET',
20 | body: undefined,
21 | },
22 | }
23 |
24 | // Assert
25 | expect(result).toEqual(expected)
26 | })
27 | })
28 |
29 | describe("when `req.method` is `'patch'`", () => {
30 | it('should return `{ url, options }` with an valid `body`', () => {
31 | // Arrange
32 | const req = {
33 | url: '/api/local/myurl',
34 | method: 'patch',
35 | body: 'body',
36 | }
37 | const fetchOptions = {}
38 |
39 | // Act
40 | const result = buildLocalApiUrl(req, fetchOptions)
41 | const expected = {
42 | url: 'myurl',
43 | options: {
44 | method: 'PATCH',
45 | body: 'body',
46 | },
47 | }
48 |
49 | // Assert
50 | expect(result).toEqual(expected)
51 | })
52 | })
53 | })
54 |
--------------------------------------------------------------------------------
/features/focusSession/components/FocusSessionFooter.test.js:
--------------------------------------------------------------------------------
1 | import FocusSessionFooter from './FocusSessionFooter'
2 | import { render, screen, fireEvent } from '@testing-library/react'
3 |
4 | jest.mock('@glrodasz/components', () => {
5 | const { dummyRender } = require('../../../utils/testUtils/dummyRender')
6 | const originalModule = jest.requireActual('@glrodasz/components')
7 | return {
8 | ...originalModule,
9 | Spacer: { Vertical: dummyRender('Spacer.Vertical') },
10 | }
11 | })
12 |
13 | describe('[ features / focusSession / components / FocusSessionFooter ]', () => {
14 | describe('when `FocusSessionFooter` is mounted', () => {
15 | it('should render', () => {
16 | // Arrange
17 | const props = {
18 | onClickEndSession: () => {},
19 | }
20 |
21 | // Act
22 | const { asFragment } = render( )
23 |
24 | // Assert
25 | expect(asFragment()).toMatchSnapshot()
26 | })
27 | })
28 |
29 | describe('when then `Button` is clicked', () => {
30 | it('should call `onClickEndSession`', () => {
31 | // Arrange
32 | const onClickEndSessionMock = jest.fn()
33 | const props = {
34 | onClickEndSession: onClickEndSessionMock,
35 | }
36 |
37 | // Act
38 | render( )
39 | fireEvent.click(screen.getByText('Finalizar tu sesión'))
40 |
41 | // Assert
42 | expect(onClickEndSessionMock).toHaveBeenCalled()
43 | })
44 | })
45 | })
46 |
--------------------------------------------------------------------------------
/features/tasks/containers/EditTask.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import EditTaskModal from '../components/EditTaskModal'
4 | import useTask from '../hooks/useTask'
5 |
6 | import {
7 | handleDeleteTask,
8 | handleCloseEditTaskModal,
9 | handleUpdateTask,
10 | } from '../../tasks/handlers'
11 |
12 | const EditTask = ({ editTaskModal, deleteConfirmation }) => {
13 | const { taskId } = editTaskModal
14 | const task = useTask({
15 | id: taskId,
16 | })
17 |
18 | return (
19 | <>
20 | {editTaskModal.showDialog && (
21 |
31 | )}
32 | >
33 | )
34 | }
35 |
36 | EditTask.propTypes = {
37 | editTaskModal: PropTypes.shape({
38 | taskId: PropTypes.string.isRequired,
39 | setTaskId: PropTypes.func.isRequired,
40 | showDialog: PropTypes.bool.isRequired,
41 | setShowDialog: PropTypes.func.isRequired,
42 | }).isRequired,
43 | deleteConfirmation: PropTypes.shape({
44 | taskId: PropTypes.string.isRequired,
45 | setTaskId: PropTypes.func.isRequired,
46 | showDialog: PropTypes.bool.isRequired,
47 | setShowDialog: PropTypes.func.isRequired,
48 | }).isRequired,
49 | }
50 |
51 | export default EditTask
52 |
--------------------------------------------------------------------------------
/pages/form.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | import {
4 | Heading,
5 | Input,
6 | Button,
7 | Spacer,
8 | CenteredContent,
9 | } from '@glrodasz/components'
10 |
11 | export default function Index() {
12 | const [formValues, setFormValues] = useState({})
13 |
14 | const onChange = (key) => (event) => {
15 | const { value } = event.target
16 | setFormValues({ ...formValues, [key]: value })
17 | }
18 |
19 | return (
20 |
21 | Cuentame sobre ti
22 |
23 |
28 |
29 |
34 |
35 |
40 |
41 |
48 |
49 | Completa tu perfil
50 |
51 | Saltar este paso por ahora
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/features/focusSession/components/PauseTimer.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import {
4 | Modal,
5 | CenteredContent,
6 | Picture,
7 | Spacer,
8 | Heading,
9 | Paragraph,
10 | Button,
11 | } from '@glrodasz/components'
12 |
13 | import useTime from '../../common/hooks/useTime'
14 | import formatMilliseconds from '../../../utils/formatMilliseconds'
15 |
16 | const createHandleClose =
17 | ({ onClose }) =>
18 | () => {
19 | onClose()
20 | }
21 |
22 | const PauseTimer = ({ onClose }) => {
23 | const { currentTime } = useTime({
24 | isTimer: false, // TODO: type: { isTimer: true, isStopwatch: false }
25 | })
26 |
27 | return (
28 |
29 |
30 |
31 | {formatMilliseconds(currentTime)}
32 |
33 |
34 |
35 |
36 |
37 | TU RETO ESTA EN PAUSA
38 |
39 |
40 |
41 | Durante esta pausa tu tiempo de productividad no será registrado.
42 |
43 |
44 |
45 | Volver a mis tareas
46 |
47 |
48 |
49 | )
50 | }
51 |
52 | export default PauseTimer
53 |
--------------------------------------------------------------------------------
/pages/api/local/tasks/[id]/reset.js:
--------------------------------------------------------------------------------
1 | import buildLocalApiUrl from '../../../../../utils/buildLocalApiUrl'
2 | import fetchJsonServer from '../../../../../utils/fetchJsonServer'
3 |
4 | async function getPendingTasks({ options }) {
5 | const fetchOptions = {
6 | ...options,
7 | method: 'get',
8 | body: undefined,
9 | }
10 |
11 | return fetchJsonServer({
12 | resource: 'task',
13 | url: 'tasks?status=pending',
14 | options: fetchOptions,
15 | })
16 | }
17 |
18 | async function updatePendingTasksPriority({ tasks, options }) {
19 | return await Promise.all(
20 | tasks.map(({ id, ...body }, index) => {
21 | const fetchOptions = {
22 | ...options,
23 | method: 'patch',
24 | body: { ...body, priority: index + 1 },
25 | }
26 |
27 | return fetchJsonServer({
28 | resource: 'task',
29 | url: `tasks/${id}`,
30 | options: fetchOptions,
31 | })
32 | })
33 | )
34 | }
35 |
36 | async function resetTask({ taskId, options, res }) {
37 | const fetchOptions = {
38 | ...options,
39 | body: { status: 'pending', priority: 0 },
40 | }
41 | return fetchJsonServer({
42 | resource: 'task',
43 | url: `tasks/${taskId}`,
44 | options: fetchOptions,
45 | res,
46 | })
47 | }
48 |
49 | export default async function handler(req, res) {
50 | const { options } = buildLocalApiUrl(req)
51 |
52 | if (req.method === 'PATCH') {
53 | const pendingTasks = await getPendingTasks({ options })
54 | await updatePendingTasksPriority({ tasks: pendingTasks, options })
55 | await resetTask({ taskId: req.query.id, options, res })
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/pages/api/local/focus-sessions/active.js:
--------------------------------------------------------------------------------
1 | import buildLocalApiUrl from '../../../../utils/buildLocalApiUrl'
2 | import fetchJsonServer from '../../../../utils/fetchJsonServer'
3 | import isEmpty from '../../../../utils/isEmpty'
4 |
5 | async function getActiveFocusSession({ options }) {
6 | const fetchOptions = {
7 | ...options,
8 | method: 'get',
9 | body: undefined,
10 | }
11 |
12 | return fetchJsonServer({
13 | resource: 'focus-sessions',
14 | url: 'focus-sessions?status=active',
15 | options: fetchOptions,
16 | singular: true,
17 | })
18 | }
19 |
20 | function getTotalPauseTime(totalPauseTime, pause) {
21 | if (pause.endTime != null) {
22 | const pauseTime = pause.endTime - pause.startTime
23 | return totalPauseTime + pauseTime
24 | }
25 | return totalPauseTime
26 | }
27 |
28 | function getActiveFocusSessionWithPauseTime({ activeFocusSession, res }) {
29 | const currentPauses = activeFocusSession?.pauses ?? []
30 | const totalPauseTime = currentPauses.reduce(getTotalPauseTime, 0)
31 |
32 | const calculatedActiveFocusSession = {
33 | ...activeFocusSession,
34 | startTime: activeFocusSession.startTime + totalPauseTime,
35 | }
36 |
37 | res.status(200).json(calculatedActiveFocusSession)
38 | }
39 |
40 | export default async function handler(req, res) {
41 | const { options } = buildLocalApiUrl(req)
42 |
43 | if (req.method === 'GET') {
44 | const activeFocusSession = await getActiveFocusSession({ options })
45 | if (isEmpty(activeFocusSession)) {
46 | res.status(200).json({})
47 | } else {
48 | getActiveFocusSessionWithPauseTime({ activeFocusSession, res })
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/pages/api/local/tasks/[id]/complete.js:
--------------------------------------------------------------------------------
1 | import buildLocalApiUrl from '../../../../../utils/buildLocalApiUrl'
2 | import fetchJsonServer from '../../../../../utils/fetchJsonServer'
3 |
4 | async function getCompletedTasks({ options }) {
5 | const fetchOptions = {
6 | ...options,
7 | method: 'get',
8 | body: undefined,
9 | }
10 |
11 | return fetchJsonServer({
12 | resource: 'task',
13 | url: 'tasks?status=completed',
14 | options: fetchOptions,
15 | })
16 | }
17 |
18 | async function updateCompletedTasksPriority({ tasks, options }) {
19 | return await Promise.all(
20 | tasks.map(({ id, ...body }, index) => {
21 | const fetchOptions = {
22 | ...options,
23 | method: 'patch',
24 | body: { ...body, priority: index + 1 },
25 | }
26 |
27 | return fetchJsonServer({
28 | resource: 'task',
29 | url: `tasks/${id}`,
30 | options: fetchOptions,
31 | })
32 | })
33 | )
34 | }
35 |
36 | async function completeTask({ taskId, options, res }) {
37 | const fetchOptions = {
38 | ...options,
39 | body: { status: 'completed', priority: 0 },
40 | }
41 |
42 | return fetchJsonServer({
43 | resource: 'task',
44 | url: `tasks/${taskId}`,
45 | options: fetchOptions,
46 | res,
47 | })
48 | }
49 |
50 | export default async function handler(req, res) {
51 | const { options } = buildLocalApiUrl(req)
52 |
53 | if (req.method === 'PATCH') {
54 | const completedTasks = await getCompletedTasks({ options })
55 | await updateCompletedTasksPriority({ tasks: completedTasks, options })
56 | await completeTask({ taskId: req.query.id, options, res })
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/features/tasks/components/EditTaskModal.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import { Modal, Heading, Paragraph } from '@glrodasz/components'
4 |
5 | import timeAgo from '../../../utils/timeAgo'
6 |
7 | const handleClose =
8 | ({ onClose }) =>
9 | () => {
10 | onClose()
11 | }
12 |
13 | const handleDelete =
14 | ({ id, onDelete }) =>
15 | () => {
16 | onDelete({ id })
17 | }
18 |
19 | const handleUpdate =
20 | ({ id, onUpdate }) =>
21 | (event) => {
22 | const description = event.currentTarget.textContent
23 | onUpdate({ id, data: { description } })
24 | }
25 |
26 | const EditTaskModal = ({ task, onClose, onDelete, onUpdate }) => {
27 | return (
28 |
36 |
37 |
42 | {task?.description}
43 |
44 | {task?.createdAt && (
45 |
46 | Creada {timeAgo(task?.createdAt)}
47 |
48 | )}
49 |
50 |
51 | )
52 | }
53 |
54 | EditTaskModal.propTypes = {
55 | task: PropTypes.object,
56 | onClose: PropTypes.func,
57 | onDelete: PropTypes.func,
58 | }
59 |
60 | EditTaskModal.defaultProps = {
61 | onClose: () => {},
62 | onDelete: () => {},
63 | }
64 |
65 | export default EditTaskModal
66 |
--------------------------------------------------------------------------------
/pages/api/test/tasks.js:
--------------------------------------------------------------------------------
1 | export default (req, res) => {
2 | res.statusCode = 200
3 | res.json([
4 | {
5 | description:
6 | "Crevice returns knocking sleeping Thranduil venture enters roll. Sisters Luin relative wax country wished pouf travel they've self moonlight fashioning.",
7 | priority: 0,
8 | status: 'in-progress',
9 | id: 2,
10 | },
11 | {
12 | description:
13 | "One Ring to rule them all. Foreign paid pushing hair strength's regurgitation hot Isildur's manage torches slightest",
14 | priority: 1,
15 | status: 'in-progress',
16 | id: 32,
17 | },
18 | {
19 | description:
20 | "One Ring to rule them all. Foreign paid pushing hair strength's regurgitation hot Isildur's manage torches slightest",
21 | priority: 2,
22 | status: 'in-progress',
23 | id: 33,
24 | },
25 | {
26 | description:
27 | 'Done possess candles hairy heir rune odds 30 Caradhras crispy swish labyrinth?',
28 | priority: 3,
29 | status: 'pending',
30 | id: 5,
31 | },
32 | {
33 | description:
34 | 'Done possess candles hairy heir rune odds 30 Caradhras crispy swish labyrinth?',
35 | priority: 4,
36 | status: 'pending',
37 | id: 6,
38 | },
39 | {
40 | description:
41 | 'Done possess candles hairy heir rune odds 30 Caradhras crispy swish labyrinth?',
42 | priority: 5,
43 | status: 'pending',
44 | id: 7,
45 | },
46 | {
47 | description: "Pity's wheel King's. Nazgûl butter youngest report.",
48 | priority: 6,
49 | status: 'completed',
50 | id: 1,
51 | },
52 | ])
53 | }
54 |
--------------------------------------------------------------------------------
/features/focusSession/hooks/useBreaktimeConfirmation.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/rules-of-hooks */
2 | import { renderHook, act } from '@testing-library/react-hooks'
3 | import useBreaktimeConfirmation from './useBreaktimeConfirmation'
4 |
5 | describe('[ features / focusSession / hooks / useBreaktimeConfirmation ]', () => {
6 | describe('when `useBreaktimeConfirmation` is called', () => {
7 | it('should return a `showDialog` as `false`', () => {
8 | // Arrange
9 | const hook = () => useBreaktimeConfirmation()
10 |
11 | // Act
12 | const { result: hookResult } = renderHook(hook)
13 | const result = hookResult.current.showDialog
14 | const expected = false
15 |
16 | // Assert
17 | expect(result).toBe(expected)
18 | })
19 |
20 | it('should return a `setShowDialog` as a `function`', () => {
21 | // Arrange
22 | const hook = () => useBreaktimeConfirmation()
23 |
24 | // Act
25 | const { result: hookResult } = renderHook(hook)
26 | const result = typeof hookResult.current.setShowDialog
27 | const expected = 'function'
28 |
29 | // Assert
30 | expect(result).toBe(expected)
31 | })
32 |
33 | it('should change `showDialog` when `setShowDialog` called', () => {
34 | // Arrange
35 | const hook = () => useBreaktimeConfirmation()
36 |
37 | // Act
38 | const { result: hookResult } = renderHook(hook)
39 | act(() => {
40 | hookResult.current.setShowDialog(true)
41 | })
42 | const result = hookResult.current.showDialog
43 | const expected = true
44 |
45 | // Assert
46 | expect(result).toBe(expected)
47 | })
48 | })
49 | })
50 |
--------------------------------------------------------------------------------
/pages/api/local/focus-sessions/resume.js:
--------------------------------------------------------------------------------
1 | import buildLocalApiUrl from '../../../../utils/buildLocalApiUrl'
2 | import fetchJsonServer from '../../../../utils/fetchJsonServer'
3 |
4 | async function getActiveFocusSession({ options }) {
5 | const fetchOptions = {
6 | ...options,
7 | method: 'get',
8 | body: undefined,
9 | }
10 |
11 | return fetchJsonServer({
12 | resource: 'focus-sessions',
13 | url: 'focus-sessions?status=active',
14 | options: fetchOptions,
15 | singular: true,
16 | })
17 | }
18 |
19 | export async function resumeActiveFocusSession({
20 | activeFocusSession,
21 | options,
22 | res,
23 | }) {
24 | const currentPauses = activeFocusSession.pauses ?? []
25 |
26 | const activePause = currentPauses.find((pause) => pause.endTime === null)
27 | const resumedPauses = currentPauses.filter((pause) => pause.endTime !== null)
28 |
29 | if (!activePause) {
30 | return res.status(200).json(activeFocusSession)
31 | }
32 |
33 | const pauseToResume = {
34 | ...activePause,
35 | endTime: Date.now(),
36 | }
37 |
38 | const fetchOptions = {
39 | ...options,
40 | body: { pauses: [...resumedPauses, pauseToResume] },
41 | }
42 |
43 | return fetchJsonServer({
44 | resource: 'focus-sessions',
45 | url: `focus-sessions/${activeFocusSession.id}`,
46 | options: fetchOptions,
47 | res,
48 | })
49 | }
50 |
51 | export default async function handler(req, res) {
52 | const { options } = buildLocalApiUrl(req)
53 |
54 | if (req.method === 'PATCH') {
55 | const activeFocusSession = await getActiveFocusSession({ options })
56 | await resumeActiveFocusSession({ activeFocusSession, options, res })
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/features/common/components/MainLayout/MainLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import classNames from 'classnames'
3 | import PropTypes from 'prop-types'
4 |
5 | const MainLayout = ({ menu, content, isPlayground }) => {
6 | return (
7 | <>
8 |
11 |
12 |
13 |
14 |
53 | >
54 | )
55 | }
56 |
57 | MainLayout.propTypes = {
58 | menu: PropTypes.node,
59 | content: PropTypes.node,
60 | isPlayground: PropTypes.bool,
61 | }
62 |
63 | export default MainLayout
64 |
--------------------------------------------------------------------------------
/styles/globals.scss:
--------------------------------------------------------------------------------
1 | @mixin dark-color-scheme {
2 | --color-primary: var(--color-amber-600);
3 | --color-primary-muted: var(--color-amber-800);
4 | --color-primary-highlight: var(--color-orange-800);
5 | --color-primary-inverted: var(--color-gray-50);
6 |
7 | --color-secondary: var(--color-rose-600);
8 | --color-secondary-muted: var(--color-rose-400);
9 | --color-secondary-inverted: var(--color-rose-900);
10 |
11 | --color-tertiary: var(--color-lime-50);
12 |
13 | --color-font-base: var(--color-gray-50);
14 | --color-font-muted: var(--color-gray-500);
15 | --color-font-highlight: var(--color-gray-400);
16 | --color-font-inverted: var(--color-cool-gray-800);
17 |
18 | --background-color-primary: var(--color-cool-gray-900);
19 | --background-color-primary-highlight: var(--color-cool-gray-800);
20 |
21 | --button-border-radius-sm: var(--border-radius-xs);
22 | --button-border-radius-md: var(--border-radius-xs);
23 | --button-border-radius-lg: var(--border-radius-xs);
24 |
25 | --input-height: 55px;
26 | --input-background: var(--color-cool-gray-800);
27 | --input-border-radius: 2;
28 |
29 | --picture-border: 1px solid var(--color-cool-gray-200);
30 |
31 | --card-border-radius: var(--border-radius-xs);
32 |
33 | --task-border-radius: var(--border-radius-xs);
34 | }
35 |
36 | @media (prefers-color-scheme: dark) {
37 | :root:not([data-color-scheme='light']) {
38 | @include dark-color-scheme;
39 | }
40 | }
41 |
42 | :root[data-color-scheme='dark'] {
43 | @include dark-color-scheme;
44 | }
45 |
46 | html,
47 | body {
48 | height: 100%;
49 | background: var(--background-color-primary);
50 | font-family: var(--font-family-sans);
51 | font-size: var(--font-size-base);
52 | }
53 |
54 | #__next {
55 | height: 100%;
56 | }
57 |
--------------------------------------------------------------------------------
/features/focusSession/components/BreaktimeTimer.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import {
4 | Modal,
5 | CenteredContent,
6 | Picture,
7 | Heading,
8 | Spacer,
9 | Paragraph,
10 | Link,
11 | } from '@glrodasz/components'
12 |
13 | import useTime from '../../common/hooks/useTime'
14 | import formatMilliseconds from '../../../utils/formatMilliseconds'
15 |
16 | const createHandleClose =
17 | ({ onClose }) =>
18 | () => {
19 | onClose()
20 | }
21 |
22 | const BreaktimeTimer = ({ onClose, breaktime }) => {
23 | const { currentTime } = useTime({
24 | isTimer: true,
25 | startTime: breaktime,
26 | callback: onClose,
27 | })
28 |
29 | return (
30 |
31 |
32 |
33 | {formatMilliseconds(currentTime)}
34 |
35 |
36 |
37 |
38 |
39 | DESCONÉCTATE
40 |
41 |
42 |
43 | Trabajar cuando te levantas no te permite despertar completamente. Por
44 | eso, date un tiempo antes de empezar tu día.
45 |
46 |
47 |
48 | Leer más
49 |
50 |
51 |
52 | )
53 | }
54 |
55 | BreaktimeTimer.propTypes = {
56 | onClose: PropTypes.func.isRequired,
57 | breaktime: PropTypes.number.isRequired,
58 | }
59 |
60 | export default BreaktimeTimer
61 |
--------------------------------------------------------------------------------
/features/tasks/components/Board/Board.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { Spacer } from '@glrodasz/components'
3 | import PropTypes from 'prop-types'
4 | import { DragDropContext } from 'react-beautiful-dnd'
5 |
6 | import Column from '../Column/Column'
7 |
8 | import { normalizeData, filterColumns } from '../../helpers'
9 |
10 | const Board = ({ tasks, isActive, onDragEnd, actions }) => {
11 | const [data, setData] = useState(normalizeData(tasks))
12 |
13 | useEffect(() => {
14 | setData(normalizeData(tasks))
15 | }, [tasks])
16 |
17 | const tasksLength = tasks.length
18 |
19 | return (
20 | <>
21 |
22 | {!!tasksLength &&
23 | data.columnOrder
24 | .filter(filterColumns({ tasksLength, isActive }))
25 | .map((columnId) => {
26 | const column = data.columns[columnId]
27 | const tasks = column.taskIds.map((taskId) => data.tasks[taskId])
28 |
29 | return (
30 | <>
31 |
32 |
39 | >
40 | )
41 | })}
42 |
43 | >
44 | )
45 | }
46 |
47 | Board.propTypes = {
48 | tasks: PropTypes.object,
49 | isActive: PropTypes.bool,
50 | onDragEnd: PropTypes.bool,
51 | actions: PropTypes.shape({
52 | onDeleteTask: PropTypes.func,
53 | onCompleteTask: PropTypes.func,
54 | onEditTask: PropTypes.func,
55 | }),
56 | }
57 |
58 | export default Board
59 |
--------------------------------------------------------------------------------
/features/tasks/hooks/useTasks.js:
--------------------------------------------------------------------------------
1 | import { tasksApi } from '../../planning/api'
2 | import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3 | import useLocalData from '../../common/hooks/useLocalData'
4 |
5 | const QUERY_KEY = 'tasks'
6 |
7 | const useTasks = ({ initialData, onRemove }) => {
8 | const queryClient = useQueryClient()
9 |
10 | const {
11 | isLoading,
12 | error,
13 | data: fetchedData,
14 | } = useQuery([QUERY_KEY], () => tasksApi.getAll(), {
15 | initialData,
16 | })
17 |
18 | const { mutateAsync: create } = useMutation(
19 | (params) => tasksApi.create(params),
20 | {
21 | onSuccess: () => {
22 | queryClient.invalidateQueries(QUERY_KEY)
23 | },
24 | }
25 | )
26 |
27 | const { mutateAsync: remove } = useMutation(
28 | (params) => tasksApi.delete(params),
29 | {
30 | onSuccess: () => {
31 | queryClient.invalidateQueries(QUERY_KEY)
32 | onRemove?.()
33 | },
34 | }
35 | )
36 |
37 | const { mutateAsync: updateStatus } = useMutation(
38 | (params) => tasksApi.updateStatus(params),
39 | {
40 | onSuccess: () => {
41 | queryClient.invalidateQueries(QUERY_KEY)
42 | },
43 | }
44 | )
45 |
46 | const { mutateAsync: updatePriorities } = useMutation(
47 | (params) => tasksApi.updatePriorities(params),
48 | {
49 | onSuccess: () => {
50 | queryClient.invalidateQueries(QUERY_KEY)
51 | },
52 | }
53 | )
54 |
55 | const { localData, setLocalData } = useLocalData(fetchedData)
56 |
57 | return {
58 | isLoading,
59 | error,
60 | data: localData,
61 | setLocalData,
62 | api: {
63 | create,
64 | remove,
65 | updatePriorities,
66 | updateStatus,
67 | },
68 | }
69 | }
70 |
71 | export default useTasks
72 |
--------------------------------------------------------------------------------
/features/retrospective/containers/Retrospective.js:
--------------------------------------------------------------------------------
1 | import {
2 | Spacer,
3 | Heading,
4 | Accordion,
5 | FullHeightContent,
6 | Paragraph,
7 | Score,
8 | Textarea,
9 | } from '@glrodasz/components'
10 |
11 | import RetrospectiveFooter from '../components/RetrospectiveFooter'
12 |
13 | const Retrospective = () => {
14 | return (
15 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
32 | <>
33 |
34 |
35 | ¿Cómo te sentiste el día de hoy?
36 |
37 |
38 |
39 |
40 |
¿Qué bloqueos tuviste?
41 |
42 |
43 | Durante el día, existen distractores y escribirlos te ayuda a
44 | identificarlos para mantenerte enfocado y saludable.
45 |
46 |
47 |
48 | >
49 |
50 | >
51 | }
52 | footer={ }
53 | >
54 | )
55 | }
56 |
57 | export default Retrospective
58 |
--------------------------------------------------------------------------------
/features/tasks/components/Column/Column.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import { Droppable } from 'react-beautiful-dnd'
4 | import { TaskCounter, Spacer } from '@glrodasz/components'
5 |
6 | import DraggableTask from '../DraggableTask/DraggableTask'
7 | import { getTitle, getTotal, getCurrent } from '../../helpers'
8 |
9 | import { COMPLETED_COLUMN_ID } from '../../constants'
10 |
11 | const Column = ({ column, tasks, isActive, actions }) => {
12 | return (
13 |
20 |
21 |
22 | {(provided) => (
23 |
24 | {tasks.map((task, index) => (
25 | <>
26 |
34 |
35 | >
36 | ))}
37 | {provided.placeholder}
38 |
39 | )}
40 |
41 |
42 | )
43 | }
44 |
45 | Column.propTypes = {
46 | column: PropTypes.object,
47 | tasks: PropTypes.array,
48 | isActive: PropTypes.bool,
49 | actions: PropTypes.shape({
50 | onDeleteTask: PropTypes.func,
51 | onCompleteTask: PropTypes.func,
52 | onEditTask: PropTypes.func,
53 | }),
54 | }
55 |
56 | export default Column
57 |
--------------------------------------------------------------------------------
/features/tasks/components/DraggableTask/DraggableTask.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Task } from '@glrodasz/components'
3 | import { Draggable } from 'react-beautiful-dnd'
4 | import PropTypes from 'prop-types'
5 |
6 | import { getTaskType } from '../../../tasks/helpers'
7 |
8 | import {
9 | handleCompleteTask,
10 | handleDeleteTask,
11 | handleEditTask,
12 | } from './handlers'
13 |
14 | const DraggableTask = ({ task, index, columnId, isActive, actions }) => {
15 | const { onDeleteTask, onCompleteTask, onEditTask } = actions
16 |
17 | return (
18 |
19 | {(provided) => (
20 |
25 |
40 | {task.description}
41 |
42 |
43 | )}
44 |
45 | )
46 | }
47 |
48 | DraggableTask.propTypes = {
49 | task: PropTypes.array,
50 | index: PropTypes.number,
51 | columnId: PropTypes.string,
52 | isActive: PropTypes.bool,
53 | actions: PropTypes.shape({
54 | onDeleteTask: PropTypes.func,
55 | onCompleteTask: PropTypes.func,
56 | onEditTask: PropTypes.func,
57 | }),
58 | }
59 |
60 | DraggableTask.defaultProps = {
61 | actions: {},
62 | }
63 |
64 | export default DraggableTask
65 |
--------------------------------------------------------------------------------
/features/focusSession/handlers.js:
--------------------------------------------------------------------------------
1 | import Router from 'next/router'
2 |
3 | export const handleCheckCompleteTask =
4 | ({ breaktimeConfirmation, tasks }) =>
5 | ({ id, isChecked }) => {
6 | const { setShowDialog } = breaktimeConfirmation
7 | isChecked && setShowDialog(true)
8 | tasks.api.updateStatus({ id, isChecked })
9 | }
10 |
11 | export const handleClickCloseBreaktimeConfirmation =
12 | ({ breaktimeConfirmation }) =>
13 | () => {
14 | const { setShowDialog } = breaktimeConfirmation
15 | setShowDialog(false)
16 | }
17 |
18 | export const handleClickCloseBreaktimeTimer =
19 | ({ breaktimeTimer, focusSession }) =>
20 | () => {
21 | const { setShowDialog } = breaktimeTimer
22 | setShowDialog(false)
23 | focusSession.api.resume()
24 | }
25 |
26 | export const createPauseTimerHandlerClose =
27 | ({ pauseTimer, focusSession }) =>
28 | async () => {
29 | await focusSession.api.resume()
30 | pauseTimer.setShowDialog(false)
31 | }
32 |
33 | export const handleClickChooseBreaktime =
34 | ({ breaktimeTimer, breaktimeConfirmation, focusSession }) =>
35 | (time) => {
36 | breaktimeConfirmation.setShowDialog(false)
37 | breaktimeTimer.setShowDialog(true)
38 | breaktimeTimer.setTime(time)
39 | focusSession.api.pause({ time })
40 | }
41 |
42 | export const handleClickEndSession =
43 | ({ focusSessions }) =>
44 | async () => {
45 | await focusSessions.api.finish()
46 | Router.push('/planning')
47 | }
48 |
49 | export const createHandlerClickChronometer =
50 | ({ isPaused, onPause }) =>
51 | async () => {
52 | onPause(isPaused)
53 | }
54 |
55 | export const createHandlerPauseChronometer =
56 | ({ focusSession, pauseTimer, clearTime }) =>
57 | async (isPaused) => {
58 | if (isPaused) {
59 | await focusSession.api.resume()
60 | } else {
61 | pauseTimer.setShowDialog(true)
62 | await focusSession.api.pause()
63 | clearTime()
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/pages/api/local/focus-sessions/pause.js:
--------------------------------------------------------------------------------
1 | import crypto from 'crypto'
2 |
3 | import buildLocalApiUrl from '../../../../utils/buildLocalApiUrl'
4 | import fetchJsonServer from '../../../../utils/fetchJsonServer'
5 | import { resumeActiveFocusSession } from './resume'
6 |
7 | async function getActiveFocusSession({ options }) {
8 | const fetchOptions = {
9 | ...options,
10 | method: 'get',
11 | body: undefined,
12 | }
13 |
14 | return fetchJsonServer({
15 | resource: 'focus-sessions',
16 | url: 'focus-sessions?status=active',
17 | options: fetchOptions,
18 | singular: true,
19 | })
20 | }
21 |
22 | async function pauseActiveFocusSession({ activeFocusSession, options, res }) {
23 | const { time } = options.body
24 | let currentPauses = activeFocusSession.pauses ?? []
25 | const activePause = currentPauses.find((pause) => pause.endTime === null)
26 |
27 | if (activePause && time) {
28 | const { pauses } = await resumeActiveFocusSession({
29 | activeFocusSession,
30 | options,
31 | })
32 | currentPauses = pauses
33 | } else if (activePause) {
34 | return res.status(200).json(activeFocusSession)
35 | }
36 |
37 | const newPause = {
38 | id: crypto.randomUUID(),
39 | startTime: Date.now(),
40 | endTime: null,
41 | time: time ? Number(time) : undefined,
42 | }
43 |
44 | const fetchOptions = {
45 | ...options,
46 | body: { pauses: [...currentPauses, newPause] },
47 | }
48 |
49 | return fetchJsonServer({
50 | resource: 'focus-sessions',
51 | url: `focus-sessions/${activeFocusSession.id}`,
52 | options: fetchOptions,
53 | res,
54 | })
55 | }
56 |
57 | export default async function handler(req, res) {
58 | const { options } = buildLocalApiUrl(req)
59 |
60 | if (req.method === 'PATCH') {
61 | const activeFocusSession = await getActiveFocusSession({ options })
62 | await pauseActiveFocusSession({ activeFocusSession, options, res })
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/pages/api/local/focus-sessions/index.js:
--------------------------------------------------------------------------------
1 | import buildLocalApiUrl from '../../../../utils/buildLocalApiUrl'
2 | import fetchJsonServer from '../../../../utils/fetchJsonServer'
3 |
4 | async function updateTasksFocusSessionId({ tasks, focusSessionId, options }) {
5 | return await Promise.all(
6 | tasks.map(({ id, ...body }) => {
7 | const fetchOptions = {
8 | ...options,
9 | method: 'patch',
10 | body: { ...body, focusSessionId },
11 | }
12 |
13 | return fetchJsonServer({
14 | resource: 'task',
15 | url: `tasks/${id}`,
16 | options: fetchOptions,
17 | })
18 | })
19 | )
20 | }
21 |
22 | async function getInProgressAndPedingTasks({ options }) {
23 | const fetchOptions = {
24 | ...options,
25 | method: 'get',
26 | body: undefined,
27 | }
28 |
29 | return fetchJsonServer({
30 | resource: 'task',
31 | url: 'tasks?status=in-progress&status=pending',
32 | options: fetchOptions,
33 | })
34 | }
35 |
36 | export default async function handler(req, res) {
37 | const { options } = buildLocalApiUrl(req)
38 |
39 | if (req.method === 'GET') {
40 | const url = `focus-sessions`
41 | fetchJsonServer({
42 | resource: 'focus-sessions',
43 | url,
44 | options,
45 | res,
46 | })
47 | }
48 |
49 | if (req.method === 'POST') {
50 | const tasks = await getInProgressAndPedingTasks({ options })
51 |
52 | const fetchOptions = {
53 | ...options,
54 | body: {
55 | status: 'active',
56 | startTime: Date.now(),
57 | tasks: tasks.map((task) => task.id),
58 | },
59 | }
60 |
61 | const focusSession = await fetchJsonServer({
62 | resource: 'focus-sessions',
63 | url: `focus-sessions`,
64 | options: fetchOptions,
65 | })
66 |
67 | const focusSessionId = focusSession.id
68 | await updateTasksFocusSessionId({ tasks, focusSessionId, options })
69 |
70 | return res.status(201).json(focusSession)
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/features/common/components/NavigationMenu/NavigationMenu.js:
--------------------------------------------------------------------------------
1 | import { IconLabel } from '@glrodasz/components'
2 | import Link from 'next/link'
3 | import React from 'react'
4 | import { useRouter } from 'next/router'
5 | import useBreakpoints from '../../hooks/useBreakpoints'
6 |
7 | const pages = [
8 | {
9 | icon: 'home',
10 | label: 'Home',
11 | href: '/home',
12 | },
13 | {
14 | icon: 'tasks',
15 | label: 'Tasks',
16 | href: '/planning',
17 | },
18 | {
19 | icon: 'reports',
20 | label: 'Reports',
21 | href: '/reports',
22 | },
23 | {
24 | icon: 'settings',
25 | label: 'Settings',
26 | href: '/settings',
27 | },
28 | ]
29 |
30 | const NavigationMenu = () => {
31 | const router = useRouter()
32 | const { isDesktop } = useBreakpoints()
33 |
34 | return (
35 | <>
36 |
37 | {pages.map(({ icon, label, href }) => (
38 |
39 |
40 |
47 |
48 |
49 | ))}
50 |
51 |
74 | >
75 | )
76 | }
77 |
78 | export default NavigationMenu
79 |
--------------------------------------------------------------------------------
/features/planning/components/PlanningFooter.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent } from '@testing-library/react'
2 | import PlanningFooter from './PlanningFooter'
3 |
4 | jest.mock('@glrodasz/components', () => {
5 | const { dummyRender } = require('../../../utils/testUtils/dummyRender')
6 | const originalModule = jest.requireActual('@glrodasz/components')
7 | return {
8 | ...originalModule,
9 | Spacer: { Vertical: dummyRender('Spacer.Vertical') },
10 | Paragraph: dummyRender('Paragraph'),
11 | }
12 | })
13 |
14 | describe('[ features / planning / components / PlanningFooter ]', () => {
15 | describe('when `tasksLength` is greater or equal to one', () => {
16 | it('should return the footer', () => {
17 | // Arrange
18 | const props = {
19 | tasksLength: 1,
20 | onClickStartSession: () => {},
21 | }
22 |
23 | // Act
24 | const { asFragment } = render( )
25 | const result = asFragment()
26 |
27 | // Assert
28 | expect(result).toMatchSnapshot()
29 | })
30 | })
31 |
32 | describe('when `tasksLength` is less than one', () => {
33 | it('should return null', () => {
34 | // Arrange
35 | const props = {
36 | tasksLength: 0,
37 | onClickStartSession: () => {},
38 | }
39 | const expected = null
40 |
41 | // Act
42 | const { container } = render( )
43 | const result = container.firstChild
44 |
45 | // Assert
46 | expect(result).toBe(expected)
47 | })
48 | })
49 |
50 | describe('when the `Button` is clicked', () => {
51 | it('should call `onClickStartSession`', () => {
52 | // Arrange
53 | const onClickStartSessionStub = jest.fn()
54 | const props = {
55 | tasksLength: 1,
56 | onClickStartSession: onClickStartSessionStub,
57 | }
58 |
59 | // Act
60 | render( )
61 | fireEvent.click(screen.getByText('Empieza ahora'))
62 |
63 | // Assert
64 | expect(onClickStartSessionStub).toHaveBeenCalled()
65 | })
66 | })
67 | })
68 |
--------------------------------------------------------------------------------
/pages/api/local/focus-sessions/finish.js:
--------------------------------------------------------------------------------
1 | import buildLocalApiUrl from '../../../../utils/buildLocalApiUrl'
2 | import fetchJsonServer from '../../../../utils/fetchJsonServer'
3 |
4 | async function updateTasksFocusSessionIdToNull({ tasks, options }) {
5 | return await Promise.all(
6 | tasks.map(({ id, ...body }) => {
7 | const fetchOptions = {
8 | ...options,
9 | method: 'patch',
10 | body: { ...body, focusSessionId: null },
11 | }
12 |
13 | return fetchJsonServer({
14 | resource: 'task',
15 | url: `tasks/${id}`,
16 | options: fetchOptions,
17 | })
18 | })
19 | )
20 | }
21 |
22 | async function getInProgressAndPedingTasks({ options }) {
23 | const fetchOptions = {
24 | ...options,
25 | method: 'get',
26 | body: undefined,
27 | }
28 |
29 | return fetchJsonServer({
30 | resource: 'task',
31 | url: 'tasks?status=in-progress&status=pending',
32 | options: fetchOptions,
33 | })
34 | }
35 |
36 | async function getActiveFocusSession({ options }) {
37 | const fetchOptions = {
38 | ...options,
39 | method: 'get',
40 | body: undefined,
41 | }
42 |
43 | return fetchJsonServer({
44 | resource: 'focus-sessions',
45 | url: 'focus-sessions?status=active',
46 | options: fetchOptions,
47 | singular: true,
48 | })
49 | }
50 |
51 | async function updateActiveFocusSession({ activeFocusSession, options, res }) {
52 | const fetchOptions = {
53 | ...options,
54 | body: { status: 'finished' },
55 | }
56 |
57 | return fetchJsonServer({
58 | resource: 'focus-sessions',
59 | url: `focus-sessions/${activeFocusSession.id}`,
60 | options: fetchOptions,
61 | res,
62 | })
63 | }
64 |
65 | export default async function handler(req, res) {
66 | const { options } = buildLocalApiUrl(req)
67 |
68 | if (req.method === 'PATCH') {
69 | const activeFocusSession = await getActiveFocusSession({ options })
70 | await updateActiveFocusSession({ activeFocusSession, options, res })
71 |
72 | const tasks = await getInProgressAndPedingTasks({ options })
73 | await updateTasksFocusSessionIdToNull({ tasks, options })
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/features/focusSession/components/BreaktimeConfirmation.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import {
4 | Modal,
5 | CenteredContent,
6 | Picture,
7 | Heading,
8 | Spacer,
9 | Paragraph,
10 | Button,
11 | } from '@glrodasz/components'
12 |
13 | import time from '../../../utils/time'
14 |
15 | const createHandlerClose =
16 | ({ onClose }) =>
17 | () => {
18 | onClose()
19 | }
20 |
21 | const createHandlerChoose =
22 | ({ onChoose }) =>
23 | (time) =>
24 | () => {
25 | onChoose(time)
26 | }
27 |
28 | const BreaktimeConfirmation = ({ onClose, onChoose }) => {
29 | const handleChooseBreaktime = createHandlerChoose({ onChoose })
30 |
31 | return (
32 |
33 |
34 |
35 |
36 |
37 | Tomate un tiempo para refrescarte
38 |
39 |
40 |
41 | Siempre hay que celebrar los pequeños triunfos, por eso te invitamos a
42 | tomar un descanso para despejar tu mente.
43 |
44 |
45 |
46 |
50 | 5 min
51 |
52 |
56 | 10 min
57 |
58 |
62 | 15 min
63 |
64 |
65 |
66 |
67 | )
68 | }
69 |
70 | BreaktimeConfirmation.propTypes = {
71 | onClose: PropTypes.func.isRequired,
72 | onChoose: PropTypes.func.isRequired,
73 | }
74 |
75 | export default BreaktimeConfirmation
76 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Cero a Producción — Web
2 | [](https://github.com/glrodasz/cero-web/actions/workflows/release.yml) [](https://app.codecov.io/gh/glrodasz/cero-web)
3 |
4 |
5 | 0️⃣ 🚀 **Cero a Producción** is a project of live coding sessions where we develop a a productivity management app called **RETO** from the scratch to production.
6 |
7 | ## The idea behind
8 | The idea behind this sessions is to show a real developer experience where we explore every decision that a common programmer do in daily basis with JavaScript and other tools. You will see failing tests, refactors, Google and StackOverflow searchs, but also a lot of fun and the struggle of naming things.
9 |
10 | Watch the project in live streaming in 🇪🇸 Spanish, from **Tuesdays** to **Fridays**. [](https://glrz.me/stream)
11 |
12 |
13 | ## Table of Contents
14 |
15 | - [Running the project locally](#Running-the-project-locally)
16 | - [Running the tests](#Running-the-tests)
17 | - [Throubleshooting](#Throubleshooting)
18 |
19 | ## Running the project locally
20 |
21 | Follow these steps to `start the project` in development
22 |
23 | 1. Clone repository. `git clone https://github.com/glrodasz/cero-web.git`
24 | 2. Install dependencies in the project folder running `yarn` or `npm install`
25 | 3. Copy the `.env.local.example` to `.env.local` and fill the env variables.
26 | 4. Run the server with `yarn dev` or `npm run dev`, this command will run:
27 |
28 | - The web project at `http://localhost:3000`
29 | - The local api at `http://localhost:3000/local/api`
30 | - The JSON server at `http://localhost:3001`
31 |
32 |
33 | ## Running the tests
34 |
35 | 1. Run `yarn run test`or `npm run test`
36 | 2. To keep the tests running, run `yarn run test:watch`
37 | ## Throubleshooting
38 | ### M1 (Apple Silicon) Macs: npm ERR! sharp Prebuilt libvips 8.10.5 binaries are not yet available for darwin-arm64v8
39 | Update libvips to v0.29.0, running the following:
40 | ```
41 | brew install vips
42 | ```
43 | More info: https://sharp.pixelplumbing.com/install#apple-m1
44 |
--------------------------------------------------------------------------------
/features/focusSession/hooks/useFocusSessions.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/rules-of-hooks */
2 | import { renderHook } from '@testing-library/react-hooks'
3 | import useFocusSessions, {
4 | createMutation,
5 | finishMutation,
6 | } from './useFocusSessions'
7 |
8 | jest.mock('@tanstack/react-query', () => ({
9 | useQueryClient: () => {},
10 | useMutation: jest
11 | .fn()
12 | .mockImplementationOnce(() => ({ mutateAsync: 'create' }))
13 | .mockImplementationOnce(() => ({ mutateAsync: 'finish' })),
14 | }))
15 |
16 | import { focusSessionsApi } from '../../planning/api'
17 | jest.mock('../../planning/api', () => ({
18 | focusSessionsApi: {
19 | create: jest.fn(),
20 | finish: jest.fn(),
21 | },
22 | }))
23 |
24 | describe('[ features / focusSession / hooks / useFocusSessions ]', () => {
25 | describe('#default', () => {
26 | describe('when `useFocusSessions` is called', () => {
27 | it('should return a `api` object with `{ create, finish }`', () => {
28 | // Arrange
29 | const params = {}
30 | const hook = () => useFocusSessions(params)
31 |
32 | // Act
33 | const { result: hookResult } = renderHook(hook)
34 | const result = hookResult.current.api
35 | const expected = { create: 'create', finish: 'finish' }
36 |
37 | // Assert
38 | expect(result).toStrictEqual(expected)
39 | })
40 | })
41 | })
42 |
43 | describe('#createMutation', () => {
44 | describe('when `createMutation` is called', () => {
45 | it('should call `focusSessionsApi.create`', () => {
46 | // Arange
47 | const params = { id: 'foo' }
48 |
49 | // Act
50 | createMutation(params)
51 |
52 | // Assert
53 | expect(focusSessionsApi.create).toHaveBeenCalledWith({ id: 'foo' })
54 | })
55 | })
56 | })
57 |
58 | describe('#finishMutation', () => {
59 | describe('when `finishMutation` is called', () => {
60 | it('should call `focusSessionsApi.finish`', () => {
61 | // Arange
62 | const params = { id: 'bar' }
63 |
64 | // Act
65 | finishMutation(params)
66 |
67 | // Assert
68 | expect(focusSessionsApi.finish).toHaveBeenCalledWith({ id: 'bar' })
69 | })
70 | })
71 | })
72 | })
73 |
--------------------------------------------------------------------------------
/pages/api/local/tasks/index.js:
--------------------------------------------------------------------------------
1 | import { MAXIMUN_IN_PRIORITY_TASKS } from '../../../../config'
2 | import buildLocalApiUrl from '../../../../utils/buildLocalApiUrl'
3 | import fetchJsonServer from '../../../../utils/fetchJsonServer'
4 | import isEmpty from '../../../../utils/isEmpty'
5 |
6 | async function getInProgressTasks({ options }) {
7 | const fetchOptions = {
8 | ...options,
9 | method: 'get',
10 | body: undefined,
11 | }
12 |
13 | return fetchJsonServer({
14 | resource: 'task',
15 | url: 'tasks?status=in-progress',
16 | options: fetchOptions,
17 | })
18 | }
19 |
20 | async function getActiveFocusSession({ options }) {
21 | const fetchOptions = {
22 | ...options,
23 | method: 'get',
24 | body: undefined,
25 | }
26 |
27 | return fetchJsonServer({
28 | resource: 'focus-sessions',
29 | url: 'focus-sessions?status=active',
30 | options: fetchOptions,
31 | singular: true,
32 | })
33 | }
34 |
35 | export default async function handler(req, res) {
36 | const { options } = buildLocalApiUrl(req)
37 |
38 | if (req.method === 'GET') {
39 | const activeFocusSession = await getActiveFocusSession({ options })
40 |
41 | let url = `tasks?_sort=priority&_order=asc&focusSessionId=${activeFocusSession?.id}`
42 |
43 | if (isEmpty(activeFocusSession)) {
44 | url = `tasks?_sort=priority&_order=asc&status_like=in-progress|pending`
45 | }
46 |
47 | fetchJsonServer({ resource: 'task', url, options, res })
48 | }
49 |
50 | if (req.method === 'POST') {
51 | const activeFocusSession = await getActiveFocusSession({ options })
52 | const inProgressTasks = await getInProgressTasks({ options })
53 |
54 | let status = 'in-progress'
55 |
56 | if (inProgressTasks?.length === MAXIMUN_IN_PRIORITY_TASKS) {
57 | status = 'pending'
58 | }
59 |
60 | const { description } = req.body
61 | const fetchOptions = {
62 | ...options,
63 | body: {
64 | status,
65 | priority: 0,
66 | description,
67 | focusSessionId: activeFocusSession.id,
68 | createdAt: Date.now(),
69 | },
70 | }
71 |
72 | return fetchJsonServer({
73 | resource: 'task',
74 | url: `tasks`,
75 | options: fetchOptions,
76 | res,
77 | })
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/features/focusSession/components/Chronometer.js:
--------------------------------------------------------------------------------
1 | import { Icon, Paragraph, Spacer } from '@glrodasz/components'
2 | import PropTypes from 'prop-types'
3 | import formatMilliseconds from '../../../utils/formatMilliseconds'
4 |
5 | import { createHandlerClickChronometer } from '../handlers'
6 | import { getBarWidth } from '../helpers'
7 |
8 | const Chronometer = ({ currentTime, isPaused, onPause }) => {
9 | const barWidth = getBarWidth(currentTime)
10 |
11 | return (
12 | <>
13 |
14 |
15 |
22 | {formatMilliseconds(currentTime)}
23 |
24 |
25 |
26 |
32 |
33 |
42 |
43 |
62 | >
63 | )
64 | }
65 |
66 | Chronometer.propTypes = {
67 | currentTime: PropTypes.number,
68 | isPaused: PropTypes.bool,
69 | onPause: PropTypes.func.isRequired,
70 | }
71 |
72 | Chronometer.defaultProps = {
73 | currentTime: 0,
74 | isPaused: false,
75 | }
76 |
77 | export default Chronometer
78 |
--------------------------------------------------------------------------------
/utils/timeAgo.test.js:
--------------------------------------------------------------------------------
1 | import time from './time'
2 | import timeAgo from './timeAgo'
3 | import dateNowMock from './testUtils/dateNowMock'
4 |
5 | Date.now = dateNowMock()
6 |
7 | describe('[ utils / timeAgo ]', () => {
8 | describe('when the `timestamp` is a zero seconds ago', () => {
9 | it('should return `hace 0 segundos`', () => {
10 | // Arrange
11 | const timestamp = Date.now()
12 |
13 | // Act
14 | const result = timeAgo(timestamp)
15 | const expected = 'hace 0 segundos'
16 |
17 | // Assert
18 | expect(result).toBe(expected)
19 | })
20 | })
21 |
22 | describe('when the `timestamp` is a second ago', () => {
23 | it('should return `hace 1 segundo`', () => {
24 | // Arrange
25 | const timestamp = Date.now() - time.ONE_SECOND_IN_MS
26 |
27 | // Act
28 | const result = timeAgo(timestamp)
29 | const expected = 'hace 1 segundo'
30 |
31 | // Assert
32 | expect(result).toBe(expected)
33 | })
34 | })
35 |
36 | describe('when the `timestamp` is a minute', () => {
37 | it('should return `hace 1 minuto`', () => {
38 | // Arrange
39 | const timestamp = Date.now() - time.ONE_MINUTE_IN_MS
40 |
41 | // Act
42 | const result = timeAgo(timestamp)
43 | const expected = 'hace 1 minuto'
44 |
45 | // Assert
46 | expect(result).toBe(expected)
47 | })
48 | })
49 |
50 | describe('when the `timestamp` is an hour ago', () => {
51 | it('should return `hace 1 hora`', () => {
52 | // Arrange
53 | const timestamp = Date.now() - time.ONE_HOUR_IN_MS
54 |
55 | // Act
56 | const result = timeAgo(timestamp)
57 | const expected = 'hace 1 hora'
58 |
59 | // Assert
60 | expect(result).toBe(expected)
61 | })
62 | })
63 |
64 | describe('when the `timestamp` is a day ago', () => {
65 | it('should return `hace 1 día`', () => {
66 | // Arrange
67 | const timestamp = Date.now() - time.ONE_DAY_IN_MS
68 |
69 | // Act
70 | const result = timeAgo(timestamp)
71 | const expected = 'hace 1 día'
72 |
73 | // Assert
74 | expect(result).toBe(expected)
75 | })
76 | })
77 |
78 | describe('when the `timestamp` is 2 days ago and `locale` is `en-US`', () => {
79 | it('should return `2 days ago`', () => {
80 | // Arrange
81 | const timestamp = Date.now() - time.ONE_DAY_IN_MS * 2
82 | const locale = 'en-US'
83 |
84 | // Actz
85 | const result = timeAgo(timestamp, locale)
86 | const expected = '2 days ago'
87 |
88 | // Assert
89 | expect(result).toBe(expected)
90 | })
91 | })
92 | })
93 |
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import Head from 'next/head'
2 | import Link from 'next/link'
3 | import PropTypes from 'prop-types'
4 | import { Accordion, Button, Container } from '@glrodasz/components'
5 | import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
6 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
7 | import { UserProvider } from '@auth0/nextjs-auth0'
8 |
9 | import ToggleColorScheme from '../features/common/components/ToggleColorScheme'
10 | import NavigationMenu from '../features/common/components/NavigationMenu'
11 | import MainLayout from '../features/common/components/MainLayout'
12 | import useColorScheme from '../features/common/hooks/useColorScheme'
13 |
14 | import 'minireset.css'
15 | import '@glrodasz/components/styles/globals.css'
16 | import '@glrodasz/components/styles/tokens.css'
17 | import '../styles/globals.scss'
18 |
19 | const queryClient = new QueryClient()
20 | function MyApp({ Component, pageProps }) {
21 | useColorScheme()
22 |
23 | return (
24 |
25 |
26 |
30 |
34 |
35 |
36 |
44 |
45 | {[
46 | '/',
47 | '/api/auth/login',
48 | '/api/auth/logout',
49 | '/home',
50 | '/planning',
51 | '/retrospective',
52 | ].map((link) => (
53 |
54 |
55 |
56 | {link.replace('/', '') || '/index'}
57 |
58 |
59 |
60 | ))}
61 |
62 |
63 |
64 |
65 |
66 | }
68 | content={
69 |
70 |
71 |
72 |
73 |
74 | }
75 | />
76 |
77 |
78 | )
79 | }
80 |
81 | MyApp.propTypes = {
82 | Component: PropTypes.node.isRequired,
83 | pageProps: PropTypes.object,
84 | }
85 |
86 | export default MyApp
87 |
--------------------------------------------------------------------------------
/features/tasks/hooks/useDeleteConfirmation.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/rules-of-hooks */
2 | import { renderHook, act } from '@testing-library/react-hooks'
3 | import useDeleteConfirmation from './useDeleteConfirmation'
4 |
5 | describe('[ features / tasks / hooks / useDeleteConfirmation ]', () => {
6 | describe('when `useDeleteConfirmation` is called', () => {
7 | it('should return a `showDialog` as `false`', () => {
8 | // Arrange
9 | const hook = () => useDeleteConfirmation()
10 |
11 | // Act
12 | const { result: hookResult } = renderHook(hook)
13 | const result = hookResult.current.showDialog
14 | const expected = false
15 |
16 | // Assert
17 | expect(result).toBe(expected)
18 | })
19 |
20 | it('should return a `setShowDialog` as a `function`', () => {
21 | // Arrange
22 | const hook = () => useDeleteConfirmation()
23 |
24 | // Act
25 | const { result: hookResult } = renderHook(hook)
26 | const result = typeof hookResult.current.setShowDialog
27 | const expected = 'function'
28 |
29 | // Assert
30 | expect(result).toBe(expected)
31 | })
32 |
33 | it('should change `showDialog` when `setShowDialog` called', () => {
34 | // Arrange
35 | const hook = () => useDeleteConfirmation()
36 |
37 | // Act
38 | const { result: hookResult } = renderHook(hook)
39 | act(() => {
40 | hookResult.current.setShowDialog(true)
41 | })
42 | const result = hookResult.current.showDialog
43 | const expected = true
44 |
45 | // Assert
46 | expect(result).toBe(expected)
47 | })
48 |
49 | it('should return a `taskId` as `null`', () => {
50 | // Arrange
51 | const hook = () => useDeleteConfirmation()
52 |
53 | // Act
54 | const { result: hookResult } = renderHook(hook)
55 | const result = hookResult.current.taskId
56 | const expected = null
57 |
58 | // Assert
59 | expect(result).toBe(expected)
60 | })
61 |
62 | it('should return a `setTaskId` as a `function`', () => {
63 | // Arrange
64 | const hook = () => useDeleteConfirmation()
65 |
66 | // Act
67 | const { result: hookResult } = renderHook(hook)
68 | const result = typeof hookResult.current.setTaskId
69 | const expected = 'function'
70 |
71 | // Assert
72 | expect(result).toBe(expected)
73 | })
74 |
75 | it('should change `taskId` when `setTaskId` called', () => {
76 | // Arrange
77 | const hook = () => useDeleteConfirmation()
78 |
79 | // Act
80 | const { result: hookResult } = renderHook(hook)
81 | act(() => {
82 | hookResult.current.setTaskId('foo')
83 | })
84 | const result = hookResult.current.taskId
85 | const expected = 'foo'
86 |
87 | // Assert
88 | expect(result).toBe(expected)
89 | })
90 | })
91 | })
92 |
--------------------------------------------------------------------------------
/utils/isObject.test.js:
--------------------------------------------------------------------------------
1 | import isObject from './isObject'
2 |
3 | describe('[ utils / isObject ]', () => {
4 | describe('when the `param` is an object', () => {
5 | it('should return `true`', () => {
6 | // Arrange
7 | const param = {}
8 |
9 | // Act
10 | const result = isObject(param)
11 | const expected = true
12 |
13 | // Assert
14 | expect(result).toBe(expected)
15 | })
16 | })
17 |
18 | describe('when the `param` is an array', () => {
19 | it('should return `false`', () => {
20 | // Arrange
21 | const param = []
22 |
23 | // Act
24 | const result = isObject(param)
25 | const expected = false
26 |
27 | // Assert
28 | expect(result).toBe(expected)
29 | })
30 | })
31 |
32 | describe('when the `param` is an number', () => {
33 | it('should return `false`', () => {
34 | // Arrange
35 | const param = 42
36 |
37 | // Act
38 | const result = isObject(param)
39 | const expected = false
40 |
41 | // Assert
42 | expect(result).toBe(expected)
43 | })
44 | })
45 |
46 | describe('when the `param` is an string', () => {
47 | it('should return `false`', () => {
48 | // Arrange
49 | const param = ''
50 |
51 | // Act
52 | const result = isObject(param)
53 | const expected = false
54 |
55 | // Assert
56 | expect(result).toBe(expected)
57 | })
58 | })
59 |
60 | describe('when the `param` is an bigint', () => {
61 | it('should return `false`', () => {
62 | // Arrange
63 | const param = 10n
64 |
65 | // Act
66 | const result = isObject(param)
67 | const expected = false
68 |
69 | // Assert
70 | expect(result).toBe(expected)
71 | })
72 | })
73 |
74 | describe('when the `param` is an symbol', () => {
75 | it('should return `false`', () => {
76 | // Arrange
77 | const param = Symbol()
78 |
79 | // Act
80 | const result = isObject(param)
81 | const expected = false
82 |
83 | // Assert
84 | expect(result).toBe(expected)
85 | })
86 | })
87 |
88 | describe('when the `param` is an null', () => {
89 | it('should return `false`', () => {
90 | // Arrange
91 | const param = null
92 |
93 | // Act
94 | const result = isObject(param)
95 | const expected = false
96 |
97 | // Assert
98 | expect(result).toBe(expected)
99 | })
100 | })
101 |
102 | describe('when the `param` is an undefined', () => {
103 | it('should return `false`', () => {
104 | // Arrange
105 | const param = undefined
106 |
107 | // Act
108 | const result = isObject(param)
109 | const expected = false
110 |
111 | // Assert
112 | expect(result).toBe(expected)
113 | })
114 | })
115 | })
116 |
--------------------------------------------------------------------------------
/pages/home.js:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import { useUser } from '@auth0/nextjs-auth0'
3 | import {
4 | Spacer,
5 | Card,
6 | Picture,
7 | Icon,
8 | FullHeightContent,
9 | Paragraph,
10 | LoadingError,
11 | } from '@glrodasz/components'
12 | import { withPageAuthRequired } from '@auth0/nextjs-auth0'
13 |
14 | import UserHeader from '../features/common/components/UserHeader/UserHeader'
15 |
16 | export const getServerSideProps = withPageAuthRequired()
17 |
18 | // TODO: Move Home content to it's own container
19 | export default function Home() {
20 | const { user, isLoading: isLoadingUser, error: errorUser } = useUser()
21 |
22 | return (
23 |
26 |
30 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | Buscar un espacio para trabajar
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | Iniciar una sesión de productividad
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | Maria ha hecho check-in en Factoria
60 |
61 |
62 |
63 |
64 |
65 | Frank ha iniciado una sesión de productividad
66 |
67 |
68 |
69 |
70 |
71 |
72 | Tu productividad ha incrementado un 30% durante la última semana
73 |
74 |
75 | >
76 | }
77 | >
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/features/focusSession/helpers.test.js:
--------------------------------------------------------------------------------
1 | import dateNowMock from '../../utils/testUtils/dateNowMock'
2 | import time from '../../utils/time'
3 | import { getBarWidth, getChronometerStartTime } from './helpers'
4 |
5 | describe('[ features / focusSession / helpers ]', () => {
6 | describe('#getBarWidth', () => {
7 | describe('when `currentTime` is 30 minutes', () => {
8 | it('should return `50` percent of the width', () => {
9 | // Arrange
10 | const params = 30 * 60 * 1000
11 |
12 | // Act
13 | const result = getBarWidth(params)
14 | const expected = 50
15 |
16 | // Assert
17 | expect(result).toBe(expected)
18 | })
19 | })
20 |
21 | describe('when `currentTime` is 1 hour', () => {
22 | it('should return `0` percent of the width', () => {
23 | // Arrange
24 | const params = 60 * 60 * 1000
25 |
26 | // Act
27 | const result = getBarWidth(params)
28 | const expected = 0
29 |
30 | // Assert
31 | expect(result).toBe(expected)
32 | })
33 | })
34 |
35 | describe('when `currentTime` is 2 hours', () => {
36 | it('should return `0` percent of the width', () => {
37 | // Arrange
38 | const params = 2 * 60 * 60 * 1000
39 |
40 | // Act
41 | const result = getBarWidth(params)
42 | const expected = 0
43 |
44 | // Assert
45 | expect(result).toBe(expected)
46 | })
47 | })
48 | })
49 |
50 | describe('#getChronometerStartTime', () => {
51 | describe('when `startTime` is 1 hour ago', () => {
52 | it('should return `3_600_000` ms', () => {
53 | // Arrange
54 | Date.now = dateNowMock()
55 | const params = {
56 | startTime: new Date('1970-01-01T01:00:00.000Z').getTime(),
57 | }
58 |
59 | // Act
60 | const result = getChronometerStartTime(params)
61 | const expected = time.ONE_HOUR_IN_MS
62 |
63 | // Assert
64 | expect(result).toBe(expected)
65 | })
66 | })
67 |
68 | describe('when `startTime` is 1 hour ago and `pauseStarTime` is 30 min ago', () => {
69 | it('should return `1_800_000` ms', () => {
70 | // Arrange
71 | Date.now = jest.fn(() => new Date('1970-01-01T02:00:00.000Z').getTime())
72 | const params = {
73 | startTime: new Date('1970-01-01T01:00:00.000Z').getTime(),
74 | pauseStartTime: new Date('1970-01-01T01:30:00.000Z').getTime(),
75 | }
76 |
77 | // Act
78 | const result = getChronometerStartTime(params)
79 | const expected = time.HALF_HOUR_IN_MS
80 |
81 | // Assert
82 | expect(result).toBe(expected)
83 | })
84 | })
85 |
86 | describe('when `startTime` is `undefined`', () => {
87 | it('should return `0` ms', () => {
88 | // Arrange
89 | const params = {
90 | startTime: undefined,
91 | }
92 |
93 | // Act
94 | const result = getChronometerStartTime(params)
95 | const expected = 0
96 |
97 | // Assert
98 | expect(result).toBe(expected)
99 | })
100 | })
101 | })
102 | })
103 |
--------------------------------------------------------------------------------
/utils/isEmpty.test.js:
--------------------------------------------------------------------------------
1 | import isEmpty from './isEmpty'
2 |
3 | describe('[ utils / isEmpty ]', () => {
4 | describe('when the `param` is an empty object', () => {
5 | it('should return `true`', () => {
6 | // Arrange
7 | const param = {}
8 |
9 | // Act
10 | const result = isEmpty(param)
11 | const expected = true
12 |
13 | // Assert
14 | expect(result).toBe(expected)
15 | })
16 | })
17 |
18 | describe('when the `param` is `Object.create({})`', () => {
19 | it('should return `true`', () => {
20 | // Arrange
21 | const param = Object.create({})
22 |
23 | // Act
24 | const result = isEmpty(param)
25 | const expected = true
26 |
27 | // Assert
28 | expect(result).toBe(expected)
29 | })
30 | })
31 |
32 | describe('when the `param` is an empty array', () => {
33 | it('should return `true`', () => {
34 | // Arrange
35 | const param = []
36 |
37 | // Act
38 | const result = isEmpty(param)
39 | const expected = true
40 |
41 | // Assert
42 | expect(result).toBe(expected)
43 | })
44 | })
45 |
46 | describe('when the `param` is an empty string', () => {
47 | it('should return `true`', () => {
48 | // Arrange
49 | const param = ''
50 |
51 | // Act
52 | const result = isEmpty(param)
53 | const expected = true
54 |
55 | // Assert
56 | expect(result).toBe(expected)
57 | })
58 | })
59 |
60 | describe('when the `param` is NOT an empty object', () => {
61 | it('should return `false`', () => {
62 | // Arrange
63 | const param = { foo: 'bar' }
64 |
65 | // Act
66 | const result = isEmpty(param)
67 | const expected = false
68 |
69 | // Assert
70 | expect(result).toBe(expected)
71 | })
72 | })
73 |
74 | describe('when the `param` is NOT an empty array', () => {
75 | it('should return `false`', () => {
76 | // Arrange
77 | const param = ['foo', 'bar']
78 |
79 | // Act
80 | const result = isEmpty(param)
81 | const expected = false
82 |
83 | // Assert
84 | expect(result).toBe(expected)
85 | })
86 | })
87 |
88 | describe('when the `param` is NOT an empty string', () => {
89 | it('should return `false`', () => {
90 | // Arrange
91 | const param = 'foo'
92 |
93 | // Act
94 | const result = isEmpty(param)
95 | const expected = false
96 |
97 | // Assert
98 | expect(result).toBe(expected)
99 | })
100 | })
101 |
102 | const NOT_EMPTIABLE_VALUES = [
103 | [42],
104 | [null],
105 | [undefined],
106 | [Symbol()],
107 | [true],
108 | [10n],
109 | [() => {}],
110 | ]
111 |
112 | describe('when the params are NOT emptiable', () => {
113 | it.each(NOT_EMPTIABLE_VALUES)(
114 | 'should trhow an error for param `%s`',
115 | (param) => {
116 | // Act
117 | const result = () => isEmpty(param)
118 | const expected = 'The value is not an object, an array or a string'
119 |
120 | // Assert
121 | expect(result).toThrow(expected)
122 | }
123 | )
124 | })
125 | })
126 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "version": "0.1.0",
4 | "private": true,
5 | "engines": {
6 | "node": ">=16.13.2",
7 | "yarn": ">=1.22.17"
8 | },
9 | "scripts": {
10 | "build": "next build",
11 | "dev:json-server": "json-server --watch db.json --port 3001",
12 | "dev:web": "next dev",
13 | "dev:web:debug": "NODE_OPTIONS='--inspect' next dev",
14 | "dev": "concurrently npm:dev:json-server npm:dev:web",
15 | "dev:debug": "concurrently npm:dev:json-server npm:dev:web:debug",
16 | "start": "next start",
17 | "commit": "cz",
18 | "lint:css:fix": "yarn lint:css:prettier:fix && yarn lint:css:stylelint:fix",
19 | "lint:css:prettier:fix": "yarn lint:css:prettier --write",
20 | "lint:css:prettier": "prettier '**/*.css' --list-different --ignore-path .gitignore",
21 | "lint:css:stylelint:fix": "yarn lint:css:stylelint --fix",
22 | "lint:css:stylelint": "stylelint '**/*.css' --ignore-path .gitignore",
23 | "lint:css": "run-s lint:css:stylelint lint:css:prettier",
24 | "lint:fix": "run-p lint:js:fix lint:json:fix lint:css:fix ",
25 | "lint:js:fix": "yarn lint:js --fix",
26 | "lint:js": "eslint --cache --ignore-path .gitignore '**/*.js'",
27 | "lint:json:fix": "yarn lint:json --write",
28 | "lint:json": "prettier '**/!(db).json' --list-different --ignore-path .gitignore",
29 | "lint": "run-p lint:js lint:json lint:css",
30 | "test:coverage": "yarn test --coverage",
31 | "test:coverage:html": "yarn test:coverage & open coverage/lcov-report/index.html",
32 | "test:watch": "CONSOLE_LEVEL=debug yarn test --watch",
33 | "testcafe:chrome": "testcafe chrome **/*.integration.test.js",
34 | "test:integration": "NODE_ENV=test concurrently 'npm:dev:web' 'npm:testcafe:chrome'",
35 | "test": "jest",
36 | "prepare": "husky install"
37 | },
38 | "dependencies": {
39 | "@auth0/nextjs-auth0": "1.9.1",
40 | "@glrodasz/components": "2.12.1",
41 | "@tanstack/react-query": "4.0.10",
42 | "minireset.css": "0.0.7",
43 | "next": "12.2.3",
44 | "react": "18.2.0",
45 | "react-beautiful-dnd": "13.1.0",
46 | "react-dom": "18.2.0"
47 | },
48 | "devDependencies": {
49 | "@commitlint/cli": "17.0.3",
50 | "@commitlint/config-conventional": "17.0.3",
51 | "@svgr/webpack": "6.3.1",
52 | "@tanstack/react-query-devtools": "4.0.10",
53 | "@testing-library/react": "13.3.0",
54 | "@testing-library/react-hooks": "8.0.1",
55 | "@testing-library/user-event": "14.3.0",
56 | "commitizen": "4.2.5",
57 | "concurrently": "7.3.0",
58 | "cz-conventional-changelog": "3.3.0",
59 | "eslint": "8.20.0",
60 | "eslint-config-next": "12.2.3",
61 | "eslint-config-prettier": "8.5.0",
62 | "eslint-plugin-prettier": "4.2.1",
63 | "eslint-plugin-react": "7.30.1",
64 | "eslint-plugin-testcafe": "0.2.1",
65 | "husky": "^8.0.0",
66 | "identity-obj-proxy": "3.0.0",
67 | "jest": "28.1.3",
68 | "jest-environment-jsdom": "28.1.3",
69 | "json-server": "0.17.0",
70 | "lint-staged": "13.0.3",
71 | "next-transpile-modules": "9.0.0",
72 | "npm-run-all": "4.1.5",
73 | "prettier": "2.7.1",
74 | "sass": "1.54.0",
75 | "stylelint": "14.9.1",
76 | "stylelint-config-idiomatic-order": "8.1.0",
77 | "stylelint-config-recommended": "8.0.0",
78 | "testcafe": "1.20.0",
79 | "testcafe-react-selectors": "5.0.2"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/utils/formatMilliseconds.test.js:
--------------------------------------------------------------------------------
1 | import formatMilliseconds from './formatMilliseconds'
2 |
3 | describe('[ utils / formatMilliseconds ]', () => {
4 | describe('when a time `milliseconds` are 15 minutes', () => {
5 | it('should return a formatted time as `15:00`', () => {
6 | // Arrrange
7 | const milliseconds = 15 * 60 * 1000
8 |
9 | // Act
10 | const result = formatMilliseconds(milliseconds)
11 | const expected = '15:00'
12 |
13 | // Assert
14 | expect(result).toBe(expected)
15 | })
16 | })
17 |
18 | describe('when a time `milliseconds` are 0 milliseconds', () => {
19 | it('should return a formatted time as `00:00`', () => {
20 | // Arrrange
21 | const milliseconds = 0
22 |
23 | // Act
24 | const result = formatMilliseconds(milliseconds)
25 | const expected = '00:00'
26 |
27 | // Assert
28 | expect(result).toBe(expected)
29 | })
30 | })
31 |
32 | describe('when a time `milliseconds` are 1 hour', () => {
33 | it('should return a formatted time as `01:00:00`', () => {
34 | // Arrrange
35 | const milliseconds = 60 * 60 * 1000
36 |
37 | // Act
38 | const result = formatMilliseconds(milliseconds)
39 | const expected = '01:00:00'
40 |
41 | // Assert
42 | expect(result).toBe(expected)
43 | })
44 | })
45 |
46 | describe('when a time `milliseconds` are 25 hours', () => {
47 | it('should return a formatted time as `25:00:00`', () => {
48 | // Arrrange
49 | const milliseconds = 25 * 60 * 60 * 1000
50 |
51 | // Act
52 | const result = formatMilliseconds(milliseconds)
53 | const expected = '25:00:00'
54 |
55 | // Assert
56 | expect(result).toBe(expected)
57 | })
58 | })
59 |
60 | describe('when a time `milliseconds` are 100 hours', () => {
61 | it('should return a formatted time as `100:00:00`', () => {
62 | // Arrrange
63 | const milliseconds = 100 * 60 * 60 * 1000
64 |
65 | // Act
66 | const result = formatMilliseconds(milliseconds)
67 | const expected = '100:00:00'
68 |
69 | // Assert
70 | expect(result).toBe(expected)
71 | })
72 | })
73 |
74 | describe('when a time `milliseconds` is `undefined`', () => {
75 | it('should return a formatted time as `00:00`', () => {
76 | // Arrrange
77 | const milliseconds = undefined
78 |
79 | // Act
80 | const result = formatMilliseconds(milliseconds)
81 | const expected = '00:00'
82 |
83 | // Assert
84 | expect(result).toBe(expected)
85 | })
86 | })
87 |
88 | describe('when a time `milliseconds` is `null`', () => {
89 | it('should throw an exception', () => {
90 | // Arrrange
91 | const milliseconds = null
92 |
93 | // Act
94 | const result = () => formatMilliseconds(milliseconds)
95 |
96 | // Assert
97 | expect(result).toThrow('milliseconds is not a number')
98 | })
99 | })
100 |
101 | describe('when a time `milliseconds` is `{}`', () => {
102 | it('should throw an exception', () => {
103 | // Arrrange
104 | const milliseconds = {}
105 |
106 | // Act
107 | const result = () => formatMilliseconds(milliseconds)
108 |
109 | // Assert
110 | expect(result).toThrow('milliseconds is not a number')
111 | })
112 | })
113 | })
114 |
--------------------------------------------------------------------------------
/features/tasks/helpers.js:
--------------------------------------------------------------------------------
1 | import {
2 | MAXIMUN_IN_PRIORITY_TASKS,
3 | MAXIMUM_BACKLOG_QUANTITY,
4 | } from '../../config'
5 |
6 | import {
7 | IN_PROGRESS_COLUMN_ID,
8 | PENDING_COLUMN_ID,
9 | COMPLETED_COLUMN_ID,
10 | } from './constants'
11 |
12 | export const reorderTasks = (
13 | tasks,
14 | startIndex,
15 | endIndex,
16 | taskStatus,
17 | newTask
18 | ) => {
19 | const clonedTasks = Array.from(tasks)
20 |
21 | if (endIndex !== null && startIndex !== null) {
22 | const [removed] = clonedTasks.splice(startIndex, 1)
23 | clonedTasks.splice(endIndex, 0, removed)
24 | } else if (startIndex === null) {
25 | clonedTasks.splice(endIndex, 0, newTask)
26 | }
27 |
28 | if (taskStatus) {
29 | return clonedTasks.map((task, index) => ({
30 | ...task,
31 | priority: index,
32 | status: taskStatus,
33 | }))
34 | }
35 | return clonedTasks.map((task, index) => ({
36 | ...task,
37 | priority: index,
38 | }))
39 | }
40 |
41 | export const getTaskType = (index) => {
42 | if (index > MAXIMUN_IN_PRIORITY_TASKS - 1) {
43 | return null
44 | }
45 |
46 | return index === 0 ? 'active' : 'standby'
47 | }
48 |
49 | export const getTitle = ({ column, isActive }) => {
50 | if (column.id === IN_PROGRESS_COLUMN_ID && !isActive) {
51 | return 'Tareas'
52 | }
53 |
54 | if (column.id === PENDING_COLUMN_ID && !isActive) {
55 | return ''
56 | }
57 |
58 | return column.title
59 | }
60 |
61 | export const getCurrent = ({ column, isActive, tasks }) => {
62 | if (column.id === PENDING_COLUMN_ID && !isActive) {
63 | return null
64 | }
65 |
66 | return tasks.length
67 | }
68 |
69 | export const getTotal = ({ column, isActive }) => {
70 | if (column.id === IN_PROGRESS_COLUMN_ID && !isActive) {
71 | return MAXIMUN_IN_PRIORITY_TASKS
72 | }
73 |
74 | if (column.id === PENDING_COLUMN_ID && isActive) {
75 | return MAXIMUM_BACKLOG_QUANTITY
76 | }
77 |
78 | return null
79 | }
80 |
81 | export const getSortedTaskIdsFilteredByStatus = (filteredStatus) => (tasks) => {
82 | return tasks
83 | .filter(({ status }) => status === filteredStatus)
84 | .sort((a, b) => a.priority - b.priority)
85 | .map((task) => task.id)
86 | }
87 |
88 | export const normalizeData = (tasks) => {
89 | const normalizeTasks = tasks.reduce((prev, cur) => {
90 | prev[cur.id] = { ...cur }
91 | return prev
92 | }, {})
93 |
94 | const columns = {
95 | [IN_PROGRESS_COLUMN_ID]: {
96 | id: IN_PROGRESS_COLUMN_ID,
97 | title: 'En Progreso',
98 | taskIds: getSortedTaskIdsFilteredByStatus(IN_PROGRESS_COLUMN_ID)(tasks),
99 | },
100 | [PENDING_COLUMN_ID]: {
101 | id: PENDING_COLUMN_ID,
102 | title: 'Pendientes',
103 | taskIds: getSortedTaskIdsFilteredByStatus(PENDING_COLUMN_ID)(tasks),
104 | },
105 | [COMPLETED_COLUMN_ID]: {
106 | id: COMPLETED_COLUMN_ID,
107 | title: 'Completadas',
108 | taskIds: getSortedTaskIdsFilteredByStatus(COMPLETED_COLUMN_ID)(tasks),
109 | },
110 | }
111 |
112 | const columnOrder = [
113 | IN_PROGRESS_COLUMN_ID,
114 | PENDING_COLUMN_ID,
115 | COMPLETED_COLUMN_ID,
116 | ]
117 |
118 | return { tasks: normalizeTasks, columns, columnOrder }
119 | }
120 |
121 | export const filterColumns =
122 | ({ tasksLength, isActive }) =>
123 | (column) => {
124 | const AreWeInFocusSession = isActive
125 | const AreWeInPlanning = !isActive
126 | const DoWeHaveBacklogTasks = tasksLength >= MAXIMUN_IN_PRIORITY_TASKS
127 |
128 | if (AreWeInFocusSession) {
129 | return true
130 | }
131 |
132 | if (AreWeInPlanning && column === IN_PROGRESS_COLUMN_ID) {
133 | return true
134 | }
135 |
136 | if (
137 | AreWeInPlanning &&
138 | DoWeHaveBacklogTasks &&
139 | column === PENDING_COLUMN_ID
140 | ) {
141 | return true
142 | }
143 |
144 | if (AreWeInPlanning && column === COMPLETED_COLUMN_ID) {
145 | return false
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/features/tasks/handlers.js:
--------------------------------------------------------------------------------
1 | import { reorderTasks } from './helpers'
2 | import Router from 'next/router'
3 | import isEmpty from '../../utils/isEmpty'
4 |
5 | export const handleDragEndTask =
6 | ({ tasks }) =>
7 | ({ source, destination, draggableId }) => {
8 | const hasBeenMoveOutsideAColumn = !destination
9 | const hasBeenMovedToSamePlace =
10 | destination?.droppableId === source?.droppableId &&
11 | destination?.index === source?.index
12 |
13 | if (hasBeenMoveOutsideAColumn || hasBeenMovedToSamePlace) {
14 | return
15 | }
16 |
17 | const hasBeenMovedToSameColumn =
18 | source.droppableId === destination.droppableId
19 |
20 | if (hasBeenMovedToSameColumn) {
21 | const { data, api, setLocalData } = tasks
22 | const currentColumnId = destination.droppableId
23 |
24 | const otherTasks = data.filter((task) => task.status !== currentColumnId)
25 |
26 | const orderedTasks = reorderTasks(
27 | data.filter((task) => task.status === currentColumnId),
28 | source.index,
29 | destination.index,
30 | destination.droppableId
31 | )
32 |
33 | const concatenatedTasks = [...otherTasks, ...orderedTasks]
34 |
35 | setLocalData(concatenatedTasks)
36 | return api.updatePriorities({ tasks: concatenatedTasks })
37 | }
38 |
39 | const { data, api, setLocalData } = tasks
40 | const sourceColumnId = source.droppableId
41 | const destinationColumnId = destination.droppableId
42 |
43 | const startTasks = data.filter(
44 | (task) =>
45 | task.status === sourceColumnId && String(task.id) !== draggableId
46 | )
47 | const orderedStartTasks = reorderTasks(
48 | startTasks,
49 | source.index,
50 | null,
51 | source.droppableId
52 | )
53 |
54 | const destinationTasks = data.filter(
55 | (task) => task.status === destinationColumnId
56 | )
57 | const orderedDestionationTasks = reorderTasks(
58 | destinationTasks,
59 | null,
60 | destination.index,
61 | destination.droppableId,
62 | data.find((task) => String(task.id) === draggableId)
63 | )
64 |
65 | const otherTasks = data.filter(
66 | (task) =>
67 | task.status !== sourceColumnId && task.status !== destinationColumnId
68 | )
69 |
70 | const concatenatedTasks = [
71 | ...reorderTasks(otherTasks, null, null),
72 | ...orderedStartTasks,
73 | ...orderedDestionationTasks,
74 | ]
75 |
76 | setLocalData(concatenatedTasks)
77 | return api.updatePriorities({ tasks: concatenatedTasks })
78 | }
79 |
80 | export const handleAddTask =
81 | ({ tasks }) =>
82 | ({ value }) => {
83 | const { api } = tasks
84 | !isEmpty(value) && api.create({ description: value })
85 | }
86 |
87 | export const handleDeleteTask =
88 | ({ deleteConfirmation }) =>
89 | ({ id }) => {
90 | const { setTaskId, setShowDialog } = deleteConfirmation
91 | setTaskId(id)
92 | setShowDialog(true)
93 | }
94 |
95 | export const handleCancelRemove =
96 | ({ deleteConfirmation }) =>
97 | () => {
98 | const { setTaskId, setShowDialog } = deleteConfirmation
99 | setTaskId(null)
100 | setShowDialog(false)
101 | }
102 |
103 | export const handleConfirmRemove =
104 | ({ tasks, deleteConfirmation }) =>
105 | () => {
106 | const { taskId, setShowDialog } = deleteConfirmation
107 | tasks.api.remove({ id: taskId })
108 | setShowDialog(false)
109 | }
110 |
111 | export const handleStartSession =
112 | ({ focusSessions }) =>
113 | () => {
114 | focusSessions.api.create()
115 | Router.push('/focus-session')
116 | }
117 |
118 | export const handleOpenEditTaskModal =
119 | ({ editTaskModal }) =>
120 | ({ id }) => {
121 | const { setTaskId, setShowDialog } = editTaskModal
122 | setTaskId(id)
123 | setShowDialog(true)
124 | }
125 |
126 | export const handleCloseEditTaskModal =
127 | ({ editTaskModal }) =>
128 | () => {
129 | const { setTaskId, setShowDialog } = editTaskModal
130 | setTaskId(null)
131 | setShowDialog(false)
132 | }
133 |
134 | // TODO: Rethink the whole useTask, useTasks naming
135 | // maybe change task to taskApi? to be more specific
136 | export const handleUpdateTask =
137 | ({ task }) =>
138 | ({ id, data }) => {
139 | task.api.update({ id, task: data })
140 | }
141 |
--------------------------------------------------------------------------------
/features/planning/containers/Planning.js:
--------------------------------------------------------------------------------
1 | import { useUser } from '@auth0/nextjs-auth0'
2 | import PropTypes from 'prop-types'
3 |
4 | import { FullHeightContent, LoadingError, Link } from '@glrodasz/components'
5 |
6 | import UserHeader from '../../common/components/UserHeader'
7 | import Board from '../../tasks/components/Board'
8 | import DeleteTaskModal from '../../tasks/components/DeleteTaskModal'
9 | import PlanningOnboarding from '../components/PlanningOnboarding'
10 |
11 | import useDeleteConfirmation from '../../tasks/hooks/useDeleteConfirmation'
12 | import useEditTaskModal from '../../tasks/hooks/useEditTaskModal'
13 | import useTasks from '../../tasks/hooks/useTasks'
14 | import useFocusSessions from '../../focusSession/hooks/useFocusSessions'
15 |
16 | import {
17 | handleDragEndTask,
18 | handleDeleteTask,
19 | handleAddTask,
20 | handleCancelRemove,
21 | handleConfirmRemove,
22 | handleStartSession,
23 | handleOpenEditTaskModal,
24 | } from '../../tasks/handlers'
25 |
26 | import PlanningFooter from '../components/PlanningFooter'
27 | import AddTaskButton from '../components/AddTaskButton'
28 | import EditTask from '../../tasks/containers/EditTask'
29 |
30 | import {
31 | MAXIMUM_BACKLOG_QUANTITY,
32 | MAXIMUN_IN_PRIORITY_TASKS,
33 | } from '../../../config/index'
34 |
35 | const Planning = ({ initialData }) => {
36 | const { user, isLoading: isLoadingUser, error: errorUser } = useUser()
37 | const deleteConfirmation = useDeleteConfirmation()
38 | const editTaskModal = useEditTaskModal()
39 |
40 | const tasks = useTasks({
41 | initialData: initialData.tasks,
42 | onRemove: () => {
43 | deleteConfirmation.setTaskId(null)
44 | editTaskModal.setShowDialog(false)
45 | },
46 | })
47 |
48 | const focusSessions = useFocusSessions()
49 |
50 | const tasksLength = tasks.data?.length
51 | const shouldShowAddTaskButton =
52 | tasksLength < MAXIMUM_BACKLOG_QUANTITY + MAXIMUN_IN_PRIORITY_TASKS
53 |
54 | return (
55 | <>
56 |
62 |
66 |
71 | Conoce la metodologia RETO
72 | >
73 | }
74 | />
75 |
76 |
77 |
91 |
92 |
97 |
98 | }
99 | footer={
100 |
104 | }
105 | />
106 |
110 | {deleteConfirmation.showDialog && (
111 |
118 | )}
119 | >
120 | )
121 | }
122 |
123 | Planning.propTypes = {
124 | initialData: PropTypes.shape({
125 | tasks: PropTypes.arrayOf(
126 | PropTypes.shape({
127 | id: PropTypes.number.isRequired,
128 | description: PropTypes.string.isRequired,
129 | priority: PropTypes.number.isRequired,
130 | })
131 | ),
132 | }),
133 | }
134 |
135 | export default Planning
136 |
--------------------------------------------------------------------------------
/features/common/components/ToggleColorScheme/helpers.test.js:
--------------------------------------------------------------------------------
1 | import { persistColorScheme, loadAndListenColorScheme } from './helpers'
2 |
3 | describe('[ features / common / ToggleColorS≈heme / helpers ]', () => {
4 | describe('#persistColorScheme', () => {
5 | describe('when `persistColorScheme` is called with `isDarkMode` as `true`', () => {
6 | it('should set `html.dataset.colorScheme` with `dark` value', () => {
7 | // Arrange
8 | const params = {
9 | isDarkMode: true,
10 | setIsDarkMode: () => {},
11 | }
12 |
13 | // Act
14 | persistColorScheme(params)
15 | const result = document.querySelector('html').dataset.colorScheme
16 | const expected = 'dark'
17 |
18 | // Assert
19 | expect(result).toBe(expected)
20 | })
21 |
22 | it('should call `localStorage.setItem` with `dark` value', () => {
23 | // Arrange
24 | const params = {
25 | isDarkMode: true,
26 | setIsDarkMode: () => {},
27 | }
28 |
29 | // Act
30 | persistColorScheme(params)
31 | const result = localStorage.getItem('prefers-color-scheme')
32 | const expected = 'dark'
33 |
34 | // Assert
35 | expect(result).toBe(expected)
36 | })
37 |
38 | it('should call `setIsDarkMode` with `isDarkMode` as `true`', () => {
39 | // Arrange
40 | const setIsDarkModeMock = jest.fn()
41 | const params = {
42 | isDarkMode: true,
43 | setIsDarkMode: setIsDarkModeMock,
44 | }
45 |
46 | // Act
47 | persistColorScheme(params)
48 |
49 | // Assert
50 | expect(setIsDarkModeMock).toHaveBeenCalledWith(true)
51 | })
52 | })
53 |
54 | describe('when `persistColorScheme` is called with `isDarkMode` as `false`', () => {
55 | it('should set `html.dataset.colorScheme` with `light` value', () => {
56 | // Arrange
57 | const params = {
58 | isDarkMode: false,
59 | setIsDarkMode: () => {},
60 | }
61 |
62 | // Act
63 | persistColorScheme(params)
64 | const result = document.querySelector('html').dataset.colorScheme
65 | const expected = 'light'
66 |
67 | // Assert
68 | expect(result).toBe(expected)
69 | })
70 |
71 | it('should call `localStorage.setItem` with `light` value', () => {
72 | // Arrange
73 | const params = {
74 | isDarkMode: false,
75 | setIsDarkMode: () => {},
76 | }
77 |
78 | // Act
79 | persistColorScheme(params)
80 | const result = localStorage.getItem('prefers-color-scheme')
81 | const expected = 'light'
82 |
83 | // Assert
84 | expect(result).toBe(expected)
85 | })
86 |
87 | it('should call `setIsDarkMode` with `isDarkMode` as `false`', () => {
88 | // Arrange
89 | const setIsDarkModeMock = jest.fn()
90 | const params = {
91 | isDarkMode: false,
92 | setIsDarkMode: setIsDarkModeMock,
93 | }
94 |
95 | // Act
96 | persistColorScheme(params)
97 |
98 | // Assert
99 | expect(setIsDarkModeMock).toHaveBeenCalledWith(false)
100 | })
101 | })
102 | })
103 |
104 | describe('#loadAndListenColorScheme', () => {
105 | describe('when `loadAndListenColorScheme` is called', () => {
106 | it('should call `darkModeMediaQuery.addListener`', () => {
107 | // Arrange
108 | const darkModeMediaQuery = { addListener: jest.fn() }
109 | global.matchMedia = () => darkModeMediaQuery
110 | const params = { setIsDarkMode: () => {} }
111 |
112 | // Act
113 | loadAndListenColorScheme(params)
114 |
115 | // Assert
116 | expect(darkModeMediaQuery.addListener).toHaveBeenCalled()
117 | })
118 |
119 | describe('and `localStorageColorScheme` is `dark`', () => {
120 | it('should call `persistColorScheme` with `isDarkMode` as `true`', () => {
121 | // Arrange
122 | const persistColorSchemeMock = jest.fn()
123 | const params = {
124 | setIsDarkMode: 'setIsDarkMode',
125 | __persistColorScheme: persistColorSchemeMock,
126 | }
127 | localStorage.setItem('prefers-color-scheme', 'dark')
128 |
129 | // Act
130 | loadAndListenColorScheme(params)
131 |
132 | // Assert
133 | expect(persistColorSchemeMock).toHaveBeenCalledWith({
134 | isDarkMode: true,
135 | setIsDarkMode: 'setIsDarkMode',
136 | })
137 | })
138 | })
139 | })
140 |
141 | describe('and `localStorageColorScheme` is empty', () => {
142 | it('should call `persistColorScheme` with `isDarkMode` as `darkModeMediaQuery.events`', () => {
143 | // Arrange
144 | const darkModeMediaQuery = {
145 | addListener: () => {},
146 | matches: 'isDarkMode',
147 | }
148 | global.matchMedia = () => darkModeMediaQuery
149 | const persistColorSchemeMock = jest.fn()
150 | const params = {
151 | setIsDarkMode: 'setIsDarkMode',
152 | __persistColorScheme: persistColorSchemeMock,
153 | }
154 | localStorage.setItem('prefers-color-scheme', '')
155 |
156 | // Act
157 | loadAndListenColorScheme(params)
158 |
159 | // Assert
160 | expect(persistColorSchemeMock).toHaveBeenCalledWith({
161 | isDarkMode: 'isDarkMode',
162 | setIsDarkMode: 'setIsDarkMode',
163 | })
164 | })
165 | })
166 | })
167 | })
168 |
--------------------------------------------------------------------------------
/features/focusSession/handlers.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | handleCheckCompleteTask,
3 | handleClickCloseBreaktimeConfirmation,
4 | handleClickCloseBreaktimeTimer,
5 | handleClickEndSession,
6 | } from './handlers'
7 |
8 | import Router from 'next/router'
9 | jest.mock('next/router', () => ({
10 | push: jest.fn(),
11 | }))
12 |
13 | describe('[ features / focusSession / handlers ]', () => {
14 | describe('#handleCheckCompleteTask', () => {
15 | describe('when `handleCheckCompleteTask` is called', () => {
16 | it('should return a function', () => {
17 | // Arrange
18 | const params = {}
19 |
20 | // Act
21 | const result = typeof handleCheckCompleteTask(params)
22 | const expected = 'function'
23 |
24 | // Assert
25 | expect(result).toBe(expected)
26 | })
27 | })
28 |
29 | describe('when `handleCheckCompleteTask` returned function is called', () => {
30 | describe('and `isChecked` is `true`', () => {
31 | it('should call `setShowDialog` with `true`', () => {
32 | // Arrange
33 | const setShowDialogMock = jest.fn()
34 | const params = {
35 | breaktimeConfirmation: {
36 | setShowDialog: setShowDialogMock,
37 | },
38 | tasks: {
39 | api: {
40 | updateStatus: () => {},
41 | },
42 | },
43 | }
44 |
45 | // Act
46 | handleCheckCompleteTask(params)({ id: 'id', isChecked: true })
47 |
48 | // Assert
49 | expect(setShowDialogMock).toHaveBeenCalledWith(true)
50 | })
51 | })
52 |
53 | describe('and `isChecked` is `false`', () => {
54 | it('should call `setShowDialog` with `true`', () => {
55 | // Arrange
56 | const setShowDialogMock = jest.fn()
57 | const params = {
58 | breaktimeConfirmation: {
59 | setShowDialog: setShowDialogMock,
60 | },
61 | tasks: {
62 | api: {
63 | updateStatus: () => {},
64 | },
65 | },
66 | }
67 |
68 | // Act
69 | handleCheckCompleteTask(params)({ id: 'id', isChecked: false })
70 |
71 | // Assert
72 | expect(setShowDialogMock).not.toHaveBeenCalled()
73 | })
74 | })
75 |
76 | it('should call `tasks.api.updateStatus` with the params', () => {
77 | // Arrange
78 | const updateStatusMock = jest.fn()
79 | const params = {
80 | breaktimeConfirmation: {
81 | setShowDialog: () => {},
82 | },
83 | tasks: {
84 | api: {
85 | updateStatus: updateStatusMock,
86 | },
87 | },
88 | }
89 |
90 | // Act
91 | handleCheckCompleteTask(params)({ id: 'id', isChecked: false })
92 |
93 | // Assert
94 | expect(updateStatusMock).toHaveBeenCalledWith({
95 | id: 'id',
96 | isChecked: false,
97 | })
98 | })
99 | })
100 | })
101 |
102 | describe('#handleClickCloseBreaktimeTimer', () => {
103 | // Given, when, then
104 | describe('when `handleClickCloseBreaktimeTimer` is called', () => {
105 | it('should return a function', () => {
106 | // Arrange
107 | const params = {}
108 |
109 | // Act
110 | const result = typeof handleClickCloseBreaktimeTimer(params)
111 | const expected = 'function'
112 |
113 | // Assert
114 | expect(result).toBe(expected)
115 | })
116 | })
117 |
118 | describe('when `handleClickCloseBreaktimeTimer` returned is called', () => {
119 | it('should call `setShowDialog` with `false`', () => {
120 | // Arrange
121 | const setShowDialogMock = jest.fn()
122 | const params = {
123 | breaktimeTimer: {
124 | setShowDialog: setShowDialogMock,
125 | },
126 | focusSession: {
127 | api: {
128 | resume: () => {},
129 | },
130 | },
131 | }
132 |
133 | // Act
134 | handleClickCloseBreaktimeTimer(params)()
135 |
136 | // Assert
137 | expect(setShowDialogMock).toHaveBeenCalledWith(false)
138 | })
139 |
140 | it('should call `focusSession.api.resume`', () => {
141 | // Arrange
142 | const focusSessionApiResumeMock = jest.fn()
143 | const params = {
144 | breaktimeTimer: {
145 | setShowDialog: () => {},
146 | },
147 | focusSession: {
148 | api: {
149 | resume: focusSessionApiResumeMock,
150 | },
151 | },
152 | }
153 |
154 | // Act
155 | handleClickCloseBreaktimeTimer(params)()
156 |
157 | // Assert
158 | expect(focusSessionApiResumeMock).toHaveBeenCalled()
159 | })
160 | })
161 | })
162 |
163 | describe('#handleClickCloseBreaktimeConfirmation', () => {
164 | describe('when `handleClickCloseBreaktimeConfirmation` is called', () => {
165 | it('should return a function', () => {
166 | // Arrange
167 | const params = {}
168 |
169 | // Act
170 | const result = typeof handleClickCloseBreaktimeConfirmation(params)
171 | const expected = 'function'
172 |
173 | // Assert
174 | expect(result).toBe(expected)
175 | })
176 | })
177 |
178 | describe('when `handleClickCloseBreaktimeConfirmation` returned function is called', () => {
179 | it('should call `setShowDialog` with `true`', () => {
180 | // Arrange
181 | const setShowDialogMock = jest.fn()
182 | const params = {
183 | breaktimeConfirmation: {
184 | setShowDialog: setShowDialogMock,
185 | },
186 | }
187 |
188 | // Act
189 | handleClickCloseBreaktimeConfirmation(params)()
190 |
191 | // Assert
192 | expect(setShowDialogMock).toHaveBeenCalledWith(false)
193 | })
194 | })
195 | })
196 |
197 | describe('#handleClickEndSession', () => {
198 | describe('when `handleClickEndSession` is called', () => {
199 | it('should return a function', () => {
200 | // Arrange
201 | const params = {}
202 |
203 | // Act
204 | const result = typeof handleClickEndSession(params)
205 | const expected = 'function'
206 |
207 | // Assert
208 | expect(result).toBe(expected)
209 | })
210 | })
211 |
212 | describe('when `handleClickEndSession` returned function is called', () => {
213 | it('should call `focusSessions.api.finish` with an `id`', () => {
214 | // Arrange
215 | const finishMock = jest.fn()
216 | const params = {
217 | focusSessions: {
218 | api: {
219 | finish: finishMock,
220 | },
221 | },
222 | }
223 |
224 | // Act
225 | handleClickEndSession(params)()
226 |
227 | // Assert
228 | expect(finishMock).toHaveBeenCalledWith()
229 | })
230 |
231 | it('should call `Router.push` with an `/planning`', () => {
232 | // Arrange
233 | const params = {
234 | focusSessions: {
235 | api: {
236 | finish: () => {},
237 | },
238 | },
239 | initialData: {
240 | activeFocusSession: {
241 | id: 'foo',
242 | },
243 | },
244 | }
245 |
246 | // Act
247 | handleClickEndSession(params)()
248 |
249 | // Assert
250 | expect(Router.push).toHaveBeenCalledWith('/planning')
251 | })
252 | })
253 | })
254 | })
255 |
--------------------------------------------------------------------------------
/features/focusSession/containers/FocusSession.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { useEffect, useMemo, useState } from 'react'
3 | import { useUser } from '@auth0/nextjs-auth0'
4 |
5 | import {
6 | FullHeightContent,
7 | LoadingError,
8 | Link,
9 | Spacer,
10 | } from '@glrodasz/components'
11 |
12 | import UserHeader from '../../common/components/UserHeader'
13 | import Board from '../../tasks/components/Board'
14 | import DeleteTaskModal from '../../tasks/components/DeleteTaskModal'
15 | import BreaktimeConfirmation from '../components/BreaktimeConfirmation'
16 | import BreaktimeTimer from '../components/BreaktimeTimer'
17 | import FocusSessionFooter from '../components/FocusSessionFooter'
18 | import Chronometer from '../components/Chronometer'
19 | import AddTaskButton from '../../planning/components/AddTaskButton'
20 | import PauseTimer from '../components/PauseTimer'
21 |
22 | import EditTask from '../../tasks/containers/EditTask'
23 |
24 | import {
25 | handleDeleteTask,
26 | handleCancelRemove,
27 | handleConfirmRemove,
28 | handleDragEndTask,
29 | handleAddTask,
30 | handleOpenEditTaskModal,
31 | } from '../../tasks/handlers'
32 |
33 | import {
34 | handleClickCloseBreaktimeConfirmation,
35 | handleClickCloseBreaktimeTimer,
36 | handleClickChooseBreaktime,
37 | handleClickEndSession,
38 | handleCheckCompleteTask,
39 | createHandlerPauseChronometer,
40 | createPauseTimerHandlerClose,
41 | } from '../handlers.js'
42 |
43 | import useEditTaskModal from '../../tasks/hooks/useEditTaskModal'
44 | import useTasks from '../../tasks/hooks/useTasks'
45 | import useDeleteConfirmation from '../../tasks/hooks/useDeleteConfirmation'
46 | import useBreaktimeConfirmation from '../hooks/useBreaktimeConfirmation'
47 | import useBreaktimeTimer from '../hooks/useBreaktimeTimer'
48 | import useFocusSessions from '../hooks/useFocusSessions'
49 | import useFocusSession from '../hooks/useFocusSession'
50 | import useChronometer from '../hooks/useChronometer'
51 |
52 | import isEmpty from '../../../utils/isEmpty'
53 | import isObject from '../../../utils/isObject'
54 |
55 | import {
56 | MAXIMUM_BACKLOG_QUANTITY,
57 | MAXIMUN_IN_PRIORITY_TASKS,
58 | } from '../../../config'
59 | import { COMPLETED_COLUMN_ID } from '../../tasks/constants'
60 | import useDialog from '../../common/hooks/useDialog'
61 |
62 | const getActivePause = ({ focusSession }) => {
63 | return focusSession?.data?.pauses?.find((pause) => pause.endTime === null)
64 | }
65 |
66 | const FocusSession = ({ initialData }) => {
67 | const { user, isLoading: isLoadingUser, error: errorUser } = useUser()
68 | const deleteConfirmation = useDeleteConfirmation()
69 | const breaktimeConfirmation = useBreaktimeConfirmation()
70 | const breaktimeTimer = useBreaktimeTimer()
71 | const editTaskModal = useEditTaskModal()
72 | const pauseTimer = useDialog()
73 | const [renderChronometer, setRenderChronometer] = useState(false)
74 |
75 | const tasks = useTasks({
76 | initialData: initialData.tasks,
77 | onRemove: () => {
78 | deleteConfirmation.setTaskId(null)
79 | editTaskModal.setShowDialog(false)
80 | },
81 | })
82 |
83 | const focusSession = useFocusSession({
84 | initialData: initialData.activeFocusSession,
85 | onResume: () => resumeTime(),
86 | })
87 |
88 | const activePause = getActivePause({ focusSession })
89 | const isPaused = isObject(activePause) && !isEmpty(activePause)
90 |
91 | const startTime = useMemo(
92 | () => focusSession?.data?.startTime ?? 0,
93 | [focusSession?.data?.startTime]
94 | )
95 | const pauseStartTime = useMemo(
96 | () => activePause?.startTime ?? 0,
97 | [activePause?.startTime]
98 | )
99 |
100 | const { currentTime, clearTime, resumeTime } = useChronometer({
101 | startTime,
102 | pauseStartTime,
103 | isPaused,
104 | })
105 |
106 | useEffect(() => setRenderChronometer(true), [])
107 |
108 | const focusSessions = useFocusSessions()
109 |
110 | const tasksLength = tasks.data?.filter(
111 | (task) => task.status !== COMPLETED_COLUMN_ID
112 | )?.length
113 | const shouldShowAddTaskButton =
114 | tasksLength < MAXIMUM_BACKLOG_QUANTITY + MAXIMUN_IN_PRIORITY_TASKS
115 |
116 | return (
117 | <>
118 |
124 |
128 |
133 | Conoce la metodologia RETO
134 | >
135 | }
136 | />
137 |
138 |
139 | {renderChronometer && (
140 |
149 | )}
150 |
168 | {shouldShowAddTaskButton && (
169 | <>
170 |
171 |
176 | >
177 | )}
178 |
179 | }
180 | footer={
181 |
186 | }
187 | />
188 | {breaktimeConfirmation.showDialog && (
189 |
199 | )}
200 | {breaktimeTimer.showDialog && (
201 |
208 | )}
209 | {pauseTimer.showDialog && (
210 |
216 | )}
217 |
221 | {deleteConfirmation.showDialog && (
222 |
229 | )}
230 | >
231 | )
232 | }
233 |
234 | FocusSession.propTypes = {
235 | initialData: PropTypes.object,
236 | }
237 |
238 | export default FocusSession
239 |
--------------------------------------------------------------------------------
/features/tasks/helpers.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | filterColumns,
3 | getTitle,
4 | getCurrent,
5 | reorderTasks,
6 | getTaskType,
7 | getTotal,
8 | } from './helpers'
9 |
10 | jest.mock('../../config', () => ({
11 | MAXIMUN_IN_PRIORITY_TASKS: 3,
12 | MAXIMUM_BACKLOG_QUANTITY: 5,
13 | }))
14 |
15 | jest.mock('./constants', () => ({
16 | IN_PROGRESS_COLUMN_ID: 'IN_PROGRESS_COLUMN_ID',
17 | PENDING_COLUMN_ID: 'PENDING_COLUMN_ID',
18 | COMPLETED_COLUMN_ID: 'COMPLETED_COLUMN_ID',
19 | }))
20 |
21 | describe('[ features / tasks / helpers ]', () => {
22 | describe('#reorderTasks', () => {
23 | describe('when a list of tasks is provided', () => {
24 | it('should return a reordered tasks list', () => {
25 | // Arrange
26 | const tasks = [{ letter: 'a' }, { letter: 'b' }, { letter: 'c' }]
27 |
28 | // Act
29 | const result = reorderTasks(tasks, 0, 2)
30 | const expected = [
31 | { letter: 'b', priority: 0 },
32 | { letter: 'c', priority: 1 },
33 | { letter: 'a', priority: 2 },
34 | ]
35 |
36 | // Assert
37 | expect(result).toEqual(expected)
38 | })
39 | })
40 | })
41 |
42 | describe('#getTaskType', () => {
43 | describe('when `index` is bigger than `MAXIMUN_IN_PRIORITY_TASKS - 1`', () => {
44 | it('should return null', () => {
45 | // Arrange
46 | const index = 10
47 | // Act
48 | const result = getTaskType(index)
49 | const expected = null
50 |
51 | // Assert
52 | expect(result).toBe(expected)
53 | })
54 | })
55 |
56 | describe('when `index` is 0', () => {
57 | it('should return "active"', () => {
58 | // Arrange
59 | const index = 0
60 | // Act
61 | const result = getTaskType(index)
62 | const expected = 'active'
63 |
64 | // Assert
65 | expect(result).toBe(expected)
66 | })
67 | })
68 |
69 | describe('when `index` is smaller than `MAXIMUN_IN_PRIORITY_TASKS - 1`', () => {
70 | it('should return "active"', () => {
71 | // Arrange
72 | const index = 1
73 | // Act
74 | const result = getTaskType(index)
75 | const expected = 'standby'
76 |
77 | // Assert
78 | expect(result).toBe(expected)
79 | })
80 | })
81 | })
82 |
83 | describe('#getTitle', () => {
84 | describe('when `isActive`is `true`', () => {
85 | it('should return `column.title`', () => {
86 | // Arrange
87 | const params = {
88 | isActive: true,
89 | column: {
90 | title: 'title',
91 | },
92 | }
93 |
94 | // Act
95 | const result = getTitle(params)
96 | const expected = 'title'
97 |
98 | // Assert
99 | expect(result).toBe(expected)
100 | })
101 | })
102 |
103 | describe('when `isActive`is `false` and `column.id` is `IN_PROGRESS_COLUMN_ID`', () => {
104 | it('should return `Tareas`', () => {
105 | // Arrange
106 | const params = {
107 | isActive: false,
108 | column: {
109 | id: 'IN_PROGRESS_COLUMN_ID',
110 | },
111 | }
112 |
113 | // Act
114 | const result = getTitle(params)
115 | const expected = 'Tareas'
116 |
117 | // Assert
118 | expect(result).toBe(expected)
119 | })
120 | })
121 |
122 | describe('when `isActive`is `false` and `column.id` is `PENDING_COLUMN_ID`', () => {
123 | it('should return an empty string', () => {
124 | // Arrange
125 | const params = {
126 | isActive: false,
127 | column: {
128 | id: 'PENDING_COLUMN_ID',
129 | },
130 | }
131 |
132 | // Act
133 | const result = getTitle(params)
134 | const expected = ''
135 |
136 | // Assert
137 | expect(result).toBe(expected)
138 | })
139 | })
140 | })
141 |
142 | describe('#getCurrent', () => {
143 | describe('when `column.id` is `PENDING_COLUMN_ID` and `isActive` is `false`', () => {
144 | it('should return `null`', () => {
145 | // Arrange
146 | const params = {
147 | tasks: [],
148 | isActive: false,
149 | column: {
150 | id: 'PENDING_COLUMN_ID',
151 | },
152 | }
153 |
154 | // Act
155 | const result = getCurrent(params)
156 | const expected = null
157 |
158 | // Assert
159 | expect(result).toBe(expected)
160 | })
161 | })
162 |
163 | describe('when `column.id` is `PENDING_COLUMN_ID` and `isActive` is `true`', () => {
164 | it('should return the length of the `tasks`', () => {
165 | // Arrange
166 | const params = {
167 | tasks: [null, null],
168 | isActive: true,
169 | column: {
170 | id: 'PENDING_COLUMN_ID',
171 | },
172 | }
173 |
174 | // Act
175 | const result = getCurrent(params)
176 | const expected = 2
177 |
178 | // Assert
179 | expect(result).toBe(expected)
180 | })
181 | })
182 | })
183 |
184 | describe('#getTotal', () => {
185 | describe('when `column.id` is `IN_PROGRESS_COLUMN_ID` and `isActive` is false', () => {
186 | it('should return `MAXIMUN_IN_PRIORITY_TASKS`', () => {
187 | // Arrange
188 | const params = {
189 | column: {
190 | id: 'IN_PROGRESS_COLUMN_ID',
191 | },
192 | isActive: false,
193 | }
194 |
195 | // Act
196 | const result = getTotal(params)
197 | const expected = 3
198 |
199 | // Assert
200 | expect(result).toBe(expected)
201 | })
202 | })
203 |
204 | describe('when `column.id` is `PENDING_COLUMN_ID` and `isActive` is true', () => {
205 | it('should return `MAXIMUM_BACKLOG_QUANTITY`', () => {
206 | // Arrange
207 | const params = {
208 | column: {
209 | id: 'PENDING_COLUMN_ID',
210 | },
211 | isActive: true,
212 | }
213 |
214 | // Act
215 | const result = getTotal(params)
216 | const expected = 5
217 |
218 | // Assert
219 | expect(result).toBe(expected)
220 | })
221 | })
222 |
223 | describe('when `column.id` is `OTHER_COLUMN_ID` and `isActive` is true/false', () => {
224 | it('should return `null`', () => {
225 | // Arrange
226 | const params = {
227 | column: {
228 | id: 'OTHER_COLUMN_ID',
229 | },
230 | isActive: true,
231 | }
232 |
233 | // Act
234 | const result = getTotal(params)
235 | const expected = null
236 |
237 | // Assert
238 | expect(result).toBe(expected)
239 | })
240 | })
241 | })
242 |
243 | describe('#filterColumns', () => {
244 | describe('when `isActive` is `true`', () => {
245 | it('should return `true`', () => {
246 | // Arrange
247 | const params = {
248 | isActive: true,
249 | }
250 | const column = 'IN_PROGRESS_COLUMN_ID'
251 |
252 | // Act
253 | const result = filterColumns(params)(column)
254 | const expected = true
255 |
256 | // Assert
257 | expect(result).toBe(expected)
258 | })
259 | })
260 |
261 | describe('when `isActive` is `false` and `column` is `IN_PROGRESS_COLUMN_ID`', () => {
262 | it('should return `true`', () => {
263 | // Arrange
264 | const params = {
265 | isActive: false,
266 | }
267 | const column = 'IN_PROGRESS_COLUMN_ID'
268 |
269 | // Act
270 | const result = filterColumns(params)(column)
271 | const expected = true
272 |
273 | // Assert
274 | expect(result).toBe(expected)
275 | })
276 | })
277 |
278 | describe('when `isActive` is `false` and `column` is `COMPLETED_COLUMN_ID`', () => {
279 | it('should return `true`', () => {
280 | // Arrange
281 | const params = {
282 | isActive: false,
283 | }
284 | const column = 'COMPLETED_COLUMN_ID'
285 |
286 | // Act
287 | const result = filterColumns(params)(column)
288 | const expected = false
289 |
290 | // Assert
291 | expect(result).toBe(expected)
292 | })
293 | })
294 |
295 | describe('when `isActive` is `false`, and `column` is `PENDING_COLUMN_ID`, and `tasksLength` is greater or equal than `MAXIMUN_IN_PRIORITY_TASKS` ', () => {
296 | it('should return `true`', () => {
297 | // Arrange
298 | const params = {
299 | isActive: false,
300 | tasksLength: 10,
301 | }
302 | const column = 'PENDING_COLUMN_ID'
303 |
304 | // Act
305 | const result = filterColumns(params)(column)
306 | const expected = true
307 |
308 | // Assert
309 | expect(result).toBe(expected)
310 | })
311 | })
312 | })
313 | })
314 |
--------------------------------------------------------------------------------
/public/images/yoga-pause.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/public/images/forest-pause.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------