Skip to content

Commit d0ed153

Browse files
author
VORTEX
committed
feat: rebrand to Lumbre and harden startup/api flows
1 parent d915c5f commit d0ed153

File tree

23 files changed

+638
-44
lines changed

23 files changed

+638
-44
lines changed

README.md

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,61 @@
1-
# HablaFlow (spanish-open-source)
1+
# Lumbre
22

3-
Open-source Spaanse leerapp met Duolingo-achtige mechanics, gebouwd met Next.js App Router + TypeScript + Tailwind + Drizzle + PostgreSQL + Docker.
3+
Lumbre is an open-source Spanish learning platform (Next.js + TypeScript + Drizzle + PostgreSQL) with a curriculum-first exercise engine, SM-2 SRS, XP/streak progression, and Docker support.
44

5-
## Features
6-
- 120 lessen (A1/A2)
7-
- 2640 zinnen (offline seedbaar)
8-
- 10 exercise types
9-
- XP + streak + SM-2 SRS
10-
- Admin panel (`/admin`)
11-
- Vocabulary browser (`/vocab`)
12-
- API endpoints voor progress en admin
5+
## Why the name?
6+
**Lumbre** means flame/glow. The product focus is steady mastery and momentum, not gamified noise.
137

14-
## Local run
8+
## Core capabilities
9+
- 120 lessons (A1/A2)
10+
- 2640 seeded sentences
11+
- 10 supported exercise types
12+
- XP + streak + SM-2 SRS progression
13+
- Admin panel (`/admin`) and vocabulary browser (`/vocab`)
14+
- Resilient API surface (`/api/health`, `/api/progress`, `/api/streak`, `/api/srs/review`, `/api/lesson/[slug]`)
15+
16+
## Local development
1517
```bash
18+
cp .env.example .env
1619
npm install
1720
npm run lint
1821
npm run test
1922
npm run build
20-
docker build -t spanish-open-source .
23+
npm run dev
24+
```
25+
26+
## Production-like run (with DB migrate + seed on startup)
27+
```bash
2128
docker compose up --build
2229
```
2330

24-
## Database
31+
This starts:
32+
- `lumbre-db` (PostgreSQL 16)
33+
- `lumbre-app` (Next.js app)
34+
35+
The app startup path automatically runs:
36+
1. `npm run db:migrate` (with DB readiness retries)
37+
2. `npm run db:seed`
38+
3. `next start`
39+
40+
## API quick checks
2541
```bash
26-
cp .env.example .env
27-
npm run db:generate
28-
npm run db:migrate
29-
npm run db:seed
42+
curl http://localhost:3000/api/health
43+
curl -X POST http://localhost:3000/api/progress -H 'content-type: application/json' -d '{"correct":8,"total":10,"quality":4,"streak":3}'
44+
curl -X POST http://localhost:3000/api/streak -H 'content-type: application/json' -d '{"streak":4,"lastActiveAt":"2026-03-01T00:00:00.000Z"}'
3045
```
3146

32-
## Offline curriculum artifact
47+
## Known limits
48+
- Current curriculum text is synthetic and not CEFR-reviewed by native teachers yet.
49+
- Startup migrates/seeds each container boot by design (safe via conflict handling, but slower cold starts).
50+
- No auth layer yet; admin APIs/pages are currently open in local/dev setups.
51+
52+
## Useful scripts
3353
```bash
54+
npm run lint
55+
npm run test
56+
npm run build
57+
npm run db:generate
58+
npm run db:migrate
59+
npm run db:seed
3460
npm run seed:offline
3561
```

docker-compose.yml

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
services:
2-
db:
2+
lumbre-db:
33
image: postgres:16-alpine
44
environment:
55
POSTGRES_DB: spanish
66
POSTGRES_USER: postgres
77
POSTGRES_PASSWORD: postgres
88
ports: ["5432:5432"]
9-
volumes: ["pgdata:/var/lib/postgresql/data"]
10-
app:
9+
volumes: ["lumbre_pgdata:/var/lib/postgresql/data"]
10+
healthcheck:
11+
test: ["CMD-SHELL", "pg_isready -U postgres -d spanish"]
12+
interval: 5s
13+
timeout: 3s
14+
retries: 20
15+
16+
lumbre-app:
1117
build: .
1218
environment:
13-
DATABASE_URL: postgres://postgres:postgres@db:5432/spanish
19+
DATABASE_URL: postgres://postgres:postgres@lumbre-db:5432/spanish
1420
ports: ["3000:3000"]
15-
depends_on: [db]
21+
depends_on:
22+
lumbre-db:
23+
condition: service_healthy
24+
1625
volumes:
17-
pgdata:
26+
lumbre_pgdata:

package-lock.json

Lines changed: 66 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
{
2-
"name": "spanish-open-source",
2+
"name": "lumbre-spanish",
33
"version": "1.0.0",
44
"private": true,
55
"scripts": {
66
"dev": "next dev",
77
"build": "next build",
8-
"start": "next start",
8+
"start": "tsx scripts/startup.ts",
99
"lint": "eslint",
1010
"test": "vitest run",
1111
"test:watch": "vitest",
1212
"db:generate": "drizzle-kit generate",
1313
"db:migrate": "drizzle-kit migrate",
1414
"db:seed": "tsx scripts/seed.ts",
15-
"seed:offline": "tsx scripts/generate-curriculum.ts"
15+
"seed:offline": "tsx scripts/generate-curriculum.ts",
16+
"start:next": "next start"
1617
},
1718
"dependencies": {
1819
"clsx": "^2.1.1",

scripts/startup.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { spawn } from "node:child_process";
2+
3+
function run(command: string, args: string[]) {
4+
return new Promise<void>((resolve, reject) => {
5+
const child = spawn(command, args, { stdio: "inherit", env: process.env });
6+
child.on("error", reject);
7+
child.on("exit", (code) => {
8+
if (code === 0) {
9+
resolve();
10+
} else {
11+
reject(new Error(`${command} ${args.join(" ")} failed with code ${code ?? "unknown"}`));
12+
}
13+
});
14+
});
15+
}
16+
17+
async function waitForPostgres(maxAttempts = 20, delayMs = 1500) {
18+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
19+
try {
20+
await run("npm", ["run", "db:migrate"]);
21+
return;
22+
} catch (error) {
23+
if (attempt === maxAttempts) {
24+
throw error;
25+
}
26+
console.log(`[startup] postgres not ready (attempt ${attempt}/${maxAttempts}), retrying...`);
27+
await new Promise((resolve) => setTimeout(resolve, delayMs));
28+
}
29+
}
30+
}
31+
32+
async function main() {
33+
console.log("[startup] Ensuring database schema and seed...");
34+
await waitForPostgres();
35+
await run("npm", ["run", "db:seed"]);
36+
37+
console.log("[startup] Starting application...");
38+
await run("npm", ["run", "start:next"]);
39+
}
40+
41+
main().catch((error) => {
42+
console.error("[startup] fatal error", error);
43+
process.exit(1);
44+
});

src/app/admin/page.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,31 @@
1+
import { BRAND_NAME } from "@/lib/branding";
12
import { curriculum } from "@/lib/curriculum";
2-
export default function AdminPage() { return (<main className="p-8 max-w-5xl mx-auto space-y-4"><h1 className="text-3xl font-bold">Admin Panel</h1><p className="text-gray-600">Manage lessons/content snapshots.</p><div className="overflow-auto border rounded"><table className="w-full text-sm"><thead><tr className="bg-gray-100"><th className="p-2 text-left">Slug</th><th className="p-2">Level</th><th className="p-2">Sentences</th></tr></thead><tbody>{curriculum.lessons.slice(0, 20).map((l) => (<tr key={l.slug} className="border-t"><td className="p-2">{l.slug}</td><td className="p-2 text-center">{l.level}</td><td className="p-2 text-center">{l.sentences.length}</td></tr>))}</tbody></table></div></main>); }
3+
4+
export default function AdminPage() {
5+
return (
6+
<main className="p-8 max-w-5xl mx-auto space-y-4">
7+
<h1 className="text-3xl font-bold">{BRAND_NAME} Admin</h1>
8+
<p className="text-gray-600">Manage lessons/content snapshots.</p>
9+
<div className="overflow-auto border rounded">
10+
<table className="w-full text-sm">
11+
<thead>
12+
<tr className="bg-gray-100">
13+
<th className="p-2 text-left">Slug</th>
14+
<th className="p-2">Level</th>
15+
<th className="p-2">Sentences</th>
16+
</tr>
17+
</thead>
18+
<tbody>
19+
{curriculum.lessons.slice(0, 20).map((l) => (
20+
<tr key={l.slug} className="border-t">
21+
<td className="p-2">{l.slug}</td>
22+
<td className="p-2 text-center">{l.level}</td>
23+
<td className="p-2 text-center">{l.sentences.length}</td>
24+
</tr>
25+
))}
26+
</tbody>
27+
</table>
28+
</div>
29+
</main>
30+
);
31+
}

src/app/api/admin/lesson/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1-
import { NextResponse } from "next/server";
21
import { curriculum } from "@/lib/curriculum";
3-
export async function GET() { return NextResponse.json({ lessons: curriculum.lessons.length, sample: curriculum.lessons.slice(0, 5) }); }
2+
import { ok, withErrorHandling } from "@/lib/api/http";
3+
4+
export const GET = withErrorHandling(async () => {
5+
return ok({
6+
lessons: curriculum.lessons.length,
7+
sample: curriculum.lessons.slice(0, 5),
8+
});
9+
});

src/app/api/health/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ok, withErrorHandling } from "@/lib/api/http";
2+
import { curriculum } from "@/lib/curriculum";
3+
import { curriculumExerciseSummary } from "@/lib/exercise-engine";
4+
5+
export const GET = withErrorHandling(async () => {
6+
const summary = curriculumExerciseSummary();
7+
8+
return ok({
9+
service: "lumbre-api",
10+
status: "healthy",
11+
checks: {
12+
curriculumLoaded: curriculum.lessons.length > 0,
13+
exerciseTypesComplete: summary.missing.length === 0 && summary.unknown.length === 0,
14+
},
15+
uptimeSeconds: Math.round(process.uptime()),
16+
timestamp: new Date().toISOString(),
17+
});
18+
});

src/app/api/lesson/[slug]/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ApiError, ok, withErrorHandling } from "@/lib/api/http";
2+
import { getLesson } from "@/lib/curriculum";
3+
4+
export const GET = withErrorHandling(async (_req: Request, context: { params: Promise<{ slug: string }> }) => {
5+
const { slug } = await context.params;
6+
const lesson = getLesson(slug);
7+
if (!lesson) {
8+
throw new ApiError(404, `Lesson not found: ${slug}`);
9+
}
10+
11+
return ok({ lesson });
12+
});

src/app/api/progress/route.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,33 @@
1-
import { NextResponse } from "next/server";
1+
import { z } from "zod";
22
import { calcStreak, calcXP, sm2Review } from "@/lib/progress";
3-
export async function POST(req: Request) { const body = await req.json(); const xp = calcXP(body.correct ?? 0, body.total ?? 10); const streak = calcStreak(body.lastActiveAt ? new Date(body.lastActiveAt) : undefined, body.streak ?? 0); const srs = sm2Review({ interval: 1, repetition: 0, ease: 2.5, dueDate: new Date() }, body.quality ?? 4); return NextResponse.json({ xp, streak, nextReview: srs.dueDate.toISOString(), interval: srs.interval }); }
3+
import { ApiError, ok, safeJson, withErrorHandling } from "@/lib/api/http";
4+
5+
const progressSchema = z.object({
6+
correct: z.number().int().min(0).default(0),
7+
total: z.number().int().min(1).default(10),
8+
streak: z.number().int().min(0).default(0),
9+
quality: z.number().int().min(0).max(5).default(4),
10+
lastActiveAt: z.string().datetime().optional(),
11+
});
12+
13+
export const POST = withErrorHandling(async (req: Request) => {
14+
const body = progressSchema.safeParse(await safeJson(req));
15+
if (!body.success) {
16+
throw new ApiError(422, "Invalid progress payload", body.error.flatten());
17+
}
18+
19+
const xp = calcXP(body.data.correct, body.data.total);
20+
const streak = calcStreak(body.data.lastActiveAt ? new Date(body.data.lastActiveAt) : undefined, body.data.streak);
21+
const srs = sm2Review({ interval: 1, repetition: 0, ease: 2.5, dueDate: new Date() }, body.data.quality);
22+
23+
return ok({
24+
xp,
25+
streak,
26+
srs: {
27+
nextReview: srs.dueDate.toISOString(),
28+
interval: srs.interval,
29+
repetition: srs.repetition,
30+
ease: srs.ease,
31+
},
32+
});
33+
});

0 commit comments

Comments
 (0)