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:
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.