Este año me he matriculado de nueve asignaturas, una de ellas anual de modo que me ha tocado preparar diez exámenes. A diferencia de convocatorias anteriores este segundo cuatrimestre he decido estudiarlo a base de hacer tests, muchos tests. No he leído de antemano los manuales, ni siquiera he utilizado apuntes. También he prescindido de las tutorías presenciales o remotas, solo he consultado los foros dos o tres veces para informarme de alguna PEC.
Después de cuatro cursos, muchos conceptos me son familiares y si topo con alguno desconocido. o que no entiendo del todo, recurro al manual o busco información en internet. Así, a fuerza de realizar simulacros de exámenes, he ido formándome una idea del contenido de cada asignatura.
Mi metodología es sencilla. En un fichero de texto apunto los simulacros que voy haciendo, agrupados por año de examen y acompañados del enlace al test online. Por lo general a medida que los voy repitiendo consigo mejores puntuaciones. Hay algunas materias que se me dan mejor que otras y exámenes específicos en los que cometo más errores de la media. Por una mera cuestión de prioridades el tiempo que dedico es limitado y a menudo interrumpido por parones más prolongados de lo que quisiera. Son tantos los simulacros que, llegado un momento, no sé qué asignatura llevo mejor y a cuál debería dedicarle más esfuerzo. De ahí que se me ocurriera programar una aplicación sencilla que me ayudara a poner un poco de orden.
Estos son los requisitos funcionales:
- Un gráfico que muestre mi progreso en el tiempo, con una serie temporal por cada asignatura
- Un gráfico de barras en el que pueda ver de una pasada qué asignaturas llevo peor
- Una tabla con los exámenes realizados, agrupados por nombre, con el número de intentos y nota media. Al estar ordenados de mayor a menor tasa de error, tengo que dedicar más atención al primero de la lista
- Un formulario para introducir datos
- Una lista modificable con todos los datos introducidos hasta la fecha, por si tuviera que cambiar alguno guardado con errores
SubjectForm.js
Un formulario con cinco campos: Un combo con las cinco asignaturas de este cuatrimestre; un campo numérico con la tasa de error del simulacro (número de errores / número de preguntas); un campo de texto para identificar el examen (por ejemplo 06/21) y, por último, un campo de fecha para guardar cuándo hice el simulacro. Los datos se almacenarán en LocalStorage.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
import React, { useState } from 'react'; import { Form, Button } from 'react-bootstrap'; import { v4 as uuidv4 } from 'uuid'; const SubjectForm = ({ saveExam, className }) => { const [subject, setSubject] = useState(''); const [hitRate, setHitRate] = useState(''); const [monthYear, setMonthYear] = useState(''); const [simulationDate, setSimulationDate] = useState(''); const handleSubmit = (e) => { e.preventDefault(); if (!subject || !hitRate || !monthYear || !simulationDate) { alert("Please fill in all fields"); return; } const exam = { id: uuidv4(), subject, hitRate: parseFloat(hitRate), monthYear, simulationDate, }; saveExam(exam); setSubject(''); setHitRate(''); setMonthYear(''); setSimulationDate(''); }; return ( <Form onSubmit={handleSubmit} className={className}> {/* Subject select */} <Form.Group controlId="subject"> <Form.Label>Asignatura</Form.Label> <Form.Control as="select" value={subject} onChange={(e) => setSubject(e.target.value)} > <option value="">Selecciona</option> <option value="Psicología social">Psicología social</option> <option value="Psicología social aplicada">Psicología social aplicada</option> <option value="Evaluación en psicología clínica">Evaluación en psicología clínica</option> <option value="Intervención psicológica y salud">Intervención psicológica y salud</option> <option value="Neuropsicología del desarrollo">Neuropsicología del desarrollo</option> </Form.Control> </Form.Group> {/* Hit rate input */} <Form.Group controlId="hitRate"> <Form.Label>Tasa de error</Form.Label> <Form.Control type="number" step="0.01" min="0" max="1" value={hitRate} onChange={(e) => setHitRate(e.target.value)} /> </Form.Group> {/* Month-year input */} <Form.Group controlId="monthYear"> <Form.Label>Examen</Form.Label> <Form.Control type="month" value={monthYear} onChange={(e) => setMonthYear(e.target.value)} /> </Form.Group> {/* Simulation date input */} <Form.Group controlId="simulationDate"> <Form.Label>Fecha</Form.Label> <Form.Control type="date" value={simulationDate} onChange={(e) => setSimulationDate(e.target.value)} /> </Form.Group> {/* Submit button */} <Button type="submit" className="mt-2 float-right btn-success">Guardar</Button> </Form> ); }; export default SubjectForm; |
ExamResultsTable.js
Una tabla que muestre los exámenes realizados. Los campos de cada fila deben poder ser editados, y contar con un botón de borrado. Dado que esta tabla tenderá a crecer, debe estar oculta por defecto, se podrá mostrar haciendo click en el botón pertinente.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
import React from "react"; import PropTypes from "prop-types"; const ExamResultsTable = ({ exams, onDelete, onUpdateExams }) => { const handleDelete = (id) => { onDelete(id); }; const handleCellChange = (e, examId, field) => { const newValue = e.target.textContent; const updatedExams = exams.map((exam) => { if (exam.id === examId) { return { ...exam, [field]: newValue }; } return exam; }); onUpdateExams(updatedExams); }; const sortBySimulationDateDesc = (a, b) => { const dateA = new Date(a.simulationDate); const dateB = new Date(b.simulationDate); return dateB - dateA; }; const sortedExams = exams.sort(sortBySimulationDateDesc); return ( <table className="table table-striped"> <thead> <tr> <th scope="col">Asignatura</th> <th scope="col">Tasa de error</th> <th scope="col">Examen</th> <th scope="col">Fecha</th> <th scope="col">Acciones</th> </tr> </thead> <tbody> {sortedExams.map((exam, index) => ( <tr key={index}> <td contentEditable suppressContentEditableWarning onBlur={(e) => handleCellChange(e, exam.id, "subject")} > {exam.subject} </td> <td contentEditable suppressContentEditableWarning onBlur={(e) => handleCellChange(e, exam.id, "hitRate")} > {exam.hitRate} </td> <td contentEditable suppressContentEditableWarning onBlur={(e) => handleCellChange(e, exam.id, "monthYear")} > {exam.monthYear} </td> <td contentEditable suppressContentEditableWarning onBlur={(e) => handleCellChange(e, exam.id, "simulationDate")} > {exam.simulationDate} </td> <td> <button className="btn btn-danger btn-sm" onClick={() => handleDelete(exam.id)} > Borrar </button> </td> </tr> ))} </tbody> </table> ); }; ExamResultsTable.propTypes = { exams: PropTypes.array.isRequired, onDelete: PropTypes.func.isRequired, }; export default ExamResultsTable; |
ExamResultsChart.js
Dos gráficas, una de tipo series temporales y otra de barras en las que, respectivamente, se muestre el progreso en el tiempo, y se compare la tasa de error de las distintas asignaturas. Ambas gráficas contarán con una línea horizontal roja situada en el valor 0.33, que indica la tasa de error máxima permitida para poder aprobar.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
import React from "react"; import { Line, Bar } from "react-chartjs-2"; import { Chart, registerables } from "chart.js"; import "chartjs-adapter-date-fns"; import AnnotationPlugin from "chartjs-plugin-annotation"; Chart.register(...registerables); Chart.register(AnnotationPlugin); const ExamResultsChart = ({ exams }) => { const uniqueDates = Array.from( new Set(exams.map((exam) => exam.simulationDate)) ).sort(); const colors = ["#f7bce9", "#36a2eb", "#497046", "#ffce56", "#5edfff"]; const subjects = [ "Psicología social", "Psicología social aplicada", "Evaluación en psicología clínica", "Intervención psicológica y salud", "Neuropsicología del desarrollo", ]; const options = { scales: { x: { type: "time", time: { unit: "day", }, }, y: { min: 0, ticks: { callback: (value) => `${value * 100}%`, }, }, }, spanGaps: true, plugins: { annotation: { annotations: { threshold: { type: "line", yMin: 0.33, yMax: 0.33, borderColor: "red", borderWidth: 2, label: { enabled: false, }, }, }, }, }, }; // Process data for the time series chart const timeSeriesData = { labels: uniqueDates, datasets: subjects.map((subject) => { const subjectExams = exams.filter((exam) => exam.subject === subject); return { label: subject, showLine: true, // return the avg fail rate for the subject data: uniqueDates.map((date) => { const examsOnDate = subjectExams.filter( (exam) => exam.simulationDate === date ); const avgHitRate = examsOnDate.reduce((sum, exam) => sum + parseFloat(exam.hitRate), 0) / examsOnDate.length; return avgHitRate; }), fill: false, borderColor: colors[subjects.indexOf(subject)], backgroundColor: colors[subjects.indexOf(subject)], }; }), }; const datasets = subjects.map((subject, index) => { const subjectExams = exams.filter((exam) => exam.subject === subject); const avgHitRate = subjectExams.reduce((sum, exam) => sum + parseFloat(exam.hitRate), 0) / subjectExams.length; return { label: subject, data: [avgHitRate], backgroundColor: colors[index], borderColor: colors[index].replace("0.2", "1"), borderWidth: 1, }; }); const barChartDataFormatted = { labels: ["Tasa de error"], datasets: datasets, }; const barOptions = { scales: { y: { beginAtZero: true, }, }, plugins: { annotation: { annotations: { threshold: { type: "line", yMin: 0.33, yMax: 0.33, borderColor: "red", borderWidth: 2, label: { enabled: false, }, }, }, }, }, }; return ( <> <h2>Progreso</h2> <Line data={timeSeriesData} options={options} /> <h2>Tasa de errores por asignatura</h2> <Bar data={barChartDataFormatted} options={barOptions} /> </> ); }; export default ExamResultsChart; |
SummaryTable.js
Una tabla que muestre los exámenes agrupados por asignatura y examen. Los campos serán «asignatura/examen», «números de intentos» y «tasa promedio de fallos». Habrá una línea roja que muestre el umbral de porcentaje requerido para aprobar.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
import React from "react"; import { Table } from "react-bootstrap"; const SummaryTable = ({ exams }) => { // Creating the summary const summary = exams.reduce((acc, exam) => { const key = `${exam.subject} / ${exam.monthYear}`; if (!acc[key]) { acc[key] = { attempts: 0, totalHitRate: 0 }; } acc[key].attempts += 1; acc[key].totalHitRate += exam.hitRate; return acc; }, {}); // Defining the colors for each subject const subjectsColors = { "Psicología social": "#f7bce9", "Psicología social aplicada": "#36a2eb", "Evaluación en psicología clínica": "#497046", "Intervención psicológica y salud": "#ffce56", "Neuropsicología del desarrollo": "#5edfff", }; // Transforming the summary object into an array and sorting it const summaryArray = Object.keys(summary).map((key) => ({ subjectMonthYear: key, attempts: summary[key].attempts, averageHitRate: summary[key].totalHitRate / summary[key].attempts, color: subjectsColors[key.split(" / ")[0]], })); summaryArray.sort((a, b) => b.averageHitRate - a.averageHitRate); let redRowDrawn = false; const drawRedRow = (item) => { if (item.averageHitRate < .34 && !redRowDrawn) { redRowDrawn = true; return <tr style={{backgroundColor: "#ff0000", color: "#ffffff"}}><td colSpan="3"></td></tr> } } return ( <Table striped bordered hover> <thead> <tr> <th>Asignatura/Examen</th> <th>Número de intentos</th> <th>Tasa promedio de fallos</th> </tr> </thead> <tbody> {summaryArray.map((item, index) => ( <> <tr key={index}> <td><i style={{backgroundColor: item.color, width: "16px", height:"10px", display: "inline-block"}} ></i> {item.subjectMonthYear}</td> <td>{item.attempts}</td> <td>{item.averageHitRate.toFixed(2)}</td> </tr> {/* draw just one single red row once item.averageHitRate goes under .33 */} {drawRedRow(item)} </> ))} </tbody> </Table> ); }; export default SummaryTable; |
App.js
Por último el fichero de entrada en el que se importa los componentes.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
import React, { useState, useEffect } from "react"; import { Container } from "react-bootstrap"; import SubjectForm from "./components/SubjectForm"; import ExamResultsChart from "./components/ExamResultsChart"; import ExamResultsTable from "./components/ExamResultsTable"; import SummaryTable from "./components/SummaryTable"; const App = () => { const [exams, setExams] = useState(() => { const storedExams = localStorage.getItem("exams"); return storedExams ? JSON.parse(storedExams) : []; }); const [showResultsTable, setShowResultsTable] = useState(false); // Load data from localStorage useEffect(() => { const storedExams = localStorage.getItem("exams"); if (storedExams) { setExams(JSON.parse(storedExams)); } }, []); // Save data to localStorage useEffect(() => { localStorage.setItem("exams", JSON.stringify(exams)); }, [exams]); const saveExam = (exam) => { setExams([...exams, exam]); }; const deleteExamResult = (id) => { setExams(exams.filter((exam) => exam.id !== id)); localStorage.setItem( "exams", JSON.stringify(exams.filter((exam) => exam.id !== id)) ); }; return ( <Container> <h1>Rastreador de simulacros</h1> <SubjectForm saveExam={saveExam} className="pb-5" /> <hr /> <button className="btn btn-primary" onClick={() => setShowResultsTable((prevState) => !prevState)} > Muestra/Oculta tabla de resultados </button> {showResultsTable && ( <ExamResultsTable exams={exams} onDelete={deleteExamResult} onUpdateExams={setExams} /> )} <hr /> <ExamResultsChart exams={exams} /> <hr /> <h1>Sumario de tasa de error por asignatura y examen</h1> <SummaryTable exams={exams} /> </Container> ); }; export default App; |
Conclusión
Lo más sorprendente es la facilidad con la que GPT-4 entendió el tipo de proyecto que quería. Sobretodo me ha ayudado a poner en marcha el proyecto al inciar de manera rápida la estructura base del código. También en ocasiones me ha sido muy útil para solucionar problemas, y otras veces me ha llevado por un camino equivocado retrasándome. No sé cuánto hubiera tardado en hacerlo sin su ayuda, es probable que no mucho más. Lo que más valoro es su disponibilidad 24h, y sus ganas de trabajar; en este sentido supone un valioso empujón para arrancar. Y, de la misma manera que se atasca, en numerosas ocasiones me ha ayudado a destascarme, haciéndome ver errores que me estaban pasando desapercibidos.