Files
admin-edu-space/example/aulas-unicaba-mvp/src/app/services/data.ts
T

346 lines
11 KiB
TypeScript

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<ClaseHorario[]> {
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<ClaseHorario[]> {
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<string[]> {
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<string, number> = {
'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;
}
}