Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

⏰ Marcador automático

Registra marcas de asistencia de forma programada, sin intervención manual. Corre solo en Val.town.


✨ Qué hace

  • 🔐 Se autentica contra un proveedor de identidad (OIDC) y obtiene un token fresco en cada corrida.
  • 🕒 Envía la marca al endpoint del sistema a la hora programada.
  • 🎲 Hora humana, no robótica: en vez de marcar al segundo exacto, espera un tiempo aleatorio (jitter) antes de marcar, para que no caiga siempre igual.
  • ♻️ Idempotente: antes de marcar consulta el estado del día. Si esa marca ya existe, no la repite (evita duplicados y errores de secuencia).
  • 📧 Alerta por correo: si una corrida falla, envía un correo con el error.
  • 🔒 Sin secretos en el código: todas las credenciales viven en Environment Variables, nunca en el código.

🧩 Cómo funciona

Cron dispara ─► espera aleatoria (jitter) ─► login OIDC (token fresco)
     │                                              │
     └───────────────────────────────────►  ¿ya marcó hoy? ─sí─► fin
                                                    │no
                                                    ▼
                                            POST marca  ✅

El token se pide después de la espera, porque los tokens duran pocos minutos. Así siempre es válido al momento de marcar.


🗂️ Estructura

Una carpeta por día laboral, y dentro las cuatro marcas del día. Cada archivo es un cron independiente (Val.town permite un solo horario por archivo).

Lunes/      → 4 marcas
Martes/     → 4 marcas
Miercoles/  → 4 marcas
Jueves/     → 4 marcas
Viernes/    → 4 marcas

Toda la lógica (login, chequeo de ausencia, idempotencia, POST y alerta por correo) vive en lib/marcar.ts. Cada uno de los 20 archivos es solo un cron que llama correrMarca(ACTION, FIELD) con su tipo de marca; lo único propio de cada archivo es esa línea y su expresión cron. El horario varía por día de la semana; sumado al jitter de segundos, la marca nunca cae exactamente a la misma hora.

lib/marcar.ts    → lógica compartida por las 20 marcas
lib/ausencias.ts → chequeo de vacaciones/permisos

⚙️ Configuración (Environment Variables)

Se definen una sola vez y las comparten todos los archivos. Los valores son privados; aquí solo se documentan los nombres.

VariableDescripción
API_BASEBase URL del sistema (sin / final)
KC_URLServidor del proveedor de identidad
KC_REALMRealm
KC_CLIENTClient ID
KC_USERUsuario
KC_PASSContraseña
EMPLOYEE_IDIdentificador interno
FACE_DESCRIPTORDescriptor facial: array JSON de 128 números
SUPERVISOR_TOKENToken que protege Supervisor/endpoint.ts (ver § Supervisor)
HC_PING_URLDead man's switch de healthchecks.io (opcional)

FACE_DESCRIPTOR: desde el 2026-09-04 el endpoint de marca exige verificación facial y rechaza el POST con "Esta marca requiere verificación facial" si no lo mandas. Es un dato biométrico, así que vive en Environment Variables y no en el código (este val es público). Para cambiarlo: abre el marcador en el navegador, copia el faceDescriptor del payload que envía y pega el array completo (con corchetes) en la variable. Se valida al arrancar: si no son 128 números, la corrida falla con un mensaje claro en vez de un 400 del servidor.


🌴 Ausencias y vacaciones

Para que un día no se marque (vacaciones, incapacidad, permiso) no hay que desactivar los 4 crons a mano. Basta con agregar un registro a la tabla ausencias. Antes de marcar, cada cron consulta si el día de hoy (hora local UTC−6) cae dentro de algún rango registrado; si es así, no marca y lo deja en el log.

La lógica del chequeo vive en un solo lugar (lib/ausencias.ts) y la comparten las 20 marcas.

🗃️ Tabla ausencias

ColumnaTipoFormato / valoresDescripción
idINTEGER PK AUTOINCREMENTautomáticoIdentificador
fecha_inicioTEXT NOT NULL'YYYY-MM-DD'Primer día de la ausencia
fecha_finTEXT NOT NULL'YYYY-MM-DD'Último día, inclusive (día suelto = inicio)
tipoTEXT NOT NULL'vacacion' | 'incapacidad' | 'permiso'Tipo de ausencia
comentarioTEXTtexto libre (opcional)Nota
created_atTEXTautocompletadoFecha/hora de creación (UTC)

Las fechas se comparan como texto 'YYYY-MM-DD', que en ese formato ordena igual cronológicamente, así el chequeo es un simple BETWEEN.

created_at se autocompleta solo: si lo omites usa el DEFAULT, y si lo dejas en NULL un trigger (ausencias_set_created_at) lo rellena con la hora actual.

➕ Cómo agregar una ausencia

Desde la consola SQL del val:

-- Un rango de vacaciones (una sola fila cubre todos los días): INSERT INTO ausencias (fecha_inicio, fecha_fin, tipo, comentario) VALUES ('2026-08-03', '2026-08-07', 'vacacion', 'Vacaciones de agosto'); -- Un día suelto (inicio = fin): INSERT INTO ausencias (fecha_inicio, fecha_fin, tipo, comentario) VALUES ('2026-08-15', '2026-08-15', 'permiso', 'Cita médica');

Para cancelar una ausencia, borra su fila (DELETE FROM ausencias WHERE id = ?).


🛟 Supervisor de marcas

Los 20 crons marcan; el supervisor verifica que se haya marcado y repara si no. Existe porque los crons del plan gratuito son best effort: el 2026-09-07 el end_lunch no se ejecutó (cero traces, cero logs) y, al no haber ejecución, tampoco hubo excepción ni correo de alerta.

Corre una vez por marca, a una hora fija posterior al último horario posible de esa marca en la semana. Sin jitter: no simula comportamiento humano, es una reparación que ya va tarde por definición.

MarcaObjetivo (UTC)SupervisorLocalLímite
start_shift13:570 14 * * 1-58:00 am40 min
start_lunch19:025 19 * * 1-51:05 pm30 min
end_lunch19:560 20 * * 1-52:00 pm20 min
end_shift23:0710 23 * * 1-55:10 pm45 min

El objetivo es la corrida más tardía de la semana para esa marca; así bastan 4 constantes en lib/supervisor.ts en vez de duplicar los 20 horarios.

El límite es lo que impide reparaciones absurdas. Si la marca lleva más retraso que su límite, el supervisor no marca y solo alerta: reponer un end_lunch con hora y media de retraso crea un almuerzo de más de dos horas, que dispara anomalías en las métricas. El end_lunch tiene el límite más corto por eso mismo. Y si todavía no llegó la hora objetivo, tampoco marca: el supervisor solo repara el pasado.

🔌 Por qué vive en dos vals

El plan gratuito permite 10 crons por val y este ya tiene 20 (creados antes de que se aplicara el tope: siguen corriendo, pero no admiten uno más). Los supervisores viven en el val elgmg/marcador_ECP_supervisor, que estrena su propio cupo.

marcador_ECP_supervisor        marcador_ECP
  4 crons  ──── HTTP + token ────►  Supervisor/endpoint.ts
                                      └─► lib/supervisor.ts

La lógica se ejecuta aquí, no allá: así las credenciales, el FACE_DESCRIPTOR y la tabla ausencias (privada de cada val) no se duplican. El val de supervisores no sabe nada del marcador, solo dispara. Los archivos http no consumen cupo de crons.

SUPERVISOR_TOKEN protege el endpoint (es público) y debe ser idéntico en los dos vals.

🚨 Vigilante externo (dead man's switch)

El correo solo avisa de lo que se ejecutó. El caso que dejó a la vista el 2026-09-07 es el contrario: no corrió nada, así que nadie avisó. Para eso está HC_PING_URL (healthchecks.io): cada corrida del supervisor hace ping, y si el ping no llega, healthchecks manda el aviso.

El ping sale desde este val, no desde el de crons, porque así cubre la cadena completa: tanto si Val Town no ejecuta el val supervisor como si este val no responde, el ping falta igual.

  • Corrida normal → POST a la URL con una línea de detalle (end_lunch: verificada).
  • Corrida fallida → POST a <URL>/fail.
  • Sin HC_PING_URL configurada, el ping se omite y todo lo demás funciona igual. El ping nunca lanza ni bloquea: tiene 5 s de timeout y su fallo solo se anota en el log.

Ojo al configurar el check: es un solo check para las 4 marcas, así que detecta que el supervisor dejó de correr, no cuál de los cuatro faltó. Y los crons son de lunes a viernes: un schedule de "1 día" daría falsa alarma cada sábado. Con un único check, lo razonable es modo cron 10 23 * * 1-5 (el último supervisor del día) con una hora de gracia. Para cobertura por marca harían falta 4 checks.

📓 Bitácora (supervisor_eventos)

Los logs de Val Town se retienen 3 días en el plan gratuito, así que esta tabla es el único histórico de cuántas veces la plataforma se salta un cron. Es el dato que hoy no existe y que haría falta para decidir si algún día conviene mover el marcador fuera de Val Town.

Se registran todas las corridas, no solo las incidencias: sin el total no hay denominador para calcular una tasa de fallo. Son 4 filas por día hábil.

ColumnaContenido
fecha'YYYY-MM-DD' local (UTC−6)
hora_utcInstante ISO de la corrida
accionstart_shift | start_lunch | end_lunch | end_shift
resultadoverificada | recuperada | fuera_de_ventana | aun_no_es_hora | ausencia | jornada_repuesta | error
retraso_minMinutos respecto a la hora objetivo
detalleTexto libre (máx. 500 caracteres)

En verificada, retraso_min mide cuánto tardó realmente el cron de ese día: es la medición continua de la puntualidad de la plataforma. Puede salir negativo, porque el objetivo es el peor caso de la semana y no el horario de hoy.

La tabla se crea sola (CREATE TABLE IF NOT EXISTS en la primera corrida del worker), y si el INSERT falla solo se anota en el log: la bitácora nunca debe impedir que una marca se recupere.

-- ¿Cuántas veces falló la plataforma este mes? SELECT resultado, COUNT(*) FROM supervisor_eventos WHERE fecha >= '2026-09-01' GROUP BY resultado; -- Puntualidad de los crons de marca: retraso medio y peor caso SELECT accion, ROUND(AVG(retraso_min), 1) AS medio, MAX(retraso_min) AS peor FROM supervisor_eventos WHERE resultado = 'verificada' GROUP BY accion; -- Todo lo que no fue rutina SELECT * FROM supervisor_eventos WHERE resultado NOT IN ('verificada', 'ausencia') ORDER BY id DESC;

📬 Qué esperar

SituaciónQué hace
La marca estáLog ✅ verificada. Sin correo: si escribiera cada día, dejarías de leerlo
La misma situación se repiteLog 🔁 ya se avisó hoy. Máximo un correo por marca y desenlace al día: la bitácora es la memoria que lo impide
Falta, dentro de la ventanaLa marca y avisa: 🛟 recuperada
Falta, fuera de ventanaNo marca, avisa para que decidas
Aún no es horaNo hace nada
Falta la jornada completaRepone start_shift y avisa: la otra marca no se puede encadenar (cooldown de 5 min del backend)
Rostro rechazadoCorreo distinto: hay que renovar FACE_DESCRIPTOR, reintentar no sirve

🩺 Cómo leer los logs

En los logs verás…Significa
✅ … registradaMarcó correctamente 🎉
↩️ Ya existe … hoy; no se marcaYa estaba marcado; no duplicó (correcto)
🌴 Ausencia hoy (…); no se marcaHoy cae en un rango de la tabla ausencias (correcto)
Login … falló … unauthorized_clientEl proveedor no acepta ese tipo de login
Marca … falló (HTTP 4xx)Token o permisos incorrectos
… requiere verificación facialFalta o está mal FACE_DESCRIPTOR

📝 Notas

  • Jitter: el código espera 0–50 segundos aleatorios antes de marcar. En el plan gratuito de Val.town cada corrida está limitada a ~1 minuto, por lo que la ventana de variación se mantiene dentro de ese margen.
  • Orden secuencial: el sistema valida la secuencia de marcas del día; los horarios están configurados para respetarla.
  • Zona horaria: Val.town corre en UTC; los crons ya están convertidos a la zona local (UTC−6).

Automatización personal. Usa credenciales y datos propios.