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 @@ -15,7 +15,8 @@
"SCRIPT_USER_EMAIL": "readonly",
"IS_ADMIN": "readonly",
"escapeHtml": "readonly",
"App": "writable"
"App": "writable",
"ViewDecisionHelpers": "readonly"
},
"rules": {
"no-unused-vars": "warn",
Expand Down
1 change: 1 addition & 0 deletions Index.html
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ <h2 class="text-2xl font-bold mb-6 text-center text-purple-800">PDF 下載選項
<?!= HtmlService.createHtmlOutputFromFile('Api.js.html').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('Modals.js.html').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('History.js.html').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('ViewDecisionHelpers.js.html').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('UI.js.html').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('Interaction.js.html').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('JavaScript').getContent(); ?>
Expand Down
1 change: 1 addition & 0 deletions Interaction.js.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script>
function createInteractionModule(app) {

Check warning on line 2 in Interaction.js.html

View workflow job for this annotation

GitHub Actions / validate

'createInteractionModule' is defined but never used
const elements = AppElements; // Shortcut to elements

const interactionModule = {
Expand Down Expand Up @@ -547,6 +547,7 @@
}
const classroom = cell.dataset.classroom;
const day = parseInt(cell.dataset.day);
// Time-grid cells have no natural classroom and must remain non-addable.
if (!classroom || isNaN(day)) return;

this.createOrEditCourseForm(cell, null, { classroom, day });
Expand Down
6 changes: 5 additions & 1 deletion JavaScript.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@
activeFilters: [],
currentUserEmail: '',
currentViewMode: localStorage.getItem('lastViewMode') || AppConfig.MODES.WEEK,
viewSortMode: (localStorage.getItem('lastViewMode') || AppConfig.MODES.WEEK) === AppConfig.MODES.DAY ? 'time' : 'classroom',
viewSortMode: ViewDecisionHelpers.resolveRestoredSort({
currentViewMode: localStorage.getItem('lastViewMode') || AppConfig.MODES.WEEK,
storedSort: localStorage.getItem('lastViewSortMode'),
dayMode: AppConfig.MODES.DAY
}),
currentDayIndex: (() => {
// If the initial view mode is 'day', set the index to today. Otherwise, default to Monday (0).
if ((localStorage.getItem('lastViewMode') || AppConfig.MODES.WEEK) === AppConfig.MODES.DAY) {
Expand Down
19 changes: 12 additions & 7 deletions PDFExport.js.html
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,12 @@
// "星期" right-aligned at top
doc.text('星期', x + width - 1.5, topY, { align: 'right' });

// "教室/老師" left-aligned at bottom
const bottomText = App.viewSortMode === 'teacher' ? '老師' : '教室';
// "教室/老師/時間" left-aligned at bottom
const bottomText = ViewDecisionHelpers.resolvePdfDiagonalLabel({
currentViewMode: App.currentViewMode,
viewSortMode: App.viewSortMode,
weekMode: AppConfig.MODES.WEEK
});
doc.text(bottomText, x + 1.5, bottomY, { align: 'left' });
}
};
Expand Down Expand Up @@ -259,11 +263,12 @@
const time = item.querySelector('[data-field="time"]')?.textContent.trim() || '';
const refTeacher = item.querySelector('[data-field="teacher"]')?.textContent.trim() || '';

let bottomObj = `(${refTeacher})`;
if (App.viewSortMode === 'teacher') {
const cRoom = item.dataset.classroom || '';
bottomObj = `(教室:${cRoom})`;
}
const cRoom = item.dataset.classroom || '';
const bottomObj = ViewDecisionHelpers.resolvePdfBottomText({
viewSortMode: App.viewSortMode,
teacher: refTeacher,
classroom: cRoom
});

const content = `${name}\n${time}\n${bottomObj}`;
const color = App.courseColorMap[name];
Expand Down
16 changes: 16 additions & 0 deletions TestCases.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@
| :--- | :--- | :--- | :--- |
| **B-1-1** | **週/日檢視切換** | 1. 點擊右上角的「日檢視」按鈕。<br>2. 在日檢視下,點擊左右箭頭切換日期。<br>3. 點擊「週檢視」按鈕。 | 1. 應能正確切換週/日檢視的表格佈局。<br>2. 日檢視下應能正確切換日期並顯示對應課程。 |

### B-1-T: 週檢視「依時間」時間軸(issue #162)

> **Production gate:** 以下案例必須在當前 PR head 上實際操作,不得以 `tests/lib/` 綠燈替代。每案證據欄必填:tested head SHA、`clasp push` dev 版或 preview 識別、執行時間、執行者;PR 有新 commit 後必須以新 head 重驗。

| 測試案例 ID | 測試項目 | 操作步驟 | 通過判準 | 證據 provenance |
| :--- | :--- | :--- | :--- | :--- |
| **WT-1** | **週檢視依時間 renderer dispatch** | 週檢視按「依時間」。 | 左欄顯示時間軸,每列是一個起始時間,不是教室列。 | head SHA: ____<br>部署/preview: ____<br>時間: ____<br>執行者: ____ |
| **WT-2** | **7-day placement 與同格堆疊** | 準備週一至週日相同起始時間的課程,並在同一天安排多間教室;切換依時間。 | 課程正確落在週一~日欄位;同一時間×日期的多間教室課程在同格垂直堆疊。 | head SHA: ____<br>部署/preview: ____<br>時間: ____<br>執行者: ____ |
| **WT-3** | **卡片同時顯示老師與教室** | 在週檢視依時間檢視含老師、教室資料的課程卡。 | 每張卡同時顯示老師與教室;卡片仍保留可 inline 編輯/刪除所需資料。 | head SHA: ____<br>部署/preview: ____<br>時間: ____<br>執行者: ____ |
| **WT-4** | **week+time 持久化** | 週檢視按「依時間」,重新整理頁面。 | 重新整理後仍為週檢視+依時間;切換至日檢視後不受 week key 污染。 | head SHA: ____<br>部署/preview: ____<br>時間: ____<br>執行者: ____ |
| **WT-5** | **week-time PDF** | 週檢視依時間匯出 PDF;再於日檢視依時間匯出 PDF。 | 週 PDF 每張卡含老師與教室,左上角為「星期/時間」;日 PDF 左欄標籤維持「星期/教室」。 | head SHA: ____<br>部署/preview: ____<br>時間: ____<br>執行者: ____ |
| **WT-6** | **time-grid 空 cell 不新增** | 週檢視依時間對沒有課程的時間×星期空白格 double-click。 | 不開啟新增課程表單;課程卡仍可正常 inline 編輯/刪除。 | head SHA: ____<br>部署/preview: ____<br>時間: ____<br>執行者: ____ |
| **WT-7** | **全部課表 week-time 唯讀** | 切換「所有課程」後選週檢視、依時間。 | 顯示時間軸而非教室 fallback;課程卡為 readonly、無刪除鈕,editable 欄位不會繞過 `activeScheduleId` guard。 | head SHA: ____<br>部署/preview: ____<br>時間: ____<br>執行者: ____ |
| **WT-8** | **日檢視回歸** | 日檢視按依時間;檢查多課、教室左欄與 notes;在日檢視選 teacher 後重新整理。 | 日檢視仍一課一列、教室左欄、notes 行為不變;重新整理仍為 day+time。 | head SHA: ____<br>部署/preview: ____<br>時間: ____<br>執行者: ____ |
| **WT-9** | **篩選後全空不產列** | 週檢視依時間套用會濾掉全部課程的篩選;確認教室/日容器仍存在但課程為零。 | `tbody` 不出現任何時間列(零 `<tr>`,只剩表頭),不得產生空時間列。 | head SHA: ____<br>部署/preview: ____<br>時間: ____<br>執行者: ____ |

### B-2: 篩選功能

| 測試案例 ID | 測試項目 | 操作步驟 | 預期結果 |
Expand Down
152 changes: 107 additions & 45 deletions UI.js.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script>
function createUIModule(app) {

Check warning on line 2 in UI.js.html

View workflow job for this annotation

GitHub Actions / validate

'createUIModule' is defined but never used
const elements = AppElements; // Shortcut to elements
let activeTooltip = { element: null, hideTimeout: null }; // Make tooltip state accessible within the module

Expand Down Expand Up @@ -345,11 +345,13 @@
const allTagsTooltip = escapeHtml((classItem.tags || []).join(', '));
const notesHtml = app.currentViewMode === AppConfig.MODES.DAY ? `<div class="course-notes editable" data-field="notes">${escapeHtml(classItem.notes || '')}</div>` : '';

const { showTeacher, showClassroom } = ViewDecisionHelpers.resolveClassElementFields(viewContext);
let contextSpecificHtml = '';
if (viewContext === 'teacherSort') {
contextSpecificHtml = `<div>教室:${escapeHtml(classroom)}</div>`;
} else {
contextSpecificHtml = `<div class="editable" data-field="teacher">${escapeHtml(classItem.teacher)}</div>`;
if (showTeacher) {
contextSpecificHtml += `<div class="editable" data-field="teacher">${escapeHtml(classItem.teacher)}</div>`;
}
if (showClassroom) {
contextSpecificHtml += `<div>教室:${escapeHtml(classroom)}</div>`;
}

const viewModeClass = app.currentViewMode === AppConfig.MODES.DAY ? 'day-view-layout' : '';
Expand Down Expand Up @@ -411,18 +413,15 @@
// Default sort for day view is by time
app.viewSortMode = 'time';
} else { // mode === AppConfig.MODES.WEEK
// If switching to week view and the sort mode was 'time' (which is invalid for week view),
// default to 'classroom' sort.
if (app.viewSortMode === 'time') {
app.viewSortMode = 'classroom';
}
// Week view supports the time sort mode.
}
this.renderScheduleTable();
},

setViewSortMode: function(mode) {
if (app.viewSortMode === mode) return;
app.viewSortMode = mode;
localStorage.setItem('lastViewSortMode', mode);
// The actual button state is updated in updateViewControls
this.renderScheduleTable();
},
Expand All @@ -439,7 +438,7 @@

// --- Control visibility of sort selectors ---
AppElements.viewSortSelector.style.display = isAllSchedules ? 'none' : 'flex';
AppElements.daySortTimeBtn.style.display = isDayView ? 'block' : 'none';
AppElements.daySortTimeBtn.style.display = 'block';

const tableHead = AppElements.scheduleBody.previousElementSibling;
tableHead.innerHTML = '';
Expand Down Expand Up @@ -473,7 +472,8 @@
`
);
} else { // Week View
const weekViewHeaderLabel = app.viewSortMode === 'teacher' ? '老師' : '教室';
const weekViewHeaderLabel = app.viewSortMode === 'teacher' ? '老師'
: app.viewSortMode === 'time' ? '時間' : '教室';
tr.innerHTML = (
`<th class="border border-gray-300 p-2 w-20 bg-gray-200 diagonal-split">
<span class="diagonal-split-top">星期</span>
Expand All @@ -486,14 +486,43 @@
tableHead.appendChild(tr);
},

_flattenCoursesForDays: function(dataToRender, days) {
const out = [];
for (const classroom in dataToRender) {
for (const day of days) {
const list = dataToRender[classroom] && dataToRender[classroom][day];
if (list) list.forEach(course => out.push({ ...course, classroom, day }));
}
}
return out;
},

_groupCoursesByStartTime: function(flat) {
const map = new Map();
flat.forEach(course => {
if (!map.has(course.timeStart)) map.set(course.timeStart, []);
map.get(course.timeStart).push(course);
});
return [...map.entries()]
.sort((a, b) => app.timeToMinutes(a[0]) - app.timeToMinutes(b[0]))
.map(([timeStart, courses]) => ({ timeStart, courses }));
},

renderScheduleTable: function() {
try {
this.hideActiveTooltip();
app.findNextUpcomingClasses();
this.updateViewControls(); // This now handles the header and button visibility

if (app.activeScheduleId === AppConfig.ALL_SCHEDULES_ID) {
this.renderAllSchedulesView();
const renderTarget = ViewDecisionHelpers.resolveRenderTarget({
activeScheduleId: app.activeScheduleId,
allSchedulesId: AppConfig.ALL_SCHEDULES_ID,
currentViewMode: app.currentViewMode,
viewSortMode: app.viewSortMode,
dayMode: AppConfig.MODES.DAY
});
if (renderTarget.renderer === 'allSchedules') {
this.renderAllSchedulesView(renderTarget.scheduleRenderer);
return;
}

Expand All @@ -504,14 +533,16 @@

AppElements.scheduleBody.innerHTML = '';

if (app.viewSortMode === 'classroom') {
const renderer = renderTarget.scheduleRenderer;
if (renderer === 'classroom') {
this.renderByClassroom(dataToRender);
} else if (app.viewSortMode === 'teacher') {
} else if (renderer === 'teacher') {
this.renderByTeacher(dataToRender);
} else if (app.currentViewMode === AppConfig.MODES.DAY && app.viewSortMode === 'time') {
} else if (renderer === 'time') {
this.renderDayViewByTime(dataToRender);
} else if (renderer === 'weekTime') {
this.renderWeekViewByTime(dataToRender);
} else {
// Fallback for any invalid state
this.renderByClassroom(dataToRender);
}

Expand All @@ -524,7 +555,18 @@
}
},

renderAllSchedulesView: function() {
renderAllSchedulesView: function(scheduleRenderer) {
if (!scheduleRenderer) {
const renderTarget = ViewDecisionHelpers.resolveRenderTarget({
activeScheduleId: app.activeScheduleId,
allSchedulesId: AppConfig.ALL_SCHEDULES_ID,
currentViewMode: app.currentViewMode,
viewSortMode: app.viewSortMode,
dayMode: AppConfig.MODES.DAY
});
scheduleRenderer = renderTarget.scheduleRenderer;
}

AppElements.scheduleBody.innerHTML = '';
const classroomMetadataMap = new Map();
const dataForFiltering = {};
Expand Down Expand Up @@ -559,16 +601,9 @@
dataToRender = app.filterDataByActiveFilters(dataToRender);
}

if (app.currentViewMode === AppConfig.MODES.DAY && app.viewSortMode === 'time') {
if (scheduleRenderer === 'time') {
const day = app.currentDayIndex;
let allCoursesForDay = [];
for (const uniqueClassroomName in dataToRender) {
if (dataToRender[uniqueClassroomName][day]) {
dataToRender[uniqueClassroomName][day].forEach(course => {
allCoursesForDay.push({ ...course, classroom: uniqueClassroomName });
});
}
}
const allCoursesForDay = this._flattenCoursesForDays(dataToRender, [day]);
allCoursesForDay.sort((a, b) => app.timeToMinutes(a.timeStart) - app.timeToMinutes(b.timeStart));
allCoursesForDay.forEach(course => {
const tr = document.createElement('tr');
Expand All @@ -585,6 +620,27 @@
tr.appendChild(td);
AppElements.scheduleBody.appendChild(tr);
});
} else if (scheduleRenderer === 'weekTime') {
const days = [0, 1, 2, 3, 4, 5, 6];
const flat = this._flattenCoursesForDays(dataToRender, days);
const groups = this._groupCoursesByStartTime(flat);
groups.forEach(({ timeStart, courses }) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td class="border border-gray-300 p-2 font-medium bg-gray-100 classroom-cell-stacked"><span class="classroom-name-main">${escapeHtml(timeStart)}</span></td>`;
for (const day of days) {
const td = document.createElement('td');
td.setAttribute('class', 'border border-gray-300 p-2 schedule-cell align-top readonly-cell');
td.dataset.day = day;
courses.filter(course => course.day === day).forEach(course => {
const classDiv = this.createClassElement(course, course.classroom, day, { viewContext: 'timeSort' });
classDiv.classList.add('readonly');
classDiv.querySelector('.delete-btn')?.remove();
td.appendChild(classDiv);
});
tr.appendChild(td);
}
AppElements.scheduleBody.appendChild(tr);
});
} else {
const sortedClassrooms = Object.keys(dataToRender).sort();
sortedClassrooms.forEach(uniqueClassroomName => {
Expand Down Expand Up @@ -632,14 +688,7 @@

renderDayViewByTime: function(dataToRender) {
const day = app.currentDayIndex;
let allCoursesForDay = [];
for (const classroom in dataToRender) {
if (dataToRender[classroom][day]) {
dataToRender[classroom][day].forEach(course => {
allCoursesForDay.push({ ...course, classroom: classroom });
});
}
}
const allCoursesForDay = this._flattenCoursesForDays(dataToRender, [day]);
allCoursesForDay.sort((a, b) => app.timeToMinutes(a.timeStart) - app.timeToMinutes(b.timeStart));
allCoursesForDay.forEach(course => {
const tr = document.createElement('tr');
Expand All @@ -655,6 +704,27 @@
});
},

renderWeekViewByTime: function(dataToRender) {
const days = [0, 1, 2, 3, 4, 5, 6];
const flat = this._flattenCoursesForDays(dataToRender, days);
const groups = this._groupCoursesByStartTime(flat);
groups.forEach(({ timeStart, courses }) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td class="border border-gray-300 p-2 font-medium bg-gray-100 classroom-cell-stacked"><span class="classroom-name-main">${escapeHtml(timeStart)}</span></td>`;
for (const day of days) {
const td = document.createElement('td');
td.setAttribute('class', 'border border-gray-300 p-2 schedule-cell align-top');
td.dataset.day = day;
courses.filter(course => course.day === day).forEach(course => {
const classDiv = this.createClassElement(course, course.classroom, day, { viewContext: 'timeSort' });
td.appendChild(classDiv);
});
tr.appendChild(td);
}
AppElements.scheduleBody.appendChild(tr);
});
},

renderByTeacher: function(dataToRender) {
// 1. Get all unique teachers from the data
const teachers = new Set();
Expand Down Expand Up @@ -683,16 +753,8 @@
td.dataset.day = day;

// 4. Find all courses for this teacher on this day
let coursesForDay = [];
for (const classroom in dataToRender) {
if (dataToRender[classroom][day]) {
dataToRender[classroom][day].forEach(course => {
if (course.teacher === teacher) {
coursesForDay.push({ ...course, classroom: classroom }); // Add classroom info
}
});
}
}
let coursesForDay = this._flattenCoursesForDays(dataToRender, [day])
.filter(course => course.teacher === teacher);

// 5. Sort and render the courses into the cell
coursesForDay.sort((a, b) => app.timeToMinutes(a.timeStart) - app.timeToMinutes(b.timeStart));
Expand Down
Loading
Loading