From e6564b641141ee3e3c2a96dcf49fb59330ae7031 Mon Sep 17 00:00:00 2001 From: CheerC Date: Mon, 20 Jul 2026 19:34:30 +0800 Subject: [PATCH 01/11] feat: persist week time sort across refresh (refs #162) Closes t-20260720112615791999-29953-14 Co-Authored-By: Claude Agend-Agent: cb-team-impl Agend-Task: t-20260720112615791999-29953-14 Agend-Branch: feat/162-week-view-time-axis Agend-Issued-At: 2026-07-20T11:28:30.450120+00:00 --- .eslintrc.json | 3 ++- Index.html | 1 + JavaScript.html | 6 ++++- UI.js.html | 7 ++---- ViewDecisionHelpers.js.html | 43 ++++++++++++++++++++++++++++++++++++ tests/lib/uiHelpers.js | 11 +++++++++ tests/unit/uiHelpers.test.js | 24 ++++++++++++++++++++ 7 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 ViewDecisionHelpers.js.html 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/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/UI.js.html b/UI.js.html index f6a9c0d..b259ed9 100644 --- a/UI.js.html +++ b/UI.js.html @@ -411,11 +411,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 +419,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(); }, diff --git a/ViewDecisionHelpers.js.html b/ViewDecisionHelpers.js.html new file mode 100644 index 0000000..f498eaf --- /dev/null +++ b/ViewDecisionHelpers.js.html @@ -0,0 +1,43 @@ + diff --git a/tests/lib/uiHelpers.js b/tests/lib/uiHelpers.js index a5619b0..96c9759 100644 --- a/tests/lib/uiHelpers.js +++ b/tests/lib/uiHelpers.js @@ -47,6 +47,17 @@ export function resolveRenderTarget(state) { return { renderer: 'classroom', 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'; +} + /** * Computes the CSS classes, background color, and content flags for a class element. * Original: createClassElement data→props (UI.js.html L338-376) diff --git a/tests/unit/uiHelpers.test.js b/tests/unit/uiHelpers.test.js index 58b21f6..f1feedb 100644 --- a/tests/unit/uiHelpers.test.js +++ b/tests/unit/uiHelpers.test.js @@ -4,6 +4,7 @@ */ import { resolveRenderTarget, + resolveRestoredSort, computeClassElementProps, resolveLoadingActions, resolveHeaderState, @@ -79,6 +80,29 @@ describe('resolveRenderTarget', () => { }); }); +// ============================================================ +// 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'); + }); +}); + // ============================================================ // computeClassElementProps // ============================================================ From 03a0e6c0323267cef72e41f6dc2903183e31af08 Mon Sep 17 00:00:00 2001 From: CheerC Date: Mon, 20 Jul 2026 19:35:13 +0800 Subject: [PATCH 02/11] feat: dispatch week time view to dedicated renderer (refs #162) Closes t-20260720112615791999-29953-14 Co-Authored-By: Claude Agend-Agent: cb-team-impl Agend-Task: t-20260720112615791999-29953-14 Agend-Branch: feat/162-week-view-time-axis Agend-Issued-At: 2026-07-20T11:28:30.450120+00:00 --- UI.js.html | 14 ++++++++++---- tests/lib/uiHelpers.js | 2 ++ tests/unit/uiHelpers.test.js | 4 ++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/UI.js.html b/UI.js.html index b259ed9..2aa3f34 100644 --- a/UI.js.html +++ b/UI.js.html @@ -501,14 +501,20 @@ AppElements.scheduleBody.innerHTML = ''; - if (app.viewSortMode === 'classroom') { + const renderer = ViewDecisionHelpers.resolveRenderTarget({ + currentViewMode: app.currentViewMode, + viewSortMode: app.viewSortMode, + dayMode: AppConfig.MODES.DAY + }); + 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); } diff --git a/tests/lib/uiHelpers.js b/tests/lib/uiHelpers.js index 96c9759..df4bd71 100644 --- a/tests/lib/uiHelpers.js +++ b/tests/lib/uiHelpers.js @@ -41,6 +41,8 @@ export function resolveRenderTarget(state) { return { renderer: 'teacher', shouldFilter }; } else if (currentViewMode === dayMode && viewSortMode === 'time') { return { renderer: 'time', shouldFilter }; + } else if (viewSortMode === 'time') { + return { renderer: 'weekTime', shouldFilter }; } // Fallback diff --git a/tests/unit/uiHelpers.test.js b/tests/unit/uiHelpers.test.js index f1feedb..fca2750 100644 --- a/tests/unit/uiHelpers.test.js +++ b/tests/unit/uiHelpers.test.js @@ -48,13 +48,13 @@ describe('resolveRenderTarget', () => { expect(result).toEqual({ renderer: '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', shouldFilter: false }); }); it('sets shouldFilter true when activeFilters non-empty', () => { From 4f34a609a94f1954ec319f2c69cfae8baf00eefe Mon Sep 17 00:00:00 2001 From: CheerC Date: Mon, 20 Jul 2026 19:36:19 +0800 Subject: [PATCH 03/11] feat: show teacher and classroom in time-sort cards (refs #162) Closes t-20260720112615791999-29953-14 Co-Authored-By: Claude Agend-Agent: cb-team-impl Agend-Task: t-20260720112615791999-29953-14 Agend-Branch: feat/162-week-view-time-axis Agend-Issued-At: 2026-07-20T11:28:30.450120+00:00 --- UI.js.html | 10 ++++++---- tests/lib/uiHelpers.js | 6 ++++-- tests/unit/uiHelpers.test.js | 14 ++++++++++++-- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/UI.js.html b/UI.js.html index 2aa3f34..ee7e6a2 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' : ''; diff --git a/tests/lib/uiHelpers.js b/tests/lib/uiHelpers.js index df4bd71..15b4894 100644 --- a/tests/lib/uiHelpers.js +++ b/tests/lib/uiHelpers.js @@ -93,7 +93,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', @@ -108,7 +109,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 fca2750..2303c17 100644 --- a/tests/unit/uiHelpers.test.js +++ b/tests/unit/uiHelpers.test.js @@ -131,7 +131,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, @@ -175,7 +176,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', () => { From 03446a78d33cb25d83c0037438e74d5dacf96dca Mon Sep 17 00:00:00 2001 From: CheerC Date: Mon, 20 Jul 2026 19:39:36 +0800 Subject: [PATCH 04/11] feat: add week time-axis renderer and controls (refs #162) Closes t-20260720112615791999-29953-14 Co-Authored-By: Claude Agend-Agent: cb-team-impl Agend-Task: t-20260720112615791999-29953-14 Agend-Branch: feat/162-week-view-time-axis Agend-Issued-At: 2026-07-20T11:28:30.450120+00:00 --- UI.js.html | 78 ++++++++++++++++++++----------- tests/lib/integrationHelpers.js | 3 ++ tests/lib/uiHelpers.js | 24 ++++++++++ tests/unit/uiHelpers.test.js | 83 +++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 28 deletions(-) diff --git a/UI.js.html b/UI.js.html index ee7e6a2..6c779f6 100644 --- a/UI.js.html +++ b/UI.js.html @@ -438,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 = ''; @@ -472,7 +472,8 @@ ` ); } else { // Week View - const weekViewHeaderLabel = app.viewSortMode === 'teacher' ? '老師' : '教室'; + const weekViewHeaderLabel = app.viewSortMode === 'teacher' ? '老師' + : app.viewSortMode === 'time' ? '時間' : '教室'; tr.innerHTML = ( ` 星期 @@ -485,6 +486,28 @@ 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(); @@ -566,14 +589,7 @@ if (app.currentViewMode === AppConfig.MODES.DAY && app.viewSortMode === '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'); @@ -637,14 +653,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'); @@ -660,6 +669,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(); @@ -688,16 +718,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/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 15b4894..85ea5d8 100644 --- a/tests/lib/uiHelpers.js +++ b/tests/lib/uiHelpers.js @@ -60,6 +60,30 @@ export function resolveRestoredSort({ currentViewMode, storedSort, dayMode, week 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 })); +} + /** * Computes the CSS classes, background color, and content flags for a class element. * Original: createClassElement data→props (UI.js.html L338-376) diff --git a/tests/unit/uiHelpers.test.js b/tests/unit/uiHelpers.test.js index 2303c17..c42e27c 100644 --- a/tests/unit/uiHelpers.test.js +++ b/tests/unit/uiHelpers.test.js @@ -8,7 +8,10 @@ import { computeClassElementProps, resolveLoadingActions, resolveHeaderState, + flattenCoursesForDays, + groupCoursesByStartTime, } from '../lib/uiHelpers.js'; +import { timeToMinutes } from '../lib/frontendUtils.js'; // ============================================================ // resolveRenderTarget @@ -212,6 +215,86 @@ 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); + }); +}); + // ============================================================ // resolveLoadingActions // ============================================================ From 1c4789bd5b06f6a9055ac8690cdbacc35df0f5c1 Mon Sep 17 00:00:00 2001 From: CheerC Date: Mon, 20 Jul 2026 19:40:03 +0800 Subject: [PATCH 05/11] fix: keep time-grid empty cells non-addable (refs #162) Closes t-20260720112615791999-29953-14 Co-Authored-By: Claude Agend-Agent: cb-team-impl Agend-Task: t-20260720112615791999-29953-14 Agend-Branch: feat/162-week-view-time-axis Agend-Issued-At: 2026-07-20T11:28:30.450120+00:00 --- Interaction.js.html | 1 + 1 file changed, 1 insertion(+) 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 }); From 7a196d65be1232241612e6eb8fe6af345bc6bd0c Mon Sep 17 00:00:00 2001 From: CheerC Date: Mon, 20 Jul 2026 19:40:30 +0800 Subject: [PATCH 06/11] feat: render all schedules week time view read-only (refs #162) Closes t-20260720112615791999-29953-14 Co-Authored-By: Claude Agend-Agent: cb-team-impl Agend-Task: t-20260720112615791999-29953-14 Agend-Branch: feat/162-week-view-time-axis Agend-Issued-At: 2026-07-20T11:28:30.450120+00:00 --- UI.js.html | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/UI.js.html b/UI.js.html index 6c779f6..46f228d 100644 --- a/UI.js.html +++ b/UI.js.html @@ -606,6 +606,27 @@ tr.appendChild(td); AppElements.scheduleBody.appendChild(tr); }); + } else if (app.currentViewMode === AppConfig.MODES.WEEK && app.viewSortMode === 'time') { + 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 => { From c53e716682450fb60fe915ada2e1359e60060616 Mon Sep 17 00:00:00 2001 From: CheerC Date: Mon, 20 Jul 2026 19:41:51 +0800 Subject: [PATCH 07/11] feat: include teacher and classroom in week-time PDFs (refs #162) Closes t-20260720112615791999-29953-14 Co-Authored-By: Claude Agend-Agent: cb-team-impl Agend-Task: t-20260720112615791999-29953-14 Agend-Branch: feat/162-week-view-time-axis Agend-Issued-At: 2026-07-20T11:28:30.450120+00:00 --- PDFExport.js.html | 19 ++++++++------ tests/lib/uiHelpers.js | 13 ++++++++++ tests/unit/uiHelpers.test.js | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) 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/tests/lib/uiHelpers.js b/tests/lib/uiHelpers.js index 85ea5d8..21b7feb 100644 --- a/tests/lib/uiHelpers.js +++ b/tests/lib/uiHelpers.js @@ -84,6 +84,19 @@ export function groupCoursesByStartTime(flatCourses, timeToMinutes) { .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' ? '老師' : '教室'; +} + /** * Computes the CSS classes, background color, and content flags for a class element. * Original: createClassElement data→props (UI.js.html L338-376) diff --git a/tests/unit/uiHelpers.test.js b/tests/unit/uiHelpers.test.js index c42e27c..ec7cedd 100644 --- a/tests/unit/uiHelpers.test.js +++ b/tests/unit/uiHelpers.test.js @@ -10,6 +10,8 @@ import { resolveHeaderState, flattenCoursesForDays, groupCoursesByStartTime, + resolvePdfBottomText, + resolvePdfDiagonalLabel, } from '../lib/uiHelpers.js'; import { timeToMinutes } from '../lib/frontendUtils.js'; @@ -295,6 +297,52 @@ describe('groupCoursesByStartTime', () => { }); }); +// ============================================================ +// 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 // ============================================================ From 8108110ca3127cb872b82e378ce14590566cc61a Mon Sep 17 00:00:00 2001 From: CheerC Date: Mon, 20 Jul 2026 19:43:01 +0800 Subject: [PATCH 08/11] test: document week-time manual gate and mirror boundary (refs #162) Closes t-20260720112615791999-29953-14 Co-Authored-By: Claude Agend-Agent: cb-team-impl Agend-Task: t-20260720112615791999-29953-14 Agend-Branch: feat/162-week-view-time-axis Agend-Issued-At: 2026-07-20T11:28:30.450120+00:00 --- TestCases.md | 16 ++++++++++++++++ tests/lib/uiHelpers.js | 4 ++++ 2 files changed, 20 insertions(+) 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/tests/lib/uiHelpers.js b/tests/lib/uiHelpers.js index 21b7feb..9deb24d 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 * From 0b1fd82c197d14601133ec93bb2dd1301c89fe98 Mon Sep 17 00:00:00 2001 From: CheerC Date: Mon, 20 Jul 2026 19:44:17 +0800 Subject: [PATCH 09/11] docs: add week-time manual verification checklist (refs #162) Closes t-20260720112615791999-29953-14 Co-Authored-By: Claude Agend-Agent: cb-team-impl Agend-Task: t-20260720112615791999-29953-14 Agend-Branch: feat/162-week-view-time-axis Agend-Issued-At: 2026-07-20T11:28:30.450120+00:00 --- ...hecklist-2026-07-20-week-view-time-axis.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/verification-checklist-2026-07-20-week-view-time-axis.md 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..088e1fa --- /dev/null +++ b/docs/verification-checklist-2026-07-20-week-view-time-axis.md @@ -0,0 +1,121 @@ +# 週檢視「依時間」時間軸模式:驗證清單 + +> Generated: 2026-07-20 +> PR: 待建立 +> Verified-at: `8108110`(程式碼驗證當下;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** | From 9b8667700f2b91f1dca959d19a943c9d6de548ad Mon Sep 17 00:00:00 2001 From: CheerC Date: Mon, 20 Jul 2026 20:12:54 +0800 Subject: [PATCH 10/11] fix: share render decision with all-schedules mode (refs #162) Closes t-20260720112615791999-29953-14 Co-Authored-By: Claude Agend-Agent: cb-team-impl Agend-Task: t-20260720112615791999-29953-14 Agend-Branch: feat/162-week-view-time-axis Agend-Issued-At: 2026-07-20T11:28:30.450120+00:00 --- UI.js.html | 34 +++++++++++++++++++-------- ViewDecisionHelpers.js.html | 24 ++++++++++++++----- tests/lib/uiHelpers.js | 28 ++++++++++++---------- tests/unit/uiHelpers.test.js | 45 ++++++++++++++++++++++++++++++++---- 4 files changed, 98 insertions(+), 33 deletions(-) diff --git a/UI.js.html b/UI.js.html index 46f228d..1e572a9 100644 --- a/UI.js.html +++ b/UI.js.html @@ -514,8 +514,15 @@ 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; } @@ -526,11 +533,7 @@ AppElements.scheduleBody.innerHTML = ''; - const renderer = ViewDecisionHelpers.resolveRenderTarget({ - currentViewMode: app.currentViewMode, - viewSortMode: app.viewSortMode, - dayMode: AppConfig.MODES.DAY - }); + const renderer = renderTarget.scheduleRenderer; if (renderer === 'classroom') { this.renderByClassroom(dataToRender); } else if (renderer === 'teacher') { @@ -552,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 = {}; @@ -587,7 +601,7 @@ dataToRender = app.filterDataByActiveFilters(dataToRender); } - if (app.currentViewMode === AppConfig.MODES.DAY && app.viewSortMode === 'time') { + if (scheduleRenderer === 'time') { const day = app.currentDayIndex; const allCoursesForDay = this._flattenCoursesForDays(dataToRender, [day]); allCoursesForDay.sort((a, b) => app.timeToMinutes(a.timeStart) - app.timeToMinutes(b.timeStart)); @@ -606,7 +620,7 @@ tr.appendChild(td); AppElements.scheduleBody.appendChild(tr); }); - } else if (app.currentViewMode === AppConfig.MODES.WEEK && app.viewSortMode === 'time') { + } else if (scheduleRenderer === 'weekTime') { const days = [0, 1, 2, 3, 4, 5, 6]; const flat = this._flattenCoursesForDays(dataToRender, days); const groups = this._groupCoursesByStartTime(flat); diff --git a/ViewDecisionHelpers.js.html b/ViewDecisionHelpers.js.html index f498eaf..5919e1f 100644 --- a/ViewDecisionHelpers.js.html +++ b/ViewDecisionHelpers.js.html @@ -13,12 +13,24 @@ return LEGAL_SORT_MODES.includes(storedSort) ? storedSort : 'classroom'; }, - resolveRenderTarget: function({ currentViewMode, viewSortMode, dayMode }) { - if (viewSortMode === 'classroom') return 'classroom'; - if (viewSortMode === 'teacher') return 'teacher'; - if (currentViewMode === dayMode && viewSortMode === 'time') return 'time'; - if (viewSortMode === 'time') return 'weekTime'; - return 'classroom'; + resolveRenderTarget: function({ activeScheduleId, allSchedulesId, currentViewMode, viewSortMode, dayMode }) { + let scheduleRenderer; + if (viewSortMode === 'classroom') { + scheduleRenderer = 'classroom'; + } else if (viewSortMode === 'teacher') { + scheduleRenderer = 'teacher'; + } else if (currentViewMode === dayMode && viewSortMode === 'time') { + scheduleRenderer = 'time'; + } else if (viewSortMode === 'time') { + scheduleRenderer = 'weekTime'; + } else { + scheduleRenderer = 'classroom'; + } + + return { + renderer: activeScheduleId === allSchedulesId ? 'allSchedules' : scheduleRenderer, + scheduleRenderer + }; }, resolveClassElementFields: function(viewContext) { diff --git a/tests/lib/uiHelpers.js b/tests/lib/uiHelpers.js index 9deb24d..50fc71b 100644 --- a/tests/lib/uiHelpers.js +++ b/tests/lib/uiHelpers.js @@ -21,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 { @@ -33,24 +33,28 @@ 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') { - return { renderer: 'weekTime', shouldFilter }; + scheduleRenderer = 'weekTime'; + } else { + scheduleRenderer = 'classroom'; } - // Fallback - return { renderer: 'classroom', shouldFilter }; + return { + renderer: activeScheduleId === allSchedulesId ? 'allSchedules' : scheduleRenderer, + scheduleRenderer, + shouldFilter, + }; } /** diff --git a/tests/unit/uiHelpers.test.js b/tests/unit/uiHelpers.test.js index ec7cedd..2716618 100644 --- a/tests/unit/uiHelpers.test.js +++ b/tests/unit/uiHelpers.test.js @@ -31,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', () => { @@ -50,7 +62,11 @@ describe('resolveRenderTarget', () => { viewSortMode: 'time', currentViewMode: 'day', }); - expect(result).toEqual({ renderer: 'time', shouldFilter: false }); + expect(result).toEqual({ + renderer: 'time', + scheduleRenderer: 'time', + shouldFilter: false, + }); }); it('routes week + time to the week-time renderer', () => { @@ -59,7 +75,11 @@ describe('resolveRenderTarget', () => { viewSortMode: 'time', currentViewMode: 'week', }); - expect(result).toEqual({ renderer: 'weekTime', shouldFilter: false }); + expect(result).toEqual({ + renderer: 'weekTime', + scheduleRenderer: 'weekTime', + shouldFilter: false, + }); }); it('sets shouldFilter true when activeFilters non-empty', () => { @@ -79,9 +99,24 @@ 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'); }); }); From 76f6ca1fc19f96986b8261d071505bdc7db2d450 Mon Sep 17 00:00:00 2001 From: CheerC Date: Mon, 20 Jul 2026 20:13:40 +0800 Subject: [PATCH 11/11] docs: refresh PR verification checklist head (refs #162) Closes t-20260720112615791999-29953-14 Co-Authored-By: Claude Agend-Agent: cb-team-impl Agend-Task: t-20260720112615791999-29953-14 Agend-Branch: feat/162-week-view-time-axis Agend-Issued-At: 2026-07-20T11:28:30.450120+00:00 --- docs/verification-checklist-2026-07-20-week-view-time-axis.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 088e1fa..df2f48e 100644 --- a/docs/verification-checklist-2026-07-20-week-view-time-axis.md +++ b/docs/verification-checklist-2026-07-20-week-view-time-axis.md @@ -1,8 +1,8 @@ # 週檢視「依時間」時間軸模式:驗證清單 > Generated: 2026-07-20 -> PR: 待建立 -> Verified-at: `8108110`(程式碼驗證當下;checklist commit 會推進 PR head) +> PR: #163 +> Verified-at: `9b86677`(修正程式碼驗證當下;checklist commit 會推進 PR head) > Plan: `docs/superpowers/plans/2026-07-20-week-view-time-axis.md` ## How to test