feat: reorganiza estrutura de métricas e feedback de leitura

- Exporta interface MetricsData do StoryMetrics para reuso
- Adiciona importação da interface no StoryPage
- Mantém consistência de tipos entre gravações e métricas
- Melhora organização do feedback em colunas
- Implementa layout responsivo para diferentes tamanhos de tela
This commit is contained in:
Lucas Santana 2024-12-22 15:58:32 -03:00
parent 797967ca5b
commit 1132f7438d
5 changed files with 547 additions and 80 deletions

79
n8n.js
View File

@ -1,79 +0,0 @@
// Estrutura do fluxo
[
{
// 1. Webhook Trigger
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"parameters": {
"path": "audio-processing",
"responseMode": "lastNode"
}
},
{
// 2. Download do Áudio do Supabase
"name": "Supabase",
"type": "n8n-nodes-base.supabase",
"parameters": {
"operation": "download",
"bucket": "audios",
"filePath": "={{$json.file_path}}"
}
},
{
// 3. Pré-processamento do Áudio (usando FFmpeg)
"name": "FFmpeg",
"type": "n8n-nodes-base.executeCommand",
"parameters": {
"command": "ffmpeg -i input.wav -af 'anlmdn,highpass=f=200,lowpass=f=3000,silenceremove=1:0:-50dB' output.wav"
}
},
{
// 4. Transcrição (usando OpenAI Whisper)
"name": "Whisper",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"url": "https://api.openai.com/v1/audio/transcriptions",
"method": "POST",
"headers": {
"Authorization": "Bearer {{$env.OPENAI_API_KEY}}"
}
}
},
{
// 5. Análise do Texto (usando GPT-4)
"name": "GPT4Analysis",
"type": "n8n-nodes-base.openAi",
"parameters": {
"model": "gpt-4",
"prompt": `Analise a seguinte transcrição considerando:
1. Fluência (velocidade, pausas, prosódia)
2. Pronúncia (precisão fonética, clareza)
3. Erros (substituições, omissões)
4. Compreensão (coerência, autocorreção)
Transcrição: {{$node.Whisper.data.text}}
Forneça uma análise detalhada seguindo as métricas especificadas.`
}
},
{
// 6. Salvar Resultados no Supabase
"name": "SaveResults",
"type": "n8n-nodes-base.supabase",
"parameters": {
"operation": "insert",
"table": "audio_analysis",
"data": {
"audio_path": "={{$json.file_path}}",
"transcription": "={{$node.Whisper.data.text}}",
"analysis": "={{$node.GPT4Analysis.data.choices[0].text}}",
"metrics": {
"fluency": "={{$node.GPT4Analysis.data.metrics.fluency}}",
"pronunciation": "={{$node.GPT4Analysis.data.metrics.pronunciation}}",
"errors": "={{$node.GPT4Analysis.data.metrics.errors}}",
"comprehension": "={{$node.GPT4Analysis.data.metrics.comprehension}}"
}
}
}
}
]

View File

@ -0,0 +1,123 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion';
export function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
const metrics = [
{ label: 'Fluência', value: recording.fluency_score, color: 'text-blue-600' },
{ label: 'Pronúncia', value: recording.pronunciation_score, color: 'text-green-600' },
{ label: 'Precisão', value: recording.accuracy_score, color: 'text-purple-600' },
{ label: 'Compreensão', value: recording.comprehension_score, color: 'text-orange-600' }
];
const details = [
{ label: 'Palavras por minuto', value: recording.words_per_minute },
{ label: 'Pausas', value: recording.pause_count },
{ label: 'Erros', value: recording.error_count },
{ label: 'Autocorreções', value: recording.self_corrections }
];
return (
<Accordion type="single" collapsible className="bg-white rounded-lg border border-gray-200">
<AccordionItem value={recording.id}>
<AccordionTrigger className="px-4 hover:no-underline hover:bg-gray-50">
<div className="w-full">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gray-500">
{new Date(recording.created_at).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{metrics.map((metric) => (
<div key={metric.label} className="flex flex-col">
<span className={`text-sm font-medium ${metric.color}`}>
{metric.label}
</span>
<span className="text-lg font-semibold">
{metric.value}%
</span>
</div>
))}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Coluna 1: Detalhes Técnicos */}
<div className="space-y-4">
<h5 className="text-sm font-medium text-gray-900">Detalhes Técnicos</h5>
<div className="grid grid-cols-2 gap-3">
{details.map((detail) => (
<div key={detail.label} className="bg-gray-50 p-3 rounded-lg">
<span className="text-xs text-gray-500 block">
{detail.label}
</span>
<span className="text-sm font-medium">
{detail.value}
</span>
</div>
))}
</div>
</div>
{/* Coluna 2: Pontos Fortes e Melhorias */}
<div className="space-y-4">
<div>
<h5 className="text-sm font-medium text-green-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-green-600 rounded-full" />
Pontos Fortes
</h5>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
{recording.strengths.map((strength, i) => (
<li key={i} className="leading-relaxed">{strength}</li>
))}
</ul>
</div>
<div>
<h5 className="text-sm font-medium text-orange-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-orange-600 rounded-full" />
Pontos para Melhorar
</h5>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
{recording.improvements.map((improvement, i) => (
<li key={i} className="leading-relaxed">{improvement}</li>
))}
</ul>
</div>
</div>
{/* Coluna 3: Sugestões e Próximos Passos */}
<div className="space-y-4">
<div>
<h5 className="text-sm font-medium text-blue-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-blue-600 rounded-full" />
Sugestões
</h5>
<p className="text-sm text-gray-600 leading-relaxed bg-blue-50 p-3 rounded-lg">
{recording.suggestions}
</p>
</div>
<div>
<h5 className="text-sm font-medium text-purple-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-purple-600 rounded-full" />
Próxima Meta
</h5>
<p className="text-sm text-gray-600 leading-relaxed bg-purple-50 p-3 rounded-lg">
Tente alcançar {Math.min(100, recording.fluency_score + 5)}% de fluência na próxima leitura.
</p>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@ -0,0 +1,142 @@
import React from 'react';
import { Activity, Book, Mic, Brain } from 'lucide-react';
export interface MetricsData {
metrics: {
fluency: number;
pronunciation: number;
accuracy: number;
comprehension: number;
};
feedback: {
strengths: string[];
improvements: string[];
suggestions: string;
};
details: {
wordsPerMinute: number;
pauseCount: number;
errorCount: number;
selfCorrections: number;
};
}
interface StoryMetricsProps {
data?: MetricsData;
isLoading?: boolean;
}
export function StoryMetrics({ data, isLoading }: StoryMetricsProps): JSX.Element {
if (isLoading) {
return (
<div className="animate-pulse">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-24 bg-gray-100 rounded-lg" />
))}
</div>
</div>
);
}
if (!data) {
return (
<div className="bg-gray-50 rounded-lg p-6 text-center mb-6">
<p className="text-gray-600">
Aguardando gravação para gerar métricas de leitura...
</p>
</div>
);
}
const metrics = [
{
label: 'Fluência',
value: data.metrics.fluency,
icon: Activity,
color: 'text-blue-600',
detail: `${data.details.wordsPerMinute} palavras/min`
},
{
label: 'Pronúncia',
value: data.metrics.pronunciation,
icon: Mic,
color: 'text-green-600',
detail: `${data.details.errorCount} erros`
},
{
label: 'Precisão',
value: data.metrics.accuracy,
icon: Book,
color: 'text-purple-600',
detail: `${data.details.selfCorrections} autocorreções`
},
{
label: 'Compreensão',
value: data.metrics.comprehension,
icon: Brain,
color: 'text-orange-600',
detail: `${data.details.pauseCount} pausas`
}
];
return (
<div className="space-y-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{metrics.map((metric) => (
<div
key={metric.label}
className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm"
>
<div className="flex items-center justify-between mb-2">
<metric.icon className={`w-5 h-5 ${metric.color}`} />
<span className="text-2xl font-bold">{metric.value}%</span>
</div>
<h3 className="text-sm font-medium text-gray-600">{metric.label}</h3>
<p className="text-xs text-gray-500 mt-1">{metric.detail}</p>
</div>
))}
</div>
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h3 className="font-medium mb-4">Feedback da Leitura</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h4 className="text-sm font-medium text-green-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-green-600 rounded-full" />
Pontos Fortes
</h4>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
{data.feedback.strengths.map((strength, i) => (
<li key={i} className="leading-relaxed">{strength}</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-orange-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-orange-600 rounded-full" />
Pontos para Melhorar
</h4>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
{data.feedback.improvements.map((improvement, i) => (
<li key={i} className="leading-relaxed">{improvement}</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-blue-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-blue-600 rounded-full" />
Sugestões
</h4>
<p className="text-sm text-gray-600 leading-relaxed">
{data.feedback.suggestions}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,54 @@
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import { cn } from '../../lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-gray-500 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = 'AccordionTrigger';
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = 'AccordionContent';
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -1,9 +1,129 @@
import React from 'react'; import React from 'react';
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save } from 'lucide-react'; import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp } from 'lucide-react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { AudioRecorder } from '../../components/story/AudioRecorder'; import { AudioRecorder } from '../../components/story/AudioRecorder';
import type { Story } from '../../types/database'; import type { Story } from '../../types/database';
import { StoryMetrics } from '../../components/story/StoryMetrics';
import type { MetricsData } from '../../components/story/StoryMetrics';
interface StoryRecording {
id: string;
fluency_score: number;
pronunciation_score: number;
accuracy_score: number;
comprehension_score: number;
words_per_minute: number;
pause_count: number;
error_count: number;
self_corrections: number;
strengths: string[];
improvements: string[];
suggestions: string;
created_at: string;
processed_at: string | null;
}
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
const [isExpanded, setIsExpanded] = React.useState(false);
const metrics = [
{ label: 'Fluência', value: recording.fluency_score, color: 'text-blue-600' },
{ label: 'Pronúncia', value: recording.pronunciation_score, color: 'text-green-600' },
{ label: 'Precisão', value: recording.accuracy_score, color: 'text-purple-600' },
{ label: 'Compreensão', value: recording.comprehension_score, color: 'text-orange-600' }
];
return (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{/* Cabeçalho sempre visível */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full p-4 text-left hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gray-500">
{new Date(recording.created_at).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</span>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</div>
{/* Grid de métricas */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{metrics.map((metric) => (
<div key={metric.label} className="flex flex-col">
<span className={`text-sm font-medium ${metric.color}`}>
{metric.label}
</span>
<span className="text-lg font-semibold">
{metric.value}%
</span>
</div>
))}
</div>
</button>
{/* Conteúdo expandido */}
{isExpanded && (
<div className="px-4 pb-4 border-t border-gray-100">
<div className="grid grid-cols-2 gap-4 mt-4 text-sm">
<div>
<span className="text-gray-500">Palavras por minuto:</span>
<span className="ml-2 font-medium">{recording.words_per_minute}</span>
</div>
<div>
<span className="text-gray-500">Pausas:</span>
<span className="ml-2 font-medium">{recording.pause_count}</span>
</div>
<div>
<span className="text-gray-500">Erros:</span>
<span className="ml-2 font-medium">{recording.error_count}</span>
</div>
<div>
<span className="text-gray-500">Autocorreções:</span>
<span className="ml-2 font-medium">{recording.self_corrections}</span>
</div>
</div>
<div className="mt-4 space-y-3">
<div>
<h5 className="text-sm font-medium text-green-600 mb-1">Pontos Fortes</h5>
<ul className="list-disc list-inside text-sm text-gray-600">
{recording.strengths.map((strength, i) => (
<li key={i}>{strength}</li>
))}
</ul>
</div>
<div>
<h5 className="text-sm font-medium text-orange-600 mb-1">Pontos para Melhorar</h5>
<ul className="list-disc list-inside text-sm text-gray-600">
{recording.improvements.map((improvement, i) => (
<li key={i}>{improvement}</li>
))}
</ul>
</div>
<div>
<h5 className="text-sm font-medium text-blue-600 mb-1">Sugestões</h5>
<p className="text-sm text-gray-600">{recording.suggestions}</p>
</div>
</div>
</div>
)}
</div>
);
}
export function StoryPage() { export function StoryPage() {
const { id } = useParams(); const { id } = useParams();
@ -13,6 +133,10 @@ export function StoryPage() {
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [isPlaying, setIsPlaying] = React.useState(false); const [isPlaying, setIsPlaying] = React.useState(false);
const [metrics, setMetrics] = React.useState<MetricsData | undefined>();
const [loadingMetrics, setLoadingMetrics] = React.useState(true);
const [recordings, setRecordings] = React.useState<StoryRecording[]>([]);
const [loadingRecordings, setLoadingRecordings] = React.useState(true);
React.useEffect(() => { React.useEffect(() => {
const fetchStory = async () => { const fetchStory = async () => {
@ -39,6 +163,53 @@ export function StoryPage() {
fetchStory(); fetchStory();
}, [id]); }, [id]);
React.useEffect(() => {
const fetchMetrics = async () => {
if (!story?.id) return;
try {
const { data, error } = await supabase
.from('reading_metrics')
.select('*')
.eq('story_id', story.id)
.single();
if (error) throw error;
setMetrics(data);
} catch (err) {
console.error('Erro ao carregar métricas:', err);
} finally {
setLoadingMetrics(false);
}
};
fetchMetrics();
}, [story?.id]);
React.useEffect(() => {
const fetchRecordings = async () => {
if (!story?.id) return;
try {
const { data, error } = await supabase
.from('story_recordings')
.select('*')
.eq('story_id', story.id)
.eq('status', 'completed')
.order('created_at', { ascending: false });
if (error) throw error;
setRecordings(data || []);
} catch (err) {
console.error('Erro ao carregar gravações:', err);
} finally {
setLoadingRecordings(false);
}
};
fetchRecordings();
}, [story?.id]);
const handleShare = async () => { const handleShare = async () => {
if (navigator.share) { if (navigator.share) {
try { try {
@ -53,6 +224,28 @@ export function StoryPage() {
} }
}; };
const getLatestRecording = () => recordings[0];
const formatMetricsData = (recording: StoryRecording) => ({
metrics: {
fluency: recording.fluency_score,
pronunciation: recording.pronunciation_score,
accuracy: recording.accuracy_score,
comprehension: recording.comprehension_score
},
feedback: {
strengths: recording.strengths,
improvements: recording.improvements,
suggestions: recording.suggestions
},
details: {
wordsPerMinute: recording.words_per_minute,
pauseCount: recording.pause_count,
errorCount: recording.error_count,
selfCorrections: recording.self_corrections
}
});
if (loading) { if (loading) {
return ( return (
<div className="animate-pulse"> <div className="animate-pulse">
@ -105,6 +298,40 @@ export function StoryPage() {
</div> </div>
</div> </div>
{/* Dashboard de métricas */}
{loadingRecordings ? (
<div className="animate-pulse">
<div className="h-48 bg-gray-100 rounded-lg mb-6" />
</div>
) : recordings.length > 0 ? (
<StoryMetrics
data={formatMetricsData(getLatestRecording())}
isLoading={false}
/>
) : (
<div className="bg-gray-50 rounded-lg p-6 text-center mb-6">
<p className="text-gray-600">
Você ainda não tem gravações para esta história.
Faça sua primeira gravação para ver suas métricas!
</p>
</div>
)}
{/* Histórico de gravações */}
{recordings.length > 1 && (
<div className="mb-6">
<h3 className="text-lg font-medium mb-4">Histórico de Gravações</h3>
<div className="space-y-4">
{recordings.slice(1).map((recording) => (
<RecordingHistoryCard
key={recording.id}
recording={recording}
/>
))}
</div>
</div>
)}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{/* Imagem da página atual */} {/* Imagem da página atual */}
{story.content.pages[currentPage].image && ( {story.content.pages[currentPage].image && (