From 1fb32153e3c87168cbd7c8c47004436eebd0c5b1 Mon Sep 17 00:00:00 2001 From: MatsuneMikuroi Date: Thu, 2 Jul 2026 14:34:13 +0200 Subject: [PATCH] newest validation check --- index.html | 905 +++++++++++++++++++++++++++++++++++++++++++---------- sw.js | 2 +- 2 files changed, 733 insertions(+), 174 deletions(-) diff --git a/index.html b/index.html index 82dc1c2..6ad3399 100644 --- a/index.html +++ b/index.html @@ -178,12 +178,14 @@ white-space: nowrap; } .badge-pass { background: var(--success-light); color: #065F46; } + .badge-comp { background: var(--danger-light); color: #991B1B; } .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-comp { background: #450a0a; color: #fca5a5; } :root.dark .badge-fail { background: #450a0a; color: #fca5a5; } :root.dark .badge-prog { background: #451a03; color: #fcd34d; } :root.dark .badge-info { background: #1e1b4b; color: #a5b4fc; } @@ -371,6 +373,14 @@ } .switch input:checked ~ .switch-thumb { transform: translateX(18px); } + /* ── Pass/Fail segmented control ── */ + .pf-seg { display:flex; border:1.5px solid var(--gray-200); border-radius:10px; overflow:hidden; margin-bottom:14px; } + .pf-seg-btn { flex:1; padding:9px 4px; font-size:13px; font-weight:500; background:var(--bg-surface); border:none; cursor:pointer; color:var(--gray-500); transition:background .15s,color .15s; } + .pf-seg-btn:not(:last-child) { border-right:1.5px solid var(--gray-200); } + .pf-seg-btn.seg-default { background:var(--gray-100); color:var(--gray-800); } + .pf-seg-btn.seg-passed { background:var(--success-light); color:#065F46; } + .pf-seg-btn.seg-failed { background:var(--danger-light); color:var(--danger); } + /* ── File input ── */ .file-input-label { display: flex; align-items: center; justify-content: center; gap: 8px; @@ -411,7 +421,7 @@ .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-scroll { overflow-y: auto; flex: 1; min-height: 0; } .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; } @@ -485,6 +495,10 @@ let S = { studyPlans: [], blocks: [], subBlocks: [], semesters: [], courses: [], let settings = { minCreditsBachelor: 180, minCreditsMaster: 120, allSeasons: false, showOptional: false }; let page = 'dashboard'; let dashView = 'plans'; +let collapsed = {}; +let editModeBlock = null; +let _syncConflicts = []; +let _syncMergedBase = null; function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } @@ -548,18 +562,22 @@ function blockMean(blockId) { } function status(course) { - if (course.passFail) return 'pass'; + if (course.passFail === 'passed' || course.passFail === true) return 'pass'; + if (course.passFail === 'failed') return 'fail'; if (course.grade == null || course.grade === '') return 'prog'; if (+course.grade >= 4) return 'pass'; if (course.blockId) { const m = blockMean(course.blockId); - if (m !== null && m >= 4) return 'pass'; + if (m !== null && m >= 4) return 'comp'; // compensation pass } return 'fail'; } +function isPass(st) { return st === 'pass' || st === 'comp'; } + function badge(st) { if (st === 'pass') return 'Passed'; + if (st === 'comp') return 'Passed'; if (st === 'fail') return 'Failed'; return 'In progress'; } @@ -570,7 +588,8 @@ function gradeClass(g) { } function fmtGrade(g, passFail) { - if (passFail) return 'P/F'; + if (passFail === 'passed' || passFail === true) return 'Pass'; + if (passFail === 'failed') return 'Fail'; if (g == null || g === '') return '—'; const n = +g; return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); @@ -617,8 +636,8 @@ function render(p) { function updateHeaderSub() { const total = S.courses.length; - const passed = S.courses.filter(c => status(c) === 'pass').length; - const credits = S.courses.filter(c => status(c) === 'pass').reduce((s, c) => s + (+c.credits || 0), 0); + const passed = S.courses.filter(c => isPass(status(c))).length; + const credits = S.courses.filter(c => isPass(status(c))).reduce((s, c) => s + (+c.credits || 0), 0); document.getElementById('header-sub').textContent = total ? `${credits} credits earned · ${passed}/${total} courses passed` : 'No courses yet'; } @@ -747,13 +766,13 @@ function renderDash() { 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 passed = all.filter(c => isPass(status(c))); 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 passedGraded = graded.filter(c => isPass(status(c))).length; const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null; document.getElementById('dash-stats').innerHTML = `
@@ -767,13 +786,13 @@ function renderDashStats() { function dashGlobal() { 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 passed = all.filter(c => isPass(status(c))); 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 passedGraded = graded.filter(c => isPass(status(c))).length; const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null; let html = `
@@ -804,6 +823,62 @@ function dashGlobal() { const WARN_SVG = ``; const NOSEM_SVG = ``; const GEAR_SVG = ``; +const CHEV_SVG = ``; + +function chevBtn(key) { + const rot = collapsed[key] ? 'rotate(-90deg)' : 'rotate(0deg)'; + return ``; +} + +function toggleCollapse(key) { collapsed[key] = !collapsed[key]; renderDash(); } + +function toggleEditMode(blockId) { + editModeBlock = (editModeBlock === blockId) ? null : blockId; + renderDash(); +} + +function planDisplayName(plan) { + const major = S.blocks.find(b => b.planId === plan.id && b.role === 'major'); + const minors = S.blocks.filter(b => b.planId === plan.id && b.role === 'minor'); + const blockCr = (blk) => S.courses.filter(c => c.blockId === blk.id).reduce((s,c) => s + (+c.credits||0), 0); + const parts = []; + if (major) parts.push(`${esc(major.name)} ${blockCr(major)} cr.`); + minors.forEach(m => parts.push(`${esc(m.name)} ${blockCr(m)} cr.`)); + return parts.length ? parts.join(' · ') : esc(plan.name); +} + +function moveCourse(id, dir) { + const c = S.courses.find(x => x.id === id); + if (!c) return; + const sibs = S.courses.map((x,i) => ({x,i})).filter(({x}) => x.blockId === (c.blockId||'') && (x.subBlockId||'') === (c.subBlockId||'')); + const pos = sibs.findIndex(({x}) => x.id === id); + const swap = dir === 'up' ? pos - 1 : pos + 1; + if (swap < 0 || swap >= sibs.length) return; + [S.courses[sibs[pos].i], S.courses[sibs[swap].i]] = [S.courses[sibs[swap].i], S.courses[sibs[pos].i]]; + save(); renderDash(); +} + +function moveSubBlock(id, dir) { + const sb = S.subBlocks.find(x => x.id === id); + if (!sb) return; + const sibs = S.subBlocks.map((x,i) => ({x,i})).filter(({x}) => x.blockId === sb.blockId); + const pos = sibs.findIndex(({x}) => x.id === id); + const swap = dir === 'up' ? pos - 1 : pos + 1; + if (swap < 0 || swap >= sibs.length) return; + [S.subBlocks[sibs[pos].i], S.subBlocks[sibs[swap].i]] = [S.subBlocks[sibs[swap].i], S.subBlocks[sibs[pos].i]]; + save(); renderDash(); +} + +function moveBlock(id, dir) { + const b = S.blocks.find(x => x.id === id); + if (!b) return; + const sibs = S.blocks.map((x,i) => ({x,i})).filter(({x}) => x.planId === b.planId && x.role === b.role); + const pos = sibs.findIndex(({x}) => x.id === id); + const swap = dir === 'up' ? pos - 1 : pos + 1; + if (swap < 0 || swap >= sibs.length) return; + [S.blocks[sibs[pos].i], S.blocks[sibs[swap].i]] = [S.blocks[sibs[swap].i], S.blocks[sibs[pos].i]]; + save(); renderDash(); +} function roleGroupSection(planId, label, role, blocks, allPlanCourses, planColor) { const headerColor = role === 'major' @@ -811,15 +886,20 @@ function roleGroupSection(planId, label, role, blocks, allPlanCourses, 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; + const colKey = `role_${planId}_${role}`; + const isCollapsed = collapsed[colKey]; let html = `
-
- ${label} +
+ ${chevBtn(colKey)} + ${label} ${canAdd ? `` : ''}
`; - if (blocks.length) { - blocks.forEach(blk => { html += planBlockSection(planId, blk, '', allPlanCourses); }); - } else { - html += `
No ${label.toLowerCase()} defined yet.
`; + if (!isCollapsed) { + if (blocks.length) { + blocks.forEach((blk, i) => { html += planBlockSection(planId, blk, allPlanCourses, blocks.length, i); }); + } else { + html += `
No ${label.toLowerCase()} defined yet.
`; + } } html += `
`; return html; @@ -830,11 +910,11 @@ function dashPlans() { let html = ''; S.studyPlans.forEach(plan => { - 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 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 => isPass(status(c))).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; const majors = S.blocks.filter(b => b.planId === plan.id && b.role === 'major'); @@ -842,11 +922,15 @@ function dashPlans() { 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); - const pClr = planBaseColor(plan); + const pClr = planBaseColor(plan); + const colKey = `plan_${plan.id}`; + const isCollapsed = collapsed[colKey]; + html += `
-
- ${esc(plan.name)} +
+ ${chevBtn(colKey)} + ${planDisplayName(plan)} ${cycleBadge(plan.cycle)} ${!creditOk ? WARN_SVG : ''} @@ -859,30 +943,20 @@ function dashPlans() {
`; - // 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); + if (!isCollapsed) { + html += roleGroupSection(plan.id, 'Major', 'major', majors, pc, pClr); + html += roleGroupSection(plan.id, 'Minor', 'minor', minors, pc, pClr); + if (settings.showOptional) { + html += roleGroupSection(plan.id, 'Optional Studies', 'optional', optionals, pc, pClr); + } + others.forEach(blk => { html += planBlockSection(plan.id, blk, pc, 1, 0); }); + const nbc = pc.filter(c => !c.blockId); + if (nbc.length) { + html += `
Unassigned courses
`; + html += coursesMgmtList(nbc); + } } - - // 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 += coursesMgmtList(nbc); - } - - html += `
- -
`; + html += `
`; }); const up = S.courses.filter(c => !c.planId); @@ -895,60 +969,85 @@ function dashPlans() { 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}` - : ''; +function planBlockSection(planId, blk, allPlanCourses, sibCount, sibIdx) { + 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 colKey = `block_${blk.id}`; + const isCollapsed = collapsed[colKey]; + const inEdit = editModeBlock === blk.id; + const failedCourses = bc.filter(c => c.passFail === 'failed').length; + const blockFailed = failedCourses > 1; + const blockIsPassed = blockPassedStatus(blk.id); + let html = `
-
- ${roleTxt}${esc(blk.name)} -
- ${bCr} cr.${bm !== null ? ` · avg ${bm.toFixed(2)}` : ''} - - -
+
+ ${chevBtn(colKey)} + ${esc(blk.name)}${blockFailed ? ` FAIL` : ''} + ${blockIsPassed + ? `✓ Validé` + : ``} + ${bCr} cr.${bm !== null ? ` · avg ${bm.toFixed(2)}` : ''} + ${inEdit && sibCount > 1 ? `` : ''} + + ${inEdit ? `` : ''}
`; - 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
`; + + if (!isCollapsed) { + sbs.forEach((sb, i) => { + const sbC = bc.filter(c => c.subBlockId === sb.id); + html += planSubBlockSection(planId, blk.id, sb, sbC, bClr, inEdit, sbs.length, i); + }); + if (freeC.length) { + html += `
+ ${sbs.length ? `
Free courses
` : ''} + ${coursesMgmtList(freeC, false, bClr, inEdit)} +
`; + } else if (!sbs.length && !inEdit) { + html += `
No courses yet
`; + } + if (inEdit) { + html += `
+ + +
`; + } } - html += `
- - -
-
`; + 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
'} - -
`; +function planSubBlockSection(planId, blockId, sb, courses, parentColor, inEdit, sibCount, sibIdx) { + const sbCr = courses.reduce((s, c) => s + (+c.credits || 0), 0); + const sbClr = subBlockEffectiveColor(sb); + const colKey = `sb_${sb.id}`; + const isCollapsed = collapsed[colKey]; + + let html = `
+
+ ${chevBtn(colKey)} + ${esc(sb.name)} + ${sb.passed + ? `✓ Validé` + : ``} + ${sbCr} cr. + ${inEdit && sibCount > 1 ? `` : ''} + ${inEdit ? `` : ''} +
`; + + if (!isCollapsed) { + html += courses.length + ? coursesMgmtList(courses, false, sbClr, inEdit) + : `
No courses yet
`; + if (inEdit) { + html += ``; + } + } + html += `
`; + return html; } const CAL_SVG = ``; @@ -960,7 +1059,7 @@ function dashSemesters() { 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); + const passCr = sc.filter(c => isPass(status(c))).reduce((s, c) => s + (+c.credits || 0), 0); const graded = sc.filter(c => !c.passFail && c.grade != null && c.grade !== ''); const sumW = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0); const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0); @@ -989,9 +1088,9 @@ function dashSemesters() { return html; } -function coursesMgmtList(courses, showPlan, inheritColor) { +function coursesMgmtList(courses, showPlan, inheritColor, reorderable) { if (!courses.length) return '
No courses yet
'; - return courses.map(c => { + return courses.map((c, idx) => { const st = status(c); const meta = [ c.credits ? c.credits + ' cr.' : null, @@ -1013,7 +1112,11 @@ function coursesMgmtList(courses, showPlan, inheritColor) {
${badge(st)} - ${fmtGrade(c.grade, c.passFail)} + ${c.attempts && c.attempts.length > 1 + ? c.attempts.map((a,i) => `${a.grade}${i < c.attempts.length-1 ? '' : ''}`).join('') + : `${fmtGrade(c.grade, c.passFail)}` + } + ${reorderable ? `` : ''}
@@ -1090,7 +1193,10 @@ function courseCard(c, showSem) {
${metaParts.join(' · ') || '—'}
- ${fmtGrade(c.grade)} + ${c.attempts && c.attempts.length > 1 + ? `
${c.attempts.map((a,i) => `${a.grade}${i < c.attempts.length-1 ? '' : ''}`).join('')}
` + : `${fmtGrade(c.grade)}` + }
@@ -1283,7 +1389,22 @@ function renderWeekGrid(semId) { _courseId: c.id })); }); - const totalH = (WK_END - WK_START) * PX_PER_MIN; + const totalH = (WK_END - WK_START) * PX_PER_MIN; + + if (!events.length) { + const semCourses = S.courses.filter(c => c.semesterId === semId); + const noSlotCount = semCourses.filter(c => !c.slots || !c.slots.length).length; + const hint = semCourses.length === 0 + ? 'No courses are assigned to this semester yet.' + : noSlotCount > 0 + ? `${semCourses.length} course${semCourses.length > 1 ? 's are' : ' is'} in this semester but ${noSlotCount} ${noSlotCount > 1 ? 'have' : 'has'} no schedule set. Open a course and add a time slot under "Weekly Schedule".` + : 'Add a manual event or import an ICS file.'; + return `
+
📅
+
No schedule yet
+
${hint}
+
`; + } // Time labels let timeLbls = ''; @@ -1503,7 +1624,49 @@ function overlayClick(e) { if (e.target.id === 'overlay') closeModal(); } // ══════════════════════════════════════════════════════ // COURSE MODAL // ══════════════════════════════════════════════════════ -let _slots = []; // working slot list while course modal is open +let _slots = []; // working slot list while course modal is open +let _attempts = []; // working attempt list while course modal is open + +function renderAttemptsUI() { + if (!_attempts.length) return `
No attempts logged yet
`; + return _attempts.map((a, i) => { + const sem = S.semesters.find(s => s.id === a.semesterId); + const gCls = +a.grade >= 4 ? 'var(--success)' : 'var(--danger)'; + const label = sem ? esc(sem.name) : ''; + return `
+ #${i+1} + ${a.grade} + ${+a.grade >= 4 + ? 'Passed' + : 'Failed'} + ${label ? `${label}` : ''} + +
`; + }).join(''); +} + +function logAttempt() { + const gEl = document.getElementById('f-att-grade'); + const sEl = document.getElementById('f-att-sem'); + const g = parseFloat(gEl?.value); + if (isNaN(g) || g < 1 || g > 6) { alert('Enter a valid grade between 1 and 6.'); return; } + if (_attempts.length >= 2 && !confirm('You already have 2 attempts logged. Add a third?')) return; + _attempts.push({ id: uid(), grade: g, semesterId: sEl?.value || '' }); + // propagate latest grade to the main grade field + const mainG = document.getElementById('f-grade'); + if (mainG) mainG.value = g; + if (gEl) gEl.value = ''; + const el = document.getElementById('attempts-list'); + if (el) el.innerHTML = renderAttemptsUI(); +} + +function removeAttempt(idx) { + _attempts.splice(idx, 1); + const mainG = document.getElementById('f-grade'); + if (mainG && _attempts.length) mainG.value = _attempts[_attempts.length - 1].grade; + const el = document.getElementById('attempts-list'); + if (el) el.innerHTML = renderAttemptsUI(); +} function renderSlotsUI() { const chips = _slots.map((s, i) => { @@ -1549,8 +1712,11 @@ function showCourseModal(id, prefill = {}) { ).join(''); const blockOpts = buildBlockOpts(curPlan, curBlock); - const isPF = c ? !!c.passFail : false; - _slots = c && c.slots ? c.slots.map(s => ({ ...s })) : []; + const pfRaw = c ? c.passFail : null; + const pfMode = pfRaw === 'passed' || pfRaw === true ? 'passed' : pfRaw === 'failed' ? 'failed' : 'default'; + _slots = c && c.slots ? c.slots.map(s => ({ ...s })) : []; + _attempts = c && c.attempts ? c.attempts.map(a => ({ ...a })) : []; + const semOptsAttempt = `` + S.semesters.map(s => ``).join(''); openModal(`