An NLP featured To-do List app in Next.js
Efficiently Organising Your Tasks with NLP: A To-Do List Application with automatic deadline extractor
A to-do list application is one of the simplest and most basic applications that any beginner web developer must build in order to learn and apply the basic concepts of web development. This tutorial focuses on building and deploying a to-do list application with a tiny twist of using NLP packages from npm
to create a production-ready to-do list application featuring NLP capability and using local-storage
of the browser to make the content persist upon reloads. This NLP package will help us get the deadline from the task text itself without having the user select it manually.
In this project, I am using is Next.js
, TailwindCSS
, and daisyui
for developing the application's frontend. I am using chrono-node
for extracting date and time from the task text. Further, I am using yarn
as a package manager for this project. So, without any further ado let's begin...
Step 1: Setting up the project
This should be the first step in any project, howsoever small it be. First let's create an example Next.js
application. I am using VS Code and its integrated terminal. If you don't have yarn
, run
sudo npm install --global yarn
After successful yarn
installation, create an example Next.js
application inside an empty directory (folder for windows).
yarn create next-app ./
We will use JS in this project for development, so make sure you choose No
to every prompt while this command is executing.
Now, you can run yarn dev
in the terminal and go to the localhost:3000 to see if this works properly. You will see a standard Next.js
screen.
Now let's install tailwindcss
and daisyui
plugin. I would recommend following this page from tailwindcss
site to help you configure tailwindCSS. Also run yarn add daisyui
to add this plugin and tell tailwind about it by require it in tailwind.config.js
file. Finally this file becomes
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
fontFamily: {
inter: ["Inter", "sans-serif"],
montserrat: ["Montserrat", "serif"],
russo: ["Russo One", "monospace"],
},
extend: {},
},
plugins: [require("daisyui")],
daisyui: {
themes: false,
},
};
I have also defined some font-names which we'll use in this project. Now, remove everything from pages/index.js
file inside main
tag.
Make Sure You have this structure in pages/index.js
file
import Head from "next/head";
import Image from "next/image";
const inter = Inter({ subsets: ["latin"] });
export default function Home() {
return (
<>
<Head>
<title>TodoFirst</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" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter&family=Montserrat:ital,wght@0,300;0,400;0,500;1,200&family=Russo+One&display=swap"
rel="stylesheet"
/>
</Head>
<main className="text-2xl flex justify-center">
<div className="">
<div>Todo</div>
<div>Card</div>
</div>
</main>
</>
);
}
Make sure the `styles/Home.module.css` file looks like this
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
And your `_app.js` looks like this
import '@/styles/globals.css'
import 'tailwindcss/tailwind.css' //add this import here.
export default function App({ Component, pageProps }) {
return <Component {...pageProps} />
}
Now, that you've completed the project setup, let's move to the next step.
Step 2: Logic for add, remove, complete features
Let's start with adding the functions for adding new task, deleted tasks, and marking the task as complete.
Now, we want to display the current time in our front-end. So, let's create a file `utils/date_time.js` and make sure it looks like this:
export default function getClockInfo() {
const days = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const currentDate = new Date();
const day = days[currentDate.getDay()];
const date = currentDate.getDate();
const time = currentDate.toLocaleTimeString().slice(0, 8);
return { day, date, time };
}
Also, we want the time format to be 12-hour instead of 24-hour, so let's create another file in `utils/to12Hours.js`, which takes "hh:mm" and converts it into AM or PM format time.
function to12Hours(time24) {
if (time24 == "undefined") return "undefined"
let hour = parseInt(time24);
let minutes = time24?.slice(3, 5);
if (hour == 12) {
return `12:${minutes} PM`;
} else if (hour == 0) {
return `12:${minutes} AM`;
} else if (hour < 12) {
return hour + `:${minutes} AM`;
} else {
return hour - 12 + `:${minutes} PM`;
}
}
export { to12Hours };
Now let's add some frontend code and some functions in pages/index.js
file. Also, you can find the images that I have used from my GitHub repo.
import Head from "next/head";
import getClockInfo from "../utils/date_time";
import { useState, useEffect } from "react";
import { day, date, time } from "../utils/date_time";
import TodoItem from "./components/TodoItem";
import Image from "next/image";
export default function Home() {
// removing the hydration error
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
const [input, setInput] = useState("");
let [todos, setTodos] = useState([]);
//changing the clock time
const [time, setTime] = useState(getClockInfo().time);
const [day, setDay] = useState(getClockInfo().day);
const [date, setDate] = useState(getClockInfo().date);
setInterval(() => {
setTime(getClockInfo().time);
setDay(getClockInfo().day);
setDate(getClockInfo().date);
}, 1000);
const handleSubmit = (e) => {
if (input == "") return;
const todoItem = {
id: Date.now(),
deadline: "1674244809",
task: taskString,
completed: false,
};
setTodos([...todos, todoItem]);
setInput("");
};
const handleKeyUp = (e) => {
if (e.keyCode == 13) {
handleSubmit(e);
}
};
const handleDelete = (id) => {
setTodos(todos.filter((todo) => todo.id != id));
};
const handleCompleted = (id) => {
let todosCopy = [...todos];
for (let i = 0; i < todosCopy.length; i++) {
if (todosCopy[i].id == id) {
todosCopy[i].completed = !todosCopy[i].completed;
}
}
setTodos(todosCopy);
};
if (!hydrated) return null;
return (
<>
<Head>
<title>TodoFirst</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" className="rounded-full" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter&family=Montserrat:ital,wght@0,300;0,400;0,500;1,200&family=Russo+One&display=swap"
rel="stylesheet"
/>
</Head>
<main className="flex h-screen flex-col items-center sm:flex-row justify-around">
<div className="flex flex-col items-center md:mt-0 mt-20 mb-10 md:mb-0 md:ml-20 justify-center">
<Image
src="/images/todoicon.png"
alt="To Do Icon"
width={120}
height={120}
className="rounded-full w-40 mb-5"
/>
<div className="mx-auto font-montserrat text-3xl md:text-6xl text-blue-600 font-black md:ml-6">
TodoFirst
</div>
</div>
<div className="mx-auto font-inter">
<div className="card w-96 bg-base-100 shadow-xl">
<figure>
<Image
src="/images/todoimage.jpg"
alt="Abstract Image"
width={500}
height={500}
className="z-40 h-56 w-80 rounded-xl shadow-md blur-xs"
/>
<div className="absolute right-12 top-36 z-50 font-inter text-gray-700">
<div className="text-md text-end font-russo">
{day} {date}
</div>
<div className="font-russo text-4xl">{time}</div>
</div>
</figure>
<div className="card-body">
<div>
<div>
<div className="flex flex-row">
<input
type="text"
className="input input-bordered w-3/4 input-md font-montserrat ml-3"
onChange={(e) => {
setInput(e.target.value);
}}
onKeyUp={handleKeyUp}
value={input}
autoFocus={true}
/>
<button
className="btn btn-primary btn-md font-montserrat ml-3 py-2"
onClick={handleSubmit}
>
+
</button>
</div>
<div className="mt-3 overflow-y-auto h-56">
{todos.length > 0 ? (
todos.map((todoItem, index) => (
<div
className="flex flex-row justify-between items-center "
key={index}
>
<TodoItem
key={index}
todoItem={todoItem}
handleCompleted={handleCompleted}
handleDelete={handleDelete}
/>
</div>
))
) : (
<div className="font-montserrat text-gray-600 text-center pt-4">
Wow Such Empty ๐ค
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</>
);
}
You can note that in the above code we've added many event handler callback functions, prefixed with handle
. Our todoItem
is an object with following properties:
const todoItem = {
id: Date.now(), // id to recognize each task uniquely
deadline: "1674244809", // time in unix timestamp
task: "Task", // what is the task string
completed: false, // Is the task completed?
};
Other functions are pretty simple to understand. If you face any difficulty, please ask in the comments.
The below code snipped inside `pages/index.js` is used to remove any [hydration error](https://nextjs.org/docs/messages/react-hydration-error) that will be coming while displaying the time (with precision of seconds) in our application.
// removing the hydration error
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
The below code snippet gives us the modified day
, date
and time
every second using `setInterval` method.
//changing the clock time
const [time, setTime] = useState(getClockInfo().time);
const [day, setDay] = useState(getClockInfo().day);
const [date, setDate] = useState(getClockInfo().date);
setInterval(() => {
setTime(getClockInfo().time);
setDay(getClockInfo().day);
setDate(getClockInfo().date);
}, 1000);
If you see any errors coming, in the [localhost:3000](localhost:3000), either try to debug the code, or best continue reading this article.
Now, let's create a TodoItem
component that we've mentioned above, in `pages/index.js`.
import { to12Hours } from "../../utils/to12Hours";
export default function TodoItem(props) {
return (
<div className="flex flex-ro mt-1 items-center px-2 py-1 rounded-lg">
<div className="ml-3">
<div
className={
props.todoItem?.completed
? "line-through text-gray-700 w-52 text-md capitalize"
: "w-52 text-md capitalize"
}
>
{props.todoItem?.task == "" ? "Empty Task" : props.todoItem?.task}
</div>
<div className="text-xs text-gray-400">
{props.todoItem?.deadline?.toString().slice(0, 15) || "No deadline"}{" "}
{props.todoItem?.deadline && to12Hours(props.todoItem.deadline?.toLocaleTimeString().slice(0, 5))}
</div>
</div>
<input
type="checkbox"
className="checkbox checkbox-warning ml-3 checkbox-sm"
onClick={() => {
props.handleCompleted(props.todoItem.id);
}}
/>
<div
className="text-red-700 items-center ml-4"
onClick={() => {
props.handleDelete(props.todoItem.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</div>
</div>
);
}
Step 3: Add deadline extractor feature
We'll use [chrono-node](https://www.npmjs.com/package/chrono-node) npm library to add this feature to our application. This library standalone will not be able to remove stop-words
like for
, a
, in
, at
, from
, etc, so we use another npm package - `stopword` to remove custom stop words. I have also specified some stop words. You can add more stop words if you prefer to.
We'll modify our pages/index.js
file using the below code snippet:
const handleSubmit = (e) => {
if (input == "") return;
// NLP parsing
const referenceDate = new Date();
const parsedResults = chrono.parse(input, referenceDate, {
forwardDate: true,
});
//removing stop words from the task name
const oldTaskStrings = input.slice(0, parsedResults[0]?.index).split(" ");
const newTaskString = removeStopwords(oldTaskStrings, [
"by",
"BY",
"By",
"The",
"the",
"THE",
"a",
"A",
"from",
"From",
"FROM",
"at",
"AT",
"At",
"in",
"IN",
"In",
"for",
"For",
"FOR",
"to",
"To",
"TO",
"I",
"i",
"want",
"Want",
"WANT",
]);
const taskString = newTaskString.join(" ");
const todoItem = {
id: Date.now(),
deadline: parsedResults[0]?.start.date(),
task: taskString,
completed: false,
};
setTodos([...todos, todoItem]);
setInput("");
};
The modified pages/index.js
file will now become:
import Head from "next/head";
import getClockInfo from "../utils/date_time";
import { useState, useEffect } from "react";
import TodoItem from "./components/TodoItem";
import * as chrono from "chrono-node";
import { removeStopwords } from "stopword";
import Image from "next/image";
export default function Home() {
// removing the hydration error
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
const [input, setInput] = useState("");
let [todos, setTodos] = useState([]);
//changing the clock time
const [time, setTime] = useState(getClockInfo().time);
const [day, setDay] = useState(getClockInfo().day);
const [date, setDate] = useState(getClockInfo().date);
setInterval(() => {
setTime(getClockInfo().time);
setDay(getClockInfo().day);
setDate(getClockInfo().date);
}, 1000);
const handleSubmit = (e) => {
if (input == "") return;
// NLP parsing
const referenceDate = new Date();
const parsedResults = chrono.parse(input, referenceDate, {
forwardDate: true,
});
//removing stop words from the task name
const oldTaskStrings = input.slice(0, parsedResults[0]?.index).split(" ");
const newTaskString = removeStopwords(oldTaskStrings, [
"by",
"BY",
"By",
"The",
"the",
"THE",
"a",
"A",
"from",
"From",
"FROM",
"at",
"AT",
"At",
"in",
"IN",
"In",
"for",
"For",
"FOR",
"to",
"To",
"TO",
"I",
"i",
"want",
"Want",
"WANT",
]);
const taskString = newTaskString.join(" ");
const todoItem = {
id: Date.now(),
deadline: parsedResults[0]?.start.date(),
task: taskString,
completed: false,
};
setTodos([...todos, todoItem]);
setInput("");
};
const handleKeyUp = (e) => {
if (e.keyCode == 13) {
handleSubmit(e);
}
};
const handleDelete = (id) => {
setTodos(todos.filter((todo) => todo.id != id));
};
const handleCompleted = (id) => {
let todosCopy = [...todos];
for (let i = 0; i < todosCopy.length; i++) {
if (todosCopy[i].id == id) {
todosCopy[i].completed = !todosCopy[i].completed;
}
}
setTodos(todosCopy);
};
if (!hydrated) return null;
return (
<>
<Head>
<title>TodoFirst</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" className="rounded-full" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter&family=Montserrat:ital,wght@0,300;0,400;0,500;1,200&family=Russo+One&display=swap"
rel="stylesheet"
/>
</Head>
<main className="flex h-screen flex-col items-center sm:flex-row justify-around">
<div className="flex flex-col items-center md:mt-0 mt-20 mb-10 md:mb-0 md:ml-20 justify-center">
<Image
src="/images/todoicon.png"
alt="To Do Icon"
width={120}
height={120}
className="rounded-full w-40 mb-5"
/>
<div className="mx-auto font-montserrat text-3xl md:text-6xl text-blue-600 font-black md:ml-6">
TodoFirst
</div>
</div>
<div className="mx-auto font-inter">
<div className="card w-96 bg-base-100 shadow-xl">
<figure>
<Image
src="/images/todoimage.jpg"
alt="Abstract Image"
width={500}
height={500}
className="z-40 h-56 w-80 rounded-xl shadow-md blur-xs"
/>
<div className="absolute right-12 top-36 z-50 font-inter text-gray-700">
<div className="text-md text-end font-russo">
{day} {date}
</div>
<div className="font-russo text-4xl">{time}</div>
</div>
</figure>
<div className="card-body">
<div>
<div>
<div className="flex flex-row">
<input
type="text"
className="input input-bordered w-3/4 input-md font-montserrat ml-3"
onChange={(e) => {
setInput(e.target.value);
// console.log(e.target.value);
}}
onKeyUp={handleKeyUp}
value={input}
autoFocus={true}
/>
<button
className="btn btn-primary btn-md font-montserrat ml-3 py-2"
onClick={handleSubmit}
>
+
</button>
</div>
<div className="mt-3 overflow-y-auto h-56">
{todos.length > 0 ? (
todos.map((todoItem, index) => (
<div
className="flex flex-row justify-between items-center "
key={index}
>
<TodoItem
key={index}
todoItem={todoItem}
handleCompleted={handleCompleted}
handleDelete={handleDelete}
/>
</div>
))
) : (
<div className="font-montserrat text-gray-600 text-center pt-4">
Wow Such Empty ๐ค
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</>
);
}
Now, our application is ready with a basic to-do app, but it has one deal breaker disadvantage. As soon, as we reload the page, the tasks automatically goes away. This is not very useful. This happens because we are storing this information in RAM memory allocated to browser. That's why when we reload the page, all the data is cleaned and we get the application in the initialized empty state. In the next step we'll try to eliminate this shortcoming.
Step 4: Storing data in local storage of Browser
You can read more about local storage [here](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage).
We can use local storage of the browser to store small amounts of user data. However, you should never store any sensitive data in local storage unencrypted. I don't want this article to be much long so I am ignoring this security blunder. However, if you need this security feature, you can ask in the comment.
We will have to slightly modify, our add
, delete
and complete
handler functions and also modify our initialization of `todos` array to look for local storage data first if present any then display it when the first component loads.
So, our pages/index.js
will be modified and finally becomes:
import Head from "next/head";
import getClockInfo from "../utils/date_time";
import { useState, useEffect } from "react";
import TodoItem from "./components/TodoItem";
import * as chrono from "chrono-node";
import { removeStopwords } from "stopword";
import Image from "next/image";
export default function Home() {
// removing the hydration error
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
// Because we want the browser's localStorage object and window object is not defined in server-side environment.
const ISSERVER = typeof window === "undefined";
const [input, setInput] = useState("");
let [todos, setTodos] = useState(
!ISSERVER && localStorage.getItem("todos")
? JSON.parse(localStorage.getItem("todos"))
: []
);
//changing the clock time
const [time, setTime] = useState(getClockInfo().time);
const [day, setDay] = useState(getClockInfo().day);
const [date, setDate] = useState(getClockInfo().date);
setInterval(() => {
setTime(getClockInfo().time);
setDay(getClockInfo().day);
setDate(getClockInfo().date);
}, 1000);
const handleSubmit = (e) => {
if (input == "") return;
// NLP parsing
const referenceDate = new Date();
const parsedResults = chrono.parse(input, referenceDate, {
forwardDate: true,
});
//removing stop words from the task name
const oldTaskStrings = input.slice(0, parsedResults[0]?.index).split(" ");
const newTaskString = removeStopwords(oldTaskStrings, [
"by",
"BY",
"By",
"The",
"the",
"THE",
"a",
"A",
"from",
"From",
"FROM",
"at",
"AT",
"At",
"in",
"IN",
"In",
"for",
"For",
"FOR",
"to",
"To",
"TO",
"I",
"i",
"want",
"Want",
"WANT",
]);
const taskString = newTaskString.join(" ");
const todoItem = {
id: Date.now(),
deadline: parsedResults[0]?.start.date(),
task: taskString,
completed: false,
};
setTodos([...todos, todoItem]);
setInput("");
localStorage.setItem("todos", JSON.stringify([...todos, todoItem]));
};
const handleKeyUp = (e) => {
if (e.keyCode == 13) {
handleSubmit(e);
}
};
const handleDelete = (id) => {
setTodos(todos.filter((todo) => todo.id != id));
localStorage.setItem(
"todos",
JSON.stringify(todos.filter((todo) => todo.id != id))
);
// console.log("deleted item with id: ", id);
// console.log(todos);
};
const handleCompleted = (id) => {
let todosCopy = [...todos];
for (let i = 0; i < todosCopy.length; i++) {
if (todosCopy[i].id == id) {
todosCopy[i].completed = !todosCopy[i].completed;
}
}
setTodos(todosCopy);
localStorage.setItem("todos", JSON.stringify(todosCopy)); };
if (!hydrated) return null;
return (
<>
<Head>
<title>TodoFirst</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" className="rounded-full" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter&family=Montserrat:ital,wght@0,300;0,400;0,500;1,200&family=Russo+One&display=swap"
rel="stylesheet"
/>
</Head>
<main className="flex h-screen flex-col items-center sm:flex-row justify-around">
<div className="flex flex-col items-center md:mt-0 mt-20 mb-10 md:mb-0 md:ml-20 justify-center">
<Image
src="/images/todoicon.png"
alt="To Do Icon"
width={120}
height={120}
className="rounded-full w-40 mb-5"
/>
<div className="mx-auto font-montserrat text-3xl md:text-6xl text-blue-600 font-black md:ml-6">
TodoFirst
</div>
</div>
<div className="mx-auto font-inter">
<div className="card w-96 bg-base-100 shadow-xl">
<figure>
<Image
src="/images/todoimage.jpg"
alt="Abstract Image"
width={500}
height={500}
className="z-40 h-56 w-80 rounded-xl shadow-md blur-xs"
/>
<div className="absolute right-12 top-36 z-50 font-inter text-gray-700">
<div className="text-md text-end font-russo">
{day} {date}
</div>
<div className="font-russo text-4xl">{time}</div>
</div>
</figure>
<div className="card-body">
<div>
<div>
<div className="flex flex-row">
<input
type="text"
className="input input-bordered w-3/4 input-md font-montserrat ml-3"
onChange={(e) => {
setInput(e.target.value);
// console.log(e.target.value);
}}
onKeyUp={handleKeyUp}
value={input}
autoFocus={true}
/>
<button
className="btn btn-primary btn-md font-montserrat ml-3 py-2"
onClick={handleSubmit}
>
+
</button>
</div>
<div className="mt-3 overflow-y-auto h-56">
{todos.length > 0 ? (
todos.map((todoItem, index) => (
<div
className="flex flex-row justify-between items-center "
key={index}
>
<TodoItem
key={index}
todoItem={todoItem}
handleCompleted={handleCompleted}
handleDelete={handleDelete}
/>
</div>
))
) : (
<div className="font-montserrat text-gray-600 text-center pt-4">
Wow Such Empty ๐ค
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</>
);
}
I have deployed this application at [todonlp.vercel.app](todonlp.vercel.app) and the code for this application is open source at [todofirst-nlp](https://github.com/sadityakumar9211/todofirst-nlp).
If you're getting the error, while accessing the site, clear you're browser cookies.
If you face any difficulty, please comment below. I will be there to reply.
Adios ๐