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/focus.ts DELETED
@@ -1,231 +0,0 @@
1
- import type { Difficulty, Domain, Focus, Skill } from "./types.ts";
2
-
3
- export const difficultyOptions = ["E", "M", "H"] as const satisfies readonly Difficulty[];
4
- export const domainOptions = ["INI", "CAS", "EOI", "SEC"] as const satisfies readonly Domain[];
5
- export const skillOptions = ["CID", "INF", "COE", "WIC", "TSP", "CTC", "SYN", "TRA", "BOU", "FSS"] as const satisfies readonly Skill[];
6
-
7
- export const skillsByDomain: Record<Domain, readonly Skill[]> = {
8
- INI: ["CID", "INF", "COE"],
9
- CAS: ["WIC", "TSP", "CTC"],
10
- EOI: ["SYN", "TRA"],
11
- SEC: ["BOU", "FSS"],
12
- };
13
-
14
- export const defaultFocus: Focus = {
15
- difficulties: ["M", "H"],
16
- domains: [...domainOptions],
17
- skills: [...skillOptions],
18
- };
19
-
20
- export type FocusGroup = "difficulties" | "domains" | "skills";
21
-
22
- export type FocusRow =
23
- | { kind: "header"; label: string }
24
- | {
25
- kind: "option";
26
- group: FocusGroup;
27
- value: Difficulty | Domain | Skill;
28
- label: string;
29
- checked: boolean;
30
- partial?: boolean;
31
- depth: number;
32
- };
33
-
34
- export function normalizeFocus(value: unknown): Focus {
35
- const raw = value && typeof value === "object" ? value as Partial<Record<keyof Focus, unknown>> : {};
36
- const skills = normalizeSkills(raw.skills, raw.domains);
37
- return {
38
- difficulties: normalizeSelection(raw.difficulties, difficultyOptions, defaultFocus.difficulties),
39
- domains: domainsForSkills(skills),
40
- skills,
41
- };
42
- }
43
-
44
- export function domainsForSkills(skills: readonly Skill[]): Domain[] {
45
- return domainOptions.filter((domain) => skillsForDomain(domain).some((skill) => skills.includes(skill)));
46
- }
47
-
48
- export function skillsForDomain(domain: Domain): readonly Skill[] {
49
- return skillsByDomain[domain];
50
- }
51
-
52
- export function focusSummary(focus: Focus): string {
53
- const difficulty = focus.difficulties.join(",");
54
- const skillCount = focus.skills.length;
55
- const domainCount = focus.domains.length;
56
- return `${skillCount} skills · ${difficulty} · ${domainCount} domains`;
57
- }
58
-
59
- export function focusRows(focus: Focus): FocusRow[] {
60
- return [
61
- { kind: "header", label: "Difficulty" },
62
- ...difficultyOptions.map((value) => optionRow("difficulties", value, difficultyLabels[value], focus.difficulties.includes(value), 0)),
63
- { kind: "header", label: "Reading and Writing" },
64
- ...domainOptions.flatMap((domain) => domainRows(domain, focus)),
65
- ];
66
- }
67
-
68
- export function toggleFocusRow(focus: Focus, row: FocusRow | undefined): Focus {
69
- if (!row || row.kind !== "option") {
70
- return focus;
71
- }
72
-
73
- if (row.group === "domains") {
74
- return toggleDomainFocus(focus, row.value as Domain);
75
- }
76
-
77
- if (row.group === "difficulties") {
78
- return toggleDifficultyFocus(focus, row.value as Difficulty);
79
- }
80
-
81
- return toggleSkillFocus(focus, row.value as Skill);
82
- }
83
-
84
- export function nextFocusableIndex(rows: FocusRow[], current: number): number {
85
- for (let index = Math.min(current + 1, rows.length - 1); index < rows.length; index++) {
86
- if (rows[index]?.kind === "option") {
87
- return index;
88
- }
89
- }
90
- return current;
91
- }
92
-
93
- export function previousFocusableIndex(rows: FocusRow[], current: number): number {
94
- for (let index = Math.max(current - 1, 0); index >= 0; index--) {
95
- if (rows[index]?.kind === "option") {
96
- return index;
97
- }
98
- }
99
- return current;
100
- }
101
-
102
- function domainRows(domain: Domain, focus: Focus): FocusRow[] {
103
- const skills = skillsForDomain(domain);
104
- const selected = skills.filter((skill) => focus.skills.includes(skill));
105
- return [
106
- optionRow("domains", domain, domainLabels[domain], selected.length > 0, 0, selected.length > 0 && selected.length < skills.length),
107
- ...skills.map((skill) => optionRow("skills", skill, skillLabels[skill], focus.skills.includes(skill), 1)),
108
- ];
109
- }
110
-
111
- function optionRow(
112
- group: FocusGroup,
113
- value: Difficulty | Domain | Skill,
114
- label: string,
115
- checked: boolean,
116
- depth: number,
117
- partial = false,
118
- ): FocusRow {
119
- return { kind: "option", group, value, label: `${value} ${label}`, checked, partial, depth };
120
- }
121
-
122
- function toggleDifficultyFocus(focus: Focus, difficulty: Difficulty): Focus {
123
- const selected = focus.difficulties.includes(difficulty);
124
- if (selected && focus.difficulties.length === 1) {
125
- return focus;
126
- }
127
-
128
- return {
129
- ...focus,
130
- difficulties: selected
131
- ? focus.difficulties.filter((value) => value !== difficulty)
132
- : unique([...focus.difficulties, difficulty]),
133
- };
134
- }
135
-
136
- function toggleSkillFocus(focus: Focus, skill: Skill): Focus {
137
- const selected = focus.skills.includes(skill);
138
- if (selected && focus.skills.length === 1) {
139
- return focus;
140
- }
141
-
142
- const skills = selected ? focus.skills.filter((value) => value !== skill) : unique([...focus.skills, skill]);
143
- return {
144
- ...focus,
145
- domains: domainsForSkills(skills),
146
- skills,
147
- };
148
- }
149
-
150
- function toggleDomainFocus(focus: Focus, domain: Domain): Focus {
151
- const domainSkills = skillsForDomain(domain);
152
- const allSelected = domainSkills.every((skill) => focus.skills.includes(skill));
153
-
154
- if (allSelected) {
155
- const remaining = focus.skills.filter((skill) => !domainSkills.includes(skill));
156
- if (remaining.length === 0) {
157
- return focus;
158
- }
159
-
160
- return {
161
- ...focus,
162
- domains: domainsForSkills(remaining),
163
- skills: remaining,
164
- };
165
- }
166
-
167
- const skills = unique([...focus.skills, ...domainSkills]);
168
- return {
169
- ...focus,
170
- domains: domainsForSkills(skills),
171
- skills,
172
- };
173
- }
174
-
175
- function normalizeSelection<T extends string>(value: unknown, options: readonly T[], fallback: readonly T[]): T[] {
176
- if (!Array.isArray(value)) {
177
- return [...fallback];
178
- }
179
-
180
- const selected = options.filter((option) => value.includes(option));
181
- return selected.length > 0 ? selected : [...fallback];
182
- }
183
-
184
- function normalizeSkills(value: unknown, domains: unknown): Skill[] {
185
- if (Array.isArray(value)) {
186
- const selected = skillOptions.filter((option) => value.includes(option));
187
- if (selected.length > 0) {
188
- return selected;
189
- }
190
- }
191
-
192
- if (Array.isArray(domains)) {
193
- const selectedDomains = domainOptions.filter((option) => domains.includes(option));
194
- const selectedSkills = selectedDomains.flatMap((domain) => [...skillsForDomain(domain)]);
195
- if (selectedSkills.length > 0) {
196
- return selectedSkills;
197
- }
198
- }
199
-
200
- return [...defaultFocus.skills];
201
- }
202
-
203
- function unique<T>(values: T[]): T[] {
204
- return values.filter((value, index, list) => list.indexOf(value) === index);
205
- }
206
-
207
- export const difficultyLabels: Record<Difficulty, string> = {
208
- E: "Easy",
209
- M: "Medium",
210
- H: "Hard",
211
- };
212
-
213
- export const domainLabels: Record<Domain, string> = {
214
- INI: "Information and Ideas",
215
- CAS: "Craft and Structure",
216
- EOI: "Expression of Ideas",
217
- SEC: "Standard English Conventions",
218
- };
219
-
220
- export const skillLabels: Record<Skill, string> = {
221
- CID: "Central Ideas and Details",
222
- INF: "Inferences",
223
- COE: "Command of Evidence",
224
- WIC: "Words in Context",
225
- TSP: "Text Structure and Purpose",
226
- CTC: "Cross-Text Connections",
227
- SYN: "Transitions",
228
- TRA: "Rhetorical Synthesis",
229
- BOU: "Boundaries",
230
- FSS: "Form, Structure, and Sense",
231
- };
package/src/main.ts DELETED
@@ -1,13 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { parseArgs, runCliCommand } from "./cli.ts";
3
- import { runTui } from "./tui.ts";
4
-
5
- const parsed = parseArgs(process.argv.slice(2));
6
-
7
- if (parsed.kind === "tui") {
8
- await runTui();
9
- } else if (parsed.kind === "review") {
10
- await runTui({ mode: "review" });
11
- } else {
12
- process.exitCode = await runCliCommand(parsed);
13
- }
package/src/progress.ts DELETED
@@ -1,48 +0,0 @@
1
- const partialBlocks = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"] as const;
2
-
3
- export type ProgressBar = {
4
- filled: string;
5
- partial: string;
6
- empty: string;
7
- fullCells: number;
8
- emptyCells: number;
9
- };
10
-
11
- export function progressBar(ratio: number, width: number): ProgressBar {
12
- const normalized = Number.isFinite(ratio) ? Math.min(1, Math.max(0, ratio)) : 0;
13
- const cells = Math.max(0, Math.floor(width));
14
- const exact = normalized * cells;
15
- const full = Math.floor(exact);
16
- const partial = Math.round((exact - full) * 8);
17
-
18
- if (cells === 0) {
19
- return { filled: "", partial: "", empty: "", fullCells: 0, emptyCells: 0 };
20
- }
21
-
22
- if (partial === 8 && full < cells) {
23
- const fullCells = full + 1;
24
- const emptyCells = cells - fullCells;
25
- return {
26
- filled: "█".repeat(fullCells),
27
- partial: "",
28
- empty: " ".repeat(emptyCells),
29
- fullCells,
30
- emptyCells,
31
- };
32
- }
33
-
34
- const partialBlock = full < cells ? partialBlocks[partial] : "";
35
- const emptyCells = cells - full - (partialBlock ? 1 : 0);
36
- return {
37
- filled: "█".repeat(full),
38
- partial: partialBlock,
39
- empty: " ".repeat(emptyCells),
40
- fullCells: full,
41
- emptyCells,
42
- };
43
- }
44
-
45
- export function progressBarText(ratio: number, width: number): string {
46
- const bar = progressBar(ratio, width);
47
- return `${bar.filled}${bar.partial}${bar.empty}`;
48
- }
package/src/state.ts DELETED
@@ -1,346 +0,0 @@
1
- import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { dirname, join, sep } from "node:path";
4
- import { defaultFocus, normalizeFocus } from "./focus.ts";
5
- import type { Attempt, AttemptEvent, Focus, Outcome, QuestionMeta, SummaryRow } from "./types.ts";
6
-
7
- export function resolveStateDir(home = homedir()): string {
8
- return join(home, ".saterminal", "userlocal");
9
- }
10
-
11
- export function displayStateDir(dir: string, home = homedir()): string {
12
- if (dir === home) {
13
- return "~";
14
- }
15
-
16
- const prefix = `${home}${sep}`;
17
- if (dir.startsWith(prefix)) {
18
- return `~${dir.slice(home.length)}`;
19
- }
20
-
21
- return dir;
22
- }
23
-
24
- export const stateDir = resolveStateDir();
25
- export const attemptsPath = join(stateDir, "attempts.csv");
26
- export const eventsPath = join(stateDir, "events.csv");
27
- export const summaryPath = join(stateDir, "summary.csv");
28
- export const focusPath = join(stateDir, "focus.json");
29
-
30
- const attemptsHeader = "question_id,outcome,updated_at,elapsed_seconds,difficulty,domain,domain_desc,skill,skill_desc";
31
- const eventsHeader = "question_id,correct,answered_at,elapsed_seconds,difficulty,domain,domain_desc,skill,skill_desc";
32
- const summaryHeader = "metric,value,updated_at";
33
-
34
- export async function stateDirExists(dir = stateDir): Promise<boolean> {
35
- try {
36
- return (await stat(dir)).isDirectory();
37
- } catch (error) {
38
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
39
- return false;
40
- }
41
-
42
- throw error;
43
- }
44
- }
45
-
46
- export async function ensureStateFiles(dir = stateDir): Promise<void> {
47
- await mkdir(dir, { recursive: true });
48
- await ensureFile(join(dir, "attempts.csv"), `${attemptsHeader}\n`);
49
- await ensureFile(join(dir, "events.csv"), `${eventsHeader}\n`);
50
- await ensureFile(join(dir, "summary.csv"), `${summaryHeader}\n`);
51
- await ensureFile(join(dir, "focus.json"), `${JSON.stringify(defaultFocus, null, 2)}\n`);
52
- }
53
-
54
- export async function loadAttempts(path = attemptsPath): Promise<Map<string, Attempt>> {
55
- await ensureFile(path, `${attemptsHeader}\n`);
56
- const raw = await readFile(path, "utf8");
57
- const rows = parseCsv(raw);
58
- const attempts = new Map<string, Attempt>();
59
-
60
- for (const row of rows.slice(1)) {
61
- const [question_id, outcome, updated_at, elapsed_seconds, difficulty, domain, domain_desc, skill, skill_desc] = row;
62
- if (!question_id || !isOutcome(outcome) || !updated_at) {
63
- continue;
64
- }
65
-
66
- attempts.set(question_id, {
67
- question_id,
68
- outcome,
69
- updated_at,
70
- elapsed_seconds: readElapsedSeconds(elapsed_seconds),
71
- ...optionalMetadata({ difficulty, domain, domain_desc, skill, skill_desc }),
72
- });
73
- }
74
-
75
- return attempts;
76
- }
77
-
78
- export async function saveAttempts(attempts: Map<string, Attempt>, path = attemptsPath): Promise<void> {
79
- await mkdir(dirname(path), { recursive: true });
80
- const rows = [...attempts.values()]
81
- .sort((a, b) => a.updated_at.localeCompare(b.updated_at))
82
- .map((attempt) => [
83
- attempt.question_id,
84
- attempt.outcome,
85
- attempt.updated_at,
86
- String(attempt.elapsed_seconds),
87
- attempt.difficulty ?? "",
88
- attempt.domain ?? "",
89
- attempt.domain_desc ?? "",
90
- attempt.skill ?? "",
91
- attempt.skill_desc ?? "",
92
- ]);
93
-
94
- await writeFile(path, formatCsv([attemptsHeader.split(","), ...rows]), "utf8");
95
- }
96
-
97
- export async function loadAttemptEvents(path = eventsPath): Promise<AttemptEvent[]> {
98
- await ensureFile(path, `${eventsHeader}\n`);
99
- const raw = await readFile(path, "utf8");
100
- const rows = parseCsv(raw);
101
- const events: AttemptEvent[] = [];
102
-
103
- for (const row of rows.slice(1)) {
104
- const [question_id, correct, answered_at, elapsed_seconds, difficulty, domain, domain_desc, skill, skill_desc] = row;
105
- if (!question_id || !isBooleanText(correct) || !answered_at) {
106
- continue;
107
- }
108
-
109
- events.push({
110
- question_id,
111
- correct: correct === "true",
112
- answered_at,
113
- elapsed_seconds: readElapsedSeconds(elapsed_seconds),
114
- difficulty: difficulty ?? "",
115
- domain: domain ?? "",
116
- domain_desc: domain_desc || undefined,
117
- skill: skill ?? "",
118
- skill_desc: skill_desc || undefined,
119
- });
120
- }
121
-
122
- return events;
123
- }
124
-
125
- export async function appendAttemptEvent(
126
- meta: QuestionMeta,
127
- correct: boolean,
128
- elapsedSeconds = 0,
129
- now = new Date(),
130
- path = eventsPath,
131
- ): Promise<AttemptEvent> {
132
- const event: AttemptEvent = {
133
- question_id: meta.questionId,
134
- correct,
135
- answered_at: now.toISOString(),
136
- elapsed_seconds: elapsedSeconds,
137
- difficulty: meta.difficulty,
138
- domain: meta.primary_class_cd,
139
- domain_desc: meta.primary_class_cd_desc,
140
- skill: meta.skill_cd,
141
- skill_desc: meta.skill_desc,
142
- };
143
-
144
- await ensureFile(path, `${eventsHeader}\n`);
145
- await appendFile(path, formatCsv([attemptEventRow(event)]), "utf8");
146
- return event;
147
- }
148
-
149
- export function recordAttempt(
150
- attempts: Map<string, Attempt>,
151
- questionId: string,
152
- wasCorrect: boolean,
153
- elapsedSeconds = 0,
154
- now = new Date(),
155
- meta?: QuestionMeta,
156
- ): Attempt {
157
- const existing = attempts.get(questionId);
158
- const updated_at = now.toISOString();
159
- const outcome = nextOutcome(existing?.outcome, wasCorrect);
160
- const attempt = {
161
- question_id: questionId,
162
- outcome,
163
- updated_at,
164
- elapsed_seconds: elapsedSeconds,
165
- ...metadataFromQuestionMeta(meta),
166
- };
167
- attempts.set(questionId, attempt);
168
- return attempt;
169
- }
170
-
171
- export function nextOutcome(previous: Outcome | undefined, wasCorrect: boolean): Outcome {
172
- if (previous === "correct" || previous === "corrected") {
173
- return previous;
174
- }
175
-
176
- if (previous === "incorrect" && wasCorrect) {
177
- return "corrected";
178
- }
179
-
180
- return wasCorrect ? "correct" : "incorrect";
181
- }
182
-
183
- export function buildSummaryRows(attempts: Map<string, Attempt>, now = new Date()): SummaryRow[] {
184
- const updated_at = now.toISOString();
185
- const values = [...attempts.values()];
186
- const total = values.length;
187
- const correct = values.filter((attempt) => attempt.outcome === "correct").length;
188
- const incorrect = values.filter((attempt) => attempt.outcome === "incorrect").length;
189
- const corrected = values.filter((attempt) => attempt.outcome === "corrected").length;
190
- const mastered = correct + corrected;
191
- const accuracy = total === 0 ? "0.00" : (mastered / total).toFixed(2);
192
- const totalSeconds = values.reduce((sum, attempt) => sum + attempt.elapsed_seconds, 0);
193
- const avgSeconds = total === 0 ? 0 : totalSeconds / total;
194
-
195
- return [
196
- { metric: "answered", value: String(total), updated_at },
197
- { metric: "correct", value: String(correct), updated_at },
198
- { metric: "incorrect", value: String(incorrect), updated_at },
199
- { metric: "corrected", value: String(corrected), updated_at },
200
- { metric: "accuracy", value: accuracy, updated_at },
201
- { metric: "avg_seconds", value: avgSeconds.toFixed(1), updated_at },
202
- ];
203
- }
204
-
205
- export async function saveSummary(attempts: Map<string, Attempt>, path = summaryPath): Promise<void> {
206
- await mkdir(dirname(path), { recursive: true });
207
- const rows = buildSummaryRows(attempts).map((row) => [row.metric, row.value, row.updated_at]);
208
- await writeFile(path, formatCsv([summaryHeader.split(","), ...rows]), "utf8");
209
- }
210
-
211
- export async function loadFocus(path = focusPath): Promise<Focus> {
212
- await ensureFile(path, `${JSON.stringify(defaultFocus, null, 2)}\n`);
213
- const raw = await readFile(path, "utf8");
214
- let parsed: unknown;
215
- try {
216
- parsed = JSON.parse(raw);
217
- } catch (error) {
218
- throw new Error(`Invalid focus file at ${path}: ${error instanceof Error ? error.message : String(error)}`);
219
- }
220
-
221
- return normalizeFocus(parsed);
222
- }
223
-
224
- export async function saveFocus(focus: Focus, path = focusPath): Promise<void> {
225
- await mkdir(dirname(path), { recursive: true });
226
- await writeFile(path, `${JSON.stringify(normalizeFocus(focus), null, 2)}\n`, "utf8");
227
- }
228
-
229
- function isOutcome(value: string | undefined): value is Outcome {
230
- return value === "correct" || value === "incorrect" || value === "corrected";
231
- }
232
-
233
- function isBooleanText(value: string | undefined): value is "true" | "false" {
234
- return value === "true" || value === "false";
235
- }
236
-
237
- function readElapsedSeconds(value: string | undefined): number {
238
- const seconds = Number(value);
239
- return Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
240
- }
241
-
242
- function metadataFromQuestionMeta(meta: QuestionMeta | undefined): Partial<Attempt> {
243
- if (!meta) {
244
- return {};
245
- }
246
-
247
- return optionalMetadata({
248
- difficulty: meta.difficulty,
249
- domain: meta.primary_class_cd,
250
- domain_desc: meta.primary_class_cd_desc,
251
- skill: meta.skill_cd,
252
- skill_desc: meta.skill_desc,
253
- });
254
- }
255
-
256
- function optionalMetadata(metadata: Pick<Attempt, "difficulty" | "domain" | "domain_desc" | "skill" | "skill_desc">): Partial<Attempt> {
257
- return Object.fromEntries(Object.entries(metadata).filter(([, value]) => value)) as Partial<Attempt>;
258
- }
259
-
260
- function attemptEventRow(event: AttemptEvent): string[] {
261
- return [
262
- event.question_id,
263
- String(event.correct),
264
- event.answered_at,
265
- String(event.elapsed_seconds),
266
- event.difficulty,
267
- event.domain,
268
- event.domain_desc ?? "",
269
- event.skill,
270
- event.skill_desc ?? "",
271
- ];
272
- }
273
-
274
- async function ensureFile(path: string, contents: string): Promise<void> {
275
- try {
276
- await readFile(path, "utf8");
277
- } catch (error) {
278
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
279
- throw error;
280
- }
281
-
282
- await mkdir(dirname(path), { recursive: true });
283
- await writeFile(path, contents, "utf8");
284
- }
285
- }
286
-
287
- function parseCsv(raw: string): string[][] {
288
- const rows: string[][] = [];
289
- let row: string[] = [];
290
- let field = "";
291
- let quoted = false;
292
-
293
- for (let index = 0; index < raw.length; index += 1) {
294
- const char = raw[index];
295
- const next = raw[index + 1];
296
-
297
- if (quoted) {
298
- if (char === "\"" && next === "\"") {
299
- field += "\"";
300
- index += 1;
301
- } else if (char === "\"") {
302
- quoted = false;
303
- } else {
304
- field += char;
305
- }
306
- continue;
307
- }
308
-
309
- if (char === "\"") {
310
- quoted = true;
311
- } else if (char === ",") {
312
- row.push(field.trim());
313
- field = "";
314
- } else if (char === "\n") {
315
- row.push(field.trim());
316
- if (row.some((value) => value.length > 0)) {
317
- rows.push(row);
318
- }
319
- row = [];
320
- field = "";
321
- } else if (char !== "\r") {
322
- field += char;
323
- }
324
- }
325
-
326
- if (field.length > 0 || row.length > 0) {
327
- row.push(field.trim());
328
- if (row.some((value) => value.length > 0)) {
329
- rows.push(row);
330
- }
331
- }
332
-
333
- return rows;
334
- }
335
-
336
- function formatCsv(rows: string[][]): string {
337
- return `${rows.map((row) => row.map(csvEscape).join(",")).join("\n")}\n`;
338
- }
339
-
340
- function csvEscape(value: string): string {
341
- if (!/[",\n\r]/.test(value)) {
342
- return value;
343
- }
344
-
345
- return `"${value.replaceAll("\"", "\"\"")}"`;
346
- }