diff --git a/.gitignore b/.gitignore index 6a5bf56..aa13299 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ coverage/ # OS .DS_Store Thumbs.db + +# Claude Code local tooling +.claude/ diff --git a/README.md b/README.md index 39e34d9..dbbf465 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ A front-end only web application for scheduling instructor workloads with drag-a ### Course Management Courses should be treated as an instance of a section. -- Add courses with name, credits, and assigned instructor +- Add courses with name, credits, and assigned instructor(s) +- Credits may be fractional (e.g. 2.5) +- **Co-teaching** — assign multiple instructors to a course and split its credits between them. Each instructor's workload counts only their share, and the split must add up to the course's total credits - Edit course details by **double-clicking** on any scheduled course - Delete courses (automatically removes from schedule) - Drag courses to schedule slots diff --git a/index.html b/index.html index 246b06f..2dfbfc8 100644 --- a/index.html +++ b/index.html @@ -80,7 +80,7 @@

- +
@@ -169,7 +169,7 @@

Edit Course

- +
@@ -196,10 +196,11 @@

Edit Course

- - + +
+ +
@@ -291,7 +292,7 @@

How to Use

  • Add Programs - Create programs (e.g. CPW) to organize courses
  • Add Instructors - Enter names in the Instructors panel on the left
  • Add Courses - Add courses to the global catalog with program, number, and credits
  • -
  • Assign Instructors - Double-click a course to assign an instructor for the current schedule
  • +
  • Assign Instructors - Double-click a course to assign one or more instructors for the current schedule, splitting the course's credits between them
  • Add Classrooms - Create rooms with room numbers
  • Add Time Slots - Set up shared schedule times for each day (applies to all rooms)
  • Set Quarter - Choose a quarter (Fall/Winter/Spring/Summer) for each schedule
  • @@ -306,6 +307,7 @@

    Tips

    diff --git a/src/ui/ModalManager.js b/src/ui/ModalManager.js index dcb64b3..5c2719c 100644 --- a/src/ui/ModalManager.js +++ b/src/ui/ModalManager.js @@ -99,11 +99,8 @@ export class ModalManager { const sectionInput = document.getElementById('editCourseSection'); if (sectionInput) sectionInput.value = currentSection; - const instructorSelect = document.getElementById('editCourseInstructor'); - instructorSelect.innerHTML = '' + - d.instructors.map(i => ``).join(''); - const instrKey = Helpers.getCourseInstructorKey(courseId, currentSection); - instructorSelect.value = sd.courseInstructors[instrKey] || ''; + const assignments = Helpers.getInstructorAssignments(sd, courseId, currentSection); + this._renderInstructorRows(assignments); const modal = document.getElementById('courseModal'); modal.style.display = 'block'; @@ -142,10 +139,8 @@ export class ModalManager { const sectionGroup = document.getElementById('editCourseSection')?.closest('.form-group'); if (sectionGroup) sectionGroup.style.display = 'none'; - const instructorSelect = document.getElementById('editCourseInstructor'); - instructorSelect.innerHTML = '' + - d.instructors.map(i => ``).join(''); - instructorSelect.value = sd.courseInstructors[courseId] || ''; + const assignments = Helpers.getInstructorAssignments(sd, courseId, ''); + this._renderInstructorRows(assignments); const modal = document.getElementById('courseModal'); modal.style.display = 'block'; @@ -160,13 +155,34 @@ export class ModalManager { document.getElementById('courseModal').style.display = 'none'; } - saveCourseChanges(courseId, name, credits, instructorId, classroomId, day, timeslot, modality, courseIndex, quarterTaken, programId, courseNumber, quartersOffered, section) { + saveCourseChanges(courseId, name, credits, classroomId, day, timeslot, modality, courseIndex, quarterTaken, programId, courseNumber, quartersOffered, section) { const d = this.#dataStore.data; const course = d.courseCatalog.find(c => c.id === courseId); if (!course) return; + const courseCredits = parseFloat(credits); + const assignments = this._readInstructorRows(); + + // Enforced split: any assigned instructors must share exactly the + // course's total credits, and no instructor may appear twice. + const ids = assignments.map(a => a.instructorId); + if (new Set(ids).size !== ids.length) { + alert('Each instructor can only be listed once for this section.'); + return; + } + if (assignments.length > 0) { + const assigned = assignments.reduce((sum, a) => sum + a.credits, 0); + if (Math.abs(assigned - courseCredits) > 0.001) { + alert( + `Instructor credits must add up to the course total.\n` + + `Assigned: ${Helpers.formatCredits(assigned)} — Course total: ${Helpers.formatCredits(courseCredits)}` + ); + return; + } + } + course.name = name || ''; - course.credits = parseInt(credits); + course.credits = courseCredits; course.quarterTaken = quarterTaken || null; course.programId = programId || null; course.courseNumber = courseNumber || ''; @@ -175,8 +191,8 @@ export class ModalManager { const currentSection = section || ''; const sd = this.#dataStore.getCurrentSchedule(); const instrKey = Helpers.getCourseInstructorKey(courseId, currentSection); - if (instructorId) { - sd.courseInstructors[instrKey] = instructorId; + if (assignments.length > 0) { + sd.courseInstructors[instrKey] = assignments; } else { delete sd.courseInstructors[instrKey]; } @@ -198,6 +214,88 @@ export class ModalManager { this.closeCourse(); } + // ── Instructor / credit-split rows (Course Edit Modal) ── + + _instructorOptions(selectedId) { + const d = this.#dataStore.data; + return '' + + d.instructors.map(i => + `` + ).join(''); + } + + _instructorRowHTML(instructorId = '', credits = '') { + const creditsVal = (credits === null || credits === undefined) ? '' : credits; + return ` +
    + + + +
    + `; + } + + _renderInstructorRows(assignments) { + const container = document.getElementById('editInstructorRows'); + if (!container) return; + container.innerHTML = (assignments || []) + .map(a => this._instructorRowHTML(a.instructorId, a.credits)) + .join(''); + this.updateCreditsSummary(); + } + + addInstructorRow() { + const container = document.getElementById('editInstructorRows'); + if (!container) return; + // Default the new row's credits to whatever is still unassigned. + const courseCredits = parseFloat(document.getElementById('editCourseCredits').value) || 0; + const assigned = this._readInstructorRows().reduce((sum, a) => sum + a.credits, 0); + const remaining = Math.max(0, parseFloat((courseCredits - assigned).toFixed(2))); + container.insertAdjacentHTML('beforeend', this._instructorRowHTML('', remaining || '')); + this.updateCreditsSummary(); + } + + removeInstructorRow(rowEl) { + if (rowEl) rowEl.remove(); + this.updateCreditsSummary(); + } + + _readInstructorRows() { + const container = document.getElementById('editInstructorRows'); + if (!container) return []; + return Array.from(container.querySelectorAll('.instructor-row')) + .map(row => { + const instructorId = row.querySelector('.instructor-row-select').value; + const credits = parseFloat(row.querySelector('.instructor-row-credits').value) || 0; + return { instructorId, credits }; + }) + .filter(a => a.instructorId); + } + + updateCreditsSummary() { + const summary = document.getElementById('instructorCreditsSummary'); + if (!summary) return; + + const assignments = this._readInstructorRows(); + if (assignments.length === 0) { + summary.textContent = 'No instructors assigned (optional).'; + summary.className = 'credits-summary'; + return; + } + + const courseCredits = parseFloat(document.getElementById('editCourseCredits').value) || 0; + const assigned = assignments.reduce((sum, a) => sum + a.credits, 0); + const balanced = Math.abs(assigned - courseCredits) <= 0.001; + summary.textContent = balanced + ? `Assigned ${Helpers.formatCredits(assigned)} of ${Helpers.formatCredits(courseCredits)} credits ✓` + : `Assigned ${Helpers.formatCredits(assigned)} of ${Helpers.formatCredits(courseCredits)} credits — must total ${Helpers.formatCredits(courseCredits)}`; + summary.className = `credits-summary ${balanced ? 'credits-summary-ok' : 'credits-summary-error'}`; + } + // ── Instructor Edit Modal ── showInstructor(instructorId) { diff --git a/src/ui/Renderer.js b/src/ui/Renderer.js index a51589b..60bca5c 100644 --- a/src/ui/Renderer.js +++ b/src/ui/Renderer.js @@ -79,7 +79,7 @@ export class Renderer { style="cursor: pointer; border-left: 4px solid ${color};">
    ${instructor.name}
    -
    ${workload} credits
    +
    ${Helpers.formatCredits(workload)} credits
    @@ -143,8 +143,9 @@ export class Renderer { const sd = this.#dataStore.getCurrentSchedule(); container.innerHTML = displayCourses.map(course => { - const instructorId = sd.courseInstructors[course.id] || null; - const instructor = instructorId ? d.instructors.find(i => i.id === instructorId) : null; + const instructorLabel = this._instructorLabel( + Helpers.getInstructorAssignments(sd, course.id, ''), course.credits + ); const isScheduled = this.#callbacks.isScheduled(course.id); const statusClass = isScheduled ? 'course-scheduled' : 'course-unscheduled'; const quarterLabel = course.quarterTaken ? ` • ${course.quarterTaken}` : ''; @@ -159,7 +160,7 @@ export class Renderer { data-action="course-edit-list" data-id="${course.id}">
    ${displayName}${hasNoProgram ? ' ⚠️' : ''}
    -
    ${course.credits} credits${instructor ? ' • ' + instructor.name : ''}${quarterLabel}${quartersStr}
    +
    ${Helpers.formatCredits(course.credits)} credits${instructorLabel ? ' • ' + instructorLabel : ''}${quarterLabel}${quartersStr}
    @@ -352,9 +353,8 @@ export class Renderer { const sectionLabel = item.section ? ` §${item.section}` : ''; const sd = this.#dataStore.getCurrentSchedule(); - const instrKey = Helpers.getCourseInstructorKey(item.courseId, item.section); - const instructorId = sd.courseInstructors[instrKey] || null; - const instructor = instructorId ? d.instructors.find(i => i.id === instructorId) : null; + const assignments = Helpers.getInstructorAssignments(sd, item.courseId, item.section); + const instructorLabel = this._instructorLabel(assignments, course ? course.credits : 0); return `
    ×
    ${displayName}${sectionLabel}${hasConflict ? ' ⚠️' : ''}
    - ${course ? course.credits + ' credits' : ''}${instructor ? ' • ' + instructor.name : ''}${course && course.quarterTaken ? '' + course.quarterTaken + '' : ''} + ${course ? Helpers.formatCredits(course.credits) + ' credits' : ''}${instructorLabel ? ' • ' + instructorLabel : ''}${course && course.quarterTaken ? '' + course.quarterTaken + '' : ''} ${Config.MODALITY_ICONS[item.modality]} ${item.modality}
    @@ -388,18 +388,41 @@ export class Renderer { _getScheduledCourseStyle(courseId, section) { const d = this.#dataStore.data; const sd = this.#dataStore.getCurrentSchedule(); - const instrKey = Helpers.getCourseInstructorKey(courseId, section); - const instructorId = sd.courseInstructors[instrKey] || null; - const instructor = instructorId ? d.instructors.find(i => i.id === instructorId) : null; - const color = instructor ? (instructor.color || Config.DEFAULT_COLOR) : '#95a5a6'; + const assignments = Helpers.getInstructorAssignments(sd, courseId, section); + const primaryId = assignments.length > 0 ? assignments[0].instructorId : null; + const primary = primaryId ? d.instructors.find(i => i.id === primaryId) : null; + const color = primary ? (primary.color || Config.DEFAULT_COLOR) : '#95a5a6'; + // With co-teaching, keep the slot visible if ANY assigned instructor + // passes the active filter. const isFiltering = d.instructorFilter && d.instructorFilter.length > 0; - const isFiltered = isFiltering && instructorId && !d.instructorFilter.includes(instructorId); + const matchesFilter = assignments.some(a => d.instructorFilter.includes(a.instructorId)); + const isFiltered = isFiltering && assignments.length > 0 && !matchesFilter; const opacity = isFiltered ? '0.2' : '1'; return `background: ${color}; opacity: ${opacity};`; } + /** + * Build a display label for a set of instructor assignments. + * Single instructor → "Name". Co-teaching → "Name (2), Name2 (1)". + * @param {Array<{instructorId: string, credits: number|null}>} assignments + * @param {number} courseCredits — fallback credit total for unspecified shares + * @returns {string} + */ + _instructorLabel(assignments, courseCredits) { + const d = this.#dataStore.data; + if (!assignments || assignments.length === 0) return ''; + const names = assignments.map(a => { + const instructor = d.instructors.find(i => i.id === a.instructorId); + const name = instructor ? instructor.name : 'Unknown'; + if (assignments.length === 1) return name; + const credits = a.credits == null ? courseCredits : a.credits; + return `${name} (${Helpers.formatCredits(credits)})`; + }); + return names.join(', '); + } + // ── Timeslot Manager ── renderTimeslotManager() { diff --git a/src/utils/Helpers.js b/src/utils/Helpers.js index ac13bf5..414644c 100644 --- a/src/utils/Helpers.js +++ b/src/utils/Helpers.js @@ -13,6 +13,75 @@ export class Helpers { return section ? `${courseId}::${section}` : courseId; } + /** + * Normalise a raw `courseInstructors[key]` value into an array of + * `{ instructorId, credits }` assignment entries. + * + * Accepts every historical shape: + * - a bare instructor id string (legacy single-instructor) + * - an array of id strings and/or `{ instructorId, credits }` objects + * - null / undefined (no assignment) + * + * A `credits` of null means "unspecified" — callers fall back to the + * course's total credits (which is correct for a lone instructor). + * @param {*} value + * @returns {Array<{ instructorId: string, credits: number|null }>} + */ + static normalizeInstructorAssignments(value) { + const toEntry = (item) => { + if (typeof item === 'string') { + return item ? { instructorId: item, credits: null } : null; + } + if (item && item.instructorId) { + const c = item.credits; + const credits = (c === null || c === undefined || c === '') ? null : Number(c); + return { instructorId: item.instructorId, credits: Number.isNaN(credits) ? null : credits }; + } + return null; + }; + + if (Array.isArray(value)) return value.map(toEntry).filter(Boolean); + const single = toEntry(value); + return single ? [single] : []; + } + + /** + * Return the normalised instructor assignments for a (course, section) + * pair in the given schedule. + * @param {object} scheduleData — the current per-schedule data object + * @param {string} courseId + * @param {string} section + * @returns {Array<{ instructorId: string, credits: number|null }>} + */ + static getInstructorAssignments(scheduleData, courseId, section) { + const key = Helpers.getCourseInstructorKey(courseId, section); + return Helpers.normalizeInstructorAssignments(scheduleData.courseInstructors[key]); + } + + /** + * The "primary" instructor id for a (course, section) pair — the first + * assignment — used for grid colouring. Returns null when unassigned. + * @param {object} scheduleData + * @param {string} courseId + * @param {string} section + * @returns {string|null} + */ + static getPrimaryInstructorId(scheduleData, courseId, section) { + const assignments = Helpers.getInstructorAssignments(scheduleData, courseId, section); + return assignments.length > 0 ? assignments[0].instructorId : null; + } + + /** + * Format a (possibly fractional) credit value for display: at most two + * decimal places, trailing zeros stripped (3 → "3", 2.5 → "2.5"). + * @param {number} value + * @returns {string} + */ + static formatCredits(value) { + const n = Number(value) || 0; + return parseFloat(n.toFixed(2)).toString(); + } + /** * Build a human-readable display name: "PROGRAM NUMBER - Name" or fallback. * @param {object|null} course diff --git a/src/utils/Validation.js b/src/utils/Validation.js index 128ec26..62f6824 100644 --- a/src/utils/Validation.js +++ b/src/utils/Validation.js @@ -118,16 +118,16 @@ export class ValidationService { const instructorEntries = {}; entries.forEach(entry => { const course = d.courseCatalog.find(c => c.id === entry.courseId); - const instrKey = Helpers.getCourseInstructorKey(entry.courseId, entry.section); - const instructorId = sd.courseInstructors[instrKey] || null; - if (course && instructorId) { + if (!course) return; + const assignments = Helpers.getInstructorAssignments(sd, entry.courseId, entry.section); + const sectionSuffix = entry.section ? ` \u00a7${entry.section}` : ''; + assignments.forEach(({ instructorId }) => { if (!instructorEntries[instructorId]) instructorEntries[instructorId] = []; - const sectionSuffix = entry.section ? ` \u00a7${entry.section}` : ''; instructorEntries[instructorId].push({ ...entry, courseName: Helpers.getCourseDisplayName(course, d.programs) + sectionSuffix, }); - } + }); }); for (const instructorId in instructorEntries) { diff --git a/styles.css b/styles.css index 5266885..efd973e 100644 --- a/styles.css +++ b/styles.css @@ -1317,4 +1317,64 @@ input:focus, select:focus { font-size: 13px; color: var(--color-text-muted2); font-weight: 500; -} \ No newline at end of file +} +/* ── Instructor & credit-split rows (Course Edit modal) ── */ +.instructor-rows { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 8px; +} + +.instructor-row { + display: flex; + align-items: center; + gap: 8px; +} + +.instructor-row .instructor-row-select { + flex: 1 1 auto; + min-width: 0; +} + +.instructor-row .instructor-row-credits { + flex: 0 0 90px; + width: 90px; +} + +.instructor-row-remove { + flex: 0 0 auto; + background: var(--color-danger); + color: #fff; + border: none; + border-radius: 4px; + width: 28px; + height: 28px; + font-size: 18px; + line-height: 1; + cursor: pointer; +} + +.instructor-row-remove:hover { + background: var(--color-danger-hover); +} + +.add-instructor-row-btn { + font-size: 13px; + padding: 6px 12px; +} + +.credits-summary { + margin-top: 8px; + font-size: 13px; + font-weight: 600; + color: var(--color-muted); +} + +.credits-summary-ok { + color: var(--color-success); +} + +.credits-summary-error { + color: var(--color-danger); +} diff --git a/tests/e2e/courses.spec.js b/tests/e2e/courses.spec.js index a21d026..684d384 100644 --- a/tests/e2e/courses.spec.js +++ b/tests/e2e/courses.spec.js @@ -74,3 +74,74 @@ test.describe('Course management', () => { await expect(page.locator('.course-item')).toContainText('CPW Course'); }); }); + +test.describe('Co-teaching and credit splits', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + }); + + /** Add an instructor via the sidebar form. */ + async function addInstructor(page, name) { + await page.fill('#instructorName', name); + await page.click('#addInstructorForm button[type="submit"]'); + } + + test('supports a fractional course credit value', async ({ page }) => { + await addCourse(page, { name: 'Seminar', credits: '2.5' }); + await expect(page.locator('.course-item')).toContainText('2.5 credits'); + }); + + test('splits credits between two co-teaching instructors', async ({ page }) => { + await addInstructor(page, 'Alice'); + await addInstructor(page, 'Bob'); + await addCourse(page, { name: 'Physics', credits: '3' }); + + await page.locator('.course-item').first().dblclick(); + await expect(page.locator('#courseModal')).toBeVisible(); + + // First instructor row + await page.click('[data-action="add-instructor-row"]'); + const row1 = page.locator('#editInstructorRows .instructor-row').nth(0); + await row1.locator('.instructor-row-select').selectOption({ label: 'Alice' }); + await row1.locator('.instructor-row-credits').fill('2'); + + // Second instructor row + await page.click('[data-action="add-instructor-row"]'); + const row2 = page.locator('#editInstructorRows .instructor-row').nth(1); + await row2.locator('.instructor-row-select').selectOption({ label: 'Bob' }); + await row2.locator('.instructor-row-credits').fill('1'); + + await expect(page.locator('#instructorCreditsSummary')).toHaveText(/Assigned 3 of 3 credits/); + + await page.locator('#editCourseForm button[type="submit"]').click(); + await expect(page.locator('#courseModal')).toBeHidden(); + + // Sidebar reflects both instructors with their shares + await expect(page.locator('.course-item')).toContainText('Alice (2), Bob (1)'); + }); + + test('blocks a save when instructor credits do not total the course credits', async ({ page }) => { + await addInstructor(page, 'Alice'); + await addInstructor(page, 'Bob'); + await addCourse(page, { name: 'Physics', credits: '3' }); + + await page.locator('.course-item').first().dblclick(); + + await page.click('[data-action="add-instructor-row"]'); + const row1 = page.locator('#editInstructorRows .instructor-row').nth(0); + await row1.locator('.instructor-row-select').selectOption({ label: 'Alice' }); + await row1.locator('.instructor-row-credits').fill('2'); + + await page.click('[data-action="add-instructor-row"]'); + const row2 = page.locator('#editInstructorRows .instructor-row').nth(1); + await row2.locator('.instructor-row-select').selectOption({ label: 'Bob' }); + await row2.locator('.instructor-row-credits').fill('2'); // 2 + 2 = 4 ≠ 3 + + // The mismatch alert is dismissed and the modal stays open (save blocked) + page.once('dialog', dialog => dialog.accept()); + await page.locator('#editCourseForm button[type="submit"]').click(); + await expect(page.locator('#courseModal')).toBeVisible(); + }); +}); diff --git a/tests/unit/dataStore.test.js b/tests/unit/dataStore.test.js index d2b98df..3bb04f5 100644 --- a/tests/unit/dataStore.test.js +++ b/tests/unit/dataStore.test.js @@ -72,6 +72,38 @@ describe('DataStore', () => { expect(() => ds.load()).not.toThrow(); }); + it('migrates a legacy string instructor assignment to a credit-split array', () => { + ds.data.courseCatalog.push({ id: 'c1', name: 'Math', credits: 3, programId: null, courseNumber: '', quartersOffered: [] }); + const sd = ds.getCurrentSchedule(); + sd.courseInstructors['c1'] = 'i1'; // legacy single-instructor shape + sd.courseInstructors['c2::A'] = 'i2'; // legacy sectioned shape (unknown course) + ds.save(); + + const ds2 = new DataStore(); + ds2.load(); + const reloaded = ds2.getCurrentSchedule(); + expect(reloaded.courseInstructors['c1']).toEqual([{ instructorId: 'i1', credits: 3 }]); + // Unknown course → credits fall back to null + expect(reloaded.courseInstructors['c2::A']).toEqual([{ instructorId: 'i2', credits: null }]); + }); + + it('leaves an already-migrated array assignment untouched', () => { + ds.data.courseCatalog.push({ id: 'c1', name: 'Math', credits: 3, programId: null, courseNumber: '', quartersOffered: [] }); + const sd = ds.getCurrentSchedule(); + sd.courseInstructors['c1'] = [ + { instructorId: 'i1', credits: 1.5 }, + { instructorId: 'i2', credits: 1.5 }, + ]; + ds.save(); + + const ds2 = new DataStore(); + ds2.load(); + expect(ds2.getCurrentSchedule().courseInstructors['c1']).toEqual([ + { instructorId: 'i1', credits: 1.5 }, + { instructorId: 'i2', credits: 1.5 }, + ]); + }); + it('preserves currentSchedule after reload', () => { ds.data.schedules['Fall 2026'] = ds.createEmptySchedule('Fall'); ds.data.currentSchedule = 'Fall 2026'; diff --git a/tests/unit/helpers.test.js b/tests/unit/helpers.test.js index 1883559..07b29cd 100644 --- a/tests/unit/helpers.test.js +++ b/tests/unit/helpers.test.js @@ -15,6 +15,94 @@ describe('Helpers', () => { }); }); + describe('normalizeInstructorAssignments', () => { + it('returns [] for null/undefined/empty', () => { + expect(Helpers.normalizeInstructorAssignments(null)).toEqual([]); + expect(Helpers.normalizeInstructorAssignments(undefined)).toEqual([]); + expect(Helpers.normalizeInstructorAssignments('')).toEqual([]); + }); + + it('wraps a legacy id string with unspecified credits', () => { + expect(Helpers.normalizeInstructorAssignments('i1')).toEqual([ + { instructorId: 'i1', credits: null }, + ]); + }); + + it('normalizes an array of {instructorId, credits} entries', () => { + expect(Helpers.normalizeInstructorAssignments([ + { instructorId: 'i1', credits: 2 }, + { instructorId: 'i2', credits: 1.5 }, + ])).toEqual([ + { instructorId: 'i1', credits: 2 }, + { instructorId: 'i2', credits: 1.5 }, + ]); + }); + + it('coerces string credits to numbers and blanks to null', () => { + expect(Helpers.normalizeInstructorAssignments([ + { instructorId: 'i1', credits: '2.5' }, + { instructorId: 'i2', credits: '' }, + ])).toEqual([ + { instructorId: 'i1', credits: 2.5 }, + { instructorId: 'i2', credits: null }, + ]); + }); + + it('drops entries without an instructorId', () => { + expect(Helpers.normalizeInstructorAssignments([ + { instructorId: '', credits: 2 }, + 'i2', + ])).toEqual([{ instructorId: 'i2', credits: null }]); + }); + }); + + describe('getInstructorAssignments / getPrimaryInstructorId', () => { + const scheduleData = { + courseInstructors: { + c1: [{ instructorId: 'i1', credits: 2 }, { instructorId: 'i2', credits: 1 }], + 'c2::A': 'iLegacy', + }, + }; + + it('reads array assignments for a bare course key', () => { + expect(Helpers.getInstructorAssignments(scheduleData, 'c1', '')).toEqual([ + { instructorId: 'i1', credits: 2 }, + { instructorId: 'i2', credits: 1 }, + ]); + }); + + it('reads legacy string assignments for a sectioned key', () => { + expect(Helpers.getInstructorAssignments(scheduleData, 'c2', 'A')).toEqual([ + { instructorId: 'iLegacy', credits: null }, + ]); + }); + + it('returns the first instructor id as primary', () => { + expect(Helpers.getPrimaryInstructorId(scheduleData, 'c1', '')).toBe('i1'); + }); + + it('returns null primary when unassigned', () => { + expect(Helpers.getPrimaryInstructorId(scheduleData, 'missing', '')).toBeNull(); + }); + }); + + describe('formatCredits', () => { + it('renders whole numbers without decimals', () => { + expect(Helpers.formatCredits(3)).toBe('3'); + expect(Helpers.formatCredits(3.0)).toBe('3'); + }); + + it('renders fractional values, trimming trailing zeros', () => { + expect(Helpers.formatCredits(2.5)).toBe('2.5'); + expect(Helpers.formatCredits(1.25)).toBe('1.25'); + expect(Helpers.formatCredits(1.5)).toBe('1.5'); + }); + + it('avoids floating point noise', () => { + expect(Helpers.formatCredits(0.1 + 0.2)).toBe('0.3'); + }); + }); + describe('getCourseDisplayName', () => { const programs = [{ id: 'p1', name: 'CPW' }]; diff --git a/tests/unit/managers/instructorManager.test.js b/tests/unit/managers/instructorManager.test.js index 10b8864..2f4b74b 100644 --- a/tests/unit/managers/instructorManager.test.js +++ b/tests/unit/managers/instructorManager.test.js @@ -70,6 +70,19 @@ describe('InstructorManager', () => { expect(ds.data.instructors).toHaveLength(1); }); + it('does NOT remove a co-teaching instructor (array assignment)', () => { + ds.data.instructors.push({ id: 'i1', name: 'Prof One', color: '#fff' }); + ds.data.instructors.push({ id: 'i2', name: 'Prof Two', color: '#fff' }); + const sd = ds.getCurrentSchedule(); + sd.courseInstructors['course1'] = [ + { instructorId: 'i1', credits: 2 }, + { instructorId: 'i2', credits: 1 }, + ]; + vi.spyOn(window, 'alert').mockImplementation(() => {}); + manager.remove('i2'); + expect(ds.data.instructors).toHaveLength(2); + }); + it('calls onUpdate after removing', () => { ds.data.instructors.push({ id: 'i1', name: 'To Remove', color: '#fff' }); manager.remove('i1'); @@ -109,6 +122,55 @@ describe('InstructorManager', () => { expect(manager.getWorkload('i1')).toBe(7); }); + it('counts only an instructor\'s share of a co-taught course', () => { + ds.data.instructors.push({ id: 'i1', name: 'Prof One', color: '#fff' }); + ds.data.instructors.push({ id: 'i2', name: 'Prof Two', color: '#fff' }); + ds.data.courseCatalog.push({ id: 'c1', credits: 3 }); + const sd = ds.getCurrentSchedule(); + sd.courseInstructors['c1'] = [ + { instructorId: 'i1', credits: 2 }, + { instructorId: 'i2', credits: 1 }, + ]; + const roomId = 'r1'; + sd.classrooms.push({ id: roomId, roomNumber: '101', visible: true }); + sd.schedule[roomId] = { + Monday: { '09:00-10:00': [{ courseId: 'c1', modality: 'in-person', section: '' }] }, + }; + expect(manager.getWorkload('i1')).toBe(2); + expect(manager.getWorkload('i2')).toBe(1); + }); + + it('supports fractional credit splits', () => { + ds.data.instructors.push({ id: 'i1', name: 'Prof One', color: '#fff' }); + ds.data.instructors.push({ id: 'i2', name: 'Prof Two', color: '#fff' }); + ds.data.courseCatalog.push({ id: 'c1', credits: 3 }); + const sd = ds.getCurrentSchedule(); + sd.courseInstructors['c1'] = [ + { instructorId: 'i1', credits: 1.5 }, + { instructorId: 'i2', credits: 1.5 }, + ]; + const roomId = 'r1'; + sd.classrooms.push({ id: roomId, roomNumber: '101', visible: true }); + sd.schedule[roomId] = { + Monday: { '09:00-10:00': [{ courseId: 'c1', modality: 'in-person', section: '' }] }, + }; + expect(manager.getWorkload('i1')).toBe(1.5); + expect(manager.getWorkload('i2')).toBe(1.5); + }); + + it('falls back to full course credits for a legacy string assignment', () => { + ds.data.instructors.push({ id: 'i1', name: 'Prof', color: '#fff' }); + ds.data.courseCatalog.push({ id: 'c1', credits: 4 }); + const sd = ds.getCurrentSchedule(); + sd.courseInstructors['c1'] = 'i1'; // legacy shape + const roomId = 'r1'; + sd.classrooms.push({ id: roomId, roomNumber: '101', visible: true }); + sd.schedule[roomId] = { + Monday: { '09:00-10:00': [{ courseId: 'c1', modality: 'in-person', section: '' }] }, + }; + expect(manager.getWorkload('i1')).toBe(4); + }); + it('does not double-count the same course scheduled multiple times', () => { ds.data.instructors.push({ id: 'i1', name: 'Prof', color: '#fff' }); ds.data.courseCatalog.push({ id: 'c1', credits: 3 });