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