docs: update memory and gemini with frequency parity rules; fix: absolute logic parity for portal frequency matching
This commit is contained in:
parent
bc9f012129
commit
8b05fd95f0
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
15
MEMORY.md
15
MEMORY.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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++;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue