What if you opened a web app every morning and it had a fresh, personalized English lesson waiting for you? And you couldn’t unlock tomorrow’s lesson until you completed today’s?

That’s what we’re building in this post. A Daily Lesson Generator that:

  • ✅ Generates a new lesson every day using AI
  • ✅ Requires completion before unlocking the next day
  • ✅ Tracks your progress with an assessment dashboard
  • ✅ Adapts difficulty based on your performance
  • ✅ Covers real tech lead scenarios from Part 9

App Overview

Core Flow

┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│   Open App   │───▶│ Today's      │───▶│  Complete    │
│   (Login)    │    │ Lesson       │    │  Exercises   │
└──────────────┘    └──────────────┘    └──────────────┘

                    ┌──────────────┐    ┌──────┴───────┐
                    │  Unlock      │◀───│  Get Score   │
                    │  Next Day    │    │  + Feedback  │
                    └──────────────┘    └──────────────┘

Features

FeatureDescription
Daily LessonOne new lesson per day, generated by AI
Completion GateMust complete today’s lesson to unlock tomorrow’s
Streak TrackerTrack consecutive days of practice
Assessment ScoreScore each exercise (vocabulary, grammar, fluency)
Progress DashboardVisual charts showing improvement over time
Lesson HistoryReview and redo past lessons
Difficulty AdaptationAI adjusts based on your performance

Tech Stack

Frontend:  Next.js 14 (App Router) + TypeScript
Styling:   Tailwind CSS
Database:  Supabase (PostgreSQL)
AI:        Claude API (lesson generation + feedback)
Auth:      Supabase Auth
Deploy:    Vercel (free tier)

Database Schema

Supabase Tables

-- Users (handled by Supabase Auth, extended with profile)
CREATE TABLE user_profiles (
  id UUID PRIMARY KEY REFERENCES auth.users(id),
  display_name TEXT,
  current_level TEXT DEFAULT 'beginner', -- beginner, intermediate, advanced
  streak_count INTEGER DEFAULT 0,
  longest_streak INTEGER DEFAULT 0,
  total_lessons_completed INTEGER DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Daily Lessons
CREATE TABLE lessons (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id),
  day_number INTEGER NOT NULL,
  date DATE NOT NULL,
  title TEXT NOT NULL,
  category TEXT NOT NULL, -- standup, meeting, technical, presentation, email
  difficulty TEXT NOT NULL, -- beginner, intermediate, advanced
  content JSONB NOT NULL, -- full lesson structure
  is_completed BOOLEAN DEFAULT FALSE,
  is_unlocked BOOLEAN DEFAULT FALSE,
  completed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, day_number)
);

-- Exercise Results
CREATE TABLE exercise_results (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  lesson_id UUID REFERENCES lessons(id),
  user_id UUID REFERENCES auth.users(id),
  exercise_type TEXT NOT NULL, -- vocabulary, speaking, writing, scenario
  user_answer TEXT,
  ai_feedback TEXT,
  score INTEGER, -- 0-100
  completed_at TIMESTAMPTZ DEFAULT NOW()
);

-- Assessment Progress
CREATE TABLE assessments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id),
  assessment_date DATE NOT NULL,
  vocabulary_score INTEGER, -- 0-100
  grammar_score INTEGER, -- 0-100
  fluency_score INTEGER, -- 0-100
  confidence_score INTEGER, -- 0-100
  overall_score INTEGER, -- 0-100
  notes TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

AI Lesson Generation

The Lesson Generation Prompt

Here’s the core prompt that generates daily lessons:

// lib/generate-lesson.ts
import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic();

interface LessonRequest {
  dayNumber: number;
  userLevel: string;
  previousScores: { category: string; avgScore: number }[];
  completedCategories: string[];
}

export async function generateLesson(request: LessonRequest) {
  const weakestCategory = request.previousScores
    .sort((a, b) => a.avgScore - b.avgScore)[0]?.category || 'standup';

  const prompt = `You are an English teacher for Vietnamese tech leads. 
Generate a daily lesson for Day ${request.dayNumber}.

Student level: ${request.userLevel}
Weakest area: ${weakestCategory}
Previously completed categories: ${request.completedCategories.join(', ')}

Generate a lesson in this EXACT JSON structure:
{
  "title": "Day ${request.dayNumber}: [Descriptive Title]",
  "category": "[standup|meeting|technical|presentation|email|negotiation]",
  "estimatedMinutes": 15,
  "warmup": {
    "type": "vocabulary",
    "instruction": "[What to do]",
    "items": [
      {
        "word": "[English word/phrase]",
        "pronunciation": "[IPA]",
        "meaning": "[Simple definition]",
        "exampleSentence": "[Usage in tech context]",
        "vietnameseHint": "[Vietnamese translation]"
      }
    ]
  },
  "mainExercise": {
    "type": "[scenario|writing|speaking|reading]",
    "scenario": "[Realistic tech lead situation]",
    "instruction": "[Clear task description]",
    "templateToComplete": "[Template with blanks ___ for user to fill]",
    "idealAnswer": "[Model answer for reference]",
    "scoringCriteria": ["Clarity", "Professional tone", "Structure", "Vocabulary"]
  },
  "speakingDrill": {
    "instruction": "[What to practice saying out loud]",
    "phrases": ["[Phrase 1]", "[Phrase 2]", "[Phrase 3]"],
    "contextNote": "[When to use these phrases]"
  },
  "reflectionPrompt": "[Question for the user to reflect on]"
}

Focus the lesson on ${weakestCategory} scenarios. 
Make it practical — every exercise should relate to real work situations.
Vocabulary should be tech-industry specific.
Difficulty: ${request.userLevel}.`;

  const response = await anthropic.messages.create({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 2000,
    messages: [{ role: 'user', content: prompt }],
  });

  return JSON.parse(response.content[0].text);
}

Scoring User Answers

// lib/score-answer.ts
export async function scoreAnswer(
  exercise: any, 
  userAnswer: string
) {
  const prompt = `You are grading an English exercise for a Vietnamese tech lead.

Exercise: ${exercise.instruction}
Scenario: ${exercise.scenario}
Ideal answer: ${exercise.idealAnswer}
User's answer: ${userAnswer}

Score the answer on these criteria (each 0-25, total 0-100):
1. Clarity: Is the message clear and easy to understand?
2. Professional tone: Is it appropriate for a business context?
3. Structure: Is it well-organized (introduction, body, conclusion)?
4. Vocabulary: Does it use appropriate technical/business vocabulary?

Respond in this JSON format:
{
  "clarity": [0-25],
  "professionalTone": [0-25],
  "structure": [0-25],
  "vocabulary": [0-25],
  "totalScore": [0-100],
  "feedback": "[2-3 sentences of constructive feedback]",
  "improvedVersion": "[The user's answer rewritten with improvements]",
  "keyTakeaway": "[One specific thing to remember]"
}`;

  const response = await anthropic.messages.create({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 1000,
    messages: [{ role: 'user', content: prompt }],
  });

  return JSON.parse(response.content[0].text);
}

Key Pages

1. Today’s Lesson Page

// app/lesson/today/page.tsx
'use client';

import { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase-client';

export default function TodayLesson() {
  const [lesson, setLesson] = useState(null);
  const [currentSection, setCurrentSection] = useState('warmup');
  const [userAnswer, setUserAnswer] = useState('');
  const [feedback, setFeedback] = useState(null);
  const [isCompleted, setIsCompleted] = useState(false);

  useEffect(() => {
    loadTodayLesson();
  }, []);

  async function loadTodayLesson() {
    const res = await fetch('/api/lesson/today');
    const data = await res.json();
    setLesson(data);
  }

  async function submitExercise() {
    const res = await fetch('/api/lesson/submit', {
      method: 'POST',
      body: JSON.stringify({
        lessonId: lesson.id,
        exerciseType: currentSection,
        userAnswer,
      }),
    });
    const result = await res.json();
    setFeedback(result);
  }

  async function completeLesson() {
    await fetch('/api/lesson/complete', {
      method: 'POST',
      body: JSON.stringify({ lessonId: lesson.id }),
    });
    setIsCompleted(true);
  }

  if (!lesson) return <LoadingSkeleton />;

  return (
    <div className="max-w-2xl mx-auto p-6">
      {/* Header */}
      <div className="flex justify-between items-center mb-8">
        <div>
          <span className="text-sm text-gray-500">
            Day {lesson.day_number}
          </span>
          <h1 className="text-2xl font-bold">{lesson.title}</h1>
          <span className="inline-block mt-1 px-3 py-1 rounded-full 
                           text-xs bg-blue-100 text-blue-800">
            {lesson.category}
          </span>
        </div>
        <div className="text-right">
          <span className="text-3xl">🔥</span>
          <p className="text-sm font-medium">
            {lesson.streak} day streak
          </p>
        </div>
      </div>

      {/* Progress Bar */}
      <div className="w-full bg-gray-200 rounded-full h-2 mb-8">
        <div
          className="bg-green-500 h-2 rounded-full transition-all"
          style={{
            width: currentSection === 'warmup' ? '25%' 
                 : currentSection === 'main' ? '50%' 
                 : currentSection === 'speaking' ? '75%' 
                 : '100%',
          }}
        />
      </div>

      {/* Section Content */}
      {currentSection === 'warmup' && (
        <WarmupSection 
          data={lesson.content.warmup}
          onComplete={() => setCurrentSection('main')} 
        />
      )}

      {currentSection === 'main' && (
        <MainExercise
          data={lesson.content.mainExercise}
          userAnswer={userAnswer}
          onChange={setUserAnswer}
          onSubmit={submitExercise}
          feedback={feedback}
          onNext={() => setCurrentSection('speaking')}
        />
      )}

      {currentSection === 'speaking' && (
        <SpeakingDrill
          data={lesson.content.speakingDrill}
          onComplete={() => setCurrentSection('reflection')}
        />
      )}

      {currentSection === 'reflection' && (
        <ReflectionSection
          prompt={lesson.content.reflectionPrompt}
          onComplete={completeLesson}
        />
      )}

      {/* Completion */}
      {isCompleted && (
        <div className="mt-8 p-6 bg-green-50 rounded-xl text-center">
          <span className="text-5xl">🎉</span>
          <h2 className="text-xl font-bold mt-4">
            Day {lesson.day_number} Complete!
          </h2>
          <p className="text-gray-600 mt-2">
            Tomorrow's lesson unlocks in{' '}
            <CountdownTimer targetDate={tomorrow()} />
          </p>
          <div className="mt-4 grid grid-cols-4 gap-4">
            <ScoreCard label="Clarity" score={feedback?.clarity} />
            <ScoreCard label="Tone" score={feedback?.professionalTone} />
            <ScoreCard label="Structure" score={feedback?.structure} />
            <ScoreCard label="Vocabulary" score={feedback?.vocabulary} />
          </div>
        </div>
      )}
    </div>
  );
}

2. Progress Dashboard

// app/progress/page.tsx
export default function ProgressDashboard() {
  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-8">Your Progress</h1>

      {/* Stats Row */}
      <div className="grid grid-cols-4 gap-4 mb-8">
        <StatCard icon="🔥" label="Current Streak" value="12 days" />
        <StatCard icon="📚" label="Lessons Done" value="42" />
        <StatCard icon="📈" label="Avg Score" value="78/100" />
        <StatCard icon="🏆" label="Level" value="Intermediate" />
      </div>

      {/* Score History Chart */}
      <div className="bg-white rounded-xl p-6 shadow mb-8">
        <h2 className="text-xl font-bold mb-4">Score Trend</h2>
        <ScoreChart data={assessments} />
        {/* Line chart showing daily scores over time */}
      </div>

      {/* Category Breakdown */}
      <div className="bg-white rounded-xl p-6 shadow mb-8">
        <h2 className="text-xl font-bold mb-4">Skills Breakdown</h2>
        <div className="grid grid-cols-2 gap-4">
          <SkillBar label="Vocabulary" score={82} color="blue" />
          <SkillBar label="Grammar" score={68} color="green" />
          <SkillBar label="Fluency" score={75} color="purple" />
          <SkillBar label="Confidence" score={71} color="orange" />
        </div>
      </div>

      {/* Category Performance */}
      <div className="bg-white rounded-xl p-6 shadow mb-8">
        <h2 className="text-xl font-bold mb-4">By Scenario Type</h2>
        <RadarChart
          data={{
            'Standups': 85,
            'Meetings': 72,
            'Technical': 78,
            'Presentations': 65,
            'Emails': 80,
            'Negotiations': 60,
          }}
        />
      </div>

      {/* Lesson History */}
      <div className="bg-white rounded-xl p-6 shadow">
        <h2 className="text-xl font-bold mb-4">Lesson History</h2>
        <div className="space-y-3">
          {lessons.map((lesson) => (
            <LessonRow
              key={lesson.id}
              day={lesson.day_number}
              title={lesson.title}
              score={lesson.score}
              date={lesson.date}
              category={lesson.category}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

3. API Routes

// app/api/lesson/today/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase-server';
import { generateLesson } from '@/lib/generate-lesson';

export async function GET(req: NextRequest) {
  const supabase = createServerClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Get user profile
  const { data: profile } = await supabase
    .from('user_profiles')
    .select('*')
    .eq('id', user.id)
    .single();

  // Check if today's lesson already exists
  const today = new Date().toISOString().split('T')[0];
  const { data: existingLesson } = await supabase
    .from('lessons')
    .select('*')
    .eq('user_id', user.id)
    .eq('date', today)
    .single();

  if (existingLesson) {
    return NextResponse.json({
      ...existingLesson,
      content: existingLesson.content,
      streak: profile.streak_count,
    });
  }

  // Check if previous day's lesson is completed
  const { data: previousLesson } = await supabase
    .from('lessons')
    .select('is_completed')
    .eq('user_id', user.id)
    .eq('day_number', profile.total_lessons_completed)
    .single();

  // Day 1 is always unlocked, others need previous completion
  const dayNumber = profile.total_lessons_completed + 1;
  if (dayNumber > 1 && !previousLesson?.is_completed) {
    return NextResponse.json({
      locked: true,
      message: 'Complete the previous lesson first!',
      previousDayNumber: dayNumber - 1,
    });
  }

  // Get previous scores for adaptive difficulty
  const { data: previousResults } = await supabase
    .from('exercise_results')
    .select('exercise_type, score')
    .eq('user_id', user.id)
    .order('completed_at', { ascending: false })
    .limit(30);

  const categoryScores = calculateCategoryAverages(previousResults);
  const completedCategories = await getCompletedCategories(
    supabase, user.id
  );

  // Generate new lesson with AI
  const lessonContent = await generateLesson({
    dayNumber,
    userLevel: profile.current_level,
    previousScores: categoryScores,
    completedCategories,
  });

  // Save to database
  const { data: newLesson } = await supabase
    .from('lessons')
    .insert({
      user_id: user.id,
      day_number: dayNumber,
      date: today,
      title: lessonContent.title,
      category: lessonContent.category,
      difficulty: profile.current_level,
      content: lessonContent,
      is_unlocked: true,
    })
    .select()
    .single();

  return NextResponse.json({
    ...newLesson,
    streak: profile.streak_count,
  });
}
// app/api/lesson/complete/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createServerClient } from '@/lib/supabase-server';

export async function POST(req: NextRequest) {
  const supabase = createServerClient();
  const { data: { user } } = await supabase.auth.getUser();
  const { lessonId } = await req.json();

  // Mark lesson as completed
  await supabase
    .from('lessons')
    .update({
      is_completed: true,
      completed_at: new Date().toISOString(),
    })
    .eq('id', lessonId);

  // Update streak
  const { data: profile } = await supabase
    .from('user_profiles')
    .select('*')
    .eq('id', user.id)
    .single();

  const yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  const yesterdayStr = yesterday.toISOString().split('T')[0];

  const { data: yesterdayLesson } = await supabase
    .from('lessons')
    .select('is_completed')
    .eq('user_id', user.id)
    .eq('date', yesterdayStr)
    .single();

  const newStreak = yesterdayLesson?.is_completed 
    ? profile.streak_count + 1 
    : 1;

  await supabase
    .from('user_profiles')
    .update({
      streak_count: newStreak,
      longest_streak: Math.max(newStreak, profile.longest_streak),
      total_lessons_completed: profile.total_lessons_completed + 1,
    })
    .eq('id', user.id);

  // Calculate if user should level up
  const avgScore = await calculateRecentAverage(supabase, user.id);
  let newLevel = profile.current_level;
  if (avgScore > 85 && profile.current_level === 'beginner') {
    newLevel = 'intermediate';
  } else if (avgScore > 85 && profile.current_level === 'intermediate') {
    newLevel = 'advanced';
  }

  if (newLevel !== profile.current_level) {
    await supabase
      .from('user_profiles')
      .update({ current_level: newLevel })
      .eq('id', user.id);
  }

  return NextResponse.json({
    success: true,
    streak: newStreak,
    levelUp: newLevel !== profile.current_level ? newLevel : null,
  });
}

Weekly Assessment

Every 7 days, the app generates a comprehensive assessment:

// lib/generate-assessment.ts
export async function generateWeeklyAssessment(userId: string) {
  const weekResults = await getWeekResults(userId);
  
  const prompt = `Based on these exercise results from the past 7 days, 
generate a weekly assessment:

Results: ${JSON.stringify(weekResults)}

Provide scores (0-100) and specific recommendations:
{
  "vocabulary_score": [0-100],
  "grammar_score": [0-100],
  "fluency_score": [0-100],
  "confidence_score": [0-100],
  "overall_score": [0-100],
  "strengths": ["[strength 1]", "[strength 2]"],
  "areasToImprove": ["[area 1]", "[area 2]"],
  "weeklyGoal": "[One specific goal for next week]",
  "recommendation": "[Personalized advice]"
}`;

  // ... call AI and store assessment
}

Quick Start Guide

1. Create the project

npx -y create-next-app@latest english-daily --typescript --tailwind --app --src-dir --use-npm
cd english-daily

2. Install dependencies

npm install @supabase/supabase-js @anthropic-ai/sdk recharts

3. Set up environment variables

# .env.local
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
ANTHROPIC_API_KEY=your_claude_api_key

4. Set up Supabase

  1. Create a project at supabase.com
  2. Run the SQL schema from above in the SQL Editor
  3. Enable Row Level Security (RLS)
  4. Set up email/password authentication

5. Run locally

npm run dev

User Experience Flow

First Visit

  1. Sign up / Log in
  2. Take a quick level assessment (5 questions)
  3. App assigns you: Beginner / Intermediate / Advanced
  4. Day 1 lesson is immediately unlocked

Daily Experience

  1. Open the app → see today’s lesson
  2. Warmup (2 min): Learn 3-5 new vocabulary words
  3. Main Exercise (8 min): Complete a scenario-based exercise
  4. Speaking Drill (3 min): Practice saying phrases out loud
  5. Reflection (2 min): Answer a reflection question
  6. Get your score + AI feedback
  7. Tomorrow’s lesson unlocks

If You Miss a Day

  • Streak resets (motivates consistency)
  • You can still access your current lesson
  • Previous lessons remain available for review
  • No penalty — just encouragement to continue

Estimated Costs

ServiceFree TierEstimated Monthly
Vercel100GB bandwidth$0
Supabase50K API calls, 500MB DB$0
Claude API~30 lessons × $0.01~$0.30
Total~$0.30/month

For personal use, this app is essentially free.

What You’ll Learn Building This

Beyond English practice, building this app teaches you:

  • Next.js App Router with server components
  • Supabase authentication and database
  • AI integration with Claude API
  • Streak/gamification mechanics
  • Adaptive learning algorithms

It’s an English learning app that also teaches you modern web development. Win-win.


Next up: Part 11 — English for Client Interviews & Project Bidding — frameworks and scripts for winning new projects, first client meetings, and bid presentations.

Export for reading

Comments