Compare commits

...

4 Commits

Author SHA1 Message Date
Lucas Santana
478ca2441d refactor: extrai componentes de métricas do dashboard
Some checks are pending
Docker Build and Push / build (push) Waiting to run
Modulariza os cards de métricas em componentes reutilizáveis:

- Cria componente MetricCard para cards individuais
- Cria componente DashboardMetrics para agrupamento
- Move configurações de métricas para constantes
- Adiciona suporte a tooltips e ícones personalizados
- Mantém responsividade e acessibilidade
- Simplifica o StudentDashboardPage

Mudanças técnicas:
- Extrai lógica de renderização para componentes dedicados
- Centraliza configuração de métricas em constantes
- Melhora tipagem com interfaces dedicadas
- Adiciona suporte a tooltips informativos
- Mantém consistência visual com o design system
- Reduz duplicação de código
2025-02-06 14:34:58 -03:00
Lucas Santana
7a0bc3f8ca refactor: extrai componente MetricsChart do dashboard
Modulariza o gráfico de métricas em um componente reutilizável:

- Cria novo componente @/components/dashboard/MetricsChart
- Move toda a lógica de filtragem e visualização para o componente
- Define interfaces e tipos apropriados
- Encapsula estados e lógica de filtragem
- Simplifica a interface do componente (props)
- Mantém toda funcionalidade existente

Mudanças técnicas:
- Extrai interfaces e tipos relacionados
- Move constantes de configuração para o componente
- Encapsula lógica de filtragem temporal
- Simplifica o StudentDashboardPage
- Melhora a manutenibilidade e reusabilidade
2025-02-06 14:27:45 -03:00
Lucas Santana
7bb2a9a1b7 feat: adiciona filtro de período no gráfico de métricas
Implementa um sistema de filtragem temporal no gráfico de evolução das métricas,
permitindo visualizar diferentes períodos de tempo:

- Opções de 3, 6, 12 meses e todo período
- Visualização padrão dos últimos 12 meses
- Interface intuitiva com botões de período
- Filtragem automática dos dados
- Design consistente com o resto da aplicação

Mudanças técnicas:
- Adiciona sistema de filtragem temporal
- Implementa conversão de datas semana/ano
- Otimiza renderização do gráfico
2025-02-06 14:01:03 -03:00
Lucas Santana
c029aab50f 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
2025-02-06 13:54:30 -03:00
7 changed files with 904 additions and 246 deletions

View File

@ -216,3 +216,37 @@ 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
- Filtro de período com opções de 3, 6, 12 meses e todo período
- Visualização padrão dos últimos 12 meses
### 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
- Sistema de filtragem temporal com conversão de datas
- Componente MetricsChart extraído e modularizado
- Interfaces e tipos bem definidos
- Lógica de filtragem encapsulada
- Estado interno gerenciado
- Props minimalistas e bem tipadas
- Componente reutilizável em outros contextos
- Componentes de métricas extraídos e modularizados
- Novo componente MetricCard para cards individuais
- Novo componente DashboardMetrics para agrupamento
- Configuração centralizada de métricas
- Suporte a tooltips e ícones personalizados
- Responsividade e acessibilidade melhoradas

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

@ -0,0 +1,160 @@
import React from 'react';
import { BookOpen, Clock, TrendingUp, Award, Mic, Target, Brain, Gauge, Pause, XCircle, HelpCircle } from 'lucide-react';
import { MetricCard } from './MetricCard';
interface DashboardMetricsData {
totalStories: number;
averageReadingFluency: number;
totalReadingTime: number;
currentLevel: number;
averagePronunciation: number;
averageAccuracy: number;
averageComprehension: number;
averageWordsPerMinute: number;
averagePauses: number;
averageErrors: number;
}
interface DashboardMetricsProps {
data: DashboardMetricsData;
className?: string;
}
const MAIN_METRICS = [
{
key: 'totalStories',
title: 'Total de Histórias',
getValue: (data: DashboardMetricsData) => data.totalStories,
icon: BookOpen,
iconColor: 'text-purple-600',
iconBgColor: 'bg-purple-100'
},
{
key: 'averageReadingFluency',
title: 'Fluência Média',
getValue: (data: DashboardMetricsData) => `${data.averageReadingFluency}%`,
icon: TrendingUp,
iconColor: 'text-green-600',
iconBgColor: 'bg-green-100'
},
{
key: 'totalReadingTime',
title: 'Tempo de Leitura',
getValue: (data: DashboardMetricsData) => `${data.totalReadingTime}min`,
icon: Clock,
iconColor: 'text-blue-600',
iconBgColor: 'bg-blue-100'
},
{
key: 'currentLevel',
title: 'Nível Atual',
getValue: (data: DashboardMetricsData) => data.currentLevel,
icon: Award,
iconColor: 'text-yellow-600',
iconBgColor: 'bg-yellow-100'
}
];
const DETAILED_METRICS = [
{
key: 'averagePronunciation',
title: 'Pronúncia Média',
getValue: (data: DashboardMetricsData) => `${data.averagePronunciation}%`,
icon: Mic,
iconColor: 'text-indigo-600',
iconBgColor: 'bg-indigo-100',
tooltip: 'Avalia a qualidade da sua pronúncia durante a leitura, considerando a clareza e correção dos sons das palavras'
},
{
key: 'averageAccuracy',
title: 'Precisão na Leitura',
getValue: (data: DashboardMetricsData) => `${data.averageAccuracy}%`,
icon: Target,
iconColor: 'text-pink-600',
iconBgColor: 'bg-pink-100',
tooltip: 'Indica o quão preciso você é ao ler as palavras, sem trocas ou omissões de letras e sílabas'
},
{
key: 'averageComprehension',
title: 'Compreensão do Texto',
getValue: (data: DashboardMetricsData) => `${data.averageComprehension}%`,
icon: Brain,
iconColor: 'text-orange-600',
iconBgColor: 'bg-orange-100',
tooltip: 'Avalia seu nível de entendimento do texto durante a leitura, baseado no ritmo e entonação adequados'
},
{
key: 'averageWordsPerMinute',
title: 'Velocidade de Leitura',
getValue: (data: DashboardMetricsData) => `${data.averageWordsPerMinute} WPM`,
icon: Gauge,
iconColor: 'text-cyan-600',
iconBgColor: 'bg-cyan-100',
tooltip: 'Média de palavras lidas por minuto (WPM), indicando a velocidade e fluidez da sua leitura'
},
{
key: 'averagePauses',
title: 'Pausas na Leitura',
getValue: (data: DashboardMetricsData) => data.averagePauses,
icon: Pause,
iconColor: 'text-amber-600',
iconBgColor: 'bg-amber-100',
tooltip: 'Média de pausas não planejadas durante a leitura, indicando momentos de hesitação'
},
{
key: 'averageErrors',
title: 'Erros de Leitura',
getValue: (data: DashboardMetricsData) => data.averageErrors,
icon: XCircle,
iconColor: 'text-red-600',
iconBgColor: 'bg-red-100',
tooltip: 'Média de erros cometidos durante a leitura, como trocas, omissões ou adições de palavras'
}
];
export function DashboardMetrics({ data, className = '' }: DashboardMetricsProps) {
return (
<div className={className}>
{/* Métricas Principais */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{MAIN_METRICS.map(metric => (
<MetricCard
key={metric.key}
title={metric.title}
value={metric.getValue(data)}
icon={metric.icon}
iconColor={metric.iconColor}
iconBgColor={metric.iconBgColor}
/>
))}
</div>
{/* Métricas Detalhadas */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-gray-900">Métricas Detalhadas de Leitura</h2>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Estas métricas são calculadas com base em todas as suas gravações de leitura, fornecendo uma visão detalhada do seu progresso"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{DETAILED_METRICS.map(metric => (
<MetricCard
key={metric.key}
title={metric.title}
value={metric.getValue(data)}
icon={metric.icon}
iconColor={metric.iconColor}
iconBgColor={metric.iconBgColor}
tooltip={metric.tooltip}
/>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { HelpCircle } from 'lucide-react';
interface MetricCardProps {
title: string;
value: string | number;
icon: LucideIcon;
iconColor: string;
iconBgColor: string;
tooltip?: string;
}
export function MetricCard({
title,
value,
icon: Icon,
iconColor,
iconBgColor,
tooltip
}: MetricCardProps) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className={`p-3 ${iconBgColor} rounded-lg`}>
<Icon className={`h-6 w-6 ${iconColor}`} />
</div>
<div>
<div className="flex items-center gap-1">
<p className="text-sm text-gray-500">{title}</p>
{tooltip && (
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title={tooltip}
>
<HelpCircle className="h-4 w-4" />
</div>
)}
</div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,248 @@
import React from 'react';
import { Calendar, HelpCircle } from 'lucide-react';
import { Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Bar, ComposedChart } from 'recharts';
interface WeeklyMetrics {
week: string;
fluency: number;
pronunciation: number;
accuracy: number;
comprehension: number;
wordsPerMinute: number;
pauses: number;
errors: number;
minutesRead: number;
}
interface MetricConfig {
key: string;
name: string;
color: string;
}
type TimeFilter = '3m' | '6m' | '12m' | 'all';
interface TimeFilterOption {
value: TimeFilter;
label: string;
months: number | null;
}
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' }
];
const TIME_FILTERS: TimeFilterOption[] = [
{ value: '3m', label: '3 meses', months: 3 },
{ value: '6m', label: '6 meses', months: 6 },
{ value: '12m', label: '12 meses', months: 12 },
{ value: 'all', label: 'Todo período', months: null },
];
interface MetricsChartProps {
data: WeeklyMetrics[];
className?: string;
}
export function MetricsChart({ data, className = '' }: MetricsChartProps) {
const [visibleMetrics, setVisibleMetrics] = React.useState<Set<string>>(
new Set(METRICS_CONFIG.map(metric => metric.key))
);
const [timeFilter, setTimeFilter] = React.useState<TimeFilter>('12m');
const toggleMetric = (metricKey: string) => {
setVisibleMetrics(prev => {
const newSet = new Set(prev);
if (newSet.has(metricKey)) {
newSet.delete(metricKey);
} else {
newSet.add(metricKey);
}
return newSet;
});
};
const filterDataByTime = (data: WeeklyMetrics[]): WeeklyMetrics[] => {
if (timeFilter === 'all') return data;
const months = TIME_FILTERS.find(f => f.value === timeFilter)?.months || 12;
const cutoffDate = new Date();
cutoffDate.setMonth(cutoffDate.getMonth() - months);
return data.filter(item => {
const [year, week] = item.week.split('-W').map(Number);
const itemDate = new Date(year, 0, 1 + (week - 1) * 7);
return itemDate >= cutoffDate;
});
};
const filteredData = filterDataByTime(data);
return (
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-8 ${className}`}>
<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-4">
{/* Filtro de Período */}
<div className="flex items-center gap-2 bg-gray-50 p-1 rounded-lg">
<Calendar className="h-4 w-4 text-gray-500" />
{TIME_FILTERS.map(filter => (
<button
key={filter.value}
onClick={() => setTimeFilter(filter.value)}
className={`
px-3 py-1 rounded-md text-sm font-medium transition-all duration-200
${timeFilter === filter.value
? 'bg-white text-purple-600 shadow-sm'
: 'text-gray-600 hover:bg-gray-100'
}
`}
>
{filter.label}
</button>
))}
</div>
<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={filteredData} 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>
);
}

View File

@ -1,8 +1,10 @@
import React from 'react';
import { Plus, BookOpen, Clock, TrendingUp, Award, Mic, Target, Brain, Gauge, Pause, XCircle, HelpCircle } from 'lucide-react';
import { Plus, BookOpen } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import type { Story, Student } from '../../types/database';
import { MetricsChart } from '@/components/dashboard/MetricsChart';
import { DashboardMetrics } from '@/components/dashboard/DashboardMetrics';
interface DashboardMetrics {
totalStories: number;
@ -17,6 +19,41 @@ 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 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 WeeklyData {
count: number;
fluency: number;
pronunciation: number;
accuracy: number;
comprehension: number;
wordsPerMinute: number;
pauses: number;
errors: number;
minutesRead: number;
}
export function StudentDashboardPage() {
const navigate = useNavigate();
const [student, setStudent] = React.useState<Student | null>(null);
@ -32,10 +69,58 @@ 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 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));
};
React.useEffect(() => {
const fetchDashboardData = async () => {
try {
@ -64,24 +149,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) {
@ -106,10 +225,10 @@ export function StudentDashboardPage() {
// Calcular médias
setMetrics({
totalStories: allStoriesData.length, // Usando o total de histórias
totalStories: allStoriesData.length,
averageReadingFluency: Math.round(metricsSum.fluency / totalRecordings),
totalReadingTime: recordings.length * 2, // Exemplo: 2 minutos por gravação
currentLevel: Math.ceil(metricsSum.fluency / (totalRecordings * 20)), // Exemplo: nível baseado na fluência
totalReadingTime: recordings.length * 2,
currentLevel: Math.ceil(metricsSum.fluency / (totalRecordings * 20)),
averagePronunciation: Math.round(metricsSum.pronunciation / totalRecordings),
averageAccuracy: Math.round(metricsSum.accuracy / totalRecordings),
averageComprehension: Math.round(metricsSum.comprehension / totalRecordings),
@ -119,42 +238,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');
@ -234,197 +317,11 @@ export function StudentDashboardPage() {
</div>
</div>
{/* Métricas Principais */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-purple-100 rounded-lg">
<BookOpen className="h-6 w-6 text-purple-600" />
</div>
<div>
<p className="text-sm text-gray-500">Total de Histórias</p>
<p className="text-2xl font-bold text-gray-900">{metrics.totalStories}</p>
</div>
</div>
</div>
{/* Métricas */}
<DashboardMetrics data={metrics} />
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-green-100 rounded-lg">
<TrendingUp className="h-6 w-6 text-green-600" />
</div>
<div>
<p className="text-sm text-gray-500">Fluência Média</p>
<p className="text-2xl font-bold text-gray-900">{metrics.averageReadingFluency}%</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-blue-100 rounded-lg">
<Clock className="h-6 w-6 text-blue-600" />
</div>
<div>
<p className="text-sm text-gray-500">Tempo de Leitura</p>
<p className="text-2xl font-bold text-gray-900">{metrics.totalReadingTime}min</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-yellow-100 rounded-lg">
<Award className="h-6 w-6 text-yellow-600" />
</div>
<div>
<p className="text-sm text-gray-500">Nível Atual</p>
<p className="text-2xl font-bold text-gray-900">{metrics.currentLevel}</p>
</div>
</div>
</div>
</div>
{/* Métricas Detalhadas */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-gray-900">Métricas Detalhadas de Leitura</h2>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Estas métricas são calculadas com base em todas as suas gravações de leitura, fornecendo uma visão detalhada do seu progresso"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Pronúncia */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-indigo-100 rounded-lg">
<Mic className="h-6 w-6 text-indigo-600" />
</div>
<div>
<div className="flex items-center gap-1">
<p className="text-sm text-gray-500">Pronúncia Média</p>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Avalia a qualidade da sua pronúncia durante a leitura, considerando a clareza e correção dos sons das palavras"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-bold text-gray-900">{metrics.averagePronunciation}%</p>
</div>
</div>
</div>
{/* Precisão */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-pink-100 rounded-lg">
<Target className="h-6 w-6 text-pink-600" />
</div>
<div>
<div className="flex items-center gap-1">
<p className="text-sm text-gray-500">Precisão na Leitura</p>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Indica o quão preciso você é ao ler as palavras, sem trocas ou omissões de letras e sílabas"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-bold text-gray-900">{metrics.averageAccuracy}%</p>
</div>
</div>
</div>
{/* Compreensão */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-orange-100 rounded-lg">
<Brain className="h-6 w-6 text-orange-600" />
</div>
<div>
<div className="flex items-center gap-1">
<p className="text-sm text-gray-500">Compreensão do Texto</p>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Avalia seu nível de entendimento do texto durante a leitura, baseado no ritmo e entonação adequados"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-bold text-gray-900">{metrics.averageComprehension}%</p>
</div>
</div>
</div>
{/* Velocidade */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-cyan-100 rounded-lg">
<Gauge className="h-6 w-6 text-cyan-600" />
</div>
<div>
<div className="flex items-center gap-1">
<p className="text-sm text-gray-500">Velocidade de Leitura</p>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Média de palavras lidas por minuto (WPM), indicando a velocidade e fluidez da sua leitura"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-bold text-gray-900">{metrics.averageWordsPerMinute} WPM</p>
</div>
</div>
</div>
{/* Pausas */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-amber-100 rounded-lg">
<Pause className="h-6 w-6 text-amber-600" />
</div>
<div>
<div className="flex items-center gap-1">
<p className="text-sm text-gray-500">Pausas na Leitura</p>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Média de pausas não planejadas durante a leitura, indicando momentos de hesitação"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-bold text-gray-900">{metrics.averagePauses}</p>
</div>
</div>
</div>
{/* Erros */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-red-100 rounded-lg">
<XCircle className="h-6 w-6 text-red-600" />
</div>
<div>
<div className="flex items-center gap-1">
<p className="text-sm text-gray-500">Erros de Leitura</p>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Média de erros cometidos durante a leitura, como trocas, omissões ou adições de palavras"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-bold text-gray-900">{metrics.averageErrors}</p>
</div>
</div>
</div>
</div>
</div>
{/* Gráfico de Evolução */}
<MetricsChart data={weeklyMetrics} className="mb-8" />
{/* Histórias Recentes */}
<div>