diff --git a/plan.txt b/plan.txt new file mode 100644 index 0000000..f52b622 --- /dev/null +++ b/plan.txt @@ -0,0 +1,40 @@ +A scratch pad for me to think through my plan of attack. normally i just scribble +this kind of stuff onto whatever scrap paper is closest to me, but figured +the extra insight for y'all wouldnt hurt + +El plan: + +- `useTodos` hook? + - accept arbitrary number/order of categories + - use array order for now, keep it simple + - generate a UUID in memory for each category, just to avoid key conflicts that could result from reusing category names + + - return API for creating and moving todos + - createTodo(name: string) -> Todo + - canMoveTodo(todoId: string, direction: 'left' | 'right') -> boolean + - this should probs live at the 'category' level, actually + - when rendering each category, have it pass this bool into each todo + + - moveTodo(todoId: string, direction: 'left' | 'right') -> void + +- tests + - i assume claude will be able to one shot proper test coverage for this but i guess we'll see + - spoiler: it did not + - unit tests for new hook API, a couple integration tests for testing the whole jawn + - I ended up skipping the integration tests at the end, just for time's sake. there isn't + much that's actually being "integrated" anyway, so figured this was fine considering the + unit test coverage for the hook + - manually smoke test everything + +- components + - todoList (wrapper) + - category + - todoItem + - moveTodoButton + - newTodoInput + +- random + - local storage persistence?? + - nah + - shortcuts/accessibility/tab focus/etc??? + - also nah \ No newline at end of file diff --git a/src/ChallengeComponent.tsx b/src/ChallengeComponent.tsx index 2344883..45fe7a6 100644 --- a/src/ChallengeComponent.tsx +++ b/src/ChallengeComponent.tsx @@ -1,10 +1,33 @@ +import CategoryColumn from "./components/Category"; +import NewTodoInput from "./components/NewTodoInput"; +import { CATEGORY_NAMES } from "./constants"; +import useTodos from "./hooks/useTodos"; + export function ChallengeComponent() { + const { categories, createTodo, getCategoryItems, isCategoryAtEdge, moveTodo } = useTodos(CATEGORY_NAMES); + return ( - <> - {/* Delete this h2, and add your own code here. */} -

- Your code goes here -

- +
+
+ {categories.map(category => { + const categoryTodos = getCategoryItems(category.id); + + return ( + + ); + })} +
+ +
+ +
+
); } diff --git a/src/components/Category.tsx b/src/components/Category.tsx new file mode 100644 index 0000000..47633ca --- /dev/null +++ b/src/components/Category.tsx @@ -0,0 +1,26 @@ +import { Category, Todo } from "../types"; +import ToDoItem from "./ToDoItem"; + +interface CategoryColumnProps { + category: Category; + todos: Todo[]; + isLeftmostCategory?: boolean; + isRightmostCategory?: boolean; + moveTodo: (targetTodoId: string, direction: 'left' | 'right') => void; +} + +const CategoryColumn = ({ category, todos, isLeftmostCategory = false, isRightmostCategory = false, moveTodo }: CategoryColumnProps) => { + return ( +
+
+

{category.name}

+
+ + {todos.map(todo => ( + + ))} +
+ ) +} + +export default CategoryColumn; \ No newline at end of file diff --git a/src/components/NewTodoInput.tsx b/src/components/NewTodoInput.tsx new file mode 100644 index 0000000..be9d023 --- /dev/null +++ b/src/components/NewTodoInput.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; + +interface NewTodoInputProps { + onSubmit: (todoName: string) => void; +} + +const NewTodoInput = ({ onSubmit }: NewTodoInputProps) => { + const [todoName, setTodoName] = useState(""); + + const handleChange = (e: React.ChangeEvent) => { + setTodoName(e.target.value); + }; + + const handleSubmit = (e: React.FormEvent) => { + const trimmedTodoName = todoName.trim(); + if (!trimmedTodoName) { + return; + } + + e.preventDefault(); + onSubmit(todoName); + setTodoName(""); + }; + + return ( +
+ + + +
+ ) +}; + +export default NewTodoInput; \ No newline at end of file diff --git a/src/components/ToDoItem.tsx b/src/components/ToDoItem.tsx new file mode 100644 index 0000000..5605ab2 --- /dev/null +++ b/src/components/ToDoItem.tsx @@ -0,0 +1,25 @@ +import { Todo } from "../types"; +import TodoButton from "./TodoButton"; + +interface ToDoItemProps { + todo: Todo; + canMoveLeft: boolean; + canMoveRight: boolean; + moveTodo: (targetTodoId: string, direction: 'left' | 'right') => void; +} + +const ToDoItem = ({ todo, canMoveLeft, canMoveRight, moveTodo }: ToDoItemProps) => { + const handleMoveTodo = (direction: 'left' | 'right') => { + moveTodo(todo.id, direction); + }; + + return ( +
+ +
{todo.name}
+ +
+ ) +} + +export default ToDoItem; \ No newline at end of file diff --git a/src/components/TodoButton.tsx b/src/components/TodoButton.tsx new file mode 100644 index 0000000..cd0b0af --- /dev/null +++ b/src/components/TodoButton.tsx @@ -0,0 +1,18 @@ +interface TodoButtonProps { + direction: 'left' | 'right'; + disabled: boolean; + handleClick: (direction: 'left' | 'right') => void; +} +const TodoButton = ({ direction, disabled, handleClick }: TodoButtonProps) => { + return ( + + ); +}; + +export default TodoButton; \ No newline at end of file diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..d129636 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,5 @@ +export const CATEGORY_NAMES = [ + "To Do", + "In Progress", + "Done", +] \ No newline at end of file diff --git a/src/hooks/useTodos.test.ts b/src/hooks/useTodos.test.ts new file mode 100644 index 0000000..8a4dcf8 --- /dev/null +++ b/src/hooks/useTodos.test.ts @@ -0,0 +1,446 @@ +import { act } from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import useTodos from "./useTodos"; +import * as utils from "../utils"; + +// Mock the utils module to control UUID generation +vi.mock("../utils", async () => { + const actual = await vi.importActual("../utils"); + return { + ...actual, + newUUID: vi.fn(), + }; +}); + +describe("useTodos", () => { + let mockUUIDCounter = 0; + + beforeEach(() => { + mockUUIDCounter = 0; + // Reset and setup mock UUID generator + vi.mocked(utils.newUUID).mockImplementation(() => { + return `mock-uuid-${mockUUIDCounter++}`; + }); + }); + + describe("initialization", () => { + it("should initialize categories from provided category names", () => { + const categoryNames = ["Work", "Personal", "Shopping"]; + const { result } = renderHook(() => useTodos(categoryNames)); + + expect(result.current.categories).toHaveLength(3); + expect(result.current.categories[0].name).toBe("Work"); + expect(result.current.categories[1].name).toBe("Personal"); + expect(result.current.categories[2].name).toBe("Shopping"); + }); + + it("should generate unique IDs for categories", () => { + const { result } = renderHook(() => useTodos(["Work", "Personal"])); + + const categoryIds = result.current.categories.map((cat) => cat.id); + const uniqueIds = new Set(categoryIds); + expect(uniqueIds.size).toBe(categoryIds.length); + }); + + it("should handle empty category names array", () => { + const { result } = renderHook(() => useTodos([])); + + expect(result.current.categories).toEqual([]); + expect(result.current.todos).toEqual([]); + }); + }); + + describe("createTodo", () => { + it("should create a new todo with the given name", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + act(() => { + result.current.createTodo("Buy groceries"); + }); + + expect(result.current.todos).toHaveLength(1); + expect(result.current.todos[0].name).toBe("Buy groceries"); + }); + + it("should assign new todo to the first category", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + const firstCategoryId = result.current.categories[0].id; + + act(() => { + result.current.createTodo("Complete task"); + }); + + expect(result.current.todos[0].categoryId).toBe(firstCategoryId); + }); + + it("should generate unique IDs for todos", () => { + const { result } = renderHook(() => useTodos(["Work"])); + + act(() => { + result.current.createTodo("Task 1"); + result.current.createTodo("Task 2"); + result.current.createTodo("Task 3"); + }); + + const todoIds = result.current.todos.map((todo) => todo.id); + const uniqueIds = new Set(todoIds); + expect(uniqueIds.size).toBe(todoIds.length); + }); + + it("should create multiple todos", () => { + const { result } = renderHook(() => useTodos(["Work"])); + + act(() => { + result.current.createTodo("Task 1"); + }); + + act(() => { + result.current.createTodo("Task 2"); + }); + + act(() => { + result.current.createTodo("Task 3"); + }); + + expect(result.current.todos).toHaveLength(3); + expect(result.current.todos[0].name).toBe("Task 1"); + expect(result.current.todos[1].name).toBe("Task 2"); + expect(result.current.todos[2].name).toBe("Task 3"); + }); + + it("should throw error when no categories exist", () => { + const { result } = renderHook(() => useTodos([])); + + expect(() => { + act(() => { + result.current.createTodo("Task without category"); + }); + }).toThrow("No initial category found"); + }); + + it("should handle empty todo name", () => { + const { result } = renderHook(() => useTodos(["Work"])); + + act(() => { + result.current.createTodo(""); + }); + + expect(result.current.todos).toHaveLength(1); + expect(result.current.todos[0].name).toBe(""); + }); + + it("should handle todo names with special characters", () => { + const { result } = renderHook(() => useTodos(["Work"])); + + const specialName = "Task with @#$%^&*() characters!"; + + act(() => { + result.current.createTodo(specialName); + }); + + expect(result.current.todos[0].name).toBe(specialName); + }); + }); + + describe("getCategoryItems", () => { + it("should return empty array for category with no todos", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + const categoryId = result.current.categories[0].id; + const items = result.current.getCategoryItems(categoryId); + + expect(items).toEqual([]); + }); + + it("should return todos for a specific category", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + const firstCategoryId = result.current.categories[0].id; + + act(() => { + result.current.createTodo("Task 1"); + }); + + act(() => { + result.current.createTodo("Task 2"); + }); + + const items = result.current.getCategoryItems(firstCategoryId); + + expect(items).toHaveLength(2); + expect(items[0].name).toBe("Task 1"); + expect(items[1].name).toBe("Task 2"); + }); + + it("should return empty array for non-existent category", () => { + const { result } = renderHook(() => useTodos(["Work"])); + + act(() => { + result.current.createTodo("Task 1"); + }); + + const items = result.current.getCategoryItems("non-existent-id"); + + expect(items).toEqual([]); + }); + + it("should return empty array for undefined category", () => { + const { result } = renderHook(() => useTodos(["Work"])); + + const items = result.current.getCategoryItems("undefined-category"); + + expect(items).toEqual([]); + }); + + it("should correctly separate todos by category", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + const workCategoryId = result.current.categories[0].id; + const personalCategoryId = result.current.categories[1].id; + const shoppingCategoryId = result.current.categories[2].id; + + // Manually create todos in different categories + act(() => { + result.current.createTodo("Work task 1"); + }); + + act(() => { + result.current.createTodo("Work task 2"); + }); + + // At this point all todos are in the first category (Work) + const workItems = result.current.getCategoryItems(workCategoryId); + const personalItems = result.current.getCategoryItems(personalCategoryId); + const shoppingItems = result.current.getCategoryItems(shoppingCategoryId); + + expect(workItems).toHaveLength(2); + expect(personalItems).toHaveLength(0); + expect(shoppingItems).toHaveLength(0); + }); + }); + + describe("isCategoryAtEdge", () => { + it("should return true for first category when direction is left", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + const firstCategoryId = result.current.categories[0].id; + const isAtEdge = result.current.isCategoryAtEdge(firstCategoryId, "left"); + + expect(isAtEdge).toBe(true); + }); + + it("should return false for first category when direction is right", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + const firstCategoryId = result.current.categories[0].id; + const isAtEdge = result.current.isCategoryAtEdge(firstCategoryId, "right"); + + expect(isAtEdge).toBe(false); + }); + + it("should return true for last category when direction is right", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + const lastCategoryId = result.current.categories[2].id; + const isAtEdge = result.current.isCategoryAtEdge(lastCategoryId, "right"); + + expect(isAtEdge).toBe(true); + }); + + it("should return false for last category when direction is left", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + const lastCategoryId = result.current.categories[2].id; + const isAtEdge = result.current.isCategoryAtEdge(lastCategoryId, "left"); + + expect(isAtEdge).toBe(false); + }); + + it("should return false for middle category in both directions", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + const middleCategoryId = result.current.categories[1].id; + const isAtLeftEdge = result.current.isCategoryAtEdge(middleCategoryId, "left"); + const isAtRightEdge = result.current.isCategoryAtEdge(middleCategoryId, "right"); + + expect(isAtLeftEdge).toBe(false); + expect(isAtRightEdge).toBe(false); + }); + + it("should return true for single category in both directions", () => { + const { result } = renderHook(() => useTodos(["OnlyCategory"])); + + const categoryId = result.current.categories[0].id; + const isAtLeftEdge = result.current.isCategoryAtEdge(categoryId, "left"); + const isAtRightEdge = result.current.isCategoryAtEdge(categoryId, "right"); + + expect(isAtLeftEdge).toBe(true); + expect(isAtRightEdge).toBe(true); + }); + + it("should work correctly with two categories", () => { + const { result } = renderHook(() => useTodos(["First", "Second"])); + + const firstId = result.current.categories[0].id; + const secondId = result.current.categories[1].id; + + expect(result.current.isCategoryAtEdge(firstId, "left")).toBe(true); + expect(result.current.isCategoryAtEdge(firstId, "right")).toBe(false); + expect(result.current.isCategoryAtEdge(secondId, "left")).toBe(false); + expect(result.current.isCategoryAtEdge(secondId, "right")).toBe(true); + }); + }); + + describe("moveTodo", () => { + it("should move todo from first to second category when direction is right", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + const firstCategoryId = result.current.categories[0].id; + const secondCategoryId = result.current.categories[1].id; + + act(() => { + result.current.createTodo("Test Task"); + }); + + const todoId = result.current.todos[0].id; + expect(result.current.todos[0].categoryId).toBe(firstCategoryId); + + act(() => { + result.current.moveTodo(todoId, "right"); + }); + + expect(result.current.todos[0].categoryId).toBe(secondCategoryId); + }); + + it("should move todo from second to first category when direction is left", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + const firstCategoryId = result.current.categories[0].id; + const secondCategoryId = result.current.categories[1].id; + + act(() => { + result.current.createTodo("Test Task"); + }); + + const todoId = result.current.todos[0].id; + + // Move right first + act(() => { + result.current.moveTodo(todoId, "right"); + }); + + expect(result.current.todos[0].categoryId).toBe(secondCategoryId); + + // Move back left + act(() => { + result.current.moveTodo(todoId, "left"); + }); + + expect(result.current.todos[0].categoryId).toBe(firstCategoryId); + }); + + it("should throw an error when moving todo left from first category", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + act(() => { + result.current.createTodo("Test Task"); + }); + + const todoId = result.current.todos[0].id; + + expect(() => { + act(() => { + result.current.moveTodo(todoId, "left"); + }); + }).toThrow("New category not found"); + }); + + it("should throw an error when moving todo right from last category", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + act(() => { + result.current.createTodo("Test Task"); + }); + + const todoId = result.current.todos[0].id; + + // Move to last category + act(() => { + result.current.moveTodo(todoId, "right"); + }); + + act(() => { + result.current.moveTodo(todoId, "right"); + }); + + expect(() => { + act(() => { + result.current.moveTodo(todoId, "right"); + }); + }).toThrow("New category not found"); + }); + + it("should throw error when todo is not found", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + expect(() => { + act(() => { + result.current.moveTodo("non-existent-id", "right"); + }); + }).toThrow("Todo not found"); + }); + + it("should preserve todo properties when moving", () => { + const { result } = renderHook(() => + useTodos(["Work", "Personal", "Shopping"]) + ); + + act(() => { + result.current.createTodo("Important Task"); + }); + + const todoId = result.current.todos[0].id; + const todoName = result.current.todos[0].name; + + act(() => { + result.current.moveTodo(todoId, "right"); + }); + + expect(result.current.todos[0].id).toBe(todoId); + expect(result.current.todos[0].name).toBe(todoName); + }); + }); +}); + diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts new file mode 100644 index 0000000..0e9b978 --- /dev/null +++ b/src/hooks/useTodos.ts @@ -0,0 +1,114 @@ +import { useEffect, useState } from "react"; +import { createCategories, createTodo as createTodoUtil } from "../utils" +import { Category, Todo } from "../types"; + +interface UseTodosAPI { + categories: Category[]; + todos: Todo[]; + createTodo: (name: string) => void; + getCategoryItems: (categoryId: string) => Todo[]; + isCategoryAtEdge: (categoryId: string, direction: 'left' | 'right') => boolean; + moveTodo: (targetTodoId: string, direction: 'left' | 'right') => void; +} + +interface CategoryTodoMap { + [categoryId: Category["id"]]: Todo[]; +} + +const useTodos = (categoryNames: string[]): UseTodosAPI => { + const [categories, _setCategories] = useState(createCategories(categoryNames)); + const [todos, setTodos] = useState([]); + const [categoryTodoMap, setCategoryTodoMap] = useState({}); + + const getInitialCategory = () => { + return categories[0]; + }; + + const createTodo = (name: string) => { + const initialCategory = getInitialCategory(); + if (!initialCategory) { + throw new Error("No initial category found"); + } + + const newTodo = createTodoUtil(name, initialCategory.id); + setTodos([...todos, newTodo]); + }; + + const getCategoryItems = (categoryId: string) => { + return categoryTodoMap[categoryId] || []; + }; + + const isCategoryAtEdge = (categoryId: string, direction: 'left' | 'right') => { + if (direction === 'left') { + return categoryId === categories[0].id; + } else { + return categoryId === categories[categories.length - 1].id; + } + }; + + const moveTodo = (targetTodoId: string, direction: 'left' | 'right') => { + + const todo = todos.find(todo => todo.id === targetTodoId); + if (!todo) { + throw new Error("Todo not found"); + } + + const currentCategory = categories.find(category => category.id === todo.categoryId); + if (!currentCategory) { + throw new Error("Current category not found"); + } + + const currentCategoryIndex = categories.indexOf(currentCategory); + if (currentCategoryIndex === -1) { + throw new Error("Current category not found"); + } + + const newCategoryIndex = currentCategoryIndex + (direction === 'left' ? -1 : 1); + if (newCategoryIndex < 0 || newCategoryIndex >= categories.length) { + throw new Error("New category not found"); + } + + const newCategory = categories[newCategoryIndex]; + if (!newCategory) { + throw new Error("New category not found"); + } + + setTodos(todos.map(todo => { + if (todo.id === targetTodoId) { + return { + ...todo, + categoryId: newCategory.id, + }; + } + + return todo; + })); + }; + + /* + In reality, implementing something like this is overkill for merely + needing to get all the todos for a given category, but figured it'd demonstrate + an awareness of how to structure data to reduce superfluous computation. + You could also just stick todos inside categories (in memory), but that's more + cumbersome imho. + */ + useEffect(() => { + const newCategoryTodoMap = todos.reduce((acc, todo) => { + acc[todo.categoryId] = [...(acc[todo.categoryId] || []), todo]; + return acc; + }, {}); + + setCategoryTodoMap(newCategoryTodoMap); + }, [todos, categories]); + + return { + categories, + todos, + createTodo, + getCategoryItems, + isCategoryAtEdge, + moveTodo, + }; +} + +export default useTodos; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..c01d28a --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,10 @@ +export type Todo = { + id: string; + name: string; + categoryId: string; +} + +export type Category = { + id: string; + name: string; +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..fb3a0af --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,20 @@ +import { Category, Todo } from "../types"; + +export const newUUID = (): string => { + return crypto.randomUUID(); +} + +export const createCategories = (categoryNames: string[]): Category[] => { + return categoryNames.map(name => ({ + id: newUUID(), + name, + })); +} + +export const createTodo = (name: string, categoryId: string): Todo => { + return { + id: newUUID(), + name, + categoryId, + } +} \ No newline at end of file