diff --git a/index.html b/index.html
index 6ad3399..121eaf5 100644
--- a/index.html
+++ b/index.html
@@ -430,10 +430,10 @@
.wk-gline { position: absolute; left: 0; right: 0; border-top: 1px solid var(--gray-100); }
.wk-gline-h { border-top-color: var(--gray-200); }
.wk-event {
- position: absolute; left: 2px; right: 2px;
+ position: absolute;
border-radius: 4px; font-size: 10px; font-weight: 600;
padding: 2px 4px; overflow: hidden; cursor: pointer;
- color: #fff; line-height: 1.3;
+ color: #fff; line-height: 1.3; box-sizing: border-box;
}
.wk-event:active { filter: brightness(0.85); }
.ev-swatch {
@@ -944,10 +944,10 @@ function dashPlans() {
`;
if (!isCollapsed) {
- html += roleGroupSection(plan.id, 'Major', 'major', majors, pc, pClr);
- html += roleGroupSection(plan.id, 'Minor', 'minor', minors, pc, pClr);
+ majors.forEach((blk, i) => { html += planBlockSection(plan.id, blk, pc, majors.length, i); });
+ minors.forEach((blk, i) => { html += planBlockSection(plan.id, blk, pc, minors.length, i); });
if (settings.showOptional) {
- html += roleGroupSection(plan.id, 'Optional Studies', 'optional', optionals, pc, pClr);
+ optionals.forEach((blk, i) => { html += planBlockSection(plan.id, blk, pc, optionals.length, i); });
}
others.forEach(blk => { html += planBlockSection(plan.id, blk, pc, 1, 0); });
const nbc = pc.filter(c => !c.blockId);
@@ -955,6 +955,11 @@ function dashPlans() {
html += `
Unassigned courses
`;
html += coursesMgmtList(nbc);
}
+ html += `
+ ${!majors.length ? `+ Major ` : ''}
+ + Minor
+ ${settings.showOptional ? `+ Optional ` : ''}
+
`;
}
html += ``;
});
@@ -1052,6 +1057,90 @@ function planSubBlockSection(planId, blockId, sb, courses, parentColor, inEdit,
const CAL_SVG = ` `;
+function semCourseRow(c) {
+ const st = status(c);
+ const blk = c.blockId ? S.blocks.find(b => b.id === c.blockId) : null;
+ const bClr = blk ? blockEffectiveColor(blk) : null;
+ const borderStyle = bClr ? `border-left:3px solid ${bClr};padding-left:6px;` : '';
+ const slotsStr = c.slots && c.slots.length
+ ? c.slots.map(s => WK_DAYS[s.day - 1] + ' ' + toHHMM(s.startMin) + '–' + toHHMM(s.endMin)).join(' · ')
+ : '';
+ const attemptsHtml = c.attempts && c.attempts.length > 1
+ ? c.attempts.map((a, i) => `${a.grade}${i < c.attempts.length - 1 ? '→ ' : ''} `).join('')
+ : `${fmtGrade(c.grade, c.passFail)} `;
+ return `
+
+
${esc(c.name)}
+ ${c.credits ? `
${c.credits} cr.
` : ''}
+ ${slotsStr ? `
${slotsStr}
` : ''}
+
+
+ ${badge(st)}
+ ${attemptsHtml}
+ ✎
+ ×
+
+
`;
+}
+
+function semCoursesByPlan(sc) {
+ if (!sc.length) return `No courses this semester
`;
+ let html = '';
+
+ // Plans that have courses in this semester (in S.studyPlans order)
+ S.studyPlans.forEach(plan => {
+ const planCrs = sc.filter(c => c.planId === plan.id);
+ if (!planCrs.length) return;
+ const pClr = planBaseColor(plan);
+
+ html += `${esc(planDisplayName(plan))}
`;
+
+ // Blocks in plan order: major → minors → optionals → unroled
+ const ordered = [
+ ...S.blocks.filter(b => b.planId === plan.id && b.role === 'major'),
+ ...S.blocks.filter(b => b.planId === plan.id && b.role === 'minor'),
+ ...S.blocks.filter(b => b.planId === plan.id && b.role === 'optional'),
+ ...S.blocks.filter(b => b.planId === plan.id && !b.role),
+ ].filter(blk => planCrs.some(c => c.blockId === blk.id));
+
+ ordered.forEach(blk => {
+ const blkCrs = planCrs.filter(c => c.blockId === blk.id);
+ const bClr = blockEffectiveColor(blk);
+
+ html += ``;
+ html += `
${esc(blk.name)}
`;
+
+ // Sub-blocks (in S.subBlocks order)
+ S.subBlocks.filter(sb => sb.blockId === blk.id && blkCrs.some(c => c.subBlockId === sb.id)).forEach(sb => {
+ const sbCrs = blkCrs.filter(c => c.subBlockId === sb.id);
+ const sbClr = subBlockEffectiveColor(sb);
+ html += `
`;
+ html += `
${esc(sb.name)}
`;
+ sbCrs.forEach(c => { html += semCourseRow(c); });
+ html += `
`;
+ });
+
+ // Free courses (in block, no sub-block), preserve S.courses order
+ blkCrs.filter(c => !c.subBlockId).forEach(c => { html += semCourseRow(c); });
+ html += `
`;
+ });
+
+ // Courses in plan but no block
+ planCrs.filter(c => !c.blockId).forEach(c => { html += semCourseRow(c); });
+ });
+
+ // Courses without a plan
+ const noPlan = sc.filter(c => !c.planId);
+ if (noPlan.length) {
+ if (S.studyPlans.some(p => sc.some(c => c.planId === p.id))) {
+ html += `Sans plan
`;
+ }
+ noPlan.forEach(c => { html += semCourseRow(c); });
+ }
+
+ return html;
+}
+
function dashSemesters() {
if (!S.semesters.length) return emptyState('📅', 'No semesters yet. Tap + to create one!');
let html = '';
@@ -1077,12 +1166,9 @@ function dashSemesters() {
${GEAR_SVG}
Del
- `;
-
- if (!sc.length) { html += `No courses this semester
`; }
- else html += coursesMgmtList(sc, true);
-
- html += `+ Add Course
+
+ ${semCoursesByPlan(sc)}
+ + Add Course
`;
});
return html;
@@ -1334,6 +1420,23 @@ function fromHHMM(str) {
return h * 60 + (m || 0);
}
+function layoutOverlapping(events) {
+ if (!events.length) return [];
+ const sorted = [...events].sort((a, b) => a.startMin - b.startMin || b.endMin - a.endMin);
+ const colEnds = [];
+ const assigned = sorted.map(ev => {
+ let col = colEnds.findIndex(end => end <= ev.startMin);
+ if (col === -1) col = colEnds.length;
+ colEnds[col] = ev.endMin;
+ return { ev, col };
+ });
+ return assigned.map(item => {
+ const peers = assigned.filter(o => o.ev.startMin < item.ev.endMin && o.ev.endMin > item.ev.startMin);
+ const totalCols = peers.reduce((m, o) => Math.max(m, o.col + 1), 1);
+ return { ev: item.ev, col: item.col, totalCols };
+ });
+}
+
function showWeekCalendar(semId) {
const sem = S.semesters.find(x => x.id === semId);
if (!sem) return;
@@ -1424,17 +1527,21 @@ function renderWeekGrid(semId) {
const dayCols = WK_DAYS.map((_, di) => {
const dayNum = di + 1;
const dayEvents = events.filter(e => e.day === dayNum);
- const evHtml = dayEvents.map(ev => {
+ const laid = layoutOverlapping(dayEvents);
+ const evHtml = laid.map(({ ev, col, totalCols }) => {
const t = Math.max(ev.startMin, WK_START);
const b = Math.min(ev.endMin, WK_END);
if (t >= b) return '';
const top = (t - WK_START) * PX_PER_MIN;
const height = Math.max((b - t) * PX_PER_MIN, 18);
+ const pct = (100 / totalCols).toFixed(2);
+ const left = `calc(${(col / totalCols * 100).toFixed(2)}% + 2px)`;
+ const width = `calc(${pct}% - 4px)`;
const loc = ev.location ? `${esc(ev.location)}
` : '';
const onclick = ev._courseId
? `showCourseModal('${ev._courseId}')`
: `showEventModal('${ev.semesterId}','${ev.id}')`;
- return `
${esc(ev.title)}${height > 30 ? `${toHHMM(ev.startMin)} ${loc}` : ''}
`;
@@ -1624,48 +1731,11 @@ function overlayClick(e) { if (e.target.id === 'overlay') closeModal(); }
// ══════════════════════════════════════════════════════
// COURSE MODAL
// ══════════════════════════════════════════════════════
-let _slots = []; // working slot list while course modal is open
-let _attempts = []; // working attempt list while course modal is open
+let _slots = []; // working slot list while course modal is open
-function renderAttemptsUI() {
- if (!_attempts.length) return `No attempts logged yet
`;
- return _attempts.map((a, i) => {
- const sem = S.semesters.find(s => s.id === a.semesterId);
- const gCls = +a.grade >= 4 ? 'var(--success)' : 'var(--danger)';
- const label = sem ? esc(sem.name) : '';
- return `
- #${i+1}
- ${a.grade}
- ${+a.grade >= 4
- ? 'Passed '
- : 'Failed '}
- ${label ? `${label} ` : ''}
- ×
-
`;
- }).join('');
-}
-
-function logAttempt() {
- const gEl = document.getElementById('f-att-grade');
- const sEl = document.getElementById('f-att-sem');
- const g = parseFloat(gEl?.value);
- if (isNaN(g) || g < 1 || g > 6) { alert('Enter a valid grade between 1 and 6.'); return; }
- if (_attempts.length >= 2 && !confirm('You already have 2 attempts logged. Add a third?')) return;
- _attempts.push({ id: uid(), grade: g, semesterId: sEl?.value || '' });
- // propagate latest grade to the main grade field
- const mainG = document.getElementById('f-grade');
- if (mainG) mainG.value = g;
- if (gEl) gEl.value = '';
- const el = document.getElementById('attempts-list');
- if (el) el.innerHTML = renderAttemptsUI();
-}
-
-function removeAttempt(idx) {
- _attempts.splice(idx, 1);
- const mainG = document.getElementById('f-grade');
- if (mainG && _attempts.length) mainG.value = _attempts[_attempts.length - 1].grade;
- const el = document.getElementById('attempts-list');
- if (el) el.innerHTML = renderAttemptsUI();
+function onGradeInput(v) {
+ const fg2 = document.getElementById('fg-grade2');
+ if (fg2) fg2.style.display = (v !== '' && parseFloat(v) < 4) ? 'block' : 'none';
}
function renderSlotsUI() {
@@ -1714,10 +1784,13 @@ function showCourseModal(id, prefill = {}) {
const blockOpts = buildBlockOpts(curPlan, curBlock);
const pfRaw = c ? c.passFail : null;
const pfMode = pfRaw === 'passed' || pfRaw === true ? 'passed' : pfRaw === 'failed' ? 'failed' : 'default';
- _slots = c && c.slots ? c.slots.map(s => ({ ...s })) : [];
- _attempts = c && c.attempts ? c.attempts.map(a => ({ ...a })) : [];
- const semOptsAttempt = `— none — ` + S.semesters.map(s => `${esc(s.name)} `).join('');
-
+ _slots = c && c.slots ? c.slots.map(s => ({ ...s })) : [];
+ // Pre-populate grade fields from attempts (fallback to course.grade for legacy courses)
+ const a0 = c?.attempts?.[0];
+ const a1 = c?.attempts?.[1];
+ const g1val = pfMode === 'default' ? (a0 != null ? a0.grade : (c?.grade != null && c.grade !== '' ? c.grade : '')) : '';
+ const g2val = pfMode === 'default' && a1 != null ? a1.grade : '';
+ const g2vis = g1val !== '' && parseFloat(g1val) < 4;
openModal(`
@@ -1743,21 +1820,6 @@ function showCourseModal(id, prefill = {}) {
Passed
Failed
-
Semester
@@ -1851,6 +1913,8 @@ function onPFChange(v) {
if (inp) inp.value = v;
const fgGrade = document.getElementById('fg-grade');
if (fgGrade) fgGrade.style.display = v === 'default' ? 'block' : 'none';
+ const fg2 = document.getElementById('fg-grade2');
+ if (fg2 && v !== 'default') fg2.style.display = 'none';
document.querySelectorAll('.pf-seg-btn').forEach(btn => {
btn.className = 'pf-seg-btn';
if (btn.textContent.trim().toLowerCase() === v) btn.classList.add('seg-' + v);
@@ -1863,7 +1927,9 @@ function saveCourse(id) {
const pfVal = document.getElementById('f-passfail').value || 'default';
const passFail = pfVal === 'default' ? null : pfVal;
const gradeEl = document.getElementById('f-grade');
+ const grade2El = document.getElementById('f-grade-2');
const gradeV = (!passFail && gradeEl) ? gradeEl.value : '';
+ const grade2V = (!passFail && grade2El && grade2El.closest('#fg-grade2')?.style.display !== 'none') ? grade2El.value : '';
const semId = document.getElementById('f-semester').value;
const planId = document.getElementById('f-plan').value;
const fg = document.getElementById('fg-block');
@@ -1874,7 +1940,14 @@ function saveCourse(id) {
if (!name) { alert('Please enter a course name.'); return; }
if (!credits || +credits < 0) { alert('Please enter valid credits.'); return; }
- const grade = (passFail === null && gradeV !== '') ? parseFloat(gradeV) : null;
+ const grade1 = (passFail === null && gradeV !== '') ? parseFloat(gradeV) : null;
+ const grade2 = (passFail === null && grade2V !== '') ? parseFloat(grade2V) : null;
+ const grade = grade2 !== null ? grade2 : grade1;
+
+ // Build attempts from the two inline grade fields
+ const attempts = [];
+ 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');
@@ -1885,8 +1958,7 @@ function saveCourse(id) {
if (!alreadyIn) _slots.push({ day: pickerDay, startMin: pickerStart, endMin: pickerEnd });
}
- const slots = [..._slots];
- const attempts = [..._attempts];
+ 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 };
diff --git a/sw.js b/sw.js
index 480dbfd..0c93487 100644
--- a/sw.js
+++ b/sw.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = 'credittracker-v33';
+const CACHE_NAME = 'credittracker-v36';
const ASSETS = [
'./index.html',
'./manifest.json',