saterminal 0.4.0 → 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 (84) hide show
  1. package/README.md +157 -9
  2. package/data/question-bank.json.zst +0 -0
  3. package/package.json +13 -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/cli.ts +0 -917
  63. package/src/focus.ts +0 -231
  64. package/src/main.ts +0 -13
  65. package/src/progress.ts +0 -48
  66. package/src/question-bank.ts +0 -219
  67. package/src/state.ts +0 -346
  68. package/src/tui/app.ts +0 -140
  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 -47
  77. package/src/tui/render.ts +0 -630
  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
  84. package/src/urls.ts +0 -2
@@ -0,0 +1,28 @@
1
+ export { formatDuration as duration } from "@/text/duration.ts";
2
+
3
+ export type OutputMode = "pretty" | "plain" | "json";
4
+ export type FormatSettings = { mode: OutputMode; color: boolean };
5
+
6
+ export const ansi = {
7
+ reset: "\x1b[0m", bold: "\x1b[1m", gray: "\x1b[90m", red: "\x1b[31m",
8
+ green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m",
9
+ };
10
+
11
+ export function table(headers: string[], rows: (string | number)[][]): string {
12
+ const values = [headers, ...rows.map((row) => row.map(String))];
13
+ const widths = headers.map((_, column) => Math.max(...values.map((row) => Bun.stringWidth(row[column] ?? ""))));
14
+ return values.map((row) => row.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()).join("\n");
15
+ }
16
+
17
+ export function meter(ratio: number, width: number, settings: FormatSettings, color: string): string {
18
+ const full = Math.round(Math.max(0, Math.min(1, ratio)) * width);
19
+ return paint("█".repeat(full), settings, color) + paint("░".repeat(width - full), settings, ansi.gray);
20
+ }
21
+
22
+ export function percent(value: number): string {
23
+ return `${Math.round(value * 100)}%`;
24
+ }
25
+
26
+ export function paint(value: string, settings: FormatSettings, ...codes: string[]): string {
27
+ return settings.color && settings.mode === "pretty" ? `${codes.join("")}${value}${ansi.reset}` : value;
28
+ }
@@ -0,0 +1,36 @@
1
+ import { ansi, duration, meter, paint, percent, table, type FormatSettings } from "@/cli/reports/terminal-format.ts";
2
+ import type { Weakness } from "@/progress/weaknesses.ts";
3
+
4
+ export function formatWeaknesses(rows: Weakness[], settings: FormatSettings): string {
5
+ if (settings.mode === "json") return JSON.stringify(rows);
6
+ if (!rows.length) return settings.mode === "plain"
7
+ ? "No metadata-backed attempts yet."
8
+ : `${paint("weak", settings, ansi.bold, ansi.cyan)}\n${paint("No skill data yet.", settings, ansi.gray)}`;
9
+ if (settings.mode === "plain") return table(["skill", "accuracy", "missed", "total", "avg", "focus"], rows.map((row) => [row.skill, percent(row.accuracy), row.missed, row.total, duration(row.averageSeconds), row.label]));
10
+
11
+ const skillWidth = Math.max(...rows.map((row) => Bun.stringWidth(skillName(row))), 5);
12
+ return [
13
+ paint("weak", settings, ansi.bold, ansi.cyan),
14
+ paint(`${"skill".padEnd(skillWidth)} acc miss avg mastery`, settings, ansi.gray),
15
+ ...rows.map((row) => weaknessRow(row, skillWidth, settings)),
16
+ ].join("\n");
17
+ }
18
+
19
+ function weaknessRow(row: Weakness, skillWidth: number, settings: FormatSettings): string {
20
+ const color = accuracyColor(row.accuracy);
21
+ return [
22
+ paint(skillName(row).padEnd(skillWidth), settings, color),
23
+ paint(percent(row.accuracy).padStart(4), settings, color, ansi.bold),
24
+ paint(`${row.missed}/${row.total}`.padStart(5), settings, row.missed ? ansi.red : ansi.gray),
25
+ paint(duration(row.averageSeconds).padStart(4), settings, ansi.cyan),
26
+ meter(row.accuracy, 20, settings, color),
27
+ ].join(" ");
28
+ }
29
+
30
+ function skillName(row: Weakness): string {
31
+ return `${row.skill} ${row.label}`;
32
+ }
33
+
34
+ function accuracyColor(value: number): string {
35
+ return value >= 0.9 ? ansi.green : value >= 0.75 ? ansi.yellow : ansi.red;
36
+ }
@@ -0,0 +1,26 @@
1
+ import type { Focus } from "@/questions/focus.ts";
2
+ import { defaultFocus, normalizeFocus } from "@/questions/focus.ts";
3
+ import { databasePath } from "@/local-data/paths.ts";
4
+ import { openDatabase } from "@/database/index.ts";
5
+
6
+ export function loadFocus(path = databasePath): Focus {
7
+ const database = openDatabase(path);
8
+ try {
9
+ const row = database.query("select difficulties, skills from focus where id = 1").get() as { difficulties: string; skills: string } | null;
10
+ return row ? normalizeFocus({ difficulties: JSON.parse(row.difficulties), skills: JSON.parse(row.skills) }) : defaultFocus;
11
+ } finally {
12
+ database.close();
13
+ }
14
+ }
15
+
16
+ export function saveFocus(focus: Focus, path = databasePath): void {
17
+ const normalized = normalizeFocus(focus);
18
+ const database = openDatabase(path);
19
+ try {
20
+ database.query(`insert into focus (id, difficulties, skills, updated_at) values (1, ?, ?, ?)
21
+ on conflict(id) do update set difficulties = excluded.difficulties, skills = excluded.skills, updated_at = excluded.updated_at`)
22
+ .run(JSON.stringify(normalized.difficulties), JSON.stringify(normalized.skills), new Date().toISOString());
23
+ } finally {
24
+ database.close();
25
+ }
26
+ }
@@ -0,0 +1,28 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { Database } from "bun:sqlite";
5
+ import { dataDirectory, databasePath } from "@/local-data/paths.ts";
6
+ import { migrate } from "@/database/migrations.ts";
7
+
8
+ export function openDatabase(path = databasePath): Database {
9
+ mkdirSync(dirname(path), { recursive: true });
10
+ const database = new Database(path);
11
+ database.exec("pragma journal_mode = WAL; pragma foreign_keys = ON; pragma busy_timeout = 5000;");
12
+ migrate(database);
13
+ return database;
14
+ }
15
+
16
+ export function ensureDatabase(path = databasePath): void {
17
+ const database = openDatabase(path);
18
+ database.close();
19
+ }
20
+
21
+ export async function dataDirectoryExists(path = dataDirectory): Promise<boolean> {
22
+ try {
23
+ return (await stat(path)).isDirectory();
24
+ } catch (error) {
25
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
26
+ throw error;
27
+ }
28
+ }
@@ -0,0 +1,100 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ export const schemaVersion = 2;
4
+
5
+ export function migrate(database: Database): void {
6
+ const version = userVersion(database);
7
+ if (version > schemaVersion) throw new Error(`Database schema ${version} is newer than this app supports.`);
8
+ if (version === 0) createSchema(database);
9
+ else if (version === 1) migrateFromVersionOne(database);
10
+ }
11
+
12
+ export function userVersion(database: Database): number {
13
+ const row = database.query("pragma user_version").get() as { user_version?: number } | undefined;
14
+ return row?.user_version ?? 0;
15
+ }
16
+
17
+ function createSchema(database: Database): void {
18
+ database.exec(`
19
+ begin;
20
+ create table attempts (
21
+ question_id text primary key,
22
+ outcome text not null check (outcome in ('correct', 'incorrect', 'corrected')),
23
+ answered_at text not null,
24
+ duration_seconds integer not null default 0,
25
+ difficulty text,
26
+ domain text,
27
+ skill text
28
+ );
29
+ create table attempt_events (
30
+ id integer primary key autoincrement,
31
+ question_id text not null,
32
+ correct integer not null check (correct in (0, 1)),
33
+ answered_at text not null,
34
+ duration_seconds integer not null default 0,
35
+ difficulty text not null,
36
+ domain text not null,
37
+ skill text not null
38
+ );
39
+ create table focus (
40
+ id integer primary key check (id = 1),
41
+ difficulties text not null,
42
+ skills text not null,
43
+ updated_at text not null
44
+ );
45
+ create index attempt_events_answered_at_idx on attempt_events(answered_at);
46
+ create index attempt_events_question_id_idx on attempt_events(question_id);
47
+ create index attempts_answered_at_idx on attempts(answered_at);
48
+ pragma user_version = ${schemaVersion};
49
+ commit;
50
+ `);
51
+ }
52
+
53
+ function migrateFromVersionOne(database: Database): void {
54
+ database.exec(`
55
+ begin;
56
+ create table attempts_v2 (
57
+ question_id text primary key,
58
+ outcome text not null check (outcome in ('correct', 'incorrect', 'corrected')),
59
+ answered_at text not null,
60
+ duration_seconds integer not null default 0,
61
+ difficulty text,
62
+ domain text,
63
+ skill text
64
+ );
65
+ insert into attempts_v2 select question_id, outcome, updated_at, elapsed_seconds, difficulty, domain, skill from attempts;
66
+ drop table attempts;
67
+ alter table attempts_v2 rename to attempts;
68
+
69
+ create table attempt_events_v2 (
70
+ id integer primary key autoincrement,
71
+ question_id text not null,
72
+ correct integer not null check (correct in (0, 1)),
73
+ answered_at text not null,
74
+ duration_seconds integer not null default 0,
75
+ difficulty text not null,
76
+ domain text not null,
77
+ skill text not null
78
+ );
79
+ insert into attempt_events_v2 (id, question_id, correct, answered_at, duration_seconds, difficulty, domain, skill)
80
+ select id, question_id, correct, answered_at, elapsed_seconds, difficulty, domain, skill from attempt_events;
81
+ drop table attempt_events;
82
+ alter table attempt_events_v2 rename to attempt_events;
83
+
84
+ create table focus_v2 (
85
+ id integer primary key check (id = 1),
86
+ difficulties text not null,
87
+ skills text not null,
88
+ updated_at text not null
89
+ );
90
+ insert into focus_v2 (id, difficulties, skills, updated_at) select id, difficulties, skills, updated_at from focus;
91
+ drop table focus;
92
+ alter table focus_v2 rename to focus;
93
+
94
+ create index attempt_events_answered_at_idx on attempt_events(answered_at);
95
+ create index attempt_events_question_id_idx on attempt_events(question_id);
96
+ create index attempts_answered_at_idx on attempts(answered_at);
97
+ pragma user_version = ${schemaVersion};
98
+ commit;
99
+ `);
100
+ }
@@ -0,0 +1,90 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { Difficulty, DomainCode, SkillCode } from "@/questions/question.ts";
3
+ import type { AnswerRecord, Attempt, AttemptEvent, Outcome } from "@/progress/attempt.ts";
4
+ import { databasePath } from "@/local-data/paths.ts";
5
+ import { openDatabase } from "@/database/index.ts";
6
+
7
+ export function loadAttempts(path = databasePath): Map<string, Attempt> {
8
+ return usingDatabase(path, (database) => {
9
+ const rows = database.query(`select question_id, outcome, answered_at, duration_seconds, difficulty, domain, skill from attempts order by answered_at`).all() as AttemptRow[];
10
+ return new Map(rows.map((row) => {
11
+ const attempt = readAttempt(row);
12
+ return [attempt.questionId, attempt];
13
+ }));
14
+ });
15
+ }
16
+
17
+ export function loadAttemptEvents(path = databasePath): AttemptEvent[] {
18
+ return usingDatabase(path, (database) => {
19
+ const rows = database.query(`select question_id, correct, answered_at, duration_seconds, difficulty, domain, skill from attempt_events order by answered_at, id`).all() as EventRow[];
20
+ return rows.map((row) => ({
21
+ questionId: row.question_id,
22
+ correct: row.correct === 1,
23
+ answeredAt: row.answered_at,
24
+ durationSeconds: row.duration_seconds,
25
+ difficulty: row.difficulty as Difficulty,
26
+ domain: row.domain as DomainCode,
27
+ skill: row.skill as SkillCode,
28
+ }));
29
+ });
30
+ }
31
+
32
+ export function recordAnswer(record: AnswerRecord, path = databasePath): void {
33
+ usingDatabase(path, (database) => {
34
+ const transaction = database.transaction(() => {
35
+ const { event, attempt } = record;
36
+ database.query(`insert into attempt_events (question_id, correct, answered_at, duration_seconds, difficulty, domain, skill) values (?, ?, ?, ?, ?, ?, ?)`)
37
+ .run(event.questionId, event.correct ? 1 : 0, event.answeredAt, event.durationSeconds, event.difficulty, event.domain, event.skill);
38
+ database.query(`insert into attempts (question_id, outcome, answered_at, duration_seconds, difficulty, domain, skill) values (?, ?, ?, ?, ?, ?, ?)
39
+ on conflict(question_id) do update set outcome = excluded.outcome, answered_at = excluded.answered_at, duration_seconds = excluded.duration_seconds, difficulty = excluded.difficulty, domain = excluded.domain, skill = excluded.skill`)
40
+ .run(attempt.questionId, attempt.outcome, attempt.answeredAt, attempt.durationSeconds, attempt.difficulty ?? null, attempt.domain ?? null, attempt.skill ?? null);
41
+ });
42
+ transaction();
43
+ });
44
+ }
45
+
46
+ type AttemptRow = {
47
+ question_id: string;
48
+ outcome: string;
49
+ answered_at: string;
50
+ duration_seconds: number;
51
+ difficulty: string | null;
52
+ domain: string | null;
53
+ skill: string | null;
54
+ };
55
+
56
+ type EventRow = {
57
+ question_id: string;
58
+ correct: number;
59
+ answered_at: string;
60
+ duration_seconds: number;
61
+ difficulty: string;
62
+ domain: string;
63
+ skill: string;
64
+ };
65
+
66
+ function readAttempt(row: AttemptRow): Attempt {
67
+ return {
68
+ questionId: row.question_id,
69
+ outcome: readOutcome(row.outcome),
70
+ answeredAt: row.answered_at,
71
+ durationSeconds: row.duration_seconds,
72
+ ...(row.difficulty ? { difficulty: row.difficulty as Difficulty } : {}),
73
+ ...(row.domain ? { domain: row.domain as DomainCode } : {}),
74
+ ...(row.skill ? { skill: row.skill as SkillCode } : {}),
75
+ };
76
+ }
77
+
78
+ function readOutcome(value: string): Outcome {
79
+ if (value === "correct" || value === "incorrect" || value === "corrected") return value;
80
+ throw new Error(`Invalid attempt outcome in database: ${value}`);
81
+ }
82
+
83
+ function usingDatabase<T>(path: string, work: (database: Database) => T): T {
84
+ const database = openDatabase(path);
85
+ try {
86
+ return work(database);
87
+ } finally {
88
+ database.close();
89
+ }
90
+ }
@@ -0,0 +1,17 @@
1
+ import { homedir } from "node:os";
2
+ import { join, sep } from "node:path";
3
+
4
+ export function resolveDataDirectory(home = homedir()): string {
5
+ return join(home, ".saterminal");
6
+ }
7
+
8
+ export const dataDirectory = resolveDataDirectory();
9
+ export const databasePath = join(dataDirectory, "sat.db");
10
+ export const preferencesPath = join(dataDirectory, "preferences.json");
11
+ export const preferencesSchemaPath = join(dataDirectory, "preferences.schema.json");
12
+ export const questionBankCachePath = join(dataDirectory, "cache", "question-bank.json");
13
+
14
+ export function displayPath(path: string, home = homedir()): string {
15
+ if (path === home) return "~";
16
+ return path.startsWith(`${home}${sep}`) ? `~${path.slice(home.length)}` : path;
17
+ }
@@ -0,0 +1,25 @@
1
+ import { mkdir, open } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { ensureDatabase } from "@/database/index.ts";
4
+ import { dataDirectory } from "@/local-data/paths.ts";
5
+ import { ensurePreferences } from "@/preferences/index.ts";
6
+
7
+ export async function ensureLocalData(directory = dataDirectory): Promise<void> {
8
+ await mkdir(directory, { recursive: true });
9
+ ensureDatabase(join(directory, "sat.db"));
10
+ await ensurePreferences(join(directory, "preferences.json"));
11
+ await createIgnoreFile(join(directory, ".ignore"));
12
+ }
13
+
14
+ async function createIgnoreFile(path: string): Promise<void> {
15
+ try {
16
+ const file = await open(path, "wx", 0o600);
17
+ try {
18
+ await file.writeFile("cache\n");
19
+ } finally {
20
+ await file.close();
21
+ }
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
24
+ }
25
+ }
@@ -0,0 +1,25 @@
1
+ import { recordAnswer } from "@/database/progress-repository.ts";
2
+ import type { Question } from "@/questions/question.ts";
3
+ import type { Attempt, AnswerRecord } from "@/progress/attempt.ts";
4
+ import { createAnswerRecord } from "@/progress/attempt.ts";
5
+
6
+ export type AnswerQuestionInput = {
7
+ attempts: ReadonlyMap<string, Attempt>;
8
+ question: Question;
9
+ answer: string;
10
+ durationSeconds?: number;
11
+ answeredAt?: Date;
12
+ databasePath?: string;
13
+ };
14
+
15
+ export async function answerQuestion(input: AnswerQuestionInput): Promise<AnswerRecord> {
16
+ const record = createAnswerRecord(
17
+ input.attempts.get(input.question.id),
18
+ input.question,
19
+ input.answer,
20
+ input.durationSeconds,
21
+ input.answeredAt,
22
+ );
23
+ await recordAnswer(record, input.databasePath);
24
+ return record;
25
+ }
@@ -0,0 +1,58 @@
1
+ import type { Attempt, AttemptEvent } from "@/progress/attempt.ts";
2
+ import { reviewQueue, type ReviewRequirements } from "@/progress/review-queue.ts";
3
+ import type { Focus } from "@/questions/focus.ts";
4
+ import { findQuestion, nextQuestion } from "@/questions/local-bank.ts";
5
+ import type { Question } from "@/questions/question.ts";
6
+
7
+ export type QuestionQueue =
8
+ | { kind: "unanswered"; skippedIds: ReadonlySet<string> }
9
+ | { kind: "review"; pendingIds: readonly string[] };
10
+
11
+ export type QueueResult = {
12
+ queue: QuestionQueue;
13
+ question?: Question;
14
+ };
15
+
16
+ export function unansweredQuestions(): QuestionQueue {
17
+ return { kind: "unanswered", skippedIds: new Set() };
18
+ }
19
+
20
+ export function questionsToReview(
21
+ attempts: Iterable<Attempt>,
22
+ events: Iterable<AttemptEvent>,
23
+ requirements: ReviewRequirements,
24
+ now = new Date(),
25
+ ): QuestionQueue {
26
+ return { kind: "review", pendingIds: reviewQueue(attempts, events, requirements, now) };
27
+ }
28
+
29
+ export function skipQuestion(queue: QuestionQueue, questionId: string): QuestionQueue {
30
+ if (queue.kind === "review") return queue;
31
+ return { ...queue, skippedIds: new Set([...queue.skippedIds, questionId]) };
32
+ }
33
+
34
+ export async function takeNextQuestion(
35
+ queue: QuestionQueue,
36
+ attempts: ReadonlyMap<string, Attempt>,
37
+ focus: Focus,
38
+ ): Promise<QueueResult> {
39
+ if (queue.kind === "unanswered") {
40
+ return {
41
+ queue,
42
+ question: await nextQuestion([...attempts.keys(), ...queue.skippedIds], focus),
43
+ };
44
+ }
45
+
46
+ const pendingIds = [...queue.pendingIds];
47
+ while (pendingIds.length) {
48
+ const question = await findQuestion(pendingIds.shift()!);
49
+ if (question) return { queue: { kind: "review", pendingIds }, question };
50
+ }
51
+ return { queue: { kind: "review", pendingIds } };
52
+ }
53
+
54
+ export function emptyQueueMessage(queue: QuestionQueue): string {
55
+ return queue.kind === "review"
56
+ ? "Review queue complete."
57
+ : "No unanswered questions match this focus.";
58
+ }
@@ -0,0 +1,131 @@
1
+ import { chmod, mkdir, rename } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { preferencesPath } from "@/local-data/paths.ts";
5
+
6
+ export const resultDetailLevels = ["brief", "standard", "detailed"] as const;
7
+ export type ResultDetail = typeof resultDetailLevels[number];
8
+ export const bundledPreferencesSchemaPath = fileURLToPath(new URL("./preferences.schema.json", import.meta.url));
9
+
10
+ export type ReviewPreferences = {
11
+ minimumDays: number;
12
+ minimumAnswersAfter: number;
13
+ };
14
+
15
+ export type Preferences = {
16
+ review: ReviewPreferences;
17
+ display: {
18
+ resultDetail: ResultDetail;
19
+ };
20
+ };
21
+
22
+ export const defaultPreferences: Preferences = {
23
+ review: {
24
+ minimumDays: 7,
25
+ minimumAnswersAfter: 100,
26
+ },
27
+ display: {
28
+ resultDetail: "standard",
29
+ },
30
+ };
31
+
32
+ export async function ensurePreferences(path = preferencesPath): Promise<void> {
33
+ if (await Bun.file(path).exists()) await savePreferences(await loadPreferences(path), path);
34
+ else await savePreferences(defaultPreferences, path);
35
+ }
36
+
37
+ export async function loadPreferences(path = preferencesPath): Promise<Preferences> {
38
+ let source: string;
39
+ try {
40
+ source = await Bun.file(path).text();
41
+ } catch (error) {
42
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return structuredClone(defaultPreferences);
43
+ throw error;
44
+ }
45
+
46
+ try {
47
+ return parsePreferences(JSON.parse(source));
48
+ } catch (error) {
49
+ throw new Error(`Invalid preferences at ${path}: ${message(error)}`);
50
+ }
51
+ }
52
+
53
+ export async function savePreferences(preferences: Preferences, path = preferencesPath): Promise<void> {
54
+ const normalized = parsePreferences(preferences);
55
+ await mkdir(dirname(path), { recursive: true });
56
+ await writePreferencesSchema(path);
57
+ const temporaryPath = `${path}.${process.pid}.tmp`;
58
+ const document = { $schema: `./${basename(schemaPathFor(path))}`, ...normalized };
59
+ await Bun.write(temporaryPath, `${JSON.stringify(document, null, 2)}\n`);
60
+ await chmod(temporaryPath, 0o600);
61
+ await rename(temporaryPath, path);
62
+ }
63
+
64
+ export function parsePreferences(value: unknown): Preferences {
65
+ const root = record(value, "preferences");
66
+ knownKeys(root, ["$schema", "review", "display"], "preferences");
67
+ if (root.$schema !== undefined && typeof root.$schema !== "string") throw new Error("$schema must be a string");
68
+ const review = root.review === undefined ? {} : record(root.review, "review");
69
+ knownKeys(review, ["minimumDays", "minimumAnswersAfter"], "review");
70
+ const display = root.display === undefined ? {} : record(root.display, "display");
71
+ knownKeys(display, ["resultDetail", "showTaxonomy"], "display");
72
+ const legacyTaxonomy = optionalBoolean(display.showTaxonomy, "display.showTaxonomy");
73
+ return {
74
+ review: {
75
+ minimumDays: nonNegativeInteger(review.minimumDays, "review.minimumDays", defaultPreferences.review.minimumDays),
76
+ minimumAnswersAfter: nonNegativeInteger(review.minimumAnswersAfter, "review.minimumAnswersAfter", defaultPreferences.review.minimumAnswersAfter),
77
+ },
78
+ display: {
79
+ resultDetail: resultDetail(display.resultDetail, legacyTaxonomy),
80
+ },
81
+ };
82
+ }
83
+
84
+ async function writePreferencesSchema(path: string): Promise<void> {
85
+ const schemaPath = schemaPathFor(path);
86
+ const temporaryPath = `${schemaPath}.${process.pid}.tmp`;
87
+ await Bun.write(temporaryPath, Bun.file(bundledPreferencesSchemaPath));
88
+ await chmod(temporaryPath, 0o600);
89
+ await rename(temporaryPath, schemaPath);
90
+ }
91
+
92
+ function schemaPathFor(path: string): string {
93
+ return join(dirname(path), "preferences.schema.json");
94
+ }
95
+
96
+ function record(value: unknown, name: string): Record<string, unknown> {
97
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`);
98
+ return value as Record<string, unknown>;
99
+ }
100
+
101
+ function knownKeys(value: Record<string, unknown>, allowed: readonly string[], name: string): void {
102
+ const unknown = Object.keys(value).find((key) => !allowed.includes(key));
103
+ if (unknown) throw new Error(`unknown ${name} setting: ${unknown}`);
104
+ }
105
+
106
+ function nonNegativeInteger(value: unknown, name: string, fallback: number): number {
107
+ if (value === undefined) return fallback;
108
+ if (!Number.isInteger(value) || (value as number) < 0) throw new Error(`${name} must be a non-negative integer`);
109
+ return value as number;
110
+ }
111
+
112
+ function optionalBoolean(value: unknown, name: string): boolean | undefined {
113
+ if (value === undefined) return undefined;
114
+ if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`);
115
+ return value;
116
+ }
117
+
118
+ function resultDetail(value: unknown, legacyTaxonomy: boolean | undefined): ResultDetail {
119
+ if (value === undefined) {
120
+ if (legacyTaxonomy !== undefined) return legacyTaxonomy ? "detailed" : "standard";
121
+ return defaultPreferences.display.resultDetail;
122
+ }
123
+ if (!resultDetailLevels.includes(value as ResultDetail)) {
124
+ throw new Error(`display.resultDetail must be one of: ${resultDetailLevels.join(", ")}`);
125
+ }
126
+ return value as ResultDetail;
127
+ }
128
+
129
+ function message(value: unknown): string {
130
+ return value instanceof Error ? value.message : String(value);
131
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "saterminal preferences",
4
+ "description": "Local preferences for SAT practice and answer review.",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "$schema": {
9
+ "type": "string",
10
+ "description": "Editor schema association. Managed by saterminal."
11
+ },
12
+ "review": {
13
+ "type": "object",
14
+ "description": "Spacing requirements for missed and corrected questions.",
15
+ "additionalProperties": false,
16
+ "properties": {
17
+ "minimumDays": {
18
+ "type": "integer",
19
+ "minimum": 0,
20
+ "default": 7,
21
+ "description": "Days before a question becomes eligible for review."
22
+ },
23
+ "minimumAnswersAfter": {
24
+ "type": "integer",
25
+ "minimum": 0,
26
+ "default": 100,
27
+ "description": "Later answers required before a question becomes eligible for review."
28
+ }
29
+ }
30
+ },
31
+ "display": {
32
+ "type": "object",
33
+ "description": "Controls learner-facing information in the terminal interface.",
34
+ "additionalProperties": false,
35
+ "properties": {
36
+ "resultDetail": {
37
+ "type": "string",
38
+ "enum": ["brief", "standard", "detailed"],
39
+ "default": "standard",
40
+ "description": "Answer-result context: brief shows time; standard adds difficulty; detailed adds taxonomy codes and labels."
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,55 @@
1
+ import type { AttemptEvent } from "@/progress/attempt.ts";
2
+
3
+ export type ActivityDay = { date: string; count: number };
4
+ export type Activity = {
5
+ streak: number;
6
+ activeDays: number;
7
+ todayCount: number;
8
+ totalAnswers: number;
9
+ days: ActivityDay[];
10
+ };
11
+
12
+ export function activity(events: readonly AttemptEvent[], now = new Date(), dayCount = 84): Activity {
13
+ const counts = new Map<string, number>();
14
+ for (const event of events) {
15
+ const date = new Date(event.answeredAt);
16
+ if (!Number.isNaN(date.getTime())) counts.set(dateKey(date), (counts.get(dateKey(date)) ?? 0) + 1);
17
+ }
18
+ const today = startOfDay(now);
19
+ const days = Array.from({ length: dayCount }, (_, offset) => {
20
+ const date = addDays(today, offset - dayCount + 1);
21
+ const key = dateKey(date);
22
+ return { date: key, count: counts.get(key) ?? 0 };
23
+ });
24
+ return {
25
+ streak: streak(counts, today),
26
+ activeDays: days.filter((day) => day.count > 0).length,
27
+ todayCount: counts.get(dateKey(today)) ?? 0,
28
+ totalAnswers: events.length,
29
+ days,
30
+ };
31
+ }
32
+
33
+ function streak(counts: Map<string, number>, today: Date): number {
34
+ let cursor = counts.get(dateKey(today)) ? today : addDays(today, -1);
35
+ let value = 0;
36
+ while ((counts.get(dateKey(cursor)) ?? 0) > 0) {
37
+ value += 1;
38
+ cursor = addDays(cursor, -1);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ function startOfDay(date: Date): Date {
44
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
45
+ }
46
+
47
+ function addDays(date: Date, amount: number): Date {
48
+ const next = new Date(date);
49
+ next.setDate(next.getDate() + amount);
50
+ return startOfDay(next);
51
+ }
52
+
53
+ function dateKey(date: Date): string {
54
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
55
+ }