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.
- package/README.md +168 -4
- package/data/question-bank.json.zst +0 -0
- package/package.json +16 -10
- 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/src/api.ts +0 -113
- 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/state.ts +0 -346
- package/src/tui/app.ts +0 -134
- 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 -46
- package/src/tui/render.ts +0 -627
- 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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Difficulty, DomainCode, Question, SkillCode } from "@/questions/question.ts";
|
|
2
|
+
import { checkAnswer } from "@/questions/question.ts";
|
|
3
|
+
|
|
4
|
+
export type Outcome = "correct" | "incorrect" | "corrected";
|
|
5
|
+
|
|
6
|
+
export type Attempt = {
|
|
7
|
+
questionId: string;
|
|
8
|
+
outcome: Outcome;
|
|
9
|
+
answeredAt: string;
|
|
10
|
+
durationSeconds: number;
|
|
11
|
+
difficulty?: Difficulty;
|
|
12
|
+
domain?: DomainCode;
|
|
13
|
+
skill?: SkillCode;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type AttemptEvent = {
|
|
17
|
+
questionId: string;
|
|
18
|
+
correct: boolean;
|
|
19
|
+
answeredAt: string;
|
|
20
|
+
durationSeconds: number;
|
|
21
|
+
difficulty: Difficulty;
|
|
22
|
+
domain: DomainCode;
|
|
23
|
+
skill: SkillCode;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type AnswerRecord = {
|
|
27
|
+
answer: string;
|
|
28
|
+
correct: boolean;
|
|
29
|
+
attempt: Attempt;
|
|
30
|
+
event: AttemptEvent;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function createAnswerRecord(
|
|
34
|
+
previous: Attempt | undefined,
|
|
35
|
+
question: Question,
|
|
36
|
+
answer: string,
|
|
37
|
+
durationSeconds = 0,
|
|
38
|
+
answeredAt = new Date(),
|
|
39
|
+
): AnswerRecord {
|
|
40
|
+
const correct = checkAnswer(question, answer);
|
|
41
|
+
const timestamp = answeredAt.toISOString();
|
|
42
|
+
return {
|
|
43
|
+
answer,
|
|
44
|
+
correct,
|
|
45
|
+
attempt: {
|
|
46
|
+
questionId: question.id,
|
|
47
|
+
outcome: nextOutcome(previous?.outcome, correct),
|
|
48
|
+
answeredAt: timestamp,
|
|
49
|
+
durationSeconds,
|
|
50
|
+
difficulty: question.difficulty,
|
|
51
|
+
domain: question.domain,
|
|
52
|
+
skill: question.skill,
|
|
53
|
+
},
|
|
54
|
+
event: {
|
|
55
|
+
questionId: question.id,
|
|
56
|
+
correct,
|
|
57
|
+
answeredAt: timestamp,
|
|
58
|
+
durationSeconds,
|
|
59
|
+
difficulty: question.difficulty,
|
|
60
|
+
domain: question.domain,
|
|
61
|
+
skill: question.skill,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function nextOutcome(previous: Outcome | undefined, correct: boolean): Outcome {
|
|
67
|
+
if (previous === "correct" || previous === "corrected") return previous;
|
|
68
|
+
if (previous === "incorrect" && correct) return "corrected";
|
|
69
|
+
return correct ? "correct" : "incorrect";
|
|
70
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Attempt } from "@/progress/attempt.ts";
|
|
2
|
+
|
|
3
|
+
export type HistoryFilters = {
|
|
4
|
+
wrong?: boolean;
|
|
5
|
+
corrected?: boolean;
|
|
6
|
+
limit?: number;
|
|
7
|
+
since?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function history(attempts: Iterable<Attempt>, filters: HistoryFilters = {}, now = new Date()): Attempt[] {
|
|
11
|
+
let rows = [...attempts].sort((a, b) => b.answeredAt.localeCompare(a.answeredAt));
|
|
12
|
+
if (filters.since) {
|
|
13
|
+
const since = parseSince(filters.since, now);
|
|
14
|
+
rows = rows.filter((attempt) => new Date(attempt.answeredAt) >= since);
|
|
15
|
+
}
|
|
16
|
+
if (filters.wrong || filters.corrected) {
|
|
17
|
+
rows = rows.filter((attempt) =>
|
|
18
|
+
(filters.wrong && attempt.outcome === "incorrect") ||
|
|
19
|
+
(filters.corrected && attempt.outcome === "corrected")
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return filters.limit ? rows.slice(0, filters.limit) : rows;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseSince(value: string, now = new Date()): Date {
|
|
26
|
+
const relative = /^(\d+)(d|w)$/.exec(value);
|
|
27
|
+
if (relative) {
|
|
28
|
+
const amount = Number(relative[1]) * (relative[2] === "w" ? 7 : 1);
|
|
29
|
+
return new Date(now.getTime() - amount * 86_400_000);
|
|
30
|
+
}
|
|
31
|
+
const date = new Date(value);
|
|
32
|
+
if (Number.isNaN(date.getTime())) throw new Error(`Invalid date: ${value}`);
|
|
33
|
+
return date;
|
|
34
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Attempt, AttemptEvent } from "@/progress/attempt.ts";
|
|
2
|
+
|
|
3
|
+
export type ReviewRequirements = {
|
|
4
|
+
minimumDays: number;
|
|
5
|
+
minimumAnswersAfter: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function reviewQueue(
|
|
9
|
+
attempts: Iterable<Attempt>,
|
|
10
|
+
events: Iterable<AttemptEvent>,
|
|
11
|
+
requirements: ReviewRequirements,
|
|
12
|
+
now = new Date(),
|
|
13
|
+
): string[] {
|
|
14
|
+
const eventTimes = [...events]
|
|
15
|
+
.map((event) => new Date(event.answeredAt).getTime())
|
|
16
|
+
.filter(Number.isFinite)
|
|
17
|
+
.sort((left, right) => left - right);
|
|
18
|
+
const minimumAge = requirements.minimumDays * 86_400_000;
|
|
19
|
+
return [...attempts]
|
|
20
|
+
.filter((attempt) => {
|
|
21
|
+
if (attempt.outcome !== "incorrect" && attempt.outcome !== "corrected") return false;
|
|
22
|
+
const answeredAt = new Date(attempt.answeredAt).getTime();
|
|
23
|
+
return Number.isFinite(answeredAt)
|
|
24
|
+
&& now.getTime() - answeredAt >= minimumAge
|
|
25
|
+
&& answersAfter(eventTimes, answeredAt) >= requirements.minimumAnswersAfter;
|
|
26
|
+
})
|
|
27
|
+
.sort((a, b) => priority(a) - priority(b) || a.answeredAt.localeCompare(b.answeredAt))
|
|
28
|
+
.map((attempt) => attempt.questionId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function answersAfter(sortedTimes: readonly number[], answeredAt: number): number {
|
|
32
|
+
let low = 0;
|
|
33
|
+
let high = sortedTimes.length;
|
|
34
|
+
while (low < high) {
|
|
35
|
+
const middle = Math.floor((low + high) / 2);
|
|
36
|
+
if (sortedTimes[middle] <= answeredAt) low = middle + 1;
|
|
37
|
+
else high = middle;
|
|
38
|
+
}
|
|
39
|
+
return sortedTimes.length - low;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function priority(attempt: Attempt): number {
|
|
43
|
+
return attempt.outcome === "incorrect" ? 0 : 1;
|
|
44
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Attempt } from "@/progress/attempt.ts";
|
|
2
|
+
|
|
3
|
+
export type ProgressStatistics = {
|
|
4
|
+
answered: number;
|
|
5
|
+
correct: number;
|
|
6
|
+
incorrect: number;
|
|
7
|
+
corrected: number;
|
|
8
|
+
mastered: number;
|
|
9
|
+
accuracy: number;
|
|
10
|
+
averageSeconds: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function progressStatistics(attempts: Iterable<Attempt>): ProgressStatistics {
|
|
14
|
+
const values = [...attempts];
|
|
15
|
+
let correct = 0;
|
|
16
|
+
let incorrect = 0;
|
|
17
|
+
let corrected = 0;
|
|
18
|
+
let totalSeconds = 0;
|
|
19
|
+
for (const attempt of values) {
|
|
20
|
+
if (attempt.outcome === "correct") correct += 1;
|
|
21
|
+
else if (attempt.outcome === "incorrect") incorrect += 1;
|
|
22
|
+
else corrected += 1;
|
|
23
|
+
totalSeconds += attempt.durationSeconds;
|
|
24
|
+
}
|
|
25
|
+
const mastered = correct + corrected;
|
|
26
|
+
return {
|
|
27
|
+
answered: values.length,
|
|
28
|
+
correct,
|
|
29
|
+
incorrect,
|
|
30
|
+
corrected,
|
|
31
|
+
mastered,
|
|
32
|
+
accuracy: values.length === 0 ? 0 : mastered / values.length,
|
|
33
|
+
averageSeconds: values.length === 0 ? 0 : totalSeconds / values.length,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { domainLabels, skillLabels } from "@/questions/taxonomy.ts";
|
|
2
|
+
import type { DomainCode, SkillCode } from "@/questions/question.ts";
|
|
3
|
+
import type { Attempt } from "@/progress/attempt.ts";
|
|
4
|
+
|
|
5
|
+
export type Weakness = {
|
|
6
|
+
skill: SkillCode;
|
|
7
|
+
label: string;
|
|
8
|
+
domain: DomainCode;
|
|
9
|
+
domainLabel: string;
|
|
10
|
+
total: number;
|
|
11
|
+
mastered: number;
|
|
12
|
+
missed: number;
|
|
13
|
+
accuracy: number;
|
|
14
|
+
averageSeconds: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function weaknesses(attempts: Iterable<Attempt>): Weakness[] {
|
|
18
|
+
const groups = new Map<SkillCode, Omit<Weakness, "accuracy" | "averageSeconds"> & { totalSeconds: number }>();
|
|
19
|
+
for (const attempt of attempts) {
|
|
20
|
+
if (!attempt.skill || !attempt.domain) continue;
|
|
21
|
+
const row = groups.get(attempt.skill) ?? {
|
|
22
|
+
skill: attempt.skill,
|
|
23
|
+
label: skillLabels[attempt.skill],
|
|
24
|
+
domain: attempt.domain,
|
|
25
|
+
domainLabel: domainLabels[attempt.domain],
|
|
26
|
+
total: 0,
|
|
27
|
+
mastered: 0,
|
|
28
|
+
missed: 0,
|
|
29
|
+
totalSeconds: 0,
|
|
30
|
+
};
|
|
31
|
+
row.total += 1;
|
|
32
|
+
row.totalSeconds += attempt.durationSeconds;
|
|
33
|
+
if (attempt.outcome === "incorrect") row.missed += 1;
|
|
34
|
+
else row.mastered += 1;
|
|
35
|
+
groups.set(attempt.skill, row);
|
|
36
|
+
}
|
|
37
|
+
return [...groups.values()].map(({ totalSeconds, ...row }) => ({
|
|
38
|
+
...row,
|
|
39
|
+
accuracy: row.total === 0 ? 0 : row.mastered / row.total,
|
|
40
|
+
averageSeconds: row.total === 0 ? 0 : totalSeconds / row.total,
|
|
41
|
+
})).sort((a, b) =>
|
|
42
|
+
b.missed - a.missed || a.accuracy - b.accuracy || b.total - a.total || a.skill.localeCompare(b.skill)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Difficulty, DomainCode, SkillCode } from "@/questions/question.ts";
|
|
2
|
+
import { difficulties, domains, skills, skillsByDomain } from "@/questions/taxonomy.ts";
|
|
3
|
+
|
|
4
|
+
export type Focus = {
|
|
5
|
+
difficulties: Difficulty[];
|
|
6
|
+
skills: SkillCode[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const defaultFocus: Focus = {
|
|
10
|
+
difficulties: ["M", "H"],
|
|
11
|
+
skills: [...skills],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function normalizeFocus(value: unknown): Focus {
|
|
15
|
+
const raw = value && typeof value === "object"
|
|
16
|
+
? value as { difficulties?: unknown; skills?: unknown; domains?: unknown }
|
|
17
|
+
: {};
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
difficulties: selection(raw.difficulties, difficulties, defaultFocus.difficulties),
|
|
21
|
+
skills: normalizedSkills(raw.skills, raw.domains),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function selectedDomains(focus: Focus): DomainCode[] {
|
|
26
|
+
return domains.filter((domain) => skillsByDomain[domain].some((skill) => focus.skills.includes(skill)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function toggleDifficulty(focus: Focus, difficulty: Difficulty): Focus {
|
|
30
|
+
const selected = focus.difficulties.includes(difficulty);
|
|
31
|
+
if (selected && focus.difficulties.length === 1) return focus;
|
|
32
|
+
return {
|
|
33
|
+
...focus,
|
|
34
|
+
difficulties: selected
|
|
35
|
+
? focus.difficulties.filter((value) => value !== difficulty)
|
|
36
|
+
: ordered([...focus.difficulties, difficulty], difficulties),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toggleSkill(focus: Focus, skill: SkillCode): Focus {
|
|
41
|
+
const selected = focus.skills.includes(skill);
|
|
42
|
+
if (selected && focus.skills.length === 1) return focus;
|
|
43
|
+
return {
|
|
44
|
+
...focus,
|
|
45
|
+
skills: selected
|
|
46
|
+
? focus.skills.filter((value) => value !== skill)
|
|
47
|
+
: ordered([...focus.skills, skill], skills),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function toggleDomain(focus: Focus, domain: DomainCode): Focus {
|
|
52
|
+
const children = skillsByDomain[domain];
|
|
53
|
+
const allSelected = children.every((skill) => focus.skills.includes(skill));
|
|
54
|
+
if (allSelected) {
|
|
55
|
+
const remaining = focus.skills.filter((skill) => !children.includes(skill));
|
|
56
|
+
return remaining.length === 0 ? focus : { ...focus, skills: remaining };
|
|
57
|
+
}
|
|
58
|
+
return { ...focus, skills: ordered([...new Set([...focus.skills, ...children])], skills) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizedSkills(value: unknown, legacyDomains: unknown): SkillCode[] {
|
|
62
|
+
const direct = selection(value, skills, []);
|
|
63
|
+
if (direct.length > 0) return direct;
|
|
64
|
+
const selected = selection(legacyDomains, domains, []);
|
|
65
|
+
if (selected.length > 0) return selected.flatMap((domain) => [...skillsByDomain[domain]]);
|
|
66
|
+
return [...defaultFocus.skills];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function selection<T extends string>(value: unknown, options: readonly T[], fallback: readonly T[]): T[] {
|
|
70
|
+
if (!Array.isArray(value)) return [...fallback];
|
|
71
|
+
const selected = options.filter((option) => value.includes(option));
|
|
72
|
+
return selected.length > 0 ? selected : [...fallback];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ordered<T extends string>(values: readonly T[], order: readonly T[]): T[] {
|
|
76
|
+
return order.filter((value) => values.includes(value));
|
|
77
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { mkdir, rename, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { questionBankCachePath } from "@/local-data/paths.ts";
|
|
5
|
+
import type { Focus } from "@/questions/focus.ts";
|
|
6
|
+
import type { Question } from "@/questions/question.ts";
|
|
7
|
+
|
|
8
|
+
export const questionBankVersion = 2;
|
|
9
|
+
export const bundledQuestionBankPath = fileURLToPath(import.meta.resolve("@data/question-bank.json.zst"));
|
|
10
|
+
|
|
11
|
+
export type QuestionBank = {
|
|
12
|
+
version: typeof questionBankVersion;
|
|
13
|
+
source: string;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
questions: Question[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type QuestionBankStatus = {
|
|
19
|
+
path: string;
|
|
20
|
+
exists: boolean;
|
|
21
|
+
bytes?: number;
|
|
22
|
+
source?: string;
|
|
23
|
+
updatedAt?: string;
|
|
24
|
+
questions?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let defaultBank: Promise<QuestionBank> | undefined;
|
|
28
|
+
const indexes = new WeakMap<QuestionBank, Map<string, Question>>();
|
|
29
|
+
|
|
30
|
+
export async function loadQuestionBank(path = questionBankCachePath): Promise<QuestionBank> {
|
|
31
|
+
if (path !== questionBankCachePath) return requireBank(await readQuestionBank(path));
|
|
32
|
+
defaultBank ??= materializeQuestionBank();
|
|
33
|
+
return defaultBank;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function materializeQuestionBank(
|
|
37
|
+
cachePath = questionBankCachePath,
|
|
38
|
+
bundledPath = bundledQuestionBankPath,
|
|
39
|
+
): Promise<QuestionBank> {
|
|
40
|
+
const cached = await readQuestionBank(cachePath, true);
|
|
41
|
+
if (cached) return cached;
|
|
42
|
+
const bundled = requireBank(await readQuestionBank(bundledPath));
|
|
43
|
+
await saveQuestionBank(bundled, cachePath);
|
|
44
|
+
return bundled;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function readQuestionBank(path: string, replaceable = false): Promise<QuestionBank | undefined> {
|
|
48
|
+
try {
|
|
49
|
+
const value = path.endsWith(".zst")
|
|
50
|
+
? JSON.parse(new TextDecoder().decode(Bun.zstdDecompressSync(new Uint8Array(await Bun.file(path).arrayBuffer()))))
|
|
51
|
+
: await Bun.file(path).json();
|
|
52
|
+
return parseQuestionBank(value);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
55
|
+
if (replaceable && error instanceof UnsupportedBankVersionError) return undefined;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function saveQuestionBank(bank: QuestionBank, path = questionBankCachePath): Promise<void> {
|
|
61
|
+
await mkdir(dirname(path), { recursive: true });
|
|
62
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
63
|
+
await Bun.write(temporaryPath, `${JSON.stringify(bank)}\n`);
|
|
64
|
+
await rename(temporaryPath, path);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function questionBankStatus(path = questionBankCachePath): Promise<QuestionBankStatus> {
|
|
68
|
+
try {
|
|
69
|
+
const bank = path === questionBankCachePath ? await loadQuestionBank() : await loadQuestionBank(path);
|
|
70
|
+
return {
|
|
71
|
+
path,
|
|
72
|
+
exists: true,
|
|
73
|
+
bytes: (await stat(path)).size,
|
|
74
|
+
source: bank.source,
|
|
75
|
+
updatedAt: bank.updatedAt,
|
|
76
|
+
questions: bank.questions.length,
|
|
77
|
+
};
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { path, exists: false };
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function findQuestion(id: string): Promise<Question | undefined> {
|
|
85
|
+
const bank = await loadQuestionBank();
|
|
86
|
+
let index = indexes.get(bank);
|
|
87
|
+
if (!index) {
|
|
88
|
+
index = new Map(bank.questions.map((question) => [question.id, question]));
|
|
89
|
+
indexes.set(bank, index);
|
|
90
|
+
}
|
|
91
|
+
return index.get(id);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function nextQuestion(
|
|
95
|
+
excludedIds: Iterable<string>,
|
|
96
|
+
focus: Focus,
|
|
97
|
+
random = Math.random,
|
|
98
|
+
): Promise<Question | undefined> {
|
|
99
|
+
const excluded = new Set(excludedIds);
|
|
100
|
+
const bank = await loadQuestionBank();
|
|
101
|
+
const candidates = bank.questions.filter((question) =>
|
|
102
|
+
!excluded.has(question.id) &&
|
|
103
|
+
focus.difficulties.includes(question.difficulty) &&
|
|
104
|
+
focus.skills.includes(question.skill)
|
|
105
|
+
);
|
|
106
|
+
if (candidates.length === 0) return undefined;
|
|
107
|
+
return candidates[Math.min(candidates.length - 1, Math.floor(random() * candidates.length))];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function resetQuestionBankMemoryCache(): void {
|
|
111
|
+
defaultBank = undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseQuestionBank(value: unknown): QuestionBank {
|
|
115
|
+
if (!value || typeof value !== "object") throw new Error("Question bank is not an object.");
|
|
116
|
+
const bank = value as Partial<QuestionBank>;
|
|
117
|
+
if (bank.version !== questionBankVersion) throw new UnsupportedBankVersionError(bank.version);
|
|
118
|
+
if (typeof bank.source !== "string" || !bank.source) throw new Error("Question bank is missing its source.");
|
|
119
|
+
if (typeof bank.updatedAt !== "string") throw new Error("Question bank has an invalid update timestamp.");
|
|
120
|
+
if (!Array.isArray(bank.questions)) throw new Error("Question bank is missing questions.");
|
|
121
|
+
return bank as QuestionBank;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function requireBank(bank: QuestionBank | undefined): QuestionBank {
|
|
125
|
+
if (!bank) throw new Error("Question bank is missing from the package.");
|
|
126
|
+
return bank;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
class UnsupportedBankVersionError extends Error {
|
|
130
|
+
constructor(version: unknown) {
|
|
131
|
+
super(`Question bank version ${String(version)} is not supported.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { Question } from "@/questions/question.ts";
|
|
3
|
+
|
|
4
|
+
export const practiceSatSite = "https://practicesat.vercel.app";
|
|
5
|
+
|
|
6
|
+
export function questionPageUrl(question: Question): string {
|
|
7
|
+
return `${practiceSatSite}/question/${encodeURIComponent(question.id)}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function openQuestionPage(question: Question): void {
|
|
11
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
12
|
+
const child = spawn(command, [questionPageUrl(question)], { detached: true, stdio: "ignore" });
|
|
13
|
+
child.unref();
|
|
14
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type Difficulty = "E" | "M" | "H";
|
|
2
|
+
export type DomainCode = "INI" | "CAS" | "EOI" | "SEC";
|
|
3
|
+
export type SkillCode = "CID" | "INF" | "COE" | "WIC" | "TSP" | "CTC" | "SYN" | "TRA" | "BOU" | "FSS";
|
|
4
|
+
|
|
5
|
+
export type QuestionChoice = {
|
|
6
|
+
key: string;
|
|
7
|
+
content: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type Question = {
|
|
11
|
+
id: string;
|
|
12
|
+
sourceId: string;
|
|
13
|
+
difficulty: Difficulty;
|
|
14
|
+
domain: DomainCode;
|
|
15
|
+
skill: SkillCode;
|
|
16
|
+
passage?: string;
|
|
17
|
+
prompt: string;
|
|
18
|
+
choices: QuestionChoice[];
|
|
19
|
+
correctAnswers: string[];
|
|
20
|
+
explanation?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function checkAnswer(question: Question, answer: string): boolean {
|
|
24
|
+
return question.correctAnswers.includes(answer);
|
|
25
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Difficulty, DomainCode, SkillCode } from "@/questions/question.ts";
|
|
2
|
+
|
|
3
|
+
export const difficulties = ["E", "M", "H"] as const satisfies readonly Difficulty[];
|
|
4
|
+
export const domains = ["INI", "CAS", "EOI", "SEC"] as const satisfies readonly DomainCode[];
|
|
5
|
+
export const skills = ["CID", "INF", "COE", "WIC", "TSP", "CTC", "SYN", "TRA", "BOU", "FSS"] as const satisfies readonly SkillCode[];
|
|
6
|
+
|
|
7
|
+
export const skillsByDomain: Record<DomainCode, readonly SkillCode[]> = {
|
|
8
|
+
INI: ["CID", "INF", "COE"],
|
|
9
|
+
CAS: ["WIC", "TSP", "CTC"],
|
|
10
|
+
EOI: ["SYN", "TRA"],
|
|
11
|
+
SEC: ["BOU", "FSS"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const difficultyLabels: Record<Difficulty, string> = {
|
|
15
|
+
E: "Easy",
|
|
16
|
+
M: "Medium",
|
|
17
|
+
H: "Hard",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const domainLabels: Record<DomainCode, string> = {
|
|
21
|
+
INI: "Information and Ideas",
|
|
22
|
+
CAS: "Craft and Structure",
|
|
23
|
+
EOI: "Expression of Ideas",
|
|
24
|
+
SEC: "Standard English Conventions",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const skillLabels: Record<SkillCode, string> = {
|
|
28
|
+
CID: "Central Ideas and Details",
|
|
29
|
+
INF: "Inferences",
|
|
30
|
+
COE: "Command of Evidence",
|
|
31
|
+
WIC: "Words in Context",
|
|
32
|
+
TSP: "Text Structure and Purpose",
|
|
33
|
+
CTC: "Cross-Text Connections",
|
|
34
|
+
SYN: "Transitions",
|
|
35
|
+
TRA: "Rhetorical Synthesis",
|
|
36
|
+
BOU: "Boundaries",
|
|
37
|
+
FSS: "Form, Structure, and Sense",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function isDifficulty(value: unknown): value is Difficulty {
|
|
41
|
+
return typeof value === "string" && difficulties.includes(value as Difficulty);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isDomainCode(value: unknown): value is DomainCode {
|
|
45
|
+
return typeof value === "string" && domains.includes(value as DomainCode);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isSkillCode(value: unknown): value is SkillCode {
|
|
49
|
+
return typeof value === "string" && skills.includes(value as SkillCode);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function domainForSkill(skill: SkillCode): DomainCode {
|
|
53
|
+
const domain = domains.find((candidate) => skillsByDomain[candidate].includes(skill));
|
|
54
|
+
if (!domain) throw new Error(`Unknown SAT skill: ${skill}`);
|
|
55
|
+
return domain;
|
|
56
|
+
}
|