From 14772ff449d96f8a6cb88c18ef8801fcd57ed7ba Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 10:19:36 -0300 Subject: [PATCH 01/14] feat: setup API service layer and project structure --- .env | 1 + .env.example | 1 + src/App.tsx | 4 +-- src/services/api.ts | 85 +++++++++++++++++++++++++++++++++++++++++++++ src/types/index.ts | 30 ++++++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 .env create mode 100644 .env.example create mode 100644 src/services/api.ts create mode 100644 src/types/index.ts diff --git a/.env b/.env new file mode 100644 index 0000000..a366373 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://localhost:7027/api diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..64c0b37 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:5000/api diff --git a/src/App.tsx b/src/App.tsx index 0b0df93..6ebad42 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,9 @@ -import logo from 'assets/logo.png' +import logo from './assets/logo.png' function App() { return ( <> - + Logo ) } diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..86c19e0 --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,85 @@ +import type { + TodoList, + TodoItem, + CreateTodoListPayload, + UpdateTodoListPayload, + CreateTodoItemPayload, + UpdateTodoItemPayload, +} from '../types'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'; + +// Todo Lists +export async function getTodoLists(): Promise { + const response = await fetch(`${API_BASE_URL}/todolists`); + if (!response.ok) throw new Error('Failed to fetch todo lists'); + return response.json(); +} + +export async function createTodoList(payload: CreateTodoListPayload): Promise<{ id: number }> { + const response = await fetch(`${API_BASE_URL}/todolists`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error('Failed to create todo list'); + return response.json(); +} + +export async function updateTodoList(id: number, payload: UpdateTodoListPayload): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error('Failed to update todo list'); + return response.json(); +} + +export async function deleteTodoList(id: number): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${id}`, { + method: 'DELETE', + }); + if (!response.ok) throw new Error('Failed to delete todo list'); +} + +// Todo Items +export async function getTodoItems(todoListId: number): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/todos`); + if (!response.ok) throw new Error('Failed to fetch todo items'); + return response.json(); +} + +export async function createTodoItem( + todoListId: number, + payload: CreateTodoItemPayload +): Promise<{ id: number; description: string }> { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/todos`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error('Failed to create todo item'); + return response.json(); +} + +export async function updateTodoItem( + todoListId: number, + id: number, + payload: UpdateTodoItemPayload +): Promise<{ description: string; completed: boolean }> { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/todos/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error('Failed to update todo item'); + return response.json(); +} + +export async function deleteTodoItem(todoListId: number, id: number): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/todos/${id}`, { + method: 'DELETE', + }); + if (!response.ok) throw new Error('Failed to delete todo item'); +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..f18e4f9 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,30 @@ +export interface TodoList { + id: number; + name: string; + todoItems: TodoItem[]; +} + +export interface TodoItem { + id: number; + description: string; + completed: boolean; + todoListId?: number; +} + +export interface CreateTodoListPayload { + name: string; +} + +export interface UpdateTodoListPayload { + name: string; +} + +export interface CreateTodoItemPayload { + description: string; + completed: boolean; +} + +export interface UpdateTodoItemPayload { + description: string; + completed: boolean; +} From 6c2864a93cb4adcf9414fb129fb4464e5a3fac77 Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 10:27:34 -0300 Subject: [PATCH 02/14] feat: add basic layout and styles --- index.html | 2 +- src/App.css | 189 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 24 +++++-- 3 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 src/App.css diff --git a/index.html b/index.html index bdb7567..ee3b1eb 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite + React + TS + Crunchloop Todo Lists
diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..acfdf79 --- /dev/null +++ b/src/App.css @@ -0,0 +1,189 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #f5f5f5; +} + +.app { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.app-header { + background-color: #282c34; + padding: 20px; + color: white; + margin-bottom: 30px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.app-header h1 { + font-size: 2rem; + margin: 0; +} + +.container { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 20px; + min-height: 500px; +} + +.panel { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.panel h2 { + margin-bottom: 20px; + color: #282c34; + font-size: 1.5rem; +} + +.lists-panel { + border-right: 1px solid #e0e0e0; +} + +.items-panel { + display: flex; + flex-direction: column; +} + +.list-item { + padding: 12px; + margin-bottom: 8px; + background: #f9f9f9; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; + display: flex; + justify-content: space-between; + align-items: center; +} + +.list-item:hover { + background: #e8e8e8; +} + +.list-item.active { + background: #007bff; + color: white; +} + +.todo-item { + padding: 12px; + margin-bottom: 8px; + background: #f9f9f9; + border-radius: 4px; + display: flex; + align-items: center; + gap: 12px; + transition: background-color 0.2s; +} + +.todo-item:hover { + background: #e8e8e8; +} + +.todo-item.completed { + opacity: 0.6; +} + +.todo-item.completed .todo-text { + text-decoration: line-through; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #007bff; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: background-color 0.2s; +} + +.btn-primary { + background-color: #007bff; + color: white; +} + +.btn-primary:hover { + background-color: #0056b3; +} + +.btn-danger { + background-color: #dc3545; + color: white; +} + +.btn-danger:hover { + background-color: #c82333; +} + +.btn-small { + padding: 5px 10px; + font-size: 12px; +} + +.checkbox { + width: 20px; + height: 20px; + cursor: pointer; +} + +.empty-state { + text-align: center; + padding: 40px; + color: #666; +} + +.error { + color: #dc3545; + padding: 10px; + background: #f8d7da; + border-radius: 4px; + margin-bottom: 15px; +} + +@media (max-width: 768px) { + .container { + grid-template-columns: 1fr; + } + + .lists-panel { + border-right: none; + border-bottom: 1px solid #e0e0e0; + } +} diff --git a/src/App.tsx b/src/App.tsx index 6ebad42..fda1a65 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,26 @@ -import logo from './assets/logo.png' +import './App.css' function App() { return ( - <> - Logo - +
+
+

Crunchloop Todo Lists Manager

+
+
+
+

Lists

+
+

No todo lists yet

+
+
+
+

Todo Items

+
+

Select a list to view items

+
+
+
+
) } From 678a31e147c771a63cebad9614cb6b19180cac8f Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 10:35:16 -0300 Subject: [PATCH 03/14] feat: fetch and display todo lists --- src/App.css | 5 +++ src/App.tsx | 54 +++++++++++++++++++++++++++++++-- src/components/TodoListItem.tsx | 19 ++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 src/components/TodoListItem.tsx diff --git a/src/App.css b/src/App.css index acfdf79..7a6c1bb 100644 --- a/src/App.css +++ b/src/App.css @@ -74,6 +74,11 @@ body { align-items: center; } +.list-item .item-count { + font-size: 0.9rem; + color: #666; +} + .list-item:hover { background: #e8e8e8; } diff --git a/src/App.tsx b/src/App.tsx index fda1a65..cbb3c71 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,36 @@ +import { useEffect, useState } from 'react' import './App.css' +import type { TodoList } from './types' +import { getTodoLists } from './services/api' +import TodoListItem from './components/TodoListItem' function App() { + const [lists, setLists] = useState([]) + const [selectedListId, setSelectedListId] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + loadLists() + }, []) + + const loadLists = async () => { + try { + setLoading(true) + setError(null) + const data = await getTodoLists() + setLists(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load lists') + } finally { + setLoading(false) + } + } + + const handleSelectList = (id: number) => { + setSelectedListId(id) + } + return (
@@ -9,9 +39,27 @@ function App() {

Lists

-
-

No todo lists yet

-
+ {error &&
{error}
} + {loading ? ( +
+

Loading...

+
+ ) : lists.length === 0 ? ( +
+

No todo lists yet

+
+ ) : ( +
+ {lists.map((list) => ( + + ))} +
+ )}

Todo Items

diff --git a/src/components/TodoListItem.tsx b/src/components/TodoListItem.tsx new file mode 100644 index 0000000..54c8e25 --- /dev/null +++ b/src/components/TodoListItem.tsx @@ -0,0 +1,19 @@ +import type { TodoList } from '../types' + +interface TodoListItemProps { + list: TodoList + isActive: boolean + onSelect: (id: number) => void +} + +export default function TodoListItem({ list, isActive, onSelect }: TodoListItemProps) { + return ( +
onSelect(list.id)} + > + {list.name} + ({list.todoItems?.length || 0}) +
+ ) +} From a7aa7a28c120f60410f13ca91e78951b50e030e5 Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 10:46:27 -0300 Subject: [PATCH 04/14] feat: add create todo list feature --- src/App.tsx | 18 ++++++++++++- src/components/CreateListForm.tsx | 42 +++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/components/CreateListForm.tsx diff --git a/src/App.tsx b/src/App.tsx index cbb3c71..653e349 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react' import './App.css' import type { TodoList } from './types' -import { getTodoLists } from './services/api' +import { getTodoLists, createTodoList } from './services/api' import TodoListItem from './components/TodoListItem' +import CreateListForm from './components/CreateListForm' function App() { const [lists, setLists] = useState([]) @@ -31,6 +32,20 @@ function App() { setSelectedListId(id) } + const handleCreateList = async (name: string) => { + try { + setError(null) + const result = await createTodoList({ name }) + // Reload lists to get the newly created list with all its data + await loadLists() + // Auto-select the newly created list + setSelectedListId(result.id) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create list') + throw err + } + } + return (
@@ -39,6 +54,7 @@ function App() {

Lists

+ {error &&
{error}
} {loading ? (
diff --git a/src/components/CreateListForm.tsx b/src/components/CreateListForm.tsx new file mode 100644 index 0000000..8e28145 --- /dev/null +++ b/src/components/CreateListForm.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react' + +interface CreateListFormProps { + onCreateList: (name: string) => Promise +} + +export default function CreateListForm({ onCreateList }: CreateListFormProps) { + const [name, setName] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) return + + try { + setIsSubmitting(true) + await onCreateList(name) + setName('') + } catch (error) { + // Error handled by parent + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ setName(e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ ) +} From 074d58bea1de6effafdf4292bd8e74612a00db57 Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 10:51:56 -0300 Subject: [PATCH 05/14] feat: add delete todo list feature --- src/App.tsx | 18 +++++++++++++++++- src/components/TodoListItem.tsx | 20 ++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 653e349..2d9ed70 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import './App.css' import type { TodoList } from './types' -import { getTodoLists, createTodoList } from './services/api' +import { getTodoLists, createTodoList, deleteTodoList } from './services/api' import TodoListItem from './components/TodoListItem' import CreateListForm from './components/CreateListForm' @@ -46,6 +46,21 @@ function App() { } } + const handleDeleteList = async (id: number) => { + try { + setError(null) + await deleteTodoList(id) + // If we deleted the selected list, clear the selection + if (selectedListId === id) { + setSelectedListId(null) + } + // Reload lists to reflect the deletion + await loadLists() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete list') + } + } + return (
@@ -72,6 +87,7 @@ function App() { list={list} isActive={selectedListId === list.id} onSelect={handleSelectList} + onDelete={handleDeleteList} /> ))}
diff --git a/src/components/TodoListItem.tsx b/src/components/TodoListItem.tsx index 54c8e25..87628c2 100644 --- a/src/components/TodoListItem.tsx +++ b/src/components/TodoListItem.tsx @@ -4,16 +4,32 @@ interface TodoListItemProps { list: TodoList isActive: boolean onSelect: (id: number) => void + onDelete: (id: number) => void } -export default function TodoListItem({ list, isActive, onSelect }: TodoListItemProps) { +export default function TodoListItem({ list, isActive, onSelect, onDelete }: TodoListItemProps) { + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation() + if (confirm(`Are you sure you want to delete "${list.name}"?`)) { + onDelete(list.id) + } + } + return (
onSelect(list.id)} > {list.name} - ({list.todoItems?.length || 0}) +
+ ({list.todoItems?.length || 0}) + +
) } From ff0d44434da40be44d60263aef4cb4bcbc3f9419 Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 10:56:35 -0300 Subject: [PATCH 06/14] feat: add update todo list name feature --- src/App.tsx | 14 ++++- src/components/TodoListItem.tsx | 98 +++++++++++++++++++++++++++++---- 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2d9ed70..bc8ed37 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import './App.css' import type { TodoList } from './types' -import { getTodoLists, createTodoList, deleteTodoList } from './services/api' +import { getTodoLists, createTodoList, deleteTodoList, updateTodoList } from './services/api' import TodoListItem from './components/TodoListItem' import CreateListForm from './components/CreateListForm' @@ -61,6 +61,17 @@ function App() { } } + const handleUpdateList = async (id: number, name: string) => { + try { + setError(null) + await updateTodoList(id, { name }) + // Reload lists to reflect the update + await loadLists() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update list') + } + } + return (
@@ -88,6 +99,7 @@ function App() { isActive={selectedListId === list.id} onSelect={handleSelectList} onDelete={handleDeleteList} + onUpdate={handleUpdateList} /> ))}
diff --git a/src/components/TodoListItem.tsx b/src/components/TodoListItem.tsx index 87628c2..9dc0db6 100644 --- a/src/components/TodoListItem.tsx +++ b/src/components/TodoListItem.tsx @@ -1,13 +1,19 @@ +import { useState } from 'react' import type { TodoList } from '../types' +import * as React from 'react'; interface TodoListItemProps { list: TodoList isActive: boolean onSelect: (id: number) => void onDelete: (id: number) => void + onUpdate: (id: number, name: string) => void } -export default function TodoListItem({ list, isActive, onSelect, onDelete }: TodoListItemProps) { +export default function TodoListItem({ list, isActive, onSelect, onDelete, onUpdate }: TodoListItemProps) { + const [isEditing, setIsEditing] = useState(false) + const [editName, setEditName] = useState(list.name) + const handleDelete = (e: React.MouseEvent) => { e.stopPropagation() if (confirm(`Are you sure you want to delete "${list.name}"?`)) { @@ -15,20 +21,92 @@ export default function TodoListItem({ list, isActive, onSelect, onDelete }: Tod } } + const handleEdit = (e: React.MouseEvent) => { + e.stopPropagation() + setIsEditing(true) + setEditName(list.name) + } + + const handleSave = async (e: React.MouseEvent) => { + e.stopPropagation() + if (editName.trim() && editName !== list.name) { + onUpdate(list.id, editName.trim()) + } + setIsEditing(false) + } + + const handleCancel = (e: React.MouseEvent) => { + e.stopPropagation() + setIsEditing(false) + setEditName(list.name) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + if (editName.trim() && editName !== list.name) { + onUpdate(list.id, editName.trim()) + } + setIsEditing(false) + } else if (e.key === 'Escape') { + setIsEditing(false) + setEditName(list.name) + } + } + return (
onSelect(list.id)} + onClick={() => !isEditing && onSelect(list.id)} > - {list.name} + {isEditing ? ( + setEditName(e.target.value)} + onKeyDown={handleKeyDown} + onClick={(e) => e.stopPropagation()} + autoFocus + style={{ flex: 1, marginRight: '10px' }} + /> + ) : ( + {list.name} + )}
- ({list.todoItems?.length || 0}) - + {!isEditing && ({list.todoItems?.length || 0})} + {isEditing ? ( + <> + + + + ) : ( + <> + + + + )}
) From c176959a3b3c183a9bcf7b12f59e6308280e54d9 Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 11:04:34 -0300 Subject: [PATCH 07/14] feat: add list todo items feature --- src/App.tsx | 53 +++++++-------------- src/components/TodoItemComponent.tsx | 19 ++++++++ src/components/TodoItemsPanel.tsx | 71 ++++++++++++++++++++++++++++ src/components/TodoListsPanel.tsx | 55 +++++++++++++++++++++ 4 files changed, 163 insertions(+), 35 deletions(-) create mode 100644 src/components/TodoItemComponent.tsx create mode 100644 src/components/TodoItemsPanel.tsx create mode 100644 src/components/TodoListsPanel.tsx diff --git a/src/App.tsx b/src/App.tsx index bc8ed37..fdccf05 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react' import './App.css' import type { TodoList } from './types' import { getTodoLists, createTodoList, deleteTodoList, updateTodoList } from './services/api' -import TodoListItem from './components/TodoListItem' -import CreateListForm from './components/CreateListForm' +import TodoListsPanel from './components/TodoListsPanel' +import TodoItemsPanel from './components/TodoItemsPanel' function App() { const [lists, setLists] = useState([]) @@ -72,45 +72,28 @@ function App() { } } + const selectedList = lists.find((list) => list.id === selectedListId) + return (

Crunchloop Todo Lists Manager

-
-

Lists

- - {error &&
{error}
} - {loading ? ( -
-

Loading...

-
- ) : lists.length === 0 ? ( -
-

No todo lists yet

-
- ) : ( -
- {lists.map((list) => ( - - ))} -
- )} -
-
-

Todo Items

-
-

Select a list to view items

-
-
+ +
) diff --git a/src/components/TodoItemComponent.tsx b/src/components/TodoItemComponent.tsx new file mode 100644 index 0000000..9a8d65a --- /dev/null +++ b/src/components/TodoItemComponent.tsx @@ -0,0 +1,19 @@ +import type { TodoItem } from '../types' + +interface TodoItemProps { + item: TodoItem +} + +export default function TodoItemComponent({ item }: TodoItemProps) { + return ( +
+ + {item.description} +
+ ) +} diff --git a/src/components/TodoItemsPanel.tsx b/src/components/TodoItemsPanel.tsx new file mode 100644 index 0000000..7396168 --- /dev/null +++ b/src/components/TodoItemsPanel.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react' +import type { TodoItem } from '../types' +import { getTodoItems } from '../services/api' +import TodoItemComponent from './TodoItemComponent' + +interface TodoItemsPanelProps { + todoListId: number | null + todoListName: string | null +} + +export default function TodoItemsPanel({ todoListId, todoListName }: TodoItemsPanelProps) { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (todoListId) { + loadItems() + } else { + setItems([]) + } + }, [todoListId]) + + const loadItems = async () => { + if (!todoListId) return + + try { + setLoading(true) + setError(null) + const data = await getTodoItems(todoListId) + setItems(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load items') + } finally { + setLoading(false) + } + } + + if (!todoListId) { + return ( +
+

Todo Items

+
+

Select a list to view items

+
+
+ ) + } + + return ( +
+

Todo Items {todoListName && `- ${todoListName}`}

+ {error &&
{error}
} + {loading ? ( +
+

Loading items...

+
+ ) : items.length === 0 ? ( +
+

No items in this list yet

+
+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/components/TodoListsPanel.tsx b/src/components/TodoListsPanel.tsx new file mode 100644 index 0000000..bf7c7ea --- /dev/null +++ b/src/components/TodoListsPanel.tsx @@ -0,0 +1,55 @@ +import type { TodoList } from '../types' +import TodoListItem from './TodoListItem' +import CreateListForm from './CreateListForm' + +interface TodoListsPanelProps { + lists: TodoList[] + selectedListId: number | null + loading: boolean + error: string | null + onCreateList: (name: string) => Promise + onSelectList: (id: number) => void + onDeleteList: (id: number) => Promise + onUpdateList: (id: number, name: string) => Promise +} + +export default function TodoListsPanel({ + lists, + selectedListId, + loading, + error, + onCreateList, + onSelectList, + onDeleteList, + onUpdateList, +}: TodoListsPanelProps) { + return ( +
+

Lists

+ + {error &&
{error}
} + {loading ? ( +
+

Loading...

+
+ ) : lists.length === 0 ? ( +
+

No todo lists yet

+
+ ) : ( +
+ {lists.map((list) => ( + + ))} +
+ )} +
+ ) +} From e4bb03403f6b0ba97ed7fc2d964374c21dbb6571 Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 11:06:59 -0300 Subject: [PATCH 08/14] feat: add create todo item feature --- src/components/CreateItemForm.tsx | 59 +++++++++++++++++++++++++++++++ src/components/TodoItemsPanel.tsx | 18 +++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/components/CreateItemForm.tsx diff --git a/src/components/CreateItemForm.tsx b/src/components/CreateItemForm.tsx new file mode 100644 index 0000000..7895b8b --- /dev/null +++ b/src/components/CreateItemForm.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' + +interface CreateItemFormProps { + onCreateItem: (description: string, completed: boolean) => Promise +} + +export default function CreateItemForm({ onCreateItem }: CreateItemFormProps) { + const [description, setDescription] = useState('') + const [completed, setCompleted] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!description.trim()) return + + try { + setIsSubmitting(true) + await onCreateItem(description.trim(), completed) + setDescription('') + setCompleted(false) + } catch (error) { + // Error handled by parent + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ setDescription(e.target.value)} + disabled={isSubmitting} + /> +
+
+ + +
+
+ ) +} diff --git a/src/components/TodoItemsPanel.tsx b/src/components/TodoItemsPanel.tsx index 7396168..70f3542 100644 --- a/src/components/TodoItemsPanel.tsx +++ b/src/components/TodoItemsPanel.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react' import type { TodoItem } from '../types' -import { getTodoItems } from '../services/api' +import { getTodoItems, createTodoItem } from '../services/api' import TodoItemComponent from './TodoItemComponent' +import CreateItemForm from './CreateItemForm' interface TodoItemsPanelProps { todoListId: number | null @@ -36,6 +37,20 @@ export default function TodoItemsPanel({ todoListId, todoListName }: TodoItemsPa } } + const handleCreateItem = async (description: string, completed: boolean) => { + if (!todoListId) return + + try { + setError(null) + await createTodoItem(todoListId, { description, completed }) + // Reload items to reflect the new item + await loadItems() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create item') + throw err + } + } + if (!todoListId) { return (
@@ -50,6 +65,7 @@ export default function TodoItemsPanel({ todoListId, todoListName }: TodoItemsPa return (

Todo Items {todoListName && `- ${todoListName}`}

+ {error &&
{error}
} {loading ? (
From 98ca3522b2f298b6abe2d00daaed4dff87d5d428 Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 11:11:10 -0300 Subject: [PATCH 09/14] feat: add toggle item completion feature --- src/components/TodoItemComponent.tsx | 9 +++++++-- src/components/TodoItemsPanel.tsx | 25 +++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/components/TodoItemComponent.tsx b/src/components/TodoItemComponent.tsx index 9a8d65a..3693881 100644 --- a/src/components/TodoItemComponent.tsx +++ b/src/components/TodoItemComponent.tsx @@ -2,16 +2,21 @@ import type { TodoItem } from '../types' interface TodoItemProps { item: TodoItem + onToggleComplete: (id: number, completed: boolean) => void } -export default function TodoItemComponent({ item }: TodoItemProps) { +export default function TodoItemComponent({ item, onToggleComplete }: TodoItemProps) { + const handleToggle = () => { + onToggleComplete(item.id, !item.completed) + } + return (
{item.description}
diff --git a/src/components/TodoItemsPanel.tsx b/src/components/TodoItemsPanel.tsx index 70f3542..1a3f9f0 100644 --- a/src/components/TodoItemsPanel.tsx +++ b/src/components/TodoItemsPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import type { TodoItem } from '../types' -import { getTodoItems, createTodoItem } from '../services/api' +import { getTodoItems, createTodoItem, updateTodoItem } from '../services/api' import TodoItemComponent from './TodoItemComponent' import CreateItemForm from './CreateItemForm' @@ -51,6 +51,23 @@ export default function TodoItemsPanel({ todoListId, todoListName }: TodoItemsPa } } + const handleToggleComplete = async (itemId: number, completed: boolean) => { + if (!todoListId) return + + // Find the item to get its description + const item = items.find((i) => i.id === itemId) + if (!item) return + + try { + setError(null) + await updateTodoItem(todoListId, itemId, { description: item.description, completed }) + // Reload items to reflect the update + await loadItems() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update item') + } + } + if (!todoListId) { return (
@@ -78,7 +95,11 @@ export default function TodoItemsPanel({ todoListId, todoListName }: TodoItemsPa ) : (
{items.map((item) => ( - + ))}
)} From 58aab196e8a5e5252629df4e41d20d46fd75fdb9 Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 11:21:44 -0300 Subject: [PATCH 10/14] feat: add delete todo item feature --- src/components/TodoItemComponent.tsx | 16 +++++++++++++++- src/components/TodoItemsPanel.tsx | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/components/TodoItemComponent.tsx b/src/components/TodoItemComponent.tsx index 3693881..54e3ce8 100644 --- a/src/components/TodoItemComponent.tsx +++ b/src/components/TodoItemComponent.tsx @@ -3,13 +3,20 @@ import type { TodoItem } from '../types' interface TodoItemProps { item: TodoItem onToggleComplete: (id: number, completed: boolean) => void + onDelete: (id: number) => void } -export default function TodoItemComponent({ item, onToggleComplete }: TodoItemProps) { +export default function TodoItemComponent({ item, onToggleComplete, onDelete }: TodoItemProps) { const handleToggle = () => { onToggleComplete(item.id, !item.completed) } + const handleDelete = () => { + if (confirm(`Are you sure you want to delete "${item.description}"?`)) { + onDelete(item.id) + } + } + return (
{item.description} +
) } diff --git a/src/components/TodoItemsPanel.tsx b/src/components/TodoItemsPanel.tsx index 1a3f9f0..f738bb2 100644 --- a/src/components/TodoItemsPanel.tsx +++ b/src/components/TodoItemsPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import type { TodoItem } from '../types' -import { getTodoItems, createTodoItem, updateTodoItem } from '../services/api' +import { getTodoItems, createTodoItem, updateTodoItem, deleteTodoItem } from '../services/api' import TodoItemComponent from './TodoItemComponent' import CreateItemForm from './CreateItemForm' @@ -68,6 +68,19 @@ export default function TodoItemsPanel({ todoListId, todoListName }: TodoItemsPa } } + const handleDeleteItem = async (itemId: number) => { + if (!todoListId) return + + try { + setError(null) + await deleteTodoItem(todoListId, itemId) + // Reload items to reflect the deletion + await loadItems() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete item') + } + } + if (!todoListId) { return (
@@ -99,6 +112,7 @@ export default function TodoItemsPanel({ todoListId, todoListName }: TodoItemsPa key={item.id} item={item} onToggleComplete={handleToggleComplete} + onDelete={handleDeleteItem} /> ))}
From 73bdc1c528694e0b184d5ecc32b9c51078ec38e0 Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 11:35:06 -0300 Subject: [PATCH 11/14] refactor: update TodoList type to use incompleteItemCount --- src/App.tsx | 1 + src/components/TodoItemsPanel.tsx | 7 ++++++- src/components/TodoListItem.tsx | 2 +- src/types/index.ts | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index fdccf05..345585f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -93,6 +93,7 @@ function App() {
diff --git a/src/components/TodoItemsPanel.tsx b/src/components/TodoItemsPanel.tsx index f738bb2..8592e52 100644 --- a/src/components/TodoItemsPanel.tsx +++ b/src/components/TodoItemsPanel.tsx @@ -7,9 +7,10 @@ import CreateItemForm from './CreateItemForm' interface TodoItemsPanelProps { todoListId: number | null todoListName: string | null + onItemsChange: () => Promise } -export default function TodoItemsPanel({ todoListId, todoListName }: TodoItemsPanelProps) { +export default function TodoItemsPanel({ todoListId, todoListName, onItemsChange }: TodoItemsPanelProps) { const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -45,6 +46,8 @@ export default function TodoItemsPanel({ todoListId, todoListName }: TodoItemsPa await createTodoItem(todoListId, { description, completed }) // Reload items to reflect the new item await loadItems() + // Refresh parent lists to update item count + await onItemsChange() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create item') throw err @@ -76,6 +79,8 @@ export default function TodoItemsPanel({ todoListId, todoListName }: TodoItemsPa await deleteTodoItem(todoListId, itemId) // Reload items to reflect the deletion await loadItems() + // Refresh parent lists to update item count + await onItemsChange() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete item') } diff --git a/src/components/TodoListItem.tsx b/src/components/TodoListItem.tsx index 9dc0db6..4500501 100644 --- a/src/components/TodoListItem.tsx +++ b/src/components/TodoListItem.tsx @@ -73,7 +73,7 @@ export default function TodoListItem({ list, isActive, onSelect, onDelete, onUpd {list.name} )}
- {!isEditing && ({list.todoItems?.length || 0})} + {!isEditing && ({list.incompleteItemCount})} {isEditing ? ( <> + + {jobStatus && ( +
+ {jobStatus.state === JobState.Queued &&

Job queued, waiting to start...

} + {jobStatus.state === JobState.Processing && ( +

Processing: {jobStatus.processedCount}/{jobStatus.totalCount} items completed...

+ )} +
+ )} +
+ )}
) } diff --git a/src/hooks/useSignalR.ts b/src/hooks/useSignalR.ts new file mode 100644 index 0000000..b1ff9ef --- /dev/null +++ b/src/hooks/useSignalR.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; +import * as signalR from '@microsoft/signalr'; +import type { JobStatus } from '../types'; + +const getHubUrl = () => { + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'; + + const baseUrl = apiBaseUrl.replace(/\/api$/, ''); + return `${baseUrl}/hubs/todo-progress`; +}; + +interface UseSignalRResult { + connection: signalR.HubConnection | null; + jobStatus: JobStatus | null; + error: string | null; +} + +export function useSignalR(jobId: string | null): UseSignalRResult { + const [connection, setConnection] = useState(null); + const [jobStatus, setJobStatus] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!jobId) { + setConnection(null); + setJobStatus(null); + setError(null); + return; + } + + const hubUrl = getHubUrl(); + const newConnection = new signalR.HubConnectionBuilder() + .withUrl(hubUrl) + .withAutomaticReconnect() + .build(); + + setConnection(newConnection); + + const startConnection = async () => { + try { + await newConnection.start(); + + // Join the job group + await newConnection.invoke('JoinJobGroup', jobId); + + // Listen for job status updates + newConnection.on('JobStatusUpdate', (status: JobStatus) => { + setJobStatus(status); + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'SignalR connection failed'); + } + }; + + startConnection(); + + return () => { + if (newConnection) { + newConnection.stop(); + } + }; + }, [jobId]); + + return { connection, jobStatus, error }; +} diff --git a/src/services/api.ts b/src/services/api.ts index 86c19e0..f46d626 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -5,6 +5,8 @@ import type { UpdateTodoListPayload, CreateTodoItemPayload, UpdateTodoItemPayload, + CompleteAllResponse, + JobStatus, } from '../types'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'; @@ -83,3 +85,18 @@ export async function deleteTodoItem(todoListId: number, id: number): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/complete-all`, { + method: 'POST', + }); + if (!response.ok) throw new Error('Failed to start complete all job'); + return response.json(); +} + +export async function getJobStatus(todoListId: number, jobId: string): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/jobs/${jobId}/status`); + if (!response.ok) throw new Error('Failed to fetch job status'); + return response.json(); +} diff --git a/src/types/index.ts b/src/types/index.ts index f35da05..de283c2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,3 +28,24 @@ export interface UpdateTodoItemPayload { description: string; completed: boolean; } + +export enum JobState { + Queued = 0, + Processing = 1, + Completed = 2, + Failed = 3, +} + +export interface JobStatus { + jobId: string; + state: JobState; + processedCount: number; + totalCount: number; + errorMessage?: string; + createdAt: string; + completedAt?: string; +} + +export interface CompleteAllResponse { + jobId: string; +} From 11503e1c0c1457a632fcbe2c0e1d3f9fdcd49384 Mon Sep 17 00:00:00 2001 From: Martin Duran Date: Wed, 15 Oct 2025 12:24:44 -0300 Subject: [PATCH 14/14] feat: enable 'Mark all as done' button only when incomplete items exist --- src/components/TodoItemsPanel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/TodoItemsPanel.tsx b/src/components/TodoItemsPanel.tsx index 4d8ed4f..2c89990 100644 --- a/src/components/TodoItemsPanel.tsx +++ b/src/components/TodoItemsPanel.tsx @@ -131,6 +131,8 @@ export default function TodoItemsPanel({ todoListId, todoListName, onItemsChange const isProcessingJob = currentJobId !== null && (!jobStatus || (jobStatus.state !== JobState.Completed && jobStatus.state !== JobState.Failed)) + const hasIncompleteItems = items.some(item => !item.completed) + if (!todoListId) { return (
@@ -175,7 +177,7 @@ export default function TodoItemsPanel({ todoListId, todoListName, onItemsChange