Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand Down
79 changes: 55 additions & 24 deletions apps/frontend/src/app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { parseISO } from "date-fns";
import { getISOWeek, getYear, parseISO } from "date-fns";

import { DashboardView } from "@/components/dashboard/DashboardView";
import { EmptyInsightCard } from "@/components/dashboard/EmptyInsightCard";
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";
Expand Down Expand Up @@ -42,6 +44,42 @@ async function loadCurrentInsight(): Promise<Insight | null | "missing" | "error
}
}

/**
* Pulls fresh events from the user's Google Calendar and Canvas iCal feed
* before the dashboard reads from the `eventos` table. Errors are swallowed
* — a stale cache is better than a broken dashboard. We run both syncs in
* parallel since they hit different external APIs.
*/
async function autoSyncExternalCalendars() {
if (!hasSupabaseConfig()) return;
try {
const supabase = await getSupabaseServer({ allowCookieWriteFailure: true });
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;

const { data: perfil } = await supabase
.from("profiles")
.select("canvas_ical_url")
.eq("id", user.id)
.maybeSingle();

await Promise.allSettled([
syncGoogleCalendarToDb(supabase, user.id).catch((err) => {
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<Evento[] | "missing" | "error"> {
if (!hasSupabaseConfig()) return "missing";
try {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 <EmptyInsightCard />;
}
const reference = usingFixture ? FIXTURE_REFERENCE_DATE : today;
const week = getWeekRange(reference);

const realEventos = demo ? "missing" : await loadWeekEventos(week.start, week.end);
Expand All @@ -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;
}
24 changes: 22 additions & 2 deletions apps/frontend/src/app/api/calendar/create/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
)
}
Expand Down
61 changes: 43 additions & 18 deletions apps/frontend/src/app/api/insights/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -74,19 +78,28 @@ 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();

if (!perfil) {
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
Expand All @@ -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<string>();
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,
})),
];
Expand All @@ -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,
};
Expand Down
86 changes: 86 additions & 0 deletions apps/frontend/src/app/api/profile/horario/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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 });
}
}
Loading
Loading