SIENEP · del navegador a la base

Ciclo completo del recordatorio

Un funcionario quiere revisar el avance de un estudiante tres lunes seguidos. Abre una instancia, crea un recordatorio semanal y consulta las fechas. Después cumple la primera, convierte la segunda en una instancia y deja la tercera pendiente. Ese ejemplo acompaña el recorrido por React, la API y PostgreSQL.

Lectura del código · 09/11/2026 · Frontend React + API Spring + PostgreSQL

Requisitos funcionales del recorrido

Fuente oficial Requerimientos de Programación de UTEC, 2026 · p. 5

Los ejemplos usan IDs y datos ficticios. Podés copiar los fragmentos o imprimir los cinco recorridos. El HTML se abre sin internet.

Crear un recordatorio

Instancia → formulario → POST → regla, código y confirmación

1. Abrir el formulario desde una instancia

Un funcionario abre el detalle de una instancia activa y elige Nuevo recordatorio. El ID en la URL permite que el diálogo arranque con esa instancia. Desde /recordatorios también puede abrir el alta y elegir una instancia disponible.

PanelDetalleInstancia.tsx · líneas 204–210

TSX
if (onNuevoRecordatorio) {
                onNuevoRecordatorio(instancia.idInstancia)
              } else {
                navigate(`/recordatorios/nuevo?instancia=${instancia.idInstancia}`, {
                  state: { backgroundLocation: location },
                })
              }

La gestión en /recordatorios está dentro de las rutas autenticadas. La ruta de alta exige recordatorios.leer y recordatorios.crear. Cuando llega con el ID contextual, el formulario pide esa instancia a la API. El servidor vuelve a validar el alcance al guardar.

2. Completar título, fecha, tipo y repetición

El diálogo mantiene los datos en React. El tipo viene del catálogo de categorías de recordatorio y es opcional. Se puede elegir una fecha única o completar la frecuencia, intervalo y final de una serie. La fecha y la hora se interpretan con la zona America/Montevideo.

validacion.ts · líneas 105–107

TypeScript
const instante = resolverInstanteMontevideo(datos.fecha, datos.hora)
  if (!errores.fecha && !errores.hora) {
    if (!instante.valido) errores.fecha = instante.error

Al confirmar, se validan los campos y se enfoca el primero con error. El diálogo conserva un controlador para el envío en curso y comprueba el ciclo de sesión antes de aplicar el resultado. La opción de visibilidad para el estudiante empieza desmarcada en el alta.

3. Enviar el POST a la API de SIENEP

recordatoriosApi.ts · líneas 158–162

TypeScript
const creado = validarRecordatorioResponse(
    await clienteHttp.post<unknown>('/api/recordatorios', cuerpo, { senal: opciones?.senal })
  )
  validarCodigoGuardado(creado, creado.codigoRecordatorio)
  return creado
HTTP · ejemplo ficticio
POST /api/recordatorios
Content-Type: application/json
Authorization: Bearer CREDENCIAL_DE_EJEMPLO

{
  "idInstancia": 54,
  "titulo": "Revisar avance del semestre",
  "fecHora": "2026-11-16T10:00:00-03:00",
  "recurrencia": "SEMANAL",
  "intervaloRecurrencia": 1,
  "cantidadOcurrencias": 3,
  "idCategoria": 2
}

El cliente compartido serializa el cuerpo y adjunta el token. Spring recibe el DTO, comprueba recordatorios.crear y toma la identidad de quien hace la operación de la sesión. La categoría de este ejemplo usa un ID ficticio.

4. Guardar el recordatorio y confirmar su código

RecordatorioService.java · líneas 69–78

Java
Instancia instancia = resolverInstancia(dto.getIdInstancia(), actorId, isAdmin, true, "recordatorios.crear");
        validarComprobaciones(dto, instancia);
        CategoriaRecordatorio categoria = resolverCategoriaRecordatorio(dto.getIdCategoria(), null);
        boolean publicar = dto.wasProvided("visibleParaEstudiante")
                && exigirBooleano(dto.getVisibleParaEstudiante(), "visibleParaEstudiante");

        Recordatorio r = new Recordatorio();
        r.setInstancia(instancia);
        r.setFuncionario(instancia.getFuncionario());
        r.setEstudiante(instancia.getEstudiante());

El servicio exige una instancia activa y referencias utilizables. Para un funcionario con alcance acotado, comprueba el responsable de la instancia y, si hay estudiante, su grupo. El responsable y el estudiante se derivan de la instancia asociada. Los IDs de esas personas, si se envían, sirven como comprobación.

RecordatorioService.java · líneas 86–95

Java
r.setRevisionRegla(1);
        r.setCategoria(categoria);
        r.setEstActivo(true);
        r.setEsHistorico(false);
        if (publicar) validarPublicable(r, instancia);
        r.setVisibleParaEstudiante(publicar);
        r.setCodigoRecordatorio("REC-" + UUID.randomUUID());
        r = recordatorioRepository.save(r);
        correoRecordatorioService.registrarIntencion(r, actorId, CorreoRecordatorio.Motivo.ALTA);
        googleCalendarService.crearDesdeRecordatorio(r);

La fila nueva queda activa con revisión 1 y código REC- seguido de un UUID. PostgreSQL controla la unicidad del código y el servicio permite hasta tres intentos en transacciones separadas si hay una colisión de ese campo. La auditoría del alta se programa después del commit.

La API responde 201 Created. React valida el DTO y vuelve a leer el registro por ID para confirmar el código y la regla. Si la segunda lectura falla por un error recuperable, puede mostrar el alta con una advertencia de lectura. Así distingue el POST confirmado de la comprobación posterior.

Calcular y consultar ocurrencias

Fecha inicial + frecuencia + intervalo + final → ocurrencias → estado y próxima fecha

1. Guardar la regla que describe la serie

En recordatorios quedan la fecha inicial, la frecuencia, el intervalo, el final elegido y la revisión. Una regla NINGUNA produce una única fecha con intervalo 1. Una regla diaria, semanal o mensual necesita exactamente un final por fecha o por cantidad.

RecordatorioRecurrenceCalculator.java · líneas 22–26

Java
if (recurrencia == Recurrencia.NINGUNA) {
            if (intervalo != 1 || fin != null || cantidad != null) throw new IllegalArgumentException("NINGUNA requiere intervalo 1 y no admite terminadores");
            return;
        }
        if ((fin == null) == (cantidad == null)) throw new IllegalArgumentException("La recurrencia requiere exactamente uno de fecFinRecurrencia o cantidadOcurrencias");

El backend admite intervalos entre 1 y 365, con un máximo de 10.000 ocurrencias y un horizonte de diez años. El primer ordinal es 1. Al calcular una fecha se aplica el intervalo a partir del ancla y se conserva su hora local en Montevideo.

RecordatorioRecurrenceCalculator.java · líneas 54–64

Java
long paso = Math.multiplyExact((long) ordinal - 1, intervalo);
            LocalDate fecha = switch (recurrencia) {
                case NINGUNA -> local.toLocalDate();
                case DIARIA -> local.toLocalDate().plusDays(paso);
                case SEMANAL -> local.toLocalDate().plusWeeks(paso);
                case MENSUAL -> {
                    YearMonth mes = YearMonth.from(local).plusMonths(paso);
                    yield mes.atDay(Math.min(local.getDayOfMonth(), mes.lengthOfMonth()));
                }
            };
            return ZonedDateTime.of(LocalDateTime.of(fecha, local.toLocalTime()), ZONA).toOffsetDateTime();

La regla mensual conserva el día del ancla cuando el mes lo permite. Si ese día excede el último día del mes, usa el último día disponible.

2. Pedir una ventana de ocurrencias

HTTP · ID ficticio
GET /api/recordatorios/58/ocurrencias?limite=20

La API comprueba el permiso de lectura y el alcance, expande la regla vigente y combina las fechas calculadas con los cumplimientos guardados. Admite una ventana de hasta 366 días y entre 1 y 100 resultados por petición. Por defecto devuelve hasta 20 desde el inicio de la ventana.

RecordatorioService.java · líneas 284–288

Java
RecordatorioOcurrencia hecho = cumplidas.get(ordinal);
                    boolean completado = hecho != null && Boolean.TRUE.equals(hecho.getCumplido());
                    String estado = completado ? "CUMPLIDO" : !Boolean.TRUE.equals(r.getEstActivo()) ? "INACTIVO" : estadoTemporal(fecha);
                    return new RecordatorioOcurrenciaResponseDto(r.getRevisionRegla(), ordinal, fecha, completado,
                            estado, hecho == null || hecho.getInstanciaGenerada() == null ? null : hecho.getInstanciaGenerada().getIdInstancia());

Cada fecha tiene un ordinal dentro de su revisión. La clave de negocio es (idRecordatorio, revisionRegla, numeroOcurrencia). Una fecha calculada puede aparecer en pantalla sin que exista una fila de cumplimiento para ella.

3. Leer el estado a partir de la primera ocurrencia pendiente

RecordatorioService.java · líneas 549–554

Java
for (int n = 1; n <= last; n++) {
            if (ordinalesCumplidos.contains(n)) continue;
            OffsetDateTime next = recurrenceCalculator.calcular(r.getFecHora(), r.getRecurrencia(), r.getIntervaloRecurrencia(), n);
            return new EstadoVigente(estadoTemporal(next), n, next);
        }
        return new EstadoVigente("CUMPLIDO", null, null);

La próxima es la primera ocurrencia sin cumplir, incluso si ya venció. Su día en Montevideo determina VENCIDO, HOY o FUTURO. Cuando todas se cumplieron, la serie queda CUMPLIDO. Los registros inactivos y los históricos incompletos tienen su propio estado.

Ejemplo semanal del alta anterior, siempre a las 10:00
RevisiónOcurrenciaFechaAcción del ejemplo
1116/11/2026Marcar como cumplida
1223/11/2026Convertir en instancia
1330/11/2026Queda pendiente

Después de registrar las dos primeras acciones, la próxima pasa a ser la tercera. El detalle muestra el resultado que recalcula el servidor.

Buscar, editar, reprogramar y dar de baja

Listado autorizado → filtros → edición parcial → revisión de la regla o baja

1. Consultar y filtrar el listado

filtrosRecordatorios.ts · líneas 90–96

TypeScript
const porEstado = filtrarPorEstado(lista, filtros.estado ?? 'pendientes')
  const porTipo = filtrarRecordatoriosPorTipo(porEstado, filtros.tipo)
  const texto = normalizarTitulo(filtros.q)
  if (!texto) return porTipo
  return porTipo.filter((item) =>
    coincideCodigoExacto(item, filtros.q) || normalizarTitulo(item.titulo).includes(texto)
  )

La API entrega los recordatorios visibles según el alcance. La pantalla puede consultar por instancia y rango de fechas, aplicado a la fecha inicial del recordatorio. El frontend filtra el conjunto recibido por estado y tipo. La búsqueda local compara el código exacto o parte del título normalizado.

El detalle reúne código, instancia de origen, tipo, estado y próxima ocurrencia. Mantener separados el ID numérico y el código permite usar el primero en rutas y el segundo para identificar el recordatorio en pantalla y correo.

2. Cambiar el tipo o editar otros datos

recordatoriosApi.ts · líneas 228–231

TypeScript
const cuerpo = Object.fromEntries(
    permitidas.filter((campo) => Object.hasOwn(cambios, campo))
      .map((campo) => [campo, cambios[campo]])
  )

La edición usa PUT /api/recordatorios/{id} con los campos modificados. Si un campo se omite, conserva su valor. En campos como descripción y categoría, un null explícito lo limpia. Cambiar el tipo desde el detalle usa el mismo endpoint con idCategoria. La categoría nueva debe estar activa.

El backend comprueba recordatorios.modificar, el alcance y el estado activo. El frontend verifica que la respuesta conserve el ID y el código del recordatorio antes de actualizar la pantalla.

3. Reprogramar incrementa la revisión de la regla

RecordatorioService.java · líneas 201–207

Java
boolean programacionCambio = asociacionCambio || reglaCambio;

        if (programacionCambio && ocurrenciaRepository.existsByRecordatorio_IdRecordatorio(id)) {
            throw new IllegalStateException("La asociación y programación no se pueden cambiar después de un cumplimiento");
        }
        if (programacionCambio) {
            if (destino == null) throw new IllegalArgumentException("Se requiere una instancia asociada para cambiar la programación");

RecordatorioService.java · líneas 216–224

Java
r.setRevisionRegla(r.getRevisionRegla() + 1);
            r.setInstancia(destino);
            r.setFuncionario(destino.getFuncionario());
            r.setEstudiante(destino.getEstudiante());
            r.setFecHora(fecHora);
            r.setRecurrencia(recurrencia);
            r.setIntervaloRecurrencia(intervalo);
            r.setFecFinRecurrencia(fin);
            r.setCantidadOcurrencias(cantidad);

Cambiar la instancia, la fecha inicial o algún componente de la regla genera otra revisión. El servidor valida la programación final y pide una fecha futura si cambió el ancla. Editar título, descripción o tipo mantiene la revisión si la programación sigue igual.

Cuando ya hay un cumplimiento registrado, el servidor rechaza cambiar la asociación o programación. Así preserva la relación entre lo que se cumplió y la fecha original. La edición permitida de los demás campos sigue sus propias validaciones.

4. Dar de baja el recordatorio

RecordatorioService.java · líneas 249–254

Java
public void desactivar(Integer id, Integer actorId, boolean isAdmin) {
        Recordatorio r = cargarBloqueado(id);
        verificarAcceso(r, actorId, isAdmin, "recordatorios.eliminar");
        if (!Boolean.TRUE.equals(r.getEstActivo())) return;
        r.setEstActivo(false);
        googleCalendarService.inactivarRecordatorio(id);

La confirmación envía DELETE /api/recordatorios/{id} y el servidor exige recordatorios.eliminar y alcance. El servicio cambia el estado a inactivo, conserva el registro y sus cumplimientos, e inactiva sus proyecciones de Calendar. La respuesta del DELETE es 204 No Content.

Después del 204, el frontend intenta consultar el detalle para confirmar el estado. Si la lectura falla, presenta ese resultado separado de la solicitud de baja.

Actuar sobre una ocurrencia

Revisión + ordinal → confirmar → cumplimiento o instancia vinculada → estado actualizado

1. Marcar como cumplida una sola fecha

HTTP · ejemplo ficticio
PUT /api/recordatorios/58/ocurrencias/1/cumplir

{ "revisionRegla": 1 }

La confirmación toma la ocurrencia que el usuario eligió. El servicio bloquea la fila del recordatorio durante la operación y valida el permiso recordatorios.modificar, el alcance, el estado, la revisión vigente y que el ordinal pertenezca a la regla. Una revisión vieja puede devolver 409.

RecordatorioService.java · líneas 309–314

Java
if (hecho != null) return respuestaOcurrencia(r, hecho);
        Usuario actor = usuarioRepository.findById(actorId)
                .orElseThrow(() -> new IllegalArgumentException("Actor no encontrado"));
        hecho = nuevaOcurrencia(r, ordinal, actor);
        hecho = ocurrenciaRepository.save(hecho);
        googleCalendarService.inactivarOcurrencia(id, revision, ordinal);

Si ya había un cumplimiento para esa clave, la API devuelve ese mismo resultado. En el primer cumplimiento guarda la fecha programada, cuándo se cumplió y quién lo registró en recordatorio_ocurrencias. La restricción única usa el recordatorio, la revisión y el ordinal. La inactivación del evento corresponde solo a esa ocurrencia.

2. Convertir una ocurrencia en instancia

accionesOcurrenciaApi.ts · líneas 62–72

TypeScript
const cuerpo: ConversionPayload = {
    revisionRegla: datos.revisionRegla,
    numeroOcurrencia: datos.numeroOcurrencia,
    confirmarCumplimiento: true,
    titulo: datos.titulo.trim(),
    descripcion: datos.descripcion.trim(),
    fecHora: datos.fecHora,
    idCategoriaInstancia: datos.idCategoriaInstancia,
    lugar: datos.lugar?.trim() || null,
    urlMeet: datos.urlMeet?.trim() || null
  }

El diálogo envía POST /api/recordatorios/{id}/instancia. El cuerpo identifica la revisión y el ordinal, confirma el cumplimiento y lleva los datos de la nueva instancia. La API exige lectura y modificación de recordatorios y creación de instancias, con alcance sobre el contexto.

RecordatorioService.java · líneas 353–361

Java
Instancia generada = instanciaService.crearDesdeRecordatorio(r, dto, actorId, isAdmin);
        if (hecho == null) {
            Usuario actor = usuarioRepository.findById(actorId)
                    .orElseThrow(() -> new IllegalArgumentException("Actor no encontrado"));
            hecho = nuevaOcurrencia(r, dto.getNumeroOcurrencia(), actor);
        }
        hecho.setInstanciaGenerada(generada);
        hecho.setHashConversion(hash);
        hecho = ocurrenciaRepository.save(hecho);

La instancia nueva hereda el funcionario y el estudiante del contexto. Recibe su propio código INST-…. En la misma transacción queda enlazada al cumplimiento de esa fecha, que puede ser nuevo o ya existente. Así, si la creación falla, la ocurrencia conserva su estado anterior.

Por ejemplo, podés cumplir la primera fecha y convertir la segunda directamente. La conversión de la segunda ya registra su cumplimiento. El alta de la instancia generada sigue también su propio camino de Calendar y notificación de identificador.

3. Reconocer un reintento de la misma conversión

RecordatorioService.java · líneas 346–351

Java
if (hecho != null && hecho.getInstanciaGenerada() != null) {
            if (!Objects.equals(hash, hecho.getHashConversion())) {
                throw new IllegalStateException("La ocurrencia ya fue convertida con otros datos");
            }
            return new RecordatorioConversionResultado(instanciaRespuesta(hecho.getInstanciaGenerada(), id, hecho.getRevisionRegla(), hecho.getNumeroOcurrencia()), false);
        }

La primera conversión devuelve 201. Si se repite la operación con los mismos datos para esa ocurrencia, el servicio compara una huella del contenido y devuelve la instancia existente con 200. Si los datos difieren, responde conflicto. El bloqueo y las restricciones evitan duplicar la relación.

4. Actualizar lo que ve el funcionario

useAccionOcurrencia.ts · líneas 106–107

TypeScript
const actualizado = await obtenerRecordatorio(recordatorio.idRecordatorio, opciones)
        if (vigente()) onActualizado(actualizado)

El frontend valida la respuesta de la acción y vuelve a pedir el recordatorio para actualizar estado y próxima ocurrencia. El detalle puede mostrar un enlace hacia la instancia generada. Si la conexión falla durante una escritura, conviene consultar el estado antes de repetir la acción.

Seguir lo que sale del flujo de gestión

Registro local → eventos y correo · publicación explícita → consulta propia del estudiante

1. Calendar proyecta las ocurrencias

GoogleCalendarService.java · líneas 90–96

Java
if (cumplidas.contains(ordinal)) continue;
            OffsetDateTime inicio = fechas.get(index);
            OffsetDateTime fin  = inicio.plusHours(1);
            String eventId      = "SIENEP-REC-" + recordatorio.getIdRecordatorio() + "-"
                    + recordatorio.getRevisionRegla() + "-" + ordinal;
            String link         = buildCalendarLink(recordatorio.getTitulo(), inicio, fin,
                                                    recordatorio.getDescripcion(), null);

El alta o la edición prepara proyecciones en eventos_calendario. La operación expande hasta 100 fechas, omite las cumplidas y relaciona cada evento con el recordatorio, su revisión y su ordinal. Con una reprogramación, las proyecciones anteriores pueden quedar inactivas. Al cumplir o convertir se inactiva la proyección de esa fecha.

Con la integración habilitada, el backend encola el trabajo de Google después del commit. El panel de detalle permite consultar el estado. Un enlace de borrador permite preparar un evento manual y el estado de sincronización indica el resultado de la integración. Las invitaciones dependen de la configuración de Calendar y de los integrantes del evento.

2. El correo RF23 sigue un camino separado

CorreoRecordatorioService.java · líneas 84–93

Java
String correo = r.getFuncionario() == null || r.getFuncionario().getUsuario() == null
                ? null : r.getFuncionario().getUsuario().getCorreo();
        c.setDestinatario(destinatarioValido(correo));
        c.setAsunto("SIENEP: recordatorio " + limpiar(r.getCodigoRecordatorio()));
        c.setCuerpo(cuerpo(r));
        c.setFecRegistro(OffsetDateTime.now(clock));
        Long id = correos.saveAndFlush(c).getIdEnvio();
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() { encolar(id); }

El alta y los cambios de programación registran una intención en correos_recordatorio. La clave combina recordatorio, revisión y motivo. El destinatario se toma del funcionario asociado y el asunto incluye el código REC-…. El intento de envío se encola después del commit.

El correo RF23 de este flujo se dispara por alta o reprogramación. Una serie de tres ocurrencias puede tener tres proyecciones de Calendar y una sola intención de correo por alta. Un correo de invitación de Google Calendar tiene su propio origen.

El resultado de la operación local y el resultado externo se comprueban por separado. El registro del correo distingue pendiente, en proceso, enviado, error y deshabilitado. Para comprobar recepción, se busca el mensaje en la cuenta destinataria.

3. Publicar un recordatorio para el estudiante

DialogoFormularioRecordatorio.tsx · líneas 340–343

TypeScript
const payloadAlta = visible && !sinEstudiante
        ? { ...resultadoAlta.payload, visibleParaEstudiante: true }
        : resultadoAlta.payload
      const creado = await crearRecordatorio(payloadAlta, { senal: controller.signal })

La opción Visible para el estudiante agrega visibleParaEstudiante: true al envío. El valor inicial en la API es privado. Publicar exige un recordatorio activo con regla completa y una instancia activa de un estudiante activo. Al cambiar de instancia, se vuelve a privado salvo confirmación explícita para el nuevo contexto.

En /mis-recordatorios, el backend obtiene la identidad desde la sesión y filtra por publicación, estado activo y coincidencia de estudiante en el recordatorio y su instancia. Usa GET /api/estudiantes/mis-recordatorios y un DTO de lectura con el título, código, fechas, estado y enlace contextual a la instancia. La descripción interna queda fuera del DTO.

Esa visibilidad gobierna la consulta propia en SIENEP. Las invitaciones de Calendar y el correo al funcionario tienen reglas de entrega separadas.

Para contarlo en la defensa

“El funcionario crea un recordatorio a partir de una instancia. Guardamos una regla y un código. La API calcula sus fechas y conserva los cumplimientos por revisión y número de ocurrencia. Podemos marcar una fecha como cumplida o convertirla en una instancia en la misma operación. Calendar y correo se procesan después de guardar, y el estudiante ve el recordatorio en su consulta propia cuando lo publicamos.”

Archivos revisados

Fragmentos extraídos de las copias del paquete del 06/11/2026. Las rutas y líneas corresponden a esas copias.