In this video, we'll dive into the development of a todo list feature for the productivity app Increaser. We'll cover a range of topics, including backend setup, responsive UI design, drag-and-drop functionality, visualization of completed tasks, and deadline management. Our aim is to build this feature without relying on external component libraries, demonstrating how to create a custom solution from scratch. While the Increaser codebase is private, we've made all reusable components, utilities, and hooks available in the RadzionKit repository. Throughout this video, we'll provide detailed explanations of Increaser-specific code, giving you insights into how we approach this development challenge.

Before we dive into the technical details, let's take a moment to outline the features we aim to include in our todo list and the reasons behind them. Increaser is a productivity platform designed for remote workers, offering tools like a focus timer, habit tracker, time tracker, and scheduler. By adding a todo list feature, we're aiming to enhance its functionality, making Increaser a comprehensive productivity hub. However, designing an effective todo list comes with its own set of challenges. Instead of replicating the extensive features of established todo list apps like Todoist, our focus is on identifying and refining core features that cater to the majority of use-cases. This approach allows us to focus on delivering a todo list that is both practical and user-friendly, aligning with the overall mission of Increaser to enhance productivity.

Here's a concise overview of the planned features:

First, we have deadline categorization, where tasks are divided into four groups: today, tomorrow, this week, and next week. This approach strikes a balance between user needs and UI simplicity, avoiding the complexity of more intricate scheduling systems.

Next, there's efficient task addition. The "Add task" button under each deadline category allows for quick task entry. Users can add tasks sequentially by pressing the "Enter" key, facilitating rapid task addition without unnecessary clicks.

Then, we have flexible task management. The interface design enables easy reassignment of tasks between different time frames and prioritization within categories, enhancing organizational flexibility.

We also provide mobile accessibility to facilitate task management on-the-go.

Another feature is automatic task cleanup. Completed tasks are moved to a separate section and automatically removed at the beginning of the next week, maintaining a clean interface.

Finally, we have streamlined task editing. Users can simply click on a task to edit its name, minimizing the need for additional interactions.

The backend setup, being the simplest part of our implementation, uses DynamoDB at Increaser. We have a users table, where each user's data is stored in a single item. Inside this item, the tasks attribute contains the todo list data, organized as a record of tasks, each with a unique ID. Using a record structure instead of a list allows for direct updates to tasks by their ID, avoiding the need to iterate through a list.

The Task entity has the following structure:

  • startedAt: The timestamp of when the task was created.
  • id: A unique identifier for the task.
  • name: The name of the task.
  • completedAt: The timestamp of when the task was completed, or null if it's incomplete.
  • deadlineAt: The timestamp of the task's deadline.
  • order: The order of the task within its category.

Increaser's API takes a unique approach, deviating from traditional RESTful and GraphQL paradigms. For those interested in backend development, this could be a fascinating area to explore, as discussed in our previous YouTube video. To manage tasks, we use three resolvers: createTask, updateTask, and deleteTask. Each resolver serves as a straightforward wrapper around the tasksDB module, which handles direct interactions with the database.

Take the updateTask resolver as an example. It's designed to accept an ID and a fields object containing the updates. Internally, the corresponding function within the tasksDB module retrieves the specific task from the user's item using a projection expression. It then merges the incoming fields with the existing task data before invoking the putTask function. This function is responsible for updating the task in the database by reassigning it with its unique ID.

The organizeTasks function is key to keeping the to-do list clean. It automatically filters out tasks completed before the current week's start. If this changes the task count, the user's tasks field in the database gets updated. To save resources and avoid extra costs from a cron job, this function triggers on a user query request.

Let's dive into the frontend implementation details. Our application, Increaser, is built as a Static Site Generation (SSG) application using NextJS. We've created a specific page for the to-do list feature, which you can access via the /tasks route.

To separate completed tasks from the active to-do list, we provide a seamless user experience by allowing users to toggle between these two views. We achieve this using the getViewSetup utility. This utility simplifies view state management by taking the default view and a name for the view as arguments. It returns a provider, a hook, and a render function. The provider makes the view state accessible throughout the component tree, while the hook allows components to access and update the current view. The render function dynamically displays the appropriate view based on the current state, ensuring efficient rendering. This setup streamlines the management of multiple views in the code, enhancing both the maintainability and readability of the view-related logic.

Our Tasks view supports two states: "todo" and "done," with "todo" set as the default view, aptly named "tasks." The TasksViewSelector component is responsible for rendering the view selector, enabling users to toggle between these two states. Meanwhile, the PageTitleNavigation component is employed to integrate the view selector into the UI.

The PageTitleNavigation component is designed to mirror the appearance of a page title in other sections by embedding it within the PageTitle component. This parent component sets the text size, weight, and color, ensuring that the PageTitleNavigation component maintains a consistent look with titles across the application. The options within the component are displayed in a flexbox layout and separated by a line for clear visual distinction.

In our TasksPage, the content is neatly contained within a flexbox container that has a maximum width of 520px. This design choice prevents the content from stretching across the entire screen width, enhancing usability. This is especially useful when buttons appear next to to-do items on hover, ensuring a clean and user-friendly interface. Additionally, we integrate the UserStateOnly component to ensure that content is displayed only after user data has been successfully fetched. At Increaser, we use a comprehensive query to retrieve nearly all necessary user data. Although this query is blocking, users typically experience negligible delays due to the efficient caching of user data in local storage through react-query. This strategy ensures that the app's functionality is readily available upon subsequent launches, providing a seamless user experience.

The TasksDeadlinesOverview widget provides a clear summary of the current and upcoming weeks, showing the start and end dates, the current weekday, and a graphical representation of completed tasks. As tasks are marked as completed, the corresponding circles in the visualization turn green, giving an instant visual indication of progress.

To improve the responsiveness of the TasksDeadlinesOverview, we use the UniformColumnGrid component, which simplifies the creation of CSS grid layouts. This component ensures that all grid columns are of equal width, regardless of their content. By setting a minChildrenWidth property, it ensures that grid items will wrap to a new row when there isn't enough space to accommodate them in a single line. Additionally, to visually distinguish between the two weeks, we apply backgrounds to the child elements rather than the parent container, creating a clear separation.

To categorize tasks into two groups, we first calculate the timestamp for the start of the current week. We then add the duration of one week to determine the timestamp for the start of the next week. At Increaser, we often need to convert between different time units, so we use a handy utility called convertDuration. This utility takes a value and the units to convert from and to, making the process of time unit conversion straightforward.

In the WeekDeadlinesOverview component, which displays tasks for both the current and upcoming weeks, we provide details such as the week's name, its start and end dates, the current day of the week, and a visual representation of task completion. Completed tasks are indicated by green circles, while pending tasks are represented by gray circles. These circles are rendered by the TaskStatus styled component, where the completed property determines the color. The design of this component utilizes the round and sameDimensions utilities to ensure that each circle is perfectly round and uniformly sized, respectively.

Depending on the user's selected view, the application dynamically switches between the TasksToDo and TasksDone components. Rather than using a hook to determine the current view, we employ the RenderTaskView component. This approach streamlines the process, enabling RenderTaskView to directly render the appropriate component based on the current state, thereby improving code readability.

To ensure our to-do list feature is accessible on mobile devices, we check for hover support using the hover and pointer media queries. If a device supports hover, we enable a hoverable drag handle—a small vertical bar on the left side of each task item. Additionally, buttons for changing the deadline and deleting a task are positioned on the opposite side of a to-do item. Conversely, on mobile devices that do not support hover, the drag handle remains visible constantly, and a "more" button is introduced. This button activates a slide-over panel for task management, optimizing the user interface for touch interactions.

To categorize tasks based on their deadline status, our implementation relies on two key functions: getDeadlineTypes and getDeadlineStatus. These functions help in identifying the type of deadline by examining the current weekday. Specifically, on Saturdays, we exclude the thisWeek category from our deadline types. This adjustment is due to the fact that only today and tomorrow remain as viable deadline categories for the current week, ensuring our task grouping logic accurately reflects the time-sensitive nature of task completion.

The getDeadlineStatus function categorizes tasks by their deadlines, taking a timestamp for the deadline and the current time as inputs. It returns the status corresponding to DeadlineType, with an additional overdue status for past deadlines. Specifically, it returns overdue for deadlines in the past, today for deadlines set for the current day, tomorrow for deadlines set for the next day, thisWeek for deadlines within the current week, and nextWeek for deadlines falling in the following week.

To categorize tasks by their respective deadlines, we implement a systematic approach using two utility functions. The process begins with the getRecord function, which converts an array of tasks into a record format. This conversion segregates tasks into distinct categories, or "buckets," based on their deadline types.

Following this, we utilize the recordMap function to process the generated record by turning each value into an empty array.

Next, we merge empty buckets with tasks organized into groups based on their deadline status. This process is facilitated by the groupItems function, which accepts an array of tasks and a function to extract the deadline status from each task. The function then returns a record of tasks, categorized by their deadline status.

With the task groups prepared, we can now focus on implementing drag-and-drop functionality. For this, we use the react-beautiful-dnd library. The choice of this library is based on my previous experience and its straightforwardness for common drag-and-drop scenarios between buckets. Since react-beautiful-dnd is not a core library, we choose not to include it in the RadzionKit template. Instead, we'll keep the drag-and-drop logic in the Increaser codebase, adjacent to our TasksToDo component.

It's important to note that we don't use react-beautiful-dnd directly in the TasksToDo component. Instead, we utilize a custom DnDGroups component, which acts as a wrapper around react-beautiful-dnd. This approach allows us to easily replace react-beautiful-dnd with another library if needed, as the consumer of the DnDGroups component is unaware of the underlying library and simply implements the required interface.

Another reason for keeping the drag-and-drop-related code in a separate component is to maintain the readability and understandability of our TasksToDo component. Mixing drag-and-drop logic with the business logic of the to-do list would make the component overly complex.

The DnDGroups component requires several props. It needs a groups prop, which is a record of items grouped by a key, in our case, the deadline status. There's also a getGroupOrder function to determine the order of groups, with the 'overdue' group being the first and the 'nextWeek' group being the last. The getItemOrder function determines the order of items within a group, sorting tasks within each deadline status group. The getItemId function determines the unique identifier of an item, which in our case would be the task ID. The onChange function handles changes in item order and group. Lastly, there are renderGroup and renderItem functions to render a group and an item, respectively.

First, we maintain the ID of the currently dragged item in the state. This allows us to disable dragging for other items while one item is being dragged, ensuring that other items won't display the drag handle on hover.

When the drag ends, we check if the destination or the index has changed from the source. If there is a change, we calculate the new order using the getNewOrder utility function and call the onChange function with the new order and the group ID. The order field is used solely to sort items within a group, and its actual value is arbitrary as long as it's unique within the group. By utilizing the order field, we only need to update a single task in the database when the order changes, instead of reordering all the tasks.

The logic within the getNewOrder function is straightforward. When it's the only item within a group, we return 0. If the destination index is 0, we return the order of the first item minus 1. If the destination index is the last index, we return the order of the last item plus 1. Otherwise, we calculate the new order based on the previous and next items' orders, "placing" the dragged item between them.

To sort the groups and items within the groups, we use the order utility function. This function accepts an array of items and a function to extract the order value from each item. It then sorts the items according to the specified order, which can be either ascending or descending.

Both the renderGroup and renderItem functions receive props and a ref, which should be propagated to the corresponding tasks list and task items, respectively. This ensures that the Droppable and Draggable components are aware of the underlying DOM elements, allowing the drag-and-drop functionality to work as expected.

Now let's examine how the TasksToDo component leverages the DnDGroups component to implement drag-and-drop functionality for tasks. We pass tasks that are already grouped into deadline buckets to the groups prop. To determine the order of the groups, we simply use the index of the deadline status in the deadlineStatuses array. The getItemId function accesses the task ID, while the getItemOrder function retrieves the order of the task. The onChange function is responsible for calling the API to update the task's order and deadline type.

We render each group within a vertical flexbox, which includes a title, a list of tasks provided by the content prop, and a prompt to create a new task. The renderItem function is responsible for rendering each task item, including the drag handle and the task itself.

In the onChange callback, we need to convert the groupId into a deadline timestamp. We achieve this by using the getDeadlineAt utility function. This function takes the deadline type and the current timestamp as inputs and returns the timestamp for the deadline. After determining the timestamp, we call the updateTask mutation to update the task's order and deadline timestamp.

When rendering a task, we must consider whether it is on a mobile device. If it is, we keep the handle always visible by displaying it next to the task within a horizontal flexbox container. If hover is enabled, we employ the OnHoverDragContainer component, which changes the opacity of the drag handle based on the hover state and the isDragging prop. If hover is not enabled, we use the DragContainer component, which always displays the drag handle.

To make the task entity accessible to all children within the TaskItem component without resorting to deep prop drilling, we use the CurrentTaskProvider component. This component leverages the getValueProviderSetup utility function to create lightweight context providers that serve the sole purpose of maintaining a single value.

Within the TaskItem component, we use the useCurrentTask hook to access the current task. We apply the same pattern to check if hover is enabled. Based on that, we either display the "manage deadline" and "delete task" buttons on hover or show the "more" button that will trigger the slide-over panel on mobile devices.

We display the content within the TaskItemFrame component, which defines the shape of the task item. This component is also used for the "Create Task" button and the inline form for task creation. By using such "frame" components, we ensure a consistent look and feel across the application.

To create a checkbox, we leverage a native HTML checkbox, which is hidden from the user but still functional. It will trigger the onChange event when the user clicks on the Check component, which is rendered as a label element.

The CheckStatus component is a reusable component for creating "checkbox-like" elements used in checklists and form fields. It has different styles based on the isChecked and isInteractive props. By using aspect-ratio together with width: 100%, we make the component a square that covers the available space. In the case of our TaskItemFrame, it will cover the entire width of the TaskItemFrame's first column.

After the checkbox, we display the EditableTaskName, a component that allows the user to edit the task name. To avoid spamming our API with requests for every keystroke, we use the InputDebounce component.

The InputDebounce component receives value and onChange props but also keeps track of the current value in the local state. When the user types, the onChange function is called with the new value, but the actual update is delayed by 300ms. The user won't notice the delay because the local state holds the value, and the input is updated immediately.

The ManageTaskDeadline component handles the logic for providing the current deadline and calling the API, but the actual rendering is determined by the consumer. Thus, on desktop, we'll display a dropdown, and on mobile, we'll display a slide-over panel. When changing the deadline, we also need to update the task's order so that the task is placed last in the new group.

On mobile, we display the options for deadlines together with the "Delete Task" button within a BottomSlideOver component. Here, we use the same ManageTaskDeadline component, but instead of a dropdown, it will be a list of menu options. The Opener component is a wrapper around the useState hook, which makes the code more readable and easier to maintain.

As we covered the tasks in the TO-DO list, let's check how the tasks are being created. As we saw before, each bucket of tasks, except for the "overdue" bucket, has the CreateTask component at the bottom. It receives an order and a deadline type as props so that it can create a new task in the right bucket and position it last. The CreateTask component will first display the AddTaskButton, and when clicked, it will display the CreateTaskForm component.

As we mentioned before, both the AddTaskButton and the CreateTaskForm components are wrapped in the TaskItemFrame component, which ensures a consistent look and feel across the application. Similar to the checkbox, we use a container with an aspect ratio of 1/1 to ensure that the icon takes the same size as other elements rendered within the TaskItemFrame. To make the hover effect appear wider than the element itself, we use the Hoverable component.

The CreateTaskForm is a straightforward form with a single input field, as the bucket is already predefined by the location of the task creation prompt. Both on form submit and blur, we check if the input is empty, and if not, we create a new task and reset the input. Since there is autofocus on the input, the user can create tasks one after another without the need to click any buttons. As we are setting an ID for the task on the front-end and performing an optimistic update by updating the state before the API call is resolved, the user will see the task appear instantly.

The final piece of our feature is the view with completed tasks. Here, we filter the tasks by checking for the presence of the completedAt field. Based on the presence of completed tasks, we either display a message at the top of the list indicating that completed tasks will be shown here or a message that completed tasks will be cleared each week. We then display the tasks in the same way as in the TO-DO list, but without the drag-and-drop functionality.