diff --git a/readme.md b/.claude
similarity index 100%
rename from readme.md
rename to .claude
diff --git a/index.html b/index.html
index e713fba..82dc1c2 100644
--- a/index.html
+++ b/index.html
@@ -32,14 +32,40 @@
--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(--gray-100);
+ background: var(--bg-body);
color: var(--gray-800);
min-height: 100vh;
- padding-bottom: 72px;
+ padding-bottom: 20px;
+ transition: background 0.2s, color 0.2s;
}
/* ── Header ── */
@@ -55,39 +81,10 @@
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; }
+ .header-right { display: flex; gap: 6px; align-items: center; }
- /* ── 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 ── */
.content { max-width: 680px; margin: 0 auto; padding: 14px 12px; }
.page { display: none; }
.page.active { display: block; }
@@ -116,7 +113,7 @@
/* ── Cards ── */
.card {
- background: white;
+ background: var(--bg-surface);
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
@@ -138,7 +135,7 @@
margin-bottom: 14px;
}
.stat-card {
- background: white;
+ background: var(--bg-surface);
border-radius: 12px;
padding: 14px 12px;
text-align: center;
@@ -180,10 +177,16 @@
font-weight: 600;
white-space: nowrap;
}
- .badge-pass { background: var(--success-light); color: #065F46; }
- .badge-fail { background: var(--danger-light); color: #991B1B; }
- .badge-prog { background: var(--warning-light); color: #92400E; }
- .badge-info { background: var(--primary-light); color: #3730A3; }
+ .badge-pass { background: var(--success-light); color: #065F46; }
+ .badge-fail { background: var(--danger-light); color: #991B1B; }
+ .badge-prog { background: var(--warning-light); color: #92400E; }
+ .badge-info { background: var(--primary-light); color: #3730A3; }
+ .badge-cycle { background: var(--gray-100); color: var(--gray-600); font-size: 10px; }
+
+ :root.dark .badge-pass { background: #022c22; color: #6ee7b7; }
+ :root.dark .badge-fail { background: #450a0a; color: #fca5a5; }
+ :root.dark .badge-prog { background: #451a03; color: #fcd34d; }
+ :root.dark .badge-info { background: #1e1b4b; color: #a5b4fc; }
/* ── Buttons ── */
.btn {
@@ -195,10 +198,10 @@
transition: opacity 0.15s;
}
.btn:active { opacity: 0.75; }
- .btn-primary { background: var(--primary); color: white; }
+ .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-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 ── */
@@ -214,7 +217,7 @@
display: flex; align-items: flex-end; justify-content: center;
}
.modal {
- background: white;
+ background: var(--bg-surface);
border-radius: 20px 20px 0 0;
padding: 20px 16px 28px;
width: 100%; max-width: 680px;
@@ -247,7 +250,7 @@
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: white;
+ font-size: 15px; color: var(--gray-800); background: var(--bg-surface);
-webkit-appearance: auto; appearance: auto;
transition: border-color 0.15s;
}
@@ -289,7 +292,7 @@
margin-top: 8px;
}
.block-title { font-size: 13px; font-weight: 600; color: var(--gray-600); }
- .block-meta { font-size: 11px; color: var(--gray-400); }
+ .block-meta { font-size: 11px; color: var(--gray-400); }
/* ── Segmented control ── */
.seg-ctrl {
@@ -306,7 +309,7 @@
-webkit-tap-highlight-color: transparent;
}
.seg-btn.active {
- background: white; color: var(--gray-800);
+ background: var(--bg-surface); color: var(--gray-800);
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
}
@@ -318,7 +321,7 @@
.empty-icon { font-size: 44px; margin-bottom: 10px; }
.empty-text { font-size: 14px; line-height: 1.5; }
- /* ── Header data button ── */
+ /* ── Header buttons ── */
.header-btn {
background: rgba(255,255,255,0.15); border: none; border-radius: 8px;
color: white; cursor: pointer; padding: 7px 9px;
@@ -329,6 +332,15 @@
}
.header-btn:active { background: rgba(255,255,255,0.28); }
.header-btn svg { width: 18px; height: 18px; stroke-width: 1.8; flex-shrink: 0; }
+ .theme-btn {
+ background: rgba(255,255,255,0.15); border: none; border-radius: 8px;
+ color: white; cursor: pointer; padding: 7px 8px;
+ display: flex; align-items: center;
+ -webkit-tap-highlight-color: transparent;
+ transition: background 0.15s;
+ }
+ .theme-btn:active { background: rgba(255,255,255,0.28); }
+ .theme-btn svg { width: 18px; height: 18px; stroke-width: 1.8; }
/* ── Pass/Fail toggle ── */
.pf-toggle {
@@ -343,7 +355,6 @@
}
.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 pill */
.switch { position: relative; width: 44px; height: 26px; flex-shrink: 0; margin-left: 12px; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch-track {
@@ -374,76 +385,90 @@
/* ── Course card ── */
.course-card {
- background: white; border-radius: 12px;
+ 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-name { font-size: 15px; font-weight: 600; color: var(--gray-800); }
+ .course-meta { font-size: 11px; color: var(--gray-400); margin-top: 3px; }
.course-grade { font-size: 22px; font-weight: 700; line-height: 1; text-align: right; }
+
+ /* ── Drag and drop ── */
+ .drag-handle {
+ cursor: grab; color: var(--gray-300); padding: 2px 8px 2px 0;
+ display: flex; align-items: center; flex-shrink: 0;
+ touch-action: none; user-select: none;
+ }
+ .drag-handle:active { cursor: grabbing; color: var(--gray-500); }
+ .drag-handle svg { width: 16px; height: 16px; }
+ .course-card.dragging { opacity: 0.35; }
+ .course-card.drag-over { outline: 2px dashed var(--primary); outline-offset: -2px; background: var(--primary-light); }
+
+ /* ── Week Calendar ── */
+ .wk-outer { border: 1px solid var(--gray-200); border-radius: 10px; overflow: hidden; }
+ .wk-head { display: flex; border-bottom: 1px solid var(--gray-200); background: var(--bg-surface); position: sticky; top: 0; z-index: 5; }
+ .wk-htime { width: 40px; flex-shrink: 0; }
+ .wk-hday { flex: 1; text-align: center; font-size: 11px; font-weight: 700; color: var(--gray-500); padding: 7px 2px; text-transform: uppercase; letter-spacing: 0.04em; }
+ .wk-scroll { overflow-y: auto; max-height: 60vh; }
+ .wk-body { display: flex; }
+ .wk-tcol { width: 40px; flex-shrink: 0; position: relative; }
+ .wk-tlbl { position: absolute; font-size: 10px; color: var(--gray-400); right: 5px; transform: translateY(-50%); white-space: nowrap; }
+ .wk-days { display: flex; flex: 1; }
+ .wk-dcol { flex: 1; position: relative; border-left: 1px solid var(--gray-100); min-width: 0; }
+ .wk-gline { position: absolute; left: 0; right: 0; border-top: 1px solid var(--gray-100); }
+ .wk-gline-h { border-top-color: var(--gray-200); }
+ .wk-event {
+ position: absolute; left: 2px; right: 2px;
+ border-radius: 4px; font-size: 10px; font-weight: 600;
+ padding: 2px 4px; overflow: hidden; cursor: pointer;
+ color: #fff; line-height: 1.3;
+ }
+ .wk-event:active { filter: brightness(0.85); }
+ .ev-swatch {
+ display: inline-block; width: 26px; height: 26px; border-radius: 50%;
+ cursor: pointer; flex-shrink: 0;
+ transition: outline 0.1s;
+ }
-
+
+
- Global
- By Plan
- By Semester
+ Plans
+ Semesters
-
-
-
-
-
-
-
-
+
-
-
-
- Dashboard
-
-
-
- Courses
-
-
-
- Plans
-
-
-
- Semesters
-
-
@@ -451,10 +476,15 @@
// ══════════════════════════════════════════════════════
// STATE
// ══════════════════════════════════════════════════════
-const STORE_KEY = 'creditTracker_v1';
-let S = { studyPlans: [], blocks: [], semesters: [], courses: [] };
+const STORE_KEY = 'creditTracker_v1';
+const SETTINGS_KEY = 'creditTracker_settings';
+const TABS = ['dashboard'];
+const PLAN_PALETTE = ['#4F46E5','#0D9488','#E11D48','#D97706','#7C3AED','#0891B2','#65A30D','#DB2777'];
+const MINOR_PALETTE = ['#F97316','#EAB308','#22C55E','#06B6D4','#A78BFA','#FB7185','#38BDF8','#84CC16'];
+let S = { studyPlans: [], blocks: [], subBlocks: [], semesters: [], courses: [], schedule: [] };
+let settings = { minCreditsBachelor: 180, minCreditsMaster: 120, allSeasons: false, showOptional: false };
let page = 'dashboard';
-let dashView = 'global';
+let dashView = 'plans';
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
@@ -465,10 +495,22 @@ function load() {
const parsed = JSON.parse(raw);
S.studyPlans = parsed.studyPlans || [];
S.blocks = parsed.blocks || [];
+ S.subBlocks = parsed.subBlocks || [];
S.semesters = parsed.semesters || [];
S.courses = parsed.courses || [];
+ S.schedule = parsed.schedule || [];
}
} catch (e) { console.error('Load failed', e); }
+ try {
+ const rs = localStorage.getItem(SETTINGS_KEY);
+ if (rs) settings = { ...settings, ...JSON.parse(rs) };
+ } catch (e) {}
+ const theme = localStorage.getItem('ct_theme');
+ if (theme === 'dark') applyTheme(true);
+}
+
+function saveSettings() {
+ try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (e) {}
}
function save() {
@@ -476,11 +518,27 @@ function save() {
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) {
- // 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);
@@ -489,9 +547,8 @@ function blockMean(blockId) {
return sumWeighted / sumCoef;
}
-// status: 'pass' | 'fail' | 'prog'
function status(course) {
- if (course.passFail) return 'pass'; // pass/fail courses always count as passed
+ if (course.passFail) return 'pass';
if (course.grade == null || course.grade === '') return 'prog';
if (+course.grade >= 4) return 'pass';
if (course.blockId) {
@@ -519,9 +576,16 @@ function fmtGrade(g, passFail) {
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 semName(id) { const s = S.semesters.find(x => x.id === id); return s ? esc(s.name) : '—'; }
+function planName(id) { const p = S.studyPlans.find(x => x.id === id); return p ? esc(p.name) : '—'; }
+function blockName(id) { const b = S.blocks.find(x => x.id === id); return b ? esc(b.name) : '—'; }
+function subBlockName(id) { const b = S.subBlocks.find(x => x.id === id); return b ? esc(b.name) : '—'; }
+
+function cycleBadge(cycle) {
+ if (!cycle) return '';
+ const labels = { bachelor: 'Bachelor', master: 'Master', phd: 'PhD' };
+ return `
${labels[cycle] || cycle} `;
+}
function esc(s) {
if (!s) return '';
@@ -533,9 +597,8 @@ function esc(s) {
// ══════════════════════════════════════════════════════
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');
+ const pg = document.getElementById('page-' + p);
+ if (pg) pg.classList.add('active');
page = p;
render(p);
}
@@ -543,15 +606,12 @@ function go(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));
+ ['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();
}
@@ -564,24 +624,148 @@ function updateHeaderSub() {
}
function onFab() {
- if (page === 'plans') { showPlanModal(); return; }
- if (page === 'semesters') { showSemesterModal(); return; }
- showCourseModal(); // dashboard + courses
+ 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 += `
`;
+ }
+ html += palette.map(c => {
+ const isSel = c === selected;
+ return `
`;
+ }).join('');
+ return `
${html}
`;
+}
+
+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 === 'global') el.innerHTML = dashGlobal();
if (dashView === 'plans') el.innerHTML = dashPlans();
if (dashView === 'semesters') el.innerHTML = dashSemesters();
}
+function renderDashStats() {
+ const all = S.courses;
+ const graded = all.filter(c => !c.passFail && c.grade != null && c.grade !== '');
+ const passed = all.filter(c => status(c) === 'pass');
+ const totCr = all.reduce((s, c) => s + (+c.credits || 0), 0);
+ const passCr = passed.reduce((s, c) => s + (+c.credits || 0), 0);
+ const sumW = graded.reduce((s, c) => s + +c.grade * (+c.credits || 0), 0);
+ const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0);
+ const mean = sumC > 0 ? sumW / sumC : null;
+ const passedGraded = graded.filter(c => status(c) === 'pass').length;
+ const rate = graded.length ? Math.round(passedGraded / graded.length * 100) : null;
+
+ document.getElementById('dash-stats').innerHTML = `
+
+
+
${mean !== null ? mean.toFixed(2) : '—'}
Mean grade
+
${rate !== null ? rate + '%' : '—'}
Pass rate
+
`;
+}
+
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);
@@ -617,64 +801,164 @@ function dashGlobal() {
return html;
}
+const WARN_SVG = `
`;
+const NOSEM_SVG = `
`;
+const GEAR_SVG = `
`;
+
+function roleGroupSection(planId, label, role, blocks, allPlanCourses, planColor) {
+ const headerColor = role === 'major'
+ ? planColor
+ : (blocks.length ? blockEffectiveColor(blocks[0]) : MINOR_PALETTE[role === 'optional' ? 4 : 0]);
+ const addLabel = role === 'major' ? 'Major' : role === 'minor' ? 'Minor' : 'Optional';
+ const canAdd = role !== 'major' || !blocks.length;
+ let html = `
+
+ ${label}
+ ${canAdd ? `+ ${addLabel} ` : ''}
+
`;
+ if (blocks.length) {
+ blocks.forEach(blk => { html += planBlockSection(planId, blk, '', allPlanCourses); });
+ } else {
+ html += `
No ${label.toLowerCase()} defined yet.
`;
+ }
+ html += `
`;
+ return html;
+}
+
function dashPlans() {
- if (!S.studyPlans.length) return emptyState('📋', 'No study plans yet.
Create one in the Plans tab!');
+ if (!S.studyPlans.length) return emptyState('📋', 'No study plans yet.
Tap + to create one!');
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 pc = S.courses.filter(c => c.planId === plan.id);
const totCr = pc.reduce((s, c) => s + (+c.credits || 0), 0);
const passCr = pc.filter(c => status(c) === 'pass').reduce((s, c) => s + (+c.credits || 0), 0);
+ const minCr = plan.cycle === 'bachelor' ? settings.minCreditsBachelor
+ : plan.cycle === 'master' ? settings.minCreditsMaster : null;
+ const creditOk = minCr === null || totCr >= minCr;
- html += `
`;
+ 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);
- if (!pc.length) { html += `
No courses in this plan
`; 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 += ``;
});
- // Unplanned courses
const up = S.courses.filter(c => !c.planId);
if (up.length) {
- html += `
`;
- html += coursesMiniTable(up, true);
- html += `
`;
+ html += `
+
+ ${coursesMgmtList(up)}
+
`;
}
return html;
}
+function planBlockSection(planId, blk, roleLabel, allPlanCourses) {
+ const bc = allPlanCourses.filter(c => c.blockId === blk.id);
+ const bm = blockMean(blk.id);
+ const bCr = bc.reduce((s, c) => s + (+c.credits || 0), 0);
+ const bClr = blockEffectiveColor(blk);
+ const sbs = S.subBlocks.filter(sb => sb.blockId === blk.id);
+ const freeC = bc.filter(c => !c.subBlockId);
+ const roleTxt = roleLabel
+ ? `
${roleLabel} `
+ : '';
+ let html = `
+ `;
+ sbs.forEach(sb => {
+ const sbC = bc.filter(c => c.subBlockId === sb.id);
+ html += planSubBlockSection(planId, blk.id, sb, sbC, bClr);
+ });
+ if (freeC.length) {
+ html += `
+ ${sbs.length ? `
Free courses
` : ''}
+ ${coursesMgmtList(freeC, false, bClr)}
+
`;
+ } else if (!sbs.length) {
+ html += `
No courses yet
`;
+ }
+ html += `
+ + Block
+ + Course
+
+
`;
+ return html;
+}
+
+function planSubBlockSection(planId, blockId, sb, courses, parentColor) {
+ const sbCr = courses.reduce((s, c) => s + (+c.credits || 0), 0);
+ const sbClr = subBlockEffectiveColor(sb);
+ return `
+
+
${esc(sb.name)}
+
+ ${sbCr} cr.
+ ✎
+ ×
+
+
+ ${courses.length ? coursesMgmtList(courses, false, sbClr) : '
No courses yet
'}
+
+ Course
+
`;
+}
+
+const CAL_SVG = `
`;
+
function dashSemesters() {
- if (!S.semesters.length) return emptyState('📅', 'No semesters yet.
Create one in the Semesters tab!');
+ if (!S.semesters.length) return emptyState('📅', 'No semesters yet.
Tap + to create one!');
let html = '';
S.semesters.forEach(sem => {
- const sc = S.courses.filter(c => c.semesterId === sem.id);
+ 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 !== '');
@@ -682,41 +966,63 @@ function dashSemesters() {
const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0);
const mean = sumC > 0 ? sumW / sumC : null;
- html += `
`;
-
- if (mean !== null) {
- html += `
-
Avg: ${mean.toFixed(2)}
-
${sc.filter(c => status(c) === 'pass').length} / ${sc.length} passed
+ html += `
+ `;
- }
- if (!sc.length) { html += `
No courses this semester
`; }
- else html += coursesMiniTable(sc, true);
+ if (!sc.length) { html += `
No courses this semester
`; }
+ else html += coursesMgmtList(sc, true);
- html += `
`;
+ html += `
+ Add Course
+
`;
});
return html;
}
-function coursesMiniTable(courses, showMeta) {
- let rows = '';
- courses.forEach(c => {
+function coursesMgmtList(courses, showPlan, inheritColor) {
+ if (!courses.length) return '
No courses yet
';
+ return courses.map(c => {
const st = status(c);
- const meta = showMeta
- ? `${c.planId ? planName(c.planId) + (c.blockId ? ' › ' + blockName(c.blockId) : '') : ''}`
+ const meta = [
+ c.credits ? c.credits + ' cr.' : null,
+ !c.semesterId ? '
no semester' + NOSEM_SVG + ' ' : 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(' · ')
: '';
- rows += `
- ${esc(c.name)}
${meta ? `${meta}
` : ''}
- ${c.credits || '—'}
- ${fmtGrade(c.grade, c.passFail)}
- ${badge(st)}
- `;
- });
- return `
Course Cr. Grade Status ${rows}
`;
+ // 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 `
+
+
${esc(c.name)}
+
${meta}
+ ${slotsStr ? `
${slotsStr}
` : ''}
+
+
+ ${badge(st)}
+ ${fmtGrade(c.grade, c.passFail)}
+ ✎
+ ×
+
+
`;
+ }).join('');
+}
+
+function showCourseModalPrefilled(planId, blockId, semId, subBlockId = '') {
+ showCourseModal(null, { planId, blockId, semId, subBlockId });
}
// ══════════════════════════════════════════════════════
@@ -726,42 +1032,62 @@ function renderCourses() {
const el = document.getElementById('courses-content');
if (!S.courses.length) { el.innerHTML = emptyState('📖', 'No courses yet.
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 += `
${esc(sem.name)}
`;
- list.forEach(c => html += courseCard(c));
- });
- if (noSem.length) {
- html += `
No Semester
`;
- noSem.forEach(c => html += courseCard(c));
+
+ 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 += `
${esc(sem.name)}
`;
+ list.forEach(c => html += courseCard(c));
+ });
+ if (noSem.length) {
+ html += `
No Semester
`;
+ 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 += `
${esc(plan.name)}${plan.cycle ? ` · ${plan.cycle}` : ''}
`;
+ pc.forEach(c => html += courseCard(c, true));
+ });
+ const unplanned = S.courses.filter(c => !c.planId);
+ if (unplanned.length) {
+ html += `
No Study Plan
`;
+ unplanned.forEach(c => html += courseCard(c, true));
+ }
}
+
el.innerHTML = html;
+ initDrag(el);
}
-function courseCard(c) {
+function courseCard(c, showSem) {
const st = status(c);
- const meta = [
+ const metaParts = [
c.credits ? c.credits + ' cr.' : null,
- c.planId ? planName(c.planId) + (c.blockId ? ' › ' + blockName(c.blockId) : '') : null,
- ].filter(Boolean).join(' · ');
+ showSem && c.semesterId ? semName(c.semesterId) : null,
+ !showSem && c.planId ? planName(c.planId) + (c.blockId ? ' › ' + blockName(c.blockId) : '') : null,
+ ].filter(Boolean);
- return `
+ return `
+
+
+
${esc(c.name)}
${badge(st)}
-
${meta || '—'}
+
${metaParts.join(' · ') || '—'}
${fmtGrade(c.grade)}
@@ -774,81 +1100,394 @@ function courseCard(c) {
}
// ══════════════════════════════════════════════════════
-// PLANS PAGE
+// DRAG AND DROP (courses)
// ══════════════════════════════════════════════════════
-function renderPlans() {
- const el = document.getElementById('plans-content');
- if (!S.studyPlans.length) { el.innerHTML = emptyState('📋', 'No study plans yet.
Tap + to create one!'); return; }
+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 };
- 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);
+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';
+ });
- html += `
- `;
+ container.addEventListener('dragend', () => {
+ container.querySelectorAll('.course-card').forEach(el => el.classList.remove('dragging', 'drag-over'));
+ dragSrcId = dragOverId = null;
+ });
- if (pb.length) {
- pb.forEach(blk => {
- const bc = S.courses.filter(c => c.blockId === blk.id);
- const bm = blockMean(blk.id);
- html += `
-
-
${esc(blk.name)}
-
${bc.length} courses${bm !== null ? ' · avg ' + bm.toFixed(2) : ''}
-
-
- Edit
- Del
-
-
`;
- });
- } else {
- html += `
No blocks yet.
`;
+ 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');
}
- html += `
+ Add Block
-
`;
+ 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 };
});
- el.innerHTML = html;
+}
+
+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);
}
// ══════════════════════════════════════════════════════
-// SEMESTERS PAGE
+// WEEK CALENDAR
// ══════════════════════════════════════════════════════
-function renderSemesters() {
- const el = document.getElementById('semesters-content');
- if (!S.semesters.length) { el.innerHTML = emptyState('📅', 'No semesters yet.
Tap + to create one!'); return; }
+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'];
- 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);
+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);
+}
- html += `
- `);
+ // Scroll to 8:00
+ const sc = document.getElementById('wk-scroll');
+ if (sc) sc.scrollTop = (8 * 60 - WK_START) * PX_PER_MIN;
+}
+
+function courseSlotColor(courseId) {
+ const c = S.courses.find(x => x.id === courseId);
+ if (c) {
+ if (c.blockId) { const blk = S.blocks.find(b => b.id === c.blockId); if (blk) return blockEffectiveColor(blk); }
+ if (c.planId) { const plan = S.studyPlans.find(p => p.id === c.planId); if (plan) return planBaseColor(plan); }
+ }
+ let h = 0;
+ for (let i = 0; i < courseId.length; i++) h = (h * 31 + courseId.charCodeAt(i)) | 0;
+ return EV_COLORS[Math.abs(h) % EV_COLORS.length];
+}
+
+function renderWeekGrid(semId) {
+ // Manual events + course-slot events merged
+ const events = [...S.schedule.filter(e => e.semesterId === semId)];
+ S.courses.filter(c => c.semesterId === semId && c.slots && c.slots.length).forEach(c => {
+ c.slots.forEach(slot => events.push({
+ id: `__c_${c.id}_${slot.day}_${slot.startMin}`,
+ title: c.name,
+ day: slot.day,
+ startMin: slot.startMin,
+ endMin: slot.endMin,
+ color: courseSlotColor(c.id),
+ _courseId: c.id
+ }));
});
- el.innerHTML = html;
+ const totalH = (WK_END - WK_START) * PX_PER_MIN;
+
+ // Time labels
+ let timeLbls = '';
+ for (let m = WK_START; m <= WK_END; m += 60) {
+ const top = (m - WK_START) * PX_PER_MIN;
+ timeLbls += `
${toHHMM(m)}
`;
+ }
+
+ // 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 += `
`;
+ }
+
+ // Day columns
+ const dayCols = WK_DAYS.map((_, di) => {
+ const dayNum = di + 1;
+ const dayEvents = events.filter(e => e.day === dayNum);
+ const evHtml = dayEvents.map(ev => {
+ const t = Math.max(ev.startMin, WK_START);
+ const b = Math.min(ev.endMin, WK_END);
+ if (t >= b) return '';
+ const top = (t - WK_START) * PX_PER_MIN;
+ const height = Math.max((b - t) * PX_PER_MIN, 18);
+ const loc = ev.location ? `
${esc(ev.location)}
` : '';
+ const onclick = ev._courseId
+ ? `showCourseModal('${ev._courseId}')`
+ : `showEventModal('${ev.semesterId}','${ev.id}')`;
+ return `
+ ${esc(ev.title)}${height > 30 ? `${toHHMM(ev.startMin)} ${loc}` : ''}
+
`;
+ }).join('');
+ return `
${gridLines}${evHtml}
`;
+ }).join('');
+
+ const dayHeaders = WK_DAYS.map(d => `
${d}
`).join('');
+
+ return `
+
+
${timeLbls}
+
${dayCols}
+
`;
+}
+
+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) =>
+ `
${d} `
+ ).join('');
+ const swatches = EV_COLORS.map(c =>
+ `
`
+ ).join('');
+
+ openModal(`
+
+
+ Title *
+
+
+
+
+
+
+ ${ev ? `Delete `
+ : `Cancel `}
+ Save
+
+
`);
+}
+
+function selectEvColor(color) {
+ const inp = document.getElementById('f-ev-color');
+ if (inp) inp.value = color;
+ document.querySelectorAll('.ev-swatch').forEach(s => {
+ const sel = s.dataset.color === color;
+ s.style.outline = sel ? '2px solid ' + color : 'none';
+ s.style.outlineOffset = sel ? '2px' : '0';
+ });
+}
+
+function saveEvent(semId, id) {
+ const title = document.getElementById('f-ev-title').value.trim();
+ if (!title) { alert('Please enter a title.'); return; }
+ const day = parseInt(document.getElementById('f-ev-day').value);
+ const loc = document.getElementById('f-ev-loc').value.trim();
+ const start = document.getElementById('f-ev-start').value;
+ const end = document.getElementById('f-ev-end').value;
+ const color = document.getElementById('f-ev-color').value || EV_COLORS[0];
+ const startMin = fromHHMM(start);
+ const endMin = fromHHMM(end);
+ if (endMin <= startMin) { alert('End time must be after start time.'); return; }
+ if (id) {
+ const i = S.schedule.findIndex(x => x.id === id);
+ if (i >= 0) S.schedule[i] = { ...S.schedule[i], title, day, startMin, endMin, color, location: loc, semesterId: semId };
+ } else {
+ S.schedule.push({ id: uid(), title, day, startMin, endMin, color, location: loc, semesterId: semId });
+ }
+ save(); showWeekCalendar(semId);
+}
+
+function deleteEvent(id, semId) {
+ if (!confirm('Delete this event?')) return;
+ S.schedule = S.schedule.filter(e => e.id !== id);
+ save(); showWeekCalendar(semId);
+}
+
+function importICS(input, semId) {
+ const file = input.files && input.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = function(e) {
+ const events = parseICS(e.target.result, semId);
+ if (!events.length) { alert('No weekly events found in this ICS file.'); return; }
+ if (!confirm(`Import ${events.length} event(s) into this week schedule?`)) return;
+ events.forEach(ev => S.schedule.push(ev));
+ save(); showWeekCalendar(semId);
+ };
+ reader.readAsText(file);
+ input.value = '';
+}
+
+function parseICS(text, semId) {
+ const events = [];
+ const DAY_MAP = { MO:1, TU:2, WE:3, TH:4, FR:5, SA:6, SU:7 };
+ const blocks = text.split(/BEGIN:VEVENT/i).slice(1);
+
+ blocks.forEach(block => {
+ const endIdx = block.search(/END:VEVENT/i);
+ if (endIdx < 0) return;
+ // Unfold continuation lines
+ const unfolded = block.slice(0, endIdx).replace(/\r?\n[ \t]/g, '');
+ const props = {};
+ unfolded.split(/\r?\n/).forEach(line => {
+ const ci = line.indexOf(':');
+ if (ci < 0) return;
+ const key = line.slice(0, ci).split(';')[0].trim().toUpperCase();
+ props[key] = line.slice(ci + 1).trim();
+ });
+
+ const summary = props['SUMMARY'];
+ const dtstart = props['DTSTART'];
+ const dtend = props['DTEND'];
+ const rrule = props['RRULE'];
+ if (!summary || !dtstart) return;
+
+ const tsm = dtstart.match(/T(\d{2})(\d{2})/);
+ const tem = dtend ? dtend.match(/T(\d{2})(\d{2})/) : null;
+ if (!tsm) return;
+ const startMin = +tsm[1] * 60 + +tsm[2];
+ const endMin = tem ? +tem[1] * 60 + +tem[2] : startMin + 60;
+
+ let days = [];
+ if (rrule) {
+ const rm = rrule.match(/BYDAY=([A-Z,]+)/i);
+ if (rm) days = rm[1].split(',').map(d => DAY_MAP[d.trim().toUpperCase()]).filter(Boolean);
+ }
+ if (!days.length) {
+ const dm = dtstart.match(/^(\d{4})(\d{2})(\d{2})/);
+ if (dm) {
+ const dow = new Date(+dm[1], +dm[2]-1, +dm[3]).getDay();
+ days = [dow === 0 ? 7 : dow];
+ }
+ }
+ const clr = EV_COLORS[events.length % EV_COLORS.length];
+ const loc = props['LOCATION'] || '';
+ days.forEach(day => events.push({
+ id: uid(), title: summary.replace(/\\n/g,' ').replace(/\\,/g,','),
+ day, startMin, endMin, color: clr, location: loc, semesterId: semId
+ }));
+ });
+ return events;
}
// ══════════════════════════════════════════════════════
@@ -864,21 +1503,54 @@ function overlayClick(e) { if (e.target.id === 'overlay') closeModal(); }
// ══════════════════════════════════════════════════════
// COURSE MODAL
// ══════════════════════════════════════════════════════
-function showCourseModal(id) {
+let _slots = []; // working slot list while course modal is open
+
+function renderSlotsUI() {
+ const chips = _slots.map((s, i) => {
+ const d = WK_DAYS[s.day - 1];
+ return `
+ ${d} ${toHHMM(s.startMin)}–${toHHMM(s.endMin)}
+ ×
+ `;
+ }).join('');
+ return chips || `
No schedule yet
`;
+}
+
+function addSlot() {
+ const day = parseInt(document.getElementById('f-slot-day').value);
+ const startMin = fromHHMM(document.getElementById('f-slot-start').value);
+ const endMin = fromHHMM(document.getElementById('f-slot-end').value);
+ if (endMin <= startMin) { alert('End must be after start.'); return; }
+ _slots.push({ day, startMin, endMin });
+ const el = document.getElementById('slots-list');
+ if (el) el.innerHTML = renderSlotsUI();
+}
+
+function removeSlot(i) {
+ _slots.splice(i, 1);
+ const el = document.getElementById('slots-list');
+ if (el) el.innerHTML = renderSlotsUI();
+}
+
+function showCourseModal(id, prefill = {}) {
const c = id ? S.courses.find(x => x.id === id) : null;
+ const curPlan = c ? (c.planId || '') : (prefill.planId || '');
+ const curBlock = c ? (c.blockId || '') : (prefill.blockId || '');
+ const curSubBlock = c ? (c.subBlockId || '') : (prefill.subBlockId || '');
+ const curSem = c ? (c.semesterId || '') : (prefill.semId || '');
+
const semOpts = S.semesters.map(s =>
- `
${esc(s.name)} `
+ `
${esc(s.name)} `
).join('');
const planOpts = `
— None — ` + S.studyPlans.map(p =>
- `
${esc(p.name)} `
+ `
${esc(p.name)} `
).join('');
- const curPlan = c ? (c.planId || '') : '';
- const curBlock = c ? (c.blockId || '') : '';
const blockOpts = buildBlockOpts(curPlan, curBlock);
const isPF = c ? !!c.passFail : false;
+ _slots = c && c.slots ? c.slots.map(s => ({ ...s })) : [];
openModal(`
+ Major / Minor
+ ${blockOpts}
+
+
Block
- ${blockOpts}
+ ${buildSubBlockOpts(curBlock, curSubBlock)}
+
+
Cancel
@@ -935,19 +1624,42 @@ function showCourseModal(id) {
}
function buildBlockOpts(planId, selBlock) {
- if (!planId) return '
No block ';
+ if (!planId) return '
— None — ';
const list = S.blocks.filter(b => b.planId === planId);
- if (!list.length) return '
No blocks in this plan ';
- return `
— None — ` + list.map(b =>
- `
${esc(b.name)} `
+ if (!list.length) return '
No majors/minors in this plan ';
+ const minors = list.filter(b => b.role === 'minor');
+ return `
— None — ` + 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 `
${esc(prefix + b.name)} `;
+ }).join('');
+}
+
+function buildSubBlockOpts(blockId, selSubBlock) {
+ if (!blockId) return '
— None — ';
+ const list = S.subBlocks.filter(sb => sb.blockId === blockId);
+ return `
— None — ` + list.map(sb =>
+ `
${esc(sb.name)} `
).join('');
}
function onPlanChange(pid) {
- const fg = document.getElementById('fg-block');
- const fs = document.getElementById('f-block');
+ const fg = document.getElementById('fg-block');
+ const fs = document.getElementById('f-block');
+ const fgSub = document.getElementById('fg-subblock');
if (pid) { fg.style.display = 'block'; fs.innerHTML = buildBlockOpts(pid, ''); }
else { fg.style.display = 'none'; }
+ if (fgSub) fgSub.style.display = 'none';
+}
+
+function onBlockChange(bid) {
+ const fgSub = document.getElementById('fg-subblock');
+ const fsSub = document.getElementById('f-subblock');
+ if (bid) { fgSub.style.display = 'block'; fsSub.innerHTML = buildSubBlockOpts(bid, ''); }
+ else { fgSub.style.display = 'none'; }
}
function onPassFailChange(checked) {
@@ -962,20 +1674,23 @@ function saveCourse(id) {
const gradeEl = document.getElementById('f-grade');
const gradeV = (!passFail && gradeEl) ? gradeEl.value : '';
const semId = document.getElementById('f-semester').value;
- const planId = document.getElementById('f-plan').value;
- const fg = document.getElementById('fg-block');
- const blockId = (fg && fg.style.display !== 'none') ? document.getElementById('f-block').value : '';
+ const planId = document.getElementById('f-plan').value;
+ const fg = document.getElementById('fg-block');
+ const blockId = (fg && fg.style.display !== 'none') ? document.getElementById('f-block').value : '';
+ const fgSub = document.getElementById('fg-subblock');
+ const subBlockId = (fgSub && fgSub.style.display !== 'none') ? document.getElementById('f-subblock').value : '';
if (!name) { alert('Please enter a course name.'); return; }
if (!credits || +credits < 0) { alert('Please enter valid credits.'); return; }
const grade = (!passFail && gradeV !== '') ? parseFloat(gradeV) : null;
+ const slots = [..._slots];
if (id) {
const i = S.courses.findIndex(c => c.id === id);
- if (i >= 0) S.courses[i] = { id, name, credits: +credits, semesterId: semId, grade, planId, blockId, passFail };
+ if (i >= 0) S.courses[i] = { id, name, credits: +credits, semesterId: semId, grade, planId, blockId, subBlockId, passFail, slots };
} else {
- S.courses.push({ id: uid(), name, credits: +credits, semesterId: semId, grade, planId, blockId, passFail });
+ S.courses.push({ id: uid(), name, credits: +credits, semesterId: semId, grade, planId, blockId, subBlockId, passFail, slots });
}
save(); closeModal(); render(page);
@@ -988,31 +1703,80 @@ function deleteCourse(id) {
}
// ══════════════════════════════════════════════════════
-// PLAN MODAL
+// PLAN MODALS
// ══════════════════════════════════════════════════════
-function showPlanModal(id) {
- const p = id ? S.studyPlans.find(x => x.id === id) : null;
+function showPlanTypeModal() {
openModal(`
-
- Plan Name *
-
-
-
-
Cancel
-
Save
+
+ 🎓 Bachelor
+ 🏛️ Master
`);
}
-function savePlan(id) {
- const name = document.getElementById('f-pname').value.trim();
+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(`
+
+
+ Plan Name *
+
+
+
+ Major Name
+
+
+
+ Plan Color
+ ${colorSwatches(PLAN_PALETTE, defColor, 'selectPlanColor', false, '')}
+
+
+
+ Cancel
+ Save
+
+
`);
+}
+
+function selectPlanColor(color) {
+ const inp = document.getElementById('f-pcolor');
+ if (inp) inp.value = color;
+ syncSwatchOutlines('cp-swatches', color);
+}
+
+function savePlan(id, cycle) {
+ const name = document.getElementById('f-pname').value.trim();
+ const majorName = document.getElementById('f-major').value.trim();
+ const color = document.getElementById('f-pcolor')?.value || '';
if (!name) { alert('Please enter a plan name.'); return; }
- 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 });
+ let planId = id;
+ if (id) {
+ const i = S.studyPlans.findIndex(x => x.id === id);
+ if (i >= 0) { S.studyPlans[i].name = name; S.studyPlans[i].cycle = cycle; S.studyPlans[i].color = color; }
+ if (majorName) {
+ const major = S.blocks.find(b => b.planId === id && b.role === 'major');
+ if (major) major.name = majorName;
+ else S.blocks.push({ id: uid(), planId: id, name: majorName, role: 'major' });
+ }
+ } else {
+ planId = uid();
+ S.studyPlans.push({ id: planId, name, cycle, color });
+ if (majorName) S.blocks.push({ id: uid(), planId, name: majorName, role: 'major' });
+ }
save(); closeModal(); render(page);
}
@@ -1025,38 +1789,116 @@ function deletePlan(id) {
}
// ══════════════════════════════════════════════════════
-// BLOCK MODAL
+// MINOR / BLOCK MODAL
// ══════════════════════════════════════════════════════
-function showBlockModal(planId, id) {
- const b = id ? S.blocks.find(x => x.id === id) : null;
+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(`
`);
}
-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 });
+function selectBlockColor(color) {
+ const inp = document.getElementById('f-bcolor');
+ if (inp) inp.value = color;
+ syncSwatchOutlines('cp-swatches', color);
+}
+
+function saveMinor(planId, id, role) {
+ const name = document.getElementById('f-bname').value.trim();
+ const color = document.getElementById('f-bcolor')?.value || '';
+ if (!name) { alert('Please enter a name.'); return; }
+ if (!id && role === 'major' && S.blocks.some(b => b.planId === planId && b.role === 'major')) {
+ alert('This plan already has a major. Only one major is allowed per plan.'); return;
+ }
+ if (id) {
+ const i = S.blocks.findIndex(x => x.id === id);
+ if (i >= 0) { S.blocks[i].name = name; S.blocks[i].role = role; S.blocks[i].color = color; }
+ } else {
+ S.blocks.push({ id: uid(), planId, name, role, color });
+ }
save(); closeModal(); render(page);
}
+function showSubBlockModal(blockId, id) {
+ const sb = id ? S.subBlocks.find(x => x.id === id) : null;
+ openModal(`
+
+
+ Block Name *
+
+
+
+ Cancel
+ Save
+
+
`);
+}
+
+function saveSubBlock(blockId, id) {
+ const name = document.getElementById('f-sbname').value.trim();
+ if (!name) { alert('Please enter a block name.'); return; }
+ if (id) {
+ const i = S.subBlocks.findIndex(x => x.id === id);
+ if (i >= 0) S.subBlocks[i].name = name;
+ } else {
+ S.subBlocks.push({ id: uid(), blockId, name });
+ }
+ save(); closeModal(); render(page);
+}
+
+function deleteSubBlock(id) {
+ if (!confirm('Delete this block? Courses will lose their sub-block assignment.')) return;
+ S.subBlocks = S.subBlocks.filter(b => b.id !== id);
+ S.courses.forEach(c => { if (c.subBlockId === id) c.subBlockId = ''; });
+ save(); render(page);
+}
+
function deleteBlock(id) {
- if (!confirm('Delete this block? Courses will lose their block assignment.')) return;
+ 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);
- S.courses.forEach(c => { if (c.blockId === id) c.blockId = ''; });
save(); render(page);
}
@@ -1073,21 +1915,20 @@ function showSemesterModal(id) {
${s ? 'Edit Semester' : 'New Semester'}
✕
-
- Display Name *
-
-
@@ -1097,18 +1938,11 @@ function showSemesterModal(id) {
`);
}
-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; }
+ const year = parseInt(document.getElementById('f-syear').value, 10);
+ const name = season + ' ' + year;
+ if (!season || !year) { alert('Please select a season and year.'); return; }
if (id) {
const i = S.semesters.findIndex(x => x.id === id);
if (i >= 0) S.semesters[i] = { ...S.semesters[i], name, season, year };
@@ -1125,6 +1959,66 @@ function deleteSemester(id) {
save(); render(page);
}
+// ══════════════════════════════════════════════════════
+// SETTINGS MODAL
+// ══════════════════════════════════════════════════════
+function settingsToggleRow(label, key) {
+ const on = !!settings[key];
+ return `
+ ${label}
+
+
+
+
+
+
+
`;
+}
+
+function showSettingsModal() {
+ openModal(`
+
+
Credit Requirements
+
+
Semesters
+
+ ${settingsToggleRow('Show all seasons (Summer & Winter)', 'allSeasons')}
+
+
Study Plans
+
+ ${settingsToggleRow('Show "Optional Studies" section', 'showOptional')}
+
+
Done
+
`);
+}
+
+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
// ══════════════════════════════════════════════════════
@@ -1144,21 +2038,31 @@ function showDataModal() {
Data Management
✕
-
+
${total} course${total!==1?'s':''} · ${plans} plan${plans!==1?'s':''} · ${sems} semester${sems!==1?'s':''} stored locally
-
Export
-
Download a JSON backup you can re-import on any device.
-
-
- Export JSON
-
+
Sync via QR Code
+
Show a QR code on this device, scan it on the other one.
+
+
+
+ Show QR
+
+
+
+ Scan QR
+
+
-
Import
-
Load a previously exported JSON file. This will replace all current data.
+
Export / Import JSON
+
Download a JSON backup or load one from a file.
+
+
+ Export JSON
+
Choose JSON file
@@ -1177,8 +2081,10 @@ function exportData() {
_exported: new Date().toISOString(),
studyPlans: S.studyPlans,
blocks: S.blocks,
+ subBlocks: S.subBlocks,
semesters: S.semesters,
- courses: S.courses
+ courses: S.courses,
+ schedule: S.schedule
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -1199,7 +2105,6 @@ function importData(input) {
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;
@@ -1207,8 +2112,10 @@ function importData(input) {
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);
@@ -1222,12 +2129,175 @@ function importData(input) {
function clearData() {
if (!confirm('Clear ALL data? This cannot be undone.')) return;
- S = { studyPlans: [], blocks: [], semesters: [], courses: [] };
+ S = { studyPlans: [], blocks: [], subBlocks: [], semesters: [], courses: [], schedule: [] };
save();
closeModal();
render(page);
}
+// ══════════════════════════════════════════════════════
+// QR CODE SHARE / SCAN
+// ══════════════════════════════════════════════════════
+const QR_LIB = 'https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js';
+const JSQR_LIB = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js';
+
+function loadScript(src) {
+ return new Promise((res, rej) => {
+ if (document.querySelector(`script[src="${src}"]`)) { res(); return; }
+ const s = document.createElement('script');
+ s.src = src; s.onload = res; s.onerror = rej;
+ document.head.appendChild(s);
+ });
+}
+
+async function compress(str) {
+ if (!window.CompressionStream) return btoa(unescape(encodeURIComponent(str)));
+ const bytes = new TextEncoder().encode(str);
+ const cs = new CompressionStream('deflate-raw');
+ const w = cs.writable.getWriter();
+ w.write(bytes); w.close();
+ const buf = await new Response(cs.readable).arrayBuffer();
+ // base64 in chunks to avoid call-stack overflow on large arrays
+ const u8 = new Uint8Array(buf);
+ let b64 = '';
+ for (let i = 0; i < u8.length; i += 0x8000)
+ b64 += String.fromCharCode(...u8.subarray(i, i + 0x8000));
+ return btoa(b64);
+}
+
+async function decompress(b64) {
+ const binary = atob(b64);
+ const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
+ if (!window.DecompressionStream) return decodeURIComponent(escape(binary));
+ const ds = new DecompressionStream('deflate-raw');
+ const w = ds.writable.getWriter();
+ w.write(bytes); w.close();
+ return new Response(ds.readable).text();
+}
+
+async function showQRShare() {
+ const isDark = document.documentElement.classList.contains('dark');
+ openModal(`
+
+
+
+ Scan this on your other device to import all data
+
+
+
+
+ Switch to Scan mode
+
+
`);
+
+ try {
+ await loadScript(QR_LIB);
+ const payload = JSON.stringify({ _ct: 'ct1', studyPlans: S.studyPlans, blocks: S.blocks, subBlocks: S.subBlocks, semesters: S.semesters, courses: S.courses, schedule: S.schedule });
+ const encoded = await compress(payload);
+ const statusEl = document.getElementById('qr-status');
+ const canvas = document.getElementById('qr-canvas');
+ if (!canvas) return;
+ if (statusEl) statusEl.textContent = `${S.courses.length} course${S.courses.length !== 1 ? 's' : ''} · ${encoded.length} bytes encoded`;
+ await QRCode.toCanvas(canvas, encoded, {
+ errorCorrectionLevel: 'L',
+ width: Math.min(260, window.innerWidth - 80),
+ margin: 1,
+ color: { dark: isDark ? '#f1f5f9' : '#111827', light: isDark ? '#1e293b' : '#ffffff' }
+ });
+ } catch (e) {
+ const st = document.getElementById('qr-status');
+ if (st) {
+ st.style.color = 'var(--danger)';
+ st.textContent = e.message && e.message.includes('too big')
+ ? 'Too much data for a single QR code — use Export JSON instead.'
+ : 'Failed to generate QR code.';
+ }
+ }
+}
+
+let _scanStream = null;
+let _scanRaf = null;
+
+async function showQRScan() {
+ openModal(`
+
+
+
+
Point camera at QR code…
+
+ Switch to Show QR mode
+
+
`);
+
+ try {
+ await loadScript(JSQR_LIB);
+ _scanStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
+ const video = document.getElementById('qr-video');
+ if (!video) { _stopScan(); return; }
+ video.srcObject = _scanStream;
+ await video.play();
+ _scanFrame(video);
+ } catch (e) {
+ const st = document.getElementById('scan-status');
+ if (st) {
+ st.style.color = 'var(--danger)';
+ st.textContent = e.name === 'NotAllowedError' ? 'Camera permission denied.' : 'Camera not available on this device.';
+ }
+ }
+}
+
+function _scanFrame(video) {
+ const canvas = document.getElementById('qr-scan-canvas');
+ if (!canvas || !video.videoWidth) { _scanRaf = requestAnimationFrame(() => _scanFrame(video)); return; }
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ ctx.drawImage(video, 0, 0);
+ const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ const code = jsQR(img.data, canvas.width, canvas.height, { inversionAttempts: 'dontInvert' });
+ if (code) { _stopScan(); _importFromQR(code.data); }
+ else _scanRaf = requestAnimationFrame(() => _scanFrame(video));
+}
+
+function _stopScan() {
+ if (_scanRaf) { cancelAnimationFrame(_scanRaf); _scanRaf = null; }
+ if (_scanStream) { _scanStream.getTracks().forEach(t => t.stop()); _scanStream = null; }
+}
+
+async function _importFromQR(encoded) {
+ try {
+ const json = await decompress(encoded);
+ const parsed = JSON.parse(json);
+ if (parsed._ct !== 'ct1' || !Array.isArray(parsed.courses)) throw new Error('invalid');
+ const msg = `Import data from QR code?\n• ${parsed.courses.length} courses\n• ${(parsed.studyPlans||[]).length} plans\n• ${parsed.semesters.length} semesters\n\nThis replaces all current data.`;
+ if (!confirm(msg)) return;
+ S.studyPlans = parsed.studyPlans || [];
+ S.blocks = parsed.blocks || [];
+ S.subBlocks = parsed.subBlocks || [];
+ S.semesters = parsed.semesters || [];
+ S.courses = parsed.courses || [];
+ S.schedule = parsed.schedule || [];
+ save(); closeModal(); render(page);
+ alert('Data imported successfully!');
+ } catch (e) {
+ alert('Could not read this QR code. Make sure it was generated by Credit Tracker.');
+ }
+}
+
// ══════════════════════════════════════════════════════
// INIT
// ══════════════════════════════════════════════════════
diff --git a/sw.js b/sw.js
index 73eda6d..b0c0cad 100644
--- a/sw.js
+++ b/sw.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = 'credittracker-v3';
+const CACHE_NAME = 'credittracker-v22';
const ASSETS = [
'./index.html',
'./manifest.json',