Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nbtca/prompt",
"version": "1.5.21",
"version": "1.5.22",
"type": "module",
"main": "dist/index.js",
"exports": {
Expand Down
4 changes: 2 additions & 2 deletions src/app/views/schedule-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}]`);
}
});
Expand Down Expand Up @@ -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']);
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/app/views/schedule-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 2 additions & 2 deletions src/app/views/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
},
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 10 additions & 4 deletions src/app/views/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 [];
Expand All @@ -494,6 +498,7 @@ export const scheduleView = {
},

capturesInput(): boolean {
if (state.mode === 'records') return recordsPanel?.capturesInput() ?? false;
return (
state.mode === 'needsLoginId' ||
state.mode === 'needsLoginPassword' ||
Expand All @@ -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' ||
Expand Down Expand Up @@ -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': {
Expand Down Expand Up @@ -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;
}
Expand Down
160 changes: 160 additions & 0 deletions src/app/views/student-records-render.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading