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 下載選項 + 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 // ============================================================