Creating a Drag-and-Drop Todo List in React: A Complete Tutorial

radzion

Radzion Chachura

Posted on March 4, 2024

Creating a Drag-and-Drop Todo List in React: A Complete Tutorial

🐙 GitHub | 🎮 Demo

Building a Custom Todo List Feature in Increaser: From Backend to UI Design

In this post, we explore the development of a todo list feature for the productivity app Increaser, covering a range of topics including backend setup, responsive UI design, drag-and-drop functionality, visualization of completed tasks, and deadline management. Our goal is to build this feature without using external component libraries, showcasing 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. We'll provide detailed explanations of Increaser-specific code throughout this blog post.

TO-DO list

Enhancing Productivity for Remote Workers: The Strategic Addition of a Todo List to Increaser

Before delving into the technical specifics, it's essential to define the features we aim to include and the reasons behind them. Increaser, a productivity platform for remote workers, already offers a range of tools such as a focus timer, habit tracker, time tracker, and scheduler. Adding a todo list feature further enhances its functionality, making Increaser a one-stop productivity hub. However, designing an effective todo list is not without its challenges. Instead of replicating the comprehensive features of established todo list apps like Todoist, our approach concentrates on identifying and refining core features that cater to the majority of use-cases.

Here's a concise overview of the planned features:

  1. Deadline Categorization: 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.
  2. 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.
  3. Flexible Task Management: The interface design enables easy reassignment of tasks between different time frames and prioritization within categories, enhancing organizational flexibility.
  4. Mobile Accessibility: Basic mobile access is provided to facilitate task management on-the-go.
  5. 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.
  6. Streamlined Task Editing: Users can simply click on a task to edit its name, minimizing the need for additional interactions.

Simplifying Backend Development: DynamoDB Integration for Todo List Feature in Increaser

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.
export type Task = {
  startedAt: number
  id: string
  name: string
  completedAt?: number | null
  deadlineAt: number
  order: number
}
Enter fullscreen mode Exit fullscreen mode

Increaser's API deviates from traditional RESTful and GraphQL paradigms. If you're interested in exploring backend development with ease, consider reading this blog post for more insights. To manage tasks, we use three resolvers: createTask, updateTask, and deleteTask. Each resolver acts as a straightforward wrapper around the tasksDB module, which handles direct interactions with the database.

import { assertUserId } from "../../auth/assertUserId"
import * as tasksDB from "@increaser/db/task"
import { ApiResolver } from "../../resolvers/ApiResolver"

export const updateTask: ApiResolver<"updateTask"> = async ({
  input,
  context,
}) => {
  const userId = assertUserId(context)

  const { id, fields } = input

  const task = tasksDB.updateTask(userId, id, fields)

  return task
}
Enter fullscreen mode Exit fullscreen mode

For example, the updateTask resolver is 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.

import { GetCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"
import { Task } from "@increaser/entities/Task"
import { getUserItemParams } from "./user"
import { dbDocClient } from "@lib/dynamodb/client"

export const putTask = async (userId: string, task: Task) => {
  const command = new UpdateCommand({
    ...getUserItemParams(userId),
    UpdateExpression: `set tasks.#id = :task`,
    ExpressionAttributeValues: {
      ":task": task,
    },
    ExpressionAttributeNames: {
      "#id": task.id,
    },
  })

  return dbDocClient.send(command)
}

export const getTask = async (userId: string, taskId: string) => {
  const command = new GetCommand({
    ...getUserItemParams(userId),
    ProjectionExpression: "tasks.#id",
    ExpressionAttributeNames: {
      "#id": taskId,
    },
  })
  const { Item } = await dbDocClient.send(command)

  if (!Item) {
    throw new Error(`No user with id=${userId}`)
  }

  return Item.tasks[taskId] as Task
}

export const updateTask = async (
  userId: string,
  taskId: string,
  fields: Partial<Omit<Task, "id">>
) => {
  const task = await getTask(userId, taskId)

  const newTask = {
    ...task,
    ...fields,
  }

  await putTask(userId, newTask)

  return newTask
}

export const deleteTask = async (userId: string, taskId: string) => {
  const comand = new UpdateCommand({
    ...getUserItemParams(userId),
    UpdateExpression: "REMOVE tasks.#id",
    ExpressionAttributeNames: {
      "#id": taskId,
    },
  })
  return dbDocClient.send(comand)
}
Enter fullscreen mode Exit fullscreen mode

The organizeTasks function plays a crucial role in maintaining the cleanliness of the todo list by automatically filtering out tasks that were completed before the onset of the current week. If this operation results in a change in the number of tasks, the user's tasks field in the database is updated accordingly. To optimize resource utilization and avoid additional costs associated with running a cron job, this function is invoked on a user query request.

import { getUser, updateUser } from "@increaser/db/user"
import { getRecordSize } from "@lib/utils/record/getRecordSize"
import { recordFilter } from "@lib/utils/record/recordFilter"
import { getWeekStartedAt } from "@lib/utils/time/getWeekStartedAt"
import { inTimeZone } from "@lib/utils/time/inTimeZone"

export const organizeTasks = async (userId: string) => {
  const { tasks: oldTasks, timeZone } = await getUser(userId, [
    "tasks",
    "timeZone",
  ])

  const weekStartedAt = inTimeZone(getWeekStartedAt(Date.now()), timeZone)

  const tasks = recordFilter(oldTasks, ({ value }) => {
    if (!value.completedAt) return true

    return value.completedAt >= weekStartedAt
  })

  if (getRecordSize(tasks) !== getRecordSize(oldTasks)) {
    await updateUser(userId, {
      tasks,
    })
  }

  return tasks
}
Enter fullscreen mode Exit fullscreen mode

Frontend Development for Increaser's Todo List: Enhancing User Experience with NextJS

Now, let's explore the frontend implementation details. Increaser is built as a Static Site Generation (SSG) application using NextJS, and we've designated a specific page for the to-do list feature accessible via the /tasks route.

import { VStack } from "@lib/ui/layout/Stack"
import { FixedWidthContent } from "../../components/reusable/fixed-width-content"
import { PageTitle } from "../../ui/PageTitle"
import { UserStateOnly } from "../../user/state/UserStateOnly"
import { TasksDone } from "./TasksDone"
import { TasksToDo } from "./TasksToDo"
import {
  RenderTasksView,
  TasksViewProvider,
  TasksViewSelector,
} from "./TasksView"
import { TasksDeadlinesOverview } from "./TasksDeadlinesOverview"

export const TasksPage = () => {
  return (
    <FixedWidthContent>
      <TasksViewProvider>
        <PageTitle documentTitle={`✅ Tasks`} title={<TasksViewSelector />} />
        <VStack gap={40} style={{ maxWidth: 520 }}>
          <UserStateOnly>
            <TasksDeadlinesOverview />
            <RenderTasksView
              todo={() => <TasksToDo />}
              done={() => <TasksDone />}
            />
          </UserStateOnly>
        </VStack>
      </TasksViewProvider>
    </FixedWidthContent>
  )
}
Enter fullscreen mode Exit fullscreen mode

To segregate completed tasks from the active to-do list, we provide a seamless user experience by enabling users to toggle between these two views. For this purpose, we use the getViewSetup utility. This utility streamlines view state management by accepting 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 simplifies the management of multiple views in the code, enhancing the maintainability and readability of the view-related logic.

import { ReactNode, createContext, useState } from "react"
import { ComponentWithChildrenProps } from "../props"
import { createContextHook } from "../state/createContextHook"

type GetViewSetupInput<T extends string | number | symbol> = {
  defaultView: T
  name: string
}

export function getViewSetup<T extends string | number | symbol>({
  defaultView,
  name,
}: GetViewSetupInput<T>) {
  interface ViewState {
    view: T
    setView: (view: T) => void
  }

  const ViewContext = createContext<ViewState | undefined>(undefined)

  const ViewProvider = ({ children }: ComponentWithChildrenProps) => {
    const [view, setView] = useState<T>(defaultView)

    return (
      <ViewContext.Provider value={{ view, setView }}>
        {children}
      </ViewContext.Provider>
    )
  }

  const useView = createContextHook(ViewContext, `${name}ViewContent`)

  const RenderView = (props: Record<T, () => ReactNode>) => {
    const { view } = useView()
    const render = props[view]

    return <>{render()}</>
  }

  return {
    ViewProvider,
    useView,
    RenderView,
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

import { getViewSetup } from "@lib/ui/view/getViewSetup"
import { PageTitleNavigation } from "@lib/ui/navigation/PageTitleNavigation"

export const tasksViews = ["todo", "done"] as const
export type TasksView = (typeof tasksViews)[number]

export const {
  ViewProvider: TasksViewProvider,
  useView: useTasksView,
  RenderView: RenderTasksView,
} = getViewSetup<TasksView>({
  defaultView: "todo",
  name: "tasks",
})

const taskViewName: Record<TasksView, string> = {
  todo: "To Do",
  done: "Done",
}

export const TasksViewSelector = () => {
  const { view, setView } = useTasksView()

  return (
    <PageTitleNavigation
      value={view}
      options={tasksViews}
      onChange={setView}
      getOptionName={(option) => taskViewName[option]}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

import styled, { css } from "styled-components"
import { transition } from "@lib/ui/css/transition"
import { UnstyledButton } from "@lib/ui/buttons/UnstyledButton"
import { HStackSeparatedBy } from "@lib/ui/layout/StackSeparatedBy"
import { Text } from "@lib/ui/text"
import { InputProps } from "../props"

const ViewOption = styled(UnstyledButton)<{ isSelected: boolean }>`
  color: ${({ isSelected, theme }) =>
    (isSelected ? theme.colors.text : theme.colors.textShy).toCssValue()};

  ${transition}

  ${({ isSelected, theme }) =>
    !isSelected &&
    css`
      &:hover {
        color: ${theme.colors.textSupporting.toCssValue()};
      }
    `}
`

type PageTitleNavigationProps<T extends string> = InputProps<T> & {
  options: readonly T[]
  getOptionName: (option: T) => string
}

export function PageTitleNavigation<T extends string>({
  value,
  getOptionName,
  options,
  onChange,
}: PageTitleNavigationProps<T>) {
  return (
    <HStackSeparatedBy separator={<Text color="shy">|</Text>}>
      {options.map((v) => (
        <ViewOption
          onClick={() => onChange(v)}
          isSelected={v === value}
          key={value}
        >
          {getOptionName(v)}
        </ViewOption>
      ))}
    </HStackSeparatedBy>
  )
}
Enter fullscreen mode Exit fullscreen mode

Implementing a Responsive Todo List UI in Increaser: Flexbox, react-query, and CSS Grid Techniques

In our TasksPage, the content is neatly wrapped within a flexbox container that has a maximum width of 520px. This intentional design choice prevents the content from stretching across the entire screen width, thereby enhancing usability. This is particularly beneficial when buttons appear next to todo items on hover, ensuring a clean and user-friendly interface. Additionally, we integrate the UserStateOnly component to guarantee that content is displayed only after user data has been successfully fetched. At Increaser, we employ 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.

import { ComponentWithChildrenProps } from "@lib/ui/props"

import { useEffect } from "react"
import { useAuthRedirect } from "@increaser/app/auth/hooks/useAuthRedirect"
import { useAuthSession } from "@increaser/app/auth/hooks/useAuthSession"
import { useUserState } from "@increaser/ui/user/UserStateContext"

export const UserStateOnly = ({ children }: ComponentWithChildrenProps) => {
  const { state } = useUserState()
  const { toAuthenticationPage } = useAuthRedirect()

  const [authSession] = useAuthSession()

  useEffect(() => {
    if (!authSession) {
      toAuthenticationPage()
    }
  }, [authSession, toAuthenticationPage])

  return state ? <>{children}</> : null
}
Enter fullscreen mode Exit fullscreen mode

The TasksDeadlinesOverview widget offers a concise summary of the current and upcoming week, showcasing 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, providing an instant visual indication of progress.

import { useStartOfWeek } from "@lib/ui/hooks/useStartOfWeek"
import { WeekDeadlinesOverview } from "./WeekDeadlinesOverview"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { splitBy } from "@lib/utils/array/splitBy"
import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import styled from "styled-components"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { getColor } from "@lib/ui/theme/getters"
import { borderRadius } from "@lib/ui/css/borderRadius"

const Container = styled(UniformColumnGrid)`
  ${borderRadius.m};
  overflow: hidden;
  > * {
    padding: 20px;
    background: ${getColor("foreground")};
  }
`

export const TasksDeadlinesOverview = () => {
  const weekStartedAt = useStartOfWeek()
  const nextWeekStartsAt = weekStartedAt + convertDuration(1, "w", "ms")
  const { tasks } = useAssertUserState()
  const [thisWeekTasks, nextWeekTasks] = splitBy(Object.values(tasks), (task) =>
    task.deadlineAt > nextWeekStartsAt ? 1 : 0
  )

  return (
    <Container gap={1} minChildrenWidth={240}>
      <WeekDeadlinesOverview
        name="This week"
        tasks={thisWeekTasks}
        startedAt={weekStartedAt}
      />
      <WeekDeadlinesOverview
        name="Next week"
        tasks={nextWeekTasks}
        startedAt={nextWeekStartsAt}
        showWorkdays={false}
      />
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

To enhance the responsiveness of the TasksDeadlinesOverview, we utilize the UniformColumnGrid component, which streamlines 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 guarantees that grid items will wrap to a new row when there is insufficient 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.

import styled, { css } from "styled-components"
import { toSizeUnit } from "../css/toSizeUnit"

interface UniformColumnGridProps {
  gap: number
  minChildrenWidth?: number
  maxChildrenWidth?: number
  childrenWidth?: number
  rowHeight?: number
  fullWidth?: boolean
  maxColumns?: number
}

const getColumnMax = (maxColumns: number | undefined, gap: number) => {
  if (!maxColumns) return `0px`

  const gapCount = maxColumns - 1
  const totalGapWidth = `calc(${gapCount} * ${toSizeUnit(gap)})`

  return `calc((100% - ${totalGapWidth}) / ${maxColumns})`
}

const getColumnWidth = ({
  minChildrenWidth,
  maxChildrenWidth,
  maxColumns,
  gap,
  childrenWidth,
}: UniformColumnGridProps) => {
  if (childrenWidth !== undefined) {
    return toSizeUnit(childrenWidth)
  }

  return `
    minmax(
      max(
        ${toSizeUnit(minChildrenWidth || 0)},
        ${getColumnMax(maxColumns, gap)}
      ),
      ${maxChildrenWidth ? toSizeUnit(maxChildrenWidth) : "1fr"}
  )`
}

export const UniformColumnGrid = styled.div<UniformColumnGridProps>`
  display: grid;
  grid-template-columns: repeat(auto-fit, ${getColumnWidth});
  gap: ${({ gap }) => toSizeUnit(gap)};
  ${({ rowHeight }) =>
    rowHeight &&
    css`
      grid-auto-rows: ${toSizeUnit(rowHeight)};
    `}
  ${({ fullWidth }) =>
    fullWidth &&
    css`
      width: 100%;
    `}
`
Enter fullscreen mode Exit fullscreen mode

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.

import {
  MS_IN_DAY,
  MS_IN_HOUR,
  MS_IN_MIN,
  MS_IN_SEC,
  MS_IN_WEEK,
  NS_IN_MS,
} from "."
import { DurationUnit } from "./DurationUnit"

const msInUnit: Record<DurationUnit, number> = {
  ns: 1 / NS_IN_MS,
  ms: 1,
  s: MS_IN_SEC,
  min: MS_IN_MIN,
  h: MS_IN_HOUR,
  d: MS_IN_DAY,
  w: MS_IN_WEEK,
}

export const convertDuration = (
  value: number,
  from: DurationUnit,
  to: DurationUnit
) => {
  const result = value * (msInUnit[from] / msInUnit[to])

  return result
}
Enter fullscreen mode Exit fullscreen mode

In the WeekDeadlinesOverview component, which displays tasks for both the current and upcoming week, 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.

import { HStack, VStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import { range } from "@lib/utils/array/range"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { format } from "date-fns"
import { Task } from "@increaser/entities/Task"
import { useStartOfDay } from "@lib/ui/hooks/useStartOfDay"
import { getShortWeekday } from "@lib/utils/time"
import { order } from "@lib/utils/array/order"
import styled from "styled-components"
import { round } from "@lib/ui/css/round"
import { sameDimensions } from "@lib/ui/css/sameDimensions"
import { matchColor } from "@lib/ui/theme/getters"

type WeekDeadlinesOverviewProps = {
  startedAt: number
  name: string
  tasks: Task[]
  showWorkdays?: boolean
}

const TaskStatus = styled.div<{ completed: boolean }>`
  ${round};
  ${sameDimensions(12)};
  background: ${matchColor("completed", {
    true: "success",
    false: "mistExtra",
  })};
`

export const WeekDeadlinesOverview = ({
  startedAt,
  name,
  tasks,
}: WeekDeadlinesOverviewProps) => {
  const todayStartedAt = useStartOfDay()

  return (
    <VStack gap={12}>
      <VStack gap={4}>
        <HStack alignItems="center" gap={8}>
          <Text color="contrast" weight="semibold" size={14}>
            {name}
          </Text>
          <Text weight="semibold" size={14} color="supporting">
            {format(startedAt, "MMM d")} -{" "}
            {format(startedAt + convertDuration(1, "w", "ms"), "MMM d")}
          </Text>
        </HStack>
        <HStack alignItems="center" justifyContent="space-between">
          {range(convertDuration(1, "w", "d")).map((dayIndex) => {
            const dayStartedAt =
              startedAt + convertDuration(dayIndex, "d", "ms")

            const isToday = todayStartedAt === dayStartedAt
            return (
              <Text
                key={dayIndex}
                size={12}
                weight={isToday ? "bold" : "regular"}
                color={isToday ? "primary" : "shy"}
              >
                {getShortWeekday(dayIndex)}
              </Text>
            )
          })}
        </HStack>
      </VStack>
      <HStack alignItems="center" gap={4} wrap="wrap">
        {order(tasks, (task) => task.completedAt ?? 0, "desc").map((task) => (
          <TaskStatus completed={!!task.completedAt} key={task.id} />
        ))}
      </HStack>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

Adaptive UI Design for Increaser's Todo List: Handling Hover and Touch Interactions

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.

import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import { useRhythmicRerender } from "@lib/ui/hooks/useRhythmicRerender"
import { groupItems } from "@lib/utils/array/groupItems"
import { convertDuration } from "@lib/utils/time/convertDuration"
import {
  DeadlineStatus,
  Task,
  deadlineName,
  deadlineStatuses,
} from "@increaser/entities/Task"
import { VStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import { CurrentTaskProvider } from "./CurrentTaskProvider"
import { TaskItem } from "./TaskItem"
import { getDeadlineTypes } from "@increaser/entities-utils/task/getDeadlineTypes"
import { getDeadlineStatus } from "@increaser/entities-utils/task/getDeadlineStatus"
import { useCallback, useMemo } from "react"
import { useUpdateTaskMutation } from "../api/useUpdateTaskMutation"
import { getDeadlineAt } from "@increaser/entities-utils/task/getDeadlineAt"
import { getRecord } from "@lib/utils/record/getRecord"
import { recordMap } from "@lib/utils/record/recordMap"
import { DnDGroups, ItemChangeParams } from "./DnDGroups"
import { CreateTask } from "./CreateTask"
import { getLastItemOrder } from "@lib/utils/order/getLastItemOrder"
import { DragContainer, OnHoverDragContainer } from "./DragContainer"
import { DragHandle } from "@lib/ui/dnd/DragHandle"
import { GripVerticalIcon } from "@lib/ui/icons/GripVerticalIcon"
import { useMedia } from "react-use"

const hoverableDragHandleWidth = 36

export const TasksToDo = () => {
  const { tasks } = useAssertUserState()
  const now = useRhythmicRerender(convertDuration(1, "min", "ms"))

  const isHoverEnabled = useMedia("(hover: hover) and (pointer: fine)")

  const groups = useMemo(() => {
    return {
      ...recordMap(
        getRecord(getDeadlineTypes(now), (key) => key),
        () => [] as Task[]
      ),
      ...groupItems(
        Object.values(tasks).filter((task) => !task.completedAt),
        (task) =>
          getDeadlineStatus({
            deadlineAt: task.deadlineAt,
            now,
          })
      ),
    }
  }, [now, tasks])

  const { mutate: updateTask } = useUpdateTaskMutation()

  const onChange = useCallback(
    (id: string, { order, groupId }: ItemChangeParams<DeadlineStatus>) => {
      const fields: Partial<Omit<Task, "id">> = {
        order,
      }
      if (groupId !== "overdue") {
        fields.deadlineAt = getDeadlineAt({
          deadlineType: groupId,
          now,
        })
      } else if (
        getDeadlineStatus({ deadlineAt: tasks[id].deadlineAt, now }) !==
        "overdue"
      ) {
        return
      }

      updateTask({
        id,
        fields,
      })
    },
    [now, tasks, updateTask]
  )

  return (
    <DnDGroups
      groups={groups}
      getGroupOrder={(status) => deadlineStatuses.indexOf(status)}
      getItemId={(task) => task.id}
      getItemOrder={(task) => task.order}
      onChange={onChange}
      renderGroup={({ content, groupId, containerProps }) => (
        <VStack gap={4} key={groupId}>
          <Text
            weight="semibold"
            size={12}
            color={groupId === "overdue" ? "idle" : "supporting"}
          >
            {deadlineName[groupId].toUpperCase()}
          </Text>
          <VStack {...containerProps}>
            {content}
            {groupId !== "overdue" && (
              <CreateTask
                order={getLastItemOrder(
                  groups[groupId].map((task) => task.order)
                )}
                deadlineType={groupId}
              />
            )}
          </VStack>
        </VStack>
      )}
      renderItem={({
        item,
        draggableProps,
        dragHandleProps,
        isDragging,
        isDraggingEnabled,
      }) => {
        const content = (
          <CurrentTaskProvider value={item} key={item.id}>
            <TaskItem />
          </CurrentTaskProvider>
        )
        const dragHandle = (
          <DragHandle
            style={
              isHoverEnabled
                ? {
                    position: "absolute",
                    left: -hoverableDragHandleWidth,
                    width: hoverableDragHandleWidth,
                  }
                : {
                    width: 40,
                  }
            }
            isActive={isDragging}
            {...dragHandleProps}
          >
            <GripVerticalIcon />
          </DragHandle>
        )
        if (isHoverEnabled) {
          return (
            <OnHoverDragContainer
              isDraggingEnabled={isDraggingEnabled}
              isDragging={isDragging}
              {...draggableProps}
            >
              {dragHandle}
              {content}
            </OnHoverDragContainer>
          )
        }

        return (
          <DragContainer {...draggableProps}>
            {dragHandle}
            {content}
          </DragContainer>
        )
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

To ensure our to-do list feature is accessible on mobile devices, we must 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.

Deadline Management in Increaser's Todo List: Categorizing Tasks by Deadline Status

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.

import { DeadlineType, deadlineTypes } from "@increaser/entities/Task"
import { without } from "@lib/utils/array/without"
import { getWeekday } from "@lib/utils/time/getWeekday"

export const getDeadlineTypes = (now: number): readonly DeadlineType[] => {
  const weekday = getWeekday(new Date(now))
  if (weekday > 4) {
    return without(deadlineTypes, "thisWeek")
  }

  return deadlineTypes
}
Enter fullscreen mode Exit fullscreen mode

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.
import { DeadlineStatus } from "@increaser/entities/Task"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { getWeekday } from "@lib/utils/time/getWeekday"
import { endOfDay } from "date-fns"

type GetDeadlineStatusInput = {
  now: number
  deadlineAt: number
}

export const getDeadlineStatus = ({
  deadlineAt,
  now,
}: GetDeadlineStatusInput): DeadlineStatus => {
  if (deadlineAt < now) return "overdue"

  const todayEndsAt = endOfDay(now).getTime()
  if (deadlineAt <= todayEndsAt) return "today"

  const tomorrowEndsAt = todayEndsAt + convertDuration(1, "d", "ms")
  if (deadlineAt <= tomorrowEndsAt) return "tomorrow"

  const weekdayIndex = getWeekday(new Date(now))
  const thisWeekEndsAt =
    todayEndsAt +
    convertDuration(
      convertDuration(1, "w", "d") - (weekdayIndex + 1),
      "d",
      "ms"
    )
  if (deadlineAt <= thisWeekEndsAt) return "thisWeek"

  return "nextWeek"
}
Enter fullscreen mode Exit fullscreen mode

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.

export function getRecord<T, K extends string | number>(
  items: readonly T[],
  getKey: (item: T) => K
): Record<K, T> {
  const record = {} as Record<K, T>

  items.forEach((item) => {
    record[getKey(item)] = item
  })

  return record
}
Enter fullscreen mode Exit fullscreen mode

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

export const recordMap = <K extends string | number, T, V>(
  record: Record<K, T>,
  fn: (value: T) => V
): Record<K, V> => {
  return Object.fromEntries(
    Object.entries(record).map(([key, value]) => [key, fn(value as T)])
  ) as Record<K, V>
}
Enter fullscreen mode Exit fullscreen mode

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.

export const groupItems = <T, K extends string | number>(
  items: T[],
  getKey: (item: T) => K
): Record<K, T[]> => {
  const result = {} as Record<K, T[]>

  items.forEach((item) => {
    const key = getKey(item)
    if (!result[key]) {
      result[key] = []
    }
    result[key]?.push(item)
  })

  return result
}
Enter fullscreen mode Exit fullscreen mode

Implementing Drag-and-Drop Functionality in Increaser's Todo List with React-Beautiful-DnD

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

import { order } from "@lib/utils/array/order"
import { getNewOrder } from "@lib/utils/order/getNewOrder"
import { ReactNode, useCallback, useState } from "react"
import {
  DragDropContext,
  Draggable,
  Droppable,
  OnDragEndResponder,
} from "react-beautiful-dnd"
import { getRecordKeys } from "@lib/utils/record/getRecordKeys"

export type ItemChangeParams<K> = {
  order: number
  groupId: K
}

type RenderGroupParams<K> = {
  groupId: K
  content: ReactNode
  containerProps?: Record<string, any>
}

type RenderItemParams<I> = {
  item: I
  draggableProps?: Record<string, any>
  dragHandleProps?: Record<string, any> | null
  isDragging?: boolean
  isDraggingEnabled?: boolean
}

export type DnDGroupsProps<K extends string, I> = {
  groups: Record<K, I[]>
  getGroupOrder: (group: K) => number
  getItemOrder: (item: I) => number
  getItemId: (item: I) => string
  onChange: (itemId: string, params: ItemChangeParams<K>) => void
  renderGroup: (params: RenderGroupParams<K>) => ReactNode
  renderItem: (params: RenderItemParams<I>) => ReactNode
}

export function DnDGroups<K extends string, I>({
  groups,
  getItemOrder,
  getItemId,
  onChange,
  renderGroup,
  renderItem,
  getGroupOrder,
}: DnDGroupsProps<K, I>) {
  const [currentItemId, setCurrentItemId] = useState<string | null>(null)

  const handleDragEnd: OnDragEndResponder = useCallback(
    ({ destination, source, draggableId }) => {
      setCurrentItemId(null)
      if (!destination) {
        return
      }

      const isSameGroup = destination.droppableId === source.droppableId

      if (isSameGroup && destination.index === source.index) {
        return
      }

      const groupId = destination.droppableId as K

      const items = groups[groupId] || []

      onChange(draggableId, {
        order: getNewOrder({
          orders: items.map(getItemOrder),
          sourceIndex: isSameGroup ? source.index : null,
          destinationIndex: destination.index,
        }),
        groupId,
      })
    },
    [getItemOrder, groups, onChange]
  )

  const groupKeys = order(getRecordKeys(groups), getGroupOrder, "asc")

  return (
    <DragDropContext
      onDragStart={({ draggableId }) => setCurrentItemId(draggableId)}
      onDragEnd={handleDragEnd}
    >
      {groupKeys.map((groupId) => {
        const items = order(groups[groupId] || [], getItemOrder, "asc")

        return (
          <Droppable droppableId={groupId}>
            {(provided) => {
              return (
                <>
                  {renderGroup({
                    groupId,
                    containerProps: {
                      ...provided.droppableProps,
                      ref: provided.innerRef,
                    },
                    content: (
                      <>
                        {items.map((item, index) => (
                          <Draggable
                            key={getItemId(item)}
                            index={index}
                            draggableId={getItemId(item)}
                          >
                            {(
                              { dragHandleProps, draggableProps, innerRef },
                              { isDragging }
                            ) => (
                              <>
                                {renderItem({
                                  item,
                                  draggableProps: {
                                    ...draggableProps,
                                    ref: innerRef,
                                  },
                                  dragHandleProps,
                                  isDraggingEnabled:
                                    currentItemId === null ||
                                    getItemId(item) !== currentItemId,
                                  isDragging,
                                })}
                              </>
                            )}
                          </Draggable>
                        ))}
                        {provided.placeholder}
                      </>
                    ),
                  })}
                </>
              )
            }}
          </Droppable>
        )
      })}
    </DragDropContext>
  )
}
Enter fullscreen mode Exit fullscreen mode

The DnDGroups component requires the following props:

  • groups: A record of items grouped by a key, which in our case would be the deadline status.
  • getGroupOrder: A function to determine the order of groups, with the 'overdue' group being the first and the 'nextWeek' group being the last.
  • getItemOrder: A function to determine the order of items within a group, thus sorting tasks within each deadline status group.
  • getItemId: A function to determine the unique identifier of an item, which in our case would be the task ID.
  • onChange: A function to handle changes in item order and group.
  • renderGroup: A function to render a group.
  • renderItem: A function to render an item.

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.

import { getLastItem } from "../array/getLastItem"
import { isEmpty } from "../array/isEmpty"

type GetNewOrderInput = {
  orders: number[]
  sourceIndex: number | null
  destinationIndex: number
}

export const getNewOrder = ({
  orders,
  sourceIndex,
  destinationIndex,
}: GetNewOrderInput): number => {
  if (isEmpty(orders)) {
    return 0
  }

  if (destinationIndex === 0) {
    return orders[0] - 1
  }

  const movedUp = sourceIndex !== null && sourceIndex < destinationIndex
  const previousIndex = movedUp ? destinationIndex : destinationIndex - 1
  const previous = orders[previousIndex]

  const shouldBeLast =
    (destinationIndex === orders.length - 1 && sourceIndex !== null) ||
    destinationIndex > orders.length - 1

  if (shouldBeLast) {
    return getLastItem(orders) + 1
  }

  const nextIndex = movedUp ? destinationIndex + 1 : destinationIndex
  const next = orders[nextIndex]

  return previous + (next - previous) / 2
}
Enter fullscreen mode Exit fullscreen mode

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.

import { Order } from "../order/Order"

export const order = <T,>(
  array: T[],
  getValue: (item: T) => number,
  order: Order
) => {
  return [...array].sort((a, b) => {
    if (order === "asc") {
      return getValue(a) - getValue(b)
    } else {
      return getValue(b) - getValue(a)
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

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.

Leveraging DnDGroups for Task Management in Increaser: Integrating Drag-and-Drop into TasksToDo

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. It is straightforward, and we then call the updateTask mutation to update the task's order and deadline timestamp.

import { DeadlineType } from "@increaser/entities/Task"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { getWeekday } from "@lib/utils/time/getWeekday"
import { endOfDay } from "date-fns"

type GetDeadlineAtInput = {
  now: number
  deadlineType: DeadlineType
}

export const getDeadlineAt = ({
  deadlineType,
  now,
}: GetDeadlineAtInput): number => {
  const todayEndsAt = endOfDay(now).getTime()
  if (deadlineType === "today") return todayEndsAt

  const tomorrowEndsAt = todayEndsAt + convertDuration(1, "d", "ms")
  if (deadlineType === "tomorrow") return tomorrowEndsAt

  const weekdayIndex = getWeekday(new Date(now))
  const thisWeekEndsAt =
    todayEndsAt +
    convertDuration(
      convertDuration(1, "w", "d") - (weekdayIndex + 1),
      "d",
      "ms"
    )
  if (deadlineType === "thisWeek") return thisWeekEndsAt

  return thisWeekEndsAt + convertDuration(1, "w", "ms")
}
Enter fullscreen mode Exit fullscreen mode

Optimizing Task Interaction on Mobile: Employing Context Providers and Responsive Design in Increaser

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.

import { DragHandle } from "@lib/ui/dnd/DragHandle"
import { HStack } from "@lib/ui/layout/Stack"
import { getColor } from "@lib/ui/theme/getters"
import styled, { css } from "styled-components"

export const DragContainer = styled(HStack)`
  width: 100%;
  gap: 4px;
  align-items: center;
  background: ${getColor("background")};
  position: relative;
`

type OnHoverDragContainerProps = {
  isDraggingEnabled?: boolean
  isDragging?: boolean
}

export const OnHoverDragContainer = styled(
  DragContainer
)<OnHoverDragContainerProps>`
  gap: 0;
  align-items: center;
  ${({ isDraggingEnabled }) =>
    !isDraggingEnabled &&
    css`
      pointer-events: none;
    `}

  @media (hover: hover) and (pointer: fine) {
    &:not(:focus-within) > ${DragHandle} {
      opacity: ${({ isDragging }) => (isDragging ? 1 : 0)};
    }
  }

  &:hover ${DragHandle} {
    opacity: 1;
  }
`
Enter fullscreen mode Exit fullscreen mode

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.

import { Task } from "@increaser/entities/Task"
import { getValueProviderSetup } from "@lib/ui/state/getValueProviderSetup"

export const { useValue: useCurrentTask, provider: CurrentTaskProvider } =
  getValueProviderSetup<Task>("Task")
Enter fullscreen mode Exit fullscreen mode

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.

import { useCurrentTask } from "./CurrentTaskProvider"
import { HStack } from "@lib/ui/layout/Stack"
import { ManageTaskDeadline } from "./ManageTaskDeadline"
import styled from "styled-components"
import { CheckStatus } from "@lib/ui/checklist/CheckStatus"
import { InvisibleHTMLCheckbox } from "@lib/ui/inputs/InvisibleHTMLCheckbox"
import { interactive } from "@lib/ui/css/interactive"
import { useUpdateTaskMutation } from "../api/useUpdateTaskMutation"
import { TaskItemFrame } from "./TaskItemFrame"
import { EditableTaskName } from "./EditableTaskName"
import { DeleteTask } from "./DeleteTask"
import { useMedia } from "react-use"
import { ManageTaskSlideover } from "./ManageTaskSlideover"
import { ExpandableSelector } from "@lib/ui/select/ExpandableSelector"
import { IconWrapper } from "@lib/ui/icons/IconWrapper"
import { CalendarIcon } from "@lib/ui/icons/CalendarIcon"
import { Text } from "@lib/ui/text"
import { deadlineName } from "@increaser/entities/Task"

const OnHoverActions = styled.div`
  display: grid;
  grid-template-columns: 60px 36px;
  height: 36px;
  gap: 4px;

  &:not(:focus-within) {
    opacity: 0;
  }
`

const Container = styled(HStack)`
  width: 100%;
  gap: 8px;
  align-items: center;

  &:hover ${OnHoverActions} {
    opacity: 1;
  }
`

const Check = styled(CheckStatus)`
  ${interactive};
`

export const TaskItem = () => {
  const task = useCurrentTask()
  const { completedAt } = task

  const isHoverEnabled = useMedia("(hover: hover) and (pointer: fine)")

  const { mutate: updateTask } = useUpdateTaskMutation()

  const value = !!completedAt

  return (
    <Container>
      <TaskItemFrame>
        <Check isInteractive forwardedAs="label" value={value}>
          <InvisibleHTMLCheckbox
            value={value}
            onChange={() => {
              updateTask({
                id: task.id,
                fields: {
                  completedAt: task.completedAt ? null : Date.now(),
                },
              })
            }}
          />
        </Check>
        <EditableTaskName />
      </TaskItemFrame>
      {isHoverEnabled ? (
        <OnHoverActions>
          <ManageTaskDeadline
            render={({ value, onChange, options }) => (
              <ExpandableSelector
                openerContent={
                  <IconWrapper style={{ fontSize: 18 }}>
                    <CalendarIcon />
                  </IconWrapper>
                }
                floatingOptionsWidthSameAsOpener={false}
                style={{ height: "100%", padding: 8 }}
                value={value}
                onChange={onChange}
                options={options}
                getOptionKey={(option) => option}
                renderOption={(option) => (
                  <Text key={option}>{deadlineName[option]}</Text>
                )}
              />
            )}
          />
          <DeleteTask />
        </OnHoverActions>
      ) : (
        <ManageTaskSlideover />
      )}
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

import styled from "styled-components"
import { verticalPadding } from "@lib/ui/css/verticalPadding"

export const taskItemMinHeight = 40

export const TaskItemFrame = styled.div`
  display: grid;
  width: 100%;
  grid-template-columns: 24px 1fr;
  align-items: center;
  justify-items: start;
  gap: 12px;
  font-weight: 500;
  ${verticalPadding(8)};
`
Enter fullscreen mode Exit fullscreen mode

Enhancing Task UI with Custom Checkboxes: Implementing Interactive Elements in Increaser

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.

import React from "react"
import { InputProps } from "../props"
import { InvisibleInput } from "./InvisibleInput"

export type InvisibleHTMLCheckboxProps = InputProps<boolean> & {
  id?: string
  groupName?: string
}

export const InvisibleHTMLCheckbox: React.FC<InvisibleHTMLCheckboxProps> = ({
  id,
  value,
  onChange,
  groupName,
}) => (
  <InvisibleInput
    type="checkbox"
    checked={value}
    name={groupName}
    value={id}
    onChange={(event) => onChange(event.target.checked)}
  />
)
Enter fullscreen mode Exit fullscreen mode

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.

import styled, { css } from "styled-components"
import { centerContent } from "../css/centerContent"
import { getColor } from "../theme/getters"
import { transition } from "../css/transition"
import { CheckIcon } from "../icons/CheckIcon"
import { ComponentWithChildrenProps, UIComponentProps } from "../props"
import React from "react"
import { interactive } from "../css/interactive"
import { getHoverVariant } from "../theme/getHoverVariant"

type CheckStatusProps = UIComponentProps & {
  value: boolean
  as?: React.ElementType
  isInteractive?: boolean
} & Partial<ComponentWithChildrenProps>

const Container = styled.div<{ isChecked: boolean; isInteractive?: boolean }>`
  width: 100%;
  aspect-ratio: 1/1;

  ${centerContent};

  border-radius: 4px;
  border: 1px solid ${getColor("textSupporting")};
  color: ${getColor("background")};

  ${transition}

  ${({ isChecked }) =>
    isChecked &&
    css`
      background: ${getColor("primary")};
      border-color: ${getColor("primary")};
    `};

  ${({ isInteractive, isChecked }) =>
    isInteractive &&
    css`
      ${interactive};
      &:hover {
        background: ${isChecked ? getColor("primary") : getColor("mist")};
        border-color: ${isChecked
          ? getHoverVariant("primary")
          : getColor("contrast")};
      }
    `};
`

export const CheckStatus = ({
  value,
  children,
  isInteractive = false,
  ...rest
}: CheckStatusProps) => {
  return (
    <Container {...rest} isInteractive={isInteractive} isChecked={value}>
      {value && <CheckIcon />}
      {children}
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Streamlining Task Management: Using InputDebounce for Efficient API Calls in Increaser

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.

import { useCurrentTask } from "./CurrentTaskProvider"
import { TaskNameInput } from "./TaskNameInput"
import { useUpdateTaskMutation } from "../api/useUpdateTaskMutation"
import { InputDebounce } from "@lib/ui/inputs/InputDebounce"

export const EditableTaskName = () => {
  const task = useCurrentTask()

  const { mutate: updateTask } = useUpdateTaskMutation()

  return (
    <InputDebounce
      value={task.name}
      onChange={(name) => updateTask({ id: task.id, fields: { name } })}
      render={({ value, onChange }) => (
        <TaskNameInput
          autoComplete="off"
          value={value}
          onChange={(e) => onChange(e.target.value)}
        />
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

import { ReactNode, useEffect, useState } from "react"
import { InputProps } from "../props"

type InputDebounceProps<T> = InputProps<T> & {
  render: (props: InputProps<T>) => ReactNode
  interval?: number
}

export function InputDebounce<T>({
  value,
  onChange,
  interval = 300,
  render,
}: InputDebounceProps<T>) {
  const [currentValue, setCurrentValue] = useState<T>(value)

  useEffect(() => {
    if (currentValue === value) return

    const timeout = setTimeout(() => {
      onChange(currentValue)
    }, interval)

    return () => clearTimeout(timeout)
  }, [currentValue, interval, onChange, value])

  return (
    <>
      {render({
        value: currentValue,
        onChange: setCurrentValue,
      })}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Adapting Task Deadline Management for Desktop and Mobile: Interactive UI Components in Increaser

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.

import { useCurrentTask } from "./CurrentTaskProvider"
import { DeadlineType } from "@increaser/entities/Task"
import { getDeadlineAt } from "@increaser/entities-utils/task/getDeadlineAt"
import { getDeadlineTypes } from "@increaser/entities-utils/task/getDeadlineTypes"
import { useUpdateTaskMutation } from "../api/useUpdateTaskMutation"
import { useRhythmicRerender } from "@lib/ui/hooks/useRhythmicRerender"
import { getDeadlineStatus } from "@increaser/entities-utils/task/getDeadlineStatus"
import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import { groupItems } from "@lib/utils/array/groupItems"
import { getLastItemOrder } from "@lib/utils/order/getLastItemOrder"

type RenderParams = {
  value: DeadlineType | null
  onChange: (value: DeadlineType) => void
  options: readonly DeadlineType[]
}

type ManageTaskDeadlineProps = {
  render: (params: RenderParams) => React.ReactNode
}

export const ManageTaskDeadline = ({ render }: ManageTaskDeadlineProps) => {
  const { tasks } = useAssertUserState()
  const { id, deadlineAt } = useCurrentTask()

  const { mutate } = useUpdateTaskMutation()

  const now = useRhythmicRerender(1000)
  const deadlineStatus = getDeadlineStatus({
    now,
    deadlineAt,
  })
  const value = deadlineStatus === "overdue" ? null : deadlineStatus

  const changeDeadline = (deadlineType: DeadlineType) => {
    const deadlineAt = getDeadlineAt({
      now: Date.now(),
      deadlineType,
    })

    const groupedTasks = groupItems(
      Object.values(tasks).filter((task) => !task.completedAt),
      (task) =>
        getDeadlineStatus({
          deadlineAt: task.deadlineAt,
          now,
        })
    )

    mutate({
      id,
      fields: {
        deadlineAt,
        order: getLastItemOrder(
          (groupedTasks[deadlineType] ?? []).map((task) => task.order)
        ),
      },
    })
  }

  return (
    <>
      {render({
        value,
        onChange: changeDeadline,
        options: getDeadlineTypes(now),
      })}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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(source code) is a wrapper around the useState hook, which makes the code more readable and easier to maintain.

import { Opener } from "@lib/ui/base/Opener"
import { IconButton } from "@lib/ui/buttons/IconButton"
import { MoreHorizontalIcon } from "@lib/ui/icons/MoreHorizontalIcon"
import { BottomSlideOver } from "@lib/ui/modal/BottomSlideOver"
import { useCurrentTask } from "./CurrentTaskProvider"
import { useDeleteTaskMutation } from "../api/useDeleteHabitMutation"
import { SeparatedByLine } from "@lib/ui/layout/SeparatedByLine"
import { MenuOption } from "@lib/ui/menu/MenuOption"
import { TrashBinIcon } from "@lib/ui/icons/TrashBinIcon"
import { VStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import { ManageTaskDeadline } from "./ManageTaskDeadline"
import { deadlineName } from "@increaser/entities/Task"

export const ManageTaskSlideover = () => {
  const { id } = useCurrentTask()

  const { mutate: deleteTask } = useDeleteTaskMutation()

  return (
    <Opener
      renderOpener={({ onOpen }) => (
        <IconButton
          title="Manage task"
          icon={<MoreHorizontalIcon />}
          onClick={onOpen}
        />
      )}
      renderContent={({ onClose }) => (
        <BottomSlideOver title="Manage task" onClose={onClose}>
          <SeparatedByLine gap={20}>
            <VStack gap={12}>
              <Text weight="bold" size={14} color="supporting">
                Deadline
              </Text>
              <ManageTaskDeadline
                render={({ value, onChange, options }) => (
                  <>
                    {options.map((option) => (
                      <MenuOption
                        isSelected={value === option}
                        key={option}
                        text={deadlineName[option]}
                        onSelect={() => onChange(option)}
                        view="slideover"
                      />
                    ))}
                  </>
                )}
              />
            </VStack>
            <MenuOption
              kind="alert"
              view="slideover"
              text="Delete task"
              icon={<TrashBinIcon />}
              onSelect={() => deleteTask({ id })}
            />
          </SeparatedByLine>
        </BottomSlideOver>
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Streamlining Task Creation in Increaser: Integrating the CreateTask Component with Optimistic Updates

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.

import { DeadlineType } from "@increaser/entities/Task"
import { AddTaskButton } from "./AddTaskButton"
import { CreateTaskForm } from "./CreateTaskForm"
import { useState } from "react"

type CreateTaskProps = {
  deadlineType: DeadlineType
  order: number
}

export const CreateTask = ({ deadlineType, order }: CreateTaskProps) => {
  const [isActive, setIsActive] = useState(false)
  return isActive ? (
    <CreateTaskForm
      deadlineType={deadlineType}
      order={order}
      onFinish={() => setIsActive(false)}
    />
  ) : (
    <AddTaskButton onClick={() => setIsActive(true)} />
  )
}
Enter fullscreen mode Exit fullscreen mode

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 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 then the element itself, we use the Hoverable component(source code).

import { ClickableComponentProps } from "@lib/ui/props"
import { PlusIcon } from "@lib/ui/icons/PlusIcon"
import { Text } from "@lib/ui/text"
import { Hoverable } from "@lib/ui/base/Hoverable"
import styled from "styled-components"
import { centerContent } from "@lib/ui/css/centerContent"
import { getColor } from "@lib/ui/theme/getters"
import { transition } from "@lib/ui/css/transition"
import { TaskItemFrame } from "./TaskItemFrame"

const IconContainer = styled.div`
  width: 100%;
  aspect-ratio: 1/1;
  ${centerContent};
  color: ${getColor("primary")};
`

const Container = styled(Hoverable)`
  color: ${getColor("textShy")};
  ${transition};
  &:hover {
    color: ${getColor("primary")};
  }
`

export const AddTaskButton = ({ onClick }: ClickableComponentProps) => {
  return (
    <Container verticalOffset={0} onClick={onClick}>
      <TaskItemFrame>
        <IconContainer>
          <PlusIcon />
        </IconContainer>
        <Text size={14} weight="regular">
          Add task
        </Text>
      </TaskItemFrame>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

import { FormEvent, useState } from "react"
import { useKey } from "react-use"
import { handleWithPreventDefault } from "@increaser/app/shared/events"
import { FinishableComponentProps } from "@lib/ui/props"
import { getId } from "@increaser/entities-utils/shared/getId"
import { DeadlineType, Task } from "@increaser/entities/Task"
import { getDeadlineAt } from "@increaser/entities-utils/task/getDeadlineAt"
import { CheckStatus } from "@lib/ui/checklist/CheckStatus"
import { useCreateTaskMutation } from "../api/useCreateTaskMutation"
import { TaskItemFrame } from "./TaskItemFrame"
import { TaskNameInput } from "./TaskNameInput"

type CreateTaskFormProps = FinishableComponentProps & {
  deadlineType: DeadlineType
  order: number
}

export const CreateTaskForm = ({
  onFinish,
  deadlineType,
  order,
}: CreateTaskFormProps) => {
  const [name, setName] = useState("")

  const { mutate } = useCreateTaskMutation()

  useKey("Escape", onFinish)

  const createTask = () => {
    const startedAt = Date.now()
    const task: Task = {
      name,
      id: getId(),
      startedAt,
      deadlineAt: getDeadlineAt({ now: startedAt, deadlineType }),
      order,
    }
    mutate(task)
    setName("")
  }

  return (
    <div>
      <TaskItemFrame
        as="form"
        onBlur={() => {
          if (name) {
            createTask()
          } else {
            onFinish()
          }
        }}
        onSubmit={handleWithPreventDefault<FormEvent<HTMLFormElement>>(() => {
          if (name) {
            createTask()
          }
        })}
      >
        <CheckStatus value={false} />
        <TaskNameInput
          placeholder="Task name"
          autoFocus
          autoComplete="off"
          onChange={(event) => setName(event.target.value)}
          value={name}
        />
      </TaskItemFrame>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Managing Completed Tasks in Increaser: Displaying and Clearing Completed Tasks Efficiently

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.

import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import { VStack } from "@lib/ui/layout/Stack"
import { CurrentTaskProvider } from "./CurrentTaskProvider"
import { TaskItem } from "./TaskItem"
import { ShyInfoBlock } from "@lib/ui/info/ShyInfoBlock"
import { isEmpty } from "@lib/utils/array/isEmpty"

export const TasksDone = () => {
  const { tasks } = useAssertUserState()

  const completedTasks = Object.values(tasks).filter(
    (task) => !!task.completedAt
  )
  return (
    <VStack gap={20}>
      <ShyInfoBlock>
        {isEmpty(completedTasks)
          ? `Tasks you've completed will be shown here. Keep up the good work!`
          : "Completed tasks automatically clear each week, offering a fresh start and organized view."}
      </ShyInfoBlock>
      <VStack>
        {completedTasks.map((task) => (
          <CurrentTaskProvider value={task} key={task.id}>
            <TaskItem />
          </CurrentTaskProvider>
        ))}
      </VStack>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
radzion
Radzion Chachura

Posted on March 4, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related