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 = deriveYear(m); return yr > mx ? yr : 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 (maxCourseHistoryYear <= maxPlannerYear) { qualPlannerData.results.forEach(m => { const tY = deriveYear(m); const moduleData = { Id: "", Name: m.Name, Repeat: m.Repeat?.toString() || "false", Credits: m.Credits || 0, YearOfOffering: m.YearOfOffering || 0, YearOfUnderTaking: m.YearOfUnderTaking, StartsInBlock: `Block ${m.ProductNumber.slice(-2, -1)}`, ModulePeriod: m.ProductNumber.slice(-2, -1), ProductNumber: m.ProductNumber, ProductName: m.ProductName, ModuleCode: (typeof getNormalizedRootCode === "function") ? getNormalizedRootCode(m.ModuleCode || m.ProductCode || m.ProductNumber) : m.ProductCode.split("-")[0], 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 }; if ( ["FAILED", "PASSED"].includes(m.ModuleStatus) || !qualProgData.some(e => e.ModuleCode === m.ModuleCode) ) { 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); }); 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); } if (String(m.ProductNumber || "").toLowerCase().endsWith("ext")) { 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); } }); (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; })(); let isExitYear = false; if (highestYearCompleted === maxPlannerYear && yearlyCreditsEarned[maxCourseHistoryYear] <= yearlyMaxCredits[maxCourseHistoryYear]) { isExitYear = true; console.warn("Exit year."); } if (highestYearCompleted > maxCourseHistoryYear + 1) { console.warn("Prog gap >1."); return; } 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) && (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(digits.charAt(0)); } function parseBlockNumber(mod) { const src = (mod.ProductCode || mod.ProductNumber || ""); if (!src) return null; const match = src.match(new RegExp("-([A-Za-z])(\\d+)")); if (!match) return null; const digits = match[2]; const block = parseInt(digits.charAt(0), 10); return isNaN(block) ? null : block; } 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 add = (arr, limit12) => { arr.forEach(m => { const y = getIntakeYearForModule(m); if (y !== selY) return; const b = parseBlockNumber(m); if (!b || b < 1 || b > maxB) return; if (limit12 && b > 2) return; const k = String(m.ProductCode || m.ModuleCode || "").toUpperCase() + "|" + b; if (seen.has(k)) return; seen.add(k); res.push(m); }); }; add(base, true); 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(); function parseBlockNumberFromModule(moduleObj) { if (!moduleObj || typeof moduleObj.ProductCode !== 'string') return null; return parseBlockNumberFromString(moduleObj.ProductCode); } 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 pmCode = pm.ModuleCode || pm.ProductCode?.split("-")[0]; return pmCode === code; }); if (!possible.length) return; possible = possible.filter(x => { const block = parseBlockNumberFromModule(x); if (!block || block < 1 || block > 4) return false; const intakeYear = getIntakeYearForModule(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]; mod.ProductNumber = earliest.ProductCode; mod.ProductName = earliest.ProductName; mod.ProductCode = earliest.ProductCode; mod.ProductId = earliest.ProductId; mod.Repeat = earliest.Repeat?.toString(); mod.ModulePeriod = earliest.Year; mod.StartsInBlock = earliest.StartsInBlock; mod.YearOfOffering = earliest.YearOfOffering; mod.YearOfUnderTaking = earliest.YearOfUnderTaking; mod.Year = deriveYear(earliest); }); }