Files
CreditTracker/index.html
T

1205 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Credit Tracker</title>
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#4F46E5">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Credits">
<link rel="apple-touch-icon" href="icon.svg">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #4F46E5;
--primary-light: #EEF2FF;
--success: #059669;
--success-light: #D1FAE5;
--danger: #DC2626;
--danger-light: #FEE2E2;
--warning: #D97706;
--warning-light: #FEF3C7;
--gray-50: #F9FAFB;
--gray-100: #F3F4F6;
--gray-200: #E5E7EB;
--gray-300: #D1D5DB;
--gray-400: #9CA3AF;
--gray-500: #6B7280;
--gray-600: #4B5563;
--gray-700: #374151;
--gray-800: #1F2937;
--gray-900: #111827;
}
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) {
// passFail courses are excluded from the mean
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;
}
// status: 'pass' | 'fail' | 'prog'
function status(course) {
if (course.passFail) return 'pass'; // pass/fail courses always count as passed
if (course.grade == null || course.grade === '') return 'prog';
if (+course.grade >= 4) return 'pass';
if (course.blockId) {
const m = blockMean(course.blockId);
if (m !== null && m >= 4) return 'pass';
}
return 'fail';
}
function badge(st) {
if (st === 'pass') return '<span class="badge badge-pass">Passed</span>';
if (st === 'fail') return '<span class="badge badge-fail">Failed</span>';
return '<span class="badge badge-prog">In progress</span>';
}
function gradeClass(g) {
if (g == null || g === '') return '';
return +g >= 5 ? 'g-high' : +g >= 4 ? 'g-mid' : 'g-low';
}
function fmtGrade(g, passFail) {
if (passFail) return '<span style="font-size:11px;font-weight:600;color:var(--success)">P/F</span>';
if (g == null || g === '') return '—';
const n = +g;
return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}
function semName(id) { const s = S.semesters.find(x => x.id === id); return s ? esc(s.name) : '—'; }
function planName(id) { const p = S.studyPlans.find(x => x.id === id); return p ? esc(p.name) : '—'; }
function blockName(id) { const b = S.blocks.find(x => x.id === id); return b ? esc(b.name) : '—'; }
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ══════════════════════════════════════════════════════
// NAVIGATION
// ══════════════════════════════════════════════════════
function go(p) {
document.querySelectorAll('.page').forEach(el => el.classList.remove('active'));
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;
// passFail courses excluded from mean, but count for credits/pass rate denominator
const graded = all.filter(c => !c.passFail && c.grade != null && c.grade !== '');
const passed = all.filter(c => status(c) === 'pass');
const totCr = all.reduce((s, c) => s + (+c.credits || 0), 0);
const passCr = passed.reduce((s, c) => s + (+c.credits || 0), 0);
const sumW = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0);
const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0);
const mean = sumC > 0 ? sumW / sumC : null;
const passedGraded = graded.filter(c => status(c) === 'pass').length;
const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null;
let html = `<div class="stats-grid">
<div class="stat-card"><div class="stat-value">${totCr}</div><div class="stat-label">Total credits</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--success)">${passCr}</div><div class="stat-label">Credits earned</div></div>
<div class="stat-card"><div class="stat-value ${gradeClass(mean)}">${mean !== null ? mean.toFixed(2) : '—'}</div><div class="stat-label">Mean grade</div></div>
<div class="stat-card"><div class="stat-value" style="color:${rate === null ? 'var(--gray-400)' : rate >= 60 ? 'var(--success)' : 'var(--danger)'}">${rate !== null ? rate + '%' : '—'}</div><div class="stat-label">Pass rate</div></div>
</div>`;
if (!all.length) return html + emptyState('📚', 'No courses yet.<br>Tap + to add your first course!');
html += `<div class="card">
<div class="card-header"><span class="card-title">All Courses</span><span style="font-size:11px;color:var(--gray-400)">${all.length} total</span></div>
<div class="table-wrap"><table><thead><tr><th>Course</th><th>Cr.</th><th>Grade</th><th>Status</th></tr></thead><tbody>`;
all.forEach(c => {
const st = status(c);
html += `<tr class="clickable" onclick="showCourseModal('${c.id}')">
<td><div style="font-weight:500">${esc(c.name)}</div><div style="font-size:10px;color:var(--gray-400)">${semName(c.semesterId)}</div></td>
<td>${c.credits || '—'}</td>
<td class="${c.passFail ? '' : gradeClass(c.grade)}">${fmtGrade(c.grade, c.passFail)}</td>
<td>${badge(st)}</td>
</tr>`;
});
html += `</tbody></table></div></div>`;
return html;
}
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.passFail && c.grade != null && c.grade !== '');
const sumW = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0);
const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0);
const mean = sumC > 0 ? sumW / sumC : null;
html += `<div class="card"><div class="card-header">
<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="${c.passFail ? '' : gradeClass(c.grade)}">${fmtGrade(c.grade, c.passFail)}</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);
const isPF = c ? !!c.passFail : false;
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">${c ? 'Edit Course' : 'Add Course'}</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="form-group">
<label>Course Name *</label>
<input id="f-name" type="text" value="${c ? esc(c.name) : ''}" placeholder="e.g. Mathematics I">
</div>
<div class="form-row">
<div class="form-group">
<label>Credits *</label>
<input id="f-credits" type="number" value="${c ? c.credits : ''}" placeholder="e.g. 3" min="0" step="0.5">
</div>
<div class="form-group" id="fg-grade" style="display:${isPF ? 'none' : 'block'}">
<label>Grade (1 6)</label>
<input id="f-grade" type="number" value="${c && !isPF && c.grade != null && c.grade !== '' ? c.grade : ''}" placeholder="e.g. 5.5" min="1" max="6" step="0.25">
</div>
</div>
<div class="form-group" style="display:flex;align-items:center;gap:10px;padding:4px 0">
<input type="checkbox" id="f-passfail" style="width:18px;height:18px;cursor:pointer;flex-shrink:0" ${isPF ? 'checked' : ''} onchange="onPassFailChange(this.checked)">
<label for="f-passfail" style="text-transform:none;font-size:14px;font-weight:500;color:var(--gray-700);cursor:pointer;letter-spacing:0">Passed (no grade — excluded from means)</label>
</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 onPassFailChange(checked) {
const fgGrade = document.getElementById('fg-grade');
if (fgGrade) fgGrade.style.display = checked ? 'none' : 'block';
}
function saveCourse(id) {
const name = document.getElementById('f-name').value.trim();
const credits = document.getElementById('f-credits').value;
const passFail = document.getElementById('f-passfail').checked;
const gradeEl = document.getElementById('f-grade');
const gradeV = (!passFail && gradeEl) ? gradeEl.value : '';
const semId = document.getElementById('f-semester').value;
const planId = document.getElementById('f-plan').value;
const fg = document.getElementById('fg-block');
const blockId = (fg && fg.style.display !== 'none') ? document.getElementById('f-block').value : '';
if (!name) { alert('Please enter a course name.'); return; }
if (!credits || +credits < 0) { alert('Please enter valid credits.'); return; }
const grade = (!passFail && gradeV !== '') ? parseFloat(gradeV) : null;
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, passFail };
} else {
S.courses.push({ id: uid(), name, credits: +credits, semesterId: semId, grade, planId, blockId, passFail });
}
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>