diff --git a/app/board/components/tasks/modals/add-task-modal.tsx b/app/board/components/tasks/modals/add-task-modal.tsx index 7c2ca32..d81d4aa 100644 --- a/app/board/components/tasks/modals/add-task-modal.tsx +++ b/app/board/components/tasks/modals/add-task-modal.tsx @@ -6,7 +6,7 @@ import { LuPlusCircle } from 'react-icons/lu' import { TaskModal, type TaskSchema } from './task-modal' -import { useTasksBoard } from '@app/board/context' +import { useTasksBoard } from '@app/board/hooks' export function AddTaskModal() { const { onOpen, onOpenChange, isOpen, onClose } = useDisclosure() diff --git a/app/board/components/tasks/modals/update-task-modal.tsx b/app/board/components/tasks/modals/update-task-modal.tsx index 824062c..9dfa4d8 100644 --- a/app/board/components/tasks/modals/update-task-modal.tsx +++ b/app/board/components/tasks/modals/update-task-modal.tsx @@ -3,7 +3,7 @@ import type { SetOptional } from 'type-fest' import { TaskModal, type TaskSchema } from './task-modal' -import { useTasksBoard } from '@app/board/context' +import { useTasksBoard } from '@app/board/hooks' namespace UpdateTaskModal { export type Props = Readonly< diff --git a/app/board/components/tasks/reschedule-overdue-tasks.spec.tsx b/app/board/components/tasks/reschedule-overdue-tasks.spec.tsx index 01a61d7..ee160d8 100644 --- a/app/board/components/tasks/reschedule-overdue-tasks.spec.tsx +++ b/app/board/components/tasks/reschedule-overdue-tasks.spec.tsx @@ -3,9 +3,9 @@ import { userEvent, type UserEvent } from '@testing-library/user-event' import { RescheduleOverdueTasks } from './reschedule-overdue-tasks' -import { useTasksBoard } from '@app/board/context' +import { useTasksBoard } from '@app/board/hooks' -vi.mock('@app/board/context') +vi.mock('@app/board/hooks') describe('RescheduleOverdueTasks', () => { const rescheduleOverdueTasksSpy = vi.fn().mockResolvedValue(undefined) diff --git a/app/board/components/tasks/reschedule-overdue-tasks.tsx b/app/board/components/tasks/reschedule-overdue-tasks.tsx index 352884f..5ceae5a 100644 --- a/app/board/components/tasks/reschedule-overdue-tasks.tsx +++ b/app/board/components/tasks/reschedule-overdue-tasks.tsx @@ -10,7 +10,7 @@ import { LuCalendarClock } from 'react-icons/lu' import { CalendarPresets } from '../misc' import { now } from '@app/board/helpers/date' -import { useTasksBoard } from '@app/board/context' +import { useTasksBoard } from '@app/board/hooks' export function RescheduleOverdueTasks() { const [dueDateValue, setDueDateValue] = useState() diff --git a/app/board/components/tasks/task-card.tsx b/app/board/components/tasks/task-card.tsx index d0284a1..dfb1813 100644 --- a/app/board/components/tasks/task-card.tsx +++ b/app/board/components/tasks/task-card.tsx @@ -10,7 +10,7 @@ import { LuCalendarDays } from 'react-icons/lu' import { UpdateTaskModal } from './modals' -import { useTasksBoard } from '@app/board/context' +import { useTasksBoard } from '@app/board/hooks' import { formatDateValue, isOverdue } from '@app/board/helpers/date' import type { BoardKeyWithoutOverdue, PickedTask } from '@app/board/types' @@ -64,7 +64,7 @@ function TaskCard({ if (!isDone && task && sourceKey !== 'done') setTimeout(async () => { - await markTaskAsDone(task, sourceKey) + await markTaskAsDone(task) }, 500) }} /> diff --git a/app/board/components/tasks/tasks-board.tsx b/app/board/components/tasks/tasks-board.tsx index 9d80353..23f6265 100644 --- a/app/board/components/tasks/tasks-board.tsx +++ b/app/board/components/tasks/tasks-board.tsx @@ -2,10 +2,11 @@ import { DragDropContext, Droppable, type DropResult } from '@hello-pangea/dnd' import { Fragment } from 'react' +import { observer } from 'mobx-react-lite' import { TasksList } from './tasks-list' -import { useTasksBoard } from '@app/board/context' +import { useTasksBoard } from '@app/board/hooks' import type { BoardKey, BoardKeyWithoutOverdue } from '@app/board/types' namespace TasksBoard { @@ -14,7 +15,7 @@ namespace TasksBoard { }> } -function TasksBoard({ showDone }: TasksBoard.Props) { +const TasksBoard = observer(({ showDone }: TasksBoard.Props) => { const { tasksBoard, reorderTasks, changeTaskTable } = useTasksBoard() const tasksBoardEntries = Object.entries(tasksBoard).filter(([key]) => @@ -42,7 +43,7 @@ function TasksBoard({ showDone }: TasksBoard.Props) { if (destination.droppableId === 'overdue') return if (itemToDrop) { - await changeTaskTable(itemToDrop, source, destination) + await changeTaskTable(itemToDrop, destination) } } } @@ -73,6 +74,6 @@ function TasksBoard({ showDone }: TasksBoard.Props) { ) -} +}) export { TasksBoard } diff --git a/app/board/components/tasks/tasks-list.spec.tsx b/app/board/components/tasks/tasks-list.spec.tsx index be06d6b..a1be22e 100644 --- a/app/board/components/tasks/tasks-list.spec.tsx +++ b/app/board/components/tasks/tasks-list.spec.tsx @@ -6,6 +6,13 @@ import type { ReactNode } from 'react' import { TasksList } from './tasks-list' +vi.mock('@app/board/hooks', () => ({ + useTasksBoard: () => ({ + tasksBoard: vi.fn(), + markTaskAsDone: vi.fn(), + }), +})) + const Wrapper = ({ children }: Readonly<{ children: ReactNode }>) => ( diff --git a/app/board/context/tasks-board.context.spec.tsx b/app/board/context/tasks-board.context.spec.tsx deleted file mode 100644 index 55a886d..0000000 --- a/app/board/context/tasks-board.context.spec.tsx +++ /dev/null @@ -1,352 +0,0 @@ -import { getLocalTimeZone } from '@internationalized/date' -import type { Task } from '@prisma/client' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' -import { userEvent, type UserEvent } from '@testing-library/user-event' -import { Fragment } from 'react' -import { mock } from 'vitest-mock-extended' - -import { - addTask, - getTask, - rescheduleOverdueTasks, - updateTask, -} from '../actions' -import { now, tomorrow, yesterday } from '../helpers/date' -import type { BoardKeyWithoutOverdue } from '../types' - -import { TasksBoardProvider, useTasksBoard } from './tasks-board.context' - -vi.mock('@app/board/actions') - -function TestingWrapper({ initialTasks }: Readonly<{ initialTasks: Task[] }>) { - return ( - - - - - - ) -} - -function TestingComponent() { - const { - tasksBoard, - addTask, - updateTask, - markTaskAsDone, - rescheduleOverdueTasks, - reorderTasks, - changeTaskTable, - } = useTasksBoard() - - const tasksBoardEntries = Object.entries(tasksBoard) - - return ( -
- {tasksBoardEntries.map(([id, { header, items }]) => ( - - {items.length > 0 && ( -
-

{header}

- - {items.map(task => ( -
-

{task.name}

-

{task.description}

-

{task.dueDate?.toString()}

- {task.isDone && ( -

{task.isDone.toString()}

- )} - - - - - - - - - - - - -
- ))} -
- )} -
- ))} - - -
- ) -} - -describe('TasksBoardProvider', () => { - const taskName = 'Task 1' - - const initialTasks = mock([ - { - id: '1', - name: taskName, - dueDate: now.toDate(getLocalTimeZone()), - isDone: false, - }, - ]) - - let user: UserEvent - - beforeEach(() => { - user = userEvent.setup() - }) - - test('should render tasks board', () => { - render() - - expect(screen.getByText(taskName)).toBeInTheDocument() - }) - - describe('addTask', () => { - test('should add task', async () => { - const addTaskActionSpy = vi - .mocked(addTask) - .mockImplementation((data: Parameters[0]) => - Promise.resolve({ - ...data, - id: '10', - isDone: false, - } as Task) - ) - - render() - - await user.click(screen.getByRole('button', { name: 'Add task' })) - - expect(screen.getByText('Task 10')).toBeInTheDocument() - expect(addTaskActionSpy).toHaveBeenCalledWith({ - name: 'Task 10', - }) - }) - }) - - describe('updateTask', () => { - test('should update task within the same section', async () => { - const updateTaskActionSpy = vi - .mocked(updateTask) - .mockImplementation(({ id, data }: Parameters[0]) => - Promise.resolve({ - ...data, - id, - isDone: false, - } as Task) - ) - const getTaskActionSpy = vi - .mocked(getTask) - .mockResolvedValue(initialTasks[0]!) - - render() - - expect(screen.getByText('Task 1')).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'Update' })) - - expect(screen.getByText('Task 2')).toBeInTheDocument() - expect(getTaskActionSpy).toHaveBeenCalledWith('1') - expect(updateTaskActionSpy).toHaveBeenCalledWith({ - id: '1', - data: { - name: 'Task 2', - }, - }) - }) - - test('should update task within another section', async () => { - const updateTaskActionSpy = vi - .mocked(updateTask) - .mockImplementation(({ id, data }: Parameters[0]) => - Promise.resolve({ - ...data, - id, - isDone: false, - } as Task) - ) - const getTaskActionSpy = vi - .mocked(getTask) - .mockResolvedValue(initialTasks[1]!) - - render() - - expect(screen.getByText('Today')).toBeInTheDocument() - - await user.click( - screen.getAllByRole('button', { name: 'Reschedule' })[0]! - ) - - expect(getTaskActionSpy).toHaveBeenCalledWith('1') - expect(screen.getByText('Tomorrow')).toBeInTheDocument() - expect(updateTaskActionSpy).toHaveBeenCalledWith({ - id: '1', - data: { - dueDate: tomorrow.toDate(getLocalTimeZone()), - }, - }) - }) - }) - - describe('markTaskAsDone', () => { - test('should mark task as done', async () => { - const updateTaskActionSpy = vi - .mocked(updateTask) - .mockImplementation((data: Parameters[0]) => - Promise.resolve({ - ...data, - id: '10', - isDone: true, - } as unknown as Task) - ) - const getTaskActionSpy = vi - .mocked(getTask) - .mockResolvedValue(initialTasks[0]!) - - render() - - expect(screen.getByText('Task 1')).toBeInTheDocument() - - await user.click( - screen.getAllByRole('button', { name: 'Mark as done' })[0]! - ) - - expect(screen.getByText('Task 1')).toBeInTheDocument() - expect(getTaskActionSpy).toHaveBeenCalledWith('1') - expect(updateTaskActionSpy).toHaveBeenCalled() - expect(screen.getByTestId('is-done')).toHaveTextContent('true') - }) - }) - - describe('rescheduleOverdueTasks', () => { - test('should reschedule overdue tasks', async () => { - const initialTasks = mock([ - { - id: '1', - name: taskName, - dueDate: yesterday.toDate(getLocalTimeZone()), - isDone: false, - }, - ]) - - const rescheduleOverdueTasksActionSpy = vi.mocked(rescheduleOverdueTasks) - - render() - - expect(screen.getByText('Overdue')).toBeInTheDocument() - - await user.click( - screen.getAllByRole('button', { name: 'Reschedule overdue' })[0]! - ) - - expect(screen.getByText('Today')).toBeInTheDocument() - expect(rescheduleOverdueTasksActionSpy).toHaveBeenCalled() - }) - }) - - describe('reorderTasks', () => { - test('should reorder tasks', async () => { - const updateTaskActionSpy = vi - .mocked(updateTask) - .mockImplementation((data: Parameters[0]) => - Promise.resolve({ - ...data, - id: '10', - isDone: true, - } as unknown as Task) - ) - - render() - - await user.click(screen.getAllByRole('button', { name: 'Reorder' })[0]!) - - expect(updateTaskActionSpy).toHaveBeenCalled() - }) - }) - - describe('changeTaskTable', () => { - test('should change task table', async () => { - const updateTaskActionSpy = vi - .mocked(updateTask) - .mockImplementation((data: Parameters[0]) => - Promise.resolve({ - ...data, - id: '10', - isDone: true, - } as unknown as Task) - ) - - render() - - await user.click( - screen.getAllByRole('button', { name: 'Change table' })[0]! - ) - - expect(updateTaskActionSpy).toHaveBeenCalled() - }) - }) -}) diff --git a/app/board/context/tasks-board.context.tsx b/app/board/context/tasks-board.context.tsx index 215156c..fc7f033 100644 --- a/app/board/context/tasks-board.context.tsx +++ b/app/board/context/tasks-board.context.tsx @@ -1,409 +1,21 @@ 'use client' -import type { DraggableLocation } from '@hello-pangea/dnd' -import { - fromDate, - getLocalTimeZone, - type DateValue, -} from '@internationalized/date' +import { createContext, type ReactNode } from 'react' import type { Task } from '@prisma/client' -import type { ReactNode } from 'react' -import { createContext, useContext, useState } from 'react' -import { - addTask as addTaskAction, - getTask, - rescheduleOverdueTasks as rescheduleOverdueTasksAction, - updateTask as updateTaskAction, - type UpdateTaskParams, -} from '@app/board/actions' -import { - boardKeyFactory, - dueDateFactory, - nextWeek, - now, - tomorrow, -} from '@app/board/helpers/date' -import { useTasksQuery } from '@app/board/hooks' -import type { - AddTask, - BoardKeyWithoutOverdue, - TasksBoard, -} from '@app/board/types' -import { addAndReorder, initialReorder, reorder } from '@app/utils/reorder' +import { TasksBoardStore } from '../stores' -export const TasksBoardContext = createContext<{ - tasksBoard: TasksBoard - reorderTasks: ( - boardKey: BoardKeyWithoutOverdue, - sourceIndex: number, - destinationIndex: number - ) => Promise - changeTaskTable: ( - task: Task, - source: DraggableLocation, - destination: DraggableLocation - ) => Promise - addTask: (data: AddTask) => Promise - updateTask: (data: UpdateTaskParams) => Promise - markTaskAsDone: ( - task: Task, - sourceKey: Exclude - ) => Promise - rescheduleOverdueTasks: (dueDateValue: DateValue) => Promise -}>({ - tasksBoard: { - overdue: { - id: 'overdue', - header: 'Overdue', - items: [], - }, - today: { - id: 'today', - header: 'Today', - items: [], - }, - tomorrow: { - id: 'tomorrow', - header: 'Tomorrow', - items: [], - }, - thisWeek: { - id: 'thisWeek', - header: 'This week', - items: [], - }, - nextWeek: { - id: 'nextWeek', - header: 'Next week', - items: [], - }, - future: { - id: 'future', - header: 'Future', - items: [], - }, - noDate: { - id: 'noDate', - header: 'No date', - items: [], - }, - done: { - id: 'done', - header: 'Done', - items: [], - }, - }, - reorderTasks: () => Promise.resolve(), - changeTaskTable: () => Promise.resolve(), - addTask: () => Promise.resolve(), - updateTask: () => Promise.resolve(), - markTaskAsDone: () => Promise.resolve(), - rescheduleOverdueTasks: () => Promise.resolve(), -}) +export const TasksBoardContext = createContext(null!) -namespace TasksBoardProvider { - export type Props = Readonly<{ children: ReactNode; initialTasks: Task[] }> -} - -export const useTasksBoard = () => useContext(TasksBoardContext) - -function TasksBoardProvider({ +export function TasksBoardProvider({ children, initialTasks, -}: TasksBoardProvider.Props) { - const { data: tasks } = useTasksQuery(initialTasks) - - const [tasksBoard, setTasksBoard] = useState({ - overdue: { - id: 'overdue', - header: 'Overdue', - items: initialReorder( - tasks.filter( - task => - task.dueDate && - !task.isDone && - fromDate(task.dueDate, getLocalTimeZone()).compare(now) < 0 - ) - ), - }, - today: { - id: 'today', - header: 'Today', - items: initialReorder( - tasks.filter( - task => - task.dueDate && - !task.isDone && - fromDate(task.dueDate, getLocalTimeZone()).compare(now) >= 0 && - fromDate(task.dueDate, getLocalTimeZone()).compare(tomorrow) < 0 - ) - ), - }, - tomorrow: { - id: 'tomorrow', - header: 'Tomorrow', - items: initialReorder( - tasks.filter( - task => - task.dueDate && - !task.isDone && - fromDate(task.dueDate, getLocalTimeZone()).compare(tomorrow) >= 0 && - fromDate(task.dueDate, getLocalTimeZone()).compare( - tomorrow.add({ days: 1 }) - ) < 0 - ) - ), - }, - thisWeek: { - id: 'thisWeek', - header: 'This week', - items: initialReorder( - tasks.filter( - task => - task.dueDate && - !task.isDone && - fromDate(task.dueDate, getLocalTimeZone()).compare( - tomorrow.add({ days: 1 }) - ) >= 0 && - fromDate(task.dueDate, getLocalTimeZone()).compare(nextWeek) < 0 - ) - ), - }, - nextWeek: { - id: 'nextWeek', - header: 'Next week', - items: initialReorder( - tasks.filter( - task => - task.dueDate && - !task.isDone && - fromDate(task.dueDate, getLocalTimeZone()).compare(nextWeek) >= 0 && - fromDate(task.dueDate, getLocalTimeZone()).compare( - nextWeek.add({ days: 7 }) - ) < 0 - ) - ), - }, - future: { - id: 'future', - header: 'Future', - items: initialReorder( - tasks.filter( - task => - task.dueDate && - !task.isDone && - fromDate(task.dueDate, getLocalTimeZone()).compare( - nextWeek.add({ days: 7 }) - ) > 0 - ) - ), - }, - noDate: { - id: 'noDate', - header: 'No date', - items: initialReorder( - tasks.filter(task => !task.dueDate && !task.isDone) - ), - }, - done: { - id: 'done', - header: 'Done', - items: initialReorder(tasks.filter(task => task.isDone)), - }, - }) - - async function reorderTasks( - boardKey: BoardKeyWithoutOverdue, - sourceIndex: number, - destinationIndex: number - ) { - setTasksBoard({ - ...tasksBoard, - [boardKey]: { - ...tasks, - items: reorder( - tasksBoard[boardKey].items, - destinationIndex, - sourceIndex - ), - }, - }) - - const task = tasksBoard[boardKey].items[sourceIndex] - - if (task) - await updateTaskAction({ - id: task.id, - data: { - order: destinationIndex, - }, - }) - } - - async function changeTaskTable( - task: Task, - source: DraggableLocation, - destination: DraggableLocation - ) { - const sourceKey = source.droppableId as BoardKeyWithoutOverdue - const destinationKey = destination.droppableId as BoardKeyWithoutOverdue - - const sourceTasks = tasksBoard[sourceKey] - const destinationTasks = tasksBoard[destinationKey] - - const updatedData = - destinationKey === 'done' - ? { isDone: true } - : { - dueDate: dueDateFactory(destinationKey), - } - - const updatedTask = { ...task, ...updatedData } - - setTasksBoard({ - ...tasksBoard, - [sourceKey]: { - ...sourceTasks, - items: sourceTasks.items.filter(({ id }) => id !== task.id), - }, - [destinationKey]: { - ...destinationTasks, - items: addAndReorder( - destinationTasks.items, - destination.index, - updatedTask - ), - }, - }) - - await updateTaskAction({ - id: task.id, - data: { - ...updatedData, - order: destination.index, - }, - }) - } - - async function addTask(data: AddTask) { - const newTask = await addTaskAction(data) - - const boardKey = boardKeyFactory(data.dueDate) - - setTasksBoard({ - ...tasksBoard, - [boardKey]: { - ...tasksBoard[boardKey], - items: [...tasksBoard[boardKey].items, newTask], - }, - }) - } - - async function updateTask({ id, data }: UpdateTaskParams) { - const oldTask = await getTask(id) - - if (!oldTask) return - - const { dueDate: oldDueDate } = oldTask - - const updatedTask = await updateTaskAction({ - id, - data, - }) - - const oldBoardKey = boardKeyFactory(oldDueDate) - const boardKey = boardKeyFactory(data.dueDate) - - if (oldBoardKey === boardKey) - setTasksBoard({ - ...tasksBoard, - [boardKey]: { - ...tasksBoard[boardKey], - items: [ - ...tasksBoard[boardKey].items.filter(task => task.id !== id), - updatedTask, - ], - }, - }) - else - setTasksBoard({ - ...tasksBoard, - [oldBoardKey]: { - ...tasksBoard[oldBoardKey], - items: tasksBoard[oldBoardKey].items.filter(task => task.id !== id), - }, - [boardKey]: { - ...tasksBoard[boardKey], - items: [...tasksBoard[boardKey].items, updatedTask], - }, - }) - } - - async function markTaskAsDone(task: Task, sourceKey: BoardKeyWithoutOverdue) { - const updatedTask = { ...task, isDone: true } - - setTasksBoard({ - ...tasksBoard, - [sourceKey]: { - ...tasksBoard[sourceKey], - items: tasksBoard[sourceKey].items.filter(({ id }) => id !== task.id), - }, - done: { - ...tasksBoard.done, - items: [...tasksBoard.done.items, updatedTask], - }, - }) - - await updateTaskAction({ - id: task.id, - data: updatedTask, - }) - } - - async function rescheduleOverdueTasks(dueDateValue: DateValue) { - const overdueTasks = tasksBoard.overdue.items - - const boardKey = boardKeyFactory(dueDateValue) - - setTasksBoard({ - ...tasksBoard, - overdue: { - ...tasksBoard.overdue, - items: [], - }, - [boardKey]: { - ...tasksBoard[boardKey], - items: [ - ...tasksBoard[boardKey].items, - ...overdueTasks.map(task => ({ - ...task, - dueDate: dueDateValue.toDate(getLocalTimeZone()), - })), - ], - }, - }) - - await rescheduleOverdueTasksAction({ - dueDate: dueDateValue.toDate(getLocalTimeZone()), - }) - } +}: Readonly<{ children: ReactNode; initialTasks: Task[] }>) { + const store = new TasksBoardStore(initialTasks) return ( - + {children} ) } - -export { TasksBoardProvider } diff --git a/app/board/hooks/index.ts b/app/board/hooks/index.ts index 5f37952..c282d3d 100644 --- a/app/board/hooks/index.ts +++ b/app/board/hooks/index.ts @@ -1 +1 @@ -export * from './use-tasks.query' +export * from './use-tasks-board' diff --git a/app/board/hooks/use-tasks-board.ts b/app/board/hooks/use-tasks-board.ts new file mode 100644 index 0000000..3360f5f --- /dev/null +++ b/app/board/hooks/use-tasks-board.ts @@ -0,0 +1,5 @@ +import { useContext } from 'react' + +import { TasksBoardContext } from '../context' + +export const useTasksBoard = () => useContext(TasksBoardContext) diff --git a/app/board/hooks/use-tasks.query.ts b/app/board/hooks/use-tasks.query.ts deleted file mode 100644 index b564fe1..0000000 --- a/app/board/hooks/use-tasks.query.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import type { Task } from '@prisma/client' - -import { getTasks } from '../actions' - -export function useTasksQuery(initialData: Task[]) { - return useQuery({ - queryKey: ['tasks'], - initialData, - queryFn: getTasks, - refetchOnMount: true, - }) -} diff --git a/app/board/stores/index.ts b/app/board/stores/index.ts new file mode 100644 index 0000000..2b6ffff --- /dev/null +++ b/app/board/stores/index.ts @@ -0,0 +1 @@ +export * from './tasks-board.store' diff --git a/app/board/stores/tasks-board.store.spec.ts b/app/board/stores/tasks-board.store.spec.ts new file mode 100644 index 0000000..023ce32 --- /dev/null +++ b/app/board/stores/tasks-board.store.spec.ts @@ -0,0 +1,192 @@ +import { getLocalTimeZone } from '@internationalized/date' +import type { Task } from '@prisma/client' +import { mock } from 'vitest-mock-extended' + +import { addTask, getTask, updateTask } from '../actions' +import { now, tomorrow } from '../helpers/date' + +import { TasksBoardStore } from './tasks-board.store' + +vi.mock('@app/board/actions') + +describe('TasksBoardStore', () => { + test('should initialize with initial tasks', () => { + const tasks = [mock()] + + const store = new TasksBoardStore(tasks) + + expect(store.tasks).toEqual(tasks) + }) + + test('should create tasks board', () => { + const tasks = [ + mock({ + dueDate: now.toDate(getLocalTimeZone()), + isDone: false, + }), + ] + + const store = new TasksBoardStore(tasks) + + expect(store.tasks).toEqual(tasks) + expect(store.tasksBoard.today.items).toEqual(tasks) + }) + + test('should reorder tasks', async () => { + const tasks = [ + mock({ + dueDate: now.toDate(getLocalTimeZone()), + isDone: false, + }), + mock({ + dueDate: now.toDate(getLocalTimeZone()), + isDone: false, + }), + mock({ + dueDate: now.toDate(getLocalTimeZone()), + isDone: false, + }), + ] + + const store = new TasksBoardStore(tasks) + + await store.reorderTasks('today', 0, 1) + + expect(store.tasksBoard.today.items).toEqual([tasks[1], tasks[0], tasks[2]]) + }) + + test('should change task table', async () => { + const tasks = [ + mock({ + dueDate: now.toDate(getLocalTimeZone()), + isDone: false, + }), + mock({ + dueDate: now.toDate(getLocalTimeZone()), + id: '1', + isDone: false, + }), + mock({ + dueDate: tomorrow.toDate(getLocalTimeZone()), + isDone: false, + }), + ] + + const store = new TasksBoardStore(tasks) + + await store.changeTaskTable(tasks[1]!, { + droppableId: 'tomorrow', + index: 0, + }) + + expect(store.tasksBoard.today.items).toEqual([tasks[0]]) + expect(store.tasksBoard.tomorrow.items).toEqual([ + { + ...tasks[1], + dueDate: tomorrow.toDate(getLocalTimeZone()), + }, + tasks[2], + ]) + }) + + test('should add task', async () => { + const tasks = [ + mock({ + isDone: false, + name: 'Task 0', + }), + ] + + const newTaskFactory = (name: string) => + mock({ + name, + isDone: false, + }) + + vi.mocked(addTask).mockImplementation(({ name }) => + Promise.resolve(newTaskFactory(name)) + ) + + const store = new TasksBoardStore(tasks) + + await store.addTask({ + name: 'Task 1', + }) + + expect(store.tasks.find(task => task.name === 'Task 1')).toBeDefined() + }) + + test('should update task', async () => { + const tasks = [ + mock({ + isDone: false, + name: 'Task 0', + }), + ] + + vi.mocked(getTask).mockResolvedValue(tasks[0]!) + vi.mocked(updateTask).mockImplementation(({ id, data }) => + Promise.resolve( + mock({ + ...data, + id, + isDone: false, + }) + ) + ) + + const store = new TasksBoardStore(tasks) + + await store.updateTask({ + id: tasks[0]!.id, + data: { + name: 'Task 1', + }, + }) + + expect(store.tasks.find(task => task.name === 'Task 1')).toBeDefined() + }) + + test('should mark task as done', async () => { + const tasks = [ + mock({ + isDone: false, + name: 'Task 0', + }), + ] + + vi.mocked(getTask).mockResolvedValue(tasks[0]!) + vi.mocked(updateTask).mockImplementation(({ id, data }) => + Promise.resolve( + mock({ + ...data, + id, + isDone: true, + }) + ) + ) + + const store = new TasksBoardStore(tasks) + + await store.markTaskAsDone(tasks[0]!) + + expect(store.tasks.find(task => task.isDone)).toBeDefined() + }) + + test('should reschedule overdue tasks', async () => { + const tasks = [ + mock({ + dueDate: now.add({ days: 1 }).toDate(getLocalTimeZone()), + isDone: false, + }), + ] + + const store = new TasksBoardStore(tasks) + + await store.rescheduleOverdueTasks(tomorrow) + + expect(store.tasks[0]?.dueDate?.toISOString()).toEqual( + tomorrow.toDate(getLocalTimeZone()).toISOString() + ) + }) +}) diff --git a/app/board/stores/tasks-board.store.tsx b/app/board/stores/tasks-board.store.tsx new file mode 100644 index 0000000..9c4825b --- /dev/null +++ b/app/board/stores/tasks-board.store.tsx @@ -0,0 +1,268 @@ +'use client' + +import type { DraggableLocation } from '@hello-pangea/dnd' +import { + fromDate, + getLocalTimeZone, + type DateValue, +} from '@internationalized/date' +import type { Task } from '@prisma/client' +import { makeAutoObservable } from 'mobx' + +import { dueDateFactory, nextWeek, now, tomorrow } from '../helpers/date' +import type { AddTask } from '../types' +import type { BoardKeyWithoutOverdue, TasksBoard } from '../types/board' + +import { + addTask as addTaskAction, + getTask, + rescheduleOverdueTasks as rescheduleOverdueTasksAction, + updateTask as updateTaskAction, + type UpdateTaskParams, +} from '@app/board/actions' +import { addAndReorder, initialReorder, reorder } from '@app/utils/reorder' + +export class TasksBoardStore { + tasks: Task[] = [] + + constructor(initialTasks: Task[]) { + makeAutoObservable(this) + + this.tasks = initialTasks + } + + get tasksBoard(): TasksBoard { + return { + overdue: { + id: 'overdue', + header: 'Overdue', + items: initialReorder( + this.tasks.filter( + task => + task.dueDate && + !task.isDone && + fromDate(task.dueDate, getLocalTimeZone()).compare(now) < 0 + ) + ), + }, + today: { + id: 'today', + header: 'Today', + items: initialReorder( + this.tasks.filter( + task => + task.dueDate && + !task.isDone && + fromDate(task.dueDate, getLocalTimeZone()).compare(now) >= 0 && + fromDate(task.dueDate, getLocalTimeZone()).compare(tomorrow) < 0 + ) + ), + }, + tomorrow: { + id: 'tomorrow', + header: 'Tomorrow', + items: initialReorder( + this.tasks.filter( + task => + task.dueDate && + !task.isDone && + fromDate(task.dueDate, getLocalTimeZone()).compare(tomorrow) >= + 0 && + fromDate(task.dueDate, getLocalTimeZone()).compare( + tomorrow.add({ days: 1 }) + ) < 0 + ) + ), + }, + thisWeek: { + id: 'thisWeek', + header: 'This week', + items: initialReorder( + this.tasks.filter( + task => + task.dueDate && + !task.isDone && + fromDate(task.dueDate, getLocalTimeZone()).compare( + tomorrow.add({ days: 1 }) + ) >= 0 && + fromDate(task.dueDate, getLocalTimeZone()).compare(nextWeek) < 0 + ) + ), + }, + nextWeek: { + id: 'nextWeek', + header: 'Next week', + items: initialReorder( + this.tasks.filter( + task => + task.dueDate && + !task.isDone && + fromDate(task.dueDate, getLocalTimeZone()).compare(nextWeek) >= + 0 && + fromDate(task.dueDate, getLocalTimeZone()).compare( + nextWeek.add({ days: 7 }) + ) < 0 + ) + ), + }, + future: { + id: 'future', + header: 'Future', + items: initialReorder( + this.tasks.filter( + task => + task.dueDate && + !task.isDone && + fromDate(task.dueDate, getLocalTimeZone()).compare( + nextWeek.add({ days: 7 }) + ) > 0 + ) + ), + }, + noDate: { + id: 'noDate', + header: 'No date', + items: initialReorder( + this.tasks.filter(task => !task.dueDate && !task.isDone) + ), + }, + done: { + id: 'done', + header: 'Done', + items: initialReorder(this.tasks.filter(task => task.isDone)), + }, + } + } + + reorderTasks = async ( + boardKey: BoardKeyWithoutOverdue, + sourceIndex: number, + destinationIndex: number + ) => { + const destinationTasks = this.tasksBoard[boardKey].items + + const task = destinationTasks[sourceIndex] + const destinationTaskWithSameOrder = destinationTasks.find( + task => task.order === destinationIndex + ) + + if (!task) return + + this.tasks = [ + ...this.tasks.filter( + ({ id }) => !destinationTasks.map(task => task.id).includes(id) + ), + ...reorder(destinationTasks, destinationIndex, sourceIndex), + ] + + await updateTaskAction({ + id: task.id, + data: { + order: destinationIndex, + }, + }) + + if (destinationTaskWithSameOrder) { + await updateTaskAction({ + id: destinationTaskWithSameOrder.id, + data: { + order: destinationIndex - 1, + }, + }) + } + } + + changeTaskTable = async (task: Task, destination: DraggableLocation) => { + const destinationKey = destination.droppableId as BoardKeyWithoutOverdue + + const destinationTasks = this.tasksBoard[destinationKey].items + + const updatedData = + destinationKey === 'done' + ? { isDone: true } + : { + dueDate: dueDateFactory(destinationKey), + } + + const updatedTask = { ...task, ...updatedData } + + const destinationTaskWithSameOrder = destinationTasks.find( + task => task.order === destination.index + ) + + this.tasks = [ + ...this.tasks.filter( + ({ id }) => + !destinationTasks.map(task => task.id).includes(id) && id !== task.id + ), + ...addAndReorder(destinationTasks, destination.index, updatedTask), + ] + + if (destinationTaskWithSameOrder) { + await updateTaskAction({ + id: destinationTaskWithSameOrder.id, + data: { + order: destination.index - 1, + }, + }) + } + + await updateTaskAction({ + id: task.id, + data: { + ...updatedData, + order: destination.index, + }, + }) + } + + addTask = async (data: AddTask) => { + const newTask = await addTaskAction(data) + + this.tasks = [...this.tasks, newTask] + } + + updateTask = async ({ id, data }: UpdateTaskParams) => { + const oldTask = await getTask(id) + + if (!oldTask) return + + const updatedTask = await updateTaskAction({ + id, + data, + }) + + console.log(updatedTask) + + this.tasks = [...this.tasks.filter(task => task.id !== id), updatedTask] + } + + markTaskAsDone = async (task: Task) => { + const updatedTask = { ...task, isDone: true } + + this.tasks = [...this.tasks.filter(({ id }) => id !== task.id), updatedTask] + + await updateTaskAction({ + id: task.id, + data: updatedTask, + }) + } + + rescheduleOverdueTasks = async (dueDateValue: DateValue) => { + const overdueTasks = this.tasksBoard.overdue.items + + this.tasks = [ + ...this.tasks.filter( + task => !overdueTasks.map(task => task.id).includes(task.id) + ), + ...overdueTasks.map(task => ({ + ...task, + dueDate: dueDateValue.toDate(getLocalTimeZone()), + })), + ] + + await rescheduleOverdueTasksAction({ + dueDate: dueDateValue.toDate(getLocalTimeZone()), + }) + } +} diff --git a/app/utils/reorder.ts b/app/utils/reorder.ts index 43800ee..767ada1 100644 --- a/app/utils/reorder.ts +++ b/app/utils/reorder.ts @@ -1,4 +1,8 @@ -export function reorder( +interface ObjectWithOrder { + order?: number | null +} + +export function reorder( items: T[], destinationIndex: number, sourceIndex: number @@ -6,26 +10,44 @@ export function reorder( const result = [...items] const [removed] = result.splice(sourceIndex, 1) + const itemWithDestinationOrder = result.find( + item => item.order === destinationIndex + ) + + console.log(itemWithDestinationOrder) + + if (itemWithDestinationOrder) { + result.splice(destinationIndex, 1) + result.splice(destinationIndex - 1, 0, itemWithDestinationOrder) + } + result.splice(destinationIndex, 0, removed!) return result } -export function addAndReorder( +export function addAndReorder( items: T[], destinationIndex: number, item: T ) { const result = [...items] + const itemWithDestinationOrder = result.find( + item => item.order === destinationIndex + ) + + if (itemWithDestinationOrder) { + result.splice(destinationIndex, 1) + result.splice(destinationIndex - 1, 0, itemWithDestinationOrder) + } + result.splice(destinationIndex, 0, item) return result } -export function initialReorder( - items: T[] -) { +export function initialReorder(items: T[]) { const result = [...items] for (const item of items) {