In this article, you'll build a task manager application that has some artificial intelligence capabilities and is voice-enabled, sortable, and searchable.

As an extra, the application will have dark mode support that respects the users' system preferences.

The application will be able to create, read, update and delete (CRUD) tasks as well as the ability to view a given task.

You'll build this application using Appwrite as a backend, React on the frontend, Typescript for type safety and Tailwind CSS for styling.

Table of Contents

Prerequisites

You will need the following to be able to build along with this article:

  • Basic programming knowledge
  • Basic understanding of React, Typescript and Tailwind
  • An Appwrite account
  • And a text editor to code along

What is Appwrite?

Appwrite is an open source Backend-as-a-Service (BaaS) platform. A BaaS is a cloud service that packages backend tasks that are typically needed for most applications.

Appwrite offers both a managed database, authentication, functions and storage services and the ability to self-host the entire platform on your own.

Appwrite recently announced a host of new features that makes developers building on their platform lives more straightforward. You can read on that here.

How to Set Up the Appwrite Backend

Before starting to build the application and interacting with Appwrite, you'll need an Appwrite account and to set up the project.

Once you have the account ready, you will need to create an organization, then create a project within that organization. You can name the project "Tasks App" or any other name you see fit.

Note: Appwrite cloud restricts you to one organization per account on the hobby/free plan. If you already had an organization, you can go straight to creating a project within your existing organization.

In your Tasks App project, add a web platform and follow the prompts. For the hostname, add "localhost" for now. This is to allow the frontend to bypass CORS when interacting with the Appwrite backend.

Image A picture of the Appwrite Console showing the "Add a platform" section

Copy the installation instructions as you complete setting up the web platform for the project. Keep these safe, you will need them when setting up the frontend.

You should now be in the Appwrite cloud console. Click on the "Databases" on the left sidebar. Then click on the pink "Create database" button. Name your database and leave the autogenerated ID as is.

Now, click on the "Create collection" button, name your collection "tasks" and leave the autogenerated ID as is. Now, click on the grey "Create attribute" button as shown below:

Image A picture of the Appwrite Console showing the "Create attribute" button

Add the following attributes:

  • title of type String, give it a size of 49 and make it required
  • description of type String, give it a size of 200
  • due_date of type Datetime, make it required
  • done of type Boolean, give it a default of False
  • priority of type String, give it a size of 10

Finally, you need to set permissions in order for your React frontend to interact with Appwrite services. In this case, allow any one to have access. This is not ideal for production and you can read more about Appwrite permissions here.

Go to the console, click on databases, then your task database and then your tasks collection, then click on settings and scroll down to permissions. Add permissions for "Any" role and give them full CRUD access.

You are now ready to start setting up the frontend and to connect it to the Appwrite project you just completed prepping.

How to Set up the React Frontend

Open your text editor to your preferred location. Then open the integrated terminal and run the following command to create a Vite-based application:


//taskwrite is the name of the application
npm create vite@latest taskwrite

Choose React and then plain Typescript when prompted. This will create a React application with Typescript already set up for you.

Change folders into the newly created "taskwrite" one by running cd taskwrite from the terminal. Run the following command in the same terminal window to add Tailwind to the application:


npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Then in your tailwind.config.js file which is in the root of the Taskwrite application, replace the "content" key with content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],. The file should look like this:


/** @type {import('tailwindcss').Config} */
export default {
    content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
    theme: {
        extend: {},
    },
    plugins: [],
};

This tells Tailwind that it should look for its classes in the index.html file at the root and in files within the src folder that end with either .js, .ts, .jsx or .tsx extensions.

Then open the src folder and delete the "App.css" file. Open the index.css file and replace its contents with the following:


@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Quicksand:wght@300..700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
    :root{
        --base-bg: #ffffff;
        --btn-bg-primary: #be185d;
        --btn-bg-primary-hover: #9d174d;
        --btn-icon-main: #1e293b;
        --btn-bg-ok: #4ade80;
        --btn-bg-light-ok: #bbf7d0;
        --btn-bg-light: #e5e7eb;
        --low-priority: #facc15;
        --medium-priority: #fb923c;
        --high-priority: #f87171;
        --text-error: #dc2626;
        --text-ok: #16a34a;
        --text-main: #262626;
        --border-container: #9ca3af;
        --border-input: #1e293b;
        --border-error: #dc2626;
    }

    body{
        background-color: var(--base-bg);
        color: var(--text-main);    
    }

    #date::-webkit-calendar-picker-indicator {
        background-color: var(--btn-bg-light); 
    }
}

This adds some custom css variables to the application. The variables map to Tailwind colors.

Next, paste the following into the tailwind.config.js at the root of the application:


/** @type {import('tailwindcss').Config} */
export default {
    content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
    theme: {
        extend: {
            textColor: {
                error: "var(--text-error)",
                ok: "var(--text-ok)",
                main: "var(--text-main)",
                iconColor: "var(--btn-icon-main)",
            },
            backgroundColor: {
                base: "var(--base-bg)",
                primary: "var(--btn-bg-primary)",
                primaryHover: "var(--btn-bg-primary-hover)",
                ok: "var(--btn-bg-ok)",
                lightOk: "var(--btn-bg-light-ok)",
                light: "var(--btn-bg-light)",
                lowPriority: "var(--low-priority)",
                mediumPriority: "var(--medium-priority)",
                highPriority: "var(--high-priority)",
            },
            borderColor: {
                container: "var(--border-container)",
                input: "var(--border-input)",
                error: "var(--border-error)",
            },
        },
    },
    plugins: [],
};

This ties the CSS variables to the tailwind config and makes them available to use in our application.

Now Taskwrite is set up with React, Typescript and Tailwind.

How to Connect to the Appwrite Project

Firstly, you need to add the Appwrite dependency to the React application. Run the following command in the terminal window to do that: npm i appwrite.

Next thing is to set up the Appwrite keys we need as environment variables. In the .gitignore file at the root of the application, add *.env at the top of the file then save. This will ensure that the .env file you'll create is not added to version control.

Now, create a .env file at the root of the React application and paste the following variables in it:


//replace the right hand side of the equal sign with the correct values from your Appwrite project.
VITE_APPWRITE_URL=YOUR-APPWRITE-API-ENDPOINT
VITE_APPWRITE_PROJ_ID=YOUR-APPWRITE-PROJECT-ID

You can get the necessary values in your Appwrite console. Click on the settings tab at the bottom of the left sidebar and copy API credentials.

Next, create a utils folder in the src folder of the React application. Add a file called appwrite.ts within it and paste the following config information:


import { Client, Databases } from "appwrite";

export const client = new Client();

client
    .setEndpoint(import.meta.env.VITE_APPWRITE_URL)
    .setProject(import.meta.env.VITE_APPWRITE_PROJ_ID);

export const databases = new Databases(client);

export { ID } from "appwrite";

You are ready to test that the React application is connected to the Appwrite project. Replace everything in the App.tsx file within the src folder with the following code:


import { client } from "./utils/appwrite";

const App = () => {
    console.log("Appwrite", client);
    return <div className="text-purple-500 text-center font-bold text-      5xl">App</div>;
};

export default App;

Then open an integrated terminal window and run the following command: npm run dev. This will run your React application at this URL: http://localhost:5173/. Open the URL in a browser window and open the browser console.

You should see a large purple text "App" in the center of the screen and the Appwrite client logged in the console like so:

Image The web app running in the browser

Now you need to grab the database ID and the collection ID from the Appwrite console. Click on the databases tab on the left sidebar, hover on the database ID value and click to copy it.

Go back to your .env file and add an entry like so:


//replace the right hand side of the equal sign with the correct values from your Appwrite project.
VITE_APPWRITE_URL=YOUR-APPWRITE-API-ENDPOINT
VITE_APPWRITE_PROJ_ID=YOUR-APPWRITE-PROJECT-ID
//new entry below
VITE_APPWRITE_DB_ID=YOUR-APPWRITE-DB-ID

Lastly, go back to the console and click through the database to get to the collections. Hover and copy collection ID like before, then add it just below the database ID in your env file like so:


VITE_APPWRITE_DB_ID=YOUR-APPWRITE-DB-ID
VITE_APPWRITE_COLLECTION_ID=YOUR-APPWRITE-COLLECTION-ID

With that, the set up part of building Taskwrite is complete.

How to Build the Task Manager Application

To make it easier to work with Typescript, you will need to add interfaces that correspond with the shape of the Appwrite database response.

In your src folder, create a folder called models and in it, create a file called interface.ts. Paste the following in the file:


import { Models } from "appwrite";

export interface IPayload {
    title: string;
    description: string;
    due_date: Date;
    priority?: string;
    done?: boolean;
}

export interface ITask extends Models.Document {
    title: string;
    description: string;
    due_date: Date;
    priority?: string;
    done: boolean;
}

Here, you are defining an interface called "IPayload" with the same attributes as the task we defined in the Appwrite project. Then you are defining another interface called "ITask" that extends the built in base Model from Appwrite.

This means that ITask has both the attributes of the task we defined before and the built in base attributes that Appwrite collections come with.

Next, in your utils folder add a file called db.ts and paste the following in it:


import { ID, databases } from "./appwrite";
import { IPayload } from "../models/interface";

const dbID: string = import.meta.env.VITE_APPWRITE_DB_ID;
const collectionID: string = import.meta.env.VITE_APPWRITE_COLLECTION_ID;

const createDocument = async (payload: IPayload) => {
    const res = await databases.createDocument(dbID, collectionID, ID.unique(), {
        ...payload,
    });

    return res;
};

const readDocuments = async () => {
    const res = await databases.listDocuments(dbID, collectionID);

    return res;
};

const updateDocument = async (payload: IPayload, id: string) => {
    const res = await databases.updateDocument(dbID, collectionID, id, {
        ...payload,
    });

    return res;
};
const deleteDocument = async (id: string) => {
    const res = await databases.deleteDocument(dbID, collectionID, id);

    return res;
};

export { createDocument, readDocuments, updateDocument, deleteDocument };

This file defines four functions corresponding with the CRUD operations. The naming of the functions map to which operation it performs. For all functions, you pass the collection and database IDs so that Appwrite knows which resources to operate on.

To create a task on the Appwrite database, you pass an object with the shape of a task to the function and ask it to create a unique ID for each new task it creates.

To update a task, you pass it a task object similar to create but we also pass it the unique ID of the task to be updated.

To read all tasks from the Appwrite, you call the "listDocuments" function and to delete a task you pass the ID corresponding to the task to be deleted.

How to Set Up Routing with React Router V6

The Taskwrite application will have two routes and a navigation menu to help with that. To add navigation, open an integrated terminal and run the following command to install the React Router library: npm i react-router-dom.

Now, go to the main.tsx file in the src folder and paste the following in it:


import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.tsx";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
    <React.StrictMode>
        <BrowserRouter>
            <App />
        </BrowserRouter>
    </React.StrictMode>
);

Then go to the App.tsx file in src folder and paste the following in it:


import "./index.css";
import { Route, Routes } from "react-router-dom";
import Task from "./routes/Task";
import Index from "./routes/Index";
import Navbar from "./components/Navbar";

function App() {
    return (
        <>
            <Navbar/>
            <Routes>
                <Route path="/" element={<Index />} />
                <Route path="/tasks" element={<Task />} />
            </Routes>
        </>
    );
}

export default App;

You now need to create the referenced component above. In the src folder, create a folder called routes and within it create two files called Index.tsx and Task.tsx.

In Index.tsx, paste the following:


const Index = () => {
    return (
        <main className="container mx-auto">
            <section className="max-w-5xl mx-auto m-12 p-16">
                <h1 className="text-4xl md:text-7xl font-bold text-center py-3 mb-16">
                    AI-enhanced, Voice-enabled, Searchable Task Manager
                </h1>
            </section>
        </main>
    );
};

export default Index;

And in Task.tsx, paste the following:


const Task = () => {
    return (
        <main className="container mx-auto">
            <section className="max-w-5xl mx-auto m-12 p-16">
                <h1 className="text-4xl md:text-7xl font-bold text-center py-3 mb-16">
                    Your Tasks
                </h1>
            </section>
        </main>
    );
};

export default Task;

Now, create a components folder in the src folder and add a file within it called Navbar.tsx. Paste the following in that file:


import { Link, useNavigate } from "react-router-dom";
import { PencilIcon } from "@heroicons/react/24/solid";
import Button from "./Button";

const Navbar = () => {
    const navigate = useNavigate();

    return (
        <nav className="py-4 border-b-2 border-container shadow-md shadow-gray-400 w-full fixed top-0 bg-base">
            <ul className="flex items-center justify-between  w-11/12 mx-auto">
                <Link to="/">
                    <Button
                        content={{
                            text: "Taskwrite",
                            icon: PencilIcon,
                        }}
                        textClasses="font-semibold text-main"
                        iconClasses="text-main"
                    />
                </Link>
                </Link>
                <div className="flex items-center justify-between gap-6">
                    <Link
                        to="/tasks"
                        className="font-semibold hover:scale-105 transition duration-300 ease-in-out"
                    >
                        View Tasks
                    </Link>
                </div>
            </ul>
        </nav>
    );
};

export default Navbar;

This file contains navigation menu that switches between the two pages. You will need to create the Button component referenced above and add the Hero icons package.

In an integrated terminal, run the following to add Hero icons: npm i @heroicons/react . Next, add a new file called Button.tsx in the components folder. Paste the following within that file:

import { ReactNode } from "react";

interface ButtonProps {
    extraBtnClasses?: string;
    textColor?: string;
    handleClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
    title?: string;
    disable?: boolean;
    type?: "button" | "submit" | "reset";
    children: ReactNode;
}

function Button({
    extraBtnClasses,
    textColor,
    handleClick,
    title,
    disable,
    type = "button",
    children,
}: ButtonProps) {
    const handleClickProp = type === "submit" ? undefined : handleClick;

    return (
        <button
            type={type}
            title={title ?? ""}
            onClick={handleClickProp}
            disabled={disable}
            className={`flex gap-2 items-center text-iconColor ${extraBtnClasses} ${
                textColor ?? ""
            } rounded-md px-2 py-1 hover:scale-105 transition duration-300 ease-in-out`}
        >
            {children}
        </button>
    );
}

export default Button;

This file describes a shared button component and defines the props that it will accept.

Go back and fix any import errors and re-run the application by running npm run dev, you should see something like this:

Image app running with npm run dev command

How to Create the Form Component

Add a new file called AddTask.tsx in the components folder and paste the following into it:


import { useState } from "react";
import Select from "./Select";
import Button from "./Button";

const AddTask = () => {
    const [titleVal, setTitleVal] = useState("");
    const [textAreaVal, setTextAreaVal] = useState("");
    const [dueDate, setDueDate] = useState(new Date());

    const priorityArray = ["low", "medium", "high"];

    const [priority, setPriority] = useState(priorityArray[0]);

    return (
        <form id="form" className="m-8">
            <div className="flex flex-col mb-6">
                <label htmlFor="title">Task Title</label>
                <input
                    type="text"
                    id="title"
                    placeholder="Title of your task"
                    value={titleVal}
                    onChange={(e) => setTitleVal(e.target.value)}
                    className="bg-inherit border rounded-sm p-2 focus:outline-none focus:ring-1 border-input focus:ring-slate-900"
                />
            </div>
            <div className="flex flex-col mb-6">
                <label htmlFor="description" className="mb-1">
                    Task Description
                </label>
                <textarea
                    id="description"
                    placeholder="Describe your task"
                    maxLength={200}
                    value={textAreaVal}
                    onChange={(e) => setTextAreaVal(e.target.value)}
                    className="bg-inherit border rounded-sm p-2 h-32 resize-none focus:outline-none focus:ring-1 border-input focus:ring-slate-900"
                />
            </div>
            <div className="flex flex-col mb-6">
                <label htmlFor="description" className="mb-1">
                    Task Priority
                </label>
                <Select
                    defaultSelectValue={priority}
                    selectOptions={priorityArray}
                    handleSelectChange={(e) => setPriority(e.target.value)}
                />
            </div>
            <div className="flex flex-col mb-6">
                <label htmlFor="description" className="mb-1">
                    Task Due Date
                </label>
                <input
                    type="date"
                    id="date"
                    value={dueDate!.toISOString().split("T")[0]}
                    min={new Date().toISOString().split("T")[0]}
                    onChange={(e) => setDueDate(new Date(e.target.value))}
                    className="bg-inherit border rounded-sm border-input p-2 focus:outline-none focus:ring-1 focus:ring-slate-900 invalid:focus:ring-red-600"
                />
            </div>
            <Button
                type="submit"
                content={{
                    text: "Add Task",
                }}
                extraBtnClasses="bg-pink-700 justify-center text-white font-semibold px-4 py-2 outline-1 hover:bg-pink-800 focus:ring-1 focus:ring-pink-800 w-full"
            />
        </form>
    );
};

export default AddTask;

Now create a new file in components called Select.tsx, paste the following in it:


import { useState } from "react";

interface SelectProps {
    defaultSelectValue: string;
    selectOptions: string[];
    handleSelectChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
}

const Select = ({
    defaultSelectValue,
    handleSelectChange,
    selectOptions,
}: SelectProps) => {
    const [selectVal, setSelectVal] = useState(defaultSelectValue);
    return (
        <select
            value={selectVal}
            onChange={(e) => {
                setSelectVal(e.target.value);
                handleSelectChange(e);
            }}
            className="bg-inherit border rounded-sm border-input p-2 focus:outline-none focus:ring-1 focus:ring-slate-900 cursor-pointer"
        >
            {selectOptions.map((option) => (
                <option key={option} value={option}>
                    {option.charAt(0).toUpperCase() + option.slice(1)}
                </option>
            ))}
        </select>
    );
};

export default Select;

This defines a Select component and its props. The Select component props are a function for handling change, an array of options and the default value it should display.

Now, import the AddTask component in the Index.tsx file between the h1 tags like so:


import AddTask from "../components/AddTask";

const Index = () => {
    return (
        <main className="container mx-auto">
            <section className="max-w-5xl mx-auto m-12 p-16">
                <h1 className="text-4xl md:text-7xl font-bold text-center py-3 mb-16">
                    AI-enhanced, Voice-enabled, Searchable Task Manager
                </h1>
                <AddTask />
            </section>
        </main>
    );
};

export default Index;

Your application should now display the form:

Image task app form

How to Set Up Form to Create Task

To make the form functional, you need to hook it up to a submit function that will call the create function defined in the db.ts file.

Additionally, you will need to validate the form to avoid sending bad data and having Appwrite send errors back to the React application.

In the AddTask component file, paste the following code above the return statement and below the setPriority useState:


const [priority, setPriority] = useState(priorityArray[0]);

//paste here
const navigate = useNavigate();

const [isSubmitting, setIsSubmitting] = useState(false);
const [titleValidationError, setTitleValidationError] = useState("");

    const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        setTitleVal(e.target.value);

        if (e.target.value.trim() !== "") {
            setTitleValidationError("");
        }
    };

    const handleSubmitTask = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        setIsSubmitting(true);

        try {
            if (!titleVal) {
                setTitleValidationError("Please provide at least a title for the task");
                setTimeout(() => setTitleValidationError(""), 2000);
                setIsSubmitting(false);
                return;
            }

            if (titleVal.length > 49) {
                setTitleValidationError(
                    "Title too long. It can only be 49 characters long"
                );
                setTimeout(() => setTitleValidationError(""), 2000);
                setIsSubmitting(false);
                return;
            }

            const payload: IPayload = {
                title: titleVal,
                description: textAreaVal,
                due_date: dueDate,
                priority: priority,
            };

            await createDocument(payload);

            // reset form
            setTitleVal("");
            setTextAreaVal("");
            setDueDate(new Date());
            setPriority(priorityArray[0]);
            setTitleValidationError("");
            setIsSubmitting(false);
            navigate("/tasks");
        } catch (error) {
            console.error("Error in handleSubmitTask:", error);
            setIsSubmitting(false);
        }
    };

    return (
    //rest of the code unchanged below

Then replace the return statement with the following code:


return (
<form id="form" onSubmit={handleSubmitTask} className="m-8">
    <div className="flex flex-col mb-6">
        <label htmlFor="title">Task Title</label>
        <input
            type="text"
            id="title"
            placeholder="Title of your task"
            value={titleVal}
            onChange={handleTitleChange}
            className={`bg-inherit border rounded-sm p-2 focus:outline-none                         focus:ring-1 ${
                    titleValidationError
                    ? "border-error focus:ring-red-500 invalid:focus:ring-red-                         600"
                    : "border-input focus:ring-slate-900"
            }`}
        />
        {titleValidationError && (
        <span className="text-error mt-1">{titleValidationError}</span>
        )}
    </div>
    <div className="flex flex-col mb-6">
        <label htmlFor="description" className="mb-1">
            Task Description
        </label>
        <textarea
            id="description"
            placeholder="Describe your task"
            maxLength={200}
            value={textAreaVal}
            onChange={(e) => setTextAreaVal(e.target.value)}
            className={`bg-inherit border rounded-sm p-2 h-32 resize-none                             focus:outline-none focus:ring-1 ${
                        textAreaVal.length > 197
                        ? "border-error focus:ring-red-500 invalid:focus:ring-                             red-600"
                        : "border-input focus:ring-slate-900"
                }`}
        />
        {textAreaVal.length > 197 && (
        <span className="text-error mt-1">
            Warning description getting too long. Can only be 200 characters
        </span>
        )}
    </div>
    <div className="flex flex-col mb-6">
        <label htmlFor="description" className="mb-1">
            Task Priority
        </label>
        <Select
            defaultSelectValue={priority}
            selectOptions={priorityArray}
            handleSelectChange={(e) => setPriority(e.target.value)}
        />
    </div>
    <div className="flex flex-col mb-6">
        <label htmlFor="description" className="mb-1">
            Task Due Date
        </label>
        <input
            type="date"
            id="date"
            value={dueDate!.toISOString().split("T")[0]}
            min={new Date().toISOString().split("T")[0]}
            onChange={(e) => setDueDate(new Date(e.target.value))}
                className="bg-inherit border rounded-sm border-input p-2                                      focus:outline-none focus:ring-1 focus:ring-slate-                               900 invalid:focus:ring-red-600"
        />
    </div>
    <Button
        type="submit"
        disable={isSubmitting}
        extraBtnClasses="bg-primary justify-center text-white font-semibold px-4 py-2 outline-1 hover:bg-primaryHover focus:ring-1 focus:ring-pink-800 w-full"
    >
        <span>
            Add Task
        </span>
    </Button>
</form>
);

Fix import errors and your application should now be validating title and description as well as creating the task then sending you to the "/tasks" route. You can check the Appwrite console to confirm that the task has been created.

How Set Up Read and Delete Tasks

Open the Task.jsx file in the routes folder within src folder, and add the following code above the return like so:


const [tasks, setTasks] = useState<ITask[]>([]);
const [tasksError, setTasksError] = useState("");

useEffect(() => {
        getTasks()
        .then((res) => {
        setTasks(res.reverse());
        })
        .catch((err) => {
        console.error(err);
        setTasksError("Error fetching tasks, please try again");
        });
    }, []);

return (
//rest of code

Here, the file is setting some local state using useState to hold the tasks and set any potential task related errors.

Now replace the code in the return with the following code:



<main className="container mx-auto">
    <section className="max-w-5xl mx-auto m-12 p-16">
        <h1 className="text-4xl md:text-7xl font-bold text-center py-3 mb-16">
        Your Tasks
        </h1>
        {tasksError ? (
            <span className="m-8 text-error">{tasksError}</span>
        ) : (
            <div className="flex flex-col md:flex-row justify-between">
                <div className="flex-1">
                    <h3 className="text-2xl font-bold m-8">Pending Tasks</h3>
                    <div>
                         {tasks
                             .filter((task) => !task.done)
                             .map((task) => (
                                <TaskItem key={task.$id} task={task} />
                         ))}
                    </div>
                </div>
                <div className="flex-1">
                    <h3 className="text-2xl font-bold m-8">Completed Tasks</h3>
                    <div>
                        {tasks
                            .filter((task) => task.done)
                            .map((task) => (
                                <TaskItem key={task.$id} task={task} />
                        ))}
                    </div>
                </div>
            </div>
        )}
    </section>
</main>

You now need to create the getTasks() function and the TaskItem component. In the components folder, create a file called TaskItem.tsx and paste the following code in it:


interface TaskItemProps {
    task: ITask;
}
function TaskItem({ task }: TaskItemProps) {
    return (
    <>
        <div className="m-8 cursor-pointer border border-container rounded-md p-4 hover:shadow-lg transition duration-300 ease-in-out max-h-96">
            <section
            key={task.$id}
            className="flex flex-col justify-between gap-2 my-4 h-full"
            >
            <section className="flex gap-4 items-center justify-between flex-wrap">
                {task.priority && (
                <span>
                    <span className="font-medium">Priority: </span>
                        <span
                            className={`${
                            task.priority === "low"
                            ? "bg-lowPriority text-iconColor"
                            : task.priority === "medium"
                            ? "bg-mediumPriority text-iconColor"
                            : "bg-highPriority text-iconColor"
                            } py-1 px-2 rounded-md`}
                        >
                            {task.priority}
                        </span>
                </span>
                )}
                <div className="flex gap-2 py-1 ml-auto">
                    <Button
                        handleClick={() => handleEdit(task)}
                        extraBtnClasses="bg-ok"
                    >
                        <span className="font-medium">Edit</span>
                        <PencilSquareIcon height={25} className="hidden lg:flex" />
                    </Button>
                    <Button
                        handleClick={(e) => handleDelete(e, task.$id)}
                        extraBtnClasses="bg-highPriority"
                    >
                        <span className="font-medium">Delete</span>
                        <TrashIcon height={25} className="hidden lg:flex" />
                    </Button>
                </div>
            </section>
            <section className="">
                <h2 className="text-xl font-medium py-2 break-words">
                    {task.title}
                </h2>
                <p className="py-1 mb-4 min-h-16 break-words">
                    {task.description.length > 70
                        ? task.description.substring(0, 70) + "..."
                        : task.description}
                </p>
                <span className="font-extralight mt-2">
                    <span className="font-medium">Due on: </span>
                        <span className="underline">{`${new Date(
                            task.due_date
                        ).toLocaleDateString()}`}
                    </span>
                </span>
                </section>
                <section className="flex justify-between">
                    {task.done ? (
                        <span className="items-center text-ok font-bol ml-auto">
                            Completed
                        </span>
                    ) : (
                    <div className="flex items-center ml-auto hover:scale-105 transition duration-300 ease-in-out">
                        <label htmlFor="done" className="mr-2 font-light">
                            Mark as complete
                        </label>
                        <input
                            type="checkbox"
                            checked={isDone}
                            onClick={(e) => e.stopPropagation()}
                            onChange={(e) => {
                            setIsDone(e.target.checked);
                            handleCheckbox(task, task.$id, e);
                        }}
                            className="size-5 accent-pink-600 rounded-sm"
                    />
                </div>
                )}
                </section>
            </section>
        </div>
    </>
    );
}
export default TaskItem;

This gives the file some markup to display. It divides the page into two columns, one for the pending tasks and one for the completed tasks, and it handles responsiveness of the page.

In order to get rid of the errors, paste the following code just before the return statement like so:


function TaskItem({ task }: TaskItemProps) {

//paste here
const [isDone, setIsDone] = useState(false);

const handleDelete = async (
        currentTaskId: string
    ) => {
        try {
            await deleteDocument(currentTaskId);
        } catch (error) {
            console.error(error);
        }
};

const handleCheckbox = async (
        currentTask: IPayload,
        id: string,
        checkedVal: boolean
    ) => {
        if (!checkedVal) return;

        const payload: IPayload = {
        title: currentTask.title,
        description: currentTask.description,
        due_date: currentTask.due_date,
        priority: currentTask.priority,
        done: checkedVal,
        };

        try {
            await updateDocument(payload, id);
        } catch (error) {
            console.error(error);
        }
};

//rest of code below untouched
return (
......

This adds the ability to delete a task item and the ability to mark it as complete.

Create a new file in the utils folder and call it shared.ts. This file will house any function that will be called in more than two places in the application.

The getTasks function is one such repetitive function, so it will be placed in the shared.ts file. Paste the following code into it:


import { readDocuments } from "./db";
import { ITask } from "../models/interface";

export const getTasks = async () => {
    const { documents } = await readDocuments();

    return documents as ITask[];
};

This defines the function and returns an array of ITasks. Go back to the Task.tsx file and fix any import errors.

Run the application and you should see something like this:

Image your pending and completed tasks displayed in the browser

The task can be deleted or marked as complete but you won't see an update on the UI until the page is refreshed. To fix that, go back to the TaskItem file and paste the following code below the isDone useState and above the handleDelete function:


const [isDone, setIsDone] = useState(false);

//paste here
const updateTasks = async () => {
    try {
        const allTasks = await getTasks();
        if (setTasks) setTasks(allTasks.reverse());
    } catch (error) {
        console.error(error);
    }
};

//rest of code below remains as is
const handleDelete = async (

Update TaskItem props interface and the TaskItem function like so:


interface TaskItemProps {
    task: ITask;
    setTasks?: (tasks: ITask[]) => void;
}

function TaskItem({ task, setTasks }: TaskItemProps) {
//rest of code below
    const [isDone, setIsDone] = useState(false);

This gives a setter function that resets the tasks array as a prop to the TaskItem component.

Adjust the handleDelete and handleCheckbox functions in the TaskItem component to include the updateTasks function you added above. it should read like this:


const handleDelete = async (
    e: React.MouseEvent<HTMLButtonElement>,
    currentTaskId: string
) => {
    try {
        await deleteDocument(currentTaskId);
        updateTasks();
    } catch (error) {
        console.error(error);
    }
};

const handleCheckbox = async (
    currentTask: IPayload,
    id: string,
    e: React.ChangeEvent<HTMLInputElement>
    ) => {

    const payload: IPayload = {
        title: currentTask.title,
        description: currentTask.description,
        due_date: currentTask.due_date,
        priority: currentTask.priority,
        done: e.target.checked,
    };

    try {
        await updateDocument(payload, id);
        updateTasks();
    } catch (error) {
        console.error(error);
    }
};

Go back to the Task.tsx file and pass setTasks to the TaskItem component like so: <TaskItem key={task.$id} task={task} setTasks={setTasks} />. Now, the UI updates without needing to manually refresh the page.

How to Make the Tasks Editable

In order to edit a task, you will need to pass a function to the edit button in the TaskItem component.

Paste the following code in the TaskItem.tsx file between the updateTasks and handleDelete functions like so:


//paste below updateTasks
const handleEdit = async (
    currentTask: ITask
) => {
    navigate("/", { state: { task: currentTask } });
};

//rest of code untouched below

Add this line right above the isDone useState: const navigate = useNavigate();.

In the same file, find the edit button and pass the handleEdit function to it. Also, wrap it in a condition that checks if the task is done such that the button is only displayed in the case that the task is not marked as complete. Like so:

{!task.done && (
    <Button
        handleClick={() => handleEdit(task)}
        extraBtnClasses="bg-ok"
    >
        <span className="font-medium">Edit</span>
        <PencilSquareIcon height={25} className="hidden lg:flex" />
    </Button>
)

The AddTask component has to be adjusted to handle editing a task and the Index.tsx file needs to be updated to handle the task to be edited being passed to it via the navigate handleEdit function.

First, go to the AddTask file and add some prop definitions directly below the import statements, then pass the new props to the component like so:

import....

// pass a task and an isEdit boolean
// if isEdit is true, then the form will be populated with the task's data
interface ITaskFormProps {
    task: ITask | null;
    isEdit?: boolean;
    setTasks?: (tasks: ITask[]) => void;
}

//pass component props
const AddTask = ({ task, isEdit, setTasks }: ITaskFormProps) => {
//code untouched below

Adjust the due date and priority useStates to read like below:


const [dueDate, setDueDate] = useState(
    isEdit && task?.due_date ? new Date(task.due_date) : new Date()
);

const [priority, setPriority] = useState(
    isEdit && task?.priority ? task?.priority : priorityArray[0]
);

Add a useEffect below the useStates like so:


const [titleValidationError, setTitleValidationError] = useState("");

//paste below useState statements
useEffect(() => {
    if (isEdit && task) {
        setTitleVal(task.title);
        setTextAreaVal(task.description);
    } else {
        setTitleVal("");
    }
}, [isEdit, task]);

In the handleSubmit function within the same AddTask file, delete this line: await createDocument(payload); and replace with the below:


if (isEdit && task) {
    await updateDocument(payload, task!.$id);
    const allTasks = await getTasks();
    if (setTasks) return setTasks(allTasks.reverse());
} else {
    await createDocument(payload);
}

Now replace the Button component at the bottom of the file just above the form closing tag with this:


<Button
    type="submit"
    disable={isSubmitting}
    extraBtnClasses="bg-primary justify-center text-white font-semibold px-4 py-2 outline-1 hover:bg-primaryHover focus:ring-1 focus:ring-pink-800 w-full"
>
    <span>
        {isSubmitting ? "Submitting..." : task ? "Edit Task" : "Add Task"}
    </span>
</Button>
//unchanged code below
</form>
    );
};

export default AddTask;

This sets the text on the button depending on whether the form is submitting, creating a new task or updating an exisiting task.

Go to the Index.tsx file in the routes folder and paste the following above the return statement:

const Index = () => {
    //paste here
    const location = useLocation();
    const navigate = useNavigate();

    const taskFromState: ITask = location.state?.task;

    const [taskToEdit] = useState<ITask | null>(taskFromState ?? null);

    useEffect(() => {
        if (taskFromState) {
            navigate(location.pathname, {});
        }
    }, [taskFromState, location.pathname, navigate]);

    //below code remains unchanged
    return (....

Here, the file gets the task passed to it from the "/tasks" route and sets it to local state. Then the useEffect nullies the passed task so that the form is reset on refresh.

In the Index.tsx file replace the AddTask component with this line: <AddTask task={taskToEdit} isEdit={taskToEdit ? true : false} />.

Run your application and you should be able to click on the edit button, be navigated back to the "/" route, have the form pre-filled with the task details, be able to edit some of the fields and be redirected back to "/tasks" once you click on the "Edit Task" button.

How to Enable Viewing of Tasks

The application now creates, reads, updates and deletes tasks. All that is left on that is the ability to view a particular task.

Go to the TaskItem file, add the following to the interface TaskItemProps: isViewTask: boolean; handleViewTask?: (e: React.MouseEvent<HTMLDivElement>) => void;.

Add them as props to the TaskItem component and set isViewTask to a default of false like so:


function TaskItem({
    task,
    setTasks,
    isViewTask = false,
    handleViewTask,
}: TaskItemProps) {    
    //rest of code below unchanged
    const navigate = useNavigate();

Replace the paragraph tag that displays the task description within the return of the component with this markup:


<p className="py-1 mb-4 min-h-16 break-words">
    {task.description.length > 70 && !isViewTask
        ? task.description.substring(0, 70) + "..."
        : task.description
    }
</p>

The change introduced will ensure that the full description is visible if isViewTask is set to true.

On the div tag just below the return in the same component, add an onClick handler like so:


return (
<>
    <div
        className="m-8 cursor-pointer border border-container rounded-md p-4                        hover:shadow-lg transition duration-300 ease-in-out max-h-                       96"
        onClick={handleViewTask}
    >
//rest unchanged code
...

Back in the Task.tsx file, paste the following just above the useEffect:


const handleViewTask = (
    e: React.MouseEvent<HTMLDivElement>,
    activeTask: ITask
) => {
    setIsViewTask(true);
    setSelectedTask(activeTask);
};

//unchanged code below
useEffect(...

Add the following useState functions above the handleViewTask function, below the other useStates:


const [tasksError, setTasksError] = useState("");
//paste here
const [isViewTask, setIsViewTask] = useState(false);
const [selectedTask, setSelectedTask] = useState<ITask>();

//code below unchanged
const handleViewTask = (...

Now paste the following code within the return statement in the same file, just above the h1 tag displaying "Your Tasks" text:


return (
<main className="container mx-auto">
    <section className="max-w-5xl mx-auto m-12 p-16">
        //paste here
        {isViewTask && selectedTask && (
            <Dialog key={selectedTask.$id} setIsViewTask={setIsViewTask}>
                <TaskItem
                    task={selectedTask}
                    handleViewTask={() => handleViewTask(selectedTask!)}
                    isViewTask={isViewTask}
                />
            </Dialog>
        )}
        <h1 className="text-4xl md:text-7xl font-bold text-center py-3 mb-16">
        Your Tasks
        </h1>
        //rest of code below remains unchanged

You will need to create the Dialog component. Create a new file in the components folder and call it Dialog.tsx, then paste the following in it:


import { XMarkIcon } from "@heroicons/react/24/solid";
import { ReactNode, useState } from "react";
import { ITask } from "../models/interface";
import Button from "./Button";

interface DialogProps {
    setIsViewTask?: (isViewTask: boolean) => void;
    children: ReactNode;
}

function Dialog({ setIsViewTask, children }: DialogProps) {
    const [isOpen, setIsOpen] = useState(true);

    const closeModal = () => {
        if (setIsViewTask) setIsViewTask(false);
        setIsOpen(false);
    };
    return (
        <dialog
            open={isOpen}
            id="modal"
            style={{
            backgroundColor: "var(--base-bg)",
            color: "var(--text-main)",
            }}
            className={`${
                isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
                } transition-opacity duration-300 ease-in-out fixed inset-0                      backdrop-filter backdrop-blur-md backdrop-brightness-50 w-4/6                      border border-container rounded-md max-h-[80vh] overflow-y-auto                 text-main`}
        >
        <Button
            handleClick={closeModal}
            content={{ text: "Close", icon: XMarkIcon }}
            extraBtnClasses="ml-auto text-main font-medium hover:text-error"
        />
        <div className="max-h-[80vh] overflow-y-auto">{children}</div>
        </dialog>
    );
    }

export default Dialog;

Here, the file defined a Dialog component that takes in some props, displays a button and the children it receives from props.

Finally, replace the two TaskItem components in the tasks.filter... function within the return statement of the Task.tsx file with the following:


{tasks
    .filter((task) => !task.done)
    .map((task) => (
        <TaskItem
            key={task.$id}
            task={task}
            setTasks={setTasks}
            handleViewTask={() => handleViewTask(task)}
            isViewTask={isViewTask}
        />
))}

You should be able to click on the task items and have the dialog pop up with the details of the task.

However, if you try to delete the item you will notice that it opens the dialog while deleting it. To fix that, adjust the handleDelete function in the TaskItem.tsx file to read like this:


const handleDelete = async (
    e: React.MouseEvent<HTMLButtonElement>,
    currentTaskId: string
) => {
    e.stopPropagation();
    try {
        await deleteDocument(currentTaskId);
        if (isViewTask) {
            navigate(0);
        } else {
            updateTasks();
        }
    } catch (error) {
        console.error(error);
    }
};

You've added e.stopPropagation() to stop the event from bubbling up to the parent and potentially interfering with the click to open the dialog.

You've also added a check after deleting the task to see if the task is being viewed, in which case we refresh the page via the navigate(0) to force the UI to update to the proper state. If not, it proceeds to call updateTasks() to refresh the state.

You will notice the same issue when you try to mark the task as complete in which the dialog pops up. To fix this, adjust the checkbox input to include this line: onClick={(e) => e.stopPropagation()}.

The new line stops the event from bubbling up to the parent div. It is added to the onClick instead of the onChange because the event it is trying to intercept is of type onClick. The input should read like this:


<label htmlFor="done" className="mr-2 font-light">
    Mark as complete
</label>
<input
    type="checkbox"
    checked={isDone}
    onClick={(e) => e.stopPropagation()}
    onChange={(e) => {
    setIsDone(e.target.checked);
    handleCheckbox(task, task.$id, e);
    }}
    className="size-5 accent-pink-600 rounded-sm"
/>

At this point, the React application is responsive, can CRUD the Appwrite database and the user can view individual tasks.

How to Auto Generate Descriptions with Vercel's AI SDK

To enhance the application and its' user experience, you can add the ability to auto generate descriptions for the tasks using AI.

To get started, open an integrated terminal and run the following command: npm i ai. This adds the Vercel AI SDK to the React application.

Next, run this command in the terminal: npm i @huggingface/inference to add Hugging Face support. The application will use Hugging Face because you need to pay to get programmatic access to OpenAI.

Create a new file in the utils folder, call it ai.ts and paste the following in it:


import { HfInference } from "@huggingface/inference";
import { HuggingFaceStream, StreamingTextResponse } from "ai";

// Create a new HuggingFace Inference instance
const Hf = new HfInference(import.meta.env.VITE_HUGGINGFACE_KEY);

// IMPORTANT! Set the runtime to edge
export const runtime = "edge";

export const callAI = async (prompt: string) => {
    const response = Hf.textGenerationStream({
        model: "OpenAssistant/oasst-sft-4-pythia-12b-epoch-3.5",
        inputs: `<|prompter|>${prompt}<|endoftext|><|assistant|>`,
        parameters: {
            max_new_tokens: 150,
            // @ts-ignore
            typical_p: 0.2,
            repetition_penalty: 1,
            truncate: 1000,
            return_full_text: false,
        },
    });

    // Convert the response into a friendly text-stream
    const stream = HuggingFaceStream(response);

    // Respond with the stream
    return new StreamingTextResponse(stream);
};

This boilerplate creates an instance of Hugging Face, creates a function that takes in a prompt, passes that prompt to the textGenerationStream function. Then it converts the response to a stream and to a text stream.

You will need to add a Hugging Face access token. You can genrate one at this address. You will need an account before you can access it.

Once you have token, open the env file and add to it the following line


//replace with your actual token
VITE_HUGGINGFACE_KEY=YOUR-HF-ACCESS-TOKEN

Open the AddTask.tsx file and paste the following button just above the closing div tag of the div containing the textarea input:


{textAreaVal.length > 197 && (
    <span className="text-error mt-1">
        Warning description getting too long. Can only be 200 characters
    </span>
)}
//paste here
<Button
    handleClick={generateDesc}
    disable={isGenerating}
    extraBtnClasses="bg-light mt-2 w-fit ml-auto"
>
    <span>Generate description</span>
    <SparklesIcon height={20} />
</Button>
//rest of below code unchanged
</div>

Define generateDesc function just above the return statement in the AddTask file like so:


const generateDesc = async () => {
    setTextAreaVal("");

    if (!titleVal) {
    alert("Please provide a title for the task");
    return;
    }

    setIsGenerating(true);

    const prompt = `Provide a description for this task: ${titleVal}. Keep the description to a maximum of 30 words`;

    try {
        const res = await callAI(prompt);
        const responseText = await res.text();

        setIsGenerating(false);

        //create a typing effect
        responseText.split("").forEach((char, index) => {
        setTimeout(() => {
        setTextAreaVal((prevText) => prevText + char);
        }, index * 32);
        });
    } catch (error) {
        console.log("ERROR HUGGING FACE API: " + error);
    }
};

The function checks that the title is not empty and uses the title to create a prompt to pass on the Hugging Face helper. The response from the call is saved to local state. A simple typing effect is created as the textarea is populated with the response.

Next, add this useState: const [isGenerating, setIsGenerating] = useState(false); to the other useStates in the AddTask component.

Replace the textarea input in the same component with the following:


<textarea
    id="description"
    placeholder="Describe your task"
    maxLength={200}
    value={isGenerating ? "generating..." : textAreaVal}
    onChange={(e) => setTextAreaVal(e.target.value)}
    className={`bg-inherit border rounded-sm p-2 h-32 resize-none          focus:outline-none focus:ring-1 ${
    textAreaVal.length > 197
    ? "border-error focus:ring-red-500 invalid:focus:ring-red-600"
    : "border-input focus:ring-slate-900"
    }`}
/>

On checking the application, you should see the button and be able to generate a description for the title of a task as below:

Image ai-enhanced task description

Voice-enable the Application with the React Speech Recognition Package

First, you need to add the dependency and its Typescript helper by running the following commands in an integrated terminal window: npm i react-speech-recognition and npm i @types/react-speech-recognition.

Additionally, run the following command to get the dependency to work properly: npm i regenerator-runtime.

Create a hooks folder within the src folder. Create a file within it called useSpeechToTextHelper.ts and paste the following in it:


import "regenerator-runtime/runtime";
import { useState } from "react";
import { useSpeechRecognition } from "react-speech-recognition";

export function useSpeechToTextHelper() {
    const [error, setError] = useState("");

    const {
        transcript,
        listening,
        resetTranscript,
        browserSupportsSpeechRecognition,
    } = useSpeechRecognition();

    if (!browserSupportsSpeechRecognition) {
        setError("Browser doesn't support speech recognition.");
    }

    return {
        error,
        listening,
        transcript,
        resetTranscript,
    };
}

This hook exposes some built in helper functions from React Speech Recognition, handles the case that the browser does not support the relevant web APIs and returns some of that data.

Create a new file in the components folder called Speaker.tsx. Paste the following code into it:


import { useSpeechToTextHelper } from "../hooks/useSpeechToTextHelper";
import { MicrophoneIcon, XCircleIcon } from "@heroicons/react/24/solid";
import Button from "./Button";
import SpeechRecognition from "react-speech-recognition";

interface SpeakerProps {
handleClear: (e: React.MouseEvent<HTMLButtonElement>) => void;
}

function Speaker({ handleClear }: SpeakerProps) {
    const { listening, error } = useSpeechToTextHelper();

    const handleSpeech = () => {
        SpeechRecognition.startListening();
    };

    return (
    <div>
        {error && <div>{error}</div>}
        <div className="flex gap-2 py-1 items-center text-center justify-center">
            <span className="font-medium">{listening ? "Mic on" : "Mic off"}</span>
            <Button
                handleClick={handleSpeech}
                extraBtnClasses="bg-lightOk"
                title="Start"
            >
                <MicrophoneIcon height={25} />
            </Button>
            <Button
                handleClick={handleClear}
                extraBtnClasses="bg-light"
                type="reset"
                title="Reset"
            >
                <XCircleIcon height={25} />
            </Button>
        </div>
    </div>
    );
}

export default Speaker;

This components accepts a function to clear the voice input as a prop, uses the helper hook, defines a function to handle the actual speech, handles potential error state and displays a button for handling the speech and another for clearing the voice transcript.

In the AddTask file, delete the label for title and replace it with the following markup:


<div className="flex flex-row justify-between items-center">
    <label htmlFor="title">Task Title</label>
    <Speaker handleClear={clearTranscript} />
</div>

This adds the Speaker component and wraps both the label and the Speaker component in a representational div in order to maintain the form layout.

Add the clearTranscript function just above the handleSubmit function like so:


const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setTitleVal(e.target.value);

    if (e.target.value.trim() !== "") {
        setTitleValidationError("");
    }
};

//paste here
const clearTranscript = () => {
    resetTranscript();
};
//below code is unchanged
const handleSubmitTask = asyc....

Next in the same AddTask component, add the following:


const AddTask = ({ task, isEdit, setTasks }: ITaskFormProps) => {
    const navigate = useNavigate();
    //paste here
    const { transcript, resetTranscript } = useSpeechToTextHelper();
    //rest remains unchanged

Replace the useEffect in the file with this new one:


useEffect(() => {
    if (isEdit && task && !transcript) {
        setTitleVal(task.title);
        setTextAreaVal(task.description);
    } else {
        setTitleVal(transcript || "");
    }
}, [isEdit, task, transcript]);

Your application should now support creating titles for the tasks via voice inputs. And should look something like this:

Image voice input functionality added to the form

How to Add Search Functionality to the Application

To increase ease of use, it is useful to have some search functionality in the application.

To start, open up the Appwrite console. Click into your collections, click on Indexes tab then click on the "Create index" button.

Leave the index key as is, select FullText in the index type dropdown. Add title attribute and create the index. Repeat the process for the description attribute.

In your application, open db.ts file in utils folder and paste the following function just above the export keyword, then add it to the list of exports:


const searchTasks = async (searchTerm: string) => {
    const resTitle = await databases.listDocuments(dbID, collectionID, [
                        Query.search("title", searchTerm),
                    ]);
    const resDesc = await databases.listDocuments(dbID, collectionID, [
                        Query.search("description", searchTerm),
                     ]);
    const res = [...resTitle.documents, ...resDesc.documents];

    return res;
};

export {
    createDocument,
    readDocuments,
    updateDocument,
    deleteDocument,
    searchTasks,
};

Create a new file in the components folder, calll it Search.tsx. Paste the following into it:

import { FormEvent, useState } from "react";
import { ITask } from "../models/interface";
import Dialog from "./Dialog";
import TaskItem from "./TaskItem";
import Button from "./Button";
import { searchTasks } from "../utils/db";

const Search = () => {
const [searchTerm, setSearchTerm] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [searchedTasks, setSearchedTasks] = useState<ITask[]>([]);
const [error, setError] = useState("");

    return (
        <div className="flex flex-col w-full md:w-1/2">
            <form
                className="flex flex-col md:flex-row items-start md:items-center gap-2"
                onSubmit={handleSubmit}
            >
                {searchedTasks.length > 0 && (
                    <Dialog setSearchedTasks={setSearchedTasks}>
                        {searchedTasks.map((task: ITask) => (
                            <TaskItem key={task.$id} task={task} isViewTask={true} />
                        ))}
                    </Dialog>
                )}
            <input
                aria-roledescription="search"
                type="text"
                id="search"
                placeholder="search your tasks..."
                value={searchTerm}
                onChange={(e) => setSearchTerm(e.target.value)}
                className={`bg-inherit w-5/6 border rounded-md p-2 focus:outline-none focus:ring-1 ${
                error
                ? "border-error focus:ring-red-500 invalid:focus:ring-red-600"
                : "border-input focus:ring-slate-900"
                }`}
            />
            <Button
            type="submit"
            extraBtnClasses="bg-primary text-white hover:bg-primaryHover font-medium text-main py-2"
            >
                <span>{isSearching ? "Searching..." : "Search"}</span>
            </Button>
            </form>
            <span className="text-error font-medium mt-1">{error}</span>
        </div>
    );
};

export default Search;

The new Search component creates some local state and returns a form with an input and search button. It also opens the dialog when it has search results.

Add this handleSubmit function above the return statement like so:


const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!searchTerm) {
        setError("No search term entered");
        setTimeout(() => {
        setError("");
        }, 3000);
        return;
    }

    setIsSearching(true);

    const res = await searchTasks(searchTerm);
    console.log("res search: ", res);
    if (res.length === 0) {
        setIsSearching(false);
        setError("No task found");
        setTimeout(() => {
        setSearchTerm("");
        setError("");
        }, 3000);
        return;
    }
    setIsSearching(false);
    setSearchedTasks(res as ITask[]);
};

This function sets an error if no search term is received, then it attempts to call the database search function passing it the search term. If successful it sets the tasks to local state and if not, it catches the error.

Click into the dialog component and replace its props with the following, then pass the setSearchedTasks to it like so:


interface DialogProps {
    setIsViewTask?: (isViewTask: boolean) => void;
    setSearchedTasks?: (tasks: ITask[]) => void;
    children: ReactNode;
}

function Dialog({ setIsViewTask, setSearchedTasks, children }: DialogProps) {...

Replace the closeModal function in the dialog component with this snippet:


const closeModal = () => {
    if (setIsViewTask) setIsViewTask(false);
    //this is the new line
    if (setSearchedTasks) setSearchedTasks([]);
    setIsOpen(false);
};

Go back into the Task.tsx file and paste this below the h1 tag that displays the "Your Tasks" text:


<h1 className="text-4xl md:text-7xl font-bold text-center py-3 mb-16">
Your Tasks
</h1>
//paste here
<div className="m-8 flex flex-col-reverse md:flex-row gap-8 items-start                     md:items-center md:justify-between">
    <Search />
    <Button
        handleClick={() => navigate("/")}
        extraBtnClasses="bg-primary text-white font-medium py-2 hover:bg-    primaryHover ml-auto"
    >
        <span>Add Task</span>
        <PlusIcon height={25} className="hidden md:flex" />
    </Button>
</div>
//rest of code stays unchanged

This adds the search component and a button that takes you back to the Index page when clicked.

Add the following within the Task component just below the useStates:


const [selectedTask, setSelectedTask] = useState<ITask>();
//paste here
const navigate = useNavigate();
//all below remain unchanged
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>)

You can now test your search functionality. It works but has one bug: if the search term is present in the both the title and the description we get back two search results.

To fix that, modify the searchTasks function in db.ts to filter out duplicate tasks by IDs like so:


const searchTasks = async (searchTerm: string) => {
    const resTitle = await databases.listDocuments(dbID, collectionID, [
        Query.search("title", searchTerm),
    ]);
    const resDesc = await databases.listDocuments(dbID, collectionID, [
        Query.search("description", searchTerm),
    ]);

    const res = [...resTitle.documents, ...resDesc.documents];

    // remove duplicate tasks
    const uniqueRes = res.filter(
        (task, index, self) => index === self.findIndex((t) => t.$id ===                  task.$id)
    );

    return uniqueRes;
};

Now your search should work as expected and should look like this:

Image your pending and completed tasks displayed in the browser

How to Add Ability to Sort Tasks via Due Date and Priority

The application will only sort the pending tasks as it makes the most sense. It will sort by due date from the earliest date to the latest and vice versa. It will also sort by priority from lowest to highest and vice versa.

To get started, paste the following in the Tasks.tsx file right below the h3 tag with the "Pending Tasks" text like so:


<h3 className="text-2xl font-bold m-8">Pending Tasks</h3>
//paste here
<div className="m-8 flex items-start lg:items-center gap-1 justify-between flex-col lg:flex-row">
    <span className="font-medium">Sort Tasks by: </span>
    <Select
        defaultSelectValue={selectArray[0]}
        handleSelectChange={handleSelectChange}
        selectOptions={selectArray}
    />
</div>

Then paste the following array that will contain the options for select component above. Paste it right above handleViewTask function like so:


const navigate = useNavigate();
//paste here
const selectArray = [
    "priority - (low - high)",
    "priority - (high - low)",
    "due date - (earliest - latest)",
    "due date - (latest - earliest)",
];
//rest remains unchanged
const handleViewTask = (...

Add the handleSelectChange and the sort functions above the selectArray, like so:


const sortByPriority = (tasksList: ITask[], isAsc: boolean): ITask[] => {
    const priorityOrder: { [key: string]: number } = {
    low: 1,
    medium: 2,
    high: 3,
    };

    return [...tasksList].sort((a, b) => {
    const priorityA = priorityOrder[a.priority!.toLowerCase()];
    const priorityB = priorityOrder[b.priority!.toLowerCase()];
    return isAsc ? priorityA - priorityB : priorityB - priorityA;
    });
};

const handleSelectChange = async (
    e: React.ChangeEvent<HTMLSelectElement>
) => {
    const selectedOption = e.target.value;
    const doneTasks = tasks.filter((task) => task.done);

    switch (selectedOption) {
        case "priority - (low - high)":
        case "priority - (high - low)": {
            const isAsc = selectedOption === "priority - (low - high)";
            const sortedTasks = sortByPriority(tasks, isAsc);
            setTasks([...doneTasks, ...sortedTasks.filter((task) =>                           !task.done)]);
            break;
        }
        case "due date - (earliest - latest)":
        case "due date - (latest - earliest)": {
            const isEarliestToLatest =
            selectedOption === "due date - (earliest - latest)";
            const dueDateResult = await sortByDueDate(isEarliestToLatest);
            const sortedTasks = dueDateResult.documents as ITask[];
            setTasks([...doneTasks, ...sortedTasks.filter((task) =>                            !task.done)]);
            break;
        }
        default:
            break;
        }
};

//below remains unchanged
const selectArray = .....

The sortByPriority function creates an object whose keys map to the priority array and gives them numerical values. This makes it easier to sort as it is hard to tell which string is higher priority without that.

The handleSelectChange function picks out the selected option and filters the tasks to get the completed ones. It does matching logic in the switch statements, calling sortByPriority for the cases where the user is trying to do that and it calls sortByDueDate for the rest of the cases.

sortByDueDate is defined in the db.ts file. Open it and paste the following at the bottom of the file above the exports. Then add it to the exports list like so:


const sortByDueDate = async (isEarliestToLatest: boolean) => {
    const orderQuery = isEarliestToLatest
        ? Query.orderAsc("due_date")
        : Query.orderDesc("due_date");
    const res = await databases.listDocuments(dbID, collectionID,              [orderQuery]);
    return res;
};

export {
    createDocument,
    readDocuments,
    updateDocument,
    deleteDocument,
    searchTasks,
    sortByDueDate,
};

This function leverages Appwrites' Query methods to sort the date string according to the Boolean that is passed to it.

Going back to your application, run it to test the sorting functionality. The application should be sorted and the sorting should only apply to the pending tasks.

Bonus: How to Add Dark Mode Support

The last thing left is to add Dark Mode support that respects the users' systems setting.

For this, open the tailwind.config.ts file and replace its contents with the following:


/** @type {import('tailwindcss').Config} */
export default {
    content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
    darkMode: "selector",
    theme: {
        extend: {
            textColor: {
                error: "var(--text-error)",
                ok: "var(--text-ok)",
                main: "var(--text-main)",
                iconColor: "var(--btn-icon-main)",
            },
            backgroundColor: {
                base: "var(--base-bg)",
                primary: "var(--btn-bg-primary)",
                primaryHover: "var(--btn-bg-primary-hover)",
                ok: "var(--btn-bg-ok)",
                lightOk: "var(--btn-bg-light-ok)",
                light: "var(--btn-bg-light)",
                lowPriority: "var(--low-priority)",
                mediumPriority: "var(--medium-priority)",
                highPriority: "var(--high-priority)",
            },
            borderColor: {
                container: "var(--border-container)",
                input: "var(--border-input)",
                error: "var(--border-error)",
            },
        },
    },
    plugins: [],
};

This extends the tailwind preset colors and ties the CSS variables that were set in the index.css file to the Tailwind config.

In the index.css file, add this dark class below the date class like so:


#date::-webkit-calendar-picker-indicator {
    background-color: var(--btn-bg-light); 
}
//paste here
.dark{
    --base-bg: #262626;
    --text-main: #ffffff;
    --text-error: #fca5a5;
    --text-ok: #86efac;
    --border-input: #e2e8f0;
    --border-error: #fca5a5;
}

This changes some of the CSS variables values when the dark class is applied.

Now, open the navbar file in the components folder and replace its contents with the following:


const Navbar = () => {
    const navigate = useNavigate();

    const themeArray = ["light", "dark", "system"];
    const [theme, setTheme] = useState(() => {
        return localStorage.getItem("theme") || themeArray[2];
    });

    const applyTheme = (selectedTheme: string) => {
        const isDarkModePreferred = window.matchMedia(
            "(prefers-color-scheme: dark)"
            ).matches;

        document.documentElement.classList.remove("light", "dark");
        document.documentElement.classList.add(selectedTheme);

        if (selectedTheme === "system") {
        document.documentElement.classList.toggle("dark", isDarkModePreferred);
        document.documentElement.classList.toggle("light",                  !isDarkModePreferred);
        }
    };

    const handleSelectTheme = (e: React.ChangeEvent<HTMLSelectElement>) => {
        const selectedTheme = e.target.value;
        setTheme(selectedTheme);

        // Store the selected theme in localStorage
        localStorage.setItem("theme", selectedTheme);
    };

    useEffect(() => {
        applyTheme(theme);
    }, [theme]);

    return (
        <nav className="py-4 border-b-2 border-container shadow-md shadow-gray-400 w-full fixed top-0 bg-base">
            <ul className="flex items-center justify-between  w-11/12 mx-auto">
                <Link to="/">
                    <Button>
                        <span className="font-semibold text-main">Taskwrite</span>
                        <PencilIcon height={20} className="text-main" />
                    </Button>
                </Link>
                <div className="flex items-center justify-between gap-6">
                <Link
                    to="/tasks"
                    className="font-semibold hover:scale-105 transition duration-300 ease-in-out"
                >
                    View Tasks
                </Link>
                <div className="flex gap-2 items-center">
                    <span className="font-semibold"> Theme: </span>
                    <Select
                        defaultSelectValue={theme}
                        selectOptions={themeArray}
                        handleSelectChange={handleSelectTheme}
                    />
                </div>
                </div>
            </ul>
        </nav>
    );
};

export default Navbar;

Your application should now have a select in the navigation menu that successfully toggles between dark and light themes while defaulting to the system preferences when set to "System".

It should look like so:

Image Taskwrite complete interface and functionalities

And Taskwrite is complete! You have successfully built a task manager application that is AI-enhanced, voice-enabled, searchable and sortable using React and Appwrite.

Notes

Appwrite recently announced some new features that would greatly simplify the search functionality above but, at the time of writing, these changes were not rolled out to their cloud offering.

The application could be further simplified by using state management solutions and this will be added to it in subsequent articles.

The application is live here.

Limitations

The following are some known limitations and issues with this application:

  • The navigation menu is not responsive
  • The application has no tests written
  • The permissions set for Appwrite are permissive and not recommended for production environments
  • The application could leverage Appwrites' Realtime capabilites for a smoother experience
  • The application could do with push notifications to remind the user when the task due date is coming up

That said, the application is going to continue being improved and worked on. You can follow along with that on GitHub. All contributions and improvements on the codebase are welcome. Please star the repository while you are at it.