mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-16 21:37:51 +00:00
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:
parent
18bc42d280
commit
c029aab50f
19
CHANGELOG.md
19
CHANGELOG.md
@ -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
283
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user