fix: resolve sync infinite loop and unify frequency logic across portal and manager

This commit is contained in:
Sidney 2026-05-06 08:35:22 -03:00
parent 488051673a
commit ef8b7c51a8
6 changed files with 54 additions and 17 deletions

View File

@ -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.

View File

@ -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).

View File

@ -39,6 +39,7 @@ const App = () => {
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'saved' | 'error' | 'conflict'>('idle');
const [isCloudEnabled, setIsCloudEnabled] = useState(false);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isUpdatingFromSaveRef = useRef<boolean>(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');

View File

@ -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 });

View File

@ -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];

View File

@ -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);