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.
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
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>
)
}
Top comments (3)
Hi, I just want to say thank you for taking the time to write a text version for those of us who can't or don't want to watch a video ๐
You're welcome!
Cool)))