From e5a825ae3366776e4b5082fd168f004e12a4e270 Mon Sep 17 00:00:00 2001 From: Joe-Ortiz <7156063+Joe-Ortiz@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:57:11 -0700 Subject: [PATCH] feat: Co-teaching and partial credits (#20) Allow multiple instructors on a course, each assigned their own share of the course's credits, with fractional (floating-point) values supported. - Data model: per-schedule courseInstructors[key] becomes an array of { instructorId, credits } entries; a load-time migration converts legacy single-instructor strings (lone instructor inherits full course credits), and reads tolerate both shapes for backward compatibility. - Edit Course modal: dynamic instructor + credits rows with add/remove and a live "Assigned X of Y credits" indicator. Save enforces that the split totals the course credits and rejects duplicate instructors. - Workload sums each instructor's share; grid/sidebar show co-teachers and their splits; slot color uses the primary instructor; the instructor filter keeps a slot visible if any assigned instructor matches. - Validation flags overlaps per co-teaching instructor. - Course credits accept fractional values (step 0.5). Tested: 98 unit + 24 e2e passing (new coverage for the split math, normalization, migration, remove-protection, and modal flows). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + README.md | 4 +- index.html | 16 ++- src/app.js | 11 +- src/data/DataStore.js | 16 +++ src/managers/CourseManager.js | 4 +- src/managers/InstructorManager.js | 26 ++-- src/ui/CohortSummaryView.js | 4 +- src/ui/ModalManager.js | 124 ++++++++++++++++-- src/ui/Renderer.js | 49 +++++-- src/utils/Helpers.js | 69 ++++++++++ src/utils/Validation.js | 10 +- styles.css | 62 ++++++++- tests/e2e/courses.spec.js | 71 ++++++++++ tests/unit/dataStore.test.js | 32 +++++ tests/unit/helpers.test.js | 88 +++++++++++++ tests/unit/managers/instructorManager.test.js | 62 +++++++++ 17 files changed, 595 insertions(+), 56 deletions(-) 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

    • Courses are stored in a global catalog shared across all schedules
    • Instructor assignments are per-schedule and per-section — the same course can have different instructors in different sections and schedules
    • +
    • Co-teaching - Add multiple instructors to a course and split its credits between them; each instructor's workload counts only their share. Credits can be fractional (e.g. 1.5)
    • Instructors are stored globally and shared across all schedules, so you only need to add them once
    • Set Quarters Offered on courses to control which quarters they can be scheduled in
    • Courses with no quarters offered are treated as "on demand" and available any quarter
    • diff --git a/src/app.js b/src/app.js index 4875dd2..2c63e7f 100644 --- a/src/app.js +++ b/src/app.js @@ -232,7 +232,6 @@ class App { const courseNumber = document.getElementById('editCourseNumber').value.trim() || ''; const name = document.getElementById('editCourseName').value.trim(); const credits = document.getElementById('editCourseCredits').value; - const instructorId = document.getElementById('editCourseInstructor').value; const modality = document.getElementById('editModality').value || 'in-person'; const quarterTaken = document.getElementById('editCourseQuarterTaken')?.value.trim() || null; const quartersOffered = this.#uiState.getQuartersOfferedFromCheckboxes('edit'); @@ -240,7 +239,7 @@ class App { if (credits) { this.#modals.saveCourseChanges( - courseId, name, credits, instructorId || null, + courseId, name, credits, classroomId, day, timeslot, modality, courseIndex, quarterTaken, programId, courseNumber, quartersOffered, section ); @@ -358,6 +357,12 @@ class App { this.#uiState.updateInstructorFilter(instrId, btn.checked); break; } + case 'add-instructor-row': + this.#modals.addInstructorRow(); + break; + case 'remove-instructor-row': + this.#modals.removeInstructorRow(btn.closest('.instructor-row')); + break; } } @@ -434,6 +439,8 @@ class App { } else if (action === 'timeslot-edit-end') { const { day, ts, start } = el.dataset; this.#timeslotManager.edit(day, ts, start, el.value); + } else if (action === 'instructor-row-change' || action === 'course-credits-change') { + this.#modals.updateCreditsSummary(); } } } diff --git a/src/data/DataStore.js b/src/data/DataStore.js index f7a25ce..f0a4ceb 100644 --- a/src/data/DataStore.js +++ b/src/data/DataStore.js @@ -248,6 +248,22 @@ export class DataStore { if (sd.quarter === undefined) sd.quarter = ''; delete sd.instructors; + // Co-teaching migration: a legacy assignment was a bare instructor-id + // string. Convert to an array of { instructorId, credits } entries, the + // lone instructor taking the course's full credit total. + Object.keys(sd.courseInstructors).forEach(key => { + const value = sd.courseInstructors[key]; + if (Array.isArray(value)) return; + if (typeof value === 'string' && value) { + const courseId = key.includes('::') ? key.split('::')[0] : key; + const course = this.#data.courseCatalog.find(c => c.id === courseId); + const credits = course ? course.credits : null; + sd.courseInstructors[key] = [{ instructorId: value, credits }]; + } else { + delete sd.courseInstructors[key]; + } + }); + // Old per-schedule courses array → global catalog if (sd.courses) { sd.courses.forEach(course => { diff --git a/src/managers/CourseManager.js b/src/managers/CourseManager.js index cd93889..2726f1d 100644 --- a/src/managers/CourseManager.js +++ b/src/managers/CourseManager.js @@ -23,8 +23,8 @@ export class CourseManager { const creditsInput = document.getElementById('courseCredits'); const quarterInput = document.getElementById('courseQuarterTaken'); - const credits = parseInt(creditsInput.value); - if (!credits) return; + const credits = parseFloat(creditsInput.value); + if (!credits || credits <= 0) return; const quartersOffered = this.#uiState.getQuartersOfferedFromCheckboxes('add'); diff --git a/src/managers/InstructorManager.js b/src/managers/InstructorManager.js index 11b70b9..e8b5a83 100644 --- a/src/managers/InstructorManager.js +++ b/src/managers/InstructorManager.js @@ -34,7 +34,9 @@ export class InstructorManager { // Check ALL schedules for assignments (instructors are global) for (const [scheduleName, sd] of Object.entries(d.schedules)) { if (sd.courseInstructors) { - const hasAssignments = Object.values(sd.courseInstructors).some(iId => iId === id); + const hasAssignments = Object.values(sd.courseInstructors).some(value => + Helpers.normalizeInstructorAssignments(value).some(a => a.instructorId === id) + ); if (hasAssignments) { alert(`Cannot delete instructor with assigned courses in schedule "${scheduleName}". Unassign them first.`); return; @@ -51,27 +53,31 @@ export class InstructorManager { const d = this.#dataStore.data; const sd = this.#dataStore.getCurrentSchedule(); - // Collect all unique (courseId, section) keys that are in the grid - const scheduledPairs = new Set(); + // Collect all unique (courseId, section) pairs that are in the grid. + const scheduledPairs = new Map(); // key -> { courseId, section } for (const classroomId in d.schedule) { for (const day in d.schedule[classroomId]) { for (const time in d.schedule[classroomId][day]) { const slotData = d.schedule[classroomId][day][time]; const items = Array.isArray(slotData) ? slotData : (slotData ? [slotData] : []); items.forEach(item => { - scheduledPairs.add(Helpers.getCourseInstructorKey(item.courseId, item.section)); + const section = item.section || ''; + const key = Helpers.getCourseInstructorKey(item.courseId, section); + scheduledPairs.set(key, { courseId: item.courseId, section }); }); } } } - // Sum credits for unique pairs assigned to this instructor + // Sum this instructor's credit share across each unique scheduled pair. let total = 0; - scheduledPairs.forEach(key => { - if (sd.courseInstructors[key] === instructorId) { - const courseId = key.includes('::') ? key.split('::')[0] : key; - const course = d.courseCatalog.find(c => c.id === courseId); - if (course) total += course.credits; + scheduledPairs.forEach(({ courseId, section }) => { + const course = d.courseCatalog.find(c => c.id === courseId); + if (!course) return; + const assignment = Helpers.getInstructorAssignments(sd, courseId, section) + .find(a => a.instructorId === instructorId); + if (assignment) { + total += assignment.credits == null ? course.credits : assignment.credits; } }); return total; diff --git a/src/ui/CohortSummaryView.js b/src/ui/CohortSummaryView.js index edbba4a..34476e3 100644 --- a/src/ui/CohortSummaryView.js +++ b/src/ui/CohortSummaryView.js @@ -127,7 +127,7 @@ export class CohortSummaryView { return `
      ${cls.name}
      -
      ${cls.credits} credits
      +
      ${Helpers.formatCredits(cls.credits)} credits
      ${scheduleHTML}
      `; @@ -138,7 +138,7 @@ export class CohortSummaryView {

      Cohort: ${cohort.name}

      Days per week: ${daysPerWeek > 0 ? daysPerWeek : 'N/A'} - Total credits: ${totalCredits} + Total credits: ${Helpers.formatCredits(totalCredits)}
      ${classesHTML}
    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 });