saterminal 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +172 -9
  2. package/data/question-bank.json.zst +0 -0
  3. package/package.json +17 -11
  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/tsconfig.json +34 -0
  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/question-bank.ts +0 -219
  68. package/src/state.ts +0 -346
  69. package/src/tui/app.ts +0 -140
  70. package/src/tui/focus-grid.ts +0 -113
  71. package/src/tui/frame.ts +0 -210
  72. package/src/tui/history.ts +0 -6
  73. package/src/tui/input.ts +0 -442
  74. package/src/tui/kit.ts +0 -9
  75. package/src/tui/layout.ts +0 -25
  76. package/src/tui/pane-content.ts +0 -150
  77. package/src/tui/question.ts +0 -47
  78. package/src/tui/render.ts +0 -630
  79. package/src/tui/timer.ts +0 -43
  80. package/src/tui/types.ts +0 -51
  81. package/src/tui/viewport.ts +0 -60
  82. package/src/tui.ts +0 -1
  83. package/src/types/terminal-kit.d.ts +0 -121
  84. package/src/types.ts +0 -69
  85. package/src/urls.ts +0 -2
package/README.md CHANGED
@@ -1,21 +1,184 @@
1
- why?
1
+ # saterminal
2
2
 
3
- - i don't like how the official SAT question bank forces u to download PDFs
4
- - official SAT question bank + other third party question readers display the question difficulty and other metadata (their UIs are also quite chopped)
5
- - used to use oneprep until i heard they started AI-generating questions, sooo...
3
+ SAT practice that stays on your computer. The app ships with a compressed question bank, works without an account or network connection, and records progress in a local SQLite database.
6
4
 
7
- usage
5
+ It exists because studying one question at a time should not require downloading worksheets, navigating a website, or looking at difficulty metadata before answering.
6
+
7
+ ## Get started
8
+
9
+ The development environment is defined by Nix and uses Bun throughout.
8
10
 
9
11
  ```sh
12
+ direnv allow
13
+ bun install
14
+ bun link
10
15
  sat
11
16
  ```
12
17
 
13
- The package ships `data/question-bank.json.zst` as the initial bank. On first use, Sat materializes it into a plain JSON cache at `~/.saterminal/userlocal/cache/question-bank.json` so regular practice reads directly from local JSON.
18
+ Without direnv, enter the shell explicitly:
19
+
20
+ ```sh
21
+ nix develop
22
+ bun install
23
+ bun run dev
24
+ ```
25
+
26
+ Build and run the packaged application directly with Nix:
27
+
28
+ ```sh
29
+ nix build
30
+ ./result/bin/sat --help
31
+ ```
32
+
33
+ The first launch asks before creating `~/.saterminal`. Choose a focus, press Enter, and start answering. Everything needed for practice is already in the package.
34
+
35
+ Practice deliberately hides difficulty, domain, and skill while a question is active. The header shows only the running timer. After submitting, the answer screen restores the complete question beside the marked choices and explanation, then reveals timing and question metadata for review.
36
+
37
+ ## Useful commands
38
+
39
+ Running `sat` opens normal practice. Reports use the richer terminal layout by default; no `-p` flag is necessary.
40
+
41
+ ```sh
42
+ sat # choose a focus and practice
43
+ sat review # revisit eligible missed and corrected questions
44
+ sat weak # rank the skills that need attention
45
+ sat stats # accuracy, timing, streak, and activity
46
+ sat history # recent question outcomes
47
+ sat history --wrong --since 2w
48
+ sat focus # inspect the active focus
49
+ sat config # inspect local preferences
50
+ ```
14
51
 
15
- maintainers
52
+ Every report also supports:
16
53
 
17
54
  ```sh
18
- bun run update-bank
55
+ sat stats --plain # compact, stable text
56
+ sat stats --json # machine-readable output
57
+ sat stats --no-color
58
+ NO_COLOR=1 sat weak
19
59
  ```
20
60
 
21
- This refreshes `data/question-bank.json.zst` from Practice SAT for the next package release.
61
+ Use `sat <command> --help` for command-specific filters.
62
+
63
+ ## Review spacing
64
+
65
+ A missed or corrected question enters `sat review` only after both default spacing requirements are met:
66
+
67
+ - at least 7 days have passed since its latest answer;
68
+ - at least 100 answer events were recorded after that answer.
69
+
70
+ This prevents an immediate retry from masquerading as durable recall. Change either threshold with:
71
+
72
+ ```sh
73
+ sat config set --minimum-days 14
74
+ sat config set --minimum-answers-after 200
75
+ sat config set --minimum-days 14 --minimum-answers-after 200
76
+ sat config reset
77
+ ```
78
+
79
+ ## Result detail
80
+
81
+ Choose how much learner-facing context appears after an answer:
82
+
83
+ ```sh
84
+ sat config set --result-detail brief # time
85
+ sat config set --result-detail standard # time and difficulty (default)
86
+ sat config set --result-detail detailed # time, difficulty, and taxonomy
87
+ ```
88
+
89
+ The verdict, answer choices, and explanation remain visible at every level. Internal question IDs stay available through `sat show`, not the result screen.
90
+
91
+ ## Preferences file
92
+
93
+ You can also edit `~/.saterminal/preferences.json` directly:
94
+
95
+ ```json
96
+ {
97
+ "$schema": "./preferences.schema.json",
98
+ "review": {
99
+ "minimumDays": 7,
100
+ "minimumAnswersAfter": 100
101
+ },
102
+ "display": {
103
+ "resultDetail": "standard"
104
+ }
105
+ }
106
+ ```
107
+
108
+ Set `display.resultDetail` to `brief`, `standard`, or `detailed` to control answer-result context. Brief shows time, standard adds difficulty, and detailed adds taxonomy codes and labels. The adjacent JSON Schema provides editor validation, descriptions, and completions. Omitted values use their defaults. Unknown, negative, fractional, or malformed values are rejected with the path to the invalid file.
109
+
110
+ ## Interactive keys
111
+
112
+ The footer always shows the keys available on the current screen. The common ones are:
113
+
114
+ | Key | Action |
115
+ | --- | --- |
116
+ | `j` / `k`, arrows | Move through focus options and answers; scroll the active review pane |
117
+ | `Tab`, left/right | Move between focus groups or question/answer panes |
118
+ | `Space` | Toggle a focus option; pause a running question timer |
119
+ | `Enter` | Start, submit, continue, or open a history item |
120
+ | `A`–`D` | Select an answer directly |
121
+ | `[` / `]`, Page Up/Down | Scroll the active question, answer, or explanation pane |
122
+ | `t` | Show or hide the timer while actively answering |
123
+ | `f` / `h` / `s` / `p` | Focus / history / summary / practice |
124
+ | `q` | Quit |
125
+
126
+ Questions containing HTML tables can be opened on Practice SAT with `o`; the rest of the practice flow remains local.
127
+
128
+ ## Local data
129
+
130
+ - `~/.saterminal/sat.db` contains focus, latest outcomes, and answer events.
131
+ - `~/.saterminal/.ignore` keeps the generated cache out of supporting editor file pickers.
132
+ - `~/.saterminal/preferences.json` contains optional review-spacing and display preferences.
133
+ - `~/.saterminal/preferences.schema.json` provides local editor validation and completions.
134
+ - `~/.saterminal/cache/question-bank.json` is the local materialized question bank.
135
+ - `data/question-bank.json.zst` is the bundled offline source used to create that cache.
136
+
137
+ Answer recording is transactional: the event history and latest per-question outcome either both commit or both roll back. Existing schema-version-one databases are migrated in place.
138
+
139
+ Deleting `~/.saterminal` resets local progress. There is deliberately no cloud account, sync service, server, or authentication layer.
140
+
141
+ ## Project map
142
+
143
+ The source tree is organized by ownership rather than file size:
144
+
145
+ ```text
146
+ src/
147
+ questions/ normalized SAT questions, taxonomy, focus, local bank
148
+ progress/ attempts and read-only analysis of recorded study
149
+ practice/ live study workflows and outcome transitions
150
+ preferences/ validated local user preferences
151
+ database/ SQLite schema, migration, and repositories
152
+ cli/ Pastel command routes and report presentation
153
+ tui/ Ink application, screens, and terminal components
154
+ text/ HTML/media normalization and terminal wrapping
155
+ local-data/ local filesystem locations
156
+ ```
157
+
158
+ Only the bank update script knows the upstream Practice SAT field names. Runtime code works with the normalized question model.
159
+
160
+ ## Maintain the question bank
161
+
162
+ Refresh the bundled bank for a future release with:
163
+
164
+ ```sh
165
+ nix develop -c bun run update-bank
166
+ ```
167
+
168
+ This is the only normal workflow that needs the network. It downloads Practice SAT data, normalizes it, and writes `data/question-bank.json.zst`.
169
+
170
+ ## Verify changes
171
+
172
+ ```sh
173
+ nix develop -c bun run typecheck
174
+ nix develop -c bun test
175
+ nix develop -c bun run src/cli/index.ts --help
176
+ nix build
177
+ ./result/bin/sat --version
178
+ ```
179
+
180
+ When `bun.lock` changes, regenerate the committed Nix dependency expression before building:
181
+
182
+ ```sh
183
+ nix develop -c bun run nix:deps
184
+ ```
Binary file
package/package.json CHANGED
@@ -1,32 +1,38 @@
1
1
  {
2
2
  "name": "saterminal",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "bin": {
6
- "sat": "src/main.ts"
6
+ "sat": "src/cli/index.ts"
7
7
  },
8
8
  "files": [
9
9
  "data",
10
- "src"
10
+ "src",
11
+ "tsconfig.json"
11
12
  ],
12
13
  "scripts": {
13
- "dev": "bun run src/main.ts",
14
- "start": "bun run src/main.ts",
14
+ "dev": "bun run src/cli/index.ts",
15
+ "start": "bun run src/cli/index.ts",
16
+ "nix:deps": "bun2nix -o nix/bun.nix",
15
17
  "update-bank": "bun run scripts/update-question-bank.ts",
16
18
  "test": "bun test",
17
19
  "typecheck": "tsc --noEmit"
18
20
  },
19
21
  "dependencies": {
22
+ "@inkjs/ui": "2.0.0",
20
23
  "he": "^1.2.0",
21
- "node-html-parser": "^8.0.4",
22
- "terminal-kit": "^3.1.2",
23
- "word-wrap": "^1.2.5"
24
+ "ink": "6.8.0",
25
+ "node-html-parser": "^9.0.0",
26
+ "pastel": "4.0.1",
27
+ "react": "19.2.0",
28
+ "zod": "4"
24
29
  },
25
30
  "devDependencies": {
26
- "@types/bun": "^1.2.22",
31
+ "@types/bun": "^1.3.14",
27
32
  "@types/he": "^1.2.3",
28
- "@types/word-wrap": "^1.2.1",
33
+ "@types/react": "19.2.0",
29
34
  "p-limit": "^7.3.0",
30
- "typescript": "^5.9.3"
35
+ "react-devtools-core": "7.0.1",
36
+ "typescript": "^7.0.2"
31
37
  }
32
38
  }
@@ -0,0 +1,10 @@
1
+ import { defaultTheme, ThemeProvider } from "@inkjs/ui";
2
+ import type { AppProps } from "pastel";
3
+
4
+ export default function CommandApp({ Component, commandProps }: AppProps) {
5
+ return (
6
+ <ThemeProvider theme={defaultTheme}>
7
+ <Component {...commandProps} />
8
+ </ThemeProvider>
9
+ );
10
+ }
@@ -0,0 +1,21 @@
1
+ import { CommandAction } from "@/cli/components/command-action.tsx";
2
+ import { displayPath, preferencesPath } from "@/local-data/paths.ts";
3
+ import { loadPreferences } from "@/preferences/index.ts";
4
+
5
+ export const description = "Show local preferences";
6
+
7
+ export default function ConfigCommand() {
8
+ return <CommandAction dependencies={[]} run={async () => {
9
+ const preferences = await loadPreferences();
10
+ return [
11
+ "preferences",
12
+ displayPath(preferencesPath),
13
+ "",
14
+ `review minimum days ${preferences.review.minimumDays}`,
15
+ `review minimum answers after ${preferences.review.minimumAnswersAfter}`,
16
+ `result detail ${preferences.display.resultDetail}`,
17
+ "",
18
+ "Update with: sat config set --minimum-days 7 --minimum-answers-after 100 --result-detail detailed",
19
+ ].join("\n");
20
+ }} />;
21
+ }
@@ -0,0 +1,12 @@
1
+ import { CommandAction } from "@/cli/components/command-action.tsx";
2
+ import { displayPath, preferencesPath } from "@/local-data/paths.ts";
3
+ import { defaultPreferences, savePreferences } from "@/preferences/index.ts";
4
+
5
+ export const description = "Restore default preferences";
6
+
7
+ export default function ResetConfigCommand() {
8
+ return <CommandAction dependencies={[]} run={async () => {
9
+ await savePreferences(defaultPreferences);
10
+ return `Reset ${displayPath(preferencesPath)} to the defaults.`;
11
+ }} />;
12
+ }
@@ -0,0 +1,34 @@
1
+ import { option } from "pastel";
2
+ import { z } from "zod";
3
+ import { CommandAction } from "@/cli/components/command-action.tsx";
4
+ import { displayPath, preferencesPath } from "@/local-data/paths.ts";
5
+ import { loadPreferences, resultDetailLevels, savePreferences } from "@/preferences/index.ts";
6
+
7
+ export const description = "Update local preferences";
8
+ export const options = z.object({
9
+ minimumDays: z.number().int().nonnegative().optional().describe(option({ description: "Days before a question can be reviewed", valueDescription: "days" })),
10
+ minimumAnswersAfter: z.number().int().nonnegative().optional().describe(option({ description: "Later answers required before review", valueDescription: "count" })),
11
+ resultDetail: z.enum(resultDetailLevels).optional().describe(option({ description: "Set answer-result detail", valueDescription: "level" })),
12
+ });
13
+ type Options = z.infer<typeof options>;
14
+
15
+ export default function SetConfigCommand({ options }: { options: Options }) {
16
+ return <CommandAction dependencies={[options.minimumDays, options.minimumAnswersAfter, options.resultDetail]} run={async () => {
17
+ if (options.minimumDays === undefined && options.minimumAnswersAfter === undefined && options.resultDetail === undefined) {
18
+ throw new Error("provide --minimum-days, --minimum-answers-after, or --result-detail");
19
+ }
20
+ const current = await loadPreferences();
21
+ const next = {
22
+ ...current,
23
+ review: {
24
+ minimumDays: options.minimumDays ?? current.review.minimumDays,
25
+ minimumAnswersAfter: options.minimumAnswersAfter ?? current.review.minimumAnswersAfter,
26
+ },
27
+ display: {
28
+ resultDetail: options.resultDetail ?? current.display.resultDetail,
29
+ },
30
+ };
31
+ await savePreferences(next);
32
+ return `Updated ${displayPath(preferencesPath)}\nreview: ${next.review.minimumDays} days · ${next.review.minimumAnswersAfter} later answers\nresult detail: ${next.display.resultDetail}`;
33
+ }} />;
34
+ }
@@ -0,0 +1,12 @@
1
+ import { loadFocus } from "@/database/focus-repository.ts";
2
+ import { Report } from "@/cli/components/report.tsx";
3
+ import { formatFocus } from "@/cli/reports/focus.ts";
4
+ import { outputMode, reportColor, reportOptions, type ReportOptions } from "@/cli/report-options.ts";
5
+
6
+ export const description = "Show the current question focus";
7
+ export const options = reportOptions;
8
+
9
+ export default function FocusCommand({ options }: { options: ReportOptions }) {
10
+ const output = formatFocus(loadFocus(), { mode: outputMode(options), color: reportColor(options) });
11
+ return <Report raw={options.json}>{output}</Report>;
12
+ }
@@ -0,0 +1,21 @@
1
+ import { option } from "pastel";
2
+ import { z } from "zod";
3
+ import { loadAttempts } from "@/database/progress-repository.ts";
4
+ import { history } from "@/progress/history.ts";
5
+ import { Report } from "@/cli/components/report.tsx";
6
+ import { formatHistory } from "@/cli/reports/history.ts";
7
+ import { outputMode, reportColor, reportOptions } from "@/cli/report-options.ts";
8
+
9
+ export const description = "Show answered questions";
10
+ export const options = reportOptions.extend({
11
+ wrong: z.boolean().describe(option({ description: "Show currently missed questions" })),
12
+ corrected: z.boolean().describe(option({ description: "Show corrected questions" })),
13
+ limit: z.number().int().positive().optional().describe(option({ description: "Limit rows", valueDescription: "count" })),
14
+ since: z.string().optional().describe(option({ description: "Show rows since an ISO date, 7d, or 2w", valueDescription: "when" })),
15
+ });
16
+ type Options = z.infer<typeof options>;
17
+
18
+ export default function HistoryCommand({ options }: { options: Options }) {
19
+ const output = formatHistory(history(loadAttempts().values(), options), { mode: outputMode(options), color: reportColor(options) });
20
+ return <Report raw={options.json}>{output}</Report>;
21
+ }
@@ -0,0 +1,7 @@
1
+ import { PracticeSession } from "@/tui/app.tsx";
2
+
3
+ export const description = "Open interactive SAT practice";
4
+
5
+ export default function PracticeCommand() {
6
+ return <PracticeSession />;
7
+ }
@@ -0,0 +1,7 @@
1
+ import { ReviewSession } from "@/tui/app.tsx";
2
+
3
+ export const description = "Practice eligible missed and corrected questions";
4
+
5
+ export default function ReviewCommand() {
6
+ return <ReviewSession />;
7
+ }
@@ -0,0 +1,36 @@
1
+ import { useApp } from "ink";
2
+ import { useEffect } from "react";
3
+ import { argument } from "pastel";
4
+ import { z } from "zod";
5
+ import { formatQuestion } from "@/cli/reports/question.ts";
6
+ import { findQuestion } from "@/questions/local-bank.ts";
7
+
8
+ export const description = "Inspect a question by ID";
9
+ export const args = z.tuple([
10
+ z.string().describe(argument({ name: "id", description: "Question ID" })),
11
+ ]);
12
+
13
+ export default function ShowCommand({ args: [id] }: { args: z.infer<typeof args> }) {
14
+ const { exit } = useApp();
15
+
16
+ useEffect(() => {
17
+ void (async () => {
18
+ try {
19
+ const question = await findQuestion(id);
20
+ if (!question) {
21
+ process.stderr.write(`sat: question ${id} was not found.\n`);
22
+ process.exitCode = 1;
23
+ return;
24
+ }
25
+ process.stdout.write(`${formatQuestion(question)}\n`);
26
+ } catch (error) {
27
+ process.stderr.write(`sat: ${error instanceof Error ? error.message : String(error)}\n`);
28
+ process.exitCode = 1;
29
+ } finally {
30
+ exit();
31
+ }
32
+ })();
33
+ }, [exit, id]);
34
+
35
+ return null;
36
+ }
@@ -0,0 +1,15 @@
1
+ import { loadAttemptEvents, loadAttempts } from "@/database/progress-repository.ts";
2
+ import { activity } from "@/progress/activity.ts";
3
+ import { progressStatistics } from "@/progress/statistics.ts";
4
+ import { Report } from "@/cli/components/report.tsx";
5
+ import { formatStats } from "@/cli/reports/stats.ts";
6
+ import { outputMode, reportColor, reportOptions, type ReportOptions } from "@/cli/report-options.ts";
7
+
8
+ export const description = "Show progress, streak, and recent activity";
9
+ export const options = reportOptions;
10
+
11
+ export default function StatsCommand({ options }: { options: ReportOptions }) {
12
+ const attempts = loadAttempts();
13
+ const output = formatStats(progressStatistics(attempts.values()), activity(loadAttemptEvents()), { mode: outputMode(options), color: reportColor(options) });
14
+ return <Report raw={options.json}>{output}</Report>;
15
+ }
@@ -0,0 +1,13 @@
1
+ import { loadAttempts } from "@/database/progress-repository.ts";
2
+ import { weaknesses } from "@/progress/weaknesses.ts";
3
+ import { Report } from "@/cli/components/report.tsx";
4
+ import { formatWeaknesses } from "@/cli/reports/weaknesses.ts";
5
+ import { outputMode, reportColor, reportOptions, type ReportOptions } from "@/cli/report-options.ts";
6
+
7
+ export const description = "Rank skills that need the most work";
8
+ export const options = reportOptions;
9
+
10
+ export default function WeakCommand({ options }: { options: ReportOptions }) {
11
+ const output = formatWeaknesses(weaknesses(loadAttempts().values()), { mode: outputMode(options), color: reportColor(options) });
12
+ return <Report raw={options.json}>{output}</Report>;
13
+ }
@@ -0,0 +1,23 @@
1
+ import { useApp } from "ink";
2
+ import { useEffect } from "react";
3
+
4
+ export function CommandAction({ run, dependencies }: { run: () => string | Promise<string>; dependencies: readonly unknown[] }) {
5
+ const { exit } = useApp();
6
+ useEffect(() => {
7
+ void (async () => {
8
+ try {
9
+ process.stdout.write(`${await run()}\n`);
10
+ } catch (error) {
11
+ process.stderr.write(`sat: ${message(error)}\n`);
12
+ process.exitCode = 1;
13
+ } finally {
14
+ exit();
15
+ }
16
+ })();
17
+ }, [exit, ...dependencies]);
18
+ return null;
19
+ }
20
+
21
+ function message(value: unknown): string {
22
+ return value instanceof Error ? value.message : String(value);
23
+ }
@@ -0,0 +1,16 @@
1
+ import { Text, useApp } from "ink";
2
+ import { useEffect } from "react";
3
+
4
+ export function Report({ children, raw = false }: { children: string; raw?: boolean }) {
5
+ if (raw) return <RawOutput value={children} />;
6
+ return <Text>{children}</Text>;
7
+ }
8
+
9
+ function RawOutput({ value }: { value: string }) {
10
+ const { exit } = useApp();
11
+ useEffect(() => {
12
+ process.stdout.write(`${value}\n`);
13
+ exit();
14
+ }, [exit, value]);
15
+ return null;
16
+ }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bun
2
+ import Pastel from "pastel";
3
+
4
+ const cli = new Pastel({
5
+ name: "sat",
6
+ description: "Local-first SAT practice in your terminal",
7
+ importMeta: import.meta,
8
+ });
9
+
10
+ await cli.run();
@@ -0,0 +1,19 @@
1
+ import { option } from "pastel";
2
+ import { z } from "zod";
3
+
4
+ export const reportOptions = z.object({
5
+ plain: z.boolean().describe(option({ description: "Use a compact plain-text layout" })),
6
+ json: z.boolean().describe(option({ description: "Output machine-readable JSON" })),
7
+ color: z.boolean().default(true).describe(option({ description: "Disable ANSI color" })),
8
+ });
9
+
10
+ export type ReportOptions = z.infer<typeof reportOptions>;
11
+
12
+ export function outputMode(options: ReportOptions): "pretty" | "plain" | "json" {
13
+ if (options.plain && options.json) throw new Error("Choose either --plain or --json, not both.");
14
+ return options.json ? "json" : options.plain ? "plain" : "pretty";
15
+ }
16
+
17
+ export function reportColor(options: ReportOptions): boolean {
18
+ return options.color && process.env.NO_COLOR === undefined && process.stdout.isTTY;
19
+ }
@@ -0,0 +1,20 @@
1
+ import type { Focus } from "@/questions/focus.ts";
2
+ import { selectedDomains } from "@/questions/focus.ts";
3
+ import { difficultyLabels, domainLabels, skillLabels } from "@/questions/taxonomy.ts";
4
+ import { ansi, paint, type FormatSettings } from "@/cli/reports/terminal-format.ts";
5
+
6
+ export function formatFocus(focus: Focus, settings: FormatSettings): string {
7
+ const domains = selectedDomains(focus);
8
+ if (settings.mode === "json") return JSON.stringify({ ...focus, domains });
9
+ if (settings.mode === "plain") return `difficulties: ${focus.difficulties.join(",")}\ndomains: ${domains.join(",")}\nskills: ${focus.skills.join(",")}`;
10
+ return [
11
+ paint("focus", settings, ansi.bold, ansi.cyan),
12
+ paint(`${focus.skills.length} skills · ${focus.difficulties.join(",")} · ${domains.length} domains`, settings, ansi.gray),
13
+ "", paint("difficulty", settings, ansi.bold),
14
+ ...focus.difficulties.map((value) => ` ${paint(value.padEnd(3), settings, ansi.yellow, ansi.bold)} ${difficultyLabels[value]}`),
15
+ "", paint("domains", settings, ansi.bold),
16
+ ...domains.map((value) => ` ${paint(value.padEnd(3), settings, ansi.cyan, ansi.bold)} ${domainLabels[value]}`),
17
+ "", paint("skills", settings, ansi.bold),
18
+ ...focus.skills.map((value) => ` ${paint(value.padEnd(3), settings, ansi.green, ansi.bold)} ${skillLabels[value]}`),
19
+ ].join("\n");
20
+ }
@@ -0,0 +1,21 @@
1
+ import type { Attempt } from "@/progress/attempt.ts";
2
+ import { ansi, duration, paint, table, type FormatSettings } from "@/cli/reports/terminal-format.ts";
3
+
4
+ export function formatHistory(attempts: Attempt[], settings: FormatSettings): string {
5
+ if (settings.mode === "json") return JSON.stringify(attempts);
6
+ if (!attempts.length) return settings.mode === "pretty" ? `${paint("history", settings, ansi.bold, ansi.cyan)}\n${paint("No attempts matched.", settings, ansi.gray)}` : "No attempts matched.";
7
+ if (settings.mode === "plain") return table(["question", "outcome", "answered at", "time"], attempts.map((attempt) => [attempt.questionId, attempt.outcome, attempt.answeredAt, duration(attempt.durationSeconds)]));
8
+ const mastered = attempts.filter((attempt) => attempt.outcome !== "incorrect").length;
9
+ const missed = attempts.length - mastered;
10
+ return [
11
+ paint("history", settings, ansi.bold, ansi.cyan),
12
+ `${attempts.length} attempts ${paint(String(mastered), settings, ansi.green, ansi.bold)} mastered ${paint(String(missed), settings, missed ? ansi.red : ansi.gray, ansi.bold)} needs review`,
13
+ "",
14
+ table(["question", "result", "skill", "time", "updated"], attempts.map((attempt) => [attempt.questionId, attempt.outcome, attempt.skill ?? "-", duration(attempt.durationSeconds), timestamp(attempt.answeredAt)])),
15
+ ].join("\n");
16
+ }
17
+
18
+ function timestamp(value: string): string {
19
+ const date = new Date(value);
20
+ return Number.isNaN(date.getTime()) ? value : `${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
21
+ }
@@ -0,0 +1,24 @@
1
+ import type { Question } from "@/questions/question.ts";
2
+ import { difficultyLabels, domainLabels, skillLabels } from "@/questions/taxonomy.ts";
3
+ import { htmlToText } from "@/text/html.ts";
4
+
5
+ export function formatQuestion(question: Question): string {
6
+ const passage = question.passage ? `${htmlToText(question.passage)}\n\n` : "";
7
+ const choices = question.choices
8
+ .map((choice) => `${choice.key}. ${htmlToText(choice.content)}`)
9
+ .join("\n");
10
+
11
+ return [
12
+ `Question ${question.id}`,
13
+ `${difficultyLabels[question.difficulty]} · ${domainLabels[question.domain]} · ${skillLabels[question.skill]}`,
14
+ "",
15
+ `${passage}${htmlToText(question.prompt)}`,
16
+ "",
17
+ choices,
18
+ "",
19
+ `Correct answer: ${question.correctAnswers.join(", ")}`,
20
+ "",
21
+ "Explanation",
22
+ htmlToText(question.explanation ?? "No explanation is available."),
23
+ ].join("\n");
24
+ }