@nbtca/prompt 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +53 -55
  4. package/dist/app/chrome.js +67 -50
  5. package/dist/app/fields/list-field.js +12 -25
  6. package/dist/app/fields/text-field.js +3 -8
  7. package/dist/app/frame.js +2 -21
  8. package/dist/app/keys.js +10 -2
  9. package/dist/app/views/docs-render.js +31 -24
  10. package/dist/app/views/docs.js +211 -60
  11. package/dist/app/views/events-render.js +19 -26
  12. package/dist/app/views/events.js +44 -31
  13. package/dist/app/views/home.js +28 -30
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +47 -71
  16. package/dist/app/views/schedule.js +158 -81
  17. package/dist/app/views/settings-render.js +8 -19
  18. package/dist/app/views/settings.js +92 -17
  19. package/dist/auth/cookie-transport.js +31 -32
  20. package/dist/auth/errors.js +3 -1
  21. package/dist/auth/nbt-auth.js +42 -25
  22. package/dist/auth/session-store.js +17 -9
  23. package/dist/config/data.js +9 -11
  24. package/dist/config/preferences.js +14 -7
  25. package/dist/core/calendar-day.js +37 -0
  26. package/dist/core/capabilities.js +6 -3
  27. package/dist/core/components/confirm.js +9 -8
  28. package/dist/core/components/menu.js +41 -16
  29. package/dist/core/components/messages.js +12 -4
  30. package/dist/core/components/painter.js +3 -1
  31. package/dist/core/components/spinner.js +17 -6
  32. package/dist/core/components/text-input.js +24 -18
  33. package/dist/core/icons.js +2 -2
  34. package/dist/core/logo.js +23 -5
  35. package/dist/core/motion.js +25 -19
  36. package/dist/core/text.js +182 -69
  37. package/dist/core/theme.js +0 -28
  38. package/dist/core/transitions.js +2 -2
  39. package/dist/core/ui.js +15 -13
  40. package/dist/core/vim-keys.js +9 -15
  41. package/dist/features/about.js +23 -0
  42. package/dist/features/calendar-heatmap.js +16 -40
  43. package/dist/features/calendar-query.js +1 -2
  44. package/dist/features/calendar.js +12 -185
  45. package/dist/features/docs.js +436 -275
  46. package/dist/features/schedule-render.js +65 -101
  47. package/dist/features/schedule-store.js +51 -9
  48. package/dist/features/schedule-view.js +46 -213
  49. package/dist/features/status.js +44 -56
  50. package/dist/features/student-timetable.js +73 -95
  51. package/dist/features/theme.js +6 -2
  52. package/dist/features/timetable-sanitize.js +40 -0
  53. package/dist/features/update.js +9 -27
  54. package/dist/i18n/index.js +83 -19
  55. package/dist/i18n/locales/en.json +1 -1
  56. package/dist/i18n/locales/zh.json +1 -1
  57. package/dist/index.js +83 -58
  58. package/dist/logo/ca-dotmatrix.txt +16 -18
  59. package/dist/main.js +7 -48
  60. package/package.json +27 -18
  61. package/bin/nbtca-welcome.js +0 -2
  62. package/dist/core/components/screen.js +0 -18
  63. package/dist/core/menu.js +0 -68
  64. package/dist/features/links.js +0 -36
  65. package/dist/features/schedule-query.js +0 -47
  66. package/dist/features/settings.js +0 -127
  67. package/dist/logo/ca-logo.png +0 -0
@@ -3,29 +3,14 @@ import { t } from '../../i18n/index.js';
3
3
  import { renderListFieldWithContext } from '../fields/list-field.js';
4
4
  import { renderCountdownBanner, renderEventBrief } from '../../features/calendar.js';
5
5
  import { renderHeatmap } from '../../features/calendar-heatmap.js';
6
- import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
6
+ import { wrapAnsiWithIndent } from '../../core/text.js';
7
7
  function wrappedIndentedLines(label, cols, style) {
8
- const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols ?? 1)) : Number.POSITIVE_INFINITY;
9
- const styled = style(label);
10
- const styledWidth = visualWidth(styled);
11
- const preferredIndent = visualWidth(space.indent) < width ? space.indent : '';
12
- const indent = preferredIndent
13
- && styledWidth > width - visualWidth(preferredIndent)
14
- && styledWidth <= width
15
- ? ''
16
- : preferredIndent;
17
- const contentWidth = Math.max(1, width - visualWidth(indent));
18
- return wrapAnsiToVisualWidth(styled, contentWidth).map((line) => `${indent}${line}`);
8
+ return wrapAnsiWithIndent(style(label), cols ?? Number.POSITIVE_INFINITY, space.indent);
19
9
  }
20
10
  function wrappedRenderedLine(line, cols) {
21
11
  const content = line.startsWith(space.indent) ? line.slice(space.indent.length) : line;
22
12
  return wrappedIndentedLines(content, cols, (value) => value);
23
13
  }
24
- // Lines a fully-expanded hub needs: banner+blank (2) + heatmap+blank (12) +
25
- // recent-activity heading+up to 5 events+blank (7) + hubField
26
- // (title+blank+6 options, 8) = 29. Below this, a terminal can't fit the
27
- // heatmap without pushing the menu into scroll territory — better to keep
28
- // it as the existing drill-down destination than show a truncated grid.
29
14
  const EXPANDED_HUB_MIN_BODY_ROWS = 29;
30
15
  function renderHubBody(state, now, bodyRows, cols) {
31
16
  const trans = t();
@@ -38,7 +23,10 @@ function renderHubBody(state, now, bodyRows, cols) {
38
23
  lines.push(...banner.split('\n'), '');
39
24
  const buckets = state.heatmapBuckets;
40
25
  if (bodyRows >= EXPANDED_HUB_MIN_BODY_ROWS && buckets && buckets.length > 0) {
41
- lines.push(...renderHeatmap(buckets, now, { color: true, cols }).split('\n'));
26
+ lines.push(...renderHeatmap(buckets, now, {
27
+ color: true,
28
+ ...(cols === undefined ? {} : { cols }),
29
+ }).split('\n'));
42
30
  lines.push('');
43
31
  }
44
32
  if (state.recentEvents && state.recentEvents.length > 0) {
@@ -77,12 +65,11 @@ export function renderEvents(state, now, bodyRows = 100, cols) {
77
65
  case 'hub':
78
66
  return renderHubBody(state, now, bodyRows, cols);
79
67
  case 'heatmap':
80
- // renderHeatmap() already prints its own title (space.indent +
81
- // type.heading), so this mode doesn't add a second heading on top —
82
- // unlike Schedule's 'week'/'unresolved' modes, which wrap a
83
- // title-less renderer.
84
68
  return state.heatmapBuckets && state.heatmapBuckets.length > 0
85
- ? renderHeatmap(state.heatmapBuckets, now, { color: true, cols }).split('\n')
69
+ ? renderHeatmap(state.heatmapBuckets, now, {
70
+ color: true,
71
+ ...(cols === undefined ? {} : { cols }),
72
+ }).split('\n')
86
73
  : wrappedIndentedLines(trans.calendar.noEvents, cols, type.hint);
87
74
  case 'list':
88
75
  return state.listField?.render(bodyRows, cols) ?? [];
@@ -92,14 +79,20 @@ export function renderEvents(state, now, bodyRows = 100, cols) {
92
79
  ...wrappedIndentedLines(state.detailMeta ?? '', cols, type.hint),
93
80
  '',
94
81
  ...(state.detailDescription
95
- ? state.detailDescription.split('\n').flatMap((line) => wrappedIndentedLines(line, cols, type.body))
82
+ ? state.detailDescription
83
+ .split('\n')
84
+ .flatMap((line) => wrappedIndentedLines(line, cols, type.body))
96
85
  : wrappedIndentedLines(trans.calendar.noDescription, cols, type.hint)),
97
86
  '',
98
- ...(state.statusMessage ? [...wrappedIndentedLines(state.statusMessage, cols, type.hint), ''] : []),
87
+ ...(state.statusMessage
88
+ ? [...wrappedIndentedLines(state.statusMessage, cols, type.hint), '']
89
+ : []),
99
90
  ];
100
91
  return state.detailField
101
92
  ? renderListFieldWithContext(context, state.detailField, bodyRows, cols)
102
- : Number.isFinite(bodyRows) ? context.slice(0, Math.max(0, Math.floor(bodyRows))) : context;
93
+ : Number.isFinite(bodyRows)
94
+ ? context.slice(0, Math.max(0, Math.floor(bodyRows)))
95
+ : context;
103
96
  }
104
97
  case 'search':
105
98
  return state.searchField?.render(cols) ?? [];
@@ -7,6 +7,7 @@ import { pickIcon } from '../../core/icons.js';
7
7
  import { t } from '../../i18n/index.js';
8
8
  import { loadCalendarOrThrow, toDisplayEvent, exportEventIcs } from '../../features/calendar.js';
9
9
  import { weekRange, monthRange, filterEvents } from '../../features/calendar-query.js';
10
+ import { addLocalDays } from '../../core/calendar-day.js';
10
11
  let state = { mode: 'loading' };
11
12
  let calendar = null;
12
13
  let currentList = [];
@@ -29,36 +30,42 @@ function buildListField(title, events, maxVisible) {
29
30
  const trans = t();
30
31
  const display = events.map(toDisplayEvent);
31
32
  const options = [
32
- ...events.map((_e, i) => ({
33
- value: String(i),
34
- label: `${display[i].date}${display[i].time ? ' ' + display[i].time : ''} ${display[i].title}`,
35
- hint: display[i].location,
33
+ ...display.map((event, index) => ({
34
+ value: String(index),
35
+ label: `${event.date}${event.time ? ` ${event.time}` : ''} ${event.title}`,
36
+ hint: event.location,
36
37
  })),
37
38
  { value: '__back__', label: backLabel() },
38
39
  ];
39
40
  return new ListField({
40
41
  title: title || trans.menu.events,
41
- options: options.length > 1 ? options : [{ value: '__back__', label: `${trans.calendar.noEvents} — ${backLabel()}` }],
42
+ options: options.length > 1
43
+ ? options
44
+ : [{ value: '__back__', label: `${trans.calendar.noEvents} — ${backLabel()}` }],
42
45
  maxVisible,
43
46
  });
44
47
  }
45
48
  function showList(title, events, ctx) {
46
49
  currentList = events;
47
- state = { mode: 'list', listField: buildListField(title, events, computeMaxVisible(ctx.bodyRows)) };
50
+ state = {
51
+ mode: 'list',
52
+ listField: buildListField(title, events, computeMaxVisible(ctx.bodyRows)),
53
+ };
48
54
  }
49
- // A glance-panel ceiling, not "browse everything" (this tab's own "Events"
50
- // list is for that) — just needs to be at least as many as the tallest
51
- // reasonable terminal could fit; renderHubBody trims further based on the
52
- // real ctx.bodyRows.
53
55
  const RECENT_ACTIVITY_FETCH_CAP = 15;
54
56
  function goToHub() {
55
57
  const upcoming = calendar ? calendar.upcoming({ days: 30 }) : [];
58
+ const nextEvent = upcoming[0];
56
59
  state = {
57
60
  mode: 'hub',
58
61
  hubField: buildHubField(),
59
- nextEvent: upcoming[0] ? toDisplayEvent(upcoming[0]) : undefined,
62
+ ...(nextEvent === undefined ? {} : { nextEvent: toDisplayEvent(nextEvent) }),
60
63
  heatmapBuckets: calendar
61
- ? calendar.heatmap({ start: new Date(Date.now() - 365 * 86400000), end: new Date(), bucket: 'day' })
64
+ ? calendar.heatmap({
65
+ start: addLocalDays(new Date(), -365),
66
+ end: new Date(),
67
+ bucket: 'day',
68
+ })
62
69
  : [],
63
70
  recentEvents: upcoming.slice(0, RECENT_ACTIVITY_FETCH_CAP).map(toDisplayEvent),
64
71
  };
@@ -74,10 +81,6 @@ function showDetail(raw) {
74
81
  detailDescription: e.description,
75
82
  detailEvent: raw,
76
83
  detailField: new ListField({
77
- // Empty, not e.title: renderEvents already prints the event title as
78
- // its own heading right above (detailTitle) -- giving the field the
79
- // same string a second time repeated it verbatim just above the
80
- // Export/Back options.
81
84
  title: '',
82
85
  options: [
83
86
  { value: 'export', label: trans.calendar.exportIcs },
@@ -102,27 +105,26 @@ export const eventsView = {
102
105
  ctx.rerender();
103
106
  },
104
107
  render(ctx) {
105
- // Sync the list's scroll window to the *current* terminal size on every
106
- // frame (not just construction time) — this is what keeps a long list
107
- // correctly windowed across a live resize.
108
108
  state.listField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
109
109
  return renderEvents(state, new Date(), ctx.bodyRows, ctx.size.cols);
110
110
  },
111
111
  capturesInput() {
112
112
  return state.mode === 'search';
113
113
  },
114
+ capturesPageKeys() {
115
+ return state.mode === 'hub' || state.mode === 'list' || state.mode === 'detail';
116
+ },
114
117
  footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
115
118
  if (state.mode === 'search')
116
119
  return captureFooterHint(cols);
117
- // A pure "read this, any key returns to the hub" drill-down (see
118
- // handleKey below) -- no field to move a cursor within or open an item
119
- // from, so the generic "move · open" hint would promise keys that
120
- // don't do that here.
121
120
  const passive = state.mode === 'loading' || state.mode === 'error' || state.mode === 'heatmap';
122
121
  return passive ? passiveFooterHint(tabCount, cols) : undefined;
123
122
  },
124
123
  handleBack() {
125
- if (state.mode === 'list' || state.mode === 'detail' || state.mode === 'search' || state.mode === 'heatmap') {
124
+ if (state.mode === 'list' ||
125
+ state.mode === 'detail' ||
126
+ state.mode === 'search' ||
127
+ state.mode === 'heatmap') {
126
128
  if (state.mode === 'search')
127
129
  setVimKeysActive(true);
128
130
  goToHub();
@@ -163,12 +165,17 @@ export const eventsView = {
163
165
  }
164
166
  if (result.selected === 'search') {
165
167
  setVimKeysActive(false);
166
- state = { mode: 'search', searchField: new TextField({ message: t().calendar.searchPrompt, placeholder: t().calendar.searchPlaceholder, allowEmpty: true }) };
168
+ state = {
169
+ mode: 'search',
170
+ searchField: new TextField({
171
+ message: t().calendar.searchPrompt,
172
+ placeholder: t().calendar.searchPlaceholder,
173
+ allowEmpty: true,
174
+ }),
175
+ };
167
176
  }
168
177
  return;
169
178
  }
170
- // A pure detail/drill-down view with no field of its own — any key
171
- // returns to the hub, matching Schedule's 'week'/'unresolved' modes.
172
179
  case 'heatmap': {
173
180
  goToHub();
174
181
  return;
@@ -196,7 +203,12 @@ export const eventsView = {
196
203
  }
197
204
  if (result.selected === 'export' && state.detailEvent) {
198
205
  const res = exportEventIcs(state.detailEvent);
199
- state = { ...state, statusMessage: res.ok ? `${t().calendar.exportSuccess}: ${res.path}` : `${t().calendar.exportError}: ${res.error ?? ''}` };
206
+ state = {
207
+ ...state,
208
+ statusMessage: res.ok
209
+ ? `${t().calendar.exportSuccess}: ${res.path}`
210
+ : `${t().calendar.exportError}: ${res.error ?? ''}`,
211
+ };
200
212
  }
201
213
  return;
202
214
  }
@@ -210,18 +222,19 @@ export const eventsView = {
210
222
  if (result?.submitted !== undefined) {
211
223
  setVimKeysActive(true);
212
224
  const query = result.submitted.trim();
213
- if (!query || !calendar) {
225
+ if (!query) {
214
226
  goToHub();
215
227
  return;
216
228
  }
217
229
  const now = new Date();
218
- const pool = calendar.inRange(now, new Date(now.getTime() + 365 * 86400000));
230
+ const pool = calendar.inRange(now, addLocalDays(now, 365));
219
231
  const results = filterEvents(pool, query);
220
232
  showList(`${t().calendar.search}: ${query}`, results, ctx);
221
233
  }
222
234
  return;
223
235
  }
224
- default:
236
+ case 'loading':
237
+ case 'error':
225
238
  return;
226
239
  }
227
240
  },
@@ -1,23 +1,16 @@
1
1
  import { c, type, space, glyph } from '../../core/theme.js';
2
2
  import { t } from '../../i18n/index.js';
3
3
  import { pickIcon } from '../../core/icons.js';
4
- import { padEndV, visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
5
- import { peekNextClassLine, peekTodayLines, peekWeekAheadInfo, peekUnresolvedCount } from '../../features/schedule-view.js';
4
+ import { padEndV, visualWidth, wrapAnsiWithIndent } from '../../core/text.js';
5
+ import { peekNextClassLine, peekTodayLines, peekWeekAheadInfo, peekUnresolvedCount, } from '../../features/schedule-view.js';
6
6
  import { loadCalendarOrThrow, toDisplayEvent, renderEventBrief } from '../../features/calendar.js';
7
7
  import { weekdayShortLabel } from '../../features/schedule-render.js';
8
- import { campusWeekday } from '../../features/schedule-query.js';
8
+ import { addLocalDays } from '../../core/calendar-day.js';
9
9
  import { passiveFooterHint } from '../chrome.js';
10
+ import { campusWeekday } from '@nbtca/nbtcal/timetable';
11
+ const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
10
12
  function wrappedIndentedLines(label, cols, style) {
11
- const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
12
- const styled = style(label);
13
- const preferredIndent = visualWidth(space.indent) < width ? space.indent : '';
14
- const indent = preferredIndent
15
- && visualWidth(styled) > width - visualWidth(preferredIndent)
16
- && visualWidth(styled) <= width
17
- ? ''
18
- : preferredIndent;
19
- const contentWidth = Math.max(1, width - visualWidth(indent));
20
- return wrapAnsiToVisualWidth(styled, contentWidth).map((line) => `${indent}${line}`);
13
+ return wrapAnsiWithIndent(style(label), cols, space.indent);
21
14
  }
22
15
  function panelHeading(label, cols) {
23
16
  return wrappedIndentedLines(label, cols, type.heading);
@@ -39,7 +32,9 @@ function renderDayProgress(now, cols) {
39
32
  const indent = visualWidth(space.indent) + 2 + visualWidth(percentage) + 1 <= width ? space.indent : '';
40
33
  const gap = visualWidth(indent) + 2 + visualWidth(percentage) + 1 <= width
41
34
  ? ' '
42
- : visualWidth(indent) + 1 + visualWidth(percentage) + 1 <= width ? ' ' : '';
35
+ : visualWidth(indent) + 1 + visualWidth(percentage) + 1 <= width
36
+ ? ' '
37
+ : '';
43
38
  const barWidth = Math.max(0, Math.min(DAY_PROGRESS_WIDTH, width - visualWidth(indent) - visualWidth(gap) - visualWidth(percentage)));
44
39
  const filled = Math.round(fraction * barWidth);
45
40
  const filledChar = glyph.barFilled();
@@ -57,17 +52,21 @@ function renderWeekAheadGrid(classDays, eventDays, cols) {
57
52
  const days = [1, 2, 3, 4, 5, 6, 7];
58
53
  const dayLabels = days.map((wd) => type.hint(weekdayShortLabel(wd))).join(' ');
59
54
  const headerLine = `${space.indent}${padEndV('', rowLabelW)}${dayLabels}`;
60
- const classCells = days.map((wd) => {
55
+ const classCells = days
56
+ .map((wd) => {
61
57
  const isWeekend = wd === 6 || wd === 7;
62
- const glyphChar = isWeekend ? weekendChar : (classDays[wd - 1] ? hasClassChar : freeChar);
58
+ const glyphChar = isWeekend ? weekendChar : classDays[wd - 1] ? hasClassChar : freeChar;
63
59
  return type.body(glyphChar);
64
- }).join(' ');
60
+ })
61
+ .join(' ');
65
62
  const classLine = `${space.indent}${type.hint(padEndV(trans.timetable.weekAheadClasses, rowLabelW))}${classCells}`;
66
- const eventCells = days.map((wd) => {
63
+ const eventCells = days
64
+ .map((wd) => {
67
65
  if (!eventDays)
68
66
  return blankCell;
69
67
  return type.body(eventDays[wd - 1] ? hasClassChar : freeChar);
70
- }).join(' ');
68
+ })
69
+ .join(' ');
71
70
  const eventLine = `${space.indent}${type.hint(padEndV(trans.menu.events, rowLabelW))}${eventCells}`;
72
71
  const legend = `${space.indent}${type.hint(`${hasClassChar} ${trans.timetable.weekAheadBusy} ${freeChar} ${trans.timetable.weekAheadFree} ${weekendChar} ${trans.timetable.weekAheadNone}`)}`;
73
72
  const wideLines = [headerLine, classLine, eventLine, legend];
@@ -76,12 +75,8 @@ function renderWeekAheadGrid(classDays, eventDays, cols) {
76
75
  return wideLines.join('\n');
77
76
  const compactHeading = wrappedIndentedLines(`${trans.timetable.weekAheadClasses} / ${trans.menu.events}`, cols, type.hint);
78
77
  const compactDays = days.flatMap((wd) => {
79
- const classCell = wd === 6 || wd === 7
80
- ? weekendChar
81
- : classDays[wd - 1] ? hasClassChar : freeChar;
82
- const eventCell = eventDays
83
- ? eventDays[wd - 1] ? hasClassChar : freeChar
84
- : blankCell;
78
+ const classCell = wd === 6 || wd === 7 ? weekendChar : classDays[wd - 1] ? hasClassChar : freeChar;
79
+ const eventCell = eventDays ? (eventDays[wd - 1] ? hasClassChar : freeChar) : blankCell;
85
80
  const row = `${type.hint(weekdayShortLabel(wd))} ${type.body(classCell)} ${type.body(eventCell)}`;
86
81
  return wrappedIndentedLines(row, cols, (value) => value);
87
82
  });
@@ -159,7 +154,7 @@ export const homeView = {
159
154
  nextClassLine: peekNextClassLine(),
160
155
  todayLines: peekTodayLines(),
161
156
  unresolvedCount: peekUnresolvedCount(),
162
- weekAhead: weekAheadInfo ? { classDays: weekAheadInfo.classDays } : undefined,
157
+ ...(weekAheadInfo ? { weekAhead: { classDays: weekAheadInfo.classDays } } : {}),
163
158
  };
164
159
  }
165
160
  catch {
@@ -174,12 +169,15 @@ export const homeView = {
174
169
  const eventLines = items.map((e) => renderEventBrief(e, now));
175
170
  let weekAhead = data.weekAhead;
176
171
  if (weekAheadInfo) {
177
- const weekEnd = new Date(weekAheadInfo.weekStartDate.getTime() + 7 * 86400000);
172
+ const weekEnd = addLocalDays(weekAheadInfo.weekStartDate, 7);
178
173
  const weekEvents = cal.inRange(weekAheadInfo.weekStartDate, weekEnd);
179
- const daySet = new Set(weekEvents.map((e) => campusWeekday(e.start)));
180
- weekAhead = { classDays: weekAheadInfo.classDays, eventDays: [1, 2, 3, 4, 5, 6, 7].map((wd) => daySet.has(wd)) };
174
+ const daySet = new Set(weekEvents.map((event) => campusWeekday(event.start)));
175
+ weekAhead = {
176
+ classDays: weekAheadInfo.classDays,
177
+ eventDays: WEEKDAYS.map((weekday) => daySet.has(weekday)),
178
+ };
181
179
  }
182
- data = { ...data, eventLines, weekAhead };
180
+ data = weekAhead ? { ...data, eventLines, weekAhead } : { ...data, eventLines };
183
181
  }
184
182
  catch {
185
183
  data = { ...data, eventsLoadFailed: true };
@@ -1,40 +1,31 @@
1
- import { meetingAtCursor } from '../../features/schedule-query.js';
1
+ import { createTimetableSchedule, } from '@nbtca/nbtcal/timetable';
2
2
  export const KEY_ARROW_LEFT = '\x1b[D';
3
3
  export const KEY_ARROW_RIGHT = '\x1b[C';
4
4
  export const KEY_ARROW_UP = '\x1b[A';
5
5
  export const KEY_ARROW_DOWN = '\x1b[B';
6
6
  export const KEY_ENTER_CR = '\r';
7
7
  export const KEY_ENTER_LF = '\n';
8
- /** The cursor's starting position on entering hub/week mode: today's own
9
- * weekday (falling back to Monday on a weekend, since the grid's Sat/Sun
10
- * columns are always empty for a personal timetable) and the first period
11
- * this term's own period table actually defines. */
12
8
  export function defaultGridCursor(todayWeekday, periods) {
13
9
  const sorted = [...periods].sort((a, b) => a.period - b.period);
14
10
  const firstPeriod = sorted[0]?.period ?? 1;
15
- const weekday = todayWeekday >= 1 && todayWeekday <= 5 ? todayWeekday : 1;
11
+ const weekday = (todayWeekday >= 1 && todayWeekday <= 5 ? todayWeekday : 1);
16
12
  return { weekday, period: firstPeriod };
17
13
  }
18
- /** Moves the cursor one weekday left/right, clamped to [1, 7] with no
19
- * wraparound -- a 7-day week has a real fixed edge, unlike a scrollable
20
- * list where wrapping back to the top makes sense. */
21
14
  export function moveCursorWeekday(cursor, delta) {
22
- return { ...cursor, weekday: Math.max(1, Math.min(7, cursor.weekday + delta)) };
15
+ return {
16
+ ...cursor,
17
+ weekday: Math.max(1, Math.min(7, cursor.weekday + delta)),
18
+ };
23
19
  }
24
- /** Moves the cursor to the previous/next *defined* period in the sorted
25
- * period table (not period±1 -- real period numbers aren't always
26
- * contiguous), clamped at the first/last period with no wraparound. */
27
20
  export function moveCursorPeriod(cursor, periods, delta) {
28
21
  const sorted = [...periods].sort((a, b) => a.period - b.period);
29
22
  if (sorted.length === 0)
30
23
  return cursor;
31
24
  const idx = sorted.findIndex((p) => p.period === cursor.period);
32
25
  const nextIdx = Math.max(0, Math.min(sorted.length - 1, (idx === -1 ? 0 : idx) + delta));
33
- return { ...cursor, period: sorted[nextIdx].period };
26
+ const nextPeriod = sorted[nextIdx];
27
+ return nextPeriod ? { ...cursor, period: nextPeriod.period } : cursor;
34
28
  }
35
- /** Pure key-to-action mapping shared by hub mode's inline grid and the
36
- * standalone full-screen 'week' mode -- both are cursor-navigable over the
37
- * exact same rules, so this is the one place that logic lives. */
38
29
  export function handleGridKey(key, cursor, tt, week) {
39
30
  if (key === KEY_ARROW_LEFT)
40
31
  return { kind: 'moveCursor', cursor: moveCursorWeekday(cursor, -1) };
@@ -45,7 +36,7 @@ export function handleGridKey(key, cursor, tt, week) {
45
36
  if (key === KEY_ARROW_DOWN)
46
37
  return { kind: 'moveCursor', cursor: moveCursorPeriod(cursor, tt.periods, 1) };
47
38
  if (key === KEY_ENTER_CR || key === KEY_ENTER_LF) {
48
- const meeting = meetingAtCursor(tt.meetings, week, cursor);
39
+ const meeting = createTimetableSchedule(tt).meetingAt(week, cursor.weekday, cursor.period);
49
40
  return meeting ? { kind: 'openDetail', meeting } : { kind: 'none' };
50
41
  }
51
42
  return { kind: 'none' };
@@ -1,11 +1,12 @@
1
+ import { createTimetableSchedule, } from '@nbtca/nbtcal/timetable';
1
2
  import { c, type, space, glyph } from '../../core/theme.js';
2
3
  import { pickIcon } from '../../core/icons.js';
3
4
  import { t, fmt } from '../../i18n/index.js';
4
5
  import { renderListFieldWithContext } from '../fields/list-field.js';
5
- import { currentWeekNumber, campusWeekday, meetingsOnDay, nextMeeting } from '../../features/schedule-query.js';
6
6
  import { renderNextClassBanner, renderWeekGrid, renderUnresolvedItems, renderTodayTimeline, weekdayShortLabel, renderTermDensity, renderMeetingDetail, renderDayTimeline, renderDaySwitcher, } from '../../features/schedule-render.js';
7
7
  import { renderEventBrief } from '../../features/calendar.js';
8
- import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
8
+ import { sanitizeTerminalLine, visualWidth, wrapAnsiWithIndent } from '../../core/text.js';
9
+ import { localDayDifference, parseLocalDate, parseLocalMonday } from '../../core/calendar-day.js';
9
10
  function heading(label) {
10
11
  return `${space.indent}${type.heading(label)}`;
11
12
  }
@@ -13,10 +14,7 @@ function hint(label) {
13
14
  return `${space.indent}${type.hint(label)}`;
14
15
  }
15
16
  function wrappedIndentedLines(label, cols, style) {
16
- const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
17
- const indent = visualWidth(space.indent) < width ? space.indent : '';
18
- const contentWidth = Math.max(1, width - visualWidth(indent));
19
- return wrapAnsiToVisualWidth(style(label), contentWidth).map((part) => `${indent}${part}`);
17
+ return wrapAnsiWithIndent(style(label), cols, space.indent);
20
18
  }
21
19
  function headingLines(label, cols) {
22
20
  return wrappedIndentedLines(label, cols, type.heading);
@@ -51,8 +49,12 @@ function renderShortcutLines(shortcuts, cols, compact = false) {
51
49
  const available = Math.max(1, cols - visualWidth(space.indent));
52
50
  const parts = shortcuts.map((shortcut) => {
53
51
  const text = compact
54
- ? shortcut.showKey === false ? `[${shortcut.key}] ${shortcut.label}` : `[${shortcut.key}]`
55
- : shortcut.showKey === false ? `[${shortcut.label}]` : `[${shortcut.key}] ${shortcut.label}`;
52
+ ? shortcut.showKey === false
53
+ ? `[${shortcut.key}] ${shortcut.label}`
54
+ : `[${shortcut.key}]`
55
+ : shortcut.showKey === false
56
+ ? `[${shortcut.label}]`
57
+ : `[${shortcut.key}] ${shortcut.label}`;
56
58
  return shortcut.warn ? c.warn(text) : type.hint(text);
57
59
  });
58
60
  const lines = [];
@@ -76,20 +78,19 @@ function hubPreGridLines(state, now, cols) {
76
78
  const tt = state.timetable;
77
79
  if (!tt || !state.weekOne)
78
80
  return null;
79
- const week = currentWeekNumber(state.weekOne, now);
81
+ const schedule = createTimetableSchedule(tt, { weekOneMonday: state.weekOne });
82
+ const week = schedule.weekAt(now);
80
83
  const lines = [];
81
- const banner = renderNextClassBanner(nextMeeting(tt.meetings, tt.periods, state.weekOne, now), now, cols);
82
- lines.push(banner || hint(trans.timetable.noNextClass));
84
+ let next = null;
85
+ try {
86
+ next = schedule.next(now);
87
+ }
88
+ catch { }
89
+ const banner = renderNextClassBanner(next, now, cols);
90
+ lines.push(...(banner ? [banner] : hintLines(trans.timetable.noNextClass, cols)));
83
91
  lines.push('');
84
- const todayWd = campusWeekday(now);
92
+ const todayWd = schedule.weekdayAt(now);
85
93
  if (week < 1) {
86
- // weekOne can be a *future* date -- auto-inferred while on break, it
87
- // deliberately points at the upcoming term (see academic-calendar.ts)
88
- // so it's ready the moment classes start. There is no "today" to show
89
- // yet, but the timetable's real week-1 data is already fetched -- show
90
- // it as an explicit preview rather than showing no grid at all
91
- // regardless of terminal height. The "Week 1 preview" heading keeps it
92
- // unambiguous that this isn't "happening right now".
93
94
  lines.push(heading(trans.timetable.termNotStarted));
94
95
  lines.push(hint(fmt(trans.timetable.termStartsIn, {
95
96
  date: state.weekOne,
@@ -99,49 +100,32 @@ function hubPreGridLines(state, now, cols) {
99
100
  lines.push(heading(trans.timetable.termPreviewWeek));
100
101
  return { inlineLines: lines, fallbackLines: [...lines], week: 1, tt };
101
102
  }
102
- const today = meetingsOnDay(tt.meetings, todayWd, week);
103
+ const today = schedule.meetingsOnDay(week, todayWd);
103
104
  const weekHeading = heading(trans.timetable.hubWeek);
104
105
  const inlineLines = [
105
106
  ...lines,
106
- heading(fmt(trans.timetable.todayHeading, { weekday: weekdayShortLabel(todayWd), week: String(week) })),
107
+ heading(fmt(trans.timetable.todayHeading, {
108
+ weekday: weekdayShortLabel(todayWd),
109
+ week: String(week),
110
+ })),
107
111
  ...renderTodayTimeline(today, tt.periods, now, cols).split('\n'),
108
112
  weekHeading,
109
113
  ];
110
114
  return { inlineLines, fallbackLines: [...lines, weekHeading], week, tt };
111
115
  }
112
- // Below this width, even an all-empty grid's own per-column floor (3, plus
113
- // row-head and separator overhead) leaves each of the 7 columns too cramped
114
- // to show real content -- a technically-fitting but practically unreadable
115
- // grid isn't better than the single-day view, so width gates the decision
116
- // just as much as height does.
117
116
  const MIN_GRID_COLS = 100;
118
- /** The one place that decides "does the real week grid fit inline (plus a
119
- * floor reserved for the shortcut bar), or does the hub fall back to the
120
- * single-day view." Both branches represent the exact same gridCursor, just
121
- * rendered differently, so unlike the old non-interactive strip fallback
122
- * this decision no longer needs to be exposed to key handling -- arrow
123
- * keys/Enter are always meaningful in hub mode regardless of which branch is
124
- * currently on screen. */
125
117
  function gridFitsInline(precedingLineCount, tt, week, now, bodyRows, cols, cursor, reservedRows) {
126
118
  if (cols < MIN_GRID_COLS)
127
119
  return false;
128
- const gridLines = renderWeekGrid(tt.meetings, tt.periods, week, now, cols, cursor).split('\n');
120
+ const gridLines = renderWeekGrid(tt, week, now, cols, cursor).split('\n');
129
121
  return precedingLineCount + gridLines.length <= bodyRows - reservedRows;
130
122
  }
131
- /** Renders the full weekday x period grid if it (plus a floor reserved for
132
- * the shortcut bar) fits within bodyRows and cols, otherwise a single day's
133
- * detailed timeline -- the day the cursor's own weekday points at (today, by
134
- * default). A large terminal genuinely has no excuse to not show the whole
135
- * week; a small one is better served by one day shown properly than seven
136
- * days crammed into unreadable slivers. Shared by the "this week" and "term
137
- * hasn't started yet, preview week 1" branches of renderHubBody -- the same
138
- * measure-and-fallback decision, just against a different week number. */
139
123
  function renderAdaptiveWeekGrid(inlineLines, fallbackLines, tt, week, todayWd, now, bodyRows, cols, cursor, reservedRows) {
140
124
  if (gridFitsInline(inlineLines.length, tt, week, now, bodyRows, cols, cursor, reservedRows)) {
141
- return [...inlineLines, ...renderWeekGrid(tt.meetings, tt.periods, week, now, cols, cursor).split('\n')];
125
+ return [...inlineLines, ...renderWeekGrid(tt, week, now, cols, cursor).split('\n')];
142
126
  }
143
127
  const selectedWd = cursor?.weekday ?? todayWd;
144
- const dayMeetings = meetingsOnDay(tt.meetings, selectedWd, week);
128
+ const dayMeetings = createTimetableSchedule(tt).meetingsOnDay(week, selectedWd);
145
129
  return [
146
130
  ...fallbackLines,
147
131
  renderDaySwitcher(selectedWd, todayWd, cols),
@@ -164,7 +148,7 @@ function renderHubBody(state, now, bodyRows, cols) {
164
148
  }
165
149
  tail.push(...shortcutLines);
166
150
  const content = pre
167
- ? renderAdaptiveWeekGrid(pre.inlineLines, pre.fallbackLines, pre.tt, pre.week, campusWeekday(now), now, rows, cols, state.gridCursor, tail.length)
151
+ ? renderAdaptiveWeekGrid(pre.inlineLines, pre.fallbackLines, pre.tt, pre.week, createTimetableSchedule(pre.tt).weekdayAt(now), now, rows, cols, state.gridCursor, tail.length)
168
152
  : [];
169
153
  return { content, tail };
170
154
  };
@@ -174,19 +158,14 @@ function renderHubBody(state, now, bodyRows, cols) {
174
158
  const compact = build(renderShortcutLines(shortcuts, cols, true));
175
159
  if (compact.tail.length >= rows)
176
160
  return rows > 0 ? compact.tail.slice(-rows) : [];
177
- return [
178
- ...compact.content.slice(0, rows - compact.tail.length),
179
- ...compact.tail,
180
- ];
161
+ return [...compact.content.slice(0, rows - compact.tail.length), ...compact.tail];
181
162
  }
182
163
  const TERM_PROGRESS_WIDTH = 20;
183
- function renderTermProgressBar(w, now, cols) {
164
+ function renderTermProgressBar(w, cols) {
184
165
  if (!w.nextBreakStart)
185
166
  return null;
186
- const weekOneMs = new Date(`${w.weekOneMonday}T00:00:00`).getTime();
187
- const nextBreakMs = new Date(`${w.nextBreakStart}T00:00:00`).getTime();
188
- const totalWeeks = Math.max(1, Math.round((nextBreakMs - weekOneMs) / (7 * 86400000)));
189
- const currentWeek = currentWeekNumber(w.weekOneMonday, now);
167
+ const totalWeeks = Math.max(1, Math.round(localDayDifference(parseLocalMonday(w.weekOneMonday), parseLocalDate(w.nextBreakStart)) / 7));
168
+ const currentWeek = w.currentWeek;
190
169
  const labelText = fmt(t().timetable.weekLabel2, { week: `${currentWeek}/${totalWeeks}` });
191
170
  const label = type.hint(labelText);
192
171
  const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
@@ -203,7 +182,7 @@ function renderTermProgressBar(w, now, cols) {
203
182
  : [`${indent}${bar}`, ...hintLines(labelText, cols)];
204
183
  }
205
184
  function daysBetween(a, b) {
206
- return Math.max(0, Math.ceil((b.getTime() - a.getTime()) / 86400000));
185
+ return Math.max(0, localDayDifference(a, b));
207
186
  }
208
187
  function renderPublicBody(state, now, bodyRows, cols) {
209
188
  const trans = t();
@@ -216,18 +195,18 @@ function renderPublicBody(state, now, bodyRows, cols) {
216
195
  lines.push(...hintLines(trans.timetable.publicUnavailable, cols));
217
196
  }
218
197
  else if (w.status === 'onBreak') {
219
- lines.push(...headingLines(fmt(trans.timetable.onBreak, { title: w.breakTitle }), cols));
198
+ lines.push(...headingLines(fmt(trans.timetable.onBreak, { title: sanitizeTerminalLine(w.breakTitle) }), cols));
220
199
  }
221
200
  else {
222
201
  const semesterLabel = w.semester === '1' ? trans.timetable.semester1 : trans.timetable.semester2;
223
- lines.push(...headingLines(`${fmt(trans.timetable.academicYearSuffix, { year: w.academicYear })} · ${semesterLabel} · ${fmt(trans.timetable.weekLabel2, { week: String(w.currentWeek) })}`, cols));
224
- const bar = renderTermProgressBar(w, now, cols);
202
+ lines.push(...headingLines(`${fmt(trans.timetable.academicYearSuffix, { year: sanitizeTerminalLine(w.academicYear) })} · ${semesterLabel} · ${fmt(trans.timetable.weekLabel2, { week: String(w.currentWeek) })}`, cols));
203
+ const bar = renderTermProgressBar(w, cols);
225
204
  if (bar)
226
205
  lines.push(...bar);
227
206
  if (w.nextBreakStart && w.nextBreakTitle) {
228
207
  lines.push(...hintLines(fmt(trans.timetable.daysUntilBreak, {
229
- title: w.nextBreakTitle,
230
- days: String(daysBetween(now, new Date(`${w.nextBreakStart}T00:00:00`))),
208
+ title: sanitizeTerminalLine(w.nextBreakTitle),
209
+ days: String(daysBetween(now, parseLocalDate(w.nextBreakStart))),
231
210
  }), cols));
232
211
  }
233
212
  }
@@ -283,20 +262,17 @@ export function renderSchedule(state, now, bodyRows = 100, cols = 80) {
283
262
  case 'week': {
284
263
  if (!state.timetable || !state.weekOne)
285
264
  return [hint(trans.timetable.genericError)];
286
- const week = currentWeekNumber(state.weekOne, now);
265
+ const schedule = createTimetableSchedule(state.timetable, {
266
+ weekOneMonday: state.weekOne,
267
+ });
268
+ const week = Math.max(1, schedule.weekAt(now));
287
269
  const weekLines = [heading(trans.timetable.hubWeek), ''];
288
- // Standalone week mode gets the *whole* bodyRows to itself (no
289
- // banner/today-section eating into it first, unlike the hub's own
290
- // inline area) -- reached via the `w` shortcut specifically so a
291
- // marginal terminal that couldn't fit the grid inline the hub still
292
- // gets a real shot at it here, falling back to the single-day view
293
- // only if even the full screen isn't enough.
294
- return renderAdaptiveWeekGrid(weekLines, weekLines, state.timetable, week, campusWeekday(now), now, bodyRows, cols, state.gridCursor, 2);
270
+ return renderAdaptiveWeekGrid(weekLines, weekLines, state.timetable, week, schedule.weekdayAt(now), now, bodyRows, cols, state.gridCursor, 2);
295
271
  }
296
272
  case 'termDensity':
297
- return state.timetable && state.weekOne
298
- ? renderTermDensity(state.timetable.meetings, state.weekOne, currentWeekNumber(state.weekOne, now), cols).split('\n')
299
- : [hint(trans.timetable.genericError)];
273
+ if (!state.timetable || !state.weekOne)
274
+ return [hint(trans.timetable.genericError)];
275
+ return renderTermDensity(state.timetable.meetings, state.weekOne, createTimetableSchedule(state.timetable, { weekOneMonday: state.weekOne }).weekAt(now), cols).split('\n');
300
276
  case 'termPicker':
301
277
  return state.termField?.render(bodyRows, cols) ?? [];
302
278
  case 'unresolved':