diff --git a/readme.md b/.claude similarity index 100% rename from readme.md rename to .claude diff --git a/index.html b/index.html index e713fba..82dc1c2 100644 --- a/index.html +++ b/index.html @@ -32,14 +32,40 @@ --gray-700: #374151; --gray-800: #1F2937; --gray-900: #111827; + --bg-body: #F3F4F6; + --bg-surface: #ffffff; + } + + :root.dark { + --primary: #818CF8; + --primary-light: #1e1b4b; + --success: #34D399; + --success-light: #022c22; + --danger: #F87171; + --danger-light: #450a0a; + --warning: #FBBF24; + --warning-light: #451a03; + --gray-50: #263350; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-300: #475569; + --gray-400: #64748b; + --gray-500: #94a3b8; + --gray-600: #cbd5e1; + --gray-700: #e2e8f0; + --gray-800: #f1f5f9; + --gray-900: #f8fafc; + --bg-body: #0f172a; + --bg-surface: #1e293b; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--gray-100); + background: var(--bg-body); color: var(--gray-800); min-height: 100vh; - padding-bottom: 72px; + padding-bottom: 20px; + transition: background 0.2s, color 0.2s; } /* ── Header ── */ @@ -55,39 +81,10 @@ justify-content: space-between; box-shadow: 0 2px 8px rgba(0,0,0,0.25); } - .header h1 { font-size: 19px; font-weight: 700; letter-spacing: -0.3px; } .header-sub { font-size: 11px; opacity: 0.75; margin-top: 1px; } + .header-right { display: flex; gap: 6px; align-items: center; } - /* ── Bottom Navigation ── */ - .bottom-nav { - position: fixed; - bottom: 0; left: 0; right: 0; - background: white; - border-top: 1px solid var(--gray-200); - display: flex; - z-index: 100; - box-shadow: 0 -2px 12px rgba(0,0,0,0.08); - } - .nav-item { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - padding: 7px 4px 5px; - cursor: pointer; - color: var(--gray-400); - font-size: 10px; - font-weight: 500; - gap: 3px; - border: none; - background: none; - transition: color 0.15s; - -webkit-tap-highlight-color: transparent; - } - .nav-item.active { color: var(--primary); } - .nav-item svg { width: 22px; height: 22px; stroke-width: 1.8; } - - /* ── Content ── */ +/* ── Content ── */ .content { max-width: 680px; margin: 0 auto; padding: 14px 12px; } .page { display: none; } .page.active { display: block; } @@ -116,7 +113,7 @@ /* ── Cards ── */ .card { - background: white; + background: var(--bg-surface); border-radius: 12px; padding: 14px; margin-bottom: 12px; @@ -138,7 +135,7 @@ margin-bottom: 14px; } .stat-card { - background: white; + background: var(--bg-surface); border-radius: 12px; padding: 14px 12px; text-align: center; @@ -180,10 +177,16 @@ font-weight: 600; white-space: nowrap; } - .badge-pass { background: var(--success-light); color: #065F46; } - .badge-fail { background: var(--danger-light); color: #991B1B; } - .badge-prog { background: var(--warning-light); color: #92400E; } - .badge-info { background: var(--primary-light); color: #3730A3; } + .badge-pass { background: var(--success-light); color: #065F46; } + .badge-fail { background: var(--danger-light); color: #991B1B; } + .badge-prog { background: var(--warning-light); color: #92400E; } + .badge-info { background: var(--primary-light); color: #3730A3; } + .badge-cycle { background: var(--gray-100); color: var(--gray-600); font-size: 10px; } + + :root.dark .badge-pass { background: #022c22; color: #6ee7b7; } + :root.dark .badge-fail { background: #450a0a; color: #fca5a5; } + :root.dark .badge-prog { background: #451a03; color: #fcd34d; } + :root.dark .badge-info { background: #1e1b4b; color: #a5b4fc; } /* ── Buttons ── */ .btn { @@ -195,10 +198,10 @@ transition: opacity 0.15s; } .btn:active { opacity: 0.75; } - .btn-primary { background: var(--primary); color: white; } + .btn-primary { background: var(--primary); color: white; } .btn-secondary { background: var(--gray-100); color: var(--gray-700); } - .btn-danger { background: var(--danger-light); color: var(--danger); } - .btn-sm { padding: 4px 9px; font-size: 12px; border-radius: 6px; } + .btn-danger { background: var(--danger-light); color: var(--danger); } + .btn-sm { padding: 4px 9px; font-size: 12px; border-radius: 6px; } .btn-full { width: 100%; justify-content: center; } /* ── Grade colors ── */ @@ -214,7 +217,7 @@ display: flex; align-items: flex-end; justify-content: center; } .modal { - background: white; + background: var(--bg-surface); border-radius: 20px 20px 0 0; padding: 20px 16px 28px; width: 100%; max-width: 680px; @@ -247,7 +250,7 @@ input:not([type="checkbox"]), select { width: 100%; padding: 10px 12px; border: 1.5px solid var(--gray-200); border-radius: 8px; - font-size: 15px; color: var(--gray-800); background: white; + font-size: 15px; color: var(--gray-800); background: var(--bg-surface); -webkit-appearance: auto; appearance: auto; transition: border-color 0.15s; } @@ -289,7 +292,7 @@ margin-top: 8px; } .block-title { font-size: 13px; font-weight: 600; color: var(--gray-600); } - .block-meta { font-size: 11px; color: var(--gray-400); } + .block-meta { font-size: 11px; color: var(--gray-400); } /* ── Segmented control ── */ .seg-ctrl { @@ -306,7 +309,7 @@ -webkit-tap-highlight-color: transparent; } .seg-btn.active { - background: white; color: var(--gray-800); + background: var(--bg-surface); color: var(--gray-800); box-shadow: 0 1px 4px rgba(0,0,0,0.12); } @@ -318,7 +321,7 @@ .empty-icon { font-size: 44px; margin-bottom: 10px; } .empty-text { font-size: 14px; line-height: 1.5; } - /* ── Header data button ── */ + /* ── Header buttons ── */ .header-btn { background: rgba(255,255,255,0.15); border: none; border-radius: 8px; color: white; cursor: pointer; padding: 7px 9px; @@ -329,6 +332,15 @@ } .header-btn:active { background: rgba(255,255,255,0.28); } .header-btn svg { width: 18px; height: 18px; stroke-width: 1.8; flex-shrink: 0; } + .theme-btn { + background: rgba(255,255,255,0.15); border: none; border-radius: 8px; + color: white; cursor: pointer; padding: 7px 8px; + display: flex; align-items: center; + -webkit-tap-highlight-color: transparent; + transition: background 0.15s; + } + .theme-btn:active { background: rgba(255,255,255,0.28); } + .theme-btn svg { width: 18px; height: 18px; stroke-width: 1.8; } /* ── Pass/Fail toggle ── */ .pf-toggle { @@ -343,7 +355,6 @@ } .pf-toggle-text { font-size: 14px; font-weight: 500; color: var(--gray-700); line-height: 1.3; } .pf-toggle-sub { font-size: 11px; color: var(--gray-400); margin-top: 2px; } - /* Switch pill */ .switch { position: relative; width: 44px; height: 26px; flex-shrink: 0; margin-left: 12px; } .switch input { opacity: 0; width: 0; height: 0; } .switch-track { @@ -374,76 +385,90 @@ /* ── Course card ── */ .course-card { - background: white; border-radius: 12px; + background: var(--bg-surface); border-radius: 12px; padding: 13px 14px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.07); display: flex; justify-content: space-between; align-items: flex-start; + transition: opacity 0.15s; } - .course-name { font-size: 15px; font-weight: 600; color: var(--gray-800); } - .course-meta { font-size: 11px; color: var(--gray-400); margin-top: 3px; } + .course-name { font-size: 15px; font-weight: 600; color: var(--gray-800); } + .course-meta { font-size: 11px; color: var(--gray-400); margin-top: 3px; } .course-grade { font-size: 22px; font-weight: 700; line-height: 1; text-align: right; } + + /* ── Drag and drop ── */ + .drag-handle { + cursor: grab; color: var(--gray-300); padding: 2px 8px 2px 0; + display: flex; align-items: center; flex-shrink: 0; + touch-action: none; user-select: none; + } + .drag-handle:active { cursor: grabbing; color: var(--gray-500); } + .drag-handle svg { width: 16px; height: 16px; } + .course-card.dragging { opacity: 0.35; } + .course-card.drag-over { outline: 2px dashed var(--primary); outline-offset: -2px; background: var(--primary-light); } + + /* ── Week Calendar ── */ + .wk-outer { border: 1px solid var(--gray-200); border-radius: 10px; overflow: hidden; } + .wk-head { display: flex; border-bottom: 1px solid var(--gray-200); background: var(--bg-surface); position: sticky; top: 0; z-index: 5; } + .wk-htime { width: 40px; flex-shrink: 0; } + .wk-hday { flex: 1; text-align: center; font-size: 11px; font-weight: 700; color: var(--gray-500); padding: 7px 2px; text-transform: uppercase; letter-spacing: 0.04em; } + .wk-scroll { overflow-y: auto; max-height: 60vh; } + .wk-body { display: flex; } + .wk-tcol { width: 40px; flex-shrink: 0; position: relative; } + .wk-tlbl { position: absolute; font-size: 10px; color: var(--gray-400); right: 5px; transform: translateY(-50%); white-space: nowrap; } + .wk-days { display: flex; flex: 1; } + .wk-dcol { flex: 1; position: relative; border-left: 1px solid var(--gray-100); min-width: 0; } + .wk-gline { position: absolute; left: 0; right: 0; border-top: 1px solid var(--gray-100); } + .wk-gline-h { border-top-color: var(--gray-200); } + .wk-event { + position: absolute; left: 2px; right: 2px; + border-radius: 4px; font-size: 10px; font-weight: 600; + padding: 2px 4px; overflow: hidden; cursor: pointer; + color: #fff; line-height: 1.3; + } + .wk-event:active { filter: brightness(0.85); } + .ev-swatch { + display: inline-block; width: 26px; height: 26px; border-radius: 50%; + cursor: pointer; flex-shrink: 0; + transition: outline 0.1s; + }
-
📚 Credit Tracker
+
📚 Credit Tracker
Loading…
- +
+ + + +
-
+
+
- - - + +
- -
-
-
- - -
-
-
- - -
-
-
- @@ -451,10 +476,15 @@ // ══════════════════════════════════════════════════════ // STATE // ══════════════════════════════════════════════════════ -const STORE_KEY = 'creditTracker_v1'; -let S = { studyPlans: [], blocks: [], semesters: [], courses: [] }; +const STORE_KEY = 'creditTracker_v1'; +const SETTINGS_KEY = 'creditTracker_settings'; +const TABS = ['dashboard']; +const PLAN_PALETTE = ['#4F46E5','#0D9488','#E11D48','#D97706','#7C3AED','#0891B2','#65A30D','#DB2777']; +const MINOR_PALETTE = ['#F97316','#EAB308','#22C55E','#06B6D4','#A78BFA','#FB7185','#38BDF8','#84CC16']; +let S = { studyPlans: [], blocks: [], subBlocks: [], semesters: [], courses: [], schedule: [] }; +let settings = { minCreditsBachelor: 180, minCreditsMaster: 120, allSeasons: false, showOptional: false }; let page = 'dashboard'; -let dashView = 'global'; +let dashView = 'plans'; function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } @@ -465,10 +495,22 @@ function load() { const parsed = JSON.parse(raw); S.studyPlans = parsed.studyPlans || []; S.blocks = parsed.blocks || []; + S.subBlocks = parsed.subBlocks || []; S.semesters = parsed.semesters || []; S.courses = parsed.courses || []; + S.schedule = parsed.schedule || []; } } catch (e) { console.error('Load failed', e); } + try { + const rs = localStorage.getItem(SETTINGS_KEY); + if (rs) settings = { ...settings, ...JSON.parse(rs) }; + } catch (e) {} + const theme = localStorage.getItem('ct_theme'); + if (theme === 'dark') applyTheme(true); +} + +function saveSettings() { + try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (e) {} } function save() { @@ -476,11 +518,27 @@ function save() { catch (e) { console.error('Save failed', e); } } +// ══════════════════════════════════════════════════════ +// THEME +// ══════════════════════════════════════════════════════ +function applyTheme(dark) { + document.documentElement.classList.toggle('dark', dark); + const moon = document.getElementById('theme-icon-moon'); + const sun = document.getElementById('theme-icon-sun'); + if (moon) moon.style.display = dark ? 'none' : ''; + if (sun) sun.style.display = dark ? '' : 'none'; +} + +function toggleTheme() { + const isDark = document.documentElement.classList.toggle('dark'); + localStorage.setItem('ct_theme', isDark ? 'dark' : 'light'); + applyTheme(isDark); +} + // ══════════════════════════════════════════════════════ // COMPUTED HELPERS // ══════════════════════════════════════════════════════ function blockMean(blockId) { - // passFail courses are excluded from the mean const graded = S.courses.filter(c => c.blockId === blockId && !c.passFail && c.grade != null && c.grade !== ''); if (!graded.length) return null; const sumWeighted = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0); @@ -489,9 +547,8 @@ function blockMean(blockId) { return sumWeighted / sumCoef; } -// status: 'pass' | 'fail' | 'prog' function status(course) { - if (course.passFail) return 'pass'; // pass/fail courses always count as passed + if (course.passFail) return 'pass'; if (course.grade == null || course.grade === '') return 'prog'; if (+course.grade >= 4) return 'pass'; if (course.blockId) { @@ -519,9 +576,16 @@ function fmtGrade(g, passFail) { return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); } -function semName(id) { const s = S.semesters.find(x => x.id === id); return s ? esc(s.name) : '—'; } -function planName(id) { const p = S.studyPlans.find(x => x.id === id); return p ? esc(p.name) : '—'; } -function blockName(id) { const b = S.blocks.find(x => x.id === id); return b ? esc(b.name) : '—'; } +function semName(id) { const s = S.semesters.find(x => x.id === id); return s ? esc(s.name) : '—'; } +function planName(id) { const p = S.studyPlans.find(x => x.id === id); return p ? esc(p.name) : '—'; } +function blockName(id) { const b = S.blocks.find(x => x.id === id); return b ? esc(b.name) : '—'; } +function subBlockName(id) { const b = S.subBlocks.find(x => x.id === id); return b ? esc(b.name) : '—'; } + +function cycleBadge(cycle) { + if (!cycle) return ''; + const labels = { bachelor: 'Bachelor', master: 'Master', phd: 'PhD' }; + return `${labels[cycle] || cycle}`; +} function esc(s) { if (!s) return ''; @@ -533,9 +597,8 @@ function esc(s) { // ══════════════════════════════════════════════════════ function go(p) { document.querySelectorAll('.page').forEach(el => el.classList.remove('active')); - document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active')); - document.getElementById('page-' + p).classList.add('active'); - document.getElementById('nav-' + p).classList.add('active'); + const pg = document.getElementById('page-' + p); + if (pg) pg.classList.add('active'); page = p; render(p); } @@ -543,15 +606,12 @@ function go(p) { function setDashView(v) { dashView = v; const btns = document.querySelectorAll('#dash-seg .seg-btn'); - ['global','plans','semesters'].forEach((n, i) => btns[i].classList.toggle('active', n === v)); + ['plans','semesters'].forEach((n, i) => btns[i].classList.toggle('active', n === v)); renderDash(); } function render(p) { if (p === 'dashboard') renderDash(); - if (p === 'courses') renderCourses(); - if (p === 'plans') renderPlans(); - if (p === 'semesters') renderSemesters(); updateHeaderSub(); } @@ -564,24 +624,148 @@ function updateHeaderSub() { } function onFab() { - if (page === 'plans') { showPlanModal(); return; } - if (page === 'semesters') { showSemesterModal(); return; } - showCourseModal(); // dashboard + courses + if (dashView === 'plans') { showPlanTypeModal(); return; } + if (dashView === 'semesters') { showSemesterModal(); return; } + showCourseModal(); +} + +// ══════════════════════════════════════════════════════ +// SWIPE NAVIGATION +// ══════════════════════════════════════════════════════ +(function() { + let sx = 0, sy = 0; + const content = document.getElementById('content'); + content.addEventListener('touchstart', e => { + sx = e.touches[0].clientX; + sy = e.touches[0].clientY; + }, { passive: true }); + content.addEventListener('touchend', e => { + const dx = e.changedTouches[0].clientX - sx; + const dy = e.changedTouches[0].clientY - sy; + if (Math.abs(dx) > 55 && Math.abs(dx) > Math.abs(dy) * 1.5) { + if (sy > window.innerHeight * 0.8) { + // Lower 20% → switch main tab + const idx = TABS.indexOf(page); + if (dx < 0 && idx < TABS.length - 1) go(TABS[idx + 1]); + else if (dx > 0 && idx > 0) go(TABS[idx - 1]); + } else { + // Upper 80% → switch sub-tab of current page + swipeSubTab(dx); + } + } + }, { passive: true }); +})(); + +function swipeSubTab(dx) { + if (page === 'dashboard') { + const views = ['plans', 'semesters']; + const idx = views.indexOf(dashView); + if (dx < 0 && idx < views.length - 1) setDashView(views[idx + 1]); + else if (dx > 0 && idx > 0) setDashView(views[idx - 1]); + } +} + +// ══════════════════════════════════════════════════════ +// COLOR HELPERS +// ══════════════════════════════════════════════════════ +function lightenHex(hex, factor) { + const r = parseInt(hex.slice(1,3),16); + const g = parseInt(hex.slice(3,5),16); + const b = parseInt(hex.slice(5,7),16); + const mix = v => Math.round(v + (255 - v) * factor).toString(16).padStart(2,'0'); + return '#' + mix(r) + mix(g) + mix(b); +} + +function planBaseColor(plan) { + if (!plan) return PLAN_PALETTE[0]; + if (plan.color) return plan.color; + const idx = S.studyPlans.indexOf(plan); + return PLAN_PALETTE[(idx >= 0 ? idx : 0) % PLAN_PALETTE.length]; +} + +function blockEffectiveColor(blk) { + if (!blk) return PLAN_PALETTE[0]; + if (blk.color) return blk.color; + const plan = S.studyPlans.find(p => p.id === blk.planId); + if (!blk.role || blk.role === 'major') return planBaseColor(plan); + if (blk.role === 'minor') { + const minors = S.blocks.filter(b => b.planId === blk.planId && b.role === 'minor'); + const mIdx = minors.findIndex(b => b.id === blk.id); + return MINOR_PALETTE[Math.max(mIdx, 0) % MINOR_PALETTE.length]; + } + // optional — offset by 4 in MINOR_PALETTE to avoid clashing with minors + const opts = S.blocks.filter(b => b.planId === blk.planId && b.role === 'optional'); + const oIdx = opts.findIndex(b => b.id === blk.id); + return MINOR_PALETTE[(Math.max(oIdx, 0) + 4) % MINOR_PALETTE.length]; +} + +function subBlockEffectiveColor(sb) { + if (!sb) return PLAN_PALETTE[0]; + const parent = S.blocks.find(b => b.id === sb.blockId); + const base = blockEffectiveColor(parent); + const sibs = S.subBlocks.filter(s => s.blockId === sb.blockId); + const idx = Math.max(sibs.findIndex(s => s.id === sb.id), 0); + return lightenHex(base, 0.18 + idx * 0.14); +} + +function colorSwatches(palette, selected, onClickFn, includeAuto, autoColor) { + let html = ''; + if (includeAuto) { + const isSel = !selected; + html += ``; + } + html += palette.map(c => { + const isSel = c === selected; + return ``; + }).join(''); + return `
${html}
`; +} + +function syncSwatchOutlines(containerId, selected) { + document.querySelectorAll(`#${containerId} .ev-swatch`).forEach(s => { + const c = s.dataset.color; + const sel = c === selected; + const clr = c || '#94a3b8'; + s.style.outline = sel ? '2px solid ' + clr : 'none'; + s.style.outlineOffset = sel ? '2px' : '0'; + }); } // ══════════════════════════════════════════════════════ // DASHBOARD // ══════════════════════════════════════════════════════ function renderDash() { + renderDashStats(); const el = document.getElementById('dash-content'); - if (dashView === 'global') el.innerHTML = dashGlobal(); if (dashView === 'plans') el.innerHTML = dashPlans(); if (dashView === 'semesters') el.innerHTML = dashSemesters(); } +function renderDashStats() { + const all = S.courses; + const graded = all.filter(c => !c.passFail && c.grade != null && c.grade !== ''); + const passed = all.filter(c => status(c) === 'pass'); + const totCr = all.reduce((s, c) => s + (+c.credits || 0), 0); + const passCr = passed.reduce((s, c) => s + (+c.credits || 0), 0); + const sumW = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0); + const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0); + const mean = sumC > 0 ? sumW / sumC : null; + const passedGraded = graded.filter(c => status(c) === 'pass').length; + const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null; + + document.getElementById('dash-stats').innerHTML = `
+
${totCr}
Total credits
+
${passCr}
Credits earned
+
${mean !== null ? mean.toFixed(2) : '—'}
Mean grade
+
${rate !== null ? rate + '%' : '—'}
Pass rate
+
`; +} + function dashGlobal() { const all = S.courses; - // passFail courses excluded from mean, but count for credits/pass rate denominator const graded = all.filter(c => !c.passFail && c.grade != null && c.grade !== ''); const passed = all.filter(c => status(c) === 'pass'); const totCr = all.reduce((s, c) => s + (+c.credits || 0), 0); @@ -617,64 +801,164 @@ function dashGlobal() { return html; } +const WARN_SVG = ``; +const NOSEM_SVG = ``; +const GEAR_SVG = ``; + +function roleGroupSection(planId, label, role, blocks, allPlanCourses, planColor) { + const headerColor = role === 'major' + ? planColor + : (blocks.length ? blockEffectiveColor(blocks[0]) : MINOR_PALETTE[role === 'optional' ? 4 : 0]); + const addLabel = role === 'major' ? 'Major' : role === 'minor' ? 'Minor' : 'Optional'; + const canAdd = role !== 'major' || !blocks.length; + let html = `
+
+ ${label} + ${canAdd ? `` : ''} +
`; + if (blocks.length) { + blocks.forEach(blk => { html += planBlockSection(planId, blk, '', allPlanCourses); }); + } else { + html += `
No ${label.toLowerCase()} defined yet.
`; + } + html += `
`; + return html; +} + function dashPlans() { - if (!S.studyPlans.length) return emptyState('📋', 'No study plans yet.
Create one in the Plans tab!'); + if (!S.studyPlans.length) return emptyState('📋', 'No study plans yet.
Tap + to create one!'); let html = ''; S.studyPlans.forEach(plan => { - const pc = S.courses.filter(c => c.planId === plan.id); - const pb = S.blocks.filter(b => b.planId === plan.id); + const pc = S.courses.filter(c => c.planId === plan.id); const totCr = pc.reduce((s, c) => s + (+c.credits || 0), 0); const passCr = pc.filter(c => status(c) === 'pass').reduce((s, c) => s + (+c.credits || 0), 0); + const minCr = plan.cycle === 'bachelor' ? settings.minCreditsBachelor + : plan.cycle === 'master' ? settings.minCreditsMaster : null; + const creditOk = minCr === null || totCr >= minCr; - html += `
- ${esc(plan.name)} - ${passCr} / ${totCr} cr. -
`; + const majors = S.blocks.filter(b => b.planId === plan.id && b.role === 'major'); + const minors = S.blocks.filter(b => b.planId === plan.id && b.role === 'minor'); + const optionals = S.blocks.filter(b => b.planId === plan.id && b.role === 'optional'); + const others = S.blocks.filter(b => b.planId === plan.id && !b.role); - if (!pc.length) { html += `
No courses in this plan
`; return; } - - // Courses by block - pb.forEach(blk => { - const bc = pc.filter(c => c.blockId === blk.id); - if (!bc.length) return; - const bm = blockMean(blk.id); - const bmOk = bm !== null && bm >= 4; - const bCr = bc.reduce((s, c) => s + (+c.credits || 0), 0); - - html += `
- ${esc(blk.name)} - ${bCr} cr.${bm !== null ? ` · avg ${bm.toFixed(2)} ${bmOk ? '✓' : '✗'}` : ''} + const pClr = planBaseColor(plan); + html += `
+
+
+ ${esc(plan.name)} + ${cycleBadge(plan.cycle)} + ${!creditOk ? WARN_SVG : ''} + + ${passCr}/${totCr}${minCr !== null ? '/' + minCr : ''} cr. + +
+
+ + +
`; - html += coursesMiniTable(bc); - }); + + // Major section + html += roleGroupSection(plan.id, 'Major', 'major', majors, pc, pClr); + + // Minor section + html += roleGroupSection(plan.id, 'Minor', 'minor', minors, pc, pClr); + + // Optional Studies section (only when enabled in settings) + if (settings.showOptional) { + html += roleGroupSection(plan.id, 'Optional Studies', 'optional', optionals, pc, pClr); + } + + // Legacy blocks (no role) + others.forEach(blk => { html += planBlockSection(plan.id, blk, '', pc); }); // Courses without a block const nbc = pc.filter(c => !c.blockId); if (nbc.length) { - html += `
No block
`; - html += coursesMiniTable(nbc); + html += `
No block
`; + html += coursesMgmtList(nbc); } - html += `
`; + html += `
+ +
`; }); - // Unplanned courses const up = S.courses.filter(c => !c.planId); if (up.length) { - html += `
No Study Plan
`; - html += coursesMiniTable(up, true); - html += `
`; + html += `
+
Without plan
+ ${coursesMgmtList(up)} +
`; } return html; } +function planBlockSection(planId, blk, roleLabel, allPlanCourses) { + const bc = allPlanCourses.filter(c => c.blockId === blk.id); + const bm = blockMean(blk.id); + const bCr = bc.reduce((s, c) => s + (+c.credits || 0), 0); + const bClr = blockEffectiveColor(blk); + const sbs = S.subBlocks.filter(sb => sb.blockId === blk.id); + const freeC = bc.filter(c => !c.subBlockId); + const roleTxt = roleLabel + ? `${roleLabel}` + : ''; + let html = `
+
+ ${roleTxt}${esc(blk.name)} +
+ ${bCr} cr.${bm !== null ? ` · avg ${bm.toFixed(2)}` : ''} + + +
+
`; + sbs.forEach(sb => { + const sbC = bc.filter(c => c.subBlockId === sb.id); + html += planSubBlockSection(planId, blk.id, sb, sbC, bClr); + }); + if (freeC.length) { + html += `
+ ${sbs.length ? `
Free courses
` : ''} + ${coursesMgmtList(freeC, false, bClr)} +
`; + } else if (!sbs.length) { + html += `
No courses yet
`; + } + html += `
+ + +
+
`; + return html; +} + +function planSubBlockSection(planId, blockId, sb, courses, parentColor) { + const sbCr = courses.reduce((s, c) => s + (+c.credits || 0), 0); + const sbClr = subBlockEffectiveColor(sb); + return `
+
+ ${esc(sb.name)} +
+ ${sbCr} cr. + + +
+
+ ${courses.length ? coursesMgmtList(courses, false, sbClr) : '
No courses yet
'} + +
`; +} + +const CAL_SVG = ``; + function dashSemesters() { - if (!S.semesters.length) return emptyState('📅', 'No semesters yet.
Create one in the Semesters tab!'); + if (!S.semesters.length) return emptyState('📅', 'No semesters yet.
Tap + to create one!'); let html = ''; S.semesters.forEach(sem => { - const sc = S.courses.filter(c => c.semesterId === sem.id); + const sc = S.courses.filter(c => c.semesterId === sem.id); const totCr = sc.reduce((s, c) => s + (+c.credits || 0), 0); const passCr = sc.filter(c => status(c) === 'pass').reduce((s, c) => s + (+c.credits || 0), 0); const graded = sc.filter(c => !c.passFail && c.grade != null && c.grade !== ''); @@ -682,41 +966,63 @@ function dashSemesters() { const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0); const mean = sumC > 0 ? sumW / sumC : null; - html += `
- ${esc(sem.name)} - ${passCr} / ${totCr} cr. -
`; - - if (mean !== null) { - html += `
- Avg: ${mean.toFixed(2)} - ${sc.filter(c => status(c) === 'pass').length} / ${sc.length} passed + html += `
+
+
+
${esc(sem.name)}
+
${esc(sem.season)} · ${sem.year}${mean !== null ? ` · avg ${mean.toFixed(2)}` : ''}
+
+
+ ${passCr}/${totCr} cr. + + + +
`; - } - if (!sc.length) { html += `
No courses this semester
`; } - else html += coursesMiniTable(sc, true); + if (!sc.length) { html += `
No courses this semester
`; } + else html += coursesMgmtList(sc, true); - html += `
`; + html += ` +
`; }); return html; } -function coursesMiniTable(courses, showMeta) { - let rows = ''; - courses.forEach(c => { +function coursesMgmtList(courses, showPlan, inheritColor) { + if (!courses.length) return '
No courses yet
'; + return courses.map(c => { const st = status(c); - const meta = showMeta - ? `${c.planId ? planName(c.planId) + (c.blockId ? ' › ' + blockName(c.blockId) : '') : ''}` + const meta = [ + c.credits ? c.credits + ' cr.' : null, + !c.semesterId ? 'no semester' + NOSEM_SVG + '' : semName(c.semesterId), + showPlan && c.planId ? planName(c.planId) + (c.blockId ? ' › ' + blockName(c.blockId) : '') + (c.subBlockId ? ' › ' + subBlockName(c.subBlockId) : '') : null, + ].filter(Boolean).join(' · '); + const slotsStr = c.slots && c.slots.length + ? c.slots.map(s => WK_DAYS[s.day - 1] + ' ' + toHHMM(s.startMin) + '–' + toHHMM(s.endMin)).join(' · ') : ''; - rows += ` -
${esc(c.name)}
${meta ? `
${meta}
` : ''} - ${c.credits || '—'} - ${fmtGrade(c.grade, c.passFail)} - ${badge(st)} - `; - }); - return `
${rows}
CourseCr.GradeStatus
`; + // Resolve block color: explicit arg > look up block > none + const blk = !inheritColor && c.blockId ? S.blocks.find(b => b.id === c.blockId) : null; + const bClr = inheritColor || (blk ? blockEffectiveColor(blk) : null); + const borderStyle = bClr ? `border-left:3px solid ${bClr};padding-left:6px;` : ''; + return `
+
+
${esc(c.name)}
+
${meta}
+ ${slotsStr ? `
${slotsStr}
` : ''} +
+
+ ${badge(st)} + ${fmtGrade(c.grade, c.passFail)} + + +
+
`; + }).join(''); +} + +function showCourseModalPrefilled(planId, blockId, semId, subBlockId = '') { + showCourseModal(null, { planId, blockId, semId, subBlockId }); } // ══════════════════════════════════════════════════════ @@ -726,42 +1032,62 @@ function renderCourses() { const el = document.getElementById('courses-content'); if (!S.courses.length) { el.innerHTML = emptyState('📖', 'No courses yet.
Tap + to add one!'); return; } - // Group by semester - const bySem = {}; - const noSem = []; - S.courses.forEach(c => { - if (c.semesterId) { (bySem[c.semesterId] = bySem[c.semesterId] || []).push(c); } - else noSem.push(c); - }); - let html = ''; - S.semesters.forEach(sem => { - const list = bySem[sem.id]; - if (!list || !list.length) return; - html += ``; - list.forEach(c => html += courseCard(c)); - }); - if (noSem.length) { - html += ``; - noSem.forEach(c => html += courseCard(c)); + + if (courseGroup === 'semester') { + const bySem = {}; + const noSem = []; + S.courses.forEach(c => { + if (c.semesterId) { (bySem[c.semesterId] = bySem[c.semesterId] || []).push(c); } + else noSem.push(c); + }); + S.semesters.forEach(sem => { + const list = bySem[sem.id]; + if (!list || !list.length) return; + html += ``; + list.forEach(c => html += courseCard(c)); + }); + if (noSem.length) { + html += ``; + noSem.forEach(c => html += courseCard(c)); + } + } else { + // Group by plan, then show semester info per course + S.studyPlans.forEach(plan => { + const pc = S.courses.filter(c => c.planId === plan.id); + if (!pc.length) return; + html += ``; + pc.forEach(c => html += courseCard(c, true)); + }); + const unplanned = S.courses.filter(c => !c.planId); + if (unplanned.length) { + html += ``; + unplanned.forEach(c => html += courseCard(c, true)); + } } + el.innerHTML = html; + initDrag(el); } -function courseCard(c) { +function courseCard(c, showSem) { const st = status(c); - const meta = [ + const metaParts = [ c.credits ? c.credits + ' cr.' : null, - c.planId ? planName(c.planId) + (c.blockId ? ' › ' + blockName(c.blockId) : '') : null, - ].filter(Boolean).join(' · '); + showSem && c.semesterId ? semName(c.semesterId) : null, + !showSem && c.planId ? planName(c.planId) + (c.blockId ? ' › ' + blockName(c.blockId) : '') : null, + ].filter(Boolean); - return `
+ return `
+
+ +
${esc(c.name)} ${badge(st)}
-
${meta || '—'}
+
${metaParts.join(' · ') || '—'}
${fmtGrade(c.grade)} @@ -774,81 +1100,394 @@ function courseCard(c) { } // ══════════════════════════════════════════════════════ -// PLANS PAGE +// DRAG AND DROP (courses) // ══════════════════════════════════════════════════════ -function renderPlans() { - const el = document.getElementById('plans-content'); - if (!S.studyPlans.length) { el.innerHTML = emptyState('📋', 'No study plans yet.
Tap + to create one!'); return; } +let dragSrcId = null; +let dragOverId = null; +// Touch drag state +let td = { active: false, id: null, clone: null, srcEl: null, overId: null, startY: 0, startTop: 0 }; - let html = ''; - S.studyPlans.forEach(plan => { - const pb = S.blocks.filter(b => b.planId === plan.id); - const pc = S.courses.filter(c => c.planId === plan.id); +function initDrag(container) { + // ── HTML5 drag (desktop) ── + container.addEventListener('dragstart', e => { + const card = e.target.closest('[data-id]'); + if (!card) { e.preventDefault(); return; } + // Only allow drag from handle + if (!e.target.closest('.drag-handle')) { e.preventDefault(); return; } + dragSrcId = card.dataset.id; + setTimeout(() => card.classList.add('dragging'), 0); + e.dataTransfer.effectAllowed = 'move'; + }); - html += `
-
- ${esc(plan.name)} -
- ${pc.length} courses - - -
-
`; + container.addEventListener('dragend', () => { + container.querySelectorAll('.course-card').forEach(el => el.classList.remove('dragging', 'drag-over')); + dragSrcId = dragOverId = null; + }); - if (pb.length) { - pb.forEach(blk => { - const bc = S.courses.filter(c => c.blockId === blk.id); - const bm = blockMean(blk.id); - html += `
-
-
${esc(blk.name)}
-
${bc.length} courses${bm !== null ? ' · avg ' + bm.toFixed(2) : ''}
-
-
- - -
-
`; - }); - } else { - html += `
No blocks yet.
`; + container.addEventListener('dragover', e => { + e.preventDefault(); + const card = e.target.closest('[data-id]'); + if (!card || card.dataset.id === dragSrcId) return; + container.querySelectorAll('.course-card').forEach(el => el.classList.remove('drag-over')); + card.classList.add('drag-over'); + dragOverId = card.dataset.id; + }); + + container.addEventListener('drop', e => { + e.preventDefault(); + if (dragSrcId && dragOverId && dragSrcId !== dragOverId) reorderCourse(dragSrcId, dragOverId); + container.querySelectorAll('.course-card').forEach(el => el.classList.remove('dragging', 'drag-over')); + dragSrcId = dragOverId = null; + }); + + // ── Touch drag (mobile) ── + container.addEventListener('touchstart', e => { + const handle = e.target.closest('.drag-handle'); + if (!handle) return; + const card = handle.closest('[data-id]'); + if (!card) return; + e.preventDefault(); // prevent scroll when starting from handle + const touch = e.touches[0]; + td = { + active: false, + id: card.dataset.id, + clone: null, + srcEl: card, + overId: null, + startY: touch.clientY, + startTop: card.getBoundingClientRect().top + }; + }, { passive: false }); + + document.addEventListener('touchmove', e => { + if (!td.id) return; + const touch = e.touches[0]; + const dy = touch.clientY - td.startY; + + if (!td.active && Math.abs(dy) > 6) { + td.active = true; + const rect = td.srcEl.getBoundingClientRect(); + td.clone = td.srcEl.cloneNode(true); + td.clone.style.cssText = `position:fixed;z-index:9999;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;opacity:0.85;pointer-events:none;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,0.18);`; + document.body.appendChild(td.clone); + td.srcEl.classList.add('dragging'); } - html += ` -
`; + if (td.active && td.clone) { + e.preventDefault(); + td.clone.style.top = (td.startTop + dy) + 'px'; + + td.clone.style.display = 'none'; + const el = document.elementFromPoint(touch.clientX, touch.clientY); + td.clone.style.display = ''; + const card = el && el.closest('[data-id]'); + container.querySelectorAll('.course-card').forEach(c => c.classList.remove('drag-over')); + if (card && card.dataset.id !== td.id) { + card.classList.add('drag-over'); + td.overId = card.dataset.id; + } else { + td.overId = null; + } + } + }, { passive: false }); + + document.addEventListener('touchend', () => { + if (!td.id) return; + if (td.active && td.overId) reorderCourse(td.id, td.overId); + if (td.clone) td.clone.remove(); + if (td.srcEl) td.srcEl.classList.remove('dragging'); + container.querySelectorAll('.course-card').forEach(c => c.classList.remove('drag-over')); + td = { active: false, id: null, clone: null, srcEl: null, overId: null, startY: 0, startTop: 0 }; }); - el.innerHTML = html; +} + +function reorderCourse(srcId, targetId) { + const si = S.courses.findIndex(c => c.id === srcId); + const ti = S.courses.findIndex(c => c.id === targetId); + if (si < 0 || ti < 0) return; + const [removed] = S.courses.splice(si, 1); + S.courses.splice(ti, 0, removed); + save(); + render(page); } // ══════════════════════════════════════════════════════ -// SEMESTERS PAGE +// WEEK CALENDAR // ══════════════════════════════════════════════════════ -function renderSemesters() { - const el = document.getElementById('semesters-content'); - if (!S.semesters.length) { el.innerHTML = emptyState('📅', 'No semesters yet.
Tap + to create one!'); return; } +const WK_START = 7 * 60; // 07:00 +const WK_END = 21 * 60; // 21:00 +const PX_PER_MIN = 1.4; +const WK_DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; +const EV_COLORS = ['#4F46E5','#059669','#DC2626','#D97706','#7C3AED','#0891B2','#DB2777','#475569']; - let html = ''; - S.semesters.forEach(sem => { - const sc = S.courses.filter(c => c.semesterId === sem.id); - const totCr = sc.reduce((s, c) => s + (+c.credits || 0), 0); - const passCr = sc.filter(c => status(c) === 'pass').reduce((s, c) => s + (+c.credits || 0), 0); +function toHHMM(min) { + return `${Math.floor(min / 60).toString().padStart(2,'0')}:${(min % 60).toString().padStart(2,'0')}`; +} +function fromHHMM(str) { + const [h, m] = str.split(':').map(Number); + return h * 60 + (m || 0); +} - html += `
-
-
-
${esc(sem.name)}
-
${esc(sem.season)} · ${esc(String(sem.year))}
-
-
- ${passCr}/${totCr} cr. - - -
+function showWeekCalendar(semId) { + const sem = S.semesters.find(x => x.id === semId); + if (!sem) return; + openModal(` +
+ + + +
+
+
${renderWeekGrid(semId)}
+
+
+ +
+
`); + // Scroll to 8:00 + const sc = document.getElementById('wk-scroll'); + if (sc) sc.scrollTop = (8 * 60 - WK_START) * PX_PER_MIN; +} + +function courseSlotColor(courseId) { + const c = S.courses.find(x => x.id === courseId); + if (c) { + if (c.blockId) { const blk = S.blocks.find(b => b.id === c.blockId); if (blk) return blockEffectiveColor(blk); } + if (c.planId) { const plan = S.studyPlans.find(p => p.id === c.planId); if (plan) return planBaseColor(plan); } + } + let h = 0; + for (let i = 0; i < courseId.length; i++) h = (h * 31 + courseId.charCodeAt(i)) | 0; + return EV_COLORS[Math.abs(h) % EV_COLORS.length]; +} + +function renderWeekGrid(semId) { + // Manual events + course-slot events merged + const events = [...S.schedule.filter(e => e.semesterId === semId)]; + S.courses.filter(c => c.semesterId === semId && c.slots && c.slots.length).forEach(c => { + c.slots.forEach(slot => events.push({ + id: `__c_${c.id}_${slot.day}_${slot.startMin}`, + title: c.name, + day: slot.day, + startMin: slot.startMin, + endMin: slot.endMin, + color: courseSlotColor(c.id), + _courseId: c.id + })); }); - el.innerHTML = html; + const totalH = (WK_END - WK_START) * PX_PER_MIN; + + // Time labels + let timeLbls = ''; + for (let m = WK_START; m <= WK_END; m += 60) { + const top = (m - WK_START) * PX_PER_MIN; + timeLbls += `
${toHHMM(m)}
`; + } + + // Gridlines (every 30 min, hour ones darker) + let gridLines = ''; + for (let m = WK_START; m <= WK_END; m += 30) { + const top = (m - WK_START) * PX_PER_MIN; + gridLines += `
`; + } + + // Day columns + const dayCols = WK_DAYS.map((_, di) => { + const dayNum = di + 1; + const dayEvents = events.filter(e => e.day === dayNum); + const evHtml = dayEvents.map(ev => { + const t = Math.max(ev.startMin, WK_START); + const b = Math.min(ev.endMin, WK_END); + if (t >= b) return ''; + const top = (t - WK_START) * PX_PER_MIN; + const height = Math.max((b - t) * PX_PER_MIN, 18); + const loc = ev.location ? `
${esc(ev.location)}
` : ''; + const onclick = ev._courseId + ? `showCourseModal('${ev._courseId}')` + : `showEventModal('${ev.semesterId}','${ev.id}')`; + return `
+ ${esc(ev.title)}${height > 30 ? `
${toHHMM(ev.startMin)}${loc}` : ''} +
`; + }).join(''); + return `
${gridLines}${evHtml}
`; + }).join(''); + + const dayHeaders = WK_DAYS.map(d => `
${d}
`).join(''); + + return `
+
${dayHeaders} +
+
+
${timeLbls}
+
${dayCols}
+
`; +} + +function showEventModal(semId, id) { + const ev = id ? S.schedule.find(x => x.id === id) : null; + const defClr = ev ? ev.color : EV_COLORS[S.schedule.filter(e => e.semesterId === semId).length % EV_COLORS.length]; + const dayOpts = WK_DAYS.map((d, i) => + `` + ).join(''); + const swatches = EV_COLORS.map(c => + `` + ).join(''); + + openModal(``); +} + +function selectEvColor(color) { + const inp = document.getElementById('f-ev-color'); + if (inp) inp.value = color; + document.querySelectorAll('.ev-swatch').forEach(s => { + const sel = s.dataset.color === color; + s.style.outline = sel ? '2px solid ' + color : 'none'; + s.style.outlineOffset = sel ? '2px' : '0'; + }); +} + +function saveEvent(semId, id) { + const title = document.getElementById('f-ev-title').value.trim(); + if (!title) { alert('Please enter a title.'); return; } + const day = parseInt(document.getElementById('f-ev-day').value); + const loc = document.getElementById('f-ev-loc').value.trim(); + const start = document.getElementById('f-ev-start').value; + const end = document.getElementById('f-ev-end').value; + const color = document.getElementById('f-ev-color').value || EV_COLORS[0]; + const startMin = fromHHMM(start); + const endMin = fromHHMM(end); + if (endMin <= startMin) { alert('End time must be after start time.'); return; } + if (id) { + const i = S.schedule.findIndex(x => x.id === id); + if (i >= 0) S.schedule[i] = { ...S.schedule[i], title, day, startMin, endMin, color, location: loc, semesterId: semId }; + } else { + S.schedule.push({ id: uid(), title, day, startMin, endMin, color, location: loc, semesterId: semId }); + } + save(); showWeekCalendar(semId); +} + +function deleteEvent(id, semId) { + if (!confirm('Delete this event?')) return; + S.schedule = S.schedule.filter(e => e.id !== id); + save(); showWeekCalendar(semId); +} + +function importICS(input, semId) { + const file = input.files && input.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = function(e) { + const events = parseICS(e.target.result, semId); + if (!events.length) { alert('No weekly events found in this ICS file.'); return; } + if (!confirm(`Import ${events.length} event(s) into this week schedule?`)) return; + events.forEach(ev => S.schedule.push(ev)); + save(); showWeekCalendar(semId); + }; + reader.readAsText(file); + input.value = ''; +} + +function parseICS(text, semId) { + const events = []; + const DAY_MAP = { MO:1, TU:2, WE:3, TH:4, FR:5, SA:6, SU:7 }; + const blocks = text.split(/BEGIN:VEVENT/i).slice(1); + + blocks.forEach(block => { + const endIdx = block.search(/END:VEVENT/i); + if (endIdx < 0) return; + // Unfold continuation lines + const unfolded = block.slice(0, endIdx).replace(/\r?\n[ \t]/g, ''); + const props = {}; + unfolded.split(/\r?\n/).forEach(line => { + const ci = line.indexOf(':'); + if (ci < 0) return; + const key = line.slice(0, ci).split(';')[0].trim().toUpperCase(); + props[key] = line.slice(ci + 1).trim(); + }); + + const summary = props['SUMMARY']; + const dtstart = props['DTSTART']; + const dtend = props['DTEND']; + const rrule = props['RRULE']; + if (!summary || !dtstart) return; + + const tsm = dtstart.match(/T(\d{2})(\d{2})/); + const tem = dtend ? dtend.match(/T(\d{2})(\d{2})/) : null; + if (!tsm) return; + const startMin = +tsm[1] * 60 + +tsm[2]; + const endMin = tem ? +tem[1] * 60 + +tem[2] : startMin + 60; + + let days = []; + if (rrule) { + const rm = rrule.match(/BYDAY=([A-Z,]+)/i); + if (rm) days = rm[1].split(',').map(d => DAY_MAP[d.trim().toUpperCase()]).filter(Boolean); + } + if (!days.length) { + const dm = dtstart.match(/^(\d{4})(\d{2})(\d{2})/); + if (dm) { + const dow = new Date(+dm[1], +dm[2]-1, +dm[3]).getDay(); + days = [dow === 0 ? 7 : dow]; + } + } + const clr = EV_COLORS[events.length % EV_COLORS.length]; + const loc = props['LOCATION'] || ''; + days.forEach(day => events.push({ + id: uid(), title: summary.replace(/\\n/g,' ').replace(/\\,/g,','), + day, startMin, endMin, color: clr, location: loc, semesterId: semId + })); + }); + return events; } // ══════════════════════════════════════════════════════ @@ -864,21 +1503,54 @@ function overlayClick(e) { if (e.target.id === 'overlay') closeModal(); } // ══════════════════════════════════════════════════════ // COURSE MODAL // ══════════════════════════════════════════════════════ -function showCourseModal(id) { +let _slots = []; // working slot list while course modal is open + +function renderSlotsUI() { + const chips = _slots.map((s, i) => { + const d = WK_DAYS[s.day - 1]; + return ` + ${d} ${toHHMM(s.startMin)}–${toHHMM(s.endMin)} + + `; + }).join(''); + return chips || `
No schedule yet
`; +} + +function addSlot() { + const day = parseInt(document.getElementById('f-slot-day').value); + const startMin = fromHHMM(document.getElementById('f-slot-start').value); + const endMin = fromHHMM(document.getElementById('f-slot-end').value); + if (endMin <= startMin) { alert('End must be after start.'); return; } + _slots.push({ day, startMin, endMin }); + const el = document.getElementById('slots-list'); + if (el) el.innerHTML = renderSlotsUI(); +} + +function removeSlot(i) { + _slots.splice(i, 1); + const el = document.getElementById('slots-list'); + if (el) el.innerHTML = renderSlotsUI(); +} + +function showCourseModal(id, prefill = {}) { const c = id ? S.courses.find(x => x.id === id) : null; + const curPlan = c ? (c.planId || '') : (prefill.planId || ''); + const curBlock = c ? (c.blockId || '') : (prefill.blockId || ''); + const curSubBlock = c ? (c.subBlockId || '') : (prefill.subBlockId || ''); + const curSem = c ? (c.semesterId || '') : (prefill.semId || ''); + const semOpts = S.semesters.map(s => - `` + `` ).join(''); const planOpts = `` + S.studyPlans.map(p => - `` + `` ).join(''); - const curPlan = c ? (c.planId || '') : ''; - const curBlock = c ? (c.blockId || '') : ''; const blockOpts = buildBlockOpts(curPlan, curBlock); const isPF = c ? !!c.passFail : false; + _slots = c && c.slots ? c.slots.map(s => ({ ...s })) : []; openModal(`