Files
CreditTracker/index.html
T
MatsuneMikuroiandClaude Sonnet 5 eb7e64ee06 Add ICS import/export with course linking, QR sync improvements, and UI polish
- Group imported ICS occurrences per weekly slot, hatch irregular/half-semester
  sessions, and link them to existing courses by stable code (not name, since
  university exports mix languages) with a one-time manual-match fallback
- Preserve exact per-date occurrences from imported ICS so exported calendars
  correctly skip holiday/vacation gaps instead of assuming a flat weekly rule
- Add "Export ICS" for a semester's course + manual schedule, prompting once
  for semester start/end dates (now also editable directly on the semester)
- Fix pinned QRCode CDN version (package dropped its browser bundle) and add
  a send/receive choice after scanning instead of always auto two-way syncing
- Fix course save silently adding a phantom Monday slot from picker defaults;
  replace with an explicit "No fixed schedule / Has a weekly schedule" toggle
- Service worker: network-first app shell so edits show up without manual
  cache-busting, self-update + reload on new worker, version indicator
- Dashboard: fold older semesters by default and jump to the current one,
  optional auto-fold for validated blocks/sub-blocks, JSON backup nudge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:39:50 +02:00

3462 lines
164 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Credit Tracker</title>
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#4F46E5">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Credits">
<link rel="apple-touch-icon" href="icon.svg">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #4F46E5;
--primary-light: #EEF2FF;
--success: #059669;
--success-light: #D1FAE5;
--danger: #DC2626;
--danger-light: #FEE2E2;
--warning: #D97706;
--warning-light: #FEF3C7;
--gray-50: #F9FAFB;
--gray-100: #F3F4F6;
--gray-200: #E5E7EB;
--gray-300: #D1D5DB;
--gray-400: #9CA3AF;
--gray-500: #6B7280;
--gray-600: #4B5563;
--gray-700: #374151;
--gray-800: #1F2937;
--gray-900: #111827;
--bg-body: #F3F4F6;
--bg-surface: #ffffff;
}
:root.dark {
--primary: #818CF8;
--primary-light: #1e1b4b;
--success: #34D399;
--success-light: #022c22;
--danger: #F87171;
--danger-light: #450a0a;
--warning: #FBBF24;
--warning-light: #451a03;
--gray-50: #263350;
--gray-100: #1e293b;
--gray-200: #334155;
--gray-300: #475569;
--gray-400: #64748b;
--gray-500: #94a3b8;
--gray-600: #cbd5e1;
--gray-700: #e2e8f0;
--gray-800: #f1f5f9;
--gray-900: #f8fafc;
--bg-body: #0f172a;
--bg-surface: #1e293b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-body);
color: var(--gray-800);
min-height: 100vh;
padding-bottom: 20px;
transition: background 0.2s, color 0.2s;
}
/* ── Header ── */
.header {
background: var(--primary);
color: white;
padding: 14px 16px;
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
}
.header-sub { font-size: 11px; opacity: 0.75; margin-top: 1px; }
.header-right { display: flex; gap: 6px; align-items: center; }
/* ── Content ── */
.content { max-width: 680px; margin: 0 auto; padding: 14px 12px; }
.page { display: none; }
.page.active { display: block; }
/* ── FAB ── */
.fab {
position: fixed;
bottom: 82px; right: 16px;
width: 50px; height: 50px;
border-radius: 50%;
background: var(--primary);
color: white;
border: none;
cursor: pointer;
box-shadow: 0 4px 14px rgba(79,70,229,0.45);
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 300;
z-index: 99;
transition: transform 0.15s, box-shadow 0.15s;
-webkit-tap-highlight-color: transparent;
}
.fab:active { transform: scale(0.93); box-shadow: 0 2px 8px rgba(79,70,229,0.3); }
/* ── Cards ── */
.card {
background: var(--bg-surface);
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.05);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.card-title { font-size: 15px; font-weight: 600; color: var(--gray-700); }
/* ── Stats Grid ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.stat-card {
background: var(--bg-surface);
border-radius: 12px;
padding: 14px 12px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.07);
}
.stat-value { font-size: 26px; font-weight: 700; color: var(--primary); line-height: 1; }
.stat-label { font-size: 11px; color: var(--gray-500); margin-top: 4px; }
/* ── Tables ── */
.table-wrap { overflow-x: auto; border-radius: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
background: var(--gray-50);
padding: 8px 10px;
text-align: left;
font-weight: 600;
color: var(--gray-500);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--gray-200);
white-space: nowrap;
}
td {
padding: 9px 10px;
border-bottom: 1px solid var(--gray-100);
color: var(--gray-700);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr.clickable:hover td { background: var(--gray-50); cursor: pointer; }
/* ── Badges ── */
.badge {
display: inline-block;
padding: 2px 7px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.badge-pass { background: var(--success-light); color: #065F46; }
.badge-comp { background: var(--danger-light); color: #991B1B; }
.badge-fail { background: var(--danger-light); color: #991B1B; }
.badge-prog { background: var(--warning-light); color: #92400E; }
.badge-info { background: var(--primary-light); color: #3730A3; }
.badge-cycle { background: var(--gray-100); color: var(--gray-600); font-size: 10px; }
:root.dark .badge-pass { background: #022c22; color: #6ee7b7; }
:root.dark .badge-comp { background: #450a0a; color: #fca5a5; }
:root.dark .badge-fail { background: #450a0a; color: #fca5a5; }
:root.dark .badge-prog { background: #451a03; color: #fcd34d; }
:root.dark .badge-info { background: #1e1b4b; color: #a5b4fc; }
/* ── Buttons ── */
.btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 8px 14px;
border-radius: 8px; border: none;
cursor: pointer; font-size: 14px; font-weight: 500;
-webkit-tap-highlight-color: transparent;
transition: opacity 0.15s;
}
.btn:active { opacity: 0.75; }
.btn-primary { background: var(--primary); color: white; }
.btn-secondary { background: var(--gray-100); color: var(--gray-700); }
.btn-danger { background: var(--danger-light); color: var(--danger); }
.btn-sm { padding: 4px 9px; font-size: 12px; border-radius: 6px; }
.btn-full { width: 100%; justify-content: center; }
/* ── Grade colors ── */
.g-high { color: var(--success); font-weight: 700; }
.g-mid { color: var(--warning); font-weight: 700; }
.g-low { color: var(--danger); font-weight: 700; }
/* ── Modal ── */
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.45);
z-index: 200;
display: flex; align-items: flex-end; justify-content: center;
}
.modal {
background: var(--bg-surface);
border-radius: 20px 20px 0 0;
padding: 20px 16px 28px;
width: 100%; max-width: 680px;
max-height: 92vh; overflow-y: auto;
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 18px;
}
.modal-title { font-size: 17px; font-weight: 700; color: var(--gray-800); }
.modal-close {
width: 30px; height: 30px; border-radius: 50%;
background: var(--gray-100); border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: var(--gray-500); font-size: 14px;
}
.modal-actions { display: flex; gap: 10px; margin-top: 12px; }
.modal-actions .btn { flex: 1; justify-content: center; }
.modal-actions .btn-primary { flex: 2; }
/* ── Forms ── */
.form-group { margin-bottom: 14px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
label { display: block; font-size: 12px; font-weight: 600; color: var(--gray-500); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.04em; }
input:not([type="checkbox"]), select {
width: 100%; padding: 10px 12px;
border: 1.5px solid var(--gray-200); border-radius: 8px;
font-size: 15px; color: var(--gray-800); background: var(--bg-surface);
-webkit-appearance: auto; appearance: auto;
transition: border-color 0.15s;
}
input:not([type="checkbox"]):focus, select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
input[type="checkbox"] {
width: 18px; height: 18px;
cursor: pointer; flex-shrink: 0;
accent-color: var(--primary);
}
/* ── List items ── */
.list-item {
display: flex; align-items: center; justify-content: space-between;
padding: 11px 0;
border-bottom: 1px solid var(--gray-100);
}
.list-item:last-child { border-bottom: none; }
.list-item-main { flex: 1; }
.list-item-title { font-size: 15px; font-weight: 500; color: var(--gray-800); }
.list-item-sub { font-size: 12px; color: var(--gray-400); margin-top: 2px; }
.list-item-actions { display: flex; gap: 5px; flex-shrink: 0; margin-left: 8px; }
/* ── Section headers ── */
.section-label {
font-size: 11px; font-weight: 700;
color: var(--gray-400); text-transform: uppercase; letter-spacing: 0.07em;
padding: 6px 0 4px; margin-top: 4px;
}
/* ── Sub-section header in plan cards ── */
.block-header {
display: flex; justify-content: space-between; align-items: center;
padding: 7px 0;
border-bottom: 1px solid var(--gray-100);
margin-top: 8px;
}
.block-title { font-size: 13px; font-weight: 600; color: var(--gray-600); }
.block-meta { font-size: 11px; color: var(--gray-400); }
/* ── Segmented control ── */
.seg-ctrl {
display: flex; background: var(--gray-200);
border-radius: 9px; padding: 3px;
margin-bottom: 14px;
}
.seg-btn {
flex: 1; padding: 6px 4px;
border: none; border-radius: 7px;
background: none; font-size: 13px; font-weight: 500;
color: var(--gray-500); cursor: pointer;
transition: all 0.15s;
-webkit-tap-highlight-color: transparent;
}
.seg-btn.active {
background: var(--bg-surface); color: var(--gray-800);
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
}
/* ── Empty state ── */
.empty {
text-align: center; padding: 48px 20px;
color: var(--gray-400);
}
.empty-icon { font-size: 44px; margin-bottom: 10px; }
.empty-text { font-size: 14px; line-height: 1.5; }
/* ── Header buttons ── */
.header-btn {
background: rgba(255,255,255,0.15); border: none; border-radius: 8px;
color: white; cursor: pointer; padding: 7px 9px;
display: flex; align-items: center; gap: 5px;
font-size: 12px; font-weight: 600;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s;
}
.header-btn:active { background: rgba(255,255,255,0.28); }
.header-btn svg { width: 18px; height: 18px; stroke-width: 1.8; flex-shrink: 0; }
.header-btn { position: relative; }
.backup-dot {
display: none; position: absolute; top: 3px; right: 3px;
width: 8px; height: 8px; border-radius: 50%;
background: var(--danger); border: 1.5px solid var(--primary);
}
.theme-btn {
background: rgba(255,255,255,0.15); border: none; border-radius: 8px;
color: white; cursor: pointer; padding: 7px 8px;
display: flex; align-items: center;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s;
}
.theme-btn:active { background: rgba(255,255,255,0.28); }
.theme-btn svg { width: 18px; height: 18px; stroke-width: 1.8; }
/* ── Pass/Fail toggle ── */
.pf-toggle {
display: flex; align-items: center; justify-content: space-between;
background: var(--gray-50); border: 1.5px solid var(--gray-200);
border-radius: 10px; padding: 11px 14px; cursor: pointer;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 14px;
}
.pf-toggle:has(input:checked) {
background: var(--success-light); border-color: var(--success);
}
.pf-toggle-text { font-size: 14px; font-weight: 500; color: var(--gray-700); line-height: 1.3; }
.pf-toggle-sub { font-size: 11px; color: var(--gray-400); margin-top: 2px; }
.switch { position: relative; width: 44px; height: 26px; flex-shrink: 0; margin-left: 12px; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch-track {
position: absolute; inset: 0;
background: var(--gray-300); border-radius: 13px;
transition: background 0.2s;
}
.switch input:checked + .switch-track { background: var(--success); }
.switch-thumb {
position: absolute; top: 3px; left: 3px;
width: 20px; height: 20px; border-radius: 50%;
background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
transition: transform 0.2s;
}
.switch input:checked ~ .switch-thumb { transform: translateX(18px); }
/* ── Pass/Fail segmented control ── */
.pf-seg { display:flex; border:1.5px solid var(--gray-200); border-radius:10px; overflow:hidden; margin-bottom:14px; }
.pf-seg-btn { flex:1; padding:9px 4px; font-size:13px; font-weight:500; background:var(--bg-surface); border:none; cursor:pointer; color:var(--gray-500); transition:background .15s,color .15s; }
.pf-seg-btn:not(:last-child) { border-right:1.5px solid var(--gray-200); }
.pf-seg-btn.seg-default { background:var(--gray-100); color:var(--gray-800); }
.pf-seg-btn.seg-passed { background:var(--success-light); color:#065F46; }
.pf-seg-btn.seg-failed { background:var(--danger-light); color:var(--danger); }
/* ── File input ── */
.file-input-label {
display: flex; align-items: center; justify-content: center; gap: 8px;
width: 100%; padding: 10px 14px;
border: 2px dashed var(--gray-300); border-radius: 8px;
color: var(--gray-500); font-size: 14px; font-weight: 500;
cursor: pointer; background: var(--gray-50);
transition: border-color 0.15s, background 0.15s;
}
.file-input-label:hover { border-color: var(--primary); background: var(--primary-light); color: var(--primary); }
#f-import { display: none; }
/* ── Course card ── */
.course-card {
background: var(--bg-surface); border-radius: 12px;
padding: 13px 14px; margin-bottom: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.07);
display: flex; justify-content: space-between; align-items: flex-start;
transition: opacity 0.15s;
}
.course-name { font-size: 15px; font-weight: 600; color: var(--gray-800); }
.course-meta { font-size: 11px; color: var(--gray-400); margin-top: 3px; }
.course-grade { font-size: 22px; font-weight: 700; line-height: 1; text-align: right; }
/* ── Drag and drop ── */
.drag-handle {
cursor: grab; color: var(--gray-300); padding: 2px 8px 2px 0;
display: flex; align-items: center; flex-shrink: 0;
touch-action: none; user-select: none;
}
.drag-handle:active { cursor: grabbing; color: var(--gray-500); }
.drag-handle svg { width: 16px; height: 16px; }
.course-card.dragging { opacity: 0.35; }
.course-card.drag-over { outline: 2px dashed var(--primary); outline-offset: -2px; background: var(--primary-light); }
/* ── Week Calendar ── */
.wk-outer { border: 1px solid var(--gray-200); border-radius: 10px; overflow: hidden; }
.wk-head { display: flex; border-bottom: 1px solid var(--gray-200); background: var(--bg-surface); position: sticky; top: 0; z-index: 5; }
.wk-htime { width: 40px; flex-shrink: 0; }
.wk-hday { flex: 1; text-align: center; font-size: 11px; font-weight: 700; color: var(--gray-500); padding: 7px 2px; text-transform: uppercase; letter-spacing: 0.04em; }
.wk-scroll { overflow-y: auto; flex: 1; min-height: 0; }
.wk-body { display: flex; }
.wk-tcol { width: 40px; flex-shrink: 0; position: relative; }
.wk-tlbl { position: absolute; font-size: 10px; color: var(--gray-400); right: 5px; transform: translateY(-50%); white-space: nowrap; }
.wk-days { display: flex; flex: 1; }
.wk-dcol { flex: 1; position: relative; border-left: 1px solid var(--gray-100); min-width: 0; }
.wk-gline { position: absolute; left: 0; right: 0; border-top: 1px solid var(--gray-100); }
.wk-gline-h { border-top-color: var(--gray-200); }
.wk-event {
position: absolute;
border-radius: 4px; font-size: 10px; font-weight: 600;
padding: 2px 4px; overflow: hidden; cursor: pointer;
color: #fff; line-height: 1.3; box-sizing: border-box;
}
.wk-event:active { filter: brightness(0.85); }
.wk-event-hatched { background-image: repeating-linear-gradient(135deg, rgba(255,255,255,.4) 0 6px, transparent 6px 12px); }
.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>
<span class="backup-dot" id="backup-dot" title="Backup overdue"></span>
</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 BACKUP_KEY = 'creditTracker_lastBackup';
const BACKUP_NUDGE_DAYS = 30;
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, autoFoldValidatedBlocks: false };
let page = 'dashboard';
let dashView = 'plans';
let collapsed = {};
let editModeBlock = null;
let _syncConflicts = [];
let _syncMergedBase = null;
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
function load() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
S.studyPlans = parsed.studyPlans || [];
S.blocks = parsed.blocks || [];
S.subBlocks = parsed.subBlocks || [];
S.semesters = parsed.semesters || [];
S.courses = parsed.courses || [];
S.schedule = parsed.schedule || [];
}
} catch (e) { console.error('Load failed', e); }
try {
const rs = localStorage.getItem(SETTINGS_KEY);
if (rs) settings = { ...settings, ...JSON.parse(rs) };
} catch (e) {}
const theme = localStorage.getItem('ct_theme');
if (theme === 'dark') applyTheme(true);
updateBackupDot();
}
function daysSinceBackup() {
const ts = localStorage.getItem(BACKUP_KEY);
if (!ts) return null;
return Math.floor((Date.now() - new Date(ts).getTime()) / 86400000);
}
function updateBackupDot() {
const dot = document.getElementById('backup-dot');
if (!dot) return;
const hasData = S.courses.length > 0 || S.semesters.length > 0;
const days = daysSinceBackup();
const overdue = hasData && (days === null || days >= BACKUP_NUDGE_DAYS);
dot.style.display = overdue ? 'block' : 'none';
}
function saveSettings() {
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (e) {}
}
function save() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(S)); }
catch (e) { console.error('Save failed', e); }
}
// ══════════════════════════════════════════════════════
// THEME
// ══════════════════════════════════════════════════════
function applyTheme(dark) {
document.documentElement.classList.toggle('dark', dark);
const moon = document.getElementById('theme-icon-moon');
const sun = document.getElementById('theme-icon-sun');
if (moon) moon.style.display = dark ? 'none' : '';
if (sun) sun.style.display = dark ? '' : 'none';
}
function toggleTheme() {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('ct_theme', isDark ? 'dark' : 'light');
applyTheme(isDark);
}
// ══════════════════════════════════════════════════════
// COMPUTED HELPERS
// ══════════════════════════════════════════════════════
function blockMean(blockId) {
const graded = S.courses.filter(c => c.blockId === blockId && !c.passFail && c.grade != null && c.grade !== '');
if (!graded.length) return null;
const sumWeighted = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0);
const sumCoef = graded.reduce((s, c) => s + (+c.credits || 0), 0);
if (sumCoef === 0) return null;
return sumWeighted / sumCoef;
}
function status(course) {
if (course.passFail === 'passed' || course.passFail === true) return 'pass';
if (course.passFail === 'failed') return 'fail';
if (course.grade == null || course.grade === '') return 'prog';
if (+course.grade >= 4) return 'pass';
if (course.blockId) {
const m = blockMean(course.blockId);
if (m !== null && m >= 4) return 'comp'; // compensation pass
}
return 'fail';
}
function isPass(st) { return st === 'pass' || st === 'comp'; }
function badge(st) {
if (st === 'pass') return '<span class="badge badge-pass">Passed</span>';
if (st === 'comp') return '<span class="badge badge-comp">Passed</span>';
if (st === 'fail') return '<span class="badge badge-fail">Failed</span>';
return '<span class="badge badge-prog">In progress</span>';
}
function gradeClass(g) {
if (g == null || g === '') return '';
return +g >= 5 ? 'g-high' : +g >= 4 ? 'g-mid' : 'g-low';
}
function fmtGrade(g, passFail) {
if (passFail === 'passed' || passFail === true) return '<span style="font-size:11px;font-weight:600;color:var(--success)">Pass</span>';
if (passFail === 'failed') return '<span style="font-size:11px;font-weight:600;color:var(--danger)">Fail</span>';
if (g == null || g === '') return '—';
const n = +g;
return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}
function semName(id) { const s = S.semesters.find(x => x.id === id); return s ? esc(s.name) : '—'; }
// Approximate calendar month each season starts, used to order/pick the "current" semester
// when no explicit startDate is set on it.
const SEASON_MONTH = { Spring: 1, Summer: 5, Autumn: 8, Winter: 11 };
function semRefDate(sem) {
if (sem.startDate) return new Date(sem.startDate + 'T00:00:00');
return new Date(sem.year, SEASON_MONTH[sem.season] ?? 0, 1);
}
function currentSemesterId() {
if (!S.semesters.length) return null;
const today = new Date();
let best = S.semesters[0], bestDiff = Infinity;
S.semesters.forEach(s => {
const diff = Math.abs(semRefDate(s) - today);
if (diff < bestDiff) { bestDiff = diff; best = s; }
});
return best.id;
}
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();
if (v === 'semesters') {
const curId = currentSemesterId();
const el = curId && document.getElementById('sem-card-' + curId);
if (el) {
const headerEl = document.querySelector('.header');
const offset = (headerEl ? headerEl.offsetHeight : 0) + 8;
const top = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior: 'smooth' });
}
} else if (v === 'plans') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
function render(p) {
if (p === 'dashboard') renderDash();
updateHeaderSub();
}
function updateHeaderSub() {
const total = S.courses.length;
const passed = S.courses.filter(c => isPass(status(c))).length;
const credits = S.courses.filter(c => isPass(status(c))).reduce((s, c) => s + (+c.credits || 0), 0);
document.getElementById('header-sub').textContent =
total ? `${credits} credits earned · ${passed}/${total} courses passed` : 'No courses yet';
}
function onFab() {
if (dashView === 'plans') { showPlanTypeModal(); return; }
if (dashView === 'semesters') { showSemesterModal(); return; }
showCourseModal();
}
// ══════════════════════════════════════════════════════
// SWIPE NAVIGATION
// ══════════════════════════════════════════════════════
(function() {
let sx = 0, sy = 0;
const content = document.getElementById('content');
content.addEventListener('touchstart', e => {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}, { passive: true });
content.addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - sx;
const dy = e.changedTouches[0].clientY - sy;
if (Math.abs(dx) > 55 && Math.abs(dx) > Math.abs(dy) * 1.5) {
if (sy > window.innerHeight * 0.8) {
// Lower 20% → switch main tab
const idx = TABS.indexOf(page);
if (dx < 0 && idx < TABS.length - 1) go(TABS[idx + 1]);
else if (dx > 0 && idx > 0) go(TABS[idx - 1]);
} else {
// Upper 80% → switch sub-tab of current page
swipeSubTab(dx);
}
}
}, { passive: true });
})();
function swipeSubTab(dx) {
if (page === 'dashboard') {
const views = ['plans', 'semesters'];
const idx = views.indexOf(dashView);
if (dx < 0 && idx < views.length - 1) setDashView(views[idx + 1]);
else if (dx > 0 && idx > 0) setDashView(views[idx - 1]);
}
}
// ══════════════════════════════════════════════════════
// COLOR HELPERS
// ══════════════════════════════════════════════════════
function lightenHex(hex, factor) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
const mix = v => Math.round(v + (255 - v) * factor).toString(16).padStart(2,'0');
return '#' + mix(r) + mix(g) + mix(b);
}
function planBaseColor(plan) {
if (!plan) return PLAN_PALETTE[0];
if (plan.color) return plan.color;
const idx = S.studyPlans.indexOf(plan);
return PLAN_PALETTE[(idx >= 0 ? idx : 0) % PLAN_PALETTE.length];
}
function blockEffectiveColor(blk) {
if (!blk) return PLAN_PALETTE[0];
if (blk.color) return blk.color;
const plan = S.studyPlans.find(p => p.id === blk.planId);
if (!blk.role || blk.role === 'major') return planBaseColor(plan);
if (blk.role === 'minor') {
const minors = S.blocks.filter(b => b.planId === blk.planId && b.role === 'minor');
const mIdx = minors.findIndex(b => b.id === blk.id);
return MINOR_PALETTE[Math.max(mIdx, 0) % MINOR_PALETTE.length];
}
// optional — offset by 4 in MINOR_PALETTE to avoid clashing with minors
const opts = S.blocks.filter(b => b.planId === blk.planId && b.role === 'optional');
const oIdx = opts.findIndex(b => b.id === blk.id);
return MINOR_PALETTE[(Math.max(oIdx, 0) + 4) % MINOR_PALETTE.length];
}
function subBlockEffectiveColor(sb) {
if (!sb) return PLAN_PALETTE[0];
const parent = S.blocks.find(b => b.id === sb.blockId);
const base = blockEffectiveColor(parent);
const sibs = S.subBlocks.filter(s => s.blockId === sb.blockId);
const idx = Math.max(sibs.findIndex(s => s.id === sb.id), 0);
return lightenHex(base, 0.18 + idx * 0.14);
}
function colorSwatches(palette, selected, onClickFn, includeAuto, autoColor) {
let html = '';
if (includeAuto) {
const isSel = !selected;
html += `<span class="ev-swatch" data-color="" onclick="${onClickFn}('')"
style="background:${autoColor};outline:${isSel ? '2px solid '+autoColor+';outline-offset:2px' : 'none'};
border:2px dashed rgba(255,255,255,0.6)" title="Auto (plan family)"></span>`;
}
html += palette.map(c => {
const isSel = c === selected;
return `<span class="ev-swatch" data-color="${c}" onclick="${onClickFn}('${c}')"
style="background:${c};outline:${isSel ? '2px solid '+c+';outline-offset:2px' : 'none'}"></span>`;
}).join('');
return `<div id="cp-swatches" style="display:flex;gap:6px;flex-wrap:wrap;padding:2px 0">${html}</div>`;
}
function syncSwatchOutlines(containerId, selected) {
document.querySelectorAll(`#${containerId} .ev-swatch`).forEach(s => {
const c = s.dataset.color;
const sel = c === selected;
const clr = c || '#94a3b8';
s.style.outline = sel ? '2px solid ' + clr : 'none';
s.style.outlineOffset = sel ? '2px' : '0';
});
}
// ══════════════════════════════════════════════════════
// DASHBOARD
// ══════════════════════════════════════════════════════
function renderDash() {
renderDashStats();
const el = document.getElementById('dash-content');
if (dashView === 'plans') el.innerHTML = dashPlans();
if (dashView === 'semesters') el.innerHTML = dashSemesters();
}
function renderDashStats() {
const all = S.courses;
const graded = all.filter(c => !c.passFail && c.grade != null && c.grade !== '');
const passed = all.filter(c => isPass(status(c)));
const totCr = all.reduce((s, c) => s + (+c.credits || 0), 0);
const passCr = passed.reduce((s, c) => s + (+c.credits || 0), 0);
const sumW = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0);
const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0);
const mean = sumC > 0 ? sumW / sumC : null;
const passedGraded = graded.filter(c => isPass(status(c))).length;
const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null;
document.getElementById('dash-stats').innerHTML = `<div class="stats-grid">
<div class="stat-card"><div class="stat-value">${totCr}</div><div class="stat-label">Total credits</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--success)">${passCr}</div><div class="stat-label">Credits earned</div></div>
<div class="stat-card"><div class="stat-value ${gradeClass(mean)}">${mean !== null ? mean.toFixed(2) : '—'}</div><div class="stat-label">Mean grade</div></div>
<div class="stat-card"><div class="stat-value" style="color:${rate === null ? 'var(--gray-400)' : rate >= 60 ? 'var(--success)' : 'var(--danger)'}">${rate !== null ? rate + '%' : '—'}</div><div class="stat-label">Pass rate</div></div>
</div>`;
}
function dashGlobal() {
const all = S.courses;
const graded = all.filter(c => !c.passFail && c.grade != null && c.grade !== '');
const passed = all.filter(c => isPass(status(c)));
const totCr = all.reduce((s, c) => s + (+c.credits || 0), 0);
const passCr = passed.reduce((s, c) => s + (+c.credits || 0), 0);
const sumW = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0);
const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0);
const mean = sumC > 0 ? sumW / sumC : null;
const passedGraded = graded.filter(c => isPass(status(c))).length;
const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null;
let html = `<div class="stats-grid">
<div class="stat-card"><div class="stat-value">${totCr}</div><div class="stat-label">Total credits</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--success)">${passCr}</div><div class="stat-label">Credits earned</div></div>
<div class="stat-card"><div class="stat-value ${gradeClass(mean)}">${mean !== null ? mean.toFixed(2) : '—'}</div><div class="stat-label">Mean grade</div></div>
<div class="stat-card"><div class="stat-value" style="color:${rate === null ? 'var(--gray-400)' : rate >= 60 ? 'var(--success)' : 'var(--danger)'}">${rate !== null ? rate + '%' : '—'}</div><div class="stat-label">Pass rate</div></div>
</div>`;
if (!all.length) return html + emptyState('📚', 'No courses yet.<br>Tap + to add your first course!');
html += `<div class="card">
<div class="card-header"><span class="card-title">All Courses</span><span style="font-size:11px;color:var(--gray-400)">${all.length} total</span></div>
<div class="table-wrap"><table><thead><tr><th>Course</th><th>Cr.</th><th>Grade</th><th>Status</th></tr></thead><tbody>`;
all.forEach(c => {
const st = status(c);
html += `<tr class="clickable" onclick="showCourseModal('${c.id}')">
<td><div style="font-weight:500">${esc(c.name)}</div><div style="font-size:10px;color:var(--gray-400)">${semName(c.semesterId)}</div></td>
<td>${c.credits || '—'}</td>
<td class="${c.passFail ? '' : gradeClass(c.grade)}">${fmtGrade(c.grade, c.passFail)}</td>
<td>${badge(st)}</td>
</tr>`;
});
html += `</tbody></table></div></div>`;
return html;
}
const WARN_SVG = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2.5" style="display:inline;vertical-align:middle;margin-left:3px"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
const NOSEM_SVG = `<svg title="No semester" style="display:inline;vertical-align:middle;margin-left:3px" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
const GEAR_SVG = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
const CHEV_SVG = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>`;
function chevBtn(key) {
const rot = collapsed[key] ? 'rotate(-90deg)' : 'rotate(0deg)';
return `<button onclick="toggleCollapse('${key}')" style="background:none;border:none;cursor:pointer;color:var(--gray-400);padding:2px 3px;display:flex;align-items:center;flex-shrink:0"><span style="display:inline-flex;transform:${rot};transition:transform .2s">${CHEV_SVG}</span></button>`;
}
function toggleCollapse(key) { collapsed[key] = !collapsed[key]; renderDash(); }
function toggleEditMode(blockId) {
editModeBlock = (editModeBlock === blockId) ? null : blockId;
renderDash();
}
function planDisplayName(plan) {
const major = S.blocks.find(b => b.planId === plan.id && b.role === 'major');
const minors = S.blocks.filter(b => b.planId === plan.id && b.role === 'minor');
const blockCr = (blk) => S.courses.filter(c => c.blockId === blk.id).reduce((s,c) => s + (+c.credits||0), 0);
const parts = [];
if (major) parts.push(`${esc(major.name)} ${blockCr(major)} cr.`);
minors.forEach(m => parts.push(`${esc(m.name)} ${blockCr(m)} cr.`));
return parts.length ? parts.join(' · ') : esc(plan.name);
}
function moveCourse(id, dir) {
const c = S.courses.find(x => x.id === id);
if (!c) return;
const sibs = S.courses.map((x,i) => ({x,i})).filter(({x}) => x.blockId === (c.blockId||'') && (x.subBlockId||'') === (c.subBlockId||''));
const pos = sibs.findIndex(({x}) => x.id === id);
const swap = dir === 'up' ? pos - 1 : pos + 1;
if (swap < 0 || swap >= sibs.length) return;
[S.courses[sibs[pos].i], S.courses[sibs[swap].i]] = [S.courses[sibs[swap].i], S.courses[sibs[pos].i]];
save(); renderDash();
}
function moveSubBlock(id, dir) {
const sb = S.subBlocks.find(x => x.id === id);
if (!sb) return;
const sibs = S.subBlocks.map((x,i) => ({x,i})).filter(({x}) => x.blockId === sb.blockId);
const pos = sibs.findIndex(({x}) => x.id === id);
const swap = dir === 'up' ? pos - 1 : pos + 1;
if (swap < 0 || swap >= sibs.length) return;
[S.subBlocks[sibs[pos].i], S.subBlocks[sibs[swap].i]] = [S.subBlocks[sibs[swap].i], S.subBlocks[sibs[pos].i]];
save(); renderDash();
}
function moveBlock(id, dir) {
const b = S.blocks.find(x => x.id === id);
if (!b) return;
const sibs = S.blocks.map((x,i) => ({x,i})).filter(({x}) => x.planId === b.planId && x.role === b.role);
const pos = sibs.findIndex(({x}) => x.id === id);
const swap = dir === 'up' ? pos - 1 : pos + 1;
if (swap < 0 || swap >= sibs.length) return;
[S.blocks[sibs[pos].i], S.blocks[sibs[swap].i]] = [S.blocks[sibs[swap].i], S.blocks[sibs[pos].i]];
save(); renderDash();
}
function roleGroupSection(planId, label, role, blocks, allPlanCourses, planColor) {
const headerColor = role === 'major'
? planColor
: (blocks.length ? blockEffectiveColor(blocks[0]) : MINOR_PALETTE[role === 'optional' ? 4 : 0]);
const addLabel = role === 'major' ? 'Major' : role === 'minor' ? 'Minor' : 'Optional';
const canAdd = role !== 'major' || !blocks.length;
const colKey = `role_${planId}_${role}`;
const isCollapsed = collapsed[colKey];
let html = `<div style="margin-top:12px">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px">
${chevBtn(colKey)}
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:${headerColor};flex:1">${label}</span>
${canAdd ? `<button class="btn btn-sm btn-secondary" onclick="showMinorModal('${planId}','','${role}')">+ ${addLabel}</button>` : ''}
</div>`;
if (!isCollapsed) {
if (blocks.length) {
blocks.forEach((blk, i) => { html += planBlockSection(planId, blk, allPlanCourses, blocks.length, i); });
} else {
html += `<div style="font-size:13px;color:var(--gray-400);padding:2px 0 4px;padding-left:20px">No ${label.toLowerCase()} defined yet.</div>`;
}
}
html += `</div>`;
return html;
}
function dashPlans() {
if (!S.studyPlans.length) return emptyState('📋', 'No study plans yet.<br>Tap + to create one!');
let html = '';
S.studyPlans.forEach(plan => {
const pc = S.courses.filter(c => c.planId === plan.id);
const totCr = pc.reduce((s, c) => s + (+c.credits || 0), 0);
const passCr = pc.filter(c => isPass(status(c))).reduce((s, c) => s + (+c.credits || 0), 0);
const minCr = plan.cycle === 'bachelor' ? settings.minCreditsBachelor
: plan.cycle === 'master' ? settings.minCreditsMaster : null;
const creditOk = minCr === null || totCr >= minCr;
const majors = S.blocks.filter(b => b.planId === plan.id && b.role === 'major');
const minors = S.blocks.filter(b => b.planId === plan.id && b.role === 'minor');
const optionals = S.blocks.filter(b => b.planId === plan.id && b.role === 'optional');
const others = S.blocks.filter(b => b.planId === plan.id && !b.role);
const pClr = planBaseColor(plan);
const colKey = `plan_${plan.id}`;
const isCollapsed = collapsed[colKey];
html += `<div class="card" style="border-left:4px solid ${pClr}">
<div class="card-header" style="flex-wrap:wrap;gap:6px">
<div style="display:flex;align-items:center;gap:4px;flex:1;min-width:0">
${chevBtn(colKey)}
<span class="card-title" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${planDisplayName(plan)}</span>
${cycleBadge(plan.cycle)}
${!creditOk ? WARN_SVG : ''}
<span style="font-size:11px;color:${creditOk ? 'var(--gray-400)' : 'var(--danger)'}">
${passCr}/${totCr}${minCr !== null ? '/' + minCr : ''} cr.
</span>
</div>
<div style="display:flex;gap:5px;align-items:center;flex-shrink:0">
<button class="btn btn-sm btn-secondary" onclick="showPlanModal('${plan.id}')" title="Edit plan">${GEAR_SVG}</button>
<button class="btn btn-sm btn-danger" onclick="deletePlan('${plan.id}')">Del</button>
</div>
</div>`;
if (!isCollapsed) {
majors.forEach((blk, i) => { html += planBlockSection(plan.id, blk, pc, majors.length, i); });
minors.forEach((blk, i) => { html += planBlockSection(plan.id, blk, pc, minors.length, i); });
if (settings.showOptional) {
optionals.forEach((blk, i) => { html += planBlockSection(plan.id, blk, pc, optionals.length, i); });
}
others.forEach(blk => { html += planBlockSection(plan.id, blk, pc, 1, 0); });
const nbc = pc.filter(c => !c.blockId);
if (nbc.length) {
html += `<div style="margin-top:8px;font-size:11px;color:var(--gray-400);text-transform:uppercase;letter-spacing:.05em">Unassigned courses</div>`;
html += coursesMgmtList(nbc);
}
html += `<div style="display:flex;gap:5px;margin-top:10px;flex-wrap:wrap">
${!majors.length ? `<button class="btn btn-sm btn-secondary" onclick="showMinorModal('${plan.id}','','major')">+ Major</button>` : ''}
<button class="btn btn-sm btn-secondary" onclick="showMinorModal('${plan.id}','','minor')">+ Minor</button>
${settings.showOptional ? `<button class="btn btn-sm btn-secondary" onclick="showMinorModal('${plan.id}','','optional')">+ Optional</button>` : ''}
</div>`;
}
html += `</div>`;
});
const up = S.courses.filter(c => !c.planId);
if (up.length) {
html += `<div class="card">
<div class="card-header"><span class="card-title" style="color:var(--gray-400)">Without plan</span></div>
${coursesMgmtList(up)}
</div>`;
}
return html;
}
function planBlockSection(planId, blk, allPlanCourses, sibCount, sibIdx) {
const bc = allPlanCourses.filter(c => c.blockId === blk.id);
const bm = blockMean(blk.id);
const bCr = bc.reduce((s, c) => s + (+c.credits || 0), 0);
const bClr = blockEffectiveColor(blk);
const sbs = S.subBlocks.filter(sb => sb.blockId === blk.id);
const freeC = bc.filter(c => !c.subBlockId);
const colKey = `block_${blk.id}`;
const inEdit = editModeBlock === blk.id;
const failedCourses = bc.filter(c => c.passFail === 'failed').length;
const blockFailed = failedCourses > 1;
const blockIsPassed = blockPassedStatus(blk.id);
const isCollapsed = collapsed[colKey] !== undefined ? collapsed[colKey] : (settings.autoFoldValidatedBlocks && blockIsPassed);
const chevRot = isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)';
let html = `<div style="border-left:3px solid ${bClr};padding-left:8px;margin-top:10px">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
<button onclick="toggleCollapse('${colKey}')" style="background:none;border:none;cursor:pointer;color:var(--gray-400);padding:2px 3px;display:flex;align-items:center;flex-shrink:0"><span style="display:inline-flex;transform:${chevRot};transition:transform .2s">${CHEV_SVG}</span></button>
<span style="font-size:14px;font-weight:600;color:${bClr};flex:1">${esc(blk.name)}${blockFailed ? ` <span style="font-size:10px;font-weight:700;background:var(--danger-light);color:var(--danger);border-radius:4px;padding:1px 5px;vertical-align:middle">FAIL</span>` : ''}</span>
${blockIsPassed
? `<span onclick="showBlockCompletionModal('${blk.id}')" title="Bloc validé — cliquer pour détails" style="cursor:pointer;font-size:10px;font-weight:700;background:var(--success-light);color:#065F46;border-radius:5px;padding:2px 7px;flex-shrink:0;white-space:nowrap">✓ Validé</span>`
: `<button onclick="showBlockCompletionModal('${blk.id}')" title="Vérifier les conditions de complétion" style="background:none;border:1px solid var(--gray-200);border-radius:5px;cursor:pointer;color:var(--gray-400);padding:2px 6px;font-size:10px;flex-shrink:0;white-space:nowrap">Check</button>`}
<span style="font-size:11px;color:var(--gray-400)">${bCr} cr.${bm !== null ? ` · avg <strong class="${gradeClass(bm)}">${bm.toFixed(2)}</strong>` : ''}</span>
${inEdit && sibCount > 1 ? `<button class="btn btn-sm btn-secondary" onclick="moveBlock('${blk.id}','up')" ${sibIdx===0?'disabled':''} style="padding:2px 5px">↑</button><button class="btn btn-sm btn-secondary" onclick="moveBlock('${blk.id}','down')" ${sibIdx===sibCount-1?'disabled':''} style="padding:2px 5px">↓</button>` : ''}
<button class="btn btn-sm btn-secondary" onclick="toggleEditMode('${blk.id}')" title="${inEdit?'Exit edit':'Edit'}" style="${inEdit?'background:var(--primary);color:#fff':''}">${GEAR_SVG}</button>
${inEdit ? `<button class="btn btn-sm btn-secondary" onclick="showMinorModal('${planId}','${blk.id}','${blk.role||'minor'}')">✎</button><button class="btn btn-sm btn-danger" onclick="deleteBlock('${blk.id}')">×</button>` : ''}
</div>`;
if (!isCollapsed) {
sbs.forEach((sb, i) => {
const sbC = bc.filter(c => c.subBlockId === sb.id);
html += planSubBlockSection(planId, blk.id, sb, sbC, bClr, inEdit, sbs.length, i);
});
if (freeC.length) {
html += `<div style="margin-top:6px">
${sbs.length ? `<div style="font-size:10px;color:var(--gray-400);text-transform:uppercase;letter-spacing:.05em;margin-bottom:3px">Free courses</div>` : ''}
${coursesMgmtList(freeC, false, bClr, inEdit)}
</div>`;
} else if (!sbs.length && !inEdit) {
html += `<div style="font-size:12px;color:var(--gray-400);padding:3px 0">No courses yet</div>`;
}
if (inEdit) {
html += `<div style="display:flex;gap:5px;margin-top:8px">
<button class="btn btn-sm btn-secondary" onclick="showSubBlockModal('${blk.id}','')">+ Block</button>
<button class="btn btn-sm btn-secondary" onclick="showCourseModalPrefilled('${planId}','${blk.id}','','')">+ Course</button>
</div>`;
}
}
html += `</div>`;
return html;
}
function planSubBlockSection(planId, blockId, sb, courses, parentColor, inEdit, sibCount, sibIdx) {
const sbCr = courses.reduce((s, c) => s + (+c.credits || 0), 0);
const sbClr = subBlockEffectiveColor(sb);
const colKey = `sb_${sb.id}`;
const isCollapsed = collapsed[colKey] !== undefined ? collapsed[colKey] : (settings.autoFoldValidatedBlocks && !!sb.passed);
const chevRot = isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)';
let html = `<div style="border-left:2px dashed ${sbClr};padding-left:8px;margin-top:7px">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
<button onclick="toggleCollapse('${colKey}')" style="background:none;border:none;cursor:pointer;color:var(--gray-400);padding:2px 3px;display:flex;align-items:center;flex-shrink:0"><span style="display:inline-flex;transform:${chevRot};transition:transform .2s">${CHEV_SVG}</span></button>
<span style="font-size:13px;font-weight:600;color:${sbClr};flex:1">${esc(sb.name)}</span>
${sb.passed
? `<span onclick="showSubBlockCompletionModal('${sb.id}')" title="Bloc validé" style="cursor:pointer;font-size:10px;font-weight:700;background:var(--success-light);color:#065F46;border-radius:5px;padding:2px 7px;flex-shrink:0;white-space:nowrap">✓ Validé</span>`
: `<button onclick="showSubBlockCompletionModal('${sb.id}')" title="Vérifier les conditions" style="background:none;border:1px solid var(--gray-200);border-radius:5px;cursor:pointer;color:var(--gray-400);padding:2px 6px;font-size:10px;flex-shrink:0;white-space:nowrap">Check</button>`}
<span style="font-size:11px;color:var(--gray-400)">${sbCr} cr.</span>
${inEdit && sibCount > 1 ? `<button class="btn btn-sm btn-secondary" onclick="moveSubBlock('${sb.id}','up')" ${sibIdx===0?'disabled':''} style="padding:2px 5px">↑</button><button class="btn btn-sm btn-secondary" onclick="moveSubBlock('${sb.id}','down')" ${sibIdx===sibCount-1?'disabled':''} style="padding:2px 5px">↓</button>` : ''}
${inEdit ? `<button class="btn btn-sm btn-secondary" onclick="showSubBlockModal('${blockId}','${sb.id}')">✎</button><button class="btn btn-sm btn-danger" onclick="deleteSubBlock('${sb.id}')">×</button>` : ''}
</div>`;
if (!isCollapsed) {
html += courses.length
? coursesMgmtList(courses, false, sbClr, inEdit)
: `<div style="font-size:12px;color:var(--gray-400);padding:2px 0">No courses yet</div>`;
if (inEdit) {
html += `<button class="btn btn-sm btn-secondary" style="margin-top:4px" onclick="showCourseModalPrefilled('${planId}','${blockId}','','${sb.id}')">+ Course</button>`;
}
}
html += `</div>`;
return html;
}
const CAL_SVG = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`;
function semCourseRow(c) {
const st = status(c);
const blk = c.blockId ? S.blocks.find(b => b.id === c.blockId) : null;
const bClr = blk ? blockEffectiveColor(blk) : null;
const borderStyle = bClr ? `border-left:3px solid ${bClr};padding-left:6px;` : '';
const slotsStr = c.slots && c.slots.length
? c.slots.map(s => WK_DAYS[s.day - 1] + ' ' + toHHMM(s.startMin) + '' + toHHMM(s.endMin)).join(' · ')
: '';
const attemptsHtml = c.attempts && c.attempts.length > 1
? c.attempts.map((a, i) => `<span class="${gradeClass(a.grade)}" style="font-size:12px;font-weight:700">${a.grade}${i < c.attempts.length - 1 ? '<span style="color:var(--gray-300);font-weight:400">→</span>' : ''}</span>`).join('')
: `<span class="${gradeClass(c.grade)}" style="font-size:14px;font-weight:700;min-width:28px;text-align:right">${fmtGrade(c.grade, c.passFail)}</span>`;
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>
${c.credits ? `<div style="font-size:11px;color:var(--gray-400);margin-top:1px">${c.credits} cr.</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)}
${attemptsHtml}
<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>`;
}
function semCoursesByPlan(sc) {
if (!sc.length) return `<div style="color:var(--gray-400);font-size:13px;padding:2px 0 4px">No courses this semester</div>`;
let html = '';
// Plans that have courses in this semester (in S.studyPlans order)
S.studyPlans.forEach(plan => {
const planCrs = sc.filter(c => c.planId === plan.id);
if (!planCrs.length) return;
const pClr = planBaseColor(plan);
html += `<div style="margin-top:10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:${pClr};padding-bottom:3px;border-bottom:2px solid ${pClr}">${esc(planDisplayName(plan))}</div>`;
// Blocks in plan order: major → minors → optionals → unroled
const ordered = [
...S.blocks.filter(b => b.planId === plan.id && b.role === 'major'),
...S.blocks.filter(b => b.planId === plan.id && b.role === 'minor'),
...S.blocks.filter(b => b.planId === plan.id && b.role === 'optional'),
...S.blocks.filter(b => b.planId === plan.id && !b.role),
].filter(blk => planCrs.some(c => c.blockId === blk.id));
ordered.forEach(blk => {
const blkCrs = planCrs.filter(c => c.blockId === blk.id);
const bClr = blockEffectiveColor(blk);
html += `<div style="border-left:3px solid ${bClr};padding-left:8px;margin-top:8px">`;
html += `<div style="font-size:12px;font-weight:700;color:${bClr};margin-bottom:2px">${esc(blk.name)}</div>`;
// Sub-blocks (in S.subBlocks order)
S.subBlocks.filter(sb => sb.blockId === blk.id && blkCrs.some(c => c.subBlockId === sb.id)).forEach(sb => {
const sbCrs = blkCrs.filter(c => c.subBlockId === sb.id);
const sbClr = subBlockEffectiveColor(sb);
html += `<div style="border-left:2px solid ${sbClr};padding-left:6px;margin-top:5px">`;
html += `<div style="font-size:11px;font-weight:600;color:${sbClr};margin-bottom:2px">${esc(sb.name)}</div>`;
sbCrs.forEach(c => { html += semCourseRow(c); });
html += `</div>`;
});
// Free courses (in block, no sub-block), preserve S.courses order
blkCrs.filter(c => !c.subBlockId).forEach(c => { html += semCourseRow(c); });
html += `</div>`;
});
// Courses in plan but no block
planCrs.filter(c => !c.blockId).forEach(c => { html += semCourseRow(c); });
});
// Courses without a plan
const noPlan = sc.filter(c => !c.planId);
if (noPlan.length) {
if (S.studyPlans.some(p => sc.some(c => c.planId === p.id))) {
html += `<div style="margin-top:10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--gray-400);padding-bottom:3px;border-bottom:1px solid var(--gray-200)">Sans plan</div>`;
}
noPlan.forEach(c => { html += semCourseRow(c); });
}
return html;
}
function dashSemesters() {
if (!S.semesters.length) return emptyState('📅', 'No semesters yet.<br>Tap + to create one!');
let html = '';
const curId = currentSemesterId();
S.semesters.forEach(sem => {
const sc = S.courses.filter(c => c.semesterId === sem.id);
const totCr = sc.reduce((s, c) => s + (+c.credits || 0), 0);
const passCr = sc.filter(c => isPass(status(c))).reduce((s, c) => s + (+c.credits || 0), 0);
const graded = sc.filter(c => !c.passFail && c.grade != null && c.grade !== '');
const sumW = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0);
const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0);
const mean = sumC > 0 ? sumW / sumC : null;
const key = 'sem_' + sem.id;
const foldedDef = sem.id !== curId; // older/other semesters start folded, current starts open
const isFolded = collapsed[key] !== undefined ? collapsed[key] : foldedDef;
const rot = isFolded ? 'rotate(-90deg)' : 'rotate(0deg)';
html += `<div class="card" id="sem-card-${sem.id}">
<div class="card-header" style="flex-wrap:wrap;gap:6px">
<button onclick="toggleCollapse('${key}')" style="background:none;border:none;cursor:pointer;color:var(--gray-400);padding:2px 3px;display:flex;align-items:center;flex-shrink:0"><span style="display:inline-flex;transform:${rot};transition:transform .2s">${CHEV_SVG}</span></button>
<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>
${isFolded ? '' : `
${semCoursesByPlan(sc)}
<button class="btn btn-secondary btn-full" style="margin-top:8px;font-size:13px" onclick="showCourseModalPrefilled('','','${sem.id}')">+ Add Course</button>`}
</div>`;
});
return html;
}
function coursesMgmtList(courses, showPlan, inheritColor, reorderable) {
if (!courses.length) return '<div style="font-size:12px;color:var(--gray-400);padding:5px 0">No courses yet</div>';
return courses.map((c, idx) => {
const st = status(c);
const meta = [
c.credits ? c.credits + ' cr.' : null,
!c.semesterId ? '<span style="color:var(--warning)">no semester' + NOSEM_SVG + '</span>' : semName(c.semesterId),
showPlan && c.planId ? planName(c.planId) + (c.blockId ? ' ' + blockName(c.blockId) : '') + (c.subBlockId ? ' ' + subBlockName(c.subBlockId) : '') : null,
].filter(Boolean).join(' · ');
const slotsStr = c.slots && c.slots.length
? c.slots.map(s => WK_DAYS[s.day - 1] + ' ' + toHHMM(s.startMin) + '' + toHHMM(s.endMin)).join(' · ')
: '';
// Resolve block color: explicit arg > look up block > none
const blk = !inheritColor && c.blockId ? S.blocks.find(b => b.id === c.blockId) : null;
const bClr = inheritColor || (blk ? blockEffectiveColor(blk) : null);
const borderStyle = bClr ? `border-left:3px solid ${bClr};padding-left:6px;` : '';
return `<div style="display:flex;align-items:center;padding:7px 0;border-bottom:1px solid var(--gray-100);gap:8px;${borderStyle}">
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500;color:var(--gray-800);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(c.name)}</div>
<div style="font-size:11px;color:var(--gray-400);margin-top:1px">${meta}</div>
${slotsStr ? `<div style="font-size:11px;color:${bClr||'var(--primary)'};margin-top:1px">${slotsStr}</div>` : ''}
</div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
${badge(st)}
${c.attempts && c.attempts.length > 1
? c.attempts.map((a,i) => `<span class="${gradeClass(a.grade)}" style="font-size:12px;font-weight:700">${a.grade}${i < c.attempts.length-1 ? '<span style="color:var(--gray-300);font-weight:400">→</span>' : ''}</span>`).join('')
: `<span class="${gradeClass(c.grade)}" style="font-size:14px;font-weight:700;min-width:28px;text-align:right">${fmtGrade(c.grade, c.passFail)}</span>`
}
${reorderable ? `<button class="btn btn-sm btn-secondary" onclick="moveCourse('${c.id}','up')" ${idx===0?'disabled':''} style="padding:2px 5px">↑</button><button class="btn btn-sm btn-secondary" onclick="moveCourse('${c.id}','down')" ${idx===courses.length-1?'disabled':''} style="padding:2px 5px">↓</button>` : ''}
<button class="btn btn-sm btn-secondary" onclick="showCourseModal('${c.id}')">✎</button>
<button class="btn btn-sm btn-danger" onclick="deleteCourse('${c.id}')">×</button>
</div>
</div>`;
}).join('');
}
function showCourseModalPrefilled(planId, blockId, semId, subBlockId = '') {
showCourseModal(null, { planId, blockId, semId, subBlockId });
}
// ══════════════════════════════════════════════════════
// COURSES PAGE
// ══════════════════════════════════════════════════════
function renderCourses() {
const el = document.getElementById('courses-content');
if (!S.courses.length) { el.innerHTML = emptyState('📖', 'No courses yet.<br>Tap + to add one!'); return; }
let html = '';
if (courseGroup === 'semester') {
const bySem = {};
const noSem = [];
S.courses.forEach(c => {
if (c.semesterId) { (bySem[c.semesterId] = bySem[c.semesterId] || []).push(c); }
else noSem.push(c);
});
S.semesters.forEach(sem => {
const list = bySem[sem.id];
if (!list || !list.length) return;
html += `<div class="section-label">${esc(sem.name)}</div>`;
list.forEach(c => html += courseCard(c));
});
if (noSem.length) {
html += `<div class="section-label">No Semester</div>`;
noSem.forEach(c => html += courseCard(c));
}
} else {
// Group by plan, then show semester info per course
S.studyPlans.forEach(plan => {
const pc = S.courses.filter(c => c.planId === plan.id);
if (!pc.length) return;
html += `<div class="section-label" style="display:flex;align-items:center;gap:6px">${esc(plan.name)}${plan.cycle ? ` · ${plan.cycle}` : ''}</div>`;
pc.forEach(c => html += courseCard(c, true));
});
const unplanned = S.courses.filter(c => !c.planId);
if (unplanned.length) {
html += `<div class="section-label">No Study Plan</div>`;
unplanned.forEach(c => html += courseCard(c, true));
}
}
el.innerHTML = html;
initDrag(el);
}
function courseCard(c, showSem) {
const st = status(c);
const metaParts = [
c.credits ? c.credits + ' cr.' : null,
showSem && c.semesterId ? semName(c.semesterId) : null,
!showSem && c.planId ? planName(c.planId) + (c.blockId ? ' ' + blockName(c.blockId) : '') : null,
].filter(Boolean);
return `<div class="course-card" data-id="${c.id}" draggable="true">
<div class="drag-handle" title="Drag to reorder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="18" x2="16" y2="18"/></svg>
</div>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<span class="course-name">${esc(c.name)}</span>
${badge(st)}
</div>
<div class="course-meta">${metaParts.join(' · ') || '—'}</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;margin-left:10px;flex-shrink:0">
${c.attempts && c.attempts.length > 1
? `<div style="display:flex;align-items:center;gap:2px">${c.attempts.map((a,i) => `<span class="${gradeClass(a.grade)}" style="font-size:13px;font-weight:700">${a.grade}${i < c.attempts.length-1 ? '<span style="color:var(--gray-300);font-weight:400;font-size:11px">→</span>' : ''}</span>`).join('')}</div>`
: `<span class="course-grade ${gradeClass(c.grade)}">${fmtGrade(c.grade)}</span>`
}
<div style="display:flex;gap:5px">
<button class="btn btn-sm btn-secondary" onclick="showCourseModal('${c.id}')">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteCourse('${c.id}')">Del</button>
</div>
</div>
</div>`;
}
// ══════════════════════════════════════════════════════
// DRAG AND DROP (courses)
// ══════════════════════════════════════════════════════
let dragSrcId = null;
let dragOverId = null;
// Touch drag state
let td = { active: false, id: null, clone: null, srcEl: null, overId: null, startY: 0, startTop: 0 };
function initDrag(container) {
// ── HTML5 drag (desktop) ──
container.addEventListener('dragstart', e => {
const card = e.target.closest('[data-id]');
if (!card) { e.preventDefault(); return; }
// Only allow drag from handle
if (!e.target.closest('.drag-handle')) { e.preventDefault(); return; }
dragSrcId = card.dataset.id;
setTimeout(() => card.classList.add('dragging'), 0);
e.dataTransfer.effectAllowed = 'move';
});
container.addEventListener('dragend', () => {
container.querySelectorAll('.course-card').forEach(el => el.classList.remove('dragging', 'drag-over'));
dragSrcId = dragOverId = null;
});
container.addEventListener('dragover', e => {
e.preventDefault();
const card = e.target.closest('[data-id]');
if (!card || card.dataset.id === dragSrcId) return;
container.querySelectorAll('.course-card').forEach(el => el.classList.remove('drag-over'));
card.classList.add('drag-over');
dragOverId = card.dataset.id;
});
container.addEventListener('drop', e => {
e.preventDefault();
if (dragSrcId && dragOverId && dragSrcId !== dragOverId) reorderCourse(dragSrcId, dragOverId);
container.querySelectorAll('.course-card').forEach(el => el.classList.remove('dragging', 'drag-over'));
dragSrcId = dragOverId = null;
});
// ── Touch drag (mobile) ──
container.addEventListener('touchstart', e => {
const handle = e.target.closest('.drag-handle');
if (!handle) return;
const card = handle.closest('[data-id]');
if (!card) return;
e.preventDefault(); // prevent scroll when starting from handle
const touch = e.touches[0];
td = {
active: false,
id: card.dataset.id,
clone: null,
srcEl: card,
overId: null,
startY: touch.clientY,
startTop: card.getBoundingClientRect().top
};
}, { passive: false });
document.addEventListener('touchmove', e => {
if (!td.id) return;
const touch = e.touches[0];
const dy = touch.clientY - td.startY;
if (!td.active && Math.abs(dy) > 6) {
td.active = true;
const rect = td.srcEl.getBoundingClientRect();
td.clone = td.srcEl.cloneNode(true);
td.clone.style.cssText = `position:fixed;z-index:9999;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;opacity:0.85;pointer-events:none;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,0.18);`;
document.body.appendChild(td.clone);
td.srcEl.classList.add('dragging');
}
if (td.active && td.clone) {
e.preventDefault();
td.clone.style.top = (td.startTop + dy) + 'px';
td.clone.style.display = 'none';
const el = document.elementFromPoint(touch.clientX, touch.clientY);
td.clone.style.display = '';
const card = el && el.closest('[data-id]');
container.querySelectorAll('.course-card').forEach(c => c.classList.remove('drag-over'));
if (card && card.dataset.id !== td.id) {
card.classList.add('drag-over');
td.overId = card.dataset.id;
} else {
td.overId = null;
}
}
}, { passive: false });
document.addEventListener('touchend', () => {
if (!td.id) return;
if (td.active && td.overId) reorderCourse(td.id, td.overId);
if (td.clone) td.clone.remove();
if (td.srcEl) td.srcEl.classList.remove('dragging');
container.querySelectorAll('.course-card').forEach(c => c.classList.remove('drag-over'));
td = { active: false, id: null, clone: null, srcEl: null, overId: null, startY: 0, startTop: 0 };
});
}
function reorderCourse(srcId, targetId) {
const si = S.courses.findIndex(c => c.id === srcId);
const ti = S.courses.findIndex(c => c.id === targetId);
if (si < 0 || ti < 0) return;
const [removed] = S.courses.splice(si, 1);
S.courses.splice(ti, 0, removed);
save();
render(page);
}
// ══════════════════════════════════════════════════════
// WEEK CALENDAR
// ══════════════════════════════════════════════════════
const WK_START = 7 * 60; // 07:00
const WK_END = 21 * 60; // 21:00
const PX_PER_MIN = 1.4;
const WK_DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
const EV_COLORS = ['#4F46E5','#059669','#DC2626','#D97706','#7C3AED','#0891B2','#DB2777','#475569'];
function toHHMM(min) {
return `${Math.floor(min / 60).toString().padStart(2,'0')}:${(min % 60).toString().padStart(2,'0')}`;
}
function fromHHMM(str) {
const [h, m] = str.split(':').map(Number);
return h * 60 + (m || 0);
}
function layoutOverlapping(events) {
if (!events.length) return [];
const sorted = [...events].sort((a, b) => a.startMin - b.startMin || b.endMin - a.endMin);
const colEnds = [];
const assigned = sorted.map(ev => {
let col = colEnds.findIndex(end => end <= ev.startMin);
if (col === -1) col = colEnds.length;
colEnds[col] = ev.endMin;
return { ev, col };
});
return assigned.map(item => {
const peers = assigned.filter(o => o.ev.startMin < item.ev.endMin && o.ev.endMin > item.ev.startMin);
const totalCols = peers.reduce((m, o) => Math.max(m, o.col + 1), 1);
return { ev: item.ev, col: item.col, totalCols };
});
}
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-secondary" style="flex:1;justify-content:center;font-size:13px" onclick="exportICS('${semId}')">${CAL_SVG} Export ICS</button>
</div>
<div style="display:flex;gap:8px;padding:0 16px 10px;flex-shrink:0">
<button class="btn btn-primary btn-full" style="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>
${_renderOtherSemesterCourses(semId)}
</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 _renderOtherSemesterCourses(semId) {
const others = S.courses.filter(c => c.semesterId !== semId && c.slots && c.slots.length);
if (!others.length) return '';
return `<div style="padding:12px 16px 4px">
<div style="font-size:11px;font-weight:600;color:var(--gray-400);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">
Courses with a timetable in other semesters
</div>
${others.map(c => `
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 0;border-bottom:1px solid var(--gray-100)">
<div style="min-width:0">
<div style="font-size:13px;font-weight:500;color:var(--gray-800);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(c.name)}</div>
<div style="font-size:11px;color:var(--gray-400)">${semName(c.semesterId)}</div>
</div>
<button class="btn btn-sm btn-secondary" style="flex-shrink:0" onclick="_moveCourseToSemester('${c.id}','${semId}')">Move here</button>
</div>
`).join('')}
</div>`;
}
function _moveCourseToSemester(courseId, semId) {
const c = S.courses.find(x => x.id === courseId);
if (!c) return;
if (!confirm(`Move "${esc(c.name)}" to this semester?`)) return;
c.semesterId = semId;
save(); render(page);
showWeekCalendar(semId);
}
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),
count: slot.count,
location: slot.location,
_courseId: c.id
}));
});
const totalH = (WK_END - WK_START) * PX_PER_MIN;
if (!events.length) {
const semCourses = S.courses.filter(c => c.semesterId === semId);
const noSlotCount = semCourses.filter(c => !c.slots || !c.slots.length).length;
const hint = semCourses.length === 0
? 'No courses are assigned to this semester yet.'
: noSlotCount > 0
? `${semCourses.length} course${semCourses.length > 1 ? 's are' : ' is'} in this semester but ${noSlotCount} ${noSlotCount > 1 ? 'have' : 'has'} no schedule set. Open a course and add a time slot under "Weekly Schedule".`
: 'Add a manual event or import an ICS file.';
return `<div style="padding:40px 16px;text-align:center;color:var(--gray-400)">
<div style="font-size:32px;margin-bottom:8px">📅</div>
<div style="font-size:14px;font-weight:500;color:var(--gray-600)">No schedule yet</div>
<div style="font-size:12px;margin-top:6px;line-height:1.6;max-width:280px;margin-left:auto;margin-right:auto">${hint}</div>
</div>`;
}
// Time labels
let timeLbls = '';
for (let m = WK_START; m <= WK_END; m += 60) {
const top = (m - WK_START) * PX_PER_MIN;
timeLbls += `<div class="wk-tlbl" style="top:${top}px">${toHHMM(m)}</div>`;
}
// Gridlines (every 30 min, hour ones darker)
let gridLines = '';
for (let m = WK_START; m <= WK_END; m += 30) {
const top = (m - WK_START) * PX_PER_MIN;
gridLines += `<div class="wk-gline${m % 60 === 0 ? ' wk-gline-h' : ''}" style="top:${top}px"></div>`;
}
// Day columns
const dayCols = WK_DAYS.map((_, di) => {
const dayNum = di + 1;
const dayEvents = events.filter(e => e.day === dayNum);
const laid = layoutOverlapping(dayEvents);
const evHtml = laid.map(({ ev, col, totalCols }) => {
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 pct = (100 / totalCols).toFixed(2);
const left = `calc(${(col / totalCols * 100).toFixed(2)}% + 2px)`;
const width = `calc(${pct}% - 4px)`;
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}')`;
const hatched = ev.count && ev.count <= 7;
const tip = ev.fullName ? `${ev.fullName} — ` : '';
return `<div class="wk-event${hatched ? ' wk-event-hatched' : ''}" style="top:${top}px;height:${height}px;left:${left};width:${width};background-color:${esc(ev.color)}"
onclick="${onclick}" title="${esc(tip + ev.title)} ${toHHMM(ev.startMin)}${toHHMM(ev.endMin)}${hatched ? ` (${ev.count}×)` : ''}">
${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 slots = parseICS(e.target.result, semId);
if (!slots.length) { alert('No weekly events found in this ICS file.'); return; }
_startICSLink(semId, slots);
};
reader.readAsText(file);
input.value = '';
}
function icsUnescape(s) {
return (s || '').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\n/gi, ' ').trim();
}
function parseICS(text, semId) {
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);
// Pass 1: expand every VEVENT into flat weekly occurrences
const occurrences = [];
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 summaryRaw = props['SUMMARY'];
const dtstart = props['DTSTART'];
const dtend = props['DTEND'];
const rrule = props['RRULE'];
if (!summaryRaw || !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;
// Split "CODE | Name (possibly multilingual, '/'-separated) | Type" — falls back to the raw text if unrecognized
const summary = icsUnescape(summaryRaw);
const parts = summary.split(' | ').map(s => s.trim());
const code = parts.length >= 3 ? parts[0] : '';
const type = parts.length >= 3 ? parts[parts.length - 1] : '';
const nameVariants = parts.length >= 3 ? parts.slice(1, -1).join(' | ').split(' / ').map(s => s.trim()) : [summary];
const fullName = nameVariants[0];
const label = code ? `${code} · ${type}` : summary;
const loc = icsUnescape(props['LOCATION'] || '');
const dm = dtstart.match(/^(\d{4})(\d{2})(\d{2})/);
const dateKey = dm ? dm[1] + dm[2] + dm[3] : dtstart;
if (rrule) {
// Recurring series declared via RRULE: treat as a regular (non-hatched) weekly slot
const rm = rrule.match(/BYDAY=([A-Z,]+)/i);
let days = rm ? rm[1].split(',').map(d => DAY_MAP[d.trim().toUpperCase()]).filter(Boolean) : [];
if (!days.length && dm) {
const dow = new Date(+dm[1], +dm[2]-1, +dm[3]).getDay();
days = [dow === 0 ? 7 : dow];
}
days.forEach(day => occurrences.push({ label, code, fullName, nameVariants, day, startMin, endMin, location: loc, dateKey: null }));
} else if (dm) {
const dow = new Date(+dm[1], +dm[2]-1, +dm[3]).getDay();
const day = dow === 0 ? 7 : dow;
occurrences.push({ label, code, fullName, nameVariants, day, startMin, endMin, location: loc, dateKey });
}
});
// Pass 2: group same weekly slot (label + day + time) across all its occurrences
const groups = new Map();
occurrences.forEach(occ => {
const key = `${occ.label}||${occ.day}||${occ.startMin}||${occ.endMin}`;
let g = groups.get(key);
if (!g) {
g = { label: occ.label, code: occ.code, fullName: occ.fullName, nameVariants: occ.nameVariants, day: occ.day, startMin: occ.startMin, endMin: occ.endMin, locCounts: {}, dateKeys: new Set(), recurring: occ.dateKey === null };
groups.set(key, g);
}
g.locCounts[occ.location] = (g.locCounts[occ.location] || 0) + 1;
if (occ.dateKey) g.dateKeys.add(occ.dateKey);
});
const events = [];
let i = 0;
groups.forEach(g => {
const location = Object.keys(g.locCounts).sort((a, b) => g.locCounts[b] - g.locCounts[a])[0] || '';
const dates = g.recurring ? null : [...g.dateKeys].sort();
events.push({
id: uid(), title: g.label, code: g.code, fullName: g.fullName, nameVariants: g.nameVariants,
day: g.day, startMin: g.startMin, endMin: g.endMin,
color: EV_COLORS[i++ % EV_COLORS.length], location, semesterId: semId,
count: dates ? dates.length : null, dates
});
});
return events;
}
// ══════════════════════════════════════════════════════
// EXPORT ICS
// ══════════════════════════════════════════════════════
function icsEscape(s) {
return String(s || '').replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\n/g, '\\n');
}
function _icsTime(min) {
return toHHMM(min).replace(':', '') + '00';
}
function _icsNowStamp() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}T${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
}
// First calendar date on/after startDateStr ('YYYY-MM-DD') that falls on the given weekday (1=Mon..7=Sun)
function _firstDateForDay(startDateStr, day) {
const d = new Date(startDateStr + 'T00:00:00');
const curDay = d.getDay() === 0 ? 7 : d.getDay();
let diff = day - curDay;
if (diff < 0) diff += 7;
d.setDate(d.getDate() + diff);
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`;
}
function _buildICS(semId) {
const sem = S.semesters.find(x => x.id === semId);
const stamp = _icsNowStamp();
const untilStr = sem.endDate.replace(/-/g, '') + 'T235959';
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//CreditTracker//EN', 'CALSCALE:GREGORIAN'];
function pushVEvent(uid, dateStr, startMin, endMin, rruleLine, summary, location, description) {
lines.push('BEGIN:VEVENT');
lines.push(`UID:${uid}@credittracker`);
lines.push(`DTSTAMP:${stamp}`);
lines.push(`DTSTART:${dateStr}T${_icsTime(startMin)}`);
lines.push(`DTEND:${dateStr}T${_icsTime(endMin)}`);
if (rruleLine) lines.push(rruleLine);
lines.push(`SUMMARY:${icsEscape(summary)}`);
if (location) lines.push(`LOCATION:${icsEscape(location)}`);
if (description) lines.push(`DESCRIPTION:${icsEscape(description)}`);
lines.push('END:VEVENT');
}
// dates (exact YYYYMMDD list from the source ICS) takes priority when known, since it
// naturally reflects any holiday/vacation gaps — a plain weekly RRULE cannot.
function addEvent(uidBase, day, startMin, endMin, count, dates, summary, location, description) {
if (dates && dates.length) {
dates.forEach(dateStr => pushVEvent(`${uidBase}-${dateStr}`, dateStr, startMin, endMin, null, summary, location, description));
return;
}
const dateStr = _firstDateForDay(sem.startDate, day);
const rrule = count ? `RRULE:FREQ=WEEKLY;COUNT=${count}` : `RRULE:FREQ=WEEKLY;UNTIL=${untilStr}`;
pushVEvent(uidBase, dateStr, startMin, endMin, rrule, summary, location, description);
}
S.courses.filter(c => c.semesterId === semId && c.slots && c.slots.length).forEach(c => {
const summary = c.code ? `${c.code}${c.name}` : c.name;
const description = c.credits ? `${c.credits} credits` : '';
c.slots.forEach(s => addEvent(`ct-course-${c.id}-${s.day}-${s.startMin}`, s.day, s.startMin, s.endMin, s.count, s.dates, summary, s.location, description));
});
S.schedule.filter(e => e.semesterId === semId).forEach(e => {
addEvent(`ct-event-${e.id}`, e.day, e.startMin, e.endMin, e.count, e.dates, e.title, e.location, '');
});
lines.push('END:VCALENDAR');
return lines.join('\r\n');
}
function _downloadICS(semId) {
const sem = S.semesters.find(x => x.id === semId);
const ics = _buildICS(semId);
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sem.name.replace(/\s+/g, '-')}.ics`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function exportICS(semId) {
const sem = S.semesters.find(x => x.id === semId);
if (!sem) return;
if (!sem.startDate || !sem.endDate) { _promptSemesterDates(semId); return; }
_downloadICS(semId);
}
function _promptSemesterDates(semId) {
const sem = S.semesters.find(x => x.id === semId);
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Semester Dates</span>
<button class="modal-close" onclick="showWeekCalendar('${semId}')">✕</button>
</div>
<p style="font-size:12px;color:var(--gray-400);margin-bottom:12px">
Needed once, to compute real calendar dates for ${esc(sem.name)}'s recurring weekly sessions when exporting.
</p>
<div class="form-row">
<div class="form-group">
<label>Start date</label>
<input id="f-sem-start" type="date" value="${sem.startDate || ''}">
</div>
<div class="form-group">
<label>End date</label>
<input id="f-sem-end" type="date" value="${sem.endDate || ''}">
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="showWeekCalendar('${semId}')">Cancel</button>
<button class="btn btn-primary" onclick="_saveSemesterDatesAndExport('${semId}')">Save &amp; Export</button>
</div>
</div>`);
}
function _saveSemesterDatesAndExport(semId) {
const start = document.getElementById('f-sem-start').value;
const end = document.getElementById('f-sem-end').value;
if (!start || !end) { alert('Please set both dates.'); return; }
if (end <= start) { alert('End date must be after the start date.'); return; }
const i = S.semesters.findIndex(x => x.id === semId);
if (i >= 0) S.semesters[i] = { ...S.semesters[i], startDate: start, endDate: end };
save();
_downloadICS(semId);
showWeekCalendar(semId);
}
// ══════════════════════════════════════════════════════
// LINK IMPORTED ICS SLOTS TO COURSES
// ══════════════════════════════════════════════════════
let _icsLink = null; // { semId, entries: [{ code, nameVariants, slots, action, courseId, matched, newName, newCredits }] }
function _slotsEqual(a, b) {
if (a.length !== b.length) return false;
const norm = arr => arr.map(s => `${s.day}|${s.startMin}|${s.endMin}|${s.count ?? ''}|${s.location ?? ''}|${(s.dates || []).join(',')}`).sort();
const na = norm(a), nb = norm(b);
return na.every((v, i) => v === nb[i]);
}
function normName(s) {
return (s || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase().replace(/\([^)]*\)/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
}
function _findCourseCodeMatch(code) {
return code ? (S.courses.find(c => c.code && c.code === code) || null) : null;
}
function _findCourseNameSuggestion(nameVariants, semId) {
const norms = nameVariants.map(normName).filter(Boolean);
const candidates = S.courses.filter(c => c.semesterId === semId && !c.code);
let exact = candidates.find(c => norms.includes(normName(c.name)));
if (exact) return exact;
return candidates.find(c => {
const cn = normName(c.name);
return cn && norms.some(n => n.includes(cn) || cn.includes(n));
}) || null;
}
function _startICSLink(semId, slots) {
const byCode = new Map();
slots.forEach(s => {
const key = s.code || `__${s.title}`;
let g = byCode.get(key);
if (!g) { g = { code: s.code, nameVariants: s.nameVariants, slots: [] }; byCode.set(key, g); }
g.slots.push(s);
});
const entries = [...byCode.values()].map(g => {
const codeMatch = _findCourseCodeMatch(g.code);
if (codeMatch) return { ...g, action: 'existing', courseId: codeMatch.id, matched: true, newName: g.nameVariants[0], newCredits: '' };
const suggestion = _findCourseNameSuggestion(g.nameVariants, semId);
return { ...g, action: suggestion ? 'existing' : 'new', courseId: suggestion ? suggestion.id : '', matched: false, newName: g.nameVariants[0], newCredits: '' };
});
_icsLink = { semId, entries };
if (entries.every(e => e.matched)) {
const totalSlots = entries.reduce((n, e) => n + e.slots.length, 0);
if (!confirm(`${entries.length} course(s) already linked by code — import ${totalSlots} slot(s) automatically?`)) { _icsLink = null; return; }
_applyICSLink();
} else {
_renderICSLinkModal();
}
}
function _renderICSLinkModal() {
const sem = S.semesters.find(x => x.id === _icsLink.semId);
openModal(`<div class="modal" style="max-height:88vh;display:flex;flex-direction:column">
<div class="modal-header">
<span class="modal-title">Link Imported Courses</span>
<button class="modal-close" onclick="_icsLink=null;closeModal()">✕</button>
</div>
<p style="font-size:12px;color:var(--gray-400);margin-bottom:10px">
${sem ? esc(sem.name) : ''} — match each course from the ICS to one of yours. Once linked, future imports of this course are recognized automatically.
</p>
<div id="ics-link-rows" style="overflow-y:auto;flex:1;min-height:0">${_icsLinkRowsHTML()}</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="_icsLink=null;closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="_applyICSLink()">Import</button>
</div>
</div>`);
}
function _icsLinkRowsHTML() {
const semCourses = S.courses.filter(c => c.semesterId === _icsLink.semId);
return _icsLink.entries.map((e, i) => {
const timesStr = e.slots.map(s => `${WK_DAYS[s.day - 1]} ${toHHMM(s.startMin)}${toHHMM(s.endMin)}`).join(' · ');
const namesStr = e.nameVariants.join(' / ');
if (e.matched) {
const course = S.courses.find(c => c.id === e.courseId);
return `<div style="border:1px solid var(--gray-200);border-radius:10px;padding:10px;margin-bottom:8px">
<div style="font-size:13px;font-weight:600;color:var(--gray-800)">${esc(e.code)}${esc(namesStr)}</div>
<div style="font-size:11px;color:var(--gray-400);margin:2px 0 6px">${esc(timesStr)}</div>
<div style="font-size:12px;color:var(--success)">✓ Already linked to ${esc(course ? course.name : '?')}</div>
</div>`;
}
const opts = `<option value="new" ${e.action === 'new' ? 'selected' : ''}> Create new course</option>`
+ (semCourses.length ? `<optgroup label="Existing courses">` + semCourses.map(c =>
`<option value="${c.id}" ${e.action === 'existing' && e.courseId === c.id ? 'selected' : ''}>${esc(c.name)}</option>`
).join('') + `</optgroup>` : '')
+ `<option value="skip" ${e.action === 'skip' ? 'selected' : ''}>Don't link (calendar only)</option>`;
return `<div style="border:1px solid var(--gray-200);border-radius:10px;padding:10px;margin-bottom:8px">
<div style="font-size:13px;font-weight:600;color:var(--gray-800)">${e.code ? esc(e.code) + ' — ' : ''}${esc(namesStr)}</div>
<div style="font-size:11px;color:var(--gray-400);margin:2px 0 8px">${esc(timesStr)}</div>
<select onchange="_icsLinkSetAction(${i}, this.value)" style="width:100%;margin-bottom:6px">${opts}</select>
${e.action === 'new' ? `
<div class="form-row" style="margin-top:6px">
<div class="form-group" style="margin-bottom:0">
<label style="font-size:10px">Name</label>
<input type="text" value="${esc(e.newName)}" oninput="_icsLinkSetField(${i},'newName',this.value)">
</div>
<div class="form-group" style="margin-bottom:0">
<label style="font-size:10px">Credits</label>
<input type="number" min="0" step="0.5" value="${esc(e.newCredits)}" oninput="_icsLinkSetField(${i},'newCredits',this.value)">
</div>
</div>` : ''}
</div>`;
}).join('');
}
function _icsLinkSetAction(i, value) {
const e = _icsLink.entries[i];
if (value === 'new') { e.action = 'new'; e.courseId = ''; }
else if (value === 'skip') { e.action = 'skip'; e.courseId = ''; }
else { e.action = 'existing'; e.courseId = value; }
const el = document.getElementById('ics-link-rows');
if (el) el.innerHTML = _icsLinkRowsHTML();
}
function _icsLinkSetField(i, field, value) {
_icsLink.entries[i][field] = value;
}
function _applyICSLink() {
const { semId, entries } = _icsLink;
entries.forEach(e => {
if (e.action === 'skip') {
e.slots.forEach(s => S.schedule.push({
id: uid(), title: s.title, fullName: s.fullName,
day: s.day, startMin: s.startMin, endMin: s.endMin,
color: s.color, location: s.location, semesterId: semId, count: s.count, dates: s.dates
}));
} else if (e.action === 'new') {
S.courses.push({
id: uid(), name: (e.newName || e.nameVariants[0] || e.code || 'Imported course').trim(),
credits: +e.newCredits || 0, semesterId: semId, grade: null,
planId: '', blockId: '', subBlockId: '', passFail: null, attempts: [],
code: e.code || undefined,
slots: e.slots.map(s => ({ day: s.day, startMin: s.startMin, endMin: s.endMin, count: s.count, location: s.location, dates: s.dates }))
});
} else {
const course = S.courses.find(c => c.id === e.courseId);
if (!course) return;
if (!course.code && e.code) course.code = e.code;
const newSlots = e.slots.map(s => ({ day: s.day, startMin: s.startMin, endMin: s.endMin, count: s.count, location: s.location, dates: s.dates }));
if (!_slotsEqual(course.slots || [], newSlots)) course.slots = newSlots;
}
});
save(); render(page); closeModal();
_icsLink = null;
showWeekCalendar(semId);
}
// ══════════════════════════════════════════════════════
// 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 onGradeInput(v) {
const fg2 = document.getElementById('fg-grade2');
if (fg2) fg2.style.display = (v !== '' && parseFloat(v) < 4) ? 'block' : 'none';
}
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 setSlotMode(mode) {
const picker = document.getElementById('slot-picker');
if (picker) picker.style.display = mode === 'has' ? 'block' : 'none';
if (mode === 'none') {
_slots = [];
const el = document.getElementById('slots-list');
if (el) el.innerHTML = renderSlotsUI();
}
}
function showCourseModal(id, prefill = {}) {
const c = id ? S.courses.find(x => x.id === id) : null;
const curPlan = c ? (c.planId || '') : (prefill.planId || '');
const curBlock = c ? (c.blockId || '') : (prefill.blockId || '');
const curSubBlock = c ? (c.subBlockId || '') : (prefill.subBlockId || '');
const curSem = c ? (c.semesterId || '') : (prefill.semId || '');
const semOpts = S.semesters.map(s =>
`<option value="${s.id}" ${s.id === curSem ? 'selected' : ''}>${esc(s.name)}</option>`
).join('');
const planOpts = `<option value="">— None —</option>` + S.studyPlans.map(p =>
`<option value="${p.id}" ${p.id === curPlan ? 'selected' : ''}>${esc(p.name)}</option>`
).join('');
const blockOpts = buildBlockOpts(curPlan, curBlock);
const pfRaw = c ? c.passFail : null;
const pfMode = pfRaw === 'passed' || pfRaw === true ? 'passed' : pfRaw === 'failed' ? 'failed' : 'default';
_slots = c && c.slots ? c.slots.map(s => ({ ...s })) : [];
// Pre-populate grade fields from attempts (fallback to course.grade for legacy courses)
const a0 = c?.attempts?.[0];
const a1 = c?.attempts?.[1];
const g1val = pfMode === 'default' ? (a0 != null ? a0.grade : (c?.grade != null && c.grade !== '' ? c.grade : '')) : '';
const g2val = pfMode === 'default' && a1 != null ? a1.grade : '';
const g2vis = g1val !== '' && parseFloat(g1val) < 4;
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${c ? 'Edit Course' : 'Add Course'}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-group">
<label>Course Name *</label>
<input id="f-name" type="text" value="${c ? esc(c.name) : ''}" placeholder="e.g. Mathematics I">
</div>
<div class="form-row">
<div class="form-group">
<label>Credits *</label>
<input id="f-credits" type="number" value="${c ? c.credits : ''}" placeholder="e.g. 3" min="0" step="0.5">
</div>
<div class="form-group" id="fg-grade" style="display:${pfMode === 'default' ? 'block' : 'none'}">
<label>Grade (1 6)</label>
<input id="f-grade" type="number" value="${g1val}" placeholder="e.g. 5.5" min="1" max="6" step="0.25" oninput="onGradeInput(this.value)">
</div>
<div class="form-group" id="fg-grade2" style="display:${g2vis ? 'block' : 'none'}">
<label>Retake (1 6)</label>
<input id="f-grade-2" type="number" value="${g2val}" placeholder="e.g. 4.5" min="1" max="6" step="0.25">
</div>
</div>
<input type="hidden" id="f-passfail" value="${pfMode}">
<div class="pf-seg">
<button type="button" class="pf-seg-btn${pfMode==='default'?' seg-default':''}" onclick="onPFChange('default')">Default</button>
<button type="button" class="pf-seg-btn${pfMode==='passed'?' seg-passed':''}" onclick="onPFChange('passed')">Passed</button>
<button type="button" class="pf-seg-btn${pfMode==='failed'?' seg-failed':''}" onclick="onPFChange('failed')">Failed</button>
</div>
<div class="form-group">
<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:14px;margin-bottom:8px">
<label style="display:flex;align-items:center;gap:5px;font-size:13px;cursor:pointer">
<input type="radio" name="f-slot-mode" value="none" ${!_slots.length ? 'checked' : ''} onchange="setSlotMode('none')"> No fixed schedule
</label>
<label style="display:flex;align-items:center;gap:5px;font-size:13px;cursor:pointer">
<input type="radio" name="f-slot-mode" value="has" ${_slots.length ? 'checked' : ''} onchange="setSlotMode('has')"> Has a weekly schedule
</label>
</div>
<div id="slot-picker" style="display:${_slots.length ? 'block' : 'none'}">
<div style="display:grid;grid-template-columns:100px 1fr;gap:6px;margin-bottom:6px">
<div>
<div style="font-size:11px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Day</div>
<select id="f-slot-day" style="width:100%;padding:8px 6px;border:1.5px solid var(--gray-200);border-radius:8px;font-size:14px;background:var(--bg-surface);color:var(--gray-800)">
${WK_DAYS.map((d, i) => `<option value="${i+1}">${d}</option>`).join('')}
</select>
</div>
<div style="display:flex;flex-direction:column;justify-content:flex-end">
<div style="font-size:11px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Time</div>
<div style="display:flex;align-items:center;gap:4px">
<input id="f-slot-start" type="time" value="08:00" style="flex:1;min-width:0">
<span style="color:var(--gray-400);font-size:13px;flex-shrink:0"></span>
<input id="f-slot-end" type="time" value="10:00" style="flex:1;min-width:0">
</div>
</div>
</div>
<button class="btn btn-sm btn-primary btn-full" onclick="addSlot()">+ Add slot</button>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveCourse('${id || ''}')">Save</button>
</div>
</div>`);
}
function buildBlockOpts(planId, selBlock) {
if (!planId) return '<option value="">— None —</option>';
const list = S.blocks.filter(b => b.planId === planId);
if (!list.length) return '<option value="">No majors/minors in this plan</option>';
const minors = list.filter(b => b.role === 'minor');
return `<option value="">— None —</option>` + list.map(b => {
const opts = list.filter(x => x.role === 'optional');
let prefix = '';
if (b.role === 'major') prefix = 'Major: ';
else if (b.role === 'minor') prefix = `Minor ${minors.indexOf(b) + 1}: `;
else if (b.role === 'optional') prefix = `Optional ${opts.indexOf(b) + 1}: `;
return `<option value="${b.id}" ${b.id === selBlock ? 'selected' : ''}>${esc(prefix + b.name)}</option>`;
}).join('');
}
function buildSubBlockOpts(blockId, selSubBlock) {
if (!blockId) return '<option value="">— None —</option>';
const list = S.subBlocks.filter(sb => sb.blockId === blockId);
return `<option value="">— None —</option>` + list.map(sb =>
`<option value="${sb.id}" ${sb.id === selSubBlock ? 'selected' : ''}>${esc(sb.name)}</option>`
).join('');
}
function onPlanChange(pid) {
const fg = document.getElementById('fg-block');
const fs = document.getElementById('f-block');
const fgSub = document.getElementById('fg-subblock');
if (pid) { fg.style.display = 'block'; fs.innerHTML = buildBlockOpts(pid, ''); }
else { fg.style.display = 'none'; }
if (fgSub) fgSub.style.display = 'none';
}
function onBlockChange(bid) {
const fgSub = document.getElementById('fg-subblock');
const fsSub = document.getElementById('f-subblock');
if (bid) { fgSub.style.display = 'block'; fsSub.innerHTML = buildSubBlockOpts(bid, ''); }
else { fgSub.style.display = 'none'; }
}
function onPFChange(v) {
const inp = document.getElementById('f-passfail');
if (inp) inp.value = v;
const fgGrade = document.getElementById('fg-grade');
if (fgGrade) fgGrade.style.display = v === 'default' ? 'block' : 'none';
const fg2 = document.getElementById('fg-grade2');
if (fg2 && v !== 'default') fg2.style.display = 'none';
document.querySelectorAll('.pf-seg-btn').forEach(btn => {
btn.className = 'pf-seg-btn';
if (btn.textContent.trim().toLowerCase() === v) btn.classList.add('seg-' + v);
});
}
function saveCourse(id) {
const name = document.getElementById('f-name').value.trim();
const credits = document.getElementById('f-credits').value;
const pfVal = document.getElementById('f-passfail').value || 'default';
const passFail = pfVal === 'default' ? null : pfVal;
const gradeEl = document.getElementById('f-grade');
const grade2El = document.getElementById('f-grade-2');
const gradeV = (!passFail && gradeEl) ? gradeEl.value : '';
const grade2V = (!passFail && grade2El && grade2El.closest('#fg-grade2')?.style.display !== 'none') ? grade2El.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 grade1 = (passFail === null && gradeV !== '') ? parseFloat(gradeV) : null;
const grade2 = (passFail === null && grade2V !== '') ? parseFloat(grade2V) : null;
const grade = grade2 !== null ? grade2 : grade1;
// Build attempts from the two inline grade fields
const attempts = [];
if (grade1 !== null) attempts.push({ id: uid(), grade: grade1, semesterId: semId });
if (grade2 !== null) attempts.push({ id: uid(), grade: grade2, semesterId: semId });
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, attempts, code: S.courses[i].code };
} else {
S.courses.push({ id: uid(), name, credits: +credits, semesterId: semId, grade, planId, blockId, subBlockId, passFail, slots, attempts });
}
save(); closeModal(); render(page);
}
function deleteCourse(id) {
if (!confirm('Delete this course?')) return;
S.courses = S.courses.filter(c => c.id !== id);
save(); render(page);
}
// ══════════════════════════════════════════════════════
// PLAN MODALS
// ══════════════════════════════════════════════════════
function showPlanTypeModal() {
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">New Study Plan</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:4px">
<button class="btn btn-secondary btn-full" style="padding:18px;font-size:15px" onclick="showPlanModal('','bachelor')">🎓 Bachelor</button>
<button class="btn btn-secondary btn-full" style="padding:18px;font-size:15px" onclick="showPlanModal('','master')">🏛️ Master</button>
</div>
</div>`);
}
function showPlanModal(id, cycleHint) {
const p = id ? S.studyPlans.find(x => x.id === id) : null;
const cycle = p ? p.cycle : (cycleHint || 'bachelor');
const label = cycle === 'master' ? 'Master' : 'Bachelor';
const count = S.studyPlans.filter(x => x.cycle === cycle && x.id !== id).length;
const defName = p ? esc(p.name) : `${label} ${count + 1}`;
const major = p ? S.blocks.find(b => b.planId === p.id && b.role === 'major') : null;
const defColor = planBaseColor(p || { color: null, id: '__new__' });
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${p ? 'Edit Plan' : `New ${label}`}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-group">
<label>Major Name</label>
<input id="f-major" type="text" value="${major ? esc(major.name) : ''}" placeholder="e.g. Computer Science">
</div>
<div class="form-group" style="margin-bottom:4px">
<label>Plan Color</label>
${colorSwatches(PLAN_PALETTE, defColor, 'selectPlanColor', false, '')}
<input type="hidden" id="f-pcolor" value="${defColor}">
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="savePlan('${id || ''}','${cycle}')">Save</button>
</div>
</div>`);
}
function selectPlanColor(color) {
const inp = document.getElementById('f-pcolor');
if (inp) inp.value = color;
syncSwatchOutlines('cp-swatches', color);
}
function savePlan(id, cycle) {
const majorName = document.getElementById('f-major').value.trim();
const color = document.getElementById('f-pcolor')?.value || '';
const label = cycle === 'master' ? 'Master' : 'Bachelor';
const count = S.studyPlans.filter(x => x.cycle === cycle && x.id !== id).length;
const name = `${label} ${count + 1}`;
let planId = id;
if (id) {
const i = S.studyPlans.findIndex(x => x.id === id);
if (i >= 0) { S.studyPlans[i].cycle = cycle; S.studyPlans[i].color = color; }
if (majorName) {
const major = S.blocks.find(b => b.planId === id && b.role === 'major');
if (major) major.name = majorName;
else S.blocks.push({ id: uid(), planId: id, name: majorName, role: 'major' });
}
} else {
planId = uid();
S.studyPlans.push({ id: planId, name, cycle, color });
if (majorName) S.blocks.push({ id: uid(), planId, name: majorName, role: 'major' });
}
save(); closeModal(); render(page);
}
function deletePlan(id) {
if (!confirm('Delete this study plan? Courses will lose their plan assignment.')) return;
S.studyPlans = S.studyPlans.filter(p => p.id !== id);
S.blocks = S.blocks.filter(b => b.planId !== id);
S.courses.forEach(c => { if (c.planId === id) { c.planId = ''; c.blockId = ''; } });
save(); render(page);
}
// ══════════════════════════════════════════════════════
// BLOCK / SUB-BLOCK COMPLETION
// ══════════════════════════════════════════════════════
function computeSixChecks(courses, minCredits) {
const defCrs = courses.filter(c => !c.passFail);
const totalCr = courses.reduce((s, c) => s + (+c.credits || 0), 0);
const graded = defCrs.filter(c => c.grade != null && c.grade !== '');
const sumW = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0);
const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0);
const mean = sumC > 0 ? sumW / sumC : null;
const below3 = defCrs.filter(c => c.grade != null && c.grade !== '' && +c.grade < 3.0);
const grade1 = defCrs.filter(c => c.grade != null && c.grade !== '' && +c.grade === 1.0);
const failCrs = courses.filter(c => c.passFail === 'failed');
const missing = defCrs.filter(c => c.grade === null || c.grade === undefined || c.grade === '');
const mc = minCredits ? +minCredits : 0;
return [
{ label: 'Nombre minimum de crédits ECTS atteint',
ok: mc === 0 || totalCr >= mc,
detail: mc > 0 ? `${totalCr} / ${mc} cr.` : `${totalCr} cr. (aucun minimum défini)` },
{ label: 'Moyenne suffisante (≥ 4.0)',
ok: mean !== null && mean >= 4.0,
detail: mean !== null ? `Moyenne : ${mean.toFixed(2)}` : 'Aucun cours noté' },
{ label: 'Aucune note de 1.0',
ok: grade1.length === 0,
detail: grade1.length > 0 ? `${grade1.length} cours avec 1.0` : null },
{ label: 'Pas plus de 2 notes inférieures à 3.0',
ok: below3.length <= 2,
detail: `${below3.length} cours en dessous de 3.0` },
{ label: 'Aucune unité d\'enseignement évaluée par « échec »',
ok: failCrs.length === 0,
detail: failCrs.length > 0 ? `${failCrs.length} cours marqué(s) « échec »` : null },
{ label: 'Tous les résultats des épreuves ont été introduits',
ok: courses.length > 0 && missing.length === 0,
detail: missing.length > 0 ? `${missing.length} résultat(s) manquant(s)` : null }
];
}
function _checksRows(checks) {
return checks.map(c => `
<div style="display:flex;align-items:flex-start;gap:10px;padding:9px 0;border-bottom:1px solid var(--gray-100)">
<span style="font-size:15px;flex-shrink:0;margin-top:1px">${c.ok ? '✅' : '❌'}</span>
<div>
<div style="font-size:13px;font-weight:500;color:var(--gray-800)">${c.label}</div>
${c.detail ? `<div style="font-size:11px;color:${c.ok ? 'var(--gray-400)' : 'var(--danger)'};margin-top:2px">${c.detail}</div>` : ''}
</div>
</div>`).join('');
}
// ── Sub-block completion ──────────────────────────────
function subBlockCompletionChecks(sbId) {
const sb = S.subBlocks.find(s => s.id === sbId);
if (!sb) return [];
return computeSixChecks(S.courses.filter(c => c.subBlockId === sbId), sb.minCredits);
}
function showSubBlockCompletionModal(sbId) {
const sb = S.subBlocks.find(s => s.id === sbId);
if (!sb) return;
const checks = subBlockCompletionChecks(sbId);
const allOk = checks.every(c => c.ok);
const actionBtn = sb.passed
? `<button class="btn btn-secondary" onclick="markSubBlockPassed('${sbId}',false)">Retirer la validation</button>`
: allOk
? `<button class="btn btn-primary" style="background:var(--success)" onclick="markSubBlockPassed('${sbId}',true)">✓ Valider ce bloc</button>`
: `<button class="btn btn-secondary" onclick="markSubBlockPassed('${sbId}',true)">Valider malgré tout</button>`;
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Complétion · ${esc(sb.name)}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:12px;color:var(--gray-500);margin-bottom:10px">Conditions requises pour valider ce bloc :</p>
${_checksRows(checks)}
<div class="modal-actions" style="margin-top:14px">
<button class="btn btn-secondary" onclick="closeModal()">Fermer</button>
${actionBtn}
</div>
</div>`);
}
function markSubBlockPassed(sbId, val) {
const i = S.subBlocks.findIndex(s => s.id === sbId);
if (i >= 0) { S.subBlocks[i].passed = val; save(); closeModal(); renderDash(); }
}
// ── Block completion ──────────────────────────────────
function blockIsAutoPassedWithSubs(blkId) {
const sbs = S.subBlocks.filter(s => s.blockId === blkId);
const freeCrs = S.courses.filter(c => c.blockId === blkId && !c.subBlockId);
const freeOk = freeCrs.length === 0 || computeSixChecks(freeCrs, 0).every(c => c.ok);
return sbs.length > 0 && sbs.every(s => s.passed) && freeOk;
}
function blockPassedStatus(blkId) {
const sbs = S.subBlocks.filter(s => s.blockId === blkId);
if (sbs.length > 0) return blockIsAutoPassedWithSubs(blkId);
const blk = S.blocks.find(b => b.id === blkId);
return !!blk?.passed;
}
function showBlockCompletionModal(blkId) {
const blk = S.blocks.find(b => b.id === blkId);
if (!blk) return;
const sbs = S.subBlocks.filter(s => s.blockId === blkId);
const hasSubs = sbs.length > 0;
let body, actionBtn;
if (hasSubs) {
const sbRows = sbs.map(sb => `
<div style="display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--gray-100)">
<span style="font-size:15px;flex-shrink:0">${sb.passed ? '✅' : '❌'}</span>
<div style="flex:1">
<div style="font-size:13px;font-weight:500;color:var(--gray-800)">${esc(sb.name)}</div>
<div style="font-size:11px;color:var(--gray-400)">${sb.passed ? 'Validé' : 'Non validé — utilisez le bouton "Check" du bloc'}</div>
</div>
</div>`).join('');
const freeCrs = S.courses.filter(c => c.blockId === blkId && !c.subBlockId);
let freeRow = '';
if (freeCrs.length > 0) {
const freeChecks = computeSixChecks(freeCrs, 0);
const freeOk = freeChecks.every(c => c.ok);
freeRow = `<div style="margin-top:10px;font-size:11px;font-weight:700;color:var(--gray-500);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px">Cours libres (${freeCrs.length})</div>` + _checksRows(freeChecks);
}
const allDone = blockIsAutoPassedWithSubs(blkId);
body = sbRows + freeRow;
actionBtn = '';
if (allDone) {
actionBtn = `<span style="font-size:13px;font-weight:600;color:var(--success)">✓ Tous les blocs sont validés</span>`;
}
} else {
const courses = S.courses.filter(c => c.blockId === blkId);
const checks = computeSixChecks(courses, blk.minCredits);
const allOk = checks.every(c => c.ok);
body = _checksRows(checks);
actionBtn = blk.passed
? `<button class="btn btn-secondary" onclick="markBlockPassed('${blkId}',false)">Retirer la validation</button>`
: allOk
? `<button class="btn btn-primary" style="background:var(--success)" onclick="markBlockPassed('${blkId}',true)">✓ Valider le bloc</button>`
: `<button class="btn btn-secondary" onclick="markBlockPassed('${blkId}',true)">Valider malgré tout</button>`;
}
const subtitle = hasSubs
? 'Le bloc est validé automatiquement quand tous ses sous-blocs sont validés.'
: 'Conditions requises pour valider ce bloc :';
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Complétion · ${esc(blk.name)}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:12px;color:var(--gray-500);margin-bottom:10px">${subtitle}</p>
${body}
<div class="modal-actions" style="margin-top:14px">
<button class="btn btn-secondary" onclick="closeModal()">Fermer</button>
${actionBtn}
</div>
</div>`);
}
function markBlockPassed(blkId, val) {
const i = S.blocks.findIndex(b => b.id === blkId);
if (i >= 0) { S.blocks[i].passed = val; save(); closeModal(); renderDash(); }
}
// ══════════════════════════════════════════════════════
// MINOR / BLOCK MODAL
// ══════════════════════════════════════════════════════
function showMinorModal(planId, id, role) {
const b = id ? S.blocks.find(x => x.id === id) : null;
const label = role === 'major' ? 'Major' : role === 'minor' ? 'Minor' : 'Optional Study';
const plan = S.studyPlans.find(p => p.id === planId);
const autoClr = (() => {
if (!b) {
if (role === 'major') return planBaseColor(plan);
if (role === 'minor') {
const cnt = S.blocks.filter(bk => bk.planId === planId && bk.role === 'minor').length;
return MINOR_PALETTE[cnt % MINOR_PALETTE.length];
}
const cnt = S.blocks.filter(bk => bk.planId === planId && bk.role === 'optional').length;
return MINOR_PALETTE[(cnt + 4) % MINOR_PALETTE.length];
}
return blockEffectiveColor({ ...b, color: '' });
})();
const curColor = b?.color || '';
const palette = role === 'major' ? PLAN_PALETTE : MINOR_PALETTE;
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${b ? `Edit ${label}` : `Add ${label}`}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-row">
<div class="form-group">
<label>${label} Name *</label>
<input id="f-bname" type="text" value="${b ? esc(b.name) : ''}" placeholder="e.g. Mathematics">
</div>
<div class="form-group" style="max-width:110px">
<label>Crédits min.</label>
<input id="f-bmin" type="number" min="0" step="1" value="${b && b.minCredits ? b.minCredits : ''}" placeholder="e.g. 60">
</div>
</div>
<div class="form-group" style="margin-bottom:4px">
<label>Color <span style="font-weight:400;color:var(--gray-400);text-transform:none">(auto = plan family)</span></label>
${colorSwatches(palette, curColor, 'selectBlockColor', true, autoClr)}
<input type="hidden" id="f-bcolor" value="${curColor}">
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveMinor('${planId}','${id || ''}','${role || 'minor'}')">Save</button>
</div>
</div>`);
}
function selectBlockColor(color) {
const inp = document.getElementById('f-bcolor');
if (inp) inp.value = color;
syncSwatchOutlines('cp-swatches', color);
}
function saveMinor(planId, id, role) {
const name = document.getElementById('f-bname').value.trim();
const color = document.getElementById('f-bcolor')?.value || '';
const minCrEl = document.getElementById('f-bmin');
const minCredits = minCrEl && minCrEl.value !== '' ? +minCrEl.value : 0;
if (!name) { alert('Please enter a name.'); return; }
if (!id && role === 'major' && S.blocks.some(b => b.planId === planId && b.role === 'major')) {
alert('This plan already has a major. Only one major is allowed per plan.'); return;
}
if (id) {
const i = S.blocks.findIndex(x => x.id === id);
if (i >= 0) { S.blocks[i].name = name; S.blocks[i].role = role; S.blocks[i].color = color; S.blocks[i].minCredits = minCredits; }
} else {
S.blocks.push({ id: uid(), planId, name, role, color, minCredits });
}
save(); closeModal(); render(page);
}
function showSubBlockModal(blockId, id) {
const sb = id ? S.subBlocks.find(x => x.id === id) : null;
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${sb ? 'Edit Block' : 'Add Block'}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-row">
<div class="form-group">
<label>Block Name *</label>
<input id="f-sbname" type="text" value="${sb ? esc(sb.name) : ''}" placeholder="e.g. Algebra">
</div>
<div class="form-group" style="max-width:110px">
<label>Crédits min.</label>
<input id="f-sbmin" type="number" min="0" step="1" value="${sb && sb.minCredits ? sb.minCredits : ''}" placeholder="e.g. 20">
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveSubBlock('${blockId}','${id||''}')">Save</button>
</div>
</div>`);
}
function saveSubBlock(blockId, id) {
const name = document.getElementById('f-sbname').value.trim();
const minCrEl = document.getElementById('f-sbmin');
const minCredits = minCrEl && minCrEl.value !== '' ? +minCrEl.value : 0;
if (!name) { alert('Please enter a block name.'); return; }
if (id) {
const i = S.subBlocks.findIndex(x => x.id === id);
if (i >= 0) { S.subBlocks[i].name = name; S.subBlocks[i].minCredits = minCredits; }
} else {
S.subBlocks.push({ id: uid(), blockId, name, minCredits });
}
save(); closeModal(); render(page);
}
function deleteSubBlock(id) {
if (!confirm('Delete this block? Courses will lose their sub-block assignment.')) return;
S.subBlocks = S.subBlocks.filter(b => b.id !== id);
S.courses.forEach(c => { if (c.subBlockId === id) c.subBlockId = ''; });
save(); render(page);
}
function deleteBlock(id) {
if (!confirm('Delete this major/minor? All its blocks and course assignments will be cleared.')) return;
const sbIds = S.subBlocks.filter(sb => sb.blockId === id).map(sb => sb.id);
S.subBlocks = S.subBlocks.filter(sb => sb.blockId !== id);
S.courses.forEach(c => {
if (c.blockId === id) { c.blockId = ''; c.subBlockId = ''; }
else if (sbIds.includes(c.subBlockId)) { c.subBlockId = ''; }
});
S.blocks = S.blocks.filter(b => b.id !== id);
save(); render(page);
}
// ══════════════════════════════════════════════════════
// SEMESTER MODAL
// ══════════════════════════════════════════════════════
function showSemesterModal(id) {
const s = id ? S.semesters.find(x => x.id === id) : null;
const yr = s ? s.year : new Date().getFullYear();
const sea = s ? s.season : 'Autumn';
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${s ? 'Edit Semester' : 'New Semester'}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-row">
<div class="form-group">
<label>Season</label>
<select id="f-season">
<option value="Autumn" ${sea==='Autumn'?'selected':''}>Autumn</option>
<option value="Spring" ${sea==='Spring'?'selected':''}>Spring</option>
${settings.allSeasons ? `
<option value="Summer" ${sea==='Summer'?'selected':''}>Summer</option>
<option value="Winter" ${sea==='Winter'?'selected':''}>Winter</option>` : ''}
</select>
</div>
<div class="form-group">
<label>Year</label>
<input id="f-syear" type="number" value="${yr}" min="2000" max="2100">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Start date <span style="font-weight:400;color:var(--gray-400)">(optional)</span></label>
<input id="f-sem-start" type="date" value="${s && s.startDate ? s.startDate : ''}">
</div>
<div class="form-group">
<label>End date <span style="font-weight:400;color:var(--gray-400)">(optional)</span></label>
<input id="f-sem-end" type="date" value="${s && s.endDate ? s.endDate : ''}">
</div>
</div>
<p style="font-size:11px;color:var(--gray-400);margin:-6px 0 4px">Used to compute exact dates when exporting the week schedule to ICS, and to detect the "current" semester.</p>
<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;
const startDate = document.getElementById('f-sem-start').value || undefined;
const endDate = document.getElementById('f-sem-end').value || undefined;
if (!season || !year) { alert('Please select a season and year.'); return; }
if (startDate && endDate && endDate <= startDate) { alert('End date must be after the start date.'); return; }
if (id) {
const i = S.semesters.findIndex(x => x.id === id);
if (i >= 0) S.semesters[i] = { ...S.semesters[i], name, season, year, startDate, endDate };
} else {
S.semesters.push({ id: uid(), name, season, year, startDate, endDate });
}
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);display:flex;flex-direction:column;gap:12px">
${settingsToggleRow('Show "Optional Studies" section', 'showOptional')}
${settingsToggleRow('Auto-fold validated blocks', 'autoFoldValidatedBlocks')}
</div>
<div id="sw-version-label" style="font-size:11px;color:var(--gray-400);margin-top:16px;text-align:center">${_swVersion ? 'App version: ' + _swVersion : 'App version: checking…'}</div>
<button class="btn btn-secondary btn-full" style="margin-top:8px" onclick="closeModal()">Done</button>
</div>`);
requestSWVersion();
}
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:6px">Download a JSON backup or load one from a file.</p>
<p id="data-backup-status" style="font-size:12px;margin-bottom:10px;color:${daysSinceBackup() === null || daysSinceBackup() >= BACKUP_NUDGE_DAYS ? 'var(--danger)' : 'var(--gray-400)'}">
${daysSinceBackup() === null ? 'Never backed up — everything only lives in this browser.' : `Last backup: ${daysSinceBackup() === 0 ? 'today' : daysSinceBackup() + ' day' + (daysSinceBackup() > 1 ? 's' : '') + ' ago'}`}
</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);
try { localStorage.setItem(BACKUP_KEY, new Date().toISOString()); } catch (e) {}
updateBackupDot();
if (document.getElementById('data-backup-status')) showDataModal();
}
function importData(input) {
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const data = JSON.parse(e.target.result);
if (!Array.isArray(data.courses) || !Array.isArray(data.semesters)) {
alert('Invalid file: missing required fields.');
return;
}
if (!confirm(`This will replace all current data with:\n• ${data.courses.length} courses\n• ${(data.studyPlans||[]).length} study plans\n• ${data.semesters.length} semesters\n\nContinue?`)) return;
S.studyPlans = data.studyPlans || [];
S.blocks = data.blocks || [];
S.subBlocks = data.subBlocks || [];
S.semesters = data.semesters || [];
S.courses = data.courses || [];
S.schedule = data.schedule || [];
save();
closeModal();
render(page);
alert('Data imported successfully!');
} catch (err) {
alert('Could not read file. Make sure it is a valid Credit Tracker JSON export.');
}
};
reader.readAsText(file);
}
function clearData() {
if (!confirm('Clear ALL data? This cannot be undone.')) return;
S = { studyPlans: [], blocks: [], subBlocks: [], semesters: [], courses: [], schedule: [] };
save();
closeModal();
render(page);
}
// ══════════════════════════════════════════════════════
// QR CODE SHARE / SCAN
// ══════════════════════════════════════════════════════
const QR_URLS = ['https://unpkg.com/qrcode@1.4.4/build/qrcode.min.js', 'https://cdn.jsdelivr.net/npm/qrcode@1.4.4/build/qrcode.min.js'];
const JSQR_URLS = ['https://unpkg.com/jsqr@1.4.0/dist/jsQR.js', 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js'];
async function loadScriptCached(urls, globalName) {
if (window[globalName]) return;
const key = 'ct_lib_' + globalName;
let code = null;
try {
const cached = localStorage.getItem(key);
// Reject cached value if it looks like HTML (SW offline fallback corruption)
if (cached && !cached.trimStart().startsWith('<')) code = cached;
else if (cached) localStorage.removeItem(key);
} catch(e) {}
if (!code) {
for (const url of urls) {
try {
const r = await fetch(url);
if (r.ok) {
const text = await r.text();
if (!text.trimStart().startsWith('<')) { code = text; break; }
}
} catch(e) {}
}
if (!code) throw new Error('Could not load the ' + globalName + ' library. Open this app once with an internet connection to cache it for offline use.');
try { localStorage.setItem(key, code); } catch(e) {}
}
const s = document.createElement('script');
s.textContent = code;
document.head.appendChild(s);
if (!window[globalName]) throw new Error(globalName + ' library failed to initialize.');
}
async function compress(str) {
if (!window.CompressionStream) return btoa(unescape(encodeURIComponent(str)));
const bytes = new TextEncoder().encode(str);
const cs = new CompressionStream('deflate-raw');
const w = cs.writable.getWriter();
w.write(bytes); w.close();
const buf = await new Response(cs.readable).arrayBuffer();
// base64 in chunks to avoid call-stack overflow on large arrays
const u8 = new Uint8Array(buf);
let b64 = '';
for (let i = 0; i < u8.length; i += 0x8000)
b64 += String.fromCharCode(...u8.subarray(i, i + 0x8000));
return btoa(b64);
}
async function decompress(b64) {
const binary = atob(b64);
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
if (!window.DecompressionStream) return decodeURIComponent(escape(binary));
const ds = new DecompressionStream('deflate-raw');
const w = ds.writable.getWriter();
w.write(bytes); w.close();
return new Response(ds.readable).text();
}
async function showQRShare() {
const isDark = document.documentElement.classList.contains('dark');
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Share via QR Code</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div style="text-align:center;padding:4px 0 8px">
<p id="qr-status" style="font-size:12px;color:var(--gray-400);margin-bottom:12px">Generating…</p>
<canvas id="qr-canvas" style="border-radius:10px;max-width:100%"></canvas>
</div>
<p style="font-size:12px;color:var(--gray-400);text-align:center;margin-top:8px">
Scan this on your other device to import all data
</p>
<div style="border-top:1px solid var(--gray-200);margin:16px 0 0"></div>
<button class="btn btn-secondary btn-full" style="margin-top:12px" onclick="showQRScan()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
Switch to Scan mode
</button>
</div>`);
try {
await loadScriptCached(QR_URLS, 'QRCode');
const payload = JSON.stringify({ _ct: 'ct1', studyPlans: S.studyPlans, blocks: S.blocks, subBlocks: S.subBlocks, semesters: S.semesters, courses: S.courses, schedule: S.schedule });
const encoded = await compress(payload);
const statusEl = document.getElementById('qr-status');
const canvas = document.getElementById('qr-canvas');
if (!canvas) return;
if (statusEl) statusEl.textContent = `${S.courses.length} course${S.courses.length !== 1 ? 's' : ''} · ${encoded.length} bytes encoded`;
await QRCode.toCanvas(canvas, encoded, {
errorCorrectionLevel: 'L',
width: Math.min(260, window.innerWidth - 80),
margin: 1,
color: { dark: isDark ? '#f1f5f9' : '#111827', light: isDark ? '#1e293b' : '#ffffff' }
});
} catch (e) {
console.error('QR generation failed:', e);
const st = document.getElementById('qr-status');
if (st) {
st.style.color = 'var(--danger)';
const msg = e?.message || '';
if (msg.includes('too big') || msg.includes('too large') || msg.includes('data is too')) {
st.textContent = 'Too much data for a single QR code — use Export JSON instead.';
} else {
st.textContent = msg || 'Failed to generate QR code.';
}
}
}
}
let _scanStream = null;
let _scanRaf = null;
async function showQRScan() {
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Scan QR Code</span>
<button class="modal-close" onclick="_stopScan();closeModal()">✕</button>
</div>
<div style="position:relative;background:#000;border-radius:12px;overflow:hidden">
<video id="qr-video" style="width:100%;display:block" playsinline muted></video>
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none">
<div style="width:55%;aspect-ratio:1;border:2.5px solid rgba(255,255,255,0.75);border-radius:12px;box-shadow:0 0 0 2000px rgba(0,0,0,0.35)"></div>
</div>
</div>
<canvas id="qr-scan-canvas" style="display:none"></canvas>
<p id="scan-status" style="font-size:13px;color:var(--gray-500);text-align:center;margin-top:12px">Point camera at QR code…</p>
<button class="btn btn-secondary btn-full" style="margin-top:10px" onclick="showQRShare()">
Switch to Show QR mode
</button>
</div>`);
try {
await loadScriptCached(JSQR_URLS, 'jsQR');
_scanStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
const video = document.getElementById('qr-video');
if (!video) { _stopScan(); return; }
video.srcObject = _scanStream;
await video.play();
_scanFrame(video);
} catch (e) {
const st = document.getElementById('scan-status');
if (st) {
st.style.color = 'var(--danger)';
st.textContent = e.name === 'NotAllowedError' ? 'Camera permission denied.' : 'Camera not available on this device.';
}
}
}
function _scanFrame(video) {
const canvas = document.getElementById('qr-scan-canvas');
if (!canvas || !video.videoWidth) { _scanRaf = requestAnimationFrame(() => _scanFrame(video)); return; }
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(video, 0, 0);
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(img.data, canvas.width, canvas.height, { inversionAttempts: 'dontInvert' });
if (code) { _stopScan(); _importFromQR(code.data); }
else _scanRaf = requestAnimationFrame(() => _scanFrame(video));
}
function _stopScan() {
if (_scanRaf) { cancelAnimationFrame(_scanRaf); _scanRaf = null; }
if (_scanStream) { _scanStream.getTracks().forEach(t => t.stop()); _scanStream = null; }
}
// ── Two-way sync helpers ──────────────────────────────────────
function _mergeById(local, remote) {
const map = {};
local.forEach(x => { map[x.id] = x; });
remote.forEach(x => { if (!map[x.id]) map[x.id] = x; });
return Object.values(map);
}
function _semName(id, semesters) {
if (!id) return '—';
const s = semesters.find(x => x.id === id);
return s ? s.name : id;
}
function _fieldLabel(f) {
return { credits: 'Credits', grade: 'Grade', semesterId: 'Semester' }[f] || f;
}
function _fieldVal(f, v, semesters) {
if (f === 'semesterId') return _semName(v, semesters);
if (f === 'grade') return (v !== null && v !== undefined) ? String(v) : '—';
if (f === 'credits') return (v !== null && v !== undefined) ? v + ' cr.' : '—';
return String(v ?? '—');
}
function _mergeAndSync(parsed) {
const mergedBase = {
studyPlans: _mergeById(S.studyPlans, parsed.studyPlans || []),
blocks: _mergeById(S.blocks, parsed.blocks || []),
subBlocks: _mergeById(S.subBlocks, parsed.subBlocks || []),
semesters: _mergeById(S.semesters, parsed.semesters || []),
schedule: _mergeById(S.schedule, parsed.schedule || []),
courses: []
};
const remoteCourses = parsed.courses || [];
const localIds = new Set(S.courses.map(c => c.id));
const remoteIds = new Set(remoteCourses.map(c => c.id));
const conflictList = [];
const noCflCourses = [];
remoteCourses.filter(c => !localIds.has(c.id)).forEach(c => noCflCourses.push(c));
S.courses.filter(c => !remoteIds.has(c.id)).forEach(c => noCflCourses.push(c));
S.courses.filter(c => remoteIds.has(c.id)).forEach(local => {
const remote = remoteCourses.find(r => r.id === local.id);
const diffs = [];
['credits','grade','semesterId'].forEach(f => {
const lv = local[f] ?? null;
const rv = remote[f] ?? null;
if (String(lv) !== String(rv)) diffs.push({ field: f, mine: lv, theirs: rv });
});
if (diffs.length) {
conflictList.push({ local, remote, diffs, choices: Object.fromEntries(diffs.map(d => [d.field, 'mine'])) });
} else {
noCflCourses.push(local);
}
});
mergedBase.courses = noCflCourses;
_syncMergedBase = mergedBase;
_syncConflicts = conflictList;
if (!conflictList.length) {
Object.assign(S, mergedBase);
save(); closeModal(); render(page);
_afterSyncPrompt();
} else {
_showConflictModal();
}
}
function _showConflictModal() {
const sems = _syncMergedBase.semesters;
const rows = _syncConflicts.map((c, ci) => `
<div style="border:1px solid var(--gray-200);border-radius:10px;padding:12px;margin-bottom:10px">
<div style="font-size:14px;font-weight:600;color:var(--gray-800);margin-bottom:8px">📚 ${esc(c.local.name)}</div>
${c.diffs.map(d => `
<div style="margin-bottom:8px">
<div style="font-size:11px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px">${_fieldLabel(d.field)}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<label id="sl_${ci}_${d.field}_mine" style="display:flex;align-items:center;gap:6px;padding:8px;border:1.5px solid var(--primary);border-radius:8px;cursor:pointer;font-size:13px">
<input type="radio" name="cf_${ci}_${d.field}" value="mine" checked onchange="syncChoice(${ci},'${d.field}','mine')">
<div><div style="font-size:10px;color:var(--gray-400);text-transform:uppercase;font-weight:600">Mine</div>${esc(_fieldVal(d.field, d.mine, sems))}</div>
</label>
<label id="sl_${ci}_${d.field}_theirs" style="display:flex;align-items:center;gap:6px;padding:8px;border:1.5px solid var(--gray-200);border-radius:8px;cursor:pointer;font-size:13px">
<input type="radio" name="cf_${ci}_${d.field}" value="theirs" onchange="syncChoice(${ci},'${d.field}','theirs')">
<div><div style="font-size:10px;color:var(--gray-400);text-transform:uppercase;font-weight:600">Theirs</div>${esc(_fieldVal(d.field, d.theirs, sems))}</div>
</label>
</div>
</div>
`).join('')}
</div>
`).join('');
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Sync Conflicts</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:13px;color:var(--gray-500);margin-bottom:14px">
${_syncConflicts.length} course${_syncConflicts.length > 1 ? 's have' : ' has'} conflicting data.
Choose which version to keep for each field.
</p>
${rows}
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="applyMergedSync()">Apply &amp; Generate Response QR</button>
</div>
</div>`);
}
function syncChoice(ci, field, value) {
if (_syncConflicts[ci]) _syncConflicts[ci].choices[field] = value;
['mine','theirs'].forEach(v => {
const el = document.getElementById(`sl_${ci}_${field}_${v}`);
if (el) el.style.border = `1.5px solid ${v === value ? 'var(--primary)' : 'var(--gray-200)'}`;
});
}
function applyMergedSync() {
_syncConflicts.forEach(c => {
const course = { ...c.local };
c.diffs.forEach(d => {
if ((c.choices[d.field] || 'mine') === 'theirs') course[d.field] = d.theirs;
});
_syncMergedBase.courses.push(course);
});
Object.assign(S, _syncMergedBase);
save(); render(page);
_afterSyncPrompt();
}
function _afterSyncPrompt() {
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Data Merged</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:13px;color:var(--gray-600);margin-bottom:14px">
Their data has been merged into yours. Send a response QR so the other device also receives your merged data?
</p>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Skip — receive only</button>
<button class="btn btn-primary" onclick="showResponseQR()">Send Response QR</button>
</div>
</div>`);
}
async function showResponseQR() {
const isDark = document.documentElement.classList.contains('dark');
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Sync Complete</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:13px;color:var(--gray-600);margin-bottom:8px">
Data merged. <strong>Have the other device scan this QR</strong> to complete the two-way sync.
</p>
<div style="text-align:center;padding:8px 0">
<p id="qr-status" style="font-size:12px;color:var(--gray-400);margin-bottom:10px">Generating…</p>
<canvas id="qr-canvas" style="border-radius:10px;max-width:100%"></canvas>
</div>
<button class="btn btn-secondary btn-full" style="margin-top:12px" onclick="closeModal()">Done</button>
</div>`);
try {
await loadScriptCached(QR_URLS, 'QRCode');
const payload = JSON.stringify({ _ct: 'ct1', studyPlans: S.studyPlans, blocks: S.blocks, subBlocks: S.subBlocks, semesters: S.semesters, courses: S.courses, schedule: S.schedule });
const encoded = await compress(payload);
const canvas = document.getElementById('qr-canvas');
const statusEl = document.getElementById('qr-status');
if (!canvas) return;
if (statusEl) statusEl.textContent = `${S.courses.length} courses · ${encoded.length} bytes`;
await QRCode.toCanvas(canvas, encoded, {
errorCorrectionLevel: 'L',
width: Math.min(260, window.innerWidth - 80),
margin: 1,
color: { dark: isDark ? '#f1f5f9' : '#111827', light: isDark ? '#1e293b' : '#ffffff' }
});
} catch (e) {
const st = document.getElementById('qr-status');
if (st) { st.style.color = 'var(--danger)'; st.textContent = e?.message || 'Failed to generate QR code.'; }
}
}
async function _importFromQR(encoded) {
try {
const json = await decompress(encoded);
const parsed = JSON.parse(json);
if (parsed._ct !== 'ct1' || !Array.isArray(parsed.courses)) throw new Error('invalid');
const incomingNew = (parsed.courses || []).filter(c => !S.courses.find(x => x.id === c.id)).length;
const msg = `Sync data from QR?\n• ${parsed.courses.length} courses incoming (${incomingNew} new)\n• ${(parsed.studyPlans||[]).length} plans · ${(parsed.semesters||[]).length} semesters\n\nYour data will be merged. Conflicts will be shown before saving.`;
if (!confirm(msg)) return;
_mergeAndSync(parsed);
} catch (e) {
alert('Could not read this QR code. Make sure it was generated by Credit Tracker.');
}
}
// ══════════════════════════════════════════════════════
// INIT
// ══════════════════════════════════════════════════════
let _swVersion = null;
function requestSWVersion() {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: 'GET_VERSION' });
}
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js').then(reg => {
reg.update(); // force an immediate check instead of waiting on the browser's own heuristic
requestSWVersion();
}).catch(err => {
console.warn('SW registration failed:', err);
});
});
// Once a new worker takes control, reload once so the page reflects it immediately
let _swRefreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (_swRefreshing) return;
_swRefreshing = true;
window.location.reload();
});
navigator.serviceWorker.addEventListener('message', e => {
if (e.data && e.data.type === 'VERSION') {
_swVersion = e.data.version;
const el = document.getElementById('sw-version-label');
if (el) el.textContent = 'App version: ' + _swVersion;
}
});
}
load();
render('dashboard');
</script>
</body>
</html>