import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, forkJoin, from, of } from 'rxjs'; import { map, tap, catchError } from 'rxjs/operators'; import { Preferences } from '@capacitor/preferences'; import { LocalNotifications, LocalNotificationSchema } from '@capacitor/local-notifications'; export interface ClaseHorario { codigo: string; asignatura: string; aula: string; carrera: string; horario: string; dia?: string; link?: string; } export interface EventoAcademico { id: string; materiaCodigo: string; tipo: string; // Dinámico según catálogo MilestoneType fecha: string; // ISO string aula?: string; link?: string; notas?: string; } @Injectable({ providedIn: 'root', }) export class DataService { private readonly BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'; private readonly CACHE_KEY = 'cache_horarios'; private readonly CACHE_VERSION = '1.1'; private readonly CACHE_FAVS_KEY = 'favoritos'; private readonly SHEETS = [ { name: 'Lunes', gid: '1915752353' }, { name: 'Martes', gid: '466778263' }, { name: 'Miércoles', gid: '615483345' }, { name: 'Jueves', gid: '682416571' }, { name: 'Viernes', gid: '1162906711' }, { name: 'Sábado', gid: '752305601' }, ]; constructor(private http: HttpClient) { } /** * Obtiene horarios del servidor y actualiza la caché. * Si hay fallos de red, se maneja en el componente llamando a getCachedHorarios. */ public getHorarios(): Observable { const requests = this.SHEETS.map(sheet => this.http.get(`${this.BASE_CSV_URL}&gid=${sheet.gid}`, { responseType: 'text' }).pipe( map(csvData => this.parseCsv(csvData, sheet.name)) ) ); return forkJoin(requests).pipe( map(results => results.reduce((acc, val) => acc.concat(val), [])) ); } /** * Guarda los nuevos datos en caché y detecta cambios de aula para notificaciones */ public async syncWithCache(newData: ClaseHorario[], favoritos: string[]) { const cachedData = await this.getCachedHorarios(); if (cachedData.length > 0) { this.checkRoomChanges(cachedData, newData, favoritos); } await Preferences.set({ key: this.CACHE_KEY, value: JSON.stringify({ version: this.CACHE_VERSION, data: newData, timestamp: Date.now() }) }); } public async getCachedHorarios(): Promise { const { value } = await Preferences.get({ key: this.CACHE_KEY }); if (!value) return []; try { const cached = JSON.parse(value); if (cached.version === this.CACHE_VERSION) { return cached.data || []; } else if (Array.isArray(cached)) { return cached; // Compatibilidad con caché vieja (v1.0) } return []; } catch (e) { return []; } } public async getFavorites(): Promise { const { value } = await Preferences.get({ key: this.CACHE_FAVS_KEY }); if (!value) return []; try { return JSON.parse(value) || []; } catch (e) { return []; } } /** * Programa notificaciones locales 30 minutos antes de cada clase favorita del día actual. * Usa IDs estables (hash del código + dia + horario) para evitar notificaciones duplicadas. */ public async scheduleClassReminders(favoriteItems: ClaseHorario[]) { try { const permStatus = await LocalNotifications.checkPermissions(); if (permStatus.display !== 'granted') return; const DIAS_ES: Record = { 'lunes': 1, 'martes': 2, 'miércoles': 3, 'miercoles': 3, 'jueves': 4, 'viernes': 5, 'sábado': 6, 'sabado': 6, 'domingo': 0 }; const now = new Date(); const todayDow = now.getDay(); // 0=Dom, 1=Lun... const notificationsToSchedule: LocalNotificationSchema[] = []; const idsToCancel: number[] = []; for (const item of favoriteItems) { if (!item.dia || !item.horario) continue; const diaNorm = item.dia.trim().toLowerCase(); const classDow = DIAS_ES[diaNorm]; if (classDow === undefined || classDow !== todayDow) continue; // Extraer primer horario de inicio, ej: "18:00 a 22:00" → "18:00" const timeMatch = item.horario.match(/(\d{1,2}):(\d{2})/); if (!timeMatch) continue; const classHour = parseInt(timeMatch[1], 10); const classMin = parseInt(timeMatch[2], 10); const classStart = new Date(now); classStart.setHours(classHour, classMin, 0, 0); const reminderTime = new Date(classStart.getTime() - 30 * 60 * 1000); // Solo programa si el recordatorio es en el futuro if (reminderTime <= now) continue; // ID estable: hash simple basado en codigo+dia+horario const stableId = this.stableHash(`${item.codigo}-${item.dia}-${item.horario}`); idsToCancel.push(stableId); notificationsToSchedule.push({ id: stableId, title: `🎓 Clase en 30 minutos`, body: `${item.asignatura} — Aula ${item.aula} a las ${timeMatch[0]}`, schedule: { at: reminderTime }, extra: { codigo: item.codigo } }); } if (idsToCancel.length > 0) { await LocalNotifications.cancel({ notifications: idsToCancel.map(id => ({ id })) }).catch(() => {}); } if (notificationsToSchedule.length > 0) { await LocalNotifications.schedule({ notifications: notificationsToSchedule }); } } catch (e) { console.warn('Error programando recordatorios de clase:', e); } } /** Genera un entero de 32 bits estable a partir de un string (djb2) */ private stableHash(str: string): number { let hash = 5381; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) + hash) ^ str.charCodeAt(i); hash = hash >>> 0; // Mantener como unsigned 32-bit } // LocalNotifications requiere IDs positivos y razonables return (hash % 2_000_000) + 100_000; } private isItemInFavorites(item: ClaseHorario, favoritos: string[]): boolean { const itemKey = `${item.codigo}::${item.asignatura.trim()}`; if (favoritos.includes(itemKey)) return true; if (favoritos.includes(item.codigo)) { const hasSpecific = favoritos.some(f => f.startsWith(`${item.codigo}::`)); return !hasSpecific; } return false; } private async checkRoomChanges(oldData: ClaseHorario[], newData: ClaseHorario[], favoritos: string[]) { for (const item of newData) { if (this.isItemInFavorites(item, favoritos)) { const oldItem = oldData.find(o => o.codigo === item.codigo && o.asignatura === item.asignatura && o.dia === item.dia && o.horario === item.horario ); if (oldItem) { const roomChanged = oldItem.aula !== item.aula; const linkChanged = oldItem.link !== item.link; if (roomChanged || linkChanged) { await this.notifyChange(item, roomChanged, linkChanged); } } } } } private async notifyChange(item: ClaseHorario, roomChanged: boolean, linkChanged: boolean) { try { let body = ''; if (roomChanged && linkChanged) { body = `El aula cambió a: ${item.aula} y el enlace fue actualizado.`; } else if (roomChanged) { body = `El aula ha cambiado al aula: ${item.aula}`; } else if (linkChanged) { body = item.link ? 'Hay un nuevo enlace disponible para tu clase.' : 'El enlace de la clase virtual ha sido removido.'; } await LocalNotifications.schedule({ notifications: [ { title: `Cambio en tu clase: ${item.asignatura}`, body: body, id: Math.floor(Math.random() * 10000), schedule: { at: new Date(Date.now() + 500) }, extra: { codigo: item.codigo } } ] }); } catch (e) { console.error('Error al enviar notificación:', e); } } private parseCsv(csv: string, dia: string): ClaseHorario[] { const result: ClaseHorario[] = []; const rows: string[][] = []; let currentRow: string[] = []; let currentCell = ''; let inQuotes = false; for (let i = 0; i < csv.length; i++) { const char = csv[i]; const nextChar = csv[i + 1]; if (inQuotes) { if (char === '"' && nextChar === '"') { currentCell += '"'; i++; } else if (char === '"') { inQuotes = false; } else { currentCell += char; } } else { if (char === '"') { inQuotes = true; } else if (char === ',') { currentRow.push(currentCell); currentCell = ''; } else if (char === '\n' || (char === '\r' && nextChar === '\n')) { currentRow.push(currentCell); rows.push(currentRow); currentRow = []; currentCell = ''; if (char === '\r') i++; } else if (char !== '\r') { currentCell += char; } } } if (currentCell !== '' || currentRow.length > 0) { currentRow.push(currentCell); rows.push(currentRow); } let headerRowIndex = -1; let colOffsetX = 0; let linkColIndex = -1; // Índice absoluto de la columna Link (si existe) let horarioColIndex = -1; // Índice absoluto de la columna Horario for (let r = 0; r < rows.length; r++) { const row = rows[r]; for (let c = 0; c < row.length; c++) { const cellLower = row[c].trim().toLowerCase(); if (cellLower.includes('codigo') || cellLower.includes('código')) { headerRowIndex = r; colOffsetX = c; break; } } if (headerRowIndex !== -1) break; } if (headerRowIndex === -1) return []; // Detectar columnas dinámicamente leyendo la fila de encabezados const headerRow = rows[headerRowIndex]; for (let c = colOffsetX; c < headerRow.length; c++) { const cellLower = headerRow[c].trim().toLowerCase(); if (cellLower === 'link' || cellLower.startsWith('http')) { linkColIndex = c; } if (cellLower.includes('horario')) { horarioColIndex = c; } } // Si no encontramos la columna horario explícitamente, usamos el offset clásico if (horarioColIndex === -1) { horarioColIndex = linkColIndex !== -1 ? colOffsetX + 5 : colOffsetX + 4; } for (let r = headerRowIndex + 1; r < rows.length; r++) { const row = rows[r]; if (row.length >= colOffsetX + 4) { const codigo = (row[colOffsetX] || '').trim().replace(/\n/g, ' - '); const asignatura = (row[colOffsetX + 1] || '').trim().replace(/\n/g, ' '); const aula = (row[colOffsetX + 2] || '').trim(); const carrera = (row[colOffsetX + 3] || '').trim(); const horario = (row[horarioColIndex] || '').trim().replace(/([0-9])\s*(hs|horas|h)\b\.?/gi, '$1').trim(); const link = linkColIndex !== -1 ? (row[linkColIndex] || '').trim() : undefined; if (codigo && asignatura && carrera && !codigo.toLowerCase().includes('codigo') && !codigo.toLowerCase().includes('código') && !codigo.toLowerCase().startsWith('turno')) { result.push({ codigo, asignatura, aula, carrera, horario, dia, link: link || undefined }); } } } return result; } }