/********************************* * moduleyear.js *********************************/ /** * Fetch all relevant module rules from Dataverse for the given program, * program version, and intake date. * * @param {string} programId - The GUID of the program * @param {string} programVersionId - The GUID of the program version * @param {string|Date} selectedIntakeDate - e.g. "2025-02-17" or a Date * @returns {Promise} Array of rule records from edv_modulerules */ async function fetchModuleRules(programId, programVersionId, selectedIntakeDate) { let isoDate; // Convert selectedIntakeDate to YYYY-MM-DD (ISO date) if needed if (typeof selectedIntakeDate === 'string') { isoDate = selectedIntakeDate.substring(0, 10); } else { isoDate = new Date(selectedIntakeDate).toISOString().split('T')[0]; } // We assume the user has a custom table `edv_modulerules` with fields: // - _edv_program_value // - _edv_programversion_value // - edv_startdate // - edv_enddate // - edv_ruletype (numeric choice) // - edv_oldmodules (JSON string) // - edv_newmodules (JSON string) // - statecode eq 0 => Active // // If no startdate is on the record, it can be assumed to start from the intake date. // If no enddate, ignore. We handle that in the filter logic. // // The filter ensures: // (program = this program OR program is null) // (programversion = this program version OR programversion is null) // (startdate is null or <= isoDate) // (enddate is null or >= isoDate) // (statecode eq 0 => active) const filter = ` (_edv_program_value eq '${programId}' or _edv_program_value eq null) and (_edv_programversion_value eq '${programVersionId}' or _edv_programversion_value eq null) and (edv_startdate eq null or edv_startdate le '${isoDate}') and (edv_enddate eq null or edv_enddate ge '${isoDate}') and (statecode eq 0) `.replace(/\s+/g, ' ').trim(); const selectFields = [ 'edv_modulerulename', 'edv_ruletype', // numeric choice field 'edv_oldmodules', // JSON 'edv_newmodules', // JSON '_edv_program_value', // references program '_edv_programversion_value', // references program version 'edv_startdate', 'edv_enddate' ].join(','); const url = `https://www.vossie.net/_api/edv_modulerules?$select=${selectFields}&$filter=${filter}`; try { const response = await fetch(url); const data = await response.json(); return data.value || []; } catch (err) { console.error("Error fetching module rules:", err); // In production you might return an empty array or re-throw: return []; } } /** * removeModulesFromQualProgData - removes modules from qualProgData matching the root codes in moduleCodes. * @param {Array} qualProgData - array of module objects for the student * @param {Array} moduleCodes - array of module code strings to remove */ function removeModulesFromQualProgData(qualProgData, moduleCodes) { moduleCodes.forEach(oldCode => { const root = getRootCode(oldCode); for (let i = qualProgData.length - 1; i >= 0; i--) { if (getRootCode(qualProgData[i].ModuleCode) === root) { qualProgData.splice(i, 1); } } }); } /** * ensureModuleIsPresent - adds or updates a module in qualProgData * @param {Array} qualProgData * @param {String} moduleCode * @param {String} [status="OUTSTANDING"] * @param {Object} qualPlannerData - optional planner data to find details */ function ensureModuleIsPresent(qualProgData, moduleCode, status = "OUTSTANDING", qualPlannerData) { const root = getNormalizedRootCode(moduleCode); let existing = qualProgData.find(m => getNormalizedRootCode(m.ModuleCode) === root); if (!existing) { // Try to find more details from qualPlannerData let plannerModule = null; if (qualPlannerData && qualPlannerData.results) { plannerModule = qualPlannerData.results.find(m => { const mc = m.ModuleCode || m.ProductCode || m.ProductNumber || ""; return getNormalizedRootCode(mc) === root; }); } if (plannerModule) { qualProgData.push({ ModuleCode: root, ModuleStatus: status, Name: plannerModule.Name, Credits: Number(plannerModule.Credits) || 0, Type: plannerModule.Type || 'Core', Year: plannerModule.Year || 1, YearOfUnderTaking: plannerModule.YearOfUnderTaking || "2024", ModulePeriod: plannerModule.ModulePeriod || '99', ProductCode: plannerModule.ProductCode, ProductId: plannerModule.ProductId, ProductNumber: plannerModule.ProductNumber, Elective: plannerModule.Elective ? plannerModule.Elective.toString() : "false", ModuleClass: plannerModule.ModuleClass, ModuleType: plannerModule.ModuleType }); } else { // fallback minimal qualProgData.push({ ModuleCode: root, ModuleStatus: status, Name: root, Credits: 0, Type: 'Core', Year: 1, YearOfUnderTaking: "2024" }); } } else { const oldStatus = (existing.ModuleStatus || "OUTSTANDING").toUpperCase(); const newStatus = (status || "OUTSTANDING").toUpperCase(); const rank = s => ["OUTSTANDING", "FAILED", "PASSED", "CREDIT", "COMPETENT", "CREDIT EXEMPTION"].indexOf(s); if (rank(newStatus) > rank(oldStatus)) { existing.ModuleStatus = newStatus; } } } function setModuleElectiveFlag(qualProgData, moduleCode, electiveFlag) { const root = getRootCode(moduleCode); const m = qualProgData.find(m => getRootCode(m.ModuleCode) === root); if (m) m.Elective = electiveFlag ? "true" : "false"; } /** * studentPassedAll - checks if all modules in moduleCodes are "passed" or "credit" * @param {Array} moduleCodes * @param {Map} moduleStatusMap - maps uppercase code => status */ function studentPassedAll(moduleCodes, moduleStatusMap) { return moduleCodes.every(c => { const key = getRootCode(String(c).toUpperCase()); const status = moduleStatusMap.get(key) || "OUTSTANDING"; return ["PASSED", "CREDIT", "COMPETENT", "CREDIT EXEMPTION"].includes(status); }); } function studentPassedAny(moduleCodes, moduleStatusMap) { return moduleCodes.some(c => { const key = getRootCode(String(c).toUpperCase()); const status = moduleStatusMap.get(key) || "OUTSTANDING"; return ["PASSED", "CREDIT", "COMPETENT", "CREDIT EXEMPTION"].includes(status); }); } /** * studentFailedOrNotAttempted - checks if a single module is "FAILED" or "OUTSTANDING" * @param {String} moduleCode * @param {Map} moduleStatusMap */ function studentFailedOrNotAttempted(moduleCode, moduleStatusMap) { const key = getRootCode(String(moduleCode).toUpperCase()); const status = moduleStatusMap.get(key) || "OUTSTANDING"; return (status === "FAILED" || status === "OUTSTANDING"); } function studentFailedOnly(moduleCode, moduleStatusMap) { const key = getRootCode(String(moduleCode).toUpperCase()); const status = moduleStatusMap.get(key) || "OUTSTANDING"; return status === "FAILED"; } /** * getRootCode - extracts the root code from a dash-delimited code e.g. "SCAPA2-B11" => "SCAPA2" * @param {String} moduleCode */ function getRootCode(moduleCode) { if (!moduleCode || typeof moduleCode !== 'string') return ""; if (!moduleCode.includes('-')) return moduleCode.trim().toUpperCase(); return moduleCode.split('-')[0].trim().toUpperCase(); } function getNormalizedRootCode(code) { const root = getRootCode(String(code || "")).trim().toUpperCase(); if (!root) return ""; if (root.startsWith("EDUV") && root.length > 4) return root.substring(4); return root; } function getModuleYear(moduleCode) { const match = moduleCode.match(new RegExp("(\\d+)-[A-Za-z]", "i")); if (match) return parseInt(match[1], 10); const baseMatch = moduleCode.match(new RegExp("(\\d+)[^\\d]*$")); return baseMatch ? parseInt(baseMatch[1], 10) : 1; } function deriveYear(obj) { const yField = Number(obj && obj.Year); if (Number.isFinite(yField) && yField > 0) return yField; const yUnd = Number(obj && obj.YearOfUnderTaking); if (Number.isFinite(yUnd) && yUnd > 0 && yUnd < 50) return yUnd; const pc = String((obj && (obj.ProductCode || obj.ProductNumber || obj.ModuleCode)) || ""); const m = pc.match(/(\d)(?=-[A-Za-z])/); if (m) return Number(m[1]); const base = pc.split("-")[0]; const yFromBase = getModuleYear(base); return Number.isFinite(yFromBase) ? yFromBase : 0; } function getAllowedIntakeYearsWindow() { const list = (intakeDict && intakeDict.results) ? intakeDict.results.slice() : []; if (!list.length) return null; list.sort((a, b) => { const da = a.Date ? new Date(a.Date) : null; const db = b.Date ? new Date(b.Date) : null; if (da && db && da.getTime() !== db.getTime()) return da - db; if (da && !db) return -1; if (!da && db) return 1; const ya = Number(a.Year), yb = Number(b.Year); if (Number.isFinite(ya) && Number.isFinite(yb) && ya !== yb) return ya - yb; return 0; }); let startIndex = 0; try { const selId = typeof $ === "function" ? $("#btfh_intake").val() : null; if (selId) { const idx = list.findIndex(r => (r.Id && r.Id === selId) || (r.IntakeId && r.IntakeId === selId) || (r.Intake && r.Intake === selId) ); if (idx >= 0) startIndex = idx; } } catch (e) { } const windowIntakes = list.slice(startIndex, startIndex + 4); const years = new Set(); windowIntakes.forEach(r => { const y = Number(r.Year); if (Number.isFinite(y) && y >= 1900) years.add(y); }); return years.size ? years : null; } function parseModulesField(raw) { if (!raw) return []; if (Array.isArray(raw)) return raw.map(s => String(s).trim().toUpperCase()).filter(Boolean); const text = String(raw).trim(); if (!text) return []; try { const parsed = JSON.parse(text); if (Array.isArray(parsed)) { return parsed.map(s => String(s).trim().toUpperCase()).filter(Boolean); } } catch (e) {} const cleaned = text.replace(/[\[\]()]/g, ''); return cleaned .split(/[,+;]+/) .map(s => s.trim().toUpperCase()) .filter(Boolean); } function getModuleType(ModuleCode, qualPlannerData) { const order = { Core: 1, Elective: 2, Repeat: 3, Optional: 4, None: 5 }; const mods = (qualPlannerData && qualPlannerData.results ? qualPlannerData.results : []) .filter(m => (m.ProductCode || "").startsWith(ModuleCode)); mods.sort((a, b) => (order[(a.Type || '').trim()] || 99) - (order[(b.Type || '').trim()] || 99)); return mods.length > 0 ? (mods[0].Type || "Core") : "Core"; } function getModuleClass(ModuleCode, qualPlannerData) { const priority = { Core: 1, Elective: 2, Repeat: 3 }; const mods = (qualPlannerData && qualPlannerData.results ? qualPlannerData.results : []) .filter(m => (m.ProductCode || "").startsWith(ModuleCode)); mods.sort((a, b) => (priority[a.Type] || 99) - (priority[b.Type] || 99)); return mods.length > 0 ? (mods[0].ModuleClass || "") : ""; } function getRepeat(ModuleCode, qualProgInput) { const src = (qualProgInput && qualProgInput.results) ? qualProgInput.results : []; const mod = src.find(m => (m.ProductCode || "").startsWith(ModuleCode)); return mod ? mod.Repeat === 'true' : 'false'; } function getElective(ModuleCode, qualProgInput) { const src = (qualProgInput && qualProgInput.results) ? qualProgInput.results : []; const mod = src.find(m => (m.ProductCode || "").startsWith(ModuleCode)); return mod ? mod.Elective === 'true' : 'false'; } function isCharInt(c) { return !isNaN(parseInt(c, 10)); } /** * applyOldToNewModuleRules - queries the edv_modulerules DB, merges them with the student's plan, * and modifies qualProgData according to the rule type. * * @param {Array} qualProgData - the modules in student's plan * @param {Object} courseHistoryData - the student's course history * @param {Object} qualPlannerData - the qualification planner (with .results array) * @param {String} programId - the GUID of the program * @param {String} programVersionId - the GUID of the program version * @param {String|Date} selectedIntakeDate - e.g. "2025-02-17" or a Date */ async function applyOldToNewModuleRules( qualProgData, courseHistoryData, qualPlannerData, programId, programVersionId, selectedIntakeDate ) { let moduleRules = []; try { moduleRules = await fetchModuleRules(programId, programVersionId, selectedIntakeDate); } catch (e) { console.error("Failed to fetch module rules:", e); // If we fail to get rules, we won't apply anything; just return return; } const appliedRulesLog = []; const equivalenceMap = new Map(); // Apply generic rules first, then program/version-specific rules so specifics can override moduleRules.sort((a, b) => { const aSpec = (a._edv_program_value ? 1 : 0) + (a._edv_programversion_value ? 1 : 0); const bSpec = (b._edv_program_value ? 1 : 0) + (b._edv_programversion_value ? 1 : 0); return aSpec - bSpec; }); // Build a status map from courseHistoryData let moduleStatusMap = new Map(); if (courseHistoryData && courseHistoryData.value) { courseHistoryData.value.forEach(course => { const fullCode = (course.mshied_CourseId.mshied_coursenumber || "").toUpperCase().trim(); const rootCode = getRootCode(fullCode); const status = (course["bt_modulestatus@OData.Community.Display.V1.FormattedValue"] || "OUTSTANDING").toUpperCase(); const existing = moduleStatusMap.get(rootCode); if (!existing) { moduleStatusMap.set(rootCode, status); } else { const rank = s => ["OUTSTANDING", "FAILED", "PASSED", "CREDIT", "COMPETENT", "CREDIT EXEMPTION"].indexOf(s); if (rank(status) > rank(existing)) { moduleStatusMap.set(rootCode, status); } } }); } // For each rule record from edv_modulerules for (let rule of moduleRules) { // rule.edv_ruletype is the numeric choice const ruleType = rule.edv_ruletype; let oldModules = []; let newModules = []; oldModules = parseModulesField(rule.edv_oldmodules); newModules = parseModulesField(rule.edv_newmodules); switch (ruleType) { case 948781000: // "All Must Pass then Exempt" { const oldRoots = oldModules.map(c => getRootCode(String(c).toUpperCase())).filter(Boolean); const newRoots = newModules.map(c => getRootCode(String(c).toUpperCase())).filter(Boolean); newRoots.forEach(nr => { let entry = equivalenceMap.get(nr); if (!entry) { entry = { type: ruleType, mode: "all", oldRoots: new Set() }; equivalenceMap.set(nr, entry); } oldRoots.forEach(or => entry.oldRoots.add(or)); }); const allOldPassed = studentPassedAll(oldModules, moduleStatusMap); if (allOldPassed) { const allCodes = [...oldModules, ...newModules]; removeModulesFromQualProgData(qualProgData, allCodes); } else { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData)); } appliedRulesLog.push({ type: ruleType, name: rule.edv_modulerulename, oldModules: oldModules.slice(), newModules: newModules.slice(), description: allOldPassed ? "All old modules passed; new modules exempted and removed." : "Old modules not all passed; new modules added as outstanding." }); } break; case 948781001: // "Failed Not Attempted Single" { const oldModule = oldModules[0]; const failedOrNotAttempted = studentFailedOrNotAttempted(oldModule, moduleStatusMap); if (failedOrNotAttempted) { removeModulesFromQualProgData(qualProgData, oldModules); ensureModuleIsPresent(qualProgData, newModules[0], "OUTSTANDING", qualPlannerData); } else { removeModulesFromQualProgData(qualProgData, oldModules); ensureModuleIsPresent(qualProgData, newModules[0], "PASSED", qualPlannerData); } appliedRulesLog.push({ type: ruleType, name: rule.edv_modulerulename, oldModules: oldModules.slice(), newModules: newModules.slice(), description: failedOrNotAttempted ? "Old module failed or not attempted; replacement set as outstanding." : "Old module not failed; replacement treated as passed." }); } break; case 948781002: // "Failed Not Attempted Choose Any" { const oldRoots = oldModules.map(c => getRootCode(String(c).toUpperCase())).filter(Boolean); const newRoots = newModules.map(c => getRootCode(String(c).toUpperCase())).filter(Boolean); newRoots.forEach(nr => { let entry = equivalenceMap.get(nr); if (!entry) { entry = { type: ruleType, mode: "any", oldRoots: new Set() }; equivalenceMap.set(nr, entry); } oldRoots.forEach(or => entry.oldRoots.add(or)); }); const anyOldFailedOrNotAttempted = oldModules.some(om => studentFailedOrNotAttempted(om, moduleStatusMap)); if (!anyOldFailedOrNotAttempted) { // Original module is not failed or outstanding; skip this rule for this student. break; } const combined = [...oldModules, ...newModules]; const requirementSatisfied = studentPassedAny(newModules, moduleStatusMap); if (requirementSatisfied) { const passedRoots = new Set(); combined.forEach(code => { const root = getRootCode(String(code).toUpperCase()); const status = moduleStatusMap.get(root) || "OUTSTANDING"; if (["PASSED", "CREDIT", "COMPETENT", "CREDIT EXEMPTION"].includes(status)) { passedRoots.add(root); } }); const toRemove = []; combined.forEach(code => { const root = getRootCode(String(code).toUpperCase()); if (!passedRoots.has(root)) { toRemove.push(root); } }); if (toRemove.length > 0) { removeModulesFromQualProgData(qualProgData, toRemove); } appliedRulesLog.push({ type: ruleType, name: rule.edv_modulerulename, oldModules: oldModules.slice(), newModules: newModules.slice(), description: "Choose any rule satisfied; non-completed linked modules removed from outstanding requirements." }); } else { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => { ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData); setModuleElectiveFlag(qualProgData, nm, true); }); appliedRulesLog.push({ type: ruleType, name: rule.edv_modulerulename, oldModules: oldModules.slice(), newModules: newModules.slice(), description: "Original module not passed; alternatives added as elective options (choose any)." }); } } break; case 948781003: // "Failed Not Attempted Choose All" { const allPassed = studentPassedAll(oldModules, moduleStatusMap); if (!allPassed) { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData)); } else { removeModulesFromQualProgData(qualProgData, oldModules); } appliedRulesLog.push({ type: ruleType, name: rule.edv_modulerulename, oldModules: oldModules.slice(), newModules: newModules.slice(), description: allPassed ? "All original modules passed; replacements not required." : "Original modules not all passed; all replacements added as outstanding." }); } break; case 948781004: // "Module Name Change" { const allPassed = studentPassedAll(oldModules, moduleStatusMap); removeModulesFromQualProgData(qualProgData, oldModules); if (allPassed) { if (newModules[0]) { ensureModuleIsPresent(qualProgData, newModules[0], "PASSED", qualPlannerData); } } else { if (newModules[0]) { ensureModuleIsPresent(qualProgData, newModules[0], "OUTSTANDING", qualPlannerData); } } appliedRulesLog.push({ type: ruleType, name: rule.edv_modulerulename, oldModules: oldModules.slice(), newModules: newModules.slice(), description: allPassed ? "Module name change applied; new module recorded as passed." : "Module name change applied; new module recorded as outstanding." }); } break; case 948781005: // "Special Assessment" { const allPassed = studentPassedAll(oldModules, moduleStatusMap); removeModulesFromQualProgData(qualProgData, oldModules); if (!allPassed) { newModules.forEach(nm => { ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData); // ?? Need to set exempted setModuleElectiveFlag(qualProgData, nm, false); }); } appliedRulesLog.push({ type: ruleType, name: rule.edv_modulerulename, oldModules: oldModules.slice(), newModules: newModules.slice(), description: allPassed ? "Special assessment rule: originals already passed; no new assessment modules added." : "Special assessment rule: assessment modules added as outstanding." }); } break; case 948781006: // "All Must Pass Then Replace with New" { const allPassed = studentPassedAll(oldModules, moduleStatusMap); if (allPassed) { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "PASSED", qualPlannerData)); } else { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData)); } appliedRulesLog.push({ type: ruleType, name: rule.edv_modulerulename, oldModules: oldModules.slice(), newModules: newModules.slice(), description: allPassed ? "All old modules passed; replacements recorded as passed." : "Old modules not all passed; replacements added as outstanding." }); } break; case 948781007: // "Pre-Requisite Override" { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => { const isPassed = studentPassedAll([nm], moduleStatusMap); ensureModuleIsPresent(qualProgData, nm, isPassed ? "PASSED" : "OUTSTANDING", qualPlannerData); }); appliedRulesLog.push({ type: ruleType, name: rule.edv_modulerulename, oldModules: oldModules.slice(), newModules: newModules.slice(), description: "Pre-requisite override applied." }); } break; case 948781008: // "Attempted but Failed – Replace Theory" { // The logic is basically the same for these two: const oldModule = oldModules[0]; const failedOnly = studentFailedOnly(oldModule, moduleStatusMap); if (failedOnly) { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData)); } else { removeModulesFromQualProgData(qualProgData, oldModules); } appliedRulesLog.push({ type: ruleType, name: rule.edv_modulerulename, oldModules: oldModules.slice(), newModules: newModules.slice(), description: failedOnly ? "Theory component failed; replacement theory module added as outstanding." : "Theory component not failed; replacement theory module not required." }); } break; case 948781009: // "Attempted but Failed – Replace Practical" { // The logic is basically the same for these two: const oldModule = oldModules[0]; const failedOnly = studentFailedOnly(oldModule, moduleStatusMap); if (failedOnly) { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData)); } else { removeModulesFromQualProgData(qualProgData, oldModules); } appliedRulesLog.push({ type: ruleType, name: rule.edv_modulerulename, oldModules: oldModules.slice(), newModules: newModules.slice(), description: failedOnly ? "Practical component failed; replacement practical module added as outstanding." : "Practical component not failed; replacement practical module not required." }); } break; case 948781010: // "Other" default: // Possibly do nothing or custom fallback logic break; } } window._appliedModuleRulesLog = appliedRulesLog; window._moduleRuleEquivalences = equivalenceMap; } // Attach functions to the global window object for easy usage in Dynamics 365 portal, etc. window.fetchModuleRules = fetchModuleRules; window.removeModulesFromQualProgData = removeModulesFromQualProgData; window.ensureModuleIsPresent = ensureModuleIsPresent; window.studentPassedAll = studentPassedAll; window.studentFailedOrNotAttempted = studentFailedOrNotAttempted; window.getRootCode = getRootCode; window.getNormalizedRootCode = getNormalizedRootCode; window.applyOldToNewModuleRules = applyOldToNewModuleRules; window.getModuleYear = getModuleYear; window.deriveYear = deriveYear; window.getAllowedIntakeYearsWindow = getAllowedIntakeYearsWindow; window.getModuleType = getModuleType; window.getModuleClass = getModuleClass; window.getRepeat = getRepeat; window.getElective = getElective; window.isCharInt = isCharInt; window.getDisplayProductCode = getDisplayProductCode; window.getMaxCredits = getMaxCredits; window.gateModulesByYear = gateModulesByYear; window.renderModuleDescription = renderModuleDescription; window._mrGetMaxCredits = getMaxCredits; window._mrGateModulesByYear = gateModulesByYear; window._mrRenderModuleDescription = renderModuleDescription; function isCoreLike(m) { const t = (m.Type || m.ModuleType || "").toString().trim(); const cls = (m.ModuleClass || "").toString().trim().toUpperCase(); if ( t === "" || t === "Core" || t === "None" || t === "Elective" || t === "Elective Group" || t === "Optional" || t === null ) { return true; } if (cls === "FUNDAMENTAL") return true; return false; } function isSCodeModule(mod) { const cls = (mod.ModuleClass || "").toUpperCase().trim(); if (cls === "FUNDAMENTAL") return false; const c = (mod.ModuleCode || "").toUpperCase().trim(); const b = c.split("-")[0]; const m1 = /([A-Z])(\d+)$/.exec(b); if (m1 && m1[1] === "S") return true; const p = (mod.ProductCode || mod.ProductNumber || "").toUpperCase(); const segments = p.split("-"); for (const seg of segments) { const m = /([A-Z])(\d+)$/.exec(seg); if (m && m[1] === "S") return true; } return false; } function getYearFromCourse(c) { const full = (c && c.mshied_CourseId && c.mshied_CourseId.mshied_coursenumber) ? String(c.mshied_CourseId.mshied_coursenumber).trim() : ""; if (!full) return 0; const base = full.split("-")[0]; if (typeof getModuleYear === "function") { const y = getModuleYear(base); if (Number.isFinite(y) && y > 0 && y < 50) return y; } const m = base.match(/(\d+)[^\d]*$/); if (m) { const y2 = parseInt(m[1], 10); if (Number.isFinite(y2) && y2 > 0 && y2 < 50) return y2; } return 0; } function addModuleToQualProg(qualProgData, d) { if (d.ModuleStatus === "FAILED" || d.ModuleStatus === "PASSED") { qualProgData.push(d); } else if (!qualProgData.some(m => m.ModuleCode === d.ModuleCode)) { qualProgData.push(d); } } function getEquivalentsHelper(p) { if (typeof getEquivalents === "function") return getEquivalents(p); return [p]; } function isPassedStatus(s) { return ["PASSED", "CREDIT", "COMPETENT", "IN PROGRESS", "CREDIT EXEMPTION"].includes( (s || "").toUpperCase() ); } function isFailedStatus(s) { return s === "FAILED"; } function updateYearlyCredits(y, m, yearlyTotalCredits, yearlyMaxCredits, qualPlannerData, programVersionsDict) { const c = parseInt(m.Credits) || 0; const b = m.Type === "Core" || m.Type === "None" || m.Type === null || m.Type === "" || m.Type === "Elective" || m.Type === "Elective Group" || m.Type === "Optional"; if (b && m.Repeat !== "true") { const max = getMaxCredits(y, qualPlannerData, programVersionsDict) || 0; yearlyTotalCredits[y] = max || yearlyTotalCredits[y] || 0; yearlyMaxCredits[y] = max || c || 0; } } function getBestStatus(mC, moduleStatusMap) { const eq = getEquivalentsHelper(mC); return eq.reduce((best, code) => { const st = moduleStatusMap.get(code); if (!st) return best; if (isPassedStatus(st) && !isPassedStatus(best)) return st; if (isFailedStatus(st) && !best) return st; return best || st; }, null); } function isAdminModule(m) { const t = (m && (m.Type || m.ModuleType) || "").toString().toUpperCase().trim(); const cls = (m && m.ModuleClass || "").toString().toUpperCase().trim(); const code = (m && (m.ModuleCode || m.ProductCode || m.ProductNumber) || "").toString().toUpperCase().trim(); const name = (m && (m.Name || m.ProductName) || "").toString().toUpperCase().trim(); const isAdminFeeText = s => (!!s && ((s.includes("ADMIN") && s.includes("FEE")) || (s.includes("ADMISSION") && s.includes("FEE")))); if (isAdminFeeText(t) || isAdminFeeText(cls) || isAdminFeeText(name)) return true; if (code.startsWith("ADMIN")) return true; if (code.includes("ADMIN") && code.includes("FEE")) return true; return false; } function processCourseHistory(courseHistoryData, qualPlannerData, qualProgData, yearlyCoreCredits) { (courseHistoryData.value || []).forEach(cr => { const y = getYearFromCourse(cr); const baseModuleData = { Id: "", Name: cr.mshied_name, Repeat: getRepeat(cr.mshied_CourseId.mshied_coursenumber, qualPlannerData).toString(), Credits: cr.mshied_CourseId.bt_creditvalue || 0, YearOfOffering: cr.bt_year || 0, YearOfUnderTaking: cr[ "bt_yearofundertaking@OData.Community.Display.V1.FormattedValue" ], StartsInBlock: `Block ${parseInt( (cr.bt_Product ? cr.bt_Product["_msdyn_sharedproductdetailid_value@OData.Community.Display.V1.FormattedValue"] : "0" )?.slice(-2, -1) || "0", 10 )}`, ModulePeriod: parseInt( (cr.bt_Product ? cr.bt_Product["_msdyn_sharedproductdetailid_value@OData.Community.Display.V1.FormattedValue"] : "0" )?.slice(-2, -1) || "0", 10 ), ProductNumber: cr.bt_Product ? cr.bt_Product["_msdyn_sharedproductdetailid_value@OData.Community.Display.V1.FormattedValue"] : "", ProductId: cr.bt_Product ? cr.bt_Product.productid : null, ProductCode: cr.mshied_CourseId ? cr.mshied_CourseId.mshied_code : null, ProductName: cr.mshied_CourseId ? cr.mshied_CourseId["_bt_product_value@OData.Community.Display.V1.FormattedValue"] : "", Year: y, Elective: window .getElective(cr.mshied_CourseId.mshied_coursenumber, qualPlannerData) .toString(), Type: window.getModuleType(cr.mshied_CourseId.mshied_coursenumber, qualPlannerData), ModuleType: window.getModuleType(cr.mshied_CourseId.mshied_coursenumber, qualPlannerData), ModuleCode: cr.mshied_CourseId.mshied_coursenumber, ModuleClass: window.getModuleClass(cr.mshied_CourseId.mshied_coursenumber, qualPlannerData), ModuleStatus: cr[ "bt_modulestatus@OData.Community.Display.V1.FormattedValue" ] ? cr[ "bt_modulestatus@OData.Community.Display.V1.FormattedValue" ].toUpperCase() : null }; if ( (baseModuleData.Type === "Core" || baseModuleData.Type === "None" || baseModuleData.Type === null || baseModuleData.Type === "" || baseModuleData.Type === "Elective" || baseModuleData.Type === "Optional") && baseModuleData.Repeat !== "true" ) { if (baseModuleData.ModuleClass !== "Fundamental") { yearlyCoreCredits[y] = (yearlyCoreCredits[y] || 0) + baseModuleData.Credits; } } const mc = cr.mshied_CourseId.mshied_coursenumber; const all = getEquivalentsHelper(mc); all.forEach(eC => { (qualPlannerData.results || []).forEach(pM => { const tC = pM.ModuleCode || (pM.ProductCode || "").split("-")[0]; if (tC === eC) addModuleToQualProg(qualProgData, { ...baseModuleData, ModuleCode: eC }); }); }); }); } function computeCoreTotalsFromPlanner(planner) { const totals = {}; (planner.results || []).forEach(m => { if (typeof EXCLUDE_S_CODES !== "undefined" && EXCLUDE_S_CODES && isSCodeModule(m)) return; const yr = deriveYear(m); const credits = Number(m.Credits) || 0; const isCore = m.Type === "Core" || m.ModuleClass === "Fundamental"; if (isCore && m.Repeat !== "true") { totals[yr] = (totals[yr] || 0) + credits; } }); return totals; } function computeRequiredBreakdownFromPlanner(planner) { const breakdown = {}; (planner.results || []).forEach(m => { if (typeof EXCLUDE_S_CODES !== "undefined" && EXCLUDE_S_CODES && isSCodeModule(m)) return; const yr = deriveYear(m); const credits = Number(m.Credits) || 0; if (!credits) return; if (m.Repeat === "true") return; if (m.Type === "Optional") return; const isCore = m.Type === "Core" || m.ModuleClass === "Fundamental"; const isElective = m.Elective === "true" || m.Type === "Elective" || m.Type === "Elective Group"; let weightedCredits = 0; if (isCore) weightedCredits = credits; else if (isElective) weightedCredits = credits * 0.5; else return; if (!breakdown[yr]) breakdown[yr] = []; const fullCode = m.ProductCode || m.ModuleCode || ""; const root = fullCode.split("-")[0] || fullCode; breakdown[yr].push({ code: fullCode, root, credits, weightedCredits, isCore, isElective, name: m.Name || m.ProductName || "" }); }); return breakdown; } function isInProgressLikeStatus(status) { const u = (status || "").toUpperCase(); if (!u) return false; if (["REGISTERED", "IN PROGRESS", "CREDIT EXEMPTION"].includes(u)) return true; if (u.startsWith("SUP") || u.includes("SPECIAL ASSESSMENT")) return true; return false; } function getBaseModuleCode(mc) { return (mc || "").split("-")[0].toUpperCase().trim(); } function getDisplayProductCode(m) { if (!m) return m && m.ProductCode; var pc = m.ProductCode || ""; var pn = m.ProductNumber || ""; var mc = m.ModuleCode || ""; if (!pc && !pn && !mc) return ""; // Prefer existing internal ROOT-XYY codes (e.g. COAFA3-B11) from ModuleCode, // then from ProductNumber, then from ProductCode, instead of rewriting them // to ROOT-26 based on year. if (mc && /^[A-Za-z0-9]+-[A-Za-z]\d{2}$/.test(mc)) return mc; if (pn && /^[A-Za-z0-9]+-[A-Za-z]\d{2}$/.test(pn)) return pn; if (pc && /^[A-Za-z0-9]+-[A-Za-z]\d{2}$/.test(pc)) return pc; var src = pc || pn || mc; var base = src.split("-")[0], tail = ""; var mt = src.match(/-(\d{2})-([A-Za-z])$/); if (mt) tail = mt[2]; var yo = Number(m.YearOfOffering), yu = Number(m.YearOfUnderTaking); var sel = (window.getSelectedIntakeYear && window.getSelectedIntakeYear()) || new Date().getFullYear(); var yr; if (Number.isFinite(yo) && yo >= sel) { // Future or current modules: show year of offering yr = yo; } else if (Number.isFinite(yu) && yu < sel) { // Past modules: show year of undertaking when available yr = yu; } else if (Number.isFinite(yo)) { // Fallback to offering year if we have it yr = yo; } else if (Number.isFinite(yu)) { // Last resort: undertaking year yr = yu; } else { yr = sel; } var yy = String(yr % 100); if (yy.length < 2) yy = "0" + yy; return tail ? base + "-" + yy + "-" + tail : base + "-" + yy; } function getMaxCredits(year, qualPlannerData, programVersionsDict) { if (!qualPlannerData || !qualPlannerData.results) return 0; let total = 0; qualPlannerData.results.forEach(m => { if (typeof EXCLUDE_S_CODES !== "undefined" && EXCLUDE_S_CODES && isSCodeModule(m)) return; const modYear = deriveYear(m); if (modYear !== year) return; if (m.Repeat === "true") return; if (m.Type === "Optional") return; const credits = parseFloat(m.Credits) || 0; const isCore = m.Type === "Core" || m.ModuleClass === "Fundamental"; const isElective = String(m.Elective).toLowerCase() === "true"; if (isCore) total += credits; else if (isElective) total += credits * 0.5; }); total = Math.ceil(total); let foundValue = 0; if (programVersionsDict && Array.isArray(programVersionsDict.results)) { const found = programVersionsDict.results.find(pv => { const name = pv && pv.Name ? String(pv.Name) : ""; const match = name.match(/\d+/); const pvYear = match ? parseInt(match[0], 10) : (parseInt(name.slice(-1), 10) || 0); return pvYear === year; }); foundValue = found ? (parseInt(found.CreditValue, 10) || 0) : 0; } if (foundValue > 0) return total > 0 ? Math.min(total, foundValue) : foundValue; return total; } function gateModulesByYear(qualProgData, yearlyCreditsEarned, yearlyTotalCredits, yearlyCoreCredits, yearlyCoreCreditsEarned) { return (qualProgData || []).filter(m => { const code = m && (m.ModuleCode || m.ProductCode || m.ProductNumber); const base = String(code || "").split("-")[0]; const yr = getModuleYear(base); if (!Number.isFinite(yr) || yr <= 0) return false; if (yr <= 1) return true; const prev = yr - 1; const prevTot = (yearlyTotalCredits && yearlyTotalCredits[prev]) || 0; const prevEarned = (yearlyCreditsEarned && yearlyCreditsEarned[prev]) || 0; const prevPct = prevTot ? (prevEarned / prevTot) : 0; const prevCoreDone = ((yearlyCoreCreditsEarned && yearlyCoreCreditsEarned[prev]) || 0) >= ((yearlyCoreCredits && yearlyCoreCredits[prev]) || 0); return !(prevPct < 0.40 && !prevCoreDone); }); } function renderModuleDescription(module, yearCode, requirements, plannerData) { const req = requirements || {}; const co = req.coRequisiteModules || []; const pr = req.preRequisiteModules || []; const cos = co.filter(r => r && r.ModuleCode === (module && module.ModuleCode)); const prs = pr.filter(r => r && r.ModuleCode === (module && module.ModuleCode)); const getReqCode = r => { if (!r) return ""; if (r.requiredCourseCode) return r.requiredCourseCode; if (r.edv_RequiredCourse && r.edv_RequiredCourse.mshied_coursenumber) return r.edv_RequiredCourse.mshied_coursenumber; return ""; }; const coList = cos.length > 0 ? cos.map(getReqCode).filter(Boolean).join(", ") : "None"; const preList = prs.length > 0 ? prs.map(getReqCode).filter(Boolean).join(", ") : "None"; const tAbbrev = module && module.Type === "Core" ? "C" : "E"; const cAbbrev = module && module.ModuleClass ? module.ModuleClass[0].toUpperCase() : "U"; const yo = Number(module && module.YearOfOffering); const yu = Number(module && module.YearOfUnderTaking); const selY = (window.getSelectedIntakeYear && window.getSelectedIntakeYear()) || new Date().getFullYear(); let dispYear = selY; if (Number.isFinite(yo) && yo >= selY) dispYear = yo; else if (Number.isFinite(yu) && yu < selY) dispYear = yu; else if (Number.isFinite(yo)) dispYear = yo; else if (Number.isFinite(yu)) dispYear = yu; let yAbbrev = String(dispYear % 100); if (yAbbrev.length < 2) yAbbrev = "0" + yAbbrev; const sAbbrev = module && module.ModuleStatus ? module.ModuleStatus[0].toUpperCase() : "U"; const codeSpan = module && module.ModuleCode ? `${module.ModuleCode}` : ""; const compact = `${tAbbrev}${cAbbrev}-${yAbbrev}-${sAbbrev}`; const baseRoot = (typeof getBaseModuleCode === "function") ? getBaseModuleCode((module && (module.ProductCode || module.ModuleCode)) || "") : String((module && (module.ProductCode || module.ModuleCode)) || "").split("-")[0]; const planner = plannerData || (typeof qualPlannerData !== "undefined" ? qualPlannerData : null) || window._gradcheckPlannerData; let displaySource = module || {}; if (baseRoot && planner && Array.isArray(planner.results)) { const plannerMatch = planner.results.find(p => { const pRoot = (typeof getBaseModuleCode === "function") ? getBaseModuleCode((p && (p.ProductCode || p.ModuleCode)) || "") : String((p && (p.ProductCode || p.ModuleCode)) || "").split("-")[0]; return pRoot === baseRoot; }); if (plannerMatch) { displaySource = { ...plannerMatch, ModuleStatus: (module && module.ModuleStatus) || plannerMatch.ModuleStatus, Credits: (module && typeof module.Credits !== "undefined") ? module.Credits : plannerMatch.Credits, ModuleCode: plannerMatch.ModuleCode || baseRoot, ProductCode: plannerMatch.ProductCode || (module && module.ProductCode), ProductNumber: plannerMatch.ProductNumber || (module && module.ProductNumber), YearOfOffering: plannerMatch.YearOfOffering || (module && module.YearOfOffering), YearOfUnderTaking: plannerMatch.YearOfUnderTaking || (module && module.YearOfUnderTaking) }; } } const displayName = (displaySource && (displaySource.Name || displaySource.ProductName)) || (module && module.Name) || ""; const nameSpan = displayName ? `${displayName}` : `Unnamed Module`; const credSpan = module && module.Credits ? `Credits: ${module.Credits}` : ""; const dpc = (window.getDisplayProductCode && window.getDisplayProductCode(displaySource)) || (module && module.ProductCode); const pCodeSpan = dpc ? `${dpc}` : ""; const coReqSpan = coList !== "None" ? `Co-requisites: ${coList}` : ""; const preReqSpan = preList !== "None" ? `Pre-requisites: ${preList}` : ""; const parts = [codeSpan, compact, nameSpan, credSpan, pCodeSpan, coReqSpan, preReqSpan] .filter(Boolean) .join(' '); return `
${parts}
`; } function isPlanPreReqMet(mod, allNewMods, notMetMods, reqList, studentProgress) { const preReqs = (reqList.preRequisiteModules || []).filter(r => r.ModuleCode === mod.ModuleCode); if (!preReqs.length) return true; const recPeriod = Number(parseBlockNumberFromString(mod.ProductNumber)); if (!recPeriod) return false; return preReqs.every(pr => { const baseCode = getBaseModuleCode(pr.requiredCourseCode); const eqCodes = getEquivalents(baseCode); return eqCodes.some(eq => { const eqBase = getBaseModuleCode(eq); if (studentProgress.passedModules.has(eqBase)) return true; const hasFailed = studentProgress.failedModules.some( mm => getBaseModuleCode(mm.ModuleCode) === eqBase ); if (hasFailed) { const retakeMod = allNewMods.find( m2 => getBaseModuleCode(m2.ModuleCode) === eqBase && m2.ModuleStatus !== "PASSED" ); if (retakeMod) { const retakeBlock = parseBlockNumberFromString(retakeMod.ProductNumber); if (retakeBlock && retakeBlock < recPeriod) return true; return false; } return false; } const preMod = allNewMods.find(m2 => getBaseModuleCode(m2.ModuleCode) === eqBase); if (!preMod || notMetMods.includes(preMod)) return false; const pPeriod = parseBlockNumberFromString(preMod.ProductNumber); return pPeriod && pPeriod < recPeriod; }); }); } function isPlanCoReqMet(mod, allNewMods, notMetMods, reqList, studentProgress) { const coReqs = (reqList.coRequisiteModules || []).filter(r => r.ModuleCode === mod.ModuleCode); if (!coReqs.length) return true; const recPeriod = parseBlockNumberFromString(mod.ProductNumber); if (!recPeriod) return false; return coReqs.every(cr => { const coReqBase = getBaseModuleCode(cr.requiredCourseCode); const eqCodes = getEquivalents(coReqBase); return eqCodes.some(eq => { const eqBase = getBaseModuleCode(eq); if ( studentProgress.passedModules.has(eqBase) || studentProgress.failedModules.some(mm => getBaseModuleCode(mm.ModuleCode) === eqBase) ) { return true; } const coMod = allNewMods.find(m2 => getBaseModuleCode(m2.ModuleCode) === eqBase); if (!coMod || notMetMods.includes(coMod)) return false; const cPeriod = parseBlockNumberFromString(coMod.ProductNumber); return cPeriod && cPeriod <= recPeriod; }); }); } function removeModuleFromScheduleOnly(mod, allNewPeriodModules, outstandingElectiveModules, outstandingRepeaterModules) { const rm = arr => { const idx = arr.indexOf(mod); if (idx !== -1) arr.splice(idx, 1); }; rm(allNewPeriodModules); rm(outstandingElectiveModules); rm(outstandingRepeaterModules); } function removeAllThatFail(allMods, notMetMods, reqList, studentProgress, allNewPeriodModules, outstandingElectiveModules, outstandingRepeaterModules) { let changed = false; [...allMods].forEach(mod => { if (notMetMods.includes(mod)) return; if ( !isPlanPreReqMet(mod, allMods, notMetMods, reqList, studentProgress) || !isPlanCoReqMet(mod, allMods, notMetMods, reqList, studentProgress) ) { notMetMods.push(mod); removeModuleFromScheduleOnly( mod, allNewPeriodModules, outstandingElectiveModules, outstandingRepeaterModules ); changed = true; } }); return changed; } function reintroduceIfNowPass(allMods, notMetMods, reqList, studentProgress) { let changed = false; [...notMetMods].forEach(mod => { if ( isPlanPreReqMet(mod, allMods, notMetMods, reqList, studentProgress) && isPlanCoReqMet(mod, allMods, notMetMods, reqList, studentProgress) ) { notMetMods.splice(notMetMods.indexOf(mod), 1); allMods.push(mod); changed = true; } }); return changed; } function replaceElectivesRepeaters(allMods, outElective, outRepeater, plannerData) { let changed = false; outElective.forEach(e => { const er = getNormalizedRootCode(e && e.ModuleCode); const arr = (plannerData.results || []).filter(i => { const ic = i && (i.ModuleCode || i.ProductCode || i.ProductNumber); return getNormalizedRootCode(ic) === er; }); if (arr.length > 0) { arr.sort( (a, b) => (parseBlockNumberFromString(a.ProductNumber) || 99) - (parseBlockNumberFromString(b.ProductNumber) || 99) ); const target = arr[0]; if (target && e.ProductNumber !== target.ProductNumber) { e.ProductNumber = target.ProductNumber; e.ProductId = target.ProductId; e.ProductName = target.ProductName; e.ProductCode = target.ProductCode; e.Repeat = target.Repeat; e.ModulePeriod = target.ModulePeriod; e.YearOfUnderTaking = target.YearOfUnderTaking; changed = true; } } }); outRepeater.forEach(r => { const rr = getNormalizedRootCode(r && r.ModuleCode); const arr = (plannerData.results || []).filter(i => { const ic = i && (i.ModuleCode || i.ProductCode || i.ProductNumber); return getNormalizedRootCode(ic) === rr; }); if (arr.length > 0) { arr.sort( (a, b) => (parseBlockNumberFromString(a.ProductNumber) || 99) - (parseBlockNumberFromString(b.ProductNumber) || 99) ); const targ = arr[0]; if (targ && r.ProductNumber !== targ.ProductNumber) { r.ProductNumber = targ.ProductNumber; r.ProductName = targ.ProductName; r.ProductId = targ.ProductId; r.ProductCode = targ.ProductCode; r.Repeat = targ.Repeat; r.ModulePeriod = targ.ModulePeriod; r.YearOfUnderTaking = targ.YearOfUnderTaking; changed = true; } } }); return changed; } function finalizePlanApproachB(allMods, notMetMods, outElect, outRepeat, reqList, plannerData, studentProgress, allNewPeriodModules, outstandingElectiveModules, outstandingRepeaterModules) { let iterationCount = 0; while (true) { iterationCount++; const step1 = removeAllThatFail( allMods, notMetMods, reqList, studentProgress, allNewPeriodModules, outstandingElectiveModules, outstandingRepeaterModules ); const step2 = reintroduceIfNowPass(allMods, notMetMods, reqList, studentProgress); const step3 = replaceElectivesRepeaters(allMods, outElect, outRepeat, plannerData); if (!step1 && !step2 && !step3) break; if (iterationCount >= 5) { console.warn("finalizePlanApproachB limit."); break; } } }