saterminal 0.1.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/src/state.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { defaultFocus, normalizeFocus } from "./focus.ts";
4
+ import type { Attempt, Focus, Outcome, SummaryRow } from "./types.ts";
5
+
6
+ export const stateDir = "userlocal";
7
+ export const attemptsPath = join(stateDir, "attempts.csv");
8
+ export const summaryPath = join(stateDir, "summary.csv");
9
+ export const focusPath = join(stateDir, "focus.json");
10
+
11
+ const attemptsHeader = "question_id,outcome,updated_at,elapsed_seconds";
12
+ const summaryHeader = "metric,value,updated_at";
13
+
14
+ export async function ensureStateFiles(): Promise<void> {
15
+ await mkdir(stateDir, { recursive: true });
16
+ await ensureFile(attemptsPath, `${attemptsHeader}\n`);
17
+ await ensureFile(summaryPath, `${summaryHeader}\n`);
18
+ await ensureFile(focusPath, `${JSON.stringify(defaultFocus, null, 2)}\n`);
19
+ }
20
+
21
+ export async function loadAttempts(path = attemptsPath): Promise<Map<string, Attempt>> {
22
+ await ensureFile(path, `${attemptsHeader}\n`);
23
+ const raw = await readFile(path, "utf8");
24
+ const rows = parseCsv(raw);
25
+ const attempts = new Map<string, Attempt>();
26
+
27
+ for (const row of rows.slice(1)) {
28
+ const [question_id, outcome, updated_at, elapsed_seconds] = row;
29
+ if (!question_id || !isOutcome(outcome) || !updated_at) {
30
+ continue;
31
+ }
32
+
33
+ attempts.set(question_id, {
34
+ question_id,
35
+ outcome,
36
+ updated_at,
37
+ elapsed_seconds: readElapsedSeconds(elapsed_seconds),
38
+ });
39
+ }
40
+
41
+ return attempts;
42
+ }
43
+
44
+ export async function saveAttempts(attempts: Map<string, Attempt>, path = attemptsPath): Promise<void> {
45
+ await mkdir(dirname(path), { recursive: true });
46
+ const rows = [...attempts.values()]
47
+ .sort((a, b) => a.updated_at.localeCompare(b.updated_at))
48
+ .map((attempt) => [attempt.question_id, attempt.outcome, attempt.updated_at, String(attempt.elapsed_seconds)]);
49
+
50
+ await writeFile(path, formatCsv([attemptsHeader.split(","), ...rows]), "utf8");
51
+ }
52
+
53
+ export function recordAttempt(
54
+ attempts: Map<string, Attempt>,
55
+ questionId: string,
56
+ wasCorrect: boolean,
57
+ elapsedSeconds = 0,
58
+ now = new Date(),
59
+ ): Attempt {
60
+ const existing = attempts.get(questionId);
61
+ const updated_at = now.toISOString();
62
+ const outcome = nextOutcome(existing?.outcome, wasCorrect);
63
+ const attempt = { question_id: questionId, outcome, updated_at, elapsed_seconds: elapsedSeconds };
64
+ attempts.set(questionId, attempt);
65
+ return attempt;
66
+ }
67
+
68
+ export function nextOutcome(previous: Outcome | undefined, wasCorrect: boolean): Outcome {
69
+ if (previous === "correct" || previous === "corrected") {
70
+ return previous;
71
+ }
72
+
73
+ if (previous === "incorrect" && wasCorrect) {
74
+ return "corrected";
75
+ }
76
+
77
+ return wasCorrect ? "correct" : "incorrect";
78
+ }
79
+
80
+ export function buildSummaryRows(attempts: Map<string, Attempt>, now = new Date()): SummaryRow[] {
81
+ const updated_at = now.toISOString();
82
+ const values = [...attempts.values()];
83
+ const total = values.length;
84
+ const correct = values.filter((attempt) => attempt.outcome === "correct").length;
85
+ const incorrect = values.filter((attempt) => attempt.outcome === "incorrect").length;
86
+ const corrected = values.filter((attempt) => attempt.outcome === "corrected").length;
87
+ const mastered = correct + corrected;
88
+ const accuracy = total === 0 ? "0.00" : (mastered / total).toFixed(2);
89
+ const totalSeconds = values.reduce((sum, attempt) => sum + attempt.elapsed_seconds, 0);
90
+ const avgSeconds = total === 0 ? 0 : totalSeconds / total;
91
+
92
+ return [
93
+ { metric: "answered", value: String(total), updated_at },
94
+ { metric: "correct", value: String(correct), updated_at },
95
+ { metric: "incorrect", value: String(incorrect), updated_at },
96
+ { metric: "corrected", value: String(corrected), updated_at },
97
+ { metric: "accuracy", value: accuracy, updated_at },
98
+ { metric: "avg_seconds", value: avgSeconds.toFixed(1), updated_at },
99
+ ];
100
+ }
101
+
102
+ export async function saveSummary(attempts: Map<string, Attempt>, path = summaryPath): Promise<void> {
103
+ await mkdir(dirname(path), { recursive: true });
104
+ const rows = buildSummaryRows(attempts).map((row) => [row.metric, row.value, row.updated_at]);
105
+ await writeFile(path, formatCsv([summaryHeader.split(","), ...rows]), "utf8");
106
+ }
107
+
108
+ export async function loadFocus(path = focusPath): Promise<Focus> {
109
+ await ensureFile(path, `${JSON.stringify(defaultFocus, null, 2)}\n`);
110
+ try {
111
+ return normalizeFocus(JSON.parse(await readFile(path, "utf8")));
112
+ } catch {
113
+ return defaultFocus;
114
+ }
115
+ }
116
+
117
+ export async function saveFocus(focus: Focus, path = focusPath): Promise<void> {
118
+ await mkdir(dirname(path), { recursive: true });
119
+ await writeFile(path, `${JSON.stringify(normalizeFocus(focus), null, 2)}\n`, "utf8");
120
+ }
121
+
122
+ function isOutcome(value: string | undefined): value is Outcome {
123
+ return value === "correct" || value === "incorrect" || value === "corrected";
124
+ }
125
+
126
+ function readElapsedSeconds(value: string | undefined): number {
127
+ const seconds = Number(value);
128
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
129
+ }
130
+
131
+ async function ensureFile(path: string, contents: string): Promise<void> {
132
+ try {
133
+ await readFile(path, "utf8");
134
+ } catch (error) {
135
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
136
+ throw error;
137
+ }
138
+
139
+ await mkdir(dirname(path), { recursive: true });
140
+ await writeFile(path, contents, "utf8");
141
+ }
142
+ }
143
+
144
+ function parseCsv(raw: string): string[][] {
145
+ const rows: string[][] = [];
146
+ let row: string[] = [];
147
+ let field = "";
148
+ let quoted = false;
149
+
150
+ for (let index = 0; index < raw.length; index += 1) {
151
+ const char = raw[index];
152
+ const next = raw[index + 1];
153
+
154
+ if (quoted) {
155
+ if (char === "\"" && next === "\"") {
156
+ field += "\"";
157
+ index += 1;
158
+ } else if (char === "\"") {
159
+ quoted = false;
160
+ } else {
161
+ field += char;
162
+ }
163
+ continue;
164
+ }
165
+
166
+ if (char === "\"") {
167
+ quoted = true;
168
+ } else if (char === ",") {
169
+ row.push(field.trim());
170
+ field = "";
171
+ } else if (char === "\n") {
172
+ row.push(field.trim());
173
+ if (row.some((value) => value.length > 0)) {
174
+ rows.push(row);
175
+ }
176
+ row = [];
177
+ field = "";
178
+ } else if (char !== "\r") {
179
+ field += char;
180
+ }
181
+ }
182
+
183
+ if (field.length > 0 || row.length > 0) {
184
+ row.push(field.trim());
185
+ if (row.some((value) => value.length > 0)) {
186
+ rows.push(row);
187
+ }
188
+ }
189
+
190
+ return rows;
191
+ }
192
+
193
+ function formatCsv(rows: string[][]): string {
194
+ return `${rows.map((row) => row.map(csvEscape).join(",")).join("\n")}\n`;
195
+ }
196
+
197
+ function csvEscape(value: string): string {
198
+ if (!/[",\n\r]/.test(value)) {
199
+ return value;
200
+ }
201
+
202
+ return `"${value.replaceAll("\"", "\"\"")}"`;
203
+ }
package/src/text.ts ADDED
@@ -0,0 +1,256 @@
1
+ import he from "he";
2
+ import { HTMLElement, NodeType, parse, type Node } from "node-html-parser";
3
+ import wrap from "word-wrap";
4
+
5
+ export type TextStyle = {
6
+ bold?: boolean;
7
+ underline?: boolean;
8
+ italic?: boolean;
9
+ };
10
+
11
+ export type TextSegment = {
12
+ text: string;
13
+ style: TextStyle;
14
+ };
15
+
16
+ export function htmlToText(html = ""): string {
17
+ const text = parseHtmlSegments(html)
18
+ .map((segment) => segment.text)
19
+ .join("")
20
+ .replace(/[ \t]+\n/g, "\n")
21
+ .replace(/\n{3,}/g, "\n\n")
22
+ .trim();
23
+
24
+ return normalizeDisplayText(text);
25
+ }
26
+
27
+ export function parseHtmlSegments(html = ""): TextSegment[] {
28
+ const root = parse(prepareHtml(html), { lowerCaseTagName: true });
29
+ const segments: TextSegment[] = [];
30
+ walkNodes(root, {}, segments);
31
+
32
+ return finalizeSegments(segments).map((segment, index, all) => ({
33
+ text: normalizeDisplayText(normalizeSegmentWhitespace(trimSegmentEdges(segment.text, index, all.length))),
34
+ style: segment.style,
35
+ }));
36
+ }
37
+
38
+ export function wrapSegments(segments: TextSegment[], width: number): TextSegment[][] {
39
+ const usableWidth = Math.max(1, width);
40
+ const lines: TextSegment[][] = [];
41
+ let currentLine: TextSegment[] = [];
42
+ let currentWidth = 0;
43
+
44
+ const flushLine = () => {
45
+ lines.push(currentLine);
46
+ currentLine = [];
47
+ currentWidth = 0;
48
+ };
49
+
50
+ const appendToLine = (text: string, style: TextStyle) => {
51
+ if (!text) {
52
+ return;
53
+ }
54
+
55
+ const last = currentLine[currentLine.length - 1];
56
+ if (last && stylesEqual(last.style, style)) {
57
+ last.text += text;
58
+ } else {
59
+ currentLine.push({ text, style: { ...style } });
60
+ }
61
+ currentWidth += text.length;
62
+ };
63
+
64
+ for (const segment of segments) {
65
+ const parts = segment.text.split("\n");
66
+ for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
67
+ if (partIndex > 0) {
68
+ flushLine();
69
+ }
70
+
71
+ const words = parts[partIndex].split(/\s+/).filter(Boolean);
72
+ for (const word of words) {
73
+ const needed = (currentWidth === 0 ? 0 : 1) + word.length;
74
+
75
+ if (currentWidth > 0 && currentWidth + needed > usableWidth) {
76
+ flushLine();
77
+ }
78
+
79
+ appendToLine(`${currentWidth === 0 ? "" : " "}${word}`, segment.style);
80
+ }
81
+ }
82
+ }
83
+
84
+ if (currentLine.length > 0 || lines.length === 0) {
85
+ flushLine();
86
+ }
87
+
88
+ return lines;
89
+ }
90
+
91
+ export function hasHtmlTable(...values: Array<string | undefined>): boolean {
92
+ return values.some((value) => parse(value ?? "").querySelector("table") !== null);
93
+ }
94
+
95
+ export function decodeEntities(value: string): string {
96
+ return he.decode(value);
97
+ }
98
+
99
+ export function wrapText(value: string, width: number): string[] {
100
+ return wrap(value, {
101
+ width: Math.max(1, width),
102
+ trim: true,
103
+ indent: "",
104
+ newline: "\n",
105
+ }).split("\n");
106
+ }
107
+
108
+ function walkNodes(node: Node, style: TextStyle, segments: TextSegment[]): void {
109
+ if (node.nodeType === NodeType.TEXT_NODE) {
110
+ pushText(segments, node.text, style);
111
+ return;
112
+ }
113
+
114
+ if (!(node instanceof HTMLElement)) {
115
+ return;
116
+ }
117
+
118
+ if (!node.tagName) {
119
+ for (const child of node.childNodes) {
120
+ walkNodes(child, style, segments);
121
+ }
122
+ return;
123
+ }
124
+
125
+ const tag = node.tagName.toLowerCase();
126
+ if (tag === "br") {
127
+ pushText(segments, "\n", style);
128
+ return;
129
+ }
130
+
131
+ const nextStyle = styleForElement(node, style);
132
+ for (const child of node.childNodes) {
133
+ walkNodes(child, nextStyle, segments);
134
+ }
135
+
136
+ if (isBlockTag(tag)) {
137
+ pushText(segments, "\n", nextStyle);
138
+ }
139
+ }
140
+
141
+ function styleForElement(node: HTMLElement, style: TextStyle): TextStyle {
142
+ const tag = node.tagName.toLowerCase();
143
+ const next = { ...style };
144
+
145
+ if (tag === "u" || hasUnderlineStyle(node.getAttribute("style") ?? "")) {
146
+ next.underline = true;
147
+ }
148
+ if (tag === "strong" || tag === "b") {
149
+ next.bold = true;
150
+ }
151
+ if (tag === "em" || tag === "i") {
152
+ next.italic = true;
153
+ }
154
+
155
+ return next;
156
+ }
157
+
158
+ function isBlockTag(tag: string): boolean {
159
+ return tag === "p" || tag === "div" || tag === "li" || /^h[1-6]$/.test(tag);
160
+ }
161
+
162
+ function hasUnderlineStyle(style: string): boolean {
163
+ return /text-decoration\s*:\s*[^;"]*underline/i.test(style);
164
+ }
165
+
166
+ function prepareHtml(html: string): string {
167
+ return html
168
+ .replace(/<\s*svg\b([^>]*)>[\s\S]*?<\s*\/\s*svg\s*>/gi, (_match, attrs: string) => mediaLabel("Graph", attrs))
169
+ .replace(/<\s*img\b([^>]*)>/gi, (_match, attrs: string) => mediaLabel("Image", attrs));
170
+ }
171
+
172
+ function pushText(segments: TextSegment[], text: string, style: TextStyle): void {
173
+ if (!text) {
174
+ return;
175
+ }
176
+
177
+ if (text === "\n") {
178
+ appendSegment(segments, "\n", style);
179
+ return;
180
+ }
181
+
182
+ const normalized = decodeEntities(text).replace(/[ \t\r\f\v]+/g, " ");
183
+ if (!normalized.trim()) {
184
+ return;
185
+ }
186
+
187
+ appendSegment(segments, normalized, style);
188
+ }
189
+
190
+ function appendSegment(segments: TextSegment[], text: string, style: TextStyle): void {
191
+ const last = segments[segments.length - 1];
192
+ if (last && stylesEqual(last.style, style)) {
193
+ last.text += text;
194
+ return;
195
+ }
196
+
197
+ segments.push({ text, style: { ...style } });
198
+ }
199
+
200
+ function stylesEqual(left: TextStyle, right: TextStyle): boolean {
201
+ return !!left.bold === !!right.bold && !!left.underline === !!right.underline && !!left.italic === !!right.italic;
202
+ }
203
+
204
+ function finalizeSegments(segments: TextSegment[]): TextSegment[] {
205
+ const merged: TextSegment[] = [];
206
+ for (const segment of segments) {
207
+ const last = merged[merged.length - 1];
208
+ if (last && stylesEqual(last.style, segment.style)) {
209
+ last.text += segment.text;
210
+ } else {
211
+ merged.push({ text: segment.text, style: { ...segment.style } });
212
+ }
213
+ }
214
+ return merged;
215
+ }
216
+
217
+ function mediaLabel(kind: string, attrs: string): string {
218
+ const label = readAttribute(attrs, "aria-label") ?? readAttribute(attrs, "alt");
219
+ return label ? `\n[${kind}: ${label}]\n` : `\n[${kind}]\n`;
220
+ }
221
+
222
+ function readAttribute(attrs: string, name: string): string | undefined {
223
+ const pattern = new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
224
+ const match = attrs.match(pattern);
225
+ return match?.[1] ?? match?.[2] ?? match?.[3];
226
+ }
227
+
228
+ function normalizeDisplayText(value: string): string {
229
+ return value
230
+ .replace(/\u2019/g, "'")
231
+ .replace(/\u2018/g, "'")
232
+ .replace(/\u2014/g, "-")
233
+ .replace(/\u2013/g, "-")
234
+ .replace(/_{3,}\s*blank\b/gi, "_______");
235
+ }
236
+
237
+ function normalizeSegmentWhitespace(value: string): string {
238
+ return value
239
+ .replace(/[ \t]*\n[ \t]*/g, "\n")
240
+ .replace(/\n{3,}/g, "\n\n")
241
+ .replace(/^\n+/, "\n")
242
+ .replace(/\n+$/, (match) => (match.length > 1 ? "\n\n" : match));
243
+ }
244
+
245
+ function trimSegmentEdges(value: string, index: number, total: number): string {
246
+ let text = value;
247
+ if (index === 0) {
248
+ text = text.replace(/^\n+/, "");
249
+ text = text.replace(/^[ \t]+/, "");
250
+ }
251
+ if (index === total - 1) {
252
+ text = text.replace(/\n+$/, "");
253
+ text = text.replace(/[ \t]+$/, "");
254
+ }
255
+ return text;
256
+ }
package/src/tui/app.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { defaultFocus } from "../focus.ts";
2
+ import { ensureStateFiles, loadAttempts, loadFocus } from "../state.ts";
3
+ import { handleKey } from "./input.ts";
4
+ import { createDocument, render } from "./render.ts";
5
+ import { term } from "./terminal.ts";
6
+ import type { AppState, KeyData } from "./types.ts";
7
+
8
+ export async function runTui(): Promise<void> {
9
+ const state: AppState = {
10
+ attempts: new Map(),
11
+ skippedIds: new Set(),
12
+ focus: defaultFocus,
13
+ focusIndex: 1,
14
+ focusColumn: 0,
15
+ focusRow: 1,
16
+ view: "focus",
17
+ selected: 0,
18
+ questionScroll: 0,
19
+ answerScroll: 0,
20
+ activePane: "answers",
21
+ elapsedMs: 0,
22
+ timerPaused: false,
23
+ timerHidden: false,
24
+ historyIndex: 0,
25
+ };
26
+
27
+ term.fullscreen(true);
28
+ term.hideCursor();
29
+ const doc = createDocument();
30
+ const tick = setInterval(() => {
31
+ if (state.view === "practice" && !state.timerPaused) {
32
+ render(doc, state);
33
+ }
34
+ }, 1000);
35
+
36
+ const cleanup = () => {
37
+ clearInterval(tick);
38
+ doc.destroy();
39
+ term.grabInput(false);
40
+ term.hideCursor(false);
41
+ term.fullscreen(false);
42
+ term.processExit(0);
43
+ };
44
+
45
+ process.on("SIGINT", cleanup);
46
+ term.on("resize", () => render(doc, state));
47
+ term.on("key", async (name: string, _matches?: string[], data?: KeyData) => {
48
+ try {
49
+ if (name === "CTRL_C" || name === "q") {
50
+ cleanup();
51
+ return;
52
+ }
53
+
54
+ await handleKey(state, name, data);
55
+ render(doc, state);
56
+ } catch (error) {
57
+ state.view = "error";
58
+ state.error = error instanceof Error ? error.message : String(error);
59
+ render(doc, state);
60
+ }
61
+ });
62
+
63
+ try {
64
+ await ensureStateFiles();
65
+ state.attempts = await loadAttempts();
66
+ state.focus = await loadFocus();
67
+ state.focusIndex = 1;
68
+ state.focusColumn = 0;
69
+ state.focusRow = 1;
70
+ state.view = "focus";
71
+ } catch (error) {
72
+ state.view = "error";
73
+ state.error = error instanceof Error ? error.message : String(error);
74
+ }
75
+
76
+ render(doc, state);
77
+ }
@@ -0,0 +1,113 @@
1
+ import {
2
+ difficultyLabels,
3
+ difficultyOptions,
4
+ domainLabels,
5
+ domainOptions,
6
+ skillsForDomain,
7
+ skillLabels,
8
+ toggleFocusRow,
9
+ type FocusRow,
10
+ } from "../focus.ts";
11
+ import type { Domain, Focus, Skill } from "../types.ts";
12
+
13
+ export type FocusGridRow = Extract<FocusRow, { kind: "option" }>;
14
+
15
+ export type FocusGridColumn = {
16
+ id: "difficulty" | Domain;
17
+ title: string;
18
+ rows: FocusGridRow[];
19
+ };
20
+
21
+ export type FocusGridPosition = {
22
+ column: number;
23
+ row: number;
24
+ };
25
+
26
+ export function focusGrid(focus: Focus): FocusGridColumn[] {
27
+ return [
28
+ {
29
+ id: "difficulty",
30
+ title: "Difficulty",
31
+ rows: difficultyOptions.map((value) => ({
32
+ kind: "option",
33
+ group: "difficulties",
34
+ value,
35
+ label: `${value} ${difficultyLabels[value]}`,
36
+ checked: focus.difficulties.includes(value),
37
+ depth: 0,
38
+ })),
39
+ },
40
+ ...domainOptions.map((domain) => domainColumn(domain, focus)),
41
+ ];
42
+ }
43
+
44
+ export function normalizeFocusGridPosition(columns: FocusGridColumn[], position: FocusGridPosition): FocusGridPosition {
45
+ const column = clamp(position.column, 0, Math.max(0, columns.length - 1));
46
+ const row = clamp(position.row, 0, Math.max(0, (columns[column]?.rows.length ?? 1) - 1));
47
+ return { column, row };
48
+ }
49
+
50
+ export function moveFocusGridPosition(
51
+ columns: FocusGridColumn[],
52
+ position: FocusGridPosition,
53
+ direction: "up" | "down" | "next" | "previous",
54
+ ): FocusGridPosition {
55
+ const current = normalizeFocusGridPosition(columns, position);
56
+
57
+ if (direction === "up") {
58
+ return { ...current, row: Math.max(0, current.row - 1) };
59
+ }
60
+
61
+ if (direction === "down") {
62
+ return { ...current, row: Math.min(columns[current.column].rows.length - 1, current.row + 1) };
63
+ }
64
+
65
+ const delta = direction === "previous" ? -1 : 1;
66
+ const column = (current.column + delta + columns.length) % columns.length;
67
+ return normalizeFocusGridPosition(columns, { column, row: current.row });
68
+ }
69
+
70
+ export function selectedFocusGridRow(focus: Focus, position: FocusGridPosition): FocusGridRow {
71
+ const columns = focusGrid(focus);
72
+ const current = normalizeFocusGridPosition(columns, position);
73
+ return columns[current.column].rows[current.row];
74
+ }
75
+
76
+ export function toggleFocusGridRow(focus: Focus, position: FocusGridPosition): Focus {
77
+ return toggleFocusRow(focus, selectedFocusGridRow(focus, position));
78
+ }
79
+
80
+ function domainColumn(domain: Domain, focus: Focus): FocusGridColumn {
81
+ const skills = skillsForDomain(domain);
82
+ const selected = skills.filter((skill) => focus.skills.includes(skill));
83
+ const checked = selected.length > 0;
84
+ const partial = checked && selected.length < skills.length;
85
+
86
+ return {
87
+ id: domain,
88
+ title: `${domain} ${domainLabels[domain]}`,
89
+ rows: [
90
+ {
91
+ kind: "option",
92
+ group: "domains",
93
+ value: domain,
94
+ label: "All skills",
95
+ checked,
96
+ partial,
97
+ depth: 0,
98
+ },
99
+ ...skills.map((skill) => ({
100
+ kind: "option" as const,
101
+ group: "skills" as const,
102
+ value: skill as Skill,
103
+ label: `${skill} ${skillLabels[skill]}`,
104
+ checked: focus.skills.includes(skill),
105
+ depth: 1,
106
+ })),
107
+ ],
108
+ };
109
+ }
110
+
111
+ function clamp(value: number, min: number, max: number): number {
112
+ return Math.max(min, Math.min(max, value));
113
+ }
@@ -0,0 +1,6 @@
1
+ import type { Attempt } from "../types.ts";
2
+ import type { AppState } from "./types.ts";
3
+
4
+ export function historyRows(state: AppState): Attempt[] {
5
+ return [...state.attempts.values()].sort((a, b) => b.updated_at.localeCompare(a.updated_at));
6
+ }