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
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"createInteractionModule": "readonly",
"SCRIPT_USER_EMAIL": "readonly",
"IS_ADMIN": "readonly",
"escapeHtml": "readonly"
"escapeHtml": "readonly",
"App": "writable"
},
"rules": {
"no-unused-vars": "warn",
Expand Down
2 changes: 2 additions & 0 deletions Index.html
Original file line number Diff line number Diff line change
Expand Up @@ -473,5 +473,7 @@ <h2 class="text-2xl font-bold mb-6 text-center text-purple-800">PDF 下載選項
<?!= HtmlService.createHtmlOutputFromFile('UI.js.html').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('Interaction.js.html').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('JavaScript').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('UtilityFunctions.js').getContent(); ?>
<script>App.init();</script>
</body>
</html>
59 changes: 8 additions & 51 deletions JavaScript.html
Original file line number Diff line number Diff line change
Expand Up @@ -855,10 +855,7 @@
});
},

getShortUserName: function (email) {
if (!email || email.indexOf('@') === -1) return email;
return email.split('@')[0];
},
// getShortUserName — moved to UtilityFunctions.js.html (Phase 1 PR1)

isCurrentUserAdmin: function () {
return typeof IS_ADMIN !== 'undefined' ? IS_ADMIN : false;
Expand Down Expand Up @@ -895,17 +892,9 @@
return scheduleData;
},

generateUniqueId: function () {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
},
// generateUniqueId — moved to UtilityFunctions.js.html (Phase 1 PR1)

stringToHashCode: function (str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
}
return hash;
},
// stringToHashCode — moved to UtilityFunctions.js.html (Phase 1 PR1)

buildCourseColorMap: function (dataSource = this.scheduleData) {
this.courseColorMap = {};
Expand Down Expand Up @@ -957,36 +946,11 @@
});
},

timeToMinutes: function (timeStr) {
try {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
} catch (e) {
console.error('時間轉換失敗:', e, timeStr);
return 0;
}
},
// timeToMinutes — moved to UtilityFunctions.js.html (Phase 1 PR1)

formatTime: function (timeStr) {
if (!timeStr) return '00:00';
timeStr = timeStr.trim();
if (AppConfig.TIME_REGEX.test(timeStr)) {
const [hours, minutes] = timeStr.split(':');
return hours.padStart(2, '0') + ':' + minutes.padStart(2, '0');
}
return '00:00';
},
// formatTime — moved to UtilityFunctions.js.html (Phase 1 PR1)

formatTimestampForFilename: function (timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
const YYYY = date.getFullYear();
const MM = String(date.getMonth() + 1).padStart(2, '0');
const DD = String(date.getDate()).padStart(2, '0');
const HH = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
return `${YYYY}${MM}${DD}_${HH}${mm}`;
},
// formatTimestampForFilename — moved to UtilityFunctions.js.html (Phase 1 PR1)

_filterScheduleData: function (data, filterPredicate) {
const filteredData = {};
Expand Down Expand Up @@ -1105,14 +1069,7 @@
this.historyModule.saveState();
},

hexToRgb: function (hex) {
if (!hex) return [255, 255, 255]; // Default to white if color is undefined
const bigint = parseInt(hex.slice(1), 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return [r, g, b];
},
// hexToRgb — moved to UtilityFunctions.js.html (Phase 1 PR1)

printScheduleToPdf: async function (pdfOptions) {
this.ui.showLoading('正在準備下載,請稍候...');
Expand Down Expand Up @@ -1437,7 +1394,7 @@

// --- Initialization ---
window.App = App; // Expose App to global scope for modules
App.init();
// App.init() moved to Index.html — runs after all domain modules load (Phase 1 PR1)

})();
</script>
72 changes: 72 additions & 0 deletions UtilityFunctions.js.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<script>
/**
* UtilityFunctions.js.html — Pure utility functions extracted from JavaScript.html
*
* Part of #129 Phase 1 (PR1/7): IIFE-wrapped domain separation.
* These are stateless, zero-dependency pure functions that operate on
* input parameters only (no `this`, no App state access).
*
* Load order: after JavaScript.html (which creates window.App),
* before App.init() trigger.
*/
(function(App) {

App.getShortUserName = function(email) {
if (!email || email.indexOf('@') === -1) return email;
return email.split('@')[0];
};

App.generateUniqueId = function() {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
};

App.stringToHashCode = function(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
}
return hash;
};

App.timeToMinutes = function(timeStr) {
try {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
} catch (e) {
console.error('時間轉換失敗:', e, timeStr);
return 0;
}
};

App.formatTime = function(timeStr) {
if (!timeStr) return '00:00';
timeStr = timeStr.trim();
if (AppConfig.TIME_REGEX.test(timeStr)) {
const [hours, minutes] = timeStr.split(':');
return hours.padStart(2, '0') + ':' + minutes.padStart(2, '0');
}
return '00:00';
};

App.formatTimestampForFilename = function(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
const YYYY = date.getFullYear();
const MM = String(date.getMonth() + 1).padStart(2, '0');
const DD = String(date.getDate()).padStart(2, '0');
const HH = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
return `${YYYY}${MM}${DD}_${HH}${mm}`;
};

App.hexToRgb = function(hex) {
if (!hex) return [255, 255, 255]; // Default to white if color is undefined
const bigint = parseInt(hex.slice(1), 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return [r, g, b];
};

})(App);
</script>
41 changes: 28 additions & 13 deletions tests/unit/appWiringContracts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,19 @@ const EXTRACTED_TO_LIB = new Set([
// stateHelpers.js
'handleEditClassroom', 'findNextUpcomingClasses', 'saveDataToServer',
'countOccurrences', 'updateAllOccurrences',
// utilityFunctions.js
'stringToHashCode', 'hexToRgb', 'getShortUserName', 'formatTime',
'formatTimestampForFilename', 'sortClassrooms', 'ensureDataIds',
// utilityFunctions.js (remaining in JavaScript.html)
'sortClassrooms', 'ensureDataIds',
'buildCourseColorMap',
// dataCollectionHelpers.js
'getAllTags', 'getGlobalAllTags', 'getGlobalAllCourseNames', 'getGlobalAllTeachers',
// frontendUtils.js
'timeToMinutes', 'checkTimeConflict', 'filterDataByTags', 'filterDataByActiveFilters',
'checkTimeConflict', 'filterDataByTags', 'filterDataByActiveFilters',
// interactionHelpers.js (handleDrop → applyDrop)
'handleDrop',
// appLifecycleHelpers.js (new — this wave)
'loadInitialSchedules', 'loadSchedule', 'canManageCurrentScheduleSettings',
'loadAndApplyPersistedFilters', 'applyFilters', 'clearAdvancedFilters',
'clearAllFilters', 'generateUniqueId', 'refreshLockHeartbeat',
'clearAllFilters', 'refreshLockHeartbeat',
'saveSchedulesToLocal',
]);

Expand Down Expand Up @@ -117,6 +116,17 @@ const PRIVATE_HELPERS = new Set([
'_filterScheduleData',
]);

/**
* Methods moved to IIFE domain modules (Phase 1 #129).
* These still exist on App at runtime but are no longer in JavaScript.html source.
* They are tested via their DI copies in tests/lib/.
*/
const IIFE_EXTRACTED = new Set([
// UtilityFunctions.js.html (PR1)
'getShortUserName', 'generateUniqueId', 'stringToHashCode',
'timeToMinutes', 'formatTime', 'formatTimestampForFilename', 'hexToRgb',
]);

// ─── Tests ─────────────────────────────────────────────────────────────────

describe('JavaScript.html App method wiring contracts (#116)', () => {
Expand All @@ -127,8 +137,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('_'));
// If this changes, a method was added/removed — update the classification above
expect(publicMethods.length).toBe(48);
// 48 original - 7 IIFE-extracted = 41 remaining in JavaScript.html
expect(publicMethods.length).toBe(41);
});

it('every public App method should be classified (extracted OR not-extractable)', () => {
Expand All @@ -138,13 +148,15 @@ describe('JavaScript.html App method wiring contracts (#116)', () => {
);
expect(
unclassified,
`Unclassified methods found — add to EXTRACTED_TO_LIB or NOT_EXTRACTABLE: ${unclassified.join(', ')}`
`Unclassified methods found — add to EXTRACTED_TO_LIB, NOT_EXTRACTABLE, or IIFE_EXTRACTED: ${unclassified.join(', ')}`
).toEqual([]);
});

it('no method should be in both EXTRACTED and NOT_EXTRACTABLE', () => {
const overlap = [...EXTRACTED_TO_LIB].filter(m => NOT_EXTRACTABLE.has(m));
expect(overlap).toEqual([]);
it('no method should be in multiple classifications', () => {
const allSets = [EXTRACTED_TO_LIB, NOT_EXTRACTABLE, IIFE_EXTRACTED];
const allItems = [...EXTRACTED_TO_LIB, ...NOT_EXTRACTABLE, ...IIFE_EXTRACTED];
const duplicates = allItems.filter((item, idx) => allItems.indexOf(item) !== idx);
expect(duplicates).toEqual([]);
});

it('private helpers should be accounted for', () => {
Expand Down Expand Up @@ -180,8 +192,11 @@ describe('JavaScript.html App method wiring contracts (#116)', () => {
it('should have good extraction ratio', () => {
const publicMethods = appMethods.filter(m => !m.startsWith('_'));
const extractedCount = publicMethods.filter(m => EXTRACTED_TO_LIB.has(m)).length;
const ratio = extractedCount / publicMethods.length;
// Target: at least 60% of public methods should be extracted
const iifeCount = IIFE_EXTRACTED.size;
const totalExtracted = extractedCount + iifeCount;
const totalPublicIncludingIife = publicMethods.length + iifeCount;
const ratio = totalExtracted / totalPublicIncludingIife;
// Target: at least 60% of ALL public methods (including IIFE-extracted) should be extracted
expect(ratio).toBeGreaterThanOrEqual(0.6);
});
});
Expand Down
Loading