From ef8b7c51a8271b8cecb2e4435acff7e5450412c3 Mon Sep 17 00:00:00 2001 From: Sidney Date: Wed, 6 May 2026 08:35:22 -0300 Subject: [PATCH] fix: resolve sync infinite loop and unify frequency logic across portal and manager --- GEMINI.md | 3 +++ MEMORY.md | 12 ++++++++++-- manager/index.tsx | 7 +++++++ portal/server.selfhosted.js | 2 +- portal/src/pages/Dashboard.tsx | 31 ++++++++++++++++++++++++++++--- portal/src/pages/Frequencia.tsx | 16 +++++----------- 6 files changed, 54 insertions(+), 17 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index b52b5d9..b52e706 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -49,4 +49,7 @@ 20. **Exam Duplication**: The system supports cloning evaluations. Duplicated exams MUST default to `draft` status and include `(Cópia)` in the title to allow verification before deployment to new classes. 21. **Automatic Attendance Closure**: The system implements an automatic closure routine (`processAutoAbsences`) that generates physical "Absence" records in the PostgreSQL database for any past lesson where a student has no presence or justification. This routine is triggered during data save operations to ensure retroactive consistency between lessons and records. 22. **Unified Notification System (SQL)**: The system uses the `notificacoes` PostgreSQL table as the single source of truth for both Portal and Manager. JSON-based notifications in `school_data` are deprecated. New features (exams, justifications) MUST use SQL INSERT/SELECT and implement **Intelligent Polling (30s)** in the UI to ensure synchronization. +23. **Soft Delete Policy (Lixeira)**: Evaluations (exams/activities) MUST NOT be hard-deleted to preserve grade history. Use the `isDeleted` flag to hide them from students and active management views, keeping them accessible in the "Lixeira" for restoration and ensuring reference integrity in the Report Card. +24. **Grading Arithmetic Logic**: All grade averages (Period, Subject, and General) MUST be calculated as arithmetic means (Average of Means). Summing grades for a final period result is prohibited to ensure consistency between Portal and Manager. +25. **SQL Date Handling**: When fetching timestamps from PostgreSQL to be matched with JSON dates/ISO strings in the frontend, ALWAYS use `TO_CHAR(column, 'YYYY-MM-DD"T"HH24:MI:SS')` in the SQL query to prevent Node.js timezone shifts from corrupting string-based matching logic. diff --git a/MEMORY.md b/MEMORY.md index 4ad7d53..c40e29c 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -17,11 +17,19 @@ - [x] **Alertas de Avaliações:** Implementado disparo automático de notificações SQL e WhatsApp (via Evolution API) para turmas inteiras ao publicar exames/atividades. - [x] **Justificativas Relacionais:** Notificações de justificativas de falta enviadas pelo Portal agora são salvas diretamente no PostgreSQL (aluno_id = 'admin'). - [x] **Intelligent Polling Admin:** O Admin Bell agora utiliza polling de 30s para sincronização em tempo real com o banco SQL, garantindo que novos alertas apareçam instantaneamente. +- [x] **Lixeira de Avaliações (Soft Delete):** Implementada aba de "Lixeira" no Manager que oculta provas sem deletar dados, preservando as notas no Boletim e no Portal. +- [x] **Unificação da Média Aritmética:** Refatorados `ReportCard.tsx` (Manager) e `Notas.tsx` (Portal) para calcular médias aritméticas reais (Média das Médias) em todos os níveis. +- [x] **Sincronização de Notas Órfãs:** Garantido que notas de provas deletadas/arquivadas permaneçam visíveis com seus respectivos títulos no Manager e Portal. +- [x] **Correção de Polling e Conflitos:** Ajustado timestamp `lastUpdated` para evitar sobrescritas de dados durante a sincronização em segundo plano. +- [x] **Git Push Realizado:** Todas as alterações de arquitetura de notas e exclusão lógica foram versionadas e enviadas ao repositório remoto. +- [x] **Correção do Sync Status:** Resolvido loop infinito no `index.tsx` que travava o status em "syncing" ao sincronizar o `lastUpdated` com o servidor. +- [x] **Blindagem de Fuso Horário (Postgres):** Rota de frequência do portal atualizada para usar `TO_CHAR` no SQL, eliminando deslocamentos de horas causados pela conversão UTC automática do driver. +- [x] **Unificação de Janela de Presença:** Portal e Manager agora utilizam a mesma janela de 30 minutos de tolerância para correlacionar presenças e faltas às aulas. +- [x] **Sincronia de Estatísticas (Portal):** O cálculo de porcentagem no Dashboard do Portal agora usa o mesmo motor lógico da página de Frequência, garantindo números idênticos. - [ ] Próximo Passo: Expandir a migração relacional para o módulo de "Minhas Aulas" no Portal para manter a consistência arquitetural. - -## 📅 Histórico Anterior (22/04/2026) +## 📅 Histórico Anterior (06/05/2026) - [x] Correção do "Bug da Tela Preta" na câmera ao alternar para câmera traseira no celular. - [x] Unificação do servidor de produção: Dockerfile agora utiliza `server.selfhosted.js` (Manager e Portal). diff --git a/manager/index.tsx b/manager/index.tsx index f1c3134..4a1f6d9 100644 --- a/manager/index.tsx +++ b/manager/index.tsx @@ -39,6 +39,7 @@ const App = () => { const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'saved' | 'error' | 'conflict'>('idle'); const [isCloudEnabled, setIsCloudEnabled] = useState(false); const saveTimeoutRef = useRef | null>(null); + const isUpdatingFromSaveRef = useRef(false); // 0. Load from IndexedDB on mount useEffect(() => { @@ -89,6 +90,11 @@ const App = () => { // Immediate Local Save dbService.saveData(data); + if (isUpdatingFromSaveRef.current) { + isUpdatingFromSaveRef.current = false; + return; + } + // Debounced Cloud Save if (isCloudEnabled) { setSyncStatus('syncing'); @@ -98,6 +104,7 @@ const App = () => { try { const result = await dbService.saveToCloud(data); if (result.success && result.lastUpdated) { + isUpdatingFromSaveRef.current = true; // Sincroniza o timestamp local com o do servidor para evitar conflitos no polling setData(prev => ({ ...prev, lastUpdated: result.lastUpdated })); setSyncStatus('saved'); diff --git a/portal/server.selfhosted.js b/portal/server.selfhosted.js index cdd66a5..9597675 100644 --- a/portal/server.selfhosted.js +++ b/portal/server.selfhosted.js @@ -294,7 +294,7 @@ app.get('/api/portal/notas', authMiddleware, async (req, res) => { app.get('/api/portal/frequencia', authMiddleware, async (req, res) => { try { const { rows: dbAttendance } = await pool.query( - 'SELECT id, aluno_id as "studentId", turma_id as "classId", data as "date", foto as "photo", verificado as "verified", tipo as "type", justificativa as "justification", justificativa_aceita as "justificationAccepted" FROM frequencias WHERE aluno_id = $1', + 'SELECT id, aluno_id as "studentId", turma_id as "classId", TO_CHAR(data, \'YYYY-MM-DD"T"HH24:MI:SS\') as "date", foto as "photo", verificado as "verified", tipo as "type", justificativa as "justification", justificativa_aceita as "justificationAccepted" FROM frequencias WHERE aluno_id = $1', [req.user.studentId] ); res.json({ attendance: dbAttendance }); diff --git a/portal/src/pages/Dashboard.tsx b/portal/src/pages/Dashboard.tsx index cb746f0..feed562 100644 --- a/portal/src/pages/Dashboard.tsx +++ b/portal/src/pages/Dashboard.tsx @@ -68,10 +68,35 @@ export default function Dashboard() { const overduePayments = data?.payments.filter(p => p.status === 'overdue') || []; const totalPending = pendingPayments.reduce((s, p) => s + (p.amount - (p.discount || 0)), 0); + // Synchronized Frequency Calculation (Matches Frequencia.tsx & Manager) + let presencesCount = 0; + let validLessonsCount = 0; + + if (data?.lessons && data?.attendance) { + const nowLocal = new Date(); + data.lessons.forEach(lesson => { + if (lesson.status === 'cancelled') return; + validLessonsCount++; + + const lessonFullISO = new Date(parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00')).toISOString(); + const lessonStartMs = parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00'); + const lessonEndMs = parseLessonDateTime(lesson.date, lesson.endTime || '00:00:00', lesson.endTime ? 0 : 60); + + const atts = data.attendance.filter(a => { + if (!a.date || typeof a.date !== 'string') return false; + if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00` || a.date === lessonFullISO) return true; + + const attMs = new Date(a.date).getTime(); + const presenceStartWindow = lessonStartMs - 30 * 60000; + return attMs >= presenceStartWindow && attMs <= lessonEndMs; + }); + + const isPresent = atts.some(a => a.type === 'presence' || a.verified === true); + if (isPresent) presencesCount++; + }); + } const totalAttendance = data?.attendance.length || 0; - const totalCourseLessons = data?.lessons.length || 0; - const presences = data?.attendance.filter(a => a.type === 'presence').length || 0; - const frequencyPercent = totalCourseLessons > 0 ? Math.round((presences / totalCourseLessons) * 100) : 0; + const frequencyPercent = validLessonsCount > 0 ? Math.round((presencesCount / validLessonsCount) * 100) : 0; const nextDue = pendingPayments .sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())[0]; diff --git a/portal/src/pages/Frequencia.tsx b/portal/src/pages/Frequencia.tsx index e3a280f..7283736 100644 --- a/portal/src/pages/Frequencia.tsx +++ b/portal/src/pages/Frequencia.tsx @@ -145,20 +145,14 @@ export default function Frequencia() { const atts = attendance.filter(a => { if (!a.date || typeof a.date !== 'string') return false; - // 1. Exact Match (Best case) - if (a.date === lessonFullISO) return true; + // 1. Exact Match (Including Manager DB fallback format) + if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00` || a.date === lessonFullISO) return true; const attMs = new Date(a.date).getTime(); + const presenceStartWindow = lessonStartMs - 30 * 60000; - // 2. Presence Match (Biometrics) - // Allow any presence within the lesson duration (+ buffer) - if (a.type === 'presence') { - return attMs >= (lessonStartMs - 10 * 60000) && attMs <= (lessonEndMs + 5 * 60000); - } - - // 3. Justification Proximity Match (Strict 10 mins from start) - const diffMinutes = Math.abs(attMs - lessonStartMs) / (1000 * 60); - return diffMinutes <= 10; + // 2. Window Match (Matches Manager Logic: 30 mins before until end of lesson) + return attMs >= presenceStartWindow && attMs <= lessonEndMs; }); const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now);