PageSourceSearch

https://fluye.site/script.js

js fluye.site collected 2026-09-24 15:11:30 UTC 89,945 bytes, 2,523 lines download raw bytes

1// --- CONFIGURACIÓN SUPABASE ---
2const SUPABASE_URL = "https://fymhvyofobicqtzmknqj.supabase.co";
3const SUPABASE_KEY =
4  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ5bWh2eW9mb2JpY3F0em1rbnFqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjM3NDQ3NjUsImV4cCI6MjA3OTMyMDc2NX0.VLwi69kX7lLTWdbsF2yFJRUrb3iamuWLT77aUOyRDl0";
5const supabaseClient = window.supabase.createClient(SUPABASE_URL, SUPABASE_KEY);
6
7// --- CONSTANTES ---
8const DEV_EMAIL = "[email protected]"; // <--- TU CORREO DE DESARROLLADOR
9
10const COLS = [
11  "#E57373", "#F06292", "#BA68C8", "#9575CD", "#64B5F6", "#4DB6AC", "#81C784", 
12  "#FFD54F", "#FF8A65", "#f2f6f0", "#D32F2F", "#C2185B", "#7B1FA2", "#000000", 
13  "#1976D2", "#388E3C", "#F57C00",
14];
15const AVATARS = [
16  "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Brian",
17  "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Nolan",
18  "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Wyatt",
19  "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Vivian",
20  "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Oliver",
21  "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Andrea",
22  "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Adrian",
23  "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Emery",
24  "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Jack",
25  "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Sara",
26];
27
28// --- FORMATEADOR DE FECHAS INTELIGENTE (UX PREMIUM) ---
29function formatearFechaLatam(fechaDB) {
30    if (!fechaDB) return ''; 
31    
32    const partes = fechaDB.split('-'); 
33    if (partes.length !== 3) return fechaDB;
34    
35    const año = parseInt(partes[0]);
36    const mes = parseInt(partes[1]) - 1; // JS cuenta los meses del 0 al 11
37    const dia = parseInt(partes[2]);
38    
39    const fechaTarea = new Date(año, mes, dia);
40    
41    // Obtenemos la fecha de hoy, eliminando las horas para comparar exacto
42    const hoy = new Date();
43    hoy.setHours(0, 0, 0, 0);
44    
45    const mañana = new Date(hoy);
46    mañana.setDate(mañana.getDate() + 1);
47    
48    const ayer = new Date(hoy);
49    ayer.setDate(ayer.getDate() - 1);
50    
51    // Nombres cortos para máxima elegancia visual
52    const dias = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
53    const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
54    
55    const nombreDia = dias[fechaTarea.getDay()];
56    const nombreMes = meses[fechaTarea.getMonth()];
57    
58    // Comparamos usando el "tiempo" matemático
59    const tTarea = fechaTarea.getTime();
60    
61    if (tTarea === hoy.getTime()) {
62        return "Hoy";
63    } else if (tTarea === mañana.getTime()) {
64        return "Mañana";
65    } else if (tTarea === ayer.getTime()) {
66        return "Ayer";
67    } else {
68        // Si es de este mismo año, no mostramos el año (Ej: "Lun, 12 Mar")
69        if (año === hoy.getFullYear()) {
70            return `${nombreDia}, ${dia} ${nombreMes}`;
71        } else {
72            // Si es de otro año, sí lo mostramos (Ej: "Lun, 12 Mar 2027")
73            return `${nombreDia}, ${dia} ${nombreMes} ${año}`;
74        }
75    }
76}
77
78// Función para abrir y cerrar el Panel Lateral (Sirve para el botón de las rayitas y la flecha)
79function toggleSidePanel() {
80    const panel = document.getElementById('sidePanel');
81    const overlay = document.getElementById('panelOverlay');
82    
83    // Toggle para el Celular
84    panel.classList.toggle('active');
85    if (overlay) overlay.classList.toggle('active');
86    
87    // 🔥 MAGIA PARA LA PC: Añade una clase al body para empujar las materias
88    document.body.classList.toggle('panel-closed');
89}
90
91// 🔥 NUEVO: Función inteligente que evalúa si estás en PC o Celular 🔥
92function closePanelMobile() {
93    // Si la pantalla es menor a 1024px (Celular o Tablet pequeña), cierra el panel.
94    // Si es más grande (PC), no hace absolutamente nada.
95    if (window.innerWidth < 1024) {
96        toggleSidePanel();
97    }
98}
99
100const app = {
101  enableCafe: false,
102  user: null,
103  data: [],
104  sCol: COLS[0],
105  pend: false,
106  cropper: null,
107  drag: null,
108  sAv: AVATARS[0],
109  delId: null,
110  
111  // 🔥 AQUÍ EMPIEZA LO NUEVO 🔥
112  filterView: 'all', 
113  sortView: 'date',
114
115  setFilter: (type) => {
116      app.filterView = type;
117      app.pend = (type === 'pending'); 
118
119      document.getElementById('chip-all').classList.remove('active');
120      document.getElementById('chip-pending').classList.remove('active');
121      document.getElementById('chip-' + type).classList.add('active');
122  },
123
124  setSort: (type) => {
125      app.sortView = type;
126
127      document.getElementById('chip-sort-date').classList.remove('active');
128      document.getElementById('chip-sort-alpha').classList.remove('active');
129      document.getElementById('chip-sort-new').classList.remove('active');
130      document.getElementById('chip-sort-' + type).classList.add('active');
131  },
132  // 🔥 AQUÍ TERMINA LO NUEVO 🔥
133
134  // =========================================
135  // 🔄 SISTEMA DE VISTAS (MATERIAS VS CRONOLÓGICO)
136  // =========================================
137  viewMode: 'subjects', 
138
139  // Esta función ahora recibe el input del switch (onchange="app.toggleViewMode(this)")
140  toggleViewMode: () => {
141    const container = document.querySelector(".fluye-switch");
142    const optSubjects = document.getElementById("optSubjects");
143    const optChrono = document.getElementById("optChrono");
144    
145    const viewMaterias = document.getElementById("mainList"); 
146    const viewCronologico = document.getElementById("chronoContainer");
147    const searchContainer = document.querySelector(".search-container");
148
149    if (app.viewMode === 'subjects') {
150        app.viewMode = 'chrono';
151        container.classList.add("is-chrono");
152        
153        // UI Updates
154        optSubjects.classList.remove("active");
155        optChrono.classList.add("active");
156        
157        if(searchContainer) searchContainer.classList.add("hidden");
158        viewMaterias.classList.add("hidden");
159        viewCronologico.classList.remove("hidden");
160        
161        app.renderChronoView(); 
162    } else {
163        app.viewMode = 'subjects';
164        container.classList.remove("is-chrono");
165        
166        // UI Updates
167        optSubjects.classList.add("active");
168        optChrono.classList.remove("active");
169        
170        viewCronologico.classList.add("hidden");
171        viewMaterias.classList.remove("hidden");
172        if(searchContainer) searchContainer.classList.remove("hidden");
173    }
174},
175
176  renderChronoView: () => {
177      const container = document.getElementById("chronoContainer");
178      container.innerHTML = ""; 
179
180      let todasLasTareas = [];
181
182      // 1. Extraer todas las tareas pendientes
183      app.data.forEach(materia => {
184          const tareasSeguras = materia.tasks || [];
185          tareasSeguras.forEach(tarea => {
186              if (!tarea.is_done && !tarea.is_hidden) {
187                  todasLasTareas.push({
188                      ...tarea,
189                      materiaNombre: materia.name,
190                      materiaColor: materia.color
191                  });
192              }
193          });
194      });
195
196      // 2. Ordenar por fecha
197      todasLasTareas.sort((a, b) => {
198          if (!a.due_date) return 1; 
199          if (!b.due_date) return -1;
200          return new Date(a.due_date) - new Date(b.due_date);
201      });
202
203      // 3. Estado vacío
204      if (todasLasTareas.length === 0) {
205          container.innerHTML = `
206            <div class="empty-state">
207                <i class="ph-duotone ph-coffee" style="font-size: 3rem; color: var(--accent); opacity: 0.5; margin-bottom: 15px;"></i>
208                <p>¡Todo al día! No tienes entregas próximas.</p>
209            </div>`;
210          return;
211      }
212
213      // 4. PINTAR CADA TAREA (Diseño de Cápsula Uniforme)
214      let htmlTareas = "";
215      todasLasTareas.forEach(tarea => {
216          
217          // Lógica de colores (solo fondo sutil, sin línea)
218          const todayStr = new Date().toISOString().split('T')[0];
219          let row = ""; 
220          if (tarea.due_date) {
221              if (tarea.due_date < todayStr) row = "state-overdue"; 
222              else if (tarea.due_date === todayStr) row = "state-warning"; 
223          }
224
225          // 🔥 Borde izquierdo forzado a ser normal para quitar la línea de color gruesa 🔥
226          htmlTareas += `
227              <div class="subject-card ${row}" style="padding: 16px; margin-bottom: 15px; display: flex; flex-direction: column; border-left: 1px solid var(--border) !important;">
228                  
229                  ${tarea.due_date ? `
230                  <div style="margin-bottom: 12px;">
231                      <span class="task-date" style="margin: 0;"><i class="ph-light ph-calendar-blank"></i> ${formatearFechaLatam(tarea.due_date)}</span>
232                  </div>` : ""}
233                  
234                  <div class="task-title" style="font-size: 1.05rem; margin-bottom: 4px;">
235                      ${tarea.title}
236                  </div>
237                  
238                  ${tarea.description ? `<div class="task-desc" style="margin-bottom: 12px;">${tarea.description}</div>` : `<div style="margin-bottom: 12px;"></div>`}
239                  
240                  <div style="margin-top: auto; padding-top: 10px; border-top: 1px dashed var(--border); display: flex; align-items: center; gap: 8px; font-size: 0.8rem; font-weight: 600; opacity: 0.75; overflow: hidden;">
241                      <div style="width: 8px; height: 8px; border-radius: 50%; background: ${tarea.materiaColor}; flex-shrink: 0;"></div>
242                      <span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;">${tarea.materiaNombre}</span>
243                  </div>
244                  
245              </div>
246          `;
247      });
248      
249      container.innerHTML = htmlTareas;
250  },
251
252// =========================================
253  // 📝 LÓGICA DE NOTAS RÁPIDAS (SUPABASE + LOCAL) 📝
254  // =========================================
255  notes: {
256    timeout: null, 
257
258    open: async () => {
259        const cajaTexto = document.getElementById("quickNotesText");
260        
261        // 1. Mostrar lo que hay en el teléfono al instante
262        const notaLocal = localStorage.getItem(`fluye_notes_${app.user ? app.user.id : 'guest'}`);
263        if (notaLocal !== null) {
264            cajaTexto.value = notaLocal;
265        }
266        
267        app.openModal('notesModal');
268        history.pushState({ modal: true, notes: true }, null, "");
269
270        // 2. Sincronizar de fondo con Supabase
271        if (app.user) {
272            try {
273                // 🔥 AQUÍ ESTÁ LA CORRECCIÓN: supabaseClient 🔥
274                const { data, error } = await supabaseClient
275                    .from('notas') 
276                    .select('contenido') 
277                    .eq('user_id', app.user.id)
278                    .single();
279
280                if (!error && data && data.contenido !== null) {
281                    cajaTexto.value = data.contenido;
282                    localStorage.setItem(`fluye_notes_${app.user.id}`, data.contenido);
283                }
284            } catch (err) {
285                console.error("Error cargando notas de Supabase:", err);
286            }
287        }
288    },
289    
290    save: () => {
291        const cajaTexto = document.getElementById("quickNotesText");
292        const texto = cajaTexto.value;
293
294        // Guardado local instantáneo
295        localStorage.setItem(`fluye_notes_${app.user ? app.user.id : 'guest'}`, texto);
296
297        // Guardado en la nube con "Debounce"
298        if (app.user) {
299            clearTimeout(app.notes.timeout); 
300            
301            app.notes.timeout = setTimeout(async () => {
302                try {
303                    // 🔥 AQUÍ ESTÁ LA CORRECCIÓN: supabaseClient 🔥
304                    const { error } = await supabaseClient
305                        .from('notas') 
306                        .upsert({ 
307                            user_id: app.user.id, 
308                            contenido: texto,
309                            actualizado_en: new Date().toISOString()
310                        }); 
311                        
312                    if (error) {
313                        console.error("Error de Supabase:", error.message);
314                        return;
315                    }
316                    console.log("☁️ Notas sincronizadas con Supabase exitosamente");
317                } catch (err) {
318                    console.error("Error en la conexión:", err);
319                }
320            }, 1000); 
321        }
322    },
323    
324    clear: () => {
325        const cajaTexto = document.getElementById("quickNotesText");
326        cajaTexto.value = "";
327        app.notes.save(); 
328        app.showToast("Notas limpias 🧹"); 
329    },
330    
331    close: () => {
332        app.closeModal('notesModal');
333    }
334  },
335
336  init: async () => {
337    // ... el resto de tu código sigue igual
338    
339    // Verificación segura para que NO falle en PC
340    if (typeof Capacitor !== 'undefined' && Capacitor.Plugins && Capacitor.Plugins.GoogleAuth) {
341       Capacitor.Plugins.GoogleAuth.initialize({
342         clientId: '850228421844-k0nku60p0trao4pgoln7is0csf5safa1.apps.googleusercontent.com',
343         scopes: ['profile', 'email'],
344         grantOfflineAccess: true,
345       });
346    }
347    
348    // Iniciar el sistema de notificaciones
349    app.initPush();
350
351    // Estilos del Toast
352    const style = document.createElement("style");
353    style.innerHTML = `
354        .toast-container { position: fixed; bottom: 90px; left: 50%; transform: translateX(-50%); z-index: 3000; display: flex; flex-direction: column; gap: 10px; pointer-events: none; width: max-content; }
355        .toast { background: rgba(31, 41, 55, 0.95); color: white; padding: 12px 24px; border-radius: 50px; font-size: 0.95rem; font-weight: 500; opacity: 0; transform: translateY(20px); transition: all 0.3s; box-shadow: 0 10px 25px rgba(0,0,0,0.2); display: flex; align-items: center; gap: 10px; backdrop-filter: blur(6px); border: 1px solid rgba(255,255,255,0.1); }
356        .toast.show { opacity: 1; transform: translateY(0); }
357        .toast.error { background: rgba(220, 38, 38, 0.9); }
358        .toast.success { background: rgba(5, 150, 105, 0.9); }
359        .toast i { font-size: 1.2rem; }
360    `;
361    document.head.appendChild(style);
362
363    
364    // --- CARGA DE TEMA INTELIGENTE (Incluye Navidad) ---
365    const savedTheme = localStorage.getItem("theme");
366    if (savedTheme === "dark") {
367      document.body.classList.add("dark");
368    }
369    app.updateLogos(); // <--- ESTO ASEGURA EL LOGO CORRECTO AL INICIAR
370
371    // --- NUEVO: CARGAR FRASE DEL DÍA ---
372    app.loadDailyQuote();
373
374    // --- GESTIÓN BOTÓN ATRÁS (NUEVO) ---
375    window.addEventListener('popstate', (event) => {
376      // Busca si hay alguna ventana abierta
377      const activeModal = document.querySelector('.modal-wrap.active');
378      if (activeModal) {
379        // Si hay una, la cierra visualmente
380        activeModal.classList.remove('active');
381      }
382    });
383
384    // --- MANEJO DEL BOTÓN ATRÁS (ANDROID) ---
385    if (typeof Capacitor !== 'undefined') {
386        const App = Capacitor.Plugins.App;
387        
388        App.addListener('backButton', ({ canGoBack }) => {
389            // 1. Buscamos si hay alguna ventana abierta (active)
390            const openModal = document.querySelector('.modal-wrap.active');
391            
392            // 2. Buscamos si el menú lateral de notificaciones está abierto
393            const notifMenu = document.querySelector('.notif-menu:not(.hidden)');
394
395            if (openModal) {
396                // Si hay modal, lo cerramos
397                openModal.classList.remove('active');
398            } else if (notifMenu) {
399                // Si el menú de notificaciones está abierto, lo cerramos
400                app.toggleNotifMenu();
401            } else {
402                // Si no hay nada abierto, salimos de la app
403                App.exitApp();
404            }
405        });
406    }
407
408   // Cargar preferencia de notificaciones
409    const savedNotif = localStorage.getItem("fluye_notif_days");
410    if (savedNotif) {
411        app.notifDays = parseInt(savedNotif);
412        // Esperamos un poco a que el DOM esté listo para actualizar el icono
413        setTimeout(() => app.updateBellIcon(), 100);
414    }
415
416    // Listeners de inputs
417    document
418      .getElementById("r_pass")
419      .addEventListener("input", app.checkStr);
420    ["l_email", "l_pass"].forEach((id) => {
421      const el = document.getElementById(id);
422      if (el)
423        el.addEventListener("keyup", (e) => {
424          if (e.key === "Enter") app.login();
425        });
426    });
427
428    const enterInputs = [
429        { id: "subName", exec: () => app.saveSub() },     // Nueva Materia
430        { id: "ttitle",  exec: () => app.saveT() },       // Tarea: Título
431        { id: "tdate",   exec: () => app.saveT() },       // Tarea: Fecha
432        { id: "p_user",  exec: () => app.saveProfile() }, // Perfil: Nombre
433        { id: "cafeRef", exec: () => app.sendDonation() },// Café: Referencia
434        { id: "cafeMsg", exec: () => app.sendDonation() } // Café: Mensaje
435    ];
436
437    enterInputs.forEach(item => {
438        const el = document.getElementById(item.id);
439        if (el) {
440            el.addEventListener("keydown", (e) => {
441                if (e.key === "Enter") {
442                    e.preventDefault(); // Evita comportamientos raros
443                    item.exec();        // Ejecuta la acción (Guardar)
444                }
445            });
446        }
447    });
448
449    // --- AUTH STATE LISTENER ---
450    const {
451      data: { session },
452    } = await supabaseClient.auth.getSession();
453
454    if (session) {
455      app.user = {
456        id: session.user.id,
457        email: session.user.email,
458        ...session.user.user_metadata,
459      };
460      
461      // El usuario existe, cargamos la app silenciosamente detrás de la cortina
462      app.showApp(false);
463    } else {
464      
465      // No hay sesión, mostramos el Login
466      document.getElementById("auth-view").classList.remove("hidden");
467      
468      // Solo bajamos nuestra cortina web
469      const fluyeLoader = document.getElementById("fluye-loader");
470      if (fluyeLoader) fluyeLoader.classList.add("oculto");
471    }
472
473    // Configuración Drag & Drop Móvil
474    const list = document.getElementById("mainList");
475    list.addEventListener("dragover", (e) => {
476      e.preventDefault();
477      const draggingItem = app.drag;
478      if (!draggingItem) return;
479      const siblings = [
480        ...list.querySelectorAll(".subject-card:not(.dragging)"),
481      ];
482      const nextSibling = siblings.reduce(
483        (closest, child) => {
484          const box = child.getBoundingClientRect();
485          const offset = e.clientY - box.top - box.height / 2;
486          if (offset < 0 && offset > closest.offset) {
487            return { offset: offset, element: child };
488          } else {
489            return closest;
490          }
491        },
492        { offset: Number.NEGATIVE_INFINITY }
493      ).element;
494      if (nextSibling) list.insertBefore(draggingItem, nextSibling);
495      else list.appendChild(draggingItem);
496    });
497    list.addEventListener("dragenter", (e) => e.preventDefault());
498
499    // LÓGICA DEL INTERRUPTOR DE CAFÉ
500    if (!app.enableCafe) {
501      const btn = document.getElementById("btnCafe");
502      if (btn) btn.classList.add("hidden");
503    }
504  },
505
506  // --- INICIO SESIÓN GOOGLE (CORREGIDO Y MEJORADO) ---
507  googleLogin: async () => {
508    // --- 🛡️ PROTECCIÓN PARA PC ---
509    if (typeof Capacitor === 'undefined') {
510        alert("💻 MODO PC: El botón de Google no funciona aquí porque falta el plugin web.\n\n📲 ¡PRUÉBALO EN EL CELULAR! Allí sí funcionará.");
511        return; 
512    }
513    // -----------------------------
514
515    try {
516      const GoogleAuth = Capacitor.Plugins.GoogleAuth;
517      const googleUser = await GoogleAuth.signIn();
518      const idToken = googleUser.authentication.idToken;
519
520      // 1. Login en Supabase
521      const { data, error } = await supabaseClient.auth.signInWithIdToken({
522        provider: 'google',
523        token: idToken,
524      });
525
526      if (error) throw error;
527
528      const user = data.session.user;
529      const meta = user.user_metadata;
530
531      // 2. OBTENER DATOS DE GOOGLE
532      // Google a veces da la foto en 'avatar_url' o 'picture'.
533      // A veces la URL de Google viene con parámetros raros, tomamos la pura.
534      let googleAvatar = meta.avatar_url || meta.picture || app.sAv;
535      const googleName = meta.full_name || meta.name || "Usuario Google";
536
537      // 3. 🧠 VERIFICAR SI YA EXISTE EN BASE DE DATOS (Para no borrar tu foto personalizada)
538      const { data: existingProfile } = await supabaseClient
539        .from('profiles')
540        .select('name, avatar')
541        .eq('id', user.id)
542        .single();
543
544      // DECISIÓN INTELIGENTE:
545      // Si ya existe perfil y tiene avatar, USAMOS ESE. Si no, usamos el de Google.
546      let finalAvatar = googleAvatar;
547      let finalName = googleName;
548
549      if (existingProfile) {
550          if (existingProfile.avatar) finalAvatar = existingProfile.avatar; // Mantenemos tu foto
551          if (existingProfile.name) finalName = existingProfile.name;     // Mantenemos tu nombre editado
552      }
553
554      // 4. GUARDAR EN MEMORIA APP
555      app.user = {
556          id: user.id,
557          email: user.email,
558          name: finalName,
559          avatar: finalAvatar,
560          ...meta
561      };
562
563      // 5. ACTUALIZAR BASE DE DATOS (Sin dañar lo existente)
564      await supabaseClient.from('profiles').upsert({
565          id: app.user.id,
566          name: finalName,
567          avatar: finalAvatar,
568          // last_seen se actualiza en showApp
569      });
570
571      // 6. Entrar
572      app.showToast("¡Bienvenido " + finalName + "!");
573      app.showApp(true);
574
575    } catch (e) {
576      console.error(e);
577      app.showToast("Error: " + (e.message || "No se pudo iniciar"), "error");
578    }
579  },
580
581  // --- NOTIFICACIONES PUSH (VERSIÓN FINAL) ---
582  initPush: async () => {
583    // 1. Protección para PC
584    if (typeof Capacitor === 'undefined' || !Capacitor.Plugins.PushNotifications) {
585        return;
586    }
587    const PushNotifications = Capacitor.Plugins.PushNotifications;
588
589    // 2. Pedir permiso
590    let permStatus = await PushNotifications.checkPermissions();
591    if (permStatus.receive === 'prompt') {
592      permStatus = await PushNotifications.requestPermissions();
593    }
594    if (permStatus.receive !== 'granted') {
595      return; // Si dice que no, no insistimos
596    }
597
598    // 3. Registrar
599    await PushNotifications.register();
600
601    // 4. ESCUCHAR ÉXITO (Silencioso)
602    PushNotifications.addListener('registration', (token) => {
603      console.log('🔔 Token obtenido');
604      // Lo guardamos en Supabase sin molestar al usuario
605      app.savePushToken(token.value);
606    });
607
608    // 5. ESCUCHAR ERRORES (Solo consola)
609    PushNotifications.addListener('registrationError', (error) => {
610      console.error("Error FCM:", error);
611    });
612
613    // 6. Al recibir notificación (App abierta)
614    PushNotifications.addListener('pushNotificationReceived', (notification) => {
615      /* app.showToast(notification.title || "Tienes tareas pendientes 🔔"); */
616    });
617  },
618
619  // Función auxiliar para guardar el token en Supabase (Opcional pero recomendada)
620  savePushToken: async (token) => {
621      if(!app.user) return;
622      
623      // Guardamos el token en la tabla de perfiles (asegúrate de tener una columna 'fcm_token' o similar)
624      // O crea una tabla 'devices' si el usuario tiene varios celulares.
625      const { error } = await supabaseClient
626        .from('profiles')
627        .update({ fcm_token: token })
628        .eq('id', app.user.id);
629        
630      if(error) console.error("Error guardando token:", error);
631  },
632
633  // --- NOTIFICACIONES ---
634  notifDays: 0, // 0 = Desactivado por defecto
635
636  toggleNotifMenu: () => {
637    const menu = document.getElementById("notifMenu");
638    menu.classList.toggle("hidden");
639    // Marcar la opción actual visualmente
640    [0,1,2,3].forEach(n => {
641        const btn = document.getElementById(`nOpt${n}`);
642        if(btn) btn.classList.toggle("selected", app.notifDays == n);
643    });
644  },
645
646  updateBellIcon: () => {
647    const icon = document.getElementById("bellIcon");
648    const btn = document.querySelector(".btn-bell-circle");
649    
650    // Validamos que existan para no dar error
651    if (!icon || !btn) return;
652
653    if (app.notifDays > 0) {
654        icon.className = "ph-fill ph-bell-ringing"; // Campana sonando
655        btn.classList.add("active");
656    } else {
657        icon.className = "ph-bold ph-bell-slash"; // Campana tachada
658        btn.classList.remove("active");
659    }
660  },
661
662  setNotif: async (days) => {
663    // 1. Actualización Visual (UI)
664    app.notifDays = days;
665    localStorage.setItem("fluye_notif_days", days);
666    app.updateBellIcon();
667    app.toggleNotifMenu();
668
669    // 2. Guardar preferencia en Supabase
670    if (app.user) {
671        await supabaseClient
672            .from('profiles')
673            .update({ notif_days: days })
674            .eq('id', app.user.id);
675    }
676
677    // 3. LÓGICA DE PERMISOS (NATIVA) 🛡️
678    // Si el usuario ACTIVÓ las notificaciones (días > 0), verificamos permisos.
679    if (days > 0 && typeof Capacitor !== 'undefined') {
680        const PushNotifications = Capacitor.Plugins.PushNotifications;
681        
682        // Revisamos el estado actual
683        let permStatus = await PushNotifications.checkPermissions();
684
685        // Si no tenemos permiso, lo pedimos AHORA MISMO
686        if (permStatus.receive !== 'granted') {
687            permStatus = await PushNotifications.requestPermissions();
688        }
689
690        // Si después de pedirlo sigue negado (el usuario dijo "No permitir" o "Nunca"),
691        // le avisamos que tiene que ir a Ajustes.
692        if (permStatus.receive !== 'granted') {
693            alert("⚠️ Las notificaciones están bloqueadas.\n\nPara que te avisemos, debes activarlas manualmente en: Ajustes > Aplicaciones > Fluye.");
694        } else {
695            // Si nos dio permiso, nos aseguramos de registrar el token por si acaso
696            app.initPush();
697            app.showToast(`Activado (${days} días antes) 🔔`);
698        }
699    } else if (days === 0) {
700        app.showToast("Notificaciones desactivadas 🔕");
701    }
702  },
703
704  // Función auxiliar para convertir la clave VAPID
705  urlBase64ToUint8Array: (base64String) => {
706    const padding = '='.repeat((4 - base64String.length % 4) % 4);
707    const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
708    const rawData = window.atob(base64);
709    const outputArray = new Uint8Array(rawData.length);
710    for (let i = 0; i < rawData.length; ++i) {
711        outputArray[i] = rawData.charCodeAt(i);
712    }
713    return outputArray;
714  },
715
716  // --- CARGAR FRASE ALEATORIA (CAMBIA AL RECARGAR) ---
717  loadDailyQuote: async () => {
718    try {
719      // 1. Buscamos el archivo de texto
720      const response = await fetch('frasesintro.txt');
721      if (!response.ok) throw new Error("No se encontró el archivo");
722      
723      const text = await response.text();
724      
725      // 2. Separamos por líneas y quitamos las vacías
726      const quotes = text.split('\n').filter(line => line.trim() !== '');
727      if (quotes.length === 0) return;
728
729      // 3. LÓGICA ALEATORIA
730      const quoteIndex = Math.floor(Math.random() * quotes.length);
731
732      // 4. Escribimos la frase en el HTML
733      const el = document.getElementById("quoteText");
734      if (el) {
735          el.innerText = quotes[quoteIndex];
736          // Animación suave de entrada
737          el.style.opacity = 0;
738          setTimeout(() => el.style.transition = "opacity 1s", 10);
739          setTimeout(() => el.style.opacity = 1, 50);
740      }
741    } catch (e) {
742      console.log("Usando frase por defecto...");
743      const el = document.getElementById("quoteText");
744      if(el) el.innerText = "Organiza tus tareas y alcanza tus metas.";
745    }
746  },
747
748  showToast: (msg, type = "success") => {
749    let c = document.querySelector(".toast-container");
750    if (!c) {
751      c = document.createElement("div");
752      c.className = "toast-container";
753      document.body.appendChild(c);
754    }
755    const t = document.createElement("div");
756    t.className = `toast ${type}`;
757    const icon =
758      type === "error"
759        ? '<i class="ph-light ph-warning-circle"></i>'
760        : '<i class="ph-light ph-check-circle"></i>';
761    t.innerHTML = `${icon} <span>${msg}</span>`;
762    c.appendChild(t);
763    requestAnimationFrame(() =>
764      setTimeout(() => t.classList.add("show"), 10)
765    );
766    setTimeout(() => {
767      t.classList.remove("show");
768      setTimeout(() => t.remove(), 300);
769    }, 3000);
770  },
771
772  toggleAuth: () => {
773    const loginForm = document.getElementById("login-form");
774    const regForm = document.getElementById("reg-form");
775    const authView = document.getElementById("auth-view"); // 👉 Capturamos la caja principal
776
777    // Revisamos cuál está visible para saber la dirección
778    const isLogin = !loginForm.classList.contains("hidden");
779
780    if (isLogin) {
781      // --- DE LOGIN A REGISTRO ---
782      
783      // 1. Animamos la salida del Login (se va a la izquierda)
784      loginForm.classList.add("anim-out-left");
785
786      // 2. Esperamos un instante (250ms) antes de cambiar
787      setTimeout(() => {
788        loginForm.classList.add("hidden");         // Ocultamos Login
789        loginForm.classList.remove("anim-out-left"); // Limpiamos la clase
790
791        regForm.classList.remove("hidden");        // Mostramos Registro
792        regForm.classList.add("anim-in-right");    // Animamos la entrada
793        
794        // ✨ AQUÍ ENTRA LA MAGIA: Activamos el modo registro para ocultar el logo
795        authView.classList.add("modo-registro");
796        
797        // Limpieza final
798        setTimeout(() => regForm.classList.remove("anim-in-right"), 400);
799      }, 250);
800
801    } else {
802      // --- VOLVER A LOGIN ---
803
804      // 1. Animamos la salida del Registro (se va a la derecha)
805      regForm.classList.add("anim-out-right");
806
807      // 2. Esperamos el cambio
808      setTimeout(() => {
809        regForm.classList.add("hidden");           // Ocultamos Registro
810        regForm.classList.remove("anim-out-right");  // Limpiamos
811
812        loginForm.classList.remove("hidden");      // Mostramos Login
813        loginForm.classList.add("anim-in-left");   // Animamos la entrada
814
815        // ✨ AQUÍ SE DESHACE: Quitamos el modo registro para que vuelva el logo
816        authView.classList.remove("modo-registro");
817
818        // Limpieza final
819        setTimeout(() => loginForm.classList.remove("anim-in-left"), 400);
820      }, 250);
821    }
822  },
823  
824  checkStr: () => {
825    const v = document.getElementById("r_pass").value;
826    const b = [1, 2, 3, 4].map((n) => document.getElementById("s" + n));
827    let s = 0;
828    if (v.length > 5) s++;
829    if (v.length > 8) s++;
830    if (/[A-Z]/.test(v)) s++;
831    if (/[0-9]/.test(v)) s++;
832    const c = ["#ef4444", "#f59e0b", "#10b981", "#059669"];
833    b.forEach(
834      (e, i) =>
835        (e.style.background = s > i ? c[s - 1] || "red" : "transparent")
836    );
837    const msg = document.getElementById("pwMsg");
838    msg.innerText =
839      ["Muy débil", "Mejorable", "Buena", "Excelente"][s - 1] ||
840      "Contraseña";
841    msg.style.color = c[s - 1] || "gray";
842    document.getElementById("btnReg").disabled = s < 2;
843  },
844
845  // --- AUTH SUPABASE (CON AVISO SILENCIOSO A TELEGRAM) ---
846  register: async () => {
847    const btn = document.getElementById("btnReg");
848    const originalText = btn.innerText;
849
850    // Bloqueo botón
851    btn.disabled = true;
852    btn.innerText = "Creando...";
853
854    try {
855      const email = document.getElementById("r_email").value.toLowerCase();
856      const password = document.getElementById("r_pass").value;
857      const name = document.getElementById("r_user").value;
858      const avatar = `https://api.dicebear.com/9.x/notionists/svg?seed=${name}`;
859
860      const { data, error } = await supabaseClient.auth.signUp({
861        email,
862        password,
863        options: { data: { name, avatar } },
864      });
865
866      if (error) {
867        app.showToast(error.message, "error");
868      } else {
869        
870        try {
871            // 👇 PON TUS DATOS AQUÍ 👇
872            const botToken = "8513861390:AAHtpZ9HLQGJI3RU64SuxFigicY5xY6lMqE"; 
873            const chatId = "721704312"; 
874
875            const texto = `🚀 *Nuevo Registro en Fluye*\n\n👤 *Nombre:* ${name}\n📧 *Email:* ${email}
875\n📅 *Fecha:* ${new Date().toLocaleString()}`;
876
877            // Enviamos la petición sin 'await' para que no frene la app
878            fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
879                method: "POST",
880                headers: { "Content-Type": "application/json" },
881                body: JSON.stringify({
882                    chat_id: chatId,
883                    text: texto,
884                    parse_mode: "Markdown"
885                })
886            }).catch(() => {}); // Si falla, no hacemos nada (silencio total)
887
888        } catch (e) {
889            // Ignorar errores del bot
890        }
891        // -------------------------------------------
892
893        app.showToast("¡Cuenta creada! Revisa tu correo para confirmar.");
894        app.toggleAuth();
895      }
896    } finally {
897      // Reactivar botón siempre
898      btn.disabled = false;
899      btn.innerText = originalText;
900    }
901  },
902
903  login: async () => {
904    const btn = document.querySelector("#login-form .btn-main");
905    const originalText = btn.innerText;
906
907    // Bloqueo botón
908    btn.disabled = true;
909    btn.innerText = "Verificando...";
910
911    try {
912      const email = document.getElementById("l_email").value.toLowerCase();
913      const password = document.getElementById("l_pass").value;
914
915      const { data, error } = await supabaseClient.auth.signInWithPassword({
916        email,
917        password,
918      });
919
920      if (error) {
921        // ❌ AQUÍ ESTÁ LA MAGIA DEL TEMBLOR
922        btn.classList.add("shake-horizontal");
923        
924        // Quitamos la clase después de 500ms para que pueda temblar de nuevo
925        setTimeout(() => {
926            btn.classList.remove("shake-horizontal");
927        }, 500);
928
929        if (error.message.includes("Email not confirmed")) {
930          app.showToast("Debes confirmar tu correo para entrar", "error");
931        } else {
932          app.showToast("Correo o Clave incorrecta", "error");
933        }
934      } else {
935        // Guardamos ID, EMAIL y METADATA
936        app.user = {
937          id: data.user.id,
938          email: data.user.email,
939          ...data.user.user_metadata,
940        };
941
942        app.showApp(true);
943        app.showToast("¡Bienvenido!");
944      }
945    } finally {
946      // Reactivar botón siempre
947      btn.disabled = false;
948      btn.innerText = originalText;
949    }
950  },
951
952  logout: async () => {
953    // 1. LIMPIEZA DE NOTIFICACIONES 🧹
954    if (app.user) {
955        await supabaseClient
956            .from('profiles')
957            .update({ fcm_token: null }) 
958            .eq('id', app.user.id);
959    }
960
961    // 2. Cerrar sesión en el servidor
962    await supabaseClient.auth.signOut();
963
964    // 3. Borrar credenciales locales
965    localStorage.removeItem("sb-fymhvyofobicqtzmknqj-auth-token");
966
967    // 4. Recargar para volver al Login
968    location.reload();
969  },
970
971  // --- DATA FETCHING ---
972  showApp: async (animate = false) => {
973    app.initPush();
974    // Cargar perfil actualizado
975    const { data: profile } = await supabaseClient
976      .from("profiles")
977      .select("*")
978      .eq("id", app.user.id)
979      .single();
980    if (profile) {
981      app.user.name = profile.name;
982      app.user.avatar = profile.avatar;
983      
984      // ✅ [NUEVO] Cargamos el Horario en memoria
985      app.user.schedule_url = profile.schedule_url; 
986
987      if (profile.notif_days !== null) {
988          app.notifDays = profile.notif_days;
989          localStorage.setItem("fluye_notif_days", profile.notif_days); 
990          // Esperamos un poquito para pintar la campana
991          setTimeout(() => { if(app.updateBellIcon) app.updateBellIcon() }, 100); 
992      }
993      
994      // ---------------------------------------------------------
995      //  ✅ ACTUALIZAR ÚLTIMA CONEXIÓN (LAST SEEN)
996      // ---------------------------------------------------------
997      supabaseClient
998        .from("profiles")
999        .update({ last_seen: new Date().toISOString() }) 
1000        .eq("id", app.user.id)
1001        .then(() => console.log("Última conexión registrada 🕒"))
1002        .catch(err =>
1002 console.error("Error al registrar conexión:", err));
1003    }
1004
1005    document.getElementById("header-username").innerText = app.user.name;
1006    document.getElementById("header-avatar").src = app.user.avatar;
1007
1008    // Cargar Materias y Tareas
1009    try {
1010        await app.fetchData();
1011    } catch (error) {
1012        console.error("Error al cargar tareas:", error);
1013    } finally {
1014        // 👇 Bajamos nuestra cortina web sin bloquear a Android 👇
1015        const fluyeLoader = document.getElementById("fluye-loader");
1016        if (fluyeLoader) {
1017            fluyeLoader.classList.add("oculto");
1018        }
1019        // 👆 ------------------------------------------------ 👆
1020    }
1021
1022    // Animaciones de transición
1023    if (animate) {
1024      const auth = document.getElementById("auth-view");
1025      auth.classList.add("fade-out");
1026      setTimeout(() => {
1027        auth.classList.add("hidden");
1028        auth.classList.remove("fade-out");
1029        const appView = document.getElementById("app-view");
1030        appView.classList.remove("hidden");
1031        appView.classList.add("fade-in");
1032        setTimeout(() => appView.classList.remove("fade-in"), 600);
1033      }, 500);
1034    } else {
1035      document.getElementById("auth-view").classList.add("hidden");
1036      document.getElementById("app-view").classList.remove("hidden");
1037    }
1038
1039    if (!localStorage.getItem("fluye_intro")) {
1040      const welcome = document.getElementById("welcomeModal");
1041      if (welcome) welcome.classList.add("active");
1042      history.pushState({modal: true}, null, "");
1043    }
1044    
1045    app.renderCols();
1046  },
1047
1048  fetchData: async () => {
1049    try {
1050        // 1. LEER DEL CACHÉ LOCAL PRIMERO
1051        const cachedData = localStorage.getItem(`fluye_data_${app.user.id}`);
1052
1053        if (cachedData) {
1054            try {
1055                // Intentamos leer el caché de forma segura
1056                const parsedData = JSON.parse(cachedData);
1057                // Validamos que realmente sea una lista/array (para que app.render no explote)
1058                if (Array.isArray(parsedData)) {
1059                    app.data = parsedData;
1060                    app.render(); 
1061                } else {
1062                    app.renderSkeleton();
1063                }
1064            } catch (parseError) {
1065                // Si el caché está corrupto, lo ignoramos y mostramos el esqueleto
1066                console.warn("Caché dañado, cargando desde cero...");
1067                app.renderSkeleton();
1068            }
1069        } else if (app.data.length === 0) {
1070            // No hay caché (primera vez), mostramos esqueleto
1071            app.renderSkeleton();
1072        }
1073
1074        // 2. PETICIÓN SILENCIOSA A SUPABASE
1075        let { data: subjects, error } = await supabaseClient
1076          .from("subjects")
1077          .select(`*, tasks(*)`)
1078          .order("position", { ascending: true })
1079          .order("id", { foreignTable: "tasks", ascending: true });
1080
1081        if (error) {
1082            if (!cachedData) app.showToast("Error cargando datos", "error");
1083            return; 
1084        }
1085
1086        // 3. ACTUALIZAR CON DATOS FRESCOS
1087        const freshDataString = JSON.stringify(subjects || []);
1088
1089        // Si hay cambios (o si el caché estaba corrupto/vacío), actualizamos
1090        if (cachedData !== freshDataString) {
1091            app.data = subjects || [];
1092            localStorage.setItem(`fluye_data_${app.user.id}`, freshDataString);
1093            app.render();
1094        }
1095
1096    } catch (fatalError) {
1097        // Atrapa cualquier otro error catastrófico para que la app no se congele
1098        console.error("Error en fetchData:", fatalError);
1099        app.showToast("Algo salió mal al sincronizar", "error");
1100    }
1101  },
1102
1103  // --- Normalizar texto (quitar acentos y minúsculas) ---
1104  normalizeText: (text) => {
1105    return text
1106      .normalize("NFD")
1107      .replace(/[\u0300-\u036f]/g, "")
1108      .toLowerCase();
1109  },
1110
1111  // --- LÓGICA SWIPE (DESLIZAR) ---
1112  addSwipeLogic: (element, id) => {
1113      let startX = 0;
1114      let currentTranslate = 0;
1115      let isDragging = false;
1116      const card = element.parentElement; // Referencia al contenedor padre
1117
1118      // 1. AL TOCAR
1119      element.addEventListener('touchstart', (e) => {
1120          if(e.target.closest('.chevron') || e.target.closest('.drag-handle')) return;
1121          
1122          // 🔴 REGLA DE ORO: Cerrar todas las demás materias
1123          app.resetSwipes(id);
1124
1125          startX = e.touches[0].clientX;
1126          isDragging = true;
1127          element.style.transition = 'none'; // Quitar animación para respuesta instantánea
1128          
1129          // 🟢 MOSTRAR LOS CUADROS AHORA (Ya no son transparentes)
1130          card.querySelectorAll('.swipe-layer').forEach(l => l.classList.add('visible'));
1131      }, { passive: true });
1132
1133      // 2. AL MOVER
1134      element.addEventListener('touchmove', (e) => {
1135          if (!isDragging) return;
1136          const currentX = e.touches[0].clientX;
1137          const diff = currentX - startX;
1138
1139          // Límites de deslizamiento
1140          if (diff > 120) return; 
1141          if (diff < -160) return;
1142
1143          currentTranslate = diff;
1144          element.style.transform = `translateX(${diff}px)`;
1145      }, { passive: true });
1146
1147      // 3. AL SOLTAR
1148      element.addEventListener('touchend', () => {
1149          isDragging = false;
1150          element.style.transition = 'transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)';
1151
1152          // Decidir si dejar abierto o cerrar
1153          if (currentTranslate > 80) {
1154              element.style.transform = `translateX(100px)`; // Abierto derecha
1155          } 
1156          else if (currentTranslate < -80) {
1157              element.style.transform = `translateX(-140px)`; // Abierto izquierda
1158          } 
1159          else {
1160              element.style.transform = `translateX(0px)`; // Cerrar
1161              
1162              // 🔴 SI SE CIERRA, OCULTAR LOS CUADROS DE NUEVO
1163              setTimeout(() => {
1164                  card.querySelectorAll('.swipe-layer').forEach(l => l.classList.remove('visible'));
1165              }, 300);
1166          }
1167      });
1168  },
1169  
1170  // Helper para cerrar swipes al tocar otro lado
1171  resetSwipes: (exceptId = null) => {
1172      document.querySelectorAll('.subject-card').forEach(card => {
1173          if (card.dataset.id != exceptId) {
1174              const wrapper = card.querySelector('.subject-swipe-wrapper');
1175              if (wrapper) wrapper.style.transform = 'translateX(0px)';
1176              card.querySelectorAll('.swipe-layer').forEach(l => l.classList.remove('visible'));
1177          }
1178      });
1179  },
1180
1181  // --- SKELETON (NUEVA) ---
1182  renderSkeleton: () => {
1183      const lst = document.getElementById("mainList");
1184      if(!lst) return;
1185      let html = "";
1186      for(let i=0; i<3; i++) {
1187          html += `
1188            <div class="skeleton-card">
1189                <div class="skeleton-shimmer"></div>
1190                <div class="subject-header" style="background:none; border:none; padding:0;">
1191                    <div class="sk-line sk-title"></div>
1192                </div>
1193                <div style="padding:0 10px;">
1194                    <div class="sk-line sk-task"></div>
1195                    <div class="sk-line sk-task"></div>
1196                    <div class="sk-line sk-task-sm"></div>
1197                </div>
1198            </div>
1199          `;
1200      }
1201      lst.innerHTML = html;
1202  },
1203
1204  // --------------------------------------------------------
1205  // ✅ GESTIÓN DE HORARIO (CON ZOOM PRO)
1206  // --------------------------------------------------------
1207
1208  handleSchedule: () => {
1209    if (app.user.schedule_url) {
1210        app.openScheduleModal();
1211    } else {
1212        if(confirm("¿Quieres subir tu horario de clases ahora?")) {
1213            document.getElementById('scheduleInput').click();
1214        }
1215    }
1216  },
1217
1218  zoomLevel: 1,
1219  isDragging: false,
1220  startX: 0,
1221  startY: 0,
1222  translateX: 0,
1223  translateY: 0,
1224  lastTap: 0,
1225
1226  openScheduleModal: () => {
1227    const img = document.getElementById("scheduleImg");
1228    img.src = app.user.schedule_url + "?t=" + Date.now();
1229    
1230    app.resetZoom();
1231    app.initZoomLogic();
1232
1233    document.getElementById("scheduleModal").classList.add("active");
1234    history.pushState({modal: true}, null, "");
1235  },
1236
1237  initZoomLogic: () => {
1238      const container = document.getElementById("imgContainer");
1239      
1240      const newContainer = container.cloneNode(true);
1241      container.parentNode.replaceChild(newContainer, container);
1242      
1243      const activeContainer = document.getElementById("imgContainer");
1244
1245      activeContainer.addEventListener('touchstart', (e) => {
1246          const curTime = new Date().getTime();
1247          const tapLen = curTime - app.lastTap;
1248          
1249          if (tapLen < 300 && tapLen > 0) {
1250              e.preventDefault(); 
1251              app.handleDoubleTap(e.touches[0].clientX, e.touches[0].clientY);
1252          } else {
1253              app.startDrag(e.touches[0].clientX, e.touches[0].clientY);
1254          }
1255          app.lastTap = curTime;
1256      }, { passive: false });
1257
1258      activeContainer.addEventListener('touchmove', (e) => {
1259          if(app.zoomLevel > 1) {
1260              e.preventDefault();
1261              app.doDrag(e.touches[0].clientX, e.touches[0].clientY);
1262          }
1263      }, { passive: false });
1264
1265      activeContainer.addEventListener('touchend', () =>
1265 app.endDrag());
1266
1267      activeContainer.addEventListener('dblclick', (e) => {
1268          app.handleDoubleTap(e.clientX, e.clientY);
1269      });
1270      activeContainer.addEventListener('mousedown', (e) => app.startDrag(e.clientX, e.clientY));
1271      activeContainer.addEventListener('mousemove', (e) => {
1272          if(app.isDragging) {
1273              e.preventDefault();
1274              app.doDrag(e.clientX, e.clientY);
1275          }
1276      });
1277      activeContainer.addEventListener('mouseup', () => app.endDrag());
1278      activeContainer.addEventListener('mouseleave', () => app.endDrag());
1279  },
1280
1281  handleDoubleTap: (x, y) => {
1282      const img = document.getElementById("scheduleImg");
1283      const rect = img.getBoundingClientRect();
1284
1285      if (app.zoomLevel === 1) {
1286          app.zoomLevel = 2.5; 
1287          
1288          const offsetX = x - rect.left;
1289          const offsetY = y - rect.top;
1290
1291          const originX = (offsetX / rect.width) * 100;
1292          const originY = (offsetY / rect.height) * 100;
1293
1294          img.style.transformOrigin = `${originX}% ${originY}%`;
1295          img.style.transform = `scale(${app.zoomLevel})`;
1296          img.classList.add("zoomed");
1297      } else {
1298          app.resetZoom();
1299      }
1300  },
1301
1302  startDrag: (x, y) => {
1303      if (app.zoomLevel <= 1) return;
1304      app.isDragging = true;
1305      app.startX = x - app.translateX;
1306      app.startY = y - app.translateY;
1307      
1308      const img = document.getElementById("scheduleImg");
1309      img.style.transition = "none"; 
1310  },
1311
1312  doDrag: (x, y) => {
1313      if (!app.isDragging || app.zoomLevel <= 1) return;
1314      
1315      app.translateX = x - app.startX;
1316      app.translateY = y - app.startY;
1317
1318      const img = document.getElementById("scheduleImg");
1319      img.style.transform = `scale(${app.zoomLevel}) translate(${app.translateX / app.zoomLevel}px, ${app.translateY / app.zoomLevel}px)`;
1320  },
1321
1322  endDrag: () => {
1323      app.isDragging = false;
1324      const img = document.getElementById("scheduleImg");
1325      if(img) img.style.transition = "transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
1326  },
1327
1328  resetZoom: () => {
1329      const img = document.getElementById("scheduleImg");
1330      if(!img) return;
1331      
1332      app.zoomLevel = 1;
1333      app.translateX = 0;
1334      app.translateY = 0;
1335      
1336      img.style.transformOrigin = "center center";
1337      img.style.transform = `scale(1) translate(0px, 0px)`;
1338      img.classList.remove("zoomed");
1339  },
1340
1341  uploadSchedule: async (event) => {
1342    const file = event.target.files[0];
1343    if (!file) return;
1344
1345    if (!file.type.startsWith("image/")) {
1346        return app.showToast("Solo imágenes (JPG, PNG)", "error");
1347    }
1348
1349    app.showToast("Subiendo horario...");
1350
1351    try {
1352        const fileName = `schedule_${app.user.id}.jpg`;
1353
1354        const { error: uploadError } = await supabaseClient.storage
1355            .from("schedules")
1356            .upload(fileName, file, { contentType: file.type, upsert: true });
1357
1358        if (uploadError) throw uploadError;
1359
1360        const { data: publicData } = supabaseClient.storage
1361            .from("schedules")
1362            .getPublicUrl(fileName);
1363
1364        const publicUrl = publicData.publicUrl;
1365
1366        const { error: dbError } = await supabaseClient
1367            .from("profiles")
1368            .update({ schedule_url: publicUrl })
1369            .eq("id", app.user.id);
1370
1371        if (dbError) throw dbError;
1372
1373        app.user.schedule_url = publicUrl;
1374        app.showToast("¡Horario guardado!");
1375        app.openScheduleModal();
1376
1377    } catch (e) {
1378        console.error(e);
1379        app.showToast("Error al subir", "error");
1380    } finally {
1381        event.target.value = "";
1382    }
1383  },
1384
1385  deleteSchedule: async () => {
1386    if(!confirm("¿Borrar horario?")) return;
1387
1388    try {
1389        const { error } = await supabaseClient
1390            .from("profiles")
1391            .update({ schedule_url: null })
1392            .eq("id", app.user.id);
1393
1394        if (error) throw error;
1395
1396        app.user.schedule_url = null;
1397        app.closeModal("scheduleModal");
1398        app.showToast("Horario eliminado");
1399
1400    } catch (e) {
1401        console.error(e);
1402        app.showToast("Error al eliminar", "error");
1403    }
1404  },
1405
1406  // --- RENDER (VERSIÓN LIMPIA SIN SWIPE) ---
1407  render: () => {
1408    const todayObj = new Date();
1409    const todayStr = `${todayObj.getFullYear()}
1409-${String(todayObj.getMonth() + 1).padStart(2, '0')}-${String(todayObj.getDate()).padStart(2, '0')}`;
1410    
1411    app.data.sort((a, b) => (a.position - b.position) || (a.id - b.id));
1412
1413    const lst = document.getElementById("mainList");
1414    lst.innerHTML = "";
1415
1416    const rawSearch = document.getElementById("search").value;
1417    const q = app.normalizeText(rawSearch);
1418
1419    if (app.data.length === 0) {
1420      lst.innerHTML = `<div style="text-align:center;opacity:0.6;margin-top:50px"><i class="ph ph-sparkle" style="font-size:3rem"></i><p>Añade una materia</p></div>`;
1421      return;
1422    }
1423
1424    app.data.forEach((s) => {
1425      const tasks = s.tasks || [];
1426
1427      let vis = tasks.filter(
1428        (t) => !t.is_hidden && (app.pend ? !t.is_done : true)
1429      );
1430
1431      if (q) {
1432        vis = vis.filter((t) => app.normalizeText(t.title).includes(q));
1433      }
1434
1435      // 🔥 EL NUEVO MOTOR DE ORDENAMIENTO 🔥
1436      vis.sort((a, b) => {
1437          // 1. Las tareas completadas SIEMPRE van al final (sin importar el filtro)
1438          if (a.is_done !== b.is_done) return a.is_done ? 1 : -1;
1439
1440          // 2. Aplicamos el filtro que eligió el usuario
1441          if (app.sortView === 'alpha') {
1442              return a.title.localeCompare(b.title); // A - Z
1443          } else if (app.sortView === 'new') {
1444              return b.id - a.id; // Más Recientes primero
1445          } else {
1446              // Por Defecto ('date'): Fecha más próxima
1447              if (!a.due_date) return 1;
1448              if (!b.due_date) return -1;
1449              return a.due_date.localeCompare(b.due_date);
1450          }
1451      });
1452
1453      if (q && vis.length === 0) return;
1454
1455      const c = document.createElement("div");
1456      c.className = "subject-card";
1457      c.dataset.id = s.id;
1458      c.draggable = false;
1459
1460      c.addEventListener("dragstart", (e) => {
1461        app.drag = c;
1462        c.classList.add("dragging");
1463        e.dataTransfer.effectAllowed = "move";
1464        e.dataTransfer.setData("text/plain", s.id);
1465      });
1466      c.addEventListener("dragend", () => {
1467        c.classList.remove("dragging");
1468        c.draggable = false;
1469        app.drag = null;
1470        app.saveOrder();
1471      });
1472
1473      const isCollapsed = s.collapsed === true;
1474      const chevronRot = isCollapsed ? "rotate(-90deg)" : "rotate(0deg)";
1475      const listClass = isCollapsed && !q ? "task-list collapsed" : "task-list";
1476
1477      c.innerHTML = `
1478        <div class="subject-header">
1479            <div class="subject-info">
1480                <span class="drag-handle" style="touch-action: none; cursor: grab; padding: 15px 5px 15px 0px; margin-right: 0px; display: inline-flex; align-items: center; opacity: 0.5;">
1481                   <i class="ph-light ph-dots-six-vertical"></i>
1482                </span>
1483                <div class="color-dot" style="background:${s.color}"></div>
1484                <span>${s.name}</span>
1485              <small>${vis.length}</small>
1486                <i class="ph-light ph-caret-down chevron" onclick="app.toggleList(${s.id})" style="transform: ${chevronRot}; cursor: pointer; padding: 10px;"></i>
1487            </div>
1488            
1489            <div class="actions" onclick="event.stopPropagation()">
1490                <button class="btn-icon" onclick="app.editSub(${s.id})"><i class="ph-light ph-pencil-simple"></i></button>
1491                <button class="btn-icon" onclick="app.newT(${s.id})"><i class="ph-light ph-plus"></i></button>
1492                <button class="btn-icon" onclick="app.openDelModal(${s.id})" style="color:#ef4444"><i class="ph-light ph-trash"></i></button>
1493            </div>
1494        </div>
1495        <ul id="ul-${s.id}" class="${listClass}"></ul>
1496      `;
1497
1498      const handle = c.querySelector(".drag-handle");
1499      handle.addEventListener("mouseenter", () => (c.draggable = true));
1500      handle.addEventListener("mouseleave", () => (c.draggable = false));
1501      handle.addEventListener("touchstart", () => (c.draggable = true), { passive: true });
1502      handle.addEventListener("touchend", () => setTimeout(() => (c.draggable = false), 100));
1503      c.style.touchAction = "pan-y";
1504      handle.style.touchAction = "none";
1505
1506      const header = c.querySelector(".subject-header");
1507      app.addLongPress(header, "subject", s.id);
1508
1509      const ul = c.querySelector("ul");
1510      
1511      if (vis.length === 0 && !q) {
1512          ul.innerHTML = `
1513            <div class="empty-state">
1514                <i class="ph-duotone ph-coffee" style="font-size: 3rem; margin-bottom: 15px; opacity: 0.5; color: var(--accent);"></i>
1515                <p>Todo limpio por aquí</p>
1516                <small>¡Disfruta tu tiempo libre!</small>
1517            </div>
1518          `;
1519      } else {
1520          vis.forEach((t) => {
1521            let row = "";
1522            if (t.is_done) {
1523                row = "state-done";
1524            } else if (t.due_date) {
1525                if (t.due_date < todayStr) {
1526                    row = "state-overdue"; 
1527                } else if (t.due_date === todayStr) {
1528                    row = "state-warning"; 
1529                }
1530            }
1531
1532            ul.innerHTML += `
1533                <li class="task-item ${row}">
1534                    <div class="task-content">
1535                        <div class="task-title">${t.title}</div>
1536                        ${t.description ? `<div class="task-desc" onclick="this.classList.toggle('expanded')">${t.description}</div>` : ""}
1537                        ${t.due_date ? `<div class="task-date"><i class="ph-light ph-calendar-blank"></i> ${formatearFechaLatam(t.due_date)}</div>` : ""}
1538                    </div>
1539
1540                    <div class="task-menu-wrapper">
1541                        <button class="btn-task-menu" id="btn-menu-${t.id}" onclick="app.toggleTaskOptions(${t.id}, event)">
1542                            <i class="ph-bold ph-dots-three-vertical"></i>
1543                        </button>
1544                        <div class="task-actions-capsule" id="menu-${t.id}">
1545                            ${t.is_done 
1546                                ? `<button class="btn-icon" onclick="app.updT(${t.id}, false); app.closeTaskOptions(${t.id})" style="color:#f59e0b"><i class="ph-light ph-arrow-u-up-left"></i></button>`
1547                                : `<button class="btn-icon" onclick="app.updT(${t.id}, true); app.closeTaskOptions(${t.id})" style="color:#10b981"><i class="ph-light ph-check"></i></button>`
1548                            }
1549                            ${!t.is_done ? `<button class="btn-icon" onclick="app.hide(${t.id})" style="color:var(--accent)"><i class="ph-light ph-archive-box"></i></button>` : ""}
1550                            <button class="btn-icon" onclick="app.editT(${t.id}); app.closeTaskOptions(${t.id})"><i class="ph-light ph-pencil-simple"></i></button>
1551                            <button class="btn-icon" onclick="app.delT(${t.id})" style="color:#ef4444"><i class="ph-light ph-trash"></i></button>
1552                        </div>
1553                    </div>
1554                </li>`;
1555          });
1556      }
1557      
1558      lst.appendChild(c);
1559    });
1560  },
1561
1562  addLongPress: (el, type, id) => {
1563    let timer;
1564    el.addEventListener(
1565      "touchstart",
1566      (e) => {
1567        if (
1568          e.target.closest(".actions") ||
1569          e.target.closest(".drag-handle") ||
1570          e.target.closest(".chevron")
1571        )
1572          return;
1573        timer = setTimeout(() => {
1574          if (navigator.vibrate) navigator.vibrate(50);
1575          app.openMobileOptions(type, id);
1576        }, 350);
1577      },
1578      { passive: true }
1579    );
1580    const cancel = () => clearTimeout(timer);
1581    el.addEventListener("touchend", cancel);
1582    el.addEventListener("touchmove", cancel);
1583  },
1584
1585  openMobileOptions: (type, id) => {
1586    const modal = document.getElementById("mobileOptionsModal");
1587    
1588    const modalContent = modal.querySelector('.modal');
1589    modalContent.classList.remove('bottom-sheet'); 
1590    modalContent.classList.add('center');
1591
1592    const container = document.getElementById("moButtons");
1593    container.innerHTML = "";
1594    
1595    if (type === "subject") {
1596      document.getElementById("moTitle").innerText = "Opciones";
1597      
1598      container.innerHTML = `
1599        <div class="action-grid">
1600            <div class="btn-box-option" onclick="app.newT(${id}); app.closeModal('mobileOptionsModal')">
1601                <i class="ph-light ph-plus-circle"></i>
1602                <span>Nueva Tarea</span>
1603            </div>
1604
1605            <div class="btn-box-option" onclick="app.editSub(${id}); app.closeModal('mobileOptionsModal')">
1606                <i class="ph-light ph-pencil-simple"></i>
1607                <span>Editar</span>
1608            </div>
1609
1610            <div class="btn-box-option danger" onclick="app.openDelModal(${id}); app.closeModal('mobileOptionsModal')">
1611                <i class="ph-light ph-trash"></i>
1612                <span>Eliminar</span>
1613            </div>
1614        </div>
1615      `;
1616    }
1617    
1618    modal.classList.add("active");
1619    history.pushState({modal: true}, null, "");
1620  },
1621
1622  // --- CRUD MATERIAS ---
1623  saveOrder: async () => {
1624    const newOrder = [
1625      ...document.querySelectorAll(".subject-card"),
1626    ].map((e) => parseInt(e.dataset.id));
1627
1628    const updates = newOrder.map((id, index) => {
1629      const localItem = app.data.find((s) => s.id === id);
1630      if (localItem) localItem.position = index; 
1631
1632      return {
1633        id: id,
1634        user_id: app.user.id,
1635        position: index,
1636        name: localItem ? localItem.name : "",
1637      };
1638    });
1639
1640    const { error } = await supabaseClient
1641      .from("subjects")
1642      .upsert(updates, { onConflict: "id" });
1643      
1644    if (error) console.error("Error guardando orden:", error);
1645  },
1646
1647  toggleList: async (id) => {
1648    const s = app.data.find((x) => x.id == id);
1649    if (s) {
1650      s.collapsed = !s.collapsed;
1651
1652      requestAnimationFrame(() => {
1653          const ul = document.getElementById(`ul-${id}`);
1654          const card = document.querySelector(`.subject-card[data-id="${id}"]`);
1655          const chevron = card ? card.querySelector(".chevron") : null;
1656
1657          if (ul && chevron) {
1658              if (s.collapsed) {
1659                  ul.classList.add("collapsed");
1660                  chevron.style.transform = "rotate(-90deg)";
1661              } else {
1662                  ul.classList.remove("collapsed");
1663                  chevron.style.transform = "rotate(0deg)";
1664              }
1665          }
1666      });
1667      
1668      supabaseClient
1669        .from("subjects")
1670        .update({ collapsed: s.collapsed })
1671        .eq("id", id)
1672        .then(() => {});
1673    }
1674  },
1675
1676  // 🔥 NUEVA FUNCIÓN PARA ABRIR CUALQUIER MODAL (Incluyendo los Filtros) 🔥
1677  openModal: (id) => {
1678    document.getElementById(id).classList.add("active");
1679    history.pushState({modal: true}, null, "");
1680  },
1681
1682  closeModal: (id) =>
1683    document.getElementById(id).classList.remove("active"),
1684
1685  openHelp: () => {
1686    document.getElementById("helpModal").classList.add("active");
1687    history.pushState({modal: true}, null, "");
1688  }, 
1689
1690  openSubModal: () => {
1691    document.getElementById("subTitle").innerText = "Nueva Materia";
1692    document.getElementById("subId").value = "";
1693    document.getElementById("subName").value = "";
1694    app.sCol = COLS[0];
1695    app.renderCols();
1696    document.getElementById("subModal").classList.add("active");
1697    history.pushState({modal: true}, null, "");
1698  },
1699  editSub: (id) => {
1700    const s = app.data.find((x) => x.id == id);
1701    document.getElementById("subTitle").innerText = "Editar Materia";
1702    document.getElementById("subId").value = s.id;
1703    document.getElementById("subName").value = s.name;
1704    app.sCol = s.color;
1705    app.renderCols();
1706    document.getElementById("subModal").classList.add("active");
1707    history.pushState({modal: true}, null, "");
1708  },
1709
1710  saveSub: async () => {
1711    const btn = document.querySelector("#subModal .btn-main");
1712    const originalText = btn.innerText;
1713
1714    btn.disabled = true;
1715    btn.innerText = "Guardando...";
1716
1717    try {
1718      const id = document.getElementById("subId").value;
1719      const name = document.getElementById("subName").value;
1720      if (!name) {
1721        app.showToast("Ponle nombre a la materia", "error");
1722        return;
1723      }
1724
1725      const payload = {
1726        name,
1727        color: app.sCol,
1728        user_id: app.user.id,
1729      };
1730
1731      let error;
1732      if (id) {
1733        ({ error } = await supabaseClient
1734          .from("subjects")
1735          .update(payload)
1736          .eq("id", id));
1737      } else {
1738        payload.position = app.data.length;
1739        ({ error } = await supabaseClient.from("subjects").insert([payload]));
1740      }
1741
1742      if (error) {
1743        app.showToast("Error guardando", "error");
1744      } else {
1745        app.closeModal("subModal");
1746        app.showToast(id ? "Materia actualizada" : "Materia creada");
1747        app.fetchData();
1748      }
1749    } finally {
1750      btn.disabled = false;
1751      btn.innerText = originalText;
1752    }
1753  },
1754
1755  openDelModal: (id) => {
1756    app.delId = id;
1757    document.getElementById("deleteModal").classList.add("active");
1758    history.pushState({modal: true}, null, "");
1759  },
1760  delSub: async () => {
1761    if (app.delId) {
1762      const { error } = await supabaseClient
1763        .from("subjects")
1764        .delete()
1765        .eq("id", app.delId);
1766      if (!error) {
1767        app.delId = null;
1768        app.closeModal("deleteModal");
1769        app.showToast("Materia eliminada");
1770        app.fetchData();
1771      } else {
1772        app.showToast("Error al eliminar", "error");
1773      }
1774    } else {
1775      app.closeModal("deleteModal");
1776    }
1777  },
1778
1779  // --- CRUD TAREAS ---
1780  newT: (sid) => {
1781    document.getElementById("taskHead").innerText = "Nueva";
1782    document.getElementById("tsid").value = sid;
1783    document.getElementById("tid").value = "";
1784    document.getElementById("ttitle").value = "";
1785    document.getElementById("tdesc").value = "";
1786    document.getElementById("tdate").value = "";
1787    document.getElementById("taskModal").classList.add("active");
1788    history.pushState({modal: true}, null, "");
1789  },
1790  editT: (id) => {
1791    let t;
1792    app.data.forEach((s) =>
1793      s.tasks.forEach((x) => {
1794        if (x.id == id) t = x;
1795      })
1796    );
1797    document.getElementById("taskHead").innerText = "Editar";
1798    document.getElementById("tid").value = t.id;
1799    document.getElementById("ttitle").value = t.title;
1800    document.getElementById("tdesc").value = t.description;
1801    document.getElementById("tdate").value = t.due_date;
1802    document.getElementById("taskModal").classList.add("active");
1803    history.pushState({modal: true}, null, "");
1804  },
1805  saveT: async () => {
1806    const btn = document.querySelector("#taskModal .btn-main");
1807    const originalText = btn.innerText;
1808
1809    btn.disabled = true;
1810    btn.innerText = "Guardando...";
1811
1812    try {
1813      const id = document.getElementById("tid").value;
1814      const sid = document.getElementById("tsid").value;
1815      const title = document.getElementById("ttitle").value;
1816      const description = document.getElementById("tdesc").value;
1817      const due_date = document.getElementById("tdate").value || null;
1818
1819      if (!title) {
1820        app.showToast("Escribe un título", "error");
1821        return;
1822      }
1823
1824      const payload = {
1825        title,
1826        description,
1827        due_date,
1828        user_id: app.user.id,
1829        subject_id: sid,
1830      };
1831
1832      let error;
1833      if (id) {
1834        delete payload.subject_id;
1835        ({ error } = await supabaseClient
1836          .from("tasks")
1837          .update(payload)
1838          .eq("id", id));
1839      } else {
1840        ({ error } = await supabaseClient.from("tasks").insert([payload]));
1841      }
1842
1843      if (error) {
1844        app.showToast("Error guardando tarea", "error");
1845      } else {
1846        app.closeModal("taskModal");
1847        app.showToast(id ? "Tarea actualizada" : "Tarea agregada");
1848        app.fetchData();
1849      }
1850    } finally {
1851      btn.disabled = false;
1852      btn.innerText = originalText;
1853    }
1854  },
1855
1856  updT: async (id, status) => {
1857    app.data.forEach((sub) =>
1858      sub.tasks.forEach((t) => {
1859        if (t.id == id) t.is_done = status;
1860      })
1861    );
1862    app.render();
1863
1864    if (status === true) {
1865      const sound = new Audio("success.mp3");
1866      sound.volume = 0.3; 
1867      sound
1868        .play()
1869        .catch((e) => console.warn("Audio bloqueado", e));
1870    }
1871
1872    const { error } = await supabaseClient
1873      .from("tasks")
1874      .update({ is_done: status })
1875      .eq("id", id);
1876    if (error) {
1877      app.fetchData();
1878      app.showToast("Error de conexión", "error");
1879    } else if (status) {
1880      app.showToast("¡Tarea completada!");
1881    }
1882  },
1883
1884  hide: async (id) => {
1885    const { error } = await supabaseClient
1886      .from("tasks")
1887      .update({ is_hidden: true, is_done: true })
1888      .eq("id", id);
1889    if (!error) {
1890      app.showToast("Tarea archivada");
1891      app.fetchData();
1892    }
1893  },
1894  delT: async (id) => {
1895    const { error } = await supabaseClient
1896      .from("tasks")
1897      .delete()
1898      .eq("id", id);
1899    if (!error) {
1900      app.showToast("Tarea eliminada");
1901      app.fetchData();
1902    }
1903  },
1904
1905  openHidden: () => {
1906    const d = document.getElementById("hiddenList");
1907    if (!d) return;
1908    
1909    d.innerHTML = "";
1910    let count = 0;
1911
1912    app.data.forEach((s) => {
1913      const safeTasks = s.tasks || [];
1914      
1915      safeTasks
1916        .filter((t) => t.is_hidden)
1917        .forEach((t) => {
1918          count++;
1919          
1920          d.innerHTML += `
1921            <div style="display:flex; justify-content:space-between; align-items:center; padding:12px; border-bottom:1px solid var(--border);">
1922                <div>
1923                    <b style="font-size:1rem;">${t.title}</b>
1924                    <br>
1925                    <small style="opacity:0.6; display:flex; align-items:center; gap:5px;">
1926                        <div style="width:8px; height:8px; border-radius:50%; background:${s.color};"></div>
1927                        ${s.name}
1928                    </small>
1929                </div>
1930                <button class="btn-icon" onclick="app.rest(${t.id})" title="Restaurar" style="color:var(--accent);">
1931                    <i class="ph-light ph-arrow-counter-clockwise" style="font-size:1.3rem;"></i>
1932                </button>
1933            </div>`;
1934        });
1935    });
1936
1937    if (count === 0) {
1938      d.innerHTML = '<div style="text-align:center; opacity:0.5; padding:30px;"><i class="ph-light ph-archive-box" style="font-size:2rem; margin-bottom:10px;"></i><p>No hay tareas archivadas</p></div>';
1939    }
1940    
1941    const modal = document.getElementById("hiddenModal");
1942    if(modal) {
1943        modal.classList.add("active");
1944      history.pushState({modal: true}, null, "");
1945    }
1946  },
1947
1948  rest: async (id) => {
1949    const { error } = await supabaseClient
1950      .from("tasks")
1951      .update({ is_hidden: false }) 
1952      .eq("id", id);
1953      
1954    if (!error) {
1955      app.showToast("Tarea restaurada");
1956      app.fetchData(); 
1957      app.closeModal("hiddenModal"); 
1958    } else {
1959      app.showToast("Error al restaurar", "error");
1960    }
1961  },
1962
1963  openProfile: () => {
1964    document.getElementById("p_user").value = app.user.name || "";
1965    document.getElementById("profAvatar").src = app.user.avatar;
1966    app.sAv = app.user.avatar;
1967    const g = document.getElementById("avGrid");
1968    g.innerHTML = "";
1969
1970    AVATARS.forEach((a) => {
1971      const i = document.createElement("img");
1972      i.src = a;
1973      i.className = "avatar-opt";
1974      if (a === app.sAv) i.classList.add("selected");
1975
1976      i.onclick = () => {
1977        app.sAv = a;
1978        document.getElementById("profAvatar").src = a;
1979        [...document.querySelectorAll(".avatar-opt")].forEach(
1980          (e) => e.classList.toggle("selected", e.src == a)
1981        );
1982      };
1983      g.appendChild(i);
1984    });
1985
1986    document.getElementById("profileModal").classList.add("active");
1987    history.pushState({modal: true}, null, "");
1988  },
1989
1990  uploadAvatar: (event) => {
1991    const file = event.target.files[0];
1992    if (!file) return;
1993
1994    if (!file.type.startsWith("image/")) {
1995      return app.showToast("Solo imágenes", "error");
1996    }
1997
1998    const reader = new FileReader();
1999    reader.onload = (e) => {
2000      const img = document.getElementById("imageToCrop");
2001      img.src = e.target.result;
2002
2003      document.getElementById("cropModal").classList.add("active");
2004      history.pushState({modal: true}, null, "");
2005
2006      if (app.cropper) {
2007        app.cropper.destroy();
2008      }
2009
2010      app.cropper = new Cropper(img, {
2011        aspectRatio: 1, 
2012        viewMode: 1,
2013        dragMode: 'move',
2014        autoCropArea: 1,
2015        guides: false,
2016        background: false,
2017        ready: function () {
2018            document.querySelector('.cropper-view-box').style.borderRadius = '50%';
2019            document.querySelector('.cropper-face').style.borderRadius = '50%';
2020        }
2021      });
2022    };
2023    reader.readAsDataURL(file);
2024    event.target.value = ""; 
2025  },
2026
2027  processCrop: async () => {
2028    if (!app.cropper) return;
2029
2030    const btn = document.querySelector("#cropModal .btn-main");
2031    btn.innerText = "Procesando...";
2032    btn.disabled = true;
2033
2034    try {
2035      const canvas = app.cropper.getCroppedCanvas({
2036        width: 300,
2037        height: 300,
2038      });
2039
2040      canvas.toBlob(async (blob) => {
2041        if (!blob) {
2042            btn.disabled = false;
2043            btn.innerText = "Recortar y Guardar";
2044            return app.showToast("Error al procesar", "error");
2045        }
2046
2047        app.showToast("Subiendo avatar...");
2048        const fileName = `${app.user.id}.jpg`;
2049
2050        const { data, error } = await supabaseClient.storage
2051          .from("avatars")
2052          .upload(fileName, blob, {
2053            contentType: 'image/jpeg',
2054            cacheControl: "3600",
2055            upsert: true,
2056          });
2057
2058        if (error) {
2059          console.error(error);
2060          app.showToast("Error al subir", "error");
2061        } else {
2062          const { data: publicData } = supabaseClient.storage
2063            .from("avatars")
2064            .getPublicUrl(fileName);
2065
2066          const publicUrl = `${publicData.publicUrl}?t=${Date.now()}`;
2067          
2068          app.sAv = publicUrl;
2069          document.getElementById("profAvatar").src = publicUrl;
2070          
2071          await app.saveProfile(); 
2072          
2073          app.closeModal("cropModal");
2074          app.showToast("¡Avatar actualizado!", "success");
2075        }
2076        
2077        btn.disabled = false;
2078        btn.innerText = "Recortar y Guardar";
2079
2080      }, 'image/jpeg', 0.7); 
2081
2082    } catch (e) {
2083      console.error(e);
2084      app.showToast("Error inesperado", "error");
2085      btn.disabled = false;
2086      btn.innerText = "Recortar y Guardar";
2087    }
2088  },
2089  
2090  saveProfile: async () => {
2091    const inputName = document.getElementById("p_user").value.trim();
2092    const name = inputName || app.user.name;
2093    const avatar = app.sAv;
2094
2095    if (!name) return app.showToast("El nombre es obligatorio", "error");
2096
2097    const { error } = await supabaseClient
2098      .from("profiles")
2099      .update({ name, avatar })
2100      .eq("id", app.user.id);
2101
2102    await supabaseClient.auth.updateUser({ data: { name, avatar } });
2103
2104    if (!error) {
2105      app.user.name = name;
2106      app.user.avatar = avatar;
2107
2108      app.closeModal("profileModal");
2109      app.showApp();
2110      app.showToast("Perfil actualizado");
2111    } else {
2112      app.showToast("Error actualizando perfil", "error");
2113    }
2114  },
2115
2116  renderCols: () => {
2117    const b = document.getElementById("colorBox");
2118    b.innerHTML = "";
2119    COLS.forEach((c) => {
2120      const d = document.createElement("div");
2121      d.className = `color-opt ${app.sCol == c ? "selected" : ""}`;
2122      d.style.background = c;
2123      d.onclick = () => {
2124        app.sCol = c;
2125        app.renderCols();
2126      };
2127      b.appendChild(d);
2128    });
2129  },
2130  toggleFilter: () => {
2131    app.pend = !app.pend;
2132    document.getElementById("filterBtn").innerText = app.pend
2133      ? "Pendientes"
2134      : "Ver Todo";
2135    app.render();
2136  },
2137
2138  toggleTheme: () => {
2139    if (document.body.classList.contains("christmas")) {
2140      document.body.classList.remove("christmas");
2141      app.stopSnow();
2142      document.body.classList.add("dark");
2143      localStorage.setItem("theme", "dark");
2144      app.showToast("Tema navideño desactivado");
2145      app.updateLogos(); 
2146      return;
2147    }
2148
2149    document.body.classList.toggle("dark");
2150    localStorage.setItem(
2151      "theme",
2152      document.body.classList.contains("dark") ? "dark" : "light"
2153    );
2154    app.updateLogos(); 
2155  },
2156
2157  updateLogos: () => {
2158      const isDark = document.body.classList.contains("dark");
2159      
2160      const headerLogo = document.querySelector(".logo img");
2161      if (headerLogo) {
2162          headerLogo.src = isDark ? "LOGO_FLUYE.png" : "LOGO_FLUYE_BLANCO.png";
2163      }
2164
2165      const loginLogo = document.querySelector(".login-logo img");
2166      if (loginLogo) {
2167          loginLogo.src = "LOGO_FLUYE.png";
2168      }
2169  },
2170
2171  openCafe: () => {
2172    app.closeModal("helpModal");
2173    document.getElementById("cafeModal").classList.add("active");
2174    history.pushState({modal: true}, null, "");
2175    app.switchCafeTab("pay");
2176    app.loadDonations();
2177  },
2178
2179  switchCafeTab: (tab) => {
2180    const payBtn = document.getElementById("tabPay");
2181    const wallBtn = document.getElementById("tabWall");
2182    const payView = document.getElementById("cafeFormView");
2183    const wallView = document.getElementById("cafeWallView");
2184
2185    if (tab === "pay") {
2186      payBtn.classList.add("active");
2187      wallBtn.classList.remove("active");
2188      payView.classList.remove("hidden");
2189      wallView.classList.add("hidden");
2190    } else {
2191      payBtn.classList.remove("active");
2192      wallBtn.classList.add("active");
2193      payView.classList.add("hidden");
2194      wallView.classList.remove("hidden");
2195    }
2196  },
2197
2198  sendDonation: async () => {
2199    const btn = document.querySelector("#cafeFormView .btn-main");
2200    const originalText = btn ? btn.innerText : "Enviar Notificación";
2201
2202    if (btn) {
2203      btn.disabled = true;
2204      btn.innerText = "Enviando...";
2205    }
2206
2207    try {
2208      const ref = document.getElementById("cafeRef").value;
2209      const nameInput = document.getElementById("cafeName").value.trim();
2210      const name = nameInput || "Anónimo";
2211      const msg = document.getElementById("cafeMsg").value.trim();
2212
2213      if (!ref || ref.length < 4) {
2214        app.showToast("Faltan los dígitos de referencia", "error");
2215        return;
2216      }
2217
2218      app.showToast("Enviando...");
2219
2220      const { error } = await supabaseClient.from("donations").insert([
2221        {
2222          name: name,
2223          message: msg,
2224          reference: ref,
2225        },
2226      ]);
2227
2228      if (error) {
2229        console.error(error);
2230        app.showToast("Error al enviar. Intenta de nuevo.", "error");
2231      } else {
2232        // ===========================================
2233        // ☕ AVISO SILENCIOSO A TELEGRAM (DONACIÓN) ☕
2234        // ===========================================
2235        try {
2236            const botToken = "8513861390:AAHtpZ9HLQGJI3RU64SuxFigicY5xY6lMqE"; 
2237            const chatId = "721704312"; 
2238            
2239            // Formateamos el mensaje para que se vea genial en Telegram
2240            const textoTelegram = `☕ *¡NUEVO CAFÉ RECIBIDO!* ☕\n\n👤 *De:* ${name}\n🔢 *Referencia:* ${ref}\n💬 *Mensaje:* ${msg || "_Sin mensaje_"}\n📅 *Fecha:* ${new Date().toLocaleString()}`;
2241
2242            // Enviamos sin 'await' para que la app fluya rápido
2243            fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
2244                method: "POST",
2245                headers: { "Content-Type": "application/json" },
2246                body: JSON.stringify({
2247                    chat_id: chatId,
2248                    text: textoTelegram,
2249                    parse_mode: "Markdown"
2250                })
2251            }).catch(() => {}); // Si falla el bot, el usuario no se entera
2252        } catch (e) {
2253            // Ignorar errores del bloque de Telegram
2254        }
2255        // ===========================================
2256
2257        app.showToast("¡Recibido! Verificaremos tu café pronto ☕");
2258
2259        document.getElementById("cafeRef").value = "";
2260        document.getElementById("cafeMsg").value = "";
2261        app.closeModal("cafeModal");
2262      }
2263    } finally {
2264      if (btn) {
2265        btn.disabled = false;
2266        btn.innerText = originalText;
2267      }
2268    }
2269  },
2270
2271  loadDonations: async () => {
2272    const list = document.getElementById("donationsList");
2273    list.innerHTML =
2274      '<p style="text-align:center; opacity:0.5; margin-top:20px;">Cargando...</p>';
2275
2276    const { data, error } = await supabaseClient
2277      .from("donations")
2278      .select("name, message, amount, created_at")
2279      .eq("status", "approved")
2280      .order("created_at", { ascending: false });
2281
2282    if (error) {
2283      console.log(error);
2284      return;
2285    }
2286
2287    if (!data || data.length === 0) {
2288      list.innerHTML = `<div style="text-align:center; opacity:0.6; padding:20px;">
2289            <i class="ph-duotone ph-coffee" style="font-size:2rem; margin-bottom:10px;"></i>
2290            <p>Sé el primero en invitar un café</p>
2291        </div>`;
2292      return;
2293    }
2294
2295    list.innerHTML = "";
2296    data.forEach((d) => {
2297      const amountBadge = d.amount
2298        ? `<span style="background:var(--accent); color:white; padding:2px 8px; border-radius:10px; font-size:0.75rem; font-weight:bold;">${d.amount}</span>`
2299        : "";
2300
2301      list.innerHTML += `
2302            <div class="donation-item">
2303                <div class="donation-header">
2304                    <span class="donation-name">${d.name}</span>
2305                    <div style="display:flex; align-items:center; gap:5px;">
2306                        ${amountBadge} <small style="opacity:0.5"><i class="ph-fill ph-coffee"></i></small>
2307                    </div>
2308                </div>
2309                ${
2310                  d.message
2311                    ? `<div class="donation-msg">"${d.message}"</div>`
2312                    : ""
2313                }
2314            </div>
2315        `;
2316    });
2317  },
2318
2319  toggleTaskOptions: (id, event) => {
2320      event.stopPropagation();
2321      
2322      const menu = document.getElementById(`menu-${id}`);
2323      const btn = document.getElementById(`btn-menu-${id}`);
2324      
2325      document.querySelectorAll('.task-actions-capsule.active').forEach(el => {
2326          if (el.id !== `menu-${id}`) {
2327              el.classList.remove('active');
2328              const otherId = el.id.replace('menu-', 'btn-menu-');
2329              const otherBtn = document.getElementById(otherId);
2330              if(otherBtn) otherBtn.classList.remove('active');
2331          }
2332      });
2333
2334      if (menu) {
2335          menu.classList.toggle('active');
2336          btn.classList.toggle('active');
2337      }
2338  },
2339
2340  closeTaskOptions: (id) => {
2341      const menu = document.getElementById(`menu-${id}`);
2342      const btn = document.getElementById(`btn-menu-${id}`);
2343      if (menu) menu.classList.remove('active');
2344      if (btn) btn.classList.remove('active');
2345  },
2346
2347  toggleFabMenu: () => {
2348      const actions = document.getElementById("fabActions");
2349      const icon = document.getElementById("fabIcon");
2350      const isActive = actions.classList.toggle("active");
2351      
2352      if (isActive) {
2353          icon.className = "ph-light ph-x";
2354      } else {
2355          icon.className = "ph-light ph-dots-three-outline-vertical";
2356      }
2357  },
2358  
2359  closeFabMenu: () => {
2360      const actions = document.getElementById("fabActions");
2361      if(actions) {
2362          actions.classList.remove("active");
2363          document.getElementById("fabIcon").className = "ph-light ph-dots-three-outline-vertical";
2364      }
2365  },
2366
2367  // --- LÓGICA CALENDARIO ---
2368  currDate: new Date(),
2369  
2370  openCalendar: () => {
2371      app.currDate = new Date(); 
2372      app.renderCalendar();
2373      document.getElementById("calendarModal").classList.add("active");
2374    history.pushState({modal: true}, null, "");
2375      document.getElementById("calDayTasks").style.display = "none";
2376  },
2377
2378  changeMonth: (delta) => {
2379      app.currDate.setMonth(app.currDate.getMonth() + delta);
2380      app.renderCalendar();
2381  },
2382
2383  jumpToToday: () => {
2384      app.currDate = new Date();
2385      app.renderCalendar();
2386  },
2387
2388  renderCalendar: () => {
2389      const y = app.currDate.getFullYear();
2390      const m = app.currDate.getMonth();
2391      const firstDay = new Date(y, m, 1).getDay();
2392      const daysInMonth = new Date(y, m + 1, 0).getDate();
2393      
2394      const months = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
2395      
2396      const titleEl = document.getElementById("calMonthYear");
2397      if(titleEl) titleEl.innerText = `${months[m]} ${y}`;
2398
2399      const grid = document.getElementById("calGrid");
2400      if(!grid) return; 
2401      grid.innerHTML = "";
2402
2403      ["D","L","M","M","J","V","S"].forEach(d => {
2404          grid.innerHTML += `<div style="opacity:0.5; font-size:0.8rem; font-weight:bold;">${d}</div>`;
2405      });
2406
2407      for(let i=0; i<firstDay; i++) grid.innerHTML += `<div></div>`;
2408
2409      const todayStr = new Date().toDateString();
2410      
2411      for(let d=1; d<=daysInMonth; d++) {
2412          const dateObj = new Date(y, m, d);
2413          const dateKey = `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
2414
2415          let hasTask = false;
2416          let allDone = true;
2417          let tasksForDay = [];
2418
2419          app.data.forEach(sub => {
2420              (sub.tasks || []).forEach(t => {
2421                  if(t.due_date === dateKey && !t.is_hidden) {
2422                      hasTask = true;
2423                      tasksForDay.push(t);
2424                      if(!t.is_done) allDone = false;
2425                  }
2426              });
2427          });
2428
2429          const cell = document.createElement("div");
2430          cell.className = "cal-day";
2431          cell.innerText = d;
2432          
2433          if(dateObj.toDateString() === todayStr) cell.classList.add("today");
2434          if(hasTask) {
2435              cell.classList.add("has-task");
2436              if(allDone) cell.classList.add("all-done");
2437          }
2438
2439          cell.onclick = () => {
2440              document.querySelectorAll(".cal-day").forEach(c => c.classList.remove("selected"));
2441              cell.classList.add("selected");
2442              app.showDayTasks(d, months[m], tasksForDay);
2443          };
2444
2445          grid.appendChild(cell);
2446      }
2447  },
2448
2449  showDayTasks: (day, monthName, tasks) => {
2450      const container = document.getElementById("calDayTasks");
2451      const list = document.getElementById("calTaskList");
2452      const title = document.getElementById("calSelectedDate");
2453
2454      if(title) title.innerHTML = `<span style="font-weight:400; opacity:0.7">Tareas del</span> ${day} de ${monthName}`;
2455      if(list) list.innerHTML = "";
2456
2457      if(tasks.length === 0) {
2458          if(list) list.innerHTML = `<li style="opacity:0.6; font-style:italic; padding:10px 0; text-align:center;">No hay tareas pendientes. 🍃</li>`;
2459      } else {
2460          tasks.forEach(t => {
2461              let color = "var(--fg)";
2462              let subName = "General"; 
2463
2464              // Buscamos a qué materia pertenece
2465              app.data.forEach(s => {
2466                   const safeTasks = s.tasks || [];
2467                   if(safeTasks.find(x => x.id === t.id)) {
2468                       color = s.color;
2469                       subName = s.name; 
2470                   }
2471              });
2472
2473              // 1. EL CAMBIO DEL ICONO: 
2474              // Verde sólido si está lista, o un círculo gris/neutral muy sutil si está pendiente.
2475              const icon = t.is_done 
2476                  ? `<i class="ph-fill ph-check-circle" style="color:#10b981; font-size:1.4rem;"></i>` 
2477                  : `<i class="ph-light ph-circle" style="color:var(--fg); opacity:0.3; font-size:1.4rem;"></i>`;
2478
2479              const titleStyle = t.is_done ? "text-decoration: line-through; opacity: 0.6;" : "";
2480
2481              if(list) {
2482                  // 2. EL CAMBIO DE LA LISTA:
2483                  // Le damos un fondo súper transparente, bordes redondeados y un borde muy sutil.
2484                  list.innerHTML += `
2485                      <li style="padding: 12px 16px; margin-bottom: 8px; background: rgba(var(--fg-rgb), 0.03); border-radius: 16px; border: 1px solid var(--border); display: flex; align-items: flex-start; gap: 12px;">
2486                          <div style="margin-top: 2px;">${icon}</div>
2487                          <div style="flex-grow: 1;">
2488                              <div style="font-weight: 600; font-size: 1rem; line-height: 1.3; margin-bottom: 4px; ${titleStyle}">${t.title}</div>
2489                              
2490                              <div style="display: flex; align-items: center; gap: 6px; opacity: 0.7; font-size: 0.85rem; font-weight: 500;">
2491                                  <div style="width: 8px; height: 8px; border-radius: 50%; background: ${color};"></div>
2492                                  ${subName}
2493                              </div>
2494                          </div>
2495                      </li>
2496                  `;
2497              }
2498          });
2499      }
2500      
2501      if(container) {
2502          container.style.display = "block";
2503          setTimeout(() => {
2504              container.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2505          }, 100);
2506      }
2507  }
2508
2509};
2510
2511/// Cerrar menús de tareas al hacer clic en cualquier otro lado
2512document.addEventListener('click', (e) => {
2513    if (!e.target.closest('.task-menu-wrapper')) {
2514        document.querySelectorAll('.task-actions-capsule.active').forEach(el => {
2515            el.classList.remove('active');
2516        });
2517        document.querySelectorAll('.btn-task-menu.active').forEach(el => {
2518            el.classList.remove('active');
2519        }); 
2520    }
2521});
2522
2523document.addEventListener("DOMContentLoaded", app.init);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.