1185 lines
48 KiB
HTML
1185 lines
48 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;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: var(--gray-100);
|
||
color: var(--gray-800);
|
||
min-height: 100vh;
|
||
padding-bottom: 72px;
|
||
}
|
||
|
||
/* ── 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 h1 { font-size: 19px; font-weight: 700; letter-spacing: -0.3px; }
|
||
.header-sub { font-size: 11px; opacity: 0.75; margin-top: 1px; }
|
||
|
||
/* ── Bottom Navigation ── */
|
||
.bottom-nav {
|
||
position: fixed;
|
||
bottom: 0; left: 0; right: 0;
|
||
background: white;
|
||
border-top: 1px solid var(--gray-200);
|
||
display: flex;
|
||
z-index: 100;
|
||
box-shadow: 0 -2px 12px rgba(0,0,0,0.08);
|
||
}
|
||
.nav-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 7px 4px 5px;
|
||
cursor: pointer;
|
||
color: var(--gray-400);
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
gap: 3px;
|
||
border: none;
|
||
background: none;
|
||
transition: color 0.15s;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.nav-item.active { color: var(--primary); }
|
||
.nav-item svg { width: 22px; height: 22px; stroke-width: 1.8; }
|
||
|
||
/* ── 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: white;
|
||
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: white;
|
||
border-radius: 12px;
|
||
padding: 14px 12px;
|
||
text-align: center;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.07);
|
||
}
|
||
.stat-value { font-size: 26px; font-weight: 700; color: var(--primary); line-height: 1; }
|
||
.stat-label { font-size: 11px; color: var(--gray-500); margin-top: 4px; }
|
||
|
||
/* ── Tables ── */
|
||
.table-wrap { overflow-x: auto; border-radius: 8px; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
th {
|
||
background: var(--gray-50);
|
||
padding: 8px 10px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: var(--gray-500);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
border-bottom: 1px solid var(--gray-200);
|
||
white-space: nowrap;
|
||
}
|
||
td {
|
||
padding: 9px 10px;
|
||
border-bottom: 1px solid var(--gray-100);
|
||
color: var(--gray-700);
|
||
vertical-align: middle;
|
||
}
|
||
tr:last-child td { border-bottom: none; }
|
||
tr.clickable:hover td { background: var(--gray-50); cursor: pointer; }
|
||
|
||
/* ── Badges ── */
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 2px 7px;
|
||
border-radius: 10px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
.badge-pass { background: var(--success-light); color: #065F46; }
|
||
.badge-fail { background: var(--danger-light); color: #991B1B; }
|
||
.badge-prog { background: var(--warning-light); color: #92400E; }
|
||
.badge-info { background: var(--primary-light); color: #3730A3; }
|
||
|
||
/* ── 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: white;
|
||
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, select {
|
||
width: 100%; padding: 10px 12px;
|
||
border: 1.5px solid var(--gray-200); border-radius: 8px;
|
||
font-size: 15px; color: var(--gray-800); background: white;
|
||
-webkit-appearance: auto; appearance: auto;
|
||
transition: border-color 0.15s;
|
||
}
|
||
input:focus, select:focus {
|
||
outline: none;
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px var(--primary-light);
|
||
}
|
||
|
||
/* ── 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: white; 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 data button ── */
|
||
.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; }
|
||
|
||
/* ── 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: white; 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;
|
||
}
|
||
.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; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header">
|
||
<div>
|
||
<div class="header h1" style="font-size:19px;font-weight:700;letter-spacing:-0.3px">📚 Credit Tracker</div>
|
||
<div class="header-sub" id="header-sub">Loading…</div>
|
||
</div>
|
||
<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>
|
||
Data
|
||
</button>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<!-- Dashboard -->
|
||
<div class="page active" id="page-dashboard">
|
||
<div class="seg-ctrl" id="dash-seg">
|
||
<button class="seg-btn active" onclick="setDashView('global')">Global</button>
|
||
<button class="seg-btn" onclick="setDashView('plans')">By Plan</button>
|
||
<button class="seg-btn" onclick="setDashView('semesters')">By Semester</button>
|
||
</div>
|
||
<div id="dash-content"></div>
|
||
</div>
|
||
|
||
<!-- Courses -->
|
||
<div class="page" id="page-courses">
|
||
<div id="courses-content"></div>
|
||
</div>
|
||
|
||
<!-- Plans -->
|
||
<div class="page" id="page-plans">
|
||
<div id="plans-content"></div>
|
||
</div>
|
||
|
||
<!-- Semesters -->
|
||
<div class="page" id="page-semesters">
|
||
<div id="semesters-content"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="fab" id="fab" onclick="onFab()">+</button>
|
||
|
||
<nav class="bottom-nav">
|
||
<button class="nav-item active" id="nav-dashboard" onclick="go('dashboard')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||
Dashboard
|
||
</button>
|
||
<button class="nav-item" id="nav-courses" onclick="go('courses')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||
Courses
|
||
</button>
|
||
<button class="nav-item" id="nav-plans" onclick="go('plans')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||
Plans
|
||
</button>
|
||
<button class="nav-item" id="nav-semesters" onclick="go('semesters')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>
|
||
Semesters
|
||
</button>
|
||
</nav>
|
||
|
||
<div id="modal-root"></div>
|
||
|
||
<script>
|
||
// ══════════════════════════════════════════════════════
|
||
// STATE
|
||
// ══════════════════════════════════════════════════════
|
||
const STORE_KEY = 'creditTracker_v1';
|
||
let S = { studyPlans: [], blocks: [], semesters: [], courses: [] };
|
||
let page = 'dashboard';
|
||
let dashView = 'global';
|
||
|
||
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.semesters = parsed.semesters || [];
|
||
S.courses = parsed.courses || [];
|
||
}
|
||
} catch (e) { console.error('Load failed', e); }
|
||
}
|
||
|
||
function save() {
|
||
try { localStorage.setItem(STORE_KEY, JSON.stringify(S)); }
|
||
catch (e) { console.error('Save failed', e); }
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// COMPUTED HELPERS
|
||
// ══════════════════════════════════════════════════════
|
||
function blockMean(blockId) {
|
||
const graded = S.courses.filter(c => c.blockId === blockId && 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;
|
||
}
|
||
|
||
// status: 'pass' | 'fail' | 'prog'
|
||
function status(course) {
|
||
if (course.grade == null || course.grade === '') return 'prog';
|
||
if (+course.grade >= 4) return 'pass';
|
||
if (course.blockId) {
|
||
const m = blockMean(course.blockId);
|
||
if (m !== null && m >= 4) return 'pass';
|
||
}
|
||
return 'fail';
|
||
}
|
||
|
||
function badge(st) {
|
||
if (st === 'pass') return '<span class="badge badge-pass">Passed</span>';
|
||
if (st === 'fail') return '<span class="badge badge-fail">Failed</span>';
|
||
return '<span class="badge badge-prog">In progress</span>';
|
||
}
|
||
|
||
function gradeClass(g) {
|
||
if (g == null || g === '') return '';
|
||
return +g >= 5 ? 'g-high' : +g >= 4 ? 'g-mid' : 'g-low';
|
||
}
|
||
|
||
function fmtGrade(g) {
|
||
if (g == null || g === '') return '—';
|
||
const n = +g;
|
||
return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||
}
|
||
|
||
function semName(id) { const s = S.semesters.find(x => x.id === id); return s ? esc(s.name) : '—'; }
|
||
function planName(id) { const p = S.studyPlans.find(x => x.id === id); return p ? esc(p.name) : '—'; }
|
||
function blockName(id) { const b = S.blocks.find(x => x.id === id); return b ? esc(b.name) : '—'; }
|
||
|
||
function 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'));
|
||
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
||
document.getElementById('page-' + p).classList.add('active');
|
||
document.getElementById('nav-' + p).classList.add('active');
|
||
page = p;
|
||
render(p);
|
||
}
|
||
|
||
function setDashView(v) {
|
||
dashView = v;
|
||
const btns = document.querySelectorAll('#dash-seg .seg-btn');
|
||
['global','plans','semesters'].forEach((n, i) => btns[i].classList.toggle('active', n === v));
|
||
renderDash();
|
||
}
|
||
|
||
function render(p) {
|
||
if (p === 'dashboard') renderDash();
|
||
if (p === 'courses') renderCourses();
|
||
if (p === 'plans') renderPlans();
|
||
if (p === 'semesters') renderSemesters();
|
||
updateHeaderSub();
|
||
}
|
||
|
||
function updateHeaderSub() {
|
||
const total = S.courses.length;
|
||
const passed = S.courses.filter(c => status(c) === 'pass').length;
|
||
const credits = S.courses.filter(c => status(c) === 'pass').reduce((s, c) => s + (+c.credits || 0), 0);
|
||
document.getElementById('header-sub').textContent =
|
||
total ? `${credits} credits earned · ${passed}/${total} courses passed` : 'No courses yet';
|
||
}
|
||
|
||
function onFab() {
|
||
if (page === 'plans') { showPlanModal(); return; }
|
||
if (page === 'semesters') { showSemesterModal(); return; }
|
||
showCourseModal(); // dashboard + courses
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// DASHBOARD
|
||
// ══════════════════════════════════════════════════════
|
||
function renderDash() {
|
||
const el = document.getElementById('dash-content');
|
||
if (dashView === 'global') el.innerHTML = dashGlobal();
|
||
if (dashView === 'plans') el.innerHTML = dashPlans();
|
||
if (dashView === 'semesters') el.innerHTML = dashSemesters();
|
||
}
|
||
|
||
function dashGlobal() {
|
||
const all = S.courses;
|
||
const graded = all.filter(c => c.grade != null && c.grade !== '');
|
||
const passed = all.filter(c => status(c) === 'pass');
|
||
const totCr = all.reduce((s, c) => s + (+c.credits || 0), 0);
|
||
const passCr = passed.reduce((s, c) => s + (+c.credits || 0), 0);
|
||
const mean = graded.length ? graded.reduce((s, c) => s + +c.grade, 0) / graded.length : null;
|
||
const passedGraded = graded.filter(c => status(c) === 'pass').length;
|
||
const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null;
|
||
|
||
let html = `<div class="stats-grid">
|
||
<div class="stat-card"><div class="stat-value">${totCr}</div><div class="stat-label">Total credits</div></div>
|
||
<div class="stat-card"><div class="stat-value" style="color:var(--success)">${passCr}</div><div class="stat-label">Credits earned</div></div>
|
||
<div class="stat-card"><div class="stat-value ${gradeClass(mean)}">${mean !== null ? mean.toFixed(2) : '—'}</div><div class="stat-label">Mean grade</div></div>
|
||
<div class="stat-card"><div class="stat-value" style="color:${rate === null ? 'var(--gray-400)' : rate >= 60 ? 'var(--success)' : 'var(--danger)'}">${rate !== null ? rate + '%' : '—'}</div><div class="stat-label">Pass rate</div></div>
|
||
</div>`;
|
||
|
||
if (!all.length) return html + emptyState('📚', 'No courses yet.<br>Tap + to add your first course!');
|
||
|
||
html += `<div class="card">
|
||
<div class="card-header"><span class="card-title">All Courses</span><span style="font-size:11px;color:var(--gray-400)">${all.length} total</span></div>
|
||
<div class="table-wrap"><table><thead><tr><th>Course</th><th>Cr.</th><th>Grade</th><th>Status</th></tr></thead><tbody>`;
|
||
all.forEach(c => {
|
||
const st = status(c);
|
||
html += `<tr class="clickable" onclick="showCourseModal('${c.id}')">
|
||
<td><div style="font-weight:500">${esc(c.name)}</div><div style="font-size:10px;color:var(--gray-400)">${semName(c.semesterId)}</div></td>
|
||
<td>${c.credits || '—'}</td>
|
||
<td class="${gradeClass(c.grade)}">${fmtGrade(c.grade)}</td>
|
||
<td>${badge(st)}</td>
|
||
</tr>`;
|
||
});
|
||
html += `</tbody></table></div></div>`;
|
||
return html;
|
||
}
|
||
|
||
function dashPlans() {
|
||
if (!S.studyPlans.length) return emptyState('📋', 'No study plans yet.<br>Create one in the Plans tab!');
|
||
let html = '';
|
||
|
||
S.studyPlans.forEach(plan => {
|
||
const pc = S.courses.filter(c => c.planId === plan.id);
|
||
const pb = S.blocks.filter(b => b.planId === plan.id);
|
||
const totCr = pc.reduce((s, c) => s + (+c.credits || 0), 0);
|
||
const passCr = pc.filter(c => status(c) === 'pass').reduce((s, c) => s + (+c.credits || 0), 0);
|
||
|
||
html += `<div class="card"><div class="card-header">
|
||
<span class="card-title">${esc(plan.name)}</span>
|
||
<span style="font-size:12px;color:var(--gray-400)">${passCr} / ${totCr} cr.</span>
|
||
</div>`;
|
||
|
||
if (!pc.length) { html += `<div style="color:var(--gray-400);font-size:13px;padding:6px 0">No courses in this plan</div></div>`; return; }
|
||
|
||
// Courses by block
|
||
pb.forEach(blk => {
|
||
const bc = pc.filter(c => c.blockId === blk.id);
|
||
if (!bc.length) return;
|
||
const bm = blockMean(blk.id);
|
||
const bmOk = bm !== null && bm >= 4;
|
||
const bCr = bc.reduce((s, c) => s + (+c.credits || 0), 0);
|
||
|
||
html += `<div class="block-header">
|
||
<span class="block-title">${esc(blk.name)}</span>
|
||
<span class="block-meta">${bCr} cr.${bm !== null ? ` · avg <strong class="${gradeClass(bm)}">${bm.toFixed(2)}</strong> ${bmOk ? '✓' : '✗'}` : ''}</span>
|
||
</div>`;
|
||
html += coursesMiniTable(bc);
|
||
});
|
||
|
||
// Courses without a block
|
||
const nbc = pc.filter(c => !c.blockId);
|
||
if (nbc.length) {
|
||
html += `<div class="block-header"><span class="block-title" style="color:var(--gray-400)">No block</span></div>`;
|
||
html += coursesMiniTable(nbc);
|
||
}
|
||
|
||
html += `</div>`;
|
||
});
|
||
|
||
// Unplanned courses
|
||
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)">No Study Plan</span></div>`;
|
||
html += coursesMiniTable(up, true);
|
||
html += `</div>`;
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function dashSemesters() {
|
||
if (!S.semesters.length) return emptyState('📅', 'No semesters yet.<br>Create one in the Semesters tab!');
|
||
let html = '';
|
||
|
||
S.semesters.forEach(sem => {
|
||
const sc = S.courses.filter(c => c.semesterId === sem.id);
|
||
const totCr = sc.reduce((s, c) => s + (+c.credits || 0), 0);
|
||
const passCr = sc.filter(c => status(c) === 'pass').reduce((s, c) => s + (+c.credits || 0), 0);
|
||
const graded = sc.filter(c => c.grade != null && c.grade !== '');
|
||
const mean = graded.length ? graded.reduce((s, c) => s + +c.grade, 0) / graded.length : null;
|
||
|
||
html += `<div class="card"><div class="card-header">
|
||
<span class="card-title">${esc(sem.name)}</span>
|
||
<span style="font-size:12px;color:var(--gray-400)">${passCr} / ${totCr} cr.</span>
|
||
</div>`;
|
||
|
||
if (mean !== null) {
|
||
html += `<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--gray-500);margin-bottom:8px">
|
||
<span>Avg: <strong class="${gradeClass(mean)}">${mean.toFixed(2)}</strong></span>
|
||
<span>${sc.filter(c => status(c) === 'pass').length} / ${sc.length} passed</span>
|
||
</div>`;
|
||
}
|
||
|
||
if (!sc.length) { html += `<div style="color:var(--gray-400);font-size:13px">No courses this semester</div>`; }
|
||
else html += coursesMiniTable(sc, true);
|
||
|
||
html += `</div>`;
|
||
});
|
||
return html;
|
||
}
|
||
|
||
function coursesMiniTable(courses, showMeta) {
|
||
let rows = '';
|
||
courses.forEach(c => {
|
||
const st = status(c);
|
||
const meta = showMeta
|
||
? `${c.planId ? planName(c.planId) + (c.blockId ? ' › ' + blockName(c.blockId) : '') : ''}`
|
||
: '';
|
||
rows += `<tr class="clickable" onclick="showCourseModal('${c.id}')">
|
||
<td><div style="font-weight:500">${esc(c.name)}</div>${meta ? `<div style="font-size:10px;color:var(--gray-400)">${meta}</div>` : ''}</td>
|
||
<td>${c.credits || '—'}</td>
|
||
<td class="${gradeClass(c.grade)}">${fmtGrade(c.grade)}</td>
|
||
<td>${badge(st)}</td>
|
||
</tr>`;
|
||
});
|
||
return `<div class="table-wrap"><table><thead><tr><th>Course</th><th>Cr.</th><th>Grade</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// 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; }
|
||
|
||
// Group by semester
|
||
const bySem = {};
|
||
const noSem = [];
|
||
S.courses.forEach(c => {
|
||
if (c.semesterId) { (bySem[c.semesterId] = bySem[c.semesterId] || []).push(c); }
|
||
else noSem.push(c);
|
||
});
|
||
|
||
let html = '';
|
||
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));
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function courseCard(c) {
|
||
const st = status(c);
|
||
const meta = [
|
||
c.credits ? c.credits + ' cr.' : null,
|
||
c.planId ? planName(c.planId) + (c.blockId ? ' › ' + blockName(c.blockId) : '') : null,
|
||
].filter(Boolean).join(' · ');
|
||
|
||
return `<div class="course-card">
|
||
<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">${meta || '—'}</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;margin-left:10px;flex-shrink:0">
|
||
<span class="course-grade ${gradeClass(c.grade)}">${fmtGrade(c.grade)}</span>
|
||
<div style="display:flex;gap:5px">
|
||
<button class="btn btn-sm btn-secondary" onclick="showCourseModal('${c.id}')">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteCourse('${c.id}')">Del</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// PLANS PAGE
|
||
// ══════════════════════════════════════════════════════
|
||
function renderPlans() {
|
||
const el = document.getElementById('plans-content');
|
||
if (!S.studyPlans.length) { el.innerHTML = emptyState('📋', 'No study plans yet.<br>Tap + to create one!'); return; }
|
||
|
||
let html = '';
|
||
S.studyPlans.forEach(plan => {
|
||
const pb = S.blocks.filter(b => b.planId === plan.id);
|
||
const pc = S.courses.filter(c => c.planId === plan.id);
|
||
|
||
html += `<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">${esc(plan.name)}</span>
|
||
<div style="display:flex;gap:5px;align-items:center">
|
||
<span style="font-size:11px;color:var(--gray-400)">${pc.length} courses</span>
|
||
<button class="btn btn-sm btn-secondary" onclick="showPlanModal('${plan.id}')">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deletePlan('${plan.id}')">Del</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
if (pb.length) {
|
||
pb.forEach(blk => {
|
||
const bc = S.courses.filter(c => c.blockId === blk.id);
|
||
const bm = blockMean(blk.id);
|
||
html += `<div class="list-item">
|
||
<div class="list-item-main">
|
||
<div class="list-item-title">${esc(blk.name)}</div>
|
||
<div class="list-item-sub">${bc.length} courses${bm !== null ? ' · avg ' + bm.toFixed(2) : ''}</div>
|
||
</div>
|
||
<div class="list-item-actions">
|
||
<button class="btn btn-sm btn-secondary" onclick="showBlockModal('${plan.id}','${blk.id}')">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteBlock('${blk.id}')">Del</button>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
} else {
|
||
html += `<div style="color:var(--gray-400);font-size:13px;padding:4px 0">No blocks yet.</div>`;
|
||
}
|
||
|
||
html += `<button class="btn btn-secondary btn-full" style="margin-top:10px" onclick="showBlockModal('${plan.id}')">+ Add Block</button>
|
||
</div>`;
|
||
});
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// SEMESTERS PAGE
|
||
// ══════════════════════════════════════════════════════
|
||
function renderSemesters() {
|
||
const el = document.getElementById('semesters-content');
|
||
if (!S.semesters.length) { el.innerHTML = emptyState('📅', 'No semesters yet.<br>Tap + to create one!'); return; }
|
||
|
||
let html = '';
|
||
S.semesters.forEach(sem => {
|
||
const sc = S.courses.filter(c => c.semesterId === sem.id);
|
||
const totCr = sc.reduce((s, c) => s + (+c.credits || 0), 0);
|
||
const passCr = sc.filter(c => status(c) === 'pass').reduce((s, c) => s + (+c.credits || 0), 0);
|
||
|
||
html += `<div class="card">
|
||
<div class="card-header">
|
||
<div>
|
||
<div class="card-title">${esc(sem.name)}</div>
|
||
<div style="font-size:11px;color:var(--gray-400);margin-top:2px">${esc(sem.season)} · ${esc(String(sem.year))}</div>
|
||
</div>
|
||
<div style="display:flex;gap:5px;align-items:center">
|
||
<span style="font-size:11px;color:var(--gray-400)">${passCr}/${totCr} cr.</span>
|
||
<button class="btn btn-sm btn-secondary" onclick="showSemesterModal('${sem.id}')">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteSemester('${sem.id}')">Del</button>
|
||
</div>
|
||
</div>
|
||
<div style="font-size:13px;color:var(--gray-500)">${sc.length} courses · ${passCr} credits earned</div>
|
||
</div>`;
|
||
});
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// 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
|
||
// ══════════════════════════════════════════════════════
|
||
function showCourseModal(id) {
|
||
const c = id ? S.courses.find(x => x.id === id) : null;
|
||
|
||
const semOpts = S.semesters.map(s =>
|
||
`<option value="${s.id}" ${c && c.semesterId === s.id ? 'selected' : ''}>${esc(s.name)}</option>`
|
||
).join('');
|
||
|
||
const planOpts = `<option value="">— None —</option>` + S.studyPlans.map(p =>
|
||
`<option value="${p.id}" ${c && c.planId === p.id ? 'selected' : ''}>${esc(p.name)}</option>`
|
||
).join('');
|
||
|
||
const curPlan = c ? (c.planId || '') : '';
|
||
const curBlock = c ? (c.blockId || '') : '';
|
||
const blockOpts = buildBlockOpts(curPlan, curBlock);
|
||
|
||
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">
|
||
<label>Grade (1 – 6)</label>
|
||
<input id="f-grade" type="number" value="${c && c.grade != null && c.grade !== '' ? c.grade : ''}" placeholder="e.g. 5.5" min="1" max="6" step="0.25">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Semester</label>
|
||
<select id="f-semester">
|
||
<option value="">— None —</option>
|
||
${semOpts}
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Study Plan</label>
|
||
<select id="f-plan" onchange="onPlanChange(this.value)">
|
||
${planOpts}
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="fg-block" style="display:${curPlan ? 'block' : 'none'}">
|
||
<label>Block</label>
|
||
<select id="f-block">${blockOpts}</select>
|
||
</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="">No block</option>';
|
||
const list = S.blocks.filter(b => b.planId === planId);
|
||
if (!list.length) return '<option value="">No blocks in this plan</option>';
|
||
return `<option value="">— None —</option>` + list.map(b =>
|
||
`<option value="${b.id}" ${b.id === selBlock ? 'selected' : ''}>${esc(b.name)}</option>`
|
||
).join('');
|
||
}
|
||
|
||
function onPlanChange(pid) {
|
||
const fg = document.getElementById('fg-block');
|
||
const fs = document.getElementById('f-block');
|
||
if (pid) { fg.style.display = 'block'; fs.innerHTML = buildBlockOpts(pid, ''); }
|
||
else { fg.style.display = 'none'; }
|
||
}
|
||
|
||
function saveCourse(id) {
|
||
const name = document.getElementById('f-name').value.trim();
|
||
const credits = document.getElementById('f-credits').value;
|
||
const gradeV = document.getElementById('f-grade').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 : '';
|
||
|
||
if (!name) { alert('Please enter a course name.'); return; }
|
||
if (!credits || +credits < 0) { alert('Please enter valid credits.'); return; }
|
||
|
||
const grade = gradeV !== '' ? parseFloat(gradeV) : null;
|
||
|
||
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 };
|
||
} else {
|
||
S.courses.push({ id: uid(), name, credits: +credits, semesterId: semId, grade, planId, blockId });
|
||
}
|
||
|
||
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 MODAL
|
||
// ══════════════════════════════════════════════════════
|
||
function showPlanModal(id) {
|
||
const p = id ? S.studyPlans.find(x => x.id === id) : null;
|
||
openModal(`<div class="modal">
|
||
<div class="modal-header">
|
||
<span class="modal-title">${p ? 'Edit Plan' : 'New Study Plan'}</span>
|
||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Plan Name *</label>
|
||
<input id="f-pname" type="text" value="${p ? esc(p.name) : ''}" placeholder="e.g. Computer Science BSc">
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="savePlan('${id || ''}')">Save</button>
|
||
</div>
|
||
</div>`);
|
||
}
|
||
|
||
function savePlan(id) {
|
||
const name = document.getElementById('f-pname').value.trim();
|
||
if (!name) { alert('Please enter a plan name.'); return; }
|
||
if (id) { const i = S.studyPlans.findIndex(x => x.id === id); if (i >= 0) S.studyPlans[i].name = name; }
|
||
else S.studyPlans.push({ id: uid(), name });
|
||
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 MODAL
|
||
// ══════════════════════════════════════════════════════
|
||
function showBlockModal(planId, id) {
|
||
const b = id ? S.blocks.find(x => x.id === id) : null;
|
||
openModal(`<div class="modal">
|
||
<div class="modal-header">
|
||
<span class="modal-title">${b ? 'Edit Block' : 'New Block'}</span>
|
||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Block Name *</label>
|
||
<input id="f-bname" type="text" value="${b ? esc(b.name) : ''}" placeholder="e.g. Mathematics Block">
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="saveBlock('${planId}','${id || ''}')">Save</button>
|
||
</div>
|
||
</div>`);
|
||
}
|
||
|
||
function saveBlock(planId, id) {
|
||
const name = document.getElementById('f-bname').value.trim();
|
||
if (!name) { alert('Please enter a block name.'); return; }
|
||
if (id) { const i = S.blocks.findIndex(x => x.id === id); if (i >= 0) S.blocks[i].name = name; }
|
||
else S.blocks.push({ id: uid(), planId, name });
|
||
save(); closeModal(); render(page);
|
||
}
|
||
|
||
function deleteBlock(id) {
|
||
if (!confirm('Delete this block? Courses will lose their block assignment.')) return;
|
||
S.blocks = S.blocks.filter(b => b.id !== id);
|
||
S.courses.forEach(c => { if (c.blockId === id) c.blockId = ''; });
|
||
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-group">
|
||
<label>Display Name *</label>
|
||
<input id="f-sname" type="text" value="${s ? esc(s.name) : ''}" placeholder="e.g. Autumn 2024">
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Season</label>
|
||
<select id="f-season" onchange="autoSemName()">
|
||
<option value="Autumn" ${sea==='Autumn'?'selected':''}>Autumn</option>
|
||
<option value="Spring" ${sea==='Spring'?'selected':''}>Spring</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Year</label>
|
||
<input id="f-syear" type="number" value="${yr}" min="2000" max="2100" oninput="autoSemName()">
|
||
</div>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="saveSemester('${id || ''}')">Save</button>
|
||
</div>
|
||
</div>`);
|
||
}
|
||
|
||
function autoSemName() {
|
||
const nameEl = document.getElementById('f-sname');
|
||
const sea = document.getElementById('f-season').value;
|
||
const yr = document.getElementById('f-syear').value;
|
||
if (nameEl && sea && yr) nameEl.value = sea + ' ' + yr;
|
||
}
|
||
|
||
function saveSemester(id) {
|
||
const name = document.getElementById('f-sname').value.trim();
|
||
const season = document.getElementById('f-season').value;
|
||
const year = parseInt(document.getElementById('f-syear').value, 10);
|
||
if (!name) { alert('Please enter a semester name.'); return; }
|
||
if (id) {
|
||
const i = S.semesters.findIndex(x => x.id === id);
|
||
if (i >= 0) S.semesters[i] = { ...S.semesters[i], name, season, year };
|
||
} else {
|
||
S.semesters.push({ id: uid(), name, season, year });
|
||
}
|
||
save(); closeModal(); render(page);
|
||
}
|
||
|
||
function deleteSemester(id) {
|
||
if (!confirm('Delete this semester? Courses will lose their semester assignment.')) return;
|
||
S.semesters = S.semesters.filter(s => s.id !== id);
|
||
S.courses.forEach(c => { if (c.semesterId === id) c.semesterId = ''; });
|
||
save(); render(page);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// 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:#3730A3">
|
||
<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">Export</div>
|
||
<p style="font-size:13px;color:var(--gray-500);margin-bottom:10px">Download a JSON backup you can re-import on any device.</p>
|
||
<button class="btn btn-primary btn-full" 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>
|
||
|
||
<div style="border-top:1px solid var(--gray-200);margin:18px 0 14px"></div>
|
||
|
||
<div class="section-label" style="margin-bottom:8px">Import</div>
|
||
<p style="font-size:13px;color:var(--gray-500);margin-bottom:10px">Load a previously exported JSON file. <strong>This will replace all current data.</strong></p>
|
||
<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,
|
||
semesters: S.semesters,
|
||
courses: S.courses
|
||
};
|
||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const ts = new Date().toISOString().slice(0, 10);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `credittracker-${ts}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function importData(input) {
|
||
const file = input.files && input.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
try {
|
||
const data = JSON.parse(e.target.result);
|
||
// Validate basic structure
|
||
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.semesters = data.semesters || [];
|
||
S.courses = data.courses || [];
|
||
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: [], semesters: [], courses: [] };
|
||
save();
|
||
closeModal();
|
||
render(page);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// INIT
|
||
// ══════════════════════════════════════════════════════
|
||
if ('serviceWorker' in navigator) {
|
||
window.addEventListener('load', () => {
|
||
navigator.serviceWorker.register('./sw.js').catch(err => {
|
||
console.warn('SW registration failed:', err);
|
||
});
|
||
});
|
||
}
|
||
|
||
load();
|
||
render('dashboard');
|
||
</script>
|
||
</body>
|
||
</html>
|