diff --git a/README.md b/README.md
index d1c68b57..b87d0d0a 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,2 @@
-# Todo
\ No newline at end of file
+TODO
+https://js-project-todo.pages.dev/
diff --git a/package.json b/package.json
index caf62893..f6ba0cf0 100644
--- a/package.json
+++ b/package.json
@@ -10,8 +10,10 @@
"preview": "vite preview"
},
"dependencies": {
+ "date-fns": "^4.1.0",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "zustand": "^5.0.10"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
diff --git a/src/App.jsx b/src/App.jsx
index 54275400..b711022f 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,5 +1,32 @@
+import TaskInput from './components/TaskInput'
+import TaskList from './components/TaskList'
+import TaskStats from './components/TaskStats'
+import FilterControls from './components/FilterControls'
+import DarkModeToggle from './components/DarkModeToggle'
+import useTaskStore from './store/useTaskStore'
+
export const App = () => {
+ const tasks = useTaskStore((state) => state.tasks)
+
return (
-
React Boilerplate
+
+
+
+
+
+
+ {tasks.length > 0 && (
+ <>
+
+
+ >
+ )}
+
+
+
+
)
}
diff --git a/src/components/DarkModeToggle.jsx b/src/components/DarkModeToggle.jsx
new file mode 100644
index 00000000..c001893c
--- /dev/null
+++ b/src/components/DarkModeToggle.jsx
@@ -0,0 +1,65 @@
+import { useEffect } from 'react'
+import useTaskStore from '../store/useTaskStore'
+
+const DarkModeToggle = () => {
+ const darkMode = useTaskStore((state) => state.darkMode)
+ const toggleDarkMode = useTaskStore((state) => state.toggleDarkMode)
+
+ useEffect(() => {
+ if (darkMode) {
+ document.documentElement.classList.add('dark-mode')
+ } else {
+ document.documentElement.classList.remove('dark-mode')
+ }
+ }, [darkMode])
+
+ return (
+
+ )
+}
+
+export default DarkModeToggle
diff --git a/src/components/EmptyState.jsx b/src/components/EmptyState.jsx
new file mode 100644
index 00000000..b0584eb0
--- /dev/null
+++ b/src/components/EmptyState.jsx
@@ -0,0 +1,31 @@
+const EmptyState = () => {
+ return (
+
+
+
No tasks yet
+
+ Start by adding your first task above. Stay organized and get things done!
+
+
+ )
+}
+
+export default EmptyState
diff --git a/src/components/FilterControls.jsx b/src/components/FilterControls.jsx
new file mode 100644
index 00000000..60ff1951
--- /dev/null
+++ b/src/components/FilterControls.jsx
@@ -0,0 +1,29 @@
+import useTaskStore from '../store/useTaskStore'
+
+const FilterControls = () => {
+ const filter = useTaskStore((state) => state.filter)
+ const setFilter = useTaskStore((state) => state.setFilter)
+
+ const filters = [
+ { value: 'all', label: 'All' },
+ { value: 'active', label: 'Active' },
+ { value: 'completed', label: 'Completed' },
+ ]
+
+ return (
+
+ {filters.map((f) => (
+
+ ))}
+
+ )
+}
+
+export default FilterControls
diff --git a/src/components/TaskInput.jsx b/src/components/TaskInput.jsx
new file mode 100644
index 00000000..754419f0
--- /dev/null
+++ b/src/components/TaskInput.jsx
@@ -0,0 +1,52 @@
+import { useState } from 'react'
+import useTaskStore from '../store/useTaskStore'
+
+const TaskInput = () => {
+ const [text, setText] = useState('')
+ const [dueDate, setDueDate] = useState('')
+ const addTask = useTaskStore((state) => state.addTask)
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ if (text.trim()) {
+ addTask(text.trim(), dueDate || null)
+ setText('')
+ setDueDate('')
+ }
+ }
+
+ return (
+
+ )
+}
+
+export default TaskInput
diff --git a/src/components/TaskItem.jsx b/src/components/TaskItem.jsx
new file mode 100644
index 00000000..6b63c3ae
--- /dev/null
+++ b/src/components/TaskItem.jsx
@@ -0,0 +1,54 @@
+// date-fns is a library for working with dates
+// format: converts dates to readable strings like "Jan 15, 2024"
+// isPast: checks if a date is in the past
+// parseISO: converts ISO string ("2024-01-15T00:00:00.000Z") back to Date object
+import { format, isPast, parseISO } from 'date-fns'
+import useTaskStore from '../store/useTaskStore'
+
+// Destructuring { task } from props - same as (props) => { const task = props.task }
+const TaskItem = ({ task }) => {
+ // Destructuring from store - get both functions at once
+ // Unlike selectors, this subscribes to the whole store (less optimized but fine for actions)
+ const { toggleTask, removeTask } = useTaskStore()
+
+ const isOverdue = task.dueDate && !task.completed && isPast(parseISO(task.dueDate))
+
+ return (
+
+
+
+
+
{task.text}
+
+
+ Created: {format(parseISO(task.createdAt), 'MMM d, yyyy')}
+
+ {task.dueDate && (
+
+ Due: {format(parseISO(task.dueDate), 'MMM d, yyyy')}
+ {isOverdue && ' (Overdue)'}
+
+ )}
+
+
+
+
+
+ )
+}
+
+export default TaskItem
diff --git a/src/components/TaskList.jsx b/src/components/TaskList.jsx
new file mode 100644
index 00000000..72168bc0
--- /dev/null
+++ b/src/components/TaskList.jsx
@@ -0,0 +1,37 @@
+import useTaskStore from '../store/useTaskStore'
+import TaskItem from './TaskItem'
+import EmptyState from './EmptyState'
+
+const TaskList = () => {
+ const allTasks = useTaskStore((state) => state.tasks)
+ const filter = useTaskStore((state) => state.filter)
+
+ // Filter tasks based on the current filter
+ const filteredTasks = allTasks.filter((task) => {
+ if (filter === 'active') return !task.completed
+ if (filter === 'completed') return task.completed
+ return true
+ })
+
+ if (allTasks.length === 0) {
+ return
+ }
+
+ if (filteredTasks.length === 0 && filter !== 'all') {
+ return (
+
+
No {filter} tasks found
+
+ )
+ }
+
+ return (
+
+ {filteredTasks.map((task) => (
+
+ ))}
+
+ )
+}
+
+export default TaskList
diff --git a/src/components/TaskStats.jsx b/src/components/TaskStats.jsx
new file mode 100644
index 00000000..8fd6f50d
--- /dev/null
+++ b/src/components/TaskStats.jsx
@@ -0,0 +1,41 @@
+import useTaskStore from '../store/useTaskStore'
+
+const TaskStats = () => {
+ const tasks = useTaskStore((state) => state.tasks)
+ const completeAll = useTaskStore((state) => state.completeAll)
+
+ // Calculate stats
+ const total = tasks.length
+ const completed = tasks.filter((task) => task.completed).length
+ const active = tasks.filter((task) => !task.completed).length
+
+ return (
+
+
+
+ {total}
+ Total Tasks
+
+
+ {active}
+ Active
+
+
+ {completed}
+ Completed
+
+
+ {active > 0 && (
+
+ )}
+
+ )
+}
+
+export default TaskStats
diff --git a/src/index.css b/src/index.css
index f7c0aef5..0ea6a0cc 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,3 +1,511 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+
+ --bg-primary: #ffffff;
+ --bg-secondary: #f8f9fa;
+ --bg-tertiary: #e9ecef;
+ --text-primary: #212529;
+ --text-secondary: #6c757d;
+ --border-color: #dee2e6;
+ --accent-color: #4f46e5;
+ --accent-hover: #4338ca;
+ --success-color: #10b981;
+ --danger-color: #ef4444;
+ --warning-color: #f59e0b;
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
+}
+
+.dark-mode {
+ --bg-primary: #1a1a1a;
+ --bg-secondary: #2d2d2d;
+ --bg-tertiary: #3d3d3d;
+ --text-primary: #f1f3f5;
+ --text-secondary: #adb5bd;
+ --border-color: #495057;
+ --accent-color: #6366f1;
+ --accent-hover: #4f46e5;
+ --success-color: #34d399;
+ --danger-color: #f87171;
+ --warning-color: #fbbf24;
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background-color: var(--bg-secondary);
+ color: var(--text-primary);
+ line-height: 1.6;
+ transition: background-color 0.3s ease, color 0.3s ease;
+}
+
+#root {
+ min-height: 100vh;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.app-container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 1rem;
+ min-height: 100vh;
+}
+
+.app-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 2rem;
+ padding: 1.5rem 0;
+}
+
+.app-title {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.dark-mode-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background-color: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.dark-mode-toggle:hover {
+ background-color: var(--accent-color);
+ color: white;
+ transform: translateY(-1px);
+}
+
+.dark-mode-toggle:focus {
+ outline: 2px solid var(--accent-color);
+ outline-offset: 2px;
+}
+
+.task-input-form {
+ margin-bottom: 2rem;
+}
+
+.input-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.task-input,
+.task-date-input {
+ padding: 0.875rem 1rem;
+ border: 2px solid var(--border-color);
+ border-radius: 8px;
+ font-size: 1rem;
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ transition: border-color 0.2s ease;
+ width: 100%;
+}
+
+.task-input:focus,
+.task-date-input:focus {
+ outline: none;
+ border-color: var(--accent-color);
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+.add-button {
+ padding: 0.875rem 1.5rem;
+ background-color: var(--accent-color);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.add-button:hover {
+ background-color: var(--accent-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.add-button:focus {
+ outline: 2px solid var(--accent-color);
+ outline-offset: 2px;
+}
+
+.add-button:active {
+ transform: translateY(0);
+}
+
+.task-stats {
+ background-color: var(--bg-primary);
+ border-radius: 12px;
+ padding: 1.5rem;
+ margin-bottom: 1.5rem;
+ box-shadow: var(--shadow-sm);
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.stat-item {
+ text-align: center;
+ padding: 0.75rem;
+ background-color: var(--bg-secondary);
+ border-radius: 8px;
+}
+
+.stat-value {
+ display: block;
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: var(--accent-color);
+ margin-bottom: 0.25rem;
+}
+
+.stat-label {
+ display: block;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.complete-all-button {
+ width: 100%;
+ padding: 0.75rem;
+ background-color: var(--success-color);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.complete-all-button:hover {
+ opacity: 0.9;
+ transform: translateY(-1px);
+}
+
+.complete-all-button:focus {
+ outline: 2px solid var(--success-color);
+ outline-offset: 2px;
+}
+
+.filter-controls {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ background-color: var(--bg-primary);
+ padding: 0.5rem;
+ border-radius: 10px;
+ box-shadow: var(--shadow-sm);
+}
+
+.filter-button {
+ flex: 1;
+ padding: 0.625rem 1rem;
+ background-color: transparent;
+ color: var(--text-secondary);
+ border: none;
+ border-radius: 6px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.filter-button:hover {
+ background-color: var(--bg-secondary);
+ color: var(--text-primary);
+}
+
+.filter-button.active {
+ background-color: var(--accent-color);
+ color: white;
+}
+
+.filter-button:focus {
+ outline: 2px solid var(--accent-color);
+ outline-offset: -2px;
+}
+
+
+.task-list {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.task-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.25rem;
+ background-color: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ transition: all 0.2s ease;
+ box-shadow: var(--shadow-sm);
+}
+
+.task-item:hover {
+ box-shadow: var(--shadow-md);
+ transform: translateY(-1px);
+}
+
+.task-item.completed {
+ opacity: 0.7;
+}
+
+.task-item.overdue {
+ border-left: 4px solid var(--danger-color);
+}
+
+.task-content {
+ display: flex;
+ align-items: flex-start;
+ gap: 1rem;
+ flex: 1;
+}
+
+.checkbox-container {
+ position: relative;
+ display: inline-block;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.checkbox-container input[type="checkbox"] {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.checkmark {
+ display: block;
+ width: 24px;
+ height: 24px;
+ border: 2px solid var(--border-color);
+ border-radius: 6px;
+ background-color: var(--bg-primary);
+ transition: all 0.2s ease;
+}
+
+.checkbox-container:hover .checkmark {
+ border-color: var(--accent-color);
+}
+
+.checkbox-container input:checked ~ .checkmark {
+ background-color: var(--accent-color);
+ border-color: var(--accent-color);
+}
+
+.checkbox-container input:checked ~ .checkmark::after {
+ content: '';
+ position: absolute;
+ left: 8px;
+ top: 4px;
+ width: 6px;
+ height: 12px;
+ border: solid white;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
+
+.checkbox-container input:focus ~ .checkmark {
+ outline: 2px solid var(--accent-color);
+ outline-offset: 2px;
+}
+
+.task-details {
+ flex: 1;
+}
+
+.task-text {
+ display: block;
+ font-size: 1rem;
+ color: var(--text-primary);
+ margin-bottom: 0.5rem;
+ word-break: break-word;
+}
+
+.task-item.completed .task-text {
+ text-decoration: line-through;
+}
+
+.task-metadata {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+.task-due {
+ color: var(--accent-color);
+ font-weight: 500;
+}
+
+.task-due.overdue-label {
+ color: var(--danger-color);
+ font-weight: 600;
+}
+
+.delete-button {
+ padding: 0.5rem 1rem;
+ background-color: transparent;
+ color: var(--danger-color);
+ border: 1px solid var(--danger-color);
+ border-radius: 6px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ flex-shrink: 0;
+}
+
+.delete-button:hover {
+ background-color: var(--danger-color);
+ color: white;
+}
+
+.delete-button:focus {
+ outline: 2px solid var(--danger-color);
+ outline-offset: 2px;
+}
+
+.empty-state {
+ text-align: center;
+ padding: 4rem 2rem;
+ background-color: var(--bg-primary);
+ border-radius: 12px;
+ box-shadow: var(--shadow-sm);
+}
+
+.empty-state-icon {
+ color: var(--text-secondary);
+ margin-bottom: 1.5rem;
+}
+
+.empty-state-title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 0.75rem;
+}
+
+.empty-state-description {
+ font-size: 1rem;
+ color: var(--text-secondary);
+ max-width: 400px;
+ margin: 0 auto;
+}
+
+.empty-filter {
+ text-align: center;
+ padding: 3rem 2rem;
+ background-color: var(--bg-primary);
+ border-radius: 12px;
+ box-shadow: var(--shadow-sm);
+}
+
+.empty-filter p {
+ font-size: 1rem;
+ color: var(--text-secondary);
+}
+
+@media (min-width: 480px) {
+ .input-group {
+ flex-direction: row;
+ align-items: stretch;
+ }
+
+ .task-input {
+ flex: 1;
+ }
+
+ .task-date-input {
+ width: auto;
+ min-width: 160px;
+ }
+
+ .add-button {
+ width: auto;
+ }
+}
+
+@media (min-width: 640px) {
+ .app-container {
+ padding: 2rem;
+ }
+
+ .app-title {
+ font-size: 2.5rem;
+ }
+
+ .task-metadata {
+ font-size: 0.813rem;
+ }
+}
+
+@media (min-width: 768px) {
+ .app-container {
+ padding: 3rem 2rem;
+ }
+
+ .stats-grid {
+ gap: 1.5rem;
+ }
+
+ .stat-item {
+ padding: 1rem;
+ }
+}
+
+@media (min-width: 1024px) {
+ .app-header {
+ margin-bottom: 2.5rem;
+ }
+}
+
+@media (min-width: 1600px) {
+ .app-container {
+ max-width: 900px;
+ }
}
diff --git a/src/store/useTaskStore.js b/src/store/useTaskStore.js
new file mode 100644
index 00000000..ee3f5782
--- /dev/null
+++ b/src/store/useTaskStore.js
@@ -0,0 +1,69 @@
+// Zustand is a state management library
+// 'create' holds your app's state
+import { create } from 'zustand'
+
+// 'persist' is middleware that automatically saves state to localStorage
+// so data survives page refreshes
+import { persist } from 'zustand/middleware'
+
+const useTaskStore = create(
+ persist(
+ // 'set' is a function to update state, 'get' retrieves current state
+ (set, get) => ({
+ tasks: [],
+ filter: 'all', // all, active, completed
+ darkMode: false,
+
+ // Action to add a new task
+ addTask: (text, dueDate = null) => {
+ const newTask = {
+ id: Date.now(),
+ text,
+ completed: false,
+ createdAt: new Date().toISOString(),
+ dueDate: dueDate ? new Date(dueDate).toISOString() : null,
+ }
+ // set() receives current state and returns new state
+ // Spread operator (...) creates a new array with old tasks + new task
+ set((state) => ({ tasks: [...state.tasks, newTask] }))
+ },
+
+ removeTask: (id) => {
+ set((state) => ({
+ tasks: state.tasks.filter((task) => task.id !== id),
+ }))
+ },
+
+ // map() creates a new array by transforming each item
+ // For the matching task: spread all properties and flip 'completed'
+ toggleTask: (id) => {
+ set((state) => ({
+ tasks: state.tasks.map((task) =>
+ task.id === id ? { ...task, completed: !task.completed } : task
+ ),
+ }))
+ },
+
+ // Mark all tasks as completed using map()
+ completeAll: () => {
+ set((state) => ({
+ tasks: state.tasks.map((task) => ({ ...task, completed: true })),
+ }))
+ },
+
+ setFilter: (filter) => {
+ set({ filter })
+ },
+
+ toggleDarkMode: () => {
+ set((state) => ({ darkMode: !state.darkMode }))
+ },
+ }),
+ {
+ // This key is used in localStorage to save/load the state
+ name: 'todo-storage',
+ }
+ )
+)
+
+export default useTaskStore