Skip to content

Commit 8732004

Browse files
authored
Merge pull request #30 from mon-b/fña-v2
Added GenerationToggle
2 parents fd577d7 + 19257ab commit 8732004

File tree

3 files changed

+230
-25
lines changed

3 files changed

+230
-25
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useState, useRef, useEffect } from 'react';
2+
import { useCoursePlanner, GENERATIONS } from '../../context/CoursePlannerContext';
3+
import styles from './GenerationToggle.module.css';
4+
5+
export default function GenerationToggle() {
6+
const { currentGeneration, switchGeneration } = useCoursePlanner();
7+
const [isOpen, setIsOpen] = useState(false);
8+
const dropdownRef = useRef<HTMLDivElement>(null);
9+
10+
const selectedGen = GENERATIONS.find(g => g.id === currentGeneration) || GENERATIONS[0];
11+
12+
const handleGenChange = (genId: string) => {
13+
switchGeneration(genId);
14+
setIsOpen(false);
15+
};
16+
17+
useEffect(() => {
18+
const handleClickOutside = (event: MouseEvent) => {
19+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
20+
setIsOpen(false);
21+
}
22+
};
23+
24+
document.addEventListener('mousedown', handleClickOutside);
25+
return () => document.removeEventListener('mousedown', handleClickOutside);
26+
}, []);
27+
28+
return (
29+
<div className={styles.paletteContainer} ref={dropdownRef} title="Seleccionar Generación">
30+
<div className={styles.customSelect}>
31+
<button
32+
className={styles.selectButton}
33+
onClick={() => setIsOpen(!isOpen)}
34+
>
35+
<span className={styles.selectedText}>
36+
{selectedGen.name}
37+
</span>
38+
<span className={`${styles.arrow} ${isOpen ? styles.arrowUp : ''}`}>
39+
40+
</span>
41+
</button>
42+
43+
{isOpen && (
44+
<div className={styles.optionsContainer}>
45+
{GENERATIONS.map(gen => (
46+
<button
47+
key={gen.id}
48+
className={`${styles.option} ${gen.id === currentGeneration ? styles.optionSelected : ''}`}
49+
onClick={() => handleGenChange(gen.id)}
50+
>
51+
<span className={styles.optionText}>
52+
{gen.name}
53+
</span>
54+
</button>
55+
))}
56+
</div>
57+
)}
58+
</div>
59+
</div>
60+
);
61+
}

src/context/CoursePlannerContext.tsx

Lines changed: 144 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { createContext, useContext, useReducer, useEffect, ReactNode, useRef, useMemo, useCallback } from 'react';
1+
import React, { createContext, useContext, useReducer, useEffect, ReactNode, useRef, useMemo, useCallback, useState } from 'react';
22
import { Course, AppState, PaletteConfig } from '../types/course';
33
import { defaultData } from '../data/defaultData';
44
import { optData } from '../data/optData';
@@ -51,21 +51,29 @@ const PALETTES: Record<string, PaletteConfig> = {
5151
}
5252
};
5353

54+
// Define available generations
55+
export const GENERATIONS = [
56+
{ id: 'genLegacy', name: 'Malla 2023-2024' },
57+
{ id: 'genNew', name: 'Malla 2025' }
58+
];
59+
5460
interface CoursePlannerContextType {
5561
state: AppState;
5662
dispatch: React.Dispatch<CoursePlannerAction>;
5763
allCourses: Course[];
5864
findCourseData: (courseId: string) => Course | undefined;
5965
getCurrentPalette: () => PaletteConfig;
6066
getAvailablePalettes: () => PaletteConfig[];
67+
currentGeneration: string;
68+
switchGeneration: (genId: string) => void;
6169
}
6270

6371
type CoursePlannerAction =
6472
| { type: 'MOVE_COURSE'; payload: { courseId: string; fromContainer: string; toContainer: string; toIndex?: number } }
6573
| { type: 'TOGGLE_COURSE_TAKEN'; payload: { courseId: string } }
6674
| { type: 'ADD_SEMESTER' }
6775
| { type: 'DELETE_SEMESTER'; payload: { semesterNumber: number } }
68-
| { type: 'RESET_PLANNER' }
76+
| { type: 'RESET_PLANNER'; payload: { genId: string } }
6977
| { type: 'TOGGLE_COURSE_POOL' }
7078
| { type: 'CREATE_CUSTOM_COURSE'; payload: Course }
7179
| { type: 'UPDATE_COURSE'; payload: { originalId: string; course: Course } }
@@ -74,8 +82,9 @@ type CoursePlannerAction =
7482

7583
const CoursePlannerContext = createContext<CoursePlannerContextType | undefined>(undefined);
7684

77-
const STORAGE_KEY = 'coursePlannerState';
78-
const DATA_VERSION = 1;
85+
const STORAGE_KEY_PREFIX = 'coursePlannerState';
86+
const CURRENT_GEN_KEY = 'coursePlannerCurrentGen';
87+
const DATA_VERSION = 2;
7988

8089
const initialState: AppState = {
8190
semesters: [],
@@ -186,7 +195,7 @@ function coursePlannerReducer(state: AppState, action: CoursePlannerAction): App
186195
}
187196

188197
case 'RESET_PLANNER': {
189-
return initializeDefaultState();
198+
return initializeDefaultState(action.payload.genId);
190199
}
191200

192201
case 'TOGGLE_COURSE_POOL': {
@@ -244,10 +253,68 @@ function findCourseState(courseId: string, state: AppState) {
244253
return state.coursePool.find(c => c.id === courseId);
245254
}
246255

247-
function initializeDefaultState(): AppState {
248-
const semesters = defaultData.map(sem => ({
256+
function initializeDefaultState(genId: string = 'genLegacy'): AppState {
257+
// Deep copy default data
258+
let semestersData = JSON.parse(JSON.stringify(defaultData));
259+
260+
// Modify for 2025 Generation
261+
if (genId === 'genNew') {
262+
const courseMoves = [
263+
{ id: 'IIC2343', toSem: 4 }, // Arqui: Sem 2 -> Sem 4
264+
{ id: 'IIC2333', toSem: 5 }, // SO: Sem 4 -> Sem 5
265+
{ id: 'OPTC1', toSem: 2 } // Opt Ciencia: Sem 5 -> Sem 2
266+
];
267+
268+
// Helper to find and remove course from any semester
269+
const extractCourse = (id: string) => {
270+
for (const sem of semestersData) {
271+
const idx = sem.courses.findIndex((c: any) => c.id === id);
272+
if (idx !== -1) {
273+
return sem.courses.splice(idx, 1)[0];
274+
}
275+
}
276+
return null;
277+
};
278+
279+
// Store extracted courses
280+
const extractedCourses: Record<string, any> = {};
281+
courseMoves.forEach(move => {
282+
extractedCourses[move.id] = extractCourse(move.id);
283+
});
284+
285+
// Insert into new locations maintaining order (before OFG/Teo)
286+
courseMoves.forEach(move => {
287+
const course = extractedCourses[move.id];
288+
if (course) {
289+
const targetSem = semestersData.find((s: any) => s.sem === move.toSem);
290+
if (targetSem) {
291+
// Find index of OFG or Teologico to insert before
292+
// Usually they are the last ones or have specific IDs like TEO123, OFG2, OFG3
293+
const lastCourseIndex = targetSem.courses.length > 0 ? targetSem.courses.length - 1 : 0;
294+
const lastCourse = targetSem.courses[lastCourseIndex];
295+
296+
if (lastCourse && (lastCourse.type === 'ofg' || lastCourse.id.startsWith('OFG') || lastCourse.id === 'TEO123')) {
297+
targetSem.courses.splice(lastCourseIndex, 0, course);
298+
} else {
299+
targetSem.courses.push(course);
300+
}
301+
}
302+
}
303+
});
304+
305+
// Update Practice Credits (IIC2002)
306+
// Find IIC2002 and update cred to 10
307+
semestersData.forEach((sem: any) => {
308+
const practice = sem.courses.find((c: any) => c.id === 'IIC2002');
309+
if (practice) {
310+
practice.cred = "10";
311+
}
312+
});
313+
}
314+
315+
const semesters = semestersData.map((sem: any) => ({
249316
number: sem.sem,
250-
courses: sem.courses.map(course => ({
317+
courses: sem.courses.map((course: any) => ({
251318
id: course.id,
252319
type: course.type,
253320
taken: false
@@ -275,9 +342,15 @@ function initializeDefaultState(): AppState {
275342
};
276343
}
277344

278-
function loadStateFromStorage(): AppState | null {
345+
function getStorageKey(genId: string) {
346+
if (genId === 'genLegacy') return STORAGE_KEY_PREFIX;
347+
return `${STORAGE_KEY_PREFIX}_${genId}`;
348+
}
349+
350+
function loadStateFromStorage(genId: string): AppState | null {
279351
try {
280-
const savedState = localStorage.getItem(STORAGE_KEY);
352+
const key = getStorageKey(genId);
353+
const savedState = localStorage.getItem(key);
281354
if (!savedState) return null;
282355

283356
const parsedState = JSON.parse(savedState);
@@ -292,7 +365,7 @@ function loadStateFromStorage(): AppState | null {
292365
typeof parsedState.coursePoolVisible === 'boolean'
293366
) {
294367
if (parsedState.version !== DATA_VERSION) {
295-
console.warn('State version mismatch (expected ' + DATA_VERSION + '), resetting state to apply updates.');
368+
console.warn('State version mismatch (expected ' + DATA_VERSION + '), resetting state.');
296369
return null;
297370
}
298371

@@ -313,44 +386,77 @@ function loadStateFromStorage(): AppState | null {
313386
}
314387
}
315388

316-
function saveStateToStorage(state: AppState): void {
389+
function saveStateToStorage(state: AppState, genId: string): void {
317390
try {
318-
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
391+
const key = getStorageKey(genId);
392+
localStorage.setItem(key, JSON.stringify(state));
319393
} catch (error) {
320394
console.error('Failed to save state to localStorage:', error);
321395
}
322396
}
323397

324398
export function CoursePlannerProvider({ children }: { children: ReactNode }) {
325399
const hasLoadedFromStorage = useRef(false);
400+
const [currentGeneration, setCurrentGeneration] = useState(() => {
401+
return localStorage.getItem(CURRENT_GEN_KEY) || 'genLegacy';
402+
});
326403

327404
const [state, dispatch] = useReducer(
328405
coursePlannerReducer,
329406
initialState,
330407
(_initial) => {
331-
const savedState = loadStateFromStorage();
408+
const initialGen = localStorage.getItem(CURRENT_GEN_KEY) || 'genLegacy';
409+
const savedState = loadStateFromStorage(initialGen);
332410
if (savedState) {
333411
hasLoadedFromStorage.current = true;
334412
return savedState;
335413
}
336-
return initializeDefaultState();
414+
return initializeDefaultState(initialGen);
337415
}
338416
);
339417

340418
const allCourses = useMemo((): Course[] => {
419+
// Determine base data based on current generation?
420+
// Ideally we should reflect the changes in allCourses too if we want tooltips to be correct regarding semesters/credits?
421+
// However, defaultData is static.
422+
// For now, modifiedCourses will handle overrides if we treated them as such, but here we are moving them in semesters.
423+
// The "allCourses" is mainly used for palette and looking up static info.
424+
// The Credits change for IIC2002 needs to be reflected here too.
425+
426+
let baseCourses = [...defaultData.flatMap(sem => sem.courses)];
427+
428+
if (currentGeneration === 'genNew') {
429+
// Update IIC2002 credits in this view as well
430+
baseCourses = baseCourses.map(c => {
431+
if (c.id === 'IIC2002') return { ...c, cred: "10" };
432+
return c;
433+
});
434+
}
435+
341436
return [
342-
...defaultData.flatMap(sem => sem.courses),
437+
...baseCourses,
343438
...optData[0].courses,
344439
...engData[0].courses,
345440
...state.customCourses
346441
];
347-
}, [state.customCourses]);
442+
}, [state.customCourses, currentGeneration]);
348443

349444
const findCourseData = useCallback((courseId: string): Course | undefined => {
350-
if (state.modifiedCourses[courseId]) {
351-
return state.modifiedCourses[courseId];
445+
const defaultCourse = allCourses.find(course => course.id === courseId);
446+
const modifiedCourse = state.modifiedCourses[courseId];
447+
448+
if (modifiedCourse) {
449+
if (defaultCourse) {
450+
return {
451+
...modifiedCourse,
452+
parity: defaultCourse.parity,
453+
prereq: defaultCourse.prereq,
454+
cred: defaultCourse.cred // Ensure credit updates propagate if they are static updates
455+
};
456+
}
457+
return modifiedCourse;
352458
}
353-
return allCourses.find(course => course.id === courseId);
459+
return defaultCourse;
354460
}, [allCourses, state.modifiedCourses]);
355461

356462
const getCurrentPalette = useCallback((): PaletteConfig => {
@@ -360,23 +466,37 @@ export function CoursePlannerProvider({ children }: { children: ReactNode }) {
360466
const getAvailablePalettes = useCallback((): PaletteConfig[] => {
361467
return Object.values(PALETTES);
362468
}, []);
469+
470+
const switchGeneration = useCallback((newGenId: string) => {
471+
if (newGenId === currentGeneration) return;
472+
473+
saveStateToStorage(state, currentGeneration);
474+
475+
const newState = loadStateFromStorage(newGenId) || initializeDefaultState(newGenId);
476+
dispatch({ type: 'LOAD_STATE', payload: newState });
477+
478+
setCurrentGeneration(newGenId);
479+
localStorage.setItem(CURRENT_GEN_KEY, newGenId);
480+
}, [currentGeneration, state]);
363481

364482
useEffect(() => {
365483
if (hasLoadedFromStorage.current) {
366-
saveStateToStorage(state);
484+
saveStateToStorage(state, currentGeneration);
367485
} else {
368486
hasLoadedFromStorage.current = true;
369487
}
370-
}, [state]);
488+
}, [state, currentGeneration]);
371489

372490
const value = useMemo(() => ({
373491
state,
374492
dispatch,
375493
allCourses,
376494
findCourseData,
377495
getCurrentPalette,
378-
getAvailablePalettes
379-
}), [state, allCourses, findCourseData, getCurrentPalette, getAvailablePalettes]);
496+
getAvailablePalettes,
497+
currentGeneration,
498+
switchGeneration
499+
}), [state, allCourses, findCourseData, getCurrentPalette, getAvailablePalettes, currentGeneration, switchGeneration]);
380500

381501
return (
382502
<CoursePlannerContext.Provider value={value}>

src/pages/PlannerPage/PlannerPage.module.css

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,4 +476,28 @@
476476
.sidePanel.open {
477477
width: 100%;
478478
}
479-
}
479+
}
480+
481+
.generationSelect {
482+
background: rgba(139, 69, 255, 0.1);
483+
border: 1px solid rgba(139, 69, 255, 0.3);
484+
border-radius: 6px;
485+
padding: 0.4rem 0.8rem;
486+
color: rgba(255, 255, 255, 0.9);
487+
font-weight: 500;
488+
font-size: 14px;
489+
cursor: pointer;
490+
outline: none;
491+
margin-left: 1rem;
492+
}
493+
494+
.generationSelect:hover {
495+
background: rgba(139, 69, 255, 0.2);
496+
border-color: rgba(139, 69, 255, 0.5);
497+
color: white;
498+
}
499+
500+
.generationSelect option {
501+
background: #1a1a2e;
502+
color: white;
503+
}

0 commit comments

Comments
 (0)