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.
Primero, mira la pantalla
Al entrar en «Registrar plan de ciclo» aparece un selector. Su valor indica qué ciclo se está consultando.

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.
<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 listaciclos.x-text="c"escribe el nombre de cada opción;:selectedidentifica la opción activa.@changeescucha el cambio, guarda el valor elegido encicloy ejecutainicio().:disabledbloquea 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.
@change="ciclo = $event.target.value; inicio()"@changeAlpine escucha el evento change: ocurre cuando cambias la opción seleccionada. El símbolo @ es una forma abreviada de indicar «escucha este evento».
$eventEs el objeto del evento que Alpine pone a disposición de esta expresión. Contiene información sobre lo que acaba de pasar.
.targetDentro del evento, target señala el elemento que originó el cambio. En este caso es el <select> del ciclo.
.valueEs 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.
2026-1 → $event.target.value vale "2026-1" → se guarda en ciclo → se ejecuta inicio().Otras piezas del selector
x-for="c in ciclos": recorre la listaciclos;crepresenta una opción en cada vuelta. Por ejemplo, si la lista contiene2026-1y2026-2, se crean dos opciones.:value="c": asigna a cada opción el valor guardado enc. Los dos puntos indican a Alpine que evalúe la expresión.:selected="c === ciclo":===compara si ambos valores son iguales. La opción queda seleccionada cuandoccoincide con el ciclo actual.:key="c": da a Alpine una clave para distinguir cada opción de la lista cuando esta cambia.:disabled="ocupado || pantalla !== 'tabla'":||significa «o» y!==significa «distinto de». Se deshabilita si hay una operación en curso o la pantalla actual no es la tabla.
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.
1. La vista
El usuario elige un ciclo. El evento @change actualiza ciclo y llama a inicio().
2. El componente
inicio() llama a planInicio(this.ciclo) mediante google.script.run y espera la respuesta.
3. El servidor
planInicio valida al usuario, consulta las hojas y devuelve { ciclos, ciclo, carreras }.
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.
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.
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.
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.
12 <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>x-show="pantalla === 'tabla'"muestra esta zona cuando la variablepantallavaletabla.x-for="c in carreras"recorre el arreglocarreras;ces la carrera de la vuelta actual.:key="c.sigla"identifica la fila por su sigla.<tr>es una fila y<td>una celda.x-text="c.sigla"yx-text="c.nombre"escriben las propiedades del objeto.c.version ? 'v' + c.version : 'Sin plan'es una condición abreviada: si existe versión muestra, por ejemplo,v1; si no, muestra «Sin plan».- Las demás celdas muestran usuario, fecha, cantidad de asignaturas, grupos y, si existe, quién edita.
fecha(c.fecha)adapta la fecha para mostrarla. @click="abrir(c.sigla)"envía la sigla al métodoabrir.c.puedeEditardecide si el botón dice «Editar» o «Ver»;!!ocupadoconvierte el estado de ocupación a verdadero o falso.
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.


<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>x-if="pantalla === 'plan'"crea este contenido solamente cuando se está mostrando un plan. A diferencia dex-show,x-iflo añade o lo retira del documento.doc.carrera,doc.nombreydoc.cicloson datos devueltos porplanAbrir. El signo+une textos para formar el título.doc.modo === "lectura"muestra el motivo de solo lectura;doc.modo === "edicion"muestra la hora de vencimiento del bloqueo.slice(11, 16)extrae la parte de hora y minutos del texto de fecha.- Los botones llaman a
guardar(),salir()ehistorial(). El avisox-show="cambios"aparece si existen modificaciones sin guardar. - El bloqueo no es el contenido del plan: se registra en la hoja para coordinar la edición. Al guardar, se crea una nueva versión; al salir, se intenta liberar el bloqueo.
Pegar o subir un CSV · marcado
123 <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><textarea>es el área de texto.x-model="texto"conecta lo que escribes conthis.textodel componente: si cambia uno, Alpine actualiza el otro.@click="interpretar()"ejecuta el método del componente. Mientrasocupadoseainterpretar, el texto del botón cambia a «Interpretando…».type="file"abre el selector de archivos;accept=".csv"orienta la selección hacia archivos CSV.@change="leerArchivo($event)"entrega al método el evento de selección.lectura && …significa: si existe el resultado de la lectura, usa sus datos.lectura.filascuenta filas leídas ylectura.descartadascuenta las que corresponden a otra carrera.
Previsualización por semestre · marcado
12 <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>x-text="totales"muestra el texto calculado por el componente.x-for="s in semestres"crea un bloque por semestre yx-for="f in s.filas"crea una fila por grupo.s.titulocontiene el encabezado del semestre;f.asig.codigoyf.asig.nombrecorresponden a la asignatura.f.g.nidentifica el número del grupo.f.g.ht,f.g.hpyf.g.thson horas de teoría, práctica y total. Las observaciones y avisos se leen def.g.x-show="editable"oculta la columna de acciones cuando no se puede editar.f.primerahace que «+ Grupo» aparezca solo en la primera fila de la asignatura.- Las marcas muestran desdobles y fusiones.
x-show="!f.g.desdoble"significa «mostrar si no hay desdoble»; el signo!niega la condición.
Diálogo de desdoble · marcado
123 <!-- DIÁLOGO DE DESDOBLE (lo abre abrirDesdoble). «Desdoblar» → aplicarDesdoble() → desdoblar(). Solo memoria. -->
<template x-if="desdobleDe">
<div class="dialogo"><div>
<h3 x-text="'Desdoblar ' + desdobleDe.titulo"></h3>
<div class="fila">
<label>Componente <select x-model="desdobleDe.componente"><option value="P">Práctica (P)</option><option value="T">Teoría (T)</option></select></label>
<label>Subgrupos <input type="number" min="2" max="9" x-model.number="desdobleDe.n"></label>
</div>
<p class="error" x-show="error" x-text="error"></p>
<div class="fila"><button class="primario" @click="aplicarDesdoble()">Desdoblar</button><button @click="desdobleDe = null; error = ''">Cancelar</button></div>
</div></div>
</template>x-if="desdobleDe"crea el diálogo cuando existe el objetodesdobleDe; si valenull, no se muestra.x-model="desdobleDe.componente"guarda la opción P o T.x-model.number="desdobleDe.n"guarda la cantidad como número;min="2"ymax="9"indican los límites del campo.- «Desdoblar» llama a
aplicarDesdoble(). «Cancelar» asignanulladesdobleDe, cierra el diálogo y limpia el error.
El componente y su memoria
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,document.addEventListener("alpine:init", …)espera el evento de inicio de Alpine.Alpine.data("planDeCiclo", …)registra el componente que usax-data="planDeCiclo".- Una propiedad guarda un dato:
cicloes el seleccionado;ciclosycarrerasson listas;pantalladecide qué zona se ve. docconserva los datos del plan abierto y su modo;plancontiene las asignaturas que se están mostrando o editando.textoconserva lo pegado ylecturaguarda el resultado de interpretarlo.falsesignifica falso,trueverdadero,""texto vacío,[]lista vacía ynullausencia de valor.ocupado,error,exitoyavisoscontrolan los estados visibles.cambiosindica modificaciones pendientes yactividadayuda a renovar el bloqueo.
Arranque y carga inicial
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(() => {});
});
},
init()se ejecuta al crear el componente.this.$watch(...)observa el valor de navegación;v => …es una función que recibe el nuevo valor comov.- Cuando
v === "plan" && !this.listo, se muestra la vista y aún no se cargó:&&exige ambas condiciones y!inviertelisto. Entonces llama athis.inicio(). - La segunda condición cubre el caso en que la vista ya era «plan» al arrancar.
pagehideintenta llamar aplanSaliral cerrar o recargar si el plan está en edición; es un intento y el bloqueo también puede vencer solo.
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 });asyncdeclara un método que puede usarawait.this.ocupado = "inicio"activa el estado de carga ythis.error = ""limpia el error anterior.google.script.runllama aplanInicioen el servidor conthis.ciclo.r.okindica si la respuesta de la aplicación fue correcta yr.datoscontiene los datos.tryejecuta la operación;catch (e)trata un fallo y guarda un mensaje;finallylimpiaocupadotanto si salió bien como si falló. DespuésObject.assigncopia los datos y Alpine actualiza la pantalla.
Editar o Ver · componente
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);
},siglaes el dato que recibe el método, por ejemploING."abrir " + siglapermite identificar qué botón muestra «Abriendo…».["planAbrir"](this.ciclo, sigla)llama al servidor con el ciclo y la carrera. La respuesta puede indicar modo de edición o de lectura; el servidor decide después de comprobar permisos y bloqueo.- Si no llegan datos,
if (!d) returntermina el método.clearIntervaldetiene el temporizador anterior yObject.assignprepara el nuevo estado. doc: dguarda los datos generales;plan: d.planguarda las asignaturas;pantalla: "plan"cambia lo que se muestra. En edición,setIntervalprograma el latido cada cinco minutos. El latido solo intenta renovar si hubo actividad y no hay otra llamada en curso.
Interpretar · componente
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 });
},
this.textoes lo pegado o leído del CSV;this.doc.carreraidentifica la carrera abierta. Ambos se envían aplanInterpretar.- El servidor responde con
asignaturas,filas,descartadasyavisos. Si existe un plan previo,confirm(...)pregunta antes de reemplazarlo en la vista; cancelar interrumpe el cambio. externas = {}crea un objeto para recordar fusiones externas. Los dosforEachrecorren asignaturas y grupos para conservar esas marcas al reemplazar el plan.Object.assign(... cambios: true)actualizathis.plan, guarda el resultado de lectura y marca cambios pendientes. Interpretar no guarda el plan en la hoja.
Subir CSV · componente
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();
},evento.targetes el campo de archivo;.fileses su lista de archivos y[0]toma el primero.if (!archivo) returntermina si no se eligió nada./\.csv$/i.test(archivo.name)comprueba la extensión:\.representa un punto,$el final eiignora mayúsculas. Si no es CSV se muestra un error.archivo.arrayBuffer()lee los bytes en el navegador.TextDecoderintenta interpretar UTF-8; si falla, usa Windows-1252.replace(..., "")quita una posible marca inicial BOM, guarda el texto enthis.textoy ejecutathis.interpretar(). El archivo no se sube a Drive; el texto sí se envía a Apps Script para interpretarlo.
Lo que muestra la pantalla · valores calculados
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;getdefine una propiedad calculada que se lee sin paréntesis.editableexige que existadoc, su modo sea edición y no se esté viendo una versión histórica.!!transforma un valor en verdadero o falso.puedeEditarActualusasome(...)para comprobar si alguna carrera coincide con la abierta y permite editarla.mostradoelige entre el plan de la versión histórica ythis.plan. El operadorcondición ? valor1 : valor2elige el primer valor si la condición se cumple; si no, el segundo.totalesusalengthpara contar asignaturas yreducepara sumar los grupos de todas ellas. Ejemplo: grupos 2 y 3 producen 5.semestresrecorre las asignaturas conforEach, agrupa por título y agrega una fila por grupo conpush.i === 0marca la primera fila y laclavedistingue cada grupo.
Acciones sobre grupos · componente
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 = ""; },agregarGrupo(asig)identifica el grupo de número más bajo como base, calcula el número más alto conMath.max(...)y suma 1;pushañade el grupo con las horas de la base.uiQuitarGrupopide confirmación.quitarGrupoimpide que una asignatura se quede sin grupos, deshace la fusión propia si corresponde y usasplicepara quitar el grupo sin renumerar los demás.abrirDesdobleprepara el objeto del diálogo; por defecto propone práctica si hay HP mayores que cero, o teoría en caso contrario.aplicarDesdobleconvierte la cantidad conNumbery llama adesdoblar. Este exige P o T, horas correspondientes mayores que cero y entre 2 y 9 subgrupos enteros. Si es válido guarda{ componente, n }en el grupo.quitarDesdobleelimina la marca asignandonull. Las acciones de grupo modificanthis.planen el navegador;cambios = trueindica que aún falta guardar.abrirFusionydeshacerFusionpreparan o deshacen fusiones en memoria. La búsqueda opcional en otras carreras es una excepción:candidatosFusion()sí llama al servidor para obtener candidatos.
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.
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;
},claveune carrera, código y número de grupo con|, por ejemploING|3.1.1.24|1. Sirve para distinguir una opción de otra.fusionDeguarda el diálogo: el grupo inicial queda marcado y se propone como principal.externos: []comienza como lista vacía.- Si se pulsa «Buscar en otras carreras»,
candidatosFusion()envía el ciclo y la carrera aplanCandidatosFusion; al volver, colocad.candidatosenfusionDe.externos. opcionesFusion(false)reúne los grupos locales y los externos, calcula una clave y aplica el filtro. Los que ya participan en otra fusión reciben una explicación de bloqueo.includescomprueba si una clave ya está entre los marcados.advertenciasDialogocompara nombre, código y horas con el principal. Las diferencias generan avisos y una confirmación, pero no impiden por sí solas la fusión.
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; }seles la lista de opciones marcadas.findlocaliza el principal; si no lo encuentra, el valor esnull.adv.lengthcomprueba si hay advertencias. Si las hay,confirmpermite continuar o cancelar;returntermina cuando se cancela.for (const r of seleccion)recorre los grupos elegidos.continuesalta los repetidos. Los grupos locales deben existir y no estar fusionados; también se comprueba que los externos no estén ocupados.- Las validaciones exigen al menos dos grupos, al menos uno de la carrera actual y que el principal pertenezca a la selección.
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 = "";
}
}));id: "F" + (mayor + 1)genera la siguiente identificación de fusión, por ejemploF2. El objeto incluyeprincipalymiembros.- El objeto se copia en los grupos de la carrera actual. Los grupos de otras carreras quedan como miembros en ese objeto; sus planes no se modifican directamente.
deshacerFusion(id)recorre las asignaturas y sus grupos; cuando la identificación coincide, asignanulla la fusión. Después marca los cambios pendientes.- La fusión y su deshacer permanecen en memoria hasta guardar. La búsqueda de candidatos sí realiza una consulta al servidor, pero no guarda la fusión.
PlanDeCiclo.js · cabecera
/** 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"
};constdeclara una referencia que no se reasigna.PLAN_DOMINIO_contiene el dominio permitido;PLAN_MAX_CELDA_yPLAN_LIMITES_establecen límites que se aplican al guardar.PLAN_SIN_BLOQUEO_es el mensaje usado cuando se pierde el bloqueo.PLAN_COLUMNAS_relaciona nombres de encabezado del archivo con nombres internos, por ejemploASIGNATURA → asignatura.- Cada función devuelve un objeto con
ok: trueydatos, o conok: falseyerror. Las funciones auxiliaresfallo,pestanayleerse declaran dentro de cada acción.
Servidor · planInicio(ciclo)
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 + "».");Session.getActiveUser().getEmail()obtiene el correo de la sesión.trim()quita espacios de los extremos ytoLowerCase()pasa a minúsculas. Se comprueban el dominio y el rol en la hojaUsuarios.leer("Planes"),leer("Bloqueos"),leer("Ciclos")yleer("Carreras")obtienen las filas.slice(1)omite la cabecera ymaptransforma cada elemento.- Si el ciclo recibido está vacío,
ciclos[ciclos.length - 1]selecciona el último. Si se envía uno que no existe, se informa un error.
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 } };filterconserva solo las filas que cumplen la condición;mapconstruye un objeto por carrera. Las versiones se ordenan de mayor a menor y[0]toma la más reciente.JSON.parse(v.contenido)convierte el texto guardado en un objeto.reducesuma sus grupos; el bloqueo solo se muestra si su fecha de vencimiento es futura.puedeEditares verdadero para un coordinador cuya lista contiene la sigla. Finalmentereturn { ok: true, datos: { ciclos, ciclo, carreras } }envía la respuesta al componente.
Servidor · planAbrir(ciclo, carrera)
/* 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(); }puedeexige que el rol sea coordinador y que la carrera esté entre las asignadas al usuario.indexOf(sigla) >= 0significa que la sigla se encontró en la lista.LockService.getScriptLock()obtiene un candado de Apps Script;waitLock(10000)espera hasta 10 segundos para que no se crucen operaciones de bloqueo. Este candado técnico es distinto de la fila persistente en la hojaBloqueos.findIndexbusca la fila del bloqueo yDate.parse(... ) > Date.now()comprueba si sigue vigente. Si puede editar y no hay otro bloqueo vigente, se escribe o actualiza la fila con vencimiento a 30 minutos.- Si no tiene permiso o la edición está ocupada por otra cuenta, la respuesta queda en modo lectura con un motivo.
finallylibera el candado técnico.
/* 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: [] };- Se lee
Planes, se filtra por ciclo y carrera y se toma la versión más alta. Si no existe, se crea un plan vacío. - Después se revisan los planes de otras carreras para incorporar las marcas de
fusionExterna. La respuesta reúne datos de carrera, modo, versión yplan; el componente los usa para cambiar a la pantalla del plan.
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.
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.");
- Comprueba la identidad, el dominio y que el usuario tenga un rol permitido; busca la carrera y rechaza texto vacío. La función
Nnormaliza textos para compararlos: quita tildes, convierte a mayúsculas y reduce espacios. - La carrera se reconoce por su sigla o por su nombre normalizado.
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 ? "," : ";";replacenormaliza saltos de línea;split("\n")divide en líneas yfilterdeja fuera las vacías. Si la primera línea tiene tabulaciones, se usa tabulador; si no, se compara la cantidad de comas y puntos y coma en las primeras cinco líneas.- El bucle que sigue procesa caracteres y respeta campos entre comillas, separadores y saltos de línea dentro de esos campos.
/* 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;findIndexbusca la primera fila conCODIGOyASIGNATURA;-1significa que no la encontró.PLAN_COLUMNAS_identifica la posición de cada campo. Si hay dos cabeceras HT, la segunda puede usarse como total cuando no existe otra columna de total.
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 });- Se omiten filas sin código ni nombre y se descartan las de otra carrera. Las que continúan se agrupan por código, o por nombre si falta código.
- La expresión regular de
semreconoce, por ejemplo,4como semestre 4 y grupo 1, y1-2como semestre 1 y grupo 2. Los valores que no se entienden producen avisos. - Se interpretan HT y HP; si no hay total y ambas son números, se suman. Si el total indicado no coincide, se agrega un aviso. Un grupo repetido recibe el siguiente número libre y un aviso.
- Finalmente se ordenan grupos y asignaturas y se devuelve
{ asignaturas, filas, descartadas, avisos }. La vista usa esa respuesta para actualizar la previsualización.