feat: adiciona gráfico de evolução das métricas na dashboard

Implementa um novo gráfico combinado (linha + barras) para visualização da
evolução das métricas do aluno ao longo do tempo. O gráfico inclui:

- Visualização das métricas principais (fluência, pronúncia, etc.)
- Gráfico de barras com minutos lidos por semana
- Botões interativos para filtrar métricas
- Design moderno com gradientes e animações
- Agrupamento automático por semana
- Layout responsivo

Principais mudanças técnicas:
- Adiciona Recharts para visualização de dados
- Implementa processamento de métricas semanais
- Otimiza carregamento e agrupamento de dados
This commit is contained in:
Lucas Santana 2025-02-06 13:54:30 -03:00
parent 18bc42d280
commit c029aab50f
4 changed files with 606 additions and 52 deletions

View File

@ -216,3 +216,22 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
- Separação entre consulta de métricas e consulta de exibição
- Otimização no cálculo de médias das métricas
- Melhoria na organização do código com comentários explicativos
## [Não publicado]
### Adicionado
- Novo gráfico de evolução das métricas na dashboard do aluno
- Visualização combinada de linhas e barras
- Métricas de fluência, pronúncia, precisão, compreensão e palavras por minuto
- Gráfico de barras mostrando minutos lidos por semana
- Botões interativos para filtrar métricas
- Design moderno com gradientes e animações suaves
- Tooltip personalizado com informações detalhadas
- Agrupamento automático por semana
- Layout responsivo e adaptável
### Técnico
- Implementação do Recharts para visualização de dados
- Novo sistema de processamento de métricas semanais
- Otimização do carregamento de dados com agrupamento eficiente
- Integração com o tema existente do sistema

283
package-lock.json generated
View File

@ -24,6 +24,7 @@
"@supabase/supabase-js": "^2.39.7",
"@tanstack/react-query": "^5.62.8",
"@testing-library/react": "^16.1.0",
"@tremor/react": "^3.18.7",
"@types/ioredis": "^4.28.10",
"@types/jest": "^29.5.14",
"@types/next": "^8.0.7",
@ -36,7 +37,7 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-router-dom": "^6.28.0",
"recharts": "^2.15.0",
"recharts": "^2.15.1",
"resend": "^3.2.0",
"shadcn-ui": "^0.9.4",
"tailwind-merge": "^2.6.0",
@ -1020,6 +1021,106 @@
"node": ">=18.x"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/react": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.19.2.tgz",
"integrity": "sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^1.3.0",
"aria-hidden": "^1.1.3",
"tabbable": "^6.0.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz",
"integrity": "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.2.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@headlessui/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz",
"integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.17.1",
"@react-aria/interactions": "^3.21.3",
"@tanstack/react-virtual": "^3.8.1"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@headlessui/react/node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@headlessui/react/node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz",
@ -2622,6 +2723,71 @@
}
}
},
"node_modules/@react-aria/focus": {
"version": "3.19.1",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.19.1.tgz",
"integrity": "sha512-bix9Bu1Ue7RPcYmjwcjhB14BMu2qzfJ3tMQLqDc9pweJA66nOw8DThy3IfVr8Z7j2PHktOLf9kcbiZpydKHqzg==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/interactions": "^3.23.0",
"@react-aria/utils": "^3.27.0",
"@react-types/shared": "^3.27.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.23.0",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.23.0.tgz",
"integrity": "sha512-0qR1atBIWrb7FzQ+Tmr3s8uH5mQdyRH78n0krYaG8tng9+u1JlSi8DGRSaC9ezKyNB84m7vHT207xnHXGeJ3Fg==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.7",
"@react-aria/utils": "^3.27.0",
"@react-types/shared": "^3.27.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.7",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz",
"integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/utils": {
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.27.0.tgz",
"integrity": "sha512-p681OtApnKOdbeN8ITfnnYqfdHS0z7GE+4l8EXlfLnr70Rp/9xicBO6d2rU+V/B3JujDw2gPWxYKEnEeh0CGCw==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.7",
"@react-stately/utils": "^3.10.5",
"@react-types/shared": "^3.27.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-email/render": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.16.tgz",
@ -2640,6 +2806,27 @@
"react-dom": "^18.2.0"
}
},
"node_modules/@react-stately/utils": {
"version": "3.10.5",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.5.tgz",
"integrity": "sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/shared": {
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.27.0.tgz",
"integrity": "sha512-gvznmLhi6JPEf0bsq7SwRYTHAKKq/wcmKqFez9sRdbED+SPMUmK5omfZ6w3EwUFQHbYUa4zPBYedQ7Knv70RMw==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@remix-run/router": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz",
@ -3067,6 +3254,33 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.12.0.tgz",
"integrity": "sha512-6krceiPN07kpxXmU6m8AY7EL0X1gHLu8m3nJdh4phvktzVNxkQfBmSwnRUpoUjGQO1PAn8wSAhYaL8hY1cS1vw==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.12.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.12.0.tgz",
"integrity": "sha512-7mDINtua3v/pOnn6WUmuT9dPXYSO7WidFej7JzoAfqEOcbbpt/iZ1WPqd+eg+FnrL9nUJK8radqj4iAU51Zchg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@ -3291,6 +3505,25 @@
}
}
},
"node_modules/@tremor/react": {
"version": "3.18.7",
"resolved": "https://registry.npmjs.org/@tremor/react/-/react-3.18.7.tgz",
"integrity": "sha512-nmqvf/1m0GB4LXc7v2ftdfSLoZhy5WLrhV6HNf0SOriE6/l8WkYeWuhQq8QsBjRi94mUIKLJ/VC3/Y/pj6VubQ==",
"license": "Apache 2.0",
"dependencies": {
"@floating-ui/react": "^0.19.2",
"@headlessui/react": "2.2.0",
"date-fns": "^3.6.0",
"react-day-picker": "^8.10.1",
"react-transition-state": "^2.1.2",
"recharts": "^2.13.3",
"tailwind-merge": "^2.5.2"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/@ts-morph/common": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.19.0.tgz",
@ -4914,6 +5147,16 @@
"node": ">= 12"
}
},
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
@ -7912,6 +8155,20 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@ -8103,6 +8360,16 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/react-transition-state": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/react-transition-state/-/react-transition-state-2.3.0.tgz",
"integrity": "sha512-OucQRyIpeq5g1/5qSBJH4p4U+SzYEoB3MsAXXz3ty116rPqG4jhwrzInImZ2gFi4XOffeLu6HNCZvkCM3DKaeg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -8165,16 +8432,16 @@
}
},
"node_modules/recharts": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz",
"integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==",
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz",
"integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.0",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
@ -8927,6 +9194,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",

View File

@ -31,6 +31,7 @@
"@supabase/supabase-js": "^2.39.7",
"@tanstack/react-query": "^5.62.8",
"@testing-library/react": "^16.1.0",
"@tremor/react": "^3.18.7",
"@types/ioredis": "^4.28.10",
"@types/jest": "^29.5.14",
"@types/next": "^8.0.7",
@ -43,7 +44,7 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-router-dom": "^6.28.0",
"recharts": "^2.15.0",
"recharts": "^2.15.1",
"resend": "^3.2.0",
"shadcn-ui": "^0.9.4",
"tailwind-merge": "^2.6.0",

View File

@ -3,6 +3,7 @@ import { Plus, BookOpen, Clock, TrendingUp, Award, Mic, Target, Brain, Gauge, Pa
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import type { Story, Student } from '../../types/database';
import { Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Bar, ComposedChart } from 'recharts';
interface DashboardMetrics {
totalStories: number;
@ -17,6 +18,55 @@ interface DashboardMetrics {
averageErrors: number;
}
interface WeeklyMetrics {
week: string;
fluency: number;
pronunciation: number;
accuracy: number;
comprehension: number;
wordsPerMinute: number;
pauses: number;
errors: number;
minutesRead: number;
}
interface WeeklyData {
count: number;
fluency: number;
pronunciation: number;
accuracy: number;
comprehension: number;
wordsPerMinute: number;
pauses: number;
errors: number;
minutesRead: number;
}
interface Recording {
created_at: string;
fluency_score: number;
pronunciation_score: number;
accuracy_score: number;
comprehension_score: number;
words_per_minute: number;
pause_count: number;
error_count: number;
}
interface MetricConfig {
key: string;
name: string;
color: string;
}
const METRICS_CONFIG: MetricConfig[] = [
{ key: 'fluency', name: 'Fluência', color: '#6366f1' },
{ key: 'pronunciation', name: 'Pronúncia', color: '#f43f5e' },
{ key: 'accuracy', name: 'Precisão', color: '#0ea5e9' },
{ key: 'comprehension', name: 'Compreensão', color: '#10b981' },
{ key: 'wordsPerMinute', name: 'Palavras/Min', color: '#8b5cf6' }
];
export function StudentDashboardPage() {
const navigate = useNavigate();
const [student, setStudent] = React.useState<Student | null>(null);
@ -32,9 +82,72 @@ export function StudentDashboardPage() {
averagePauses: 0,
averageErrors: 0
});
const [weeklyMetrics, setWeeklyMetrics] = React.useState<WeeklyMetrics[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [recentStories, setRecentStories] = React.useState<Story[]>([]);
const [visibleMetrics, setVisibleMetrics] = React.useState<Set<string>>(
new Set(METRICS_CONFIG.map(metric => metric.key))
);
const processWeeklyMetrics = (recordings: Recording[]) => {
const weeklyData = recordings.reduce((acc: { [key: string]: WeeklyData }, recording) => {
const date = new Date(recording.created_at);
const week = `${date.getFullYear()}-W${Math.ceil((date.getDate() + date.getDay()) / 7)}`;
if (!acc[week]) {
acc[week] = {
count: 0,
fluency: 0,
pronunciation: 0,
accuracy: 0,
comprehension: 0,
wordsPerMinute: 0,
pauses: 0,
errors: 0,
minutesRead: 0
};
}
acc[week].count += 1;
acc[week].fluency += recording.fluency_score;
acc[week].pronunciation += recording.pronunciation_score;
acc[week].accuracy += recording.accuracy_score;
acc[week].comprehension += recording.comprehension_score;
acc[week].wordsPerMinute += recording.words_per_minute;
acc[week].pauses += recording.pause_count;
acc[week].errors += recording.error_count;
acc[week].minutesRead += 2;
return acc;
}, {});
return Object.entries(weeklyData)
.map(([week, data]: [string, WeeklyData]) => ({
week,
fluency: Math.round(data.fluency / data.count),
pronunciation: Math.round(data.pronunciation / data.count),
accuracy: Math.round(data.accuracy / data.count),
comprehension: Math.round(data.comprehension / data.count),
wordsPerMinute: Math.round(data.wordsPerMinute / data.count),
pauses: Math.round(data.pauses / data.count),
errors: Math.round(data.errors / data.count),
minutesRead: data.minutesRead
}))
.sort((a, b) => a.week.localeCompare(b.week));
};
const toggleMetric = (metricKey: string) => {
setVisibleMetrics(prev => {
const newSet = new Set(prev);
if (newSet.has(metricKey)) {
newSet.delete(metricKey);
} else {
newSet.add(metricKey);
}
return newSet;
});
};
React.useEffect(() => {
const fetchDashboardData = async () => {
@ -64,24 +177,58 @@ export function StudentDashboardPage() {
if (allStoriesError) throw allStoriesError;
// Buscar histórias recentes para exibição
const { data: recentStoriesData, error: storiesError } = await supabase
// Buscar todas as gravações do aluno
const { data: recordings, error: recordingsError } = await supabase
.from('story_recordings')
.select(`
*,
story:stories(id, student_id)
`)
.eq('stories.student_id', session.user.id)
.eq('status', 'completed')
.order('created_at', { ascending: true });
if (recordingsError) throw recordingsError;
// Processar métricas semanais
const weeklyData = processWeeklyMetrics(recordings);
setWeeklyMetrics(weeklyData);
// Buscar histórias recentes com a capa definida
const { data: stories, error: storiesError } = await supabase
.from('stories')
.select('*')
.select(`
*,
cover:story_pages!inner(
image_url
)
`)
.eq('student_id', session.user.id)
.eq('story_pages.page_number', 1)
.order('created_at', { ascending: false })
.limit(6);
if (storiesError) throw storiesError;
// Buscar todas as gravações completadas do aluno
const { data: recordings, error: recordingsError } = await supabase
.from('story_recordings')
.select('*')
.eq('status', 'completed')
.in('story_id', allStoriesData.map(story => story.id));
// Transformar os dados para definir a propriedade 'cover'
const mappedData = (stories || []).map(story => {
let coverObj = null;
if (story.cover) {
coverObj = Array.isArray(story.cover) ? story.cover[0] : story.cover;
} else if (story.pages && story.pages.length > 0) {
coverObj = story.pages[0];
}
if (recordingsError) throw recordingsError;
if (coverObj && typeof coverObj === 'object' && coverObj.image_url) {
return { ...story, cover: coverObj };
} else if (coverObj && typeof coverObj === 'string') {
return { ...story, cover: { image_url: coverObj } };
}
return { ...story, cover: null };
});
setRecentStories(mappedData);
// Calcular métricas baseadas nas gravações
if (recordings && recordings.length > 0) {
@ -119,42 +266,6 @@ export function StudentDashboardPage() {
});
}
// Buscar histórias recentes com a capa definida (mantendo o limite de 6 para exibição)
const { data, error } = await supabase
.from('stories')
.select(`
*,
cover:story_pages!inner(
image_url
)
`)
.eq('student_id', session.user.id)
.eq('story_pages.page_number', 1) // Garante que pegamos a primeira página
.order('created_at', { ascending: false })
.limit(6);
if (error) throw error;
// Transformar os dados para definir a propriedade 'cover' de forma robusta
const mappedData = (data || []).map(story => {
let coverObj = null;
if (story.cover) {
coverObj = Array.isArray(story.cover) ? story.cover[0] : story.cover;
} else if (story.pages && story.pages.length > 0) {
coverObj = story.pages[0];
}
if (coverObj && typeof coverObj === 'object' && coverObj.image_url) {
return { ...story, cover: coverObj };
} else if (coverObj && typeof coverObj === 'string') {
return { ...story, cover: { image_url: coverObj } };
}
return { ...story, cover: null };
});
setRecentStories(mappedData);
} catch (err) {
console.error('Erro ao carregar dashboard:', err);
setError('Não foi possível carregar seus dados');
@ -198,6 +309,153 @@ export function StudentDashboardPage() {
);
}
const renderMetricsChart = () => {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 mb-8">
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="text-xl font-semibold text-gray-900">Evolução das Métricas por Semana</h2>
<p className="text-sm text-gray-500">Acompanhe seu progresso ao longo do tempo</p>
</div>
<div className="flex items-center gap-2">
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Gráfico mostrando a evolução das suas métricas de leitura ao longo das semanas"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
</div>
{/* Pill Buttons */}
<div className="flex flex-wrap gap-2 p-1">
{METRICS_CONFIG.map(metric => (
<button
key={metric.key}
onClick={() => toggleMetric(metric.key)}
className={`
px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 ease-in-out
${visibleMetrics.has(metric.key)
? 'shadow-md transform -translate-y-px'
: 'bg-gray-50 text-gray-500 hover:bg-gray-100'
}
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50
`}
style={{
backgroundColor: visibleMetrics.has(metric.key) ? metric.color : undefined,
color: visibleMetrics.has(metric.key) ? 'white' : undefined,
boxShadow: visibleMetrics.has(metric.key) ? '0 2px 4px rgba(0,0,0,0.1)' : undefined
}}
>
<span className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${visibleMetrics.has(metric.key) ? 'bg-white' : 'bg-gray-400'}`}></span>
{metric.name}
</span>
</button>
))}
</div>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={weeklyMetrics} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
<defs>
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="#f0f0f0"
/>
<XAxis
dataKey="week"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dy={10}
/>
<YAxis
yAxisId="left"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dx={-10}
/>
<YAxis
yAxisId="right"
orientation="right"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dx={10}
/>
<Tooltip
formatter={(value: number, name: string) => {
const metricNames: { [key: string]: string } = {
fluency: 'Fluência',
pronunciation: 'Pronúncia',
accuracy: 'Precisão',
comprehension: 'Compreensão',
wordsPerMinute: 'Palavras/Min',
pauses: 'Pausas',
errors: 'Erros',
minutesRead: 'Minutos Lidos'
};
return [value, metricNames[name] || name];
}}
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.98)',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
padding: '12px'
}}
isAnimationActive={false}
/>
<Legend
verticalAlign="top"
align="right"
iconType="circle"
wrapperStyle={{
paddingBottom: '20px'
}}
/>
{METRICS_CONFIG.map(metric => (
visibleMetrics.has(metric.key) && (
<Line
key={metric.key}
yAxisId="left"
type="monotone"
dataKey={metric.key}
stroke={metric.color}
name={metric.name}
strokeWidth={2.5}
dot={{ strokeWidth: 2, r: 4, fill: 'white' }}
activeDot={{ r: 6, strokeWidth: 2 }}
isAnimationActive={false}
/>
)
))}
<Bar
yAxisId="right"
dataKey="minutesRead"
name="Minutos Lidos"
fill="url(#barGradient)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
maxBarSize={50}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
};
return (
<div>
{/* Cabeçalho */}
@ -426,6 +684,9 @@ export function StudentDashboardPage() {
</div>
</div>
{/* Gráfico de Evolução */}
{renderMetricsChart()}
{/* Histórias Recentes */}
<div>
<div className="flex justify-between items-center mb-6">