Skip to content

Session 10 WebApp Final Project

Maximiliano Militzer edited this page Apr 7, 2025 · 1 revision

Session 10: WebApp - Final Project (Make Your Own Todo List)

Overview

Welcome to Session 10! In this session, we'll bring together everything we've learned so far to build a complete Todo List application from scratch. This project will integrate all the components we've covered: a Next.js frontend, Django Ninja backend, PostgreSQL database, and deployment to Vercel and Render.

Learning Objectives

  • Apply the skills learned in previous sessions to build a real-world application
  • Create a functional Todo List with CRUD operations
  • Implement user authentication
  • Connect frontend and backend components
  • Apply good software development practices
  • Deploy the completed application

Project Requirements

Our Todo List application will have the following features:

  1. User Authentication:

    • User registration and login
    • Protected routes for authenticated users
  2. Task Management:

    • Create, read, update, and delete tasks
    • Mark tasks as complete/incomplete
    • Set task due dates
    • Categorize tasks
    • Set task priorities
  3. User Interface:

    • Clean, responsive design using Tailwind CSS
    • Dark/light mode toggle
    • Filters for viewing tasks (all, active, completed)
    • Sort tasks by different criteria (due date, priority)
  4. Data Persistence:

    • Store user and task data in PostgreSQL
    • Implement proper database relationships
  5. Security:

    • Secure API endpoints
    • Form validation
    • Protection against common web vulnerabilities

Project Walkthrough

1. Project Setup

Backend Setup:

# Create and activate virtual environment
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install dependencies
pip install django django-ninja django-cors-headers psycopg2-binary pyjwt gunicorn

# Create a new Django project
django-admin startproject todo_backend
cd todo_backend

# Create a new Django app
python manage.py startapp tasks

# Create requirements.txt
pip freeze > requirements.txt

Frontend Setup:

# Create a new Next.js app
npx create-next-app@latest todo_frontend
cd todo_frontend

# Install dependencies
npm install axios react-icons date-fns formik yup zustand
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

2. Backend Implementation

Database Models:

# tasks/models.py
from django.db import models
from django.contrib.auth.models import User

class Category(models.Model):
    name = models.CharField(max_length=100)
    color = models.CharField(max_length=7, default="#000000")  # Hex color
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='categories')
    
    def __str__(self):
        return self.name
    
    class Meta:
        verbose_name_plural = "Categories"

class Priority(models.Model):
    LEVELS = (
        ('LOW', 'Low'),
        ('MEDIUM', 'Medium'),
        ('HIGH', 'High'),
        ('URGENT', 'Urgent')
    )
    
    level = models.CharField(max_length=10, choices=LEVELS, default='MEDIUM')
    
    def __str__(self):
        return self.level
    
    class Meta:
        verbose_name_plural = "Priorities"

class Task(models.Model):
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    completed = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    due_date = models.DateTimeField(null=True, blank=True)
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tasks')
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='tasks')
    priority = models.ForeignKey(Priority, on_delete=models.SET_DEFAULT, default=2, related_name='tasks')
    
    def __str__(self):
        return self.title
    
    class Meta:
        ordering = ['due_date', 'priority', 'created_at']
        indexes = [
            models.Index(fields=['user', 'completed']),
            models.Index(fields=['due_date']),
        ]

API Endpoints:

# tasks/api.py
from ninja import NinjaAPI, Schema
from ninja.security import HttpBearer
from django.contrib.auth.models import User
from django.contrib.auth import authenticate
from django.conf import settings
from typing import List, Optional
from datetime import datetime, timedelta
import jwt
from .models import Task, Category, Priority

# Authentication
class AuthBearer(HttpBearer):
    def authenticate(self, request, token):
        try:
            payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
            user_id = payload.get("user_id")
            user = User.objects.get(id=user_id)
            request.user = user
            return user
        except:
            return None

api = NinjaAPI(auth=AuthBearer())

# Schemas
class TokenSchema(Schema):
    access_token: str
    token_type: str = "bearer"

class LoginSchema(Schema):
    username: str
    password: str

class RegisterSchema(Schema):
    username: str
    email: str
    password: str

class TaskSchema(Schema):
    id: int
    title: str
    description: Optional[str] = None
    completed: bool
    due_date: Optional[datetime] = None
    category_id: Optional[int] = None
    priority_id: Optional[int] = None
    created_at: datetime

class TaskCreateSchema(Schema):
    title: str
    description: Optional[str] = None
    due_date: Optional[datetime] = None
    category_id: Optional[int] = None
    priority_id: Optional[int] = None

class CategorySchema(Schema):
    id: int
    name: str
    color: str

class CategoryCreateSchema(Schema):
    name: str
    color: str = "#000000"

class PrioritySchema(Schema):
    id: int
    level: str

# Authentication endpoints
@api.post("/token", auth=None, response=TokenSchema)
def login(request, credentials: LoginSchema):
    user = authenticate(username=credentials.username, password=credentials.password)
    if user is None:
        return api.create_response(request, {"detail": "Invalid credentials"}, status=401)
    
    token_expiry = datetime.utcnow() + timedelta(days=1)
    access_token = jwt.encode(
        {"user_id": user.id, "exp": token_expiry},
        settings.SECRET_KEY,
        algorithm="HS256"
    )
    
    return {"access_token": access_token}

@api.post("/register", auth=None, response=TokenSchema)
def register(request, user_data: RegisterSchema):
    if User.objects.filter(username=user_data.username).exists():
        return api.create_response(request, {"detail": "Username already exists"}, status=400)
    
    if User.objects.filter(email=user_data.email).exists():
        return api.create_response(request, {"detail": "Email already exists"}, status=400)
    
    user = User.objects.create_user(
        username=user_data.username,
        email=user_data.email,
        password=user_data.password
    )
    
    # Create default categories for the new user
    Category.objects.create(name="Work", color="#FF5733", user=user)
    Category.objects.create(name="Personal", color="#33FF57", user=user)
    Category.objects.create(name="Shopping", color="#3357FF", user=user)
    
    token_expiry = datetime.utcnow() + timedelta(days=1)
    access_token = jwt.encode(
        {"user_id": user.id, "exp": token_expiry},
        settings.SECRET_KEY,
        algorithm="HS256"
    )
    
    return {"access_token": access_token}

# Task endpoints
@api.get("/tasks", response=List[TaskSchema])
def list_tasks(request):
    return Task.objects.filter(user=request.user)

@api.post("/tasks", response=TaskSchema)
def create_task(request, payload: TaskCreateSchema):
    task_data = payload.dict()
    
    # Handle foreign keys
    if task_data.get('category_id'):
        try:
            category = Category.objects.get(id=task_data['category_id'], user=request.user)
        except Category.DoesNotExist:
            return api.create_response(request, {"detail": "Category not found"}, status=404)
        task_data['category'] = category
        del task_data['category_id']
    
    if task_data.get('priority_id'):
        try:
            priority = Priority.objects.get(id=task_data['priority_id'])
        except Priority.DoesNotExist:
            return api.create_response(request, {"detail": "Priority not found"}, status=404)
        task_data['priority'] = priority
        del task_data['priority_id']
    
    task = Task.objects.create(
        user=request.user,
        **task_data
    )
    return task

@api.get("/tasks/{task_id}", response=TaskSchema)
def get_task(request, task_id: int):
    try:
        return Task.objects.get(id=task_id, user=request.user)
    except Task.DoesNotExist:
        return api.create_response(request, {"detail": "Task not found"}, status=404)

@api.put("/tasks/{task_id}", response=TaskSchema)
def update_task(request, task_id: int, payload: TaskCreateSchema):
    try:
        task = Task.objects.get(id=task_id, user=request.user)
    except Task.DoesNotExist:
        return api.create_response(request, {"detail": "Task not found"}, status=404)
    
    task_data = payload.dict()
    
    # Handle foreign keys
    if task_data.get('category_id'):
        try:
            category = Category.objects.get(id=task_data['category_id'], user=request.user)
            task.category = category
        except Category.DoesNotExist:
            return api.create_response(request, {"detail": "Category not found"}, status=404)
        del task_data['category_id']
    
    if task_data.get('priority_id'):
        try:
            priority = Priority.objects.get(id=task_data['priority_id'])
            task.priority = priority
        except Priority.DoesNotExist:
            return api.create_response(request, {"detail": "Priority not found"}, status=404)
        del task_data['priority_id']
    
    # Update other fields
    for key, value in task_data.items():
        setattr(task, key, value)
    
    task.save()
    return task

@api.patch("/tasks/{task_id}/complete", response=TaskSchema)
def toggle_task_completion(request, task_id: int):
    try:
        task = Task.objects.get(id=task_id, user=request.user)
    except Task.DoesNotExist:
        return api.create_response(request, {"detail": "Task not found"}, status=404)
    
    task.completed = not task.completed
    task.save()
    return task

@api.delete("/tasks/{task_id}")
def delete_task(request, task_id: int):
    try:
        task = Task.objects.get(id=task_id, user=request.user)
    except Task.DoesNotExist:
        return api.create_response(request, {"detail": "Task not found"}, status=404)
    
    task.delete()
    return {"success": True}

# Category endpoints
@api.get("/categories", response=List[CategorySchema])
def list_categories(request):
    return Category.objects.filter(user=request.user)

@api.post("/categories", response=CategorySchema)
def create_category(request, payload: CategoryCreateSchema):
    category = Category.objects.create(
        user=request.user,
        **payload.dict()
    )
    return category

# Add additional endpoints for priorities, user details, etc.

3. Frontend Implementation

Configure Tailwind CSS:

// tailwind.config.js
module.exports = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
  darkMode: 'class',
}

Authentication State Management with Zustand:

// src/store/authStore.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import axios from 'axios';

const useAuthStore = create(
  persist(
    (set) => ({
      token: null,
      user: null,
      isAuthenticated: false,
      
      login: async (username, password) => {
        try {
          const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/token`, {
            username,
            password
          });
          
          const { access_token } = response.data;
          
          // Set up axios defaults with token
          axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
          
          // Update store
          set({ 
            token: access_token,
            isAuthenticated: true,
          });
          
          return { success: true };
        } catch (error) {
          return { 
            success: false, 
            error: error.response?.data?.detail || 'Login failed' 
          };
        }
      },
      
      register: async (username, email, password) => {
        try {
          const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/register`, {
            username,
            email,
            password
          });
          
          const { access_token } = response.data;
          
          // Set up axios defaults with token
          axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
          
          // Update store
          set({ 
            token: access_token,
            isAuthenticated: true,
          });
          
          return { success: true };
        } catch (error) {
          return { 
            success: false, 
            error: error.response?.data?.detail || 'Registration failed' 
          };
        }
      },
      
      logout: () => {
        // Clear axios defaults
        delete axios.defaults.headers.common['Authorization'];
        
        // Update store
        set({ 
          token: null,
          user: null,
          isAuthenticated: false,
        });
      },
      
      // Initialize axios with token from storage
      initializeAuth: () => {
        const state = useAuthStore.getState();
        if (state.token) {
          axios.defaults.headers.common['Authorization'] = `Bearer ${state.token}`;
        }
      }
    }),
    {
      name: 'auth-storage', // name for localStorage
    }
  )
);

export default useAuthStore;

Task State Management:

// src/store/taskStore.js
import { create } from 'zustand';
import axios from 'axios';

const useTaskStore = create((set, get) => ({
  tasks: [],
  categories: [],
  priorities: [],
  isLoading: false,
  error: null,
  
  fetchTasks: async () => {
    set({ isLoading: true, error: null });
    try {
      const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/tasks`);
      set({ tasks: response.data, isLoading: false });
    } catch (error) {
      set({ 
        error: error.response?.data?.detail || 'Failed to fetch tasks',
        isLoading: false
      });
    }
  },
  
  createTask: async (taskData) => {
    set({ isLoading: true, error: null });
    try {
      const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/tasks`, taskData);
      set({ 
        tasks: [...get().tasks, response.data],
        isLoading: false 
      });
      return { success: true, task: response.data };
    } catch (error) {
      set({ 
        error: error.response?.data?.detail || 'Failed to create task', 
        isLoading: false 
      });
      return { success: false, error: error.response?.data?.detail || 'Failed to create task' };
    }
  },
  
  updateTask: async (taskId, taskData) => {
    set({ isLoading: true, error: null });
    try {
      const response = await axios.put(`${process.env.NEXT_PUBLIC_API_URL}/tasks/${taskId}`, taskData);
      set({ 
        tasks: get().tasks.map(task => 
          task.id === taskId ? response.data : task
        ),
        isLoading: false 
      });
      return { success: true, task: response.data };
    } catch (error) {
      set({ 
        error: error.response?.data?.detail || 'Failed to update task',
        isLoading: false 
      });
      return { success: false, error: error.response?.data?.detail || 'Failed to update task' };
    }
  },
  
  toggleTaskCompletion: async (taskId) => {
    try {
      const response = await axios.patch(`${process.env.NEXT_PUBLIC_API_URL}/tasks/${taskId}/complete`);
      set({ 
        tasks: get().tasks.map(task => 
          task.id === taskId ? response.data : task
        ) 
      });
      return { success: true, task: response.data };
    } catch (error) {
      return { success: false, error: error.response?.data?.detail || 'Failed to toggle task' };
    }
  },
  
  deleteTask: async (taskId) => {
    try {
      await axios.delete(`${process.env.NEXT_PUBLIC_API_URL}/tasks/${taskId}`);
      set({ 
        tasks: get().tasks.filter(task => task.id !== taskId)
      });
      return { success: true };
    } catch (error) {
      return { success: false, error: error.response?.data?.detail || 'Failed to delete task' };
    }
  },
  
  fetchCategories: async () => {
    try {
      const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/categories`);
      set({ categories: response.data });
      return { success: true, categories: response.data };
    } catch (error) {
      return { success: false, error: error.response?.data?.detail || 'Failed to fetch categories' };
    }
  },
  
  // Add more methods as needed
}));

export default useTaskStore;

Login Page Component:

// src/app/login/page.js
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as Yup from 'yup';
import useAuthStore from '@/store/authStore';

const loginSchema = Yup.object().shape({
  username: Yup.string().required('Username is required'),
  password: Yup.string().required('Password is required'),
});

export default function LoginPage() {
  const router = useRouter();
  const login = useAuthStore(state => state.login);
  const [loginError, setLoginError] = useState('');
  
  const handleLogin = async (values, { setSubmitting }) => {
    setLoginError('');
    const result = await login(values.username, values.password);
    
    if (result.success) {
      router.push('/tasks');
    } else {
      setLoginError(result.error);
    }
    
    setSubmitting(false);
  };
  
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
      <div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-md">
        <div className="text-center">
          <h1 className="text-3xl font-bold text-gray-900 dark:text-white">Login</h1>
          <p className="mt-2 text-gray-600 dark:text-gray-300">Sign in to your account</p>
        </div>
        
        {loginError && (
          <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
            <span className="block sm:inline">{loginError}</span>
          </div>
        )}
        
        <Formik
          initialValues={{ username: '', password: '' }}
          validationSchema={loginSchema}
          onSubmit={handleLogin}
        >
          {({ isSubmitting }) => (
            <Form className="mt-8 space-y-6">
              <div>
                <label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
                  Username
                </label>
                <Field
                  id="username"
                  name="username"
                  type="text"
                  autoComplete="username"
                  className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
                />
                <ErrorMessage name="username" component="div" className="text-red-500 text-sm mt-1" />
              </div>
              
              <div>
                <label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
                  Password
                </label>
                <Field
                  id="password"
                  name="password"
                  type="password"
                  autoComplete="current-password"
                  className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
                />
                <ErrorMessage name="password" component="div" className="text-red-500 text-sm mt-1" />
              </div>
              
              <div>
                <button
                  type="submit"
                  disabled={isSubmitting}
                  className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
                >
                  {isSubmitting ? 'Signing in...' : 'Sign in'}
                </button>
              </div>
              
              <div className="text-center text-sm text-gray-600 dark:text-gray-400">
                Don't have an account?{' '}
                <Link href="/register" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
                  Register
                </Link>
              </div>
            </Form>
          )}
        </Formik>
      </div>
    </div>
  );
}

Tasks Page Component:

'use client';

import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import useAuthStore from '@/store/authStore';
import useTaskStore from '@/store/taskStore';
import TaskItem from '@/components/TaskItem';
import TaskForm from '@/components/TaskForm';
import CategoryFilter from '@/components/CategoryFilter';

export default function TasksPage() {
  const router = useRouter();
  const isAuthenticated = useAuthStore(state => state.isAuthenticated);
  const logout = useAuthStore(state => state.logout);
  
  const { 
    tasks, 
    fetchTasks, 
    categories,
    fetchCategories,
    isLoading,
    error
  } = useTaskStore();
  
  const [showTaskForm, setShowTaskForm] = useState(false);
  const [filter, setFilter] = useState('all');
  const [categoryFilter, setCategoryFilter] = useState(null);
  
  useEffect(() => {
    if (!isAuthenticated) {
      router.push('/login');
      return;
    }
    
    fetchTasks();
    fetchCategories();
  }, [isAuthenticated, router, fetchTasks, fetchCategories]);
  
  const handleLogout = () => {
    logout();
    router.push('/login');
  };
  
  const toggleTaskForm = () => {
    setShowTaskForm(!showTaskForm);
  };
  
  const filteredTasks = tasks.filter(task => {
    if (filter === 'active' && task.completed) return false;
    if (filter === 'completed' && !task.completed) return false;
    if (categoryFilter && task.category_id !== categoryFilter) return false;
    return true;
  });
  
  return (
    <div className="min-h-screen bg-gray-100 dark:bg-gray-900">
      <header className="bg-white dark:bg-gray-800 shadow">
        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
          <h1 className="text-xl font-bold text-gray-900 dark:text-white">Todo List</h1>
          <button
            onClick={handleLogout}
            className="px-4 py-2 text-sm text-white bg-red-600 rounded hover:bg-red-700"
          >
            Logout
          </button>
        </div>
      </header>
      
      <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
        <div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg divide-y divide-gray-200 dark:divide-gray-700">
          <div className="p-4 sm:p-6">
            <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6">
              <div className="mb-4 sm:mb-0">
                <h2 className="text-lg font-medium text-gray-900 dark:text-white">Your Tasks</h2>
                <p className="text-sm text-gray-500 dark:text-gray-400">
                  {filteredTasks.length} tasks ({tasks.filter(t => t.completed).length} completed)
                </p>
              </div>
              <div className="flex space-x-2">
                <button
                  onClick={toggleTaskForm}
                  className="px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700"
                >
                  {showTaskForm ? 'Cancel' : 'Add Task'}
                </button>
              </div>
            </div>
            
            {showTaskForm && <TaskForm onComplete={() => setShowTaskForm(false)} />}
            
            <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 space-y-2 sm:space-y-0">
              <div className="flex space-x-2">
                <button
                  className={`px-3 py-1 text-sm rounded ${filter === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
                  onClick={() => setFilter('all')}
                >
                  All
                </button>
                <button
                  className={`px-3 py-1 text-sm rounded ${filter === 'active' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
                  onClick={() => setFilter('active')}
                >
                  Active
                </button>
                <button
                  className={`px-3 py-1 text-sm rounded ${filter === 'completed' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
                  onClick={() => setFilter('completed')}
                >
                  Completed
                </button>
              </div>
              
              <CategoryFilter 
                categories={categories} 
                selectedCategory={categoryFilter}
                onChange={setCategoryFilter}
              />
            </div>
            
            {isLoading ? (
              <div className="text-center py-8">
                <p className="text-gray-500 dark:text-gray-400">Loading tasks...</p>
              </div>
            ) : error ? (
              <div className="text-center py-8">
                <p className="text-red-500">{error}</p>
                <button 
                  onClick={fetchTasks} 
                  className="mt-2 px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700"
                >
                  Retry
                </button>
              </div>
            ) : filteredTasks.length === 0 ? (
              <div className="text-center py-8">
                <p className="text-gray-500 dark:text-gray-400">No tasks found</p>
              </div>
            ) : (
              <div className="divide-y divide-gray-200 dark:divide-gray-700">
                {filteredTasks.map(task => (
                  <TaskItem 
                    key={task.id} 
                    task={task} 
                    categories={categories}
                  />
                ))}
              </div>
            )}
          </div>
        </div>
      </main>
    </div>
  );
}

4. Final Steps

  1. Testing the Application:

    • Test all CRUD operations
    • Test authentication
    • Test filtering and sorting
    • Test responsive design
  2. Deployment:

    • Deploy the backend to Render
    • Deploy the frontend to Vercel
    • Set up environment variables
    • Test the deployed application
  3. Additional Features (Optional):

    • User profile management
    • Task sharing
    • Email notifications
    • Dark/light theme toggle
    • Export tasks to CSV/PDF

Practice Exercises

  1. Complete the Todo List application by implementing all the features
  2. Add a registration page
  3. Implement category management (create, update, delete categories)
  4. Add search functionality for tasks
  5. Implement task sorting by different criteria
  6. Add a user profile page
  7. Implement data visualization for task completion statistics
  8. Deploy the completed application

Additional Resources

Next Steps

Congratulations on completing your Todo List application! Now, let's move on to Week 3: The MVP. In Session 11: Connect to the Real World, you'll learn how to integrate external APIs into your applications to add even more functionality.

Clone this wiki locally