Source

utils/investigation/sorting-helper.js

/**
 * 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;
    }
}