Files
CreditTracker/index.html
T

2876 lines
135 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Credit Tracker</title>
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#4F46E5">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Credits">
<link rel="apple-touch-icon" href="icon.svg">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #4F46E5;
--primary-light: #EEF2FF;
--success: #059669;
--success-light: #D1FAE5;
--danger: #DC2626;
--danger-light: #FEE2E2;
--warning: #D97706;
--warning-light: #FEF3C7;
--gray-50: #F9FAFB;
--gray-100: #F3F4F6;
--gray-200: #E5E7EB;
--gray-300: #D1D5DB;
--gray-400: #9CA3AF;
--gray-500: #6B7280;
--gray-600: #4B5563;
--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(--bg-body);
color: var(--gray-800);
min-height: 100vh;
padding-bottom: 20px;
transition: background 0.2s, color 0.2s;
}
/* ── Header ── */
.header {
background: var(--primary);
color: white;
padding: 14px 16px;
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
}
.header-sub { font-size: 11px; opacity: 0.75; margin-top: 1px; }
.header-right { display: flex; gap: 6px; align-items: center; }
/* ── Content ── */
.content { max-width: 680px; margin: 0 auto; padding: 14px 12px; }
.page { display: none; }
.page.active { display: block; }
/* ── FAB ── */
.fab {
position: fixed;
bottom: 82px; right: 16px;
width: 50px; height: 50px;
border-radius: 50%;
background: var(--primary);
color: white;
border: none;
cursor: pointer;
box-shadow: 0 4px 14px rgba(79,70,229,0.45);
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 300;
z-index: 99;
transition: transform 0.15s, box-shadow 0.15s;
-webkit-tap-highlight-color: transparent;
}
.fab:active { transform: scale(0.93); box-shadow: 0 2px 8px rgba(79,70,229,0.3); }
/* ── Cards ── */
.card {
background: var(--bg-surface);
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.05);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.card-title { font-size: 15px; font-weight: 600; color: var(--gray-700); }
/* ── Stats Grid ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.stat-card {
background: var(--bg-surface);
border-radius: 12px;
padding: 14px 12px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.07);
}
.stat-value { font-size: 26px; font-weight: 700; color: var(--primary); line-height: 1; }
.stat-label { font-size: 11px; color: var(--gray-500); margin-top: 4px; }
/* ── Tables ── */
.table-wrap { overflow-x: auto; border-radius: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
background: var(--gray-50);
padding: 8px 10px;
text-align: left;
font-weight: 600;
color: var(--gray-500);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--gray-200);
white-space: nowrap;
}
td {
padding: 9px 10px;
border-bottom: 1px solid var(--gray-100);
color: var(--gray-700);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr.clickable:hover td { background: var(--gray-50); cursor: pointer; }
/* ── Badges ── */
.badge {
display: inline-block;
padding: 2px 7px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
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; }
/* ── Buttons ── */
.btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 8px 14px;
border-radius: 8px; border: none;
cursor: pointer; font-size: 14px; font-weight: 500;
-webkit-tap-highlight-color: transparent;
transition: opacity 0.15s;
}
.btn:active { opacity: 0.75; }
.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-full { width: 100%; justify-content: center; }
/* ── Grade colors ── */
.g-high { color: var(--success); font-weight: 700; }
.g-mid { color: var(--warning); font-weight: 700; }
.g-low { color: var(--danger); font-weight: 700; }
/* ── Modal ── */
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.45);
z-index: 200;
display: flex; align-items: flex-end; justify-content: center;
}
.modal {
background: var(--bg-surface);
border-radius: 20px 20px 0 0;
padding: 20px 16px 28px;
width: 100%; max-width: 680px;
max-height: 92vh; overflow-y: auto;
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 18px;
}
.modal-title { font-size: 17px; font-weight: 700; color: var(--gray-800); }
.modal-close {
width: 30px; height: 30px; border-radius: 50%;
background: var(--gray-100); border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: var(--gray-500); font-size: 14px;
}
.modal-actions { display: flex; gap: 10px; margin-top: 12px; }
.modal-actions .btn { flex: 1; justify-content: center; }
.modal-actions .btn-primary { flex: 2; }
/* ── Forms ── */
.form-group { margin-bottom: 14px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
label { display: block; font-size: 12px; font-weight: 600; color: var(--gray-500); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.04em; }
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: var(--bg-surface);
-webkit-appearance: auto; appearance: auto;
transition: border-color 0.15s;
}
input:not([type="checkbox"]):focus, select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
input[type="checkbox"] {
width: 18px; height: 18px;
cursor: pointer; flex-shrink: 0;
accent-color: var(--primary);
}
/* ── List items ── */
.list-item {
display: flex; align-items: center; justify-content: space-between;
padding: 11px 0;
border-bottom: 1px solid var(--gray-100);
}
.list-item:last-child { border-bottom: none; }
.list-item-main { flex: 1; }
.list-item-title { font-size: 15px; font-weight: 500; color: var(--gray-800); }
.list-item-sub { font-size: 12px; color: var(--gray-400); margin-top: 2px; }
.list-item-actions { display: flex; gap: 5px; flex-shrink: 0; margin-left: 8px; }
/* ── Section headers ── */
.section-label {
font-size: 11px; font-weight: 700;
color: var(--gray-400); text-transform: uppercase; letter-spacing: 0.07em;
padding: 6px 0 4px; margin-top: 4px;
}
/* ── Sub-section header in plan cards ── */
.block-header {
display: flex; justify-content: space-between; align-items: center;
padding: 7px 0;
border-bottom: 1px solid var(--gray-100);
margin-top: 8px;
}
.block-title { font-size: 13px; font-weight: 600; color: var(--gray-600); }
.block-meta { font-size: 11px; color: var(--gray-400); }
/* ── Segmented control ── */
.seg-ctrl {
display: flex; background: var(--gray-200);
border-radius: 9px; padding: 3px;
margin-bottom: 14px;
}
.seg-btn {
flex: 1; padding: 6px 4px;
border: none; border-radius: 7px;
background: none; font-size: 13px; font-weight: 500;
color: var(--gray-500); cursor: pointer;
transition: all 0.15s;
-webkit-tap-highlight-color: transparent;
}
.seg-btn.active {
background: var(--bg-surface); color: var(--gray-800);
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
}
/* ── Empty state ── */
.empty {
text-align: center; padding: 48px 20px;
color: var(--gray-400);
}
.empty-icon { font-size: 44px; margin-bottom: 10px; }
.empty-text { font-size: 14px; line-height: 1.5; }
/* ── Header buttons ── */
.header-btn {
background: rgba(255,255,255,0.15); border: none; border-radius: 8px;
color: white; cursor: pointer; padding: 7px 9px;
display: flex; align-items: center; gap: 5px;
font-size: 12px; font-weight: 600;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s;
}
.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 {
display: flex; align-items: center; justify-content: space-between;
background: var(--gray-50); border: 1.5px solid var(--gray-200);
border-radius: 10px; padding: 11px 14px; cursor: pointer;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 14px;
}
.pf-toggle:has(input:checked) {
background: var(--success-light); border-color: var(--success);
}
.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 { position: relative; width: 44px; height: 26px; flex-shrink: 0; margin-left: 12px; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch-track {
position: absolute; inset: 0;
background: var(--gray-300); border-radius: 13px;
transition: background 0.2s;
}
.switch input:checked + .switch-track { background: var(--success); }
.switch-thumb {
position: absolute; top: 3px; left: 3px;
width: 20px; height: 20px; border-radius: 50%;
background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
transition: transform 0.2s;
}
.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;
width: 100%; padding: 10px 14px;
border: 2px dashed var(--gray-300); border-radius: 8px;
color: var(--gray-500); font-size: 14px; font-weight: 500;
cursor: pointer; background: var(--gray-50);
transition: border-color 0.15s, background 0.15s;
}
.file-input-label:hover { border-color: var(--primary); background: var(--primary-light); color: var(--primary); }
#f-import { display: none; }
/* ── Course card ── */
.course-card {
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-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; 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; }
.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;
}
</style>
</head>
<body>
<div class="header">
<div>
<div style="font-size:19px;font-weight:700;letter-spacing:-0.3px">📚 Credit Tracker</div>
<div class="header-sub" id="header-sub">Loading…</div>
</div>
<div class="header-right">
<button class="theme-btn" id="theme-btn" onclick="toggleTheme()" title="Toggle dark mode">
<svg id="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg id="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
</button>
<button class="header-btn" onclick="showDataModal()" title="Export / Import data">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
</button>
<button class="header-btn" onclick="showSettingsModal()" title="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
</div>
<div class="content" id="content">
<!-- Dashboard -->
<div class="page active" id="page-dashboard">
<div id="dash-stats"></div>
<div class="seg-ctrl" id="dash-seg">
<button class="seg-btn active" onclick="setDashView('plans')">Plans</button>
<button class="seg-btn" onclick="setDashView('semesters')">Semesters</button>
</div>
<div id="dash-content"></div>
</div>
</div>
<button class="fab" id="fab" onclick="onFab()"></button>
<div id="modal-root"></div>
<script>
// ══════════════════════════════════════════════════════
// STATE
// ══════════════════════════════════════════════════════
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 = '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); }
function load() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (raw) {
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() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(S)); }
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) {
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);
const sumCoef = graded.reduce((s, c) => s + (+c.credits || 0), 0);
if (sumCoef === 0) return null;
return sumWeighted / sumCoef;
}
function status(course) {
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 'comp'; // compensation pass
}
return 'fail';
}
function isPass(st) { return st === 'pass' || st === 'comp'; }
function badge(st) {
if (st === 'pass') return '<span class="badge badge-pass">Passed</span>';
if (st === 'comp') return '<span class="badge badge-comp">Passed</span>';
if (st === 'fail') return '<span class="badge badge-fail">Failed</span>';
return '<span class="badge badge-prog">In progress</span>';
}
function gradeClass(g) {
if (g == null || g === '') return '';
return +g >= 5 ? 'g-high' : +g >= 4 ? 'g-mid' : 'g-low';
}
function fmtGrade(g, passFail) {
if (passFail === 'passed' || passFail === true) return '<span style="font-size:11px;font-weight:600;color:var(--success)">Pass</span>';
if (passFail === 'failed') return '<span style="font-size:11px;font-weight:600;color:var(--danger)">Fail</span>';
if (g == null || g === '') return '—';
const n = +g;
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 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 `<span class="badge badge-cycle">${labels[cycle] || cycle}</span>`;
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ══════════════════════════════════════════════════════
// NAVIGATION
// ══════════════════════════════════════════════════════
function go(p) {
document.querySelectorAll('.page').forEach(el => el.classList.remove('active'));
const pg = document.getElementById('page-' + p);
if (pg) pg.classList.add('active');
page = p;
render(p);
}
function setDashView(v) {
dashView = v;
const btns = document.querySelectorAll('#dash-seg .seg-btn');
['plans','semesters'].forEach((n, i) => btns[i].classList.toggle('active', n === v));
renderDash();
}
function render(p) {
if (p === 'dashboard') renderDash();
updateHeaderSub();
}
function updateHeaderSub() {
const total = S.courses.length;
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';
}
function onFab() {
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 += `<span class="ev-swatch" data-color="" onclick="${onClickFn}('')"
style="background:${autoColor};outline:${isSel ? '2px solid '+autoColor+';outline-offset:2px' : 'none'};
border:2px dashed rgba(255,255,255,0.6)" title="Auto (plan family)"></span>`;
}
html += palette.map(c => {
const isSel = c === selected;
return `<span class="ev-swatch" data-color="${c}" onclick="${onClickFn}('${c}')"
style="background:${c};outline:${isSel ? '2px solid '+c+';outline-offset:2px' : 'none'}"></span>`;
}).join('');
return `<div id="cp-swatches" style="display:flex;gap:6px;flex-wrap:wrap;padding:2px 0">${html}</div>`;
}
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 === '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 => 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 => isPass(status(c))).length;
const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null;
document.getElementById('dash-stats').innerHTML = `<div class="stats-grid">
<div class="stat-card"><div class="stat-value">${totCr}</div><div class="stat-label">Total credits</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--success)">${passCr}</div><div class="stat-label">Credits earned</div></div>
<div class="stat-card"><div class="stat-value ${gradeClass(mean)}">${mean !== null ? mean.toFixed(2) : '—'}</div><div class="stat-label">Mean grade</div></div>
<div class="stat-card"><div class="stat-value" style="color:${rate === null ? 'var(--gray-400)' : rate >= 60 ? 'var(--success)' : 'var(--danger)'}">${rate !== null ? rate + '%' : '—'}</div><div class="stat-label">Pass rate</div></div>
</div>`;
}
function dashGlobal() {
const all = S.courses;
const graded = all.filter(c => !c.passFail && c.grade != null && c.grade !== '');
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 => isPass(status(c))).length;
const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null;
let html = `<div class="stats-grid">
<div class="stat-card"><div class="stat-value">${totCr}</div><div class="stat-label">Total credits</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--success)">${passCr}</div><div class="stat-label">Credits earned</div></div>
<div class="stat-card"><div class="stat-value ${gradeClass(mean)}">${mean !== null ? mean.toFixed(2) : '—'}</div><div class="stat-label">Mean grade</div></div>
<div class="stat-card"><div class="stat-value" style="color:${rate === null ? 'var(--gray-400)' : rate >= 60 ? 'var(--success)' : 'var(--danger)'}">${rate !== null ? rate + '%' : '—'}</div><div class="stat-label">Pass rate</div></div>
</div>`;
if (!all.length) return html + emptyState('📚', 'No courses yet.<br>Tap + to add your first course!');
html += `<div class="card">
<div class="card-header"><span class="card-title">All Courses</span><span style="font-size:11px;color:var(--gray-400)">${all.length} total</span></div>
<div class="table-wrap"><table><thead><tr><th>Course</th><th>Cr.</th><th>Grade</th><th>Status</th></tr></thead><tbody>`;
all.forEach(c => {
const st = status(c);
html += `<tr class="clickable" onclick="showCourseModal('${c.id}')">
<td><div style="font-weight:500">${esc(c.name)}</div><div style="font-size:10px;color:var(--gray-400)">${semName(c.semesterId)}</div></td>
<td>${c.credits || '—'}</td>
<td class="${c.passFail ? '' : gradeClass(c.grade)}">${fmtGrade(c.grade, c.passFail)}</td>
<td>${badge(st)}</td>
</tr>`;
});
html += `</tbody></table></div></div>`;
return html;
}
const WARN_SVG = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2.5" style="display:inline;vertical-align:middle;margin-left:3px"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
const NOSEM_SVG = `<svg title="No semester" style="display:inline;vertical-align:middle;margin-left:3px" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
const GEAR_SVG = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
const CHEV_SVG = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>`;
function chevBtn(key) {
const rot = collapsed[key] ? 'rotate(-90deg)' : 'rotate(0deg)';
return `<button onclick="toggleCollapse('${key}')" style="background:none;border:none;cursor:pointer;color:var(--gray-400);padding:2px 3px;display:flex;align-items:center;flex-shrink:0"><span style="display:inline-flex;transform:${rot};transition:transform .2s">${CHEV_SVG}</span></button>`;
}
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'
? 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 = `<div style="margin-top:12px">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px">
${chevBtn(colKey)}
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:${headerColor};flex:1">${label}</span>
${canAdd ? `<button class="btn btn-sm btn-secondary" onclick="showMinorModal('${planId}','','${role}')">+ ${addLabel}</button>` : ''}
</div>`;
if (!isCollapsed) {
if (blocks.length) {
blocks.forEach((blk, i) => { html += planBlockSection(planId, blk, allPlanCourses, blocks.length, i); });
} else {
html += `<div style="font-size:13px;color:var(--gray-400);padding:2px 0 4px;padding-left:20px">No ${label.toLowerCase()} defined yet.</div>`;
}
}
html += `</div>`;
return html;
}
function dashPlans() {
if (!S.studyPlans.length) return emptyState('📋', 'No study plans yet.<br>Tap + to create one!');
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 => 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');
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);
const pClr = planBaseColor(plan);
const colKey = `plan_${plan.id}`;
const isCollapsed = collapsed[colKey];
html += `<div class="card" style="border-left:4px solid ${pClr}">
<div class="card-header" style="flex-wrap:wrap;gap:6px">
<div style="display:flex;align-items:center;gap:4px;flex:1;min-width:0">
${chevBtn(colKey)}
<span class="card-title" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${planDisplayName(plan)}</span>
${cycleBadge(plan.cycle)}
${!creditOk ? WARN_SVG : ''}
<span style="font-size:11px;color:${creditOk ? 'var(--gray-400)' : 'var(--danger)'}">
${passCr}/${totCr}${minCr !== null ? '/' + minCr : ''} cr.
</span>
</div>
<div style="display:flex;gap:5px;align-items:center;flex-shrink:0">
<button class="btn btn-sm btn-secondary" onclick="showPlanModal('${plan.id}')" title="Edit plan">${GEAR_SVG}</button>
<button class="btn btn-sm btn-danger" onclick="deletePlan('${plan.id}')">Del</button>
</div>
</div>`;
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 += `<div style="margin-top:8px;font-size:11px;color:var(--gray-400);text-transform:uppercase;letter-spacing:.05em">Unassigned courses</div>`;
html += coursesMgmtList(nbc);
}
}
html += `</div>`;
});
const up = S.courses.filter(c => !c.planId);
if (up.length) {
html += `<div class="card">
<div class="card-header"><span class="card-title" style="color:var(--gray-400)">Without plan</span></div>
${coursesMgmtList(up)}
</div>`;
}
return html;
}
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 = `<div style="border-left:3px solid ${bClr};padding-left:8px;margin-top:10px">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
${chevBtn(colKey)}
<span style="font-size:14px;font-weight:600;color:${bClr};flex:1">${esc(blk.name)}${blockFailed ? ` <span style="font-size:10px;font-weight:700;background:var(--danger-light);color:var(--danger);border-radius:4px;padding:1px 5px;vertical-align:middle">FAIL</span>` : ''}</span>
${blockIsPassed
? `<span onclick="showBlockCompletionModal('${blk.id}')" title="Bloc validé — cliquer pour détails" style="cursor:pointer;font-size:10px;font-weight:700;background:var(--success-light);color:#065F46;border-radius:5px;padding:2px 7px;flex-shrink:0;white-space:nowrap">✓ Validé</span>`
: `<button onclick="showBlockCompletionModal('${blk.id}')" title="Vérifier les conditions de complétion" style="background:none;border:1px solid var(--gray-200);border-radius:5px;cursor:pointer;color:var(--gray-400);padding:2px 6px;font-size:10px;flex-shrink:0;white-space:nowrap">Check</button>`}
<span style="font-size:11px;color:var(--gray-400)">${bCr} cr.${bm !== null ? ` · avg <strong class="${gradeClass(bm)}">${bm.toFixed(2)}</strong>` : ''}</span>
${inEdit && sibCount > 1 ? `<button class="btn btn-sm btn-secondary" onclick="moveBlock('${blk.id}','up')" ${sibIdx===0?'disabled':''} style="padding:2px 5px">↑</button><button class="btn btn-sm btn-secondary" onclick="moveBlock('${blk.id}','down')" ${sibIdx===sibCount-1?'disabled':''} style="padding:2px 5px">↓</button>` : ''}
<button class="btn btn-sm btn-secondary" onclick="toggleEditMode('${blk.id}')" title="${inEdit?'Exit edit':'Edit'}" style="${inEdit?'background:var(--primary);color:#fff':''}">${GEAR_SVG}</button>
${inEdit ? `<button class="btn btn-sm btn-secondary" onclick="showMinorModal('${planId}','${blk.id}','${blk.role||'minor'}')">✎</button><button class="btn btn-sm btn-danger" onclick="deleteBlock('${blk.id}')">×</button>` : ''}
</div>`;
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 += `<div style="margin-top:6px">
${sbs.length ? `<div style="font-size:10px;color:var(--gray-400);text-transform:uppercase;letter-spacing:.05em;margin-bottom:3px">Free courses</div>` : ''}
${coursesMgmtList(freeC, false, bClr, inEdit)}
</div>`;
} else if (!sbs.length && !inEdit) {
html += `<div style="font-size:12px;color:var(--gray-400);padding:3px 0">No courses yet</div>`;
}
if (inEdit) {
html += `<div style="display:flex;gap:5px;margin-top:8px">
<button class="btn btn-sm btn-secondary" onclick="showSubBlockModal('${blk.id}','')">+ Block</button>
<button class="btn btn-sm btn-secondary" onclick="showCourseModalPrefilled('${planId}','${blk.id}','','')">+ Course</button>
</div>`;
}
}
html += `</div>`;
return html;
}
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 = `<div style="border-left:2px dashed ${sbClr};padding-left:8px;margin-top:7px">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
${chevBtn(colKey)}
<span style="font-size:13px;font-weight:600;color:${sbClr};flex:1">${esc(sb.name)}</span>
${sb.passed
? `<span onclick="showSubBlockCompletionModal('${sb.id}')" title="Bloc validé" style="cursor:pointer;font-size:10px;font-weight:700;background:var(--success-light);color:#065F46;border-radius:5px;padding:2px 7px;flex-shrink:0;white-space:nowrap">✓ Validé</span>`
: `<button onclick="showSubBlockCompletionModal('${sb.id}')" title="Vérifier les conditions" style="background:none;border:1px solid var(--gray-200);border-radius:5px;cursor:pointer;color:var(--gray-400);padding:2px 6px;font-size:10px;flex-shrink:0;white-space:nowrap">Check</button>`}
<span style="font-size:11px;color:var(--gray-400)">${sbCr} cr.</span>
${inEdit && sibCount > 1 ? `<button class="btn btn-sm btn-secondary" onclick="moveSubBlock('${sb.id}','up')" ${sibIdx===0?'disabled':''} style="padding:2px 5px">↑</button><button class="btn btn-sm btn-secondary" onclick="moveSubBlock('${sb.id}','down')" ${sibIdx===sibCount-1?'disabled':''} style="padding:2px 5px">↓</button>` : ''}
${inEdit ? `<button class="btn btn-sm btn-secondary" onclick="showSubBlockModal('${blockId}','${sb.id}')">✎</button><button class="btn btn-sm btn-danger" onclick="deleteSubBlock('${sb.id}')">×</button>` : ''}
</div>`;
if (!isCollapsed) {
html += courses.length
? coursesMgmtList(courses, false, sbClr, inEdit)
: `<div style="font-size:12px;color:var(--gray-400);padding:2px 0">No courses yet</div>`;
if (inEdit) {
html += `<button class="btn btn-sm btn-secondary" style="margin-top:4px" onclick="showCourseModalPrefilled('${planId}','${blockId}','','${sb.id}')">+ Course</button>`;
}
}
html += `</div>`;
return html;
}
const CAL_SVG = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`;
function dashSemesters() {
if (!S.semesters.length) return emptyState('📅', 'No semesters yet.<br>Tap + to create one!');
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 => 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);
const mean = sumC > 0 ? sumW / sumC : null;
html += `<div class="card">
<div class="card-header" style="flex-wrap:wrap;gap:6px">
<div style="flex:1;min-width:0">
<div class="card-title">${esc(sem.name)}</div>
<div style="font-size:11px;color:var(--gray-400);margin-top:2px">${esc(sem.season)} · ${sem.year}${mean !== null ? ` · avg <strong class="${gradeClass(mean)}">${mean.toFixed(2)}</strong>` : ''}</div>
</div>
<div style="display:flex;gap:5px;align-items:center;flex-shrink:0">
<span style="font-size:11px;color:var(--gray-400)">${passCr}/${totCr} cr.</span>
<button class="btn btn-sm btn-secondary" onclick="showWeekCalendar('${sem.id}')" title="Week schedule">${CAL_SVG}</button>
<button class="btn btn-sm btn-secondary" onclick="showSemesterModal('${sem.id}')" title="Edit semester">${GEAR_SVG}</button>
<button class="btn btn-sm btn-danger" onclick="deleteSemester('${sem.id}')">Del</button>
</div>
</div>`;
if (!sc.length) { html += `<div style="color:var(--gray-400);font-size:13px;padding:2px 0 4px">No courses this semester</div>`; }
else html += coursesMgmtList(sc, true);
html += `<button class="btn btn-secondary btn-full" style="margin-top:8px;font-size:13px" onclick="showCourseModalPrefilled('','','${sem.id}')">+ Add Course</button>
</div>`;
});
return html;
}
function coursesMgmtList(courses, showPlan, inheritColor, reorderable) {
if (!courses.length) return '<div style="font-size:12px;color:var(--gray-400);padding:5px 0">No courses yet</div>';
return courses.map((c, idx) => {
const st = status(c);
const meta = [
c.credits ? c.credits + ' cr.' : null,
!c.semesterId ? '<span style="color:var(--warning)">no semester' + NOSEM_SVG + '</span>' : 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(' · ')
: '';
// 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 `<div style="display:flex;align-items:center;padding:7px 0;border-bottom:1px solid var(--gray-100);gap:8px;${borderStyle}">
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500;color:var(--gray-800);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(c.name)}</div>
<div style="font-size:11px;color:var(--gray-400);margin-top:1px">${meta}</div>
${slotsStr ? `<div style="font-size:11px;color:${bClr||'var(--primary)'};margin-top:1px">${slotsStr}</div>` : ''}
</div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
${badge(st)}
${c.attempts && c.attempts.length > 1
? c.attempts.map((a,i) => `<span class="${gradeClass(a.grade)}" style="font-size:12px;font-weight:700">${a.grade}${i < c.attempts.length-1 ? '<span style="color:var(--gray-300);font-weight:400">→</span>' : ''}</span>`).join('')
: `<span class="${gradeClass(c.grade)}" style="font-size:14px;font-weight:700;min-width:28px;text-align:right">${fmtGrade(c.grade, c.passFail)}</span>`
}
${reorderable ? `<button class="btn btn-sm btn-secondary" onclick="moveCourse('${c.id}','up')" ${idx===0?'disabled':''} style="padding:2px 5px">↑</button><button class="btn btn-sm btn-secondary" onclick="moveCourse('${c.id}','down')" ${idx===courses.length-1?'disabled':''} style="padding:2px 5px">↓</button>` : ''}
<button class="btn btn-sm btn-secondary" onclick="showCourseModal('${c.id}')">✎</button>
<button class="btn btn-sm btn-danger" onclick="deleteCourse('${c.id}')">×</button>
</div>
</div>`;
}).join('');
}
function showCourseModalPrefilled(planId, blockId, semId, subBlockId = '') {
showCourseModal(null, { planId, blockId, semId, subBlockId });
}
// ══════════════════════════════════════════════════════
// COURSES PAGE
// ══════════════════════════════════════════════════════
function renderCourses() {
const el = document.getElementById('courses-content');
if (!S.courses.length) { el.innerHTML = emptyState('📖', 'No courses yet.<br>Tap + to add one!'); return; }
let html = '';
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 += `<div class="section-label">${esc(sem.name)}</div>`;
list.forEach(c => html += courseCard(c));
});
if (noSem.length) {
html += `<div class="section-label">No Semester</div>`;
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 += `<div class="section-label" style="display:flex;align-items:center;gap:6px">${esc(plan.name)}${plan.cycle ? ` · ${plan.cycle}` : ''}</div>`;
pc.forEach(c => html += courseCard(c, true));
});
const unplanned = S.courses.filter(c => !c.planId);
if (unplanned.length) {
html += `<div class="section-label">No Study Plan</div>`;
unplanned.forEach(c => html += courseCard(c, true));
}
}
el.innerHTML = html;
initDrag(el);
}
function courseCard(c, showSem) {
const st = status(c);
const metaParts = [
c.credits ? c.credits + ' cr.' : null,
showSem && c.semesterId ? semName(c.semesterId) : null,
!showSem && c.planId ? planName(c.planId) + (c.blockId ? ' ' + blockName(c.blockId) : '') : null,
].filter(Boolean);
return `<div class="course-card" data-id="${c.id}" draggable="true">
<div class="drag-handle" title="Drag to reorder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="18" x2="16" y2="18"/></svg>
</div>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<span class="course-name">${esc(c.name)}</span>
${badge(st)}
</div>
<div class="course-meta">${metaParts.join(' · ') || '—'}</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;margin-left:10px;flex-shrink:0">
${c.attempts && c.attempts.length > 1
? `<div style="display:flex;align-items:center;gap:2px">${c.attempts.map((a,i) => `<span class="${gradeClass(a.grade)}" style="font-size:13px;font-weight:700">${a.grade}${i < c.attempts.length-1 ? '<span style="color:var(--gray-300);font-weight:400;font-size:11px">→</span>' : ''}</span>`).join('')}</div>`
: `<span class="course-grade ${gradeClass(c.grade)}">${fmtGrade(c.grade)}</span>`
}
<div style="display:flex;gap:5px">
<button class="btn btn-sm btn-secondary" onclick="showCourseModal('${c.id}')">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteCourse('${c.id}')">Del</button>
</div>
</div>
</div>`;
}
// ══════════════════════════════════════════════════════
// DRAG AND DROP (courses)
// ══════════════════════════════════════════════════════
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 };
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';
});
container.addEventListener('dragend', () => {
container.querySelectorAll('.course-card').forEach(el => el.classList.remove('dragging', 'drag-over'));
dragSrcId = dragOverId = null;
});
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');
}
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 };
});
}
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);
}
// ══════════════════════════════════════════════════════
// WEEK CALENDAR
// ══════════════════════════════════════════════════════
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'];
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);
}
function showWeekCalendar(semId) {
const sem = S.semesters.find(x => x.id === semId);
if (!sem) return;
openModal(`<div class="modal" style="padding:0;overflow:hidden;max-height:96vh;display:flex;flex-direction:column;border-radius:20px 20px 0 0">
<div style="display:flex;justify-content:space-between;align-items:flex-start;padding:16px 16px 10px;flex-shrink:0">
<div>
<div class="modal-title">Week Schedule</div>
<div style="font-size:12px;color:var(--gray-400);margin-top:2px">${esc(sem.name)}</div>
</div>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div style="display:flex;gap:8px;padding:0 16px 10px;flex-shrink:0">
<label class="file-input-label" for="f-ics-${semId}" style="flex:1;padding:7px 10px;font-size:13px">
${CAL_SVG} Import ICS
</label>
<input type="file" id="f-ics-${semId}" accept=".ics,text/calendar" style="display:none" onchange="importICS(this,'${semId}')">
<button class="btn btn-primary" style="flex:1;justify-content:center;font-size:13px" onclick="showEventModal('${semId}','')">+ Add Event</button>
</div>
<div class="wk-scroll" id="wk-scroll">
<div class="wk-outer">${renderWeekGrid(semId)}</div>
</div>
<div style="padding:10px 16px 14px;flex-shrink:0">
<button class="btn btn-secondary btn-full" style="font-size:13px" onclick="closeModal()">Close</button>
</div>
</div>`);
// 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
}));
});
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 `<div style="padding:40px 16px;text-align:center;color:var(--gray-400)">
<div style="font-size:32px;margin-bottom:8px">📅</div>
<div style="font-size:14px;font-weight:500;color:var(--gray-600)">No schedule yet</div>
<div style="font-size:12px;margin-top:6px;line-height:1.6;max-width:280px;margin-left:auto;margin-right:auto">${hint}</div>
</div>`;
}
// Time labels
let timeLbls = '';
for (let m = WK_START; m <= WK_END; m += 60) {
const top = (m - WK_START) * PX_PER_MIN;
timeLbls += `<div class="wk-tlbl" style="top:${top}px">${toHHMM(m)}</div>`;
}
// 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 += `<div class="wk-gline${m % 60 === 0 ? ' wk-gline-h' : ''}" style="top:${top}px"></div>`;
}
// 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 ? `<div style="opacity:.8;font-weight:400;font-size:9px;margin-top:1px">${esc(ev.location)}</div>` : '';
const onclick = ev._courseId
? `showCourseModal('${ev._courseId}')`
: `showEventModal('${ev.semesterId}','${ev.id}')`;
return `<div class="wk-event" style="top:${top}px;height:${height}px;background:${esc(ev.color)}"
onclick="${onclick}" title="${esc(ev.title)} ${toHHMM(ev.startMin)}${toHHMM(ev.endMin)}">
${esc(ev.title)}${height > 30 ? `<br><span style="opacity:.8;font-weight:400">${toHHMM(ev.startMin)}</span>${loc}` : ''}
</div>`;
}).join('');
return `<div class="wk-dcol">${gridLines}${evHtml}</div>`;
}).join('');
const dayHeaders = WK_DAYS.map(d => `<div class="wk-hday">${d}</div>`).join('');
return `<div class="wk-head">
<div class="wk-htime"></div>${dayHeaders}
</div>
<div class="wk-body">
<div class="wk-tcol" style="height:${totalH}px">${timeLbls}</div>
<div class="wk-days" style="height:${totalH}px">${dayCols}</div>
</div>`;
}
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) =>
`<option value="${i+1}" ${ev ? ev.day === i+1 ? 'selected' : '' : i === 0 ? 'selected' : ''}>${d}</option>`
).join('');
const swatches = EV_COLORS.map(c =>
`<span class="ev-swatch" data-color="${c}" onclick="selectEvColor('${c}')"
style="background:${c};outline:${c === defClr ? '2px solid '+c+';outline-offset:2px' : 'none'}"></span>`
).join('');
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${ev ? 'Edit Event' : 'Add Event'}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-group">
<label>Title *</label>
<input id="f-ev-title" type="text" value="${ev ? esc(ev.title) : ''}" placeholder="e.g. Mathematics">
</div>
<div class="form-row">
<div class="form-group">
<label>Day</label>
<select id="f-ev-day">${dayOpts}</select>
</div>
<div class="form-group">
<label>Location</label>
<input id="f-ev-loc" type="text" value="${ev && ev.location ? esc(ev.location) : ''}" placeholder="e.g. Room 101">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Start</label>
<input id="f-ev-start" type="time" value="${ev ? toHHMM(ev.startMin) : '08:00'}">
</div>
<div class="form-group">
<label>End</label>
<input id="f-ev-end" type="time" value="${ev ? toHHMM(ev.endMin) : '10:00'}">
</div>
</div>
<div class="form-group">
<label>Color</label>
<div id="ev-cpicker" data-color="${defClr}" style="display:flex;gap:6px;flex-wrap:wrap;padding:2px 0">
${swatches}
</div>
<input type="hidden" id="f-ev-color" value="${defClr}">
</div>
<div class="modal-actions">
${ev ? `<button class="btn btn-danger" onclick="deleteEvent('${id}','${semId}')">Delete</button>`
: `<button class="btn btn-secondary" onclick="showWeekCalendar('${semId}')">Cancel</button>`}
<button class="btn btn-primary" onclick="saveEvent('${semId}','${id||''}')">Save</button>
</div>
</div>`);
}
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;
}
// ══════════════════════════════════════════════════════
// MODAL HELPERS
// ══════════════════════════════════════════════════════
function openModal(inner) {
document.getElementById('modal-root').innerHTML =
`<div class="overlay" id="overlay" onclick="overlayClick(event)">${inner}</div>`;
}
function closeModal() { document.getElementById('modal-root').innerHTML = ''; }
function overlayClick(e) { if (e.target.id === 'overlay') closeModal(); }
// ══════════════════════════════════════════════════════
// COURSE MODAL
// ══════════════════════════════════════════════════════
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 `<div style="font-size:12px;color:var(--gray-400);padding:2px 0">No attempts logged yet</div>`;
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 `<div style="display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--gray-100)">
<span style="font-size:11px;font-weight:700;color:var(--gray-400);min-width:18px">#${i+1}</span>
<span style="font-size:14px;font-weight:700;color:${gCls}">${a.grade}</span>
${+a.grade >= 4
? '<span class="badge badge-pass" style="font-size:10px;padding:1px 5px">Passed</span>'
: '<span class="badge badge-fail" style="font-size:10px;padding:1px 5px">Failed</span>'}
${label ? `<span style="font-size:11px;color:var(--gray-500)">${label}</span>` : ''}
<button onclick="removeAttempt(${i})" style="margin-left:auto;background:none;border:none;cursor:pointer;color:var(--gray-400);font-size:16px;line-height:1;padding:0 2px">×</button>
</div>`;
}).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) => {
const d = WK_DAYS[s.day - 1];
return `<span style="display:inline-flex;align-items:center;gap:3px;background:var(--primary-light);color:var(--primary);border-radius:6px;padding:3px 8px;font-size:12px;font-weight:500;margin:2px 2px 0">
${d} ${toHHMM(s.startMin)}${toHHMM(s.endMin)}
<button onclick="removeSlot(${i})" style="background:none;border:none;cursor:pointer;color:inherit;font-size:15px;line-height:1;padding:0 0 0 2px">×</button>
</span>`;
}).join('');
return chips || `<div style="font-size:12px;color:var(--gray-400);padding:2px 0">No schedule yet</div>`;
}
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 =>
`<option value="${s.id}" ${s.id === curSem ? 'selected' : ''}>${esc(s.name)}</option>`
).join('');
const planOpts = `<option value="">— None —</option>` + S.studyPlans.map(p =>
`<option value="${p.id}" ${p.id === curPlan ? 'selected' : ''}>${esc(p.name)}</option>`
).join('');
const blockOpts = buildBlockOpts(curPlan, curBlock);
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 = `<option value="">— none —</option>` + S.semesters.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('');
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${c ? 'Edit Course' : 'Add Course'}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-group">
<label>Course Name *</label>
<input id="f-name" type="text" value="${c ? esc(c.name) : ''}" placeholder="e.g. Mathematics I">
</div>
<div class="form-row">
<div class="form-group">
<label>Credits *</label>
<input id="f-credits" type="number" value="${c ? c.credits : ''}" placeholder="e.g. 3" min="0" step="0.5">
</div>
<div class="form-group" id="fg-grade" style="display:${pfMode === 'default' ? 'block' : 'none'}">
<label>Grade (1 6)</label>
<input id="f-grade" type="number" value="${c && pfMode === 'default' && c.grade != null && c.grade !== '' ? c.grade : ''}" placeholder="e.g. 5.5" min="1" max="6" step="0.25">
</div>
</div>
<input type="hidden" id="f-passfail" value="${pfMode}">
<div class="pf-seg">
<button type="button" class="pf-seg-btn${pfMode==='default'?' seg-default':''}" onclick="onPFChange('default')">Default</button>
<button type="button" class="pf-seg-btn${pfMode==='passed'?' seg-passed':''}" onclick="onPFChange('passed')">Passed</button>
<button type="button" class="pf-seg-btn${pfMode==='failed'?' seg-failed':''}" onclick="onPFChange('failed')">Failed</button>
</div>
<div class="form-group" style="margin-bottom:10px">
<label>Attempt History <span style="font-size:11px;color:var(--gray-400);font-weight:400">(up to 2 allowed · updates grade)</span></label>
<div id="attempts-list" style="margin-bottom:6px">${renderAttemptsUI()}</div>
<div style="display:grid;grid-template-columns:90px 1fr auto;gap:5px;align-items:end">
<div>
<div style="font-size:10px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px">Grade</div>
<input id="f-att-grade" type="number" min="1" max="6" step="0.25" placeholder="e.g. 3.5" style="width:100%;padding:7px 8px;border:1.5px solid var(--gray-200);border-radius:8px;font-size:14px;background:var(--bg-surface);color:var(--gray-800)">
</div>
<div>
<div style="font-size:10px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px">Semester</div>
<select id="f-att-sem" style="width:100%;padding:7px 6px;border:1.5px solid var(--gray-200);border-radius:8px;font-size:13px;background:var(--bg-surface);color:var(--gray-800)">${semOptsAttempt}</select>
</div>
<button class="btn btn-primary" style="padding:7px 12px;font-size:16px;line-height:1" onclick="logAttempt()">+</button>
</div>
</div>
<div class="form-group">
<label>Semester</label>
<select id="f-semester">
<option value="">— None —</option>
${semOpts}
</select>
</div>
<div class="form-group">
<label>Study Plan</label>
<select id="f-plan" onchange="onPlanChange(this.value)">
${planOpts}
</select>
</div>
<div class="form-group" id="fg-block" style="display:${curPlan ? 'block' : 'none'}">
<label>Major / Minor</label>
<select id="f-block" onchange="onBlockChange(this.value)">${blockOpts}</select>
</div>
<div class="form-group" id="fg-subblock" style="display:${curBlock ? 'block' : 'none'}">
<label>Block</label>
<select id="f-subblock">${buildSubBlockOpts(curBlock, curSubBlock)}</select>
</div>
<div class="form-group" style="margin-bottom:4px">
<label>Weekly Schedule</label>
<div id="slots-list" style="margin-bottom:6px">${renderSlotsUI()}</div>
<div style="display:grid;grid-template-columns:100px 1fr;gap:6px;margin-bottom:6px">
<div>
<div style="font-size:11px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Day</div>
<select id="f-slot-day" style="width:100%;padding:8px 6px;border:1.5px solid var(--gray-200);border-radius:8px;font-size:14px;background:var(--bg-surface);color:var(--gray-800)">
${WK_DAYS.map((d, i) => `<option value="${i+1}">${d}</option>`).join('')}
</select>
</div>
<div style="display:flex;flex-direction:column;justify-content:flex-end">
<div style="font-size:11px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Time</div>
<div style="display:flex;align-items:center;gap:4px">
<input id="f-slot-start" type="time" value="08:00" style="flex:1;min-width:0">
<span style="color:var(--gray-400);font-size:13px;flex-shrink:0"></span>
<input id="f-slot-end" type="time" value="10:00" style="flex:1;min-width:0">
</div>
</div>
</div>
<button class="btn btn-sm btn-primary btn-full" onclick="addSlot()">+ Add slot</button>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveCourse('${id || ''}')">Save</button>
</div>
</div>`);
}
function buildBlockOpts(planId, selBlock) {
if (!planId) return '<option value="">— None —</option>';
const list = S.blocks.filter(b => b.planId === planId);
if (!list.length) return '<option value="">No majors/minors in this plan</option>';
const minors = list.filter(b => b.role === 'minor');
return `<option value="">— None —</option>` + list.map(b => {
const opts = list.filter(x => x.role === 'optional');
let prefix = '';
if (b.role === 'major') prefix = 'Major: ';
else if (b.role === 'minor') prefix = `Minor ${minors.indexOf(b) + 1}: `;
else if (b.role === 'optional') prefix = `Optional ${opts.indexOf(b) + 1}: `;
return `<option value="${b.id}" ${b.id === selBlock ? 'selected' : ''}>${esc(prefix + b.name)}</option>`;
}).join('');
}
function buildSubBlockOpts(blockId, selSubBlock) {
if (!blockId) return '<option value="">— None —</option>';
const list = S.subBlocks.filter(sb => sb.blockId === blockId);
return `<option value="">— None —</option>` + list.map(sb =>
`<option value="${sb.id}" ${sb.id === selSubBlock ? 'selected' : ''}>${esc(sb.name)}</option>`
).join('');
}
function onPlanChange(pid) {
const fg = document.getElementById('fg-block');
const fs = document.getElementById('f-block');
const fgSub = document.getElementById('fg-subblock');
if (pid) { fg.style.display = 'block'; fs.innerHTML = buildBlockOpts(pid, ''); }
else { fg.style.display = 'none'; }
if (fgSub) fgSub.style.display = 'none';
}
function onBlockChange(bid) {
const fgSub = document.getElementById('fg-subblock');
const fsSub = document.getElementById('f-subblock');
if (bid) { fgSub.style.display = 'block'; fsSub.innerHTML = buildSubBlockOpts(bid, ''); }
else { fgSub.style.display = 'none'; }
}
function onPFChange(v) {
const inp = document.getElementById('f-passfail');
if (inp) inp.value = v;
const fgGrade = document.getElementById('fg-grade');
if (fgGrade) fgGrade.style.display = v === 'default' ? 'block' : 'none';
document.querySelectorAll('.pf-seg-btn').forEach(btn => {
btn.className = 'pf-seg-btn';
if (btn.textContent.trim().toLowerCase() === v) btn.classList.add('seg-' + v);
});
}
function saveCourse(id) {
const name = document.getElementById('f-name').value.trim();
const credits = document.getElementById('f-credits').value;
const pfVal = document.getElementById('f-passfail').value || 'default';
const passFail = pfVal === 'default' ? null : pfVal;
const gradeEl = document.getElementById('f-grade');
const gradeV = (!passFail && gradeEl) ? gradeEl.value : '';
const semId = document.getElementById('f-semester').value;
const planId = document.getElementById('f-plan').value;
const fg = document.getElementById('fg-block');
const blockId = (fg && fg.style.display !== 'none') ? document.getElementById('f-block').value : '';
const fgSub = document.getElementById('fg-subblock');
const subBlockId = (fgSub && fgSub.style.display !== 'none') ? document.getElementById('f-subblock').value : '';
if (!name) { alert('Please enter a course name.'); return; }
if (!credits || +credits < 0) { alert('Please enter valid credits.'); return; }
const grade = (passFail === null && gradeV !== '') ? parseFloat(gradeV) : null;
// Auto-capture the slot picker if user filled it in without clicking "+ Add slot"
const pickerDay = parseInt((document.getElementById('f-slot-day') || {}).value || '1');
const pickerStart = fromHHMM((document.getElementById('f-slot-start') || {}).value || '08:00');
const pickerEnd = fromHHMM((document.getElementById('f-slot-end') || {}).value || '08:00');
if (pickerEnd > pickerStart) {
const alreadyIn = _slots.some(s => s.day === pickerDay && s.startMin === pickerStart && s.endMin === pickerEnd);
if (!alreadyIn) _slots.push({ day: pickerDay, startMin: pickerStart, endMin: pickerEnd });
}
const slots = [..._slots];
const attempts = [..._attempts];
if (id) {
const i = S.courses.findIndex(c => c.id === id);
if (i >= 0) S.courses[i] = { id, name, credits: +credits, semesterId: semId, grade, planId, blockId, subBlockId, passFail, slots, attempts };
} else {
S.courses.push({ id: uid(), name, credits: +credits, semesterId: semId, grade, planId, blockId, subBlockId, passFail, slots, attempts });
}
save(); closeModal(); render(page);
}
function deleteCourse(id) {
if (!confirm('Delete this course?')) return;
S.courses = S.courses.filter(c => c.id !== id);
save(); render(page);
}
// ══════════════════════════════════════════════════════
// PLAN MODALS
// ══════════════════════════════════════════════════════
function showPlanTypeModal() {
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">New Study Plan</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:4px">
<button class="btn btn-secondary btn-full" style="padding:18px;font-size:15px" onclick="showPlanModal('','bachelor')">🎓 Bachelor</button>
<button class="btn btn-secondary btn-full" style="padding:18px;font-size:15px" onclick="showPlanModal('','master')">🏛️ Master</button>
</div>
</div>`);
}
function showPlanModal(id, cycleHint) {
const p = id ? S.studyPlans.find(x => x.id === id) : null;
const cycle = p ? p.cycle : (cycleHint || 'bachelor');
const label = cycle === 'master' ? 'Master' : 'Bachelor';
const count = S.studyPlans.filter(x => x.cycle === cycle && x.id !== id).length;
const defName = p ? esc(p.name) : `${label} ${count + 1}`;
const major = p ? S.blocks.find(b => b.planId === p.id && b.role === 'major') : null;
const defColor = planBaseColor(p || { color: null, id: '__new__' });
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${p ? 'Edit Plan' : `New ${label}`}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-group">
<label>Major Name</label>
<input id="f-major" type="text" value="${major ? esc(major.name) : ''}" placeholder="e.g. Computer Science">
</div>
<div class="form-group" style="margin-bottom:4px">
<label>Plan Color</label>
${colorSwatches(PLAN_PALETTE, defColor, 'selectPlanColor', false, '')}
<input type="hidden" id="f-pcolor" value="${defColor}">
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="savePlan('${id || ''}','${cycle}')">Save</button>
</div>
</div>`);
}
function selectPlanColor(color) {
const inp = document.getElementById('f-pcolor');
if (inp) inp.value = color;
syncSwatchOutlines('cp-swatches', color);
}
function savePlan(id, cycle) {
const majorName = document.getElementById('f-major').value.trim();
const color = document.getElementById('f-pcolor')?.value || '';
const label = cycle === 'master' ? 'Master' : 'Bachelor';
const count = S.studyPlans.filter(x => x.cycle === cycle && x.id !== id).length;
const name = `${label} ${count + 1}`;
let planId = id;
if (id) {
const i = S.studyPlans.findIndex(x => x.id === id);
if (i >= 0) { S.studyPlans[i].cycle = cycle; S.studyPlans[i].color = color; }
if (majorName) {
const major = S.blocks.find(b => b.planId === id && b.role === 'major');
if (major) major.name = majorName;
else S.blocks.push({ id: uid(), planId: id, name: majorName, role: 'major' });
}
} else {
planId = uid();
S.studyPlans.push({ id: planId, name, cycle, color });
if (majorName) S.blocks.push({ id: uid(), planId, name: majorName, role: 'major' });
}
save(); closeModal(); render(page);
}
function deletePlan(id) {
if (!confirm('Delete this study plan? Courses will lose their plan assignment.')) return;
S.studyPlans = S.studyPlans.filter(p => p.id !== id);
S.blocks = S.blocks.filter(b => b.planId !== id);
S.courses.forEach(c => { if (c.planId === id) { c.planId = ''; c.blockId = ''; } });
save(); render(page);
}
// ══════════════════════════════════════════════════════
// BLOCK / SUB-BLOCK COMPLETION
// ══════════════════════════════════════════════════════
function computeSixChecks(courses, minCredits) {
const defCrs = courses.filter(c => !c.passFail);
const totalCr = courses.reduce((s, c) => s + (+c.credits || 0), 0);
const graded = defCrs.filter(c => 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);
const mean = sumC > 0 ? sumW / sumC : null;
const below3 = defCrs.filter(c => c.grade != null && c.grade !== '' && +c.grade < 3.0);
const grade1 = defCrs.filter(c => c.grade != null && c.grade !== '' && +c.grade === 1.0);
const failCrs = courses.filter(c => c.passFail === 'failed');
const missing = defCrs.filter(c => c.grade === null || c.grade === undefined || c.grade === '');
const mc = minCredits ? +minCredits : 0;
return [
{ label: 'Nombre minimum de crédits ECTS atteint',
ok: mc === 0 || totalCr >= mc,
detail: mc > 0 ? `${totalCr} / ${mc} cr.` : `${totalCr} cr. (aucun minimum défini)` },
{ label: 'Moyenne suffisante (≥ 4.0)',
ok: mean !== null && mean >= 4.0,
detail: mean !== null ? `Moyenne : ${mean.toFixed(2)}` : 'Aucun cours noté' },
{ label: 'Aucune note de 1.0',
ok: grade1.length === 0,
detail: grade1.length > 0 ? `${grade1.length} cours avec 1.0` : null },
{ label: 'Pas plus de 2 notes inférieures à 3.0',
ok: below3.length <= 2,
detail: `${below3.length} cours en dessous de 3.0` },
{ label: 'Aucune unité d\'enseignement évaluée par « échec »',
ok: failCrs.length === 0,
detail: failCrs.length > 0 ? `${failCrs.length} cours marqué(s) « échec »` : null },
{ label: 'Tous les résultats des épreuves ont été introduits',
ok: courses.length > 0 && missing.length === 0,
detail: missing.length > 0 ? `${missing.length} résultat(s) manquant(s)` : null }
];
}
function _checksRows(checks) {
return checks.map(c => `
<div style="display:flex;align-items:flex-start;gap:10px;padding:9px 0;border-bottom:1px solid var(--gray-100)">
<span style="font-size:15px;flex-shrink:0;margin-top:1px">${c.ok ? '✅' : '❌'}</span>
<div>
<div style="font-size:13px;font-weight:500;color:var(--gray-800)">${c.label}</div>
${c.detail ? `<div style="font-size:11px;color:${c.ok ? 'var(--gray-400)' : 'var(--danger)'};margin-top:2px">${c.detail}</div>` : ''}
</div>
</div>`).join('');
}
// ── Sub-block completion ──────────────────────────────
function subBlockCompletionChecks(sbId) {
const sb = S.subBlocks.find(s => s.id === sbId);
if (!sb) return [];
return computeSixChecks(S.courses.filter(c => c.subBlockId === sbId), sb.minCredits);
}
function showSubBlockCompletionModal(sbId) {
const sb = S.subBlocks.find(s => s.id === sbId);
if (!sb) return;
const checks = subBlockCompletionChecks(sbId);
const allOk = checks.every(c => c.ok);
const actionBtn = sb.passed
? `<button class="btn btn-secondary" onclick="markSubBlockPassed('${sbId}',false)">Retirer la validation</button>`
: allOk
? `<button class="btn btn-primary" style="background:var(--success)" onclick="markSubBlockPassed('${sbId}',true)">✓ Valider ce bloc</button>`
: `<button class="btn btn-secondary" onclick="markSubBlockPassed('${sbId}',true)">Valider malgré tout</button>`;
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Complétion · ${esc(sb.name)}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:12px;color:var(--gray-500);margin-bottom:10px">Conditions requises pour valider ce bloc :</p>
${_checksRows(checks)}
<div class="modal-actions" style="margin-top:14px">
<button class="btn btn-secondary" onclick="closeModal()">Fermer</button>
${actionBtn}
</div>
</div>`);
}
function markSubBlockPassed(sbId, val) {
const i = S.subBlocks.findIndex(s => s.id === sbId);
if (i >= 0) { S.subBlocks[i].passed = val; save(); closeModal(); renderDash(); }
}
// ── Block completion ──────────────────────────────────
function blockIsAutoPassedWithSubs(blkId) {
const sbs = S.subBlocks.filter(s => s.blockId === blkId);
const freeCrs = S.courses.filter(c => c.blockId === blkId && !c.subBlockId);
const freeOk = freeCrs.length === 0 || computeSixChecks(freeCrs, 0).every(c => c.ok);
return sbs.length > 0 && sbs.every(s => s.passed) && freeOk;
}
function blockPassedStatus(blkId) {
const sbs = S.subBlocks.filter(s => s.blockId === blkId);
if (sbs.length > 0) return blockIsAutoPassedWithSubs(blkId);
const blk = S.blocks.find(b => b.id === blkId);
return !!blk?.passed;
}
function showBlockCompletionModal(blkId) {
const blk = S.blocks.find(b => b.id === blkId);
if (!blk) return;
const sbs = S.subBlocks.filter(s => s.blockId === blkId);
const hasSubs = sbs.length > 0;
let body, actionBtn;
if (hasSubs) {
const sbRows = sbs.map(sb => `
<div style="display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--gray-100)">
<span style="font-size:15px;flex-shrink:0">${sb.passed ? '✅' : '❌'}</span>
<div style="flex:1">
<div style="font-size:13px;font-weight:500;color:var(--gray-800)">${esc(sb.name)}</div>
<div style="font-size:11px;color:var(--gray-400)">${sb.passed ? 'Validé' : 'Non validé — utilisez le bouton "Check" du bloc'}</div>
</div>
</div>`).join('');
const freeCrs = S.courses.filter(c => c.blockId === blkId && !c.subBlockId);
let freeRow = '';
if (freeCrs.length > 0) {
const freeChecks = computeSixChecks(freeCrs, 0);
const freeOk = freeChecks.every(c => c.ok);
freeRow = `<div style="margin-top:10px;font-size:11px;font-weight:700;color:var(--gray-500);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px">Cours libres (${freeCrs.length})</div>` + _checksRows(freeChecks);
}
const allDone = blockIsAutoPassedWithSubs(blkId);
body = sbRows + freeRow;
actionBtn = '';
if (allDone) {
actionBtn = `<span style="font-size:13px;font-weight:600;color:var(--success)">✓ Tous les blocs sont validés</span>`;
}
} else {
const courses = S.courses.filter(c => c.blockId === blkId);
const checks = computeSixChecks(courses, blk.minCredits);
const allOk = checks.every(c => c.ok);
body = _checksRows(checks);
actionBtn = blk.passed
? `<button class="btn btn-secondary" onclick="markBlockPassed('${blkId}',false)">Retirer la validation</button>`
: allOk
? `<button class="btn btn-primary" style="background:var(--success)" onclick="markBlockPassed('${blkId}',true)">✓ Valider le bloc</button>`
: `<button class="btn btn-secondary" onclick="markBlockPassed('${blkId}',true)">Valider malgré tout</button>`;
}
const subtitle = hasSubs
? 'Le bloc est validé automatiquement quand tous ses sous-blocs sont validés.'
: 'Conditions requises pour valider ce bloc :';
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Complétion · ${esc(blk.name)}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:12px;color:var(--gray-500);margin-bottom:10px">${subtitle}</p>
${body}
<div class="modal-actions" style="margin-top:14px">
<button class="btn btn-secondary" onclick="closeModal()">Fermer</button>
${actionBtn}
</div>
</div>`);
}
function markBlockPassed(blkId, val) {
const i = S.blocks.findIndex(b => b.id === blkId);
if (i >= 0) { S.blocks[i].passed = val; save(); closeModal(); renderDash(); }
}
// ══════════════════════════════════════════════════════
// MINOR / BLOCK MODAL
// ══════════════════════════════════════════════════════
function showMinorModal(planId, id, role) {
const b = id ? S.blocks.find(x => x.id === id) : null;
const label = role === 'major' ? 'Major' : role === 'minor' ? 'Minor' : 'Optional Study';
const plan = S.studyPlans.find(p => p.id === planId);
const autoClr = (() => {
if (!b) {
if (role === 'major') return planBaseColor(plan);
if (role === 'minor') {
const cnt = S.blocks.filter(bk => bk.planId === planId && bk.role === 'minor').length;
return MINOR_PALETTE[cnt % MINOR_PALETTE.length];
}
const cnt = S.blocks.filter(bk => bk.planId === planId && bk.role === 'optional').length;
return MINOR_PALETTE[(cnt + 4) % MINOR_PALETTE.length];
}
return blockEffectiveColor({ ...b, color: '' });
})();
const curColor = b?.color || '';
const palette = role === 'major' ? PLAN_PALETTE : MINOR_PALETTE;
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${b ? `Edit ${label}` : `Add ${label}`}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-row">
<div class="form-group">
<label>${label} Name *</label>
<input id="f-bname" type="text" value="${b ? esc(b.name) : ''}" placeholder="e.g. Mathematics">
</div>
<div class="form-group" style="max-width:110px">
<label>Crédits min.</label>
<input id="f-bmin" type="number" min="0" step="1" value="${b && b.minCredits ? b.minCredits : ''}" placeholder="e.g. 60">
</div>
</div>
<div class="form-group" style="margin-bottom:4px">
<label>Color <span style="font-weight:400;color:var(--gray-400);text-transform:none">(auto = plan family)</span></label>
${colorSwatches(palette, curColor, 'selectBlockColor', true, autoClr)}
<input type="hidden" id="f-bcolor" value="${curColor}">
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveMinor('${planId}','${id || ''}','${role || 'minor'}')">Save</button>
</div>
</div>`);
}
function selectBlockColor(color) {
const inp = document.getElementById('f-bcolor');
if (inp) inp.value = color;
syncSwatchOutlines('cp-swatches', color);
}
function saveMinor(planId, id, role) {
const name = document.getElementById('f-bname').value.trim();
const color = document.getElementById('f-bcolor')?.value || '';
const minCrEl = document.getElementById('f-bmin');
const minCredits = minCrEl && minCrEl.value !== '' ? +minCrEl.value : 0;
if (!name) { alert('Please enter a name.'); return; }
if (!id && role === 'major' && S.blocks.some(b => b.planId === planId && b.role === 'major')) {
alert('This plan already has a major. Only one major is allowed per plan.'); return;
}
if (id) {
const i = S.blocks.findIndex(x => x.id === id);
if (i >= 0) { S.blocks[i].name = name; S.blocks[i].role = role; S.blocks[i].color = color; S.blocks[i].minCredits = minCredits; }
} else {
S.blocks.push({ id: uid(), planId, name, role, color, minCredits });
}
save(); closeModal(); render(page);
}
function showSubBlockModal(blockId, id) {
const sb = id ? S.subBlocks.find(x => x.id === id) : null;
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${sb ? 'Edit Block' : 'Add Block'}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-row">
<div class="form-group">
<label>Block Name *</label>
<input id="f-sbname" type="text" value="${sb ? esc(sb.name) : ''}" placeholder="e.g. Algebra">
</div>
<div class="form-group" style="max-width:110px">
<label>Crédits min.</label>
<input id="f-sbmin" type="number" min="0" step="1" value="${sb && sb.minCredits ? sb.minCredits : ''}" placeholder="e.g. 20">
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveSubBlock('${blockId}','${id||''}')">Save</button>
</div>
</div>`);
}
function saveSubBlock(blockId, id) {
const name = document.getElementById('f-sbname').value.trim();
const minCrEl = document.getElementById('f-sbmin');
const minCredits = minCrEl && minCrEl.value !== '' ? +minCrEl.value : 0;
if (!name) { alert('Please enter a block name.'); return; }
if (id) {
const i = S.subBlocks.findIndex(x => x.id === id);
if (i >= 0) { S.subBlocks[i].name = name; S.subBlocks[i].minCredits = minCredits; }
} else {
S.subBlocks.push({ id: uid(), blockId, name, minCredits });
}
save(); closeModal(); render(page);
}
function deleteSubBlock(id) {
if (!confirm('Delete this block? Courses will lose their sub-block assignment.')) return;
S.subBlocks = S.subBlocks.filter(b => b.id !== id);
S.courses.forEach(c => { if (c.subBlockId === id) c.subBlockId = ''; });
save(); render(page);
}
function deleteBlock(id) {
if (!confirm('Delete this major/minor? All its blocks and course assignments will be cleared.')) return;
const sbIds = S.subBlocks.filter(sb => sb.blockId === id).map(sb => sb.id);
S.subBlocks = S.subBlocks.filter(sb => sb.blockId !== id);
S.courses.forEach(c => {
if (c.blockId === id) { c.blockId = ''; c.subBlockId = ''; }
else if (sbIds.includes(c.subBlockId)) { c.subBlockId = ''; }
});
S.blocks = S.blocks.filter(b => b.id !== id);
save(); render(page);
}
// ══════════════════════════════════════════════════════
// SEMESTER MODAL
// ══════════════════════════════════════════════════════
function showSemesterModal(id) {
const s = id ? S.semesters.find(x => x.id === id) : null;
const yr = s ? s.year : new Date().getFullYear();
const sea = s ? s.season : 'Autumn';
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${s ? 'Edit Semester' : 'New Semester'}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-row">
<div class="form-group">
<label>Season</label>
<select id="f-season">
<option value="Autumn" ${sea==='Autumn'?'selected':''}>Autumn</option>
<option value="Spring" ${sea==='Spring'?'selected':''}>Spring</option>
${settings.allSeasons ? `
<option value="Summer" ${sea==='Summer'?'selected':''}>Summer</option>
<option value="Winter" ${sea==='Winter'?'selected':''}>Winter</option>` : ''}
</select>
</div>
<div class="form-group">
<label>Year</label>
<input id="f-syear" type="number" value="${yr}" min="2000" max="2100">
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveSemester('${id || ''}')">Save</button>
</div>
</div>`);
}
function saveSemester(id) {
const season = document.getElementById('f-season').value;
const year = parseInt(document.getElementById('f-syear').value, 10);
const name = season + ' ' + year;
if (!season || !year) { alert('Please select a season and year.'); return; }
if (id) {
const i = S.semesters.findIndex(x => x.id === id);
if (i >= 0) S.semesters[i] = { ...S.semesters[i], name, season, year };
} else {
S.semesters.push({ id: uid(), name, season, year });
}
save(); closeModal(); render(page);
}
function deleteSemester(id) {
if (!confirm('Delete this semester? Courses will lose their semester assignment.')) return;
S.semesters = S.semesters.filter(s => s.id !== id);
S.courses.forEach(c => { if (c.semesterId === id) c.semesterId = ''; });
save(); render(page);
}
// ══════════════════════════════════════════════════════
// SETTINGS MODAL
// ══════════════════════════════════════════════════════
function settingsToggleRow(label, key) {
const on = !!settings[key];
return `<div style="display:flex;align-items:center;justify-content:space-between">
<span style="font-size:14px">${label}</span>
<label style="position:relative;display:inline-block;width:40px;height:22px;flex-shrink:0">
<input type="checkbox" ${on?'checked':''} onchange="toggleSetting('${key}',this.checked)"
style="opacity:0;width:0;height:0;position:absolute">
<span style="position:absolute;inset:0;border-radius:22px;background:${on?'var(--primary)':'var(--gray-300)'};cursor:pointer;transition:.2s">
<span style="position:absolute;left:${on?'20':'2'}px;top:2px;width:18px;height:18px;border-radius:50%;background:#fff;transition:.2s"></span>
</span>
</label>
</div>`;
}
function showSettingsModal() {
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Settings</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="section-label" style="margin-bottom:8px">Credit Requirements</div>
<div class="card" style="box-shadow:none;border:1px solid var(--gray-200)">
<div class="form-group">
<label>Bachelor Minimum Credits</label>
<input type="number" value="${settings.minCreditsBachelor}" min="1" step="1"
oninput="updateSetting('minCreditsBachelor',this.value)">
</div>
<div class="form-group" style="margin-bottom:0">
<label>Master Minimum Credits</label>
<input type="number" value="${settings.minCreditsMaster}" min="1" step="1"
oninput="updateSetting('minCreditsMaster',this.value)">
</div>
</div>
<div class="section-label" style="margin:16px 0 8px">Semesters</div>
<div class="card" style="box-shadow:none;border:1px solid var(--gray-200)">
${settingsToggleRow('Show all seasons (Summer & Winter)', 'allSeasons')}
</div>
<div class="section-label" style="margin:16px 0 8px">Study Plans</div>
<div class="card" style="box-shadow:none;border:1px solid var(--gray-200)">
${settingsToggleRow('Show "Optional Studies" section', 'showOptional')}
</div>
<button class="btn btn-secondary btn-full" style="margin-top:16px" onclick="closeModal()">Done</button>
</div>`);
}
function updateSetting(key, val) {
const n = parseInt(val, 10);
if (n > 0) { settings[key] = n; saveSettings(); renderDash(); }
}
function toggleSetting(key, val) {
settings[key] = val;
saveSettings();
renderDash();
showSettingsModal();
}
// ══════════════════════════════════════════════════════
// UTILITIES
// ══════════════════════════════════════════════════════
function emptyState(icon, text) {
return `<div class="empty"><div class="empty-icon">${icon}</div><div class="empty-text">${text}</div></div>`;
}
// ══════════════════════════════════════════════════════
// DATA EXPORT / IMPORT
// ══════════════════════════════════════════════════════
function showDataModal() {
const total = S.courses.length;
const plans = S.studyPlans.length;
const sems = S.semesters.length;
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Data Management</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div style="background:var(--primary-light);border-radius:10px;padding:12px 14px;margin-bottom:18px;font-size:13px;color:var(--primary)">
<strong>${total}</strong> course${total!==1?'s':''} · <strong>${plans}</strong> plan${plans!==1?'s':''} · <strong>${sems}</strong> semester${sems!==1?'s':''} stored locally
</div>
<div class="section-label" style="margin-bottom:8px">Sync via QR Code</div>
<p style="font-size:13px;color:var(--gray-500);margin-bottom:10px">Show a QR code on this device, scan it on the other one.</p>
<div style="display:flex;gap:10px">
<button class="btn btn-primary" style="flex:1;justify-content:center" onclick="showQRShare()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><path d="M14 14h1v1h-1z M17 14h1v1h-1z M14 17h1v1h-1z M17 17h3v3h-3z"/></svg>
Show QR
</button>
<button class="btn btn-secondary" style="flex:1;justify-content:center" onclick="showQRScan()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
Scan QR
</button>
</div>
<div style="border-top:1px solid var(--gray-200);margin:18px 0 14px"></div>
<div class="section-label" style="margin-bottom:8px">Export / Import JSON</div>
<p style="font-size:13px;color:var(--gray-500);margin-bottom:10px">Download a JSON backup or load one from a file.</p>
<button class="btn btn-secondary btn-full" style="margin-bottom:10px" onclick="exportData()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export JSON
</button>
<label class="file-input-label" for="f-import">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Choose JSON file
</label>
<input type="file" id="f-import" accept=".json,application/json" onchange="importData(this)">
<div style="border-top:1px solid var(--gray-200);margin:18px 0 14px"></div>
<button class="btn btn-danger btn-full" onclick="clearData()">🗑 Clear all data</button>
</div>`);
}
function exportData() {
const payload = {
_version: 1,
_exported: new Date().toISOString(),
studyPlans: S.studyPlans,
blocks: S.blocks,
subBlocks: S.subBlocks,
semesters: S.semesters,
courses: S.courses,
schedule: S.schedule
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const ts = new Date().toISOString().slice(0, 10);
const a = document.createElement('a');
a.href = url;
a.download = `credittracker-${ts}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function importData(input) {
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const data = JSON.parse(e.target.result);
if (!Array.isArray(data.courses) || !Array.isArray(data.semesters)) {
alert('Invalid file: missing required fields.');
return;
}
if (!confirm(`This will replace all current data with:\n• ${data.courses.length} courses\n• ${(data.studyPlans||[]).length} study plans\n• ${data.semesters.length} semesters\n\nContinue?`)) return;
S.studyPlans = data.studyPlans || [];
S.blocks = data.blocks || [];
S.subBlocks = data.subBlocks || [];
S.semesters = data.semesters || [];
S.courses = data.courses || [];
S.schedule = data.schedule || [];
save();
closeModal();
render(page);
alert('Data imported successfully!');
} catch (err) {
alert('Could not read file. Make sure it is a valid Credit Tracker JSON export.');
}
};
reader.readAsText(file);
}
function clearData() {
if (!confirm('Clear ALL data? This cannot be undone.')) return;
S = { studyPlans: [], blocks: [], subBlocks: [], semesters: [], courses: [], schedule: [] };
save();
closeModal();
render(page);
}
// ══════════════════════════════════════════════════════
// QR CODE SHARE / SCAN
// ══════════════════════════════════════════════════════
const QR_URLS = ['https://unpkg.com/qrcode@1.5.4/build/qrcode.min.js', 'https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js'];
const JSQR_URLS = ['https://unpkg.com/jsqr@1.4.0/dist/jsQR.js', 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js'];
async function loadScriptCached(urls, globalName) {
if (window[globalName]) return;
const key = 'ct_lib_' + globalName;
let code = null;
try { code = localStorage.getItem(key); } catch(e) {}
if (!code) {
for (const url of urls) {
try {
const r = await fetch(url);
if (r.ok) { code = await r.text(); break; }
} catch(e) {}
}
if (!code) throw new Error('Could not load the ' + globalName + ' library. Open this app once with an internet connection to cache it for offline use.');
try { localStorage.setItem(key, code); } catch(e) {}
}
const s = document.createElement('script');
s.textContent = code;
document.head.appendChild(s);
if (!window[globalName]) throw new Error(globalName + ' library failed to initialize.');
}
async function compress(str) {
if (!window.CompressionStream) return btoa(unescape(encodeURIComponent(str)));
const bytes = new TextEncoder().encode(str);
const cs = new CompressionStream('deflate-raw');
const w = cs.writable.getWriter();
w.write(bytes); w.close();
const buf = await new Response(cs.readable).arrayBuffer();
// base64 in chunks to avoid call-stack overflow on large arrays
const u8 = new Uint8Array(buf);
let b64 = '';
for (let i = 0; i < u8.length; i += 0x8000)
b64 += String.fromCharCode(...u8.subarray(i, i + 0x8000));
return btoa(b64);
}
async function decompress(b64) {
const binary = atob(b64);
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
if (!window.DecompressionStream) return decodeURIComponent(escape(binary));
const ds = new DecompressionStream('deflate-raw');
const w = ds.writable.getWriter();
w.write(bytes); w.close();
return new Response(ds.readable).text();
}
async function showQRShare() {
const isDark = document.documentElement.classList.contains('dark');
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Share via QR Code</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div style="text-align:center;padding:4px 0 8px">
<p id="qr-status" style="font-size:12px;color:var(--gray-400);margin-bottom:12px">Generating…</p>
<canvas id="qr-canvas" style="border-radius:10px;max-width:100%"></canvas>
</div>
<p style="font-size:12px;color:var(--gray-400);text-align:center;margin-top:8px">
Scan this on your other device to import all data
</p>
<div style="border-top:1px solid var(--gray-200);margin:16px 0 0"></div>
<button class="btn btn-secondary btn-full" style="margin-top:12px" onclick="showQRScan()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
Switch to Scan mode
</button>
</div>`);
try {
await loadScriptCached(QR_URLS, 'QRCode');
const payload = JSON.stringify({ _ct: 'ct1', studyPlans: S.studyPlans, blocks: S.blocks, subBlocks: S.subBlocks, semesters: S.semesters, courses: S.courses, schedule: S.schedule });
const encoded = await compress(payload);
const statusEl = document.getElementById('qr-status');
const canvas = document.getElementById('qr-canvas');
if (!canvas) return;
if (statusEl) statusEl.textContent = `${S.courses.length} course${S.courses.length !== 1 ? 's' : ''} · ${encoded.length} bytes encoded`;
await QRCode.toCanvas(canvas, encoded, {
errorCorrectionLevel: 'L',
width: Math.min(260, window.innerWidth - 80),
margin: 1,
color: { dark: isDark ? '#f1f5f9' : '#111827', light: isDark ? '#1e293b' : '#ffffff' }
});
} catch (e) {
console.error('QR generation failed:', e);
const st = document.getElementById('qr-status');
if (st) {
st.style.color = 'var(--danger)';
const msg = e?.message || '';
if (msg.includes('too big') || msg.includes('too large') || msg.includes('data is too')) {
st.textContent = 'Too much data for a single QR code — use Export JSON instead.';
} else {
st.textContent = msg || 'Failed to generate QR code.';
}
}
}
}
let _scanStream = null;
let _scanRaf = null;
async function showQRScan() {
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Scan QR Code</span>
<button class="modal-close" onclick="_stopScan();closeModal()">✕</button>
</div>
<div style="position:relative;background:#000;border-radius:12px;overflow:hidden">
<video id="qr-video" style="width:100%;display:block" playsinline muted></video>
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none">
<div style="width:55%;aspect-ratio:1;border:2.5px solid rgba(255,255,255,0.75);border-radius:12px;box-shadow:0 0 0 2000px rgba(0,0,0,0.35)"></div>
</div>
</div>
<canvas id="qr-scan-canvas" style="display:none"></canvas>
<p id="scan-status" style="font-size:13px;color:var(--gray-500);text-align:center;margin-top:12px">Point camera at QR code…</p>
<button class="btn btn-secondary btn-full" style="margin-top:10px" onclick="showQRShare()">
Switch to Show QR mode
</button>
</div>`);
try {
await loadScriptCached(JSQR_URLS, 'jsQR');
_scanStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
const video = document.getElementById('qr-video');
if (!video) { _stopScan(); return; }
video.srcObject = _scanStream;
await video.play();
_scanFrame(video);
} catch (e) {
const st = document.getElementById('scan-status');
if (st) {
st.style.color = 'var(--danger)';
st.textContent = e.name === 'NotAllowedError' ? 'Camera permission denied.' : 'Camera not available on this device.';
}
}
}
function _scanFrame(video) {
const canvas = document.getElementById('qr-scan-canvas');
if (!canvas || !video.videoWidth) { _scanRaf = requestAnimationFrame(() => _scanFrame(video)); return; }
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(video, 0, 0);
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(img.data, canvas.width, canvas.height, { inversionAttempts: 'dontInvert' });
if (code) { _stopScan(); _importFromQR(code.data); }
else _scanRaf = requestAnimationFrame(() => _scanFrame(video));
}
function _stopScan() {
if (_scanRaf) { cancelAnimationFrame(_scanRaf); _scanRaf = null; }
if (_scanStream) { _scanStream.getTracks().forEach(t => t.stop()); _scanStream = null; }
}
// ── Two-way sync helpers ──────────────────────────────────────
function _mergeById(local, remote) {
const map = {};
local.forEach(x => { map[x.id] = x; });
remote.forEach(x => { if (!map[x.id]) map[x.id] = x; });
return Object.values(map);
}
function _semName(id, semesters) {
if (!id) return '—';
const s = semesters.find(x => x.id === id);
return s ? s.name : id;
}
function _fieldLabel(f) {
return { credits: 'Credits', grade: 'Grade', semesterId: 'Semester' }[f] || f;
}
function _fieldVal(f, v, semesters) {
if (f === 'semesterId') return _semName(v, semesters);
if (f === 'grade') return (v !== null && v !== undefined) ? String(v) : '—';
if (f === 'credits') return (v !== null && v !== undefined) ? v + ' cr.' : '—';
return String(v ?? '—');
}
function _mergeAndSync(parsed) {
const mergedBase = {
studyPlans: _mergeById(S.studyPlans, parsed.studyPlans || []),
blocks: _mergeById(S.blocks, parsed.blocks || []),
subBlocks: _mergeById(S.subBlocks, parsed.subBlocks || []),
semesters: _mergeById(S.semesters, parsed.semesters || []),
schedule: _mergeById(S.schedule, parsed.schedule || []),
courses: []
};
const remoteCourses = parsed.courses || [];
const localIds = new Set(S.courses.map(c => c.id));
const remoteIds = new Set(remoteCourses.map(c => c.id));
const conflictList = [];
const noCflCourses = [];
remoteCourses.filter(c => !localIds.has(c.id)).forEach(c => noCflCourses.push(c));
S.courses.filter(c => !remoteIds.has(c.id)).forEach(c => noCflCourses.push(c));
S.courses.filter(c => remoteIds.has(c.id)).forEach(local => {
const remote = remoteCourses.find(r => r.id === local.id);
const diffs = [];
['credits','grade','semesterId'].forEach(f => {
const lv = local[f] ?? null;
const rv = remote[f] ?? null;
if (String(lv) !== String(rv)) diffs.push({ field: f, mine: lv, theirs: rv });
});
if (diffs.length) {
conflictList.push({ local, remote, diffs, choices: Object.fromEntries(diffs.map(d => [d.field, 'mine'])) });
} else {
noCflCourses.push(local);
}
});
mergedBase.courses = noCflCourses;
_syncMergedBase = mergedBase;
_syncConflicts = conflictList;
if (!conflictList.length) {
Object.assign(S, mergedBase);
save(); closeModal(); render(page);
showResponseQR();
} else {
_showConflictModal();
}
}
function _showConflictModal() {
const sems = _syncMergedBase.semesters;
const rows = _syncConflicts.map((c, ci) => `
<div style="border:1px solid var(--gray-200);border-radius:10px;padding:12px;margin-bottom:10px">
<div style="font-size:14px;font-weight:600;color:var(--gray-800);margin-bottom:8px">📚 ${esc(c.local.name)}</div>
${c.diffs.map(d => `
<div style="margin-bottom:8px">
<div style="font-size:11px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px">${_fieldLabel(d.field)}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<label id="sl_${ci}_${d.field}_mine" style="display:flex;align-items:center;gap:6px;padding:8px;border:1.5px solid var(--primary);border-radius:8px;cursor:pointer;font-size:13px">
<input type="radio" name="cf_${ci}_${d.field}" value="mine" checked onchange="syncChoice(${ci},'${d.field}','mine')">
<div><div style="font-size:10px;color:var(--gray-400);text-transform:uppercase;font-weight:600">Mine</div>${esc(_fieldVal(d.field, d.mine, sems))}</div>
</label>
<label id="sl_${ci}_${d.field}_theirs" style="display:flex;align-items:center;gap:6px;padding:8px;border:1.5px solid var(--gray-200);border-radius:8px;cursor:pointer;font-size:13px">
<input type="radio" name="cf_${ci}_${d.field}" value="theirs" onchange="syncChoice(${ci},'${d.field}','theirs')">
<div><div style="font-size:10px;color:var(--gray-400);text-transform:uppercase;font-weight:600">Theirs</div>${esc(_fieldVal(d.field, d.theirs, sems))}</div>
</label>
</div>
</div>
`).join('')}
</div>
`).join('');
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Sync Conflicts</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:13px;color:var(--gray-500);margin-bottom:14px">
${_syncConflicts.length} course${_syncConflicts.length > 1 ? 's have' : ' has'} conflicting data.
Choose which version to keep for each field.
</p>
${rows}
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="applyMergedSync()">Apply &amp; Generate Response QR</button>
</div>
</div>`);
}
function syncChoice(ci, field, value) {
if (_syncConflicts[ci]) _syncConflicts[ci].choices[field] = value;
['mine','theirs'].forEach(v => {
const el = document.getElementById(`sl_${ci}_${field}_${v}`);
if (el) el.style.border = `1.5px solid ${v === value ? 'var(--primary)' : 'var(--gray-200)'}`;
});
}
function applyMergedSync() {
_syncConflicts.forEach(c => {
const course = { ...c.local };
c.diffs.forEach(d => {
if ((c.choices[d.field] || 'mine') === 'theirs') course[d.field] = d.theirs;
});
_syncMergedBase.courses.push(course);
});
Object.assign(S, _syncMergedBase);
save(); render(page);
showResponseQR();
}
async function showResponseQR() {
const isDark = document.documentElement.classList.contains('dark');
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Sync Complete</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:13px;color:var(--gray-600);margin-bottom:8px">
Data merged. <strong>Have the other device scan this QR</strong> to complete the two-way sync.
</p>
<div style="text-align:center;padding:8px 0">
<p id="qr-status" style="font-size:12px;color:var(--gray-400);margin-bottom:10px">Generating…</p>
<canvas id="qr-canvas" style="border-radius:10px;max-width:100%"></canvas>
</div>
<button class="btn btn-secondary btn-full" style="margin-top:12px" onclick="closeModal()">Done</button>
</div>`);
try {
await loadScriptCached(QR_URLS, 'QRCode');
const payload = JSON.stringify({ _ct: 'ct1', studyPlans: S.studyPlans, blocks: S.blocks, subBlocks: S.subBlocks, semesters: S.semesters, courses: S.courses, schedule: S.schedule });
const encoded = await compress(payload);
const canvas = document.getElementById('qr-canvas');
const statusEl = document.getElementById('qr-status');
if (!canvas) return;
if (statusEl) statusEl.textContent = `${S.courses.length} courses · ${encoded.length} bytes`;
await QRCode.toCanvas(canvas, encoded, {
errorCorrectionLevel: 'L',
width: Math.min(260, window.innerWidth - 80),
margin: 1,
color: { dark: isDark ? '#f1f5f9' : '#111827', light: isDark ? '#1e293b' : '#ffffff' }
});
} catch (e) {
const st = document.getElementById('qr-status');
if (st) { st.style.color = 'var(--danger)'; st.textContent = e?.message || 'Failed to generate QR code.'; }
}
}
async function _importFromQR(encoded) {
try {
const json = await decompress(encoded);
const parsed = JSON.parse(json);
if (parsed._ct !== 'ct1' || !Array.isArray(parsed.courses)) throw new Error('invalid');
const incomingNew = (parsed.courses || []).filter(c => !S.courses.find(x => x.id === c.id)).length;
const msg = `Sync data from QR?\n• ${parsed.courses.length} courses incoming (${incomingNew} new)\n• ${(parsed.studyPlans||[]).length} plans · ${(parsed.semesters||[]).length} semesters\n\nYour data will be merged. Conflicts will be shown before saving.`;
if (!confirm(msg)) return;
_mergeAndSync(parsed);
} catch (e) {
alert('Could not read this QR code. Make sure it was generated by Credit Tracker.');
}
}
// ══════════════════════════════════════════════════════
// INIT
// ══════════════════════════════════════════════════════
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js').catch(err => {
console.warn('SW registration failed:', err);
});
});
}
load();
render('dashboard');
</script>
</body>
</html>