Compare commits

...

2 Commits

Author SHA1 Message Date
Lucas Santana
f1f2906d09 fix: Phonic types
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-19 16:57:41 -03:00
Lucas Santana
ce845607f9 fix: Corrigindo tracking na Geracao de Historia 2025-01-19 10:47:50 -03:00
19 changed files with 464 additions and 320 deletions

View File

@ -50,6 +50,7 @@ export function AudioPlayer({ word, disabled }: AudioPlayerProps) {
className="gap-2" className="gap-2"
onClick={playAudio} onClick={playAudio}
disabled={disabled || isLoading} disabled={disabled || isLoading}
trackingId="audio-player-toggle"
> >
{isLoading ? ( {isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />

View File

@ -2,19 +2,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Clock, Star } from "lucide-react"; import { Clock, Star, Timer } from "lucide-react";
import type { PhonicsExercise, PhonicsProgress } from "@/types/phonics"; import { cn } from "@/lib/utils";
import type { PhonicsExercise, StudentPhonicsProgress } from "@/types/phonics";
interface ExerciseCardProps { interface ExerciseCardProps {
exercise: PhonicsExercise; exercise: PhonicsExercise;
progress?: PhonicsProgress; progress?: StudentPhonicsProgress;
onStart: (exerciseId: string) => void; onStart: (exerciseId: string) => void;
} }
export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps) { export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps) {
const isCompleted = progress?.completed; const isCompleted = progress?.completed;
const stars = progress?.stars || 0; const stars = progress?.stars || 0;
const progressValue = progress ? (progress.bestScore * 100) : 0; const progressValue = progress ? (progress.best_score * 100) : 0;
return ( return (
<Card className="w-full hover:shadow-lg transition-shadow"> <Card className="w-full hover:shadow-lg transition-shadow">
@ -34,7 +35,7 @@ export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps)
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
<span>{Math.ceil(exercise.estimatedTimeSeconds / 60)} min</span> <span>{Math.ceil((exercise.estimated_time_seconds ?? 0) / 60)} min</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
@ -56,10 +57,11 @@ export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps)
</div> </div>
)} )}
<Button <Button
className="w-full" className="mt-4"
onClick={() => onStart(exercise.id)} onClick={() => onStart(exercise.id)}
variant={isCompleted ? "secondary" : "default"} variant={isCompleted ? "secondary" : "default"}
trackingId="exercise-card-start"
> >
{isCompleted ? "Praticar Novamente" : "Começar"} {isCompleted ? "Praticar Novamente" : "Começar"}
</Button> </Button>

View File

@ -39,7 +39,7 @@ export function ExerciseGrid({ categoryId, studentId, onSelectExercise }: Exerci
<ExerciseCard <ExerciseCard
key={exercise.id} key={exercise.id}
exercise={exercise} exercise={exercise}
progress={progress?.find((p) => p.exerciseId === exercise.id)} progress={progress?.find((p) => p.exercise_id === exercise.id)}
onStart={onSelectExercise} onStart={onSelectExercise}
/> />
))} ))}

View File

@ -2,35 +2,36 @@ import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { useExerciseWords } from "@/hooks/phonics/useExerciseAttempt";
import { useUpdatePhonicsProgress } from "@/hooks/phonics/usePhonicsProgress"; import { useUpdatePhonicsProgress } from "@/hooks/phonics/usePhonicsProgress";
import { ExerciseFactory } from "./exercises/ExerciseFactory"; import { ExerciseFactory } from "./exercises/ExerciseFactory";
import { Timer } from "lucide-react"; import { Timer } from "lucide-react";
import type { PhonicsExercise, PhonicsWord } from "@/types/phonics"; import type { PhonicsExercise, UpdateProgressParams } from "@/types/phonics";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ExercisePlayerProps { interface ExercisePlayerProps {
exercise: PhonicsExercise; exercise: PhonicsExercise;
studentId: string; student_id: string;
onComplete: () => void; onComplete: (result: {
score: number;
stars: number;
xp_earned: number;
completed: boolean;
}) => void;
onExit: () => void; onExit: () => void;
} }
export function ExercisePlayer({ export function ExercisePlayer({
exercise, exercise,
studentId, student_id,
onComplete, onComplete,
onExit onExit
}: ExercisePlayerProps) { }: ExercisePlayerProps) {
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const [timeSpent, setTimeSpent] = useState(0); const [timeSpent, setTimeSpent] = useState(0);
const [answers, setAnswers] = useState<string[]>([]);
const [mistakes, setMistakes] = useState<string[]>([]);
const [showFeedback, setShowFeedback] = useState(false); const [showFeedback, setShowFeedback] = useState(false);
const [lastAnswerCorrect, setLastAnswerCorrect] = useState<boolean | null>(null); const [lastAnswerCorrect, setLastAnswerCorrect] = useState<boolean | null>(null);
const { data: exerciseWords, isLoading } = useExerciseWords(exercise.id);
const updateProgress = useUpdatePhonicsProgress(); const updateProgress = useUpdatePhonicsProgress();
useEffect(() => { useEffect(() => {
@ -47,16 +48,16 @@ export function ExercisePlayer({
if (isCorrect) { if (isCorrect) {
setScore((prev) => prev + 1); setScore((prev) => prev + 1);
setAnswers((prev) => [...prev, word]);
} else {
setMistakes((prev) => [...prev, word]);
} }
// Aguardar feedback antes de prosseguir // Aguardar feedback antes de prosseguir
await new Promise(resolve => setTimeout(resolve, 1500)); await new Promise(resolve => setTimeout(resolve, 1500));
setShowFeedback(false); setShowFeedback(false);
if (currentStep < (exerciseWords?.length || 0) - 1) { // Filtra apenas as palavras corretas
const correctWords = exercise.words?.filter(w => w.is_correct_answer) || [];
if (currentStep < correctWords.length - 1) {
setCurrentStep((prev) => prev + 1); setCurrentStep((prev) => prev + 1);
} else { } else {
handleComplete(); handleComplete();
@ -64,24 +65,36 @@ export function ExercisePlayer({
}; };
const handleComplete = async () => { const handleComplete = async () => {
const finalScore = score / (exerciseWords?.length || 1); // Filtra apenas as palavras corretas
const correctWords = exercise.words?.filter(w => w.is_correct_answer) || [];
const finalScore = score / correctWords.length;
const stars = Math.ceil(finalScore * 3); const stars = Math.ceil(finalScore * 3);
const xp_earned = Math.round(finalScore * exercise.points);
const completed = finalScore >= exercise.required_score;
await updateProgress.mutateAsync({ const updateParams: UpdateProgressParams = {
studentId, student_id,
exerciseId: exercise.id, exercise_id: exercise.id,
attempts: 1, best_score: finalScore,
bestScore: finalScore, last_score: finalScore,
lastScore: finalScore, completed,
completed: finalScore >= exercise.requiredScore,
stars, stars,
xpEarned: Math.round(finalScore * exercise.points) xp_earned,
}); time_spent_seconds: timeSpent,
correct_answers_count: score,
total_answers_count: correctWords.length
};
onComplete(); await updateProgress.mutateAsync(updateParams);
onComplete({
score: finalScore,
stars,
xp_earned,
completed
});
}; };
if (isLoading || !exerciseWords?.length) { if (!exercise.words?.length) {
return ( return (
<Card className="w-full max-w-2xl mx-auto"> <Card className="w-full max-w-2xl mx-auto">
<CardContent className="py-8"> <CardContent className="py-8">
@ -93,8 +106,21 @@ export function ExercisePlayer({
); );
} }
const progress = ((currentStep + 1) / exerciseWords.length) * 100; // Filtra apenas as palavras corretas e ordena por order_index
const currentWord = exerciseWords[currentStep].word as unknown as PhonicsWord; const correctWords = exercise.words
.filter(w => w.is_correct_answer)
.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
// Pega a palavra atual
const currentWord = correctWords[currentStep];
// Pega as opções (incluindo a palavra correta)
const options = exercise.words
.filter(w => w.order_index === currentWord.order_index)
.map(w => w.word)
.sort(() => Math.random() - 0.5);
const progress = ((currentStep + 1) / correctWords.length) * 100;
return ( return (
<Card className={cn( <Card className={cn(
@ -110,8 +136,8 @@ export function ExercisePlayer({
<Timer className="w-4 h-4" /> <Timer className="w-4 h-4" />
<span>{Math.floor(timeSpent / 60)}:{(timeSpent % 60).toString().padStart(2, '0')}</span> <span>{Math.floor(timeSpent / 60)}:{(timeSpent % 60).toString().padStart(2, '0')}</span>
</div> </div>
<Button variant="outline" size="sm" onClick={onExit}> <Button variant="outline" size="sm" onClick={onExit} trackingId="exercise-player-exit">
Sair Sair do Exercício
</Button> </Button>
</div> </div>
</div> </div>
@ -121,13 +147,13 @@ export function ExercisePlayer({
<CardContent> <CardContent>
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center text-muted-foreground mb-8"> <div className="text-center text-muted-foreground mb-8">
Exercício {currentStep + 1} de {exerciseWords.length} Exercício {currentStep + 1} de {correctWords.length}
</div> </div>
<ExerciseFactory <ExerciseFactory
type={exercise.exerciseType} type_id={exercise.type_id}
currentWord={currentWord} currentWord={currentWord.word}
options={exerciseWords[currentStep].options} options={options}
onAnswer={handleAnswer} onAnswer={handleAnswer}
disabled={showFeedback} disabled={showFeedback}
/> />

View File

@ -1,46 +1,40 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { BaseExercise, type BaseExerciseProps } from "./BaseExercise"; import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface AlliterationExerciseProps extends BaseExerciseProps { interface AlliterationExerciseProps {
options: Array<{ currentWord: PhonicsWord;
word: string; options: PhonicsWord[];
hasSameInitialSound: boolean; onAnswer: (word: string, isCorrect: boolean) => void;
}>; disabled?: boolean;
} }
export function AlliterationExercise({ export function AlliterationExercise({ currentWord, options, onAnswer, disabled }: AlliterationExerciseProps) {
currentWord,
onAnswer,
options,
disabled
}: AlliterationExerciseProps) {
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} /> <div className="text-center">
<h3 className="text-lg font-medium mb-2">Qual palavra começa com o mesmo som?</h3>
<div className="space-y-4"> <div className="text-4xl font-bold">{currentWord.word}</div>
<div className="text-center text-muted-foreground"> </div>
Qual palavra começa com o mesmo som que <span className="font-medium text-foreground">{currentWord.word}</span>?
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{options.map((option) => ( {options.map((option) => (
<Button <Button
key={option.word} key={option.id}
onClick={() => onAnswer(option.word, option.hasSameInitialSound)} size="lg"
disabled={disabled} variant="outline"
variant="outline" className={cn(
className={cn( "h-auto py-6 text-xl font-medium",
"h-16 text-lg", disabled && "opacity-50 cursor-not-allowed"
disabled && option.hasSameInitialSound && "border-green-500 bg-green-50", )}
disabled && !option.hasSameInitialSound && "border-red-500 bg-red-50" onClick={() => onAnswer(option.word, option.id === currentWord.id)}
)} disabled={disabled}
> trackingId={`alliteration-option-${option.word}`}
{option.word} >
</Button> {option.word}
))} </Button>
</div> ))}
</div> </div>
</div> </div>
); );

View File

@ -1,20 +1,21 @@
import type { PhonicsExerciseType, PhonicsWord } from "@/types/phonics";
import { RhymeExercise } from "./RhymeExercise"; import { RhymeExercise } from "./RhymeExercise";
import { AlliterationExercise } from "./AlliterationExercise"; import { AlliterationExercise } from "./AlliterationExercise";
import { SyllablesExercise } from "./SyllablesExercise"; import { SyllablesExercise } from "./SyllablesExercise";
import { SoundMatchExercise } from "./SoundMatchExercise"; import { InitialSoundExercise } from "./InitialSoundExercise";
import { FinalSoundExercise } from "./FinalSoundExercise";
import type { PhonicsWord } from "@/types/phonics";
interface ExerciseFactoryProps { interface ExerciseFactoryProps {
type: PhonicsExerciseType; type_id: string;
currentWord: PhonicsWord; currentWord: PhonicsWord;
options: any; // Tipo específico para cada exercício options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void; onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean; disabled?: boolean;
} }
export function ExerciseFactory({ type, currentWord, options, onAnswer, disabled }: ExerciseFactoryProps) { export function ExerciseFactory({ type_id, currentWord, options, onAnswer, disabled }: ExerciseFactoryProps) {
switch (type) { switch (type_id) {
case 'rhyme': case '1': // Rima
return ( return (
<RhymeExercise <RhymeExercise
currentWord={currentWord} currentWord={currentWord}
@ -23,8 +24,7 @@ export function ExerciseFactory({ type, currentWord, options, onAnswer, disabled
disabled={disabled} disabled={disabled}
/> />
); );
case '2': // Aliteração
case 'alliteration':
return ( return (
<AlliterationExercise <AlliterationExercise
currentWord={currentWord} currentWord={currentWord}
@ -33,45 +33,34 @@ export function ExerciseFactory({ type, currentWord, options, onAnswer, disabled
disabled={disabled} disabled={disabled}
/> />
); );
case '3': // Sílabas
case 'syllables':
return ( return (
<SyllablesExercise <SyllablesExercise
currentWord={currentWord} currentWord={currentWord}
syllables={options.syllables}
correctOrder={options.correctOrder}
onAnswer={onAnswer}
disabled={disabled}
/>
);
case 'initial_sound':
return (
<SoundMatchExercise
currentWord={currentWord}
type="initial"
options={options} options={options}
onAnswer={onAnswer} onAnswer={onAnswer}
disabled={disabled} disabled={disabled}
/> />
); );
case '4': // Som Inicial
case 'final_sound':
return ( return (
<SoundMatchExercise <InitialSoundExercise
currentWord={currentWord}
options={options}
onAnswer={onAnswer}
disabled={disabled}
/>
);
case '5': // Som Final
return (
<FinalSoundExercise
currentWord={currentWord} currentWord={currentWord}
type="final"
options={options} options={options}
onAnswer={onAnswer} onAnswer={onAnswer}
disabled={disabled} disabled={disabled}
/> />
); );
default: default:
return ( return null;
<div className="text-center text-red-500">
Tipo de exercício não implementado: {type}
</div>
);
} }
} }

View File

@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface FinalSoundExerciseProps {
currentWord: PhonicsWord;
options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
}
export function FinalSoundExercise({ currentWord, options, onAnswer, disabled }: FinalSoundExerciseProps) {
return (
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium mb-2">Qual palavra termina com o mesmo som?</h3>
<div className="text-4xl font-bold">{currentWord.word}</div>
</div>
<div className="grid grid-cols-2 gap-4">
{options.map((option) => (
<Button
key={option.id}
size="lg"
variant="outline"
className={cn(
"h-auto py-6 text-xl font-medium",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => onAnswer(option.word, option.id === currentWord.id)}
disabled={disabled}
trackingId={`final-sound-option-${option.word}`}
>
{option.word}
</Button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface InitialSoundExerciseProps {
currentWord: PhonicsWord;
options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
}
export function InitialSoundExercise({ currentWord, options, onAnswer, disabled }: InitialSoundExerciseProps) {
return (
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium mb-2">Qual palavra começa com o mesmo som?</h3>
<div className="text-4xl font-bold">{currentWord.word}</div>
</div>
<div className="grid grid-cols-2 gap-4">
{options.map((option) => (
<Button
key={option.id}
size="lg"
variant="outline"
className={cn(
"h-auto py-6 text-xl font-medium",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => onAnswer(option.word, option.id === currentWord.id)}
disabled={disabled}
trackingId={`initial-sound-option-${option.word}`}
>
{option.word}
</Button>
))}
</div>
</div>
);
}

View File

@ -1,41 +1,40 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { BaseExercise, type BaseExerciseProps } from "./BaseExercise"; import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface RhymeExerciseProps extends BaseExerciseProps { interface RhymeExerciseProps {
options: Array<{ currentWord: PhonicsWord;
word: string; options: PhonicsWord[];
isRhyme: boolean; onAnswer: (word: string, isCorrect: boolean) => void;
}>; disabled?: boolean;
} }
export function RhymeExercise({ currentWord, onAnswer, options, disabled }: RhymeExerciseProps) { export function RhymeExercise({ currentWord, options, onAnswer, disabled }: RhymeExerciseProps) {
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} /> <div className="text-center">
<h3 className="text-lg font-medium mb-2">Qual palavra rima com:</h3>
<div className="space-y-4"> <div className="text-4xl font-bold">{currentWord.word}</div>
<div className="text-center text-muted-foreground"> </div>
Qual palavra rima com <span className="font-medium text-foreground">{currentWord.word}</span>?
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{options.map((option) => ( {options.map((option) => (
<Button <Button
key={option.word} key={option.id}
onClick={() => onAnswer(option.word, option.isRhyme)} size="lg"
disabled={disabled} variant="outline"
variant="outline" className={cn(
className={cn( "h-auto py-6 text-xl font-medium",
"h-16 text-lg", disabled && "opacity-50 cursor-not-allowed"
disabled && option.isRhyme && "border-green-500 bg-green-50", )}
disabled && !option.isRhyme && "border-red-500 bg-red-50" onClick={() => onAnswer(option.word, option.id === currentWord.id)}
)} disabled={disabled}
> trackingId={`rhyme-option-${option.word}`}
{option.word} >
</Button> {option.word}
))} </Button>
</div> ))}
</div> </div>
</div> </div>
); );

View File

@ -42,6 +42,7 @@ export function SoundMatchExercise({
disabled && option.hasMatchingSound && "border-green-500 bg-green-50", disabled && option.hasMatchingSound && "border-green-500 bg-green-50",
disabled && !option.hasMatchingSound && "border-red-500 bg-red-50" disabled && !option.hasMatchingSound && "border-red-500 bg-red-50"
)} )}
trackingId={`sound-match-option-${option.word}`}
> >
{option.word} {option.word}
</Button> </Button>

View File

@ -1,80 +1,40 @@
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { BaseExercise, type BaseExerciseProps } from "./BaseExercise"; import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface SyllablesExerciseProps extends BaseExerciseProps { interface SyllablesExerciseProps {
syllables: string[]; currentWord: PhonicsWord;
correctOrder: number[]; options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
} }
export function SyllablesExercise({ export function SyllablesExercise({ currentWord, options, onAnswer, disabled }: SyllablesExerciseProps) {
currentWord,
onAnswer,
syllables,
correctOrder,
disabled
}: SyllablesExerciseProps) {
const [selectedSyllables, setSelectedSyllables] = useState<number[]>([]);
const handleSyllableClick = (index: number) => {
if (selectedSyllables.includes(index)) {
setSelectedSyllables(prev => prev.filter(i => i !== index));
} else {
setSelectedSyllables(prev => [...prev, index]);
}
};
const handleCheck = () => {
const isCorrect = selectedSyllables.every(
(syllableIndex, position) => syllableIndex === correctOrder[position]
);
onAnswer(currentWord.word, isCorrect);
};
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} /> <div className="text-center">
<h3 className="text-lg font-medium mb-2">Quantas sílabas tem a palavra?</h3>
<div className="space-y-4"> <div className="text-4xl font-bold">{currentWord.word}</div>
<div className="text-center text-muted-foreground"> </div>
Selecione as sílabas na ordem correta
</div>
<div className="flex flex-wrap justify-center gap-2"> <div className="grid grid-cols-2 gap-4">
{syllables.map((syllable, index) => ( {options.map((option) => (
<Button <Button
key={index} key={option.id}
onClick={() => handleSyllableClick(index)} size="lg"
disabled={disabled} variant="outline"
variant={selectedSyllables.includes(index) ? "default" : "outline"} className={cn(
className={cn( "h-auto py-6 text-xl font-medium",
"text-lg px-4", disabled && "opacity-50 cursor-not-allowed"
disabled && correctOrder.indexOf(index) === selectedSyllables.indexOf(index) && "border-green-500 bg-green-50", )}
disabled && correctOrder.indexOf(index) !== selectedSyllables.indexOf(index) && "border-red-500 bg-red-50" onClick={() => onAnswer(option.word, option.syllables_count === currentWord.syllables_count)}
)} disabled={disabled}
> trackingId={`syllables-option-${option.syllables_count}`}
{syllable} >
</Button> {option.syllables_count} sílabas
))} </Button>
</div> ))}
{selectedSyllables.length > 0 && !disabled && (
<div className="flex justify-center">
<Button
onClick={handleCheck}
className="mt-4"
>
Verificar
</Button>
</div>
)}
{selectedSyllables.length > 0 && (
<div className="text-center text-xl font-medium">
{syllables.filter((_, i) => selectedSyllables.includes(i)).join("-")}
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -130,6 +130,26 @@ export function StoryGenerator() {
if (storyError) throw storyError; if (storyError) throw storyError;
// Tracking da criação da história
const selectedTheme = themes?.find(t => t.id === choices.theme_id)?.title || '';
const selectedSubject = subjects?.find(s => s.id === choices.subject_id)?.title || '';
const selectedCharacter = characters?.find(c => c.id === choices.character_id)?.title || '';
const selectedSetting = settings?.find(s => s.id === choices.setting_id)?.title || '';
trackStoryGenerated({
story_id: story.id,
theme: selectedTheme,
subject: selectedSubject,
character: selectedCharacter,
setting: selectedSetting,
context: choices.context,
generation_time: Date.now() - startTime.current,
word_count: 0, // será atualizado após a geração
student_id: session.user.id,
school_id: session.user.user_metadata?.school_id,
class_id: session.user.user_metadata?.class_id
});
setGenerationStatus('generating-images'); setGenerationStatus('generating-images');
console.log('Chamando Edge Function com:', story); console.log('Chamando Edge Function com:', story);
@ -153,21 +173,14 @@ export function StoryGenerator() {
if (updateError) throw updateError; if (updateError) throw updateError;
// Track story generation // Atualizar a contagem de palavras após a geração
const selectedTheme = themes?.find(t => t.id === choices.theme_id)?.title || '';
const generationTime = Date.now() - startTime.current;
const wordCount = updatedStory.content.pages.reduce((acc: number, page: { text: string }) => const wordCount = updatedStory.content.pages.reduce((acc: number, page: { text: string }) =>
acc + page.text.split(/\s+/).length, 0); acc + page.text.split(/\s+/).length, 0);
trackStoryGenerated({ await supabase.from('story_metrics').insert({
story_id: story.id, story_id: story.id,
theme: selectedTheme,
prompt: JSON.stringify(choices),
generation_time: generationTime,
word_count: wordCount, word_count: wordCount,
student_id: session.user.id, generation_time: Date.now() - startTime.current
school_id: session.user.user_metadata?.school_id,
class_id: session.user.user_metadata?.class_id
}); });
navigate(`/aluno/historias/${story.id}`); navigate(`/aluno/historias/${story.id}`);

View File

@ -1,14 +1,23 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import type { PhonicsAttempt } from '@/types/phonics'; import type { StudentPhonicsAttempt, PhonicsWord } from '@/types/phonics';
interface ExerciseWordResponse {
word: PhonicsWord;
}
interface ExerciseWordWithOptions {
word: PhonicsWord;
options: PhonicsWord[];
}
export function useExerciseAttempt() { export function useExerciseAttempt() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (attempt: Omit<PhonicsAttempt, 'id'>) => { mutationFn: async (attempt: Omit<StudentPhonicsAttempt, 'id'>) => {
const { data, error } = await supabase const { data, error } = await supabase
.from('phonics_student_attempts') .from('student_phonics_attempts')
.insert(attempt) .insert(attempt)
.select() .select()
.single(); .single();
@ -18,7 +27,7 @@ export function useExerciseAttempt() {
}, },
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['phonics-progress', variables.studentId] queryKey: ['phonics-progress', variables.student_id]
}); });
} }
}); });
@ -28,17 +37,55 @@ export function useExerciseWords(exerciseId: string) {
return useQuery({ return useQuery({
queryKey: ['exercise-words', exerciseId], queryKey: ['exercise-words', exerciseId],
queryFn: async () => { queryFn: async () => {
const { data, error } = await supabase // Primeiro, busca a palavra correta e suas opções
const { data: exerciseWords, error: exerciseWordsError } = await supabase
.from('phonics_exercise_words') .from('phonics_exercise_words')
.select(` .select('word:phonics_words!inner(*)')
*,
word:phonics_words(*)
`)
.eq('exercise_id', exerciseId) .eq('exercise_id', exerciseId)
.eq('is_correct_answer', true)
.order('order_index', { ascending: true }); .order('order_index', { ascending: true });
if (exerciseWordsError) throw exerciseWordsError;
// Para cada palavra correta, busca suas opções
const wordsWithOptions = await Promise.all(
((exerciseWords as unknown) as ExerciseWordResponse[]).map(async (exerciseWord) => {
const { data: options, error: optionsError } = await supabase
.from('phonics_exercise_words')
.select('word:phonics_words!inner(*)')
.eq('exercise_id', exerciseId)
.neq('word.id', exerciseWord.word.id)
.limit(3);
if (optionsError) throw optionsError;
const optionWords = ((options as unknown) as ExerciseWordResponse[]).map(o => o.word);
return {
word: exerciseWord.word,
options: [exerciseWord.word, ...optionWords].sort(() => Math.random() - 0.5)
} satisfies ExerciseWordWithOptions;
})
);
return wordsWithOptions;
},
enabled: !!exerciseId
});
}
export function useExerciseAttempts(exerciseId: string) {
return useQuery({
queryKey: ['exercise-attempts', exerciseId],
queryFn: async () => {
const { data, error } = await supabase
.from('student_phonics_attempts')
.select('*')
.eq('exercise_id', exerciseId);
if (error) throw error; if (error) throw error;
return data; return data as StudentPhonicsAttempt[];
} },
enabled: !!exerciseId
}); });
} }

View File

@ -10,9 +10,16 @@ export function usePhonicsExercises(categoryId?: string) {
.from('phonics_exercises') .from('phonics_exercises')
.select(` .select(`
*, *,
category:phonics_exercise_categories(name), category:phonics_categories(
type:phonics_exercise_types(name), id,
words:phonics_exercise_words( name,
description,
level
),
words:phonics_exercise_words!inner(
id,
is_correct_answer,
order_index,
word:phonics_words(*) word:phonics_words(*)
) )
`) `)
@ -36,7 +43,7 @@ export function usePhonicsCategories() {
queryKey: ['phonics-categories'], queryKey: ['phonics-categories'],
queryFn: async () => { queryFn: async () => {
const { data, error } = await supabase const { data, error } = await supabase
.from('phonics_exercise_categories') .from('phonics_categories')
.select('*') .select('*')
.order('order_index', { ascending: true }); .order('order_index', { ascending: true });

View File

@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import type { StudentPhonicsProgress } from '@/types/phonics'; import type { StudentPhonicsProgress, UpdateProgressParams } from '@/types/phonics';
export function usePhonicsProgress(studentId: string) { export function usePhonicsProgress(studentId: string) {
return useQuery({ return useQuery({
@ -22,57 +22,49 @@ export function useUpdatePhonicsProgress() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async (params: UpdateProgressParams) => {
studentId, // Primeiro, busca o progresso atual para calcular os valores acumulados
exerciseId, const { data: currentProgress } = await supabase
score, .from('student_phonics_progress')
timeSpent .select('total_time_spent_seconds, correct_answers_count, total_answers_count')
}: { .eq('student_id', params.student_id)
studentId: string; .eq('exercise_id', params.exercise_id)
exerciseId: string;
score: number;
timeSpent: number;
}) => {
// Primeiro, registra a tentativa
const { data: attemptData, error: attemptError } = await supabase
.from('student_phonics_attempts')
.insert({
student_id: studentId,
exercise_id: exerciseId,
score,
time_spent_seconds: timeSpent
})
.select()
.single(); .single();
if (attemptError) throw attemptError; // Calcula os novos valores acumulados
const total_time_spent_seconds = (currentProgress?.total_time_spent_seconds || 0) + params.time_spent_seconds;
const correct_answers_count = (currentProgress?.correct_answers_count || 0) + params.correct_answers_count;
const total_answers_count = (currentProgress?.total_answers_count || 0) + params.total_answers_count;
// Depois, atualiza ou cria o progresso // Atualiza o progresso
const { data: progressData, error: progressError } = await supabase const { data, error } = await supabase
.from('student_phonics_progress') .from('student_phonics_progress')
.upsert({ .upsert({
student_id: studentId, student_id: params.student_id,
exercise_id: exerciseId, exercise_id: params.exercise_id,
attempts: 1, best_score: params.best_score,
best_score: score, last_score: params.last_score,
last_score: score, completed: params.completed,
completed: score >= 0.7, completed_at: params.completed ? new Date().toISOString() : null,
completed_at: score >= 0.7 ? new Date().toISOString() : null, stars: params.stars,
stars: Math.ceil(score * 3), xp_earned: params.xp_earned,
xp_earned: Math.ceil(score * 100) attempts: 'COALESCE(attempts, 0) + 1',
total_time_spent_seconds,
correct_answers_count,
total_answers_count,
last_attempt_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}, { }, {
onConflict: 'student_id,exercise_id', onConflict: 'student_id,exercise_id'
ignoreDuplicates: false
}) })
.select() .select()
.single(); .single();
if (progressError) throw progressError; if (error) throw error;
return data as StudentPhonicsProgress;
return progressData;
}, },
onSuccess: (_, { studentId }) => { onSuccess: (_, { student_id }) => {
queryClient.invalidateQueries({ queryKey: ['phonics-progress', studentId] }); queryClient.invalidateQueries({ queryKey: ['phonics-progress', student_id] });
} }
}); });
} }

View File

@ -3,7 +3,11 @@ import { analytics } from '../lib/analytics';
interface StoryGeneratedProps { interface StoryGeneratedProps {
story_id: string; story_id: string;
theme: string; theme: string;
prompt: string; subject: string;
character: string;
setting: string;
context?: string;
prompt?: string;
generation_time: number; generation_time: number;
word_count: number; word_count: number;
student_id: string; student_id: string;

View File

@ -24,7 +24,7 @@ export function PhonicsPage() {
return ( return (
<ExercisePlayer <ExercisePlayer
exercise={exercise} exercise={exercise}
studentId={user.id} student_id={user.id}
onComplete={handleExerciseComplete} onComplete={handleExerciseComplete}
onExit={() => setSelectedExercise(undefined)} onExit={() => setSelectedExercise(undefined)}
/> />

View File

@ -15,7 +15,7 @@ export function PhonicsProgressPage() {
const totalExercises = exercises.length; const totalExercises = exercises.length;
const completedExercises = progress.filter(p => p.completed).length; const completedExercises = progress.filter(p => p.completed).length;
const totalStars = progress.reduce((acc, p) => acc + p.stars, 0); const totalStars = progress.reduce((acc, p) => acc + p.stars, 0);
const totalXP = progress.reduce((acc, p) => acc + p.xpEarned, 0); const totalXP = progress.reduce((acc, p) => acc + p.xp_earned, 0);
const completionRate = (completedExercises / totalExercises) * 100; const completionRate = (completedExercises / totalExercises) * 100;
return ( return (
@ -74,8 +74,8 @@ export function PhonicsProgressPage() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{exercises.map((exercise) => { {exercises.map((exercise) => {
const exerciseProgress = progress.find(p => p.exerciseId === exercise.id); const exerciseProgress = progress.find(p => p.exercise_id === exercise.id);
const progressValue = exerciseProgress ? exerciseProgress.bestScore * 100 : 0; const progressValue = exerciseProgress ? exerciseProgress.best_score * 100 : 0;
return ( return (
<div key={exercise.id} className="flex items-center gap-4"> <div key={exercise.id} className="flex items-center gap-4">

View File

@ -1,9 +1,5 @@
export interface PhonicsExerciseType { // Tipos de Exercícios
id: string; export type PhonicsExerciseType = 'rhyme' | 'alliteration' | 'syllables' | 'initial_sound' | 'final_sound';
name: string;
description: string | null;
created_at: string;
}
export interface PhonicsExerciseCategory { export interface PhonicsExerciseCategory {
id: string; id: string;
@ -28,6 +24,18 @@ export interface PhonicsExercise {
required_score: number; required_score: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
category?: {
id: string;
name: string;
description: string | null;
level: number;
};
words?: Array<{
id: string;
is_correct_answer: boolean;
order_index: number | null;
word: PhonicsWord;
}>;
} }
export interface PhonicsWord { export interface PhonicsWord {
@ -47,6 +55,58 @@ export interface PhonicsExerciseWord {
created_at: string; created_at: string;
} }
export interface StudentPhonicsAttemptAnswer {
id: string;
attempt_id: string;
word_id: string;
is_correct: boolean;
answer_text: string | null;
time_taken_seconds?: number;
created_at: string;
}
export interface StudentPhonicsAttempt {
id: string;
student_id: string;
exercise_id: string;
score: number;
time_spent_seconds: number | null;
answers: StudentPhonicsAttemptAnswer[];
created_at: string;
}
export interface StudentPhonicsProgress {
id: string;
student_id: string;
exercise_id: string;
best_score: number;
last_score: number;
completed: boolean;
completed_at: string | null;
stars: number;
xp_earned: number;
attempts: number;
total_time_spent_seconds: number;
correct_answers_count: number;
total_answers_count: number;
last_attempt_at: string | null;
created_at: string;
updated_at: string;
}
export interface UpdateProgressParams {
student_id: string;
exercise_id: string;
best_score: number;
last_score: number;
completed: boolean;
stars: number;
xp_earned: number;
time_spent_seconds: number;
correct_answers_count: number;
total_answers_count: number;
}
export interface MediaType { export interface MediaType {
id: string; id: string;
name: string; name: string;
@ -64,39 +124,6 @@ export interface PhonicsExerciseMedia {
created_at: string; created_at: string;
} }
export interface StudentPhonicsProgress {
id: string;
student_id: string;
exercise_id: string;
attempts: number;
best_score: number;
last_score: number;
completed: boolean;
completed_at: string | null;
stars: number;
xp_earned: number;
created_at: string;
updated_at: string;
}
export interface StudentPhonicsAttempt {
id: string;
student_id: string;
exercise_id: string;
score: number;
time_spent_seconds: number | null;
created_at: string;
}
export interface StudentPhonicsAttemptAnswer {
id: string;
attempt_id: string;
word_id: string;
is_correct: boolean;
answer_text: string | null;
created_at: string;
}
export interface AchievementType { export interface AchievementType {
id: string; id: string;
name: string; name: string;