From eb7e64ee062f7d89adcea3e6ce4b4cef485c063e Mon Sep 17 00:00:00 2001 From: Romain De Groote Date: Mon, 24 Aug 2026 11:39:50 +0200 Subject: [PATCH] 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 --- .claude | 32 +-- icon-maskable.svg | 34 +-- icon.svg | 34 +-- index.html | 644 +++++++++++++++++++++++++++++++++++++++++----- manifest.json | 48 ++-- sw.js | 120 +++++---- 6 files changed, 725 insertions(+), 187 deletions(-) diff --git a/.claude b/.claude index 783cf65..2aeec6d 100644 --- a/.claude +++ b/.claude @@ -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? \ No newline at end of file diff --git a/icon-maskable.svg b/icon-maskable.svg index 68fe248..f2fe5a3 100644 --- a/icon-maskable.svg +++ b/icon-maskable.svg @@ -1,17 +1,17 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/icon.svg b/icon.svg index 38cc742..deaf14f 100644 --- a/icon.svg +++ b/icon.svg @@ -1,17 +1,17 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/index.html b/index.html index 121eaf5..bde4c99 100644 --- a/index.html +++ b/index.html @@ -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 @@ ${esc(blk.name)}${blockFailed ? ` FAIL` : ''} ${blockIsPassed ? `✓ Validé` @@ -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 = `
- ${chevBtn(colKey)} + ${esc(sb.name)} ${sb.passed ? `✓ Validé` @@ -1144,6 +1202,7 @@ function semCoursesByPlan(sc) { function dashSemesters() { if (!S.semesters.length) return emptyState('📅', 'No semesters yet.
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 += `
+ 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 += `
+
${esc(sem.name)}
${esc(sem.season)} · ${sem.year}${mean !== null ? ` · avg ${mean.toFixed(2)}` : ''}
@@ -1167,8 +1232,9 @@ function dashSemesters() {
+ ${isFolded ? '' : ` ${semCoursesByPlan(sc)} - + `}
`; }); return html; @@ -1453,10 +1519,14 @@ function showWeekCalendar(semId) { ${CAL_SVG} Import ICS - + +
+
+
${renderWeekGrid(semId)}
+ ${_renderOtherSemesterCourses(semId)}
@@ -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 `
+
+ Courses with a timetable in other semesters +
+ ${others.map(c => ` +
+
+
${esc(c.name)}
+
${semName(c.semesterId)}
+
+ +
+ `).join('')} +
`; +} + +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 `
+ const hatched = ev.count && ev.count <= 7; + const tip = ev.fullName ? `${ev.fullName} — ` : ''; + return `
${esc(ev.title)}${height > 30 ? `
${toHHMM(ev.startMin)}${loc}` : ''}
`; }).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(``); +} + +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(``); +} + +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 `
+
${esc(e.code)} — ${esc(namesStr)}
+
${esc(timesStr)}
+
✓ Already linked to ${esc(course ? course.name : '?')}
+
`; + } + const opts = `` + + (semCourses.length ? `` + semCourses.map(c => + `` + ).join('') + `` : '') + + ``; + return `
+
${e.code ? esc(e.code) + ' — ' : ''}${esc(namesStr)}
+
${esc(timesStr)}
+ + ${e.action === 'new' ? ` +
+
+ + +
+
+ + +
+
` : ''} +
`; + }).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 = {}) {
${renderSlotsUI()}
-
-
-
Day
- -
-
-
Time
-
- - - +
+ + +
+
+
+
+
Day
+ +
+
+
Time
+
+ + + +
+
-
+
+
+ + +
+
+ + +
+
+

Used to compute exact dates when exporting the week schedule to ICS, and to detect the "current" semester.

-
+
${settingsToggleRow('Show "Optional Studies" section', 'showOptional')} + ${settingsToggleRow('Auto-fold validated blocks', 'autoFoldValidatedBlocks')}
- +
${_swVersion ? 'App version: ' + _swVersion : 'App version: checking…'}
+
`); + requestSWVersion(); } function updateSetting(key, val) { @@ -2510,7 +2970,10 @@ function showDataModal() {
-

Download a JSON backup or load one from a file.

+

Download a JSON backup or load one from a file.

+

+ ${daysSinceBackup() === null ? 'Never backed up — everything only lives in this browser.' : `Last backup: ${daysSinceBackup() === 0 ? 'today' : daysSinceBackup() + ' day' + (daysSinceBackup() > 1 ? 's' : '') + ' ago'}`} +

+
+

+ Their data has been merged into yours. Send a response QR so the other device also receives your merged data? +

+ +
`); } 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(); diff --git a/manifest.json b/manifest.json index d9a1a43..1e67332 100644 --- a/manifest.json +++ b/manifest.json @@ -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" + } + ] +} diff --git a/sw.js b/sw.js index 0c93487..78a9d1a 100644 --- a/sw.js +++ b/sw.js @@ -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(); + }) + ); +});