import { InvestigationGroupType, InvestigationSectionType, InvestigationSortBy, SortOrder, } from "@typez/investigation/enums";
const DEFAULT_CURRICULUM = "N/A";
const DEFAULT_GRADE = "N/A";
const DEFAULT_LESSON = "N/A";
const DEFAULT_SUBJECT = "N/A";
const DEFAULT_AUTHOR_NAME = "Unknown Author";
const DEFAULT_AUTHOR_ID = "N/A";
const DEFAULT_UNIT = "N/A";
const DEFAULT_STATUS = "draft_incomplete";
/**
* Helper class for investigation sorting, filtering, and grouping operations
* @category Utils
*/
export class InvestigationSortingHelper {
/**
* Parses grade string to numeric value for sorting
* @param {string | null | undefined} gradeStr - Grade string
* @returns {number} Numeric grade value
*/
parseGradeValue(gradeStr) {
if (!gradeStr)
return 999;
const num = parseInt(String(gradeStr).trim(), 10);
return isNaN(num) ? 999 : num;
}
/**
* Parses unit number from unit string for sorting
* @param {string | null | undefined} unitStr - Unit string (e.g., "8.1: Weather", "K.2: Science")
* @returns {number} Unit number
*/
parseUnitNumber(unitStr) {
if (!unitStr)
return 999;
// Match patterns like "8.1", "K.2", "MYP.3" at the start of the string
const match = String(unitStr).match(/^(?:[KMk]|\d+|MYP)\.(\d+)/);
if (!match || !match[1])
return 999;
const unitNum = parseInt(match[1], 10);
return isNaN(unitNum) ? 999 : unitNum;
}
/**
* Extracts the surname from a full name
* @param {string | null} name - Full name string
* @returns {string} Surname (last word in the name)
*/
getSurnameFromName(name) {
if (!name)
return "";
const parts = name.trim().split(/\s+/);
return parts[parts.length - 1] || "";
}
/**
* Returns predefined ordering for science subjects
* Based on the unique values from subjectsFactory
* TypeScript will error if any subject from subjectsFactory is missing
* @returns {SubjectOrderMap} Object mapping subject names to their sort order
*/
getSubjectOrder() {
// Order based on subjectsFactory unique values
// If a new subject is added to subjectsFactory, TypeScript will require it here
return {
Physics: 1,
Chemistry: 2,
Biology: 3,
Kindergarten: 4,
Elementary: 5,
Middle: 6,
};
}
/**
* Sorts investigation items based on search criteria
* Default: Date Modified (updatedAt), Earlier → Later can be reversed to Later → Earlier
* @param {IInvestigationListItemDto[]} items - Array of investigation items to sort
* @param {IInvestigationSearchDto} searchDto - Search configuration containing sort field and order
* @returns {IInvestigationListItemDto[]} Sorted array of investigation items
*/
sortInvestigationItems(items, searchDto) {
const sortBy = searchDto.sortBy || InvestigationSortBy.UPDATED_AT;
// Default sort order depends on sortBy field
let defaultOrder = SortOrder.DESC;
if (sortBy === InvestigationSortBy.CREATED_AT || sortBy === InvestigationSortBy.UPDATED_AT) {
// For date fields, default is newest first (desc)
defaultOrder = SortOrder.DESC;
}
else if (sortBy === InvestigationSortBy.TITLE || sortBy === InvestigationSortBy.ACTIVITY) {
// For text fields, default is A-Z (asc)
defaultOrder = SortOrder.ASC;
}
else if (sortBy === InvestigationSortBy.TIME_IN_DEVELOPMENT ||
sortBy === InvestigationSortBy.VIEWS ||
sortBy === InvestigationSortBy.COMPLETIONS) {
// For numeric fields, default is More→Less (desc)
defaultOrder = SortOrder.DESC;
}
const sortOrder = searchDto.sortOrder || defaultOrder;
const dir = sortOrder === SortOrder.ASC ? 1 : -1;
return items.sort((a, b) => {
if (sortBy === InvestigationSortBy.CREATED_AT || sortBy === InvestigationSortBy.UPDATED_AT) {
const aVal = a[sortBy] ? new Date(a[sortBy]).getTime() : 0;
const bVal = b[sortBy] ? new Date(b[sortBy]).getTime() : 0;
return (aVal - bVal) * dir;
}
if (sortBy === InvestigationSortBy.TITLE || sortBy === InvestigationSortBy.ACTIVITY) {
const aVal = (a[sortBy] || "").toString().toLowerCase();
const bVal = (b[sortBy] || "").toString().toLowerCase();
return (aVal < bVal ? -1 : aVal > bVal ? 1 : 0) * dir;
}
if (sortBy === InvestigationSortBy.TIME_IN_DEVELOPMENT) {
const aTime = a.sentToDevelopmentAt ? new Date(a.sentToDevelopmentAt).getTime() : 0;
const bTime = b.sentToDevelopmentAt ? new Date(b.sentToDevelopmentAt).getTime() : 0;
return (aTime - bTime) * dir;
}
if (sortBy === InvestigationSortBy.VIEWS || sortBy === InvestigationSortBy.COMPLETIONS) {
const aVal = Number(a[sortBy] || 0);
const bVal = Number(b[sortBy] || 0);
return (aVal - bVal) * dir;
}
return 0;
});
}
/**
* Builds sections based on the specified section type
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {SectionType} section - Type of section to build
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationSection[]} Array of investigation sections
*/
buildSections(items, section, sortConfig) {
if (!section || section === InvestigationSectionType.CHANGE_DATE) {
return [];
}
if (section === InvestigationSectionType.CURRICULUM_GRADE) {
return this.buildCurriculumGradeSections(items, sortConfig);
}
if (section === InvestigationSectionType.GRADE) {
return this.buildGradeSections(items, sortConfig);
}
if (section === InvestigationSectionType.CURRICULUM_STATUS) {
return this.buildCurriculumStatusSections(items, sortConfig);
}
if (section === InvestigationSectionType.AUTHOR) {
return this.buildAuthorSections(items, sortConfig, sortConfig.currentUserId);
}
return [];
}
/**
* Builds sections grouped by curriculum and grade
* Headers look like "OpenSciEd, Grade 1", "Generic, Grade 8"
* Sorted a→z by curricula, inside 1→12 by grade
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationSection[]} Array of sections grouped by curriculum+grade combination
*/
buildCurriculumGradeSections(items, sortConfig) {
// Group by curriculum+grade combination
const map = new Map();
for (const item of items) {
const curriculum = item.curriculum || DEFAULT_CURRICULUM;
const grade = item.grade || DEFAULT_GRADE;
const key = `${curriculum}\0${grade}`; // Null character ensures no collision
if (!map.has(key)) {
map.set(key, { curriculum, grade, items: [] });
}
map.get(key).items.push(item);
}
const sections = [];
// Convert to array and sort by curriculum (a→z), then grade (1→12)
const sorted = Array.from(map.values()).sort((a, b) => {
const curriculumCmp = a.curriculum.localeCompare(b.curriculum);
if (curriculumCmp !== 0)
return curriculumCmp;
return this.parseGradeValue(a.grade) - this.parseGradeValue(b.grade);
});
for (const { curriculum, grade, items: groupItems } of sorted) {
const sortedItems = this.sortInvestigationItems(groupItems, sortConfig);
sections.push({
title: curriculum,
subtitle: `Grade ${grade}`,
items: sortedItems,
count: sortedItems.length,
});
}
return sections;
}
/**
* Builds sections grouped by grade
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationSection[]} Array of sections organized by grade
*/
buildGradeSections(items, sortConfig) {
const map = new Map();
for (const item of items) {
const grade = item.grade || DEFAULT_GRADE;
if (!map.has(grade))
map.set(grade, []);
map.get(grade).push(item);
}
const sections = [];
const sortedGrades = Array.from(map.keys()).sort((a, b) => this.parseGradeValue(a) - this.parseGradeValue(b));
for (const grade of sortedGrades) {
const gradeItems = map.get(grade);
const sortedItems = this.sortInvestigationItems(gradeItems, sortConfig);
sections.push({
title: `Grade ${grade}`,
items: sortedItems,
count: sortedItems.length,
});
}
return sections;
}
/**
* Builds sections grouped by curriculum and status
* Sorted a→z by curricula, inside alphabetically by status
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationSection[]} Array of sections grouped by curriculum+status combination
*/
buildCurriculumStatusSections(items, sortConfig) {
// Group by curriculum+status combination
const map = new Map();
for (const item of items) {
const curriculum = item.curriculum || DEFAULT_CURRICULUM;
const status = item.status || DEFAULT_STATUS;
const key = `${curriculum}\0${status}`; // Null character ensures no collision
if (!map.has(key)) {
map.set(key, { curriculum, status, items: [] });
}
map.get(key).items.push(item);
}
const sections = [];
const sorted = Array.from(map.values()).sort((a, b) => {
const curriculumCmp = a.curriculum.localeCompare(b.curriculum);
if (curriculumCmp !== 0)
return curriculumCmp;
return a.status.localeCompare(b.status);
});
for (const { curriculum, status, items: groupItems } of sorted) {
const sortedItems = this.sortInvestigationItems(groupItems, sortConfig);
sections.push({
title: curriculum,
subtitle: this.formatStatusDisplay(status),
items: sortedItems,
count: sortedItems.length,
});
}
return sections;
}
/**
* Builds sections grouped by author with current user first
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @param {string} currentUserId - Optional ID of the current user
* @returns {IInvestigationSection[]} Array of sections organized by author
*/
buildAuthorSections(items, sortConfig, currentUserId) {
const map = new Map();
for (const item of items) {
const authorId = item.author?.id || DEFAULT_AUTHOR_ID;
if (!map.has(authorId))
map.set(authorId, { author: item.author, items: [] });
map.get(authorId).items.push(item);
}
const currentUserSection = [];
const otherSections = [];
for (const [authorId, data] of map.entries()) {
const sortedItems = this.sortInvestigationItems(data.items, sortConfig);
const authorName = data.author?.name || DEFAULT_AUTHOR_NAME;
const isCurrentUser = currentUserId && authorId === currentUserId;
const section = {
title: isCurrentUser ? `${authorName} (you)` : authorName,
items: sortedItems,
count: sortedItems.length,
};
if (isCurrentUser)
currentUserSection.push(section);
else
otherSections.push(section);
}
otherSections.sort((a, b) => {
const surnameA = this.getSurnameFromName(a.title);
const surnameB = this.getSurnameFromName(b.title);
return surnameA.localeCompare(surnameB);
});
return [...currentUserSection, ...otherSections];
}
/**
* Formats investigation status for display
* @param {string} status - Raw status string
* @returns {string} Human-readable status text
*/
formatStatusDisplay(status) {
const map = {
draft_incomplete: "Draft (Incomplete)",
draft_non_contradictory_complete: "Draft (No Contradictions)",
draft_contradictory_complete: "Draft (Contradictions Found)",
in_development: "In Development",
published: "Published",
unpublished: "Unpublished",
};
return map[status] || status;
}
/**
* Applies grouping to investigation data
* @param {object} data - Data containing items or sections
* @param {IInvestigationListItemDto[]} data.items - Optional flat list of investigation items
* @param {IInvestigationSection[]} data.sections - Optional sections with items
* @param {GroupType} group - Type of grouping to apply
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {object} Grouped data with sections or subsections
*/
applyGrouping(data, group, sortConfig) {
if (!group)
return data;
if (data.items && !data.sections) {
const sections = this.groupFlatItems(data.items, group, sortConfig);
return { sections };
}
if (data.sections) {
const groupedSections = data.sections.map((section) => {
if (section.items) {
return this.applyGroupingToSection(section, group, sortConfig);
}
if (section.subsections) {
return {
...section,
subsections: section.subsections.map((sub) => this.applyGroupingToSubsection(sub, group, sortConfig)),
};
}
return section;
});
return { sections: groupedSections };
}
return data;
}
/**
* Groups flat list of items into sections
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {GroupType} group - Type of grouping
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationSection[]} Array of sections
*/
groupFlatItems(items, group, sortConfig) {
if (group === InvestigationGroupType.SUBJECTS)
return this.groupBySubjects(items, sortConfig);
if (group === InvestigationGroupType.UNITS)
return this.groupByUnits(items, sortConfig);
if (group === InvestigationGroupType.UNITS_LESSONS)
return this.groupByUnitsLessons(items, sortConfig);
return [];
}
/**
* Applies grouping to a section by converting items to subsections
* @param {IInvestigationSection} section - Section to group
* @param {GroupType} group - Type of grouping
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationSection} Section with grouped subsections
*/
applyGroupingToSection(section, group, sortConfig) {
if (!section.items)
return section;
const groups = this.groupItemsToGroups(section.items, group, sortConfig);
return {
...section,
items: undefined,
subsections: groups.map((g) => ({
title: g.title,
items: g.items,
count: g.count,
})),
};
}
/**
* Applies grouping to a subsection by converting items to groups
* @param {IInvestigationSubsection} subsection - Subsection to group
* @param {GroupType} group - Type of grouping
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationSubsection} Subsection with grouped items
*/
applyGroupingToSubsection(subsection, group, sortConfig) {
if (!subsection.items)
return subsection;
const groups = this.groupItemsToGroups(subsection.items, group, sortConfig);
return {
...subsection,
items: undefined,
groups,
};
}
/**
* Groups items into investigation groups
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {GroupType} group - Type of grouping
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationGroup[]} Array of investigation groups
*/
groupItemsToGroups(items, group, sortConfig) {
if (group === InvestigationGroupType.SUBJECTS)
return this.groupItemsBySubjects(items, sortConfig);
if (group === InvestigationGroupType.UNITS)
return this.groupItemsByUnits(items, sortConfig);
if (group === InvestigationGroupType.UNITS_LESSONS)
return this.groupItemsByUnitsLessons(items, sortConfig);
return [];
}
/**
* Groups items by subject into sections
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationSection[]} Array of sections grouped by subject
*/
groupBySubjects(items, sortConfig) {
const groups = this.groupItemsBySubjects(items, sortConfig);
return groups.map((g) => ({
title: g.title,
items: g.items,
count: g.count,
}));
}
/**
* Groups items by subject into investigation groups
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationGroup[]} Array of groups organized by subject
*/
groupItemsBySubjects(items, sortConfig) {
const map = new Map();
const subjectOrder = this.getSubjectOrder();
for (const item of items) {
const subject = item.subject || DEFAULT_SUBJECT;
if (!map.has(subject))
map.set(subject, []);
map.get(subject).push(item);
}
const groups = [];
const sortedSubjects = Array.from(map.keys()).sort((a, b) => {
const orderA = subjectOrder[a] || 999;
const orderB = subjectOrder[b] || 999;
return orderA - orderB;
});
for (const subject of sortedSubjects) {
const subjectItems = map.get(subject);
const sortedItems = this.sortInvestigationItems(subjectItems, sortConfig);
groups.push({
title: subject,
items: sortedItems,
count: sortedItems.length,
});
}
return groups;
}
/**
* Groups items by unit into sections
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationSection[]} Array of sections grouped by unit
*/
groupByUnits(items, sortConfig) {
const groups = this.groupItemsByUnits(items, sortConfig);
return groups.map((g) => ({
title: g.title,
items: g.items,
count: g.count,
}));
}
/**
* Groups items by unit into investigation groups
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationGroup[]} Array of groups organized by unit
*/
groupItemsByUnits(items, sortConfig) {
const map = new Map();
for (const item of items) {
const unit = item.unit || DEFAULT_UNIT;
if (!map.has(unit)) {
map.set(unit, { items: [], grade: item.grade || DEFAULT_GRADE, unit: unit });
}
map.get(unit).items.push(item);
}
const groups = [];
// Sort by grade (numeric), then by unit number
const sortedEntries = Array.from(map.values()).sort((a, b) => {
const gradeA = this.parseGradeValue(a.grade);
const gradeB = this.parseGradeValue(b.grade);
if (gradeA !== gradeB)
return gradeA - gradeB;
const unitNumA = this.parseUnitNumber(a.unit);
const unitNumB = this.parseUnitNumber(b.unit);
return unitNumA - unitNumB;
});
for (const entry of sortedEntries) {
const sortedItems = this.sortInvestigationItems(entry.items, sortConfig);
groups.push({
title: entry.unit,
items: sortedItems,
count: sortedItems.length,
});
}
return groups;
}
/**
* Groups items by unit and lesson into sections
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationSection[]} Array of sections grouped by unit and lesson
*/
groupByUnitsLessons(items, sortConfig) {
const groups = this.groupItemsByUnitsLessons(items, sortConfig);
return groups.map((g) => ({
title: g.title,
items: g.items,
count: g.count,
}));
}
/**
* Groups items by unit and lesson into investigation groups
* @param {IInvestigationListItemDto[]} items - Array of investigation items
* @param {IInvestigationSearchDto} sortConfig - Sort configuration
* @returns {IInvestigationGroup[]} Array of groups organized by unit and lesson
*/
groupItemsByUnitsLessons(items, sortConfig) {
const map = new Map();
for (const item of items) {
const unit = item.unit || DEFAULT_UNIT;
const lesson = item.lesson || DEFAULT_LESSON;
if (!map.has(unit)) {
map.set(unit, { grade: item.grade || DEFAULT_GRADE, unit: unit, lessons: new Map() });
}
const unitData = map.get(unit);
if (!unitData.lessons.has(lesson))
unitData.lessons.set(lesson, []);
unitData.lessons.get(lesson).push(item);
}
const groups = [];
// Sort by grade (numeric), then by unit number
const sortedUnits = Array.from(map.values()).sort((a, b) => {
const gradeA = this.parseGradeValue(a.grade);
const gradeB = this.parseGradeValue(b.grade);
if (gradeA !== gradeB)
return gradeA - gradeB;
const unitNumA = this.parseUnitNumber(a.unit);
const unitNumB = this.parseUnitNumber(b.unit);
return unitNumA - unitNumB;
});
for (const unitData of sortedUnits) {
const sortedLessons = Array.from(unitData.lessons.keys()).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
for (const lesson of sortedLessons) {
const lessonItems = unitData.lessons.get(lesson);
const sortedItems = this.sortInvestigationItems(lessonItems, sortConfig);
groups.push({
title: `${unitData.unit}, ${lesson}`,
items: sortedItems,
count: sortedItems.length,
});
}
}
return groups;
}
}
Source