docs: update memory and gemini with frequency parity rules; fix: absolute logic parity for portal frequency matching

This commit is contained in:
Sidney 2026-05-06 21:08:58 -03:00
parent bc9f012129
commit 8b05fd95f0
6 changed files with 130 additions and 57 deletions

View File

@ -54,4 +54,5 @@
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.
26. **SQL-First Architecture Pattern**: New features or module refactors SHOULD prioritize 100% SQL persistence (using `notas_boletim` as a template). The legacy JSON `school_data` should be treated as secondary or deprecated for these modules, ensuring real-time consistency between Manager and Portal.
27. **Safe Rendering Principle (Portal)**: All property accesses on dynamic objects (like `data` or `student`) MUST use optional chaining (`?.`). Avoid using undeclared variables copied from other modules; always perform a `tsc` check in the portal before committing to prevent "White Screen of Death" crashes.
28. **Frequency Parity Rule**: The Portal MUST mirror the Manager's attendance matching logic exactly, including time windows (30m before) and end-of-day fallbacks (23:59), to ensure data consistency between Admin and Student views.

View File

@ -34,19 +34,14 @@
- [x] **Blindagem de Conversão ISO:** Resolvida falha crítica de `RangeError: Invalid time value` em todo o Portal. Agora o sistema ignora datas corrompidas ou inválidas em vez de quebrar a interface inteira.
- [x] **Resolução de ReferenceError:** Identificada e corrigida a causa raiz da tela preta persistente (variáveis não declaradas após refatoração da pauta).
- [x] **Auditoria TypeScript:** Realizada varredura total com `tsc` no Portal, corrigindo todos os erros de tipagem remanescentes em Notas e Avaliações para garantir estabilidade absoluta.
- [ ] Próximo Passo: Iniciar a migração do módulo Financeiro para 100% SQL seguindo o padrão do Boletim.
- [x] **Paridade Lógica Absoluta (Frequência):** A lógica de matching de frequência do Portal (`Frequencia.tsx` e `Dashboard.tsx`) agora é um clone 100% idêntico ao do Manager (`AttendanceQuery.tsx`).
- [x] **Fix Janela de Matching (Portal):** Corrigido bug onde aulas sem `endTime` fechavam a janela de presença prematuramente (fallback alterado de `00:00:00` para `23:59:00`).
- [x] **Resolução de Justificativas Invisíveis:** Justificativas enviadas agora são corretamente mapeadas às aulas no Portal através da sincronização de janelas de tempo e IDs.
## 📋 Próximos Passos
- [ ] Iniciar a migração do módulo Financeiro para 100% SQL seguindo o padrão do Boletim.
## 📅 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).
- [x] Correção dos Cards de Monitoramento (PostgreSQL/MinIO) com tratativa de erro independente.
- [x] Vacina de cache global: Injeção de `normalizePhotoUrl` nos módulos de Boletim, Turmas e Frequência.
- [x] Estabilização do Build ARM64: Injeção de `max_old_space_size=4096` nos Dockerfiles para evitar crashes do Vite no Github Actions.
- [x] Correção de Rota Express 5: Migração de curingas `*` para Regex para evitar falhas de inicialização no servidor.
- [x] Correção do Crash 404 no Portal: Injeção da pasta `src/services` no container de produção para permitir o import do `storage.js`.
- [x] Correção das Imagens de Prova: Normalização das URLs nas questões de avaliações (Portal e Manager).
- [x] Estabilização de CI/CD: Transição para `runs-on: self-hosted` (ARM64 nativo) eliminando lentidão e crashes do QEMU.
- [x] Correção do Sino de Notificações: Botões sempre visíveis, suporte a anexo via chave `arquivo` e exibição do **Motivo da Falta** direto na lista do sino.
- [x] **Segurança Financeira:** Implementada trava de segurança (`isCreating`) contra cliques múltiplos em formulários financeiros, resolvendo a duplicidade de cobranças no Asaas.

50
manager/fix_db_tz.cjs Normal file
View File

@ -0,0 +1,50 @@
const { Pool } = require('pg');
const pool = new Pool({
user: 'postgres',
host: 'localhost',
database: 'edumanager',
password: 'admin',
port: 5432,
});
async function run() {
try {
// 1. Fix frequencias table
const { rows } = await pool.query("SELECT id, data FROM frequencias WHERE justificativa IS NOT NULL");
let fixedFrequencias = 0;
for (const row of rows) {
// row.data might be a Date object or string depending on pg.
const d = new Date(row.data);
// If the hour is wrong, maybe we can just identify if it's not a round number or something?
// Wait, we can't easily tell if it's wrong just by looking at it, because we don't know the original lesson time.
console.log('Frequencia:', row.id, d.toISOString(), d.toLocaleTimeString());
}
// 2. Fix school_data JSON
const { rows: sdRows } = await pool.query("SELECT data FROM school_data WHERE id = 1");
if (sdRows.length > 0) {
const data = sdRows[0].data;
let fixed = 0;
if (data.attendance) {
data.attendance.forEach(a => {
if (a.date && typeof a.date === 'string' && a.date.endsWith('Z')) {
const d = new Date(a.date);
const localIso = new Date(d.getTime() - (d.getTimezoneOffset() * 60000)).toISOString().split('.')[0];
a.date = localIso;
fixed++;
console.log('Fixed JSON date to:', localIso);
}
});
}
if (fixed > 0) {
await pool.query("UPDATE school_data SET data = $1 WHERE id = 1", [data]);
console.log(`Corrigidos ${fixed} registros no JSON school_data!`);
}
}
} catch(e) {
console.error(e);
} finally {
pool.end();
}
}
run();

29
manager/fix_tz.js Normal file
View File

@ -0,0 +1,29 @@
import fs from 'fs';
import path from 'path';
const file = path.join(process.cwd(), 'school_data.json');
if (fs.existsSync(file)) {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
let fixed = 0;
if (data.attendance) {
data.attendance.forEach(a => {
// Se tiver .000Z ou terminar com Z
if (a.date && typeof a.date === 'string' && a.date.endsWith('Z')) {
// Ajuste brusco: remove o .000Z e ajusta o horário subtraindo 3h (fuso BRT)
// Isso é apenas para limpar dados de teste que ficaram bugados hoje.
try {
const d = new Date(a.date);
const localIso = new Date(d.getTime() - (d.getTimezoneOffset() * 60000)).toISOString().split('.')[0];
a.date = localIso;
fixed++;
} catch(e) {}
}
});
}
if (fixed > 0) {
fs.writeFileSync(file, JSON.stringify(data, null, 2));
console.log(`Corrigidos ${fixed} registros com timezone bugado no JSON!`);
} else {
console.log('Nenhum registro bugado encontrado no JSON.');
}
}

View File

@ -83,21 +83,20 @@ export default function Dashboard() {
if (lesson.status === 'cancelled') return;
validLessonsCount++;
const lessonMs = parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00');
const lessonFullISO = !isNaN(lessonMs) ? new Date(lessonMs).toISOString() : '';
const lessonStartMs = lessonMs;
const lessonEndMs = parseLessonDateTime(lesson.date, lesson.endTime || '00:00:00', lesson.endTime ? 0 : 60);
// Construir janela de tempo EXATAMENTE como o Manager faz
const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000);
const atts = data.attendance.filter(a => {
const att = data.attendance.find(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;
if ((a as any).lessonId === lesson.id) return true;
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
const recordTime = new Date(a.date);
return recordTime >= presenceStartWindow && recordTime <= lessonEnd;
});
const isPresent = atts.some(a => a.type === 'presence' || a.verified === true);
const isPresent = att && (att.type === 'presence' || (!att.type && !(att as any).isVirtual) || att.verified === true);
if (isPresent) presencesCount++;
});
}

View File

@ -143,44 +143,46 @@ export default function Frequencia() {
))
);
// Merge and Categorize
// Merge and Categorize — Clone EXATO do Manager (AttendanceQuery.tsx)
const processedItems = deduplicatedLessons.map(lesson => {
const lessonMs = parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00');
const lessonFullISO = !isNaN(lessonMs) ? new Date(lessonMs).toISOString() : '';
const lessonStartMs = lessonMs;
const lessonEndMs = parseLessonDateTime(lesson.date, lesson.endTime || '00:00:00', lesson.endTime ? 0 : 60);
// Construir janela de tempo EXATAMENTE como o Manager faz (com Date objects)
const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000); // 30 mins before
// Pega todos os registros válidos na janela de tempo
const allAtts = attendance.filter(a => {
// Buscar registro com a MESMA lógica do Manager (find, não filter)
let record = attendance.find(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;
// 1. Match por lessonId (se existir)
if ((a as any).lessonId === lesson.id) return true;
// 2. Match exato de string (formato do JSON/sync)
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
// 3. Match por janela de tempo (30 min antes até fim da aula)
const recordTime = new Date(a.date);
return recordTime >= presenceStartWindow && recordTime <= lessonEnd;
});
// Prioridade de seleção para o status da aula:
// 1. Presença
// 2. Justificativa
// 3. Qualquer outro (Falta/Aguardando)
let bestRecord = allAtts.find(a => a.type === 'presence' || (!a.type && !a.isVirtual) || a.verified === true);
if (!bestRecord) {
bestRecord = allAtts.find(a => !!a.justification);
}
if (!bestRecord && allAtts.length > 0) {
bestRecord = allAtts[0];
// Se não encontrou registro real, verificar se precisa de justificativa associada
// (pode existir um registro de justificativa com data ligeiramente diferente)
if (!record) {
record = attendance.find(a => {
if (!a.date || typeof a.date !== 'string' || !a.justification) return false;
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
const recordTime = new Date(a.date);
return recordTime >= presenceStartWindow && recordTime <= lessonEnd;
}) || undefined;
}
const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now);
return {
lesson,
attendances: bestRecord ? [bestRecord] : [], // Mantém 1 registro para alinhar com Manager
attendances: record ? [record] : [],
isInProgress,
isCompleted
};
});
// Stats calculation (UNIFIED with list logic)
// Stats calculation — Clone EXATO do Manager (AttendanceQuery.tsx linhas 548-559)
let presences = 0;
let absences = 0;
let justified = 0;
@ -196,7 +198,7 @@ export default function Frequencia() {
if (record.type === 'absence') {
if (record.justificationAccepted) justified++;
else absences++;
} else if (record.type === 'presence' || (!record.type && !record.isVirtual) || record.verified) {
} else if (record.type === 'presence' || (!record.type && !(record as any).isVirtual)) {
presences++;
}
} else if (now > lessonEnd) {
@ -230,21 +232,18 @@ export default function Frequencia() {
// Check window (uses new 24h before/after logic)
if (!isLessonWithinJustificationWindow(l, now)) return false;
const lMs = parseLessonDateTime(l.date, l.startTime || '00:00:00');
const lessonFullISO = !isNaN(lMs) ? new Date(lMs).toISOString() : '';
const lessonStartMs = lMs;
const lessonEndMs = parseLessonDateTime(l.date, l.endTime || '00:00:00', l.endTime ? 0 : 60);
// Construir janela como o Manager
const lessonStart = new Date(l.date + 'T' + (l.startTime || '00:00') + ':00');
const lessonEnd = new Date(l.date + 'T' + (l.endTime || '23:59') + ':00');
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000);
// Find if THIS SPECIFIC lesson has attendance/justification
const att = attendance.find(a => {
if (!a.date || typeof a.date !== 'string') return false;
const attMs = new Date(a.date).getTime();
// Strict match by ISO or within duration for presence
if (a.date === lessonFullISO) return true;
if (a.type === 'presence' && attMs >= (lessonStartMs - 10 * 60000) && attMs <= (lessonEndMs + 5 * 60000)) return true;
return false;
if ((a as any).lessonId === l.id) return true;
if (a.date === `${l.date}T${l.startTime || '00:00'}:00`) return true;
const recordTime = new Date(a.date);
return recordTime >= presenceStartWindow && recordTime <= lessonEnd;
});
if (att) {