@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
@@ -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,72 @@ 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
+ let lifecycleGeneration = 0;
25
+ const closingSessions = new WeakMap();
26
+ function isLifecycleActive(ctx, generation) {
27
+ return generation === lifecycleGeneration && ctx.signal?.aborted !== true;
28
+ }
29
+ async function closeSession(target) {
30
+ let closing = closingSessions.get(target);
31
+ if (!closing) {
32
+ closing = Promise.resolve()
33
+ .then(() => target.close())
34
+ .catch(() => undefined);
35
+ closingSessions.set(target, closing);
36
+ }
37
+ await closing;
38
+ }
39
+ async function releaseSession(target = session) {
40
+ if (!target)
41
+ return;
42
+ if (session === target) {
43
+ session = null;
44
+ client = null;
45
+ }
46
+ await closeSession(target);
47
+ }
23
48
  function isTimetableLike(value) {
24
- return !!value && typeof value === 'object'
25
- && Array.isArray(value.meetings)
26
- && Array.isArray(value.periods);
49
+ return (!!value &&
50
+ typeof value === 'object' &&
51
+ Array.isArray(value.meetings) &&
52
+ Array.isArray(value.periods));
53
+ }
54
+ function readCachedTimetable(value) {
55
+ if (!isTimetableLike(value))
56
+ return null;
57
+ try {
58
+ return sanitizeTimetable(value);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
27
63
  }
28
64
  function returnToHub() {
29
65
  const tt = state.timetable;
30
66
  const backKey = state.key;
31
67
  const backWeekOne = state.weekOne;
32
68
  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.
69
+ const schedule = createTimetableSchedule(tt, { weekOneMonday: backWeekOne });
37
70
  state = {
38
- mode: 'hub', key: backKey, term: state.term, weekOne: backWeekOne, timetable: tt,
39
- gridCursor: state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods),
71
+ mode: 'hub',
72
+ key: backKey,
73
+ ...(state.term ? { term: state.term } : {}),
74
+ weekOne: backWeekOne,
75
+ timetable: tt,
76
+ gridCursor: state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(new Date()), tt.periods),
40
77
  };
41
78
  return true;
42
79
  }
@@ -47,8 +84,11 @@ function goToLoginId(errorMessage) {
47
84
  setVimKeysActive(false);
48
85
  state = {
49
86
  mode: 'needsLoginId',
50
- errorMessage,
51
- idField: new TextField({ message: t().timetable.studentId, placeholder: t().timetable.studentIdHint }),
87
+ ...(errorMessage === undefined ? {} : { errorMessage }),
88
+ idField: new TextField({
89
+ message: t().timetable.studentId,
90
+ placeholder: t().timetable.studentIdHint,
91
+ }),
52
92
  };
53
93
  }
54
94
  function buildPublicField() {
@@ -59,67 +99,74 @@ function buildPublicField() {
59
99
  footer: trans.menu.hintMove,
60
100
  });
61
101
  }
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
102
  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
- async function goToPublic(ctx) {
103
+ async function goToPublic(ctx, generation = lifecycleGeneration) {
104
+ if (!isLifecycleActive(ctx, generation))
105
+ return;
70
106
  setVimKeysActive(true);
71
107
  state = { mode: 'public', publicField: buildPublicField() };
72
108
  ctx.rerender();
73
109
  try {
74
- const cal = await loadCalendarOrThrow();
110
+ const cal = await loadCalendarOrThrow(ctx.signal);
111
+ if (!isLifecycleActive(ctx, generation))
112
+ return;
75
113
  const now = new Date();
76
- const windowEvents = cal.inRange(new Date(now.getTime() - 400 * 86400000), new Date(now.getTime() + 400 * 86400000));
114
+ const windowEvents = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400));
77
115
  const publicWindow = currentAcademicWindow(windowEvents, now);
78
- const publicUpcoming = cal.upcoming({ days: 30 })
116
+ const publicUpcoming = cal
117
+ .upcoming({ days: 30 })
79
118
  .filter((e) => !isAcademicBreakEvent(e))
80
119
  .slice(0, PUBLIC_UPCOMING_FETCH_CAP)
81
120
  .map(toDisplayEvent);
82
121
  state = { ...state, publicWindow, publicUpcoming };
83
122
  }
84
123
  catch {
124
+ if (!isLifecycleActive(ctx, generation))
125
+ return;
85
126
  state = { ...state, publicWindow: null };
86
127
  }
87
- ctx.rerender();
128
+ if (isLifecycleActive(ctx, generation))
129
+ ctx.rerender();
88
130
  }
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
- async function tryInferWeekOne() {
131
+ async function tryInferWeekOne(ctx, generation) {
93
132
  try {
94
- const cal = await loadCalendarOrThrow();
133
+ const cal = await loadCalendarOrThrow(ctx.signal);
134
+ if (!isLifecycleActive(ctx, generation))
135
+ return null;
95
136
  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));
137
+ const events = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400));
102
138
  return inferWeekOneMonday(events, now);
103
139
  }
104
140
  catch {
105
141
  return null;
106
142
  }
107
143
  }
108
- 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).
144
+ async function afterAuthenticated(ctx, s, generation) {
145
+ if (!isLifecycleActive(ctx, generation)) {
146
+ await closeSession(s);
147
+ return;
148
+ }
113
149
  const hadCache = state.mode === 'hub';
150
+ if (session && session !== s)
151
+ await releaseSession(session);
152
+ if (!isLifecycleActive(ctx, generation)) {
153
+ await closeSession(s);
154
+ return;
155
+ }
114
156
  session = s;
115
157
  client = createNbtTimetableClient(s.timetableTransport, { baseUrl: JWXT_ORIGIN });
116
158
  try {
117
- catalog = await client.listTerms();
159
+ const nextCatalog = (await client.listTerms(ctx.signal === undefined ? {} : { signal: ctx.signal })).map(sanitizeAcademicTerm);
160
+ if (!isLifecycleActive(ctx, generation))
161
+ return;
162
+ catalog = nextCatalog;
118
163
  const term = resolveTerm(catalog);
119
164
  const key = termKey(term);
120
165
  let weekOne = loadWeekOne(key);
121
166
  if (!weekOne) {
122
- weekOne = await tryInferWeekOne();
167
+ weekOne = await tryInferWeekOne(ctx, generation);
168
+ if (!isLifecycleActive(ctx, generation))
169
+ return;
123
170
  if (weekOne)
124
171
  saveWeekOne(key, weekOne);
125
172
  }
@@ -130,111 +177,156 @@ async function afterAuthenticated(ctx, s) {
130
177
  key,
131
178
  term,
132
179
  errorMessage: t().timetable.weekOneAutoFailed,
133
- weekOneField: new TextField({ message: t().timetable.weekOne, placeholder: t().timetable.weekOneHint }),
180
+ weekOneField: new TextField({
181
+ message: t().timetable.weekOne,
182
+ placeholder: t().timetable.weekOneHint,
183
+ }),
134
184
  };
135
185
  ctx.rerender();
136
186
  return;
137
187
  }
138
- await fetchAndShowHub(ctx, term, key, weekOne);
188
+ await fetchAndShowHub(ctx, term, key, weekOne, generation);
139
189
  }
140
190
  catch (err) {
191
+ if (!isLifecycleActive(ctx, generation))
192
+ return;
141
193
  if (isSessionExpired(err)) {
142
194
  createSessionStore().clear();
195
+ await releaseSession(s);
143
196
  if (!hadCache)
144
197
  goToLoginId(t().timetable.expiredRelogin);
145
198
  }
146
- else if (!hadCache) {
147
- state = { mode: 'error', errorMessage: safeMessage(err) };
199
+ else {
200
+ await releaseSession(s);
201
+ if (!hadCache)
202
+ state = { mode: 'error', errorMessage: safeMessage(err) };
148
203
  }
149
204
  ctx.rerender();
150
205
  }
151
206
  }
152
- async function fetchAndShowHub(ctx, term, key, weekOne) {
153
- if (!client)
207
+ async function fetchAndShowHub(ctx, term, key, weekOne, generation = lifecycleGeneration) {
208
+ if (!client || !isLifecycleActive(ctx, generation))
154
209
  return;
155
210
  state = { mode: 'loading', statusMessage: t().calendar.loading };
156
211
  ctx.rerender();
157
212
  try {
158
- const timetable = await client.fetchTerm(term);
213
+ const timetable = sanitizeTimetable(await client.fetchTerm(term, ctx.signal === undefined ? {} : { signal: ctx.signal }));
214
+ if (!isLifecycleActive(ctx, generation))
215
+ return;
216
+ const schedule = createTimetableSchedule(timetable, { weekOneMonday: weekOne });
159
217
  saveTimetableCache(key, timetable);
160
218
  saveCurrentPointer(key, weekOne);
161
219
  state = {
162
- mode: 'hub', key, term, weekOne, timetable,
163
- gridCursor: defaultGridCursor(campusWeekday(new Date()), timetable.periods),
220
+ mode: 'hub',
221
+ key,
222
+ term,
223
+ weekOne,
224
+ timetable,
225
+ gridCursor: defaultGridCursor(schedule.weekdayAt(new Date()), timetable.periods),
164
226
  };
165
227
  }
166
228
  catch (err) {
229
+ if (!isLifecycleActive(ctx, generation))
230
+ return;
167
231
  if (isSessionExpired(err)) {
168
232
  createSessionStore().clear();
233
+ await releaseSession();
169
234
  goToLoginId(t().timetable.expiredRelogin);
170
235
  }
171
236
  else {
172
237
  state = { mode: 'error', errorMessage: safeMessage(err) };
173
238
  }
174
239
  }
175
- ctx.rerender();
240
+ if (isLifecycleActive(ctx, generation))
241
+ ctx.rerender();
176
242
  }
177
- async function refreshFromNetwork(ctx) {
243
+ async function refreshFromNetwork(ctx, generation) {
244
+ if (!isLifecycleActive(ctx, generation))
245
+ return;
178
246
  const hadCache = state.mode === 'hub';
179
247
  try {
180
248
  const store = createSessionStore();
181
249
  const persisted = store.load();
182
250
  if (!persisted) {
183
251
  if (!hadCache)
184
- await goToPublic(ctx);
252
+ await goToPublic(ctx, generation);
185
253
  return;
186
254
  }
187
255
  const restored = await restoreNbtSession(persisted);
188
- await afterAuthenticated(ctx, restored);
256
+ if (!isLifecycleActive(ctx, generation)) {
257
+ await closeSession(restored);
258
+ return;
259
+ }
260
+ await afterAuthenticated(ctx, restored, generation);
189
261
  }
190
262
  catch (err) {
263
+ if (!isLifecycleActive(ctx, generation))
264
+ return;
191
265
  if (!hadCache) {
192
266
  if (err instanceof AuthError && isSessionExpired(err)) {
193
267
  createSessionStore().clear();
194
268
  }
195
- await goToPublic(ctx);
269
+ await goToPublic(ctx, generation);
196
270
  }
197
- // best-effort: a cached hub already showed, keep it as-is on refresh failure.
198
271
  }
199
272
  }
200
273
  export const scheduleView = {
201
274
  id: 'schedule',
202
275
  title: t().timetable.menuEntry,
203
276
  async load(ctx) {
277
+ const generation = ++lifecycleGeneration;
278
+ if (!isLifecycleActive(ctx, generation))
279
+ return;
204
280
  const ptr = loadCurrentPointer();
205
- const cached = ptr ? loadTimetableCache(ptr.termKey) : null;
206
- if (ptr && isTimetableLike(cached)) {
281
+ const cached = readCachedTimetable(ptr ? loadTimetableCache(ptr.termKey) : null);
282
+ if (ptr && cached) {
283
+ const schedule = createTimetableSchedule(cached, { weekOneMonday: ptr.weekOneMonday });
207
284
  state = {
208
- mode: 'hub', key: ptr.termKey, weekOne: ptr.weekOneMonday, timetable: cached,
209
- gridCursor: defaultGridCursor(campusWeekday(new Date()), cached.periods),
285
+ mode: 'hub',
286
+ key: ptr.termKey,
287
+ weekOne: ptr.weekOneMonday,
288
+ timetable: cached,
289
+ gridCursor: defaultGridCursor(schedule.weekdayAt(new Date()), cached.periods),
210
290
  };
211
291
  }
212
292
  else {
213
293
  state = { mode: 'loading' };
214
294
  }
215
295
  ctx.rerender();
216
- await refreshFromNetwork(ctx);
296
+ await refreshFromNetwork(ctx, generation);
297
+ },
298
+ async dispose() {
299
+ lifecycleGeneration += 1;
300
+ setVimKeysActive(true);
301
+ await releaseSession();
217
302
  },
218
303
  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
304
  state.termField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
223
305
  return renderSchedule(state, new Date(), ctx.bodyRows, ctx.size.cols);
224
306
  },
307
+ isBusy() {
308
+ return state.mode === 'loading';
309
+ },
225
310
  capturesInput() {
226
- return state.mode === 'needsLoginId' || state.mode === 'needsLoginPassword' || state.mode === 'needsWeekOne';
311
+ return (state.mode === 'needsLoginId' ||
312
+ state.mode === 'needsLoginPassword' ||
313
+ state.mode === 'needsWeekOne');
314
+ },
315
+ capturesPageKeys() {
316
+ return state.mode === 'public' || state.mode === 'termPicker';
227
317
  },
228
318
  footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
229
- const capturing = state.mode === 'needsLoginId' || state.mode === 'needsLoginPassword' || state.mode === 'needsWeekOne';
319
+ const capturing = state.mode === 'needsLoginId' ||
320
+ state.mode === 'needsLoginPassword' ||
321
+ state.mode === 'needsWeekOne';
230
322
  if (capturing)
231
323
  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';
324
+ const passive = state.mode === 'loading' ||
325
+ state.mode === 'authenticating' ||
326
+ state.mode === 'error' ||
327
+ state.mode === 'meetingDetail' ||
328
+ state.mode === 'unresolved' ||
329
+ state.mode === 'termDensity';
238
330
  return passive ? passiveFooterHint(tabCount, cols) : undefined;
239
331
  },
240
332
  handleBack(ctx) {
@@ -247,16 +339,16 @@ export const scheduleView = {
247
339
  return true;
248
340
  }
249
341
  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
342
  if (state.detailFrom === 'week') {
253
343
  state = { ...state, mode: 'week' };
254
344
  return true;
255
345
  }
256
346
  return returnToHub();
257
347
  }
258
- if (state.mode === 'week' || state.mode === 'unresolved' || state.mode === 'termPicker'
259
- || state.mode === 'termDensity') {
348
+ if (state.mode === 'week' ||
349
+ state.mode === 'unresolved' ||
350
+ state.mode === 'termPicker' ||
351
+ state.mode === 'termDensity') {
260
352
  return returnToHub();
261
353
  }
262
354
  return false;
@@ -279,7 +371,11 @@ export const scheduleView = {
279
371
  pendingId = result.submitted;
280
372
  state = {
281
373
  mode: 'needsLoginPassword',
282
- passwordField: new TextField({ message: t().timetable.password, placeholder: t().timetable.passwordHint, secret: true }),
374
+ passwordField: new TextField({
375
+ message: t().timetable.password,
376
+ placeholder: t().timetable.passwordHint,
377
+ secret: true,
378
+ }),
283
379
  };
284
380
  }
285
381
  return;
@@ -292,15 +388,33 @@ export const scheduleView = {
292
388
  }
293
389
  if (result?.submitted !== undefined) {
294
390
  const password = result.submitted;
391
+ const generation = lifecycleGeneration;
295
392
  setVimKeysActive(true);
296
393
  state = { mode: 'authenticating', statusMessage: t().timetable.loginWillSave };
297
394
  ctx.rerender();
298
- void loginWithStudentPassword(pendingId, password)
395
+ void loginWithStudentPassword(pendingId, password, ctx.signal === undefined ? {} : { signal: ctx.signal })
299
396
  .then(async (s) => {
300
- createSessionStore().save(await s.snapshot());
301
- await afterAuthenticated(ctx, s);
397
+ try {
398
+ if (!isLifecycleActive(ctx, generation)) {
399
+ await closeSession(s);
400
+ return;
401
+ }
402
+ const snapshot = await s.snapshot();
403
+ if (!isLifecycleActive(ctx, generation)) {
404
+ await closeSession(s);
405
+ return;
406
+ }
407
+ createSessionStore().save(snapshot);
408
+ await afterAuthenticated(ctx, s, generation);
409
+ }
410
+ catch (error) {
411
+ await closeSession(s);
412
+ throw error;
413
+ }
302
414
  })
303
415
  .catch((err) => {
416
+ if (!isLifecycleActive(ctx, generation))
417
+ return;
304
418
  goToLoginId(safeMessage(err));
305
419
  ctx.rerender();
306
420
  });
@@ -315,7 +429,13 @@ export const scheduleView = {
315
429
  }
316
430
  if (result?.submitted !== undefined) {
317
431
  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());
432
+ let valid = true;
433
+ try {
434
+ parseLocalMonday(trimmed);
435
+ }
436
+ catch {
437
+ valid = false;
438
+ }
319
439
  const targetKey = state.key;
320
440
  const targetTerm = state.term;
321
441
  if (!valid || !targetKey || !targetTerm) {
@@ -324,7 +444,7 @@ export const scheduleView = {
324
444
  }
325
445
  saveWeekOne(targetKey, trimmed);
326
446
  setVimKeysActive(true);
327
- void fetchAndShowHub(ctx, targetTerm, targetKey, trimmed);
447
+ void fetchAndShowHub(ctx, targetTerm, targetKey, trimmed, lifecycleGeneration);
328
448
  }
329
449
  return;
330
450
  }
@@ -335,15 +455,22 @@ export const scheduleView = {
335
455
  if (!tt || !hubKey || !hubWeekOne)
336
456
  return;
337
457
  {
338
- const cursor = state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods);
339
- const week = Math.max(1, currentWeekNumber(hubWeekOne, new Date()));
458
+ const schedule = createTimetableSchedule(tt, { weekOneMonday: hubWeekOne });
459
+ const now = new Date();
460
+ const cursor = state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(now), tt.periods);
461
+ const week = Math.max(1, schedule.weekAt(now));
340
462
  const nav = handleGridKey(key, cursor, tt, week);
341
463
  if (nav.kind === 'moveCursor') {
342
464
  state = { ...state, gridCursor: nav.cursor };
343
465
  return;
344
466
  }
345
467
  if (nav.kind === 'openDetail') {
346
- state = { ...state, mode: 'meetingDetail', detailMeeting: nav.meeting, detailFrom: 'hub' };
468
+ state = {
469
+ ...state,
470
+ mode: 'meetingDetail',
471
+ detailMeeting: nav.meeting,
472
+ detailFrom: 'hub',
473
+ };
347
474
  return;
348
475
  }
349
476
  }
@@ -366,19 +493,26 @@ export const scheduleView = {
366
493
  const options = relevantTerms(catalog).map((tm) => ({
367
494
  value: `${tm.academicYear}:${tm.semester}`,
368
495
  label: tm.academicYearLabel,
369
- hint: tm.current ? t().common.current : undefined,
496
+ ...(tm.current ? { hint: t().common.current } : {}),
370
497
  }));
371
- options.push({ value: '__back__', label: t().common.back, hint: undefined });
498
+ options.push({ value: '__back__', label: t().common.back });
372
499
  state = {
373
500
  ...state,
374
501
  mode: 'termPicker',
375
- termField: new ListField({ title: t().timetable.hubSwitchTerm, options, maxVisible: computeMaxVisible(ctx.bodyRows) }),
502
+ termField: new ListField({
503
+ title: t().timetable.hubSwitchTerm,
504
+ options,
505
+ maxVisible: computeMaxVisible(ctx.bodyRows),
506
+ }),
376
507
  };
377
508
  return;
378
509
  }
379
510
  if (shortcut.key === 'e') {
380
511
  try {
381
- const ics = timetableToIcs(tt, { weekOneMonday: hubWeekOne, calendarName: `NBT ${state.term?.academicYearLabel ?? ''}` });
512
+ const ics = timetableToIcs(tt, {
513
+ weekOneMonday: hubWeekOne,
514
+ calendarName: `NBT ${state.term?.academicYearLabel ?? ''}`,
515
+ });
382
516
  const out = `timetable-${hubKey}.ics`;
383
517
  writePrivateIcs(out, ics);
384
518
  state = { ...state, statusMessage: `${t().common.success}: ${path.resolve(out)}` };
@@ -389,12 +523,11 @@ export const scheduleView = {
389
523
  return;
390
524
  }
391
525
  if (shortcut.key === 'x') {
526
+ const generation = ++lifecycleGeneration;
392
527
  createSessionStore().clear();
393
528
  clearScheduleCache();
394
- void session?.close();
395
- session = null;
396
- client = null;
397
- void goToPublic(ctx);
529
+ void releaseSession();
530
+ void goToPublic(ctx, generation);
398
531
  }
399
532
  return;
400
533
  }
@@ -405,15 +538,22 @@ export const scheduleView = {
405
538
  returnToHub();
406
539
  return;
407
540
  }
408
- const cursor = state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods);
409
- const week = Math.max(1, currentWeekNumber(weekOne, new Date()));
541
+ const schedule = createTimetableSchedule(tt, { weekOneMonday: weekOne });
542
+ const now = new Date();
543
+ const cursor = state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(now), tt.periods);
544
+ const week = Math.max(1, schedule.weekAt(now));
410
545
  const nav = handleGridKey(key, cursor, tt, week);
411
546
  if (nav.kind === 'moveCursor') {
412
547
  state = { ...state, gridCursor: nav.cursor };
413
548
  return;
414
549
  }
415
550
  if (nav.kind === 'openDetail') {
416
- state = { ...state, mode: 'meetingDetail', detailMeeting: nav.meeting, detailFrom: 'week' };
551
+ state = {
552
+ ...state,
553
+ mode: 'meetingDetail',
554
+ detailMeeting: nav.meeting,
555
+ detailFrom: 'week',
556
+ };
417
557
  return;
418
558
  }
419
559
  returnToHub();
@@ -449,14 +589,19 @@ export const scheduleView = {
449
589
  mode: 'needsWeekOne',
450
590
  key: newTermKey,
451
591
  term,
452
- weekOneField: new TextField({ message: t().timetable.weekOne, placeholder: t().timetable.weekOneHint }),
592
+ weekOneField: new TextField({
593
+ message: t().timetable.weekOne,
594
+ placeholder: t().timetable.weekOneHint,
595
+ }),
453
596
  };
454
597
  return;
455
598
  }
456
- void fetchAndShowHub(ctx, term, newTermKey, weekOne);
599
+ void fetchAndShowHub(ctx, term, newTermKey, weekOne, lifecycleGeneration);
457
600
  return;
458
601
  }
459
- default:
602
+ case 'loading':
603
+ case 'authenticating':
604
+ case 'error':
460
605
  return;
461
606
  }
462
607
  },
@@ -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 [];