@nbtca/prompt 1.4.1 → 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 (68) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +27 -58
  3. package/SECURITY.md +16 -45
  4. package/dist/app/app.js +53 -55
  5. package/dist/app/chrome.js +67 -50
  6. package/dist/app/fields/list-field.js +12 -25
  7. package/dist/app/fields/text-field.js +3 -8
  8. package/dist/app/frame.js +2 -21
  9. package/dist/app/keys.js +10 -2
  10. package/dist/app/views/docs-render.js +31 -24
  11. package/dist/app/views/docs.js +211 -67
  12. package/dist/app/views/events-render.js +19 -26
  13. package/dist/app/views/events.js +44 -31
  14. package/dist/app/views/home.js +33 -76
  15. package/dist/app/views/schedule-grid-cursor.js +9 -18
  16. package/dist/app/views/schedule-render.js +47 -71
  17. package/dist/app/views/schedule.js +158 -90
  18. package/dist/app/views/settings-render.js +8 -19
  19. package/dist/app/views/settings.js +93 -18
  20. package/dist/auth/cookie-transport.js +31 -32
  21. package/dist/auth/errors.js +3 -1
  22. package/dist/auth/nbt-auth.js +42 -25
  23. package/dist/auth/session-store.js +17 -9
  24. package/dist/config/data.js +10 -13
  25. package/dist/config/preferences.js +14 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/capabilities.js +6 -3
  28. package/dist/core/components/confirm.js +9 -8
  29. package/dist/core/components/menu.js +41 -16
  30. package/dist/core/components/messages.js +12 -4
  31. package/dist/core/components/painter.js +3 -1
  32. package/dist/core/components/spinner.js +17 -6
  33. package/dist/core/components/text-input.js +24 -18
  34. package/dist/core/icons.js +2 -2
  35. package/dist/core/logo.js +25 -21
  36. package/dist/core/motion.js +25 -19
  37. package/dist/core/text.js +182 -75
  38. package/dist/core/theme.js +0 -28
  39. package/dist/core/transitions.js +2 -2
  40. package/dist/core/ui.js +15 -30
  41. package/dist/core/vim-keys.js +9 -15
  42. package/dist/features/about.js +23 -0
  43. package/dist/features/calendar-heatmap.js +16 -40
  44. package/dist/features/calendar-query.js +1 -2
  45. package/dist/features/calendar.js +12 -185
  46. package/dist/features/docs.js +439 -320
  47. package/dist/features/schedule-render.js +65 -102
  48. package/dist/features/schedule-store.js +51 -9
  49. package/dist/features/schedule-view.js +46 -220
  50. package/dist/features/status.js +44 -59
  51. package/dist/features/student-timetable.js +73 -95
  52. package/dist/features/theme.js +6 -5
  53. package/dist/features/timetable-sanitize.js +40 -0
  54. package/dist/features/update.js +9 -37
  55. package/dist/i18n/index.js +87 -65
  56. package/dist/i18n/locales/en.json +1 -1
  57. package/dist/i18n/locales/zh.json +1 -1
  58. package/dist/index.js +85 -64
  59. package/dist/logo/ca-dotmatrix.txt +16 -18
  60. package/dist/main.js +7 -48
  61. package/package.json +30 -18
  62. package/bin/nbtca-welcome.js +0 -2
  63. package/dist/core/components/screen.js +0 -18
  64. package/dist/core/menu.js +0 -71
  65. package/dist/features/links.js +0 -39
  66. package/dist/features/schedule-query.js +0 -47
  67. package/dist/features/settings.js +0 -130
  68. 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);
@@ -30,7 +23,6 @@ function wrappedRenderedLines(line, cols) {
30
23
  return wrappedIndentedLines(content, cols, (value) => value);
31
24
  }
32
25
  const DAY_PROGRESS_WIDTH = 20;
33
- /** Pure: a block-character bar for how far into the calendar day `now` is. */
34
26
  function renderDayProgress(now, cols) {
35
27
  const minutesElapsed = now.getHours() * 60 + now.getMinutes();
36
28
  const fraction = Math.min(1, Math.max(0, minutesElapsed / 1440));
@@ -40,7 +32,9 @@ function renderDayProgress(now, cols) {
40
32
  const indent = visualWidth(space.indent) + 2 + visualWidth(percentage) + 1 <= width ? space.indent : '';
41
33
  const gap = visualWidth(indent) + 2 + visualWidth(percentage) + 1 <= width
42
34
  ? ' '
43
- : visualWidth(indent) + 1 + visualWidth(percentage) + 1 <= width ? ' ' : '';
35
+ : visualWidth(indent) + 1 + visualWidth(percentage) + 1 <= width
36
+ ? ' '
37
+ : '';
44
38
  const barWidth = Math.max(0, Math.min(DAY_PROGRESS_WIDTH, width - visualWidth(indent) - visualWidth(gap) - visualWidth(percentage)));
45
39
  const filled = Math.round(fraction * barWidth);
46
40
  const filledChar = glyph.barFilled();
@@ -48,13 +42,6 @@ function renderDayProgress(now, cols) {
48
42
  const bar = filledChar.repeat(filled) + emptyChar.repeat(barWidth - filled);
49
43
  return `${indent}${type.body(bar)}${gap}${type.hint(percentage)}`;
50
44
  }
51
- /** Combined class+event density grid for the coming campus week — the one
52
- * visualization neither Schedule nor Events alone can produce, since it
53
- * needs both data sources at once. Deliberately coarser (binary, not
54
- * 5-level) than Schedule's own term-density strip, and deliberately
55
- * uncolored (see the design spec's "Visual language decision") since it's
56
- * an overview of two other already-colored things, not a third color
57
- * language to learn. */
58
45
  function renderWeekAheadGrid(classDays, eventDays, cols) {
59
46
  const trans = t();
60
47
  const hasClassChar = pickIcon('▓▓', '##');
@@ -65,25 +52,21 @@ function renderWeekAheadGrid(classDays, eventDays, cols) {
65
52
  const days = [1, 2, 3, 4, 5, 6, 7];
66
53
  const dayLabels = days.map((wd) => type.hint(weekdayShortLabel(wd))).join(' ');
67
54
  const headerLine = `${space.indent}${padEndV('', rowLabelW)}${dayLabels}`;
68
- // Class row: weekend is hardcoded to the "N/A" glyph regardless of
69
- // classDays data (campus never has weekend classes) -- the same
70
- // weekend treatment used throughout the Schedule tab's own renderers.
71
- const classCells = days.map((wd) => {
55
+ const classCells = days
56
+ .map((wd) => {
72
57
  const isWeekend = wd === 6 || wd === 7;
73
- const glyphChar = isWeekend ? weekendChar : (classDays[wd - 1] ? hasClassChar : freeChar);
58
+ const glyphChar = isWeekend ? weekendChar : classDays[wd - 1] ? hasClassChar : freeChar;
74
59
  return type.body(glyphChar);
75
- }).join(' ');
60
+ })
61
+ .join(' ');
76
62
  const classLine = `${space.indent}${type.hint(padEndV(trans.timetable.weekAheadClasses, rowLabelW))}${classCells}`;
77
- // Event row: deliberately NOT hardcoding weekend -- a club event can
78
- // happen on a Saturday, so this row checks real data for all 7 days.
79
- // undefined eventDays (events still loading, or the fetch failed) means
80
- // "not yet known" -- rendered as blank, not the "free" glyph, to
81
- // visually distinguish "no data yet" from "checked, nothing happening".
82
- const eventCells = days.map((wd) => {
63
+ const eventCells = days
64
+ .map((wd) => {
83
65
  if (!eventDays)
84
66
  return blankCell;
85
67
  return type.body(eventDays[wd - 1] ? hasClassChar : freeChar);
86
- }).join(' ');
68
+ })
69
+ .join(' ');
87
70
  const eventLine = `${space.indent}${type.hint(padEndV(trans.menu.events, rowLabelW))}${eventCells}`;
88
71
  const legend = `${space.indent}${type.hint(`${hasClassChar} ${trans.timetable.weekAheadBusy} ${freeChar} ${trans.timetable.weekAheadFree} ${weekendChar} ${trans.timetable.weekAheadNone}`)}`;
89
72
  const wideLines = [headerLine, classLine, eventLine, legend];
@@ -92,30 +75,23 @@ function renderWeekAheadGrid(classDays, eventDays, cols) {
92
75
  return wideLines.join('\n');
93
76
  const compactHeading = wrappedIndentedLines(`${trans.timetable.weekAheadClasses} / ${trans.menu.events}`, cols, type.hint);
94
77
  const compactDays = days.flatMap((wd) => {
95
- const classCell = wd === 6 || wd === 7
96
- ? weekendChar
97
- : classDays[wd - 1] ? hasClassChar : freeChar;
98
- const eventCell = eventDays
99
- ? eventDays[wd - 1] ? hasClassChar : freeChar
100
- : blankCell;
78
+ const classCell = wd === 6 || wd === 7 ? weekendChar : classDays[wd - 1] ? hasClassChar : freeChar;
79
+ const eventCell = eventDays ? (eventDays[wd - 1] ? hasClassChar : freeChar) : blankCell;
101
80
  const row = `${type.hint(weekdayShortLabel(wd))} ${type.body(classCell)} ${type.body(eventCell)}`;
102
81
  return wrappedIndentedLines(row, cols, (value) => value);
103
82
  });
104
83
  const compactLegend = wrappedIndentedLines(`${hasClassChar} ${trans.timetable.weekAheadBusy} ${freeChar} ${trans.timetable.weekAheadFree} ${weekendChar} ${trans.timetable.weekAheadNone}`, cols, type.hint);
105
84
  return [...compactHeading, ...compactDays, ...compactLegend].join('\n');
106
85
  }
107
- /** Pure: renders the schedule-first dashboard from already-fetched data. No I/O. */
108
86
  export function renderHome(data, now, bodyRows = 100, cols = 80) {
109
87
  const trans = t();
110
88
  const lines = [];
111
- // Next class (cache-only, instant).
112
89
  const nextClass = data.nextClassLine !== undefined && data.nextClassLine.trim().length > 0
113
90
  ? wrappedRenderedLines(data.nextClassLine, cols)
114
91
  : wrappedIndentedLines(trans.timetable.noNextClass, cols, type.hint);
115
92
  lines.push(...panelHeading(trans.timetable.nextClass, cols));
116
93
  lines.push(...nextClass);
117
94
  lines.push('');
118
- // Today's classes (cache-only, instant).
119
95
  lines.push(...panelHeading(trans.timetable.hubToday, cols));
120
96
  lines.push(renderDayProgress(now, cols));
121
97
  if (data.todayLines && data.todayLines.length > 0) {
@@ -126,28 +102,15 @@ export function renderHome(data, now, bodyRows = 100, cols = 80) {
126
102
  lines.push(...wrappedIndentedLines(trans.timetable.noClassToday, cols, type.hint));
127
103
  }
128
104
  lines.push('');
129
- // Week overview (Part D): only when the student has a set-up, in-term
130
- // personal timetable -- mirrors peekWeekAheadInfo's own "not set up yet
131
- // / term hasn't started" -> null contract, hiding the whole panel rather
132
- // than showing empty/misleading cells.
133
105
  if (data.weekAhead) {
134
106
  lines.push(...panelHeading(trans.timetable.weekOverviewTitle, cols));
135
107
  lines.push(...renderWeekAheadGrid(data.weekAhead.classDays, data.weekAhead.eventDays, cols).split('\n'));
136
108
  lines.push('');
137
109
  }
138
- // Unresolved schedule items (Part E): surfaced directly on Home instead
139
- // of only inside Schedule's own hub menu -- same c.warn + ⚠ treatment
140
- // buildHubField() (schedule.ts) already uses for this exact condition,
141
- // matching the "everything that needs your attention, in one place"
142
- // spirit of a gh-status-like control center.
143
110
  if ((data.unresolvedCount ?? 0) > 0) {
144
111
  lines.push(...wrappedIndentedLines(`${pickIcon('⚠', '!')} ${trans.timetable.hubUnresolved} · ${data.unresolvedCount}`, cols, c.warn));
145
112
  lines.push('');
146
113
  }
147
- // Upcoming events (network, best-effort). How many fit is whatever room
148
- // is actually left after next-class/today above — on a tall terminal
149
- // that's most of `data.eventLines`; on a normal one, still just a few,
150
- // same as before this was ever adaptive.
151
114
  lines.push(...panelHeading(trans.menu.events, cols));
152
115
  if (data.eventLines && data.eventLines.length > 0) {
153
116
  const remaining = Number.isFinite(bodyRows)
@@ -168,7 +131,7 @@ export function renderHome(data, now, bodyRows = 100, cols = 80) {
168
131
  else if (data.loading) {
169
132
  lines.push(...loadingLines(cols));
170
133
  }
171
- else if (data.eventsError) {
134
+ else if (data.eventsLoadFailed) {
172
135
  lines.push(...wrappedIndentedLines(trans.calendar.error, cols, type.hint));
173
136
  }
174
137
  else {
@@ -184,29 +147,20 @@ export const homeView = {
184
147
  return passiveFooterHint(tabCount, cols);
185
148
  },
186
149
  async load(ctx) {
187
- // Schedule panels are cache-only and instant — populate them
188
- // synchronously first. weekAheadSync is computed once here and reused
189
- // below (peekWeekAheadInfo is itself cache-only/cheap, but capturing
190
- // its result avoids a second, redundant cache read for weekStartDate).
191
- const weekAheadSync = peekWeekAheadInfo();
150
+ const weekAheadInfo = peekWeekAheadInfo();
192
151
  try {
193
152
  data = {
194
153
  loading: true,
195
154
  nextClassLine: peekNextClassLine(),
196
155
  todayLines: peekTodayLines(),
197
156
  unresolvedCount: peekUnresolvedCount(),
198
- weekAhead: weekAheadSync ? { classDays: weekAheadSync.classDays } : undefined,
157
+ ...(weekAheadInfo ? { weekAhead: { classDays: weekAheadInfo.classDays } } : {}),
199
158
  };
200
159
  }
201
160
  catch {
202
161
  data = { loading: true };
203
162
  }
204
163
  ctx.rerender();
205
- // Events is the only networked panel; best-effort. Fetches the calendar
206
- // exactly once and reuses that same Calendar instance for both the
207
- // upcoming-events list below and the week-ahead event row (when there's
208
- // a personal timetable to correlate it against) — not two separate
209
- // network round-trips for what both come from the same public feed.
210
164
  const HOME_EVENT_FETCH_CAP = 15;
211
165
  try {
212
166
  const cal = await loadCalendarOrThrow();
@@ -214,16 +168,19 @@ export const homeView = {
214
168
  const items = cal.upcoming({ days: 30 }).slice(0, HOME_EVENT_FETCH_CAP).map(toDisplayEvent);
215
169
  const eventLines = items.map((e) => renderEventBrief(e, now));
216
170
  let weekAhead = data.weekAhead;
217
- if (weekAheadSync) {
218
- const weekEnd = new Date(weekAheadSync.weekStartDate.getTime() + 7 * 86400000);
219
- const weekEvents = cal.inRange(weekAheadSync.weekStartDate, weekEnd);
220
- const daySet = new Set(weekEvents.map((e) => campusWeekday(e.start)));
221
- weekAhead = { classDays: weekAheadSync.classDays, eventDays: [1, 2, 3, 4, 5, 6, 7].map((wd) => daySet.has(wd)) };
171
+ if (weekAheadInfo) {
172
+ const weekEnd = addLocalDays(weekAheadInfo.weekStartDate, 7);
173
+ const weekEvents = cal.inRange(weekAheadInfo.weekStartDate, weekEnd);
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
+ };
222
179
  }
223
- data = { ...data, eventLines, weekAhead };
180
+ data = weekAhead ? { ...data, eventLines, weekAhead } : { ...data, eventLines };
224
181
  }
225
182
  catch {
226
- data = { ...data, eventsError: true };
183
+ data = { ...data, eventsLoadFailed: true };
227
184
  }
228
185
  finally {
229
186
  data = { ...data, loading: false };
@@ -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' };