@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
@@ -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;
@@ -139,21 +155,15 @@ async function afterAuthenticated(ctx, s) {
139
155
  }
140
156
  catch (err) {
141
157
  if (isSessionExpired(err)) {
142
- // A dead session must be cleared and routed back to the login
143
- // field — leaving it as a bare error message here was a dead end:
144
- // the stale session would keep failing the same way on every
145
- // future launch/tab-switch, and there was no way back into the
146
- // login form short of quitting the app.
147
158
  createSessionStore().clear();
159
+ await releaseSession(s);
148
160
  if (!hadCache)
149
161
  goToLoginId(t().timetable.expiredRelogin);
150
162
  }
151
- else if (!hadCache) {
152
- // Only replace the screen with an error if there was nothing
153
- // useful showing already — a background refresh failure must not
154
- // blow away a working cached timetable (matches the same
155
- // best-effort contract refreshFromNetwork documents below).
156
- state = { mode: 'error', errorMessage: safeMessage(err) };
163
+ else {
164
+ await releaseSession(s);
165
+ if (!hadCache)
166
+ state = { mode: 'error', errorMessage: safeMessage(err) };
157
167
  }
158
168
  ctx.rerender();
159
169
  }
@@ -164,17 +174,23 @@ async function fetchAndShowHub(ctx, term, key, weekOne) {
164
174
  state = { mode: 'loading', statusMessage: t().calendar.loading };
165
175
  ctx.rerender();
166
176
  try {
167
- const timetable = await client.fetchTerm(term);
177
+ const timetable = sanitizeTimetable(await client.fetchTerm(term));
178
+ const schedule = createTimetableSchedule(timetable, { weekOneMonday: weekOne });
168
179
  saveTimetableCache(key, timetable);
169
180
  saveCurrentPointer(key, weekOne);
170
181
  state = {
171
- mode: 'hub', key, term, weekOne, timetable,
172
- 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),
173
188
  };
174
189
  }
175
190
  catch (err) {
176
191
  if (isSessionExpired(err)) {
177
192
  createSessionStore().clear();
193
+ await releaseSession();
178
194
  goToLoginId(t().timetable.expiredRelogin);
179
195
  }
180
196
  else {
@@ -203,7 +219,6 @@ async function refreshFromNetwork(ctx) {
203
219
  }
204
220
  await goToPublic(ctx);
205
221
  }
206
- // best-effort: a cached hub already showed, keep it as-is on refresh failure.
207
222
  }
208
223
  }
209
224
  export const scheduleView = {
@@ -211,11 +226,15 @@ export const scheduleView = {
211
226
  title: t().timetable.menuEntry,
212
227
  async load(ctx) {
213
228
  const ptr = loadCurrentPointer();
214
- const cached = ptr ? loadTimetableCache(ptr.termKey) : null;
215
- 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 });
216
232
  state = {
217
- mode: 'hub', key: ptr.termKey, weekOne: ptr.weekOneMonday, timetable: cached,
218
- 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),
219
238
  };
220
239
  }
221
240
  else {
@@ -224,26 +243,33 @@ export const scheduleView = {
224
243
  ctx.rerender();
225
244
  await refreshFromNetwork(ctx);
226
245
  },
246
+ async dispose() {
247
+ await releaseSession();
248
+ },
227
249
  render(ctx) {
228
- // Sync every visible field's scroll window to the *current* terminal
229
- // size on every frame (not just construction time) — this is what
230
- // keeps a long list correctly windowed across a live resize.
231
250
  state.termField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
232
251
  return renderSchedule(state, new Date(), ctx.bodyRows, ctx.size.cols);
233
252
  },
234
253
  capturesInput() {
235
- 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';
236
260
  },
237
261
  footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
238
- 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';
239
265
  if (capturing)
240
266
  return captureFooterHint(cols);
241
- // These three are pure "read this, any key returns to the hub" drill-
242
- // downs (see handleKey below) — no field to move a cursor within or
243
- // open an item from, so the generic "move · open" hint would promise
244
- // keys that don't do that here.
245
- const passive = state.mode === 'loading' || state.mode === 'authenticating' || state.mode === 'error'
246
- || 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';
247
273
  return passive ? passiveFooterHint(tabCount, cols) : undefined;
248
274
  },
249
275
  handleBack(ctx) {
@@ -256,16 +282,16 @@ export const scheduleView = {
256
282
  return true;
257
283
  }
258
284
  if (state.mode === 'meetingDetail') {
259
- // Esc respects where the detail card was opened from -- from the
260
- // standalone 'week' mode, it steps back there, not all the way to hub.
261
285
  if (state.detailFrom === 'week') {
262
286
  state = { ...state, mode: 'week' };
263
287
  return true;
264
288
  }
265
289
  return returnToHub();
266
290
  }
267
- if (state.mode === 'week' || state.mode === 'unresolved' || state.mode === 'termPicker'
268
- || state.mode === 'termDensity') {
291
+ if (state.mode === 'week' ||
292
+ state.mode === 'unresolved' ||
293
+ state.mode === 'termPicker' ||
294
+ state.mode === 'termDensity') {
269
295
  return returnToHub();
270
296
  }
271
297
  return false;
@@ -288,7 +314,11 @@ export const scheduleView = {
288
314
  pendingId = result.submitted;
289
315
  state = {
290
316
  mode: 'needsLoginPassword',
291
- 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
+ }),
292
322
  };
293
323
  }
294
324
  return;
@@ -306,8 +336,16 @@ export const scheduleView = {
306
336
  ctx.rerender();
307
337
  void loginWithStudentPassword(pendingId, password)
308
338
  .then(async (s) => {
309
- createSessionStore().save(await s.snapshot());
310
- 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
+ }
311
349
  })
312
350
  .catch((err) => {
313
351
  goToLoginId(safeMessage(err));
@@ -324,7 +362,13 @@ export const scheduleView = {
324
362
  }
325
363
  if (result?.submitted !== undefined) {
326
364
  const trimmed = result.submitted.trim();
327
- 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
+ }
328
372
  const targetKey = state.key;
329
373
  const targetTerm = state.term;
330
374
  if (!valid || !targetKey || !targetTerm) {
@@ -344,15 +388,22 @@ export const scheduleView = {
344
388
  if (!tt || !hubKey || !hubWeekOne)
345
389
  return;
346
390
  {
347
- const cursor = state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods);
348
- 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));
349
395
  const nav = handleGridKey(key, cursor, tt, week);
350
396
  if (nav.kind === 'moveCursor') {
351
397
  state = { ...state, gridCursor: nav.cursor };
352
398
  return;
353
399
  }
354
400
  if (nav.kind === 'openDetail') {
355
- state = { ...state, mode: 'meetingDetail', detailMeeting: nav.meeting, detailFrom: 'hub' };
401
+ state = {
402
+ ...state,
403
+ mode: 'meetingDetail',
404
+ detailMeeting: nav.meeting,
405
+ detailFrom: 'hub',
406
+ };
356
407
  return;
357
408
  }
358
409
  }
@@ -375,19 +426,26 @@ export const scheduleView = {
375
426
  const options = relevantTerms(catalog).map((tm) => ({
376
427
  value: `${tm.academicYear}:${tm.semester}`,
377
428
  label: tm.academicYearLabel,
378
- hint: tm.current ? t().common.current : undefined,
429
+ ...(tm.current ? { hint: t().common.current } : {}),
379
430
  }));
380
- options.push({ value: '__back__', label: t().common.back, hint: undefined });
431
+ options.push({ value: '__back__', label: t().common.back });
381
432
  state = {
382
433
  ...state,
383
434
  mode: 'termPicker',
384
- 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
+ }),
385
440
  };
386
441
  return;
387
442
  }
388
443
  if (shortcut.key === 'e') {
389
444
  try {
390
- 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
+ });
391
449
  const out = `timetable-${hubKey}.ics`;
392
450
  writePrivateIcs(out, ics);
393
451
  state = { ...state, statusMessage: `${t().common.success}: ${path.resolve(out)}` };
@@ -400,9 +458,7 @@ export const scheduleView = {
400
458
  if (shortcut.key === 'x') {
401
459
  createSessionStore().clear();
402
460
  clearScheduleCache();
403
- void session?.close();
404
- session = null;
405
- client = null;
461
+ void releaseSession();
406
462
  void goToPublic(ctx);
407
463
  }
408
464
  return;
@@ -414,15 +470,22 @@ export const scheduleView = {
414
470
  returnToHub();
415
471
  return;
416
472
  }
417
- const cursor = state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods);
418
- 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));
419
477
  const nav = handleGridKey(key, cursor, tt, week);
420
478
  if (nav.kind === 'moveCursor') {
421
479
  state = { ...state, gridCursor: nav.cursor };
422
480
  return;
423
481
  }
424
482
  if (nav.kind === 'openDetail') {
425
- state = { ...state, mode: 'meetingDetail', detailMeeting: nav.meeting, detailFrom: 'week' };
483
+ state = {
484
+ ...state,
485
+ mode: 'meetingDetail',
486
+ detailMeeting: nav.meeting,
487
+ detailFrom: 'week',
488
+ };
426
489
  return;
427
490
  }
428
491
  returnToHub();
@@ -458,14 +521,19 @@ export const scheduleView = {
458
521
  mode: 'needsWeekOne',
459
522
  key: newTermKey,
460
523
  term,
461
- 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
+ }),
462
528
  };
463
529
  return;
464
530
  }
465
531
  void fetchAndShowHub(ctx, term, newTermKey, weekOne);
466
532
  return;
467
533
  }
468
- default:
534
+ case 'loading':
535
+ case 'authenticating':
536
+ case 'error':
469
537
  return;
470
538
  }
471
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 [];