├── .env.example
├── .eslintrc.json
├── .gitignore
├── Capture.JPG
├── README.md
├── next-env.d.ts
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── favicon.ico
└── vercel.svg
├── src
├── components
│ ├── Alert.tsx
│ ├── Button.tsx
│ ├── Form.tsx
│ ├── Link.tsx
│ ├── PageHeading.tsx
│ ├── home-page
│ │ ├── LogOfRecentTaskTimes.tsx
│ │ ├── Taskbar.tsx
│ │ └── Timer.tsx
│ ├── layout
│ │ ├── Footer.tsx
│ │ ├── Layout.tsx
│ │ └── Navbar.tsx
│ ├── login-page
│ │ └── LoginForm.tsx
│ └── signup-page
│ │ └── SignupForm.tsx
├── constants
│ └── constants.ts
├── contexts
│ ├── TasksContext.ts
│ └── UserContext.ts
├── custom-hooks
│ ├── useTasks.ts
│ ├── useTimer.ts
│ └── useUser.ts
├── middleware
│ └── _defaultHandler.ts
├── pages
│ ├── _app.tsx
│ ├── api
│ │ └── signup.ts
│ ├── index.tsx
│ ├── login.tsx
│ ├── signup.tsx
│ └── stats.tsx
├── styles
│ └── globals.css
├── types
│ ├── RecentTaskTime.ts
│ └── Task.ts
└── utils
│ ├── formatTime.ts
│ ├── harperdb
│ ├── addNewTask.ts
│ ├── createNewUser.ts
│ ├── deleteTask.ts
│ ├── fetchJWTTokens.ts
│ ├── getTasks.ts
│ ├── getUsername.ts
│ ├── harperFetch.ts
│ └── saveTaskTime.ts
│ └── postFormData.ts
├── tailwind.config.js
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | HARPERDB_PW=Basic yourHarperDBPasswordHere
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.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 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 |
34 | # vercel
35 | .vercel
36 |
37 | # typescript
38 | *.tsbuildinfo
39 |
--------------------------------------------------------------------------------
/Capture.JPG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoableDanny/NextJS-HarperDB-Task-Timer/0b1eeed805984729f7e368f12da9549788bdd14f/Capture.JPG
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [Project link](https://next-js-harper-db-task-timer.vercel.app/).
2 |
3 | 
4 |
5 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
6 |
7 | ## Getting Started
8 |
9 | First, run the development server:
10 |
11 | ```bash
12 | npm run dev
13 | # or
14 | yarn dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
20 |
21 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
22 |
23 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
24 |
25 | ## Learn More
26 |
27 | To learn more about Next.js, take a look at the following resources:
28 |
29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
31 |
32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
33 |
34 | ## Deploy on Vercel
35 |
36 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
37 |
38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
39 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | }
5 |
6 | module.exports = nextConfig
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "harperdb-nextjs-timer",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "next": "12.1.0",
13 | "next-connect": "^0.12.2",
14 | "react": "17.0.2",
15 | "react-dom": "17.0.2"
16 | },
17 | "devDependencies": {
18 | "@types/node": "17.0.21",
19 | "@types/react": "17.0.39",
20 | "autoprefixer": "^10.4.2",
21 | "eslint": "8.10.0",
22 | "eslint-config-next": "12.1.0",
23 | "postcss": "^8.4.7",
24 | "tailwindcss": "^3.0.23",
25 | "typescript": "4.6.2"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoableDanny/NextJS-HarperDB-Task-Timer/0b1eeed805984729f7e368f12da9549788bdd14f/public/favicon.ico
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Alert.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | children: React.ReactNode;
3 | type: 'success' | 'warning' | 'danger';
4 | key?: number;
5 | extraClasses?: string;
6 | }
7 | const Alert = ({ children, type, key, extraClasses }: Props) => {
8 | let color;
9 | switch (type) {
10 | case 'success':
11 | color = 'bg-blue-500';
12 | break;
13 | case 'warning':
14 | color = 'bg-yellow-300 text-yellow-800';
15 | break;
16 | default:
17 | color = 'bg-red-500';
18 | }
19 | const classes = `text-white text-center p-2 rounded mt-4 ${color} ${extraClasses}`;
20 |
21 | return (
22 |
23 | {children}
24 |
25 | );
26 | };
27 |
28 | export default Alert;
29 |
--------------------------------------------------------------------------------
/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | children: React.ReactNode;
3 | color: 'primary' | 'success' | 'secondary' | 'warning' | 'danger';
4 | handleClick?: () => void;
5 | type?: 'button' | 'submit';
6 | extraClasses?: string;
7 | }
8 |
9 | const Button: React.FC = ({
10 | children,
11 | color,
12 | handleClick,
13 | type,
14 | extraClasses,
15 | }) => {
16 | let colors: string;
17 | switch (color) {
18 | case 'primary':
19 | colors = 'bg-blue-500 hover:bg-blue-600';
20 | break;
21 | case 'success':
22 | colors = 'bg-green-500 hover:bg-green-600';
23 | break;
24 | case 'warning':
25 | colors = 'bg-yellow-300 hover:bg-yellow-400 text-black';
26 | break;
27 | case 'secondary':
28 | colors = 'bg-pink-500 hover:bg-pink-600';
29 | break;
30 | default:
31 | colors = 'bg-red-500 hover:bg-red-600';
32 | }
33 | const classes = `rounded text-white py-2 px-4 ${colors} ${extraClasses}`;
34 |
35 | return (
36 |
39 | );
40 | };
41 |
42 | export default Button;
43 |
--------------------------------------------------------------------------------
/src/components/Form.tsx:
--------------------------------------------------------------------------------
1 | interface InputProps {
2 | inputType: 'text' | 'email' | 'password';
3 | inputName: string;
4 | handleChange: (e: React.ChangeEvent) => void;
5 | value: string;
6 | }
7 |
8 | interface LabelAndInputProps extends InputProps {
9 | label: string;
10 | }
11 |
12 | export const LabelAndInput: React.FC = ({
13 | label,
14 | inputType,
15 | inputName,
16 | handleChange,
17 | value,
18 | }) => {
19 | return (
20 |
21 |
22 |
28 |
29 | );
30 | };
31 |
32 | export const Input: React.FC = ({
33 | inputType,
34 | inputName,
35 | handleChange,
36 | value,
37 | }) => {
38 | return (
39 |
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/src/components/Link.tsx:
--------------------------------------------------------------------------------
1 | import NextLink from 'next/link';
2 |
3 | interface Props {
4 | href: string;
5 | children: React.ReactNode;
6 | }
7 |
8 | const Link = ({ href, children }: Props) => {
9 | return (
10 |
11 | {children}
12 |
13 | );
14 | };
15 |
16 | export default Link;
17 |
--------------------------------------------------------------------------------
/src/components/PageHeading.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | extraClasses?: string;
3 | }
4 |
5 | const PageHeading: React.FC = ({ children, extraClasses }) => {
6 | const classes = 'text-4xl text-green-900 font-semibold ' + extraClasses;
7 |
8 | return {children}
;
9 | };
10 |
11 | export default PageHeading;
12 |
--------------------------------------------------------------------------------
/src/components/home-page/LogOfRecentTaskTimes.tsx:
--------------------------------------------------------------------------------
1 | import type { RecentTaskTime } from '../../types/RecentTaskTime';
2 |
3 | interface Props {
4 | recentTaskTimes: RecentTaskTime[];
5 | }
6 |
7 | const LogOfRecentTaskTimes = ({ recentTaskTimes }: Props) => {
8 | return (
9 |
10 | {recentTaskTimes.map((t, i) => (
11 |
12 |
13 | {t.seconds} seconds added to{' '}
14 | {t.name}
15 |
16 |
17 | ))}
18 |
19 | );
20 | };
21 |
22 | export default LogOfRecentTaskTimes;
23 |
--------------------------------------------------------------------------------
/src/components/home-page/Taskbar.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from 'react';
2 | import { harperAddNewTask } from '../../utils/harperdb/addNewTask';
3 | import { UserContext } from '../../contexts/UserContext';
4 | import { TasksContext } from '../../contexts/TasksContext';
5 | import Button from '../Button';
6 |
7 | interface Props {
8 | selectedTaskId: string;
9 | setSelectedTaskId: React.Dispatch>;
10 | setSelectedTaskName: React.Dispatch>;
11 | setErrorMessage: React.Dispatch>;
12 | setSeconds: React.Dispatch>;
13 | pauseTimer: () => void;
14 | }
15 |
16 | const TaskBar = ({
17 | selectedTaskId,
18 | setSelectedTaskId,
19 | setSelectedTaskName,
20 | setErrorMessage,
21 | setSeconds,
22 | pauseTimer,
23 | }: Props) => {
24 | const { username } = useContext(UserContext);
25 | const { tasks, getAndSetTasks } = useContext(TasksContext);
26 |
27 | const [isUserAddingNewTask, setIsUserAddingNewTask] = useState(false);
28 | const [taskInputValue, setTaskInputValue] = useState('');
29 |
30 | const handleChangeTaskInput = (e: { target: HTMLInputElement }) => {
31 | setTaskInputValue(e.target.value);
32 | };
33 |
34 | const handleSelectTask = (e: { target: HTMLSelectElement }) => {
35 | const { options, selectedIndex, value } = e.target;
36 | const text = options[selectedIndex].text;
37 |
38 | setErrorMessage('');
39 | setSelectedTaskId(value);
40 | setSelectedTaskName(text);
41 | setSeconds(0);
42 | pauseTimer();
43 | };
44 |
45 | const handleClickAddNewTask = () => {
46 | if (taskInputValue.trim() === '') {
47 | setErrorMessage('Type a task!');
48 | return;
49 | }
50 | addNewTask();
51 | resetAddingNewTask();
52 | };
53 |
54 | const addNewTask = async () => {
55 | try {
56 | const { response } = await harperAddNewTask(username, taskInputValue);
57 | if (response.status === 200) {
58 | // Task added to db successfully
59 | getAndSetTasks(username);
60 | } else setErrorMessage('Whoops, something went wrong');
61 | } catch (err) {
62 | console.log(err);
63 | setErrorMessage('Whoops, something went wrong');
64 | }
65 | };
66 |
67 | const resetAddingNewTask = () => {
68 | setTaskInputValue('');
69 | setIsUserAddingNewTask(false);
70 | };
71 |
72 | return (
73 |
74 | {isUserAddingNewTask ? (
75 | <>
76 |
83 |
86 |
93 | >
94 | ) : (
95 | <>
96 |
117 |
123 | >
124 | )}
125 |
126 | );
127 | };
128 |
129 | export default TaskBar;
130 |
--------------------------------------------------------------------------------
/src/components/home-page/Timer.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import { TasksContext } from '../../contexts/TasksContext';
3 | import { UserContext } from '../../contexts/UserContext';
4 | import { formatTime } from '../../utils/formatTime';
5 | import { harperSaveTaskTime } from '../../utils/harperdb/saveTaskTime';
6 | import Button from '../Button';
7 | import type { RecentTaskTime } from '../../types/RecentTaskTime';
8 |
9 | interface TimerProps {
10 | seconds: number;
11 | setSeconds: React.Dispatch>;
12 | isTimerOn: boolean;
13 | startTimer: () => void;
14 | pauseTimer: () => void;
15 | setErrorMessage: React.Dispatch>;
16 | selectedTaskId: string;
17 | selectedTaskName: string;
18 | setRecentTaskTimes: React.Dispatch>;
19 | }
20 |
21 | export const Timer: React.FC = ({
22 | seconds,
23 | setSeconds,
24 | isTimerOn,
25 | startTimer,
26 | pauseTimer,
27 | setErrorMessage,
28 | selectedTaskId,
29 | selectedTaskName,
30 | setRecentTaskTimes,
31 | }) => {
32 | const { tasks, getAndSetTasks } = useContext(TasksContext);
33 | const { username } = useContext(UserContext);
34 |
35 | const { formattedHours, formattedMins, formattedSecs } = formatTime(seconds);
36 |
37 | const handleStartTimer = () => {
38 | setErrorMessage('');
39 | if (selectedTaskId == '') {
40 | setErrorMessage('Please select a task');
41 | } else {
42 | startTimer();
43 | }
44 | };
45 |
46 | const handleLogTime = async () => {
47 | pauseTimer();
48 | const prevTaskSeconds = getTaskTimeFromId(selectedTaskId);
49 | const newTaskSeconds = prevTaskSeconds + seconds;
50 | const { response, result } = await harperSaveTaskTime(
51 | selectedTaskId,
52 | newTaskSeconds
53 | );
54 | if (response.status === 200) {
55 | getAndSetTasks(username);
56 | setSeconds(0);
57 | setRecentTaskTimes((prev) => [
58 | { name: selectedTaskName, seconds: seconds },
59 | ...prev,
60 | ]);
61 | } else setErrorMessage('Whoops, something went wrong :(');
62 | console.log({ response, result });
63 | };
64 |
65 | const getTaskTimeFromId = (id: string) => {
66 | const task = tasks.find((task) => task.id === id);
67 | if (!task) return 0;
68 | return task.time_in_seconds;
69 | };
70 |
71 | const handleResetTimer = () => {
72 | pauseTimer();
73 | setSeconds(0);
74 | };
75 |
76 | return (
77 |
78 |
79 | {formattedHours} : {formattedMins} : {formattedSecs}
80 |
81 |
82 | {/* Pause and start the timer buttons */}
83 | {isTimerOn ? (
84 | <>
85 |
88 | >
89 | ) : (
90 |
93 | )}
94 |
95 | {/* Button to update the time in the db for the chosen task */}
96 | {(seconds > 0 || isTimerOn) && (
97 |
104 | )}
105 |
106 |
107 | {/* Stop timer and reset to 0 secs */}
108 | {(seconds > 0 || isTimerOn) && (
109 |
115 | )}
116 |
117 | );
118 | };
119 |
120 | interface TimerBtnProps {
121 | handleClick: () => void;
122 | text: string;
123 | extraClasses?: string;
124 | }
125 |
126 | export const TimerBtn: React.FC = ({
127 | handleClick,
128 | text,
129 | extraClasses,
130 | }) => {
131 | return (
132 |
140 | );
141 | };
142 |
--------------------------------------------------------------------------------
/src/components/layout/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { SITE_TITLE } from '../../constants/constants';
2 |
3 | const Footer = () => {
4 | return (
5 |
9 | );
10 | };
11 |
12 | export default Footer;
13 |
--------------------------------------------------------------------------------
/src/components/layout/Layout.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from './Navbar';
2 | import Footer from './Footer';
3 |
4 | const Layout: React.FC = ({ children }) => {
5 | return (
6 |
7 |
8 |
{children}
9 |
10 |
11 |
12 |
13 | );
14 | };
15 |
16 | export default Layout;
17 |
--------------------------------------------------------------------------------
/src/components/layout/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { useContext } from 'react';
3 | import { SITE_TITLE } from '../../constants/constants';
4 | import { UserContext } from '../../contexts/UserContext';
5 |
6 | const Navbar = () => {
7 | const { username, setUsername } = useContext(UserContext);
8 |
9 | const handleLogout = () => {
10 | localStorage.removeItem('access_token');
11 | setUsername('');
12 | };
13 |
14 | return (
15 |
16 |
21 |
43 |
44 | );
45 | };
46 |
47 | interface NavLinkProps {
48 | href: string;
49 | children: string;
50 | }
51 |
52 | const NavLink: React.FC = ({ href, children }) => {
53 | return (
54 |
55 |
56 | {children}
57 |
58 |
59 | );
60 | };
61 |
62 | export default Navbar;
63 |
--------------------------------------------------------------------------------
/src/components/login-page/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from 'react';
2 | import { LabelAndInput } from '../Form';
3 | import Button from '../Button';
4 | import Alert from '../Alert';
5 |
6 | import { UserContext } from '../../contexts/UserContext';
7 | import { harperFetchJWTTokens } from '../../utils/harperdb/fetchJWTTokens';
8 |
9 | const LoginForm = () => {
10 | const [username, setUsername] = useState('');
11 | const [password, setPassword] = useState('');
12 |
13 | const [error, setError] = useState('');
14 | const user = useContext(UserContext);
15 |
16 | const handleSubmit = async (e: React.FormEvent) => {
17 | e.preventDefault();
18 | setError('');
19 | if (!username || !password) {
20 | setError('Username and password required');
21 | return;
22 | }
23 |
24 | try {
25 | const { response, result } = await harperFetchJWTTokens(
26 | username,
27 | password
28 | );
29 | const { status } = response;
30 | const accessToken = result.operation_token;
31 | if (status === 200 && accessToken) {
32 | authenticateUser(username, accessToken);
33 | } else if (status === 401) {
34 | setError('Check your username and password are correct');
35 | } else {
36 | setError('Whoops, something went wrong :(');
37 | }
38 | } catch (err) {
39 | console.log(err);
40 | setError('Whoops, something went wrong :(');
41 | }
42 | };
43 |
44 | const authenticateUser = (username: string, accessToken: string) => {
45 | user.setUsername(username);
46 | localStorage.setItem('access_token', accessToken);
47 | };
48 |
49 | return (
50 |
71 | );
72 | };
73 |
74 | export default LoginForm;
75 |
--------------------------------------------------------------------------------
/src/components/signup-page/SignupForm.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from 'react';
2 | import { UserContext } from '../../contexts/UserContext';
3 | import { useRouter } from 'next/router';
4 |
5 | import { LabelAndInput } from '../Form';
6 | import Button from '../Button';
7 | import Alert from '../Alert';
8 | import { postFormData } from '../../utils/postFormData';
9 |
10 | import { harperFetchJWTTokens } from '../../utils/harperdb/fetchJWTTokens';
11 |
12 | const SignupForm = () => {
13 | const [username, setUsername] = useState('');
14 | const [password1, setPassword1] = useState('');
15 | const [password2, setPassword2] = useState('');
16 | const [errors, setErrors] = useState('');
17 |
18 | const user = useContext(UserContext);
19 | const router = useRouter();
20 |
21 | const handleSubmit = async (e: React.FormEvent) => {
22 | e.preventDefault();
23 | setErrors('');
24 |
25 | const formData = { username, password1, password2 };
26 | const { response, result } = await postFormData(formData, '/api/signup');
27 |
28 | // Account not created successfully
29 | if (response.status !== 200) {
30 | setErrors(result.error);
31 | return;
32 | }
33 |
34 | // Account created successfully; get JWTs
35 | try {
36 | const { response, result } = await harperFetchJWTTokens(
37 | username,
38 | password1
39 | );
40 | const accessToken = result.operation_token;
41 | if (response.status === 200 && accessToken) {
42 | authenticateUser(username, accessToken);
43 | } else {
44 | // Account created, but failed to get JWTs
45 | // Redirect to login page
46 | router.push('/login');
47 | }
48 | } catch (err) {
49 | console.log(err);
50 | setErrors('Whoops, something went wrong :(');
51 | }
52 |
53 | console.log({ response, result });
54 | };
55 |
56 | const authenticateUser = (username: string, accessToken: string) => {
57 | user.setUsername(username);
58 | localStorage.setItem('access_token', accessToken);
59 | };
60 |
61 | const displayErrors = () => {
62 | if (errors.length === 0) return;
63 |
64 | return typeof errors === 'string' ? (
65 | {errors}
66 | ) : (
67 | errors.map((err, i) => (
68 |
69 | {err}
70 |
71 | ))
72 | );
73 | };
74 |
75 | return (
76 |
108 | );
109 | };
110 |
111 | export default SignupForm;
112 |
--------------------------------------------------------------------------------
/src/constants/constants.ts:
--------------------------------------------------------------------------------
1 | export const SITE_TITLE = 'Super Simple Task Timer';
2 | export const DB_URL = 'https://cloud-1-doabledanny.harperdbcloud.com';
3 |
--------------------------------------------------------------------------------
/src/contexts/TasksContext.ts:
--------------------------------------------------------------------------------
1 | import React, { createContext } from 'react';
2 | import type { Task } from '../types/Task';
3 |
4 | interface TasksContext {
5 | tasks: Task[];
6 | setTasks: React.Dispatch>;
7 | getAndSetTasks: (username: string) => Promise;
8 | }
9 |
10 | export const TasksContext = createContext({} as TasksContext);
11 |
--------------------------------------------------------------------------------
/src/contexts/UserContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react';
2 |
3 | export const UserContext = createContext({
4 | username: '',
5 | setUsername: (username: string) => {},
6 | });
7 |
--------------------------------------------------------------------------------
/src/custom-hooks/useTasks.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useEffect } from 'react';
2 | import type { Task } from '../types/Task';
3 | import { harperGetTasks } from '../utils/harperdb/getTasks';
4 |
5 | export const useTasks = (username: string) => {
6 | const [tasks, setTasks] = useState([]);
7 |
8 | // Get tasks from db then set task state
9 | const getAndSetTasks = useCallback(
10 | async (username: string) => {
11 | try {
12 | const tasks: Task[] = await harperGetTasks(username);
13 | setTasks(tasks);
14 | } catch (err) {
15 | console.log(err);
16 | }
17 | },
18 | [setTasks]
19 | );
20 |
21 | useEffect(() => {
22 | if (!username || tasks.length > 0) return;
23 | getAndSetTasks(username);
24 | }, [username, tasks.length, getAndSetTasks]);
25 |
26 | return { tasks, setTasks, getAndSetTasks };
27 | };
28 |
--------------------------------------------------------------------------------
/src/custom-hooks/useTimer.ts:
--------------------------------------------------------------------------------
1 | import { useState, useRef } from 'react';
2 |
3 | const useTimer = () => {
4 | const [isTimerOn, setIsTimerOn] = useState(false);
5 | const [seconds, setSeconds] = useState(0);
6 |
7 | const intervalRef = useRef(null);
8 |
9 | const startTimer = () => {
10 | setIsTimerOn(true);
11 |
12 | const intervalId = setInterval(() => {
13 | setSeconds((prev) => prev + 1);
14 | }, 1000);
15 |
16 | intervalRef.current = intervalId;
17 | };
18 |
19 | const pauseTimer = () => {
20 | setIsTimerOn(false);
21 | clearInterval(intervalRef.current as NodeJS.Timeout);
22 | };
23 |
24 | return {
25 | isTimerOn,
26 | seconds,
27 | setSeconds,
28 | startTimer,
29 | pauseTimer,
30 | };
31 | };
32 |
33 | export default useTimer;
34 |
--------------------------------------------------------------------------------
/src/custom-hooks/useUser.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { harperGetUsername } from '../utils/harperdb/getUsername';
3 |
4 | export const useUser = () => {
5 | const [username, setUsername] = useState('');
6 |
7 | useEffect(() => {
8 | // User is logged in
9 | if (username) return;
10 |
11 | // Check for access token and try to log user in
12 | const accessToken = localStorage.getItem('access_token');
13 | if (accessToken) {
14 | tryLogUserIn(accessToken);
15 | }
16 |
17 | async function tryLogUserIn(accessToken: string) {
18 | const username = await harperGetUsername(accessToken);
19 | if (username) {
20 | setUsername(username);
21 | }
22 | }
23 | });
24 |
25 | return { username, setUsername };
26 | };
27 |
--------------------------------------------------------------------------------
/src/middleware/_defaultHandler.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import nextConnect from 'next-connect';
3 |
4 | // This middleware function will run between every request and api handler
5 | const handler = nextConnect({
6 | onError: (err, req, res) => {
7 | res.status(501).json({ error: `Something went wrong! ${err.message}` });
8 | },
9 | onNoMatch: (req, res) => {
10 | res.status(405).json({ error: `Method ${req.method} Not Allowed` });
11 | },
12 | });
13 |
14 | export default handler;
15 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import '../styles/globals.css';
2 | import type { AppProps } from 'next/app';
3 | import Layout from '../components/layout/Layout';
4 | import { UserContext } from '../contexts/UserContext';
5 | import { useUser } from '../custom-hooks/useUser';
6 |
7 | import { TasksContext } from '../contexts/TasksContext';
8 | import { useTasks } from '../custom-hooks/useTasks';
9 |
10 | function MyApp({ Component, pageProps }: AppProps) {
11 | const { username, setUsername } = useUser();
12 |
13 | const { tasks, setTasks, getAndSetTasks } = useTasks(username);
14 |
15 | console.log(tasks);
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | }
27 |
28 | export default MyApp;
29 |
--------------------------------------------------------------------------------
/src/pages/api/signup.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next';
2 | import handler from '../../middleware/_defaultHandler';
3 | import { harperCreateNewUser } from '../../utils/harperdb/createNewUser';
4 |
5 | export default handler.post(
6 | async (req: NextApiRequest, res: NextApiResponse) => {
7 | const { username, password1, password2 } = req.body;
8 |
9 | const errors: string[] = getFormErrors(username, password1, password2);
10 | if (errors.length > 0) {
11 | return res.status(400).json({ error: errors });
12 | }
13 |
14 | // Create new user with HarperDB, and send back result
15 | try {
16 | const { response, result } = await harperCreateNewUser(
17 | username,
18 | password1
19 | );
20 | return res.status(response.status).json(result);
21 | } catch (err) {
22 | return res.status(500).json({ error: err });
23 | }
24 | }
25 | );
26 |
27 | const getFormErrors = (
28 | username: string,
29 | password1: string,
30 | password2: string
31 | ) => {
32 | const errors: string[] = [];
33 | if (!username || !password1 || !password2) {
34 | errors.push('All fields are required');
35 | }
36 | if (password1.length < 6) {
37 | errors.push('Password must be at least 6 characters');
38 | }
39 | if (password1 !== password2) {
40 | errors.push('Passwords do not match');
41 | }
42 | return errors;
43 | };
44 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from 'react';
2 | import type { NextPage } from 'next';
3 | import type { RecentTaskTime } from '../types/RecentTaskTime';
4 | import { UserContext } from '../contexts/UserContext';
5 | import useTimer from '../custom-hooks/useTimer';
6 | import Taskbar from '../components/home-page/Taskbar';
7 | import { Timer } from '../components/home-page/Timer';
8 | import Alert from '../components/Alert';
9 | import Link from '../components/Link';
10 | import LogOfRecentTaskTimes from '../components/home-page/LogOfRecentTaskTimes';
11 |
12 | const Home: NextPage = () => {
13 | const [selectedTaskId, setSelectedTaskId] = useState('');
14 | const [selectedTaskName, setSelectedTaskName] = useState('');
15 | const [errorMessage, setErrorMessage] = useState('');
16 | const [recentTaskTimes, setRecentTaskTimes] = useState([]);
17 |
18 | const { isTimerOn, seconds, setSeconds, startTimer, pauseTimer } = useTimer();
19 |
20 | const { username } = useContext(UserContext);
21 |
22 | return (
23 |
24 | {!username && (
25 |
26 | Please log in or{' '}
27 | create an account to use Super
28 | Productivity Timer
29 |
30 | )}
31 |
32 |
40 |
51 |
52 | {errorMessage &&
{errorMessage}
}
53 |
54 | {recentTaskTimes.length > 0 && (
55 |
56 | )}
57 |
58 | );
59 | };
60 |
61 | export default Home;
62 |
--------------------------------------------------------------------------------
/src/pages/login.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import type { NextPage } from 'next';
3 | import { UserContext } from '../contexts/UserContext';
4 | import PageHeading from '../components/PageHeading';
5 | import LoginForm from '../components/login-page/LoginForm';
6 |
7 | const Login: NextPage = () => {
8 | const { username } = useContext(UserContext);
9 |
10 | return (
11 |
12 | {username ? (
13 |
14 | You are logged in as{' '}
15 | {username} 👋
16 |
17 | ) : (
18 | <>
19 |
Log in
20 |
21 | >
22 | )}
23 |
24 | );
25 | };
26 |
27 | export default Login;
28 |
--------------------------------------------------------------------------------
/src/pages/signup.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import { UserContext } from '../contexts/UserContext';
3 | import Alert from '../components/Alert';
4 |
5 | import type { NextPage } from 'next';
6 | import SignupForm from '../components/signup-page/SignupForm';
7 | import PageHeading from '../components/PageHeading';
8 |
9 | const Signup: NextPage = () => {
10 | const { username } = useContext(UserContext);
11 |
12 | return (
13 |
14 | {username ? (
15 |
You are logged in as {username}
16 | ) : (
17 | <>
18 |
19 | Create an account
20 |
21 |
22 | >
23 | )}
24 |
25 | );
26 | };
27 |
28 | export default Signup;
29 |
--------------------------------------------------------------------------------
/src/pages/stats.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from 'react';
2 | import type { NextPage } from 'next';
3 | import { UserContext } from '../contexts/UserContext';
4 | import { TasksContext } from '../contexts/TasksContext';
5 | import Header from '../components/PageHeading';
6 | import Link from '../components/Link';
7 | import Alert from '../components/Alert';
8 | import {
9 | displayTimeString,
10 | timestampToDayMonthYear,
11 | } from '../utils/formatTime';
12 | import { harperDeleteTask } from '../utils/harperdb/deleteTask';
13 |
14 | const Stats: NextPage = () => {
15 | const [errorMessage, setErrorMessage] = useState('');
16 |
17 | const { username } = useContext(UserContext);
18 | const { tasks, getAndSetTasks } = useContext(TasksContext);
19 |
20 | const handleDeleteRow = async (taskId: string) => {
21 | setErrorMessage('');
22 | const areYouSure = confirm('Are you sure you want to delete this row?');
23 | if (!areYouSure) return;
24 |
25 | try {
26 | // Delete task from db
27 | const { response } = await harperDeleteTask(taskId);
28 | if (response.status === 200) {
29 | // Get tasks from db and setTasks
30 | getAndSetTasks(username);
31 | return;
32 | }
33 | } catch (err) {
34 | console.log(err);
35 | }
36 | setErrorMessage('Whoops, something went wrong :(');
37 | };
38 |
39 | return (
40 |
41 | {!username && (
42 |
43 | Please log in or{' '}
44 | create an account to use Super
45 | Productivity Timer
46 |
47 | )}
48 |
49 |
50 |
51 | {errorMessage && (
52 |
{errorMessage}
53 | )}
54 |
55 |
56 |
57 |
58 |
59 | Task |
60 | Total Time |
61 | Last Updated |
62 | Start Date |
63 | Delete |
64 |
65 |
66 |
67 | {tasks.length > 0 &&
68 | tasks.map((task) => (
69 |
70 | {task.task_name} |
71 | {displayTimeString(task.time_in_seconds)} |
72 | {timestampToDayMonthYear(task.__updatedtime__)} |
73 | {timestampToDayMonthYear(task.__createdtime__)} |
74 |
75 |
81 | |
82 |
83 | ))}
84 |
85 |
86 |
87 |
88 | );
89 | };
90 |
91 | const TH: React.FC<{ children: string }> = ({ children }) => {
92 | const classes = 'border border-slate-300 rounded-top p-4';
93 | return {children} | ;
94 | };
95 |
96 | interface TDProps {
97 | children: React.ReactNode;
98 | }
99 | const TD = ({ children }: TDProps) => {
100 | const classes = 'border border-slate-300 p-4';
101 | return {children} | ;
102 | };
103 |
104 | export default Stats;
105 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/src/types/RecentTaskTime.ts:
--------------------------------------------------------------------------------
1 | export interface RecentTaskTime {
2 | name: string;
3 | seconds: number;
4 | }
5 |
--------------------------------------------------------------------------------
/src/types/Task.ts:
--------------------------------------------------------------------------------
1 | export interface Task {
2 | __createdtime__: number;
3 | __updatedtime__: number;
4 | username: string;
5 | time_in_seconds: number;
6 | id: string;
7 | task_name: string;
8 | }
9 |
--------------------------------------------------------------------------------
/src/utils/formatTime.ts:
--------------------------------------------------------------------------------
1 | const SECONDS_PER_HOUR = 3600;
2 | const SECONDS_PER_MINUTE = 60;
3 |
4 | export const displayTimeString = (seconds: number) => {
5 | const { formattedHours, formattedMins, formattedSecs } = formatTime(seconds);
6 | return `${formattedHours}h ${formattedMins}m ${formattedSecs}s`;
7 | };
8 |
9 | // timestamp => dd/mm/yyyy
10 | export const timestampToDayMonthYear = (timestamp: number) => {
11 | const date = new Date(timestamp);
12 | const formattedDate = date.toLocaleDateString();
13 | return formattedDate;
14 | };
15 |
16 | // HH:MM:SS
17 | export const formatTime = (seconds: number) => {
18 | const { hours, mins, secs } = calculateHoursMinsAndSecs(seconds);
19 |
20 | const formattedHours = prependZeroIfLessThanTen(hours);
21 | const formattedMins = prependZeroIfLessThanTen(mins);
22 | const formattedSecs = prependZeroIfLessThanTen(secs);
23 |
24 | return {
25 | formattedHours,
26 | formattedMins,
27 | formattedSecs,
28 | };
29 | };
30 |
31 | // Prefix time with 0 if less than 10. E.g. '1' => '01'.
32 | const prependZeroIfLessThanTen = (time: number) => {
33 | const formattedTime: string = time < 10 ? `0${time}` : `${time}`;
34 | return formattedTime;
35 | };
36 |
37 | // Convert seconds into hours, mins, and secs
38 | const calculateHoursMinsAndSecs = (seconds: number) => {
39 | const hours = calculateHours(seconds);
40 | const mins = calculateMins(seconds);
41 | const secs = calculateSecs(seconds);
42 |
43 | return {
44 | hours,
45 | mins,
46 | secs,
47 | };
48 | };
49 |
50 | const calculateHours = (seconds: number) => {
51 | const hours = Math.floor(seconds / SECONDS_PER_HOUR);
52 | return hours;
53 | };
54 |
55 | const calculateMins = (seconds: number) => {
56 | const mins = Math.floor((seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
57 | return mins;
58 | };
59 |
60 | const calculateSecs = (seconds: number) => {
61 | const secs = Math.floor((seconds % SECONDS_PER_HOUR) % SECONDS_PER_MINUTE);
62 | return secs;
63 | };
64 |
--------------------------------------------------------------------------------
/src/utils/harperdb/addNewTask.ts:
--------------------------------------------------------------------------------
1 | import { harperFetch } from './harperFetch';
2 |
3 | export const harperAddNewTask = async (username: string, taskName: string) => {
4 | const data = {
5 | operation: 'insert',
6 | schema: 'productivity_timer',
7 | table: 'tasks',
8 | records: [
9 | {
10 | username: username,
11 | task_name: taskName,
12 | time_in_seconds: 0,
13 | },
14 | ],
15 | };
16 |
17 | const responseAndResult = await harperFetch(data);
18 | return responseAndResult;
19 | };
20 |
--------------------------------------------------------------------------------
/src/utils/harperdb/createNewUser.ts:
--------------------------------------------------------------------------------
1 | import { DB_URL } from '../../constants/constants';
2 |
3 | // This function can only be ran on the backend as it requires a "super_user" password
4 | export const harperCreateNewUser = async (
5 | username: string,
6 | password: string
7 | ) => {
8 | const DB_PW = process.env.HARPERDB_PW;
9 | if (!DB_URL || !DB_PW) {
10 | console.log('Error: .env variables are undefined');
11 | throw 'Internal server error';
12 | }
13 | const myHeaders = new Headers();
14 | myHeaders.append('Content-Type', 'application/json');
15 | myHeaders.append('Authorization', DB_PW);
16 | const raw = JSON.stringify({
17 | operation: 'add_user',
18 | role: 'standard_user',
19 | username: username.toLowerCase(),
20 | password: password,
21 | active: true,
22 | });
23 | const requestOptions: RequestInit = {
24 | method: 'POST',
25 | headers: myHeaders,
26 | body: raw,
27 | redirect: 'follow',
28 | };
29 |
30 | const response = await fetch(DB_URL, requestOptions);
31 | const result = await response.json();
32 | return { response, result };
33 | };
34 |
--------------------------------------------------------------------------------
/src/utils/harperdb/deleteTask.ts:
--------------------------------------------------------------------------------
1 | import { harperFetch } from './harperFetch';
2 |
3 | export const harperDeleteTask = async (taskId: string) => {
4 | const data = {
5 | operation: 'delete',
6 | schema: 'productivity_timer',
7 | table: 'tasks',
8 | hash_values: [taskId],
9 | };
10 |
11 | const responseAndResult = await harperFetch(data);
12 | return responseAndResult;
13 | };
14 |
--------------------------------------------------------------------------------
/src/utils/harperdb/fetchJWTTokens.ts:
--------------------------------------------------------------------------------
1 | import { DB_URL } from '../../constants/constants';
2 |
3 | export const harperFetchJWTTokens = async (
4 | username: string,
5 | password: string
6 | ) => {
7 | if (!DB_URL) {
8 | console.log('Error: DB_URL undefined');
9 | throw 'Internal server error';
10 | }
11 |
12 | const myHeaders = new Headers();
13 | myHeaders.append('Content-Type', 'application/json');
14 |
15 | const raw = JSON.stringify({
16 | operation: 'create_authentication_tokens',
17 | username: username,
18 | password: password,
19 | });
20 |
21 | const requestOptions: RequestInit = {
22 | method: 'POST',
23 | headers: myHeaders,
24 | body: raw,
25 | redirect: 'follow',
26 | };
27 |
28 | const response = await fetch(DB_URL, requestOptions);
29 | const result = await response.json();
30 | return { response, result };
31 | };
32 |
--------------------------------------------------------------------------------
/src/utils/harperdb/getTasks.ts:
--------------------------------------------------------------------------------
1 | import { harperFetch } from './harperFetch';
2 |
3 | export const harperGetTasks = async (username: string) => {
4 | const data = {
5 | operation: 'sql',
6 | sql: `SELECT * FROM productivity_timer.tasks WHERE username = '${username}' ORDER BY __updatedtime__ DESC`,
7 | };
8 |
9 | const { result } = await harperFetch(data);
10 | return result;
11 | };
12 |
--------------------------------------------------------------------------------
/src/utils/harperdb/getUsername.ts:
--------------------------------------------------------------------------------
1 | import { DB_URL } from '../../constants/constants';
2 |
3 | export const harperGetUsername = async (accessToken: string) => {
4 | const myHeaders = new Headers();
5 | myHeaders.append('Content-Type', 'application/json');
6 | myHeaders.append('Authorization', 'Bearer ' + accessToken);
7 |
8 | const raw = JSON.stringify({
9 | operation: 'user_info',
10 | });
11 |
12 | const requestOptions: RequestInit = {
13 | method: 'POST',
14 | headers: myHeaders,
15 | body: raw,
16 | redirect: 'follow',
17 | };
18 |
19 | try {
20 | const response = await fetch(DB_URL, requestOptions);
21 | const result = await response.json();
22 | if (response.status === 200) {
23 | return result.username;
24 | }
25 | } catch (err) {
26 | console.log(err);
27 | }
28 | return null;
29 | };
30 |
--------------------------------------------------------------------------------
/src/utils/harperdb/harperFetch.ts:
--------------------------------------------------------------------------------
1 | import { DB_URL } from '../../constants/constants';
2 |
3 | export const harperFetch = async (data: { [key: string]: any }) => {
4 | const accessToken = localStorage.getItem('access_token');
5 | if (!accessToken) throw { error: 'You need to log in' };
6 |
7 | const myHeaders = new Headers();
8 | myHeaders.append('Content-Type', 'application/json');
9 | myHeaders.append('Authorization', 'Bearer ' + accessToken);
10 |
11 | const raw = JSON.stringify(data);
12 |
13 | const requestOptions: RequestInit = {
14 | method: 'POST',
15 | headers: myHeaders,
16 | body: raw,
17 | redirect: 'follow',
18 | };
19 |
20 | const response = await fetch(DB_URL, requestOptions);
21 | const result = await response.json();
22 | return { response, result };
23 | };
24 |
--------------------------------------------------------------------------------
/src/utils/harperdb/saveTaskTime.ts:
--------------------------------------------------------------------------------
1 | import { harperFetch } from './harperFetch';
2 |
3 | export const harperSaveTaskTime = async (
4 | taskId: string,
5 | newSeconds: number
6 | ) => {
7 | const data = {
8 | operation: 'sql',
9 | sql: `UPDATE productivity_timer.tasks SET time_in_seconds = '${newSeconds}' WHERE id = '${taskId}'`,
10 | };
11 |
12 | const responseAndResult = await harperFetch(data);
13 | return responseAndResult;
14 | };
15 |
--------------------------------------------------------------------------------
/src/utils/postFormData.ts:
--------------------------------------------------------------------------------
1 | export const postFormData = async (data: { [k: string]: any }, url: string) => {
2 | const requestOptions: RequestInit = {
3 | method: 'POST',
4 | headers: {
5 | 'Content-Type': 'application/json',
6 | },
7 | body: JSON.stringify(data),
8 | };
9 | const response = await fetch(url, requestOptions);
10 | const result = await response.json();
11 | return { response, result };
12 | };
13 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: [
3 | "./src/pages/**/*.{js,ts,jsx,tsx}",
4 | "./src/components/**/*.{js,ts,jsx,tsx}",
5 | ],
6 | theme: {
7 | extend: {},
8 | },
9 | plugins: [],
10 | }
11 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true
17 | },
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------