feat: gestion RBAC granular, importacion sheets, filtrado de aulas virtuales y pisos, capacidad infinita y traduccion
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
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: 'Parcial' | 'TP' | 'Final' | 'Otro';
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user