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.
- package/README.md +172 -9
- package/data/question-bank.json.zst +0 -0
- package/package.json +17 -11
- package/src/cli/commands/_app.tsx +10 -0
- package/src/cli/commands/config/index.tsx +21 -0
- package/src/cli/commands/config/reset.tsx +12 -0
- package/src/cli/commands/config/set.tsx +34 -0
- package/src/cli/commands/focus.tsx +12 -0
- package/src/cli/commands/history.tsx +21 -0
- package/src/cli/commands/index.tsx +7 -0
- package/src/cli/commands/review.tsx +7 -0
- package/src/cli/commands/show.tsx +36 -0
- package/src/cli/commands/stats.tsx +15 -0
- package/src/cli/commands/weak.tsx +13 -0
- package/src/cli/components/command-action.tsx +23 -0
- package/src/cli/components/report.tsx +16 -0
- package/src/cli/index.ts +10 -0
- package/src/cli/report-options.ts +19 -0
- package/src/cli/reports/focus.ts +20 -0
- package/src/cli/reports/history.ts +21 -0
- package/src/cli/reports/question.ts +24 -0
- package/src/cli/reports/stats.ts +66 -0
- package/src/cli/reports/terminal-format.ts +28 -0
- package/src/cli/reports/weaknesses.ts +36 -0
- package/src/database/focus-repository.ts +26 -0
- package/src/database/index.ts +28 -0
- package/src/database/migrations.ts +100 -0
- package/src/database/progress-repository.ts +90 -0
- package/src/local-data/paths.ts +17 -0
- package/src/local-data/setup.ts +25 -0
- package/src/practice/answer-question.ts +25 -0
- package/src/practice/question-queue.ts +58 -0
- package/src/preferences/index.ts +131 -0
- package/src/preferences/preferences.schema.json +45 -0
- package/src/progress/activity.ts +55 -0
- package/src/progress/attempt.ts +70 -0
- package/src/progress/history.ts +34 -0
- package/src/progress/review-queue.ts +44 -0
- package/src/progress/statistics.ts +35 -0
- package/src/progress/weaknesses.ts +44 -0
- package/src/questions/focus.ts +77 -0
- package/src/questions/local-bank.ts +133 -0
- package/src/questions/practice-sat.ts +14 -0
- package/src/questions/question.ts +25 -0
- package/src/questions/taxonomy.ts +56 -0
- package/src/text/duration.ts +4 -0
- package/src/{text.ts → text/html.ts} +3 -119
- package/src/text/media.ts +16 -0
- package/src/text/rich-text.ts +37 -0
- package/src/text/wrap.ts +59 -0
- package/src/tui/app.tsx +243 -0
- package/src/tui/components/chrome.tsx +39 -0
- package/src/tui/components/question-content.tsx +91 -0
- package/src/tui/hooks/use-terminal-size.ts +16 -0
- package/src/tui/screens/detail.tsx +20 -0
- package/src/tui/screens/focus.tsx +244 -0
- package/src/tui/screens/history.tsx +33 -0
- package/src/tui/screens/practice.tsx +159 -0
- package/src/tui/screens/result.tsx +169 -0
- package/src/tui/screens/setup.tsx +17 -0
- package/src/tui/screens/summary.tsx +16 -0
- package/tsconfig.json +34 -0
- package/src/cli.ts +0 -917
- package/src/focus.ts +0 -231
- package/src/main.ts +0 -13
- package/src/progress.ts +0 -48
- package/src/question-bank.ts +0 -219
- package/src/state.ts +0 -346
- package/src/tui/app.ts +0 -140
- package/src/tui/focus-grid.ts +0 -113
- package/src/tui/frame.ts +0 -210
- package/src/tui/history.ts +0 -6
- package/src/tui/input.ts +0 -442
- package/src/tui/kit.ts +0 -9
- package/src/tui/layout.ts +0 -25
- package/src/tui/pane-content.ts +0 -150
- package/src/tui/question.ts +0 -47
- package/src/tui/render.ts +0 -630
- package/src/tui/timer.ts +0 -43
- package/src/tui/types.ts +0 -51
- package/src/tui/viewport.ts +0 -60
- package/src/tui.ts +0 -1
- package/src/types/terminal-kit.d.ts +0 -121
- package/src/types.ts +0 -69
- package/src/urls.ts +0 -2
package/README.md
CHANGED
|
@@ -1,21 +1,184 @@
|
|
|
1
|
-
|
|
1
|
+
# saterminal
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
Every report also supports:
|
|
16
53
|
|
|
17
54
|
```sh
|
|
18
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
|
-
"sat": "src/
|
|
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/
|
|
14
|
-
"start": "bun run src/
|
|
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
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
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.
|
|
31
|
+
"@types/bun": "^1.3.14",
|
|
27
32
|
"@types/he": "^1.2.3",
|
|
28
|
-
"@types/
|
|
33
|
+
"@types/react": "19.2.0",
|
|
29
34
|
"p-limit": "^7.3.0",
|
|
30
|
-
"
|
|
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,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
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -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
|
+
}
|