Compare commits

..

10 commits

Author SHA1 Message Date
lila
6539d3e346 fix: prevent deployment when quality checks fail
All checks were successful
Build and Deploy / quality (push) Successful in 1m47s
Build and Deploy / build-and-deploy (push) Successful in 1m0s
2026-05-02 13:22:35 +02:00
lila
531da98c24 fix: initialize Resend lazily to prevent test failures when API key is absent
All checks were successful
Build and Deploy / quality (push) Successful in 1m46s
Build and Deploy / build-and-deploy (push) Successful in 1m37s
2026-05-02 13:18:00 +02:00
lila
90b0890263 Merge branch 'dev'
Some checks failed
Build and Deploy / quality (push) Failing after 1m47s
Build and Deploy / build-and-deploy (push) Successful in 2m8s
2026-05-02 13:09:19 +02:00
lila
690e1ab72e updating status
All checks were successful
Build and Deploy / quality (push) Successful in 1m41s
Build and Deploy / build-and-deploy (push) Successful in 1m1s
2026-04-30 02:17:35 +02:00
lila
349107fa6f revert: remove registry login from deploy step
All checks were successful
Build and Deploy / quality (push) Successful in 1m41s
Build and Deploy / build-and-deploy (push) Successful in 34s
2026-04-30 02:02:58 +02:00
lila
14d1837ee9 fix: login to registry in deploy step to bypass gpg passphrase prompt
Some checks failed
Build and Deploy / quality (push) Successful in 1m40s
Build and Deploy / build-and-deploy (push) Failing after 1m1s
2026-04-30 01:52:21 +02:00
lila
5f553930c2 chore: skip husky in production installs
Some checks failed
Build and Deploy / quality (push) Successful in 1m43s
Build and Deploy / build-and-deploy (push) Failing after 1m56s
2026-04-30 01:36:07 +02:00
lila
47a0becc6e chore: fix typecheck script to use per-package tsc --noEmit
Some checks failed
Build and Deploy / quality (push) Successful in 1m41s
Build and Deploy / build-and-deploy (push) Failing after 1m2s
2026-04-30 01:29:19 +02:00
lila
89e559a4db adding pnpm-store
Some checks failed
Build and Deploy / quality (push) Failing after 1m31s
Build and Deploy / build-and-deploy (push) Failing after 48s
2026-04-30 01:20:59 +02:00
lila
4f47e18ad9 formatting 2026-04-30 01:20:12 +02:00
16 changed files with 135 additions and 134 deletions

View file

@ -29,6 +29,7 @@ jobs:
build-and-deploy: build-and-deploy:
runs-on: docker runs-on: docker
needs: quality
steps: steps:
- name: Install tools - name: Install tools
run: apt-get update && apt-get install -y docker.io openssh-client run: apt-get update && apt-get install -y docker.io openssh-client

View file

@ -18,3 +18,5 @@ coverage/
pnpm-lock.yaml pnpm-lock.yaml
routeTree.gen.ts routeTree.gen.ts
.pnpm-store/

View file

@ -7,7 +7,8 @@
"dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts", "dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/src/server.js", "start": "node dist/src/server.js",
"test": "vitest" "test": "vitest",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@lila/db": "workspace:*", "@lila/db": "workspace:*",

View file

@ -4,7 +4,6 @@ import { Resend } from "resend";
import { db } from "@lila/db"; import { db } from "@lila/db";
import * as schema from "@lila/db/schema"; import * as schema from "@lila/db/schema";
const resend = new Resend(process.env["RESEND_API_KEY"]);
const emailFrom = process.env["EMAIL_FROM"] ?? "noreply@lilastudy.com"; const emailFrom = process.env["EMAIL_FROM"] ?? "noreply@lilastudy.com";
export const auth = betterAuth({ export const auth = betterAuth({
@ -30,6 +29,7 @@ export const auth = betterAuth({
user: { email: string }; user: { email: string };
url: string; url: string;
}) => { }) => {
const resend = new Resend(process.env["RESEND_API_KEY"]);
await resend.emails.send({ await resend.emails.send({
from: emailFrom, from: emailFrom,
to: user.email, to: user.email,
@ -48,6 +48,7 @@ export const auth = betterAuth({
user: { email: string }; user: { email: string };
url: string; url: string;
}) => { }) => {
const resend = new Resend(process.env["RESEND_API_KEY"]);
await resend.emails.send({ await resend.emails.send({
from: emailFrom, from: emailFrom,
to: user.email, to: user.email,

View file

@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@lila/shared": "workspace:*", "@lila/shared": "workspace:*",

View file

@ -90,9 +90,6 @@ Directionally right, timing is unclear. Revisit when the next/now work is done.
- **Resolve eslint peer dependency warning** `[debt]` - **Resolve eslint peer dependency warning** `[debt]`
`eslint-plugin-react-hooks 7.0.1` expects `eslint ^3.0.0^9.0.0` but found `10.0.3`. Low impact but worth cleaning up when nearby. `eslint-plugin-react-hooks 7.0.1` expects `eslint ^3.0.0^9.0.0` but found `10.0.3`. Low impact but worth cleaning up when nearby.
- **husky + lint-staged** `[debt]`
Set up husky and lint-staged to run linting and formatting checks before every commit. Prevents CI failures from formatting or lint issues that slipped through locally.
- **OpenAPI documentation for REST endpoints** `[feature]` - **OpenAPI documentation for REST endpoints** `[feature]`
Document the API surface using OpenAPI/Swagger. Covers all REST endpoints with request/response shapes. Useful groundwork for the admin dashboard and any future contributors. Document the API surface using OpenAPI/Swagger. Covers all REST endpoints with request/response shapes. Useful groundwork for the admin dashboard and any future contributors.
@ -105,6 +102,7 @@ Directionally right, timing is unclear. Revisit when the next/now work is done.
Shipped milestones, newest first. Shipped milestones, newest first.
- **04 - 2026 - husky + lint-staged + CI quality gate** - Pre-commit formatting, pre-push tests, and CI lint/typecheck/test gate before every deploy.
- **04 - 2026 - t00001 - Docker credential helper** - **04 - 2026 - t00001 - Docker credential helper**
- **04 - 2026 - Pin dependencies in package.json** - Unpinned deps in a CI/CD pipeline are a real risk. - **04 - 2026 - Pin dependencies in package.json** - Unpinned deps in a CI/CD pipeline are a real risk.
- **04 - 2026 - React error boundaries** - Catch and display runtime errors gracefully instead of crashing the entire app. - **04 - 2026 - React error boundaries** - Catch and display runtime errors gracefully instead of crashing the entire app.

View file

@ -1,6 +1,6 @@
# 🔥 GameService Roast: `apps/api/src/services/gameService.ts` # 🔥 GameService Roast: `apps/api/src/services/gameService.ts`
> *"It works on my machine" is not a scalability strategy.* > _"It works on my machine" is not a scalability strategy._
**Project:** lila — Vocabulary Trainer **Project:** lila — Vocabulary Trainer
**File Roasted:** `gameService.ts` **File Roasted:** `gameService.ts`
@ -28,8 +28,8 @@
**Location:** `gameService.ts:45-58` + `InMemoryGameSessionStore.ts:update()` **Location:** `gameService.ts:45-58` + `InMemoryGameSessionStore.ts:update()`
// Current flow (VULNERABLE): // Current flow (VULNERABLE):
const session = await store.get(submission.sessionId); // READ const session = await store.get(submission.sessionId); // READ
const updatedAnswers = new Map(session.answers); // MODIFY (local copy) const updatedAnswers = new Map(session.answers); // MODIFY (local copy)
updatedAnswers.delete(submission.questionId); updatedAnswers.delete(submission.questionId);
await store.update(submission.sessionId, { answers: updatedAnswers }); // WRITE await store.update(submission.sessionId, { answers: updatedAnswers }); // WRITE
@ -46,43 +46,34 @@ Fix Options:
// Option A: Add atomic operation to store interface // Option A: Add atomic operation to store interface
interface GameSessionStore { interface GameSessionStore {
deleteAnswer(sessionId: string, questionId: string): Promise<boolean>; deleteAnswer(sessionId: string, questionId: string): Promise<boolean>;
} }
// Option B: Use Valkey Lua script for atomic read-modify-write // Option B: Use Valkey Lua script for atomic read-modify-write
// Option C: Optimistic locking with version numbers // Option C: Optimistic locking with version numbers
Priority: 🔴 CRITICAL — Data integrity issue Priority: 🔴 CRITICAL — Data integrity issue 2. N+1 Query: Database Performance Bomb
2. N+1 Query: Database Performance Bomb
Location: gameService.ts:24-26 + termModel.ts:getDistractors() Location: gameService.ts:24-26 + termModel.ts:getDistractors()
// For each of N terms, we call getDistractors(): // For each of N terms, we call getDistractors():
const questions: GameQuestion[] = await Promise.all( const questions: GameQuestion[] = await Promise.all(
terms.map(async (term) => { terms.map(async (term) => {
const distractorTexts = await getDistractors(term.termId, ...); // 🚩 N queries! const distractorTexts = await getDistractors(term.termId, ...); // 🚩 N queries!
}) })
); );
Impact Analysis: Impact Analysis:
Rounds Rounds
DB Queries DB Queries
At 50 concurrent users At 50 concurrent users
3 3
1 + 3 = 4 1 + 3 = 4
200 queries/min 200 queries/min
10 10
1 + 10 = 11 1 + 10 = 11
550 queries/min 550 queries/min
20 20
1 + 20 = 21 1 + 20 = 21
1,050 queries/min 1,050 queries/min
Each getDistractors() runs: Each getDistractors() runs:
@ -95,15 +86,15 @@ Fix: Batch Fetch Distractors
// Fetch all distractors in ONE query // Fetch all distractors in ONE query
const allDistractors = await db const allDistractors = await db
.select({ termId: terms.id, text: translations.text }) .select({ termId: terms.id, text: translations.text })
.from(terms) .from(terms)
.innerJoin(translations, /* ... */) .innerJoin(translations, /_ ... _/)
.where(and( .where(and(
eq(terms.pos, pos), eq(terms.pos, pos),
eq(translations.difficulty, difficulty), eq(translations.difficulty, difficulty),
inArray(terms.id, termIds), // Batch! inArray(terms.id, termIds), // Batch!
)) ))
.limit(DISTRACTOR_FETCH_COUNT * termIds.length); .limit(DISTRACTOR_FETCH_COUNT \* termIds.length);
// Group by termId in JS, then slice to 3 unique distractors per term // Group by termId in JS, then slice to 3 unique distractors per term
const distractorsByTerm = groupByTermId(allDistractors); const distractorsByTerm = groupByTermId(allDistractors);
@ -111,10 +102,10 @@ const distractorsByTerm = groupByTermId(allDistractors);
Priority: 🔴 CRITICAL — Performance/scalability issue Priority: 🔴 CRITICAL — Performance/scalability issue
3. Error Handling Inconsistency 3. Error Handling Inconsistency
Location: gameService.ts:33-36 Location: gameService.ts:33-36
if (uniqueDistractors.length < 3) { if (uniqueDistractors.length < 3) {
throw new Error(`Not enough unique distractors for term: ${term.targetText}`); // 🚩 throw new Error(`Not enough unique distractors for term: ${term.targetText}`); // 🚩
} }
Problem: Raw Error bypasses your errorHandler middleware: Problem: Raw Error bypasses your errorHandler middleware:
@ -127,15 +118,14 @@ Fix:
import { UnprocessableEntityError } from "../errors/AppError.js"; import { UnprocessableEntityError } from "../errors/AppError.js";
if (uniqueDistractors.length < 3) { if (uniqueDistractors.length < 3) {
logger.warn({ termId: term.termId, uniqueCount: uniqueDistractors.length }, logger.warn({ termId: term.termId, uniqueCount: uniqueDistractors.length },
"insufficient_distractors"); "insufficient_distractors");
throw new UnprocessableEntityError( throw new UnprocessableEntityError(
`Not enough unique distractors for term: ${term.targetText}` `Not enough unique distractors for term: ${term.targetText}`
); );
} }
Priority: 🟡 HIGH — Observability & UX issue Priority: 🟡 HIGH — Observability & UX issue
⚠️ High-Severity Smells ⚠️ High-Severity Smells 4. Code Duplication: Singleplayer vs Multiplayer
4. Code Duplication: Singleplayer vs Multiplayer
Compare: gameService.ts vs multiplayerGameService.ts Compare: gameService.ts vs multiplayerGameService.ts
// gameService.ts // gameService.ts
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)]; const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
@ -157,30 +147,29 @@ Fix: Extract pure function to @lila/shared or new @lila/game-logic:
// packages/shared/src/game-logic.ts // packages/shared/src/game-logic.ts
export const buildQuestionOptions = ( export const buildQuestionOptions = (
correctAnswer: string, correctAnswer: string,
distractors: string[], distractors: string[],
optionCount: number = 4 optionCount: number = 4
): { options: AnswerOption[]; correctOptionId: number } => { ): { options: AnswerOption[]; correctOptionId: number } => {
const uniqueDistractors = [...new Set(distractors.filter(d => d !== correctAnswer))]; const uniqueDistractors = [...new Set(distractors.filter(d => d !== correctAnswer))];
const optionTexts = [correctAnswer, ...uniqueDistractors.slice(0, optionCount - 1)]; const optionTexts = [correctAnswer, ...uniqueDistractors.slice(0, optionCount - 1)];
const shuffled = shuffleSecure(optionTexts); const shuffled = shuffleSecure(optionTexts);
const correctOptionId = shuffled.indexOf(correctAnswer); const correctOptionId = shuffled.indexOf(correctAnswer);
return { return {
options: shuffled.map((text, idx) => ({ optionId: idx, text })), options: shuffled.map((text, idx) => ({ optionId: idx, text })),
correctOptionId correctOptionId
}; };
}; };
Priority: 🟡 HIGH — Maintainability issue Priority: 🟡 HIGH — Maintainability issue 5. Shuffle Bias: Math.random() Trap
5. Shuffle Bias: Math.random() Trap
Location: utils.ts:shuffleArray() + multiplayerGameService.ts:shuffle() Location: utils.ts:shuffleArray() + multiplayerGameService.ts:shuffle()
export const shuffleArray = <T>(array: T[]): T[] => { export const shuffleArray = <T>(array: T[]): T[] => {
for (let i = result.length - 1; i > 0; i--) { for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); // 🚩 Modulo bias + non-crypto RNG const j = Math.floor(Math.random() \* (i + 1)); // 🚩 Modulo bias + non-crypto RNG
// ... // ...
} }
}; };
The Math: The Math:
@ -199,65 +188,64 @@ Fix (if needed):
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
const shuffleSecure = <T>(array: T[]): T[] => { const shuffleSecure = <T>(array: T[]): T[] => {
const result = [...array]; const result = [...array];
for (let i = result.length - 1; i > 0; i--) { for (let i = result.length - 1; i > 0; i--) {
// Use crypto.getRandomValues for better randomness // Use crypto.getRandomValues for better randomness
const rand = randomBytes(4).readUInt32LE(0); const rand = randomBytes(4).readUInt32LE(0);
const j = rand % (i + 1); const j = rand % (i + 1);
[result[i], result[j]] = [result[j], result[i]]; [result[i], result[j]] = [result[j], result[i]];
} }
return result; return result;
}; };
Priority: 🟢 LOW — Document tradeoff and move on for now Priority: 🟢 LOW — Document tradeoff and move on for now
6. Test Coverage Gaps 6. Test Coverage Gaps
File: gameService.test.ts File: gameService.test.ts
✅ Well Tested: ✅ Well Tested:
Happy path: session creation, answer evaluation Happy path: session creation, answer evaluation
Edge cases: duplicate distractors, empty results, invalid inputs Edge cases: duplicate distractors, empty results, invalid inputs
Error propagation from DB layer Error propagation from DB layer
❌ Missing Tests: ❌ Missing Tests:
// 1. Concurrency test (race condition) // 1. Concurrency test (race condition)
it("rejects duplicate answers for same question under concurrent load", async () => { it("rejects duplicate answers for same question under concurrent load", async () => {
const session = await createGameSession(validRequest, store, "user-1"); const session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[0]!; const question = session.questions[0]!;
// Submit two answers simultaneously // Submit two answers simultaneously
const [result1, result2] = await Promise.allSettled([ const [result1, result2] = await Promise.allSettled([
evaluateAnswer({ sessionId, questionId, selectedOptionId: 0 }, store, "user-1"), evaluateAnswer({ sessionId, questionId, selectedOptionId: 0 }, store, "user-1"),
evaluateAnswer({ sessionId, questionId, selectedOptionId: 1 }, store, "user-1"), evaluateAnswer({ sessionId, questionId, selectedOptionId: 1 }, store, "user-1"),
]); ]);
// Exactly one should succeed, one should throw ConflictError // Exactly one should succeed, one should throw ConflictError
expect([result1, result2].filter(r => r.status === "fulfilled")).toHaveLength(1); expect([result1, result2].filter(r => r.status === "fulfilled")).toHaveLength(1);
}); });
// 2. TTL expiration test // 2. TTL expiration test
it("deletes session after TTL expires", async () => { it("deletes session after TTL expires", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const session = await createGameSession(validRequest, store, "user-1"); const session = await createGameSession(validRequest, store, "user-1");
vi.advanceTimersByTime(31 * 60 * 1000); // 31 minutes vi.advanceTimersByTime(31 _ 60 _ 1000); // 31 minutes
await expect(store.get(session.sessionId)).resolves.toBeNull(); await expect(store.get(session.sessionId)).resolves.toBeNull();
}); });
// 3. Distractor fallback strategy test // 3. Distractor fallback strategy test
it("uses fallback when <3 unique distractors available", async () => { it("uses fallback when <3 unique distractors available", async () => {
mockGetDistractors.mockResolvedValue(["same", "same", "same", "same"]); mockGetDistractors.mockResolvedValue(["same", "same", "same", "same"]);
// Should either: (a) fetch from broader pool, or (b) reduce rounds gracefully // Should either: (a) fetch from broader pool, or (b) reduce rounds gracefully
}); });
Priority: 🟡 HIGH — Prevents regression on critical fixes Priority: 🟡 HIGH — Prevents regression on critical fixes
🧼 Code Quality Nitpicks 🧼 Code Quality Nitpicks 7. Magic Numbers
7. Magic Numbers
// gameService.ts:52 // gameService.ts:52
await store.create(sessionId, {...}, 30 * 60 * 1000); // What is this? await store.create(sessionId, {...}, 30 _ 60 _ 1000); // What is this?
// termModel.ts:65 // termModel.ts:65
.limit(count); // count=6, but why? .limit(count); // count=6, but why?
@ -267,16 +255,16 @@ optionId: z.number().int().min(0).max(3), // Why 4 options?
Fix: Centralize in @lila/shared/constants.ts: Fix: Centralize in @lila/shared/constants.ts:
export const GAME_SESSION_TTL_MS = 30 * 60 * 1000; export const GAME*SESSION_TTL_MS = 30 * 60 \_ 1000;
export const DISTRACTOR_FETCH_COUNT = 6; export const DISTRACTOR_FETCH_COUNT = 6;
export const GAME_OPTION_COUNT = 4; export const GAME_OPTION_COUNT = 4;
export const MIN_UNIQUE_DISTRACTORS = 3; export const MIN_UNIQUE_DISTRACTORS = 3;
8. Mutable Reference Leakage 8. Mutable Reference Leakage
Location: InMemoryGameSessionStore.ts:get() Location: InMemoryGameSessionStore.ts:get()
get(sessionId: string): Promise<GameSessionData | null> { get(sessionId: string): Promise<GameSessionData | null> {
return Promise.resolve(entry.data); // 🚩 Returns mutable reference to internal state return Promise.resolve(entry.data); // 🚩 Returns mutable reference to internal state
} }
Risk: Any code that does session.answers.delete(...) mutates the store's internal Map directly. Risk: Any code that does session.answers.delete(...) mutates the store's internal Map directly.
@ -291,37 +279,35 @@ return Promise.resolve(entry.data as Readonly<GameSessionData>);
// Option C: Use immutable data structures (overkill for now) // Option C: Use immutable data structures (overkill for now)
9. Zero Observability 9. Zero Observability
Problem: No logging, no metrics. You're flying blind in production. Problem: No logging, no metrics. You're flying blind in production.
Minimal Fix (5 minutes): Minimal Fix (5 minutes):
// apps/api/src/lib/logger.ts // apps/api/src/lib/logger.ts
import pino from "pino"; import pino from "pino";
export const logger = pino({ export const logger = pino({
level: process.env.LOG_LEVEL || "info", level: process.env.LOG_LEVEL || "info",
transport: process.env.NODE_ENV === "production" transport: process.env.NODE_ENV === "production"
? { target: "pino-pretty" } ? { target: "pino-pretty" }
: undefined : undefined
}); });
// In gameService.ts: // In gameService.ts:
import { logger } from "../lib/logger.js"; import { logger } from "../lib/logger.js";
logger.info( logger.info(
{ userId, sourceLang, targetLang, termCount: terms.length }, { userId, sourceLang, targetLang, termCount: terms.length },
"game_session_created" "game_session_created"
); );
logger.debug( logger.debug(
{ sessionId, questionId, isCorrect, responseTimeMs }, { sessionId, questionId, isCorrect, responseTimeMs },
"answer_evaluated" "answer_evaluated"
); );
Bonus: Export a Prometheus histogram for game_service_duration_seconds. Bonus: Export a Prometheus histogram for game_service_duration_seconds.
10. ORDER BY RANDOM() Time Bomb 10. ORDER BY RANDOM() Time Bomb
Location: termModel.ts:getGameTerms() + getDistractors() Location: termModel.ts:getGameTerms() + getDistractors()
.orderBy(sql`RANDOM()`) // 🚩 Fine for 10k rows, slow for 1M .orderBy(sql`RANDOM()`) // 🚩 Fine for 10k rows, slow for 1M
@ -343,6 +329,6 @@ WHERE ...
LIMIT $1 LIMIT $1
-- Option C: Random offset (simple, but still scans) -- Option C: Random offset (simple, but still scans)
OFFSET floor(random() * (SELECT count(*) FROM terms WHERE ...)) OFFSET floor(random() _ (SELECT count(_) FROM terms WHERE ...))
Action: Add a ticket to documentation/tickets/t00009.md now. Action: Add a ticket to documentation/tickets/t00009.md now.

View file

@ -84,7 +84,10 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
2. `GameSessionStore.ts` — add `userId` to `GameSessionData`: 2. `GameSessionStore.ts` — add `userId` to `GameSessionData`:
```ts ```ts
export type GameSessionData = { answers: Map<string, number>; userId: string }; export type GameSessionData = {
answers: Map<string, number>;
userId: string;
};
``` ```
3. `gameService.ts` — add `userId` to both function signatures: 3. `gameService.ts` — add `userId` to both function signatures:
@ -100,7 +103,11 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
Store it on create: Store it on create:
```ts ```ts
await store.create(sessionId, { answers: answerKey, userId }, 30 * 60 * 1000); await store.create(
sessionId,
{ answers: answerKey, userId },
30 * 60 * 1000,
);
``` ```
Assert on evaluate: Assert on evaluate:
@ -114,7 +121,7 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
4. `gameController.ts` — extract from authenticated request: 4. `gameController.ts` — extract from authenticated request:
```ts ```ts
req.session.user.id req.session.user.id;
``` ```
5. `gameRouter.ts` — cast at registration: 5. `gameRouter.ts` — cast at registration:

View file

@ -27,7 +27,9 @@ Not chosen for this ticket — the database query is in `@lila/db` and is a sepa
- Filter distractors against the correct answer before building options: - Filter distractors against the correct answer before building options:
```ts ```ts
const uniqueDistractors = distractorTexts.filter((t) => t !== term.targetText); const uniqueDistractors = distractorTexts.filter(
(t) => t !== term.targetText,
);
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)]; const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
``` ```

View file

@ -6,13 +6,13 @@
"scripts": { "scripts": {
"build": "pnpm --filter @lila/shared build && pnpm --filter @lila/db build && pnpm --filter @lila/api build", "build": "pnpm --filter @lila/shared build && pnpm --filter @lila/db build && pnpm --filter @lila/api build",
"dev": "concurrently --names \"api,web\" -c \"magenta.bold,green.bold\" \"pnpm --filter @lila/api dev\" \"pnpm --filter @lila/web dev\"", "dev": "concurrently --names \"api,web\" -c \"magenta.bold,green.bold\" \"pnpm --filter @lila/api dev\" \"pnpm --filter @lila/web dev\"",
"prepare": "husky", "prepare": "husky || true",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"typecheck": "tsc --build --noEmit" "typecheck": "pnpm -r typecheck"
}, },
"lint-staged": { "lint-staged": {
"**/*.{ts,tsx}": [ "**/*.{ts,tsx}": [

View file

@ -6,7 +6,8 @@
"scripts": { "scripts": {
"build": "rm -rf dist && tsc", "build": "rm -rf dist && tsc",
"generate": "drizzle-kit generate", "generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate" "migrate": "drizzle-kit migrate",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@lila/shared": "workspace:*", "@lila/shared": "workspace:*",

View file

@ -4,7 +4,8 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc" "build": "tsc",
"typecheck": "tsc --noEmit"
}, },
"exports": { "exports": {
".": "./dist/src/index.js" ".": "./dist/src/index.js"