Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions FilterEngine.js.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<script>
/**
* FilterEngine.js.html — Filter logic methods extracted from JavaScript.html
*
* Part of #129 Phase 1 (PR4/7): IIFE-wrapped domain separation.
* These methods handle tag/course/teacher filtering, persisted filter
* restoration, and schedule data filtering by predicates.
*
* Load order: after JavaScript.html + DataCollection.js (which provides
* App.getAllTags), before App.init() trigger.
*
* Dependencies: DataCollection.js (App.getAllTags), AppElements (filter* DOM),
* localStorage (persisted filters)
*/
(function(App) {

// --- Filter UI methods ---

App.loadAndApplyPersistedFilters = function() {
const persistedTagsJSON = localStorage.getItem('activeTagFilters');
if (persistedTagsJSON) {
try {
const persistedTags = JSON.parse(persistedTagsJSON);
const allCurrentTags = new Set(App.getAllTags());

const validPersistedTags = persistedTags.filter(tag => allCurrentTags.has(tag));

App.activeFilters = validPersistedTags.map(tag => ({ type: 'tag', value: tag }));

if (App.tagFilterTagify) {
App.tagFilterTagify.loadOriginalValues(validPersistedTags);
}

if (persistedTags.length !== validPersistedTags.length) {
localStorage.setItem('activeTagFilters', JSON.stringify(validPersistedTags));
}
} catch (e) {
console.error("Failed to parse persisted tag filters:", e);
localStorage.removeItem('activeTagFilters');
App.activeFilters = [];
}
} else {
App.activeFilters = [];
}
App.ui.updateClearFilterButtonVisibility();
App.ui.updateAdvancedFilterButtonState();
App.ui.updateClearAllFiltersButtonVisibility();
};

App.toggleAllFilterCheckboxes = function(shouldBeChecked) {
AppElements.filterCourseList.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = shouldBeChecked;
});
};

App.applyFilters = function() {
const filterType = AppElements.filterTypeSelector.querySelector('input[name="filterType"]:checked').value;
const selectedItems = Array.from(AppElements.filterCourseList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value);

const tagFilters = App.activeFilters.filter(f => f.type === 'tag');
const newAdvancedFilters = selectedItems.map(item => ({ type: filterType, value: item }));
App.activeFilters = [...tagFilters, ...newAdvancedFilters];

App.ui.renderScheduleTable();
App.ui.updateAdvancedFilterButtonState();
App.ui.updateClearAllFiltersButtonVisibility();
AppElements.filterModal.style.display = 'none';
};

App.clearAdvancedFilters = function() {
App.activeFilters = App.activeFilters.filter(f => f.type === 'tag');
App.modals.populateFilterModal();
App.ui.renderScheduleTable();
App.ui.updateAdvancedFilterButtonState();
App.ui.updateClearAllFiltersButtonVisibility();
};

App.clearAllFilters = function() {
App.activeFilters = [];
localStorage.removeItem('activeTagFilters');
if (App.tagFilterTagify) {
App.tagFilterTagify.removeAllTags();
}
App.modals.populateFilterModal();
App.ui.renderScheduleTable();
App.ui.updateAdvancedFilterButtonState();
App.ui.updateClearFilterButtonVisibility();
App.ui.updateClearAllFiltersButtonVisibility();
};

// --- Data filtering methods ---

App._filterScheduleData = function(data, filterPredicate) {
const filteredData = {};
for (const classroom in data) {
const dayData = data[classroom];
const newDayData = {};
let classroomHasCourses = false;
for (const day in dayData) {
const filteredCourses = dayData[day].filter(filterPredicate);
if (filteredCourses.length > 0) {
newDayData[day] = filteredCourses;
classroomHasCourses = true;
}
}
if (classroomHasCourses) {
filteredData[classroom] = newDayData;
}
}
return filteredData;
};

App.filterDataByTags = function(data) {
const tagFilters = new Set(App.activeFilters.filter(f => f.type === 'tag').map(f => f.value));
if (tagFilters.size === 0) {
return data;
}
return App._filterScheduleData(data, course =>
course.tags && course.tags.some(d => tagFilters.has(d))
);
};

App.filterDataByActiveFilters = function(data) {
const nameFilters = new Set(App.activeFilters.filter(f => f.type === 'name').map(f => f.value));
const tagFilters = new Set(App.activeFilters.filter(f => f.type === 'tag').map(f => f.value));
const teacherFilters = new Set(App.activeFilters.filter(f => f.type === 'teacher').map(f => f.value));

if (nameFilters.size === 0 && tagFilters.size === 0 && teacherFilters.size === 0) {
return data;
}

return App._filterScheduleData(data, course => {
const nameMatch = nameFilters.size === 0 || nameFilters.has(course.name);
const tagMatch = tagFilters.size === 0 || (course.tags && course.tags.some(d => tagFilters.has(d)));
const teacherMatch = teacherFilters.size === 0 || teacherFilters.has(course.teacher);
return nameMatch && tagMatch && teacherMatch;
});
};

})(App);
</script>
1 change: 1 addition & 0 deletions Index.html
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ <h2 class="text-2xl font-bold mb-6 text-center text-purple-800">PDF 下載選項
<?!= HtmlService.createHtmlOutputFromFile('UtilityFunctions.js').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('LockManager.js').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('DataCollection.js').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('FilterEngine.js').getContent(); ?>
<script>App.init();</script>
</body>
</html>
121 changes: 3 additions & 118 deletions JavaScript.html
Original file line number Diff line number Diff line change
Expand Up @@ -398,37 +398,8 @@
}
},

// --- FILTER LOGIC ---
loadAndApplyPersistedFilters: function () {
const persistedTagsJSON = localStorage.getItem('activeTagFilters');
if (persistedTagsJSON) {
try {
const persistedTags = JSON.parse(persistedTagsJSON);
const allCurrentTags = new Set(this.getAllTags());

const validPersistedTags = persistedTags.filter(tag => allCurrentTags.has(tag));

this.activeFilters = validPersistedTags.map(tag => ({ type: 'tag', value: tag }));

if (this.tagFilterTagify) {
this.tagFilterTagify.loadOriginalValues(validPersistedTags);
}

if (persistedTags.length !== validPersistedTags.length) {
localStorage.setItem('activeTagFilters', JSON.stringify(validPersistedTags));
}
} catch (e) {
console.error("Failed to parse persisted tag filters:", e);
localStorage.removeItem('activeTagFilters');
this.activeFilters = [];
}
} else {
this.activeFilters = [];
}
this.ui.updateClearFilterButtonVisibility();
this.ui.updateAdvancedFilterButtonState();
this.ui.updateClearAllFiltersButtonVisibility();
},
// loadAndApplyPersistedFilters, toggleAllFilterCheckboxes, applyFilters — moved to FilterEngine.js.html (Phase 1 PR4)
// clearAdvancedFilters, clearAllFilters — moved to FilterEngine.js.html (Phase 1 PR4)

applyTagFilters: async function () {
if (!this.tagFilterTagify) return;
Expand Down Expand Up @@ -460,47 +431,6 @@
this.ui.updateClearAllFiltersButtonVisibility();
},

toggleAllFilterCheckboxes: function (shouldBeChecked) {
AppElements.filterCourseList.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = shouldBeChecked;
});
},

applyFilters: function () {
const filterType = AppElements.filterTypeSelector.querySelector('input[name="filterType"]:checked').value;
const selectedItems = Array.from(AppElements.filterCourseList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value);

const tagFilters = this.activeFilters.filter(f => f.type === 'tag');
const newAdvancedFilters = selectedItems.map(item => ({ type: filterType, value: item }));
this.activeFilters = [...tagFilters, ...newAdvancedFilters];

this.ui.renderScheduleTable();
this.ui.updateAdvancedFilterButtonState();
this.ui.updateClearAllFiltersButtonVisibility();
AppElements.filterModal.style.display = 'none';
},

clearAdvancedFilters: function () {
this.activeFilters = this.activeFilters.filter(f => f.type === 'tag');
this.modals.populateFilterModal();
this.ui.renderScheduleTable();
this.ui.updateAdvancedFilterButtonState();
this.ui.updateClearAllFiltersButtonVisibility();
},

clearAllFilters: function () {
this.activeFilters = [];
localStorage.removeItem('activeTagFilters');
if (this.tagFilterTagify) {
this.tagFilterTagify.removeAllTags();
}
this.modals.populateFilterModal();
this.ui.renderScheduleTable();
this.ui.updateAdvancedFilterButtonState();
this.ui.updateClearFilterButtonVisibility();
this.ui.updateClearAllFiltersButtonVisibility();
},

// --- API COMMUNICATION ---
loadVersions: async function () {
const select = AppElements.versionHistorySelect;
Expand Down Expand Up @@ -733,52 +663,7 @@

// formatTimestampForFilename — moved to UtilityFunctions.js.html (Phase 1 PR1)

_filterScheduleData: function (data, filterPredicate) {
const filteredData = {};
for (const classroom in data) {
const dayData = data[classroom];
const newDayData = {};
let classroomHasCourses = false;
for (const day in dayData) {
const filteredCourses = dayData[day].filter(filterPredicate);
if (filteredCourses.length > 0) {
newDayData[day] = filteredCourses;
classroomHasCourses = true;
}
}
if (classroomHasCourses) {
filteredData[classroom] = newDayData;
}
}
return filteredData;
},

filterDataByTags: function (data) {
const tagFilters = new Set(this.activeFilters.filter(f => f.type === 'tag').map(f => f.value));
if (tagFilters.size === 0) {
return data;
}
return this._filterScheduleData(data, course =>
course.tags && course.tags.some(d => tagFilters.has(d))
);
},

filterDataByActiveFilters: function (data) {
const nameFilters = new Set(this.activeFilters.filter(f => f.type === 'name').map(f => f.value));
const tagFilters = new Set(this.activeFilters.filter(f => f.type === 'tag').map(f => f.value));
const teacherFilters = new Set(this.activeFilters.filter(f => f.type === 'teacher').map(f => f.value));

if (nameFilters.size === 0 && tagFilters.size === 0 && teacherFilters.size === 0) {
return data;
}

return this._filterScheduleData(data, course => {
const nameMatch = nameFilters.size === 0 || nameFilters.has(course.name);
const tagMatch = tagFilters.size === 0 || (course.tags && course.tags.some(d => tagFilters.has(d)));
const teacherMatch = teacherFilters.size === 0 || teacherFilters.has(course.teacher);
return nameMatch && tagMatch && teacherMatch;
});
},
// _filterScheduleData, filterDataByTags, filterDataByActiveFilters — moved to FilterEngine.js.html (Phase 1 PR4)

// _forEachCourse, countOccurrences, updateAllOccurrences — moved to DataCollection.js.html (Phase 1 PR3)

Expand Down
17 changes: 9 additions & 8 deletions tests/unit/appWiringContracts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,10 @@ const privateMethods = extractPrivateMethods(jsHtmlSource);
const EXTRACTED_TO_LIB = new Set([
// stateHelpers.js
'handleEditClassroom', 'saveDataToServer',
// frontendUtils.js
'filterDataByTags', 'filterDataByActiveFilters',
// interactionHelpers.js (handleDrop → applyDrop)
'handleDrop',
// appLifecycleHelpers.js (new — this wave)
'loadInitialSchedules', 'loadSchedule', 'canManageCurrentScheduleSettings',
'loadAndApplyPersistedFilters', 'applyFilters', 'clearAdvancedFilters',
'clearAllFilters',
'saveSchedulesToLocal',
]);

Expand All @@ -85,7 +81,6 @@ const NOT_EXTRACTABLE = new Set([
'handleScheduleListClick', // DOM event delegation + ServerApi + modals
'handleScheduleSelectChange', // DOM event + modal confirm + state (aggregateScheduleData already extracted)
'applyTagFilters', // Tagify instance + modal confirm + DOM
'toggleAllFilterCheckboxes', // DOM querySelectorAll
'loadVersions', // DOM + ServerApi
'handleLoadVersion', // DOM + ServerApi + state
'saveDataToLocal', // localStorage + Tagify + DOM (core sync logic extracted as processServerLoadResult)
Expand All @@ -99,7 +94,7 @@ const NOT_EXTRACTABLE = new Set([
* These are tested indirectly through their public callers.
*/
const PRIVATE_HELPERS = new Set([
'_filterScheduleData',
// _filterScheduleData — moved to FilterEngine.js.html (Phase 1 PR4)
]);

/**
Expand All @@ -121,6 +116,12 @@ const IIFE_EXTRACTED = new Set([
'checkTimeConflict',
// DataCollection.js.html (PR3) — private helpers also IIFE-extracted
'_forEachCourse', '_collectFromScheduleData', '_collectFromAllCourses',
// FilterEngine.js.html (PR4)
'loadAndApplyPersistedFilters', 'toggleAllFilterCheckboxes', 'applyFilters',
'clearAdvancedFilters', 'clearAllFilters',
'filterDataByTags', 'filterDataByActiveFilters',
// FilterEngine.js.html (PR4) — private helpers also IIFE-extracted
'_filterScheduleData',
]);

// ─── Tests ─────────────────────────────────────────────────────────────────
Expand All @@ -133,8 +134,8 @@ describe('JavaScript.html App method wiring contracts (#116)', () => {
it('should have expected total App method count', () => {
// All public methods (excluding underscore-prefixed private helpers)
const publicMethods = appMethods.filter(m => !m.startsWith('_'));
// 48 original - 7 PR1 - 4 PR2 public - 11 PR3 public = 26 remaining in JavaScript.html
expect(publicMethods.length).toBe(26);
// 48 original - 7 PR1 - 4 PR2 public - 11 PR3 public - 7 PR4 public = 19 remaining in JavaScript.html
expect(publicMethods.length).toBe(19);
});

it('every public App method should be classified (extracted OR not-extractable)', () => {
Expand Down
Loading
Loading