Add ICS import/export with course linking, QR sync improvements, and UI polish

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 11:39:50 +02:00
co-authored by Claude Sonnet 5
parent fb87091b78
commit eb7e64ee06
6 changed files with 725 additions and 187 deletions
+16 -16
View File
@@ -1,17 +1,17 @@
I need your help for a small web app i'd like to make. I need your help for a small web app i'd like to make.
I take a lot of courses and i need to plannify when to take them and how much they give. I take a lot of courses and i need to plannify when to take them and how much they give.
Each course has this attributes: Each course has this attributes:
- Study plan - Study plan
- Block - Block
- name - name
- credits - credits
- semester (Autumn or Spring) - semester (Autumn or Spring)
- Grade (if >= 4 or block.mean() >= 4, passed, else, failed) - Grade (if >= 4 or block.mean() >= 4, passed, else, failed)
I'd like to have it as a small webapp i can use on my phone, where i can easily manage my different semester (being able to create one), add my grade, manage my study plan (able to add course to a new plan, an existing one or without study plan), choose the block the course is in (if in a study plan) I'd like to have it as a small webapp i can use on my phone, where i can easily manage my different semester (being able to create one), add my grade, manage my study plan (able to add course to a new plan, an existing one or without study plan), choose the block the course is in (if in a study plan)
Everything must be sumerized into tables (global, per study plan, per semester) Everything must be sumerized into tables (global, per study plan, per semester)
Can you help me? Can you help me?
+17 -17
View File
@@ -1,17 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Full bleed background for maskable (safe zone is inner 80%) --> <!-- Full bleed background for maskable (safe zone is inner 80%) -->
<rect width="512" height="512" fill="#4F46E5"/> <rect width="512" height="512" fill="#4F46E5"/>
<!-- Book spine --> <!-- Book spine -->
<rect x="138" y="110" width="26" height="292" rx="8" fill="#A5B4FC"/> <rect x="138" y="110" width="26" height="292" rx="8" fill="#A5B4FC"/>
<!-- Book cover --> <!-- Book cover -->
<rect x="158" y="110" width="216" height="292" rx="8" fill="white"/> <rect x="158" y="110" width="216" height="292" rx="8" fill="white"/>
<!-- Lines --> <!-- Lines -->
<rect x="190" y="162" width="150" height="14" rx="7" fill="#C7D2FE"/> <rect x="190" y="162" width="150" height="14" rx="7" fill="#C7D2FE"/>
<rect x="190" y="194" width="130" height="14" rx="7" fill="#C7D2FE"/> <rect x="190" y="194" width="130" height="14" rx="7" fill="#C7D2FE"/>
<rect x="190" y="226" width="150" height="14" rx="7" fill="#C7D2FE"/> <rect x="190" y="226" width="150" height="14" rx="7" fill="#C7D2FE"/>
<rect x="190" y="258" width="110" height="14" rx="7" fill="#C7D2FE"/> <rect x="190" y="258" width="110" height="14" rx="7" fill="#C7D2FE"/>
<!-- Graduation cap --> <!-- Graduation cap -->
<path d="M256 74 L318 102 L256 130 L194 102 Z" fill="white"/> <path d="M256 74 L318 102 L256 130 L194 102 Z" fill="white"/>
<rect x="308" y="102" width="6" height="28" rx="3" fill="white"/> <rect x="308" y="102" width="6" height="28" rx="3" fill="white"/>
<circle cx="311" cy="133" r="8" fill="#A5B4FC"/> <circle cx="311" cy="133" r="8" fill="#A5B4FC"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 892 B

After

Width:  |  Height:  |  Size: 909 B

+17 -17
View File
@@ -1,17 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Background --> <!-- Background -->
<rect width="512" height="512" rx="90" fill="#4F46E5"/> <rect width="512" height="512" rx="90" fill="#4F46E5"/>
<!-- Book spine --> <!-- Book spine -->
<rect x="138" y="110" width="26" height="292" rx="8" fill="#A5B4FC"/> <rect x="138" y="110" width="26" height="292" rx="8" fill="#A5B4FC"/>
<!-- Book cover --> <!-- Book cover -->
<rect x="158" y="110" width="216" height="292" rx="8" fill="white"/> <rect x="158" y="110" width="216" height="292" rx="8" fill="white"/>
<!-- Lines representing text --> <!-- Lines representing text -->
<rect x="190" y="162" width="150" height="14" rx="7" fill="#C7D2FE"/> <rect x="190" y="162" width="150" height="14" rx="7" fill="#C7D2FE"/>
<rect x="190" y="194" width="130" height="14" rx="7" fill="#C7D2FE"/> <rect x="190" y="194" width="130" height="14" rx="7" fill="#C7D2FE"/>
<rect x="190" y="226" width="150" height="14" rx="7" fill="#C7D2FE"/> <rect x="190" y="226" width="150" height="14" rx="7" fill="#C7D2FE"/>
<rect x="190" y="258" width="110" height="14" rx="7" fill="#C7D2FE"/> <rect x="190" y="258" width="110" height="14" rx="7" fill="#C7D2FE"/>
<!-- Graduation cap on top --> <!-- Graduation cap on top -->
<path d="M256 74 L318 102 L256 130 L194 102 Z" fill="white"/> <path d="M256 74 L318 102 L256 130 L194 102 Z" fill="white"/>
<rect x="308" y="102" width="6" height="28" rx="3" fill="white"/> <rect x="308" y="102" width="6" height="28" rx="3" fill="white"/>
<circle cx="311" cy="133" r="8" fill="#A5B4FC"/> <circle cx="311" cy="133" r="8" fill="#A5B4FC"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 876 B

After

Width:  |  Height:  |  Size: 893 B

+579 -65
View File
@@ -334,6 +334,12 @@
} }
.header-btn:active { background: rgba(255,255,255,0.28); } .header-btn:active { background: rgba(255,255,255,0.28); }
.header-btn svg { width: 18px; height: 18px; stroke-width: 1.8; flex-shrink: 0; } .header-btn svg { width: 18px; height: 18px; stroke-width: 1.8; flex-shrink: 0; }
.header-btn { position: relative; }
.backup-dot {
display: none; position: absolute; top: 3px; right: 3px;
width: 8px; height: 8px; border-radius: 50%;
background: var(--danger); border: 1.5px solid var(--primary);
}
.theme-btn { .theme-btn {
background: rgba(255,255,255,0.15); border: none; border-radius: 8px; background: rgba(255,255,255,0.15); border: none; border-radius: 8px;
color: white; cursor: pointer; padding: 7px 8px; color: white; cursor: pointer; padding: 7px 8px;
@@ -436,6 +442,7 @@
color: #fff; line-height: 1.3; box-sizing: border-box; color: #fff; line-height: 1.3; box-sizing: border-box;
} }
.wk-event:active { filter: brightness(0.85); } .wk-event:active { filter: brightness(0.85); }
.wk-event-hatched { background-image: repeating-linear-gradient(135deg, rgba(255,255,255,.4) 0 6px, transparent 6px 12px); }
.ev-swatch { .ev-swatch {
display: inline-block; width: 26px; height: 26px; border-radius: 50%; display: inline-block; width: 26px; height: 26px; border-radius: 50%;
cursor: pointer; flex-shrink: 0; cursor: pointer; flex-shrink: 0;
@@ -457,6 +464,7 @@
</button> </button>
<button class="header-btn" onclick="showDataModal()" title="Export / Import data"> <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> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
<span class="backup-dot" id="backup-dot" title="Backup overdue"></span>
</button> </button>
<button class="header-btn" onclick="showSettingsModal()" title="Settings"> <button class="header-btn" onclick="showSettingsModal()" title="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
@@ -488,11 +496,13 @@
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
const STORE_KEY = 'creditTracker_v1'; const STORE_KEY = 'creditTracker_v1';
const SETTINGS_KEY = 'creditTracker_settings'; const SETTINGS_KEY = 'creditTracker_settings';
const BACKUP_KEY = 'creditTracker_lastBackup';
const BACKUP_NUDGE_DAYS = 30;
const TABS = ['dashboard']; const TABS = ['dashboard'];
const PLAN_PALETTE = ['#4F46E5','#0D9488','#E11D48','#D97706','#7C3AED','#0891B2','#65A30D','#DB2777']; const PLAN_PALETTE = ['#4F46E5','#0D9488','#E11D48','#D97706','#7C3AED','#0891B2','#65A30D','#DB2777'];
const MINOR_PALETTE = ['#F97316','#EAB308','#22C55E','#06B6D4','#A78BFA','#FB7185','#38BDF8','#84CC16']; const MINOR_PALETTE = ['#F97316','#EAB308','#22C55E','#06B6D4','#A78BFA','#FB7185','#38BDF8','#84CC16'];
let S = { studyPlans: [], blocks: [], subBlocks: [], semesters: [], courses: [], schedule: [] }; let S = { studyPlans: [], blocks: [], subBlocks: [], semesters: [], courses: [], schedule: [] };
let settings = { minCreditsBachelor: 180, minCreditsMaster: 120, allSeasons: false, showOptional: false }; let settings = { minCreditsBachelor: 180, minCreditsMaster: 120, allSeasons: false, showOptional: false, autoFoldValidatedBlocks: false };
let page = 'dashboard'; let page = 'dashboard';
let dashView = 'plans'; let dashView = 'plans';
let collapsed = {}; let collapsed = {};
@@ -521,6 +531,22 @@ function load() {
} catch (e) {} } catch (e) {}
const theme = localStorage.getItem('ct_theme'); const theme = localStorage.getItem('ct_theme');
if (theme === 'dark') applyTheme(true); if (theme === 'dark') applyTheme(true);
updateBackupDot();
}
function daysSinceBackup() {
const ts = localStorage.getItem(BACKUP_KEY);
if (!ts) return null;
return Math.floor((Date.now() - new Date(ts).getTime()) / 86400000);
}
function updateBackupDot() {
const dot = document.getElementById('backup-dot');
if (!dot) return;
const hasData = S.courses.length > 0 || S.semesters.length > 0;
const days = daysSinceBackup();
const overdue = hasData && (days === null || days >= BACKUP_NUDGE_DAYS);
dot.style.display = overdue ? 'block' : 'none';
} }
function saveSettings() { function saveSettings() {
@@ -596,6 +622,24 @@ function fmtGrade(g, passFail) {
} }
function semName(id) { const s = S.semesters.find(x => x.id === id); return s ? esc(s.name) : '—'; } function semName(id) { const s = S.semesters.find(x => x.id === id); return s ? esc(s.name) : '—'; }
// Approximate calendar month each season starts, used to order/pick the "current" semester
// when no explicit startDate is set on it.
const SEASON_MONTH = { Spring: 1, Summer: 5, Autumn: 8, Winter: 11 };
function semRefDate(sem) {
if (sem.startDate) return new Date(sem.startDate + 'T00:00:00');
return new Date(sem.year, SEASON_MONTH[sem.season] ?? 0, 1);
}
function currentSemesterId() {
if (!S.semesters.length) return null;
const today = new Date();
let best = S.semesters[0], bestDiff = Infinity;
S.semesters.forEach(s => {
const diff = Math.abs(semRefDate(s) - today);
if (diff < bestDiff) { bestDiff = diff; best = s; }
});
return best.id;
}
function planName(id) { const p = S.studyPlans.find(x => x.id === id); return p ? esc(p.name) : '—'; } function 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 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 subBlockName(id) { const b = S.subBlocks.find(x => x.id === id); return b ? esc(b.name) : '—'; }
@@ -627,6 +671,18 @@ function setDashView(v) {
const btns = document.querySelectorAll('#dash-seg .seg-btn'); const btns = document.querySelectorAll('#dash-seg .seg-btn');
['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(); renderDash();
if (v === 'semesters') {
const curId = currentSemesterId();
const el = curId && document.getElementById('sem-card-' + curId);
if (el) {
const headerEl = document.querySelector('.header');
const offset = (headerEl ? headerEl.offsetHeight : 0) + 8;
const top = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior: 'smooth' });
}
} else if (v === 'plans') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} }
function render(p) { function render(p) {
@@ -982,15 +1038,16 @@ function planBlockSection(planId, blk, allPlanCourses, sibCount, sibIdx) {
const sbs = S.subBlocks.filter(sb => sb.blockId === blk.id); const sbs = S.subBlocks.filter(sb => sb.blockId === blk.id);
const freeC = bc.filter(c => !c.subBlockId); const freeC = bc.filter(c => !c.subBlockId);
const colKey = `block_${blk.id}`; const colKey = `block_${blk.id}`;
const isCollapsed = collapsed[colKey];
const inEdit = editModeBlock === blk.id; const inEdit = editModeBlock === blk.id;
const failedCourses = bc.filter(c => c.passFail === 'failed').length; const failedCourses = bc.filter(c => c.passFail === 'failed').length;
const blockFailed = failedCourses > 1; const blockFailed = failedCourses > 1;
const blockIsPassed = blockPassedStatus(blk.id); const blockIsPassed = blockPassedStatus(blk.id);
const isCollapsed = collapsed[colKey] !== undefined ? collapsed[colKey] : (settings.autoFoldValidatedBlocks && blockIsPassed);
const chevRot = isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)';
let html = `<div style="border-left:3px solid ${bClr};padding-left:8px;margin-top:10px"> let html = `<div style="border-left:3px solid ${bClr};padding-left:8px;margin-top:10px">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px"> <div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
${chevBtn(colKey)} <button onclick="toggleCollapse('${colKey}')" style="background:none;border:none;cursor:pointer;color:var(--gray-400);padding:2px 3px;display:flex;align-items:center;flex-shrink:0"><span style="display:inline-flex;transform:${chevRot};transition:transform .2s">${CHEV_SVG}</span></button>
<span style="font-size:14px;font-weight:600;color:${bClr};flex:1">${esc(blk.name)}${blockFailed ? ` <span style="font-size:10px;font-weight:700;background:var(--danger-light);color:var(--danger);border-radius:4px;padding:1px 5px;vertical-align:middle">FAIL</span>` : ''}</span> <span style="font-size:14px;font-weight:600;color:${bClr};flex:1">${esc(blk.name)}${blockFailed ? ` <span style="font-size:10px;font-weight:700;background:var(--danger-light);color:var(--danger);border-radius:4px;padding:1px 5px;vertical-align:middle">FAIL</span>` : ''}</span>
${blockIsPassed ${blockIsPassed
? `<span onclick="showBlockCompletionModal('${blk.id}')" title="Bloc validé — cliquer pour détails" style="cursor:pointer;font-size:10px;font-weight:700;background:var(--success-light);color:#065F46;border-radius:5px;padding:2px 7px;flex-shrink:0;white-space:nowrap">✓ Validé</span>` ? `<span onclick="showBlockCompletionModal('${blk.id}')" title="Bloc validé — cliquer pour détails" style="cursor:pointer;font-size:10px;font-weight:700;background:var(--success-light);color:#065F46;border-radius:5px;padding:2px 7px;flex-shrink:0;white-space:nowrap">✓ Validé</span>`
@@ -1029,11 +1086,12 @@ function planSubBlockSection(planId, blockId, sb, courses, parentColor, inEdit,
const sbCr = courses.reduce((s, c) => s + (+c.credits || 0), 0); const sbCr = courses.reduce((s, c) => s + (+c.credits || 0), 0);
const sbClr = subBlockEffectiveColor(sb); const sbClr = subBlockEffectiveColor(sb);
const colKey = `sb_${sb.id}`; const colKey = `sb_${sb.id}`;
const isCollapsed = collapsed[colKey]; const isCollapsed = collapsed[colKey] !== undefined ? collapsed[colKey] : (settings.autoFoldValidatedBlocks && !!sb.passed);
const chevRot = isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)';
let html = `<div style="border-left:2px dashed ${sbClr};padding-left:8px;margin-top:7px"> let html = `<div style="border-left:2px dashed ${sbClr};padding-left:8px;margin-top:7px">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px"> <div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
${chevBtn(colKey)} <button onclick="toggleCollapse('${colKey}')" style="background:none;border:none;cursor:pointer;color:var(--gray-400);padding:2px 3px;display:flex;align-items:center;flex-shrink:0"><span style="display:inline-flex;transform:${chevRot};transition:transform .2s">${CHEV_SVG}</span></button>
<span style="font-size:13px;font-weight:600;color:${sbClr};flex:1">${esc(sb.name)}</span> <span style="font-size:13px;font-weight:600;color:${sbClr};flex:1">${esc(sb.name)}</span>
${sb.passed ${sb.passed
? `<span onclick="showSubBlockCompletionModal('${sb.id}')" title="Bloc validé" style="cursor:pointer;font-size:10px;font-weight:700;background:var(--success-light);color:#065F46;border-radius:5px;padding:2px 7px;flex-shrink:0;white-space:nowrap">✓ Validé</span>` ? `<span onclick="showSubBlockCompletionModal('${sb.id}')" title="Bloc validé" style="cursor:pointer;font-size:10px;font-weight:700;background:var(--success-light);color:#065F46;border-radius:5px;padding:2px 7px;flex-shrink:0;white-space:nowrap">✓ Validé</span>`
@@ -1144,6 +1202,7 @@ function semCoursesByPlan(sc) {
function dashSemesters() { function dashSemesters() {
if (!S.semesters.length) return emptyState('📅', 'No semesters yet.<br>Tap + to create one!'); if (!S.semesters.length) return emptyState('📅', 'No semesters yet.<br>Tap + to create one!');
let html = ''; let html = '';
const curId = currentSemesterId();
S.semesters.forEach(sem => { S.semesters.forEach(sem => {
const sc = S.courses.filter(c => c.semesterId === sem.id); const sc = S.courses.filter(c => c.semesterId === sem.id);
@@ -1154,8 +1213,14 @@ function dashSemesters() {
const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0); const sumC = graded.reduce((s, c) => s + (+c.credits || 0), 0);
const mean = sumC > 0 ? sumW / sumC : null; const mean = sumC > 0 ? sumW / sumC : null;
html += `<div class="card"> const key = 'sem_' + sem.id;
const foldedDef = sem.id !== curId; // older/other semesters start folded, current starts open
const isFolded = collapsed[key] !== undefined ? collapsed[key] : foldedDef;
const rot = isFolded ? 'rotate(-90deg)' : 'rotate(0deg)';
html += `<div class="card" id="sem-card-${sem.id}">
<div class="card-header" style="flex-wrap:wrap;gap:6px"> <div class="card-header" style="flex-wrap:wrap;gap:6px">
<button onclick="toggleCollapse('${key}')" style="background:none;border:none;cursor:pointer;color:var(--gray-400);padding:2px 3px;display:flex;align-items:center;flex-shrink:0"><span style="display:inline-flex;transform:${rot};transition:transform .2s">${CHEV_SVG}</span></button>
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
<div class="card-title">${esc(sem.name)}</div> <div class="card-title">${esc(sem.name)}</div>
<div style="font-size:11px;color:var(--gray-400);margin-top:2px">${esc(sem.season)} · ${sem.year}${mean !== null ? ` · avg <strong class="${gradeClass(mean)}">${mean.toFixed(2)}</strong>` : ''}</div> <div style="font-size:11px;color:var(--gray-400);margin-top:2px">${esc(sem.season)} · ${sem.year}${mean !== null ? ` · avg <strong class="${gradeClass(mean)}">${mean.toFixed(2)}</strong>` : ''}</div>
@@ -1167,8 +1232,9 @@ function dashSemesters() {
<button class="btn btn-sm btn-danger" onclick="deleteSemester('${sem.id}')">Del</button> <button class="btn btn-sm btn-danger" onclick="deleteSemester('${sem.id}')">Del</button>
</div> </div>
</div> </div>
${isFolded ? '' : `
${semCoursesByPlan(sc)} ${semCoursesByPlan(sc)}
<button class="btn btn-secondary btn-full" style="margin-top:8px;font-size:13px" onclick="showCourseModalPrefilled('','','${sem.id}')">+ Add Course</button> <button class="btn btn-secondary btn-full" style="margin-top:8px;font-size:13px" onclick="showCourseModalPrefilled('','','${sem.id}')">+ Add Course</button>`}
</div>`; </div>`;
}); });
return html; return html;
@@ -1453,10 +1519,14 @@ function showWeekCalendar(semId) {
${CAL_SVG} Import ICS ${CAL_SVG} Import ICS
</label> </label>
<input type="file" id="f-ics-${semId}" accept=".ics,text/calendar" style="display:none" onchange="importICS(this,'${semId}')"> <input type="file" id="f-ics-${semId}" accept=".ics,text/calendar" style="display:none" onchange="importICS(this,'${semId}')">
<button class="btn btn-primary" style="flex:1;justify-content:center;font-size:13px" onclick="showEventModal('${semId}','')">+ Add Event</button> <button class="btn btn-secondary" style="flex:1;justify-content:center;font-size:13px" onclick="exportICS('${semId}')">${CAL_SVG} Export ICS</button>
</div>
<div style="display:flex;gap:8px;padding:0 16px 10px;flex-shrink:0">
<button class="btn btn-primary btn-full" style="justify-content:center;font-size:13px" onclick="showEventModal('${semId}','')">+ Add Event</button>
</div> </div>
<div class="wk-scroll" id="wk-scroll"> <div class="wk-scroll" id="wk-scroll">
<div class="wk-outer">${renderWeekGrid(semId)}</div> <div class="wk-outer">${renderWeekGrid(semId)}</div>
${_renderOtherSemesterCourses(semId)}
</div> </div>
<div style="padding:10px 16px 14px;flex-shrink:0"> <div style="padding:10px 16px 14px;flex-shrink:0">
<button class="btn btn-secondary btn-full" style="font-size:13px" onclick="closeModal()">Close</button> <button class="btn btn-secondary btn-full" style="font-size:13px" onclick="closeModal()">Close</button>
@@ -1467,6 +1537,34 @@ function showWeekCalendar(semId) {
if (sc) sc.scrollTop = (8 * 60 - WK_START) * PX_PER_MIN; if (sc) sc.scrollTop = (8 * 60 - WK_START) * PX_PER_MIN;
} }
function _renderOtherSemesterCourses(semId) {
const others = S.courses.filter(c => c.semesterId !== semId && c.slots && c.slots.length);
if (!others.length) return '';
return `<div style="padding:12px 16px 4px">
<div style="font-size:11px;font-weight:600;color:var(--gray-400);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">
Courses with a timetable in other semesters
</div>
${others.map(c => `
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 0;border-bottom:1px solid var(--gray-100)">
<div style="min-width:0">
<div style="font-size:13px;font-weight:500;color:var(--gray-800);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(c.name)}</div>
<div style="font-size:11px;color:var(--gray-400)">${semName(c.semesterId)}</div>
</div>
<button class="btn btn-sm btn-secondary" style="flex-shrink:0" onclick="_moveCourseToSemester('${c.id}','${semId}')">Move here</button>
</div>
`).join('')}
</div>`;
}
function _moveCourseToSemester(courseId, semId) {
const c = S.courses.find(x => x.id === courseId);
if (!c) return;
if (!confirm(`Move "${esc(c.name)}" to this semester?`)) return;
c.semesterId = semId;
save(); render(page);
showWeekCalendar(semId);
}
function courseSlotColor(courseId) { function courseSlotColor(courseId) {
const c = S.courses.find(x => x.id === courseId); const c = S.courses.find(x => x.id === courseId);
if (c) { if (c) {
@@ -1489,6 +1587,8 @@ function renderWeekGrid(semId) {
startMin: slot.startMin, startMin: slot.startMin,
endMin: slot.endMin, endMin: slot.endMin,
color: courseSlotColor(c.id), color: courseSlotColor(c.id),
count: slot.count,
location: slot.location,
_courseId: c.id _courseId: c.id
})); }));
}); });
@@ -1541,8 +1641,10 @@ function renderWeekGrid(semId) {
const onclick = ev._courseId const onclick = ev._courseId
? `showCourseModal('${ev._courseId}')` ? `showCourseModal('${ev._courseId}')`
: `showEventModal('${ev.semesterId}','${ev.id}')`; : `showEventModal('${ev.semesterId}','${ev.id}')`;
return `<div class="wk-event" style="top:${top}px;height:${height}px;left:${left};width:${width};background:${esc(ev.color)}" const hatched = ev.count && ev.count <= 7;
onclick="${onclick}" title="${esc(ev.title)} ${toHHMM(ev.startMin)}${toHHMM(ev.endMin)}"> const tip = ev.fullName ? `${ev.fullName}` : '';
return `<div class="wk-event${hatched ? ' wk-event-hatched' : ''}" style="top:${top}px;height:${height}px;left:${left};width:${width};background-color:${esc(ev.color)}"
onclick="${onclick}" title="${esc(tip + ev.title)} ${toHHMM(ev.startMin)}${toHHMM(ev.endMin)}${hatched ? ` (${ev.count}×)` : ''}">
${esc(ev.title)}${height > 30 ? `<br><span style="opacity:.8;font-weight:400">${toHHMM(ev.startMin)}</span>${loc}` : ''} ${esc(ev.title)}${height > 30 ? `<br><span style="opacity:.8;font-weight:400">${toHHMM(ev.startMin)}</span>${loc}` : ''}
</div>`; </div>`;
}).join(''); }).join('');
@@ -1656,21 +1758,24 @@ function importICS(input, semId) {
if (!file) return; if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function(e) { reader.onload = function(e) {
const events = parseICS(e.target.result, semId); const slots = parseICS(e.target.result, semId);
if (!events.length) { alert('No weekly events found in this ICS file.'); return; } if (!slots.length) { alert('No weekly events found in this ICS file.'); return; }
if (!confirm(`Import ${events.length} event(s) into this week schedule?`)) return; _startICSLink(semId, slots);
events.forEach(ev => S.schedule.push(ev));
save(); showWeekCalendar(semId);
}; };
reader.readAsText(file); reader.readAsText(file);
input.value = ''; input.value = '';
} }
function icsUnescape(s) {
return (s || '').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\n/gi, ' ').trim();
}
function parseICS(text, semId) { function parseICS(text, semId) {
const events = [];
const DAY_MAP = { MO:1, TU:2, WE:3, TH:4, FR:5, SA:6, SU:7 }; 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); const blocks = text.split(/BEGIN:VEVENT/i).slice(1);
// Pass 1: expand every VEVENT into flat weekly occurrences
const occurrences = [];
blocks.forEach(block => { blocks.forEach(block => {
const endIdx = block.search(/END:VEVENT/i); const endIdx = block.search(/END:VEVENT/i);
if (endIdx < 0) return; if (endIdx < 0) return;
@@ -1684,11 +1789,11 @@ function parseICS(text, semId) {
props[key] = line.slice(ci + 1).trim(); props[key] = line.slice(ci + 1).trim();
}); });
const summary = props['SUMMARY']; const summaryRaw = props['SUMMARY'];
const dtstart = props['DTSTART']; const dtstart = props['DTSTART'];
const dtend = props['DTEND']; const dtend = props['DTEND'];
const rrule = props['RRULE']; const rrule = props['RRULE'];
if (!summary || !dtstart) return; if (!summaryRaw || !dtstart) return;
const tsm = dtstart.match(/T(\d{2})(\d{2})/); const tsm = dtstart.match(/T(\d{2})(\d{2})/);
const tem = dtend ? dtend.match(/T(\d{2})(\d{2})/) : null; const tem = dtend ? dtend.match(/T(\d{2})(\d{2})/) : null;
@@ -1696,28 +1801,355 @@ function parseICS(text, semId) {
const startMin = +tsm[1] * 60 + +tsm[2]; const startMin = +tsm[1] * 60 + +tsm[2];
const endMin = tem ? +tem[1] * 60 + +tem[2] : startMin + 60; const endMin = tem ? +tem[1] * 60 + +tem[2] : startMin + 60;
let days = []; // Split "CODE | Name (possibly multilingual, '/'-separated) | Type" — falls back to the raw text if unrecognized
const summary = icsUnescape(summaryRaw);
const parts = summary.split(' | ').map(s => s.trim());
const code = parts.length >= 3 ? parts[0] : '';
const type = parts.length >= 3 ? parts[parts.length - 1] : '';
const nameVariants = parts.length >= 3 ? parts.slice(1, -1).join(' | ').split(' / ').map(s => s.trim()) : [summary];
const fullName = nameVariants[0];
const label = code ? `${code} · ${type}` : summary;
const loc = icsUnescape(props['LOCATION'] || '');
const dm = dtstart.match(/^(\d{4})(\d{2})(\d{2})/);
const dateKey = dm ? dm[1] + dm[2] + dm[3] : dtstart;
if (rrule) { if (rrule) {
// Recurring series declared via RRULE: treat as a regular (non-hatched) weekly slot
const rm = rrule.match(/BYDAY=([A-Z,]+)/i); const rm = rrule.match(/BYDAY=([A-Z,]+)/i);
if (rm) days = rm[1].split(',').map(d => DAY_MAP[d.trim().toUpperCase()]).filter(Boolean); let days = rm ? rm[1].split(',').map(d => DAY_MAP[d.trim().toUpperCase()]).filter(Boolean) : [];
} if (!days.length && dm) {
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(); const dow = new Date(+dm[1], +dm[2]-1, +dm[3]).getDay();
days = [dow === 0 ? 7 : dow]; days = [dow === 0 ? 7 : dow];
} }
days.forEach(day => occurrences.push({ label, code, fullName, nameVariants, day, startMin, endMin, location: loc, dateKey: null }));
} else if (dm) {
const dow = new Date(+dm[1], +dm[2]-1, +dm[3]).getDay();
const day = dow === 0 ? 7 : dow;
occurrences.push({ label, code, fullName, nameVariants, day, startMin, endMin, location: loc, dateKey });
} }
const clr = EV_COLORS[events.length % EV_COLORS.length]; });
const loc = props['LOCATION'] || '';
days.forEach(day => events.push({ // Pass 2: group same weekly slot (label + day + time) across all its occurrences
id: uid(), title: summary.replace(/\\n/g,' ').replace(/\\,/g,','), const groups = new Map();
day, startMin, endMin, color: clr, location: loc, semesterId: semId occurrences.forEach(occ => {
})); const key = `${occ.label}||${occ.day}||${occ.startMin}||${occ.endMin}`;
let g = groups.get(key);
if (!g) {
g = { label: occ.label, code: occ.code, fullName: occ.fullName, nameVariants: occ.nameVariants, day: occ.day, startMin: occ.startMin, endMin: occ.endMin, locCounts: {}, dateKeys: new Set(), recurring: occ.dateKey === null };
groups.set(key, g);
}
g.locCounts[occ.location] = (g.locCounts[occ.location] || 0) + 1;
if (occ.dateKey) g.dateKeys.add(occ.dateKey);
});
const events = [];
let i = 0;
groups.forEach(g => {
const location = Object.keys(g.locCounts).sort((a, b) => g.locCounts[b] - g.locCounts[a])[0] || '';
const dates = g.recurring ? null : [...g.dateKeys].sort();
events.push({
id: uid(), title: g.label, code: g.code, fullName: g.fullName, nameVariants: g.nameVariants,
day: g.day, startMin: g.startMin, endMin: g.endMin,
color: EV_COLORS[i++ % EV_COLORS.length], location, semesterId: semId,
count: dates ? dates.length : null, dates
});
}); });
return events; return events;
} }
// ══════════════════════════════════════════════════════
// EXPORT ICS
// ══════════════════════════════════════════════════════
function icsEscape(s) {
return String(s || '').replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\n/g, '\\n');
}
function _icsTime(min) {
return toHHMM(min).replace(':', '') + '00';
}
function _icsNowStamp() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}T${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
}
// First calendar date on/after startDateStr ('YYYY-MM-DD') that falls on the given weekday (1=Mon..7=Sun)
function _firstDateForDay(startDateStr, day) {
const d = new Date(startDateStr + 'T00:00:00');
const curDay = d.getDay() === 0 ? 7 : d.getDay();
let diff = day - curDay;
if (diff < 0) diff += 7;
d.setDate(d.getDate() + diff);
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`;
}
function _buildICS(semId) {
const sem = S.semesters.find(x => x.id === semId);
const stamp = _icsNowStamp();
const untilStr = sem.endDate.replace(/-/g, '') + 'T235959';
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//CreditTracker//EN', 'CALSCALE:GREGORIAN'];
function pushVEvent(uid, dateStr, startMin, endMin, rruleLine, summary, location, description) {
lines.push('BEGIN:VEVENT');
lines.push(`UID:${uid}@credittracker`);
lines.push(`DTSTAMP:${stamp}`);
lines.push(`DTSTART:${dateStr}T${_icsTime(startMin)}`);
lines.push(`DTEND:${dateStr}T${_icsTime(endMin)}`);
if (rruleLine) lines.push(rruleLine);
lines.push(`SUMMARY:${icsEscape(summary)}`);
if (location) lines.push(`LOCATION:${icsEscape(location)}`);
if (description) lines.push(`DESCRIPTION:${icsEscape(description)}`);
lines.push('END:VEVENT');
}
// dates (exact YYYYMMDD list from the source ICS) takes priority when known, since it
// naturally reflects any holiday/vacation gaps — a plain weekly RRULE cannot.
function addEvent(uidBase, day, startMin, endMin, count, dates, summary, location, description) {
if (dates && dates.length) {
dates.forEach(dateStr => pushVEvent(`${uidBase}-${dateStr}`, dateStr, startMin, endMin, null, summary, location, description));
return;
}
const dateStr = _firstDateForDay(sem.startDate, day);
const rrule = count ? `RRULE:FREQ=WEEKLY;COUNT=${count}` : `RRULE:FREQ=WEEKLY;UNTIL=${untilStr}`;
pushVEvent(uidBase, dateStr, startMin, endMin, rrule, summary, location, description);
}
S.courses.filter(c => c.semesterId === semId && c.slots && c.slots.length).forEach(c => {
const summary = c.code ? `${c.code}${c.name}` : c.name;
const description = c.credits ? `${c.credits} credits` : '';
c.slots.forEach(s => addEvent(`ct-course-${c.id}-${s.day}-${s.startMin}`, s.day, s.startMin, s.endMin, s.count, s.dates, summary, s.location, description));
});
S.schedule.filter(e => e.semesterId === semId).forEach(e => {
addEvent(`ct-event-${e.id}`, e.day, e.startMin, e.endMin, e.count, e.dates, e.title, e.location, '');
});
lines.push('END:VCALENDAR');
return lines.join('\r\n');
}
function _downloadICS(semId) {
const sem = S.semesters.find(x => x.id === semId);
const ics = _buildICS(semId);
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sem.name.replace(/\s+/g, '-')}.ics`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function exportICS(semId) {
const sem = S.semesters.find(x => x.id === semId);
if (!sem) return;
if (!sem.startDate || !sem.endDate) { _promptSemesterDates(semId); return; }
_downloadICS(semId);
}
function _promptSemesterDates(semId) {
const sem = S.semesters.find(x => x.id === semId);
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Semester Dates</span>
<button class="modal-close" onclick="showWeekCalendar('${semId}')">✕</button>
</div>
<p style="font-size:12px;color:var(--gray-400);margin-bottom:12px">
Needed once, to compute real calendar dates for ${esc(sem.name)}'s recurring weekly sessions when exporting.
</p>
<div class="form-row">
<div class="form-group">
<label>Start date</label>
<input id="f-sem-start" type="date" value="${sem.startDate || ''}">
</div>
<div class="form-group">
<label>End date</label>
<input id="f-sem-end" type="date" value="${sem.endDate || ''}">
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="showWeekCalendar('${semId}')">Cancel</button>
<button class="btn btn-primary" onclick="_saveSemesterDatesAndExport('${semId}')">Save &amp; Export</button>
</div>
</div>`);
}
function _saveSemesterDatesAndExport(semId) {
const start = document.getElementById('f-sem-start').value;
const end = document.getElementById('f-sem-end').value;
if (!start || !end) { alert('Please set both dates.'); return; }
if (end <= start) { alert('End date must be after the start date.'); return; }
const i = S.semesters.findIndex(x => x.id === semId);
if (i >= 0) S.semesters[i] = { ...S.semesters[i], startDate: start, endDate: end };
save();
_downloadICS(semId);
showWeekCalendar(semId);
}
// ══════════════════════════════════════════════════════
// LINK IMPORTED ICS SLOTS TO COURSES
// ══════════════════════════════════════════════════════
let _icsLink = null; // { semId, entries: [{ code, nameVariants, slots, action, courseId, matched, newName, newCredits }] }
function _slotsEqual(a, b) {
if (a.length !== b.length) return false;
const norm = arr => arr.map(s => `${s.day}|${s.startMin}|${s.endMin}|${s.count ?? ''}|${s.location ?? ''}|${(s.dates || []).join(',')}`).sort();
const na = norm(a), nb = norm(b);
return na.every((v, i) => v === nb[i]);
}
function normName(s) {
return (s || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase().replace(/\([^)]*\)/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
}
function _findCourseCodeMatch(code) {
return code ? (S.courses.find(c => c.code && c.code === code) || null) : null;
}
function _findCourseNameSuggestion(nameVariants, semId) {
const norms = nameVariants.map(normName).filter(Boolean);
const candidates = S.courses.filter(c => c.semesterId === semId && !c.code);
let exact = candidates.find(c => norms.includes(normName(c.name)));
if (exact) return exact;
return candidates.find(c => {
const cn = normName(c.name);
return cn && norms.some(n => n.includes(cn) || cn.includes(n));
}) || null;
}
function _startICSLink(semId, slots) {
const byCode = new Map();
slots.forEach(s => {
const key = s.code || `__${s.title}`;
let g = byCode.get(key);
if (!g) { g = { code: s.code, nameVariants: s.nameVariants, slots: [] }; byCode.set(key, g); }
g.slots.push(s);
});
const entries = [...byCode.values()].map(g => {
const codeMatch = _findCourseCodeMatch(g.code);
if (codeMatch) return { ...g, action: 'existing', courseId: codeMatch.id, matched: true, newName: g.nameVariants[0], newCredits: '' };
const suggestion = _findCourseNameSuggestion(g.nameVariants, semId);
return { ...g, action: suggestion ? 'existing' : 'new', courseId: suggestion ? suggestion.id : '', matched: false, newName: g.nameVariants[0], newCredits: '' };
});
_icsLink = { semId, entries };
if (entries.every(e => e.matched)) {
const totalSlots = entries.reduce((n, e) => n + e.slots.length, 0);
if (!confirm(`${entries.length} course(s) already linked by code — import ${totalSlots} slot(s) automatically?`)) { _icsLink = null; return; }
_applyICSLink();
} else {
_renderICSLinkModal();
}
}
function _renderICSLinkModal() {
const sem = S.semesters.find(x => x.id === _icsLink.semId);
openModal(`<div class="modal" style="max-height:88vh;display:flex;flex-direction:column">
<div class="modal-header">
<span class="modal-title">Link Imported Courses</span>
<button class="modal-close" onclick="_icsLink=null;closeModal()">✕</button>
</div>
<p style="font-size:12px;color:var(--gray-400);margin-bottom:10px">
${sem ? esc(sem.name) : ''} — match each course from the ICS to one of yours. Once linked, future imports of this course are recognized automatically.
</p>
<div id="ics-link-rows" style="overflow-y:auto;flex:1;min-height:0">${_icsLinkRowsHTML()}</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="_icsLink=null;closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="_applyICSLink()">Import</button>
</div>
</div>`);
}
function _icsLinkRowsHTML() {
const semCourses = S.courses.filter(c => c.semesterId === _icsLink.semId);
return _icsLink.entries.map((e, i) => {
const timesStr = e.slots.map(s => `${WK_DAYS[s.day - 1]} ${toHHMM(s.startMin)}${toHHMM(s.endMin)}`).join(' · ');
const namesStr = e.nameVariants.join(' / ');
if (e.matched) {
const course = S.courses.find(c => c.id === e.courseId);
return `<div style="border:1px solid var(--gray-200);border-radius:10px;padding:10px;margin-bottom:8px">
<div style="font-size:13px;font-weight:600;color:var(--gray-800)">${esc(e.code)}${esc(namesStr)}</div>
<div style="font-size:11px;color:var(--gray-400);margin:2px 0 6px">${esc(timesStr)}</div>
<div style="font-size:12px;color:var(--success)">✓ Already linked to ${esc(course ? course.name : '?')}</div>
</div>`;
}
const opts = `<option value="new" ${e.action === 'new' ? 'selected' : ''}> Create new course</option>`
+ (semCourses.length ? `<optgroup label="Existing courses">` + semCourses.map(c =>
`<option value="${c.id}" ${e.action === 'existing' && e.courseId === c.id ? 'selected' : ''}>${esc(c.name)}</option>`
).join('') + `</optgroup>` : '')
+ `<option value="skip" ${e.action === 'skip' ? 'selected' : ''}>Don't link (calendar only)</option>`;
return `<div style="border:1px solid var(--gray-200);border-radius:10px;padding:10px;margin-bottom:8px">
<div style="font-size:13px;font-weight:600;color:var(--gray-800)">${e.code ? esc(e.code) + ' — ' : ''}${esc(namesStr)}</div>
<div style="font-size:11px;color:var(--gray-400);margin:2px 0 8px">${esc(timesStr)}</div>
<select onchange="_icsLinkSetAction(${i}, this.value)" style="width:100%;margin-bottom:6px">${opts}</select>
${e.action === 'new' ? `
<div class="form-row" style="margin-top:6px">
<div class="form-group" style="margin-bottom:0">
<label style="font-size:10px">Name</label>
<input type="text" value="${esc(e.newName)}" oninput="_icsLinkSetField(${i},'newName',this.value)">
</div>
<div class="form-group" style="margin-bottom:0">
<label style="font-size:10px">Credits</label>
<input type="number" min="0" step="0.5" value="${esc(e.newCredits)}" oninput="_icsLinkSetField(${i},'newCredits',this.value)">
</div>
</div>` : ''}
</div>`;
}).join('');
}
function _icsLinkSetAction(i, value) {
const e = _icsLink.entries[i];
if (value === 'new') { e.action = 'new'; e.courseId = ''; }
else if (value === 'skip') { e.action = 'skip'; e.courseId = ''; }
else { e.action = 'existing'; e.courseId = value; }
const el = document.getElementById('ics-link-rows');
if (el) el.innerHTML = _icsLinkRowsHTML();
}
function _icsLinkSetField(i, field, value) {
_icsLink.entries[i][field] = value;
}
function _applyICSLink() {
const { semId, entries } = _icsLink;
entries.forEach(e => {
if (e.action === 'skip') {
e.slots.forEach(s => S.schedule.push({
id: uid(), title: s.title, fullName: s.fullName,
day: s.day, startMin: s.startMin, endMin: s.endMin,
color: s.color, location: s.location, semesterId: semId, count: s.count, dates: s.dates
}));
} else if (e.action === 'new') {
S.courses.push({
id: uid(), name: (e.newName || e.nameVariants[0] || e.code || 'Imported course').trim(),
credits: +e.newCredits || 0, semesterId: semId, grade: null,
planId: '', blockId: '', subBlockId: '', passFail: null, attempts: [],
code: e.code || undefined,
slots: e.slots.map(s => ({ day: s.day, startMin: s.startMin, endMin: s.endMin, count: s.count, location: s.location, dates: s.dates }))
});
} else {
const course = S.courses.find(c => c.id === e.courseId);
if (!course) return;
if (!course.code && e.code) course.code = e.code;
const newSlots = e.slots.map(s => ({ day: s.day, startMin: s.startMin, endMin: s.endMin, count: s.count, location: s.location, dates: s.dates }));
if (!_slotsEqual(course.slots || [], newSlots)) course.slots = newSlots;
}
});
save(); render(page); closeModal();
_icsLink = null;
showWeekCalendar(semId);
}
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// MODAL HELPERS // MODAL HELPERS
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
@@ -1765,6 +2197,16 @@ function removeSlot(i) {
if (el) el.innerHTML = renderSlotsUI(); if (el) el.innerHTML = renderSlotsUI();
} }
function setSlotMode(mode) {
const picker = document.getElementById('slot-picker');
if (picker) picker.style.display = mode === 'has' ? 'block' : 'none';
if (mode === 'none') {
_slots = [];
const el = document.getElementById('slots-list');
if (el) el.innerHTML = renderSlotsUI();
}
}
function showCourseModal(id, prefill = {}) { function showCourseModal(id, prefill = {}) {
const c = id ? S.courses.find(x => x.id === id) : null; const c = id ? S.courses.find(x => x.id === id) : null;
@@ -1844,23 +2286,33 @@ function showCourseModal(id, prefill = {}) {
<div class="form-group" style="margin-bottom:4px"> <div class="form-group" style="margin-bottom:4px">
<label>Weekly Schedule</label> <label>Weekly Schedule</label>
<div id="slots-list" style="margin-bottom:6px">${renderSlotsUI()}</div> <div id="slots-list" style="margin-bottom:6px">${renderSlotsUI()}</div>
<div style="display:grid;grid-template-columns:100px 1fr;gap:6px;margin-bottom:6px"> <div style="display:flex;gap:14px;margin-bottom:8px">
<div> <label style="display:flex;align-items:center;gap:5px;font-size:13px;cursor:pointer">
<div style="font-size:11px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Day</div> <input type="radio" name="f-slot-mode" value="none" ${!_slots.length ? 'checked' : ''} onchange="setSlotMode('none')"> No fixed schedule
<select id="f-slot-day" style="width:100%;padding:8px 6px;border:1.5px solid var(--gray-200);border-radius:8px;font-size:14px;background:var(--bg-surface);color:var(--gray-800)"> </label>
${WK_DAYS.map((d, i) => `<option value="${i+1}">${d}</option>`).join('')} <label style="display:flex;align-items:center;gap:5px;font-size:13px;cursor:pointer">
</select> <input type="radio" name="f-slot-mode" value="has" ${_slots.length ? 'checked' : ''} onchange="setSlotMode('has')"> Has a weekly schedule
</div> </label>
<div style="display:flex;flex-direction:column;justify-content:flex-end"> </div>
<div style="font-size:11px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Time</div> <div id="slot-picker" style="display:${_slots.length ? 'block' : 'none'}">
<div style="display:flex;align-items:center;gap:4px"> <div style="display:grid;grid-template-columns:100px 1fr;gap:6px;margin-bottom:6px">
<input id="f-slot-start" type="time" value="08:00" style="flex:1;min-width:0"> <div>
<span style="color:var(--gray-400);font-size:13px;flex-shrink:0"></span> <div style="font-size:11px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Day</div>
<input id="f-slot-end" type="time" value="10:00" style="flex:1;min-width:0"> <select id="f-slot-day" style="width:100%;padding:8px 6px;border:1.5px solid var(--gray-200);border-radius:8px;font-size:14px;background:var(--bg-surface);color:var(--gray-800)">
${WK_DAYS.map((d, i) => `<option value="${i+1}">${d}</option>`).join('')}
</select>
</div>
<div style="display:flex;flex-direction:column;justify-content:flex-end">
<div style="font-size:11px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Time</div>
<div style="display:flex;align-items:center;gap:4px">
<input id="f-slot-start" type="time" value="08:00" style="flex:1;min-width:0">
<span style="color:var(--gray-400);font-size:13px;flex-shrink:0"></span>
<input id="f-slot-end" type="time" value="10:00" style="flex:1;min-width:0">
</div>
</div> </div>
</div> </div>
<button class="btn btn-sm btn-primary btn-full" onclick="addSlot()">+ Add slot</button>
</div> </div>
<button class="btn btn-sm btn-primary btn-full" onclick="addSlot()">+ Add slot</button>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button> <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
@@ -1949,19 +2401,10 @@ function saveCourse(id) {
if (grade1 !== null) attempts.push({ id: uid(), grade: grade1, semesterId: semId }); if (grade1 !== null) attempts.push({ id: uid(), grade: grade1, semesterId: semId });
if (grade2 !== null) attempts.push({ id: uid(), grade: grade2, semesterId: semId }); if (grade2 !== null) attempts.push({ id: uid(), grade: grade2, semesterId: semId });
// Auto-capture the slot picker if user filled it in without clicking "+ Add slot"
const pickerDay = parseInt((document.getElementById('f-slot-day') || {}).value || '1');
const pickerStart = fromHHMM((document.getElementById('f-slot-start') || {}).value || '08:00');
const pickerEnd = fromHHMM((document.getElementById('f-slot-end') || {}).value || '08:00');
if (pickerEnd > pickerStart) {
const alreadyIn = _slots.some(s => s.day === pickerDay && s.startMin === pickerStart && s.endMin === pickerEnd);
if (!alreadyIn) _slots.push({ day: pickerDay, startMin: pickerStart, endMin: pickerEnd });
}
const slots = [..._slots]; const slots = [..._slots];
if (id) { if (id) {
const i = S.courses.findIndex(c => c.id === id); const i = S.courses.findIndex(c => c.id === id);
if (i >= 0) S.courses[i] = { id, name, credits: +credits, semesterId: semId, grade, planId, blockId, subBlockId, passFail, slots, attempts }; if (i >= 0) S.courses[i] = { id, name, credits: +credits, semesterId: semId, grade, planId, blockId, subBlockId, passFail, slots, attempts, code: S.courses[i].code };
} else { } else {
S.courses.push({ id: uid(), name, credits: +credits, semesterId: semId, grade, planId, blockId, subBlockId, passFail, slots, attempts }); S.courses.push({ id: uid(), name, credits: +credits, semesterId: semId, grade, planId, blockId, subBlockId, passFail, slots, attempts });
} }
@@ -2383,6 +2826,17 @@ function showSemesterModal(id) {
<input id="f-syear" type="number" value="${yr}" min="2000" max="2100"> <input id="f-syear" type="number" value="${yr}" min="2000" max="2100">
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group">
<label>Start date <span style="font-weight:400;color:var(--gray-400)">(optional)</span></label>
<input id="f-sem-start" type="date" value="${s && s.startDate ? s.startDate : ''}">
</div>
<div class="form-group">
<label>End date <span style="font-weight:400;color:var(--gray-400)">(optional)</span></label>
<input id="f-sem-end" type="date" value="${s && s.endDate ? s.endDate : ''}">
</div>
</div>
<p style="font-size:11px;color:var(--gray-400);margin:-6px 0 4px">Used to compute exact dates when exporting the week schedule to ICS, and to detect the "current" semester.</p>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button> <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveSemester('${id || ''}')">Save</button> <button class="btn btn-primary" onclick="saveSemester('${id || ''}')">Save</button>
@@ -2394,12 +2848,15 @@ function saveSemester(id) {
const season = document.getElementById('f-season').value; const season = document.getElementById('f-season').value;
const year = parseInt(document.getElementById('f-syear').value, 10); const year = parseInt(document.getElementById('f-syear').value, 10);
const name = season + ' ' + year; const name = season + ' ' + year;
const startDate = document.getElementById('f-sem-start').value || undefined;
const endDate = document.getElementById('f-sem-end').value || undefined;
if (!season || !year) { alert('Please select a season and year.'); return; } if (!season || !year) { alert('Please select a season and year.'); return; }
if (startDate && endDate && endDate <= startDate) { alert('End date must be after the start date.'); return; }
if (id) { if (id) {
const i = S.semesters.findIndex(x => x.id === id); const i = S.semesters.findIndex(x => x.id === id);
if (i >= 0) S.semesters[i] = { ...S.semesters[i], name, season, year }; if (i >= 0) S.semesters[i] = { ...S.semesters[i], name, season, year, startDate, endDate };
} else { } else {
S.semesters.push({ id: uid(), name, season, year }); S.semesters.push({ id: uid(), name, season, year, startDate, endDate });
} }
save(); closeModal(); render(page); save(); closeModal(); render(page);
} }
@@ -2452,11 +2909,14 @@ function showSettingsModal() {
${settingsToggleRow('Show all seasons (Summer & Winter)', 'allSeasons')} ${settingsToggleRow('Show all seasons (Summer & Winter)', 'allSeasons')}
</div> </div>
<div class="section-label" style="margin:16px 0 8px">Study Plans</div> <div class="section-label" style="margin:16px 0 8px">Study Plans</div>
<div class="card" style="box-shadow:none;border:1px solid var(--gray-200)"> <div class="card" style="box-shadow:none;border:1px solid var(--gray-200);display:flex;flex-direction:column;gap:12px">
${settingsToggleRow('Show "Optional Studies" section', 'showOptional')} ${settingsToggleRow('Show "Optional Studies" section', 'showOptional')}
${settingsToggleRow('Auto-fold validated blocks', 'autoFoldValidatedBlocks')}
</div> </div>
<button class="btn btn-secondary btn-full" style="margin-top:16px" onclick="closeModal()">Done</button> <div id="sw-version-label" style="font-size:11px;color:var(--gray-400);margin-top:16px;text-align:center">${_swVersion ? 'App version: ' + _swVersion : 'App version: checking…'}</div>
<button class="btn btn-secondary btn-full" style="margin-top:8px" onclick="closeModal()">Done</button>
</div>`); </div>`);
requestSWVersion();
} }
function updateSetting(key, val) { function updateSetting(key, val) {
@@ -2510,7 +2970,10 @@ function showDataModal() {
<div style="border-top:1px solid var(--gray-200);margin:18px 0 14px"></div> <div style="border-top:1px solid var(--gray-200);margin:18px 0 14px"></div>
<div class="section-label" style="margin-bottom:8px">Export / Import JSON</div> <div class="section-label" style="margin-bottom:8px">Export / Import JSON</div>
<p style="font-size:13px;color:var(--gray-500);margin-bottom:10px">Download a JSON backup or load one from a file.</p> <p style="font-size:13px;color:var(--gray-500);margin-bottom:6px">Download a JSON backup or load one from a file.</p>
<p id="data-backup-status" style="font-size:12px;margin-bottom:10px;color:${daysSinceBackup() === null || daysSinceBackup() >= BACKUP_NUDGE_DAYS ? 'var(--danger)' : 'var(--gray-400)'}">
${daysSinceBackup() === null ? 'Never backed up — everything only lives in this browser.' : `Last backup: ${daysSinceBackup() === 0 ? 'today' : daysSinceBackup() + ' day' + (daysSinceBackup() > 1 ? 's' : '') + ' ago'}`}
</p>
<button class="btn btn-secondary btn-full" style="margin-bottom:10px" onclick="exportData()"> <button class="btn btn-secondary btn-full" style="margin-bottom:10px" onclick="exportData()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <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 Export JSON
@@ -2548,6 +3011,9 @@ function exportData() {
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
try { localStorage.setItem(BACKUP_KEY, new Date().toISOString()); } catch (e) {}
updateBackupDot();
if (document.getElementById('data-backup-status')) showDataModal();
} }
function importData(input) { function importData(input) {
@@ -2590,19 +3056,27 @@ function clearData() {
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// QR CODE SHARE / SCAN // QR CODE SHARE / SCAN
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
const QR_URLS = ['https://unpkg.com/qrcode@1.5.4/build/qrcode.min.js', 'https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js']; const QR_URLS = ['https://unpkg.com/qrcode@1.4.4/build/qrcode.min.js', 'https://cdn.jsdelivr.net/npm/qrcode@1.4.4/build/qrcode.min.js'];
const JSQR_URLS = ['https://unpkg.com/jsqr@1.4.0/dist/jsQR.js', 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js']; const JSQR_URLS = ['https://unpkg.com/jsqr@1.4.0/dist/jsQR.js', 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js'];
async function loadScriptCached(urls, globalName) { async function loadScriptCached(urls, globalName) {
if (window[globalName]) return; if (window[globalName]) return;
const key = 'ct_lib_' + globalName; const key = 'ct_lib_' + globalName;
let code = null; let code = null;
try { code = localStorage.getItem(key); } catch(e) {} try {
const cached = localStorage.getItem(key);
// Reject cached value if it looks like HTML (SW offline fallback corruption)
if (cached && !cached.trimStart().startsWith('<')) code = cached;
else if (cached) localStorage.removeItem(key);
} catch(e) {}
if (!code) { if (!code) {
for (const url of urls) { for (const url of urls) {
try { try {
const r = await fetch(url); const r = await fetch(url);
if (r.ok) { code = await r.text(); break; } if (r.ok) {
const text = await r.text();
if (!text.trimStart().startsWith('<')) { code = text; break; }
}
} catch(e) {} } catch(e) {}
} }
if (!code) throw new Error('Could not load the ' + globalName + ' library. Open this app once with an internet connection to cache it for offline use.'); if (!code) throw new Error('Could not load the ' + globalName + ' library. Open this app once with an internet connection to cache it for offline use.');
@@ -2812,7 +3286,7 @@ function _mergeAndSync(parsed) {
if (!conflictList.length) { if (!conflictList.length) {
Object.assign(S, mergedBase); Object.assign(S, mergedBase);
save(); closeModal(); render(page); save(); closeModal(); render(page);
showResponseQR(); _afterSyncPrompt();
} else { } else {
_showConflictModal(); _showConflictModal();
} }
@@ -2876,7 +3350,23 @@ function applyMergedSync() {
}); });
Object.assign(S, _syncMergedBase); Object.assign(S, _syncMergedBase);
save(); render(page); save(); render(page);
showResponseQR(); _afterSyncPrompt();
}
function _afterSyncPrompt() {
openModal(`<div class="modal">
<div class="modal-header">
<span class="modal-title">Data Merged</span>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:13px;color:var(--gray-600);margin-bottom:14px">
Their data has been merged into yours. Send a response QR so the other device also receives your merged data?
</p>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Skip — receive only</button>
<button class="btn btn-primary" onclick="showResponseQR()">Send Response QR</button>
</div>
</div>`);
} }
async function showResponseQR() { async function showResponseQR() {
@@ -2932,12 +3422,36 @@ async function _importFromQR(encoded) {
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// INIT // INIT
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
let _swVersion = null;
function requestSWVersion() {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: 'GET_VERSION' });
}
}
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js').catch(err => { navigator.serviceWorker.register('./sw.js').then(reg => {
reg.update(); // force an immediate check instead of waiting on the browser's own heuristic
requestSWVersion();
}).catch(err => {
console.warn('SW registration failed:', err); console.warn('SW registration failed:', err);
}); });
}); });
// Once a new worker takes control, reload once so the page reflects it immediately
let _swRefreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (_swRefreshing) return;
_swRefreshing = true;
window.location.reload();
});
navigator.serviceWorker.addEventListener('message', e => {
if (e.data && e.data.type === 'VERSION') {
_swVersion = e.data.version;
const el = document.getElementById('sw-version-label');
if (el) el.textContent = 'App version: ' + _swVersion;
}
});
} }
load(); load();
+24 -24
View File
@@ -1,24 +1,24 @@
{ {
"name": "Credit Tracker", "name": "Credit Tracker",
"short_name": "Credits", "short_name": "Credits",
"description": "Track your university courses, grades and credits", "description": "Track your university courses, grades and credits",
"start_url": "./index.html", "start_url": "./index.html",
"display": "standalone", "display": "standalone",
"background_color": "#F3F4F6", "background_color": "#F3F4F6",
"theme_color": "#4F46E5", "theme_color": "#4F46E5",
"orientation": "portrait", "orientation": "portrait",
"icons": [ "icons": [
{ {
"src": "icon.svg", "src": "icon.svg",
"sizes": "any", "sizes": "any",
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "icon-maskable.svg", "src": "icon-maskable.svg",
"sizes": "any", "sizes": "any",
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "maskable" "purpose": "maskable"
} }
] ]
} }
+72 -48
View File
@@ -1,48 +1,72 @@
const CACHE_NAME = 'credittracker-v36'; const CACHE_NAME = 'credittracker-v47';
const ASSETS = [ const ASSETS = [
'./index.html', './index.html',
'./manifest.json', './manifest.json',
'./icon.svg', './icon.svg',
'./icon-maskable.svg' './icon-maskable.svg'
]; ];
// Install: pre-cache all app shell files // Install: pre-cache all app shell files
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS)) caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
); );
self.skipWaiting(); self.skipWaiting();
}); });
// Activate: clean up old caches // Activate: clean up old caches
self.addEventListener('activate', event => { self.addEventListener('activate', event => {
event.waitUntil( event.waitUntil(
caches.keys().then(keys => caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
) )
); );
self.clients.claim(); self.clients.claim();
}); });
// Fetch: cache-first for app files, network-first for everything else // Lets the page ask "what version are you actually running" (Settings screen indicator)
self.addEventListener('fetch', event => { self.addEventListener('message', event => {
// Only handle same-origin GET requests if (event.data && event.data.type === 'GET_VERSION') {
if (event.request.method !== 'GET') return; event.source.postMessage({ type: 'VERSION', version: CACHE_NAME });
}
event.respondWith( });
caches.match(event.request).then(cached => {
if (cached) return cached; // Fetch: network-first for the app shell (so edits show up without a version bump),
return fetch(event.request).then(response => { // cache-first for everything else (icons, manifest, external libs)
// Cache successful responses for app assets self.addEventListener('fetch', event => {
if (response.ok) { if (event.request.method !== 'GET') return;
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); const reqUrl = new URL(event.request.url);
} const isSameOrigin = reqUrl.origin === self.location.origin;
return response; const isAppShell = isSameOrigin && (event.request.mode === 'navigate' || reqUrl.pathname.endsWith('/index.html'));
});
}).catch(() => { if (isAppShell) {
// Offline fallback: return the app shell event.respondWith(
return caches.match('./index.html'); fetch(event.request).then(response => {
}) if (response.ok) {
); const clone = response.clone();
}); caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
}).catch(() => caches.match(event.request).then(cached => cached || caches.match('./index.html')))
);
return;
}
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok && isSameOrigin) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
});
}).catch(() => {
// Offline fallback only for external CDN fetches: fail loudly instead of masking with app HTML
if (isSameOrigin) return caches.match('./index.html');
return Response.error();
})
);
});