Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ designed past.
- Do not invent a requirement to justify a design. If the task is lookup, a
page-marking feature nobody asked for is not evidence that an index is needed.

## The SRS schedulers are measured, not argued

`stores/simple-srs.ts` is a Midori clone and every scheduling rule in it has been changed
on simulated evidence; some tempting changes were measured and rejected. Read
[docs/simple-srs-decisions.md](docs/simple-srs-decisions.md) before editing it or the
simple-SRS branches of `app/(tabs)/lists/study.tsx`. Simulate a deck through
`nextSimpleReview` itself rather than reimplementing the rules — the lapse bug survived
every test for months because the tests called `simpleGraduate` directly and the defect
was in the argument the screen passed it.

## Reader lookup and furigana scoring is settled — measure before you touch it

Tap ranking, furigana resolution, counter readings and the deinflection rule
Expand Down
239 changes: 121 additions & 118 deletions app/(tabs)/lists/study.tsx

Large diffs are not rendered by default.

29 changes: 27 additions & 2 deletions components/DueCardsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { useUserDb } from "@/db/user-provider";
import { useSync } from "@/db/sync-provider";
import { useListsStore } from "@/stores/lists";
import { getCardStateDistribution, type CardDistribution } from "@/lib/practice-stats";
import { srsEpochDaysToDate, endOfLogicalDayEpochDays } from "@/stores/simple-srs";
import {
srsEpochDaysToDate,
endOfLogicalDayEpochDays,
resolveNewPerSession,
learningCapFor,
} from "@/stores/simple-srs";
import { useAtomValue } from "jotai";
import { dayResetHourAtom } from "@/stores/settings";
import type { FlashcardMode } from "@/db/types";
Expand Down Expand Up @@ -122,14 +127,34 @@ export function DueCardsSection({
if (flashcardMode === "simple_srs") {
const rows = await userDb.getAllAsync<{ dueDays: number }>(
`SELECT simple_n as dueDays
FROM srs_cards WHERE list_id = ? AND simple_stage IS NOT NULL AND deleted_at IS NULL`,
FROM srs_cards WHERE list_id = ? AND simple_stage = 1 AND deleted_at IS NULL`,
[listId],
);
for (const row of rows) {
const dueDate = srsEpochDaysToDate(row.dueDays);
const idx = dateToBucketIndex(dueDate, dayResetHour);
counts[idx]++;
}
// What the session will actually serve, not what the deck holds: cards in
// learning are all due now and the session shows three times the
// new-card cap of them, plus up to a cap of unseen cards. Counting the
// deck promised 8,000 due where the session showed thirty and then said
// it was done; ignoring the new cards under-reported a fresh list as 0.
const capRow = await userDb.getFirstAsync<{ new_per_session: number | null }>(
"SELECT new_per_session FROM lists WHERE id = ?",
[listId],
);
const newCap = resolveNewPerSession(capRow?.new_per_session);
const pending = await userDb.getFirstAsync<{ learning: number; unseen: number }>(
// SUM(CASE …) rather than COUNT(*) FILTER, which needs SQLite 3.30+.
`SELECT
SUM(CASE WHEN simple_stage = 0 AND simple_n <= ? THEN 1 ELSE 0 END) as learning,
SUM(CASE WHEN simple_stage IS NULL THEN 1 ELSE 0 END) as unseen
FROM srs_cards WHERE list_id = ? AND deleted_at IS NULL`,
[endOfLogicalDayEpochDays(dayResetHour), listId],
);
counts[0] += Math.min(pending?.learning ?? 0, learningCapFor(newCap));
counts[0] += Math.min(pending?.unseen ?? 0, newCap);
} else {
const newRow = await userDb.getFirstAsync<{ c: number }>(
"SELECT COUNT(*) as c FROM srs_cards WHERE list_id = ? AND state = 0 AND deleted_at IS NULL",
Expand Down
54 changes: 51 additions & 3 deletions components/FlashcardSettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { alert } from "@/lib/confirm";
import { requestVoicePermissions } from "@/lib/voice-recognition";
import { useSync } from "@/db/sync-provider";
import type { CardFace, FlashcardMode } from "@/db/types";
import { DEFAULT_NEW_PER_SESSION } from "@/stores/simple-srs";

interface FlashcardSettingsModalProps {
visible: boolean;
Expand Down Expand Up @@ -55,6 +56,7 @@ export function FlashcardSettingsModal({
const [buttonAnimation, setButtonAnimation] = useAtom(flashcardButtonAnimationAtom);
const [learningStepsText, setLearningStepsText] = useState("");
const [relearningStepsText, setRelearningStepsText] = useState("");
const [newPerSessionText, setNewPerSessionText] = useState("");
const [hasKanjiEntries, setHasKanjiEntries] = useState(false);

const inputMode = voiceMode
Expand Down Expand Up @@ -85,6 +87,7 @@ export function FlashcardSettingsModal({
setMnemonicCloze(list.mnemonicCloze ?? false);
setLearningStepsText(list.learningSteps ? list.learningSteps.join(", ") : "");
setRelearningStepsText(list.relearningSteps ? list.relearningSteps.join(", ") : "");
setNewPerSessionText(list.newPerSession != null ? String(list.newPerSession) : "");
}
}, [visible, list]);

Expand Down Expand Up @@ -121,6 +124,23 @@ export function FlashcardSettingsModal({
async function handleSave() {
if (!userDb) return;

// Empty means "use the default", not zero: a cap of 0 would send a list
// with nothing due straight to the done screen. The input only shows for
// simple SRS, so any other mode keeps whatever the list already stored
// rather than writing NULL over it.
let newPerSession: number | null = list?.newPerSession ?? null;
if (mode === "simple_srs") {
newPerSession = null;
if (newPerSessionText.trim()) {
const parsed = Number(newPerSessionText.trim());
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 500) {
alert("Invalid number", "New cards per session must be a whole number from 1 to 500.");
return;
}
newPerSession = parsed;
}
}

// Validate learning steps if provided
let learningSteps: string[] | null = null;
let relearningSteps: string[] | null = null;
Expand Down Expand Up @@ -159,7 +179,8 @@ export function FlashcardSettingsModal({
typingMode === (list.typingMode ?? false) &&
mnemonicCloze === (list.mnemonicCloze ?? false) &&
arrEq(learningSteps, list.learningSteps) &&
arrEq(relearningSteps, list.relearningSteps);
arrEq(relearningSteps, list.relearningSteps) &&
newPerSession === (list.newPerSession ?? null);
if (unchanged) {
onClose(false);
return;
Expand All @@ -168,7 +189,7 @@ export function FlashcardSettingsModal({

const now = new Date().toISOString();
await userDb.runAsync(
"UPDATE lists SET flashcard_mode = ?, front_faces = ?, back_faces = ?, auto_play_audio = ?, confusion_detection = ?, voice_mode = ?, typing_mode = ?, mnemonic_cloze = ?, learning_steps = ?, relearning_steps = ?, configured = 1, updated_at = ? WHERE id = ?",
"UPDATE lists SET flashcard_mode = ?, front_faces = ?, back_faces = ?, auto_play_audio = ?, confusion_detection = ?, voice_mode = ?, typing_mode = ?, mnemonic_cloze = ?, learning_steps = ?, relearning_steps = ?, new_per_session = ?, configured = 1, updated_at = ? WHERE id = ?",
[
mode,
JSON.stringify(frontFaces),
Expand All @@ -180,6 +201,7 @@ export function FlashcardSettingsModal({
mnemonicCloze ? 1 : 0,
learningSteps ? JSON.stringify(learningSteps) : null,
relearningSteps ? JSON.stringify(relearningSteps) : null,
newPerSession,
now,
listId,
],
Expand All @@ -196,13 +218,17 @@ export function FlashcardSettingsModal({
mnemonicCloze,
learningSteps,
relearningSteps,
newPerSession,
updatedAt: now,
});
markDirty();
if (onStartStudy) {
onStartStudy();
} else {
onClose(mode !== list?.flashcardMode);
// The queue is rebuilt for a changed mode and for a changed new-card cap:
// the cap decides how many cards the session loads, so leaving it until
// the next session means the setting looks ignored.
onClose(mode !== list?.flashcardMode || newPerSession !== (list?.newPerSession ?? null));
}
}

Expand Down Expand Up @@ -297,6 +323,28 @@ export function FlashcardSettingsModal({
</View>
)}

{/* New cards per session (simple SRS only) */}
{mode === "simple_srs" && (
<View className="mb-4">
<Text className="text-sm font-medium text-muted-foreground mb-2">
New cards per session
</Text>
<TextInput
value={newPerSessionText}
onChangeText={setNewPerSessionText}
placeholder={String(DEFAULT_NEW_PER_SESSION)}
placeholderTextColor="#999"
keyboardType="number-pad"
className="border border-border rounded-lg px-3 py-2 text-sm text-foreground"
autoCapitalize="none"
autoCorrect={false}
/>
<Text className="text-xs text-muted-foreground mt-2">
Each new card costs about ten reviews a day once it settles in.
</Text>
</View>
)}

{/* Front faces */}
<Text className="text-sm font-medium text-muted-foreground mb-2">Front</Text>
<View className="flex-row flex-wrap gap-2 mb-4">
Expand Down
3 changes: 2 additions & 1 deletion components/StudyStatisticsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export function StudyStatisticsModal({
if (flashcardMode === "simple_srs") {
const now = new Date().toISOString();
await userDb.runAsync(
"UPDATE srs_cards SET simple_stage = NULL, simple_n = NULL, simple_interval = NULL, updated_at = ? WHERE list_id = ?",
`UPDATE srs_cards SET simple_stage = NULL, simple_n = NULL, simple_interval = NULL,
simple_relearning = 0, simple_run = 0, updated_at = ? WHERE list_id = ?`,
[now, listId],
);
} else if (flashcardMode === "srs") {
Expand Down
3 changes: 3 additions & 0 deletions db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const lists = sqliteTable("lists", {
voiceMode: integer("voice_mode").notNull().default(0),
typingMode: integer("typing_mode").notNull().default(0),
mnemonicCloze: integer("mnemonic_cloze").notNull().default(0),
newPerSession: integer("new_per_session").default(10),
disableFlipAnimation: integer("disable_flip_animation").notNull().default(0),
disableSwipeAnimation: integer("disable_swipe_animation").notNull().default(0),
isDefault: integer("is_default").notNull().default(0),
Expand Down Expand Up @@ -161,6 +162,8 @@ export const srsCards = sqliteTable("srs_cards", {
simpleStage: integer("simple_stage"),
simpleN: real("simple_n"),
simpleInterval: real("simple_interval"),
simpleRelearning: integer("simple_relearning").default(0),
simpleRun: integer("simple_run").default(0),
lastConfusionCheck: text("last_confusion_check"),
deletedAt: text("deleted_at"),
learningSteps: integer("learning_steps").notNull().default(0),
Expand Down
Loading
Loading