fix: resolve sync infinite loop and unify frequency logic across portal and manager
This commit is contained in:
parent
488051673a
commit
ef8b7c51a8
|
|
@ -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.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
12
MEMORY.md
12
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] **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] **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] **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.
|
- [ ] 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 (06/05/2026)
|
||||||
## 📅 Histórico Anterior (22/04/2026)
|
|
||||||
|
|
||||||
- [x] Correção do "Bug da Tela Preta" na câmera ao alternar para câmera traseira no celular.
|
- [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).
|
- [x] Unificação do servidor de produção: Dockerfile agora utiliza `server.selfhosted.js` (Manager e Portal).
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const App = () => {
|
||||||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'saved' | 'error' | 'conflict'>('idle');
|
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'saved' | 'error' | 'conflict'>('idle');
|
||||||
const [isCloudEnabled, setIsCloudEnabled] = useState(false);
|
const [isCloudEnabled, setIsCloudEnabled] = useState(false);
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const isUpdatingFromSaveRef = useRef<boolean>(false);
|
||||||
|
|
||||||
// 0. Load from IndexedDB on mount
|
// 0. Load from IndexedDB on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -89,6 +90,11 @@ const App = () => {
|
||||||
// Immediate Local Save
|
// Immediate Local Save
|
||||||
dbService.saveData(data);
|
dbService.saveData(data);
|
||||||
|
|
||||||
|
if (isUpdatingFromSaveRef.current) {
|
||||||
|
isUpdatingFromSaveRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Debounced Cloud Save
|
// Debounced Cloud Save
|
||||||
if (isCloudEnabled) {
|
if (isCloudEnabled) {
|
||||||
setSyncStatus('syncing');
|
setSyncStatus('syncing');
|
||||||
|
|
@ -98,6 +104,7 @@ const App = () => {
|
||||||
try {
|
try {
|
||||||
const result = await dbService.saveToCloud(data);
|
const result = await dbService.saveToCloud(data);
|
||||||
if (result.success && result.lastUpdated) {
|
if (result.success && result.lastUpdated) {
|
||||||
|
isUpdatingFromSaveRef.current = true;
|
||||||
// Sincroniza o timestamp local com o do servidor para evitar conflitos no polling
|
// Sincroniza o timestamp local com o do servidor para evitar conflitos no polling
|
||||||
setData(prev => ({ ...prev, lastUpdated: result.lastUpdated }));
|
setData(prev => ({ ...prev, lastUpdated: result.lastUpdated }));
|
||||||
setSyncStatus('saved');
|
setSyncStatus('saved');
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ app.get('/api/portal/notas', authMiddleware, async (req, res) => {
|
||||||
app.get('/api/portal/frequencia', authMiddleware, async (req, res) => {
|
app.get('/api/portal/frequencia', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows: dbAttendance } = await pool.query(
|
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]
|
[req.user.studentId]
|
||||||
);
|
);
|
||||||
res.json({ attendance: dbAttendance });
|
res.json({ attendance: dbAttendance });
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,35 @@ export default function Dashboard() {
|
||||||
const overduePayments = data?.payments.filter(p => p.status === 'overdue') || [];
|
const overduePayments = data?.payments.filter(p => p.status === 'overdue') || [];
|
||||||
const totalPending = pendingPayments.reduce((s, p) => s + (p.amount - (p.discount || 0)), 0);
|
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 totalAttendance = data?.attendance.length || 0;
|
||||||
const totalCourseLessons = data?.lessons.length || 0;
|
const frequencyPercent = validLessonsCount > 0 ? Math.round((presencesCount / validLessonsCount) * 100) : 0;
|
||||||
const presences = data?.attendance.filter(a => a.type === 'presence').length || 0;
|
|
||||||
const frequencyPercent = totalCourseLessons > 0 ? Math.round((presences / totalCourseLessons) * 100) : 0;
|
|
||||||
|
|
||||||
const nextDue = pendingPayments
|
const nextDue = pendingPayments
|
||||||
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())[0];
|
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())[0];
|
||||||
|
|
|
||||||
|
|
@ -145,20 +145,14 @@ export default function Frequencia() {
|
||||||
const atts = attendance.filter(a => {
|
const atts = attendance.filter(a => {
|
||||||
if (!a.date || typeof a.date !== 'string') return false;
|
if (!a.date || typeof a.date !== 'string') return false;
|
||||||
|
|
||||||
// 1. Exact Match (Best case)
|
// 1. Exact Match (Including Manager DB fallback format)
|
||||||
if (a.date === lessonFullISO) return true;
|
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00` || a.date === lessonFullISO) return true;
|
||||||
|
|
||||||
const attMs = new Date(a.date).getTime();
|
const attMs = new Date(a.date).getTime();
|
||||||
|
const presenceStartWindow = lessonStartMs - 30 * 60000;
|
||||||
|
|
||||||
// 2. Presence Match (Biometrics)
|
// 2. Window Match (Matches Manager Logic: 30 mins before until end of lesson)
|
||||||
// Allow any presence within the lesson duration (+ buffer)
|
return attMs >= presenceStartWindow && attMs <= lessonEndMs;
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now);
|
const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue