"Wir wohnen bei der Schwester. / We live at the sister." was two bugs at
once, and both were the same shape: a template applied to a pairing nobody
had ever read.
Root cause: sentences are built combinatorially (frames x verbs x nouns x
subjects). The space is ~2900 sentences and only a sample had ever been
looked at, so every fix so far has been whack-a-mole on whichever bad one
happened to surface.
The fix is structural, not another special case:
- composeFrame() is now pure, and enumerateCorpus() walks every frame,
verb, noun and case for every consumer (fill-the-article, ein-Woerter,
possessive, Wechsel im Kontext, fused). src/corpus.txt is that full
enumeration, checked in and reviewed.
- Two tests make it foolproof rather than decorative:
* the snapshot test fails if generation drifts from corpus.txt, so no
new sentence can reach a learner unreviewed. Regenerate with
`npm run corpus` and the diff IS the review.
* a coverage test runs the real generators over every sentence level
and asserts each question it produces has a key in the corpus — so
the file is provably the live output, not a parallel implementation.
Two more assert no un-fused contraction and no German leaking into an
English line.
Bugs the audit then found and fixed:
- zu/von/bei + a person means "at/to/from their PLACE". English needs the
-'s ("at the doctor's"), and with a bare definite article German only
allows roles you visit (beim Arzt), not family — "bei der Schwester" is
wrong, "bei meiner Schwester" is right, so family frames are now
possessive-only. Added Aerztin/Lehrerin/Freundin so the role case has a
non-fusing feminine form.
- "zu dem Arzt", "in dem Haus": a fill-the-article sentence may no longer
show a two-word form German obligatorily fuses. Those pairings belong to
the Kontraktionen questions, which already teach them. "zu" therefore
leaves the Dativ lesson entirely, the way "bis" already had.
- mein Mann / meine Frau mean my husband / my wife, not my man / my woman.
- "mein Maedchen" is not idiomatic and was reachable from the possessive
templates; the coverage test caught it.
- You cannot drive into a kitchen, a cellar or a library: the "in" motion
frame is split into drivable places and on-foot-only places.
- "stellt den Stuhl an die Wand" is "against the wall", not "to the wall";
haengen/schreiben/kleben take "on"; wohnen am See is "by the lake".
Also: questions now carry the verb and case they were built from, the
article redraw is depth-bounded so a data mistake degrades to one odd
question instead of hanging, and level 3's advertised prep list matches
what it can actually generate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8
Deklinationstrainer
A daily-practice German declension trainer. Self-hosted so your progress actually saves.
Deploy to Coolify
One-time setup
-
Push these files to a git repo (GitHub / Gitea / whatever Coolify is connected to).
-
In Coolify, create a new Application:
- Source: your git repo
- Build pack: Nixpacks (auto-detects Vite) or Static
- Build command:
npm run build - Publish directory:
dist - Port: not needed for static output (Coolify serves it directly)
-
Add a domain (e.g.
deklination.yourdomain.com). Coolify provisions HTTPS via Caddy/Traefik automatically. -
Deploy. Visit the URL on any device.
Local dev
npm install
npm run dev # http://localhost:5173
npm run build # outputs to ./dist
On iPhone
Open the URL in Safari, tap Share → Add to Home Screen. It opens fullscreen like a native app, and progress saves in localStorage across reboots, sessions, and app closes.
Storage
The app tries window.storage first (Claude artifact environment), then falls back to localStorage. When self-hosted, only localStorage runs, which iOS Safari persists reliably.
To wipe progress: open browser devtools → Application → Local Storage → delete the gd-v10 key. Or on iPhone: Settings → Safari → Advanced → Website Data → search your domain → delete.
Stripe checkout
Beug uses public Stripe Checkout or Payment Link URLs, supplied through environment variables. Do not put Stripe secret keys in this Vite app.
Payments are behind a launch-mode flag:
VITE_PAYMENTS_ENABLED=false
When VITE_PAYMENTS_ENABLED is absent or false, the app runs in Reddit/free mode: all lessons are usable, paid rows are not blurred, and the paywall/pricing screen is hidden. The Stripe URLs can stay configured in the environment so billing can be switched back on later with one env change.
To turn payments back on, set:
VITE_PAYMENTS_ENABLED=true
Create one product in Stripe, for example Beug Premium, with three prices:
- Weekly subscription:
$2.99every week - Yearly subscription:
$9.99every year - Lifetime access:
$19.99one-time payment
Create a Payment Link or Checkout link for each price. Set the success redirect URL to:
https://beug.george1.dev/?checkout=success
Set the cancel URL to:
https://beug.george1.dev/
Then add these public URLs in the deployment environment:
VITE_STRIPE_YEARLY_URL=https://buy.stripe.com/...
VITE_STRIPE_LIFETIME_URL=https://buy.stripe.com/...
VITE_STRIPE_WEEKLY_URL=https://buy.stripe.com/...
The current v1 unlock is intentionally simple: after Stripe redirects back with ?checkout=success, Beug stores the unlock in this browser's localStorage. This avoids accounts for the tiny-app version, but it is a soft paywall. A stricter version needs a small backend, Stripe webhooks, and account or email-based entitlement lookup.
Future prompt to re-enable payments
Turn payments back on for Beug. Set VITE_PAYMENTS_ENABLED=true in Coolify, keep the three existing Stripe Payment Link env vars, redeploy the app, and verify that unpaid users see the soft paid lesson blur, that charts remain visible, and that tapping a paid lesson opens the pricing screen with Weekly, Yearly, and Lifetime plans.