Source

utils/investigation.utils.js

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