@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.
- package/README.md +27 -58
- package/SECURITY.md +16 -45
- package/dist/app/app.js +53 -55
- package/dist/app/chrome.js +67 -50
- package/dist/app/fields/list-field.js +12 -25
- package/dist/app/fields/text-field.js +3 -8
- package/dist/app/frame.js +2 -21
- package/dist/app/keys.js +10 -2
- package/dist/app/views/docs-render.js +31 -24
- package/dist/app/views/docs.js +211 -60
- package/dist/app/views/events-render.js +19 -26
- package/dist/app/views/events.js +44 -31
- package/dist/app/views/home.js +28 -30
- package/dist/app/views/schedule-grid-cursor.js +9 -18
- package/dist/app/views/schedule-render.js +47 -71
- package/dist/app/views/schedule.js +158 -81
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +92 -17
- package/dist/auth/cookie-transport.js +31 -32
- package/dist/auth/errors.js +3 -1
- package/dist/auth/nbt-auth.js +42 -25
- package/dist/auth/session-store.js +17 -9
- package/dist/config/data.js +9 -11
- package/dist/config/preferences.js +14 -7
- package/dist/core/calendar-day.js +37 -0
- package/dist/core/capabilities.js +6 -3
- package/dist/core/components/confirm.js +9 -8
- package/dist/core/components/menu.js +41 -16
- package/dist/core/components/messages.js +12 -4
- package/dist/core/components/painter.js +3 -1
- package/dist/core/components/spinner.js +17 -6
- package/dist/core/components/text-input.js +24 -18
- package/dist/core/icons.js +2 -2
- package/dist/core/logo.js +23 -5
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +182 -69
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -13
- package/dist/core/vim-keys.js +9 -15
- package/dist/features/about.js +23 -0
- package/dist/features/calendar-heatmap.js +16 -40
- package/dist/features/calendar-query.js +1 -2
- package/dist/features/calendar.js +12 -185
- package/dist/features/docs.js +436 -275
- package/dist/features/schedule-render.js +65 -101
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -213
- package/dist/features/status.js +44 -56
- package/dist/features/student-timetable.js +73 -95
- package/dist/features/theme.js +6 -2
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +9 -27
- package/dist/i18n/index.js +83 -19
- package/dist/i18n/locales/en.json +1 -1
- package/dist/i18n/locales/zh.json +1 -1
- package/dist/index.js +83 -58
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +27 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -68
- package/dist/features/links.js +0 -36
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -127
- 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 {
|
|
6
|
+
import { wrapAnsiWithIndent } from '../../core/text.js';
|
|
7
7
|
function wrappedIndentedLines(label, cols, style) {
|
|
8
|
-
|
|
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, {
|
|
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, {
|
|
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
|
|
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
|
|
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)
|
|
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) ?? [];
|
package/dist/app/views/events.js
CHANGED
|
@@ -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
|
-
...
|
|
33
|
-
value: String(
|
|
34
|
-
label: `${
|
|
35
|
-
hint:
|
|
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
|
|
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 = {
|
|
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
|
|
62
|
+
...(nextEvent === undefined ? {} : { nextEvent: toDisplayEvent(nextEvent) }),
|
|
60
63
|
heatmapBuckets: calendar
|
|
61
|
-
? calendar.heatmap({
|
|
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' ||
|
|
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 = {
|
|
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 = {
|
|
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
|
|
225
|
+
if (!query) {
|
|
214
226
|
goToHub();
|
|
215
227
|
return;
|
|
216
228
|
}
|
|
217
229
|
const now = new Date();
|
|
218
|
-
const pool = calendar.inRange(now,
|
|
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
|
-
|
|
236
|
+
case 'loading':
|
|
237
|
+
case 'error':
|
|
225
238
|
return;
|
|
226
239
|
}
|
|
227
240
|
},
|
package/dist/app/views/home.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
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);
|
|
@@ -39,7 +32,9 @@ function renderDayProgress(now, cols) {
|
|
|
39
32
|
const indent = visualWidth(space.indent) + 2 + visualWidth(percentage) + 1 <= width ? space.indent : '';
|
|
40
33
|
const gap = visualWidth(indent) + 2 + visualWidth(percentage) + 1 <= width
|
|
41
34
|
? ' '
|
|
42
|
-
: visualWidth(indent) + 1 + visualWidth(percentage) + 1 <= width
|
|
35
|
+
: visualWidth(indent) + 1 + visualWidth(percentage) + 1 <= width
|
|
36
|
+
? ' '
|
|
37
|
+
: '';
|
|
43
38
|
const barWidth = Math.max(0, Math.min(DAY_PROGRESS_WIDTH, width - visualWidth(indent) - visualWidth(gap) - visualWidth(percentage)));
|
|
44
39
|
const filled = Math.round(fraction * barWidth);
|
|
45
40
|
const filledChar = glyph.barFilled();
|
|
@@ -57,17 +52,21 @@ function renderWeekAheadGrid(classDays, eventDays, cols) {
|
|
|
57
52
|
const days = [1, 2, 3, 4, 5, 6, 7];
|
|
58
53
|
const dayLabels = days.map((wd) => type.hint(weekdayShortLabel(wd))).join(' ');
|
|
59
54
|
const headerLine = `${space.indent}${padEndV('', rowLabelW)}${dayLabels}`;
|
|
60
|
-
const classCells = days
|
|
55
|
+
const classCells = days
|
|
56
|
+
.map((wd) => {
|
|
61
57
|
const isWeekend = wd === 6 || wd === 7;
|
|
62
|
-
const glyphChar = isWeekend ? weekendChar :
|
|
58
|
+
const glyphChar = isWeekend ? weekendChar : classDays[wd - 1] ? hasClassChar : freeChar;
|
|
63
59
|
return type.body(glyphChar);
|
|
64
|
-
})
|
|
60
|
+
})
|
|
61
|
+
.join(' ');
|
|
65
62
|
const classLine = `${space.indent}${type.hint(padEndV(trans.timetable.weekAheadClasses, rowLabelW))}${classCells}`;
|
|
66
|
-
const eventCells = days
|
|
63
|
+
const eventCells = days
|
|
64
|
+
.map((wd) => {
|
|
67
65
|
if (!eventDays)
|
|
68
66
|
return blankCell;
|
|
69
67
|
return type.body(eventDays[wd - 1] ? hasClassChar : freeChar);
|
|
70
|
-
})
|
|
68
|
+
})
|
|
69
|
+
.join(' ');
|
|
71
70
|
const eventLine = `${space.indent}${type.hint(padEndV(trans.menu.events, rowLabelW))}${eventCells}`;
|
|
72
71
|
const legend = `${space.indent}${type.hint(`${hasClassChar} ${trans.timetable.weekAheadBusy} ${freeChar} ${trans.timetable.weekAheadFree} ${weekendChar} ${trans.timetable.weekAheadNone}`)}`;
|
|
73
72
|
const wideLines = [headerLine, classLine, eventLine, legend];
|
|
@@ -76,12 +75,8 @@ function renderWeekAheadGrid(classDays, eventDays, cols) {
|
|
|
76
75
|
return wideLines.join('\n');
|
|
77
76
|
const compactHeading = wrappedIndentedLines(`${trans.timetable.weekAheadClasses} / ${trans.menu.events}`, cols, type.hint);
|
|
78
77
|
const compactDays = days.flatMap((wd) => {
|
|
79
|
-
const classCell = wd === 6 || wd === 7
|
|
80
|
-
|
|
81
|
-
: classDays[wd - 1] ? hasClassChar : freeChar;
|
|
82
|
-
const eventCell = eventDays
|
|
83
|
-
? eventDays[wd - 1] ? hasClassChar : freeChar
|
|
84
|
-
: blankCell;
|
|
78
|
+
const classCell = wd === 6 || wd === 7 ? weekendChar : classDays[wd - 1] ? hasClassChar : freeChar;
|
|
79
|
+
const eventCell = eventDays ? (eventDays[wd - 1] ? hasClassChar : freeChar) : blankCell;
|
|
85
80
|
const row = `${type.hint(weekdayShortLabel(wd))} ${type.body(classCell)} ${type.body(eventCell)}`;
|
|
86
81
|
return wrappedIndentedLines(row, cols, (value) => value);
|
|
87
82
|
});
|
|
@@ -159,7 +154,7 @@ export const homeView = {
|
|
|
159
154
|
nextClassLine: peekNextClassLine(),
|
|
160
155
|
todayLines: peekTodayLines(),
|
|
161
156
|
unresolvedCount: peekUnresolvedCount(),
|
|
162
|
-
|
|
157
|
+
...(weekAheadInfo ? { weekAhead: { classDays: weekAheadInfo.classDays } } : {}),
|
|
163
158
|
};
|
|
164
159
|
}
|
|
165
160
|
catch {
|
|
@@ -174,12 +169,15 @@ export const homeView = {
|
|
|
174
169
|
const eventLines = items.map((e) => renderEventBrief(e, now));
|
|
175
170
|
let weekAhead = data.weekAhead;
|
|
176
171
|
if (weekAheadInfo) {
|
|
177
|
-
const weekEnd =
|
|
172
|
+
const weekEnd = addLocalDays(weekAheadInfo.weekStartDate, 7);
|
|
178
173
|
const weekEvents = cal.inRange(weekAheadInfo.weekStartDate, weekEnd);
|
|
179
|
-
const daySet = new Set(weekEvents.map((
|
|
180
|
-
weekAhead = {
|
|
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
|
+
};
|
|
181
179
|
}
|
|
182
|
-
data = { ...data, eventLines, weekAhead };
|
|
180
|
+
data = weekAhead ? { ...data, eventLines, weekAhead } : { ...data, eventLines };
|
|
183
181
|
}
|
|
184
182
|
catch {
|
|
185
183
|
data = { ...data, eventsLoadFailed: true };
|
|
@@ -1,40 +1,31 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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' };
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { createTimetableSchedule, } from '@nbtca/nbtcal/timetable';
|
|
1
2
|
import { c, type, space, glyph } from '../../core/theme.js';
|
|
2
3
|
import { pickIcon } from '../../core/icons.js';
|
|
3
4
|
import { t, fmt } from '../../i18n/index.js';
|
|
4
5
|
import { renderListFieldWithContext } from '../fields/list-field.js';
|
|
5
|
-
import { currentWeekNumber, campusWeekday, meetingsOnDay, nextMeeting } from '../../features/schedule-query.js';
|
|
6
6
|
import { renderNextClassBanner, renderWeekGrid, renderUnresolvedItems, renderTodayTimeline, weekdayShortLabel, renderTermDensity, renderMeetingDetail, renderDayTimeline, renderDaySwitcher, } from '../../features/schedule-render.js';
|
|
7
7
|
import { renderEventBrief } from '../../features/calendar.js';
|
|
8
|
-
import { visualWidth,
|
|
8
|
+
import { sanitizeTerminalLine, visualWidth, wrapAnsiWithIndent } from '../../core/text.js';
|
|
9
|
+
import { localDayDifference, parseLocalDate, parseLocalMonday } from '../../core/calendar-day.js';
|
|
9
10
|
function heading(label) {
|
|
10
11
|
return `${space.indent}${type.heading(label)}`;
|
|
11
12
|
}
|
|
@@ -13,10 +14,7 @@ function hint(label) {
|
|
|
13
14
|
return `${space.indent}${type.hint(label)}`;
|
|
14
15
|
}
|
|
15
16
|
function wrappedIndentedLines(label, cols, style) {
|
|
16
|
-
|
|
17
|
-
const indent = visualWidth(space.indent) < width ? space.indent : '';
|
|
18
|
-
const contentWidth = Math.max(1, width - visualWidth(indent));
|
|
19
|
-
return wrapAnsiToVisualWidth(style(label), contentWidth).map((part) => `${indent}${part}`);
|
|
17
|
+
return wrapAnsiWithIndent(style(label), cols, space.indent);
|
|
20
18
|
}
|
|
21
19
|
function headingLines(label, cols) {
|
|
22
20
|
return wrappedIndentedLines(label, cols, type.heading);
|
|
@@ -51,8 +49,12 @@ function renderShortcutLines(shortcuts, cols, compact = false) {
|
|
|
51
49
|
const available = Math.max(1, cols - visualWidth(space.indent));
|
|
52
50
|
const parts = shortcuts.map((shortcut) => {
|
|
53
51
|
const text = compact
|
|
54
|
-
? shortcut.showKey === false
|
|
55
|
-
|
|
52
|
+
? shortcut.showKey === false
|
|
53
|
+
? `[${shortcut.key}] ${shortcut.label}`
|
|
54
|
+
: `[${shortcut.key}]`
|
|
55
|
+
: shortcut.showKey === false
|
|
56
|
+
? `[${shortcut.label}]`
|
|
57
|
+
: `[${shortcut.key}] ${shortcut.label}`;
|
|
56
58
|
return shortcut.warn ? c.warn(text) : type.hint(text);
|
|
57
59
|
});
|
|
58
60
|
const lines = [];
|
|
@@ -76,20 +78,19 @@ function hubPreGridLines(state, now, cols) {
|
|
|
76
78
|
const tt = state.timetable;
|
|
77
79
|
if (!tt || !state.weekOne)
|
|
78
80
|
return null;
|
|
79
|
-
const
|
|
81
|
+
const schedule = createTimetableSchedule(tt, { weekOneMonday: state.weekOne });
|
|
82
|
+
const week = schedule.weekAt(now);
|
|
80
83
|
const lines = [];
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
let next = null;
|
|
85
|
+
try {
|
|
86
|
+
next = schedule.next(now);
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
const banner = renderNextClassBanner(next, now, cols);
|
|
90
|
+
lines.push(...(banner ? [banner] : hintLines(trans.timetable.noNextClass, cols)));
|
|
83
91
|
lines.push('');
|
|
84
|
-
const todayWd =
|
|
92
|
+
const todayWd = schedule.weekdayAt(now);
|
|
85
93
|
if (week < 1) {
|
|
86
|
-
// weekOne can be a *future* date -- auto-inferred while on break, it
|
|
87
|
-
// deliberately points at the upcoming term (see academic-calendar.ts)
|
|
88
|
-
// so it's ready the moment classes start. There is no "today" to show
|
|
89
|
-
// yet, but the timetable's real week-1 data is already fetched -- show
|
|
90
|
-
// it as an explicit preview rather than showing no grid at all
|
|
91
|
-
// regardless of terminal height. The "Week 1 preview" heading keeps it
|
|
92
|
-
// unambiguous that this isn't "happening right now".
|
|
93
94
|
lines.push(heading(trans.timetable.termNotStarted));
|
|
94
95
|
lines.push(hint(fmt(trans.timetable.termStartsIn, {
|
|
95
96
|
date: state.weekOne,
|
|
@@ -99,49 +100,32 @@ function hubPreGridLines(state, now, cols) {
|
|
|
99
100
|
lines.push(heading(trans.timetable.termPreviewWeek));
|
|
100
101
|
return { inlineLines: lines, fallbackLines: [...lines], week: 1, tt };
|
|
101
102
|
}
|
|
102
|
-
const today = meetingsOnDay(
|
|
103
|
+
const today = schedule.meetingsOnDay(week, todayWd);
|
|
103
104
|
const weekHeading = heading(trans.timetable.hubWeek);
|
|
104
105
|
const inlineLines = [
|
|
105
106
|
...lines,
|
|
106
|
-
heading(fmt(trans.timetable.todayHeading, {
|
|
107
|
+
heading(fmt(trans.timetable.todayHeading, {
|
|
108
|
+
weekday: weekdayShortLabel(todayWd),
|
|
109
|
+
week: String(week),
|
|
110
|
+
})),
|
|
107
111
|
...renderTodayTimeline(today, tt.periods, now, cols).split('\n'),
|
|
108
112
|
weekHeading,
|
|
109
113
|
];
|
|
110
114
|
return { inlineLines, fallbackLines: [...lines, weekHeading], week, tt };
|
|
111
115
|
}
|
|
112
|
-
// Below this width, even an all-empty grid's own per-column floor (3, plus
|
|
113
|
-
// row-head and separator overhead) leaves each of the 7 columns too cramped
|
|
114
|
-
// to show real content -- a technically-fitting but practically unreadable
|
|
115
|
-
// grid isn't better than the single-day view, so width gates the decision
|
|
116
|
-
// just as much as height does.
|
|
117
116
|
const MIN_GRID_COLS = 100;
|
|
118
|
-
/** The one place that decides "does the real week grid fit inline (plus a
|
|
119
|
-
* floor reserved for the shortcut bar), or does the hub fall back to the
|
|
120
|
-
* single-day view." Both branches represent the exact same gridCursor, just
|
|
121
|
-
* rendered differently, so unlike the old non-interactive strip fallback
|
|
122
|
-
* this decision no longer needs to be exposed to key handling -- arrow
|
|
123
|
-
* keys/Enter are always meaningful in hub mode regardless of which branch is
|
|
124
|
-
* currently on screen. */
|
|
125
117
|
function gridFitsInline(precedingLineCount, tt, week, now, bodyRows, cols, cursor, reservedRows) {
|
|
126
118
|
if (cols < MIN_GRID_COLS)
|
|
127
119
|
return false;
|
|
128
|
-
const gridLines = renderWeekGrid(tt
|
|
120
|
+
const gridLines = renderWeekGrid(tt, week, now, cols, cursor).split('\n');
|
|
129
121
|
return precedingLineCount + gridLines.length <= bodyRows - reservedRows;
|
|
130
122
|
}
|
|
131
|
-
/** Renders the full weekday x period grid if it (plus a floor reserved for
|
|
132
|
-
* the shortcut bar) fits within bodyRows and cols, otherwise a single day's
|
|
133
|
-
* detailed timeline -- the day the cursor's own weekday points at (today, by
|
|
134
|
-
* default). A large terminal genuinely has no excuse to not show the whole
|
|
135
|
-
* week; a small one is better served by one day shown properly than seven
|
|
136
|
-
* days crammed into unreadable slivers. Shared by the "this week" and "term
|
|
137
|
-
* hasn't started yet, preview week 1" branches of renderHubBody -- the same
|
|
138
|
-
* measure-and-fallback decision, just against a different week number. */
|
|
139
123
|
function renderAdaptiveWeekGrid(inlineLines, fallbackLines, tt, week, todayWd, now, bodyRows, cols, cursor, reservedRows) {
|
|
140
124
|
if (gridFitsInline(inlineLines.length, tt, week, now, bodyRows, cols, cursor, reservedRows)) {
|
|
141
|
-
return [...inlineLines, ...renderWeekGrid(tt
|
|
125
|
+
return [...inlineLines, ...renderWeekGrid(tt, week, now, cols, cursor).split('\n')];
|
|
142
126
|
}
|
|
143
127
|
const selectedWd = cursor?.weekday ?? todayWd;
|
|
144
|
-
const dayMeetings =
|
|
128
|
+
const dayMeetings = createTimetableSchedule(tt).meetingsOnDay(week, selectedWd);
|
|
145
129
|
return [
|
|
146
130
|
...fallbackLines,
|
|
147
131
|
renderDaySwitcher(selectedWd, todayWd, cols),
|
|
@@ -164,7 +148,7 @@ function renderHubBody(state, now, bodyRows, cols) {
|
|
|
164
148
|
}
|
|
165
149
|
tail.push(...shortcutLines);
|
|
166
150
|
const content = pre
|
|
167
|
-
? renderAdaptiveWeekGrid(pre.inlineLines, pre.fallbackLines, pre.tt, pre.week,
|
|
151
|
+
? renderAdaptiveWeekGrid(pre.inlineLines, pre.fallbackLines, pre.tt, pre.week, createTimetableSchedule(pre.tt).weekdayAt(now), now, rows, cols, state.gridCursor, tail.length)
|
|
168
152
|
: [];
|
|
169
153
|
return { content, tail };
|
|
170
154
|
};
|
|
@@ -174,19 +158,14 @@ function renderHubBody(state, now, bodyRows, cols) {
|
|
|
174
158
|
const compact = build(renderShortcutLines(shortcuts, cols, true));
|
|
175
159
|
if (compact.tail.length >= rows)
|
|
176
160
|
return rows > 0 ? compact.tail.slice(-rows) : [];
|
|
177
|
-
return [
|
|
178
|
-
...compact.content.slice(0, rows - compact.tail.length),
|
|
179
|
-
...compact.tail,
|
|
180
|
-
];
|
|
161
|
+
return [...compact.content.slice(0, rows - compact.tail.length), ...compact.tail];
|
|
181
162
|
}
|
|
182
163
|
const TERM_PROGRESS_WIDTH = 20;
|
|
183
|
-
function renderTermProgressBar(w,
|
|
164
|
+
function renderTermProgressBar(w, cols) {
|
|
184
165
|
if (!w.nextBreakStart)
|
|
185
166
|
return null;
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const totalWeeks = Math.max(1, Math.round((nextBreakMs - weekOneMs) / (7 * 86400000)));
|
|
189
|
-
const currentWeek = currentWeekNumber(w.weekOneMonday, now);
|
|
167
|
+
const totalWeeks = Math.max(1, Math.round(localDayDifference(parseLocalMonday(w.weekOneMonday), parseLocalDate(w.nextBreakStart)) / 7));
|
|
168
|
+
const currentWeek = w.currentWeek;
|
|
190
169
|
const labelText = fmt(t().timetable.weekLabel2, { week: `${currentWeek}/${totalWeeks}` });
|
|
191
170
|
const label = type.hint(labelText);
|
|
192
171
|
const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
|
|
@@ -203,7 +182,7 @@ function renderTermProgressBar(w, now, cols) {
|
|
|
203
182
|
: [`${indent}${bar}`, ...hintLines(labelText, cols)];
|
|
204
183
|
}
|
|
205
184
|
function daysBetween(a, b) {
|
|
206
|
-
return Math.max(0,
|
|
185
|
+
return Math.max(0, localDayDifference(a, b));
|
|
207
186
|
}
|
|
208
187
|
function renderPublicBody(state, now, bodyRows, cols) {
|
|
209
188
|
const trans = t();
|
|
@@ -216,18 +195,18 @@ function renderPublicBody(state, now, bodyRows, cols) {
|
|
|
216
195
|
lines.push(...hintLines(trans.timetable.publicUnavailable, cols));
|
|
217
196
|
}
|
|
218
197
|
else if (w.status === 'onBreak') {
|
|
219
|
-
lines.push(...headingLines(fmt(trans.timetable.onBreak, { title: w.breakTitle }), cols));
|
|
198
|
+
lines.push(...headingLines(fmt(trans.timetable.onBreak, { title: sanitizeTerminalLine(w.breakTitle) }), cols));
|
|
220
199
|
}
|
|
221
200
|
else {
|
|
222
201
|
const semesterLabel = w.semester === '1' ? trans.timetable.semester1 : trans.timetable.semester2;
|
|
223
|
-
lines.push(...headingLines(`${fmt(trans.timetable.academicYearSuffix, { year: w.academicYear })} · ${semesterLabel} · ${fmt(trans.timetable.weekLabel2, { week: String(w.currentWeek) })}`, cols));
|
|
224
|
-
const bar = renderTermProgressBar(w,
|
|
202
|
+
lines.push(...headingLines(`${fmt(trans.timetable.academicYearSuffix, { year: sanitizeTerminalLine(w.academicYear) })} · ${semesterLabel} · ${fmt(trans.timetable.weekLabel2, { week: String(w.currentWeek) })}`, cols));
|
|
203
|
+
const bar = renderTermProgressBar(w, cols);
|
|
225
204
|
if (bar)
|
|
226
205
|
lines.push(...bar);
|
|
227
206
|
if (w.nextBreakStart && w.nextBreakTitle) {
|
|
228
207
|
lines.push(...hintLines(fmt(trans.timetable.daysUntilBreak, {
|
|
229
|
-
title: w.nextBreakTitle,
|
|
230
|
-
days: String(daysBetween(now,
|
|
208
|
+
title: sanitizeTerminalLine(w.nextBreakTitle),
|
|
209
|
+
days: String(daysBetween(now, parseLocalDate(w.nextBreakStart))),
|
|
231
210
|
}), cols));
|
|
232
211
|
}
|
|
233
212
|
}
|
|
@@ -283,20 +262,17 @@ export function renderSchedule(state, now, bodyRows = 100, cols = 80) {
|
|
|
283
262
|
case 'week': {
|
|
284
263
|
if (!state.timetable || !state.weekOne)
|
|
285
264
|
return [hint(trans.timetable.genericError)];
|
|
286
|
-
const
|
|
265
|
+
const schedule = createTimetableSchedule(state.timetable, {
|
|
266
|
+
weekOneMonday: state.weekOne,
|
|
267
|
+
});
|
|
268
|
+
const week = Math.max(1, schedule.weekAt(now));
|
|
287
269
|
const weekLines = [heading(trans.timetable.hubWeek), ''];
|
|
288
|
-
|
|
289
|
-
// banner/today-section eating into it first, unlike the hub's own
|
|
290
|
-
// inline area) -- reached via the `w` shortcut specifically so a
|
|
291
|
-
// marginal terminal that couldn't fit the grid inline the hub still
|
|
292
|
-
// gets a real shot at it here, falling back to the single-day view
|
|
293
|
-
// only if even the full screen isn't enough.
|
|
294
|
-
return renderAdaptiveWeekGrid(weekLines, weekLines, state.timetable, week, campusWeekday(now), now, bodyRows, cols, state.gridCursor, 2);
|
|
270
|
+
return renderAdaptiveWeekGrid(weekLines, weekLines, state.timetable, week, schedule.weekdayAt(now), now, bodyRows, cols, state.gridCursor, 2);
|
|
295
271
|
}
|
|
296
272
|
case 'termDensity':
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
273
|
+
if (!state.timetable || !state.weekOne)
|
|
274
|
+
return [hint(trans.timetable.genericError)];
|
|
275
|
+
return renderTermDensity(state.timetable.meetings, state.weekOne, createTimetableSchedule(state.timetable, { weekOneMonday: state.weekOne }).weekAt(now), cols).split('\n');
|
|
300
276
|
case 'termPicker':
|
|
301
277
|
return state.termField?.render(bodyRows, cols) ?? [];
|
|
302
278
|
case 'unresolved':
|