GUÍA DE ESTUDIO / SIS CARGA LECTIVAGuía de estudio completa

Entiende el plan de ciclo, paso a paso

Una guía para relacionar la interfaz, el código de Alpine y las funciones de Apps Script, con explicaciones desde lo básico.

Cómo estudiarEmpieza por el selector y sigue con la interfaz, el componente y el servidor. Usa el índice para volver al tema que necesites.

Primero, mira la pantalla

Al entrar en «Registrar plan de ciclo» aparece un selector. Su valor indica qué ciclo se está consultando.

Captura real del encabezado Registrar plan de ciclo con el selector Ciclo en 2026-2
1El recuadro señala el selector «Ciclo».

Captura recortada de la aplicación abierta. El valor 2026-2 es el que se veía al tomarla.

¿Qué ocurre al cambiarlo?

El navegador guarda el ciclo elegido y vuelve a pedir la información. Mientras espera, el selector se deshabilita y puede aparecer «Cargando…».

Cuando llega una respuesta correcta, Alpine actualiza la información que usa la pantalla: la lista de ciclos, el ciclo seleccionado y las carreras.

El HTML que conecta la pantalla

Este fragmento procede de PlanDeCicloVista.html. No contiene por sí mismo la lista de ciclos: la dibuja a partir de los datos del componente.

PlanDeCicloVista.html · marcado · fragmento
<h2>Registrar plan de ciclo</h2>
<label>Ciclo
  <select :disabled="ocupado || pantalla !== 'tabla'"
          @change="ciclo = $event.target.value; inicio()">
    <template x-for="c in ciclos" :key="c">
      <option :value="c" :selected="c === ciclo"
              x-text="c"></option>
    </template>
  </select>
</label>

Cómo leerlo

  • <h2> coloca el título visible.
  • x-for="c in ciclos" crea una opción por cada elemento de la lista ciclos.
  • x-text="c" escribe el nombre de cada opción; :selected identifica la opción activa.
  • @change escucha el cambio, guarda el valor elegido en ciclo y ejecuta inicio().
  • :disabled bloquea el selector si hay una acción en curso o si la pantalla no está en la tabla.

Desarmemos el cambio de ciclo

Esta línea hace dos cosas seguidas cuando eliges otra opción: guarda el valor y luego vuelve a cargar la información.

La línea completa
@change="ciclo = $event.target.value; inicio()"
@change

Alpine escucha el evento change: ocurre cuando cambias la opción seleccionada. El símbolo @ es una forma abreviada de indicar «escucha este evento».

$event

Es el objeto del evento que Alpine pone a disposición de esta expresión. Contiene información sobre lo que acaba de pasar.

.target

Dentro del evento, target señala el elemento que originó el cambio. En este caso es el <select> del ciclo.

.value

Es el valor actual de ese elemento. Si seleccionas 2026-1, aquí se obtiene el texto "2026-1".

ciclo = …

El signo = asigna: guarda ese valor en la variable ciclo de la memoria del componente.

; inicio()

El punto y coma separa las dos instrucciones. Después, inicio() ejecuta el método que pide la información del ciclo al servidor.

Ejemplo completo: eliges 2026-1 → $event.target.value vale "2026-1" → se guarda en ciclo → se ejecuta inicio().

Otras piezas del selector

El viaje de la información

Piensa en tres lugares distintos: la pantalla que recibe la acción, el componente que coordina la llamada y el servidor que consulta los datos.

En el navegador

1. La vista

El usuario elige un ciclo. El evento @change actualiza ciclo y llama a inicio().

En el navegador

2. El componente

inicio() llama a planInicio(this.ciclo) mediante google.script.run y espera la respuesta.

En Apps Script

3. El servidor

planInicio valida al usuario, consulta las hojas y devuelve { ciclos, ciclo, carreras }.

La idea que debes recordar: el HTML muestra y dispara acciones; el componente mantiene los datos en el navegador; Apps Script prepara la información que viene de la hoja de cálculo.

La llamada, pieza por pieza

El componente hace la petición

google.script.run permite llamar desde la página a una función del servidor. La función planInicio recibe this.ciclo: el ciclo guardado en el componente.

Una promesa representa un resultado que llegará después. withSuccessHandler(listo) atiende una respuesta; withFailureHandler(mal) atiende un fallo de la llamada. await espera el resultado antes de continuar. La respuesta recibida se guarda en r.

PlanDeCicloVista.html · componente · fragmento
const r = await new Promise((listo, mal) => google.script.run
  .withSuccessHandler(listo)
  .withFailureHandler(mal)
  ["planInicio"](this.ciclo));

if (!r || !r.ok) throw new Error(
  (r && r.error) || "Respuesta vacía del servidor."
);
d = r.datos;

El servidor prepara la respuesta

La función revisa la identidad y el rol. Después lee Usuarios, Planes, Bloqueos, Ciclos y Carreras. Si no recibió un ciclo, usa el último disponible; si recibió uno desconocido, devuelve un error.

PlanDeCiclo.js · servidor · fragmento
const ciclos = leer("Ciclos").map(f => f[0]).filter(String),
      pedido = String(ciclo == null ? "" : ciclo).trim();
if (!pedido) ciclo = ciclos[ciclos.length - 1]
  || fallo("La pestaña Ciclos está vacía.");
else ciclo = ciclos.indexOf(pedido) >= 0
  ? pedido : fallo("Ciclo desconocido: «" + pedido + "».");

// Después de construir la lista de carreras:
return { ok: true, datos: { ciclos, ciclo, carreras } };

La pantalla se actualiza

Si la respuesta fue correcta, el componente copia los datos a su memoria. this se refiere al componente actual; Object.assign le copia las propiedades indicadas. Por ejemplo, ciclo: d.ciclo guarda el ciclo recibido. Alpine vuelve a mostrar las opciones y la tabla usando esos datos.

PlanDeCicloVista.html · componente · fragmento
if (d) Object.assign(this, {
  ciclos: d.ciclos,
  ciclo: d.ciclo,
  carreras: d.carreras,
  listo: true
});
Comprueba si lo entendiste

Pregunta: ¿El selector consulta directamente la hoja de cálculo?

Respuesta: No. El cambio llama a inicio(); este pide los datos a planInicio, que realiza las lecturas en el servidor y devuelve la respuesta.

La tabla de carreras · marcado

Esta tabla aparece al entrar en la vista. Cada fila representa una carrera y resume su plan para el ciclo elegido.

Recorte de la tabla real con las columnas carrera y plan12
1 Carrera: sigla y nombre. 2 Plan: versión guardada. El recorte oculta los correos de las otras columnas.
PlanDeCicloVista.html · fragmento del proyecto abierto · líneas 37–50
  <div class="tabla" x-show="pantalla === 'tabla'">
    <table>
      <thead><tr><th>Carrera</th><th>Plan</th><th>Guardado por</th><th>Fecha</th><th class="num">Asignaturas</th>
        <th class="num">Grupos</th><th>Edición</th><th></th></tr></thead>
      <tbody>
        <template x-for="c in carreras" :key="c.sigla">
          <tr>
            <td><strong x-text="c.sigla"></strong> <span x-text="c.nombre"></span></td>
            <td x-text="c.version ? 'v' + c.version : 'Sin plan'"></td><td x-text="c.usuario"></td><td x-text="fecha(c.fecha)"></td>
            <td class="num" x-text="c.asignaturas"></td><td class="num" x-text="c.grupos"></td>
            <td><span class="marca lectura" x-show="c.editor" x-text="'En edición por ' + c.editor"></span></td>
            <!-- «Editar» (azul, tus carreras) o «Ver» → abrir(sigla) → planAbrir. -->
            <td><button class="chico" :class="{ primario: c.puedeEditar }" :disabled="!!ocupado" @click="abrir(c.sigla)"
                        x-text="ocupado === 'abrir ' + c.sigla ? 'Abriendo…' : (c.puedeEditar ? 'Editar' : 'Ver')"></button></td>

La pantalla del plan · marcado

Cuando se abre una carrera, se reemplaza la tabla por el plan. La aplicación muestra la carrera, el ciclo, la versión y las acciones disponibles.

Encabezado de un plan con carrera y ciclo
El encabezado identifica la carrera abierta y su ciclo.
Aviso de solo lectura y botones Salir e Historial
En este ejemplo, el aviso indica que el plan está en modo de solo lectura.
PlanDeCicloVista.html · fragmento del proyecto abierto · líneas 58–73
  <template x-if="pantalla === 'plan'">
    <div>
      <div class="fila">
        <h3 x-text="doc.carrera + ' · ' + doc.nombre + ' · ciclo ' + doc.ciclo"></h3>
        <span class="suave" x-text="doc.version ? 'Versión base v' + doc.version + ' (' + doc.usuario + ', ' + fecha(doc.fecha) + ')' : 'Sin plan guardado'"></span>
        <span class="marca lectura" x-show="doc.modo === 'lectura'"
              x-text="/^Solo lectura/.test(doc.motivo) ? doc.motivo : 'Solo lectura · ' + doc.motivo"></span>
        <span class="marca" x-show="doc.modo === 'edicion'" x-text="'Bloqueo hasta ' + doc.vence.slice(11, 16)"></span>
      </div>
      <!-- «Guardar» → guardar() → planGuardar · «Salir» → salir() → planSalir · «Historial» → historial() → planHistorial -->
      <div class="fila">
        <button class="primario" x-show="doc.modo === 'edicion'" :disabled="ocupado || !!vistaVersion" @click="guardar()"
                x-text="ocupado === 'guardar' ? 'Guardando…' : 'Guardar'"></button>
        <button :disabled="!!ocupado" @click="salir()" x-text="ocupado === 'salir' ? 'Saliendo…' : 'Salir'"></button>
        <button :disabled="!!ocupado" @click="historial()" x-text="ocupado === 'historial' ? 'Buscando…' : 'Historial'"></button>
        <span class="aviso" x-show="cambios">Hay cambios sin guardar.</span>

Pegar o subir un CSV · marcado

Área real para pegar datos con el botón Interpretar y el selector CSV123
1 Área para pegar. 2 Botón Interpretar. 3 Selector de archivo CSV.
PlanDeCicloVista.html · fragmento del proyecto abierto · líneas 104–112
      <div class="panel" x-show="editable">
        <textarea x-model="texto" placeholder="Pega aquí el rango copiado de Excel o Google Sheets (con la fila de cabeceras)"></textarea>
        <div class="fila">
          <button :disabled="!!ocupado" @click="interpretar()" x-text="ocupado === 'interpretar' ? 'Interpretando…' : 'Interpretar'"></button>
          <label>o sube un CSV <input type="file" accept=".csv" :disabled="!!ocupado" @change="leerArchivo($event)"></label>
          <span class="suave" x-show="lectura" x-text="lectura && (lectura.filas + ' filas leídas, ' + lectura.descartadas + ' descartadas de otra carrera.')"></span>
        </div>
        <template x-for="(a, i) in (lectura ? lectura.avisos : [])" :key="i"><p class="aviso" x-text="a"></p></template>
      </div>

Previsualización por semestre · marcado

Recorte real de la tabla con total de grupos y botones de acciones12
1 Total de asignaturas y grupos. 2 Acciones del grupo. La tabla se desplazó horizontalmente para mostrar los botones.
PlanDeCicloVista.html · fragmento del proyecto abierto · líneas 116–145
      <p class="suave" x-text="totales"></p>
      <div class="tabla">
        <table>
          <thead><tr><th>Código</th><th>Asignatura</th><th>Grupo</th><th class="num">HT</th><th class="num">HP</th><th class="num">TH</th>
            <th>DPTO</th><th>Observaciones</th><th>Marcas</th><th x-show="editable">Acciones</th></tr></thead>
          <template x-for="s in semestres" :key="s.clave">
            <tbody>
              <tr><th colspan="10" x-text="s.titulo"></th></tr>
              <template x-for="f in s.filas" :key="f.clave">
                <tr>
                  <td x-text="f.asig.codigo"></td><td x-text="f.asig.nombre"></td><td x-text="f.g.n"></td>
                  <td class="num" x-text="f.g.ht"></td><td class="num" x-text="f.g.hp"></td><td class="num" x-text="f.g.th"></td>
                  <td x-text="f.asig.dpto"></td>
                  <td><span x-text="f.g.obs"></span>
                    <template x-for="(a, i) in f.g.avisos" :key="i"><div class="aviso" x-text="a"></div></template></td>
                  <td>
                    <span class="marca des" x-show="f.g.desdoble" x-text="f.g.desdoble ? f.g.desdoble.componente + '×' + f.g.desdoble.n : ''"></span>
                    <span class="marca fus" x-show="f.g.fusion" x-text="f.g.fusion ? textoFusion(f.asig, f.g) : ''"></span>
                    <span class="marca fus" x-show="f.g.fusionExterna" x-text="f.g.fusionExterna ? 'FUS – fusionado con ' + f.g.fusionExterna.carrera : ''"></span>
                  </td>
                  <!-- Acciones del grupo: agregarGrupo, uiQuitarGrupo → quitarGrupo, abrirDesdoble, quitarDesdoble, abrirFusion,
                       deshacerFusion. Ninguna llama al servidor. -->
                  <td x-show="editable">
                    <button class="chico" x-show="f.primera" :disabled="!!ocupado" @click="agregarGrupo(f.asig)">+ Grupo</button>
                    <button class="chico peligro" :disabled="ocupado || f.asig.grupos.length < 2" @click="uiQuitarGrupo(f.asig, f.g)">Quitar</button>
                    <button class="chico" x-show="!f.g.desdoble" :disabled="!!ocupado" @click="abrirDesdoble(f.asig, f.g)">Desdoblar…</button>
                    <button class="chico" x-show="f.g.desdoble" :disabled="!!ocupado" @click="quitarDesdoble(f.g)">Quitar desdoble</button>
                    <button class="chico" x-show="!f.g.fusion" :disabled="ocupado || !f.asig.codigo || !!f.g.fusionExterna"
                            @click="abrirFusion(f.asig, f.g)">Fusionar…</button>
                    <button class="chico" x-show="f.g.fusion" :disabled="!!ocupado" @click="deshacerFusion(f.g.fusion.id)">Deshacer fusión</button>

El componente y su memoria

PlanDeCicloVista.html · componente · fragmento del proyecto abierto · líneas 223–233
  document.addEventListener("alpine:init", () => {
    Alpine.data("planDeCiclo", () => ({
      /* ---------- Memoria del componente (lo que la pantalla muestra) ----------
         ocupado: acción en curso ("" = ninguna) · error / exito / avisos: mensajes · ciclos, ciclo, carreras: la tabla
         pantalla: "tabla" o "plan" · doc: el plan abierto (modo, versión, bloqueo) · plan: { asignaturas } en edición
         texto: lo pegado · lectura: resultado de interpretar · cambios: hay algo sin guardar · actividad: tocaste algo
         (lo mira el latido) · temporizador: el latido de 5 min · verHistorial, versiones, vistaVersion: el historial
         desdobleDe, fusionDe: los diálogos abiertos */
      listo: false, ocupado: "", error: "", exito: "", avisos: [], ciclos: [], ciclo: "", carreras: [],
      pantalla: "tabla", doc: null, plan: { asignaturas: [] }, texto: "", lectura: null, cambios: false, actividad: false,
      temporizador: null, verHistorial: false, versiones: [], vistaVersion: null, desdobleDe: null, fusionDe: null,

Arranque y carga inicial

PlanDeCicloVista.html · init() · fragmento del proyecto abierto · líneas 238–251
      init() {
        /* La primera vez que se muestra esta vista (botón de la barra o de Inicio cambia $store.nav.vista), pide la tabla.
           ➡ SIGUE EN inicio(), justo abajo. */
        this.$watch("$store.nav.vista", v => { if (v === "plan" && !this.listo) this.inicio(); });
        if (Alpine.store("nav").vista === "plan") this.inicio();
        /* Si se cierra o recarga la pestaña con un plan en edición, intenta soltar el bloqueo sin esperar respuesta.
           📡 → SERVIDOR: PlanDeCiclo.js → planSalir. Si no alcanza a salir, el bloqueo vence solo a los 30 minutos. */
        window.addEventListener("pagehide", () => {
          if (this.doc && this.doc.modo === "edicion" && this.pantalla === "plan")
            new Promise((listo, mal) => google.script.run.withSuccessHandler(listo).withFailureHandler(mal)
              ["planSalir"](this.doc.ciclo, this.doc.carrera)).catch(() => {});
        });
      },
PlanDeCicloVista.html · inicio() · fragmento del proyecto abierto · líneas 255–273
      async inicio() {
        /* PASO 1 · Botones en espera: «Cargando…» junto al título y selector deshabilitado. */
        this.ocupado = "inicio";
        this.error = "";
        this.actividad = true;
        let d = null;
        try {
          /* PASO 2 · 📡 → SERVIDOR: PlanDeCiclo.js → planInicio(ciclo). Lee Usuarios, Planes, Bloqueos, Ciclos y Carreras. */
          const r = await new Promise((listo, mal) => google.script.run
            .withSuccessHandler(listo)
            .withFailureHandler(mal)
            ["planInicio"](this.ciclo));
          /* PASO 3 · ⬅ VUELVE con { ciclos, ciclo, carreras } o con un error. */
          if (!r || !r.ok) throw new Error((r && r.error) || "Respuesta vacía del servidor.");
          d = r.datos;
        } catch (e) { this.error = (e && e.message) || String(e); }
        finally { this.ocupado = ""; }
        /* PASO 4 · La tabla se repinta sola (x-for="c in carreras"). listo = true: no se vuelve a pedir al volver a la vista. */
        if (d) Object.assign(this, { ciclos: d.ciclos, ciclo: d.ciclo, carreras: d.carreras, listo: true });

Editar o Ver · componente

PlanDeCicloVista.html · abrir(sigla) · fragmento del proyecto abierto · líneas 279–307
      async abrir(sigla) {
        /* PASO 1 · Limpia los mensajes y pone el botón de esa fila en «Abriendo…». */
        this.exito = "";
        this.avisos = [];
        this.ocupado = "abrir " + sigla;
        this.error = "";
        this.actividad = true;
        let d = null;
        try {
          /* PASO 2 · 📡 → SERVIDOR: PlanDeCiclo.js → planAbrir(ciclo, sigla). Decide edición o lectura; en edición escribe
             tu bloqueo en la pestaña Bloqueos. */
          const r = await new Promise((listo, mal) => google.script.run
            .withSuccessHandler(listo)
            .withFailureHandler(mal)
            ["planAbrir"](this.ciclo, sigla));
          /* PASO 3 · ⬅ VUELVE con { modo, motivo, vence, version, plan… }. */
          if (!r || !r.ok) throw new Error((r && r.error) || "Respuesta vacía del servidor.");
          d = r.datos;
        } catch (e) { this.error = (e && e.message) || String(e); }
        finally { this.ocupado = ""; }
        if (!d) return;
        /* PASO 4 · Cierra lo que hubiera abierto antes (latido, historial, diálogos). */
        clearInterval(this.temporizador);
        Object.assign(this, { temporizador: null, pantalla: "tabla", verHistorial: false, versiones: [], vistaVersion: null, desdobleDe: null, fusionDe: null });
        /* PASO 5 · Muestra la pantalla del plan. Desde aquí, todo cambio vive en this.plan hasta pulsar «Guardar». */
        Object.assign(this, { doc: d, plan: d.plan, texto: "", lectura: null, cambios: false, actividad: false, pantalla: "plan" });
        /* PASO 6 · En edición, programa el latido: cada 5 minutos → latido() (abajo) → mantener() → planMantener. */
        if (d.modo === "edicion") this.temporizador = setInterval(() => this.latido(), 5 * 60000);
      },

Interpretar · componente

PlanDeCicloVista.html · interpretar() · fragmento del proyecto abierto · líneas 351–378
      async interpretar() {
        /* PASO 1 · «Interpretando…»; también se deshabilita «Elegir archivo». */
        this.ocupado = "interpretar";
        this.error = "";
        this.actividad = true;
        let d = null;
        try {
          /* PASO 2 · 📡 → SERVIDOR: PlanDeCiclo.js → planInterpretar(texto, carrera). No escribe nada en el Sheet. */
          const r = await new Promise((listo, mal) => google.script.run
            .withSuccessHandler(listo)
            .withFailureHandler(mal)
            ["planInterpretar"](this.texto, this.doc.carrera));
          /* PASO 3 · ⬅ VUELVE con { asignaturas, filas, descartadas, avisos }. */
          if (!r || !r.ok) throw new Error((r && r.error) || "Respuesta vacía del servidor.");
          d = r.datos;
        } catch (e) { this.error = (e && e.message) || String(e); }
        finally { this.ocupado = ""; }
        /* PASO 4 · Si ya había un plan, pregunta antes de reemplazarlo (se pierden las fusiones propias). */
        if (!d || (this.plan.asignaturas.length && !confirm("Esto reemplaza todo el plan, fusiones incluidas. ¿Continuar?"))) return;
        /* PASO 5 · Conserva las marcas de fusiones que hicieron OTRAS carreras (fusionExterna): no son tuyas. */
        const externas = {};
        this.plan.asignaturas.forEach(a => a.grupos.forEach(g => { if (g.fusionExterna) externas[a.codigo + "|" + g.n] = g.fusionExterna; }));
        d.asignaturas.forEach(a => a.grupos.forEach(g => { if (externas[a.codigo + "|" + g.n]) g.fusionExterna = externas[a.codigo + "|" + g.n]; }));
        /* PASO 6 · ✎ SOLO MEMORIA: reemplaza this.plan; la previsualización por semestre se repinta y aparece
           «Hay cambios sin guardar». */
        Object.assign(this, { plan: { asignaturas: d.asignaturas }, lectura: d, cambios: true });
      },

Subir CSV · componente

PlanDeCicloVista.html · leerArchivo(evento) · fragmento del proyecto abierto · líneas 382–397
      async leerArchivo(evento) {
        /* PASO 1 · El archivo elegido. Se vacía el campo enseguida para poder volver a elegir el mismo archivo. */
        const archivo = evento.target.files[0];
        evento.target.value = "";
        if (!archivo) return;
        if (!/\.csv$/i.test(archivo.name)) { this.error = "Solo se aceptan archivos .csv; para .xlsx copia el rango y pégalo."; return; }
        /* PASO 2 · Se lee EN EL NAVEGADOR (no se sube a Drive). UTF-8 estricto; si falla, Windows-1252 (como lo guarda
           Excel en Windows), para que las tildes salgan bien. */
        const bytes = await archivo.arrayBuffer();
        let texto;
        try { texto = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
        catch (e) { texto = new TextDecoder("windows-1252").decode(bytes); }
        /* PASO 3 · Se quita la marca BOM y se pone en el área de texto (x-model="texto"). ➡ SIGUE EN interpretar() (arriba). */
        this.texto = texto.replace(/^/, "");
        await this.interpretar();
      },

Lo que muestra la pantalla · valores calculados

PlanDeCicloVista.html · getters · fragmento del proyecto abierto · líneas 553–571
      get editable() { return !!this.doc && this.doc.modo === "edicion" && !this.vistaVersion; },
      /* puedeEditarActual: «Restaurar» del historial solo aparece en tus carreras. */
      get puedeEditarActual() { return !!this.doc && this.carreras.some(c => c.sigla === this.doc.carrera && c.puedeEditar); },
      /* mostrado: el plan que dibuja la tabla: la versión del historial que estás viendo, o tu plan en edición. */
      get mostrado() { return this.vistaVersion ? this.vistaVersion.plan : this.plan; },
      /* totales: «68 asignaturas, 77 grupos» sobre la tabla. */
      get totales() { const a = this.mostrado.asignaturas; return a.length + " asignaturas, " + a.reduce((n, x) => n + x.grupos.length, 0) + " grupos"; },
      /* semestres: la tabla agrupada. Un bloque por semestre (en el orden en que vienen las asignaturas) con una fila por
         grupo; «primera» marca el primer grupo de cada asignatura (ahí va el botón «+ Grupo»). */
      get semestres() {
        const lista = [], porTitulo = {};
        this.mostrado.asignaturas.forEach(a => {
          const titulo = a.semestre === null ? "Sin semestre" : "Semestre " + a.semestre;
          if (!porTitulo[titulo]) lista.push(porTitulo[titulo] = { clave: titulo, titulo, asignaturas: 0, filas: [] });
          porTitulo[titulo].asignaturas++;
          a.grupos.forEach((g, i) => porTitulo[titulo].filas.push({ asig: a, g, primera: i === 0, clave: a.codigo + "|" + a.nombre + "|" + g.n }));
        });
        lista.forEach(s => { s.titulo += " · " + s.asignaturas + " asignaturas, " + s.filas.length + " grupos"; });
        return lista;

Acciones sobre grupos · componente

PlanDeCicloVista.html · acciones sobre grupos · fragmento del proyecto abierto · líneas 595–647
      agregarGrupo(asig) {
        /* Número = el mayor + 1 (no rellena huecos). Horas = las del grupo de número más bajo. Sin desdoble ni fusión. */
        const base = asig.grupos.reduce((m, g) => (g.n < m.n ? g : m)), n = Math.max(...asig.grupos.map(g => g.n)) + 1;
        asig.grupos.push({ n, ht: base.ht, hp: base.hp, th: base.th, obs: "", avisos: [], desdoble: null, fusion: null });
        this.cambios = true; this.actividad = true; this.error = "";
        return n;
      },

      /* ---------- Botón «Quitar» ---------- */
      uiQuitarGrupo(asig, grupo) {
        /* PASO 1 · Ventana de confirmación del navegador, con un aviso si el grupo está en una fusión (propia o de otra carrera). */
        const extra = grupo.fusion ? " Está en una fusión: se deshará." :
          grupo.fusionExterna ? " " + grupo.fusionExterna.carrera + " lo tiene en una fusión que quedará incompleta." : "";
        /* PASO 2 · Aceptar ➡ SIGUE EN quitarGrupo(), justo abajo. */
        if (confirm("¿Quitar el grupo " + grupo.n + " de " + asig.codigo + " " + asig.nombre + "?" + extra)) this.quitarGrupo(asig, grupo);
      },
      quitarGrupo(asig, grupo) {
        /* Una asignatura no puede quedarse sin grupos (el botón ya está deshabilitado en ese caso). */
        if (asig.grupos.length < 2) return false;
        /* Si el grupo estaba en una fusión, se deshace en todos los grupos de esa fusión. */
        if (grupo.fusion) {
          const id = grupo.fusion.id;
          this.plan.asignaturas.forEach(a => a.grupos.forEach(g => { if (g.fusion && g.fusion.id === id) g.fusion = null; }));
        }
        /* Se saca del arreglo: la fila desaparece. Los demás grupos no se renumeran. */
        asig.grupos.splice(asig.grupos.indexOf(grupo), 1);
        this.cambios = true; this.actividad = true; this.error = "";
        return true;
      },

      /* ---------- Botón «Desdoblar…» → diálogo → botón «Desdoblar» ---------- */
      abrirDesdoble(asig, g) {
        /* Abre el diálogo (x-if="desdobleDe"). Propone práctica (P) si el grupo tiene HP; si no, teoría (T). */
        this.error = "";
        this.desdobleDe = { titulo: asig.codigo + "-" + g.n + " " + asig.nombre, grupo: g, n: 2, componente: typeof g.hp === "number" && g.hp > 0 ? "P" : "T" };
      },
      aplicarDesdoble() {
        /* Botón «Desdoblar» del diálogo ➡ desdoblar(), justo abajo. Si sale bien, se cierra el diálogo. */
        if (this.desdoblar(this.desdobleDe.grupo, this.desdobleDe.componente, Number(this.desdobleDe.n))) this.desdobleDe = null;
      },
      desdoblar(grupo, componente, n) {
        /* Mismas reglas que comprueba el servidor al guardar: P exige HP > 0, T exige HT > 0, de 2 a 9 subgrupos.
           En la Carga docente, el grupo tendrá P1…Pn (o T1…Tn), cada uno con su docente. */
        const horas = componente === "P" ? grupo.hp : grupo.ht;
        if (componente !== "P" && componente !== "T") this.error = "Elige práctica (P) o teoría (T).";
        else if (typeof horas !== "number" || horas <= 0) this.error = "Para desdoblar la " + (componente === "P" ? "práctica el grupo necesita HP" : "teoría el grupo necesita HT") + " mayor que 0.";
        else if (!Number.isInteger(n) || n < 2 || n > 9) this.error = "El desdoble va de 2 a 9 subgrupos.";
        else { grupo.desdoble = { componente, n }; this.cambios = true; this.actividad = true; this.error = ""; return true; }
        return false;
      },

      /* ---------- Botón «Quitar desdoble» ---------- */
      quitarDesdoble(grupo) { grupo.desdoble = null; this.cambios = true; this.actividad = true; this.error = ""; },

Fusionar grupos: de la selección al resultado

Fusionar permite indicar que varios grupos se dictan juntos. Se elige un grupo principal; sus horas representan la sección compartida.

PlanDeCicloVista.html · abrirFusion y candidatosFusion · líneas 654–680
      abrirFusion(asig, g) {
        const clave = this.doc.carrera + "|" + asig.codigo + "|" + g.n;
        this.error = "";
        this.fusionDe = { titulo: asig.codigo + "-" + g.n + " " + asig.nombre, marcados: [clave], principal: clave, filtro: "", externos: [] };
      },

      /* ---------- Botón «Buscar en otras carreras» del diálogo ---------- */
      async candidatosFusion() {
        /* PASO 1 · «Buscando…». */
        this.ocupado = "candidatos";
        this.error = "";
        this.actividad = true;
        let d = null;
        try {
          /* PASO 2 · 📡 → SERVIDOR: PlanDeCiclo.js → planCandidatosFusion(ciclo, carrera). Lee los planes de las otras carreras. */
          const r = await new Promise((listo, mal) => google.script.run
            .withSuccessHandler(listo)
            .withFailureHandler(mal)
            ["planCandidatosFusion"](this.doc.ciclo, this.doc.carrera));
          /* PASO 3 · ⬅ VUELVE con { candidatos }. */
          if (!r || !r.ok) throw new Error((r && r.error) || "Respuesta vacía del servidor.");
          d = r.datos;
        } catch (e) { this.error = (e && e.message) || String(e); }
        finally { this.ocupado = ""; }
        /* PASO 4 · Se agregan a la lista del diálogo (opcionesFusion, abajo). */
        if (d && this.fusionDe) this.fusionDe.externos = d.candidatos;
      },
PlanDeCicloVista.html · aplicarFusion y validaciones de fusionar · líneas 719–750
      aplicarFusion() {
        /* PASO 1 · Los grupos marcados y el principal elegido (radio). */
        const sel = this.opcionesFusion(true).filter(o => this.fusionDe.marcados.includes(o.clave));
        const s = { sel, p: sel.find(o => o.clave === this.fusionDe.principal) || null };
        /* PASO 2 · Si no coinciden en todo, pregunta (advertenciasDialogo, arriba). */
        const adv = this.advertenciasDialogo(s);
        if (adv.length && !confirm("Los grupos no coinciden en todo:\n" + adv.join("\n") + "\n¿Fusionar de todos modos?")) return;
        /* PASO 3 · ➡ SIGUE EN fusionar(), justo abajo. Si sale bien, se cierra el diálogo. */
        if (this.fusionar(s.sel, s.p)) this.fusionDe = null;
      },

      /* ---------- La fusión en memoria (✎ SOLO MEMORIA) ----------
         seleccion: grupos { carrera, codigo, grupo }; los de otras carreras traen sus datos (de planCandidatosFusion).
         Devuelve las advertencias, o null si no se pudo fusionar (el motivo queda en this.error). */
      fusionar(seleccion, principal) {
        const mismo = (a, b) => a.carrera === b.carrera && a.codigo === b.codigo && a.grupo === b.grupo;
        const normalizar = s => String(s).normalize("NFD").replace(/[̀-ͯ]/g, "").toUpperCase().replace(/\s+/g, " ").trim();
        const enFusionLocal = r => this.plan.asignaturas.some(a => a.grupos.some(g => g.fusion && g.fusion.miembros.some(m => mismo(m, r))));
        const carrera = this.doc.carrera, miembros = [];
        /* PASO 1 · Cada grupo elegido (sin repetir): los de esta carrera tienen que existir y no estar ya fusionados; los de
           otra carrera, no estar en otra fusión. Se copian nombre y horas. */
        for (const r of seleccion) {
          if (miembros.some(m => mismo(m, r))) continue;
          const local = r.carrera === carrera, x = local ? this.buscarGrupo(r.codigo, r.grupo) : null;
          if (local && (!x || x.g.fusion || x.g.fusionExterna)) { this.error = "El grupo " + r.codigo + "-" + r.grupo + " no existe o ya está fusionado."; return null; }
          if (!local && (r.fusionadoEn || enFusionLocal(r))) { this.error = r.carrera + " " + r.codigo + "-" + r.grupo + " ya está fusionado."; return null; }
          const d = local ? { nombre: x.a.nombre, ht: x.g.ht, hp: x.g.hp, th: x.g.th } : r;
          miembros.push({ carrera: r.carrera, codigo: r.codigo, grupo: r.grupo, nombre: d.nombre, ht: d.ht, hp: d.hp, th: d.th });
        }
        /* PASO 2 · Al menos dos grupos, al menos uno de esta carrera, y el principal entre los elegidos. */
        if (miembros.length < 2) { this.error = "Elige al menos dos grupos."; return null; }
        if (!miembros.some(m => m.carrera === carrera)) { this.error = "La fusión debe incluir al menos un grupo de " + carrera + "."; return null; }
PlanDeCicloVista.html · guardar la fusión en memoria y deshacer · líneas 751–776
        const p = principal ? miembros.find(m => mismo(m, principal)) : null;
        if (!p) { this.error = "Elige el grupo principal entre los elegidos."; return null; }
        /* PASO 3 · id = «F» + (el mayor número usado + 1). El mismo objeto se copia en cada grupo de ESTA carrera. Los de
           otras carreras solo quedan dentro de este objeto: su plan no cambia (la otra carrera lo verá como
           «FUS – fusionado con …», calculado por el servidor al abrir). */
        let mayor = 0;
        this.plan.asignaturas.forEach(a => a.grupos.forEach(g => { if (g.fusion) mayor = Math.max(mayor, Number(g.fusion.id.slice(1))); }));
        const fusion = { id: "F" + (mayor + 1), principal: { carrera: p.carrera, codigo: p.codigo, grupo: p.grupo }, miembros };
        miembros.filter(m => m.carrera === carrera).forEach(m => { this.buscarGrupo(m.codigo, m.grupo).g.fusion = JSON.parse(JSON.stringify(fusion)); });
        this.cambios = true; this.actividad = true; this.error = "";
        /* PASO 4 · Devuelve las mismas advertencias que muestra el diálogo (nombre, código u horas distintos al principal). */
        return miembros.filter(m => m !== p).map(m => {
          const dif = [];
          if (normalizar(m.nombre) !== normalizar(p.nombre)) dif.push("otro nombre («" + m.nombre + "»)");
          if (m.codigo !== p.codigo) dif.push("otro código");
          if (m.ht !== p.ht || m.hp !== p.hp || m.th !== p.th) dif.push("otras horas (HT " + m.ht + ", HP " + m.hp + ", TH " + m.th + ")");
          return dif.length ? m.carrera + " " + m.codigo + "-" + m.grupo + ": " + dif.join(", ") + "." : "";
        }).filter(String);
      },

      /* ---------- Botón «Deshacer fusión» (✎ SOLO MEMORIA): quita la fusión de todos los grupos que la llevan ---------- */
      deshacerFusion(id) {
        this.plan.asignaturas.forEach(a => a.grupos.forEach(g => { if (g.fusion && g.fusion.id === id) g.fusion = null; }));
        this.cambios = true; this.actividad = true; this.error = "";
      }
    }));

PlanDeCiclo.js · cabecera

PlanDeCiclo.js · constantes · fragmento del proyecto abierto · líneas 46–59
/** Dominio de las cuentas que pueden entrar. */
const PLAN_DOMINIO_ = "@ulasalle.edu.pe";
/** Caracteres que caben en una celda del Sheet: el JSON de cada versión va entero en una sola celda. */
const PLAN_MAX_CELDA_ = 50000;
/** Topes al guardar un plan (planGuardar, PASO 3): asignaturas, grupos por asignatura y largo de los textos. */
const PLAN_LIMITES_ = { asignaturas: 400, grupos: 30, nombre: 200, obs: 300, codigo: 40, texto: 20, avisos: 10 };
/** Mensaje cuando tu bloqueo ya no es tuyo (planGuardar y planMantener). La vista lo reconoce por «Tu bloqueo». */
const PLAN_SIN_BLOQUEO_ = "Tu bloqueo venció o lo tomó otra cuenta: lo que no guardaste se perdió.";
/* Cabecera del Excel (sin tildes, en mayúsculas) → campo del plan. La segunda «HT» se trata aparte: es el total
   (planInterpretar, PASO 4). */
const PLAN_COLUMNAS_ = {
  CARRERA: "carrera", MALLA: "malla", SEMESTRE: "semestre", CODIGO: "codigo", ASIGNATURA: "asignatura", HT: "ht", HP: "hp",
  "TOTAL HORAS": "th", TH: "th", "TOTAL DE HORAS": "th", DPTO: "dpto", OBSERVACIONES: "obs", OBSERVACION: "obs"
};

Servidor · planInicio(ciclo)

PlanDeCiclo.js · autenticación y ciclo · fragmento del proyecto abierto · líneas 74–96
function planInicio(ciclo) {
  const fallo = mensaje => { throw Object.assign(new Error(mensaje), { planPrevisto: true }); };
  const pestana = nombre => SpreadsheetApp.getActiveSpreadsheet().getSheetByName(nombre) || fallo("Falta la pestaña «" + nombre + "» en la hoja.");
  const leer = nombre => pestana(nombre).getDataRange().getDisplayValues().slice(1).map(f => f.map(c => String(c).trim()));
  try {
    /* PASO 1 · ¿Quién eres? Google da el correo de la sesión; tiene que ser de la universidad. */
    const correo = String(Session.getActiveUser().getEmail() || "").trim().toLowerCase();
    if (!correo) fallo("Acceso denegado: cuenta no identificada; entra con tu cuenta " + PLAN_DOMINIO_ + ".");
    if (correo.slice(-PLAN_DOMINIO_.length) !== PLAN_DOMINIO_) fallo("Acceso denegado: cuenta fuera del dominio " + PLAN_DOMINIO_ + ".");
    /* 📥 SHEET · LEE «Usuarios»: tu fila dice tu rol (columna B) y tus carreras (columna C, separadas por coma). */
    const filaUsuario = leer("Usuarios").filter(u => u[0].toLowerCase() === correo)[0];
    const rol = filaUsuario ? filaUsuario[1].toLowerCase() : "";
    if (rol !== "coordinador" && rol !== "director")
      fallo("Acceso denegado: cuenta sin rol; " + correo + " no figura como coordinador ni director en la pestaña Usuarios.");
    const misCarreras = String(filaUsuario[2] || "").split(",").map(s => s.trim().toUpperCase()).filter(String);

    /* PASO 2 · 📥 SHEET · LEE «Planes» y «Bloqueos» enteras, una sola vez. Lo demás se cruza en memoria. */
    const planes = leer("Planes"), bloqueos = leer("Bloqueos");

    /* PASO 3 · 📥 SHEET · LEE «Ciclos». Sin ciclo elegido (la primera vez) se usa el último de la lista. */
    const ciclos = leer("Ciclos").map(f => f[0]).filter(String), pedido = String(ciclo == null ? "" : ciclo).trim();
    if (!pedido) ciclo = ciclos[ciclos.length - 1] || fallo("La pestaña Ciclos está vacía.");
    else ciclo = ciclos.indexOf(pedido) >= 0 ? pedido : fallo("Ciclo desconocido: «" + pedido + "».");
PlanDeCiclo.js · construcción de la tabla · fragmento del proyecto abierto · líneas 99–119
    const carreras = leer("Carreras").filter(f => f[0]).map(f => {
      const sigla = f[0].toUpperCase();
      /* 4a · Versión vigente = la de número más alto de esa carrera en ese ciclo (Planes es de solo agregar). */
      const v = planes.filter(p => p[0] === ciclo && p[1] === sigla)
        .map(p => ({ version: Number(p[2]), usuario: p[3], fecha: p[4], contenido: p[5] })).sort((a, b) => b.version - a.version)[0];
      /* 4b · El contenido (columna F) es el JSON del plan: aquí solo se usa para contar asignaturas y grupos. */
      const plan = v ? JSON.parse(v.contenido) : { asignaturas: [] };
      /* 4c · ¿Alguien la edita? Su fila de «Bloqueos» cuenta solo si la hora «vence» todavía no pasó. */
      const b = bloqueos.find(x => x[0] === "plan" && x[1] === ciclo && x[2] === sigla);
      return {
        sigla, nombre: f[1], departamento: f[2].toUpperCase(),
        version: v ? v.version : 0, usuario: v ? v.usuario : "", fecha: v ? v.fecha : "",
        asignaturas: plan.asignaturas.length, grupos: plan.asignaturas.reduce((n, a) => n + a.grupos.length, 0),
        editor: b && Date.parse(b[4]) > Date.now() ? b[3].toLowerCase() : "",
        /* 4d · Solo el coordinador edita, y solo sus carreras: en la tabla el botón dirá «Editar» (azul) o «Ver». */
        puedeEditar: rol === "coordinador" && misCarreras.indexOf(sigla) >= 0
      };
    });

    /* PASO 5 · ↩ RESPUESTA. ➡ SIGUE EN PlanDeCicloVista.html → inicio(): la guarda en this.carreras y x-for pinta la tabla. */
    return { ok: true, datos: { ciclos, ciclo, carreras } };

Servidor · planAbrir(ciclo, carrera)

PlanDeCiclo.js · permisos y bloqueo · fragmento del proyecto abierto · líneas 169–198
    /* PASO 4 · ¿Puedes editarla? Solo el coordinador, y solo sus carreras. */
    const puede = rol === "coordinador" && misCarreras.indexOf(sigla) >= 0;

    /* PASO 5 · 🔒 CANDADO: decide edición o lectura y, si es edición, escribe el bloqueo sin que otro se cruce. */
    const candado = LockService.getScriptLock();
    try { candado.waitLock(10000); } catch (e) { fallo("La hoja está ocupada con otro guardado; vuelve a intentarlo en unos segundos."); }
    let acceso;
    try {
      /* 5a · 📥 SHEET · LEE «Bloqueos»: la fila de este plan (cu = plan, ciclo, carrera), si existe. fila = número real
         en el Sheet (+1 por la cabecera, +1 porque las filas empiezan en 1). */
      const filasBloqueo = leer("Bloqueos");
      const i = filasBloqueo.findIndex(f => f[0] === "plan" && f[1] === ciclo && f[2] === sigla);
      const b = i < 0 ? null : { fila: i + 2, correo: filasBloqueo[i][3].toLowerCase(), vence: filasBloqueo[i][4], vigente: Date.parse(filasBloqueo[i][4]) > Date.now() };
      const vigente = b && b.vigente ? b : null;
      if (puede && (!vigente || vigente.correo === correo)) {
        /* 5b · EDICIÓN. 📤 SHEET · ESCRIBE en «Bloqueos» [plan, ciclo, carrera, tu correo, ahora + 30 min]. Si ya
           había una fila de este plan (aunque fuera de otra persona y estuviera vencida) se reescribe esa; si no, se agrega. */
        const vence = fechaPeru(Date.now() + 30 * 60000);
        const celdas = ["plan", ciclo, sigla, correo, vence].map(comoTexto);
        if (b) pestana("Bloqueos").getRange(b.fila, 1, 1, celdas.length).setNumberFormat("@").setValues([celdas]);
        else pestana("Bloqueos").appendRow(celdas);
        acceso = { modo: "edicion", motivo: "", editor: correo, vence };
      } else {
        /* 5c · LECTURA: no es tu carrera, u otra cuenta tiene el bloqueo vigente. No se escribe nada. */
        acceso = {
          modo: "lectura", motivo: puede ? "En edición por " + vigente.correo : "Solo lectura: no es una de tus carreras",
          editor: vigente ? vigente.correo : "", vence: vigente ? vigente.vence : ""
        };
      }
    } finally { candado.releaseLock(); }
PlanDeCiclo.js · lectura del plan · fragmento del proyecto abierto · líneas 200–205
    /* PASO 6 · 📥 SHEET · LEE «Planes»: la versión vigente de esta carrera (la de número más alto) y su JSON. */
    const planes = leer("Planes");
    const versiones = sig => planes.filter(f => f[0] === ciclo && f[1] === sig)
      .map(f => ({ version: Number(f[2]), usuario: f[3], fecha: f[4], contenido: f[5] })).sort((a, b) => b.version - a.version);
    const v = versiones(sigla)[0];
    const plan = v ? JSON.parse(v.contenido) : { asignaturas: [] };

Servidor · planInterpretar(texto, carrera)

Esta función convierte el texto pegado o leído del CSV en asignaturas y grupos. Lee información para validar, pero no escribe el plan en la hoja.

PlanDeCiclo.js · validación · fragmento del proyecto abierto · líneas 245–266
function planInterpretar(texto, carrera) {
  const fallo = mensaje => { throw Object.assign(new Error(mensaje), { planPrevisto: true }); };
  const pestana = nombre => SpreadsheetApp.getActiveSpreadsheet().getSheetByName(nombre) || fallo("Falta la pestaña «" + nombre + "» en la hoja.");
  const leer = nombre => pestana(nombre).getDataRange().getDisplayValues().slice(1).map(f => f.map(c => String(c).trim()));
  /* Texto para comparar: mayúsculas, sin tildes ni diéresis, espacios y saltos de línea reducidos a uno. */
  const N = s => String(s == null ? "" : s).normalize("NFD").replace(/[̀-ͯ]/g, "").toUpperCase().replace(/\s+/g, " ").trim();
  try {
    /* PASO 1 · ¿Quién eres? (correo, dominio y tu fila de 📥 «Usuarios»). Basta con tener rol. */
    const correo = String(Session.getActiveUser().getEmail() || "").trim().toLowerCase();
    if (!correo) fallo("Acceso denegado: cuenta no identificada; entra con tu cuenta " + PLAN_DOMINIO_ + ".");
    if (correo.slice(-PLAN_DOMINIO_.length) !== PLAN_DOMINIO_) fallo("Acceso denegado: cuenta fuera del dominio " + PLAN_DOMINIO_ + ".");
    const filaUsuario = leer("Usuarios").filter(u => u[0].toLowerCase() === correo)[0];
    const rol = filaUsuario ? filaUsuario[1].toLowerCase() : "";
    if (rol !== "coordinador" && rol !== "director")
      fallo("Acceso denegado: cuenta sin rol; " + correo + " no figura como coordinador ni director en la pestaña Usuarios.");

    /* PASO 2 · ¿Qué carrera? 📥 SHEET · LEE «Carreras». Se usan su sigla y su nombre para reconocer sus filas. */
    const sigla = String(carrera == null ? "" : carrera).trim().toUpperCase();
    const c = leer("Carreras").filter(f => f[0]).map(f => ({ sigla: f[0].toUpperCase(), nombre: f[1], departamento: f[2].toUpperCase() }))
      .filter(x => x.sigla === sigla)[0] || fallo("Carrera desconocida: «" + sigla + "».");
    if (!String(texto == null ? "" : texto).trim()) fallo("Pega el rango o sube un CSV.");
PlanDeCiclo.js · separador · fragmento del proyecto abierto · líneas 271–273
    texto = String(texto).replace(/^/, "").replace(/\r\n?/g, "\n");
    const lineas = texto.split("\n").filter(l => l.trim() !== ""), muestra = lineas.slice(0, 5).join("\n");
    const sep = lineas.length && lineas[0].indexOf("\t") >= 0 ? "\t" : muestra.split(",").length > muestra.split(";").length ? "," : ";";
PlanDeCiclo.js · cabeceras · fragmento del proyecto abierto · líneas 294–305
    /* PASO 4 · Buscar la fila de cabeceras (la primera con CODIGO y ASIGNATURA, aunque haya títulos encima) y decidir
       qué columna es cada campo. La primera «HT» es teoría; la segunda, si no hay TOTAL HORAS, es el total. */
    const col = {};
    const iCab = filas.findIndex(f => { const n = f.map(N); return n.indexOf("CODIGO") >= 0 && n.indexOf("ASIGNATURA") >= 0; });
    if (iCab < 0) fallo("No encontré la fila de cabeceras (CODIGO, ASIGNATURA…).");
    let segundaHt;
    filas[iCab].forEach((celda, j) => {
      const campo = PLAN_COLUMNAS_[N(celda)];
      if (campo === "ht" && col.ht !== undefined) { if (segundaHt === undefined) segundaHt = j; }
      else if (campo && col[campo] === undefined) col[campo] = j;
    });
    if (col.th === undefined) col.th = segundaHt;
PlanDeCiclo.js · lectura de filas · fragmento del proyecto abierto · líneas 320–346
    const porClave = Object.create(null), asignaturas = [];
    let leidas = 0, descartadas = 0, conAvisos = 0;
    filas.slice(iCab + 1).forEach(f => {
      const codigo = codigoDe(celda(f, "codigo")), nombre = celda(f, "asignatura"), otra = N(celda(f, "carrera"));
      if (!codigo && !nombre) return;
      leidas++;
      if (otra && otra !== c.sigla && otra !== N(c.nombre)) { descartadas++; return; }
      /* SEMESTRE «4» = semestre 4, grupo 1; «1-2» = semestre 1, grupo 2. */
      const avisos = [], textoSem = celda(f, "semestre"), sem = /^(\d{1,2})(?:\s*-\s*(\d{1,2}))?$/.exec(textoSem);
      if (!sem) avisos.push(textoSem ? "Semestre no reconocido: «" + textoSem + "»" : "Sin semestre");
      const ht = hora(celda(f, "ht"), "HT", avisos), hp = hora(celda(f, "hp"), "HP", avisos);
      const numeros = typeof ht === "number" && typeof hp === "number";
      const th = celda(f, "th") ? hora(celda(f, "th"), "TOTAL", avisos) : numeros ? ht + hp : "";
      if (numeros && typeof th === "number" && Math.abs(th - ht - hp) > 1e-9) avisos.push("TOTAL (" + th + ") no es HT + HP (" + (ht + hp) + ")");
      if (!codigo) avisos.push("Sin código");
      const clave = codigo || "NOMBRE " + N(nombre);
      let a = porClave[clave];
      if (!a) asignaturas.push(a = porClave[clave] = { codigo, nombre, malla: celda(f, "malla"), semestre: sem ? Number(sem[1]) : null, dpto: celda(f, "dpto"), grupos: [] });
      else if (N(nombre) !== N(a.nombre)) avisos.push("Nombre distinto al de la primera fila: «" + nombre + "»");
      /* Grupo repetido (o 0): se le da el siguiente libre, con aviso. */
      const libre = Math.max(0, ...a.grupos.map(g => g.n)) + 1;
      let n = sem ? Number(sem[2] || 1) : libre;
      if (!n || a.grupos.some(g => g.n === n)) {
        avisos.push((n ? "Grupo " + n + " repetido" : "Grupo 0 no válido") + ": se numeró como " + libre);
        n = libre;
      }
      a.grupos.push({ n, ht, hp, th, obs: celda(f, "obs"), avisos, desdoble: null, fusion: null });