/**
* Investigation sorting, filtering, and grouping helper
* @category Utils
*/
import { InvestigationGroupType, InvestigationSectionType, InvestigationSortBy, SortOrder, } from "@typez/investigation/enums";
import { DEFAULT_AUTHOR_ID, DEFAULT_AUTHOR_NAME, DEFAULT_CURRICULUM, DEFAULT_GRADE, DEFAULT_LESSON, DEFAULT_SUBJECT, DEFAULT_STATUS, DEFAULT_UNIT, GRADE_SORT_EMPTY, GRADE_SORT_UNKNOWN, GRADE_SORT_VALUES, STATUS_DISPLAY_MAP, SUBJECT_ORDER, } from "./constants";
import { getDefaultSortOrder } from "./mongo-aggregation";
// ============================================================================
// HELPER CLASS
// ============================================================================
/** Helper class for investigation sorting, filtering, and grouping operations */
export class InvestigationSortingHelper {
// ==========================================================================
// GRADE UTILITIES
// ==========================================================================
/**
* Converts grade to a sortable numeric value.
* Order: K=0, 1-12=1-12, P=13, B=14, C=15, other=99, empty=999
* @param {string|null|undefined} grade - The grade string
* @returns {number} Numeric sort value
*/
gradeToSortValue(grade) {
if (!grade || grade === DEFAULT_GRADE)
return GRADE_SORT_EMPTY;
const trimmed = String(grade).trim();
const lower = trimmed.toLowerCase();
if (/^[0-9]+$/.test(trimmed))
return parseInt(trimmed, 10);
return GRADE_SORT_VALUES[lower] ?? GRADE_SORT_UNKNOWN;
}
/**
* Compares two grades for sorting
* @param {string} a - First grade to compare
* @param {string} b - Second grade to compare
* @returns {number} Comparison result
*/
compareGrades(a, b) {
return this.gradeToSortValue(a) - this.gradeToSortValue(b);
}
// ==========================================================================
// UNIT UTILITIES
// ==========================================================================
/**
* Extracts unit identifier: "Unit 4.1: Title" → "4.1"
* @param {string|null|undefined} unitStr - The unit string to parse
* @returns {string|null} The unit identifier or null
*/
extractUnitIdentifier(unitStr) {
if (!unitStr)
return null;
const trimmed = String(unitStr).trim();
const words = trimmed.split(/\s+/);
if (words.length < 2)
return null;
if (words[0]?.toLowerCase() !== "unit")
return null;
const secondWord = words[1];
if (!secondWord)
return null;
return secondWord.split(":")[0] || null;
}
/**
* Parses unit number from unit string
* @param {string|null|undefined} unitStr - The unit string to parse
* @returns {number} The unit number or 999 if not found
*/
parseUnitNumber(unitStr) {
if (!unitStr)
return 999;
const identifier = this.extractUnitIdentifier(unitStr);
if (!identifier)
return 999;
const parts = identifier.split(".");
if (parts.length < 2 || !parts[1])
return 0;
const unitNum = parseInt(parts[1], 10);
return isNaN(unitNum) ? 999 : unitNum;
}
/**
* Extracts grade from unit string: "Unit 4.1: Title" → "4"
* @param {string|null|undefined} unitStr - The unit string to parse
* @returns {string} The grade string or empty string
*/
parseUnitGrade(unitStr) {
const identifier = this.extractUnitIdentifier(unitStr);
if (!identifier)
return "";
return identifier.split(".")[0] || "";
}
/**
* Gets sort type for unit grade: 0=K, 1=numeric, 2=other, 3=empty
* @param {string} grade - The grade string
* @returns {number} The sort type number
*/
getUnitGradeSortType(grade) {
if (!grade)
return 3;
const lower = grade.toLowerCase();
if (lower === "k")
return 0;
if (/^[0-9]+$/.test(grade))
return 1;
return 2;
}
/**
* Compares two unit grades
* @param {string} gradeA - First grade to compare
* @param {string} gradeB - Second grade to compare
* @returns {number} Comparison result
*/
compareUnitGrades(gradeA, gradeB) {
const typeA = this.getUnitGradeSortType(gradeA);
const typeB = this.getUnitGradeSortType(gradeB);
if (typeA !== typeB)
return typeA - typeB;
if (typeA === 1) {
return parseInt(gradeA, 10) - parseInt(gradeB, 10);
}
return gradeA.toLowerCase().localeCompare(gradeB.toLowerCase());
}
// ==========================================================================
// AUTHOR UTILITIES
// ==========================================================================
/**
* Extracts surname from full name
* @param {string|null} name - The full name
* @returns {string} The surname or empty string
*/
getSurnameFromName(name) {
if (!name)
return "";
const parts = name.trim().split(/\s+/);
return parts[parts.length - 1] || "";
}
/**
* Sorts author profiles: current user first, then by surname A→Z
* @template T
* @param {T[]} profiles - Array of profiles to sort
* @param {string} [currentUserId] - Optional current user ID to prioritize
* @returns {T[]} Sorted array of profiles
*/
sortAuthorProfiles(profiles, currentUserId) {
return [...profiles].sort((a, b) => {
if (currentUserId && a.id === currentUserId)
return -1;
if (currentUserId && b.id === currentUserId)
return 1;
const surnameA = this.getSurnameFromName(a.name);
const surnameB = this.getSurnameFromName(b.name);
return surnameA.localeCompare(surnameB);
});
}
// ==========================================================================
// SUBJECT UTILITIES
// ==========================================================================
/**
* Returns subject ordering map
* @returns {Record<string, number>} Map of subject names to sort order
*/
getSubjectOrder() {
return SUBJECT_ORDER;
}
// ==========================================================================
// ITEM SORTING
// ==========================================================================
/**
* Sorts investigation items based on search criteria
* @param {IInvestigationListItemDto[]} items - Array of items to sort
* @param {IInvestigationSearchDto} searchDto - Search configuration with sort options
* @returns {IInvestigationListItemDto[]} Sorted array of items
*/
sortInvestigationItems(items, searchDto) {
const sortBy = searchDto.sortBy || InvestigationSortBy.UPDATED_AT;
const sortOrder = searchDto.sortOrder || getDefaultSortOrder(sortBy);
const dir = sortOrder === SortOrder.ASC ? 1 : -1;
return items.sort((a, b) => {
const comparison = this.compareItems(a, b, sortBy);
return comparison * dir;
});
}
compareItems(a, b, sortBy) {
switch (sortBy) {
case InvestigationSortBy.CREATED_AT:
case 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;
}
case InvestigationSortBy.TITLE:
case InvestigationSortBy.ACTIVITY: {
const aVal = (a[sortBy] || "").toString().toLowerCase();
const bVal = (b[sortBy] || "").toString().toLowerCase();
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
}
case 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;
}
case InvestigationSortBy.VIEWS:
case InvestigationSortBy.COMPLETIONS:
return Number(a[sortBy] || 0) - Number(b[sortBy] || 0);
default:
return 0;
}
}
// ==========================================================================
// SECTION BUILDING
// ==========================================================================
/**
* Builds sections based on section type
* @param {IInvestigationListItemDto[]} items - Array of items to group into sections
* @param {SectionType} section - The section type
* @param {IInvestigationSearchDto} sortConfig - Search configuration with sort options
* @returns {IInvestigationSection[]} Array of sections
*/
buildSections(items, section, sortConfig) {
switch (section) {
case InvestigationSectionType.CURRICULUM_GRADE:
return this.buildCurriculumGradeSections(items, sortConfig);
case InvestigationSectionType.GRADE:
return this.buildGradeSections(items, sortConfig);
case InvestigationSectionType.CURRICULUM_STATUS:
return this.buildCurriculumStatusSections(items, sortConfig);
case InvestigationSectionType.AUTHOR:
return this.buildAuthorSections(items, sortConfig, sortConfig.currentUserId);
default:
return [];
}
}
/**
* Formats status for display
* @param {string} status - The status code
* @returns {string} Human-readable status string
*/
formatStatusDisplay(status) {
return STATUS_DISPLAY_MAP[status] || status;
}
/**
* Builds sections grouped by curriculum and grade
* @param {IInvestigationListItemDto[]} items - Array of items to group
* @param {IInvestigationSearchDto} sortConfig - Search configuration with sort options
* @returns {IInvestigationSection[]} Array of sections grouped by curriculum and grade
*/
buildCurriculumGradeSections(items, sortConfig) {
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}`;
if (!map.has(key)) {
map.set(key, { curriculum, grade, items: [] });
}
map.get(key).items.push(item);
}
return Array.from(map.values())
.sort((a, b) => {
const currCmp = this.compareWithDefaultLast(a.curriculum, b.curriculum);
return currCmp !== 0 ? currCmp : this.compareGrades(a.grade, b.grade);
})
.map(({ curriculum, grade, items: groupItems }) => ({
title: curriculum,
subtitle: `Grade ${grade}`,
items: this.sortInvestigationItems(groupItems, sortConfig),
count: groupItems.length,
}));
}
/**
* Builds sections grouped by grade only
* @param {IInvestigationListItemDto[]} items - Array of items to group
* @param {IInvestigationSearchDto} sortConfig - Search configuration with sort options
* @returns {IInvestigationSection[]} Array of sections grouped 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);
}
return Array.from(map.keys())
.sort((a, b) => this.compareGrades(a, b))
.map((grade) => {
const gradeItems = map.get(grade);
return {
title: `Grade ${grade}`,
items: this.sortInvestigationItems(gradeItems, sortConfig),
count: gradeItems.length,
};
});
}
/**
* Builds sections grouped by curriculum and status
* @param {IInvestigationListItemDto[]} items - Array of items to group
* @param {IInvestigationSearchDto} sortConfig - Search configuration with sort options
* @returns {IInvestigationSection[]} Array of sections grouped by curriculum and status
*/
buildCurriculumStatusSections(items, sortConfig) {
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}`;
if (!map.has(key)) {
map.set(key, { curriculum, status, items: [] });
}
map.get(key).items.push(item);
}
return Array.from(map.values())
.sort((a, b) => {
const currCmp = this.compareWithDefaultLast(a.curriculum, b.curriculum);
return currCmp !== 0 ? currCmp : a.status.localeCompare(b.status);
})
.map(({ curriculum, status, items: groupItems }) => ({
title: curriculum,
subtitle: this.formatStatusDisplay(status),
items: this.sortInvestigationItems(groupItems, sortConfig),
count: groupItems.length,
}));
}
/**
* Builds sections grouped by author with current user first
* @param {IInvestigationListItemDto[]} items - Array of items to group
* @param {IInvestigationSearchDto} sortConfig - Search configuration with sort options
* @param {string} [currentUserId] - Optional current user ID to prioritize
* @returns {IInvestigationSection[]} Array of sections grouped 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) => {
if (a.title === DEFAULT_AUTHOR_NAME)
return 1;
if (b.title === DEFAULT_AUTHOR_NAME)
return -1;
const surnameA = this.getSurnameFromName(a.title);
const surnameB = this.getSurnameFromName(b.title);
return surnameA.localeCompare(surnameB);
});
return [...currentUserSection, ...otherSections];
}
compareWithDefaultLast(a, b) {
if (a === DEFAULT_CURRICULUM && b === DEFAULT_CURRICULUM)
return 0;
if (a === DEFAULT_CURRICULUM)
return 1;
if (b === DEFAULT_CURRICULUM)
return -1;
return a.localeCompare(b);
}
// ==========================================================================
// GROUPING
// ==========================================================================
/**
* Applies grouping to investigation data
* @param {object} data - Data containing items or sections
* @param {IInvestigationListItemDto[]} [data.items] - Optional items array
* @param {IInvestigationSection[]} [data.sections] - Optional sections array
* @param {GroupType} group - The group type to apply
* @param {IInvestigationSearchDto} sortConfig - Search configuration with sort options
* @returns {object} Data with grouping applied
*/
applyGrouping(data, group, sortConfig) {
if (!group)
return data;
if (data.items && !data.sections) {
return { sections: this.groupFlatItems(data.items, group, sortConfig) };
}
if (data.sections) {
return {
sections: data.sections.map((section) => section.items
? this.applyGroupingToSection(section, group, sortConfig)
: section.subsections
? {
...section,
subsections: section.subsections.map((sub) => this.applyGroupingToSubsection(sub, group, sortConfig)),
}
: section),
};
}
return data;
}
groupFlatItems(items, group, sortConfig) {
switch (group) {
case InvestigationGroupType.SUBJECTS:
return this.groupBySubjects(items, sortConfig);
case InvestigationGroupType.UNITS:
return this.groupByUnits(items, sortConfig);
case InvestigationGroupType.UNITS_LESSONS:
return this.groupByUnitsLessons(items, sortConfig);
default:
return [];
}
}
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 })),
};
}
applyGroupingToSubsection(subsection, group, sortConfig) {
if (!subsection.items)
return subsection;
return {
...subsection,
items: undefined,
groups: this.groupItemsToGroups(subsection.items, group, sortConfig),
};
}
groupItemsToGroups(items, group, sortConfig) {
switch (group) {
case InvestigationGroupType.SUBJECTS:
return this.groupItemsBySubjects(items, sortConfig);
case InvestigationGroupType.UNITS:
return this.groupItemsByUnits(items, sortConfig);
case InvestigationGroupType.UNITS_LESSONS:
return this.groupItemsByUnitsLessons(items, sortConfig);
default:
return [];
}
}
// ==========================================================================
// GROUP BY IMPLEMENTATIONS
// ==========================================================================
groupBySubjects(items, sortConfig) {
return this.groupItemsBySubjects(items, sortConfig).map((g) => ({
title: g.title,
items: g.items,
count: g.count,
}));
}
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);
}
return Array.from(map.keys())
.sort((a, b) => (subjectOrder[a] || 999) - (subjectOrder[b] || 999))
.map((subject) => {
const subjectItems = map.get(subject);
return {
title: subject,
items: this.sortInvestigationItems(subjectItems, sortConfig),
count: subjectItems.length,
};
});
}
groupByUnits(items, sortConfig) {
return this.groupItemsByUnits(items, sortConfig).map((g) => ({
title: g.title,
items: g.items,
count: g.count,
}));
}
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: [], unitGrade: this.parseUnitGrade(unit), unit });
}
map.get(unit).items.push(item);
}
return Array.from(map.values())
.sort((a, b) => {
const gradeCmp = this.compareUnitGrades(a.unitGrade, b.unitGrade);
return gradeCmp !== 0
? gradeCmp
: this.parseUnitNumber(a.unit) - this.parseUnitNumber(b.unit);
})
.map((entry) => ({
title: entry.unit,
items: this.sortInvestigationItems(entry.items, sortConfig),
count: entry.items.length,
}));
}
groupByUnitsLessons(items, sortConfig) {
return this.groupItemsByUnitsLessons(items, sortConfig).map((g) => ({
title: g.title,
items: g.items,
count: g.count,
}));
}
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, { unitGrade: this.parseUnitGrade(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 = [];
const sortedUnits = Array.from(map.values()).sort((a, b) => {
const gradeCmp = this.compareUnitGrades(a.unitGrade, b.unitGrade);
return gradeCmp !== 0
? gradeCmp
: this.parseUnitNumber(a.unit) - this.parseUnitNumber(b.unit);
});
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);
groups.push({
title: `${unitData.unit}, ${lesson}`,
items: this.sortInvestigationItems(lessonItems, sortConfig),
count: lessonItems.length,
});
}
}
return groups;
}
}
Source