Files
CreditTracker/index.html
T
2026-07-02 01:55:13 +02:00

2317 lines
104 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-fail { background: var(--danger-light); color: #991B1B; }
.badge-prog { background: var(--warning-light); color: #92400E; }
.badge-info { background: var(--primary-light); color: #3730A3; }
.badge-cycle { background: var(--gray-100); color: var(--gray-600); font-size: 10px; }
:root.dark .badge-pass { background: #022c22; color: #6ee7b7; }
:root.dark .badge-fail { background: #450a0a; color: #fca5a5; }
:root.dark .badge-prog { background: #451a03; color: #fcd34d; }
:root.dark .badge-info { background: #1e1b4b; color: #a5b4fc; }
/* ── Buttons ── */
.btn {
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); }
/* ── 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; max-height: 60vh; }
.wk-body { display: flex; }
.wk-tcol { width: 40px; flex-shrink: 0; position: relative; }
.wk-tlbl { position: absolute; font-size: 10px; color: var(--gray-400); right: 5px; transform: translateY(-50%); white-space: nowrap; }
.wk-days { display: flex; flex: 1; }
.wk-dcol { flex: 1; position: relative; border-left: 1px solid var(--gray-100); min-width: 0; }
.wk-gline { position: absolute; left: 0; right: 0; border-top: 1px solid var(--gray-100); }
.wk-gline-h { border-top-color: var(--gray-200); }
.wk-event {
position: absolute; left: 2px; right: 2px;
border-radius: 4px; font-size: 10px; font-weight: 600;
padding: 2px 4px; overflow: hidden; cursor: pointer;
color: #fff; line-height: 1.3;
}
.wk-event:active { filter: brightness(0.85); }
.ev-swatch {
display: inline-block; width: 26px; height: 26px; border-radius: 50%;
cursor: pointer; flex-shrink: 0;
transition: outline 0.1s;
}
</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';
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) return 'pass';
if (course.grade == null || course.grade === '') return 'prog';
if (+course.grade >= 4) return 'pass';
if (course.blockId) {
const m = blockMean(course.blockId);
if (m !== null && m >= 4) return 'pass';
}
return 'fail';
}
function badge(st) {
if (st === 'pass') return '<span class="badge badge-pass">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) return '<span style="font-size:11px;font-weight:600;color:var(--success)">P/F</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 => status(c) === 'pass').length;
const credits = S.courses.filter(c => status(c) === 'pass').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 => status(c) === 'pass');
const totCr = all.reduce((s, c) => s + (+c.credits || 0), 0);
const passCr = passed.reduce((s, c) => s + (+c.credits || 0), 0);
const sumW = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0);
const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0);
const mean = sumC > 0 ? sumW / sumC : null;
const passedGraded = graded.filter(c => status(c) === 'pass').length;
const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null;
document.getElementById('dash-stats').innerHTML = `<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 => status(c) === 'pass');
const totCr = all.reduce((s, c) => s + (+c.credits || 0), 0);
const passCr = passed.reduce((s, c) => s + (+c.credits || 0), 0);
const sumW = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0);
const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0);
const mean = sumC > 0 ? sumW / sumC : null;
const passedGraded = graded.filter(c => status(c) === 'pass').length;
const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null;
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>`;
function roleGroupSection(planId, label, role, blocks, allPlanCourses, planColor) {
const headerColor = role === 'major'
? planColor
: (blocks.length ? blockEffectiveColor(blocks[0]) : MINOR_PALETTE[role === 'optional' ? 4 : 0]);
const addLabel = role === 'major' ? 'Major' : role === 'minor' ? 'Minor' : 'Optional';
const canAdd = role !== 'major' || !blocks.length;
let html = `<div style="margin-top:12px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:${headerColor}">${label}</span>
${canAdd ? `<button class="btn btn-sm btn-secondary" onclick="showMinorModal('${planId}','','${role}')">+ ${addLabel}</button>` : ''}
</div>`;
if (blocks.length) {
blocks.forEach(blk => { html += planBlockSection(planId, blk, '', allPlanCourses); });
} else {
html += `<div style="font-size:13px;color:var(--gray-400);padding:2px 0 4px">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 => status(c) === 'pass').reduce((s, c) => s + (+c.credits || 0), 0);
const minCr = plan.cycle === 'bachelor' ? settings.minCreditsBachelor
: plan.cycle === 'master' ? settings.minCreditsMaster : null;
const creditOk = minCr === null || totCr >= minCr;
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);
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:6px;flex-wrap:wrap;flex:1;min-width:0">
<span class="card-title">${esc(plan.name)}</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>`;
// Major section
html += roleGroupSection(plan.id, 'Major', 'major', majors, pc, pClr);
// Minor section
html += roleGroupSection(plan.id, 'Minor', 'minor', minors, pc, pClr);
// Optional Studies section (only when enabled in settings)
if (settings.showOptional) {
html += roleGroupSection(plan.id, 'Optional Studies', 'optional', optionals, pc, pClr);
}
// Legacy blocks (no role)
others.forEach(blk => { html += planBlockSection(plan.id, blk, '', pc); });
// Courses without a block
const nbc = pc.filter(c => !c.blockId);
if (nbc.length) {
html += `<div class="block-header" style="margin-top:8px"><span class="block-title" style="color:var(--gray-400)">No block</span></div>`;
html += coursesMgmtList(nbc);
}
html += `<div style="display:flex;gap:6px;margin-top:10px">
<button class="btn btn-secondary" style="flex:1;justify-content:center;font-size:13px" onclick="showCourseModalPrefilled('${plan.id}','','')">+ Course</button>
</div></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, roleLabel, allPlanCourses) {
const bc = allPlanCourses.filter(c => c.blockId === blk.id);
const bm = blockMean(blk.id);
const bCr = bc.reduce((s, c) => s + (+c.credits || 0), 0);
const bClr = blockEffectiveColor(blk);
const sbs = S.subBlocks.filter(sb => sb.blockId === blk.id);
const freeC = bc.filter(c => !c.subBlockId);
const roleTxt = roleLabel
? `<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:${bClr};margin-right:4px">${roleLabel}</span>`
: '';
let html = `<div style="border-left:3px solid ${bClr};padding-left:8px;margin-top:10px">
<div class="block-header" style="border:none;padding-top:0;margin-top:0">
<span class="block-title">${roleTxt}${esc(blk.name)}</span>
<div style="display:flex;align-items:center;gap:4px">
<span class="block-meta">${bCr} cr.${bm !== null ? ` · avg <strong class="${gradeClass(bm)}">${bm.toFixed(2)}</strong>` : ''}</span>
<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>
</div>`;
sbs.forEach(sb => {
const sbC = bc.filter(c => c.subBlockId === sb.id);
html += planSubBlockSection(planId, blk.id, sb, sbC, bClr);
});
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)}
</div>`;
} else if (!sbs.length) {
html += `<div style="font-size:12px;color:var(--gray-400);padding:3px 0">No courses yet</div>`;
}
html += `<div style="display:flex;gap:5px;margin-top:6px">
<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>
</div>`;
return html;
}
function planSubBlockSection(planId, blockId, sb, courses, parentColor) {
const sbCr = courses.reduce((s, c) => s + (+c.credits || 0), 0);
const sbClr = subBlockEffectiveColor(sb);
return `<div style="border-left:2px dashed ${sbClr};padding-left:8px;margin-top:7px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:2px">
<span style="font-size:13px;font-weight:600;color:${sbClr}">${esc(sb.name)}</span>
<div style="display:flex;align-items:center;gap:4px">
<span style="font-size:11px;color:var(--gray-400)">${sbCr} cr.</span>
<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>
</div>
${courses.length ? coursesMgmtList(courses, false, sbClr) : '<div style="font-size:12px;color:var(--gray-400);padding:2px 0">No courses yet</div>'}
<button class="btn btn-sm btn-secondary" style="margin-top:4px" onclick="showCourseModalPrefilled('${planId}','${blockId}','','${sb.id}')">+ Course</button>
</div>`;
}
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 => status(c) === 'pass').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) {
if (!courses.length) return '<div style="font-size:12px;color:var(--gray-400);padding:5px 0">No courses yet</div>';
return courses.map(c => {
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)}
<span class="${gradeClass(c.grade)}" style="font-size:14px;font-weight:700;min-width:28px;text-align:right">${fmtGrade(c.grade, c.passFail)}</span>
<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">
<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;
// 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
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 isPF = c ? !!c.passFail : false;
_slots = c && c.slots ? c.slots.map(s => ({ ...s })) : [];
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:${isPF ? 'none' : 'block'}">
<label>Grade (1 6)</label>
<input id="f-grade" type="number" value="${c && !isPF && c.grade != null && c.grade !== '' ? c.grade : ''}" placeholder="e.g. 5.5" min="1" max="6" step="0.25">
</div>
</div>
<label class="pf-toggle" for="f-passfail">
<div>
<div class="pf-toggle-text">Pass / Fail course</div>
<div class="pf-toggle-sub">Counts for credits but excluded from grade means</div>
</div>
<div class="switch">
<input type="checkbox" id="f-passfail" ${isPF ? 'checked' : ''} onchange="onPassFailChange(this.checked)">
<div class="switch-track"></div>
<div class="switch-thumb"></div>
</div>
</label>
<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:flex;gap:5px;align-items:center">
<select id="f-slot-day" style="flex:0 0 auto;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>
<input id="f-slot-start" type="time" value="08:00" style="flex:1">
<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">
<button class="btn btn-sm btn-primary" onclick="addSlot()" style="flex-shrink:0">+ Add</button>
</div>
</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 onPassFailChange(checked) {
const fgGrade = document.getElementById('fg-grade');
if (fgGrade) fgGrade.style.display = checked ? 'none' : 'block';
}
function saveCourse(id) {
const name = document.getElementById('f-name').value.trim();
const credits = document.getElementById('f-credits').value;
const passFail = document.getElementById('f-passfail').checked;
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 && gradeV !== '') ? parseFloat(gradeV) : null;
const slots = [..._slots];
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 };
} else {
S.courses.push({ id: uid(), name, credits: +credits, semesterId: semId, grade, planId, blockId, subBlockId, passFail, slots });
}
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>Plan Name *</label>
<input id="f-pname" type="text" value="${defName}" placeholder="${label} 1">
</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 name = document.getElementById('f-pname').value.trim();
const majorName = document.getElementById('f-major').value.trim();
const color = document.getElementById('f-pcolor')?.value || '';
if (!name) { alert('Please enter a plan name.'); return; }
let planId = id;
if (id) {
const i = S.studyPlans.findIndex(x => x.id === id);
if (i >= 0) { S.studyPlans[i].name = name; 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);
}
// ══════════════════════════════════════════════════════
// 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-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="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 || '';
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; }
} else {
S.blocks.push({ id: uid(), planId, name, role, color });
}
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-group">
<label>Block Name *</label>
<input id="f-sbname" type="text" value="${sb ? esc(sb.name) : ''}" placeholder="e.g. Algebra">
</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();
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;
} else {
S.subBlocks.push({ id: uid(), blockId, name });
}
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_LIB = 'https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js';
const JSQR_LIB = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js';
function loadScript(src) {
return new Promise((res, rej) => {
if (document.querySelector(`script[src="${src}"]`)) { res(); return; }
const s = document.createElement('script');
s.src = src; s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
}
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 loadScript(QR_LIB);
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) {
const st = document.getElementById('qr-status');
if (st) {
st.style.color = 'var(--danger)';
st.textContent = e.message && e.message.includes('too big')
? 'Too much data for a single QR code — use Export JSON instead.'
: '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 loadScript(JSQR_LIB);
_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; }
}
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 msg = `Import data from QR code?\n• ${parsed.courses.length} courses\n• ${(parsed.studyPlans||[]).length} plans\n• ${parsed.semesters.length} semesters\n\nThis replaces all current data.`;
if (!confirm(msg)) return;
S.studyPlans = parsed.studyPlans || [];
S.blocks = parsed.blocks || [];
S.subBlocks = parsed.subBlocks || [];
S.semesters = parsed.semesters || [];
S.courses = parsed.courses || [];
S.schedule = parsed.schedule || [];
save(); closeModal(); render(page);
alert('Data imported successfully!');
} 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>