@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
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { createNbtTimetableClient, timetableToIcs, } from '@nbtca/nbtcal/timetable';
2
+ import { createNbtTimetableClient, createTimetableSchedule, timetableToIcs, } from '@nbtca/nbtcal/timetable';
3
3
  import { captureFooterHint, passiveFooterHint } from '../chrome.js';
4
4
  import { ListField, computeMaxVisible } from '../fields/list-field.js';
5
5
  import { TextField } from '../fields/text-field.js';
@@ -8,35 +8,60 @@ import { defaultGridCursor, handleGridKey } from './schedule-grid-cursor.js';
8
8
  import { setVimKeysActive } from '../../core/vim-keys.js';
9
9
  import { t } from '../../i18n/index.js';
10
10
  import { AuthError } from '../../auth/errors.js';
11
- import { loginWithStudentPassword, restoreNbtSession } from '../../auth/nbt-auth.js';
11
+ import { loginWithStudentPassword, restoreNbtSession, } from '../../auth/nbt-auth.js';
12
12
  import { createSessionStore } from '../../auth/session-store.js';
13
13
  import { resolveTerm, relevantTerms, writePrivateIcs, isSessionExpired, JWXT_ORIGIN, safeMessage, } from '../../features/student-timetable.js';
14
14
  import { termKey, loadWeekOne, saveWeekOne, saveTimetableCache, saveCurrentPointer, loadCurrentPointer, loadTimetableCache, clearScheduleCache, } from '../../features/schedule-store.js';
15
15
  import { loadCalendarOrThrow, toDisplayEvent } from '../../features/calendar.js';
16
16
  import { currentAcademicWindow, inferWeekOneMonday, isAcademicBreakEvent, } from '@nbtca/nbtcal';
17
- import { currentWeekNumber, campusWeekday } from '../../features/schedule-query.js';
17
+ import { sanitizeAcademicTerm, sanitizeTimetable } from '../../features/timetable-sanitize.js';
18
+ import { addLocalDays, parseLocalMonday } from '../../core/calendar-day.js';
18
19
  let state = { mode: 'loading' };
19
20
  let session = null;
20
21
  let client = null;
21
22
  let catalog = [];
22
23
  let pendingId = '';
24
+ async function releaseSession(target = session) {
25
+ if (!target)
26
+ return;
27
+ if (session === target) {
28
+ session = null;
29
+ client = null;
30
+ }
31
+ try {
32
+ await target.close();
33
+ }
34
+ catch { }
35
+ }
23
36
  function isTimetableLike(value) {
24
- return !!value && typeof value === 'object'
25
- && Array.isArray(value.meetings)
26
- && Array.isArray(value.periods);
37
+ return (!!value &&
38
+ typeof value === 'object' &&
39
+ Array.isArray(value.meetings) &&
40
+ Array.isArray(value.periods));
41
+ }
42
+ function readCachedTimetable(value) {
43
+ if (!isTimetableLike(value))
44
+ return null;
45
+ try {
46
+ return sanitizeTimetable(value);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
27
51
  }
28
52
  function returnToHub() {
29
53
  const tt = state.timetable;
30
54
  const backKey = state.key;
31
55
  const backWeekOne = state.weekOne;
32
56
  if (tt && backKey && backWeekOne) {
33
- // Carries over the existing cursor rather than resetting it -- closing a
34
- // meeting's detail card (or backing out of term density/unresolved)
35
- // should leave the student's grid navigation exactly where it was, not
36
- // silently jump back to today.
57
+ const schedule = createTimetableSchedule(tt, { weekOneMonday: backWeekOne });
37
58
  state = {
38
- mode: 'hub', key: backKey, term: state.term, weekOne: backWeekOne, timetable: tt,
39
- gridCursor: state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods),
59
+ mode: 'hub',
60
+ key: backKey,
61
+ ...(state.term ? { term: state.term } : {}),
62
+ weekOne: backWeekOne,
63
+ timetable: tt,
64
+ gridCursor: state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(new Date()), tt.periods),
40
65
  };
41
66
  return true;
42
67
  }
@@ -47,8 +72,11 @@ function goToLoginId(errorMessage) {
47
72
  setVimKeysActive(false);
48
73
  state = {
49
74
  mode: 'needsLoginId',
50
- errorMessage,
51
- idField: new TextField({ message: t().timetable.studentId, placeholder: t().timetable.studentIdHint }),
75
+ ...(errorMessage === undefined ? {} : { errorMessage }),
76
+ idField: new TextField({
77
+ message: t().timetable.studentId,
78
+ placeholder: t().timetable.studentIdHint,
79
+ }),
52
80
  };
53
81
  }
54
82
  function buildPublicField() {
@@ -59,13 +87,7 @@ function buildPublicField() {
59
87
  footer: trans.menu.hintMove,
60
88
  });
61
89
  }
62
- // A glance-panel ceiling, not "browse everything" (matches the same-purpose
63
- // constants in events.ts/home.ts) — renderPublicBody trims further based on
64
- // the real ctx.bodyRows.
65
90
  const PUBLIC_UPCOMING_FETCH_CAP = 15;
66
- /** The default, no-login Schedule view: public term/week status sourced from
67
- * the same public calendar feed Events already uses. Login is now something
68
- * the student opts into from here, not a gate they hit immediately. */
69
91
  async function goToPublic(ctx) {
70
92
  setVimKeysActive(true);
71
93
  state = { mode: 'public', publicField: buildPublicField() };
@@ -73,9 +95,10 @@ async function goToPublic(ctx) {
73
95
  try {
74
96
  const cal = await loadCalendarOrThrow();
75
97
  const now = new Date();
76
- const windowEvents = cal.inRange(new Date(now.getTime() - 400 * 86400000), new Date(now.getTime() + 400 * 86400000));
98
+ const windowEvents = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400));
77
99
  const publicWindow = currentAcademicWindow(windowEvents, now);
78
- const publicUpcoming = cal.upcoming({ days: 30 })
100
+ const publicUpcoming = cal
101
+ .upcoming({ days: 30 })
79
102
  .filter((e) => !isAcademicBreakEvent(e))
80
103
  .slice(0, PUBLIC_UPCOMING_FETCH_CAP)
81
104
  .map(toDisplayEvent);
@@ -86,19 +109,11 @@ async function goToPublic(ctx) {
86
109
  }
87
110
  ctx.rerender();
88
111
  }
89
- /** Best-effort: try to auto-fill "week one Monday" from the same public
90
- * calendar feed before falling back to the manual prompt. Never throws —
91
- * any failure here just means the student sees today's existing prompt. */
92
112
  async function tryInferWeekOne() {
93
113
  try {
94
114
  const cal = await loadCalendarOrThrow();
95
115
  const now = new Date();
96
- // Symmetric window (matches goToPublic's): inferWeekOneMonday can look
97
- // forward to an *upcoming* semester-start marker while on break (e.g. a
98
- // student logging in mid-summer, months before the next term's own
99
- // start date) — a narrow forward window would silently miss exactly the
100
- // event this is meant to find.
101
- const events = cal.inRange(new Date(now.getTime() - 400 * 86400000), new Date(now.getTime() + 400 * 86400000));
116
+ const events = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400));
102
117
  return inferWeekOneMonday(events, now);
103
118
  }
104
119
  catch {
@@ -106,15 +121,13 @@ async function tryInferWeekOne() {
106
121
  }
107
122
  }
108
123
  async function afterAuthenticated(ctx, s) {
109
- // Captured before any state mutation below, so it reflects whether a
110
- // cached hub was already on screen when this call started (e.g. a
111
- // background session restore on launch) as opposed to a fresh login
112
- // (where state is 'authenticating', so hadCache is false).
113
124
  const hadCache = state.mode === 'hub';
125
+ if (session && session !== s)
126
+ await releaseSession(session);
114
127
  session = s;
115
128
  client = createNbtTimetableClient(s.timetableTransport, { baseUrl: JWXT_ORIGIN });
116
129
  try {
117
- catalog = await client.listTerms();
130
+ catalog = (await client.listTerms()).map(sanitizeAcademicTerm);
118
131
  const term = resolveTerm(catalog);
119
132
  const key = termKey(term);
120
133
  let weekOne = loadWeekOne(key);
@@ -130,7 +143,10 @@ async function afterAuthenticated(ctx, s) {
130
143
  key,
131
144
  term,
132
145
  errorMessage: t().timetable.weekOneAutoFailed,
133
- weekOneField: new TextField({ message: t().timetable.weekOne, placeholder: t().timetable.weekOneHint }),
146
+ weekOneField: new TextField({
147
+ message: t().timetable.weekOne,
148
+ placeholder: t().timetable.weekOneHint,
149
+ }),
134
150
  };
135
151
  ctx.rerender();
136
152
  return;
@@ -140,11 +156,14 @@ async function afterAuthenticated(ctx, s) {
140
156
  catch (err) {
141
157
  if (isSessionExpired(err)) {
142
158
  createSessionStore().clear();
159
+ await releaseSession(s);
143
160
  if (!hadCache)
144
161
  goToLoginId(t().timetable.expiredRelogin);
145
162
  }
146
- else if (!hadCache) {
147
- state = { mode: 'error', errorMessage: safeMessage(err) };
163
+ else {
164
+ await releaseSession(s);
165
+ if (!hadCache)
166
+ state = { mode: 'error', errorMessage: safeMessage(err) };
148
167
  }
149
168
  ctx.rerender();
150
169
  }
@@ -155,17 +174,23 @@ async function fetchAndShowHub(ctx, term, key, weekOne) {
155
174
  state = { mode: 'loading', statusMessage: t().calendar.loading };
156
175
  ctx.rerender();
157
176
  try {
158
- const timetable = await client.fetchTerm(term);
177
+ const timetable = sanitizeTimetable(await client.fetchTerm(term));
178
+ const schedule = createTimetableSchedule(timetable, { weekOneMonday: weekOne });
159
179
  saveTimetableCache(key, timetable);
160
180
  saveCurrentPointer(key, weekOne);
161
181
  state = {
162
- mode: 'hub', key, term, weekOne, timetable,
163
- gridCursor: defaultGridCursor(campusWeekday(new Date()), timetable.periods),
182
+ mode: 'hub',
183
+ key,
184
+ term,
185
+ weekOne,
186
+ timetable,
187
+ gridCursor: defaultGridCursor(schedule.weekdayAt(new Date()), timetable.periods),
164
188
  };
165
189
  }
166
190
  catch (err) {
167
191
  if (isSessionExpired(err)) {
168
192
  createSessionStore().clear();
193
+ await releaseSession();
169
194
  goToLoginId(t().timetable.expiredRelogin);
170
195
  }
171
196
  else {
@@ -194,7 +219,6 @@ async function refreshFromNetwork(ctx) {
194
219
  }
195
220
  await goToPublic(ctx);
196
221
  }
197
- // best-effort: a cached hub already showed, keep it as-is on refresh failure.
198
222
  }
199
223
  }
200
224
  export const scheduleView = {
@@ -202,11 +226,15 @@ export const scheduleView = {
202
226
  title: t().timetable.menuEntry,
203
227
  async load(ctx) {
204
228
  const ptr = loadCurrentPointer();
205
- const cached = ptr ? loadTimetableCache(ptr.termKey) : null;
206
- if (ptr && isTimetableLike(cached)) {
229
+ const cached = readCachedTimetable(ptr ? loadTimetableCache(ptr.termKey) : null);
230
+ if (ptr && cached) {
231
+ const schedule = createTimetableSchedule(cached, { weekOneMonday: ptr.weekOneMonday });
207
232
  state = {
208
- mode: 'hub', key: ptr.termKey, weekOne: ptr.weekOneMonday, timetable: cached,
209
- gridCursor: defaultGridCursor(campusWeekday(new Date()), cached.periods),
233
+ mode: 'hub',
234
+ key: ptr.termKey,
235
+ weekOne: ptr.weekOneMonday,
236
+ timetable: cached,
237
+ gridCursor: defaultGridCursor(schedule.weekdayAt(new Date()), cached.periods),
210
238
  };
211
239
  }
212
240
  else {
@@ -215,26 +243,33 @@ export const scheduleView = {
215
243
  ctx.rerender();
216
244
  await refreshFromNetwork(ctx);
217
245
  },
246
+ async dispose() {
247
+ await releaseSession();
248
+ },
218
249
  render(ctx) {
219
- // Sync every visible field's scroll window to the *current* terminal
220
- // size on every frame (not just construction time) — this is what
221
- // keeps a long list correctly windowed across a live resize.
222
250
  state.termField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
223
251
  return renderSchedule(state, new Date(), ctx.bodyRows, ctx.size.cols);
224
252
  },
225
253
  capturesInput() {
226
- return state.mode === 'needsLoginId' || state.mode === 'needsLoginPassword' || state.mode === 'needsWeekOne';
254
+ return (state.mode === 'needsLoginId' ||
255
+ state.mode === 'needsLoginPassword' ||
256
+ state.mode === 'needsWeekOne');
257
+ },
258
+ capturesPageKeys() {
259
+ return state.mode === 'public' || state.mode === 'termPicker';
227
260
  },
228
261
  footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
229
- const capturing = state.mode === 'needsLoginId' || state.mode === 'needsLoginPassword' || state.mode === 'needsWeekOne';
262
+ const capturing = state.mode === 'needsLoginId' ||
263
+ state.mode === 'needsLoginPassword' ||
264
+ state.mode === 'needsWeekOne';
230
265
  if (capturing)
231
266
  return captureFooterHint(cols);
232
- // These three are pure "read this, any key returns to the hub" drill-
233
- // downs (see handleKey below) — no field to move a cursor within or
234
- // open an item from, so the generic "move · open" hint would promise
235
- // keys that don't do that here.
236
- const passive = state.mode === 'loading' || state.mode === 'authenticating' || state.mode === 'error'
237
- || state.mode === 'meetingDetail' || state.mode === 'unresolved' || state.mode === 'termDensity';
267
+ const passive = state.mode === 'loading' ||
268
+ state.mode === 'authenticating' ||
269
+ state.mode === 'error' ||
270
+ state.mode === 'meetingDetail' ||
271
+ state.mode === 'unresolved' ||
272
+ state.mode === 'termDensity';
238
273
  return passive ? passiveFooterHint(tabCount, cols) : undefined;
239
274
  },
240
275
  handleBack(ctx) {
@@ -247,16 +282,16 @@ export const scheduleView = {
247
282
  return true;
248
283
  }
249
284
  if (state.mode === 'meetingDetail') {
250
- // Esc respects where the detail card was opened from -- from the
251
- // standalone 'week' mode, it steps back there, not all the way to hub.
252
285
  if (state.detailFrom === 'week') {
253
286
  state = { ...state, mode: 'week' };
254
287
  return true;
255
288
  }
256
289
  return returnToHub();
257
290
  }
258
- if (state.mode === 'week' || state.mode === 'unresolved' || state.mode === 'termPicker'
259
- || state.mode === 'termDensity') {
291
+ if (state.mode === 'week' ||
292
+ state.mode === 'unresolved' ||
293
+ state.mode === 'termPicker' ||
294
+ state.mode === 'termDensity') {
260
295
  return returnToHub();
261
296
  }
262
297
  return false;
@@ -279,7 +314,11 @@ export const scheduleView = {
279
314
  pendingId = result.submitted;
280
315
  state = {
281
316
  mode: 'needsLoginPassword',
282
- passwordField: new TextField({ message: t().timetable.password, placeholder: t().timetable.passwordHint, secret: true }),
317
+ passwordField: new TextField({
318
+ message: t().timetable.password,
319
+ placeholder: t().timetable.passwordHint,
320
+ secret: true,
321
+ }),
283
322
  };
284
323
  }
285
324
  return;
@@ -297,8 +336,16 @@ export const scheduleView = {
297
336
  ctx.rerender();
298
337
  void loginWithStudentPassword(pendingId, password)
299
338
  .then(async (s) => {
300
- createSessionStore().save(await s.snapshot());
301
- await afterAuthenticated(ctx, s);
339
+ let handedOff = false;
340
+ try {
341
+ createSessionStore().save(await s.snapshot());
342
+ handedOff = true;
343
+ await afterAuthenticated(ctx, s);
344
+ }
345
+ finally {
346
+ if (!handedOff)
347
+ await releaseSession(s);
348
+ }
302
349
  })
303
350
  .catch((err) => {
304
351
  goToLoginId(safeMessage(err));
@@ -315,7 +362,13 @@ export const scheduleView = {
315
362
  }
316
363
  if (result?.submitted !== undefined) {
317
364
  const trimmed = result.submitted.trim();
318
- const valid = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) && !Number.isNaN(new Date(`${trimmed}T00:00:00`).getTime());
365
+ let valid = true;
366
+ try {
367
+ parseLocalMonday(trimmed);
368
+ }
369
+ catch {
370
+ valid = false;
371
+ }
319
372
  const targetKey = state.key;
320
373
  const targetTerm = state.term;
321
374
  if (!valid || !targetKey || !targetTerm) {
@@ -335,15 +388,22 @@ export const scheduleView = {
335
388
  if (!tt || !hubKey || !hubWeekOne)
336
389
  return;
337
390
  {
338
- const cursor = state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods);
339
- const week = Math.max(1, currentWeekNumber(hubWeekOne, new Date()));
391
+ const schedule = createTimetableSchedule(tt, { weekOneMonday: hubWeekOne });
392
+ const now = new Date();
393
+ const cursor = state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(now), tt.periods);
394
+ const week = Math.max(1, schedule.weekAt(now));
340
395
  const nav = handleGridKey(key, cursor, tt, week);
341
396
  if (nav.kind === 'moveCursor') {
342
397
  state = { ...state, gridCursor: nav.cursor };
343
398
  return;
344
399
  }
345
400
  if (nav.kind === 'openDetail') {
346
- state = { ...state, mode: 'meetingDetail', detailMeeting: nav.meeting, detailFrom: 'hub' };
401
+ state = {
402
+ ...state,
403
+ mode: 'meetingDetail',
404
+ detailMeeting: nav.meeting,
405
+ detailFrom: 'hub',
406
+ };
347
407
  return;
348
408
  }
349
409
  }
@@ -366,19 +426,26 @@ export const scheduleView = {
366
426
  const options = relevantTerms(catalog).map((tm) => ({
367
427
  value: `${tm.academicYear}:${tm.semester}`,
368
428
  label: tm.academicYearLabel,
369
- hint: tm.current ? t().common.current : undefined,
429
+ ...(tm.current ? { hint: t().common.current } : {}),
370
430
  }));
371
- options.push({ value: '__back__', label: t().common.back, hint: undefined });
431
+ options.push({ value: '__back__', label: t().common.back });
372
432
  state = {
373
433
  ...state,
374
434
  mode: 'termPicker',
375
- termField: new ListField({ title: t().timetable.hubSwitchTerm, options, maxVisible: computeMaxVisible(ctx.bodyRows) }),
435
+ termField: new ListField({
436
+ title: t().timetable.hubSwitchTerm,
437
+ options,
438
+ maxVisible: computeMaxVisible(ctx.bodyRows),
439
+ }),
376
440
  };
377
441
  return;
378
442
  }
379
443
  if (shortcut.key === 'e') {
380
444
  try {
381
- const ics = timetableToIcs(tt, { weekOneMonday: hubWeekOne, calendarName: `NBT ${state.term?.academicYearLabel ?? ''}` });
445
+ const ics = timetableToIcs(tt, {
446
+ weekOneMonday: hubWeekOne,
447
+ calendarName: `NBT ${state.term?.academicYearLabel ?? ''}`,
448
+ });
382
449
  const out = `timetable-${hubKey}.ics`;
383
450
  writePrivateIcs(out, ics);
384
451
  state = { ...state, statusMessage: `${t().common.success}: ${path.resolve(out)}` };
@@ -391,9 +458,7 @@ export const scheduleView = {
391
458
  if (shortcut.key === 'x') {
392
459
  createSessionStore().clear();
393
460
  clearScheduleCache();
394
- void session?.close();
395
- session = null;
396
- client = null;
461
+ void releaseSession();
397
462
  void goToPublic(ctx);
398
463
  }
399
464
  return;
@@ -405,15 +470,22 @@ export const scheduleView = {
405
470
  returnToHub();
406
471
  return;
407
472
  }
408
- const cursor = state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods);
409
- const week = Math.max(1, currentWeekNumber(weekOne, new Date()));
473
+ const schedule = createTimetableSchedule(tt, { weekOneMonday: weekOne });
474
+ const now = new Date();
475
+ const cursor = state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(now), tt.periods);
476
+ const week = Math.max(1, schedule.weekAt(now));
410
477
  const nav = handleGridKey(key, cursor, tt, week);
411
478
  if (nav.kind === 'moveCursor') {
412
479
  state = { ...state, gridCursor: nav.cursor };
413
480
  return;
414
481
  }
415
482
  if (nav.kind === 'openDetail') {
416
- state = { ...state, mode: 'meetingDetail', detailMeeting: nav.meeting, detailFrom: 'week' };
483
+ state = {
484
+ ...state,
485
+ mode: 'meetingDetail',
486
+ detailMeeting: nav.meeting,
487
+ detailFrom: 'week',
488
+ };
417
489
  return;
418
490
  }
419
491
  returnToHub();
@@ -449,14 +521,19 @@ export const scheduleView = {
449
521
  mode: 'needsWeekOne',
450
522
  key: newTermKey,
451
523
  term,
452
- weekOneField: new TextField({ message: t().timetable.weekOne, placeholder: t().timetable.weekOneHint }),
524
+ weekOneField: new TextField({
525
+ message: t().timetable.weekOne,
526
+ placeholder: t().timetable.weekOneHint,
527
+ }),
453
528
  };
454
529
  return;
455
530
  }
456
531
  void fetchAndShowHub(ctx, term, newTermKey, weekOne);
457
532
  return;
458
533
  }
459
- default:
534
+ case 'loading':
535
+ case 'authenticating':
536
+ case 'error':
460
537
  return;
461
538
  }
462
539
  },
@@ -1,23 +1,16 @@
1
1
  import { space, type } from '../../core/theme.js';
2
- import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
2
+ import { wrapAnsiWithIndent } from '../../core/text.js';
3
3
  import { renderListFieldWithContext } from '../fields/list-field.js';
4
4
  function wrappedIndentedLines(label, cols, style) {
5
- const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
6
- const styled = style(label);
7
- const preferredIndent = visualWidth(space.indent) < width ? space.indent : '';
8
- const indent = preferredIndent
9
- && visualWidth(styled) > width - visualWidth(preferredIndent)
10
- && visualWidth(styled) <= width
11
- ? ''
12
- : preferredIndent;
13
- const contentWidth = Math.max(1, width - visualWidth(indent));
14
- return wrapAnsiToVisualWidth(styled, contentWidth).map((line) => `${indent}${line}`);
5
+ return wrapAnsiWithIndent(style(label), cols, space.indent);
15
6
  }
16
7
  export function renderSettings(state, bodyRows = Number.POSITIVE_INFINITY, cols = Number.POSITIVE_INFINITY) {
17
8
  switch (state.mode) {
18
9
  case 'menu': {
19
10
  const context = [
20
- ...(state.statusMessage ? [...wrappedIndentedLines(state.statusMessage, cols, type.hint), ''] : []),
11
+ ...(state.statusMessage
12
+ ? [...wrappedIndentedLines(state.statusMessage, cols, type.hint), '']
13
+ : []),
21
14
  ];
22
15
  return state.menuField
23
16
  ? renderListFieldWithContext(context, state.menuField, bodyRows, cols)
@@ -29,7 +22,7 @@ export function renderSettings(state, bodyRows = Number.POSITIVE_INFINITY, cols
29
22
  return state.subField?.render(bodyRows, cols) ?? [];
30
23
  case 'about': {
31
24
  const context = [
32
- ...(state.aboutLines ?? []).flatMap((line) => (line ? wrappedIndentedLines(line, cols, (value) => value) : [''])),
25
+ ...(state.aboutLines ?? []).flatMap((line) => line ? wrappedIndentedLines(line, cols, (value) => value) : ['']),
33
26
  '',
34
27
  ];
35
28
  if (!state.backField)
@@ -39,13 +32,9 @@ export function renderSettings(state, bodyRows = Number.POSITIVE_INFINITY, cols
39
32
  return [...context, ...fieldLines];
40
33
  }
41
34
  const rows = Math.max(0, Math.floor(bodyRows));
42
- const visibleField = fieldLines.length <= rows
43
- ? fieldLines
44
- : state.backField.render(rows, cols);
35
+ const visibleField = fieldLines.length <= rows ? fieldLines : state.backField.render(rows, cols);
45
36
  const content = context.at(-1) === '' ? context.slice(0, -1) : context;
46
- return content.length > 0
47
- ? [...visibleField, '', ...content]
48
- : visibleField;
37
+ return content.length > 0 ? [...visibleField, '', ...content] : visibleField;
49
38
  }
50
39
  default:
51
40
  return [];