@nbtca/prompt 1.3.2 → 1.4.2
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 +45 -1
- package/SECURITY.md +47 -0
- package/dist/app/app.js +202 -0
- package/dist/app/chrome.js +104 -0
- package/dist/app/fields/list-field.js +174 -0
- package/dist/app/fields/text-field.js +38 -0
- package/dist/app/frame.js +48 -0
- package/dist/app/keys.js +20 -0
- package/dist/app/tabs.js +11 -0
- package/dist/app/view.js +1 -0
- package/dist/app/views/docs-render.js +82 -0
- package/dist/app/views/docs.js +450 -0
- package/dist/app/views/events-render.js +111 -0
- package/dist/app/views/events.js +228 -0
- package/dist/app/views/home.js +195 -0
- package/dist/app/views/schedule-grid-cursor.js +52 -0
- package/dist/app/views/schedule-render.js +317 -0
- package/dist/app/views/schedule.js +463 -0
- package/dist/app/views/settings-render.js +53 -0
- package/dist/app/views/settings.js +153 -0
- package/dist/auth/cookie-transport.js +222 -0
- package/dist/auth/errors.js +18 -0
- package/dist/auth/nbt-auth.js +239 -0
- package/dist/auth/session-store.js +118 -0
- package/dist/config/data.js +1 -2
- package/dist/config/paths.js +22 -2
- package/dist/core/canvas.js +23 -0
- package/dist/core/capabilities.js +42 -0
- package/dist/core/components/confirm.js +75 -0
- package/dist/core/components/input-session.js +24 -0
- package/dist/core/components/menu.js +122 -0
- package/dist/core/components/messages.js +16 -0
- package/dist/core/components/note.js +18 -0
- package/dist/core/components/painter.js +26 -0
- package/dist/core/components/screen.js +18 -0
- package/dist/core/components/spinner.js +47 -0
- package/dist/core/components/text-input.js +98 -0
- package/dist/core/logo.js +33 -22
- package/dist/core/menu.js +24 -9
- package/dist/core/motion.js +86 -0
- package/dist/core/text.js +121 -5
- package/dist/core/theme.js +61 -0
- package/dist/core/transitions.js +19 -0
- package/dist/core/ui.js +4 -45
- package/dist/features/calendar-heatmap.js +29 -27
- package/dist/features/calendar-query.js +50 -0
- package/dist/features/calendar.js +192 -98
- package/dist/features/docs.js +222 -61
- package/dist/features/links.js +7 -7
- package/dist/features/schedule-query.js +47 -0
- package/dist/features/schedule-render.js +573 -0
- package/dist/features/schedule-store.js +73 -0
- package/dist/features/schedule-view.js +253 -0
- package/dist/features/settings.js +43 -35
- package/dist/features/status.js +37 -16
- package/dist/features/student-timetable.js +346 -0
- package/dist/features/theme.js +0 -3
- package/dist/features/update.js +16 -18
- package/dist/i18n/index.js +5 -47
- package/dist/i18n/locales/en.json +149 -6
- package/dist/i18n/locales/zh.json +149 -6
- package/dist/index.js +61 -11
- package/dist/logo/ca-dotmatrix-large.txt +26 -0
- package/dist/logo/ca-dotmatrix-small.txt +12 -0
- package/dist/logo/ca-dotmatrix.txt +18 -16
- package/dist/logo/ca-logo.png +0 -0
- package/dist/main.js +33 -13
- package/package.json +18 -12
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { captureFooterHint, passiveFooterHint } from '../chrome.js';
|
|
2
|
+
import { ListField, computeMaxVisible } from '../fields/list-field.js';
|
|
3
|
+
import { TextField } from '../fields/text-field.js';
|
|
4
|
+
import { renderEvents } from './events-render.js';
|
|
5
|
+
import { setVimKeysActive } from '../../core/vim-keys.js';
|
|
6
|
+
import { pickIcon } from '../../core/icons.js';
|
|
7
|
+
import { t } from '../../i18n/index.js';
|
|
8
|
+
import { loadCalendarOrThrow, toDisplayEvent, exportEventIcs } from '../../features/calendar.js';
|
|
9
|
+
import { weekRange, monthRange, filterEvents } from '../../features/calendar-query.js';
|
|
10
|
+
let state = { mode: 'loading' };
|
|
11
|
+
let calendar = null;
|
|
12
|
+
let currentList = [];
|
|
13
|
+
function backLabel() {
|
|
14
|
+
return t().common.back;
|
|
15
|
+
}
|
|
16
|
+
function buildHubField() {
|
|
17
|
+
const trans = t();
|
|
18
|
+
const options = [
|
|
19
|
+
{ value: 'upcoming', label: trans.menu.events },
|
|
20
|
+
{ value: 'week', label: trans.calendar.thisWeek },
|
|
21
|
+
{ value: 'month', label: trans.calendar.thisMonth },
|
|
22
|
+
{ value: 'search', label: trans.calendar.search },
|
|
23
|
+
{ value: 'past', label: trans.calendar.pastEvents },
|
|
24
|
+
{ value: 'heatmap', label: trans.calendar.heatmap.title },
|
|
25
|
+
];
|
|
26
|
+
return new ListField({ title: trans.menu.events, options });
|
|
27
|
+
}
|
|
28
|
+
function buildListField(title, events, maxVisible) {
|
|
29
|
+
const trans = t();
|
|
30
|
+
const display = events.map(toDisplayEvent);
|
|
31
|
+
const options = [
|
|
32
|
+
...events.map((_e, i) => ({
|
|
33
|
+
value: String(i),
|
|
34
|
+
label: `${display[i].date}${display[i].time ? ' ' + display[i].time : ''} ${display[i].title}`,
|
|
35
|
+
hint: display[i].location,
|
|
36
|
+
})),
|
|
37
|
+
{ value: '__back__', label: backLabel() },
|
|
38
|
+
];
|
|
39
|
+
return new ListField({
|
|
40
|
+
title: title || trans.menu.events,
|
|
41
|
+
options: options.length > 1 ? options : [{ value: '__back__', label: `${trans.calendar.noEvents} — ${backLabel()}` }],
|
|
42
|
+
maxVisible,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function showList(title, events, ctx) {
|
|
46
|
+
currentList = events;
|
|
47
|
+
state = { mode: 'list', listField: buildListField(title, events, computeMaxVisible(ctx.bodyRows)) };
|
|
48
|
+
}
|
|
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
|
+
const RECENT_ACTIVITY_FETCH_CAP = 15;
|
|
54
|
+
function goToHub() {
|
|
55
|
+
const upcoming = calendar ? calendar.upcoming({ days: 30 }) : [];
|
|
56
|
+
state = {
|
|
57
|
+
mode: 'hub',
|
|
58
|
+
hubField: buildHubField(),
|
|
59
|
+
nextEvent: upcoming[0] ? toDisplayEvent(upcoming[0]) : undefined,
|
|
60
|
+
heatmapBuckets: calendar
|
|
61
|
+
? calendar.heatmap({ start: new Date(Date.now() - 365 * 86400000), end: new Date(), bucket: 'day' })
|
|
62
|
+
: [],
|
|
63
|
+
recentEvents: upcoming.slice(0, RECENT_ACTIVITY_FETCH_CAP).map(toDisplayEvent),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function showDetail(raw) {
|
|
67
|
+
const trans = t();
|
|
68
|
+
const e = toDisplayEvent(raw);
|
|
69
|
+
const dot = pickIcon('·', '-');
|
|
70
|
+
state = {
|
|
71
|
+
mode: 'detail',
|
|
72
|
+
detailTitle: e.title,
|
|
73
|
+
detailMeta: `${e.date}${e.time ? ' ' + e.time : ''} ${dot} ${e.location}${raw.recurring ? ` ${dot} ${trans.calendar.recurringLabel}` : ''}`,
|
|
74
|
+
detailDescription: e.description,
|
|
75
|
+
detailEvent: raw,
|
|
76
|
+
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
|
+
title: '',
|
|
82
|
+
options: [
|
|
83
|
+
{ value: 'export', label: trans.calendar.exportIcs },
|
|
84
|
+
{ value: '__back__', label: backLabel() },
|
|
85
|
+
],
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export const eventsView = {
|
|
90
|
+
id: 'events',
|
|
91
|
+
title: t().menu.events,
|
|
92
|
+
async load(ctx) {
|
|
93
|
+
state = { mode: 'loading' };
|
|
94
|
+
ctx.rerender();
|
|
95
|
+
try {
|
|
96
|
+
calendar = await loadCalendarOrThrow();
|
|
97
|
+
goToHub();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
state = { mode: 'error', errorMessage: t().calendar.error };
|
|
101
|
+
}
|
|
102
|
+
ctx.rerender();
|
|
103
|
+
},
|
|
104
|
+
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
|
+
state.listField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
|
|
109
|
+
return renderEvents(state, new Date(), ctx.bodyRows, ctx.size.cols);
|
|
110
|
+
},
|
|
111
|
+
capturesInput() {
|
|
112
|
+
return state.mode === 'search';
|
|
113
|
+
},
|
|
114
|
+
footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
|
|
115
|
+
if (state.mode === 'search')
|
|
116
|
+
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
|
+
const passive = state.mode === 'loading' || state.mode === 'error' || state.mode === 'heatmap';
|
|
122
|
+
return passive ? passiveFooterHint(tabCount, cols) : undefined;
|
|
123
|
+
},
|
|
124
|
+
handleBack() {
|
|
125
|
+
if (state.mode === 'list' || state.mode === 'detail' || state.mode === 'search' || state.mode === 'heatmap') {
|
|
126
|
+
if (state.mode === 'search')
|
|
127
|
+
setVimKeysActive(true);
|
|
128
|
+
goToHub();
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
},
|
|
133
|
+
handleKey(key, ctx) {
|
|
134
|
+
if (!calendar)
|
|
135
|
+
return;
|
|
136
|
+
switch (state.mode) {
|
|
137
|
+
case 'hub': {
|
|
138
|
+
const result = state.hubField?.handleKey(key);
|
|
139
|
+
if (!result?.selected)
|
|
140
|
+
return;
|
|
141
|
+
const now = new Date();
|
|
142
|
+
if (result.selected === 'upcoming') {
|
|
143
|
+
showList(t().menu.events, calendar.upcoming({ days: 30 }), ctx);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (result.selected === 'week') {
|
|
147
|
+
const r = weekRange(now);
|
|
148
|
+
showList(t().calendar.thisWeek, calendar.inRange(r.start, r.end), ctx);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (result.selected === 'month') {
|
|
152
|
+
const r = monthRange(now);
|
|
153
|
+
showList(t().calendar.thisMonth, calendar.inRange(r.start, r.end), ctx);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (result.selected === 'past') {
|
|
157
|
+
showList(t().calendar.pastEvents, calendar.past({ days: 30 }).reverse(), ctx);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (result.selected === 'heatmap') {
|
|
161
|
+
state = { ...state, mode: 'heatmap' };
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (result.selected === 'search') {
|
|
165
|
+
setVimKeysActive(false);
|
|
166
|
+
state = { mode: 'search', searchField: new TextField({ message: t().calendar.searchPrompt, placeholder: t().calendar.searchPlaceholder, allowEmpty: true }) };
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
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
|
+
case 'heatmap': {
|
|
173
|
+
goToHub();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
case 'list': {
|
|
177
|
+
const result = state.listField?.handleKey(key);
|
|
178
|
+
if (!result?.selected)
|
|
179
|
+
return;
|
|
180
|
+
if (result.selected === '__back__') {
|
|
181
|
+
goToHub();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const raw = currentList[Number.parseInt(result.selected, 10)];
|
|
185
|
+
if (raw)
|
|
186
|
+
showDetail(raw);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
case 'detail': {
|
|
190
|
+
const result = state.detailField?.handleKey(key);
|
|
191
|
+
if (!result?.selected)
|
|
192
|
+
return;
|
|
193
|
+
if (result.selected === '__back__') {
|
|
194
|
+
showList('', currentList, ctx);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (result.selected === 'export' && state.detailEvent) {
|
|
198
|
+
const res = exportEventIcs(state.detailEvent);
|
|
199
|
+
state = { ...state, statusMessage: res.ok ? `${t().calendar.exportSuccess}: ${res.path}` : `${t().calendar.exportError}: ${res.error ?? ''}` };
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
case 'search': {
|
|
204
|
+
const result = state.searchField?.handleKey(key);
|
|
205
|
+
if (result?.cancelled) {
|
|
206
|
+
setVimKeysActive(true);
|
|
207
|
+
goToHub();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (result?.submitted !== undefined) {
|
|
211
|
+
setVimKeysActive(true);
|
|
212
|
+
const query = result.submitted.trim();
|
|
213
|
+
if (!query || !calendar) {
|
|
214
|
+
goToHub();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const now = new Date();
|
|
218
|
+
const pool = calendar.inRange(now, new Date(now.getTime() + 365 * 86400000));
|
|
219
|
+
const results = filterEvents(pool, query);
|
|
220
|
+
showList(`${t().calendar.search}: ${query}`, results, ctx);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
default:
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { c, type, space, glyph } from '../../core/theme.js';
|
|
2
|
+
import { t } from '../../i18n/index.js';
|
|
3
|
+
import { pickIcon } from '../../core/icons.js';
|
|
4
|
+
import { padEndV, visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
|
|
5
|
+
import { peekNextClassLine, peekTodayLines, peekWeekAheadInfo, peekUnresolvedCount } from '../../features/schedule-view.js';
|
|
6
|
+
import { loadCalendarOrThrow, toDisplayEvent, renderEventBrief } from '../../features/calendar.js';
|
|
7
|
+
import { weekdayShortLabel } from '../../features/schedule-render.js';
|
|
8
|
+
import { campusWeekday } from '../../features/schedule-query.js';
|
|
9
|
+
import { passiveFooterHint } from '../chrome.js';
|
|
10
|
+
function wrappedIndentedLines(label, cols, style) {
|
|
11
|
+
const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
|
|
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}`);
|
|
21
|
+
}
|
|
22
|
+
function panelHeading(label, cols) {
|
|
23
|
+
return wrappedIndentedLines(label, cols, type.heading);
|
|
24
|
+
}
|
|
25
|
+
function loadingLines(cols) {
|
|
26
|
+
return wrappedIndentedLines(t().common.loading, cols, type.hint);
|
|
27
|
+
}
|
|
28
|
+
function wrappedRenderedLines(line, cols) {
|
|
29
|
+
const content = line.startsWith(space.indent) ? line.slice(space.indent.length) : line;
|
|
30
|
+
return wrappedIndentedLines(content, cols, (value) => value);
|
|
31
|
+
}
|
|
32
|
+
const DAY_PROGRESS_WIDTH = 20;
|
|
33
|
+
function renderDayProgress(now, cols) {
|
|
34
|
+
const minutesElapsed = now.getHours() * 60 + now.getMinutes();
|
|
35
|
+
const fraction = Math.min(1, Math.max(0, minutesElapsed / 1440));
|
|
36
|
+
const pct = Math.round(fraction * 100);
|
|
37
|
+
const percentage = `${pct}%`;
|
|
38
|
+
const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
|
|
39
|
+
const indent = visualWidth(space.indent) + 2 + visualWidth(percentage) + 1 <= width ? space.indent : '';
|
|
40
|
+
const gap = visualWidth(indent) + 2 + visualWidth(percentage) + 1 <= width
|
|
41
|
+
? ' '
|
|
42
|
+
: visualWidth(indent) + 1 + visualWidth(percentage) + 1 <= width ? ' ' : '';
|
|
43
|
+
const barWidth = Math.max(0, Math.min(DAY_PROGRESS_WIDTH, width - visualWidth(indent) - visualWidth(gap) - visualWidth(percentage)));
|
|
44
|
+
const filled = Math.round(fraction * barWidth);
|
|
45
|
+
const filledChar = glyph.barFilled();
|
|
46
|
+
const emptyChar = glyph.barEmpty();
|
|
47
|
+
const bar = filledChar.repeat(filled) + emptyChar.repeat(barWidth - filled);
|
|
48
|
+
return `${indent}${type.body(bar)}${gap}${type.hint(percentage)}`;
|
|
49
|
+
}
|
|
50
|
+
function renderWeekAheadGrid(classDays, eventDays, cols) {
|
|
51
|
+
const trans = t();
|
|
52
|
+
const hasClassChar = pickIcon('▓▓', '##');
|
|
53
|
+
const freeChar = pickIcon('░░', '..');
|
|
54
|
+
const weekendChar = pickIcon('··', '..');
|
|
55
|
+
const blankCell = ' ';
|
|
56
|
+
const rowLabelW = Math.max(visualWidth(trans.timetable.weekAheadClasses), visualWidth(trans.menu.events)) + 1;
|
|
57
|
+
const days = [1, 2, 3, 4, 5, 6, 7];
|
|
58
|
+
const dayLabels = days.map((wd) => type.hint(weekdayShortLabel(wd))).join(' ');
|
|
59
|
+
const headerLine = `${space.indent}${padEndV('', rowLabelW)}${dayLabels}`;
|
|
60
|
+
const classCells = days.map((wd) => {
|
|
61
|
+
const isWeekend = wd === 6 || wd === 7;
|
|
62
|
+
const glyphChar = isWeekend ? weekendChar : (classDays[wd - 1] ? hasClassChar : freeChar);
|
|
63
|
+
return type.body(glyphChar);
|
|
64
|
+
}).join(' ');
|
|
65
|
+
const classLine = `${space.indent}${type.hint(padEndV(trans.timetable.weekAheadClasses, rowLabelW))}${classCells}`;
|
|
66
|
+
const eventCells = days.map((wd) => {
|
|
67
|
+
if (!eventDays)
|
|
68
|
+
return blankCell;
|
|
69
|
+
return type.body(eventDays[wd - 1] ? hasClassChar : freeChar);
|
|
70
|
+
}).join(' ');
|
|
71
|
+
const eventLine = `${space.indent}${type.hint(padEndV(trans.menu.events, rowLabelW))}${eventCells}`;
|
|
72
|
+
const legend = `${space.indent}${type.hint(`${hasClassChar} ${trans.timetable.weekAheadBusy} ${freeChar} ${trans.timetable.weekAheadFree} ${weekendChar} ${trans.timetable.weekAheadNone}`)}`;
|
|
73
|
+
const wideLines = [headerLine, classLine, eventLine, legend];
|
|
74
|
+
const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
|
|
75
|
+
if (wideLines.every((line) => visualWidth(line) <= width))
|
|
76
|
+
return wideLines.join('\n');
|
|
77
|
+
const compactHeading = wrappedIndentedLines(`${trans.timetable.weekAheadClasses} / ${trans.menu.events}`, cols, type.hint);
|
|
78
|
+
const compactDays = days.flatMap((wd) => {
|
|
79
|
+
const classCell = wd === 6 || wd === 7
|
|
80
|
+
? weekendChar
|
|
81
|
+
: classDays[wd - 1] ? hasClassChar : freeChar;
|
|
82
|
+
const eventCell = eventDays
|
|
83
|
+
? eventDays[wd - 1] ? hasClassChar : freeChar
|
|
84
|
+
: blankCell;
|
|
85
|
+
const row = `${type.hint(weekdayShortLabel(wd))} ${type.body(classCell)} ${type.body(eventCell)}`;
|
|
86
|
+
return wrappedIndentedLines(row, cols, (value) => value);
|
|
87
|
+
});
|
|
88
|
+
const compactLegend = wrappedIndentedLines(`${hasClassChar} ${trans.timetable.weekAheadBusy} ${freeChar} ${trans.timetable.weekAheadFree} ${weekendChar} ${trans.timetable.weekAheadNone}`, cols, type.hint);
|
|
89
|
+
return [...compactHeading, ...compactDays, ...compactLegend].join('\n');
|
|
90
|
+
}
|
|
91
|
+
export function renderHome(data, now, bodyRows = 100, cols = 80) {
|
|
92
|
+
const trans = t();
|
|
93
|
+
const lines = [];
|
|
94
|
+
const nextClass = data.nextClassLine !== undefined && data.nextClassLine.trim().length > 0
|
|
95
|
+
? wrappedRenderedLines(data.nextClassLine, cols)
|
|
96
|
+
: wrappedIndentedLines(trans.timetable.noNextClass, cols, type.hint);
|
|
97
|
+
lines.push(...panelHeading(trans.timetable.nextClass, cols));
|
|
98
|
+
lines.push(...nextClass);
|
|
99
|
+
lines.push('');
|
|
100
|
+
lines.push(...panelHeading(trans.timetable.hubToday, cols));
|
|
101
|
+
lines.push(renderDayProgress(now, cols));
|
|
102
|
+
if (data.todayLines && data.todayLines.length > 0) {
|
|
103
|
+
for (const line of data.todayLines)
|
|
104
|
+
lines.push(...wrappedRenderedLines(line, cols));
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
lines.push(...wrappedIndentedLines(trans.timetable.noClassToday, cols, type.hint));
|
|
108
|
+
}
|
|
109
|
+
lines.push('');
|
|
110
|
+
if (data.weekAhead) {
|
|
111
|
+
lines.push(...panelHeading(trans.timetable.weekOverviewTitle, cols));
|
|
112
|
+
lines.push(...renderWeekAheadGrid(data.weekAhead.classDays, data.weekAhead.eventDays, cols).split('\n'));
|
|
113
|
+
lines.push('');
|
|
114
|
+
}
|
|
115
|
+
if ((data.unresolvedCount ?? 0) > 0) {
|
|
116
|
+
lines.push(...wrappedIndentedLines(`${pickIcon('⚠', '!')} ${trans.timetable.hubUnresolved} · ${data.unresolvedCount}`, cols, c.warn));
|
|
117
|
+
lines.push('');
|
|
118
|
+
}
|
|
119
|
+
lines.push(...panelHeading(trans.menu.events, cols));
|
|
120
|
+
if (data.eventLines && data.eventLines.length > 0) {
|
|
121
|
+
const remaining = Number.isFinite(bodyRows)
|
|
122
|
+
? Math.max(0, Math.floor(bodyRows) - lines.length)
|
|
123
|
+
: Number.POSITIVE_INFINITY;
|
|
124
|
+
let usedRows = 0;
|
|
125
|
+
for (const line of data.eventLines) {
|
|
126
|
+
const wrapped = wrappedRenderedLines(line, cols);
|
|
127
|
+
if (usedRows + wrapped.length > remaining) {
|
|
128
|
+
if (usedRows === 0 && remaining === 0)
|
|
129
|
+
lines.push(...wrapped);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
lines.push(...wrapped);
|
|
133
|
+
usedRows += wrapped.length;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else if (data.loading) {
|
|
137
|
+
lines.push(...loadingLines(cols));
|
|
138
|
+
}
|
|
139
|
+
else if (data.eventsLoadFailed) {
|
|
140
|
+
lines.push(...wrappedIndentedLines(trans.calendar.error, cols, type.hint));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
lines.push(...wrappedIndentedLines(trans.calendar.noEvents, cols, type.hint));
|
|
144
|
+
}
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
let data = { loading: true };
|
|
148
|
+
export const homeView = {
|
|
149
|
+
id: 'home',
|
|
150
|
+
title: 'Home',
|
|
151
|
+
footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
|
|
152
|
+
return passiveFooterHint(tabCount, cols);
|
|
153
|
+
},
|
|
154
|
+
async load(ctx) {
|
|
155
|
+
const weekAheadInfo = peekWeekAheadInfo();
|
|
156
|
+
try {
|
|
157
|
+
data = {
|
|
158
|
+
loading: true,
|
|
159
|
+
nextClassLine: peekNextClassLine(),
|
|
160
|
+
todayLines: peekTodayLines(),
|
|
161
|
+
unresolvedCount: peekUnresolvedCount(),
|
|
162
|
+
weekAhead: weekAheadInfo ? { classDays: weekAheadInfo.classDays } : undefined,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
data = { loading: true };
|
|
167
|
+
}
|
|
168
|
+
ctx.rerender();
|
|
169
|
+
const HOME_EVENT_FETCH_CAP = 15;
|
|
170
|
+
try {
|
|
171
|
+
const cal = await loadCalendarOrThrow();
|
|
172
|
+
const now = new Date();
|
|
173
|
+
const items = cal.upcoming({ days: 30 }).slice(0, HOME_EVENT_FETCH_CAP).map(toDisplayEvent);
|
|
174
|
+
const eventLines = items.map((e) => renderEventBrief(e, now));
|
|
175
|
+
let weekAhead = data.weekAhead;
|
|
176
|
+
if (weekAheadInfo) {
|
|
177
|
+
const weekEnd = new Date(weekAheadInfo.weekStartDate.getTime() + 7 * 86400000);
|
|
178
|
+
const weekEvents = cal.inRange(weekAheadInfo.weekStartDate, weekEnd);
|
|
179
|
+
const daySet = new Set(weekEvents.map((e) => campusWeekday(e.start)));
|
|
180
|
+
weekAhead = { classDays: weekAheadInfo.classDays, eventDays: [1, 2, 3, 4, 5, 6, 7].map((wd) => daySet.has(wd)) };
|
|
181
|
+
}
|
|
182
|
+
data = { ...data, eventLines, weekAhead };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
data = { ...data, eventsLoadFailed: true };
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
data = { ...data, loading: false };
|
|
189
|
+
ctx.rerender();
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
render(ctx) {
|
|
193
|
+
return renderHome(data, new Date(), ctx.bodyRows, ctx.size.cols);
|
|
194
|
+
},
|
|
195
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { meetingAtCursor } from '../../features/schedule-query.js';
|
|
2
|
+
export const KEY_ARROW_LEFT = '\x1b[D';
|
|
3
|
+
export const KEY_ARROW_RIGHT = '\x1b[C';
|
|
4
|
+
export const KEY_ARROW_UP = '\x1b[A';
|
|
5
|
+
export const KEY_ARROW_DOWN = '\x1b[B';
|
|
6
|
+
export const KEY_ENTER_CR = '\r';
|
|
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
|
+
export function defaultGridCursor(todayWeekday, periods) {
|
|
13
|
+
const sorted = [...periods].sort((a, b) => a.period - b.period);
|
|
14
|
+
const firstPeriod = sorted[0]?.period ?? 1;
|
|
15
|
+
const weekday = todayWeekday >= 1 && todayWeekday <= 5 ? todayWeekday : 1;
|
|
16
|
+
return { weekday, period: firstPeriod };
|
|
17
|
+
}
|
|
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
|
+
export function moveCursorWeekday(cursor, delta) {
|
|
22
|
+
return { ...cursor, weekday: Math.max(1, Math.min(7, cursor.weekday + delta)) };
|
|
23
|
+
}
|
|
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
|
+
export function moveCursorPeriod(cursor, periods, delta) {
|
|
28
|
+
const sorted = [...periods].sort((a, b) => a.period - b.period);
|
|
29
|
+
if (sorted.length === 0)
|
|
30
|
+
return cursor;
|
|
31
|
+
const idx = sorted.findIndex((p) => p.period === cursor.period);
|
|
32
|
+
const nextIdx = Math.max(0, Math.min(sorted.length - 1, (idx === -1 ? 0 : idx) + delta));
|
|
33
|
+
return { ...cursor, period: sorted[nextIdx].period };
|
|
34
|
+
}
|
|
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
|
+
export function handleGridKey(key, cursor, tt, week) {
|
|
39
|
+
if (key === KEY_ARROW_LEFT)
|
|
40
|
+
return { kind: 'moveCursor', cursor: moveCursorWeekday(cursor, -1) };
|
|
41
|
+
if (key === KEY_ARROW_RIGHT)
|
|
42
|
+
return { kind: 'moveCursor', cursor: moveCursorWeekday(cursor, 1) };
|
|
43
|
+
if (key === KEY_ARROW_UP)
|
|
44
|
+
return { kind: 'moveCursor', cursor: moveCursorPeriod(cursor, tt.periods, -1) };
|
|
45
|
+
if (key === KEY_ARROW_DOWN)
|
|
46
|
+
return { kind: 'moveCursor', cursor: moveCursorPeriod(cursor, tt.periods, 1) };
|
|
47
|
+
if (key === KEY_ENTER_CR || key === KEY_ENTER_LF) {
|
|
48
|
+
const meeting = meetingAtCursor(tt.meetings, week, cursor);
|
|
49
|
+
return meeting ? { kind: 'openDetail', meeting } : { kind: 'none' };
|
|
50
|
+
}
|
|
51
|
+
return { kind: 'none' };
|
|
52
|
+
}
|