Building Recurring Task Feature with React, TypeScript, and Node.js

July 3, 2024

22 min read

Building Recurring Task Feature with React, TypeScript, and Node.js
Watch on YouTube

Building a Recurring Task Feature with React, TypeScript, and Node.js

In this article, we will build an exciting feature for a productivity app using React, TypeScript, and Node.js. This feature enables users to create task factories that automatically generate new tasks based on a specified cadence, such as weekly or monthly intervals. While the source code for Increaser is in a private repository, you can find all the reusable components, hooks, and utilities in the RadzionKit GitHub repository.

Why We Need This Feature

Before we jump into the code, let's understand why we need this feature and explore a high-level design of the system. Achieving any goal often hinges on consistently performing the right recurring actions. For instance, maintaining a successful YouTube channel requires producing a video every week. Achieving a good physique demands daily exercise. Attaining financial independence involves monthly investments. The list goes on. To help users achieve their goals, we will implement a system for recurring tasks in Increaser. Users can specify the activities they want to repeat, and Increaser will handle the rest by generating these activities automatically. Users can then focus solely on executing them.

Recurring tasks at Increaser
Recurring tasks at Increaser

System Design

In our system, there is no direct concept of recurring tasks. Instead, we have Task and TaskFactory entities. When a user navigates to the recurring tasks tab and creates a new item, they are actually creating a TaskFactory. This TaskFactory defines the rules for how new tasks will be automatically generated in our system.

import { Task } from "./Task"

export const taskCadence = ["workday", "day", "week", "month"] as const
export type TaskCadence = (typeof taskCadence)[number]

export type TaskFactory = {
  id: string
  task: Pick<Task, "name" | "projectId" | "links" | "checklist">
  cadence: TaskCadence
  lastOutputAt?: number
}

TaskFactory Entity Fields

The TaskFactory entity contains the following fields:

  • id: A unique identifier for the task factory
  • task: The template for the task that will be generated
  • cadence: The frequency at which new tasks will be generated
  • lastOutputAt: The timestamp of the last generated task
import { getUser, updateUser } from "@increaser/db/user"
import { getId } from "@increaser/entities-utils/shared/getId"
import { getDeadlineAt } from "@increaser/entities-utils/task/getDeadlineAt"
import { getCadencePeriodStart } from "@increaser/entities-utils/taskFactory/getCadencePeriodStart"
import { Task } from "@increaser/entities/Task"
import { getLastItemOrder } from "@lib/utils/order/getLastItemOrder"
import { toRecord } from "@lib/utils/record/toRecord"
import { recordMap } from "@lib/utils/record/recordMap"
import { inTimeZone } from "@lib/utils/time/inTimeZone"

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

  const oldTasks = Object.values(tasks)
  const generatedTasks: Task[] = []

  Object.values(taskFactories).forEach(
    ({ task, cadence, lastOutputAt, id }) => {
      const cadencePeriodStart = inTimeZone(
        getCadencePeriodStart(cadence),
        timeZone
      )
      if (lastOutputAt && lastOutputAt >= cadencePeriodStart) return

      const now = Date.now()
      const deadlineAt = inTimeZone(
        getDeadlineAt({
          now,
          deadlineType: "today",
        }),
        timeZone
      )

      const newTasks = [...oldTasks, ...generatedTasks]

      const orders = newTasks
        .filter((task) => task.deadlineAt === deadlineAt)
        .map((task) => task.order)

      const order = getLastItemOrder(orders)

      generatedTasks.push({
        startedAt: now,
        id: getId(),
        deadlineAt,
        order,
        factoryId: id,
        ...task,
      })
    }
  )

  if (generatedTasks.length > 0) {
    const newTasks = toRecord(
      [...oldTasks, ...generatedTasks],
      (task) => task.id
    )

    const newTaskFactories = recordMap(taskFactories, (taskFactory) => {
      if (generatedTasks.some((task) => task.factoryId === taskFactory.id)) {
        return {
          ...taskFactory,
          lastOutputAt: Date.now(),
        }
      }

      return taskFactory
    })

    await updateUser(userId, {
      tasks: newTasks,
      taskFactories: newTaskFactories,
    })
  }
}

Implementing the Task Generation Function

To generate new tasks, we use the runTaskFactories function. This function receives a userId and begins by retrieving the necessary fields from the user's table, including taskFactories, timeZone, and tasks. We represent timeZone as an offset in minutes from UTC, which can be obtained by calling new Date().getTimezoneOffset().

Working with DynamoDB

Both tasks and taskFactories are represented as records, where the key is the id of the entity. We use this format due to our choice of database, DynamoDB, which allows for more efficient update operations on specific fields within a record. Additionally, records are more convenient to work with since you can easily access values by their IDs.

import { Task } from "./Task"
import { TaskFactory } from "./TaskFactory"

export type User = {
  timeZone: number
  tasks: Record<string, Task>
  taskFactories: Record<string, TaskFactory>
  // ...
}

Generating New Tasks

Next, we define arrays for the old tasks and the newly generated tasks. We then proceed by iterating over each task factory, generating new tasks when necessary. To do this, we first need to determine the start of the current cadence period. For example, if the cadence is set to week, we need the timestamp of the start of the current week.

Calculating Cadence Period Start

The getCadencePeriodStart function matches the cadence to a specific function that calculates the start of the period. For most periods, we rely on the date-fns library, except for the workday cadence. For workday, the start would be the beginning of the current day or the start of Friday if it's a weekend.

The match function is a more elegant alternative to the switch statement, and you can find its implementation, along with the convertDuration function, in the RadzionKit repository.

import { TaskCadence } from "@increaser/entities/TaskFactory"
import { match } from "@lib/utils/match"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { getWeekStartedAt } from "@lib/utils/time/getWeekStartedAt"
import { startOfDay, startOfMonth } from "date-fns"

export const getCadencePeriodStart = (cadence: TaskCadence) => {
  const now = Date.now()
  return match(cadence, {
    week: () => getWeekStartedAt(now),
    day: () => startOfDay(now).getTime(),
    workday: () => {
      const dayStartedAt = startOfDay(now).getTime()
      const lastWorkdayStartedAt =
        getWeekStartedAt(now) + convertDuration(4, "d", "ms")

      return Math.min(dayStartedAt, lastWorkdayStartedAt)
    },
    month: () => startOfMonth(now).getTime(),
  })
}

Handling Timezones

The runTaskFactories function will run on a server that is likely not in the same timezone as the user. Therefore, we need to convert the timestamps to the user's timezone. We achieve this using the inTimeZone function, which takes a timestamp and an offset in minutes and returns the timestamp in the specified timezone. For example, the start of the week on a European server will be the end of the week in the US. The inTimeZone function adjusts the timestamp to the target timezone, ensuring accuracy across different regions.

import { getCurrentTimezoneOffset } from "./getCurrentTimezoneOffset"
import { convertDuration } from "./convertDuration"

export const inTimeZone = (timestamp: number, targetTimeZoneOffset: number) => {
  const offsetDiff = targetTimeZoneOffset - getCurrentTimezoneOffset()
  return timestamp + convertDuration(offsetDiff, "min", "ms")
}

Checking Task Factory Output

Knowing the start of the period, we check it against the lastOutputAt field of the task factory. If the last output is after the start of the period, it means we have already generated the task and can skip this factory. Otherwise, we proceed to calculate the deadline for the new task, which would be the end of the current day in the user's timezone. In the future, it would be better to allow users to specify specific deadlines for each task factory instead of defaulting to the end of the current day. The getDeadlineAt function follows the same pattern as getCadencePeriodStart, using the match function to determine the deadline based on the task's DeadlineType.

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

type GetDeadlineAtInput = {
  now: number
  deadlineType: DeadlineType
}

const getThisWeekEndsAt = (now: number): number => {
  const weekdayIndex = getWeekday(new Date(now))
  return (
    endOfDay(now).getTime() +
    convertDuration(
      convertDuration(1, "w", "d") - (weekdayIndex + 1),
      "d",
      "ms"
    )
  )
}

export const getDeadlineAt = ({
  deadlineType,
  now,
}: GetDeadlineAtInput): number => {
  return match(deadlineType, {
    today: () => endOfDay(now).getTime(),
    tomorrow: () => endOfDay(now).getTime() + convertDuration(1, "d", "ms"),
    thisMonth: () => endOfMonth(now).getTime(),
    thisWeek: () => getThisWeekEndsAt(now),
    nextWeek: () => getThisWeekEndsAt(now) + convertDuration(1, "w", "ms"),
  })
}

Creating New Tasks

Next, we need to calculate the order of the new task to place it at the end of the list. If you're curious about how task drag-and-drop (dnd) is implemented in Increaser, you can check out this post. With order and deadlineAt calculated, we create a new task object and add it to the generatedTasks array. To recongize the generated tasks later, we set the factoryId field to the id of the task factory.

Updating the Database

Finally, we check if there are any generated tasks. If there are, we update the user's tasks and task factories in the database. We use the recordMap function to iterate over record values, and toRecord to convert an array to a record. Both functions can be found in the RadzionKit repository.

When to Run the Task Generation Function

Now, the question is where and when to execute the runTaskFactories function. We chose to do it in the user state query function, which might seem like an unusual choice at first. However, it makes sense given the current stage of our application. We persist user state on the front-end, so we can afford longer user state queries without blocking the UI, making it unnoticeable to the user in most cases. This approach also prevents us from spamming the user's task list if they haven't been using the app for a while. Additionally, it saves us money by avoiding the need for a dedicated service for this feature. In the future, we might need to consider moving this logic to a dedicated process, but for now, there is no need to overengineer it.

import { assertUserId } from "../../auth/assertUserId"
import { organizeTasks } from "../../tasks/services/organizeTasks"
import { getUser, updateUser } from "@increaser/db/user"
import { ApiResolver } from "../../resolvers/ApiResolver"
import { organizeSets } from "@increaser/data-services/sets/organizeSets"
import { runTaskFactories } from "../../taskFactories/services/runTaskFactories"

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

  await updateUser(userId, { timeZone, lastVisitAt: Date.now() })

  await organizeSets(userId)

  await organizeTasks(userId)

  await runTaskFactories(userId)

  return getUser(userId)
}

CRUD Operations for Tasks and Task Factories

We won't cover CRUD operations for tasks and task factories as they are pretty straightforward. If you're curious about how to seamlessly implement backends within a TypeScript monorepo, check out this post. The only thing worth mentioning is that we need to handle the case where a user deletes a task factory. In such cases, we should also remove factoryId references from the generated tasks since they will no longer be valid. We achieve this by running syncTaskFactoriesDependantFields after deleting a task factory. That's all for the backend part. Now let's move on to the frontend.

import { getUser, updateUser } from "@increaser/db/user"
import { omit } from "@lib/utils/record/omit"
import { recordMap } from "@lib/utils/record/recordMap"

export const syncTaskFactoriesDependantFields = async (userId: string) => {
  const oldUser = await getUser(userId, ["tasks", "taskFactories", "goals"])

  const ids = new Set(Object.values(oldUser.taskFactories).map(({ id }) => id))

  const tasks = recordMap(oldUser.tasks, (task) =>
    task.factoryId && ids.has(task.factoryId) ? omit(task, "factoryId") : task
  )

  const goals = recordMap(oldUser.goals, (goal) => ({
    ...goal,
    taskFactories: goal.taskFactories?.filter((id) => ids.has(id)),
  }))

  await updateUser(userId, {
    tasks,
    goals,
  })
}

Frontend Implementation

On the tasks page, we have four sections: tasks to do, tasks done, tasks backlog, and recurring tasks. We want to focus on the ManageTaskFactories component, which is responsible for the recurring tasks section.

import { VStack } from "@lib/ui/layout/Stack"
import styled from "styled-components"
import {
  RenderTasksView,
  TasksViewProvider,
  TasksViewSelector,
} from "@increaser/ui/tasks/TasksView"
import { TasksDone } from "@increaser/ui/tasks/TasksDone"
import { TasksBacklogView } from "@increaser/ui/tasks/TasksBacklogView"
import { TasksToDoView } from "./TasksToDoView"
import { ManageTaskFactories } from "../taskFactories/ManageTaskFactories"

const TasksContainer = styled(VStack)`
  max-width: 560px;
  width: 100%;
  gap: 32px;
`

export const Tasks = () => {
  return (
    <TasksContainer>
      <TasksViewProvider>
        <TasksViewSelector />
        <RenderTasksView
          recurring={() => <ManageTaskFactories />}
          done={() => <TasksDone />}
          todo={() => <TasksToDoView />}
          backlog={() => <TasksBacklogView />}
        />
      </TasksViewProvider>
    </TasksContainer>
  )
}

Displaying Task Factories

Here, we first display an educational block that the user can dismiss. We then render a list of task factories and a component for adding new task factories.

import { ProductEducationBlock } from "@increaser/ui/education/ProductEducationBlock"
import { AddTaskFactory } from "@increaser/ui/taskFactories/AddTaskFactory"
import { VStack } from "@lib/ui/layout/Stack"
import { useTaskFactories } from "./hooks/useTaskFactories"
import { CurrentTaskFactoryProvider } from "./CurrentTaskFactoryProvider"
import { TaskFactoryItem } from "./TaskFactoryItem"
import { ActiveItemIdProvider } from "@lib/ui/list/ActiveItemIdProvider"

export const ManageTaskFactories = () => {
  const items = useTaskFactories()

  return (
    <>
      <ProductEducationBlock value="recurringTasks" />
      <VStack>
        <ActiveItemIdProvider initialValue={null}>
          {items.map((item) => (
            <CurrentTaskFactoryProvider key={item.id} value={item}>
              <TaskFactoryItem />
            </CurrentTaskFactoryProvider>
          ))}
        </ActiveItemIdProvider>
        <AddTaskFactory />
      </VStack>
    </>
  )
}

Ordering Task Factories

The useTaskFactories component takes taskFactories record from the user state and orders them according the the cadence field, so that the most frequent tasks are displayed first. The order function is a utility function that sorts an array of items based on a key and direction. You can find it in the RadzionKit repository.

import { useMemo } from "react"
import { useAssertUserState } from "../../user/UserStateContext"
import { order } from "@lib/utils/array/order"
import { taskCadence } from "@increaser/entities/TaskFactory"

export const useTaskFactories = () => {
  const { taskFactories } = useAssertUserState()

  return useMemo(
    () =>
      order(
        Object.values(taskFactories),
        ({ cadence }) => taskCadence.indexOf(cadence),
        "asc"
      ),
    [taskFactories]
  )
}

Managing Task Factory State

The ActiveItemIdProvider holds the ID of the currently edited task factory. Having that ID in the context allows us to change the behavior of other items in the list. For example, it's useful in a drag-and-drop list where we want to disable dragging of the elements while one of them is being edited. To simplify the creation of such a straightforward state management, we use the getStateProviderSetup helper from RadzionKit.

Avoiding Prop Drilling

import { getStateProviderSetup } from "../state/getStateProviderSetup"

export const { useState: useActiveItemId, provider: ActiveItemIdProvider } =
  getStateProviderSetup<string | null>("ActiveItemId")

To avoid prop drilling, we use the CurrentTaskFactoryProvider to provide the current task factory to the TaskFactoryItem component and its children. We use the getValueProviderSetup helper from RadzionKit to create the provider and hook. This helper is different from getStateProviderSetup as it doesn't provide a setter function, since we only need to read the value.

Rendering Task Factory Items

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

export const {
  useValue: useCurrentTaskFactory,
  provider: CurrentTaskFactoryProvider,
} = getValueProviderSetup<TaskFactory>("TaskFactory")

The TaskFactoryItem component compares the current task factory ID with the active item ID. If the current item is active, it renders the EditTaskFactoryForm component. Otherwise, it renders the TaskFactoryItemContent component wrapped in a Hoverable component. The Hoverable component extends the hover effect outside the children boundaries using position: absolute. You can learn more about this effect in this post.

Task Factory Item Content

import { useActiveItemId } from "@lib/ui/list/ActiveItemIdProvider"
import { useCurrentTaskFactory } from "./CurrentTaskFactoryProvider"
import { EditTaskFactoryForm } from "./form/EditTaskFactoryForm"

import { TaskFactoryItemContent } from "./TaskFactoryItemContent"
import { Hoverable } from "@lib/ui/base/Hoverable"

export const TaskFactoryItem = () => {
  const { id } = useCurrentTaskFactory()

  const [activeItemId, setActiveItemId] = useActiveItemId()

  if (activeItemId === id) {
    return <EditTaskFactoryForm />
  }

  return (
    <Hoverable
      verticalOffset={0}
      onClick={() => {
        setActiveItemId(id)
      }}
    >
      <TaskFactoryItemContent />
    </Hoverable>
  )
}

To display the content of the task factory item, we use the PrefixedItemFrame component. This frame is useful for maintaining a consistent layout. For example, on this page, it's also used for the button that adds a proposal item, ensuring that the button's plus icon and text are aligned with the task factory emoji and name. You can find the PrefixedItemFrame component in the RadzionKit repository.

Editing Task Factory Items

import { HStack } from "@lib/ui/layout/Stack"
import { useCurrentTaskFactory } from "./CurrentTaskFactoryProvider"
import { TaskCadence } from "./TaskCadence"
import { PrefixedItemFrame } from "@lib/ui/list/PrefixedItemFrame"
import { Text } from "@lib/ui/text"
import { ProjectEmoji } from "../projects/ProjectEmoji"

export const TaskFactoryItemContent = () => {
  const { task } = useCurrentTaskFactory()

  return (
    <PrefixedItemFrame
      prefix={
        <Text size={16} color="contrast">
          <ProjectEmoji id={task.projectId} />
        </Text>
      }
    >
      <HStack fullWidth gap={20}>
        <Text style={{ flex: 1 }}>{task.name}</Text>
        <TaskCadence />
      </HStack>
    </PrefixedItemFrame>
  )
}

Inside the frame, we place a flexbox row element containing the task name and its cadence. We display how often the task will be generated within a pill-shaped element that has a refresh icon at the beginning.

import styled from "styled-components"
import { useCurrentTaskFactory } from "./CurrentTaskFactoryProvider"
import { HStack } from "@lib/ui/layout/Stack"
import { round } from "@lib/ui/css/round"
import { getColor } from "@lib/ui/theme/getters"
import { centerContent } from "@lib/ui/css/centerContent"
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { horizontalPadding } from "@lib/ui/css/horizontalPadding"
import { taskCadenceShortName } from "@increaser/entities/TaskFactory"
import { IconWrapper } from "@lib/ui/icons/IconWrapper"
import { RefreshIcon } from "@lib/ui/icons/RefreshIcon"
import { Text } from "@lib/ui/text"
import { tightListConfig } from "@lib/ui/list/tightListConfig"

const Container = styled(HStack)`
  align-items: center;
  gap: 8px;
  ${round};
  background: ${getColor("foreground")};
  ${centerContent};
  height: ${toSizeUnit(tightListConfig.lineHeight)};
  font-weight: 500;
  font-size: 14px;
  ${horizontalPadding(12)};

  svg {
    color: ${getColor("textSupporting")};
    font-size: 12px;
  }
`

export const TaskCadence = () => {
  const { cadence } = useCurrentTaskFactory()

  return (
    <Container>
      <IconWrapper>
        <RefreshIcon />
      </IconWrapper>
      <Text nowrap>{taskCadenceShortName[cadence]}</Text>
    </Container>
  )
}

Handling Form Input

Edit task factory form
Edit task factory form

We display the form for editing the task factory within a Panel component from RadzionKit. This component applies padding to its children and, when used with the withSections prop, separates them with a line.

import { useCallback, useState } from "react"
import { Panel } from "@lib/ui/panel/Panel"
import { HStack } from "@lib/ui/layout/Stack"
import { useActiveItemId } from "@lib/ui/list/ActiveItemIdProvider"
import { TaskNameInput } from "../../tasks/TaskNameInput"
import { TaskProjectSelector } from "../../tasks/TaskProjectSelector"
import { TaskLinksInput } from "../../tasks/form/TaskLinksInput"
import { useIsTaskFormDisabled } from "../../tasks/form/useIsTaskFormDisabled"
import { TaskFactoryFormShape } from "./TaskFactoryFormShape"
import { useCurrentTaskFactory } from "../CurrentTaskFactoryProvider"
import { useUpdateTaskFactoryMutation } from "../api/useUpdateTaskFactoryMutation"
import { useDeleteTaskFactoryMutation } from "../api/useDeleteTaskFactoryMutation"
import { TaskFactory } from "@increaser/entities/TaskFactory"
import { fixLinks } from "../../tasks/form/fixLinks"
import { TaskCadenceInput } from "./TaskCadenceInput"
import { getFormProps } from "@lib/ui/form/utils/getFormProps"
import { TaskChecklistInput } from "../../tasks/form/checklist/TaskChecklistInput"
import { fixChecklist } from "../../tasks/form/checklist/fixChecklist"
import { EditDeleteFormFooter } from "@lib/ui/form/components/EditDeleteFormFooter"

export const EditTaskFactoryForm = () => {
  const taskFactory = useCurrentTaskFactory()
  const [value, setValue] = useState<TaskFactoryFormShape>({
    name: taskFactory.task.name,
    projectId: taskFactory.task.projectId,
    links: taskFactory.task.links ?? [],
    cadence: taskFactory.cadence,
    checklist: taskFactory.task.checklist ?? [],
  })
  const { mutate: updateTaskFactory } = useUpdateTaskFactoryMutation()
  const { mutate: deleteTaskFactory } = useDeleteTaskFactoryMutation()

  const [, setActiveItemId] = useActiveItemId()

  const onFinish = useCallback(() => {
    setActiveItemId(null)
  }, [setActiveItemId])

  const isDisabled = useIsTaskFormDisabled(value)

  const onSubmit = () => {
    const fields: Partial<Omit<TaskFactory, "id">> = {
      task: {
        name: value.name,
        projectId: value.projectId,
        links: fixLinks(value.links),
        checklist: fixChecklist(value.checklist),
      },
      cadence: value.cadence,
    }

    updateTaskFactory({
      id: taskFactory.id,
      fields,
    })
    onFinish()
  }

  return (
    <Panel
      withSections
      kind="secondary"
      as="form"
      style={{ width: "100%" }}
      {...getFormProps({
        onClose: onFinish,
        isDisabled,
        onSubmit,
      })}
    >
      <TaskNameInput
        placeholder="Task name"
        autoFocus
        onChange={(name) => setValue((prev) => ({ ...prev, name }))}
        value={value.name}
        onSubmit={onSubmit}
      />
      <TaskLinksInput
        value={value.links}
        onChange={(links) => setValue((prev) => ({ ...prev, links }))}
      />
      <TaskChecklistInput
        value={value.checklist}
        onChange={(checklist) => setValue((prev) => ({ ...prev, checklist }))}
      />
      <HStack alignItems="center" gap={8}>
        <TaskProjectSelector
          value={value.projectId}
          onChange={(projectId) => setValue((prev) => ({ ...prev, projectId }))}
        />
        <TaskCadenceInput
          value={value.cadence}
          onChange={(cadence) => setValue((prev) => ({ ...prev, cadence }))}
        />
      </HStack>
      <EditDeleteFormFooter
        onDelete={() => {
          deleteTaskFactory({ id: taskFactory.id })
          onFinish()
        }}
        onCancel={onFinish}
        isDisabled={isDisabled}
      />
    </Panel>
  )
}

To support Escape and Enter key presses while disabling submission when the form is invalid, we use the getFormProps function. This function returns the onKeyDown and onSubmit properties for the form element.

import { preventDefault } from "../../utils/preventDefault"
import { FormEvent, KeyboardEvent } from "react"
import { stopPropagation } from "../../utils/stopPropagation"

type GetFormPropsInput = {
  onClose?: () => void
  onSubmit: () => void
  isDisabled?: boolean | string
}

export const getFormProps = ({
  onClose,
  onSubmit,
  isDisabled = false,
}: GetFormPropsInput) => {
  return {
    onKeyDown: onClose
      ? (event: KeyboardEvent<HTMLFormElement>) => {
          if (event.key === "Escape") {
            onClose()
          }
        }
      : undefined,
    onSubmit: stopPropagation<FormEvent>(
      preventDefault(() => {
        if (isDisabled) return

        onSubmit()
      })
    ),
  }
}

Since the only required field for a task factory is its name, we don't need sophisticated validation; it's sufficient to check if the name is empty.

import { TaskFactoryFormShape } from "./TaskFactoryFormShape"

export const useIsTaskFactoryFormDisabled = ({
  name,
}: TaskFactoryFormShape) => {
  if (!name.trim()) {
    return "Name is required"
  }
}

Form Footer

To allow the user to delete, save, and exit the form, we use the EditDeleteFormFooter component. We render each button, except the save button, with the type attribute set to "button" to prevent form submission. The isDisabled prop is used to disable the save button when the form is invalid. When isDisabled is a string, it serves as a tooltip for the button.

import { Button } from "../../buttons/Button"
import { HStack } from "../../layout/Stack"

type EditDeleteFormFooterProps = {
  onDelete: () => void
  onCancel: () => void
  isDisabled?: string | boolean
}

export const EditDeleteFormFooter = ({
  onCancel,
  onDelete,
  isDisabled,
}: EditDeleteFormFooterProps) => {
  return (
    <HStack
      wrap="wrap"
      justifyContent="space-between"
      fullWidth
      alignItems="center"
      gap={20}
    >
      <Button kind="alert" type="button" onClick={onDelete}>
        Delete
      </Button>
      <HStack alignItems="center" gap={8}>
        <Button
          type="button"
          isDisabled={isDisabled}
          onClick={onCancel}
          kind="secondary"
        >
          Cancel
        </Button>
        <Button>Save</Button>
      </HStack>
    </HStack>
  )
}

Task Cadence Input

We reuse most of the inputs from the task form, the only difference being the cadence input. The TaskCadenceInput component which leverages the ExpandableSelector component from RadzionKit to display a dropdown with the available cadences.

import {
  TaskCadence,
  taskCadence,
  taskCadenceName,
} from "@increaser/entities/TaskFactory"
import { IconWrapper } from "@lib/ui/icons/IconWrapper"
import { RefreshIcon } from "@lib/ui/icons/RefreshIcon"
import { HStack } from "@lib/ui/layout/Stack"
import { InputProps } from "@lib/ui/props"
import { ExpandableSelector } from "@lib/ui/select/ExpandableSelector"
import { Text } from "@lib/ui/text"

export const TaskCadenceInput = ({
  value,
  onChange,
}: InputProps<TaskCadence>) => {
  return (
    <ExpandableSelector
      style={{ width: 168 }}
      openerContent={
        <HStack alignItems="center" gap={8}>
          <IconWrapper style={{ fontSize: 14 }}>
            <RefreshIcon />
          </IconWrapper>
          <Text>{taskCadenceName[value]}</Text>
        </HStack>
      }
      value={value}
      onChange={onChange}
      options={taskCadence}
      getOptionKey={(option) => option}
      renderOption={(option) => (
        <Text key={option}>{taskCadenceName[option]}</Text>
      )}
    />
  )
}

Optimistic Updates

Both mutations make an optimistic update to the user state to ensure a seamless experience. After that, they call the API to persist the changes and finally pull the remote state. This is necessary because the changes might affect other parts of the app. Specifically, in the case of task factories, we might need to receive the newly generated tasks.

import { ApiInterface } from "@increaser/api-interface/ApiInterface"
import { recordMap } from "@lib/utils/record/recordMap"
import { useApi } from "@increaser/api-ui/state/ApiContext"
import { useMutation } from "@tanstack/react-query"
import {
  useAssertUserState,
  useUserState,
} from "@increaser/ui/user/UserStateContext"

export const useUpdateTaskFactoryMutation = () => {
  const api = useApi()
  const { updateState, pullRemoteState } = useUserState()
  const { taskFactories } = useAssertUserState()

  return useMutation({
    mutationFn: async (input: ApiInterface["updateTaskFactory"]["input"]) => {
      updateState({
        taskFactories: recordMap(taskFactories, (item) =>
          item.id === input.id ? { ...item, ...input.fields } : item
        ),
      })

      await api.call("updateTaskFactory", input)

      pullRemoteState()
    },
  })
}

Adding New Task Factories

The AddTaskFactory component leverages the Opener and ListAddButton components from RadzionKit to display the button that opens the form for creating a new task factory. The Opener is a wrapper around useState that provides a more declarative way to manage the open state of a component. The ListAddButton is a button built on top of the PrefixedItemFrame component we used earlier.

import { Opener } from "@lib/ui/base/Opener"
import { ListAddButton } from "@lib/ui/list/ListAddButton"
import { CreateTaskFactoryForm } from "./form/CreateTaskFactoryForm"

export const AddTaskFactory = () => {
  return (
    <Opener
      renderOpener={({ onOpen, isOpen }) =>
        isOpen ? null : (
          <ListAddButton onClick={onOpen} text="Add a recurring task" />
        )
      }
      renderContent={({ onClose }) => (
        <CreateTaskFactoryForm onFinish={onClose} />
      )}
    />
  )
}

In the CreateTaskFactoryForm we use similar priniciples and component as in the EditTaskFactoryForm. To make the user experience more seamless we again use the fixLinks and fixChecklist functions that will remove any empty links or checklist items.

import { useCallback, useState } from "react"
import { getId } from "@increaser/entities-utils/shared/getId"
import { Panel } from "@lib/ui/panel/Panel"
import { HStack } from "@lib/ui/layout/Stack"
import { otherProject } from "@increaser/entities/Project"
import { TaskNameInput } from "../../tasks/TaskNameInput"
import { TaskProjectSelector } from "../../tasks/TaskProjectSelector"
import { TaskFactoryFormShape } from "./TaskFactoryFormShape"
import { useIsTaskFactoryFormDisabled } from "./useIsTaskFactoryFormDisabled"
import { fixLinks } from "../../tasks/form/fixLinks"
import { getFormProps } from "@lib/ui/form/utils/getFormProps"
import { TaskFactory } from "@increaser/entities/TaskFactory"
import { useCreateTaskFactoryMutation } from "../api/useCreateTaskFactoryMutation"
import { TaskLinksInput } from "../../tasks/form/TaskLinksInput"
import { TaskCadenceInput } from "./TaskCadenceInput"
import { TaskChecklistInput } from "../../tasks/form/checklist/TaskChecklistInput"
import { fixChecklist } from "../../tasks/form/checklist/fixChecklist"
import { CreateFormFooter } from "@lib/ui/form/components/CreateFormFooter"

type CreateTaskFormProps = {
  onFinish: (id?: string) => void
}

export const CreateTaskFactoryForm = ({ onFinish }: CreateTaskFormProps) => {
  const [value, setValue] = useState<TaskFactoryFormShape>({
    name: "",
    projectId: otherProject.id,
    links: [],
    cadence: "week",
    checklist: [],
  })
  const { mutate } = useCreateTaskFactoryMutation()

  const isDisabled = useIsTaskFactoryFormDisabled(value)

  const onSubmit = useCallback(() => {
    const taskFactory: TaskFactory = {
      id: getId(),
      task: {
        name: value.name,
        projectId: value.projectId,
        links: fixLinks(value.links),
        checklist: fixChecklist(value.checklist),
      },
      cadence: value.cadence,
    }
    mutate(taskFactory)
    onFinish(taskFactory.id)
  }, [mutate, onFinish, value])

  return (
    <Panel
      withSections
      kind="secondary"
      as="form"
      {...getFormProps({
        onClose: () => onFinish(),
        isDisabled,
        onSubmit,
      })}
    >
      <TaskNameInput
        placeholder="Task name"
        autoFocus
        value={value.name}
        onChange={(name) => setValue((prev) => ({ ...prev, name }))}
        onSubmit={onSubmit}
      />
      <TaskLinksInput
        value={value.links}
        onChange={(links) => setValue((prev) => ({ ...prev, links }))}
      />
      <TaskChecklistInput
        value={value.checklist}
        onChange={(checklist) => setValue((prev) => ({ ...prev, checklist }))}
      />
      <HStack alignItems="center" gap={8}>
        <TaskProjectSelector
          value={value.projectId}
          onChange={(projectId) => setValue((prev) => ({ ...prev, projectId }))}
        />
        <TaskCadenceInput
          value={value.cadence}
          onChange={(cadence) => setValue((prev) => ({ ...prev, cadence }))}
        />
      </HStack>
      <CreateFormFooter onCancel={onFinish} isDisabled={isDisabled} />
    </Panel>
  )
}