diff --git a/.gitignore b/.gitignore index 541b719f..292d817a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ public/themes/nano/ CLAUDE.md TODO.md BITACORA.md +PROPUESTA_LEADERBOARD.md diff --git a/SISTEMA_PUNTOS.txt b/SISTEMA_PUNTOS.txt new file mode 100644 index 00000000..57d6b8b2 --- /dev/null +++ b/SISTEMA_PUNTOS.txt @@ -0,0 +1,227 @@ +Sistema de puntos nuevo MAEs +============================ + +Este documento explica como quedo el nuevo sistema de puntos del leaderboard. +La idea principal es que los puntos representen mejor el trabajo real de los +MAEs, principalmente las asesorias que dan, y no tanto horas acumuladas o reglas +viejas que ya no estaban dando resultados correctos. + +Puntos importantes: + +- Ninguna accion puede dejar a un MAE con puntos negativos. +- Si se corrige algo y eso baja puntos, el minimo siempre sera 0. +- Los estudiantes son quienes registran las asesorias que recibieron. +- Los puntos se otorgan al MAE que el estudiante selecciona como asesor. +- El sistema nuevo aplica hacia adelante. +- Las asesorias marcadas como prueba no cuentan para puntos, perfil, + evaluaciones, estadisticas ni leaderboard. + +Formula general: + + puntos = asesorias + asistencia + bonus de rating + + +1. Asesorias individuales +------------------------- +Esta es la parte principal del sistema. + +Cuando un estudiante registra una asesoria, la pagina revisa que MAE fue +seleccionado como asesor y le suma los puntos a ese MAE. + +Reglas: + +- Primera asesoria de ese estudiante con ese MAE: +10 puntos. +- Asesoria recurrente del mismo estudiante con ese MAE: +5 puntos. +- Si el mismo estudiante registra otra asesoria con el mismo MAE en menos de + 3 horas, la segunda asesoria vale puntos medios: + - Si normalmente valia 10, ahora vale 5. + - Si normalmente valia 5, ahora vale 2.5. + +La primera asesoria se calcula por relacion MAE-estudiante. Es decir, si un +estudiante toma asesoria por primera vez con un MAE, ese MAE recibe los puntos +de primera vez. Si despues ese mismo estudiante toma asesoria por primera vez +con otro MAE, ese otro MAE tambien puede recibir los puntos de primera vez. + +Esto se hizo asi porque queremos incentivar que los MAEs atiendan a mas alumnos, +pero sin dejar de contar las asesorias recurrentes cuando un estudiante vuelve +con el mismo MAE. + +Campos que se guardan en cada asesoria: + +- pointsAwarded: puntos que se dieron por esa asesoria. +- pointsReason: razon del calculo. +- pointsSubjectId: materia de la asesoria. +- pointsAwardedAt: fecha en que se dieron los puntos. + +Ejemplos de pointsReason: + +- first_time_student +- recurrent_student +- first_time_under_3_hours +- recurrent_under_3_hours + + +2. Asesorias grupales +--------------------- +Las asesorias grupales dan +20 puntos por cada MAE asignado. + +Reglas: + +- Cada MAE asignado recibe 20 puntos. +- Los puntos no se dividen entre todos los MAEs. +- Solo cuentan los MAEs que esten asignados a la asesoria grupal. +- Los puntos se dan una sola vez por evento. +- Si despues se cambia la asistencia de alumnos, no se duplican los puntos. + +La idea es que las asesorias grupales tambien tengan peso en el leaderboard, +porque hay MAEs que participan mas en grupales que en asesorias individuales. + +Campos que se guardan en el anuncio de la asesoria grupal: + +- pointsAwarded: true cuando ya se dieron los puntos. +- pointsAwardedAt: fecha en que se dieron. +- pointsAwardedTo: lista de MAEs que recibieron puntos. + + +3. Asistencia y horario +----------------------- +La asistencia al horario suma puntos, pero las faltas no restan. + +Valores: + +- A = asistencia completa = +3 puntos. +- R = retraso = +1 punto. +- J = falta justificada = +0 puntos. +- F = falta = +0 puntos. + +Si una asistencia se cambia despues, la pagina aplica la diferencia entre el +valor anterior y el nuevo. + +Ejemplos: + +- Sin registro a A: suma 3 puntos. +- A a R: baja 2 puntos, porque antes valia 3 y ahora vale 1. +- R a A: suma 2 puntos. +- A a F: baja 3 puntos, pero el total del MAE nunca baja de 0. +- F a J: no cambia puntos. + +Tambien se quitaron reglas anteriores que ya no correspondian al sistema nuevo: + +- Ya no se dan puntos extra al coordi por pasar asistencia. +- Ya no se usan puntos para "jackpot". +- Una falta ya no quita puntos. + + +4. Bonus por rating +------------------- +El rating funciona como un bonus del semestre. + +Regla: + + bonus = promedio de ratings del semestre * 10 + +Limites: + +- Maximo: 50 puntos. +- Sin evaluaciones: 0 puntos. +- Con evaluaciones, el minimo del bonus es 20 puntos. + +Ejemplos: + +- Promedio 5.0 = +50 puntos. +- Promedio 4.0 = +40 puntos. +- Promedio 3.0 = +30 puntos. +- Promedio 2.0 o menor = +20 puntos. +- Sin ratings = +0 puntos. + +Este bonus no se vuelve a sumar completo cada vez que entra una evaluacion. La +pagina guarda ratingBonusPoints en el usuario y, cuando cambia el promedio, solo +aplica la diferencia. + +Ejemplo: + +- Bonus anterior: 30. +- Bonus nuevo: 40. +- Diferencia aplicada al leaderboard: +10. + + +5. Minimo de puntos +------------------- +La funcion central de puntos siempre aplica esta regla: + + puntos_finales = max(0, puntos_actuales + cambio) + +Esto evita que un MAE quede con puntos negativos aunque una correccion de +asistencia o de rating baje su puntaje. + + +6. Boton para reiniciar leaderboard +----------------------------------- +En la pestaña de funciones de admin se agrego el boton: + + Reiniciar puntos del leaderboard + +Este boton solo reinicia: + +- points = 0 +- ratingBonusPoints = 0 + +No reinicia: + +- horas +- monedas +- materias +- horarios +- asesorias +- asistencias +- evaluaciones +- badges + +La idea es poder empezar el nuevo sistema desde cero sin borrar el historial ni +afectar otras partes de la pagina. + +Roles a los que aplica: + +- admin +- coordi +- mae +- subjectCoordi +- publi +- tec + + +7. Donde esta en el codigo +-------------------------- +Las reglas principales estan en: + + src/utils/PointsUtils.js + +Se usan en: + +- src/firebase/db/asesorias.js + - Asesorias individuales. + - Bonus de rating. +- src/firebase/db/annoucement.js + - Asesorias grupales. +- src/views/Coordi.vue + - Asistencia y correcciones. +- src/firebase/db/users.js + - updatePoints. + - Reset del leaderboard. + + +8. Reglas anteriores que ya no se deben usar +-------------------------------------------- +Con este sistema ya no se deberian usar reglas anteriores como: + +- -150 por duplicado. +- +60 por asesoria normal. +- +15 por materia MAE. +- +100 por tener horario. +- -500 por no tener horario. +- +50 dividido entre MAEs de una grupal. +- Puntos negativos por faltas. +- Puntos por guardar horario o materias. + +Si aparece alguna de estas reglas en otro flujo, probablemente es codigo viejo +que se quedo pendiente de quitar. diff --git a/package-lock.json b/package-lock.json index 7211a3bb..fe200fe9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "file-saver": "^2.0.5", "firebase": "^10.14.1", "primeflex": "^3.3.1", - "primeicons": "^6.0.1", + "primeicons": "^7.0.0", "primevue": "^3.30.2", "uuid": "^10.0.0", "vue": "^3.2.41", @@ -2696,9 +2696,10 @@ "integrity": "sha512-zaOq3YvcOYytbAmKv3zYc+0VNS9Wg5d37dfxZnveKBFPr7vEIwfV5ydrpiouTft8MVW6qNjfkaQphHSnvgQbpQ==" }, "node_modules/primeicons": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-6.0.1.tgz", - "integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA==" + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", + "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==", + "license": "MIT" }, "node_modules/primevue": { "version": "3.30.2", diff --git a/package.json b/package.json index 67efe763..35f72bf4 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "file-saver": "^2.0.5", "firebase": "^10.14.1", "primeflex": "^3.3.1", - "primeicons": "^6.0.1", + "primeicons": "^7.0.0", "primevue": "^3.30.2", "uuid": "^10.0.0", "vue": "^3.2.41", diff --git a/src/firebase/db/annoucement.js b/src/firebase/db/annoucement.js index 38be7de8..c211c119 100644 --- a/src/firebase/db/annoucement.js +++ b/src/firebase/db/annoucement.js @@ -8,7 +8,8 @@ import { updateDoc, doc, getDoc, - deleteDoc + deleteDoc, + serverTimestamp } from 'firebase/firestore'; import { addAnnoucement } from "../img/users"; import { @@ -16,6 +17,7 @@ import { } from './users'; import { invalidateCacheTags, withCache } from '../cache/cache'; import { CACHE_TAGS, CACHE_TTL_MS, cacheKeys } from '../cache/config'; +import { POINTS_RULES } from "../../utils/PointsUtils"; async function invalidateAnnouncementCaches() { await invalidateCacheTags([CACHE_TAGS.ANNOUNCEMENTS, CACHE_TAGS.GROUP_ANNOUNCEMENTS]); @@ -239,29 +241,28 @@ export async function updateUserAsistence(announcementId, userId) { [userId]: newAsistenceStatus, }; - const totalMaes = maesAsignados.length; + const awardableMaes = maesAsignados.filter(mae => mae?.uid && mae.assigned !== false); + const shouldAwardGroupPoints = newAsistenceStatus && announcementData.pointsAwarded !== true; + const pointsUpdate = {}; - if (totalMaes > 0) { - const pointsPerMae = 50 / totalMaes; - - - for (const mae of maesAsignados) { - if (newAsistenceStatus) { - - await updatePoints(mae.uid, pointsPerMae); - console.log(`Puntos distribuidos a ${mae.name}: +${pointsPerMae}`); - } else { - - await updatePoints(mae.uid, -pointsPerMae); - console.log(`Puntos distribuidos a ${mae.name}: -${pointsPerMae}`); + if (shouldAwardGroupPoints) { + if (awardableMaes.length > 0) { + for (const mae of awardableMaes) { + await updatePoints(mae.uid, POINTS_RULES.groupAdvisory); + console.log(`Puntos de asesoría grupal para ${mae.name}: +${POINTS_RULES.groupAdvisory}`); } + + pointsUpdate.pointsAwarded = true; + pointsUpdate.pointsAwardedAt = serverTimestamp(); + pointsUpdate.pointsAwardedTo = awardableMaes.map(mae => mae.uid); + } else { + console.log('No hay MAEs asignados para asignar puntos.'); } - } else { - console.log('No hay MAEs asignados para asignar puntos.'); } await updateDoc(announcementRef, { asistence: updatedAsistence, + ...pointsUpdate }); await invalidateAnnouncementCaches(); diff --git a/src/firebase/db/asesorias.js b/src/firebase/db/asesorias.js index a4f491a5..5c5c1e93 100644 --- a/src/firebase/db/asesorias.js +++ b/src/firebase/db/asesorias.js @@ -6,16 +6,24 @@ import { where, getDocs, Timestamp, - updateDoc, + updateDoc, doc, deleteDoc, + getDoc, } from 'firebase/firestore'; import { updatePoints, - updateUserAchievementBadge + updateRatingBonusPoints } from './users'; import { invalidateCacheTags, withCache } from '../cache/cache'; import { CACHE_TAGS, CACHE_TTL_MS, cacheKeys } from '../cache/config'; +import { + calculateRatingBonus, + getAdvisoryPointsReason, + getIndividualAdvisoryPoints, + POINTS_RULES, + toMillis +} from "../../utils/PointsUtils"; const SEMESTER_START = new Date('2024-08-05'); @@ -31,8 +39,8 @@ function normalizeDateKey(date) { return String(date); } -function getCurrentSemesterRange() { - const now = new Date(); +function getCurrentSemesterRange(referenceDate = new Date()) { + const now = referenceDate; const currentYear = now.getFullYear(); if (now.getMonth() < 6) { @@ -53,16 +61,16 @@ async function invalidateAsesoriaCaches() { } function timestampToMs(value) { - if (!value) return null; - if (typeof value?.toMillis === 'function') return value.toMillis(); - if (typeof value?.toDate === 'function') return value.toDate().getTime(); - if (value instanceof Date) return value.getTime(); - if (typeof value === 'string') return new Date(value).getTime(); - return null; + return toMillis(value); +} + +function isRealAsesoria(asesoria) { + return asesoria?._test !== true && !asesoria?._type; } // Registra la asesoría del mae export async function addAsesoria(maeInfo, userInfo, subject, comment, rating) { + const createdAt = Timestamp.now(); // Changing to use payload instead to debug it const payload = { peerInfo: { @@ -88,15 +96,21 @@ export async function addAsesoria(maeInfo, userInfo, subject, comment, rating) { rating, comment, subject, - date: Timestamp.now() + duplicate: false, + pointsAwarded: 0, + pointsReason: null, + date: createdAt }; // Debug para ver q se anden guardando los datos correctos //console.log("Saving asesoria:", payload); - await addDoc(collection(firestoreDB, "asesorias"), payload); + const docRef = await addDoc(collection(firestoreDB, "asesorias"), payload); - updateExperienceAsesorias(maeInfo.uid, userInfo.uid, subject.id, Timestamp.now()); + await updateExperienceAsesorias(maeInfo.uid, userInfo.uid, subject.id, createdAt, docRef.id); + if (rating != null) { + await updateRatingBonusForMae(maeInfo.uid); + } await invalidateAsesoriaCaches(); return; } @@ -119,6 +133,7 @@ export async function getAsesoriasCountForUserInCurrentSemester(userId, options const dateMs = timestampToMs(doc.date); return ( doc.peerInfo?.uid === userId && + isRealAsesoria(doc) && doc.duplicate !== true && dateMs !== null && dateMs >= start.getTime() && @@ -177,8 +192,12 @@ export async function getAsesoriasByUid(uid, options = {}) { try { const today = new Date(); const asesorias = await getAsesorias(SEMESTER_START, today, options); + const includeTests = options.includeTests === true; - const asesoriasFiltradas = asesorias.filter(asesoria => asesoria.peerInfo?.uid === uid); + const asesoriasFiltradas = asesorias.filter(asesoria => + asesoria.peerInfo?.uid === uid && + (includeTests || isRealAsesoria(asesoria)) + ); return asesoriasFiltradas; } catch (error) { @@ -259,59 +278,123 @@ async function updateAdvisoryDuplicateField(advisoryDate, isDuplicate) { } -// Función para actualizar puntos basados en asesorías similares -export async function updateExperienceAsesorias(peerUid, userUid, subjectId, advisoryDate) { +// Función para actualizar puntos basados en el nuevo sistema de asesorías. +export async function updateExperienceAsesorias(peerUid, userUid, subjectId, advisoryDate, advisoryId = null) { try { - if (!(advisoryDate instanceof Date)) { - if (advisoryDate.toDate) { - advisoryDate = advisoryDate.toDate(); - } else { - advisoryDate = new Date(advisoryDate); + const advisoryMs = toMillis(advisoryDate); + if (!advisoryMs) return 0; + + if (advisoryId) { + const advisoryRef = doc(firestoreDB, "asesorias", advisoryId); + const advisorySnap = await getDoc(advisoryRef); + const currentData = advisorySnap.exists() ? advisorySnap.data() : null; + if (!isRealAsesoria(currentData)) { + return 0; + } + if (typeof currentData?.pointsAwarded === 'number' && currentData.pointsAwarded > 0) { + return currentData.pointsAwarded; } } - const today = Timestamp.now().toDate(); - const startOfDay = new Date(today); - startOfDay.setHours(0, 0, 0, 0); - const endOfDay = new Date(today); - endOfDay.setHours(23, 59, 59, 999); - const asesoriasRef = collection(firestoreDB, "asesorias"); + const q = query(asesoriasRef, where("peerInfo.uid", "==", peerUid)); + const querySnapshot = await getDocs(q); - const q = query( - asesoriasRef, - where("date", ">=", startOfDay), - where("date", "<=", endOfDay) - ); + const previousAdvisories = querySnapshot.docs + .map((docSnap) => ({ id: docSnap.id, ...docSnap.data() })) + .filter((advisory) => { + const dateMs = toMillis(advisory.date); + return advisory.id !== advisoryId && + isRealAsesoria(advisory) && + advisory.userInfo?.uid === userUid && + dateMs !== null && + dateMs < advisoryMs; + }); + + const isFirstTime = previousAdvisories.length === 0; + const antiFarmingWindowMs = POINTS_RULES.advisory.antiFarmingWindowHours * 60 * 60 * 1000; + const isWithinAntiFarmingWindow = previousAdvisories.some((advisory) => { + const dateMs = toMillis(advisory.date); + const diff = advisoryMs - dateMs; + return diff > 0 && diff < antiFarmingWindowMs; + }); + const pointsAwarded = getIndividualAdvisoryPoints({ + isFirstTime, + isWithinAntiFarmingWindow + }); + const pointsReason = getAdvisoryPointsReason({ + isFirstTime, + isWithinAntiFarmingWindow + }); + await updatePoints(peerUid, pointsAwarded); - const querySnapshot = await getDocs(q); - const asesorias = querySnapshot.docs.map(doc => doc.data()); - const similarAdvisories = asesorias.filter(ad => { - const adDate = ad.date.toDate(); - return ad.peerInfo.uid === peerUid && - ad.userInfo.uid === userUid && - ad.subject.id === subjectId && - Math.abs(adDate.getTime() - advisoryDate.getTime()) <= 2 * 60 * 60 * 1000; - }); - const ultimoElemento = asesorias[asesorias.length - 1]; - if (similarAdvisories.length > 1) { - await updatePoints(peerUid, -150); - await updateAdvisoryDuplicateField(ultimoElemento.date, true ); - await updateUserAchievementBadge(userUid, "18"); - } else { - await updateAdvisoryDuplicateField(ultimoElemento.date, false ); - if(subjectId === "MAE"){ - await updatePoints(peerUid, 15); - }else{ - await updatePoints(peerUid, 60); - } - + if (advisoryId) { + await updateDoc(doc(firestoreDB, "asesorias", advisoryId), { + duplicate: false, + pointsAwarded, + pointsReason, + pointsSubjectId: subjectId, + pointsAwardedAt: Timestamp.now() + }); } await invalidateAsesoriaCaches(); + return pointsAwarded; } catch (error) { console.error("Error actualizando la experiencia de asesorías:", error); + return 0; + } +} + +export async function getEvaluacionesRecibidas(uid, revealedAt = undefined, clearedAt = null, options = {}) { + try { + const includeTests = options.includeTests === true; + const asesorias = await getAsesoriasByUid(uid, options); + + return (asesorias ?? []) + .filter(a => a.rating != null && a.duplicate !== true && (includeTests || !a._test)) + .filter(a => { + const dateMs = toMillis(a.date); + if (includeTests && a._test) return true; + if (clearedAt && dateMs !== null && dateMs <= toMillis(clearedAt)) return false; + if (revealedAt === undefined) return true; + if (!revealedAt) return false; + return dateMs !== null && dateMs <= toMillis(revealedAt); + }) + .sort((a, b) => (toMillis(b.date) || 0) - (toMillis(a.date) || 0)); + } catch (error) { + console.error("Error fetching evaluaciones recibidas:", error); + return []; + } +} + +export async function updateRatingBonusForMae(uid, referenceDate = new Date()) { + try { + if (!uid) return null; + + const { start, end } = getCurrentSemesterRange(referenceDate); + const startMs = start.getTime(); + const endMs = end.getTime(); + const asesorias = await getAsesoriasByUid(uid); + const ratings = (asesorias ?? []) + .filter(a => { + const dateMs = toMillis(a.date); + return a.rating != null && + a.duplicate !== true && + !a._test && + !a._type && + dateMs !== null && + dateMs >= startMs && + dateMs <= endMs; + }) + .map(a => a.rating); + + const bonus = calculateRatingBonus(ratings); + return await updateRatingBonusPoints(uid, bonus); + } catch (error) { + console.error("Error actualizando bonus de rating:", error); + return null; } } @@ -326,7 +409,6 @@ export async function getCommentsByUid(uid, options = {}) { } } - async function fetchAsesoriasByUidAndRatingFresh(uidUser , uidPeer = null) { try { const asesoriasRef = collection(firestoreDB, "asesorias"); @@ -347,7 +429,7 @@ async function fetchAsesoriasByUidAndRatingFresh(uidUser , uidPeer = null) { const asesorias = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() - })); + })).filter(isRealAsesoria); return asesorias; } catch (error) { @@ -372,7 +454,7 @@ export async function updateAsesoria(id, data) { export async function getTotalAsesorias(startDate = null, endDate = null, options = {}) { try { const asesorias = await getAsesorias(startDate, endDate, options); - const totalAsesorias = asesorias.length; + const totalAsesorias = (asesorias ?? []).filter(isRealAsesoria).length; return totalAsesorias; } catch (error) { console.error("Error fetching total asesorias: ", error); @@ -384,7 +466,7 @@ export async function updateAsesoria(id, data) { export async function getAsesoriasCountByUser(options = {}) { try { const asesorias = await getAsesorias(null, null, options); - const userAsesoriasSet = new Set((asesorias ?? []).map(doc => doc.userInfo?.uid).filter(Boolean)); + const userAsesoriasSet = new Set((asesorias ?? []).filter(isRealAsesoria).map(doc => doc.userInfo?.uid).filter(Boolean)); return userAsesoriasSet.size; } catch (error) { console.error("Error al obtener el conteo de asesorías por usuario: ", error); @@ -397,7 +479,7 @@ export async function getAsesoriasCountByArea(options = {}) { const asesorias = await getAsesorias(null, null, options); const areasCount = {}; - (asesorias ?? []).forEach(asesoriaData => { + (asesorias ?? []).filter(isRealAsesoria).forEach(asesoriaData => { const subjectArea = asesoriaData?.subject?.area; const userUid = asesoriaData?.userInfo?.uid; @@ -431,7 +513,7 @@ export async function getAsesoriasCountByCampus(options = {}) { const asesorias = await getAsesorias(null, null, options); const campusCount = {}; - (asesorias ?? []).forEach(doc => { + (asesorias ?? []).filter(isRealAsesoria).forEach(doc => { const campus = doc?.userInfo?.campus; if (campus) { campusCount[campus] = (campusCount[campus] || 0) + 1; @@ -474,6 +556,43 @@ export async function deleteOldAsesorias() { } } +export async function borrarTodasEvaluaciones() { + const asesoriasRef = collection(firestoreDB, "asesorias"); + const snap = await getDocs(asesoriasRef); + if (snap.empty) return { scanned: 0, cleared: 0, failed: 0, sampleErrors: [] }; + + const docs = snap.docs.filter(d => { + const data = d.data(); + return isRealAsesoria(data) && data.rating != null; + }); + + const results = await Promise.allSettled( + docs.map(d => updateDoc(d.ref, { rating: null, comment: '' })) + ); + + let cleared = 0; + let failed = 0; + const sampleErrors = []; + results.forEach((r, i) => { + if (r.status === 'fulfilled') { + cleared++; + } else { + failed++; + if (sampleErrors.length < 3) { + sampleErrors.push({ id: docs[i].id, reason: r.reason?.message || String(r.reason) }); + } + } + }); + + await invalidateAsesoriaCaches(); + if (failed > 0) { + console.error(`borrarTodasEvaluaciones: fallaron ${failed}/${docs.length}. Ejemplos:`, sampleErrors); + } + console.log(`borrarTodasEvaluaciones: ${cleared} limpiadas en DB, ${failed} fallidas`); + + return { scanned: snap.size, cleared, failed, sampleErrors }; +} + export async function getAsesorias(startDate = null, endDate = null, options = {}) { const startKey = normalizeDateKey(startDate); const endKey = normalizeDateKey(endDate); diff --git a/src/firebase/db/settings.js b/src/firebase/db/settings.js new file mode 100644 index 00000000..d67459f4 --- /dev/null +++ b/src/firebase/db/settings.js @@ -0,0 +1,62 @@ +import { firestoreDB } from "../../main"; +import { collection, addDoc, query, where, getDocs, Timestamp, orderBy, limit } from 'firebase/firestore'; + +export async function getEvaluationsRevealedAt() { + try { + const q = query( + collection(firestoreDB, "asesorias"), + where("_type", "==", "reveal_config") + ); + const snap = await getDocs(q); + if (snap.empty) return null; + + let latest = null; + snap.docs.forEach(doc => { + const ts = doc.data().evaluationsRevealedAt; + if (ts && (!latest || ts.seconds > latest.seconds)) { + latest = ts; + } + }); + return latest; + } catch (error) { + console.error("Error fetching settings:", error); + return null; + } +} + +export async function revelarEvaluaciones() { + await addDoc(collection(firestoreDB, "asesorias"), { + _type: "reveal_config", + evaluationsRevealedAt: Timestamp.now() + }); +} + +export async function getEvaluationsClearedAt() { + try { + const q = query( + collection(firestoreDB, "asesorias"), + where("_type", "==", "evaluations_cleared_config") + ); + const snap = await getDocs(q); + if (snap.empty) return null; + + let latest = null; + snap.docs.forEach(doc => { + const ts = doc.data().evaluationsClearedAt; + if (ts && (!latest || ts.seconds > latest.seconds)) { + latest = ts; + } + }); + return latest; + } catch (error) { + console.error("Error fetching evaluations cleared at:", error); + return null; + } +} + +export async function borrarEvaluaciones() { + await addDoc(collection(firestoreDB, "asesorias"), { + _type: "evaluations_cleared_config", + evaluationsClearedAt: Timestamp.now() + }); +} diff --git a/src/firebase/db/users.js b/src/firebase/db/users.js index e9141393..9e717fb4 100644 --- a/src/firebase/db/users.js +++ b/src/firebase/db/users.js @@ -19,10 +19,29 @@ import * as XLSX from 'xlsx'; import { writeBatch } from "firebase/firestore"; import { invalidateCacheTags, withCache } from "../cache/cache"; import { CACHE_TAGS, CACHE_TTL_MS, cacheKeys, userTag } from "../cache/config"; +import { applyPointsDelta, LEADERBOARD_ROLES, roundPoints } from "../../utils/PointsUtils"; const db = getFirestore(); const MAE_DIRECTORY_ROLES = ['mae', 'coordi', 'admin', 'subjectCoordi', 'publi', 'tec']; +async function getUserRecordByUid(uid) { + if (!uid) return null; + + const directRef = doc(db, 'users', uid); + const directSnap = await getDoc(directRef); + if (directSnap.exists()) { + return { id: directSnap.id, ref: directRef, data: directSnap.data() }; + } + + const usersRef = collection(db, 'users'); + const q = query(usersRef, where('uid', '==', uid)); + const querySnapshot = await getDocs(q); + if (querySnapshot.empty) return null; + + const userDoc = querySnapshot.docs[0]; + return { id: userDoc.id, ref: userDoc.ref, data: userDoc.data() }; +} + function getEmailUsername(email) { var atIndex = email.indexOf('@'); @@ -271,6 +290,11 @@ export async function updateUserInfo(userId, userInfo) { return result; } +export async function updateUserCareer(userId, career, area) { + const userRef = doc(firestoreDB, "users", userId); + return await updateDoc(userRef, { career, area }); +} + export async function updateUserSubjects(userId, newSubjects) { const userRef = doc(firestoreDB, "users", userId); const result = await updateDoc(userRef, { @@ -629,76 +653,50 @@ export async function updateUserToMae(data) { } export const saveScheduleSubjectsExperience = async () => { - try { - const usersRef = collection(db, 'users'); - const usersSnap = await getDocs(usersRef); - - if (usersSnap.empty) { - console.error("No se encontraron usuarios en la tabla 'users'."); - return; - } - - const rolesPermitidos = ['admin', 'publi', 'mae', 'coordi', 'tec']; - - const updatePromises = []; - usersSnap.forEach(async (userDoc) => { - const user = userDoc.data(); - - if (!rolesPermitidos.includes(user.role)) { - return; - } - - let puntos = 0; - - if (user.subjects && user.subjects.length > 0) { - puntos += 15; - } else { - puntos -= 30; - await updateUserAchievementBadge(user.uid, "18"); - } - - if (user.weekSchedule && Object.keys(user.weekSchedule).length > 0) { - puntos += 100; - } else { - puntos -= 500; - await updateUserAchievementBadge(user.uid, "18"); - } - - const userRef = doc(db, 'users', userDoc.id); - updatePromises.push( - updateDoc(userRef, { - points: (user.points || 0) + puntos - }) - ); - }); - - await Promise.all(updatePromises); - await invalidateUserCaches(null, { includeActive: true, includeLeaderboard: true }); - } catch (error) { - console.error("Error al guardar la experiencia:", error); - } + console.warn("saveScheduleSubjectsExperience está desactivada: el nuevo leaderboard no suma puntos por materias/horario configurado."); + return { updated: 0 }; }; export async function updatePoints(uid, newPoints) { - const userRef = doc(db, 'users', uid); - const userSnap = await getDoc(userRef); + const user = await getUserRecordByUid(uid); + const pointsDelta = Number(newPoints) || 0; - if (!userSnap.exists()) { + if (user) { + const updatedPoints = applyPointsDelta(user.data.points, pointsDelta); + await updateDoc(user.ref, { points: updatedPoints }); + await invalidateUserCaches(uid, { includeLeaderboard: true }); + return [{ id: user.id, ...user.data, points: updatedPoints }]; + } else { console.log(`Usuario con uid ${uid} no encontrado.`); return []; } +} - const user = userSnap.data(); - const updatedPoints = (user.points || 0) + newPoints; - - await updateDoc(userRef, { points: updatedPoints }); - if (newPoints < 0) { - await updateUserAchievementBadge(uid, "18"); +export async function updateRatingBonusPoints(uid, newBonusPoints) { + const user = await getUserRecordByUid(uid); + if (!user) { + console.log(`Usuario con uid ${uid} no encontrado.`); + return null; } + const previousBonus = Number(user.data.ratingBonusPoints) || 0; + const nextBonus = roundPoints(newBonusPoints); + const delta = roundPoints(nextBonus - previousBonus); + const updatedPoints = applyPointsDelta(user.data.points, delta); + + await updateDoc(user.ref, { + points: updatedPoints, + ratingBonusPoints: nextBonus + }); await invalidateUserCaches(uid, { includeLeaderboard: true }); - return [{ id: uid, ...user, points: updatedPoints }]; + + return { + previousBonus, + nextBonus, + delta, + points: updatedPoints + }; } export async function getExperience(options = {}) { @@ -712,7 +710,7 @@ export async function getExperience(options = {}) { }, async () => { const data = await getMaeDirectory(options); - return (data ?? []).slice().sort((a, b) => (b.points || 0) - (a.points || 0)); + return (data ?? []).slice().sort((a, b) => (Number(b.points) || 0) - (Number(a.points) || 0)); } ); } @@ -1030,3 +1028,35 @@ export async function resetAllUsersTotalTimeAndPoints({ dryRun = false, batchSiz console.log(`Reset complete. Se restablecieron totalTime y points para ${updated} usuarios.`); return { scanned: docs.length, updated }; } + +export async function resetAllUsersLeaderboardPoints({ dryRun = false, batchSize = 450 } = {}) { + const usersSnap = await getDocs(collection(firestoreDB, "users")); + if (usersSnap.empty) return { scanned: 0, updated: 0 }; + + const docs = usersSnap.docs.filter((d) => LEADERBOARD_ROLES.includes(d.data().role)); + let updated = 0; + + if (dryRun) { + return { scanned: usersSnap.size, updated: docs.length }; + } + + for (let i = 0; i < docs.length; i += batchSize) { + const chunk = docs.slice(i, i + batchSize); + const batch = writeBatch(firestoreDB); + + chunk.forEach((d) => { + batch.update(d.ref, { + points: 0, + ratingBonusPoints: 0 + }); + }); + + await batch.commit(); + updated += chunk.length; + console.log(`Reinicio de leaderboard en progreso: ${updated}/${docs.length}`); + } + + await invalidateUserCaches(null, { includeLeaderboard: true }); + console.log(`Listo. Se reiniciaron los puntos del leaderboard para ${updated} usuarios.`); + return { scanned: usersSnap.size, updated }; +} diff --git a/src/layout/AppMenu.vue b/src/layout/AppMenu.vue index 1b66fcd1..140705b2 100644 --- a/src/layout/AppMenu.vue +++ b/src/layout/AppMenu.vue @@ -27,7 +27,6 @@ onMounted(async () => { if (['admin', 'tec'].includes(role)) { const adminItems = [ - { label: 'Usuarios', icon: 'pi pi-fw pi-users', to: '/admin/usuarios' }, { label: 'Materias', icon: 'pi pi-fw pi-pencil', to: '/admin/materias' } ]; @@ -36,7 +35,7 @@ onMounted(async () => { adminItems.push( { label: 'Asesorías', icon: 'pi pi-fw pi-list', to: '/admin/asesorias' }, { label: 'Funciones', icon: 'pi pi-fw pi-key', to: '/admin/funciones' }, - { label: 'Dashboard', icon: 'pi pi-fw pi-chart-bar', to: '/admin/dashboard' }, + { label: 'Dashboard', icon: 'pi pi-fw pi-chart-bar', to: '/admin/dashboard' }, { label: 'Historial asistencia', icon: 'pi pi-fw pi-history', to: '/admin/historialAsistencia'} ); } @@ -47,9 +46,7 @@ onMounted(async () => { }); } - - - if (['publi','mae', 'coordi', 'subjectCoordi', 'admin','tec'].includes(role)) { + if (['publi', 'mae', 'coordi', 'subjectCoordi', 'admin', 'tec'].includes(role)) { model.value.push({ label: 'MAE', items: [ @@ -59,18 +56,17 @@ onMounted(async () => { { label: 'Mis evaluaciones', icon:'pi pi-fw pi-heart', to: '/misevaluaciones'}, { label: 'Asistencia grupales', icon:'pi pi-fw pi-th-large', to: '/asistenciaGrupales'}, ] - }) + }); } - if (['coordi', 'subjectCoordi', 'admin','tec'].includes(role)) { + if (['coordi', 'subjectCoordi', 'admin', 'tec'].includes(role)) { model.value.push({ label: 'Coordi', items: [ { label: 'Asistencia', icon: 'pi pi-fw pi-check-square', to: '/coordi' }, { label: 'Gestión de anuncios', icon: 'pi pi-fw pi-cog', to: '/gestionAnuncios' }, - ] - }) + }); } // model.value.push({ diff --git a/src/router/index.js b/src/router/index.js index 185c296c..c3d71391 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -109,7 +109,7 @@ const router = createRouter({ name: 'coordi', component: () => import('@/views/Coordi.vue'), meta: { - roles: ['admin', 'coordi','tec'] + roles: ['admin', 'coordi', 'tec'] } }, { @@ -117,7 +117,7 @@ const router = createRouter({ name: 'gestionAnuncios', component: () => import('@/views/GestionAnuncios.vue'), meta: { - roles: ['admin', 'coordi','tec'] + roles: ['admin', 'coordi', 'tec'] } }, { @@ -125,7 +125,7 @@ const router = createRouter({ name: 'adminasesorias', component: () => import('@/views/AdminAsesorias.vue'), meta: { - roles: ['admin','tec'] + roles: ['admin', 'tec'] } }, { @@ -133,7 +133,7 @@ const router = createRouter({ name: 'adminusuarios', component: () => import('@/views/AdminUsers.vue'), meta: { - roles: ['admin' ,'tec'] + roles: ['admin', 'tec'] } }, { @@ -141,7 +141,7 @@ const router = createRouter({ name: 'adminmaterias', component: () => import('@/views/AdminSubjects.vue'), meta: { - roles: ['admin','tec'] + roles: ['admin', 'tec'] } }, { @@ -149,7 +149,7 @@ const router = createRouter({ name: 'adminfunciones', component: () => import('@/views/AdminFunciones.vue'), meta: { - roles: ['admin','tec'] + roles: ['admin', 'tec'] } }, { @@ -157,10 +157,9 @@ const router = createRouter({ name: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { - roles: ['admin','tec'] + roles: ['admin', 'tec'] } }, - /* Adding path for historial */ { path: '/admin/historialAsistencia', name: 'asistencia', @@ -247,4 +246,4 @@ router.beforeEach((to, from, next) => { }); }); -export default router; \ No newline at end of file +export default router; diff --git a/src/utils/CoordiUtils.js b/src/utils/CoordiUtils.js index 7dedb327..a7636eee 100644 --- a/src/utils/CoordiUtils.js +++ b/src/utils/CoordiUtils.js @@ -1,3 +1,5 @@ +import { POINTS_RULES } from './PointsUtils'; + export const getSubjectColor = (area) => { switch (area) { case 'Ingeniería y Ciencias': @@ -19,9 +21,5 @@ export const getSubjectColor = (area) => { export const pointsRules = { - A: 5, // Asistencia - F: -5, // Falta - R: 3, // Retraso - J: 0, // Justificado - C: 10 // Coordi + ...POINTS_RULES.attendance }; diff --git a/src/utils/PointsUtils.js b/src/utils/PointsUtils.js new file mode 100644 index 00000000..3b9ba429 --- /dev/null +++ b/src/utils/PointsUtils.js @@ -0,0 +1,97 @@ +export const POINTS_RULES = { + advisory: { + firstTime: 10, + recurrent: 5, + antiFarmingWindowHours: 3, + antiFarmingMultiplier: 0.5 + }, + groupAdvisory: 20, + attendance: { + A: 3, + R: 1, + F: 0, + J: 0 + }, + rating: { + multiplier: 10, + max: 50, + minWithRatings: 20 + } +}; + +export const LEADERBOARD_ROLES = ['admin', 'coordi', 'mae', 'subjectCoordi', 'publi', 'tec']; + +export function roundPoints(value) { + return Math.round((Number(value) || 0) * 100) / 100; +} + +export function applyPointsDelta(currentPoints, delta) { + return Math.max(0, roundPoints((Number(currentPoints) || 0) + (Number(delta) || 0))); +} + +export function toMillis(dateValue) { + if (!dateValue) return null; + if (typeof dateValue.toMillis === 'function') return dateValue.toMillis(); + if (typeof dateValue.toDate === 'function') return dateValue.toDate().getTime(); + if (dateValue instanceof Date) return dateValue.getTime(); + if (typeof dateValue === 'number') return dateValue; + if (typeof dateValue === 'string') { + const parsed = new Date(dateValue).getTime(); + return Number.isNaN(parsed) ? null : parsed; + } + if (typeof dateValue.seconds === 'number') return dateValue.seconds * 1000; + return null; +} + +export function getSemesterRange(referenceDate = new Date()) { + const year = referenceDate.getFullYear(); + const firstHalf = referenceDate.getMonth() < 6; + + return { + start: firstHalf ? new Date(year, 0, 1) : new Date(year, 6, 1), + end: firstHalf + ? new Date(year, 5, 30, 23, 59, 59, 999) + : new Date(year, 11, 31, 23, 59, 59, 999) + }; +} + +export function getIndividualAdvisoryPoints({ isFirstTime, isWithinAntiFarmingWindow }) { + const basePoints = isFirstTime + ? POINTS_RULES.advisory.firstTime + : POINTS_RULES.advisory.recurrent; + + return roundPoints( + isWithinAntiFarmingWindow + ? basePoints * POINTS_RULES.advisory.antiFarmingMultiplier + : basePoints + ); +} + +export function getAdvisoryPointsReason({ isFirstTime, isWithinAntiFarmingWindow }) { + if (isFirstTime && isWithinAntiFarmingWindow) return 'first_time_under_3_hours'; + if (isFirstTime) return 'first_time_student'; + if (isWithinAntiFarmingWindow) return 'recurrent_under_3_hours'; + return 'recurrent_student'; +} + +export function getAttendancePoints(code) { + return POINTS_RULES.attendance[code] || 0; +} + +export function getAttendancePointsDelta(previousCode, nextCode) { + return roundPoints(getAttendancePoints(nextCode) - getAttendancePoints(previousCode)); +} + +export function calculateRatingBonus(ratings) { + const validRatings = ratings + .map(Number) + .filter(rating => Number.isFinite(rating)); + + if (validRatings.length === 0) return 0; + + const average = validRatings.reduce((sum, rating) => sum + rating, 0) / validRatings.length; + const rawBonus = average * POINTS_RULES.rating.multiplier; + const cappedBonus = Math.min(POINTS_RULES.rating.max, rawBonus); + + return roundPoints(Math.max(POINTS_RULES.rating.minWithRatings, cappedBonus)); +} diff --git a/src/views/AdminFunciones.vue b/src/views/AdminFunciones.vue index a553bddb..d3574060 100644 --- a/src/views/AdminFunciones.vue +++ b/src/views/AdminFunciones.vue @@ -10,12 +10,13 @@ import { clearAllUsersWeekSchedule, checkAndUpdateUserRole, updateUserToMae, - saveScheduleSubjectsExperience, updatePoints, clearUsersData, - resetAllUsersTotalTimeAndPoints + resetAllUsersTotalTimeAndPoints, + resetAllUsersLeaderboardPoints } from '../firebase/db/users'; -import { deleteOldAsesorias} from '../firebase/db/asesorias' +import { deleteOldAsesorias, borrarTodasEvaluaciones } from '../firebase/db/asesorias.js' +import { revelarEvaluaciones, borrarEvaluaciones } from '../firebase/db/settings' const toast = useToast(); const confirm = useConfirm(); @@ -88,6 +89,75 @@ const restartMaes = () => { }); }; +const confirmRevealEvaluaciones = () => { + confirm.require({ + message: '¿Estás seguro de revelar todas las evaluaciones pendientes a los MAEs? Las evaluaciones creadas después de este momento permanecerán ocultas hasta la próxima revelación.', + header: 'Revelar evaluaciones', + icon: 'pi pi-eye', + acceptLabel: 'Sí, revelar', + rejectLabel: 'Cancelar', + acceptClass: 'p-button-success', + accept: async () => { + try { + await revelarEvaluaciones(); + toast.add({ severity: 'success', summary: 'Éxito', detail: 'Las evaluaciones han sido reveladas a los MAEs.', life: 3000 }); + } catch (error) { + console.error("Error al revelar evaluaciones:", error); + toast.add({ severity: 'error', summary: 'Error', detail: 'Ocurrió un error al revelar las evaluaciones.', life: 3000 }); + } + }, + reject: () => { + toast.add({ severity: 'info', summary: 'Cancelado', detail: 'No se han realizado cambios.', life: 3000 }); + } + }); +}; + +const confirmDeleteAllEvaluaciones = () => { + confirm.require({ + message: 'Vas a borrar TODAS las evaluaciones (rating y comentario) de la base de datos. Las asesorías en sí se mantienen. Esta acción no se puede deshacer. ¿Continuar?', + header: 'Borrar todas las evaluaciones', + icon: 'pi pi-exclamation-triangle', + acceptLabel: 'Sí, borrar evaluaciones', + rejectLabel: 'Cancelar', + acceptClass: 'p-button-danger', + accept: async () => { + let dbRes = { cleared: 0, failed: 0, sampleErrors: [] }; + let dbError = null; + try { + dbRes = await borrarTodasEvaluaciones(); + } catch (error) { + dbError = error; + console.error("Error al borrar evaluaciones en DB:", error); + } + + // Seguro extra: marcar timestamp para que la UI oculte evaluaciones + // previas aunque algunas hayan fallado por reglas. + try { + await borrarEvaluaciones(); + } catch (error) { + console.error("Error al marcar evaluaciones como borradas:", error); + } + + if (dbError) { + toast.add({ severity: 'error', summary: 'Error', detail: `Error: ${dbError.message || dbError}`, life: 7000 }); + } else if (dbRes.failed > 0) { + const firstErr = dbRes.sampleErrors?.[0]?.reason || 'desconocido'; + toast.add({ + severity: 'warn', + summary: 'Parcial', + detail: `Borradas en DB: ${dbRes.cleared}. Fallidas: ${dbRes.failed} (${firstErr}). El UI se ocultó igualmente.`, + life: 8000 + }); + } else { + toast.add({ severity: 'success', summary: 'Éxito', detail: `Se borraron ${dbRes.cleared} evaluaciones de la base de datos.`, life: 4000 }); + } + }, + reject: () => { + toast.add({ severity: 'info', summary: 'Cancelado', detail: 'No se han realizado cambios.', life: 3000 }); + } + }); +}; + const confirmDeleteAsesorias = () => { confirm.require({ message: '¿Estás seguro eliminar todas las asesorías deñ año pasado?', @@ -134,6 +204,29 @@ const confirmResetTimeAndPoints = () => { }); }; +const confirmResetLeaderboardPoints = () => { + confirm.require({ + message: '¿Estás seguro de reiniciar SOLO los puntos del leaderboard a 0? Las horas, asesorías y evaluaciones se conservarán.', + header: 'Reiniciar puntos del leaderboard', + icon: 'pi pi-exclamation-triangle', + acceptLabel: 'Sí, reiniciar puntos', + rejectLabel: 'Cancelar', + acceptClass: 'p-button-danger', + accept: async () => { + try { + const res = await resetAllUsersLeaderboardPoints({ dryRun: false }); + toast.add({ severity: 'success', summary: 'Éxito', detail: `Puntos del leaderboard reiniciados: ${res.updated} usuarios.`, life: 4000 }); + } catch (error) { + console.error("Error al reiniciar puntos del leaderboard:", error); + toast.add({ severity: 'error', summary: 'Error', detail: 'Ocurrió un error al reiniciar los puntos del leaderboard.', life: 4000 }); + } + }, + reject: () => { + toast.add({ severity: 'info', summary: 'Cancelado', detail: 'No se han realizado cambios.', life: 3000 }); + } + }); +}; + const openUploadDialog = () => { displayUploadDialog.value = true; @@ -221,29 +314,6 @@ const handleAddUser = async () => { }; -const confirmSaveExperience = () => { - confirm.require({ - message: '¿Estás seguro de que deseas guardar la experiencia de horario y materias para los usuarios?', - header: 'Confirmación de guardar experiencia', - icon: 'pi pi-exclamation-circle', - acceptLabel: 'Sí, guardar', - rejectLabel: 'Cancelar', - acceptClass: 'p-button-success', - accept: async () => { - try { - await saveScheduleSubjectsExperience(); - toast.add({ severity: 'success', summary: 'Éxito', detail: 'Experiencia guardada exitosamente.', life: 3000 }); - } catch (error) { - console.error("Error al guardar la experiencia:", error); - toast.add({ severity: 'error', summary: 'Error', detail: 'Ocurrió un error al intentar guardar la experiencia.', life: 3000 }); - } - }, - reject: () => { - toast.add({ severity: 'info', summary: 'Cancelado', detail: 'No se han realizado cambios.', life: 3000 }); - } - }); -}; - const userId = ref(''); const newPoints = ref(null) @@ -316,15 +386,6 @@ const handleUpdatePoints = async () => { /> -
-
-
-
+ +
+
+ +
+
-
+ +
+
diff --git a/src/views/Leaderboard.vue b/src/views/Leaderboard.vue index d89bcfe8..5d7c23da 100644 --- a/src/views/Leaderboard.vue +++ b/src/views/Leaderboard.vue @@ -1,17 +1,34 @@ @@ -943,6 +986,14 @@ const guardarEvaluacion = async () => { Horarios mentoring icon + @@ -988,21 +1039,51 @@ const guardarEvaluacion = async () => {

Sin asesorías para evaluar

-