Building a Voice and Eye-Controlled To-Do App - Part 1
Tags: webdev, javascript, tutorial, frontend
Published: 2024-03-20 19:14:39

Hi, I invite you to join me in creating another TODO app. I hope we can learn a lot during the creation.

**Task list**
- Create basic functionality
- Add styles and animations
- Add voice control and responses 
- Add cursor control with gaze
- Protect tasks with face/voice recognition and possibly fingerprint recognition

I will post an article each time after I finish a task, so if you have any questions or suggestions please speak up.

### Create basic functionality

I don't want to overload the application with logic at the start. So let's leave only the basic actions of creating and deleting a task.

![App Sketch](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vqlpuxmxsnm22nzpm3qy.png)

#### Create a project
`npx create-next-app@latest`
```
What is your project named? todo-ai
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? No
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) No
Would you like to customize the default import alias (@/*)? No
```
and install the dependencies `npm i --save @chakra-ui/next-js @chakra-ui/react @emotion/react @emotion/styled framer-motion swr`

At this stage things like chakra-ui or framer-motion will be unnecessary, but when we will style and animate the application they will be useful, so let's use them right away.

#### Creating the skeleton
`src/pages/_app.tsx`
```tsx
import type { AppProps } from "next/app";
import { ChakraProvider } from '@chakra-ui/react'
import { SWRConfig } from "swr";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <SWRConfig
      value={{
        refreshInterval: 3000,
      }}
    >
      <ChakraProvider>
        <Component {...pageProps} />
      </ChakraProvider>
    </SWRConfig>
  )
}
```
`src/pages/index.tsx`
```tsx
import Head from "next/head";
import { TaskCreator } from "@/components/TaskCreator";
import { TaskList } from "@/components/TaskList";

export default function Home() {
  return (
    <>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <main>
        <TaskCreator />
        <TaskList />
      </main>
    </>
  );
}
```

#### Create a components
`src/components/TaskCreator/index.tsx`
```tsx
import { createTask } from "@/api"
import { useTasks } from "@/hooks/useTasks"
import { Button, FormControl, Input, InputGroup, InputRightElement, useToast } from "@chakra-ui/react"
import { ChangeEvent, ChangeEventHandler, FormEvent, FormEventHandler, ReactEventHandler, useState } from "react"

export const TaskCreator = () => {
    const toast = useToast()
    const { tasks, refresh } = useTasks()

    const [taskName, setTaskName] = useState('')
    const handleChange = (event: ChangeEvent<HTMLInputElement>) => setTaskName(event.target.value)

    const handleAddTask = async (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        createTask(taskName).then((task) => {
            refresh([...tasks, task])
        }).catch(() => {
            toast({
                title: 'Error on creating a task',
                status: 'error'
            })
        })
    }

    return (
        <form onSubmit={handleAddTask}>
            <InputGroup>
                <Input variant='outline' placeholder="Input a task" onChange={handleChange} />
                <InputRightElement>
                    <Button type='submit'>
                        Add
                    </Button>
                </InputRightElement>
            </InputGroup>
        </form>
    )
}
```

`src/components/TaskList/index.tsx`
```tsx
import { Card, CardBody, Heading, Spinner, Stack, useToast } from "@chakra-ui/react"
import { removeTask } from "@/api"
import { Task } from "@/types"
import { useTasks } from "@/hooks/useTasks"
import { useDelay } from "@/hooks/useDelay"

type onDeleteHandler = (id: string) => void
type TaskCardProps = Task & {
    onDelete: onDeleteHandler
}
const TaskCard: React.FC<TaskCardProps> = ({ id, name, onDelete }) => {
    const handleDelete = () => {
        onDelete(id)
    }

    return (
        <Card
            direction={{ base: 'column', sm: 'row' }}
            overflow='hidden'
            variant='outline'
        >
            <CardBody>
                <Heading size='md'>{name}</Heading>
            </CardBody>
            <button onClick={handleDelete}>
                Delete task
            </button>
        </Card>
    )
}

export const TaskList = () => {
    const toast = useToast()
    const { tasks, error, isLoading, refresh } = useTasks()
    const isLoadingDelayed = useDelay(isLoading, {
        initialValue: false,
    })

    const handleDeleteTask = (id: string) => {
        removeTask(id).then(({ id }) => {
            refresh([...tasks.filter((task: Task) => task.id !== id)])
        }).catch(() => {
            toast({
                title: 'Error on delete task',
                status: 'error'
            })
        })
    }

    if (error) {
        return <div>
            {error.message}
        </div>
    }

    return isLoadingDelayed ? <Spinner size='xl' /> : (
        <Stack spacing={4}>
            {...tasks.map((task, idx) => (
                <TaskCard key={task.id || `${task.name}_${idx}`} {...task} onDelete={handleDeleteTask} />
            ))}
        </Stack>
    )
}
```

#### The hooks 

`useDelay` - delays changes of variable state - it's almost like debounce hook but with different initial state. Used to not show the download spinner if the download was fast enough.

`src/hooks/useDelay.ts`
```ts
import { useEffect, useState } from "react";

interface UseDelayParams<T> {
    initialValue: T
    delay?: number
}
export const useDelay = <T>(value: T, { initialValue, delay = 300 }: UseDelayParams<T>) => {
    const [currentValue, setCurrentValue] = useState(initialValue)

    useEffect(() => {
        let timer: NodeJS.Timeout;

        timer = setTimeout(() => {
            setCurrentValue(value)
        }, delay)

        return () => {
            timer && clearTimeout(timer)
        }
    }, [value, delay])

    return currentValue
}
```

`useTask` - encapsulates the logic of task handling. Basically it is just a wrapper over `useSWR`.

`src/hooks/useTasks.ts`
```ts
import { FetchError, fetchTasks } from "@/api"
import useSWR from "swr"

export const useTasks = () => {
    const { data = [], error, isLoading, mutate } = useSWR('/api/tasks', fetchTasks)

    return {
        tasks: data,
        error: error as FetchError,
        isLoading: isLoading,
        refresh: mutate
    }
}
```

Since I don't want to complicate the application by using something like redux or mobx or even a small zustand. Tasks will be stored here on the server, and access to them will be through the api that we will write a bit later.


#### The missing parts

`src/api/index.ts`
```ts
import { Task, TaskList } from '@/types'

export class FetchError extends Error {
    constructor(message: string, public status: number) {
        super(message);
    }
}

const fetcher = async (url: string, init?: RequestInit | undefined) => {
    const res = await fetch(url, init)

    if (!res.ok) {
        const message = (await res.json())?.message
        const error = new FetchError(message, res.status)
        throw error
    }

    return res.json()
}
export const fetchTasks = async (): Promise<TaskList> => {
    return fetcher('/api/tasks')
}

export const createTask = async (name: string): Promise<Task> => {
    return fetcher('/api/tasks', {
        method: 'POST',
        body: JSON.stringify({
            name
        }, null, 0)
    })
}

export const removeTask = async (id: string): Promise<Task> => {
    return fetcher('/api/tasks', {
        method: 'DELETE',
        body: JSON.stringify({
            id
        }, null, 0)
    })
}
```

And not forget about types
`src/types/index/ts`
```ts
export interface Task {
    id: string
    name: string
}

export type TaskList = Task[]
```
### API 

We need three methods `GET`, `POST` and `DELETE`. To get, create and delete tasks. 
For storage a relational database is used, with the following schema.

![Database relation diagram](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6deqmpp03g7vzmet349k.png)

#### Authentication

As you can see there is a user here, but where can he come from?
I don't want to add a registration and login form for now. So we will create a new user ourselves when a request comes in without the necessary cookies. And if the request has the necessary cookies, we will take the user's id from them and give back the data associated with it.

![Authentication schema](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/raq80o889od4uv5afx77.png)

#### Initialize database

First, let's install the dependencies for the server.
`npm i --save @prisma/client cookie jose`
`npm i --save-dev prisma @types/cookie`

Next, initialize prisma.
`npx prisma init --datasource-provider sqlite`

Create a schema.
`prisma/schema.prisma`
```prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id    String @id @default(cuid())
  tasks Task[]
}

model Task {
  id      String @id @default(cuid())
  name    String
  owner   User   @relation(fields: [ownerId], references: [id])
  ownerId String
}
```

Run the migration to create the database and types.
`npx prisma migrate dev --name init`

Write functions for necessary CRUD operations.
`db/tasks.ts`
```ts
import prisma from "./prisma"

export const addTask = (userId: string, taskName: string) => {
    return prisma.task.create({
        data: {
            name: taskName,
            ownerId: userId
        }
    })
}
export const deleteTask = (taskId: string) => {
    return prisma.task.delete({
        where: {
            id: taskId
        }
    })
}
export const getTasks = (userId: string) => {
    return prisma.task.findMany({
        where: {
            ownerId: userId
        }
    })
}
export const getTask = (taskId: string) => {
    return prisma.task.findUnique({
        where: {
            id: taskId
        }
    })
}
```

`db/users.ts`
```ts
import prisma from "./prisma"

export const createUser = () => {
    return prisma.user.create({ data: {} })
}

export const getUser = (id: string) => {
    return prisma.user.findUnique({
        where: {
            id: id
        }
    })
}
```

And PrismaClient initialization code.
`db/prisma.ts`
```ts
import { PrismaClient } from '@prisma/client'

const prismaClientSingleton = () => {
    return new PrismaClient()
}

declare global {
    var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>
}

const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()

export default prisma

if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
```

#### The API

`src/pages/api/tasks.ts`
```ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { serialize } from 'cookie'
import { createUser, getUser } from '@/db/users'
import { createSession, decrypt, encrypt } from '@/session'
import { addTask, deleteTask, getTask, getTasks } from '@/db/tasks'
import { User } from '@prisma/client'

const taskHandlers = {
    'GET': async (req: NextApiRequest, res: NextApiResponse, userId: string) => {
        const tasks = await getTasks(userId)
        return res.status(200).json(tasks)
    },
    'POST': async (req: NextApiRequest, res: NextApiResponse, userId: string) => {
        const { name } = JSON.parse(req.body)
        if (name) {
            const task = await addTask(userId, name)
            return res.status(200).json(task)
        } else {
            return res.status(406).send({ message: 'Not Acceptable' })
        }
    },
    'DELETE': async (req: NextApiRequest, res: NextApiResponse, userId: string) => {
        const { id } = JSON.parse(req.body)
        if (id) {
            const processedTask = await getTask(id)
            if (processedTask?.ownerId !== userId) {
                return res.status(403).send({ message: 'Forbidden' })
            }
            const task = await deleteTask(id)
            return res.status(200).json(task)
        } else {
            return res.status(406).send({ message: 'Not Acceptable' })
        }
    },
}
type AllowedMethods = keyof typeof taskHandlers

const authorize = async (req: NextApiRequest, res: NextApiResponse) => {
    const initializeNewUser = async (req: NextApiRequest, res: NextApiResponse) => {
        const user = await createUser()
        const sessionData = createSession(user.id)
        const encryptedSessionData = await encrypt(sessionData)
        const cookie = serialize('session', encryptedSessionData, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            expires: sessionData.expires,
            path: '/'
        })
        res.setHeader('Set-Cookie', cookie)
        return user
    }

    const session = req.cookies['session']
    let user!: User | null
    if (!session) {
        user = await initializeNewUser(req, res)
    } else {
        try {
            const userId = (await decrypt(session)).payload.userId as string
            user = await getUser(userId)
        } catch (e) {
            user = await initializeNewUser(req, res)
        }
    }

    return user
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    try {
        const user = await authorize(req, res)
        if (!user) {
            return res.status(401).send({ message: 'Unauthorized' })
        }
        if (req.method && req.method in taskHandlers) {
            return await taskHandlers[req.method as AllowedMethods](req, res, user.id)
        } else {
            return res.status(501).send({ message: 'Not Implemented' })
        }
    } catch (e) {
        return res.status(500).send({ message: 'Internal Error' })
    }
}

export const config = {
    api: {
        bodyParser: {
            sizeLimit: '1mb',
        },
    },
    maxDuration: 5,
}
```

**Session managment**
`src/session.ts`
```ts
import { SignJWT, jwtVerify } from "jose"

const secretKey = process.env.SECRET_KEY
const key = new TextEncoder().encode(secretKey)

interface Session {
    userId: String
    expires: Date
}

export const encrypt = async (payload: Session) => {
    return new SignJWT(payload as any).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().setExpirationTime(payload.expires).sign(key)
}
export const decrypt = async (input: string) => {
    return jwtVerify<Session>(input, key, {
        algorithms: ['HS256']
    })
}
export const createSession = (userId: string): Session => {
    const expires = new Date(new Date().setDate(new Date().getDate() + 1));

    return {
        userId,
        expires
    }
}
export const updateSession = async (enryptedSession: string): Promise<Session> => {
    const parsed = await decrypt(enryptedSession)

    return {
        ...parsed.payload,
        expires: new Date(new Date().setDate(new Date().getDate() + 1))
    }
}
```

Our session will be considered valid one day after creation, let's make it so that the session expiration time is updated with each request and we can use the application every day without losing the task list too often. 

To do this, let's write middleware that updates the session.
`src/middleware.ts`
```ts
import { NextResponse, type NextRequest } from 'next/server'
import { encrypt, updateSession } from './session'

export async function middleware(req: NextRequest) {
    const res = NextResponse.next()
    const session = req.cookies.get('session')?.value
    if (!session) {
        return res
    }

    try {
        const updatedSession = await updateSession(session)
        res.cookies.set({
            name: 'session',
            value: await encrypt(updatedSession),
            expires: updatedSession.expires,
            httpOnly: true
        })
    } finally {
        return res
    }
}
```

<center><b>Result</b></center>

![Demo GIF](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jbkhk5qmbg5s6u8qjsey.gif)

That's all for the functional part. In the next part, we'll give it a little bit prettier look and add some animations too. Thank you for creating part one with me. If you have any questions or suggestions please leave them in the comments.

The full code is available at [GitHub](https://github.com/Gerpea/todo-ai)