diff --git a/.eslintrc.json b/.eslintrc.json
index 2837a8e..628bf68 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -15,7 +15,8 @@
"SCRIPT_USER_EMAIL": "readonly",
"IS_ADMIN": "readonly",
"escapeHtml": "readonly",
- "App": "writable"
+ "App": "writable",
+ "ViewDecisionHelpers": "readonly"
},
"rules": {
"no-unused-vars": "warn",
diff --git a/Index.html b/Index.html
index 977f839..bb6d61e 100644
--- a/Index.html
+++ b/Index.html
@@ -470,6 +470,7 @@
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(); ?>
diff --git a/Interaction.js.html b/Interaction.js.html
index 2b81c31..45cde27 100644
--- a/Interaction.js.html
+++ b/Interaction.js.html
@@ -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 });
diff --git a/JavaScript.html b/JavaScript.html
index 88f1e37..04e1e72 100644
--- a/JavaScript.html
+++ b/JavaScript.html
@@ -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) {
diff --git a/PDFExport.js.html b/PDFExport.js.html
index 8ebcf02..82ad6db 100644
--- a/PDFExport.js.html
+++ b/PDFExport.js.html
@@ -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' });
}
};
@@ -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];
diff --git a/TestCases.md b/TestCases.md
index 89db061..617f773 100644
--- a/TestCases.md
+++ b/TestCases.md
@@ -33,6 +33,22 @@
| :--- | :--- | :--- | :--- |
| **B-1-1** | **週/日檢視切換** | 1. 點擊右上角的「日檢視」按鈕。
2. 在日檢視下,點擊左右箭頭切換日期。
3. 點擊「週檢視」按鈕。 | 1. 應能正確切換週/日檢視的表格佈局。
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: ____
部署/preview: ____
時間: ____
執行者: ____ |
+| **WT-2** | **7-day placement 與同格堆疊** | 準備週一至週日相同起始時間的課程,並在同一天安排多間教室;切換依時間。 | 課程正確落在週一~日欄位;同一時間×日期的多間教室課程在同格垂直堆疊。 | head SHA: ____
部署/preview: ____
時間: ____
執行者: ____ |
+| **WT-3** | **卡片同時顯示老師與教室** | 在週檢視依時間檢視含老師、教室資料的課程卡。 | 每張卡同時顯示老師與教室;卡片仍保留可 inline 編輯/刪除所需資料。 | head SHA: ____
部署/preview: ____
時間: ____
執行者: ____ |
+| **WT-4** | **week+time 持久化** | 週檢視按「依時間」,重新整理頁面。 | 重新整理後仍為週檢視+依時間;切換至日檢視後不受 week key 污染。 | head SHA: ____
部署/preview: ____
時間: ____
執行者: ____ |
+| **WT-5** | **week-time PDF** | 週檢視依時間匯出 PDF;再於日檢視依時間匯出 PDF。 | 週 PDF 每張卡含老師與教室,左上角為「星期/時間」;日 PDF 左欄標籤維持「星期/教室」。 | head SHA: ____
部署/preview: ____
時間: ____
執行者: ____ |
+| **WT-6** | **time-grid 空 cell 不新增** | 週檢視依時間對沒有課程的時間×星期空白格 double-click。 | 不開啟新增課程表單;課程卡仍可正常 inline 編輯/刪除。 | head SHA: ____
部署/preview: ____
時間: ____
執行者: ____ |
+| **WT-7** | **全部課表 week-time 唯讀** | 切換「所有課程」後選週檢視、依時間。 | 顯示時間軸而非教室 fallback;課程卡為 readonly、無刪除鈕,editable 欄位不會繞過 `activeScheduleId` guard。 | head SHA: ____
部署/preview: ____
時間: ____
執行者: ____ |
+| **WT-8** | **日檢視回歸** | 日檢視按依時間;檢查多課、教室左欄與 notes;在日檢視選 teacher 後重新整理。 | 日檢視仍一課一列、教室左欄、notes 行為不變;重新整理仍為 day+time。 | head SHA: ____
部署/preview: ____
時間: ____
執行者: ____ |
+| **WT-9** | **篩選後全空不產列** | 週檢視依時間套用會濾掉全部課程的篩選;確認教室/日容器仍存在但課程為零。 | `tbody` 不出現任何時間列(零 ``,只剩表頭),不得產生空時間列。 | head SHA: ____
部署/preview: ____
時間: ____
執行者: ____ |
+
### B-2: 篩選功能
| 測試案例 ID | 測試項目 | 操作步驟 | 預期結果 |
diff --git a/UI.js.html b/UI.js.html
index f6a9c0d..1e572a9 100644
--- a/UI.js.html
+++ b/UI.js.html
@@ -345,11 +345,13 @@
const allTagsTooltip = escapeHtml((classItem.tags || []).join(', '));
const notesHtml = app.currentViewMode === AppConfig.MODES.DAY ? `${escapeHtml(classItem.notes || '')}
` : '';
+ const { showTeacher, showClassroom } = ViewDecisionHelpers.resolveClassElementFields(viewContext);
let contextSpecificHtml = '';
- if (viewContext === 'teacherSort') {
- contextSpecificHtml = `教室:${escapeHtml(classroom)}
`;
- } else {
- contextSpecificHtml = `${escapeHtml(classItem.teacher)}
`;
+ if (showTeacher) {
+ contextSpecificHtml += `${escapeHtml(classItem.teacher)}
`;
+ }
+ if (showClassroom) {
+ contextSpecificHtml += `教室:${escapeHtml(classroom)}
`;
}
const viewModeClass = app.currentViewMode === AppConfig.MODES.DAY ? 'day-view-layout' : '';
@@ -411,11 +413,7 @@
// 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();
},
@@ -423,6 +421,7 @@
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();
},
@@ -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 = '';
@@ -473,7 +472,8 @@
`
);
} else { // Week View
- const weekViewHeaderLabel = app.viewSortMode === 'teacher' ? '老師' : '教室';
+ const weekViewHeaderLabel = app.viewSortMode === 'teacher' ? '老師'
+ : app.viewSortMode === 'time' ? '時間' : '教室';
tr.innerHTML = (
`
星期
@@ -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;
}
@@ -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);
}
@@ -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 = {};
@@ -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');
@@ -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 = ` | ${escapeHtml(timeStart)} | `;
+ 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 => {
@@ -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');
@@ -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 = `${escapeHtml(timeStart)} | `;
+ 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();
@@ -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));
diff --git a/ViewDecisionHelpers.js.html b/ViewDecisionHelpers.js.html
new file mode 100644
index 0000000..5919e1f
--- /dev/null
+++ b/ViewDecisionHelpers.js.html
@@ -0,0 +1,55 @@
+
diff --git a/docs/verification-checklist-2026-07-20-week-view-time-axis.md b/docs/verification-checklist-2026-07-20-week-view-time-axis.md
new file mode 100644
index 0000000..df2f48e
--- /dev/null
+++ b/docs/verification-checklist-2026-07-20-week-view-time-axis.md
@@ -0,0 +1,121 @@
+# 週檢視「依時間」時間軸模式:驗證清單
+
+> Generated: 2026-07-20
+> PR: #163
+> Verified-at: `9b86677`(修正程式碼驗證當下;checklist commit 會推進 PR head)
+> Plan: `docs/superpowers/plans/2026-07-20-week-view-time-axis.md`
+
+## How to test
+
+1. 在 bound worktree 執行 `npm run build`。
+2. 以 `clasp push` 推送 dev 版,記錄 dev 版/preview 識別與當前 PR head SHA。
+3. 開啟 classroombooking 的 GAS dev 版,準備至少兩間教室、週一至週日及多個起始時間的課程資料。
+4. 每項測試都記錄:tested head SHA、部署/preview 識別、執行時間、執行者;PR 有新 commit 後須以新 head 重驗。
+
+---
+
+## 週檢視依時間行為
+
+### 驗證步驟
+1. 切換至週檢視,按下「依時間」。
+2. 準備同一起始時間橫跨週一至週日的課程,並讓同一天同一時間有多間教室課程。
+3. 觀察左欄、日期欄位與同格課程卡排列。
+
+### 預期行為
+- 「依時間」按鈕在週檢視可見且可按。
+- 左欄是資料中實際出現的起始時間;每一時間一列,右側橫向對應週一至週日。
+- 相同時間×日期的多間教室課程在同一格垂直堆疊。
+- 每張課程卡同時顯示老師與教室。
+- 課程卡可 inline 編輯、可刪除;時間軸模式不可拖拉,空白格不顯示「+ 新增課程」。
+
+| # | 測試項目 | Pass? |
+|---|---------|-------|
+| 1 | 週檢視依時間顯示時間軸而非教室列 | |
+| 2 | 週一~週日 placement 與同格多教室堆疊正確 | |
+| 3 | 課程卡同時顯示老師與教室 | |
+| 4 | 課程卡編輯/刪除可用,拖拉與空格新增不可用 | |
+
+---
+
+## 持久化與日檢視回歸
+
+### 驗證步驟
+1. 在週檢視選「依時間」,重新整理頁面。
+2. 切換至日檢視,確認依時間排列、教室左欄與 notes。
+3. 在日檢視選「依老師」後重新整理。
+
+### 預期行為
+- 重新整理後仍為週檢視+依時間。
+- 日檢視仍是一課一列、教室在左欄、notes 行為不變。
+- 日檢視 refresh 永遠恢復依時間,不被 week 的 `lastViewSortMode` 或日檢視暫選值污染。
+
+| # | 測試項目 | Pass? |
+|---|---------|-------|
+| 5 | week+time refresh 後維持 week+time | |
+| 6 | day-time 版面與 notes 回歸正確 | |
+| 7 | day 選 teacher 後 refresh 仍 day+time | |
+
+---
+
+## PDF 與空白格安全性
+
+### 驗證步驟
+1. 週檢視依時間匯出 PDF。
+2. 日檢視依時間匯出 PDF。
+3. 回到週檢視依時間,對沒有課程的時間×星期空白格 double-click。
+
+### 預期行為
+- 週 PDF 每張卡同時含老師與教室,左上角標示「星期/時間」。
+- 日 PDF 左上角仍標示「星期/教室」,不得被週 time 分支改動。
+- time-grid 空白格 double-click 不開新增課程表單。
+
+| # | 測試項目 | Pass? |
+|---|---------|-------|
+| 8 | week-time PDF 同時含老師/教室且標籤為「星期/時間」 | |
+| 9 | day-time PDF 標籤維持「星期/教室」 | |
+| 10 | time-grid 空白格不觸發新增 | |
+
+---
+
+## 全部課表與篩選後全空
+
+### 驗證步驟
+1. 切換「所有課程」,選週檢視、依時間。
+2. 確認課程卡互動與 delete 按鈕。
+3. 在一般課表週檢視依時間套用會濾掉全部課程的篩選條件。
+
+### 預期行為
+- 全部課表走時間軸,不 fallback 至教室列。
+- 全部課表課程卡為 readonly、沒有刪除鈕;editable 欄位不會繞過 `activeScheduleId` guard。
+- 篩選後外層教室/日期容器仍存在但課程為零時,tbody 不產生任何時間列,只保留表頭。
+
+| # | 測試項目 | Pass? |
+|---|---------|-------|
+| 11 | 全部課表 week-time 為時間軸且唯讀 | |
+| 12 | filtered-to-empty 不產生空時間列 | |
+
+---
+
+## Provenance 記錄
+
+| 測試項目 | Tested head SHA | `clasp push` dev/preview 識別 | 執行時間 | 執行者 | 證據連結/檔案 |
+|---|---|---|---|---|---|
+| WT-1 | | | | | |
+| WT-2 | | | | | |
+| WT-3 | | | | | |
+| WT-4 | | | | | |
+| WT-5 | | | | | |
+| WT-6 | | | | | |
+| WT-7 | | | | | |
+| WT-8 | | | | | |
+| WT-9 | | | | | |
+
+## Summary
+
+| Section | 測試項目數 |
+|---------|----------|
+| 週檢視依時間行為 | 4 |
+| 持久化與日檢視回歸 | 3 |
+| PDF 與空白格安全性 | 3 |
+| 全部課表與篩選後全空 | 2 |
+| **合計** | **12** |
diff --git a/tests/lib/integrationHelpers.js b/tests/lib/integrationHelpers.js
index c8e87db..44d928f 100644
--- a/tests/lib/integrationHelpers.js
+++ b/tests/lib/integrationHelpers.js
@@ -77,8 +77,11 @@ export const FACTORY_CONTRACTS = {
'updateViewControls',
'renderScheduleTable',
'renderAllSchedulesView',
+ '_flattenCoursesForDays',
+ '_groupCoursesByStartTime',
'addUpcomingClassIndicators',
'renderDayViewByTime',
+ 'renderWeekViewByTime',
'renderByTeacher',
'renderByClassroom',
'setupDragAndDrop',
diff --git a/tests/lib/uiHelpers.js b/tests/lib/uiHelpers.js
index a5619b0..50fc71b 100644
--- a/tests/lib/uiHelpers.js
+++ b/tests/lib/uiHelpers.js
@@ -1,4 +1,8 @@
/**
+ * ⚠️ mirror-only: 本檔為 UI 決策邏輯的平行實作。本檔綠不代表 production
+ * (.html) 正確——.html 被 coverage 排除。production HTML 正確性由
+ * TestCases.md 的 WT-1~WT-9 手動案例(provenance-bound 證據)gate。
+ *
* UI helpers — extracted pure decision/transform logic from UI.js.html.
* Ref: #92 — Wave 3B UI rendering core
*
@@ -17,7 +21,7 @@
* @param {string} state.viewSortMode - 'classroom' | 'teacher' | 'time'
* @param {string} state.currentViewMode - 'week' | 'day'
* @param {string} state.dayMode - AppConfig.MODES.DAY value
- * @returns {{ renderer: string, shouldFilter: boolean }}
+ * @returns {{ renderer: string, scheduleRenderer: string, shouldFilter: boolean }}
*/
export function resolveRenderTarget(state) {
const {
@@ -29,22 +33,76 @@ export function resolveRenderTarget(state) {
dayMode,
} = state;
- if (activeScheduleId === allSchedulesId) {
- return { renderer: 'allSchedules', shouldFilter: false };
- }
-
- const shouldFilter = activeFilters && activeFilters.length > 0;
+ const shouldFilter = activeScheduleId === allSchedulesId
+ ? false
+ : activeFilters && activeFilters.length > 0;
+ let scheduleRenderer;
if (viewSortMode === 'classroom') {
- return { renderer: 'classroom', shouldFilter };
+ scheduleRenderer = 'classroom';
} else if (viewSortMode === 'teacher') {
- return { renderer: 'teacher', shouldFilter };
+ scheduleRenderer = 'teacher';
} else if (currentViewMode === dayMode && viewSortMode === 'time') {
- return { renderer: 'time', shouldFilter };
+ scheduleRenderer = 'time';
+ } else if (viewSortMode === 'time') {
+ scheduleRenderer = 'weekTime';
+ } else {
+ scheduleRenderer = 'classroom';
}
- // Fallback
- return { renderer: 'classroom', shouldFilter };
+ return {
+ renderer: activeScheduleId === allSchedulesId ? 'allSchedules' : scheduleRenderer,
+ scheduleRenderer,
+ shouldFilter,
+ };
+}
+
+/**
+ * View-scoped sort restore. Day always forces 'time' (day behavior unchanged);
+ * only week reads the persisted sort with legal-value fallback.
+ * Mirror of production restore in JavaScript.html:26-35.
+ */
+export function resolveRestoredSort({ currentViewMode, storedSort, dayMode, weekMode }) {
+ if (currentViewMode === dayMode) return 'time';
+ const legal = ['classroom', 'teacher', 'time'];
+ return legal.includes(storedSort) ? storedSort : 'classroom';
+}
+
+/** Flatten { classroom → day → course[] } into tagged course records. */
+export function flattenCoursesForDays(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;
+}
+
+/** Group flat courses by timeStart, ascending by injected time converter. */
+export function groupCoursesByStartTime(flatCourses, timeToMinutes) {
+ const map = new Map();
+ for (const course of flatCourses) {
+ if (!map.has(course.timeStart)) map.set(course.timeStart, []);
+ map.get(course.timeStart).push(course);
+ }
+ return [...map.entries()]
+ .sort((a, b) => timeToMinutes(a[0]) - timeToMinutes(b[0]))
+ .map(([timeStart, courses]) => ({ timeStart, courses }));
+}
+
+/** Bottom line of a course card in the week PDF. */
+export function resolvePdfBottomText({ viewSortMode, teacher = '', classroom = '' }) {
+ if (viewSortMode === 'teacher') return `(教室:${classroom})`;
+ if (viewSortMode === 'time') return `${teacher} · ${classroom}`;
+ return `(${teacher})`;
+}
+
+/** Diagonal header label; only week + time uses 時間. */
+export function resolvePdfDiagonalLabel({ currentViewMode, viewSortMode, weekMode }) {
+ if (currentViewMode === weekMode && viewSortMode === 'time') return '時間';
+ return viewSortMode === 'teacher' ? '老師' : '教室';
}
/**
@@ -80,7 +138,8 @@ export function computeClassElementProps(classItem, classroom, day, options = {}
const bgColor = overrideColor || courseColorMap[classItem.name] || '#E2E8F0';
const viewModeClass = currentViewMode === dayMode ? 'day-view-layout' : '';
const showNotes = currentViewMode === dayMode;
- const showClassroomInContent = viewContext === 'teacherSort';
+ const showTeacher = viewContext === 'default' || viewContext === 'timeSort';
+ const showClassroom = viewContext === 'teacherSort' || viewContext === 'timeSort';
const cssClasses = [
'class-item',
@@ -95,7 +154,8 @@ export function computeClassElementProps(classItem, classroom, day, options = {}
isUpcoming,
hasConflict,
showNotes,
- showClassroomInContent,
+ showTeacher,
+ showClassroom,
tags: classItem.tags || [],
dataAttributes: {
id: classItem.id,
diff --git a/tests/unit/uiHelpers.test.js b/tests/unit/uiHelpers.test.js
index 58b21f6..2716618 100644
--- a/tests/unit/uiHelpers.test.js
+++ b/tests/unit/uiHelpers.test.js
@@ -4,10 +4,16 @@
*/
import {
resolveRenderTarget,
+ resolveRestoredSort,
computeClassElementProps,
resolveLoadingActions,
resolveHeaderState,
+ flattenCoursesForDays,
+ groupCoursesByStartTime,
+ resolvePdfBottomText,
+ resolvePdfDiagonalLabel,
} from '../lib/uiHelpers.js';
+import { timeToMinutes } from '../lib/frontendUtils.js';
// ============================================================
// resolveRenderTarget
@@ -25,17 +31,29 @@ describe('resolveRenderTarget', () => {
it('routes to allSchedules when activeScheduleId matches allSchedulesId', () => {
const result = resolveRenderTarget({ ...base, activeScheduleId: ALL_ID });
- expect(result).toEqual({ renderer: 'allSchedules', shouldFilter: false });
+ expect(result).toEqual({
+ renderer: 'allSchedules',
+ scheduleRenderer: 'classroom',
+ shouldFilter: false,
+ });
});
it('routes to classroom renderer by default', () => {
const result = resolveRenderTarget(base);
- expect(result).toEqual({ renderer: 'classroom', shouldFilter: false });
+ expect(result).toEqual({
+ renderer: 'classroom',
+ scheduleRenderer: 'classroom',
+ shouldFilter: false,
+ });
});
it('routes to teacher renderer', () => {
const result = resolveRenderTarget({ ...base, viewSortMode: 'teacher' });
- expect(result).toEqual({ renderer: 'teacher', shouldFilter: false });
+ expect(result).toEqual({
+ renderer: 'teacher',
+ scheduleRenderer: 'teacher',
+ shouldFilter: false,
+ });
});
it('routes to time renderer in day mode', () => {
@@ -44,16 +62,24 @@ describe('resolveRenderTarget', () => {
viewSortMode: 'time',
currentViewMode: 'day',
});
- expect(result).toEqual({ renderer: 'time', shouldFilter: false });
+ expect(result).toEqual({
+ renderer: 'time',
+ scheduleRenderer: 'time',
+ shouldFilter: false,
+ });
});
- it('falls back to classroom for time sort in week mode', () => {
+ it('routes week + time to the week-time renderer', () => {
const result = resolveRenderTarget({
...base,
viewSortMode: 'time',
currentViewMode: 'week',
});
- expect(result).toEqual({ renderer: 'classroom', shouldFilter: false });
+ expect(result).toEqual({
+ renderer: 'weekTime',
+ scheduleRenderer: 'weekTime',
+ shouldFilter: false,
+ });
});
it('sets shouldFilter true when activeFilters non-empty', () => {
@@ -73,9 +99,47 @@ describe('resolveRenderTarget', () => {
expect(result.shouldFilter).toBe(false);
});
+ it('routes all-schedules plus unsupported view mode to the shared week-time renderer', () => {
+ const result = resolveRenderTarget({
+ ...base,
+ activeScheduleId: ALL_ID,
+ currentViewMode: 'preview',
+ viewSortMode: 'time',
+ });
+ expect(result).toEqual({
+ renderer: 'allSchedules',
+ scheduleRenderer: 'weekTime',
+ shouldFilter: false,
+ });
+ });
+
it('falls back to classroom for unknown viewSortMode', () => {
const result = resolveRenderTarget({ ...base, viewSortMode: 'unknown' });
expect(result.renderer).toBe('classroom');
+ expect(result.scheduleRenderer).toBe('classroom');
+ });
+});
+
+// ============================================================
+// resolveRestoredSort
+// ============================================================
+describe('resolveRestoredSort', () => {
+ const base = { dayMode: 'day', weekMode: 'week' };
+
+ it('week + stored time restores time (week+time persists)', () => {
+ expect(resolveRestoredSort({ ...base, currentViewMode: 'week', storedSort: 'time' })).toBe('time');
+ });
+
+ it('day forces time regardless of stored teacher (day behavior unchanged)', () => {
+ expect(resolveRestoredSort({ ...base, currentViewMode: 'day', storedSort: 'teacher' })).toBe('time');
+ });
+
+ it('week + illegal stored falls back to classroom', () => {
+ expect(resolveRestoredSort({ ...base, currentViewMode: 'week', storedSort: 'bogus' })).toBe('classroom');
+ });
+
+ it('week + missing key (null) falls back to classroom (back-compat)', () => {
+ expect(resolveRestoredSort({ ...base, currentViewMode: 'week', storedSort: null })).toBe('classroom');
});
});
@@ -107,7 +171,8 @@ describe('computeClassElementProps', () => {
expect(result.isUpcoming).toBe(false);
expect(result.hasConflict).toBe(false);
expect(result.showNotes).toBe(false);
- expect(result.showClassroomInContent).toBe(false);
+ expect(result.showTeacher).toBe(true);
+ expect(result.showClassroom).toBe(false);
expect(result.tags).toEqual(['core', 'math']);
expect(result.dataAttributes).toEqual({
id: 'c1', name: 'Math', classroom: 'Room A', day: 0,
@@ -151,7 +216,16 @@ describe('computeClassElementProps', () => {
const result = computeClassElementProps(
classItem, 'Room A', 0, { viewContext: 'teacherSort' }, defaultAppState
);
- expect(result.showClassroomInContent).toBe(true);
+ expect(result.showTeacher).toBe(false);
+ expect(result.showClassroom).toBe(true);
+ });
+
+ it('shows both teacher and classroom in time sort view context', () => {
+ const result = computeClassElementProps(
+ classItem, 'Room A', 0, { viewContext: 'timeSort' }, defaultAppState
+ );
+ expect(result.showTeacher).toBe(true);
+ expect(result.showClassroom).toBe(true);
});
it('handles course with no tags', () => {
@@ -178,6 +252,132 @@ describe('computeClassElementProps', () => {
});
});
+// ============================================================
+// flattenCoursesForDays / groupCoursesByStartTime
+// ============================================================
+describe('flattenCoursesForDays', () => {
+ const data = {
+ R1: { 0: [{ id: 'a', timeStart: '09:10' }], 6: [{ id: 'b', timeStart: '19:00' }] },
+ R2: { 0: [{ id: 'c', timeStart: '09:10' }] },
+ };
+
+ it('flattens all 7 days, tagging classroom + day (7-day placement)', () => {
+ const flat = flattenCoursesForDays(data, [0, 1, 2, 3, 4, 5, 6]);
+ expect(flat).toHaveLength(3);
+ expect(flat.find(c => c.id === 'b')).toMatchObject({ classroom: 'R1', day: 6 });
+ expect(flat.find(c => c.id === 'a')).toMatchObject({ classroom: 'R1', day: 0 });
+ expect(flat.find(c => c.id === 'c')).toMatchObject({ classroom: 'R2', day: 0 });
+ });
+
+ it('single-day slice returns only that day (day-view reuse)', () => {
+ expect(flattenCoursesForDays(data, [0])).toHaveLength(2);
+ });
+
+ it('empty data yields empty flat', () => {
+ expect(flattenCoursesForDays({}, [0, 1, 2, 3, 4, 5, 6])).toEqual([]);
+ });
+
+ it('filtered-to-empty containers yield empty flat', () => {
+ const filtered = { R1: { 0: [], 3: [] }, R2: {} };
+ expect(flattenCoursesForDays(filtered, [0, 1, 2, 3, 4, 5, 6])).toEqual([]);
+ });
+});
+
+describe('groupCoursesByStartTime', () => {
+ it('groups by timeStart, sorts ascending, keeps day for placement', () => {
+ const flat = [
+ { id: '1', timeStart: '13:30', classroom: 'R2', day: 1 },
+ { id: '2', timeStart: '09:10', classroom: 'R1', day: 0 },
+ { id: '3', timeStart: '09:10', classroom: 'R3', day: 2 },
+ ];
+ const groups = groupCoursesByStartTime(flat, timeToMinutes);
+ expect(groups.map(x => x.timeStart)).toEqual(['09:10', '13:30']);
+ expect(groups[0].courses.map(c => c.day).sort()).toEqual([0, 2]);
+ });
+
+ it('sorts irregular non-aligned start times correctly', () => {
+ const flat = [
+ { timeStart: '09:10', day: 0 }, { timeStart: '08:55', day: 0 }, { timeStart: '13:05', day: 0 },
+ ];
+ expect(groupCoursesByStartTime(flat, timeToMinutes).map(x => x.timeStart))
+ .toEqual(['08:55', '09:10', '13:05']);
+ });
+
+ it('groups same start with different end times together', () => {
+ const flat = [
+ { timeStart: '09:10', timeEnd: '11:00', day: 0 },
+ { timeStart: '09:10', timeEnd: '10:00', day: 0 },
+ ];
+ expect(groupCoursesByStartTime(flat, timeToMinutes)).toHaveLength(1);
+ });
+
+ it('returns empty groups for empty input', () => {
+ expect(groupCoursesByStartTime([], timeToMinutes)).toEqual([]);
+ });
+
+ it('filtered-to-empty yields zero groups', () => {
+ const filtered = { R1: { 0: [], 3: [] }, R2: {} };
+ const flat = flattenCoursesForDays(filtered, [0, 1, 2, 3, 4, 5, 6]);
+ expect(groupCoursesByStartTime(flat, timeToMinutes)).toEqual([]);
+ });
+
+ it('groups abnormal timeStart without throwing', () => {
+ const flat = [
+ { timeStart: '09:10', day: 0 },
+ { timeStart: 'N/A', day: 1 },
+ ];
+ const groups = groupCoursesByStartTime(flat, timeToMinutes);
+ expect(groups.map(x => x.timeStart)).toContain('N/A');
+ expect(groups).toHaveLength(2);
+ });
+});
+
+// ============================================================
+// PDF view decisions
+// ============================================================
+describe('resolvePdfBottomText (week PDF card bottom line)', () => {
+ const base = { teacher: '張老師', classroom: '301教室' };
+
+ it('teacher sort prints classroom only', () => {
+ expect(resolvePdfBottomText({ ...base, viewSortMode: 'teacher' })).toBe('(教室:301教室)');
+ });
+
+ it('time sort prints both teacher and classroom', () => {
+ const output = resolvePdfBottomText({ ...base, viewSortMode: 'time' });
+ expect(output).toContain('張老師');
+ expect(output).toContain('301教室');
+ });
+
+ it('classroom sort prints teacher only', () => {
+ expect(resolvePdfBottomText({ ...base, viewSortMode: 'classroom' })).toBe('(張老師)');
+ });
+
+ it('missing classroom does not crash time sort', () => {
+ expect(() => resolvePdfBottomText({ teacher: '王', classroom: '', viewSortMode: 'time' })).not.toThrow();
+ });
+});
+
+describe('resolvePdfDiagonalLabel (shared day/week hook)', () => {
+ const base = { weekMode: 'week' };
+
+ it('week + time uses 時間', () => {
+ expect(resolvePdfDiagonalLabel({ ...base, currentViewMode: 'week', viewSortMode: 'time' })).toBe('時間');
+ });
+
+ it('day + time keeps 教室 for day-time PDF', () => {
+ expect(resolvePdfDiagonalLabel({ ...base, currentViewMode: 'day', viewSortMode: 'time' })).toBe('教室');
+ });
+
+ it('teacher sort uses 老師 in both views', () => {
+ expect(resolvePdfDiagonalLabel({ ...base, currentViewMode: 'week', viewSortMode: 'teacher' })).toBe('老師');
+ expect(resolvePdfDiagonalLabel({ ...base, currentViewMode: 'day', viewSortMode: 'teacher' })).toBe('老師');
+ });
+
+ it('classroom sort uses 教室', () => {
+ expect(resolvePdfDiagonalLabel({ ...base, currentViewMode: 'week', viewSortMode: 'classroom' })).toBe('教室');
+ });
+});
+
// ============================================================
// resolveLoadingActions
// ============================================================