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/(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/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/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/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/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 +} 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; };