Skip to content

Commit 2bbf705

Browse files
committed
feat: add "train second key" guided lesson option
Adds lesson.guided.trainSecondKey: previews and unlocks a second key alongside the primary focus, one letter at a time.
1 parent 3f31dde commit 2bbf705

9 files changed

Lines changed: 1210 additions & 35 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { type CodePoint } from "@keybr/unicode";
2+
3+
const storageKey = "lesson.guided.confirmedUnlocks";
4+
5+
// Local-only, like @keybr/settings's Preferences -- deliberately not a
6+
// Settings prop, since writing one reconstructs GuidedLesson itself.
7+
export class ConfirmedUnlocks {
8+
static load(scope: string): ReadonlySet<CodePoint> {
9+
return new Set(readAll()[scope] ?? []);
10+
}
11+
12+
static confirm(scope: string, codePoint: CodePoint): void {
13+
const all = readAll();
14+
const scoped = new Set(all[scope] ?? []);
15+
if (!scoped.has(codePoint)) {
16+
scoped.add(codePoint);
17+
writeAll({ ...all, [scope]: [...scoped] });
18+
}
19+
}
20+
}
21+
22+
function readAll(): Record<string, readonly CodePoint[]> {
23+
if (typeof window !== "object") {
24+
return {};
25+
}
26+
const item = window.localStorage.getItem(storageKey);
27+
if (item == null) {
28+
return {};
29+
}
30+
try {
31+
const parsed: unknown = JSON.parse(item);
32+
return parsed != null && typeof parsed === "object"
33+
? (parsed as Record<string, readonly CodePoint[]>)
34+
: {};
35+
} catch {
36+
window.localStorage.removeItem(storageKey);
37+
return {};
38+
}
39+
}
40+
41+
function writeAll(all: Record<string, readonly CodePoint[]>): void {
42+
if (typeof window === "object") {
43+
window.localStorage.setItem(storageKey, JSON.stringify(all));
44+
}
45+
}

packages/keybr-lesson/lib/fakes.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
import { type Letter } from "@keybr/phonetic-model";
2-
import { type KeyStats, type KeyStatsMap, speedToTime } from "@keybr/result";
2+
import {
3+
type KeySample,
4+
type KeyStats,
5+
type KeyStatsMap,
6+
speedToTime,
7+
} from "@keybr/result";
38
import { type Settings } from "@keybr/settings";
49
import { type LessonKeys } from "./key.ts";
510
import { Target } from "./target.ts";
611

12+
const defaultSampleCount = 5;
13+
714
export function fakeKeyStatsMap(
815
settings: Settings,
916
items: [
1017
letter: Letter,
1118
confidence: number | null,
1219
bestConfidence: number | null,
20+
sampleCount?: number,
1321
][],
1422
): KeyStatsMap {
1523
const target = new Target(settings);
1624
const map = new Map<Letter, KeyStats>(
17-
items.map(([letter, confidence, bestConfidence]) => {
25+
items.map(([letter, confidence, bestConfidence, sampleCount]) => {
1826
const timeToType =
1927
confidence == null
2028
? null
@@ -23,28 +31,54 @@ export function fakeKeyStatsMap(
2331
bestConfidence == null
2432
? null
2533
: speedToTime(target.targetSpeed) / bestConfidence;
34+
const count =
35+
confidence == null && bestConfidence == null
36+
? 0
37+
: (sampleCount ?? defaultSampleCount);
38+
const samples: KeySample[] = Array.from(
39+
{ length: count },
40+
(_, index) => ({
41+
index,
42+
timeStamp: 0,
43+
hitCount: 1,
44+
missCount: 0,
45+
timeToType: timeToType ?? 0,
46+
filteredTimeToType: timeToType ?? 0,
47+
}),
48+
);
2649
return [
2750
letter,
2851
{
2952
letter,
30-
samples: [],
53+
samples,
3154
timeToType,
3255
bestTimeToType,
3356
} as KeyStats,
3457
];
3558
}),
3659
);
60+
// A real KeyStatsMap.results grows by one per round; GuidedLesson.update()
61+
// memoizes on it (plus identity of the last entry) to make its two
62+
// same-round call sites idempotent. Synthesize one placeholder entry per
63+
// sample so that varying sample counts across simulated rounds is seen
64+
// as new evidence, while passing the exact same fakeKeyStatsMap() result
65+
// to update() twice (same array, same last entry) is seen as a repeat.
66+
const totalSamples = [...map.values()].reduce(
67+
(sum, keyStats) => sum + keyStats.samples.length,
68+
0,
69+
);
70+
const results = Array.from({ length: totalSamples }, () => ({}));
3771
return {
3872
letters: [...map.keys()],
39-
results: [],
73+
results,
4074
get: (letter) => map.get(letter),
4175
[Symbol.iterator]: () => map.values(),
4276
} as KeyStatsMap;
4377
}
4478

4579
export function printLessonKeys(lessonKeys: LessonKeys) {
46-
return lessonKeys
47-
.findIncludedKeys()
80+
return [...lessonKeys]
81+
.filter((key) => key.isIncluded || key.isSecondFocused)
4882
.map((key) => {
4983
let s = `${key.letter}`;
5084
if (key.isForced) {
@@ -53,6 +87,9 @@ export function printLessonKeys(lessonKeys: LessonKeys) {
5387
if (key.isFocused) {
5488
s = `[${s}]`;
5589
}
90+
if (key.isSecondFocused) {
91+
s = `(${s})`;
92+
}
5693
return s;
5794
})
5895
.join("");

0 commit comments

Comments
 (0)