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
+579 -65
View File
@@ -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 &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
// ══════════════════════════════════════════════════════
@@ -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();
+31 -7
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'credittracker-v36';
const CACHE_NAME = 'credittracker-v47';
const ASSETS = [
'./index.html',
'./manifest.json',
@@ -24,25 +24,49 @@ self.addEventListener('activate', event => {
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('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 => {
// Only handle same-origin GET requests
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 => {
// Cache successful responses for app assets
if (response.ok) {
if (response.ok && isSameOrigin) {
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');
// 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();
})
);
});