- 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>
3462 lines
164 KiB
HTML
3462 lines
164 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// 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 & 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 & 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>
|