Radzion Chachura
Posted on March 4, 2024
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.
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:
- 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.
- 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.
- Flexible Task Management: The interface design enables easy reassignment of tasks between different time frames and prioritization within categories, enhancing organizational flexibility.
- Mobile Accessibility: Basic mobile access is provided to facilitate task management on-the-go.
- 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.
- 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, ornull
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
}
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
}
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)
}
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
}
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>
)
}
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,
}
}
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]}
/>
)
}
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>
)
}
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
}
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>
)
}
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%;
`}
`
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
}
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>
)
}
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>
)
}}
/>
)
}
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
}
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"
}
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
}
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>
}
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
}
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>
)
}
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
}
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)
}
})
}
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")
}
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;
}
`
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")
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>
)
}
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)};
`
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)}
/>
)
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>
)
}
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)}
/>
)}
/>
)
}
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,
})}
</>
)
}
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),
})}
</>
)
}
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>
)}
/>
)
}
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)} />
)
}
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>
)
}
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>
)
}
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>
)
}
Posted on March 4, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.