@nbtca/prompt 1.4.2 → 1.5.1

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 (71) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +167 -64
  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 +16 -23
  8. package/dist/app/keys.js +110 -2
  9. package/dist/app/views/docs-render.js +34 -26
  10. package/dist/app/views/docs.js +280 -68
  11. package/dist/app/views/events-render.js +21 -27
  12. package/dist/app/views/events.js +57 -33
  13. package/dist/app/views/home.js +67 -47
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +51 -74
  16. package/dist/app/views/schedule.js +246 -101
  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/cli.js +570 -0
  24. package/dist/config/data.js +9 -11
  25. package/dist/config/preferences.js +21 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/canvas.js +1 -0
  28. package/dist/core/capabilities.js +6 -3
  29. package/dist/core/components/confirm.js +9 -8
  30. package/dist/core/components/menu.js +64 -38
  31. package/dist/core/components/messages.js +12 -4
  32. package/dist/core/components/painter.js +3 -1
  33. package/dist/core/components/spinner.js +34 -7
  34. package/dist/core/components/text-input.js +24 -18
  35. package/dist/core/icons.js +2 -2
  36. package/dist/core/logo.js +23 -5
  37. package/dist/core/motion.js +25 -19
  38. package/dist/core/text.js +186 -69
  39. package/dist/core/theme.js +0 -28
  40. package/dist/core/transitions.js +2 -2
  41. package/dist/core/ui.js +15 -13
  42. package/dist/core/vim-keys.js +156 -19
  43. package/dist/features/about.js +23 -0
  44. package/dist/features/calendar-heatmap.js +16 -40
  45. package/dist/features/calendar-query.js +1 -2
  46. package/dist/features/calendar-store.js +27 -0
  47. package/dist/features/calendar.js +66 -190
  48. package/dist/features/docs-client.js +225 -0
  49. package/dist/features/docs.js +615 -298
  50. package/dist/features/links.js +44 -29
  51. package/dist/features/schedule-render.js +65 -101
  52. package/dist/features/schedule-store.js +51 -9
  53. package/dist/features/schedule-view.js +46 -213
  54. package/dist/features/status.js +117 -60
  55. package/dist/features/student-timetable.js +74 -97
  56. package/dist/features/theme.js +9 -5
  57. package/dist/features/timetable-sanitize.js +40 -0
  58. package/dist/features/update.js +12 -29
  59. package/dist/i18n/index.js +83 -19
  60. package/dist/i18n/locales/en.json +8 -3
  61. package/dist/i18n/locales/zh.json +8 -3
  62. package/dist/index.js +6 -474
  63. package/dist/logo/ca-dotmatrix.txt +16 -18
  64. package/dist/main.js +7 -48
  65. package/package.json +28 -18
  66. package/bin/nbtca-welcome.js +0 -2
  67. package/dist/core/components/screen.js +0 -18
  68. package/dist/core/menu.js +0 -68
  69. package/dist/features/schedule-query.js +0 -47
  70. package/dist/features/settings.js +0 -127
  71. package/dist/logo/ca-logo.png +0 -0
@@ -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 },
@@ -90,39 +93,49 @@ export const eventsView = {
90
93
  id: 'events',
91
94
  title: t().menu.events,
92
95
  async load(ctx) {
96
+ if (ctx.signal?.aborted)
97
+ return;
93
98
  state = { mode: 'loading' };
94
99
  ctx.rerender();
95
100
  try {
96
- calendar = await loadCalendarOrThrow();
101
+ const loadedCalendar = await loadCalendarOrThrow(ctx.signal);
102
+ if (ctx.signal?.aborted)
103
+ return;
104
+ calendar = loadedCalendar;
97
105
  goToHub();
98
106
  }
99
107
  catch {
108
+ if (ctx.signal?.aborted)
109
+ return;
100
110
  state = { mode: 'error', errorMessage: t().calendar.error };
101
111
  }
102
- ctx.rerender();
112
+ if (!ctx.signal?.aborted)
113
+ ctx.rerender();
103
114
  },
104
115
  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
116
  state.listField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
109
117
  return renderEvents(state, new Date(), ctx.bodyRows, ctx.size.cols);
110
118
  },
119
+ isBusy() {
120
+ return state.mode === 'loading';
121
+ },
111
122
  capturesInput() {
112
123
  return state.mode === 'search';
113
124
  },
125
+ capturesPageKeys() {
126
+ return state.mode === 'hub' || state.mode === 'list' || state.mode === 'detail';
127
+ },
114
128
  footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
115
129
  if (state.mode === 'search')
116
130
  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
131
  const passive = state.mode === 'loading' || state.mode === 'error' || state.mode === 'heatmap';
122
132
  return passive ? passiveFooterHint(tabCount, cols) : undefined;
123
133
  },
124
134
  handleBack() {
125
- if (state.mode === 'list' || state.mode === 'detail' || state.mode === 'search' || state.mode === 'heatmap') {
135
+ if (state.mode === 'list' ||
136
+ state.mode === 'detail' ||
137
+ state.mode === 'search' ||
138
+ state.mode === 'heatmap') {
126
139
  if (state.mode === 'search')
127
140
  setVimKeysActive(true);
128
141
  goToHub();
@@ -163,12 +176,17 @@ export const eventsView = {
163
176
  }
164
177
  if (result.selected === 'search') {
165
178
  setVimKeysActive(false);
166
- state = { mode: 'search', searchField: new TextField({ message: t().calendar.searchPrompt, placeholder: t().calendar.searchPlaceholder, allowEmpty: true }) };
179
+ state = {
180
+ mode: 'search',
181
+ searchField: new TextField({
182
+ message: t().calendar.searchPrompt,
183
+ placeholder: t().calendar.searchPlaceholder,
184
+ allowEmpty: true,
185
+ }),
186
+ };
167
187
  }
168
188
  return;
169
189
  }
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
190
  case 'heatmap': {
173
191
  goToHub();
174
192
  return;
@@ -196,7 +214,12 @@ export const eventsView = {
196
214
  }
197
215
  if (result.selected === 'export' && state.detailEvent) {
198
216
  const res = exportEventIcs(state.detailEvent);
199
- state = { ...state, statusMessage: res.ok ? `${t().calendar.exportSuccess}: ${res.path}` : `${t().calendar.exportError}: ${res.error ?? ''}` };
217
+ state = {
218
+ ...state,
219
+ statusMessage: res.ok
220
+ ? `${t().calendar.exportSuccess}: ${res.path}`
221
+ : `${t().calendar.exportError}: ${res.error ?? ''}`,
222
+ };
200
223
  }
201
224
  return;
202
225
  }
@@ -210,18 +233,19 @@ export const eventsView = {
210
233
  if (result?.submitted !== undefined) {
211
234
  setVimKeysActive(true);
212
235
  const query = result.submitted.trim();
213
- if (!query || !calendar) {
236
+ if (!query) {
214
237
  goToHub();
215
238
  return;
216
239
  }
217
240
  const now = new Date();
218
- const pool = calendar.inRange(now, new Date(now.getTime() + 365 * 86400000));
241
+ const pool = calendar.inRange(now, addLocalDays(now, 365));
219
242
  const results = filterEvents(pool, query);
220
243
  showList(`${t().calendar.search}: ${query}`, results, ctx);
221
244
  }
222
245
  return;
223
246
  }
224
- default:
247
+ case 'loading':
248
+ case 'error':
225
249
  return;
226
250
  }
227
251
  },
@@ -1,30 +1,21 @@
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';
6
- import { loadCalendarOrThrow, toDisplayEvent, renderEventBrief } from '../../features/calendar.js';
4
+ import { padEndV, visualWidth, wrapAnsiWithIndent } from '../../core/text.js';
5
+ import { peekNextClassLine, peekTodayLines, peekWeekAheadInfo, peekUnresolvedCount, } from '../../features/schedule-view.js';
6
+ import { loadCalendarOrThrow, peekCalendar, 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
+ import { loadingLines } from '../../core/components/spinner.js';
12
+ const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
10
13
  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}`);
14
+ return wrapAnsiWithIndent(style(label), cols, space.indent);
21
15
  }
22
16
  function panelHeading(label, cols) {
23
17
  return wrappedIndentedLines(label, cols, type.heading);
24
18
  }
25
- function loadingLines(cols) {
26
- return wrappedIndentedLines(t().common.loading, cols, type.hint);
27
- }
28
19
  function wrappedRenderedLines(line, cols) {
29
20
  const content = line.startsWith(space.indent) ? line.slice(space.indent.length) : line;
30
21
  return wrappedIndentedLines(content, cols, (value) => value);
@@ -39,7 +30,9 @@ function renderDayProgress(now, cols) {
39
30
  const indent = visualWidth(space.indent) + 2 + visualWidth(percentage) + 1 <= width ? space.indent : '';
40
31
  const gap = visualWidth(indent) + 2 + visualWidth(percentage) + 1 <= width
41
32
  ? ' '
42
- : visualWidth(indent) + 1 + visualWidth(percentage) + 1 <= width ? ' ' : '';
33
+ : visualWidth(indent) + 1 + visualWidth(percentage) + 1 <= width
34
+ ? ' '
35
+ : '';
43
36
  const barWidth = Math.max(0, Math.min(DAY_PROGRESS_WIDTH, width - visualWidth(indent) - visualWidth(gap) - visualWidth(percentage)));
44
37
  const filled = Math.round(fraction * barWidth);
45
38
  const filledChar = glyph.barFilled();
@@ -57,17 +50,21 @@ function renderWeekAheadGrid(classDays, eventDays, cols) {
57
50
  const days = [1, 2, 3, 4, 5, 6, 7];
58
51
  const dayLabels = days.map((wd) => type.hint(weekdayShortLabel(wd))).join(' ');
59
52
  const headerLine = `${space.indent}${padEndV('', rowLabelW)}${dayLabels}`;
60
- const classCells = days.map((wd) => {
53
+ const classCells = days
54
+ .map((wd) => {
61
55
  const isWeekend = wd === 6 || wd === 7;
62
- const glyphChar = isWeekend ? weekendChar : (classDays[wd - 1] ? hasClassChar : freeChar);
56
+ const glyphChar = isWeekend ? weekendChar : classDays[wd - 1] ? hasClassChar : freeChar;
63
57
  return type.body(glyphChar);
64
- }).join(' ');
58
+ })
59
+ .join(' ');
65
60
  const classLine = `${space.indent}${type.hint(padEndV(trans.timetable.weekAheadClasses, rowLabelW))}${classCells}`;
66
- const eventCells = days.map((wd) => {
61
+ const eventCells = days
62
+ .map((wd) => {
67
63
  if (!eventDays)
68
64
  return blankCell;
69
65
  return type.body(eventDays[wd - 1] ? hasClassChar : freeChar);
70
- }).join(' ');
66
+ })
67
+ .join(' ');
71
68
  const eventLine = `${space.indent}${type.hint(padEndV(trans.menu.events, rowLabelW))}${eventCells}`;
72
69
  const legend = `${space.indent}${type.hint(`${hasClassChar} ${trans.timetable.weekAheadBusy} ${freeChar} ${trans.timetable.weekAheadFree} ${weekendChar} ${trans.timetable.weekAheadNone}`)}`;
73
70
  const wideLines = [headerLine, classLine, eventLine, legend];
@@ -76,12 +73,8 @@ function renderWeekAheadGrid(classDays, eventDays, cols) {
76
73
  return wideLines.join('\n');
77
74
  const compactHeading = wrappedIndentedLines(`${trans.timetable.weekAheadClasses} / ${trans.menu.events}`, cols, type.hint);
78
75
  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;
76
+ const classCell = wd === 6 || wd === 7 ? weekendChar : classDays[wd - 1] ? hasClassChar : freeChar;
77
+ const eventCell = eventDays ? (eventDays[wd - 1] ? hasClassChar : freeChar) : blankCell;
85
78
  const row = `${type.hint(weekdayShortLabel(wd))} ${type.body(classCell)} ${type.body(eventCell)}`;
86
79
  return wrappedIndentedLines(row, cols, (value) => value);
87
80
  });
@@ -116,7 +109,10 @@ export function renderHome(data, now, bodyRows = 100, cols = 80) {
116
109
  lines.push(...wrappedIndentedLines(`${pickIcon('⚠', '!')} ${trans.timetable.hubUnresolved} · ${data.unresolvedCount}`, cols, c.warn));
117
110
  lines.push('');
118
111
  }
119
- lines.push(...panelHeading(trans.menu.events, cols));
112
+ const eventsStale = data.eventsLoadFailed === true && (data.eventLines?.length ?? 0) > 0;
113
+ lines.push(...(eventsStale
114
+ ? wrappedIndentedLines(`${type.heading(trans.menu.events)} ${type.hint(`${pickIcon('·', '-')} ${trans.calendar.stale}`)}`, cols, (value) => value)
115
+ : panelHeading(trans.menu.events, cols)));
120
116
  if (data.eventLines && data.eventLines.length > 0) {
121
117
  const remaining = Number.isFinite(bodyRows)
122
118
  ? Math.max(0, Math.floor(bodyRows) - lines.length)
@@ -134,7 +130,7 @@ export function renderHome(data, now, bodyRows = 100, cols = 80) {
134
130
  }
135
131
  }
136
132
  else if (data.loading) {
137
- lines.push(...loadingLines(cols));
133
+ lines.push(...loadingLines(trans.common.loading, cols));
138
134
  }
139
135
  else if (data.eventsLoadFailed) {
140
136
  lines.push(...wrappedIndentedLines(trans.calendar.error, cols, type.hint));
@@ -144,6 +140,25 @@ export function renderHome(data, now, bodyRows = 100, cols = 80) {
144
140
  }
145
141
  return lines;
146
142
  }
143
+ const HOME_EVENT_FETCH_CAP = 15;
144
+ function calendarSnapshot(cal, weekAheadInfo) {
145
+ const now = new Date();
146
+ const eventLines = cal
147
+ .upcoming({ days: 30 })
148
+ .slice(0, HOME_EVENT_FETCH_CAP)
149
+ .map((event) => renderEventBrief(toDisplayEvent(event), now));
150
+ if (!weekAheadInfo)
151
+ return { eventLines };
152
+ const weekEnd = addLocalDays(weekAheadInfo.weekStartDate, 7);
153
+ const daySet = new Set(cal.inRange(weekAheadInfo.weekStartDate, weekEnd).map((event) => campusWeekday(event.start)));
154
+ return {
155
+ eventLines,
156
+ weekAhead: {
157
+ classDays: weekAheadInfo.classDays,
158
+ eventDays: WEEKDAYS.map((weekday) => daySet.has(weekday)),
159
+ },
160
+ };
161
+ }
147
162
  let data = { loading: true };
148
163
  export const homeView = {
149
164
  id: 'home',
@@ -152,6 +167,8 @@ export const homeView = {
152
167
  return passiveFooterHint(tabCount, cols);
153
168
  },
154
169
  async load(ctx) {
170
+ if (ctx.signal?.aborted)
171
+ return;
155
172
  const weekAheadInfo = peekWeekAheadInfo();
156
173
  try {
157
174
  data = {
@@ -159,36 +176,39 @@ export const homeView = {
159
176
  nextClassLine: peekNextClassLine(),
160
177
  todayLines: peekTodayLines(),
161
178
  unresolvedCount: peekUnresolvedCount(),
162
- weekAhead: weekAheadInfo ? { classDays: weekAheadInfo.classDays } : undefined,
179
+ ...(weekAheadInfo ? { weekAhead: { classDays: weekAheadInfo.classDays } } : {}),
163
180
  };
164
181
  }
165
182
  catch {
166
183
  data = { loading: true };
167
184
  }
185
+ const cached = peekCalendar();
186
+ if (cached)
187
+ data = { ...data, ...calendarSnapshot(cached, weekAheadInfo) };
188
+ if (ctx.signal?.aborted)
189
+ return;
168
190
  ctx.rerender();
169
- const HOME_EVENT_FETCH_CAP = 15;
170
191
  try {
171
- const cal = await loadCalendarOrThrow();
172
- const now = new Date();
173
- const items = cal.upcoming({ days: 30 }).slice(0, HOME_EVENT_FETCH_CAP).map(toDisplayEvent);
174
- const eventLines = items.map((e) => renderEventBrief(e, now));
175
- let weekAhead = data.weekAhead;
176
- if (weekAheadInfo) {
177
- const weekEnd = new Date(weekAheadInfo.weekStartDate.getTime() + 7 * 86400000);
178
- 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)) };
181
- }
182
- data = { ...data, eventLines, weekAhead };
192
+ const cal = await loadCalendarOrThrow(ctx.signal);
193
+ if (ctx.signal?.aborted)
194
+ return;
195
+ data = { ...data, ...calendarSnapshot(cal, weekAheadInfo) };
183
196
  }
184
197
  catch {
198
+ if (ctx.signal?.aborted)
199
+ return;
185
200
  data = { ...data, eventsLoadFailed: true };
186
201
  }
187
202
  finally {
188
- data = { ...data, loading: false };
189
- ctx.rerender();
203
+ if (!ctx.signal?.aborted) {
204
+ data = { ...data, loading: false };
205
+ ctx.rerender();
206
+ }
190
207
  }
191
208
  },
209
+ isBusy() {
210
+ return data.loading === true;
211
+ },
192
212
  render(ctx) {
193
213
  return renderHome(data, new Date(), ctx.bodyRows, ctx.size.cols);
194
214
  },
@@ -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' };