From f31c37eda91ffd295f8e389d4d8c241f234a96e0 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Mon, 19 Oct 2026 15:12:22 +0800 Subject: [PATCH] fix(tui): improve exam and grade views --- README.md | 8 +- package-lock.json | 4 +- package.json | 2 +- src/app/views/schedule-render.test.ts | 4 +- src/app/views/schedule-render.ts | 2 +- src/app/views/schedule.test.ts | 4 +- src/app/views/schedule.ts | 14 +- src/app/views/student-records-render.test.ts | 160 +++++++++ src/app/views/student-records-render.ts | 348 +++++++++++++++++++ src/app/views/student-records.test.ts | 102 ++++++ src/app/views/student-records.ts | 256 ++++++++++++-- src/i18n/locales/en.json | 43 ++- src/i18n/locales/zh.json | 43 ++- 13 files changed, 937 insertions(+), 53 deletions(-) create mode 100644 src/app/views/student-records-render.test.ts create mode 100644 src/app/views/student-records-render.ts diff --git a/README.md b/README.md index 4481d57..418bda8 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,12 @@ Or try it without installing: `npx @nbtca/prompt`. - **Home**: today's classes and the next club events. - **Schedule**: the current term, week, and next break. Log in to the campus system to see your timetable and export it to any calendar app as `.ics`. - Press `a` for personal exam arrangements or `g` for grades. Both are also - available from the public Schedule menu before login. + Press `a` for personal exam arrangements or `c` for grades. Both are also + available from the public Schedule menu before login. Use `←` / `→` to switch + between exams and grades, `↑` / `↓` or `PgUp` / `PgDn` to select courses, and + `Enter` to read full details. Press `/` to search by course name or code, `f` + to filter upcoming exams or unpublished and void grades, and `s` to change + terms. Wide terminals show details beside the list. - **Events**: a countdown to the next event, plus week and month views, search, history, and a one-year activity heatmap. - **Docs**: read and search the knowledge base. It works even when GitHub is diff --git a/package-lock.json b/package-lock.json index c2701ef..b6e2ac0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nbtca/prompt", - "version": "1.5.21", + "version": "1.5.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nbtca/prompt", - "version": "1.5.21", + "version": "1.5.22", "license": "MIT", "dependencies": { "@nbtca/docs": "^0.3.5", diff --git a/package.json b/package.json index 9829525..b860b3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nbtca/prompt", - "version": "1.5.21", + "version": "1.5.22", "type": "module", "main": "dist/index.js", "exports": { diff --git a/src/app/views/schedule-render.test.ts b/src/app/views/schedule-render.test.ts index 3b080d9..2d37849 100644 --- a/src/app/views/schedule-render.test.ts +++ b/src/app/views/schedule-render.test.ts @@ -556,7 +556,7 @@ describe('renderSchedule', () => { const shortcutLines = lines.filter((line) => /\[(?:w|t|s|e|x)\]/.test(stripAnsi(line))); expect(lines.length).toBeLessThanOrEqual(7); expect(shortcutLines.every((line) => visualWidth(line) <= 37)).toBe(true); - for (const key of ['w', 't', 's', 'e', 'a', 'g', 'u', 'x']) { + for (const key of ['w', 't', 's', 'e', 'a', 'c', 'u', 'x']) { expect(text).toContain(`[${key}]`); } }); @@ -925,7 +925,7 @@ describe('hubShortcuts', () => { it('always includes the full-grid, term-density, switch-term, export, and logout shortcuts', () => { const shortcuts = hubShortcuts({ ...baseTimetable, unresolvedItems: [] }); - expect(shortcuts.map((s) => s.key)).toEqual(['w', 't', 's', 'e', 'a', 'g', 'x']); + expect(shortcuts.map((s) => s.key)).toEqual(['w', 't', 's', 'e', 'a', 'c', 'x']); }); }); diff --git a/src/app/views/schedule-render.ts b/src/app/views/schedule-render.ts index 90c3ff4..7ae2920 100644 --- a/src/app/views/schedule-render.ts +++ b/src/app/views/schedule-render.ts @@ -115,7 +115,7 @@ export function hubShortcuts(tt: Timetable): HubShortcut[] { { key: 's', label: trans.timetable.hubSwitchTerm }, { key: 'e', label: trans.timetable.hubExport }, { key: 'a', label: trans.records.exams }, - { key: 'g', label: trans.records.grades }, + { key: 'c', label: trans.records.grades }, ]; if (tt.unresolvedItems.length > 0) { shortcuts.push({ diff --git a/src/app/views/schedule.test.ts b/src/app/views/schedule.test.ts index fabaaa8..cc86c50 100644 --- a/src/app/views/schedule.test.ts +++ b/src/app/views/schedule.test.ts @@ -918,7 +918,7 @@ describe('scheduleView student records', () => { expect(rendered).toContain('Synthetic Algorithms'); expect(rendered).toContain(t().records[kind]); expect(rendered).not.toContain(t().timetable.weekOneAutoFailed); - expect(scheduleView.scrollsBody()).toBe(true); + expect(scheduleView.scrollsBody()).toBe(false); expect(scheduleView.handleBack(ctx)).toBe(true); expect(scheduleView.render(ctx).join('\n')).not.toContain('Synthetic Algorithms'); }, @@ -981,7 +981,7 @@ describe('scheduleView student records', () => { await vi.waitFor(() => { expect(listTerms).toHaveBeenCalledOnce(); }); - scheduleView.handleKey('g', ctx); + scheduleView.handleKey('c', ctx); await vi.waitFor(() => { expect(recordsTransport).toHaveBeenCalledOnce(); expect(scheduleView.isBusy()).toBe(false); diff --git a/src/app/views/schedule.ts b/src/app/views/schedule.ts index 956689e..a249878 100644 --- a/src/app/views/schedule.ts +++ b/src/app/views/schedule.ts @@ -391,7 +391,7 @@ async function openStudentRecords( session.timetableTransport, () => isLifecycleActive(ctx, generation) && recordsPanel === panel, () => { - pendingRecordsKind = kind; + pendingRecordsKind = panel.kind; createSessionStore().clear(); recordsPanel?.dispose(); recordsPanel = null; @@ -479,6 +479,10 @@ export const scheduleView = { return state.mode === 'records' && !recordsPanel?.capturesPageKeys(); }, + contextPath(): readonly string[] | undefined { + return state.mode === 'records' ? recordsPanel?.contextPath() : undefined; + }, + shortcuts(): readonly { key: string; label: string }[] { if (state.mode === 'records') return recordsPanel?.shortcuts() ?? []; if ((state.mode !== 'hub' && state.mode !== 'week') || !state.timetable) return []; @@ -494,6 +498,7 @@ export const scheduleView = { }, capturesInput(): boolean { + if (state.mode === 'records') return recordsPanel?.capturesInput() ?? false; return ( state.mode === 'needsLoginId' || state.mode === 'needsLoginPassword' || @@ -507,7 +512,8 @@ export const scheduleView = { }, footerHint(tabCount: number, cols = Number.POSITIVE_INFINITY): string | undefined { - if (state.mode === 'records') return passiveFooterHint(tabCount, cols); + if (state.mode === 'records') + return recordsPanel?.footerHint(tabCount, cols) ?? passiveFooterHint(tabCount, cols); const capturing = state.mode === 'needsLoginId' || state.mode === 'needsLoginPassword' || @@ -579,7 +585,7 @@ export const scheduleView = { return; } case 'records': - if (key === 'x' && !recordsPanel?.capturesPageKeys()) logout(ctx); + if (key === 'x' && recordsPanel?.acceptsActions()) logout(ctx); else recordsPanel?.handleKey(key, ctx); return; case 'needsLoginId': { @@ -698,7 +704,7 @@ export const scheduleView = { const shortcut = hubShortcuts(tt).find((sc) => sc.key === key); if (!shortcut) return; - if (shortcut.key === 'a' || shortcut.key === 'g') { + if (shortcut.key === 'a' || shortcut.key === 'c') { void openStudentRecords(ctx, shortcut.key === 'a' ? 'exams' : 'grades'); return; } diff --git a/src/app/views/student-records-render.test.ts b/src/app/views/student-records-render.test.ts new file mode 100644 index 0000000..0e1356c --- /dev/null +++ b/src/app/views/student-records-render.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { StudentRecords } from '@nbtca/nbtcal/academic'; +import { + renderStudentRecordsPanel, + renderStudentRecordDetail, + visibleStudentRecords, + type StudentRecordsDisplay, +} from './student-records-render.js'; +import { stripAnsi, visualWidth } from '../../core/text.js'; +import { setLanguage, t } from '../../i18n/index.js'; + +const now = new Date('2026-10-19T16:30:00Z'); +const term = { + academicYear: '2026', + semester: '3', + academicYearLabel: '2026-2027', + semesterLabel: '1', + current: true, +} as const; +const grades: StudentRecords = { + kind: 'grades', + term, + fetchedAt: now, + items: Array.from({ length: 18 }, (_, i) => ({ + courseName: `课程 Course ${i}`, + courseCode: `CS${i}`, + grade: i === 0 ? '0' : i === 1 ? null : '优秀', + credits: 2.5, + gradePoint: i === 0 ? 0 : null, + courseNature: 'Required', + examNature: null, + remark: null, + voided: i === 2, + })), +}; +const exams: StudentRecords = { + kind: 'exams', + term, + fetchedAt: now, + items: Array.from({ length: 3 }, (_, i) => ({ + courseName: ['Past course', 'Next course', 'Pending course'][i] ?? '', + courseCode: `EX${i}`, + examName: null, + assessment: null, + campus: 'Campus', + location: 'Room 201', + seat: '08', + timeText: i === 2 ? '未放开不可查' : null, + start: i === 2 ? null : new Date(`2026-10-${19 + i}T17:00:00+08:00`), + end: i === 2 ? null : new Date(`2026-10-${19 + i}T19:00:00+08:00`), + })), +}; +function state(data: StudentRecords): StudentRecordsDisplay { + return { + kind: data.kind, + term, + data, + pending: false, + error: undefined, + selected: 0, + query: '', + attention: false, + searching: false, + }; +} +function text(lines: string[]): string { + return lines.map(stripAnsi).join('\n'); +} +afterEach(() => { + setLanguage('en'); +}); + +describe('student records viewport', () => { + it.each(['en', 'zh'] as const)( + 'fits %s lists, searches, errors and loading in resized terminals', + (language) => { + setLanguage(language); + for (const data of [grades, exams]) { + for (const [cols, bodyRows] of [ + [120, 25], + [80, 19], + [60, 15], + [40, 7], + [20, 9], + [4, 2], + [1, 0], + ]) { + if (cols === undefined || bodyRows === undefined) continue; + for (const changes of [ + {}, + { searching: true, query: '课程' }, + { pending: true, data: undefined }, + { error: 'Failed to query this term. Please retry.', data: undefined }, + ]) { + const rendered = renderStudentRecordsPanel( + { ...state(data), ...changes }, + bodyRows, + cols, + now, + ); + expect(rendered.lines.length).toBeLessThanOrEqual(bodyRows); + expect(rendered.lines.every((line) => visualWidth(line) <= cols)).toBe(true); + if (bodyRows >= 7) + expect(stripAnsi(rendered.lines.at(-1) ?? '')).toContain( + 'searching' in changes ? 'Enter' : 'x', + ); + } + } + } + }, + ); + + it('shows a dense grade table and pages to the selected course', () => { + const first = renderStudentRecordsPanel(state(grades), 19, 80, now); + expect(first.pageSize).toBeGreaterThanOrEqual(10); + expect(text(first.lines)).toContain('Credits'); + expect(text(first.lines)).toContain('Course 0'); + expect(text(first.lines)).toContain('1/18'); + const last = renderStudentRecordsPanel({ ...state(grades), selected: 17 }, 19, 80, now); + expect(text(last.lines)).toContain('Course 17'); + expect(text(last.lines)).not.toContain('Course 0'); + expect(text(last.lines.at(-1) ? [last.lines.at(-1) ?? ''] : [])).toContain('18/18'); + }); + + it('shows full details beside the list only when there is room', () => { + expect(text(renderStudentRecordsPanel(state(grades), 25, 120, now).lines)).toContain('CS0'); + expect(text(renderStudentRecordsPanel(state(grades), 19, 80, now).lines)).not.toContain('CS0'); + const detail = renderStudentRecordDetail(grades, 0, 20, now); + expect(text(detail)).toContain('Grade: 0'); + expect(text(detail)).toContain('Grade points: 0'); + expect(detail.every((line) => visualWidth(line) <= 20)).toBe(true); + }); + + it('highlights the next exam using campus dates and keeps unpublished times intact', () => { + const output = text(renderStudentRecordsPanel(state(exams), 19, 80, now).lines); + expect(output).toContain('Next exam · Today · Next course'); + expect(output).toContain('17:00–19:00'); + expect(output).toContain('未放开不可查'); + expect(output).toContain('Ended'); + expect(output).toContain('Seat: 08'); + }); + + it('keeps the exam time and room visible in a narrow terminal', () => { + setLanguage('zh'); + const output = text(renderStudentRecordsPanel(state(exams), 7, 40, now).lines); + expect(output).toContain('10/20 17:00–19:00 · Room 201'); + }); + + it('searches names and codes and filters without treating zero or labels as missing grades', () => { + expect(visibleStudentRecords(grades, '', true, now)).toEqual([1, 2]); + expect(visibleStudentRecords(grades, 'CS0', false, now)).toEqual([0]); + expect(visibleStudentRecords(grades, '课程 course 17', false, now)).toEqual([17]); + expect(visibleStudentRecords(exams, '', true, now)).toEqual([1, 2]); + expect(visibleStudentRecords(exams, 'pending', true, now)).toEqual([2]); + const empty = text( + renderStudentRecordsPanel({ ...state(grades), query: 'missing' }, 19, 80, now).lines, + ); + expect(empty).toContain(t().records.clearFilters); + }); +}); diff --git a/src/app/views/student-records-render.ts b/src/app/views/student-records-render.ts new file mode 100644 index 0000000..4ca1795 --- /dev/null +++ b/src/app/views/student-records-render.ts @@ -0,0 +1,348 @@ +import type { + StudentExam, + StudentGrade, + StudentRecords, + StudentRecordsKind, +} from '@nbtca/nbtcal/academic'; +import { campusIsoDate, type AcademicTerm } from '@nbtca/nbtcal/timetable'; +import { c, type, glyph, space, bodyEdge } from '../../core/theme.js'; +import { + clipAnsiToVisualWidth, + padEndV, + sanitizeTerminalLine, + truncate, + visualWidth, + wrapAnsiToVisualWidth, +} from '../../core/text.js'; +import { spinnerFrame } from '../../core/components/spinner.js'; +import { isoDayDifference } from '../../core/calendar-day.js'; +import { displaySemesterLabel } from '../../features/student-timetable.js'; +import { fmt, getCurrentLanguage, t } from '../../i18n/index.js'; + +export interface StudentRecordsDisplay { + kind: StudentRecordsKind; + term: AcademicTerm; + data: StudentRecords | undefined; + pending: boolean; + error: string | undefined; + selected: number; + query: string; + attention: boolean; + searching: boolean; +} + +const formatters = new Map(); +function dateText(date: Date, format: 'day' | 'shortDay' | 'clock'): string { + const language = getCurrentLanguage(); + const key = `${language}:${format}`; + let formatter = formatters.get(key); + if (!formatter) { + formatter = new Intl.DateTimeFormat(language === 'zh' ? 'zh-CN' : 'en-GB', { + timeZone: 'Asia/Shanghai', + ...(format !== 'clock' + ? ({ + month: '2-digit', + day: '2-digit', + ...(format === 'day' ? { weekday: 'short' } : {}), + } as const) + : ({ hour: '2-digit', minute: '2-digit', hourCycle: 'h23' } as const)), + }); + formatters.set(key, formatter); + } + if (format === 'clock') return formatter.format(date); + const parts = formatter.formatToParts(date); + const part = (name: Intl.DateTimeFormatPartTypes) => + parts.find((entry) => entry.type === name)?.value ?? ''; + if (format === 'shortDay') return `${part('month')}/${part('day')}`; + return language === 'zh' + ? `${part('month')}/${part('day')} ${part('weekday')}` + : `${part('weekday')} ${part('day')}/${part('month')}`; +} + +function examStatus(exam: StudentExam, now: Date): string { + const trans = t().records; + if (!exam.start || !exam.end) return trans.pendingTag; + if (exam.end <= now) return trans.ended; + if (exam.start <= now) return trans.inProgress; + const days = isoDayDifference(campusIsoDate(now), campusIsoDate(exam.start)); + return days === 0 ? trans.today : days === 1 ? trans.tomorrow : fmt(trans.daysAway, { days }); +} + +function examTime(exam: StudentExam, compact = false): string { + return exam.start && exam.end + ? `${dateText(exam.start, compact ? 'shortDay' : 'day')} ${dateText(exam.start, 'clock')}–${dateText(exam.end, 'clock')}` + : (exam.timeText ?? t().records.timeUnavailable); +} + +export function visibleStudentRecords( + data: StudentRecords | undefined, + query: string, + attention: boolean, + now: Date, +): number[] { + if (!data) return []; + const needle = query.trim().normalize('NFKC').toLowerCase(); + return data.items.flatMap((item, index) => { + if ( + needle && + !`${item.courseName} ${item.courseCode ?? ''}` + .normalize('NFKC') + .toLowerCase() + .includes(needle) + ) + return []; + if ( + attention && + ('end' in item ? item.end !== null && item.end <= now : item.grade !== null && !item.voided) + ) + return []; + return [index]; + }); +} + +function pair(left: string, right: string, width: number): string { + const room = width - visualWidth(right) - 2; + if (room < 4) return truncate(left, width, '…'); + return padEndV(truncate(left, room, '…'), width - visualWidth(right)) + right; +} + +function resultText(grade: StudentGrade): string { + const value = grade.grade ?? t().records.notPublished; + return grade.voided ? `${value} ${t().records.voidTag}` : value; +} + +function gradeRow(grade: StudentGrade, width: number, selected: boolean, table: boolean): string[] { + const marker = selected ? `${glyph.cursor()} ` : ' '; + if (!table) { + return [ + pair(marker + grade.courseName, resultText(grade), width), + ` ${[grade.credits === null ? '' : fmt(t().records.credits, { value: grade.credits }), grade.gradePoint === null ? '' : fmt(t().records.gradePoint, { value: grade.gradePoint })].filter(Boolean).join(' · ')}`, + ]; + } + const courseWidth = width - 31; + const value = padEndV(truncate(resultText(grade), 10, '…'), 10); + const credits = String(grade.credits ?? '—'); + const point = String(grade.gradePoint ?? '—'); + const plain = `${marker}${padEndV(truncate(grade.courseName, courseWidth, '…'), courseWidth)} ${value} ${padEndV(credits, 7)} ${padEndV(point, 6)}`; + if (selected) return [type.cursor(padEndV(plain, width))]; + return [ + `${marker}${padEndV(truncate(grade.courseName, courseWidth, '…'), courseWidth)} ${grade.voided || grade.grade === null ? c.warn(value) : type.heading(value)} ${type.hint(padEndV(credits, 7))} ${type.hint(padEndV(point, 6))}`, + ]; +} + +function examRow(exam: StudentExam, width: number, selected: boolean, now: Date): string[] { + const main = pair( + `${selected ? glyph.cursor() : ' '} ${exam.courseName}`, + examStatus(exam, now), + width, + ); + const location = [ + exam.location, + width >= 54 && exam.seat ? fmt(t().records.seat, { value: exam.seat }) : null, + ] + .filter(Boolean) + .join(' · '); + const meta = ` ${examTime(exam, width < 54)}${location ? ` · ${location}` : ''}`; + return [ + selected + ? type.cursor(padEndV(main, width)) + : exam.end && exam.end <= now + ? type.hint(main) + : type.heading(main), + type.hint(truncate(meta, width, '…')), + ]; +} + +export function renderStudentRecordDetail( + data: StudentRecords, + index: number, + cols: number, + now: Date, +): string[] { + const item = data.items[index]; + if (!item) return []; + const trans = t().records; + const lines: string[] = []; + const add = (value: string, style = type.body) => { + lines.push(...wrapAnsiToVisualWidth(style(sanitizeTerminalLine(value)), cols)); + }; + add(item.courseName, type.heading); + if (item.courseCode) add(item.courseCode, type.hint); + lines.push(''); + if (data.kind === 'grades') { + const grade = item as StudentGrade; + add( + fmt(trans.gradeValue, { value: grade.grade ?? trans.notPublished }), + grade.voided ? c.warn : type.active, + ); + if (grade.voided) add(trans.voided, c.warn); + lines.push(''); + if (grade.credits !== null) add(fmt(trans.credits, { value: grade.credits })); + if (grade.gradePoint !== null) add(fmt(trans.gradePoint, { value: grade.gradePoint })); + if (grade.courseNature) add(`${trans.courseNature} ${grade.courseNature}`); + if (grade.examNature) add(`${trans.examNature} ${grade.examNature}`); + if (grade.remark) { + lines.push(''); + add(grade.remark, type.hint); + } + } else { + const exam = item as StudentExam; + add(examStatus(exam, now), exam.start ? type.active : c.warn); + add(examTime(exam)); + lines.push(''); + if (exam.location) add(`${trans.location} ${exam.location}`); + if (exam.campus) add(`${trans.campus} ${exam.campus}`); + if (exam.seat) add(fmt(trans.seat, { value: exam.seat }), type.heading); + if (exam.examName) add(`${trans.examName} ${exam.examName}`); + if (exam.assessment) add(`${trans.assessment} ${exam.assessment}`); + } + return lines; +} + +function summary(data: StudentRecords, attention: boolean): string { + const trans = t().records; + if (data.kind === 'grades') { + const published = data.items.filter((grade) => grade.grade !== null).length; + return `${fmt(trans.gradeSummary, { published, total: data.items.length })}${attention ? ` · ${trans.needsAttention}` : ''}`; + } + const pending = data.items.filter((exam) => !exam.start).length; + return `${fmt(trans.examSummary, { total: data.items.length, pending })}${attention ? ` · ${trans.upcomingOnly}` : ''}`; +} + +export function renderStudentRecordsPanel( + state: StudentRecordsDisplay, + bodyRows: number, + cols: number, + now: Date, +): { lines: string[]; pageSize: number } { + const rows = Math.max(0, Math.floor(bodyRows)); + const width = Math.max(1, bodyEdge(cols) - (cols > 6 ? space.indent.length : 0)); + const indent = cols > 6 ? space.indent : ''; + const trans = t().records; + const lines: string[] = []; + const tabs = (['exams', 'grades'] as const) + .map((kind) => (kind === state.kind ? type.active(`[${trans[kind]}]`) : type.hint(trans[kind]))) + .join(' '); + lines.push( + visualWidth(tabs) <= width ? tabs : type.heading(truncate(trans[state.kind], width, '…')), + ); + if (rows >= 6) { + const term = `${state.term.academicYearLabel} · ${displaySemesterLabel(state.term)}`; + const updated = + state.attention && rows < 10 + ? trans.filtered + : state.data + ? `${trans.updated} ${dateText(state.data.fetchedAt, 'clock')}` + : ''; + lines.push(type.hint(pair(term, updated, width))); + } + if (rows >= 10 && state.data) + lines.push(type.hint(truncate(summary(state.data, state.attention), width, '…'))); + if (state.query || state.searching) + lines.push( + type.active( + truncate( + `/ ${state.query}${state.searching ? '▏' : ''}${state.searching && !state.query ? ` ${trans.searchHint}` : ''}`, + width, + '…', + ), + ), + ); + if (state.data?.kind === 'exams' && rows >= 15 && !state.query) { + const next = state.data.items.find((exam) => exam.start && exam.end && exam.end > now); + if (next) { + lines.push(''); + lines.push( + type.active( + truncate(`${trans.nextExam} · ${examStatus(next, now)} · ${next.courseName}`, width, '…'), + ), + ); + lines.push( + type.hint( + truncate(`${examTime(next)}${next.location ? ` · ${next.location}` : ''}`, width, '…'), + ), + ); + } + } + if (rows >= 10) lines.push(''); + const indices = visibleStudentRecords(state.data, state.query, state.attention, now); + const selected = Math.min(Math.max(0, state.selected), Math.max(0, indices.length - 1)); + const split = width >= 94 && rows >= 12 && indices.length > 0; + const listWidth = split ? Math.floor(width * 0.6) : width; + const table = state.kind === 'grades' && listWidth >= 54 && rows >= 9 && indices.length > 0; + if (table) { + lines.push( + type.hint( + ` ${padEndV(trans.course, listWidth - 31)} ${padEndV(trans.result, 10)} ${padEndV(trans.creditLabel, 7)} ${trans.pointLabel}`, + ), + ); + lines.push(type.hint(glyph.rule().repeat(width))); + } + const available = Math.max(0, rows - lines.length - 1); + const rowHeight = table || rows < 7 ? 1 : 2; + const pageSize = Math.max(1, Math.floor(available / rowHeight)); + const start = Math.floor(selected / pageSize) * pageSize; + const list: string[] = []; + if (state.pending) list.push(type.hint(`${spinnerFrame()} ${trans.loading}`)); + else if (state.error) list.push(...wrapAnsiToVisualWidth(c.warn(state.error), listWidth)); + else if (indices.length === 0) { + const filtered = Boolean(state.query || state.attention); + list.push( + ...wrapAnsiToVisualWidth( + filtered ? trans.noMatches : state.kind === 'grades' ? trans.noGrades : trans.noExams, + listWidth, + ), + ); + list.push(''); + list.push( + ...wrapAnsiToVisualWidth( + type.hint(filtered ? trans.clearFilters : trans.emptyHint), + listWidth, + ), + ); + } else if (state.data) { + for (let offset = start; offset < Math.min(indices.length, start + pageSize); offset += 1) { + const index = indices[offset]; + const item = index === undefined ? undefined : state.data.items[index]; + if (!item) continue; + const record = + state.kind === 'grades' + ? gradeRow(item as StudentGrade, listWidth, offset === selected, table) + : examRow(item as StudentExam, listWidth, offset === selected, now); + list.push( + ...record + .slice(0, rowHeight) + .map((line) => + offset === selected && !table && state.kind === 'grades' ? type.active(line) : line, + ), + ); + } + } + const detail = + split && state.data && indices[selected] !== undefined + ? renderStudentRecordDetail(state.data, indices[selected], width - listWidth - 3, now) + : []; + for (let row = 0; row < available; row += 1) { + const left = clipAnsiToVisualWidth(list[row] ?? '', listWidth); + lines.push( + split ? `${padEndV(left, listWidth)} ${type.hint(glyph.bar())} ${detail[row] ?? ''}` : left, + ); + } + const count = indices.length ? `${selected + 1}/${indices.length}` : ''; + const actions = + (state.searching + ? [trans.searchFooter, 'Enter · Esc'] + : state.pending + ? ['←→ · x'] + : [ + `s ${trans.termAction} · / ${trans.searchAction} · f ${state.attention ? trans.allRecords : trans.filterAction} · r ${trans.refresh} · x ${t().timetable.hubLogout}`, + 's · / · f · r · x', + 's / f r x', + ] + ).find((value) => visualWidth(value) + visualWidth(count) + 2 <= width) ?? ''; + lines.push(type.hint(pair(actions, count, width))); + return { + lines: lines.slice(0, rows).map((line) => indent + clipAnsiToVisualWidth(line, width)), + pageSize, + }; +} diff --git a/src/app/views/student-records.test.ts b/src/app/views/student-records.test.ts index d2cbbe8..ddd2ccf 100644 --- a/src/app/views/student-records.test.ts +++ b/src/app/views/student-records.test.ts @@ -69,7 +69,109 @@ describe('student records panel', () => { '2025', ); expect(panel.render(context).join('\n')).toContain('2025-2026'); + expect(panel.capturesPageKeys()).toBe(true); + }); + + it('keeps course selection, live search and detail navigation in the page', async () => { + const transport: AuthenticatedTransport = () => + Promise.resolve( + new Response( + JSON.stringify({ + items: Array.from({ length: 20 }, (_, i) => ({ + xnm: '2026', + xqm: '3', + kcmc: `Course ${i}`, + kch: `CS${i}`, + cj: i === 0 ? null : String(i), + })), + currentPage: 1, + totalPage: 1, + totalResult: 20, + }), + ), + ); + const panel = new StudentRecordsPanel('grades', term, terms, transport, () => true, vi.fn()); + const context = ctx(); + await panel.load(context); + panel.render(context); + panel.handleKey('\x1b[6~', context); + expect(panel.render(context).join('\n')).not.toContain('Course 0'); + panel.handleKey('\x1b[F', context); + panel.handleKey('\r', context); expect(panel.capturesPageKeys()).toBe(false); + expect(panel.render(context).join('\n')).toContain('CS19'); + expect(panel.shortcuts().some((item) => item.key === '/')).toBe(false); + expect(panel.handleBack()).toBe(true); + expect(panel.capturesPageKeys()).toBe(true); + panel.handleKey('/', context); + expect(panel.capturesInput()).toBe(true); + expect(panel.acceptsActions()).toBe(false); + panel.handleKey('CS19', context); + expect(panel.render(context).join('\n')).toContain('1/1'); + panel.handleKey('\r', context); + expect(panel.capturesInput()).toBe(false); + panel.handleKey('/', context); + panel.handleKey('gq123', context); + expect(panel.render(context).join('\n')).toContain('gq123'); + expect(panel.handleBack()).toBe(true); + expect(panel.render(context).join('\n')).toContain('/ CS19'); + expect(panel.handleBack()).toBe(true); + panel.handleKey('f', context); + expect(panel.render(context).join('\n')).toContain('1/1'); + expect(panel.render(context).join('\n')).toContain('Course 0'); + expect(panel.handleBack()).toBe(true); + expect(panel.handleBack()).toBe(false); + panel.dispose(); + }); + + it('reuses the other view in memory and invalidates it when changing terms', async () => { + const transport = vi.fn((url, init) => { + const body = new URLSearchParams(init.body as string); + return Promise.resolve( + new Response( + JSON.stringify({ + items: [ + { + xnm: body.get('xnm'), + xqm: body.get('xqm'), + kcmc: url.pathname.includes('cjcx') ? 'Grade course' : 'Exam course', + cj: '90', + }, + ], + currentPage: 1, + totalPage: 1, + totalResult: 1, + }), + ), + ); + }); + const panel = new StudentRecordsPanel('grades', term, terms, transport, () => true, vi.fn()); + const context = ctx(); + await panel.load(context); + panel.handleKey('\x1b[C', context); + await vi.waitFor(() => { + expect(panel.isBusy()).toBe(false); + }); + expect(panel.kind).toBe('exams'); + panel.handleKey('\x1b[D', context); + expect(panel.kind).toBe('grades'); + expect(transport).toHaveBeenCalledTimes(2); + expect(panel.render(context).join('\n')).toContain('Grade course'); + panel.handleKey('s', context); + panel.handleKey('\x1b[B', context); + panel.handleKey('\r', context); + await vi.waitFor(() => { + expect(panel.isBusy()).toBe(false); + }); + panel.handleKey('\x1b[C', context); + await vi.waitFor(() => { + expect(panel.isBusy()).toBe(false); + }); + expect(transport).toHaveBeenCalledTimes(4); + expect(new URLSearchParams(transport.mock.calls[3]?.[1].body as string).get('xnm')).toBe( + '2025', + ); + panel.dispose(); }); it('clears the previous term before a failed refresh', async () => { diff --git a/src/app/views/student-records.ts b/src/app/views/student-records.ts index 4c5ff74..7cfba8e 100644 --- a/src/app/views/student-records.ts +++ b/src/app/views/student-records.ts @@ -1,31 +1,43 @@ import type { AcademicTerm, AuthenticatedTransport } from '@nbtca/nbtcal/timetable'; +import type { StudentRecordsKind, StudentRecords } from '@nbtca/nbtcal/academic'; import { JWXT_ORIGIN } from '../../config/campus.js'; -import { loadingLines } from '../../core/components/spinner.js'; import { wrapAnsiWithIndent } from '../../core/text.js'; +import { space, type, glyph } from '../../core/theme.js'; +import { setVimKeysActive } from '../../core/vim-keys.js'; import { isSessionExpired, safeMessage, displaySemesterLabel, } from '../../features/student-timetable.js'; -import { - createStudentRecordsClient, - type StudentRecords, - type StudentRecordsKind, -} from '../../features/student-records-client.js'; -import { renderStudentRecords } from '../../features/student-records-render.js'; +import { createStudentRecordsClient } from '../../features/student-records-client.js'; import { t } from '../../i18n/index.js'; import { ListField, computeMaxVisible, renderListFieldWithContext } from '../fields/list-field.js'; +import { TextField } from '../fields/text-field.js'; +import { fitFooterHint, passiveFooterHint } from '../chrome.js'; +import { + renderStudentRecordsPanel, + renderStudentRecordDetail, + visibleStudentRecords, +} from './student-records-render.js'; import type { AppContext } from '../view.js'; export class StudentRecordsPanel { private data: StudentRecords | undefined; private error: string | undefined; private termsField: ListField | undefined; + private searchField: TextField | undefined; + private previousQuery = ''; + private query = ''; + private attention = false; + private selected = 0; + private pageSize = 1; + private detailIndex: number | undefined; private controller: AbortController | undefined; private pending = false; + private readonly results = new Map(); constructor( - readonly kind: StudentRecordsKind, + public kind: StudentRecordsKind, private term: AcademicTerm, private readonly catalog: readonly AcademicTerm[], private readonly transport: AuthenticatedTransport, @@ -43,7 +55,9 @@ export class StudentRecordsPanel { this.pending = true; this.error = undefined; this.data = undefined; + this.results.delete(this.kind); this.termsField = undefined; + this.detailIndex = undefined; ctx.resetScroll(); ctx.rerender(); try { @@ -54,6 +68,12 @@ export class StudentRecordsPanel { ); if (signal.aborted || !this.isActive()) return; this.data = data; + this.results.set(data.kind, data); + const next = + data.kind === 'exams' + ? data.items.findIndex((exam) => exam.start && exam.end && exam.end > new Date()) + : -1; + this.selected = Math.max(0, this.indices().indexOf(next)); } catch (error) { if (signal.aborted || !this.isActive()) return; if (isSessionExpired(error)) this.onSessionExpired(); @@ -69,31 +89,109 @@ export class StudentRecordsPanel { dispose(): void { this.controller?.abort(); this.data = undefined; + this.results.clear(); this.termsField = undefined; + this.searchField = undefined; + this.query = ''; + setVimKeysActive(true); } isBusy(): boolean { return this.pending; } - + capturesInput(): boolean { + return this.searchField !== undefined; + } capturesPageKeys(): boolean { - return this.termsField !== undefined; + return this.detailIndex === undefined; + } + acceptsActions(): boolean { + return !this.termsField && !this.searchField; + } + contextPath(): readonly string[] { + return [ + t().records[this.kind], + `${this.term.academicYearLabel} · ${displaySemesterLabel(this.term)}`, + ]; + } + + footerHint(tabCount: number, cols: number): string { + if (this.searchField) + return fitFooterHint( + cols, + `${t().records.searchFooter} · Ctrl+C ${t().menu.hintQuit}`, + 'Enter · Esc · Ctrl+C', + 'Esc Ctrl+C', + ); + if (this.pending) return passiveFooterHint(tabCount, cols); + if (this.termsField || this.detailIndex !== undefined) return passiveFooterHint(tabCount, cols); + const trans = t(); + return fitFooterHint( + cols, + `${glyph.updown()} ${trans.records.moveAction} · ${glyph.enter()} ${trans.records.detailAction} · ←→ ${trans.records.switchView} · Esc ${trans.menu.hintBack} · ? ${trans.help.title}`, + `${glyph.updown()} ${glyph.enter()} ${trans.records.detailAction} · ←→ ${trans.records.switchView} · Esc · ?`, + `${glyph.updown()} ${glyph.enter()} · ←→ · Esc · ?`, + 'Esc · ?', + ); } handleBack(): boolean { - if (!this.termsField) return false; - this.termsField = undefined; - return true; + if (this.searchField) { + this.query = this.previousQuery; + this.searchField = undefined; + this.selected = 0; + setVimKeysActive(true); + return true; + } + if (this.termsField) { + this.termsField = undefined; + return true; + } + if (this.detailIndex !== undefined) { + this.detailIndex = undefined; + return true; + } + if (this.query || this.attention) { + this.query = ''; + this.attention = false; + this.selected = 0; + return true; + } + return false; } shortcuts(): readonly { key: string; label: string }[] { - return this.pending || this.termsField - ? [] - : [ - { key: 's', label: t().records.switchTerm }, - { key: 'r', label: t().records.refresh }, - { key: 'x', label: t().timetable.hubLogout }, - ]; + if (this.searchField || this.termsField) return []; + if (this.pending) + return [ + { key: '← →', label: t().records.switchView }, + { key: 'x', label: t().timetable.hubLogout }, + ]; + const trans = t(); + return [ + ...(this.detailIndex === undefined + ? [ + { key: glyph.updown(), label: trans.records.moveAction }, + { key: glyph.enter(), label: trans.records.detailAction }, + ] + : []), + { key: '← →', label: trans.records.switchView }, + ...(this.detailIndex === undefined + ? [ + { key: '/', label: trans.records.searchAction }, + { + key: 'f', + label: + this.kind === 'exams' + ? trans.records.upcomingFilter + : trans.records.attentionFilter, + }, + ] + : []), + { key: 's', label: trans.records.switchTerm }, + { key: 'r', label: trans.records.refresh }, + { key: 'x', label: trans.timetable.hubLogout }, + ]; } render(ctx: AppContext): string[] { @@ -101,39 +199,94 @@ export class StudentRecordsPanel { this.termsField.setMaxVisible(computeMaxVisible(ctx.bodyRows)); return renderListFieldWithContext([], this.termsField, ctx.bodyRows, ctx.size.cols); } - if (this.pending) return loadingLines(t().common.loading, ctx.size.cols); - const lines = this.data - ? renderStudentRecords(this.data, this.term, ctx.size.cols) - : wrapAnsiWithIndent(this.error ?? t().timetable.genericError, ctx.size.cols, ' '); - return [ - ...lines, - '', - ...wrapAnsiWithIndent( - `[s] ${t().records.switchTerm} · [r] ${t().records.refresh} · [x] ${t().timetable.hubLogout}`, - ctx.size.cols, - ' ', - ), - ]; + if (this.detailIndex !== undefined && this.data) { + const index = this.detailIndex; + return [ + ...wrapAnsiWithIndent(type.hint(t().records.detailHint), ctx.size.cols, space.indent), + '', + ...renderStudentRecordDetail( + this.data, + index, + Math.max(1, ctx.size.cols - 6), + new Date(), + ).map((line) => space.indent + line), + ]; + } + const rendered = renderStudentRecordsPanel( + { + kind: this.kind, + term: this.term, + data: this.data, + pending: this.pending, + error: this.error, + selected: this.selected, + query: this.query, + attention: this.attention, + searching: this.searchField !== undefined, + }, + ctx.bodyRows, + ctx.size.cols, + new Date(), + ); + this.pageSize = rendered.pageSize; + return rendered.lines; + } + + private indices(): number[] { + return visibleStudentRecords(this.data, this.query, this.attention, new Date()); } handleKey(key: string, ctx: AppContext): void { + if (this.searchField) { + const result = this.searchField.handleKey(key); + this.query = this.searchField.currentValue; + this.selected = 0; + if (result.cancelled) this.handleBack(); + else if (result.submitted !== undefined) { + this.searchField = undefined; + setVimKeysActive(true); + } + return; + } if (this.termsField) { const result = this.termsField.handleKey(key); - if (result.cancelled || result.selected === '__back__') { - this.termsField = undefined; - } else if (result.selected) { + if (result.cancelled || result.selected === '__back__') this.termsField = undefined; + else if (result.selected) { const term = this.catalog.find( (candidate) => `${candidate.academicYear}:${candidate.semester}` === result.selected, ); if (term) { this.term = term; + this.results.clear(); + this.query = ''; + this.attention = false; + this.selected = 0; void this.load(ctx); } } return; } + if (key === '\x1b[C' || key === '\x1b[D') { + this.controller?.abort(); + this.kind = this.kind === 'exams' ? 'grades' : 'exams'; + this.detailIndex = undefined; + this.query = ''; + this.attention = false; + this.selected = 0; + this.error = undefined; + ctx.resetScroll(); + const cached = this.results.get(this.kind); + if (cached) { + this.data = cached; + this.pending = false; + } else void this.load(ctx); + return; + } if (this.pending) return; - if (key === 'r') void this.load(ctx); + if (key === 'r') { + void this.load(ctx); + return; + } if (key === 's') { this.termsField = new ListField({ title: t().records.switchTerm, @@ -153,6 +306,35 @@ export class StudentRecordsPanel { ), }); ctx.resetScroll(); + return; + } + if (this.detailIndex !== undefined) return; + if (key === '/') { + this.previousQuery = this.query; + this.searchField = new TextField({ message: t().records.searchPrompt, allowEmpty: true }); + this.query = ''; + this.selected = 0; + setVimKeysActive(false); + return; + } + if (key === 'f') { + this.attention = !this.attention; + this.selected = 0; + return; + } + const count = this.indices().length; + const last = Math.max(0, count - 1); + this.selected = Math.min(last, this.selected); + if (key === '\x1b[A') this.selected = Math.max(0, this.selected - 1); + if (key === '\x1b[B') this.selected = Math.min(last, this.selected + 1); + if (key === '\x1b[5~') this.selected = Math.max(0, this.selected - this.pageSize); + if (key === '\x1b[6~' || key === ' ') + this.selected = Math.min(last, this.selected + this.pageSize); + if (key === '\x1b[H') this.selected = 0; + if (key === '\x1b[F') this.selected = last; + if ((key === '\r' || key === '\n') && count > 0) { + this.detailIndex = this.indices()[this.selected]; + ctx.resetScroll(); } } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e22d7cd..e91c70c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -229,7 +229,48 @@ "switchTerm": "Switch term", "exportNeedsTime": "Some exam times are unpublished. A complete exam calendar cannot be exported yet.", "invalidExportOptions": "Use a nonempty --output path for exams without --json.", - "exported": "Exported the exam calendar to {file}." + "exported": "Exported the exam calendar to {file}.", + "pendingTag": "Pending", + "ended": "Ended", + "inProgress": "In progress", + "today": "Today", + "tomorrow": "Tomorrow", + "daysAway": "In {days}d", + "voidTag": "Void", + "courseNature": "Course type", + "examNature": "Attempt", + "location": "Room", + "campus": "Campus", + "examName": "Exam", + "assessment": "Assessment", + "gradeSummary": "{published}/{total} courses published", + "examSummary": "{total} exams · {pending} awaiting time", + "needsAttention": "Needs attention", + "upcomingOnly": "Upcoming + pending", + "updated": "Updated", + "nextExam": "Next exam", + "course": "Course", + "result": "Grade", + "creditLabel": "Credits", + "pointLabel": "Points", + "loading": "Querying the selected term…", + "noMatches": "No courses match this view.", + "clearFilters": "Press Esc to clear the search and filter.", + "emptyHint": "Press s to check another term.", + "termAction": "Term", + "searchAction": "Search", + "filterAction": "Filter", + "detailAction": "Details", + "moveAction": "Select course", + "switchView": "Switch exams / grades", + "searchPrompt": "Search courses", + "searchHint": "Course name or code", + "allRecords": "All", + "detailHint": "Esc returns to the list", + "searchFooter": "Enter apply · Esc cancel", + "upcomingFilter": "Upcoming exams", + "attentionFilter": "Unpublished / void grades", + "filtered": "Filtered" }, "timetable": { "menuTitle": "My Timetable", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6b436cd..3799e98 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -229,7 +229,48 @@ "switchTerm": "切换学期", "exportNeedsTime": "部分考试尚未公布时间,暂时无法导出完整的考试日历。", "invalidExportOptions": "请使用非空的 --output 路径导出考试日历,并且不要同时使用 --json。", - "exported": "考试日历已导出至 {file}。" + "exported": "考试日历已导出至 {file}。", + "pendingTag": "待定", + "ended": "已结束", + "inProgress": "进行中", + "today": "今天", + "tomorrow": "明天", + "daysAway": "{days}天后", + "voidTag": "作废", + "courseNature": "课程性质", + "examNature": "考试性质", + "location": "考场", + "campus": "校区", + "examName": "考试名称", + "assessment": "考核方式", + "gradeSummary": "共 {total} 门课程,已公布 {published} 门", + "examSummary": "共 {total} 场考试,{pending} 场时间待定", + "needsAttention": "仅待关注", + "upcomingOnly": "仅待参加及时间待定", + "updated": "更新于", + "nextExam": "下一场考试", + "course": "课程", + "result": "成绩", + "creditLabel": "学分", + "pointLabel": "绩点", + "loading": "正在查询这个学期…", + "noMatches": "没有符合条件的课程。", + "clearFilters": "按 Esc 清除搜索和筛选。", + "emptyHint": "按 s 切换学期,查看历史记录。", + "termAction": "学期", + "searchAction": "搜索", + "filterAction": "筛选", + "detailAction": "详情", + "moveAction": "选择课程", + "switchView": "切换考试和成绩", + "searchPrompt": "搜索课程", + "searchHint": "输入课程名或课程代码", + "allRecords": "全部", + "detailHint": "按 Esc 返回列表", + "searchFooter": "Enter 确认 · Esc 取消", + "upcomingFilter": "待参加的考试", + "attentionFilter": "待公布或作废的成绩", + "filtered": "筛选中" }, "timetable": { "menuTitle": "我的课表",