saterminal 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +168 -4
  2. package/data/question-bank.json.zst +0 -0
  3. package/package.json +16 -10
  4. package/src/cli/commands/_app.tsx +10 -0
  5. package/src/cli/commands/config/index.tsx +21 -0
  6. package/src/cli/commands/config/reset.tsx +12 -0
  7. package/src/cli/commands/config/set.tsx +34 -0
  8. package/src/cli/commands/focus.tsx +12 -0
  9. package/src/cli/commands/history.tsx +21 -0
  10. package/src/cli/commands/index.tsx +7 -0
  11. package/src/cli/commands/review.tsx +7 -0
  12. package/src/cli/commands/show.tsx +36 -0
  13. package/src/cli/commands/stats.tsx +15 -0
  14. package/src/cli/commands/weak.tsx +13 -0
  15. package/src/cli/components/command-action.tsx +23 -0
  16. package/src/cli/components/report.tsx +16 -0
  17. package/src/cli/index.ts +10 -0
  18. package/src/cli/report-options.ts +19 -0
  19. package/src/cli/reports/focus.ts +20 -0
  20. package/src/cli/reports/history.ts +21 -0
  21. package/src/cli/reports/question.ts +24 -0
  22. package/src/cli/reports/stats.ts +66 -0
  23. package/src/cli/reports/terminal-format.ts +28 -0
  24. package/src/cli/reports/weaknesses.ts +36 -0
  25. package/src/database/focus-repository.ts +26 -0
  26. package/src/database/index.ts +28 -0
  27. package/src/database/migrations.ts +100 -0
  28. package/src/database/progress-repository.ts +90 -0
  29. package/src/local-data/paths.ts +17 -0
  30. package/src/local-data/setup.ts +25 -0
  31. package/src/practice/answer-question.ts +25 -0
  32. package/src/practice/question-queue.ts +58 -0
  33. package/src/preferences/index.ts +131 -0
  34. package/src/preferences/preferences.schema.json +45 -0
  35. package/src/progress/activity.ts +55 -0
  36. package/src/progress/attempt.ts +70 -0
  37. package/src/progress/history.ts +34 -0
  38. package/src/progress/review-queue.ts +44 -0
  39. package/src/progress/statistics.ts +35 -0
  40. package/src/progress/weaknesses.ts +44 -0
  41. package/src/questions/focus.ts +77 -0
  42. package/src/questions/local-bank.ts +133 -0
  43. package/src/questions/practice-sat.ts +14 -0
  44. package/src/questions/question.ts +25 -0
  45. package/src/questions/taxonomy.ts +56 -0
  46. package/src/text/duration.ts +4 -0
  47. package/src/{text.ts → text/html.ts} +3 -119
  48. package/src/text/media.ts +16 -0
  49. package/src/text/rich-text.ts +37 -0
  50. package/src/text/wrap.ts +59 -0
  51. package/src/tui/app.tsx +243 -0
  52. package/src/tui/components/chrome.tsx +39 -0
  53. package/src/tui/components/question-content.tsx +91 -0
  54. package/src/tui/hooks/use-terminal-size.ts +16 -0
  55. package/src/tui/screens/detail.tsx +20 -0
  56. package/src/tui/screens/focus.tsx +244 -0
  57. package/src/tui/screens/history.tsx +33 -0
  58. package/src/tui/screens/practice.tsx +159 -0
  59. package/src/tui/screens/result.tsx +169 -0
  60. package/src/tui/screens/setup.tsx +17 -0
  61. package/src/tui/screens/summary.tsx +16 -0
  62. package/src/api.ts +0 -113
  63. package/src/cli.ts +0 -917
  64. package/src/focus.ts +0 -231
  65. package/src/main.ts +0 -13
  66. package/src/progress.ts +0 -48
  67. package/src/state.ts +0 -346
  68. package/src/tui/app.ts +0 -134
  69. package/src/tui/focus-grid.ts +0 -113
  70. package/src/tui/frame.ts +0 -210
  71. package/src/tui/history.ts +0 -6
  72. package/src/tui/input.ts +0 -442
  73. package/src/tui/kit.ts +0 -9
  74. package/src/tui/layout.ts +0 -25
  75. package/src/tui/pane-content.ts +0 -150
  76. package/src/tui/question.ts +0 -46
  77. package/src/tui/render.ts +0 -627
  78. package/src/tui/timer.ts +0 -43
  79. package/src/tui/types.ts +0 -51
  80. package/src/tui/viewport.ts +0 -60
  81. package/src/tui.ts +0 -1
  82. package/src/types/terminal-kit.d.ts +0 -121
  83. package/src/types.ts +0 -69
package/src/tui/input.ts DELETED
@@ -1,442 +0,0 @@
1
- import { fetchPracticeQuestion, findQuestionByShortId } from "../api.ts";
2
- import { appendAttemptEvent, recordAttempt, saveAttempts, saveFocus, saveSummary } from "../state.ts";
3
- import { focusGrid, moveFocusGridPosition, normalizeFocusGridPosition, toggleFocusGridRow } from "./focus-grid.ts";
4
- import {
5
- answerKeys,
6
- openExternalQuestion,
7
- questionNeedsExternalDisplay,
8
- questionRows,
9
- } from "./question.ts";
10
- import { historyRows } from "./history.ts";
11
- import { elapsedQuestionSeconds, formatElapsed, pauseTimer, resumeTimer, toggleTimer } from "./timer.ts";
12
- import { paneLayout, paneViewportHeight } from "./layout.ts";
13
- import { detailPaneRows, practiceAnswerPaneRows, reviewPaneRows } from "./pane-content.ts";
14
- import { alternatePane, ensureRangeVisible, scrollBy, scrollPage, scrollToEdge, type PaneViewport } from "./viewport.ts";
15
- import type { AppState, KeyData } from "./types.ts";
16
- import type { PracticeQuestion } from "../types.ts";
17
-
18
- export function isPauseKey(name: string, data?: KeyData): boolean {
19
- if (name === " " || name.toUpperCase() === "SPACE") {
20
- return true;
21
- }
22
-
23
- if (data?.isCharacter && data.codepoint === 32) {
24
- return true;
25
- }
26
-
27
- if (data?.code === " ") {
28
- return true;
29
- }
30
-
31
- if (Buffer.isBuffer(data?.code) && data.code.toString("utf8") === " ") {
32
- return true;
33
- }
34
-
35
- return false;
36
- }
37
-
38
- export async function handleKey(state: AppState, name: string, data?: KeyData): Promise<void> {
39
- if (state.view === "loading") {
40
- return;
41
- }
42
-
43
- if (state.view === "focus") {
44
- await handleFocusKey(state, name, data);
45
- return;
46
- }
47
-
48
- if (name === "f") {
49
- pauseTimer(state);
50
- state.view = "focus";
51
- return;
52
- }
53
-
54
- if (name === "h") {
55
- pauseTimer(state);
56
- state.view = "history";
57
- state.historyIndex = 0;
58
- return;
59
- }
60
-
61
- if (name === "s") {
62
- pauseTimer(state);
63
- state.view = "summary";
64
- return;
65
- }
66
-
67
- if (name === "t") {
68
- state.timerHidden = !state.timerHidden;
69
- return;
70
- }
71
-
72
- if (name === "p") {
73
- state.view = state.question ? "practice" : "loading";
74
- resetPaneScroll(state);
75
- resumeTimer(state);
76
- if (!state.question) {
77
- await loadNextQuestion(state);
78
- }
79
- return;
80
- }
81
-
82
- if (state.view === "error") {
83
- if (name === "r") {
84
- state.view = "loading";
85
- await loadNextQuestion(state);
86
- }
87
- return;
88
- }
89
-
90
- if (isScrollablePaneView(state) && handlePaneKey(state, name)) {
91
- return;
92
- }
93
-
94
- if (state.view === "practice") {
95
- if (isPauseKey(name, data)) {
96
- toggleTimer(state);
97
- return;
98
- }
99
-
100
- if (state.question && questionNeedsExternalDisplay(state.question.detail)) {
101
- if (name === "n" || name === "x" || name === "ENTER") {
102
- skipQuestion(state);
103
- await loadNextQuestion(state);
104
- } else if (name === "o") {
105
- openExternalQuestion(state.question);
106
- }
107
- return;
108
- }
109
-
110
- const choices = answerKeys(state.question);
111
- const directChoice = choices.findIndex((choice) => choice.toLowerCase() === name.toLowerCase());
112
- if (directChoice >= 0) {
113
- state.selected = directChoice;
114
- ensureSelectedAnswerVisible(state);
115
- } else if (name === "ENTER") {
116
- const answer = choices[state.selected];
117
- const correct = state.question?.detail.correct_answer.includes(answer) ?? false;
118
- const elapsedSeconds = elapsedQuestionSeconds(state);
119
- state.lastAnswer = answer;
120
- state.lastCorrect = correct;
121
- pauseTimer(state);
122
- if (state.question) {
123
- const answeredAt = new Date();
124
- recordAttempt(state.attempts, state.question.meta.questionId, correct, elapsedSeconds, answeredAt, state.question.meta);
125
- await appendAttemptEvent(state.question.meta, correct, elapsedSeconds, answeredAt);
126
- await saveAttempts(state.attempts);
127
- await saveSummary(state.attempts);
128
- }
129
- state.view = "review";
130
- state.activePane = "answers";
131
- state.answerScroll = 0;
132
- }
133
- return;
134
- }
135
-
136
- if (state.view === "review" && (name === "ENTER" || name === "n")) {
137
- await loadNextQuestion(state);
138
- return;
139
- }
140
-
141
- if (state.view === "history") {
142
- const attempts = historyRows(state);
143
- if (name === "UP" || name === "k") {
144
- state.historyIndex = Math.max(0, state.historyIndex - 1);
145
- } else if (name === "DOWN" || name === "j") {
146
- state.historyIndex = Math.min(attempts.length - 1, state.historyIndex + 1);
147
- } else if (name === "ENTER" && attempts[state.historyIndex]) {
148
- state.view = "loading";
149
- resetPaneScroll(state);
150
- state.detailQuestion = await findQuestionByShortId(attempts[state.historyIndex].question_id);
151
- state.view = "detail";
152
- }
153
- return;
154
- }
155
-
156
- if (state.view === "detail" && name === "ESCAPE") {
157
- state.view = "history";
158
- }
159
- }
160
-
161
- async function handleFocusKey(state: AppState, name: string, data?: KeyData): Promise<void> {
162
- const columns = focusGrid(state.focus);
163
- const position = normalizeFocusGridPosition(columns, { column: state.focusColumn, row: state.focusRow });
164
- state.focusColumn = position.column;
165
- state.focusRow = position.row;
166
-
167
- if (name === "UP" || name === "k") {
168
- const next = moveFocusGridPosition(columns, position, "up");
169
- state.focusColumn = next.column;
170
- state.focusRow = next.row;
171
- return;
172
- }
173
-
174
- if (name === "DOWN" || name === "j") {
175
- const next = moveFocusGridPosition(columns, position, "down");
176
- state.focusColumn = next.column;
177
- state.focusRow = next.row;
178
- return;
179
- }
180
-
181
- if (name === "TAB") {
182
- const next = moveFocusGridPosition(columns, position, "next");
183
- state.focusColumn = next.column;
184
- state.focusRow = next.row;
185
- return;
186
- }
187
-
188
- if (name === "SHIFT_TAB") {
189
- const next = moveFocusGridPosition(columns, position, "previous");
190
- state.focusColumn = next.column;
191
- state.focusRow = next.row;
192
- return;
193
- }
194
-
195
- if (isPauseKey(name, data)) {
196
- const focus = toggleFocusGridRow(state.focus, position);
197
- if (focus !== state.focus) {
198
- state.focus = focus;
199
- state.nextQuestion = undefined;
200
- state.question = undefined;
201
- await saveFocus(state.focus);
202
- }
203
- return;
204
- }
205
-
206
- if (name === "ENTER") {
207
- state.nextQuestion = undefined;
208
- state.question = undefined;
209
- await loadNextQuestion(state);
210
- }
211
- }
212
-
213
- export async function loadNextQuestion(state: AppState): Promise<void> {
214
- state.view = "loading";
215
- state.question = await takeNextQuestion(state);
216
- if (!state.question) {
217
- state.notice = "Review queue complete.";
218
- state.view = "history";
219
- return;
220
- }
221
- state.selected = 0;
222
- resetPaneScroll(state);
223
- state.elapsedMs = 0;
224
- state.timerStartedAt = Date.now();
225
- state.timerPaused = false;
226
- state.lastAnswer = undefined;
227
- state.lastCorrect = undefined;
228
- state.view = "practice";
229
- cacheNextQuestion(state);
230
- }
231
-
232
- async function takeNextQuestion(state: AppState) {
233
- if (state.reviewMode) {
234
- return takeReviewQuestion(state);
235
- }
236
-
237
- const cached = state.nextQuestion;
238
- state.nextQuestion = undefined;
239
-
240
- if (cached) {
241
- const question = await cached;
242
- if (question) {
243
- return question;
244
- }
245
- }
246
-
247
- return fetchPracticeQuestion(questionExclusions(state), state.focus);
248
- }
249
-
250
- async function takeReviewQuestion(state: AppState): Promise<PracticeQuestion | undefined> {
251
- while (state.reviewQuestionIds && state.reviewQuestionIds.length > 0) {
252
- const id = state.reviewQuestionIds.shift();
253
- if (!id) {
254
- continue;
255
- }
256
-
257
- const question = await findQuestionByShortId(id);
258
- if (question) {
259
- return question;
260
- }
261
- }
262
-
263
- return undefined;
264
- }
265
-
266
- function cacheNextQuestion(state: AppState): void {
267
- if (state.reviewMode) {
268
- return;
269
- }
270
-
271
- state.nextQuestion = fetchPracticeQuestion(questionExclusions(state), state.focus).catch(() => undefined);
272
- }
273
-
274
- function questionExclusions(state: AppState): string[] {
275
- return [
276
- ...state.attempts.keys(),
277
- ...state.skippedIds,
278
- ...(state.question ? [state.question.meta.questionId] : []),
279
- ];
280
- }
281
-
282
- function skipQuestion(state: AppState): void {
283
- if (state.question) {
284
- state.skippedIds.add(state.question.meta.questionId);
285
- }
286
- }
287
-
288
- function handlePaneKey(state: AppState, name: string): boolean {
289
- if (name === "TAB" || name === "SHIFT_TAB") {
290
- state.activePane = alternatePane(state.activePane);
291
- return true;
292
- }
293
-
294
- if (state.view === "practice" && state.activePane === "answers" && (name === "UP" || name === "k" || name === "DOWN" || name === "j")) {
295
- moveSelectedAnswer(state, name === "UP" || name === "k" ? -1 : 1);
296
- return true;
297
- }
298
-
299
- if (name === "UP" || name === "k") {
300
- scrollActivePaneBy(state, -1);
301
- return true;
302
- }
303
-
304
- if (name === "DOWN" || name === "j") {
305
- scrollActivePaneBy(state, 1);
306
- return true;
307
- }
308
-
309
- if (name === "PAGE_UP" || name === "[") {
310
- scrollActivePanePage(state, -1);
311
- return true;
312
- }
313
-
314
- if (name === "PAGE_DOWN" || name === "]") {
315
- scrollActivePanePage(state, 1);
316
- return true;
317
- }
318
-
319
- if (name === "HOME" || name === "g") {
320
- scrollActivePaneToEdge(state, "top");
321
- return true;
322
- }
323
-
324
- if (name === "END" || name === "G") {
325
- scrollActivePaneToEdge(state, "bottom");
326
- return true;
327
- }
328
-
329
- return false;
330
- }
331
-
332
- function moveSelectedAnswer(state: AppState, delta: number): void {
333
- const choices = answerKeys(state.question);
334
- if (choices.length === 0) {
335
- return;
336
- }
337
-
338
- state.selected = Math.max(0, Math.min(choices.length - 1, state.selected + delta));
339
- ensureSelectedAnswerVisible(state);
340
- }
341
-
342
- function scrollActivePaneBy(state: AppState, delta: number): void {
343
- setActivePaneScroll(state, scrollBy(activePaneViewport(state), delta));
344
- }
345
-
346
- function scrollActivePanePage(state: AppState, direction: 1 | -1): void {
347
- setActivePaneScroll(state, scrollPage(activePaneViewport(state), direction));
348
- }
349
-
350
- function scrollActivePaneToEdge(state: AppState, edge: "top" | "bottom"): void {
351
- setActivePaneScroll(state, scrollToEdge(activePaneViewport(state), edge));
352
- }
353
-
354
- function activePaneViewport(state: AppState): PaneViewport {
355
- const panes = paneLayout();
356
- const height = paneViewportHeight();
357
-
358
- if (state.activePane === "question") {
359
- const question = currentPaneQuestion(state);
360
- return {
361
- scroll: state.questionScroll,
362
- height,
363
- contentRows: question ? questionRows(question.detail, panes.leftWidth).length : 0,
364
- };
365
- }
366
-
367
- return {
368
- scroll: state.answerScroll,
369
- height,
370
- contentRows: rightPaneRows(state, panes.rightWidth).length,
371
- };
372
- }
373
-
374
- function setActivePaneScroll(state: AppState, scroll: number): void {
375
- if (state.activePane === "question") {
376
- state.questionScroll = scroll;
377
- } else {
378
- state.answerScroll = scroll;
379
- }
380
- }
381
-
382
- function ensureSelectedAnswerVisible(state: AppState): void {
383
- if (!state.question) {
384
- return;
385
- }
386
-
387
- const panes = paneLayout();
388
- const content = practiceAnswerPaneRows(state.question, state.selected, panes.rightWidth);
389
- const choice = content.choices[state.selected];
390
- if (!choice) {
391
- return;
392
- }
393
-
394
- state.answerScroll = ensureRangeVisible(
395
- {
396
- scroll: state.answerScroll,
397
- height: paneViewportHeight(),
398
- contentRows: content.rows.length,
399
- },
400
- choice.start,
401
- choice.end,
402
- );
403
- }
404
-
405
- function resetPaneScroll(state: AppState): void {
406
- state.questionScroll = 0;
407
- state.answerScroll = 0;
408
- state.activePane = "answers";
409
- }
410
-
411
- function currentPaneQuestion(state: AppState): PracticeQuestion | undefined {
412
- return state.view === "detail" ? state.detailQuestion : state.question;
413
- }
414
-
415
- function isScrollablePaneView(state: AppState): boolean {
416
- if (state.view !== "practice" && state.view !== "review" && state.view !== "detail") {
417
- return false;
418
- }
419
-
420
- const question = currentPaneQuestion(state);
421
- return Boolean(question && !questionNeedsExternalDisplay(question.detail));
422
- }
423
-
424
- function rightPaneRows(state: AppState, width: number) {
425
- if (state.view === "practice" && state.question) {
426
- return practiceAnswerPaneRows(state.question, state.selected, width).rows;
427
- }
428
-
429
- if (state.view === "review" && state.question) {
430
- return reviewPaneRows(state.question, {
431
- lastAnswer: state.lastAnswer,
432
- lastCorrect: state.lastCorrect,
433
- elapsed: formatElapsed(elapsedQuestionSeconds(state)),
434
- }, width);
435
- }
436
-
437
- if (state.view === "detail" && state.detailQuestion) {
438
- return detailPaneRows(state.detailQuestion, state.attempts.get(state.detailQuestion.meta.questionId), width);
439
- }
440
-
441
- return [];
442
- }
package/src/tui/kit.ts DELETED
@@ -1,9 +0,0 @@
1
- import terminalKit from "terminal-kit";
2
-
3
- export const term = terminalKit.terminal;
4
-
5
- export function terminalSize(): { width: number; height: number } {
6
- const width = Number.isFinite(term.width) ? term.width : 80;
7
- const height = Number.isFinite(term.height) ? term.height : 24;
8
- return { width, height };
9
- }
package/src/tui/layout.ts DELETED
@@ -1,25 +0,0 @@
1
- import { terminalSize } from "./kit.ts";
2
- import type { PaneLayout } from "./types.ts";
3
-
4
- export const PANE_HEADER_Y = 2;
5
- export const PANE_BODY_Y = 4;
6
-
7
- export function paneLayout(): PaneLayout {
8
- const { width } = terminalSize();
9
- const gutter = 2;
10
- const usable = Math.max(40, width - gutter);
11
- const leftWidth = Math.max(20, Math.floor(usable / 2));
12
- const rightX = leftWidth + gutter;
13
- const rightWidth = Math.max(20, width - rightX);
14
-
15
- return {
16
- leftX: 0,
17
- leftWidth,
18
- rightX,
19
- rightWidth,
20
- };
21
- }
22
-
23
- export function paneViewportHeight(): number {
24
- return Math.max(1, terminalSize().height - 6);
25
- }
@@ -1,150 +0,0 @@
1
- import { htmlToText, wrapText } from "../text.ts";
2
- import type { Attempt, Outcome, PracticeQuestion, QuestionMeta } from "../types.ts";
3
- import { answerKeys } from "./question.ts";
4
-
5
- export type PaneRowKind = "normal" | "muted" | "heading" | "info" | "success" | "danger" | "warning" | "selected";
6
-
7
- export type PaneTextRow = {
8
- text: string;
9
- kind?: PaneRowKind;
10
- bold?: boolean;
11
- };
12
-
13
- export type ChoiceRange = {
14
- key: string;
15
- start: number;
16
- end: number;
17
- };
18
-
19
- export type PracticeAnswerPane = {
20
- rows: PaneTextRow[];
21
- choices: ChoiceRange[];
22
- };
23
-
24
- export type ReviewPaneOptions = {
25
- lastAnswer?: string;
26
- lastCorrect?: boolean;
27
- elapsed: string;
28
- };
29
-
30
- export function practiceAnswerPaneRows(question: PracticeQuestion, selected: number, width: number): PracticeAnswerPane {
31
- const rows: PaneTextRow[] = [];
32
- const choices: ChoiceRange[] = [];
33
- const keys = answerKeys(question);
34
-
35
- for (const [index, key] of keys.entries()) {
36
- const selectedChoice = index === selected;
37
- const marker = selectedChoice ? ">" : " ";
38
- const start = rows.length;
39
- appendWrapped(rows, `${marker} ${key}. ${htmlToText(question.detail.answerOptions[key])}`, width, {
40
- kind: selectedChoice ? "selected" : "normal",
41
- bold: selectedChoice,
42
- });
43
- choices.push({ key, start, end: rows.length - 1 });
44
-
45
- if (index < keys.length - 1) {
46
- rows.push({ text: "" });
47
- }
48
- }
49
-
50
- return { rows, choices };
51
- }
52
-
53
- export function reviewPaneRows(question: PracticeQuestion, options: ReviewPaneOptions, width: number): PaneTextRow[] {
54
- const rows: PaneTextRow[] = [];
55
- const correct = options.lastCorrect ? "correct" : "incorrect";
56
- const resultKind = options.lastCorrect ? "success" : "danger";
57
-
58
- rows.push({ text: correct.toUpperCase(), kind: resultKind, bold: true });
59
- rows.push({ text: `your answer: ${options.lastAnswer ?? "-"}`, kind: resultKind });
60
- rows.push({ text: `correct: ${question.detail.correct_answer.join(", ")}`, kind: "success" });
61
- rows.push({ text: `time: ${options.elapsed}`, kind: "info" });
62
- appendGap(rows);
63
- appendMetadataRows(rows, question.meta);
64
- appendGap(rows);
65
- rows.push({ text: "rationale", kind: "heading", bold: true });
66
- appendWrapped(rows, htmlToText(question.detail.rationale), width);
67
-
68
- return rows;
69
- }
70
-
71
- export function detailPaneRows(question: PracticeQuestion, attempt: Attempt | undefined, width: number): PaneTextRow[] {
72
- const rows: PaneTextRow[] = [];
73
- appendMetadataRows(rows, question.meta, attempt);
74
- appendGap(rows);
75
- rows.push({ text: `correct: ${question.detail.correct_answer.join(", ")}`, kind: "success", bold: true });
76
- rows.push({ text: "answer key", kind: "heading", bold: true });
77
-
78
- for (const key of answerKeys(question)) {
79
- const correct = question.detail.correct_answer.includes(key);
80
- const label = correct ? "*" : " ";
81
- appendWrapped(rows, `${label} ${key}. ${htmlToText(question.detail.answerOptions[key])}`, width, {
82
- kind: correct ? "success" : "muted",
83
- bold: correct,
84
- });
85
- rows.push({ text: "" });
86
- }
87
-
88
- if (question.detail.rationale) {
89
- appendGap(rows);
90
- rows.push({ text: "rationale", kind: "heading", bold: true });
91
- appendWrapped(rows, htmlToText(question.detail.rationale), width);
92
- }
93
-
94
- return trimTrailingBlankRows(rows);
95
- }
96
-
97
- export function formatDomain(meta: QuestionMeta): string {
98
- return meta.primary_class_cd_desc ? `${meta.primary_class_cd} ${meta.primary_class_cd_desc}` : meta.primary_class_cd;
99
- }
100
-
101
- export function formatSkill(meta: QuestionMeta): string {
102
- return meta.skill_desc ? `${meta.skill_cd} ${meta.skill_desc}` : meta.skill_cd;
103
- }
104
-
105
- export function outcomeKind(outcome: Outcome): PaneRowKind {
106
- if (outcome === "correct") {
107
- return "success";
108
- }
109
- if (outcome === "incorrect") {
110
- return "danger";
111
- }
112
- return "warning";
113
- }
114
-
115
- export function difficultyKind(difficulty: string): PaneRowKind {
116
- if (difficulty === "E") {
117
- return "success";
118
- }
119
- if (difficulty === "H") {
120
- return "danger";
121
- }
122
- return "warning";
123
- }
124
-
125
- function appendMetadataRows(rows: PaneTextRow[], meta: QuestionMeta, attempt?: Attempt): void {
126
- if (attempt) {
127
- rows.push({ text: attempt.outcome.toUpperCase(), kind: outcomeKind(attempt.outcome), bold: true });
128
- }
129
-
130
- rows.push({ text: formatDomain(meta), kind: "info" });
131
- rows.push({ text: formatSkill(meta), kind: "info" });
132
- rows.push({ text: `difficulty ${meta.difficulty} | ${meta.questionId}`, kind: difficultyKind(meta.difficulty) });
133
- }
134
-
135
- function appendWrapped(rows: PaneTextRow[], value: string | undefined, width: number, row: Omit<PaneTextRow, "text"> = {}): void {
136
- for (const text of wrapText(value ?? "", width)) {
137
- rows.push({ ...row, text });
138
- }
139
- }
140
-
141
- function appendGap(rows: PaneTextRow[]): void {
142
- rows.push({ text: "" });
143
- }
144
-
145
- function trimTrailingBlankRows(rows: PaneTextRow[]): PaneTextRow[] {
146
- while (rows.at(-1)?.text === "") {
147
- rows.pop();
148
- }
149
- return rows;
150
- }
@@ -1,46 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { hasHtmlTable, parseHtmlSegments, wrapSegments } from "../text.ts";
3
- import type { PracticeQuestion, QuestionDetail } from "../types.ts";
4
- import type { DisplayRow } from "./types.ts";
5
-
6
- export function answerKeys(question?: PracticeQuestion): string[] {
7
- return Object.keys(question?.detail.answerOptions ?? {}).sort();
8
- }
9
-
10
- export function questionNeedsExternalDisplay(detail: QuestionDetail): boolean {
11
- return hasHtmlTable(
12
- detail.stimulus,
13
- detail.stem,
14
- detail.rationale,
15
- ...Object.values(detail.answerOptions),
16
- );
17
- }
18
-
19
- export function questionRows(detail: QuestionDetail, width: number): DisplayRow[] {
20
- const rows: DisplayRow[] = [];
21
- const stimulus = wrapSegments(parseHtmlSegments(detail.stimulus), width).map((segments) => ({ segments }));
22
- const stem = wrapSegments(parseHtmlSegments(detail.stem), width).map((segments) => ({ segments, bold: true }));
23
-
24
- rows.push(...stimulus);
25
- if (stimulus.length > 0 && stem.length > 0) {
26
- rows.push({ segments: [{ text: "", style: {} }] });
27
- }
28
- rows.push(...stem);
29
- return rows;
30
- }
31
-
32
- export function practiceQuestionUrl(question: PracticeQuestion): string {
33
- return `https://mysatprep.fun/question/${encodeURIComponent(question.meta.questionId)}`;
34
- }
35
-
36
- export function practiceQuestionApiUrl(question: PracticeQuestion): string {
37
- return `https://mysatprep.fun/api/question/${encodeURIComponent(question.meta.external_id)}`;
38
- }
39
-
40
- export function openExternalQuestion(question: PracticeQuestion): void {
41
- const child = spawn("open", [practiceQuestionUrl(question)], {
42
- detached: true,
43
- stdio: "ignore",
44
- });
45
- child.unref();
46
- }