fix: Phonic types
Some checks are pending
Docker Build and Push / build (push) Waiting to run

This commit is contained in:
Lucas Santana 2025-01-19 16:57:41 -03:00
parent ce845607f9
commit f1f2906d09
17 changed files with 436 additions and 309 deletions

View File

@ -50,6 +50,7 @@ export function AudioPlayer({ word, disabled }: AudioPlayerProps) {
className="gap-2"
onClick={playAudio}
disabled={disabled || isLoading}
trackingId="audio-player-toggle"
>
{isLoading ? (
<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 { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Clock, Star } from "lucide-react";
import type { PhonicsExercise, PhonicsProgress } from "@/types/phonics";
import { Clock, Star, Timer } from "lucide-react";
import { cn } from "@/lib/utils";
import type { PhonicsExercise, StudentPhonicsProgress } from "@/types/phonics";
interface ExerciseCardProps {
exercise: PhonicsExercise;
progress?: PhonicsProgress;
progress?: StudentPhonicsProgress;
onStart: (exerciseId: string) => void;
}
export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps) {
const isCompleted = progress?.completed;
const stars = progress?.stars || 0;
const progressValue = progress ? (progress.bestScore * 100) : 0;
const progressValue = progress ? (progress.best_score * 100) : 0;
return (
<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 gap-2">
<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 className="flex items-center gap-1">
{Array.from({ length: 3 }).map((_, i) => (
@ -56,10 +57,11 @@ export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps)
</div>
)}
<Button
className="w-full"
<Button
className="mt-4"
onClick={() => onStart(exercise.id)}
variant={isCompleted ? "secondary" : "default"}
trackingId="exercise-card-start"
>
{isCompleted ? "Praticar Novamente" : "Começar"}
</Button>

View File

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

View File

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

View File

@ -1,46 +1,40 @@
import { Button } from "@/components/ui/button";
import { BaseExercise, type BaseExerciseProps } from "./BaseExercise";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface AlliterationExerciseProps extends BaseExerciseProps {
options: Array<{
word: string;
hasSameInitialSound: boolean;
}>;
interface AlliterationExerciseProps {
currentWord: PhonicsWord;
options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
}
export function AlliterationExercise({
currentWord,
onAnswer,
options,
disabled
}: AlliterationExerciseProps) {
export function AlliterationExercise({ currentWord, options, onAnswer, disabled }: AlliterationExerciseProps) {
return (
<div className="space-y-8">
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} />
<div className="space-y-4">
<div className="text-center text-muted-foreground">
Qual palavra começa com o mesmo som que <span className="font-medium text-foreground">{currentWord.word}</span>?
</div>
<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.word}
onClick={() => onAnswer(option.word, option.hasSameInitialSound)}
disabled={disabled}
variant="outline"
className={cn(
"h-16 text-lg",
disabled && option.hasSameInitialSound && "border-green-500 bg-green-50",
disabled && !option.hasSameInitialSound && "border-red-500 bg-red-50"
)}
>
{option.word}
</Button>
))}
</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={`alliteration-option-${option.word}`}
>
{option.word}
</Button>
))}
</div>
</div>
);

View File

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

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 { BaseExercise, type BaseExerciseProps } from "./BaseExercise";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface RhymeExerciseProps extends BaseExerciseProps {
options: Array<{
word: string;
isRhyme: boolean;
}>;
interface RhymeExerciseProps {
currentWord: PhonicsWord;
options: PhonicsWord[];
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 (
<div className="space-y-8">
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} />
<div className="space-y-4">
<div className="text-center text-muted-foreground">
Qual palavra rima com <span className="font-medium text-foreground">{currentWord.word}</span>?
</div>
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium mb-2">Qual palavra rima com:</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.word}
onClick={() => onAnswer(option.word, option.isRhyme)}
disabled={disabled}
variant="outline"
className={cn(
"h-16 text-lg",
disabled && option.isRhyme && "border-green-500 bg-green-50",
disabled && !option.isRhyme && "border-red-500 bg-red-50"
)}
>
{option.word}
</Button>
))}
</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={`rhyme-option-${option.word}`}
>
{option.word}
</Button>
))}
</div>
</div>
);

View File

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

View File

@ -1,80 +1,40 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { BaseExercise, type BaseExerciseProps } from "./BaseExercise";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface SyllablesExerciseProps extends BaseExerciseProps {
syllables: string[];
correctOrder: number[];
interface SyllablesExerciseProps {
currentWord: PhonicsWord;
options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
}
export function SyllablesExercise({
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);
};
export function SyllablesExercise({ currentWord, options, onAnswer, disabled }: SyllablesExerciseProps) {
return (
<div className="space-y-8">
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} />
<div className="space-y-4">
<div className="text-center text-muted-foreground">
Selecione as sílabas na ordem correta
</div>
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium mb-2">Quantas sílabas tem a palavra?</h3>
<div className="text-4xl font-bold">{currentWord.word}</div>
</div>
<div className="flex flex-wrap justify-center gap-2">
{syllables.map((syllable, index) => (
<Button
key={index}
onClick={() => handleSyllableClick(index)}
disabled={disabled}
variant={selectedSyllables.includes(index) ? "default" : "outline"}
className={cn(
"text-lg px-4",
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"
)}
>
{syllable}
</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 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.syllables_count === currentWord.syllables_count)}
disabled={disabled}
trackingId={`syllables-option-${option.syllables_count}`}
>
{option.syllables_count} sílabas
</Button>
))}
</div>
</div>
);

View File

@ -1,14 +1,23 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
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() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (attempt: Omit<PhonicsAttempt, 'id'>) => {
mutationFn: async (attempt: Omit<StudentPhonicsAttempt, 'id'>) => {
const { data, error } = await supabase
.from('phonics_student_attempts')
.from('student_phonics_attempts')
.insert(attempt)
.select()
.single();
@ -18,7 +27,7 @@ export function useExerciseAttempt() {
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: ['phonics-progress', variables.studentId]
queryKey: ['phonics-progress', variables.student_id]
});
}
});
@ -28,17 +37,55 @@ export function useExerciseWords(exerciseId: string) {
return useQuery({
queryKey: ['exercise-words', exerciseId],
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')
.select(`
*,
word:phonics_words(*)
`)
.select('word:phonics_words!inner(*)')
.eq('exercise_id', exerciseId)
.eq('is_correct_answer', 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;
return data;
}
return data as StudentPhonicsAttempt[];
},
enabled: !!exerciseId
});
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,5 @@
export interface PhonicsExerciseType {
id: string;
name: string;
description: string | null;
created_at: string;
}
// Tipos de Exercícios
export type PhonicsExerciseType = 'rhyme' | 'alliteration' | 'syllables' | 'initial_sound' | 'final_sound';
export interface PhonicsExerciseCategory {
id: string;
@ -28,6 +24,18 @@ export interface PhonicsExercise {
required_score: number;
created_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 {
@ -47,6 +55,58 @@ export interface PhonicsExerciseWord {
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 {
id: string;
name: string;
@ -64,39 +124,6 @@ export interface PhonicsExerciseMedia {
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 {
id: string;
name: string;