From 2dbae3b2a28083e7784078b5c39ff173e97a4214 Mon Sep 17 00:00:00 2001 From: Daniel Diaz de Leon Date: Sat, 9 May 2026 14:59:00 -0600 Subject: [PATCH 1/2] feat: parse MiTec horario PDF for real Tec21 calendar awareness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-rolled "5+1+5+1+5" Tec21 calendar with the real periods extracted from the student's official MiTec horario PDF. Periods, materias, CRNs, schedules, and Semana Tec flags now come from the source of truth instead of being computed from a single start date. Backend - lib/pdf/horario-parser.ts: sends the PDF directly to Gemini multimodal (gemini-2.5-flash) with a structured response schema. Returns alumno, matricula, periodo, and an array of materias with their `periodos` (each with inicio/fin/dias/hora_inicio/hora_fin/ubicacion/es_semana_tec). - POST /api/profile/horario: multipart upload, parses, replaces materias_inscritas with PDF-derived rows and updates profiles with periodo_nombre/periodo_inicio/periodo_fin. - lib/tec21/calendar.ts refactor: getPeriodoActivo() reads materias_inscritas.periodos to detect Semana Tec (any active period flagged) or compute the current block boundaries (intersection of active non-tec periods). Returns a human label and dias_restantes_bloque for prompt context. - lib/gemini/prompts.ts: prompt now uses periodo_activo (real data) instead of computed semana_actual; new rules around Semana Tec; stronger Canvas prioritization; few-shot updated to a Semana Tec scenario. - /api/insights/generate: filters materias to those active in the current period, marks Semana Tec materias, keeps Canvas-inferred materias. Frontend - OnboardingFlow rewritten: paso 3 ahora es "sube tu PDF de MiTec" en lugar de selección manual de materias. Auto-fills nombre/matricula del PDF. - Old SelectedMateria/TOGGLE_MATERIA logic removed — PDF is source of truth. Schema - profiles.semestre_inicio dropped; replaced by periodo_nombre/periodo_inicio/periodo_fin. - materias_inscritas: + crn text, + periodos jsonb. Migration: ALTER TABLE profiles DROP COLUMN IF EXISTS semestre_inicio; ALTER TABLE profiles ADD COLUMN IF NOT EXISTS periodo_nombre text; ALTER TABLE profiles ADD COLUMN IF NOT EXISTS periodo_inicio date; ALTER TABLE profiles ADD COLUMN IF NOT EXISTS periodo_fin date; ALTER TABLE materias_inscritas ADD COLUMN IF NOT EXISTS crn text; ALTER TABLE materias_inscritas ADD COLUMN IF NOT EXISTS periodos jsonb default '[]'::jsonb; Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 +- .../src/app/api/insights/generate/route.ts | 61 ++- .../src/app/api/profile/horario/route.ts | 86 ++++ .../src/app/api/profile/setup/route.ts | 39 +- .../components/onboarding/OnboardingFlow.tsx | 449 ++++++------------ apps/frontend/src/lib/gemini/prompts.ts | 92 ++-- apps/frontend/src/lib/pdf/horario-parser.ts | 148 ++++++ apps/frontend/src/lib/tec21/calendar.ts | 213 ++++++--- apps/frontend/src/lib/types/planes.ts | 3 - 9 files changed, 635 insertions(+), 473 deletions(-) create mode 100644 apps/frontend/src/app/api/profile/horario/route.ts create mode 100644 apps/frontend/src/lib/pdf/horario-parser.ts diff --git a/README.md b/README.md index 0da5b8f..fc13540 100644 --- a/README.md +++ b/README.md @@ -108,14 +108,19 @@ create table if not exists profiles ( carrera_clave text, modelo text check (modelo in ('tec21','tec26')), semestre integer check (semestre between 1 and 10), - semestre_inicio date, + periodo_nombre text, + periodo_inicio date, + periodo_fin date, canvas_ical_url text, created_at timestamptz default now(), updated_at timestamptz default now() ); --- If profiles already exists, add the new column: --- ALTER TABLE profiles ADD COLUMN IF NOT EXISTS semestre_inicio date; +-- If profiles already exists, run this migration: +-- ALTER TABLE profiles DROP COLUMN IF EXISTS semestre_inicio; +-- ALTER TABLE profiles ADD COLUMN IF NOT EXISTS periodo_nombre text; +-- ALTER TABLE profiles ADD COLUMN IF NOT EXISTS periodo_inicio date; +-- ALTER TABLE profiles ADD COLUMN IF NOT EXISTS periodo_fin date; create table if not exists planes_estudio ( carrera_clave text primary key, @@ -128,13 +133,19 @@ create table if not exists materias_inscritas ( user_id uuid references auth.users(id) on delete cascade, clave text not null, nombre text, + crn text, creditos integer, prioridad integer, horas_clase integer, horas_auto integer, + periodos jsonb default '[]'::jsonb, created_at timestamptz default now() ); +-- If materias_inscritas already exists: +-- ALTER TABLE materias_inscritas ADD COLUMN IF NOT EXISTS crn text; +-- ALTER TABLE materias_inscritas ADD COLUMN IF NOT EXISTS periodos jsonb default '[]'::jsonb; + -- NOTE: column names are fecha_inicio/fecha_fin (not inicio/fin) and source (not fuente) create table if not exists eventos ( id uuid primary key default gen_random_uuid(), diff --git a/apps/frontend/src/app/api/insights/generate/route.ts b/apps/frontend/src/app/api/insights/generate/route.ts index 3552c08..89b8d00 100644 --- a/apps/frontend/src/app/api/insights/generate/route.ts +++ b/apps/frontend/src/app/api/insights/generate/route.ts @@ -11,7 +11,11 @@ import { } from "@/lib/gemini/prompts"; import { resolveConflicts } from "@/lib/scheduling/conflicts"; import { getSupabaseServer } from "@/lib/supabase/server"; -import { extractClavesFromText, getTec21WeekInfo } from "@/lib/tec21/calendar"; +import { + extractClavesFromText, + getPeriodoActivo, + type MateriaConPeriodos, +} from "@/lib/tec21/calendar"; function currentSemanaISO(): string { const now = new Date(); @@ -74,7 +78,7 @@ export async function POST(request: NextRequest) { const { data: perfil } = await supabase .from("profiles") - .select("nombre, carrera_clave, modelo, semestre, semestre_inicio") + .select("nombre, carrera_clave, modelo, semestre, periodo_inicio, periodo_fin") .eq("id", user.id) .maybeSingle(); @@ -82,11 +86,20 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Profile not found" }, { status: 404 }); } + // Materias with their periods (uploaded from MiTec PDF). const { data: materiasRows } = await supabase .from("materias_inscritas") - .select("clave, nombre, creditos, prioridad") + .select("clave, nombre, creditos, prioridad, periodos") .eq("user_id", user.id); + const enrolled: MateriaConPeriodos[] = (materiasRows ?? []).map((m) => ({ + clave: m.clave, + nombre: m.nombre ?? m.clave, + creditos: m.creditos ?? 0, + prioridad: m.prioridad ?? 3, + periodos: Array.isArray(m.periodos) ? m.periodos : [], + })); + const now = new Date(); const weekLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); const { data: eventosRows } = await supabase @@ -97,36 +110,48 @@ export async function POST(request: NextRequest) { .lte("fecha_inicio", weekLater.toISOString()) .order("fecha_inicio", { ascending: true }); - // --- Tec21 calendar context --- - // Default to Feb 9 of the current year if the profile doesn't have it set yet. - const fallbackInicio = `${now.getFullYear()}-02-09`; - const semestreInicio = perfil.semestre_inicio ?? fallbackInicio; - const calendario_tec21 = getTec21WeekInfo(now, semestreInicio); + // --- Compute current period from real schedule --- + const periodo_activo = getPeriodoActivo(now, enrolled, perfil.periodo_inicio ?? null); + + // --- Filter to only materias active in the current period --- + const activas = enrolled.filter((m) => + m.periodos.some((p) => { + const t = now.getTime(); + const start = new Date(`${p.inicio}T00:00:00`).getTime(); + const end = new Date(`${p.fin}T00:00:00`).getTime() + 24 * 60 * 60 * 1000 - 1; + return t >= start && t <= end; + }), + ); - // --- Materias inferred from Canvas events --- - const enrolled = materiasRows ?? []; - const enrolledClaves = new Set(enrolled.map((m) => m.clave.toUpperCase())); + const activasClaves = new Set(activas.map((m) => m.clave.toUpperCase().split(".")[0])); + // --- Materias inferred from Canvas events --- const inferredClaves = new Set(); for (const ev of eventosRows ?? []) { if (ev.source !== "canvas") continue; for (const clave of extractClavesFromText(ev.titulo ?? "")) { - if (!enrolledClaves.has(clave)) inferredClaves.add(clave); + if (!activasClaves.has(clave)) inferredClaves.add(clave); } } const materias: MateriaCtx[] = [ - ...enrolled.map((m) => ({ + ...activas.map((m) => ({ clave: m.clave, - nombre: m.nombre ?? m.clave, - creditos: m.creditos ?? 0, - prioridad: m.prioridad ?? 3, + nombre: m.nombre, + creditos: m.creditos, + prioridad: m.prioridad, + es_semana_tec: m.periodos.some( + (p) => + p.es_semana_tec && + new Date(`${p.inicio}T00:00:00`).getTime() <= now.getTime() && + now.getTime() <= new Date(`${p.fin}T00:00:00`).getTime() + 24 * 60 * 60 * 1000, + ), })), ...[...inferredClaves].map((clave) => ({ clave, nombre: clave, creditos: 0, - prioridad: 4, // inferred from Canvas → likely active and has deadlines + prioridad: 4, inferida: true, })), ]; @@ -145,7 +170,7 @@ export async function POST(request: NextRequest) { modelo: perfil.modelo ?? "tec21", semestre: perfil.semestre ?? 1, }, - calendario_tec21, + periodo_activo, materias, eventos_proximos, }; diff --git a/apps/frontend/src/app/api/profile/horario/route.ts b/apps/frontend/src/app/api/profile/horario/route.ts new file mode 100644 index 0000000..ce39637 --- /dev/null +++ b/apps/frontend/src/app/api/profile/horario/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { parseHorarioPdf } from "@/lib/pdf/horario-parser"; +import { getSupabaseServer } from "@/lib/supabase/server"; + +const MAX_PDF_BYTES = 10 * 1024 * 1024; // 10 MB + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function POST(request: NextRequest) { + try { + const supabase = await getSupabaseServer(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("pdf"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "Missing 'pdf' file" }, { status: 400 }); + } + if (file.size > MAX_PDF_BYTES) { + return NextResponse.json({ error: "PDF too large (max 10 MB)" }, { status: 413 }); + } + + const buffer = new Uint8Array(await file.arrayBuffer()); + const parsed = await parseHorarioPdf(buffer); + + if (parsed.materias.length === 0) { + return NextResponse.json( + { error: "No materias found in PDF. ¿Es el horario correcto?" }, + { status: 422 }, + ); + } + + // Update profile period info. Don't touch nombre/matricula/carrera if they + // already exist — onboarding step 1 set them. + const profileUpdate: Record = { + id: user.id, + periodo_nombre: parsed.periodo_nombre, + periodo_inicio: parsed.periodo_inicio, + periodo_fin: parsed.periodo_fin, + updated_at: new Date().toISOString(), + }; + if (parsed.matricula) profileUpdate.matricula = parsed.matricula; + if (parsed.alumno_nombre) profileUpdate.nombre = parsed.alumno_nombre; + + const { error: profileError } = await supabase + .from("profiles") + .upsert(profileUpdate, { onConflict: "id" }); + if (profileError) throw profileError; + + // Replace materias_inscritas with PDF data (PDF is source of truth). + await supabase.from("materias_inscritas").delete().eq("user_id", user.id); + const rows = parsed.materias.map((m) => ({ + user_id: user.id, + clave: m.clave_completa, // keep section info to match Canvas titles + nombre: m.nombre, + crn: m.crn, + creditos: 0, + prioridad: m.periodos.some((p) => p.es_semana_tec) ? 5 : 3, + periodos: m.periodos, + })); + const { error: materiasError } = await supabase + .from("materias_inscritas") + .insert(rows); + if (materiasError) throw materiasError; + + return NextResponse.json({ + success: true, + alumno: parsed.alumno_nombre, + matricula: parsed.matricula, + periodo: parsed.periodo_nombre, + materias: parsed.materias.length, + }); + } catch (err) { + console.error("[profile/horario]", err); + const message = err instanceof Error ? err.message : "Failed to parse PDF"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/frontend/src/app/api/profile/setup/route.ts b/apps/frontend/src/app/api/profile/setup/route.ts index 7eeb4ce..41c8aae 100644 --- a/apps/frontend/src/app/api/profile/setup/route.ts +++ b/apps/frontend/src/app/api/profile/setup/route.ts @@ -1,35 +1,39 @@ import { NextRequest, NextResponse } from "next/server"; import { getSupabaseServer } from "@/lib/supabase/server"; +/** + * Light profile setup — only saves identity + carrera + modelo + semestre. + * Materias and period dates come from the MiTec PDF parsed by + * POST /api/profile/horario. + */ export async function POST(request: NextRequest) { try { const supabase = await getSupabaseServer(); - const { data: { user }, error: authError } = await supabase.auth.getUser(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); if (authError || !user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const body = await request.json() as { - matricula: string; - nombre: string; + const body = (await request.json()) as { + matricula?: string; + nombre?: string; carreraClave: string; modelo: string; semestre: number; - semestreInicio?: string; - materias: Array<{ clave: string; nombre: string; creditos: number; prioridad?: number }>; canvasIcalUrl?: string; }; - // Upsert profile const { error: profileError } = await supabase.from("profiles").upsert( { id: user.id, - matricula: body.matricula, - nombre: body.nombre, + matricula: body.matricula ?? null, + nombre: body.nombre ?? null, carrera_clave: body.carreraClave, modelo: body.modelo, semestre: body.semestre, - semestre_inicio: body.semestreInicio ?? null, canvas_ical_url: body.canvasIcalUrl ?? null, updated_at: new Date().toISOString(), }, @@ -37,21 +41,6 @@ export async function POST(request: NextRequest) { ); if (profileError) throw profileError; - // Replace enrolled classes - await supabase.from("materias_inscritas").delete().eq("user_id", user.id); - if (body.materias.length > 0) { - const { error: materiasError } = await supabase.from("materias_inscritas").insert( - body.materias.map((m) => ({ - user_id: user.id, - clave: m.clave, - nombre: m.nombre, - creditos: m.creditos, - prioridad: m.prioridad ?? 3, - })), - ); - if (materiasError) throw materiasError; - } - return NextResponse.json({ success: true }); } catch (err) { console.error("[profile/setup]", err); diff --git a/apps/frontend/src/components/onboarding/OnboardingFlow.tsx b/apps/frontend/src/components/onboarding/OnboardingFlow.tsx index 9aed47a..b193d9b 100644 --- a/apps/frontend/src/components/onboarding/OnboardingFlow.tsx +++ b/apps/frontend/src/components/onboarding/OnboardingFlow.tsx @@ -7,6 +7,7 @@ import { ArrowRight, CheckCircle2, ChevronDown, + FileUp, GraduationCap, Loader2, Search, @@ -23,7 +24,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -41,33 +41,15 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Slider } from "@/components/ui/slider"; import { isDemoMode } from "@/lib/demo"; -import { - CARRERAS_FIXTURE, - getFixturePlan, -} from "@/lib/fixtures/planes"; -import type { - CarreraSummary, - Materia, - Modelo, - PlanEstudio, - ProfileSetupBody, -} from "@/lib/types/planes"; +import { CARRERAS_FIXTURE } from "@/lib/fixtures/planes"; +import type { CarreraSummary, Modelo } from "@/lib/types/planes"; const MATRICULA_REGEX = /^A0\d{7}$/; const ICAL_URL_REGEX = /^https:\/\/\S+\.ics(?:[?#]\S*)?$/i; const TOTAL_STEPS = 3; const SEMESTRES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; -type SelectedMateria = { - clave: string; - nombre: string; - creditos: number; - prioridad: number; -}; - type State = { step: 1 | 2 | 3; nombre: string; @@ -75,29 +57,16 @@ type State = { carreraClave: string; modelo: Modelo | ""; semestre: number | null; - /** YYYY-MM-DD del día que inició el semestre actual del alumno. */ - semestreInicio: string; - materias: Record; + pdfFile: File | null; canvasIcalUrl: string; submitting: boolean; }; type Action = | { type: "SET"; field: keyof State; value: State[keyof State] } - | { type: "TOGGLE_MATERIA"; materia: Materia } - | { type: "SET_PRIORIDAD"; clave: string; prioridad: number } - | { type: "RESET_MATERIAS" } | { type: "NEXT" } | { type: "BACK" }; -function defaultSemesterStart(): string { - // Tec semesters typically start the second Monday of February or August. - // Default to Feb 9 of the current year so the field is pre-filled with a - // reasonable guess; the student can override. - const now = new Date(); - return `${now.getFullYear()}-02-09`; -} - const INITIAL: State = { step: 1, nombre: "", @@ -105,8 +74,7 @@ const INITIAL: State = { carreraClave: "", modelo: "", semestre: null, - semestreInicio: defaultSemesterStart(), - materias: {}, + pdfFile: null, canvasIcalUrl: "", submitting: false, }; @@ -115,39 +83,16 @@ function reducer(state: State, action: Action): State { switch (action.type) { case "SET": return { ...state, [action.field]: action.value }; - case "TOGGLE_MATERIA": { - const { materia } = action; - const next = { ...state.materias }; - if (next[materia.clave]) { - delete next[materia.clave]; - } else { - next[materia.clave] = { - clave: materia.clave, - nombre: materia.nombre, - creditos: materia.creditos, - prioridad: 3, - }; - } - return { ...state, materias: next }; - } - case "SET_PRIORIDAD": - if (!state.materias[action.clave]) return state; + case "NEXT": return { ...state, - materias: { - ...state.materias, - [action.clave]: { - ...state.materias[action.clave], - prioridad: action.prioridad, - }, - }, + step: Math.min(TOTAL_STEPS, state.step + 1) as State["step"], }; - case "RESET_MATERIAS": - return { ...state, materias: {} }; - case "NEXT": - return { ...state, step: Math.min(TOTAL_STEPS, state.step + 1) as State["step"] }; case "BACK": - return { ...state, step: Math.max(1, state.step - 1) as State["step"] }; + return { + ...state, + step: Math.max(1, state.step - 1) as State["step"], + }; default: return state; } @@ -164,15 +109,11 @@ function isStepValid(step: number, state: State): boolean { return ( Boolean(state.carreraClave) && Boolean(state.modelo) && - state.semestre != null && - /^\d{4}-\d{2}-\d{2}$/.test(state.semestreInicio) + state.semestre != null ); } if (step === 3) { - return ( - Object.keys(state.materias).length >= 1 && - isCanvasIcalValid(state.canvasIcalUrl) - ); + return Boolean(state.pdfFile) && isCanvasIcalValid(state.canvasIcalUrl); } return false; } @@ -182,27 +123,12 @@ function isCanvasIcalValid(value: string): boolean { return trimmed.length === 0 || ICAL_URL_REGEX.test(trimmed); } -function toDemoProfile(body: ProfileSetupBody) { - const safeBody: Omit = { - matricula: body.matricula, - nombre: body.nombre, - carreraClave: body.carreraClave, - modelo: body.modelo, - semestre: body.semestre, - materias: body.materias, - }; - return safeBody; -} - export function OnboardingFlow() { const router = useRouter(); const [state, dispatch] = useReducer(reducer, INITIAL); const [carreras, setCarreras] = useState(CARRERAS_FIXTURE); - const [plan, setPlan] = useState(null); - const [planLoading, setPlanLoading] = useState(false); const [usingFixture, setUsingFixture] = useState(false); - // Try real /api/planes for the carrera list. Fall back to fixture on 404 or error. useEffect(() => { let cancelled = false; fetch("/api/planes") @@ -215,48 +141,13 @@ export function OnboardingFlow() { } }) .catch(() => { - if (!cancelled) { - setUsingFixture(true); - } + if (!cancelled) setUsingFixture(true); }); return () => { cancelled = true; }; }, []); - // Fetch the plan for the selected carrera. Fall back to fixture on 404 / error. - useEffect(() => { - if (!state.carreraClave) { - setPlan(null); - return; - } - let cancelled = false; - setPlanLoading(true); - fetch(`/api/planes/${encodeURIComponent(state.carreraClave)}`) - .then(async (res) => { - if (!res.ok) throw new Error(`status ${res.status}`); - const json = (await res.json()) as PlanEstudio; - if (!cancelled) { - setPlan(json); - setPlanLoading(false); - } - }) - .catch(() => { - if (!cancelled) { - const fallback = getFixturePlan(state.carreraClave) ?? null; - setPlan(fallback); - setPlanLoading(false); - setUsingFixture(true); - } - }); - return () => { - cancelled = true; - }; - }, [state.carreraClave]); - - const semestreMaterias = - plan?.data.semestres.find((s) => s.numero === state.semestre)?.materias ?? []; - const canAdvance = !state.submitting && isStepValid(state.step, state); const progress = (state.step / TOTAL_STEPS) * 100; @@ -270,27 +161,11 @@ export function OnboardingFlow() { } async function handleSubmit() { - if (!isStepValid(3, state) || !state.carreraClave || !state.modelo || state.semestre == null) { + if (!isStepValid(3, state) || !state.carreraClave || !state.modelo || state.semestre == null || !state.pdfFile) { return; } if (state.submitting) return; - const body: ProfileSetupBody = { - matricula: state.matricula.trim(), - nombre: state.nombre.trim(), - carreraClave: state.carreraClave, - modelo: state.modelo as Modelo, - semestre: state.semestre, - semestreInicio: state.semestreInicio, - materias: Object.values(state.materias).map(({ clave, nombre, creditos, prioridad }) => ({ - clave, - nombre, - creditos, - prioridad, - })), - canvasIcalUrl: state.canvasIcalUrl.trim() || undefined, - }; - dispatch({ type: "SET", field: "submitting", value: true }); const demo = isDemoMode( @@ -299,53 +174,48 @@ export function OnboardingFlow() { : null, ); if (demo) { - if (typeof window !== "undefined") { - window.localStorage.setItem( - "teccoach.demoProfile", - JSON.stringify(toDemoProfile(body)), - ); - } - toast.success("Perfil guardado en modo demo", { - description: "Saltando el backend para mantener el demo limpio.", + toast.success("Modo demo: saltando backend", { + description: "El horario no se sube en demo.", }); - const next = - typeof window !== "undefined" && - new URLSearchParams(window.location.search).get("demo") === "1" - ? "/dashboard?demo=1" - : "/dashboard"; - router.push(next); + router.push("/dashboard?demo=1"); return; } try { - const res = await fetch("/api/profile/setup", { + // Step A: save profile basics + const setupRes = await fetch("/api/profile/setup", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), + body: JSON.stringify({ + matricula: state.matricula.trim(), + nombre: state.nombre.trim(), + carreraClave: state.carreraClave, + modelo: state.modelo as Modelo, + semestre: state.semestre, + canvasIcalUrl: state.canvasIcalUrl.trim() || undefined, + }), }); + if (!setupRes.ok) throw new Error(`Profile setup failed (${setupRes.status})`); - if (!res.ok) { - if (res.status === 404) { - // Persona A's endpoint doesn't exist yet — persist locally so Phase 4 - // can read the profile. - if (typeof window !== "undefined") { - window.localStorage.setItem( - "teccoach.demoProfile", - JSON.stringify(toDemoProfile(body)), - ); - } - toast.success("Perfil guardado en modo demo", { - description: "Lo conectamos al backend en cuanto esté listo.", - }); - router.push("/dashboard"); - return; - } - const msg = await res.text().catch(() => ""); - throw new Error(msg || `status ${res.status}`); + // Step B: upload PDF horario + toast.info("Procesando tu horario con Gemini…"); + const formData = new FormData(); + formData.append("pdf", state.pdfFile); + const horarioRes = await fetch("/api/profile/horario", { + method: "POST", + body: formData, + }); + if (!horarioRes.ok) { + const err = await horarioRes.json().catch(() => ({})); + throw new Error(err.error ?? `Horario upload failed (${horarioRes.status})`); } + const horarioJson = (await horarioRes.json()) as { + materias: number; + periodo: string | null; + }; - toast.success("Listo, ahora a estudiar", { - description: "Tu perfil de TecCoach está configurado.", + toast.success("Listo, vamos al dashboard", { + description: `${horarioJson.materias} materias detectadas. ${horarioJson.periodo ?? ""}`, }); router.push("/dashboard"); } catch (err) { @@ -364,7 +234,10 @@ export function OnboardingFlow() { Paso {state.step} de {TOTAL_STEPS} {usingFixture && ( - + Modo demo )} @@ -372,8 +245,8 @@ export function OnboardingFlow() { {state.step === 1 && "Cuéntanos quién eres"} - {state.step === 2 && "Tu carrera y tu semestre"} - {state.step === 3 && "Tus materias y Canvas"} + {state.step === 2 && "Tu carrera"} + {state.step === 3 && "Sube tu horario"} @@ -382,14 +255,7 @@ export function OnboardingFlow() { {state.step === 2 && ( )} - {state.step === 3 && ( - - )} + {state.step === 3 && }
@@ -409,7 +275,7 @@ export function OnboardingFlow() { > {state.submitting ? ( <> - Guardando… + Procesando… ) : state.step === TOTAL_STEPS ? ( <> @@ -445,7 +311,9 @@ function StepProfile({ autoComplete="name" placeholder="Andrés Hernández" value={state.nombre} - onChange={(e) => dispatch({ type: "SET", field: "nombre", value: e.target.value })} + onChange={(e) => + dispatch({ type: "SET", field: "nombre", value: e.target.value }) + } />
@@ -507,7 +375,9 @@ function CarreraPicker({ {selected.carreraClave} - {selected.nombre} + + {selected.nombre} + ) : ( Elige tu carrera @@ -515,10 +385,14 @@ function CarreraPicker({ - { setOpen(v); if (!v) setQuery(""); }}> - + { + setOpen(v); + if (!v) setQuery(""); + }} + > + Elige tu carrera @@ -536,7 +410,9 @@ function CarreraPicker({
{filtered.length === 0 ? ( -

Sin resultados.

+

+ Sin resultados. +

) : (
    {filtered.map((c) => ( @@ -545,11 +421,17 @@ function CarreraPicker({ type="button" onClick={() => handleSelect(c.carreraClave)} className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition hover:bg-muted/60 ${ - value === c.carreraClave ? "bg-gemini-blue/5 text-gemini-blue" : "" + value === c.carreraClave + ? "bg-gemini-blue/5 text-gemini-blue" + : "" }`} > - {c.carreraClave} - {c.nombre} + + {c.carreraClave} + + + {c.nombre} + ))} @@ -578,10 +460,9 @@ function StepCareer({ { - dispatch({ type: "SET", field: "carreraClave", value: clave }); - dispatch({ type: "RESET_MATERIAS" }); - }} + onChange={(clave) => + dispatch({ type: "SET", field: "carreraClave", value: clave }) + } />
@@ -622,10 +503,9 @@ function StepCareer({
- -
- - - dispatch({ type: "SET", field: "semestreInicio", value: e.target.value }) - } - /> -

- Lo usamos para saber en qué bloque Tec21 estás (las materias dependen del periodo). -

-
); } -function StepClasses({ +function StepHorario({ state, dispatch, - materias, - loading, }: { state: State; dispatch: React.Dispatch; - materias: Materia[]; - loading: boolean; }) { const canvasIcalInvalid = !isCanvasIcalValid(state.canvasIcalUrl); + function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0] ?? null; + if (file && file.type !== "application/pdf") { + toast.error("El archivo debe ser PDF"); + return; + } + dispatch({ type: "SET", field: "pdfFile", value: file }); + } + return (
-
-
- - - {Object.keys(state.materias).length} seleccionadas - -
- - {loading && ( -
- - - -
- )} - - {!loading && materias.length === 0 && ( -

- No hay materias para este semestre todavía. Cambia el semestre o - avísanos. -

- )} - - {!loading && materias.length > 0 && ( -
    - {materias.map((m) => { - const selected = state.materias[m.clave]; - return ( -
  • - - - {selected && ( -
    - - Prioridad - - - dispatch({ - type: "SET_PRIORIDAD", - clave: m.clave, - prioridad: Array.isArray(v) ? v[0] : Number(v), - }) - } - className="flex-1" - /> - - {selected.prioridad} - -
    - )} -
  • - ); - })} -
- )} +
+ +

+ Entra a mitec.itesm.mx → Horarios y descarga el PDF. + Lo procesamos con Gemini para detectar tus materias y los bloques + Tec21 reales — incluyendo Semanas Tec. +

+
diff --git a/apps/frontend/src/lib/gemini/prompts.ts b/apps/frontend/src/lib/gemini/prompts.ts index a854f75..94c8a1a 100644 --- a/apps/frontend/src/lib/gemini/prompts.ts +++ b/apps/frontend/src/lib/gemini/prompts.ts @@ -1,4 +1,4 @@ -import type { Tec21WeekInfo } from "@/lib/tec21/calendar"; +import type { PeriodoActivoInfo } from "@/lib/tec21/calendar"; export type EventoCtx = { titulo: string; @@ -12,6 +12,8 @@ export type MateriaCtx = { nombre: string; creditos: number; prioridad: number; + /** True if currently in a Semana Tec period (special intensive course). */ + es_semana_tec?: boolean; /** True if the materia was inferred from Canvas events, not in materias_inscritas. */ inferida?: boolean; }; @@ -23,7 +25,8 @@ export type CoachContext = { modelo: string; semestre: number; }; - calendario_tec21: Tec21WeekInfo; + periodo_activo: PeriodoActivoInfo; + /** Materias active in the current period only (not all enrolled). */ materias: MateriaCtx[]; eventos_proximos: EventoCtx[]; preferencias?: { @@ -72,42 +75,39 @@ export const INSIGHT_RESPONSE_SCHEMA = { const SYSTEM_PROMPT = `Eres TecCoach, un coach académico para estudiantes del Tecnológico de Monterrey. +[CALENDARIO ACADÉMICO REAL] +El alumno te está pasando el "periodo_activo" calculado a partir de su horario oficial del MiTec. Es la fuente de verdad — no inventes números de semana ni rangos de fecha. +- Si "es_semana_tec" es true: el alumno está en una Semana Tec (proyecto intensivo de una materia específica). NO sugieras bloques de estudio para otras materias; el foco es el proyecto. Sugiere a lo más 1 bloque corto de descanso/repaso ligero. +- Si NO es Semana Tec: estás dentro de un bloque académico regular ("Bloque 1/2/3"). El campo "dias_restantes_bloque" indica cuántos días faltan para que termine el bloque — si quedan 7 días o menos, sube urgencia (cierre de bloque). + [CONTEXTO INSTITUCIONAL] -- El modelo Tec21 organiza el semestre en 3 bloques de 5 semanas cada uno, con una "Semana Tec" (proyectos intensivos) entre bloques. - Patrón: Bloque 1 (5 semanas) → Semana Tec → Bloque 2 (5 semanas) → Semana Tec → Bloque 3 (5 semanas) → Finales. -- En Semana Tec las clases regulares se suspenden. NO sugieras bloques de estudio normales en Semana Tec; sugiere trabajo de proyecto si aplica. -- El final de cada bloque (semanas 4 y 5) suele tener exámenes y entregas — sube la urgencia. -- Las "materias life" son electivas humanísticas; suelen tener menor carga. -- TEC26 es el nuevo plan piloto con mayor flexibilidad. +- "materias life" son electivas humanísticas (ej. Ética, Liderazgo); menor carga. +- TEC21 organiza el semestre en 3 bloques de 5 semanas con Semanas Tec entre bloques. +- TEC26 es el modelo nuevo, más flexible. [PERSONALIDAD] Cálido pero directo. Habla como un mentor que conoce al alumno y respeta su tiempo. -NO uses frases motivacionales cliché ("¡Tú puedes!", "Sigue adelante", "¡Ánimo!"). -Usa el nombre del estudiante de forma natural, no en cada oración. -Sé específico y práctico. +NO uses frases motivacionales cliché ("¡Tú puedes!", "¡Ánimo!"). +Usa el nombre del estudiante de forma natural. +Sé específico y práctico — cita fechas y materias por nombre. [REGLAS DE EVENTOS] -- Cada evento tiene una "fuente": "canvas", "google", "ai_suggested" o "manual". -- Eventos con fuente="canvas" son entregas/exámenes/tareas de Canvas — son OBLIGACIONES con fecha límite y deben pesar fuerte en las prioridades. -- Eventos con fuente="google" son compromisos del calendario personal — respétalos como bloqueos de tiempo, no son entregas. -- Trata cada evento como bloqueo: NO propongas bloques de estudio que se solapen. +- Cada evento tiene "fuente": "canvas", "google", "ai_suggested" o "manual". +- "canvas" = entregas/exámenes/tareas — son OBLIGACIONES con fecha límite, peso fuerte en prioridades. +- "google" = compromisos del calendario personal — respétalos como bloqueos, no son entregas. +- NO propongas bloques que se solapen con cualquier evento. [REGLAS DE MATERIAS] -- Algunas materias pueden venir marcadas como "inferida": true. Significa que aparecieron en eventos de Canvas pero el alumno no las metió en su perfil. Inclúyelas como prioridad si tienen entregas próximas, y menciona en la razón que detectaste el conflicto. +- Solo recibes materias activas en el periodo actual (las que ya terminaron o aún no empiezan se filtran). +- Si una materia tiene "inferida": true, apareció en Canvas pero no en horario oficial — menciona el conflicto. +- Si una materia tiene "es_semana_tec": true, es la materia central de Semana Tec. [REGLAS PARA BLOQUES SUGERIDOS] -- Cada bloque debe durar entre 60 y 120 minutos. -- NO propongas bloques que choquen con eventos existentes (de cualquier fuente). -- Distribuye los bloques entre diferentes días de la semana. -- Usa ISO 8601 con timezone de Monterrey: ejemplo "2026-05-12T14:00:00-06:00". +- Cada bloque dura entre 60 y 120 minutos. +- NO propongas bloques que choquen con eventos existentes. +- Distribuye los bloques entre diferentes días. +- ISO 8601 con timezone Monterrey: "2026-05-12T14:00:00-06:00". - Máximo 2 bloques por día. -- Si la semana actual es Semana Tec, sugiere máximo 1-2 bloques cortos para asuntos pendientes; el foco es el proyecto. - -[REGLAS PARA PRIORIDADES] -- Exactamente entre 2 y 4 materias prioritarias. -- Urgencia "alta" si tiene evento de Canvas en los próximos 3 días, o si estamos al final de un bloque (semanas 4-5). -- Urgencia "media" si tiene entrega esta semana pero no urgente, o créditos altos sin evento próximo. -- Urgencia "baja" si baja prioridad declarada por el alumno y sin entregas próximas. [FORMATO DE RESPUESTA] JSON puro. Sin markdown, sin texto fuera del JSON.`; @@ -116,31 +116,31 @@ const FEW_SHOT_EXAMPLE = ` [EJEMPLO] Input: { - "perfil": { "nombre": "Sofía", "carrera_nombre": "Ingeniería en Tecnologías Computacionales", "modelo": "tec21", "semestre": 5 }, - "calendario_tec21": { "semestre_inicio": "2026-02-09", "semana_actual": 11, "bloque_actual": 2, "semana_en_bloque": 5, "es_semana_tec": false, "etiqueta": "Bloque 2 — semana 5 de 5" }, + "perfil": { "nombre": "Rodrigo", "carrera_nombre": "ITC", "modelo": "tec21", "semestre": 5 }, + "periodo_activo": { + "es_semana_tec": true, + "bloque_inicio": "2026-05-04", + "bloque_fin": "2026-05-10", + "etiqueta": "Semana Tec — Inteligencia artificial para textos científicos", + "semana_actual": 13, + "dias_restantes_bloque": 1 + }, "materias": [ - { "clave": "TC2025", "nombre": "Compiladores", "creditos": 8, "prioridad": 5 }, - { "clave": "TC2018", "nombre": "Bases de datos II", "creditos": 8, "prioridad": 4 }, - { "clave": "ET1011", "nombre": "Ética y ciudadanía", "creditos": 4, "prioridad": 2 } + { "clave": "TI2002S.214", "nombre": "Inteligencia artificial para textos científicos", "creditos": 0, "prioridad": 5, "es_semana_tec": true } ], "eventos_proximos": [ - { "titulo": "TC2025 Parcial Compiladores", "inicio": "2026-05-14T09:00:00-06:00", "fin": "2026-05-14T11:00:00-06:00", "fuente": "canvas" }, - { "titulo": "Comida con familia", "inicio": "2026-05-15T14:00:00-06:00", "fin": "2026-05-15T16:00:00-06:00", "fuente": "google" } + { "titulo": "Entrega final TI2002S", "inicio": "2026-05-10T23:59:00-06:00", "fin": "2026-05-10T23:59:00-06:00", "fuente": "canvas" } ] } Output: { - "mensaje": "Sofía, estás en la semana 5 del Bloque 2 — última semana antes de la Semana Tec. Compiladores tiene parcial el miércoles según Canvas, prioridad total esta semana. Bases de datos puede esperar al siguiente bloque.", + "mensaje": "Rodrigo, estás en Semana Tec — IA para textos científicos. Hoy y mañana son el cierre del proyecto. Ignora otras materias por estos días.", "prioridades": [ - { "materia": "Compiladores", "razon": "Parcial confirmado en Canvas el miércoles 14 de mayo. Última semana del bloque, repasa gramáticas y parsers.", "urgencia": "alta" }, - { "materia": "Bases de datos II", "razon": "Alta carga crediticia. Sin entregas inmediatas pero conviene avanzar antes de Semana Tec.", "urgencia": "media" }, - { "materia": "Ética y ciudadanía", "razon": "Baja prioridad declarada y sin eventos próximos.", "urgencia": "baja" } + { "materia": "Inteligencia artificial para textos científicos", "razon": "Entrega final mañana en Canvas. Foco total en el proyecto de Semana Tec.", "urgencia": "alta" } ], "bloques_sugeridos": [ - { "titulo": "Repaso Compiladores — gramáticas", "materia": "Compiladores", "inicio_iso": "2026-05-12T16:00:00-06:00", "fin_iso": "2026-05-12T18:00:00-06:00", "razon": "Dos días antes del parcial, gramáticas libres de contexto." }, - { "titulo": "Repaso Compiladores — parsers", "materia": "Compiladores", "inicio_iso": "2026-05-13T10:00:00-06:00", "fin_iso": "2026-05-13T12:00:00-06:00", "razon": "Víspera del parcial. Parsers LL y LR." }, - { "titulo": "Bases de datos — práctica SQL", "materia": "Bases de datos II", "inicio_iso": "2026-05-15T17:00:00-06:00", "fin_iso": "2026-05-15T18:30:00-06:00", "razon": "Después de la comida familiar, retomar BD." } + { "titulo": "Bloque final — IA textos científicos", "materia": "Inteligencia artificial para textos científicos", "inicio_iso": "2026-05-09T16:00:00-06:00", "fin_iso": "2026-05-09T18:00:00-06:00", "razon": "Cierre y revisión del entregable antes de subir." } ] }`; @@ -148,7 +148,7 @@ export function buildCoachPrompt(ctx: CoachContext): string { const data = JSON.stringify( { perfil: ctx.perfil, - calendario_tec21: ctx.calendario_tec21, + periodo_activo: ctx.periodo_activo, materias: ctx.materias, eventos_proximos: ctx.eventos_proximos, preferencias: ctx.preferencias ?? {}, @@ -161,10 +161,10 @@ export function buildCoachPrompt(ctx: CoachContext): string { ${FEW_SHOT_EXAMPLE} [TAREA] -Analiza la carga académica del estudiante esta semana y devuelve: -1. Un mensaje personalizado de 2-3 líneas (específico, no genérico, mencionando bloque/Semana Tec si aplica). -2. Las 2-4 materias prioritarias con urgencia y razón concreta (cita eventos de Canvas si los hay). -3. Entre 2 y 4 bloques de estudio que NO choquen con los eventos existentes. +Analiza la carga académica del estudiante esta semana usando los datos REALES de su horario y devuelve: +1. Mensaje personalizado de 2-3 líneas que mencione el periodo activo (bloque o Semana Tec). +2. Las 2-4 materias prioritarias con razón concreta (cita eventos de Canvas si los hay). +3. Entre 1 y 4 bloques de estudio que NO choquen con eventos existentes y respeten el periodo activo. [DATOS DEL ESTUDIANTE] ${data}`; diff --git a/apps/frontend/src/lib/pdf/horario-parser.ts b/apps/frontend/src/lib/pdf/horario-parser.ts new file mode 100644 index 0000000..7c95ba4 --- /dev/null +++ b/apps/frontend/src/lib/pdf/horario-parser.ts @@ -0,0 +1,148 @@ +import { getGeminiClient, FLASH_MODEL } from "@/lib/gemini/client"; + +export type HorarioPeriodo = { + /** ISO date YYYY-MM-DD */ + inicio: string; + /** ISO date YYYY-MM-DD */ + fin: string; + /** e.g. "Lu-Ju", "Ma-Vi", null if no schedule */ + dias: string | null; + /** "HH:MM" 24h or null */ + hora_inicio: string | null; + hora_fin: string | null; + /** "Edificio CIAP / Salón 517" or similar */ + ubicacion: string | null; + /** True if attributes mention "Semana Tec". */ + es_semana_tec: boolean; +}; + +export type HorarioMateria = { + /** Just the alphanumeric clave, e.g. "TC2037" (the part before the dot). */ + clave: string; + /** Full clave including section, e.g. "TC2037.602". */ + clave_completa: string; + nombre: string; + crn: string; + periodos: HorarioPeriodo[]; +}; + +export type HorarioParsed = { + alumno_nombre: string | null; + matricula: string | null; + periodo_nombre: string | null; + /** Earliest periodo inicio across all materias. */ + periodo_inicio: string | null; + /** Latest periodo fin across all materias. */ + periodo_fin: string | null; + materias: HorarioMateria[]; +}; + +const HORARIO_SCHEMA = { + type: "object", + properties: { + alumno_nombre: { type: "string", nullable: true }, + matricula: { type: "string", nullable: true }, + periodo_nombre: { type: "string", nullable: true }, + materias: { + type: "array", + items: { + type: "object", + properties: { + clave: { type: "string" }, + clave_completa: { type: "string" }, + nombre: { type: "string" }, + crn: { type: "string" }, + periodos: { + type: "array", + items: { + type: "object", + properties: { + inicio: { type: "string" }, + fin: { type: "string" }, + dias: { type: "string", nullable: true }, + hora_inicio: { type: "string", nullable: true }, + hora_fin: { type: "string", nullable: true }, + ubicacion: { type: "string", nullable: true }, + es_semana_tec: { type: "boolean" }, + }, + required: [ + "inicio", + "fin", + "dias", + "hora_inicio", + "hora_fin", + "ubicacion", + "es_semana_tec", + ], + }, + }, + }, + required: ["clave", "clave_completa", "nombre", "crn", "periodos"], + }, + }, + }, + required: ["alumno_nombre", "matricula", "periodo_nombre", "materias"], +}; + +const SYSTEM_PROMPT = `Vas a recibir un PDF del MiTec (Tec de Monterrey) con el horario académico de un estudiante. + +Extrae: +- Nombre completo del alumno y matrícula (formato A0XXXXXXX). +- Nombre del periodo (e.g. "Semestral Ene - Jun de 2026"). +- Cada materia única (deduplicada por clave + CRN). +- Para cada materia, todos sus periodos de fechas con horario. + +REGLAS: +- "clave" es solo la parte alfanumérica antes del punto, ej. "TC2037" para "TC2037.602". +- "clave_completa" incluye todo, ej. "TC2037.602". +- "nombre" es el título completo de la materia. +- "crn" es el número CRN tal cual aparece. +- Las fechas deben estar en formato ISO YYYY-MM-DD. +- Los días vienen como "Lu-Ju", "Ma-Vi", "Lu-Mi-Ju", etc. Si dice "No Aplica" pon null. +- Los horarios deben estar en formato HH:MM 24h. Si "No Aplica" pon null. +- "ubicacion" combina edificio + salón, ej. "Edificio CIAP / Salón 517". Si "No Aplica" pon null. +- "es_semana_tec" = true SOLO si los atributos del curso mencionan "Semana Tec". +- Si una materia aparece varias veces con diferentes rangos de fechas (porque corre en múltiples periodos del semestre), agrúpala como UNA sola materia con un array de "periodos". +- Ignora la sección "Actividades Formativas" si dice "Curso Sin Crédito Académico"; sí incluye las que tienen créditos. + +Devuelve JSON puro siguiendo el schema. Sin texto fuera del JSON.`; + +export async function parseHorarioPdf(pdfBytes: Uint8Array): Promise { + const client = getGeminiClient(); + + const base64 = Buffer.from(pdfBytes).toString("base64"); + + const response = await client.models.generateContent({ + model: FLASH_MODEL, + contents: [ + { + role: "user", + parts: [ + { text: SYSTEM_PROMPT }, + { + inlineData: { + mimeType: "application/pdf", + data: base64, + }, + }, + ], + }, + ], + config: { + responseMimeType: "application/json", + responseSchema: HORARIO_SCHEMA, + temperature: 0.1, + }, + }); + + const text = response.candidates?.[0]?.content?.parts?.[0]?.text ?? ""; + const parsed = JSON.parse(text) as Omit; + + // Compute overall semester start and end from materia periods. + const allInicios = parsed.materias.flatMap((m) => m.periodos.map((p) => p.inicio)); + const allFines = parsed.materias.flatMap((m) => m.periodos.map((p) => p.fin)); + const periodo_inicio = allInicios.length ? allInicios.sort()[0] : null; + const periodo_fin = allFines.length ? allFines.sort().at(-1) ?? null : null; + + return { ...parsed, periodo_inicio, periodo_fin }; +} diff --git a/apps/frontend/src/lib/tec21/calendar.ts b/apps/frontend/src/lib/tec21/calendar.ts index 0cff77a..ac324ae 100644 --- a/apps/frontend/src/lib/tec21/calendar.ts +++ b/apps/frontend/src/lib/tec21/calendar.ts @@ -1,93 +1,170 @@ /** - * Tec21 academic calendar helpers. + * Tec21 academic calendar helpers — period-aware version. * - * The Tec21 model splits each semester into 3 blocks of 5 weeks each, with a - * "Semana Tec" (intensive project week) between blocks. Regular classes are - * suspended during Semana Tec. After block 3 there is usually a finals week. - * - * Layout (1-indexed weeks from semester start): - * - Weeks 1-5 → Block 1 - * - Week 6 → Semana Tec 1 - * - Weeks 7-11 → Block 2 - * - Week 12 → Semana Tec 2 - * - Weeks 13-17 → Block 3 - * - Week 18+ → Finals / vacation + * Instead of computing block boundaries from a single semester start date, + * we read the real periods uploaded from the student's MiTec horario PDF. + * Each materia carries an array of `periodos` with explicit start/end dates + * and a `es_semana_tec` flag. */ -const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000; +export type MateriaPeriodo = { + inicio: string; + fin: string; + dias: string | null; + hora_inicio: string | null; + hora_fin: string | null; + ubicacion: string | null; + es_semana_tec: boolean; +}; -export type Tec21WeekInfo = { - /** ISO date string (YYYY-MM-DD) of the semester start. */ - semestre_inicio: string; - /** 1-indexed week number from semester start. */ - semana_actual: number; - /** Block number 1 / 2 / 3, or null if outside the academic period. */ - bloque_actual: 1 | 2 | 3 | null; - /** 1-indexed week within the current block (1-5), or 0 if Semana Tec / finales. */ - semana_en_bloque: number; - /** True if the current week is a Semana Tec. */ +export type MateriaConPeriodos = { + clave: string; + nombre: string; + crn?: string | null; + creditos: number; + prioridad: number; + periodos: MateriaPeriodo[]; +}; + +export type PeriodoActivoInfo = { + /** True if today falls inside any Semana Tec period. */ es_semana_tec: boolean; - /** Human-readable label, e.g. "Bloque 2 — semana 3 de 5". */ + /** ISO date YYYY-MM-DD where the current "block" started. */ + bloque_inicio: string | null; + /** ISO date YYYY-MM-DD where the current "block" ends. */ + bloque_fin: string | null; + /** Human label, e.g. "Bloque 2 (23-mar al 3-may)" or "Semana Tec — Inteligencia artificial para textos científicos". */ etiqueta: string; + /** Total weeks since semester start. */ + semana_actual: number; + /** Days remaining until current block ends. */ + dias_restantes_bloque: number | null; }; -export function getTec21WeekInfo( +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const MS_PER_WEEK = 7 * MS_PER_DAY; + +function toDate(iso: string): Date { + return new Date(`${iso}T00:00:00`); +} + +function isWithin(now: Date, inicioIso: string, finIso: string): boolean { + const t = now.getTime(); + return t >= toDate(inicioIso).getTime() && t <= toDate(finIso).getTime() + MS_PER_DAY - 1; +} + +/** + * Materias activas hoy: aquellas con al menos un periodo que contenga la fecha. + */ +export function materiasActivas( now: Date, - semestreInicio: string, -): Tec21WeekInfo { - const start = new Date(`${semestreInicio}T00:00:00`); - const diff = now.getTime() - start.getTime(); - const semana_actual = Math.max(1, Math.floor(diff / MS_PER_WEEK) + 1); - - let bloque_actual: 1 | 2 | 3 | null = null; - let semana_en_bloque = 0; - let es_semana_tec = false; - let etiqueta = ""; - - if (semana_actual >= 1 && semana_actual <= 5) { - bloque_actual = 1; - semana_en_bloque = semana_actual; - etiqueta = `Bloque 1 — semana ${semana_en_bloque} de 5`; - } else if (semana_actual === 6) { - bloque_actual = 1; - es_semana_tec = true; - etiqueta = "Semana Tec 1 (proyectos intensivos)"; - } else if (semana_actual >= 7 && semana_actual <= 11) { - bloque_actual = 2; - semana_en_bloque = semana_actual - 6; - etiqueta = `Bloque 2 — semana ${semana_en_bloque} de 5`; - } else if (semana_actual === 12) { - bloque_actual = 2; - es_semana_tec = true; - etiqueta = "Semana Tec 2 (proyectos intensivos)"; - } else if (semana_actual >= 13 && semana_actual <= 17) { - bloque_actual = 3; - semana_en_bloque = semana_actual - 12; - etiqueta = `Bloque 3 — semana ${semana_en_bloque} de 5`; + materias: MateriaConPeriodos[], +): MateriaConPeriodos[] { + return materias.filter((m) => + m.periodos.some((p) => isWithin(now, p.inicio, p.fin)), + ); +} + +/** + * Computes the current period info from the union of all materias' periodos. + * Returns a label that distinguishes Semana Tec from regular blocks, and the + * boundaries of the current period so we can talk about days-remaining. + */ +export function getPeriodoActivo( + now: Date, + materias: MateriaConPeriodos[], + semestreInicioIso: string | null, +): PeriodoActivoInfo { + const activas = materiasActivas(now, materias); + + // Detect Semana Tec — it's a 1-week period explicitly flagged. + const semanaTec = activas + .flatMap((m) => + m.periodos + .filter((p) => p.es_semana_tec && isWithin(now, p.inicio, p.fin)) + .map((p) => ({ materia: m, periodo: p })), + ) + .at(0); + + let bloque_inicio: string | null = null; + let bloque_fin: string | null = null; + let etiqueta = "Periodo desconocido"; + let dias_restantes_bloque: number | null = null; + + if (semanaTec) { + bloque_inicio = semanaTec.periodo.inicio; + bloque_fin = semanaTec.periodo.fin; + etiqueta = `Semana Tec — ${semanaTec.materia.nombre}`; } else { - bloque_actual = null; - etiqueta = semana_actual > 17 ? "Finales / vacaciones" : "Antes del semestre"; + // Compute the current "block" as the intersection of all active periods + // that aren't Semana Tec. Take the LATEST inicio and EARLIEST fin among + // active non-tec periods. + const periodosRegulares = activas + .flatMap((m) => m.periodos.filter((p) => !p.es_semana_tec)) + .filter((p) => isWithin(now, p.inicio, p.fin)); + + if (periodosRegulares.length > 0) { + bloque_inicio = periodosRegulares + .map((p) => p.inicio) + .sort() + .at(-1) ?? null; + bloque_fin = periodosRegulares + .map((p) => p.fin) + .sort() + .at(0) ?? null; + + if (bloque_inicio && bloque_fin) { + // Identify which "block" number this is, by counting how many + // distinct period intervals end before this one starts. + const allPeriodos = materias.flatMap((m) => m.periodos); + const earlierEnds = new Set( + allPeriodos + .filter((p) => !p.es_semana_tec) + .filter((p) => toDate(p.fin).getTime() < toDate(bloque_inicio!).getTime()) + .map((p) => p.fin), + ); + const numero = earlierEnds.size + 1; + const fmt = (iso: string) => { + const d = toDate(iso); + return `${d.getDate()}-${["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"][d.getMonth()]}`; + }; + etiqueta = `Bloque ${numero} (${fmt(bloque_inicio)} al ${fmt(bloque_fin)})`; + } + } else { + etiqueta = "Periodo de transición o vacaciones"; + } + } + + if (bloque_fin) { + dias_restantes_bloque = Math.max( + 0, + Math.ceil((toDate(bloque_fin).getTime() - now.getTime()) / MS_PER_DAY), + ); } + // Weeks since semester start (informational — uses periodo_inicio if present). + const semana_actual = semestreInicioIso + ? Math.max(1, Math.floor((now.getTime() - toDate(semestreInicioIso).getTime()) / MS_PER_WEEK) + 1) + : 1; + return { - semestre_inicio: semestreInicio, - semana_actual, - bloque_actual, - semana_en_bloque, - es_semana_tec, + es_semana_tec: !!semanaTec, + bloque_inicio, + bloque_fin, etiqueta, + semana_actual, + dias_restantes_bloque, }; } /** * Try to extract Tec course claves from arbitrary text (e.g. Canvas event - * titles like "TC2025 - Tarea 3" or "Bases de Datos II [TC2018]"). Returns - * uppercase claves found, deduplicated. + * titles). Returns uppercase claves found, deduplicated. * - * Pattern: 1-3 letters followed by 3-4 digits. Examples that should match: - * TC2025, MA1010, F1001, ET1011, ST5001 + * Pattern: 1-3 letters followed by 3-4 digits, optionally followed by a dot + * and a section number. */ -const CLAVE_PATTERN = /\b([A-Z]{1,3}\d{3,4})\b/g; +const CLAVE_PATTERN = /\b([A-Z]{1,3}\d{3,4})(?:\.\d+)?\b/g; export function extractClavesFromText(text: string): string[] { if (!text) return []; diff --git a/apps/frontend/src/lib/types/planes.ts b/apps/frontend/src/lib/types/planes.ts index 0043966..f3b8ab3 100644 --- a/apps/frontend/src/lib/types/planes.ts +++ b/apps/frontend/src/lib/types/planes.ts @@ -33,8 +33,5 @@ export type ProfileSetupBody = { carreraClave: string; modelo: Modelo; semestre: number; - /** ISO date YYYY-MM-DD for the start of the current semester. */ - semestreInicio?: string; - materias: Array & { prioridad: number }>; canvasIcalUrl?: string; }; From 73f30e68f97aca06c579467e5f0a993c77d0888b Mon Sep 17 00:00:00 2001 From: Daniel Diaz de Leon Date: Sat, 9 May 2026 15:39:21 -0600 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20dashboard=20polish=20=E2=80=94=20cur?= =?UTF-8?q?rent-week=20anchor,=20auto-sync,=20auto-generate=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: dashboard always shows current week + surface Google Calendar errors Two bugs reported on demo: 1. Dashboard header showed stale week (e.g. "27 de abril al 3 de mayo" while today is May 9). Cause: dashboard anchored the week to insight.semana_iso, so a cached insight from a previous ISO week dragged the UI back. Fix: always anchor to today; if the cached insight is from a different ISO week, treat as no-insight and render EmptyInsightCard so the user regenerates. 2. /api/calendar/create returned a generic "No se pudo crear ningún evento" that hid the real Google API error. Cause: createEvents only logged errors to the server. Fix: return per-event errors with status + message in the response. Also adds timeZone: "America/Monterrey" to start/end (Google rejects ambiguous datetimes without offset). When the first error is 401/403 the response includes a "cierra sesión y vuelve a entrar" hint so the user can refresh the OAuth token if scope is the issue. Co-Authored-By: Claude Sonnet 4.6 * fix: better error messages when Google Calendar API is disabled - Bump error message truncation from 200 to 500 chars so the full Cloud Console URL Google returns isn't cut off mid-word. - New hint logic: if the response mentions "has not been used" or "disabled" we tell the user to enable the API instead of pointing them to re-login. The previous re-login hint only made sense for actual auth failures. Co-Authored-By: Claude Sonnet 4.6 * feat(dashboard): auto-sync Google Calendar + Canvas iCal on every load The dashboard reads `eventos` from the DB but nothing was populating that table on its own — the user had to manually POST to /api/calendar/sync or /api/canvas/sync to see anything. Now we sync both right before rendering so a fresh login or page reload shows real events without any extra step. - lib/google/calendar.ts: extract syncGoogleCalendarToDb(supabase, userId) reusable from server components, so the dashboard and the existing route share one implementation. - lib/ical/parser.ts: same — syncCanvasIcalToDb(supabase, userId, icalUrl) reads profile.canvas_ical_url and upserts entregas. - dashboard/page.tsx: autoSyncExternalCalendars() runs both syncs in parallel via Promise.allSettled before loadCurrentInsight + loadWeekEventos, swallowing per-source errors so a stale cache is preferred over a broken dashboard. Skipped in demo mode (?demo=1). Co-Authored-By: Claude Sonnet 4.6 * feat(dashboard): auto-generate weekly insight on empty state When the dashboard lands on EmptyInsightCard the user had to click "Generar mi semana" to get any blocks. Now the card auto-fires the generation on first mount (using useRef guard so React strict-mode double-render doesn't double-trigger). Loading title + spinner reflect the in-flight call. If generation errors, surface the server's error message inline and switch the button to "Reintentar" without auto-retrying. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../frontend/src/app/(app)/dashboard/page.tsx | 79 +++++++++++----- .../src/app/api/calendar/create/route.ts | 24 ++++- .../components/dashboard/EmptyInsightCard.tsx | 60 +++++++----- apps/frontend/src/lib/google/calendar.ts | 94 ++++++++++++++++++- apps/frontend/src/lib/ical/parser.ts | 32 +++++++ 5 files changed, 237 insertions(+), 52 deletions(-) diff --git a/apps/frontend/src/app/(app)/dashboard/page.tsx b/apps/frontend/src/app/(app)/dashboard/page.tsx index 739d0fe..5602e63 100644 --- a/apps/frontend/src/app/(app)/dashboard/page.tsx +++ b/apps/frontend/src/app/(app)/dashboard/page.tsx @@ -1,4 +1,4 @@ -import { parseISO } from "date-fns"; +import { getISOWeek, getYear, parseISO } from "date-fns"; import { DashboardView } from "@/components/dashboard/DashboardView"; import { EmptyInsightCard } from "@/components/dashboard/EmptyInsightCard"; @@ -6,6 +6,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { getWeekRange } from "@/lib/dates"; import { EVENTOS_FIXTURE } from "@/lib/fixtures/eventos"; import { INSIGHT_FIXTURE } from "@/lib/fixtures/insights"; +import { syncGoogleCalendarToDb } from "@/lib/google/calendar"; +import { syncCanvasIcalToDb } from "@/lib/ical/parser"; import { getSupabaseServer } from "@/lib/supabase/server"; import type { Evento } from "@/lib/types/eventos"; import type { Insight } from "@/lib/types/insights"; @@ -42,6 +44,42 @@ async function loadCurrentInsight(): Promise { + console.warn("[dashboard] google sync failed:", err); + }), + perfil?.canvas_ical_url + ? syncCanvasIcalToDb(supabase, user.id, perfil.canvas_ical_url).catch((err) => { + console.warn("[dashboard] canvas sync failed:", err); + }) + : Promise.resolve(), + ]); + } catch (err) { + console.warn("[dashboard] autoSync skipped:", err); + } +} + async function loadWeekEventos(start: Date, end: Date): Promise { if (!hasSupabaseConfig()) return "missing"; try { @@ -86,6 +124,12 @@ export default async function DashboardPage({ const demo = params.demo === "1"; const showEmpty = params.empty === "1"; + // Auto-sync external calendars (Google + Canvas) before reading eventos so + // the user sees fresh data without clicking anything. Skipped in demo mode. + if (!demo) { + await autoSyncExternalCalendars(); + } + const realInsight = demo ? "missing" : await loadCurrentInsight(); const insight: Insight | null | "error" = realInsight === "missing" @@ -116,12 +160,16 @@ export default async function DashboardPage({ const usingFixture = realInsight === "missing"; - // Anchor the calendar week to whatever data we're showing. With fixtures, use - // the seed reference date so the demo events line up. With real data, parse - // the insight's semana_iso (ISO week) or fall back to today. - const reference = usingFixture - ? FIXTURE_REFERENCE_DATE - : parseIsoWeekToDate(insight.semana_iso) ?? new Date(); + // Anchor the calendar week to TODAY for real data. Fixtures use the seed + // reference so demo events line up. Stale cached insights (from a previous + // ISO week) get treated as "no insight yet" so the user is prompted to + // regenerate instead of seeing last week's blocks on this week's calendar. + const today = new Date(); + const currentSemanaISO = `${getYear(today)}-W${String(getISOWeek(today)).padStart(2, "0")}`; + if (!usingFixture && insight.semana_iso !== currentSemanaISO) { + return ; + } + const reference = usingFixture ? FIXTURE_REFERENCE_DATE : today; const week = getWeekRange(reference); const realEventos = demo ? "missing" : await loadWeekEventos(week.start, week.end); @@ -142,20 +190,3 @@ export default async function DashboardPage({ ); } -/** Convert "2026-W20" to the Monday of that ISO week. */ -function parseIsoWeekToDate(semanaIso: string): Date | null { - const match = /^(\d{4})-W(\d{2})$/.exec(semanaIso); - if (!match) return null; - const year = Number(match[1]); - const week = Number(match[2]); - // ISO week 1 is the week with the year's first Thursday. Construct from - // simple date math: take Jan 4 of the year (always in week 1), back up to - // Monday, then add (week - 1) weeks. - const jan4 = new Date(Date.UTC(year, 0, 4)); - const dayOfWeek = jan4.getUTCDay() || 7; - const week1Monday = new Date(jan4); - week1Monday.setUTCDate(jan4.getUTCDate() - dayOfWeek + 1); - const target = new Date(week1Monday); - target.setUTCDate(week1Monday.getUTCDate() + (week - 1) * 7); - return target; -} diff --git a/apps/frontend/src/app/api/calendar/create/route.ts b/apps/frontend/src/app/api/calendar/create/route.ts index 4cfe8ad..2ec08c7 100644 --- a/apps/frontend/src/app/api/calendar/create/route.ts +++ b/apps/frontend/src/app/api/calendar/create/route.ts @@ -81,11 +81,31 @@ export async function POST(request: Request) { fin: e.fin, })) - const createdIds = await createEvents(token, newCalEvents) + const { ids: createdIds, errors: createErrors } = await createEvents( + token, + newCalEvents, + ) if (createdIds.length === 0) { + const first = createErrors[0] + const detail = first + ? ` Google respondió ${first.status}: ${first.message}` + : '' + let hint = '' + if (first) { + const msg = first.message.toLowerCase() + if (msg.includes('has not been used') || msg.includes('disabled')) { + hint = ' Habilita la Google Calendar API en Google Cloud Console y reintenta.' + } else if (first.status === 401 || msg.includes('insufficient')) { + hint = ' Cierra sesión y vuelve a entrar para autorizar Google Calendar.' + } + } return NextResponse.json( - { success: false, error: 'No se pudo crear ningún evento en Google Calendar' }, + { + success: false, + error: `No se pudo crear ningún evento.${detail}${hint}`, + errors: createErrors, + }, { status: 500 }, ) } diff --git a/apps/frontend/src/components/dashboard/EmptyInsightCard.tsx b/apps/frontend/src/components/dashboard/EmptyInsightCard.tsx index 3744c16..bc6acc5 100644 --- a/apps/frontend/src/components/dashboard/EmptyInsightCard.tsx +++ b/apps/frontend/src/components/dashboard/EmptyInsightCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Loader2, WandSparkles } from "lucide-react"; import { toast } from "sonner"; @@ -16,55 +16,70 @@ import { export function EmptyInsightCard() { const router = useRouter(); const [pending, setPending] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const autoTriggered = useRef(false); - async function handleGenerate() { + async function handleGenerate(force = false) { setPending(true); + setErrorMessage(null); try { const res = await fetch("/api/insights/generate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ forceRefresh: false }), + body: JSON.stringify({ forceRefresh: force }), }); - if (res.status === 404) { - toast.info("Aún no hay backend de insights", { - description: "Mientras llega Persona B, usa modo demo (?demo=1).", - }); - return; - } if (!res.ok) { - const msg = await res.text().catch(() => ""); - throw new Error(msg || `status ${res.status}`); + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `status ${res.status}`); } toast.success("Tu semana está lista"); router.refresh(); } catch (err) { const description = - err instanceof Error - ? err.message - : "Inténtalo de nuevo en un momento."; + err instanceof Error ? err.message : "Inténtalo de nuevo en un momento."; + setErrorMessage(description); toast.error("No pudimos generar tus insights", { description }); - } finally { - setPending(false); + setPending(false); // only reset on error; success path triggers refresh } } + // Auto-trigger generation on first mount so the user doesn't have to click. + useEffect(() => { + if (autoTriggered.current) return; + autoTriggered.current = true; + void handleGenerate(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( - + {pending ? ( + + ) : ( + + )} - Vamos a planear tu semana + + {pending ? "Generando tu semana…" : "Vamos a planear tu semana"} +

- Gemini va a analizar tus materias, tus entregas en Canvas y tu - calendario para sugerir qué estudiar y cuándo. + {pending + ? "Gemini está analizando tu horario, materias y entregas. Toma 5-15 segundos." + : "Gemini va a analizar tus materias, tus entregas en Canvas y tu calendario para sugerir qué estudiar y cuándo."}

+ {errorMessage && ( +

+ {errorMessage} +

+ )} diff --git a/apps/frontend/src/lib/google/calendar.ts b/apps/frontend/src/lib/google/calendar.ts index cfa4c78..57ff2d9 100644 --- a/apps/frontend/src/lib/google/calendar.ts +++ b/apps/frontend/src/lib/google/calendar.ts @@ -1,3 +1,5 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + import { getSupabaseServer } from '@/lib/supabase/server' export type GCalEvent = { @@ -67,11 +69,20 @@ export async function listEvents( // --- createEvents --- +export type CreateEventsResult = { + ids: string[] + /** Per-event errors keyed by titulo. Empty if everything succeeded. */ + errors: Array<{ titulo: string; status: number; message: string }> +} + +const TIMEZONE = 'America/Monterrey' + export async function createEvents( token: string, events: NewCalEvent[], -): Promise { +): Promise { const ids: string[] = [] + const errors: CreateEventsResult['errors'] = [] for (const event of events) { try { @@ -86,8 +97,10 @@ export async function createEvents( body: JSON.stringify({ summary: event.titulo, description: `[TecCoach] ${event.descripcion ?? ''}`, - start: { dateTime: event.inicio }, - end: { dateTime: event.fin }, + // Always include timeZone so Google doesn't reject when the + // dateTime string is missing an offset. + start: { dateTime: event.inicio, timeZone: TIMEZONE }, + end: { dateTime: event.fin, timeZone: TIMEZONE }, }), }, ) @@ -97,15 +110,88 @@ export async function createEvents( console.error( `[createEvents] falló "${event.titulo}" (${response.status}): ${text}`, ) + // Try to surface a clean message from Google's JSON error envelope. + let message = text + try { + const json = JSON.parse(text) as { error?: { message?: string } } + if (json.error?.message) message = json.error.message + } catch { + // not JSON, keep raw text (truncated) + } + errors.push({ + titulo: event.titulo, + status: response.status, + message: message.slice(0, 500), + }) continue } const created = (await response.json()) as GCalCreatedEvent if (created.id) ids.push(created.id) } catch (err) { + const message = err instanceof Error ? err.message : String(err) console.error(`[createEvents] error inesperado en "${event.titulo}":`, err) + errors.push({ titulo: event.titulo, status: 0, message }) } } - return ids + return { ids, errors } +} + +// --- syncGoogleCalendarToDb --- + +/** + * Pulls the next 14 days of Google Calendar events and upserts them into + * the `eventos` table. Reusable from API routes and from server components. + * Throws on failure so the caller can decide how to surface it. + */ +export async function syncGoogleCalendarToDb( + supabase: SupabaseClient, + userId: string, +): Promise { + const token = await getProviderToken(userId) + + const fromDate = new Date() + const toDate = new Date() + toDate.setDate(toDate.getDate() + 14) + + const events = await listEvents(token, fromDate, toDate) + + type EventoRow = { + user_id: string + source: 'google' + external_id: string + titulo: string + fecha_inicio: string + fecha_fin: string + } + + const rows: EventoRow[] = [] + for (const event of events) { + if (!event.summary) continue + const inicio = + event.start.dateTime ?? + (event.start.date ? `${event.start.date}T00:00:00Z` : null) + const fin = + event.end.dateTime ?? + (event.end.date ? `${event.end.date}T23:59:59Z` : null) + if (!inicio || !fin) continue + rows.push({ + user_id: userId, + source: 'google', + external_id: event.id, + titulo: event.summary, + fecha_inicio: inicio, + fecha_fin: fin, + }) + } + + if (rows.length === 0) return 0 + + const { error } = await supabase + .from('eventos') + .upsert(rows, { onConflict: 'user_id,external_id' }) + if (error) throw new Error(`upsert eventos: ${error.message}`) + + return rows.length } diff --git a/apps/frontend/src/lib/ical/parser.ts b/apps/frontend/src/lib/ical/parser.ts index 8435ee1..3050755 100644 --- a/apps/frontend/src/lib/ical/parser.ts +++ b/apps/frontend/src/lib/ical/parser.ts @@ -46,3 +46,35 @@ export async function parseCanvasIcal(url: string): Promise { return entregas } + +import type { SupabaseClient } from '@supabase/supabase-js' + +/** + * Parses a Canvas iCal feed and upserts the entregas into the `eventos` + * table for the given user. Reusable from the route and from server + * components that want to auto-refresh on dashboard load. + */ +export async function syncCanvasIcalToDb( + supabase: SupabaseClient, + userId: string, + icalUrl: string, +): Promise { + const entregas = await parseCanvasIcal(icalUrl) + if (entregas.length === 0) return 0 + + const rows = entregas.map((e) => ({ + user_id: userId, + source: 'canvas' as const, + external_id: e.external_id, + titulo: e.titulo, + fecha_inicio: `${e.fecha_entrega}T00:00:00Z`, + fecha_fin: `${e.fecha_entrega}T23:59:59Z`, + })) + + const { error } = await supabase + .from('eventos') + .upsert(rows, { onConflict: 'user_id,external_id' }) + if (error) throw new Error(`upsert eventos: ${error.message}`) + + return rows.length +}