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