async function analyzeCourseProgress(courseHistoryData, qualPlannerData, qualProgInput, fullCourseHistoryData) { let qualProgData = qualProgInput || []; let highestYearCompleted = 0, yearlyCreditsEarned = {}, yearlyCoreCreditsEarned = {}, yearlyCreditsRequired = {}, yearlyTotalCredits = {}, yearlyMaxCredits = {}, yearlyCoreCredits = {}, totalCreditsEarned = 0, passedCoreModules = [], remainingModules = [], extModules = [], maxCredits; const maxPlannerYear = qualPlannerData.results.reduce((mx, m) => { const yr = Number(deriveYear(m)); if (Number.isFinite(yr) && yr > 0 && yr < 50) return yr > mx ? yr : mx; const raw = (m && (m.ModuleCode || m.ProductNumber || m.ProductCode)) || ""; const base = String(raw || "").split("-")[0]; const y2 = (typeof getModuleYear === "function") ? Number(getModuleYear(base)) : NaN; return (Number.isFinite(y2) && y2 > mx && y2 > 0 && y2 < 50) ? y2 : mx; }, 0), maxCourseHistoryYear = courseHistoryData?.value?.reduce((mx, c) => Math.max(mx, getYearFromCourse(c)), 0) || 1; processCourseHistory(courseHistoryData, qualPlannerData, qualProgData, yearlyCoreCredits); for (let y = 1; y <= maxPlannerYear; y++) { yearlyTotalCredits[y] = getMaxCredits(y, qualPlannerData, programVersionsDict); yearlyMaxCredits[y] = yearlyTotalCredits[y]; } yearlyCoreCredits = computeCoreTotalsFromPlanner(qualPlannerData); const yearlyRequiredBreakdown = computeRequiredBreakdownFromPlanner(qualPlannerData); window._yearlyRequiredBreakdown = yearlyRequiredBreakdown; if (qualPlannerData && Array.isArray(qualPlannerData.results) && qualPlannerData.results.length) { qualPlannerData.results.forEach(m => { const tY = deriveYear(m); const root = (typeof getNormalizedRootCode === "function") ? getNormalizedRootCode(m.ModuleCode || m.ProductCode || m.ProductNumber) : String((m.ProductCode || "")).split("-")[0]; const moduleData = { Id: "", Name: m.Name, Repeat: m.Repeat?.toString() || "false", Credits: m.Credits || 0, YearOfOffering: m.YearOfOffering || 0, YearOfUnderTaking: m.YearOfUnderTaking, StartsInBlock: m.ProductNumber ? `Block ${m.ProductNumber.slice(-2, -1)}` : (m.StartsInBlock || ""), ModulePeriod: m.ProductNumber ? m.ProductNumber.slice(-2, -1) : (m.ModulePeriod || ""), ProductNumber: m.ProductNumber, ProductName: m.ProductName, ModuleCode: root, ProductId: m.ProductId, ProductCode: m.ProductCode, Year: tY, Elective: m.Elective?.toString() || "false", Type: m.Type, ModuleType: m.ModuleType, ModuleClass: m.ModuleClass, ModuleStatus: m.ModuleStatus }; const alreadyPresent = qualProgData.some(e => String(e && e.ModuleCode || "").toUpperCase() === String(root || "").toUpperCase()); if (["FAILED", "PASSED"].includes(m.ModuleStatus) || !alreadyPresent) { addModuleToQualProg(qualProgData, moduleData); } }); } function isFailedStatus(status) { return status && status.toUpperCase().includes("FAILED"); } function isPassedStatus(status) { return ["PASSED", "CREDIT", "COMPETENT", "IN PROGRESS", "CREDIT EXEMPTION"].includes((status || "").toUpperCase()); } function iP(status) { return isPassedStatus(status); } function getBestStatus(moduleCode, moduleStatusMap) { const codes = (typeof getEquivalentsHelper === "function" ? getEquivalentsHelper(moduleCode) : (typeof getEquivalents === "function" ? getEquivalents(moduleCode) : [moduleCode])) || [moduleCode]; return codes.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 gL(moduleCode) { return getBestStatus(moduleCode, moduleStatusMap); } const maxCourseYear = qualProgData.reduce((mx, m) => { const y = deriveYear(m); return y > mx ? y : mx; }, 0); qualProgData.forEach(m => { const y = deriveYear(m); updateYearlyCredits(y, m, yearlyTotalCredits, yearlyMaxCredits, qualPlannerData, programVersionsDict); }); const moduleStatusMap = new Map(); fullCourseHistoryData.value.forEach(rec => { const mc = rec.mshied_CourseId.mshied_coursenumber; const newStatus = rec["bt_modulestatus@OData.Community.Display.V1.FormattedValue"]; if (!newStatus) return; const oldMain = moduleStatusMap.get(mc); const finalMain = (() => { if (!oldMain) return newStatus; if (isPassedStatus(oldMain) && !isPassedStatus(newStatus)) return oldMain; if (!isPassedStatus(oldMain) && isPassedStatus(newStatus)) return newStatus; return newStatus; })(); moduleStatusMap.set(mc, finalMain); const eqRecords = courseEquivalencyDict.filter(e => e.primarycode === mc || e.equivalentcodes.includes(mc) ); eqRecords.forEach(e => { const eqCodes = [e.primarycode, ...e.equivalentcodes]; eqCodes.forEach(eqCode => { const oldEq = moduleStatusMap.get(eqCode); const finalEq = (() => { if (!oldEq) return newStatus; if (isPassedStatus(oldEq) && !isPassedStatus(newStatus)) return oldEq; if (!isPassedStatus(oldEq) && isPassedStatus(newStatus)) return newStatus; return newStatus; })(); moduleStatusMap.set(eqCode, finalEq); }); }); }); let passedModules = new Set(), failedModulesMap = new Map(); qualProgData.forEach(m => { const y = deriveYear(m); const c = Number(m.Credits) || 0; const st = getBestStatus(m.ModuleCode, moduleStatusMap); if (st) { m.ModuleStatus = st; if (isPassedStatus(st)) { if (!passedModules.has(m.ModuleCode)) { passedModules.add(m.ModuleCode); const countInTotals = !(EXCLUDE_S_CODES && isSCodeModule(m)); if (countInTotals) { yearlyCreditsEarned[y] = (yearlyCreditsEarned[y] || 0) + c; if (isCoreLike(m)) { yearlyCoreCreditsEarned[y] = (yearlyCoreCreditsEarned[y] || 0) + c; } totalCreditsEarned += c; } } } else if (isFailedStatus(st)) { if (!passedModules.has(m.ModuleCode)) failedModulesMap.set(m.ModuleCode, m); } } }); let hasChanges; do { hasChanges = false; for (const code of failedModulesMap.keys()) { if (passedModules.has(code)) { failedModulesMap.delete(code); qualProgData = qualProgData.filter(m => { if (m.ModuleCode === code && m.ModuleStatus === "FAILED") { return false; } return true; }); hasChanges = true; } } } while (hasChanges); try { const selOptText = $("#btfh_intake option:selected").text() || ""; const selYear = extractIntakeYear(selOptText) || ""; const intakeRow = (intakeDict.results || []).find(x => x.Year === selYear); const selectedIntakeDate = intakeRow ? intakeRow.Date : new Date().toISOString().slice(0, 10); if (window.applyOldToNewModuleRules) { await window.applyOldToNewModuleRules( qualProgData, courseHistoryData, qualPlannerData, btfhCurrentProgram, btfhCurrentProgramVersion, selectedIntakeDate ); } } catch (e) { console.warn("Module rules not applied", e); } let plannerForOfferings; try { plannerForOfferings = buildPlannerForCurrentYear(); } catch (e) { plannerForOfferings = futurePlannerData && futurePlannerData.results && futurePlannerData.results.length ? futurePlannerData : qualPlannerData; } pickEarliestOfferings(qualProgData, plannerForOfferings, moduleStatusMap); qualProgData = qualProgData.filter(mod => { if (mod.Repeat === "true") { const allCodes = getEquivalentsHelper(mod.ModuleCode); const hasFail = allCodes.some(code => isFailedStatus(moduleStatusMap.get(code))); const hasPass = allCodes.some(code => isPassedStatus(moduleStatusMap.get(code))); return hasFail && !hasPass; } return true; }); const everFailed = (cd) => qualProgData.some(m => m.ModuleCode === cd && m.ModuleStatus === "FAILED"); const everPassed = (cd) => qualProgData.some(m => m.ModuleCode === cd && isPassedStatus(m.ModuleStatus)); outstandingRepeaterModules = qualProgData.filter(m => { return everFailed(m.ModuleCode) && !everPassed(m.ModuleCode); }); (function recomputeYearlyCreditsWithRules() { const eqMap = window._moduleRuleEquivalences; const counted = new Set(); const yearlyCreditBreakdown = {}; yearlyCreditsEarned = {}; yearlyCoreCreditsEarned = {}; const getRootFromCode = (code) => { const c = (code || "").toString().toUpperCase().trim(); if (!c) return ""; const dash = c.indexOf("-"); return dash >= 0 ? c.substring(0, dash) : c; }; const isPassStatus = (s) => ["PASSED", "CREDIT", "COMPETENT", "CREDIT EXEMPTION"].includes((s || "").toUpperCase()); const rootIsPassed = (root) => { if (!root) return false; const st = getBestStatus(root, moduleStatusMap); return isPassStatus(st); }; (qualPlannerData.results || []).forEach(pm => { if (EXCLUDE_S_CODES && isSCodeModule(pm)) return; const year = deriveYear(pm); if (!Number.isFinite(year) || year <= 0) return; const credits = Number(pm.Credits) || 0; if (!credits) return; const root = getRootFromCode(pm.ModuleCode || pm.ProductCode || pm.ProductNumber); if (!root) return; const isCoreLikePlanner = (pm.Type === "Core" || pm.ModuleClass === "Fundamental"); let satisfied = false; let groupKey = root; const directStatus = gL(root); if (isPassStatus(directStatus)) { satisfied = true; } else if (eqMap && typeof eqMap.get === "function") { const entry = eqMap.get(root); if (entry && entry.oldRoots && entry.oldRoots.size) { const olds = Array.from(entry.oldRoots); const passedOlds = olds.filter(or => rootIsPassed(or)); if (entry.mode === "any") { satisfied = passedOlds.length > 0; } else if (entry.mode === "all") { satisfied = olds.length > 0 && passedOlds.length === olds.length; } if (satisfied) { groupKey = `RULE:${entry.mode}:${olds.sort().join("+")}`; } } } if (!satisfied) return; if (counted.has(groupKey)) return; counted.add(groupKey); yearlyCreditsEarned[year] = (yearlyCreditsEarned[year] || 0) + credits; if (isCoreLikePlanner) { yearlyCoreCreditsEarned[year] = (yearlyCoreCreditsEarned[year] || 0) + credits; } const codeLabel = pm.ProductCode || pm.ModuleCode || root; if (codeLabel) { if (!yearlyCreditBreakdown[year]) yearlyCreditBreakdown[year] = []; yearlyCreditBreakdown[year].push({ code: codeLabel, root, credits, isCore: isCoreLikePlanner }); } }); window._yearlyCreditBreakdown = yearlyCreditBreakdown; })(); (function recomputeHighestYearCompleted() { let hyc = 0; const maxY = Math.max( ...Object.keys(yearlyTotalCredits).map(Number), ...Object.keys(yearlyCreditsEarned).map(Number), maxPlannerYear || 1 ); for (let y = 1; y <= maxY; y++) { const total = Number(yearlyTotalCredits[y] ?? 0); const earned = Number(yearlyCreditsEarned[y] ?? 0); const coreTotal = Number(yearlyCoreCredits[y] ?? 0); const coreEarned = Number(yearlyCoreCreditsEarned[y] ?? 0); if (total <= 0) break; const pct = earned / total; const coreDone = coreTotal === 0 ? true : (coreEarned >= coreTotal); if (pct >= 0.4 || coreDone) { hyc = y; } else { break; } } highestYearCompleted = hyc; })(); qualProgData = gateModulesByYear( qualProgData, yearlyCreditsEarned, yearlyTotalCredits, yearlyCoreCredits, yearlyCoreCreditsEarned ); let highestYearFound = 0; qualProgData.forEach(m => { const y = deriveYear(m); if (y > highestYearCompleted && !remainingModules.some(mm => mm.ModuleCode === m.ModuleCode)) { remainingModules.push(m); } const isExt = (typeof isExtOffering === "function") ? isExtOffering(m) : String(m.ProductNumber || "").toLowerCase().endsWith("ext"); if (isExt) { remainingModules.push(m); extModules.push(m); } highestYearFound = Math.max(highestYearFound, y); }); failedModulesMap.forEach((cr, cd) => { if (!remainingModules.some(mm => mm.ModuleCode === cd)) { remainingModules.push(cr); } }); qualProgData.forEach(m => { const cd = m.ModuleCode; if (!passedModules.has(cd) && !failedModulesMap.has(cd) && !remainingModules.some(mm => mm.ModuleCode === cd)) { remainingModules.push(m); } }); if (highestYearCompleted > maxCourseHistoryYear + 1) { console.warn("Prog gap >1."); highestYearCompleted = Math.max(1, maxCourseHistoryYear + 1); } let isExitYear = false; if (highestYearCompleted === maxPlannerYear && yearlyCreditsEarned[maxCourseHistoryYear] <= yearlyMaxCredits[maxCourseHistoryYear]) { isExitYear = true; console.warn("Exit year."); } if (highestYearCompleted > maxPlannerYear) { btfhTargetProgamVersionYear = maxPlannerYear; } else if (highestYearCompleted <= maxPlannerYear) { btfhTargetProgamVersionYear = highestYearCompleted + 1; } else { btfhTargetProgamVersionYear = highestYearCompleted; } if (Math.abs(highestYearCompleted - btfhTargetProgamVersionYear) > 1) { btfhTargetProgamVersionYear = highestYearCompleted + 1; } if (isExitYear) { btfhTargetProgamVersionYear = maxPlannerYear; } const foundProgramVersion = programVersionsDict.results.find( x => x.Name.slice(-1) === btfhTargetProgamVersionYear.toString() ); if (foundProgramVersion) { maxCredits = foundProgramVersion.CreditValue; btfhTargetProgamVersion = foundProgramVersion.Id; btfhTargetProgamVersionText = foundProgramVersion.Name; } else { maxCredits = btfhMaxCreditValue; btfhTargetProgamVersion = btfhCurrentProgramVersion; btfhTargetProgamVersionText = btfhCurrentProgramVersionText; } const coreModules = qualProgData .filter(m => isCoreLike(m)) .filter(m => m.Repeat !== "true") .map(m => m.ModuleCode); qualProgData.forEach(cr => { if (isCoreLike(cr) && !isNaN(cr.Credits) && cr.Credits !== 0) { const c = cr.ModuleCode; if (coreModules.includes(c) && iP(cr.ModuleStatus)) { passedCoreModules.push({ ModuleCode: c, CourseName: cr.Name }); } else if (isCoreLike(cr) && ( (typeof isExtOffering === "function") ? isExtOffering(cr) : (cr.ProductNumber || "").toLowerCase().endsWith("ext") )) { } } }); if (isExitYear) { qualPlannerData.results.forEach(m => { if ( (m.ModuleClass === "Core" || m.ModuleClass === "Fundamental") && !remainingModules.some(mm => mm.ModuleCode === m.ModuleCode) ) { remainingModules.push(m); } }); } updateProgressionAnalysisTable( maxPlannerYear, yearlyCoreCreditsEarned, yearlyCreditsEarned, yearlyCoreCredits, yearlyTotalCredits, coreModules, totalCreditsEarned, highestYearCompleted ); const failedModules = Array.from(failedModulesMap.entries()).map(([cd, val]) => ({ ModuleCode: cd, ModuleName: val })); return { highestYearCompleted, passedCoreModules, passedModules, remainingModules, failedModules, extModules, qualProgData, isExitYear }; } async function fetchProgramVersionData() { const url = `/fetchprogramversion/?id=${btfhCurrentProgram}`; const resp = await fetch(url); const raw = await resp.text(); if (!resp.ok) { throw new Error("Program version fetch failed"); } const sanitized = raw.replace(new RegExp("[\\x00-\\x1F\\x7F]+", "g"), ""); let json; try { json = JSON.parse(sanitized); } catch (parseErr) { console.error("ProgramVersion parse error"); throw parseErr; } return json; } async function fetchCourseEquivalencyData() { var eqUrl = "https://www.vossie.net/_api/edv_courseequivalents?$select=edv_primarycoursecode,edv_equivalentcoursecode"; const list = await fetchData(eqUrl), dict = {}; list.value.forEach(i => { if (!dict[i.edv_primarycoursecode]) dict[i.edv_primarycoursecode] = [i.edv_equivalentcoursecode]; else dict[i.edv_primarycoursecode].push(i.edv_equivalentcoursecode); if (!dict[i.edv_equivalentcoursecode]) dict[i.edv_equivalentcoursecode] = [i.edv_primarycoursecode]; else dict[i.edv_equivalentcoursecode].push(i.edv_primarycoursecode); }); let data = Object.entries(dict).map(([primary, equivalents]) => ({ primarycode: primary, equivalentcodes: equivalents })); return data; } function parseBlockNumberFromString(str) { if (!str || typeof str !== 'string') return null; const re = new RegExp("-([A-Za-z])(\\d+)", "g"); let lastMatch = null; let m; while ((m = re.exec(str)) !== null) { lastMatch = m; } if (!lastMatch) return null; const digits = lastMatch[2]; return Number(String(digits).charAt(0)); } function parseBlockNumber(mod) { if (!mod) return null; const candidates = [mod.ProductNumber, mod.ProductCode, mod.ModuleCode] .filter(Boolean) .map(v => String(v)); for (const src of candidates) { if (!src) continue; const match = src.match(new RegExp("-([A-Za-z])(\\d+)")); if (!match) continue; const digits = match[2]; const block = parseInt(String(digits).charAt(0), 10); if (!isNaN(block)) return block; } return null; } function buildPlannerForCurrentYear() { const selY = getSelectedIntakeYear(); const base = (qualPlannerData && qualPlannerData.results) || []; const fut = (futurePlannerData && futurePlannerData.results) || []; if (!selY || (!base.length && !fut.length)) return fut.length ? futurePlannerData : qualPlannerData; const res = [], seen = new Set(); const maxB = 4; const getRootKey = (row) => { const raw = (row && (row.ModuleCode || row.ProductNumber || row.ProductCode)) || ""; if (!raw) return ""; try { if (typeof getNormalizedRootCode === "function") return getNormalizedRootCode(raw); } catch (e) { } try { if (typeof getBaseModuleCode === "function") return getBaseModuleCode(raw); } catch (e) { } return String(raw).split("-")[0]; }; const add = (arr, limit12) => { arr.forEach(m => { const y = getIntakeYearForModule(m); const yu = Number(m && m.YearOfUnderTaking); const matchesSel = (y === selY) || (Number.isFinite(yu) && yu >= 1900 && yu === selY); if (!matchesSel) return; const b = parseBlockNumber(m); if (!b || b < 1 || b > maxB) return; if (limit12 && b > 2) return; const k = String(getRootKey(m) || "").toUpperCase() + "|" + b; if (seen.has(k)) return; seen.add(k); res.push(m); }); }; add(base, false); add(fut, false); if (res.length) return { results: res }; return fut.length ? futurePlannerData : qualPlannerData; } async function pickEarliestOfferings(qualProgData, qualPlannerData, moduleStatusMap) { preRequisiteModules = [...new Set(preRequisiteModules)]; coRequisiteModules = [...new Set(coRequisiteModules)]; return { preRequisiteModules, coRequisiteModules }; } function pickEarliestOfferings(qualProgData, qualPlannerData, moduleStatusMap) { const allowedIntakeYears = window.getAllowedIntakeYearsWindow && window.getAllowedIntakeYearsWindow(); const selectedIntakeYear = getSelectedIntakeYear(); const getIntakeYearForSelection = (obj) => { const yu = Number(obj && obj.YearOfUnderTaking); if (Number.isFinite(yu) && yu >= 1900) return yu; return getIntakeYearForModule(obj); }; function parseBlockNumberFromModule(moduleObj) { return parseBlockNumber(moduleObj); } function isFailedModule(code) { const status = moduleStatusMap.get(code); if (!status) return false; return status.toUpperCase().includes("FAILED"); } qualProgData.forEach(mod => { const code = mod.ModuleCode; let possible = qualPlannerData.results.filter(pm => { const raw = pm && (pm.ModuleCode || pm.ProductNumber || pm.ProductCode) || ""; if (!raw) return false; let root; try { if (typeof getNormalizedRootCode === "function") root = getNormalizedRootCode(raw); } catch (e) { } if (!root) { try { if (typeof getBaseModuleCode === "function") root = getBaseModuleCode(raw); } catch (e) { } } if (!root) root = String(raw).split("-")[0]; return String(root).toUpperCase() === String(code).toUpperCase(); }); if (!possible.length) return; possible = possible.filter(x => { const block = parseBlockNumberFromModule(x); if (!block || block < 1 || block > 4) return false; const intakeYear = getIntakeYearForSelection(x); if (!isIntakeYearAllowedForSelection(intakeYear, allowedIntakeYears, selectedIntakeYear)) return false; return true; }); if (!possible.length) return; const failed = isFailedModule(code); possible.sort((a, b) => { const blockA = parseBlockNumberFromModule(a) || 99; const blockB = parseBlockNumberFromModule(b) || 99; if (!failed) { if (a.Repeat === 'false' && b.Repeat === 'true') return -1; if (a.Repeat === 'true' && b.Repeat === 'false') return +1; } else { if (a.Repeat === 'true' && b.Repeat === 'false') return -1; if (a.Repeat === 'false' && b.Repeat === 'true') return +1; } if (blockA !== blockB) { return blockA - blockB; } return 0; }); const earliest = possible[0]; const earliestCode = earliest.ProductNumber || earliest.ProductCode; if (earliestCode) mod.ProductNumber = earliestCode; if (earliest.ProductName) mod.ProductName = earliest.ProductName; if (earliest.ProductCode) mod.ProductCode = earliest.ProductCode; if (earliest.ProductId) mod.ProductId = earliest.ProductId; if (typeof earliest.Repeat !== "undefined") mod.Repeat = earliest.Repeat?.toString(); if (typeof earliest.YearOfOffering !== "undefined") mod.YearOfOffering = earliest.YearOfOffering; if (typeof earliest.YearOfUnderTaking !== "undefined") mod.YearOfUnderTaking = earliest.YearOfUnderTaking; mod.Year = deriveYear(earliest); const block = parseBlockNumber(earliest); if (Number.isFinite(block) && block >= 0) { mod.ModulePeriod = block; mod.StartsInBlock = `Block ${block}`; } else { if (earliest.StartsInBlock) mod.StartsInBlock = earliest.StartsInBlock; if (typeof earliest.ModulePeriod !== "undefined") mod.ModulePeriod = earliest.ModulePeriod; } }); }