From f61a1a9f6aa4e5b0157354738ba40437ac6919d4 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sat, 26 Sep 2026 23:30:47 +0800 Subject: [PATCH] fix(schedule): polish the hub, grid, detail card, and term density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keep the hub layout after [e]; show "Saved: " on one line with a middle ellipsis instead of a wrapped "Success:" block. - Show the week agenda below 120 columns when it fits, and only use the grid when names fit unclipped or the frame cannot grow further. - Spread spare grid width evenly across days, keep a three-column gutter after the time column, and stay inside the frame rule. - Name the weekday in the class detail ("周一"/"Mon") and use one range dash for times and week spans. - Replace the term density strip with a bar chart scaled to the busiest week, with week numbers, months, this week, and a one-line summary. - Align the status and location columns in the day timelines. - Label Esc in the schedule footer when it fits. - Hide [w] while the hub already shows the whole week grid. - Mark the next-class banner with a dot instead of the selection arrow. --- src/app/views/schedule-render.test.ts | 98 ++++-- src/app/views/schedule-render.ts | 137 +++++--- src/app/views/schedule.test.ts | 28 +- src/app/views/schedule.ts | 9 +- src/features/schedule-render.test.ts | 277 ++++++++++------ src/features/schedule-render.ts | 436 ++++++++++++++------------ src/i18n/index.ts | 6 + src/i18n/locales/en.json | 6 + src/i18n/locales/zh.json | 6 + 9 files changed, 644 insertions(+), 359 deletions(-) diff --git a/src/app/views/schedule-render.test.ts b/src/app/views/schedule-render.test.ts index a05096a..46c8c6b 100644 --- a/src/app/views/schedule-render.test.ts +++ b/src/app/views/schedule-render.test.ts @@ -448,13 +448,78 @@ describe('renderSchedule', () => { timetable: busyTimetable, }, campusDateTime('2026-09-07', '07:00'), - 19, + 12, 80, ).map(stripAnsi); expect(lines.filter((line) => line.includes('08:00'))).toHaveLength(1); }); + it('shows the week agenda instead of one day when it fits below a hundred columns', () => { + const lines = renderSchedule( + { mode: 'hub', key: '2026-3', weekOne: '2026-09-07', timetable: busyTimetable }, + campusDateTime('2026-09-07', '07:00'), + 25, + 100, + ).map(stripAnsi); + const out = lines.join('\n'); + + expect(out).toMatch(/Mon\S? +08:00 Math/); + expect(out).toMatch(/ +10:00 Physics/); + expect(out).toContain('Sun'); + expect(lines.some((line) => line.trimStart().startsWith('←'))).toBe(false); + }); + + it('hides [w] while the hub already shows the whole week grid', () => { + const render = (rows: number, cols: number): string => + stripAnsi( + renderSchedule( + { mode: 'hub', key: '2026-3', weekOne: '2026-09-07', timetable: busyTimetable }, + campusDateTime('2026-09-07', '07:00'), + rows, + cols, + ).join('\n'), + ); + + expect(render(45, 120)).toMatch(/19:00.19:45/); + expect(render(45, 120)).not.toContain('[w]'); + expect(render(25, 100)).toContain('[w]'); + }); + + it.each([ + ['en', 'Saved: '], + ['zh', '已保存:'], + ] as const)( + 'keeps the grid after an export and shows the path on one line (%s)', + (lang, prefix) => { + setLanguage(lang); + try { + const exportedPath = `/Users/someone/${'nested-directory/'.repeat(8)}timetable-2026-3.ics`; + const lines = renderSchedule( + { + mode: 'hub', + key: '2026-3', + weekOne: '2026-09-07', + timetable: busyTimetable, + exportedPath, + }, + campusDateTime('2026-09-07', '07:00'), + 35, + 120, + ).map(stripAnsi); + const status = lines.filter((line) => line.includes(prefix)); + + expect(lines.join('\n')).toContain('19:00'); + expect(status).toHaveLength(1); + expect(status[0]).toMatch(/^ {3}\S.*\/Users\/someone\/.*….*\/timetable-2026-3\.ics$/); + expect(lines.every((line) => visualWidth(line) <= 120 - 3)).toBe(true); + expect(lines.length).toBeLessThanOrEqual(35); + } finally { + setLanguage('en'); + } + }, + ); + it('wraps the full shortcut bar within a narrow terminal', () => { const lines = renderSchedule( { @@ -524,7 +589,7 @@ describe('renderSchedule', () => { gridCursor: { weekday: 6, period: 1 }, }, campusDateTime('2026-09-07', '07:00'), - 15, + 12, 40, ); const switcher = lines.find((line) => stripAnsi(line).includes('[Sat]')); @@ -575,8 +640,8 @@ describe('renderSchedule', () => { } }); - it('threads the real terminal column width down to the grid, so a wide terminal stops truncating real course names', () => { - const longName = '习近平新时代中国特色社会主义思想概论'; + it('prefers the agenda over a grid that would clip names, until the frame cannot grow', () => { + const longName = '习近平新时代中国特色社会主义'; const longNameTimetable: Timetable = { ...timetable, meetings: [ @@ -613,18 +678,14 @@ describe('renderSchedule', () => { }, campusDateTime('2026-09-07', '07:00'), 45, - 210, + 120, ).map((l) => stripAnsi(l)); - const narrowHeadingIdx = narrowLines.findIndex((l) => l.includes('This week')); - const wideHeadingIdx = wideLines.findIndex((l) => l.includes('This week')); - const narrowGridRow = defined( - narrowLines.slice(narrowHeadingIdx).find((l) => l.trim().startsWith('08:00')), - ); - const wideGridRow = defined( - wideLines.slice(wideHeadingIdx).find((l) => l.trim().startsWith('08:00')), - ); - expect(narrowGridRow).not.toContain(longName); - expect(wideGridRow).toContain(longName); + const gridRow = (lines: string[]): string | undefined => + lines.find((l) => /^\s*08:00.08:45 /.test(l)); + + expect(gridRow(narrowLines)).toBeUndefined(); + expect(narrowLines.join('\n')).toMatch(new RegExp(`08:00 ${longName}`)); + expect(gridRow(wideLines)).toContain(longName); }); }); @@ -819,11 +880,12 @@ describe('renderSchedule', () => { 20, ); const plain = lines.map(stripAnsi); - const densityLines = plain.filter((line) => /^\s*(?:█\s*)+$/.test(line)); + const weekNumbers = plain + .filter((line) => /^\s+\d+(?:\s+\d+)*$/.test(line)) + .flatMap((line) => line.trim().split(/\s+/).map(Number)); expect(lines.every((line) => visualWidth(line) <= 20)).toBe(true); - expect(densityLines).toHaveLength(2); - expect(densityLines.join('').match(/█/g)).toHaveLength(18); + expect(weekNumbers).toEqual(weeks); expect(plain.some((line) => line.includes('This week'))).toBe(true); }); diff --git a/src/app/views/schedule-render.ts b/src/app/views/schedule-render.ts index ae65139..9f473fb 100644 --- a/src/app/views/schedule-render.ts +++ b/src/app/views/schedule-render.ts @@ -6,7 +6,7 @@ import { type TimetableMeeting, type Weekday, } from '@nbtca/nbtcal/timetable'; -import { c, type, space, glyph } from '../../core/theme.js'; +import { c, type, space, glyph, MAX_FRAME_COLS } from '../../core/theme.js'; import { pickIcon } from '../../core/icons.js'; import { t, fmt } from '../../i18n/index.js'; import { type ListField, renderListFieldWithContext } from '../fields/list-field.js'; @@ -22,11 +22,18 @@ import { renderDayTimeline, renderDaySwitcher, renderWeekAgenda, + weekGridFullWidth, lineBudget, } from '../../features/schedule-render.js'; import type { AcademicWindow, OnBreak } from '@nbtca/nbtcal'; import type { GridCursor } from './schedule-grid-cursor.js'; -import { sanitizeTerminalLine, visualWidth, wrapAnsiWithIndent } from '../../core/text.js'; +import { + sanitizeTerminalLine, + truncate, + truncateStart, + visualWidth, + wrapAnsiWithIndent, +} from '../../core/text.js'; import { isoDayDifference } from '../../core/calendar-day.js'; import { loadingLines } from '../../core/components/spinner.js'; @@ -49,6 +56,7 @@ export interface ScheduleViewState { mode: ScheduleMode; errorMessage?: string; statusMessage?: string; + exportedPath?: string; idField?: TextField; passwordField?: TextField; weekOneField?: TextField; @@ -202,8 +210,6 @@ function hubPreGridLines( return { inlineLines, fallbackLines: [...lines, weekHeading], week, tt }; } -const MIN_GRID_COLS = 100; - function fittingWeekGrid( tt: Timetable, week: number, @@ -212,7 +218,7 @@ function fittingWeekGrid( cols: number, cursor: GridCursor | undefined, ): string[] | null { - if (cols < MIN_GRID_COLS) return null; + if (cols < Math.min(MAX_FRAME_COLS, weekGridFullWidth(tt, week, now))) return null; const grid = renderWeekGrid(tt, week, now, cols, cursor).split('\n'); return grid.length <= rows ? grid : null; } @@ -239,28 +245,36 @@ function renderDayFallback( ]; } -function renderAdaptiveWeekGrid( +interface HubWeek { + lines: string[]; + grid: boolean; +} + +function renderHubWeek( inlineLines: string[], fallbackLines: string[], tt: Timetable, week: number, todayWd: Weekday, now: Date, - bodyRows: number, + rows: number, cols: number, cursor: GridCursor | undefined, - reservedRows: number, -): string[] { - const grid = fittingWeekGrid( - tt, - week, - now, - bodyRows - reservedRows - inlineLines.length, - cols, - cursor, - ); - if (grid) return [...inlineLines, ...grid]; - return [...fallbackLines, ...renderDayFallback(tt, week, todayWd, now, cols, cursor)]; +): HubWeek { + const layouts: [string[] | null, boolean][] = [ + [fittingWeekGrid(tt, week, now, rows, cols, cursor), true], + [renderWeekAgenda(tt, week, now, cols, cursor), false], + ]; + for (const [layout, grid] of layouts) { + if (!layout) continue; + for (const head of [inlineLines, inlineLines.filter((line) => line !== ''), fallbackLines]) { + if (head.length + layout.length <= rows) return { lines: [...head, ...layout], grid }; + } + } + return { + lines: [...fallbackLines, ...renderDayFallback(tt, week, todayWd, now, cols, cursor)], + grid: false, + }; } function renderWeekBody( @@ -293,55 +307,102 @@ function renderWeekBody( } const MIN_HUB_CONTENT_ROWS = 4; +const MIN_PATH_COLS = 12; -function renderHubBody( +function middleClip(text: string, width: number): string { + if (visualWidth(text) <= width) return text; + const mark = pickIcon('…', '...'); + const room = width - visualWidth(mark); + const tail = Math.ceil((room * 2) / 3); + return `${truncate(text, room - tail, '')}${mark}${truncateStart(text, tail, '')}`; +} + +function hubStatusLines(state: ScheduleViewState, cols: number): string[] { + if (state.exportedPath !== undefined) { + const template = t().timetable.exportSaved; + const path = sanitizeTerminalLine(state.exportedPath); + const room = lineBudget(cols) - visualWidth(space.indent + fmt(template, { path: '' })); + return room < MIN_PATH_COLS + ? hintLines(fmt(template, { path }), cols) + : [hint(fmt(template, { path: middleClip(path, room) }))]; + } + return state.statusMessage ? hintLines(state.statusMessage, cols) : []; +} + +function layoutHub( state: ScheduleViewState, now: Date, bodyRows: number, cols: number, -): string[] { - const tt = state.timetable; + shortcuts: readonly HubShortcut[], +): HubWeek { const pre = hubPreGridLines(state, now, cols); - const shortcuts = tt ? hubShortcuts(tt) : []; + const status = hubStatusLines(state, cols); const rows = Math.max(0, Math.floor(bodyRows)); - const build = (shortcutLines: string[], gap = true): { content: string[]; tail: string[] } => { + const build = (shortcutLines: string[], gap = true): HubWeek & { tail: string[] } => { const tail: string[] = []; - if (gap && pre && (state.statusMessage || shortcutLines.length > 0)) tail.push(''); - if (state.statusMessage) { - tail.push(...hintLines(state.statusMessage, cols)); + if (gap && pre && (status.length > 0 || shortcutLines.length > 0)) tail.push(''); + if (status.length > 0) { + tail.push(...status); if (shortcutLines.length > 0) tail.push(''); } tail.push(...shortcutLines); - const content = pre - ? renderAdaptiveWeekGrid( + const week = pre + ? renderHubWeek( pre.inlineLines, pre.fallbackLines, pre.tt, pre.week, createTimetableSchedule(pre.tt).weekdayAt(now), now, - rows, + rows - tail.length, cols, state.gridCursor, - tail.length, ) - : []; - return { content, tail }; + : { lines: [], grid: false }; + return { ...week, tail }; }; const labelled = renderShortcutLines(shortcuts, cols); const full = build(labelled); - if (full.content.length + full.tail.length <= rows) return [...full.content, ...full.tail]; + if (full.lines.length + full.tail.length <= rows) { + return { lines: [...full.lines, ...full.tail], grid: full.grid }; + } const tight = build(labelled, false); if (rows - tight.tail.length >= MIN_HUB_CONTENT_ROWS) { - const content = tight.content.filter((line) => line !== ''); - return [...content.slice(0, rows - tight.tail.length), ...tight.tail]; + const content = tight.lines.filter((line) => line !== ''); + return { + lines: [...content.slice(0, rows - tight.tail.length), ...tight.tail], + grid: tight.grid, + }; } const compact = build(renderShortcutLines(shortcuts, cols, true)); - if (compact.tail.length >= rows) return rows > 0 ? compact.tail.slice(-rows) : []; - return [...compact.content.slice(0, rows - compact.tail.length), ...compact.tail]; + if (compact.tail.length >= rows) { + return { lines: rows > 0 ? compact.tail.slice(-rows) : [], grid: false }; + } + return { + lines: [...compact.lines.slice(0, rows - compact.tail.length), ...compact.tail], + grid: compact.grid, + }; +} + +function renderHubBody( + state: ScheduleViewState, + now: Date, + bodyRows: number, + cols: number, +): string[] { + const shortcuts = state.timetable ? hubShortcuts(state.timetable) : []; + const withGrid = layoutHub( + state, + now, + bodyRows, + cols, + shortcuts.filter((shortcut) => shortcut.key !== 'w'), + ); + return withGrid.grid ? withGrid.lines : layoutHub(state, now, bodyRows, cols, shortcuts).lines; } const TERM_PROGRESS_WIDTH = 20; diff --git a/src/app/views/schedule.test.ts b/src/app/views/schedule.test.ts index e98eb04..50cdc66 100644 --- a/src/app/views/schedule.test.ts +++ b/src/app/views/schedule.test.ts @@ -6,6 +6,7 @@ import type * as TimetableModule from '@nbtca/nbtcal/timetable'; import type * as NbtAuthModule from '../../auth/nbt-auth.js'; import type * as ScheduleStoreModule from '../../features/schedule-store.js'; import type * as CalendarModule from '../../features/calendar.js'; +import type * as StudentTimetableModule from '../../features/student-timetable.js'; const sessionStoreClear = vi.fn(); const sessionStoreLoad = vi.fn(); @@ -60,6 +61,12 @@ vi.mock('@nbtca/nbtcal/timetable', async (importOriginal) => { }; }); +const writePrivateIcsMock = vi.hoisted(() => vi.fn()); +vi.mock('../../features/student-timetable.js', async (importOriginal) => ({ + ...(await importOriginal()), + writePrivateIcs: writePrivateIcsMock, +})); + const loadWeekOneMock = vi.fn(); const saveWeekOneMock = vi.fn(); const saveTimetableCacheMock = vi.fn(); @@ -509,6 +516,25 @@ describe('scheduleView — hub navigation', () => { ); }); + it('labels Esc in the hub footer when the row has room', async () => { + await loadIntoHub(); + const hint = stripAnsi(scheduleView.footerHint(5, 120) ?? ''); + expect(hint).toContain(`Esc ${t().menu.hintBack}`); + expect(hint).toContain('⏎'); + }); + + it('reports an export on one status line and keeps the hub', async () => { + const ctx = await loadIntoHub(); + scheduleView.handleKey('e', ctx); + const lines = scheduleView.render(ctx).map(stripAnsi); + const status = lines.filter((line) => line.includes('Saved: ')); + + expect(writePrivateIcsMock).toHaveBeenCalledWith('timetable-2026-3.ics', expect.any(String)); + expect(status).toHaveLength(1); + expect(status[0]).toMatch(/timetable-2026-3\.ics$/); + expect(lines.join('\n')).toContain(t().timetable.hubLogout); + }); + it('lists the day-switching arrows in the ? key list', async () => { await loadIntoHub(); const keys = scheduleView.shortcuts(); @@ -558,7 +584,7 @@ describe('scheduleView — hub navigation', () => { async function loadIntoShortHub(): Promise { const ctx = await loadIntoHub({ periods: busyPeriods, meetings: busyMeetings }); - ctx.bodyRows = 19; + ctx.bodyRows = 12; return ctx; } diff --git a/src/app/views/schedule.ts b/src/app/views/schedule.ts index d483d49..875ebe0 100644 --- a/src/app/views/schedule.ts +++ b/src/app/views/schedule.ts @@ -73,11 +73,14 @@ function gridFooterHint(tabCount: number, cols: number): string { const dot = pickIcon('·', '-'); const keys = gridKeys(); const local = `${keys.days} ${trans.timetable.footerDay} ${dot} ${keys.periods} ${trans.timetable.footerClass} ${dot} ${keys.enter} ${trans.timetable.footerDetail}`; + const labelled = `Esc ${trans.menu.hintBack} ${dot} q ${trans.menu.hintQuit}`; const quit = `Esc ${dot} q ${trans.menu.hintQuit}`; return fitFooterHint( cols, - `${digitTabHint(tabCount)}${local} ${dot} ${quit} ${dot} ${trans.help.hint}`, + `${digitTabHint(tabCount)}${local} ${dot} ${labelled} ${dot} ${trans.help.hint}`, + `${digitTabHint(tabCount)}${local} ${dot} ${labelled}`, `${digitTabHint(tabCount)}${local} ${dot} ${quit}`, + `${local} ${dot} ${labelled}`, `${local} ${dot} ${quit}`, `${local} ${dot} Esc ${dot} q`, local, @@ -606,9 +609,11 @@ export const scheduleView = { }); const out = `timetable-${hubKey}.ics`; writePrivateIcs(out, ics); - state = { ...state, statusMessage: `${t().common.success}: ${path.resolve(out)}` }; + state = { ...state, exportedPath: path.resolve(out) }; + delete state.statusMessage; } catch (error) { state = { ...state, statusMessage: safeMessage(error) }; + delete state.exportedPath; } return; } diff --git a/src/features/schedule-render.test.ts b/src/features/schedule-render.test.ts index d7e2044..c1f9158 100644 --- a/src/features/schedule-render.test.ts +++ b/src/features/schedule-render.test.ts @@ -115,6 +115,20 @@ describe('renderNextClassBanner', () => { expect(out).toMatch(/1h/); done(); }); + it('marks the banner with a dot rather than the selection cursor', () => { + process.env['NBTCA_ICON_MODE'] = 'unicode'; + resetIconCache(); + const out = stripAnsi( + renderNextClassBanner( + { meeting: mk({}), start: campusDateTime('2026-09-07', '08:00') }, + campusDateTime('2026-09-07', '06:30'), + ), + ); + expect(out.startsWith(`${space.indent}● `)).toBe(true); + expect(out).not.toContain('→'); + done(); + }); + it('empty when no next class', () => { expect(renderNextClassBanner(null, new Date())).toBe(''); done(); @@ -306,7 +320,7 @@ describe('renderWeekGrid', () => { }); it("does not let one day's long course name affect another day's column width", () => { - const longName = '习近平新时代中国特色社会主义思想概论'; // 18 CJK chars = 36 display columns + const longName = '习近平新时代中国特色社会主义'; const meetings = [ mk({ courseName: 'PE', @@ -326,21 +340,49 @@ describe('renderWeekGrid', () => { }), ]; const out = stripAnsi( - renderWeekGrid(meetings, periods, 1, campusDateTime('2026-09-07', '09:00'), 250), + renderWeekGrid(meetings, periods, 1, campusDateTime('2026-09-07', '09:00'), 120), ); - expect(out).toContain(longName); // Tuesday's column grew enough to fit it in full - const headerLine = lineAt(out.split('\n'), 0); - const globalWidthDesignTotal = space.indent.length + 12 + 36 * 7 + 6 * 3; - expect(visualWidth(headerLine)).toBeLessThan(globalWidthDesignTotal - 36 * 3); + expect(out).toContain(longName); + const header = lineAt(out.split('\n'), 0); + const [monday = '', tuesday = ''] = header.slice(space.indent.length + 14).split(' | '); + expect(visualWidth(monday)).toBeLessThan(visualWidth(longName) / 2); + expect(visualWidth(tuesday)).toBe(visualWidth(longName)); done(); }); - it('keeps a short floor width for a column with no real content', () => { + it('spreads spare width evenly across the days and stays inside the frame', () => { const out = stripAnsi( - renderWeekGrid([], periods, 1, campusDateTime('2026-09-07', '09:00'), 300), + renderWeekGrid( + [mk({ courseName: 'Programming Lab', location: null, weekday: 5, endPeriod: 1 })], + periods, + 1, + campusDateTime('2026-09-07', '09:00'), + 115, + ), ); - const headerLine = lineAt(out.split('\n'), 0); - expect(visualWidth(headerLine)).toBe(3 + 12 + 7 * 8 + 6 * 3); + const row = findLine(out.split('\n'), (line) => line.trim().startsWith('08:00')); + const cells = row.slice(space.indent.length + 14).split(' | '); + const widths = cells.map((cell) => visualWidth(cell)); + + expect(visualWidth(row)).toBeGreaterThanOrEqual(115 - space.indent.length - 1); + expect(visualWidth(row)).toBeLessThanOrEqual(115 - space.indent.length); + const others = widths.filter((_, index) => index !== 4); + expect(Math.max(...others) - Math.min(...others)).toBeLessThanOrEqual(1); + done(); + }); + + it('keeps a three-column gutter between the time column and the first day', () => { + const out = stripAnsi( + renderWeekGrid( + [mk({ courseName: 'Advanced Maths', location: null, endPeriod: 1 })], + periods, + 1, + campusDateTime('2026-09-07', '09:00'), + 100, + ), + ); + const row = findLine(out.split('\n'), (line) => line.trim().startsWith('08:00')); + expect(row).toMatch(/08:00-08:45 {3,}Advanced/); done(); }); @@ -397,7 +439,7 @@ describe('renderWeekGrid', () => { }); }); - it('gives every day the same width when the widest day fits seven times', () => { + it('keeps every day within one column of the others when the widest day nearly fits seven times', () => { const meetings = [ mk({ courseName: '程序设计实践', location: null, weekday: 5, endPeriod: 1 }), mk({ courseName: 'PE', location: null, weekday: 1, endPeriod: 1 }), @@ -410,7 +452,7 @@ describe('renderWeekGrid', () => { ); const separators = [...header.matchAll(/\|/g)].map((match) => match.index); const gaps = separators.slice(1).map((index, i) => index - (separators[i] ?? 0)); - expect(new Set(gaps).size).toBe(1); + expect(Math.max(...gaps) - Math.min(...gaps)).toBeLessThanOrEqual(1); done(); }); @@ -664,6 +706,23 @@ describe('renderMeetingDetail', () => { expect(out).toContain('08:00-09:40'); }); + it('names the weekday the way the grid header and today heading do in Chinese', () => { + process.env['NBTCA_ICON_MODE'] = 'unicode'; + resetIconCache(); + setLanguage('zh'); + try { + const out = stripAnsi(renderMeetingDetail(mk({ weekday: 1, endPeriod: 2 }), periods)); + expect(out).toMatch(/时间\s+周一 08:00–09:40/); + const grid = stripAnsi( + renderWeekGrid([], periods, 1, campusDateTime('2026-09-07', '09:00'), 120), + ); + expect(grid).toContain('08:00–08:45'); + } finally { + setLanguage('en'); + done(); + } + }); + it('shows the location when present', () => { const out = stripAnsi(renderMeetingDetail(mk({ location: 'sl707' }), periods)); expect(out).toContain('sl707'); @@ -736,6 +795,20 @@ const dayPeriods: TimetablePeriod[] = [ ]; describe('renderTodayTimeline', () => { + it('lines up the status column when course names differ in width', () => { + const meetings = [ + mk({ courseName: '形势与政策', startPeriod: 1, endPeriod: 1 }), + mk({ courseName: '社团活动', startPeriod: 2, endPeriod: 2 }), + ]; + const lines = stripAnsi( + renderTodayTimeline(meetings, dayPeriods, campusDateTime('2026-09-07', '12:00')), + ).split('\n'); + const column = (line: string): number => visualWidth(line.slice(0, line.indexOf('Done'))); + + expect(column(lineAt(lines, 0))).toBe(column(lineAt(lines, 1))); + done(); + }); + it('shows the empty-state line when there are no classes today', () => { expect(stripAnsi(renderTodayTimeline([], dayPeriods, new Date()))).toContain( 'No classes today', @@ -861,6 +934,22 @@ describe('renderTodayTimeline', () => { }); describe('renderDayTimeline', () => { + it('lines up the location column across finished and upcoming classes', () => { + const meetings = [ + mk({ courseName: 'Policy', location: 'Hall', startPeriod: 1, endPeriod: 1 }), + mk({ courseName: 'Club activity', location: 'Maker space', startPeriod: 3, endPeriod: 3 }), + ]; + const lines = stripAnsi( + renderDayTimeline(meetings, dayPeriods, campusDateTime('2026-09-07', '10:00'), { + weekday: 1, + isToday: true, + }), + ).split('\n'); + + expect(lineAt(lines, 0).indexOf('Hall')).toBe(lineAt(lines, 1).indexOf('Maker space')); + done(); + }); + it('shows the empty-state line when the viewed day has no classes', () => { expect( stripAnsi(renderDayTimeline([], dayPeriods, new Date(), { weekday: 1, isToday: true })), @@ -1069,46 +1158,64 @@ describe('renderUnresolvedItems', () => { }); describe('renderTermDensity', () => { - it('buckets each week into the correct relative density level', () => { + const chartRows = (out: string): string[] => + out.split('\n').filter((line) => /^\s*\d*\s[|+]\s/.test(line)); + + it('scales each week against the busiest one', () => { process.env['NBTCA_ICON_MODE'] = 'unicode'; resetIconCache(); try { const meetings: TimetableMeeting[] = [ - mk({ weeks: [2], startPeriod: 1, endPeriod: 1 }), // 1 slot - mk({ weeks: [3], startPeriod: 1, endPeriod: 2 }), // 2 slots - mk({ weeks: [4], startPeriod: 1, endPeriod: 3 }), // 3 slots - mk({ weeks: [5], startPeriod: 1, endPeriod: 4 }), // 4 slots (max) + mk({ weeks: [1], startPeriod: 1, endPeriod: 1 }), + mk({ weeks: [2], startPeriod: 1, endPeriod: 4 }), ]; - const out = stripAnsi(renderTermDensity(meetings, '2026-09-07', 1)); - const lines = out.split('\n'); - const glyphLine = lines[3] ?? ''; - expect(glyphLine.trim()).toBe('· ░ ▒ ▓ █'); + const lines = stripAnsi(renderTermDensity(meetings, '2026-09-07', 1)).split('\n'); + const top = lines.find((line) => line.includes('┤')) ?? ''; + const bottom = lines[lines.findIndex((line) => line.includes('└')) - 1] ?? ''; + + expect(top).toMatch(/^\s*4 ┤\s+██$/); + expect(bottom).toMatch(/[▁▂▃▄▅▆▇█]{2} ██$/); } finally { - process.env['NBTCA_ICON_MODE'] = 'ascii'; - resetIconCache(); + done(); } }); - it('places the current-week marker at the correct column', () => { - const meetings: TimetableMeeting[] = [mk({ weeks: [1] }), mk({ weeks: [5] })]; - const out = stripAnsi(renderTermDensity(meetings, '2026-09-07', 3)); - const lines = out.split('\n'); - const markerLine = lines[4] ?? ''; - expect(markerLine.indexOf('^')).toBe(7); - expect(markerLine).toContain('This week'); + it('summarises the load, free weeks, and this week in words', () => { + const meetings: TimetableMeeting[] = [ + mk({ weeks: [1, 2, 4], startPeriod: 1, endPeriod: 2 }), + mk({ weeks: [4], startPeriod: 3, endPeriod: 3 }), + ]; + const out = stripAnsi(renderTermDensity(meetings, '2026-09-07', 2)); + + expect(out).toContain('4 weeks'); + expect(out).toContain('1 without classes'); + expect(out).toContain('2-3 periods a week'); + expect(out).toContain('2 this week'); }); - it('renders a single all-dot week when there are no meetings at all', () => { - process.env['NBTCA_ICON_MODE'] = 'unicode'; - resetIconCache(); - try { - const out = stripAnsi(renderTermDensity([], '2026-09-07', 5)); - const lines = out.split('\n'); - expect(lines[3]?.trim()).toBe('·'); - } finally { - process.env['NBTCA_ICON_MODE'] = 'ascii'; - resetIconCache(); - } + it('breaks the summary between phrases rather than inside one', () => { + const lines = stripAnsi(renderTermDensity([mk({ weeks: [1, 2] })], '2026-09-07', 1, 30)).split( + '\n', + ); + expect(lines).toContain(' 2 weeks'); + expect(lines).toContain(' 2 periods a week'); + expect(lines).toContain(' 2 this week'); + }); + + it('labels every week and marks the current one', () => { + const meetings: TimetableMeeting[] = [mk({ weeks: [1, 2, 3, 4, 5] })]; + const lines = stripAnsi(renderTermDensity(meetings, '2026-09-07', 3)).split('\n'); + const numbers = lines.find((line) => /^\s+1\s+2\s+3\s+4\s+5$/.test(line)) ?? ''; + const marker = lines.find((line) => line.includes('This week')) ?? ''; + + expect(numbers).not.toBe(''); + expect(marker.indexOf('^')).toBe(numbers.indexOf('3') - 1); + }); + + it('shows a plain notice when there are no meetings at all', () => { + const out = stripAnsi(renderTermDensity([], '2026-09-07', 5)); + expect(out).toContain('No classes scheduled this term'); + expect(chartRows(out)).toHaveLength(0); }); it('never collapses into one array entry when split on newlines', () => { @@ -1118,71 +1225,49 @@ describe('renderTermDensity', () => { for (const line of lines) expect(line).not.toContain('\n'); }); - it('places a CJK month label at its real terminal column even after an earlier CJK label of different width', () => { + it('places a CJK month label over the week that starts the month', () => { process.env['NBTCA_ICON_MODE'] = 'unicode'; resetIconCache(); setLanguage('zh'); - resetIconCache(); try { - const meetings: TimetableMeeting[] = [mk({ weeks: [1, 14] })]; - const out = stripAnsi(renderTermDensity(meetings, '2026-09-07', 1)); - const monthLine = out.split('\n')[2] ?? ''; - - const base = campusDateTime('2026-09-07', '00:00'); - let secondLabelWeekIndex = -1; - let secondLabelText = ''; - let prevMonth = new Date('2026-09-07T00:00:00').getMonth(); - for (let i = 1; i < 14; i++) { - const d = new Date(base.getTime() + i * 7 * 86400000); - if (d.getMonth() !== prevMonth) { - secondLabelWeekIndex = i; - secondLabelText = `${String(d.getMonth() + 1)}月`; - break; - } - prevMonth = d.getMonth(); - } - expect(secondLabelWeekIndex).toBeGreaterThan(0); // sanity: the fixture actually crosses a month boundary + const out = stripAnsi(renderTermDensity([mk({ weeks: [1, 14] })], '2026-09-07', 1)); + const lines = out.split('\n'); + const monthLine = lines.find((line) => line.includes('9月')) ?? ''; + const numbers = lines.find((line) => /^\s+1\s+2\s/.test(line)) ?? ''; + const octoberWeek = 5; - const idx = monthLine.indexOf(secondLabelText); - expect(idx).toBeGreaterThan(0); - const prefix = monthLine.slice(0, idx); - const targetCol = space.indent.length + secondLabelWeekIndex * 2; - expect(visualWidth(prefix)).toBe(targetCol); + const idx = monthLine.indexOf('10月'); + expect(visualWidth(monthLine.slice(0, idx))).toBe(numbers.indexOf(` ${octoberWeek} `)); } finally { - process.env['NBTCA_ICON_MODE'] = 'ascii'; setLanguage('en'); - resetIconCache(); + done(); } }); - it('shows every week across narrow chunks and keeps a late current week visible', () => { - const weeks = Array.from({ length: 18 }, (_, index) => index + 1); - const lines = stripAnsi(renderTermDensity([mk({ weeks })], '2026-09-07', 18, 20)).split('\n'); - const densityLines = lines.filter((line) => /^\s*(?:=\s*)+$/.test(line)); - - expect(lines.every((line) => visualWidth(line) <= 20)).toBe(true); - expect(densityLines).toHaveLength(2); - expect(densityLines.join('').match(/=/g)).toHaveLength(18); - expect(lines.some((line) => line.includes('This week ^'))).toBe(true); - done(); - }); - - it('keeps the Chinese narrow density view within twenty columns', () => { - setLanguage('zh'); - try { - const weeks = Array.from({ length: 18 }, (_, index) => index + 1); - const lines = stripAnsi(renderTermDensity([mk({ weeks })], '2026-09-07', 18, 20)).split('\n'); - const densityLines = lines.filter((line) => /^\s*(?:=\s*)+$/.test(line)); - - expect(lines.every((line) => visualWidth(line) <= 20)).toBe(true); - expect(densityLines.join('').match(/=/g)).toHaveLength(18); - expect(lines.some((line) => line.includes('本周 ^'))).toBe(true); - } finally { - setLanguage('en'); - resetIconCache(); - } - done(); - }); + it.each(['en', 'zh'] as const)( + 'shows every week across narrow chunks and keeps a late current week visible (%s)', + (language) => { + setLanguage(language); + try { + const weeks = Array.from({ length: 18 }, (_, index) => index + 1); + const lines = stripAnsi(renderTermDensity([mk({ weeks })], '2026-09-07', 18, 20)).split( + '\n', + ); + const bars = lines.filter((line) => /[|+] (?:## ?)+$/.test(line)); + const barCount = bars + .filter((line) => line.includes('+ ')) + .join('') + .match(/##/g); + + expect(lines.every((line) => visualWidth(line) <= 20)).toBe(true); + expect(barCount).toHaveLength(18); + expect(lines.some((line) => line.includes('^'))).toBe(true); + } finally { + setLanguage('en'); + done(); + } + }, + ); }); describe('formatClassCountdown', () => { diff --git a/src/features/schedule-render.ts b/src/features/schedule-render.ts index f67c7c3..871ec66 100644 --- a/src/features/schedule-render.ts +++ b/src/features/schedule-render.ts @@ -35,10 +35,14 @@ function formatMinutesLeft(end: Date, now: Date): string { return fmt(t().timetable.minutesRemaining, { minutes: String(minutes) }); } +function rangeLabel(start: string, end: string): string { + return end ? `${start}${pickIcon('–', '-')}${end}` : start; +} + function span(m: TimetableMeeting, periods: readonly TimetablePeriod[]): string { const s = periods.find((p) => p.period === m.startPeriod)?.start ?? ''; const e = periods.find((p) => p.period === m.endPeriod)?.end ?? ''; - return e ? `${s}${pickIcon('–', '-')}${e}` : s; + return rangeLabel(s, e); } export function renderNextClassBanner( @@ -54,7 +58,7 @@ export function renderNextClassBanner( const styleWhen = isCountdownUrgent(p) ? c.warn : type.hint; const whenStyled = styleWhen(when); const dot = pickIcon('·', '-'); - const marker = type.active(glyph.cursor()); + const marker = type.active(glyph.dot()); const separator = ` ${dot} `; const detailedPrefix = labelled ? `${space.indent}${marker} ${type.label(trans.timetable.nextClass)}${separator}` @@ -107,9 +111,9 @@ export function weekdayShortLabel(wd: number): string { return labels[wd - 1] ?? ''; } -function weekdayLongLabel(wd: number): string { +function weekdayName(wd: number, weekday: 'short' | 'long'): string { return new Intl.DateTimeFormat(getCurrentLanguage() === 'zh' ? 'zh-CN' : 'en-US', { - weekday: 'long', + weekday, timeZone: 'UTC', }).format(Date.UTC(2024, 0, wd)); } @@ -141,7 +145,7 @@ function renderTimeline( if (sorted.length === 0) { const empty = day.isToday ? trans.timetable.noClassToday - : fmt(trans.timetable.noClassOnDay, { weekday: weekdayLongLabel(day.weekday) }); + : fmt(trans.timetable.noClassOnDay, { weekday: weekdayName(day.weekday, 'long') }); return `${space.indent}${type.hint(empty)}`; } @@ -153,71 +157,91 @@ function renderTimeline( const topConnector = pickIcon('┬', '+'); const bottomConnector = pickIcon('┴', '+'); - const lines = sorted.map((m, i) => { + const rows = sorted.map((m) => { const startStr = periods.find((p) => p.period === m.startPeriod)?.start ?? '00:00'; const endStr = periods.find((p) => p.period === m.endPeriod)?.end ?? '23:59'; const end = campusDateTime(today, endStr); const isLive = day.isToday && campusDateTime(today, startStr).getTime() <= minute && minute <= end.getTime(); const isDone = day.isToday && minute > end.getTime(); - const isCursor = - cursorPeriod !== undefined && m.startPeriod <= cursorPeriod && cursorPeriod <= m.endPeriod; - const connector = i === 0 ? topConnector : midConnector; - const marker = isLive ? type.active(pickIcon('▶', '>')) : ' '; - const timeCol = `${marker}${type.hint(startStr)} ${rule}${connector}${rule}`; - const styleName = (name: string): string => - isLive ? type.active(name) : isDone ? type.hint(name) : type.body(name); - const minutesLeft = isLive ? formatMinutesLeft(end, now) : ''; const statusText = isDone ? trans.timetable.classDone : isLive ? `${trans.timetable.classLive} ${dot} ${minutesLeft}` : ''; - const compactStatusText = isDone ? trans.timetable.classDone : minutesLeft; const showLoc = alwaysShowLocation ? Boolean(m.location) : isLive && Boolean(m.location); - const locationText = showLoc ? (m.location ?? '') : ''; - const renderLine = ( - name: string, - status: string, - location: string, - currentTimeCol = timeCol, - indent: string = space.indent, - ): string => { - const statusCol = status ? ` ${type.hint(status)}` : ''; - const locationCol = location ? ` ${type.hint(location)}` : ''; - const content = `${currentTimeCol} ${styleName(name)}${statusCol}${locationCol}`; - return `${indent}${isCursor ? type.cursor(content) : content}`; + return { + m, + startStr, + isLive, + isDone, + minutesLeft, + statusText, + locationText: showLoc ? (m.location ?? '') : '', }; + }); + const nameColW = Math.max(...rows.map((row) => visualWidth(row.m.courseName))); + const statusColW = Math.max(...rows.map((row) => visualWidth(row.statusText))); + + const lines = rows.map( + ({ m, startStr, isLive, isDone, minutesLeft, statusText, locationText }, i) => { + const isCursor = + cursorPeriod !== undefined && m.startPeriod <= cursorPeriod && cursorPeriod <= m.endPeriod; + const connector = i === 0 ? topConnector : midConnector; + const marker = isLive ? type.active(pickIcon('▶', '>')) : ' '; + const timeCol = `${marker}${type.hint(startStr)} ${rule}${connector}${rule}`; + const styleName = (name: string): string => + isLive ? type.active(name) : isDone ? type.hint(name) : type.body(name); + + const compactStatusText = isDone ? trans.timetable.classDone : minutesLeft; + const renderLine = ( + name: string, + status: string, + location: string, + currentTimeCol = timeCol, + indent: string = space.indent, + ): string => { + const aligned = name === m.courseName; + const statusW = aligned && location ? statusColW : 0; + const statusCol = status || statusW > 0 ? ` ${type.hint(padEndV(status, statusW))}` : ''; + const locationCol = location ? ` ${type.hint(location)}` : ''; + const shownName = aligned && (statusCol || locationCol) ? padEndV(name, nameColW) : name; + const content = `${currentTimeCol} ${styleName(shownName)}${statusCol}${locationCol}`; + return `${indent}${isCursor ? type.cursor(content) : content}`; + }; + + const whole = [ + renderLine(m.courseName, statusText, locationText), + renderLine(m.courseName, statusText, ''), + renderLine(m.courseName, compactStatusText, ''), + ].find(fits); + if (whole) return whole; + + for (const status of compactStatusText ? [compactStatusText, ''] : ['']) { + const courseWidth = Math.floor(width - visualWidth(renderLine('', status, ''))); + if (courseWidth <= visualWidth(ellipsis())) continue; + return renderLine(clip(m.courseName, courseWidth), status, ''); + } - const whole = [ - renderLine(m.courseName, statusText, locationText), - renderLine(m.courseName, statusText, ''), - renderLine(m.courseName, compactStatusText, ''), - ].find(fits); - if (whole) return whole; - - for (const status of compactStatusText ? [compactStatusText, ''] : ['']) { - const courseWidth = Math.floor(width - visualWidth(renderLine('', status, ''))); - if (courseWidth <= visualWidth(ellipsis())) continue; - return renderLine(clip(m.courseName, courseWidth), status, ''); - } - - const compactTimeCol = `${marker}${type.hint(startStr)}`; - for (const indent of [space.indent, '']) { - const courseWidth = Math.floor( - width - visualWidth(renderLine('', '', '', compactTimeCol, indent)), - ); - if (courseWidth <= visualWidth(ellipsis())) continue; - return renderLine(clip(m.courseName, courseWidth), '', '', compactTimeCol, indent); - } + const compactTimeCol = `${marker}${type.hint(startStr)}`; + for (const indent of [space.indent, '']) { + const courseWidth = Math.floor( + width - visualWidth(renderLine('', '', '', compactTimeCol, indent)), + ); + if (courseWidth <= visualWidth(ellipsis())) continue; + return renderLine(clip(m.courseName, courseWidth), '', '', compactTimeCol, indent); + } - const timeOnly = [`${space.indent}${compactTimeCol}`, compactTimeCol, type.hint(startStr)].find( - fits, - ); - if (timeOnly) return timeOnly; - return type.hint(startStr.slice(0, Math.max(0, Math.floor(width)))); - }); + const timeOnly = [ + `${space.indent}${compactTimeCol}`, + compactTimeCol, + type.hint(startStr), + ].find(fits); + if (timeOnly) return timeOnly; + return type.hint(startStr.slice(0, Math.max(0, Math.floor(width)))); + }, + ); const last = sorted.at(-1); if (!last) return lines.join('\n'); @@ -318,6 +342,53 @@ function centerInWidth(text: string, width: number): string { const MIN_COL_WIDTH = 8; +function spreadWidths(ideal: readonly number[], available: number): number[] { + const widths = [...ideal]; + const target = Number.isFinite(available) ? available : Math.max(0, ...ideal) * ideal.length; + let spare = target - widths.reduce((a, b) => a + b, 0); + while (spare > 0 && widths.length > 0) { + const narrowest = widths.indexOf(Math.min(...widths)); + widths[narrowest] = (widths[narrowest] ?? 0) + 1; + spare -= 1; + } + return widths; +} + +const GRID_SEP_W = 3; + +interface GridMetrics { + rowHeadW: number; + idealColWidths: number[]; + totalIdealColW: number; +} + +function weekGridMetrics(timetable: Timetable, weekNumber: number, now: Date): GridMetrics { + const schedule = createTimetableSchedule(timetable); + const week = schedule.meetingsInWeek(weekNumber); + const todayWd = schedule.weekdayAt(now); + const rowHeadW = + timetable.periods.reduce((w, p) => Math.max(w, visualWidth(rangeLabel(p.start, p.end))), 0) + + GRID_SEP_W; + const idealColWidths = WEEKDAY_KEYS.map((_, i) => { + const wd = i + 1; + const dayMeetings = week.filter((m) => m.weekday === wd); + const nameW = dayMeetings.reduce((max, m) => Math.max(max, visualWidth(m.courseName)), 0); + const locW = dayMeetings.reduce( + (max, m) => Math.max(max, m.location ? visualWidth(m.location) : 0), + 0, + ); + const headerW = + visualWidth(weekdayShortLabel(wd)) + (wd === todayWd ? visualWidth(pickIcon('•', '*')) : 0); + return Math.max(nameW, locW, headerW, MIN_COL_WIDTH); + }); + return { rowHeadW, idealColWidths, totalIdealColW: idealColWidths.reduce((a, b) => a + b, 0) }; +} + +export function weekGridFullWidth(timetable: Timetable, weekNumber: number, now: Date): number { + const { rowHeadW, totalIdealColW } = weekGridMetrics(timetable, weekNumber, now); + return visualWidth(space.indent) * 2 + rowHeadW + totalIdealColW + 6 * GRID_SEP_W; +} + export function renderWeekGrid( timetable: Timetable, weekNumber: number, @@ -329,39 +400,21 @@ export function renderWeekGrid( const week = schedule.meetingsInWeek(weekNumber); const todayWd = schedule.weekdayAt(now); const periods = timetable.periods; - const rowHeadW = 12; const todayMark = pickIcon('•', '*'); const connector = pickIcon('│', '|'); const emptyGlyph = pickIcon('·', '.'); const sepGlyph = pickIcon('│', '|'); const sep = type.hint(` ${sepGlyph} `); - const sepW = 3; // " │ " / " | " -- always 3 display columns regardless of icon mode - - const idealColWidths = WEEKDAY_KEYS.map((_, i) => { - const wd = i + 1; - const dayMeetings = week.filter((m) => m.weekday === wd); - const nameW = dayMeetings.reduce((max, m) => Math.max(max, visualWidth(m.courseName)), 0); - const locW = dayMeetings.reduce( - (max, m) => Math.max(max, m.location ? visualWidth(m.location) : 0), - 0, - ); - const headerW = - visualWidth(weekdayShortLabel(wd)) + (wd === todayWd ? visualWidth(todayMark) : 0); - return Math.max(nameW, locW, headerW, MIN_COL_WIDTH); - }); - const fixedOverhead = space.indent.length + rowHeadW + 6 * sepW; - const availableForCols = Math.max(0, cols - fixedOverhead); - const evenColW = Math.max(...idealColWidths); - const totalIdealColW = idealColWidths.reduce((a, b) => a + b, 0); + const { rowHeadW, idealColWidths, totalIdealColW } = weekGridMetrics(timetable, weekNumber, now); + const availableForCols = Math.max( + 0, + lineBudget(cols) - visualWidth(space.indent) - rowHeadW - 6 * GRID_SEP_W, + ); const colWidths = - evenColW * 7 <= availableForCols - space.indent.length - ? idealColWidths.map(() => evenColW) - : totalIdealColW <= availableForCols - ? idealColWidths - : idealColWidths.map((w) => - Math.max(3, Math.floor(w * (availableForCols / totalIdealColW))), - ); - const totalW = rowHeadW + colWidths.reduce((a, b) => a + b, 0) + 6 * sepW; + totalIdealColW <= availableForCols + ? spreadWidths(idealColWidths, availableForCols) + : idealColWidths.map((w) => Math.max(3, Math.floor(w * (availableForCols / totalIdealColW)))); + const totalW = rowHeadW + colWidths.reduce((a, b) => a + b, 0) + 6 * GRID_SEP_W; const startingAt = (wd: number, period: number) => week.find((m) => m.weekday === wd && m.startPeriod === period); @@ -382,7 +435,7 @@ export function renderWeekGrid( const sorted = [...periods].sort((a, b) => a.period - b.period); sorted.forEach((p, i) => { - const rowHead = type.hint(padEndV(`${p.start}-${p.end}`, rowHeadW)); + const rowHead = type.hint(padEndV(rangeLabel(p.start, p.end), rowHeadW)); const nameCells: string[] = []; const locCells: string[] = []; for (let wdIdx = 0; wdIdx < 7; wdIdx++) { @@ -517,7 +570,7 @@ function formatWeekRange(weeks: readonly number[]): string { if (isContiguous) { const first = sorted[0]; const last = sorted.at(-1); - return sorted.length > 1 ? `${first}-${last}` : `${first}`; + return sorted.length > 1 ? rangeLabel(String(first), String(last)) : `${first}`; } return sorted.join(', '); } @@ -529,7 +582,10 @@ export function renderMeetingDetail( ): string { const trans = t(); const rows: [string, string][] = [ - [trans.timetable.detailTime, `${weekdayShortLabel(meeting.weekday)} ${span(meeting, periods)}`], + [ + trans.timetable.detailTime, + `${weekdayName(meeting.weekday, 'short')} ${span(meeting, periods)}`, + ], ]; if (meeting.location) rows.push([trans.timetable.detailLocation, meeting.location]); if (meeting.teacherNames.length > 0) { @@ -614,23 +670,13 @@ export function renderUnresolvedItems( return lines.join('\n'); } -const DENSITY_GLYPHS: [string, string][] = [ - ['·', ' '], - ['░', '.'], - ['▒', ':'], - ['▓', '-'], - ['█', '='], -]; - -function levelGlyph(level: number): string { - const pair = DENSITY_GLYPHS[Math.max(0, Math.min(4, level))] ?? ['·', ' ']; - return pickIcon(pair[0], pair[1]); -} +const DENSITY_HEIGHT = 5; +const DENSITY_COL = 3; +const BAR_RAMP = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; -function applyDensityColor(glyphChar: string, level: number): string { - if (level <= 0) return type.hint(glyphChar); - if (level >= 4) return type.active(glyphChar); - return c.brand(glyphChar); +function barGlyph(eighths: number): string { + const fill = Math.max(0, Math.min(8, eighths)); + return pickIcon(BAR_RAMP[fill] ?? ' ', fill >= 4 ? '#' : fill > 0 ? '_' : ' '); } function weekStartDate(weekOneMonday: string, week: number): Date { @@ -642,7 +688,7 @@ function densityMonthText( startWeek: number, count: number, lang: Language, - maxWidth = Number.POSITIVE_INFINITY, + maxWidth: number, ): string { let text = ''; let visualCol = 0; @@ -655,22 +701,21 @@ function densityMonthText( const label = new Intl.DateTimeFormat(lang === 'zh' ? 'zh-CN' : 'en-US', { month: 'short', }).format(date); - const targetCol = index * 2; - if (targetCol + visualWidth(label) > maxWidth) continue; - if (targetCol > visualCol) text += ' '.repeat(targetCol - visualCol); - text += label; + const targetCol = index * DENSITY_COL; + if (targetCol < visualCol || targetCol + visualWidth(label) > maxWidth) continue; + text += ' '.repeat(targetCol - visualCol) + label; visualCol = targetCol + visualWidth(label); } return text; } -function densityMarkerText(index: number, width: number, marker: string, label: string): string { - const markerCol = index * 2; +function densityMarkerLines(index: number, width: number, marker: string, label: string): string[] { + const markerCol = index * DENSITY_COL; const after = `${' '.repeat(markerCol)}${marker} ${label}`; - if (visualWidth(after) <= width) return after; + if (visualWidth(after) <= width) return [after]; const beforeStart = markerCol - visualWidth(label) - 1; - if (beforeStart >= 0) return `${' '.repeat(beforeStart)}${label} ${marker}`; - return `${' '.repeat(markerCol)}${marker}`; + if (beforeStart >= 0) return [`${' '.repeat(beforeStart)}${label} ${marker}`]; + return [`${' '.repeat(markerCol)}${marker}`, label]; } export function renderTermDensity( @@ -679,110 +724,93 @@ export function renderTermDensity( currentWeek: number, cols = Number.POSITIVE_INFINITY, ): string { - const trans = t(); + const trans = t().timetable; const lang = getCurrentLanguage(); - - let minWeek = currentWeek; - let maxWeek = currentWeek; - for (const m of meetings) { - for (const w of m.weeks) { - if (w < minWeek) minWeek = w; - if (w > maxWeek) maxWeek = w; - } - } - const numWeeks = maxWeek - minWeek + 1; - - const weekSlots: number[] = []; - for (let w = minWeek; w <= maxWeek; w++) { - let slots = 0; - for (const m of meetings) { - if (m.weeks.includes(w)) slots += m.endPeriod - m.startPeriod + 1; - } - weekSlots.push(slots); - } - const max = Math.max(0, ...weekSlots); - - const levels = weekSlots.map((v) => { - if (v === 0 || max === 0) return 0; - if (v <= max * 0.25) return 1; - if (v <= max * 0.5) return 2; - if (v <= max * 0.75) return 3; - return 4; - }); - - const monthLabelText = densityMonthText(weekOneMonday, minWeek, numWeeks, lang); - const monthLabelLine = `${space.indent}${monthLabelText}`; - - const glyphLine = `${space.indent}${levels.map((lvl) => applyDensityColor(levelGlyph(lvl), lvl)).join(' ')}`; - - const currentWeekIndex = Math.max(0, currentWeek - minWeek); - const markerGlyph = pickIcon('↑', '^'); - const markerLine = `${space.indent}${type.hint( - `${' '.repeat(currentWeekIndex * 2)}${markerGlyph} ${trans.timetable.termDensityThisWeek}`, - )}`; - - const legendGlyphs = [0, 1, 2, 3, 4].map((lvl) => applyDensityColor(levelGlyph(lvl), lvl)); - const legendContent = `${type.hint(trans.calendar.heatmap.legendLess)} ${legendGlyphs.join('')} ${type.hint(trans.calendar.heatmap.legendMore)}`; - const legendLine = `${space.indent}${legendContent}`; - - const fullLines = [ - `${space.indent}${type.heading(trans.timetable.termDensityTitle)}`, - '', - monthLabelLine, - glyphLine, - markerLine, - '', - legendLine, - ]; const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY; - if (!Number.isFinite(width) || fullLines.every((line) => visualWidth(line) <= width)) { - return fullLines.join('\n'); - } - const indent = visualWidth(space.indent) < width ? space.indent : ''; const contentWidth = Math.max(1, width - visualWidth(indent)); - const weeksPerChunk = Math.max(1, Math.floor((contentWidth + 1) / 2)); - const lines = wrapAnsiToVisualWidth( - type.heading(trans.timetable.termDensityTitle), - contentWidth, - ).map((part) => `${indent}${part}`); - lines.push(''); + const wrap = (text: string): string[] => + wrapAnsiToVisualWidth(text, contentWidth).map((part) => `${indent}${part}`); + const lines = wrap(type.heading(trans.termDensityTitle)); - for (let start = 0; start < numWeeks; start += weeksPerChunk) { - if (start > 0) lines.push(''); - const count = Math.min(weeksPerChunk, numWeeks - start); - const chunkMonthText = densityMonthText( - weekOneMonday, - minWeek + start, - count, - lang, - contentWidth, + const allWeeks = meetings.flatMap((m) => m.weeks); + if (allWeeks.length === 0) { + return [...lines, ...wrap(type.hint(trans.termDensityEmpty))].join('\n'); + } + const firstWeek = Math.min(1, ...allWeeks); + const slots = Array.from({ length: Math.max(...allWeeks) - firstWeek + 1 }, (_, index) => + meetings.reduce( + (sum, m) => + m.weeks.includes(firstWeek + index) ? sum + m.endPeriod - m.startPeriod + 1 : sum, + 0, + ), + ); + const busy = slots.filter((value) => value > 0); + const low = Math.min(...busy); + const high = Math.max(...busy); + const current = currentWeek - firstWeek; + const currentSlots = slots[current]; + const freeWeeks = slots.length - busy.length; + const summary = [ + fmt(trans.termDensityWeeks, { weeks: String(slots.length) }), + ...(freeWeeks > 0 ? [fmt(trans.termDensityFreeWeeks, { count: String(freeWeeks) })] : []), + fmt(trans.termDensityPerWeek, { + periods: low === high ? String(high) : rangeLabel(String(low), String(high)), + }), + ...(currentSlots === undefined + ? [] + : [fmt(trans.termDensityCurrent, { periods: String(currentSlots) })]), + ]; + const summaryLines: string[] = []; + for (const part of summary) { + const joined = `${summaryLines.at(-1) ?? ''} ${pickIcon('·', '-')} ${part}`; + if (summaryLines.length > 0 && visualWidth(joined) <= contentWidth) { + summaryLines[summaryLines.length - 1] = joined; + } else { + summaryLines.push(part); + } + } + lines.push(...summaryLines.flatMap((line) => wrap(type.hint(line)))); + + const axisW = visualWidth(String(high)); + const gutter = ' '.repeat(axisW + 3); + const perChunk = Math.max(1, Math.floor((contentWidth - gutter.length + 1) / DENSITY_COL)); + for (let start = 0; start < slots.length; start += perChunk) { + const chunk = slots.slice(start, start + perChunk); + const style = (index: number): ((value: string) => string) => + start + index === current ? type.active : type.body; + lines.push(''); + for (let row = DENSITY_HEIGHT - 1; row >= 0; row -= 1) { + const top = row === DENSITY_HEIGHT - 1; + const axis = `${(top ? String(high) : '').padStart(axisW)} ${top ? pickIcon('┤', '+') : glyph.bar()}`; + const bars = chunk.map((value, index) => { + const eighths = + value === 0 ? 0 : Math.max(1, Math.round((value / high) * DENSITY_HEIGHT * 8)); + return style(index)(barGlyph(eighths - row * 8).repeat(DENSITY_COL - 1)); + }); + lines.push(`${indent}${type.hint(axis)} ${bars.join(' ')}`); + } + lines.push( + `${indent}${type.hint(`${'0'.padStart(axisW)} ${pickIcon('└', '+')}${glyph.rule().repeat(chunk.length * DENSITY_COL)}`)}`, ); - lines.push(`${indent}${chunkMonthText}`); + const numbers = chunk.map((_, index) => { + const label = String(firstWeek + start + index).padStart(DENSITY_COL - 1); + return start + index === current ? type.active(label) : type.hint(label); + }); + lines.push(`${indent}${gutter}${numbers.join(' ')}`); + const room = contentWidth - gutter.length; lines.push( - `${indent}${levels - .slice(start, start + count) - .map((level) => applyDensityColor(levelGlyph(level), level)) - .join(' ')}`, + `${indent}${gutter}${type.hint(densityMonthText(weekOneMonday, firstWeek + start, chunk.length, lang, room))}`, ); - if (currentWeekIndex >= start && currentWeekIndex < start + count) { - const relativeIndex = currentWeekIndex - start; - lines.push( - `${indent}${type.hint( - densityMarkerText( - relativeIndex, - contentWidth, - markerGlyph, - trans.timetable.termDensityThisWeek, - ), - )}`, + if (current >= start && current < start + chunk.length) { + const marker = densityMarkerLines( + current - start, + room, + pickIcon('↑', '^'), + trans.termDensityThisWeek, ); + lines.push(...marker.map((line) => `${indent}${gutter}${type.hint(line)}`)); } } - - lines.push(''); - lines.push( - ...wrapAnsiToVisualWidth(legendContent, contentWidth).map((part) => `${indent}${part}`), - ); return lines.join('\n'); } diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 23fb7a5..9db331b 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -315,6 +315,12 @@ export interface Translations { hubTermDensity: string; termDensityTitle: string; termDensityThisWeek: string; + termDensityWeeks: string; + termDensityPerWeek: string; + termDensityFreeWeeks: string; + termDensityCurrent: string; + termDensityEmpty: string; + exportSaved: string; weekOverviewTitle: string; weekAheadClasses: string; weekAheadBusy: string; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2c4e574..cb9e87d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -304,6 +304,12 @@ "hubTermDensity": "Term density", "termDensityTitle": "Term density", "termDensityThisWeek": "This week", + "termDensityWeeks": "{weeks} weeks", + "termDensityPerWeek": "{periods} periods a week", + "termDensityFreeWeeks": "{count} without classes", + "termDensityCurrent": "{periods} this week", + "termDensityEmpty": "No classes scheduled this term", + "exportSaved": "Saved: {path}", "weekOverviewTitle": "Week overview", "weekAheadClasses": "Classes", "weekAheadBusy": "Busy", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e8b161b..f82c999 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -304,6 +304,12 @@ "hubTermDensity": "学期活跃度", "termDensityTitle": "学期活跃度", "termDensityThisWeek": "本周", + "termDensityWeeks": "共 {weeks} 周", + "termDensityPerWeek": "每周 {periods} 节", + "termDensityFreeWeeks": "其中 {count} 周无课", + "termDensityCurrent": "本周 {periods} 节", + "termDensityEmpty": "本学期还没有课程安排", + "exportSaved": "已保存:{path}", "weekOverviewTitle": "本周概览", "weekAheadClasses": "课程", "weekAheadBusy": "较满",