- Introduction
- Pitch
- Hogwarts
- Live Demo
- MVP
- Roadmap
- Launch Sprint
- PRD
- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Playwright
- Prettier
- Block Rebound
Structure
src/components/translation/— the whole feature — one self-contained moduledisplay.ts— getText, getFields — read-time translationactions.ts— translate, autoTranslate, translateText/Fieldsgoogle.ts— translateRaw, translateBatch — provider callsperson.ts— getName, getNames, getLabelssearch.ts— search — bilingual, cache-onlytransliterate.ts— transliterate, formatName — ar→Latinutil.ts— withLang, detectScript, detectLang, fullNametypes.ts— Lang + request/result typesconfig.ts— Google API URL + constants__tests__/actions.test.tsdisplay.test.tsgoogle.test.tsprisma/models/translation.prisma— Translation model (table translation_cache)src/app/api/mobile/translate/route.ts— POST /api/mobile/translatescripts/audit-untranslated.ts— finds DB content rendered without translationHow it works
The flow is: store in one language → stamp it with lang → translate at display time → cache the result per school.
The one call you need: localize()
For any model in the registry (src/components/translation/registry.ts), translate
a whole page in one batched pass — no per-field calls, no passing schoolId/lang:
// content.tsx — resolve on the server, hand plain strings to the client table
import { localize } from "@/components/translation/localize"
const rows = await getAnnouncementsList(schoolId, filters)
const localized = await localize("Announcement", rows) // ← that's the whole thinglocalize collapses what used to be N×M getText lookups into a single
db.translation.findMany, serves hot terms from an in-memory LRU (zero DB round-trip),
and falls back to the source string if anything fails (a render never blocks).
Adding a new content model is a one-line entry in registry.ts — then every
list of it is translated automatically. Pair it with prewarm on the write path
(after(() => prewarm("Announcement", row, { schoolId }))) and the first reader in
either language never waits on Google and never sees the source language —
translation is invisible.
The lang is resolved ambiently (getDisplayLang() reads the x-locale header),
so you stop prop-drilling it. The helpers below are the lower-level primitives
localize is built on — reach for them only for a one-off field, a person's name,
or an ad-hoc label.
Which helper do I use?
| You have… | Use | From |
|---|---|---|
| A list/entity of a content model | localize / localizeOne (batched, default) | localize.ts |
| One stored field to display | getText | display.ts |
| Several fields of one entity | getFields | display.ts |
| A person's name | getName | person.ts |
| A list of rows with names | getNames (de-dups) | person.ts |
| Arbitrary labels (rooms, subjects, grades) | getLabels (de-dups) | person.ts |
| Writing content to the DB | withLang / detectScript | util.ts |
| Pre-translate on write (no read latency) | prewarm | prewarm.ts |
| Searching across languages | search | search.ts |
Every read here is server-only and needs a schoolId (resolved ambiently if you
don't pass one). Resolve in content.tsx, pass plain strings down to client columns.tsx.
Single-language storage (the data model)
Every content model stores text in one language plus a lang field. Field names are generic — never titleAr/titleEn. The default storage language follows School.preferredLanguage.
model Announcement {
title String? // generic name (NOT titleEn/titleAr)
body String? @db.Text
lang String @default("ar") // the language THIS row is stored in
}The language type is Lang = "en" | "ar" (src/components/translation/types.ts).
Writing content — stamp the language
withLang simply attaches the lang field to whatever you're about to persist:
// src/components/translation/util.ts
await db.announcement.create({ data: withLang(input, lang) })
// => { ...input, lang }When you don't have a trustworthy locale at write time, detect it from the actual script with detectScript — Arabic script wins, then Latin, else "ar". This is deliberately robust to a wrong flag (admission writes Latin names with the default lang="ar") and an absent one (StaffMember has no lang column):
// src/components/translation/util.ts
detectScript("محمد علي") // "ar"
detectScript("Mohammed Ali") // "en"
detectScript("B102") // "en"detectScript keys off the characters themselves, which is why it's the right detector for names and any untrusted lang flag. A sibling detectLang (also in util.ts) exists for locale-style guesses — but when in doubt about a name, reach for detectScript.
Reading content — translate on display
getText is the core read primitive. If content and display languages match it returns the text untouched; it guards against script mismatches (so a mislabeled row isn't garbled); and if translation fails it falls back to the source string — it never blocks the render.
// src/components/translation/display.ts
const title = await getText(a.title, a.lang, displayLang, schoolId)For multiple fields of one entity, getFields translates them in parallel:
const { title, body } = await getFields(
announcement,
["title", "body"],
announcement.lang,
displayLang,
schoolId
)Caching — translate once per tenant
Translation is cache-first. translate looks up Translation, bumps hitCount on a hit (fire-and-forget), and only calls Google on a miss — so each unique string is paid for once per school:
// src/components/translation/actions.ts
export async function translate(
text: string,
sourceLang: "en" | "ar",
targetLang: "en" | "ar",
schoolId: string
): Promise<string> // findUnique → hit ? bump+return : translateRaw → create// prisma/models/translation.prisma — tenant-scoped, dedup by composite key
model Translation {
schoolId String
sourceText String @db.Text
sourceLanguage String
targetLanguage String
translatedText String @db.Text
provider String @default("google")
hitCount Int @default(0)
lastAccessedAt DateTime @default(now())
@@unique([schoolId, sourceText, sourceLanguage, targetLanguage])
@@index([schoolId, sourceLanguage, targetLanguage])
@@index([lastAccessedAt])
@@map("translation_cache")
}Companion server actions in the same file:
| Function | Purpose |
|---|---|
translateText | Auth + tenant-gated single-string translate (returns { success, translated }). |
translateFields | Batch-translate a Record via translateBatch, then upsert every result. |
autoTranslate | Stamp lang + optionally pre-translate fields (e.g. admin preview before save). |
These three take typed input objects (see types.ts), not positional string args — unlike the lower-level translate(text, source, target, schoolId).
The Google layer
src/components/translation/google.ts wraps Cloud Translation v2: translateRaw() and translateBatch() read GOOGLE_TRANSLATE_API_KEY (free tier ~500K chars/month). A missing or rejected key throws (so getText/translate fall back to source) and is surfaced by a private, throttled reportTranslationDegraded() — at most one console.error to the Vercel runtime logs every 5 minutes, so a silently-misconfigured key still leaves a trail instead of an unexplained all-Arabic /en. There is no public getter or health endpoint yet — see areas of improvement.
Names & labels — use the canonical helper
Names are the highest-traffic case, so they have a dedicated module — src/components/translation/person.ts. Prefer it over getText for names. It composes the parts, detects the script (ignoring a possibly-wrong lang flag), de-duplicates the list to avoid an N+1, and — uniquely — transliterates ar→Latin offline when the API is down, so a degraded /en shows "Mohammed Ali" rather than "محمد علي".
// resolve once on the server, then hand plain strings to client columns
const names = await getNames(rows, (r) => r.student, lang, schoolId)
const display = names.get(fullName(row.student)) ?? fullName(row.student)
// getName(person, lang, schoolId) — a single name
// getLabels(values, lang, schoolId) — rooms / subjects / grade labelsThese helpers are server-only (DB cache + Google API). For client tables,
resolve names in the server content.tsx and pass already-translated strings
down to columns.tsx — never call them from a client component.
Bilingual search
search (src/components/translation/search.ts) builds Prisma OR conditions that match both the raw field and its cached translation — so searching "Ahmed" finds "أحمد". It reads Translation only and never triggers the API.
Mobile API
POST /api/mobile/translate lets iOS/Android reuse the same cache instead of baking a second pipeline. It's whitelist-only (announcement, assignment, event, exam) and authenticated. School-owned content stays schoolId-scoped — an announcement/event is loaded via findFirst({ where: { id, schoolId } }); assignment and exam are global catalog templates (no schoolId column) so they're fetched by id. The route probes the cache to honestly report cached: true|false, then calls translate.
Caching & fine-tuning
The cache key is (schoolId, sourceText, sourceLanguage, targetLanguage) — per-school on purpose, and cache-first, which makes the Translation table both the dedup layer and the override layer.
Fine-tuning is already possible
Because reads hit Translation before Google, you fine-tune by writing the row you want: set translatedText to the corrected/preferred wording and flag it (e.g. provider: "manual") so a later API call never overwrites it. Per-school keys mean one school's wording never leaks into another's.
Why not one global cache
Dropping schoolId (a global cache) would raise the hit-rate and cut Google spend — a string translated once would serve every tenant. But it (a) co-mingles tenant content into a shared row, breaking the schoolId isolation the whole app rests on, and (b) can hold only one translation, so it can't represent two schools' different preferred wording. Globalize a generic slice, not the whole thing.
"Cache single words" → a glossary, not sentence-splitting
MT isn't compositional — you can't translate a sentence by translating its words and joining them (بنك الدم = "blood bank", not "bank blood"). But the intuition holds for a bounded set of repeated terms (subjects, grade labels, statuses, room codes): translate each term once and reuse it — that's a glossary. The highest-repetition case, names, is already handled without MT: getNames/getLabels dedupe unique values and transliterate romanizes offline.
A layered cache (where this lands)
| Layer | Scope | Holds | Role |
|---|---|---|---|
| Glossary / term base | global defaults + per-school overrides | subjects, grades, statuses, common phrases | the "by-term" + fine-tuning surface; hot terms can sit in memory |
| Shared auto-cache (optional) | global | generic, non-sensitive strings | the global hit-rate win, gated to avoid co-mingling sensitive content |
| Per-school cache (today) | schoolId | free-form content (announcements) + long tail | isolation; names via transliterate + dedupe |
Upgrade paths
- Google Cloud Translation v3 supports custom glossaries (v2 — used today — does not).
- Route the curated tier through an LLM (with the glossary in-prompt) for context- and dialect-aware quality; keep Google for the cheap long tail.
Performance reality
A cache hit is one indexed lookup either way — globalizing makes more requests hit (fewer slow Google calls), it doesn't speed up a hit. The real latency win is an in-memory glossary for hot terms (no DB roundtrip). Two gaps amplify everything: there's no eviction/TTL (a global cache grows unbounded), and lookups are by exact sourceText — normalizing (trim/collapse whitespace) lifts the hit-rate for free.
Pros & cons
Why this approach
| Benefit | Detail |
|---|---|
| No bilingual-field sprawl | One title + lang, not titleAr/titleEn per model. One row, one write path. |
| Pay-once translation | Translation is tenant-scoped with a hitCount; each unique string hits Google once. |
| Graceful degradation | getText falls back to source; names transliterate ar→Latin offline. Never a blank UI. |
| Script-aware | detectScript trusts the actual script, so a wrong/absent lang flag doesn't break display. |
| N+1 avoidance | getNames / getLabels de-dup and translate a whole list in parallel. |
| Cross-language search | Cached translations make search bilingual at zero API cost. |
| One cache, web + mobile | The mobile API surfaces the same Translation — no duplicate pipeline. |
What it costs
| Trade-off | Detail |
|---|---|
| Server-only + async | Helpers need a schoolId and can't run on the client; you must resolve in content.tsx. |
| First-view latency | Lazy-on-read: an uncached string pays the Google round-trip the first time it's viewed. |
| Machine-translation quality | Google MT, weakest on proper nouns/names; transliteration is heuristic. |
| Single vendor + ceiling | One Google dependency; the free tier (~500K chars/mo) is a real cap; missing key → silent source fallback. |
| Unbounded cache | Translation has a lastAccessedAt index but no eviction/TTL policy. |
| Detection edge cases | Mixed-script strings resolve to Arabic; short codes like B102 resolve to English. |
| Adoption gap | A small, tracked backlog of render surfaces still composes names without a helper — run pnpm i18n:audit-content for the current list; a Vitest ratchet keeps that count from growing. |
| Schema drift | Some models lack a lang column; some write paths persist the wrong flag. |
Best practices
- Stamp
langon every write —withLang(data, lang), ordetectScript(text)when the locale isn't trustworthy. - Never trust a stored
langflag for names — detect from script (the name helpers already do). - Read with the right helper: fields →
getText/getFields; names →getName/getNames; arbitrary labels (rooms, subjects, grades) →getLabels. - Always pass
schoolId— it scopes the cache and enforces tenant isolation. - Resolve on the server, render on the client — translate in
content.tsx, pass plain strings tocolumns.tsx(helpers are server-only). - Batch to avoid N+1 —
getFieldsfor an entity,getNames/getLabelsfor lists (they de-dup). - Search via
search— bilingual, cache-only, no API cost. - Never reintroduce bilingual fields or per-language helpers (
titleAr/titleEn,nameEn) — one generic field pluslangis the only pattern. - Mobile goes through
/api/mobile/translate— reuse the cache, don't bake a second pipeline. - Mind the audit ratchet —
pnpm i18n:audit-contentlists raw-name surfaces, and a Vitest test (src/tests/i18n/audit-untranslated.test.ts) fails if you add one. Fix it (route throughgetName/getNames) and lower the baseline.
Areas of improvement
Most of the original backlog SHIPPED in the 2026-06 production-readiness pass — kept here with ✅ so the history of the design is legible:
- ✅ Adoption.
localize()/localizeOnewired across the school dashboard (announcements, events, classes, classrooms, exams, grades, students, teachers, admission, conference, departments, year-levels, notifications, subjects, library, lumos, parent-portal, mobile routes). Three Vitest ratchets enforce it: raw person-names (baseline 0), zero-translation features, and prewarm-less write paths (src/tests/i18n/audit-untranslated.test.ts). - ✅ Pre-warm on write. Every school-scoped registered write path either prewarm via
after()or is a verifiedPREWARM_EXEMPTentry (provisioning/non-content/draft scaffolding — reasons inline inscripts/audit-untranslated.ts). Notifications warm at thedispatchNotificationhub. The existing corpus is covered bypnpm i18n:backfill(dry-run cost report; run--executeat deploy). - ✅ Engine hardening. 2.5s timeout (read path fails fast to source), chunked batches (100 q / 4k chars), transient-only opt-in retry (prewarm/backfill only), in-memory LRU,
pnpm i18n:prune(age+zero-hits keyed — recency would evict the hottest rows). - ✅ Registry truth.
registry-schema.test.tspins every registry field to a real Prisma String column andCATALOG_GLOBALto schema truth (theDepartment: ["name"]silent no-op can't recur). - ✅ CI. The test job runs the suite (script-name typo reconciled); all ratchets enforce on every push.
- ✅ Mobile API extended to
announcement/assignment/event/exam. - Degradation observability. Still only a throttled
console.error(reportTranslationDegraded). Add a public signal — a getter or/api/healthfield — so a dashboard can see when/enis silently falling back to source. - Name/proper-noun quality. Add a glossary / do-not-translate list and consider human review for high-visibility names; transliteration already covers person names when the API is down.
- Provider abstraction. The
providercolumn only ever stores"google"— abstract the client so a fallback/second provider is possible. - Schema consistency. Stamp
langcorrectly on every write (e.g. admission persisting Latin names aslang="ar"), so script detection becomes a safety net rather than the primary mechanism. - Placeholder-preserving template translation.
AnnouncementTemplate/NotificationTemplatebodies are deliberately unregistered — MT mangles{{placeholders}}; a placeholder-aware pipeline would unlock them.
Claude Code config & keywords
Most i18n automation targets the static dictionary system (hardcoded-string scanning). The DB-content pipeline has its own, narrower set — and that thinness is itself an area of improvement.
Project level (/.claude/, in-repo)
| File | Role (DB-content relevance) |
|---|---|
.claude/rules/translation.md | The rulebook. Its "Dynamic Content (On-Demand Translation)" section is the authority for getText, withLang, the lang field, and the Translation. |
scripts/audit-untranslated.ts | The DB-content audit — finds render surfaces composing stored text (${x.firstName} ${x.lastName}, etc.) without a translation helper. Run pnpm i18n:audit-content (--json for the machine list). Now enforced as a Vitest ratchet (src/tests/i18n/audit-untranslated.test.ts). |
.claude/agents/i18n.md | Project i18n agent (model sonnet) — covers both systems, including on-demand content. |
CLAUDE.md | "Single-Language Storage (CRITICAL)" section (rule 3 prescribes localize-first) + the "Hardcoded Strings" gotcha. |
The static-only tooling — .claude/hooks/check-i18n.sh, the /i18n-check skill/command, dictionary-validator, and scripts/dev-i18n-sync.ts — targets hardcoded JSX strings and ar/en key parity, not DB content. See Internationalization for those.
User level (~/.claude/, personal/global)
| File / keyword | Role |
|---|---|
~/.claude/agents/internationalization.md | Global i18n playbook (model sonnet) — includes the single-language-storage + on-demand sections. |
~/.claude/rules/i18n.md | Condensed global rule; covers the lang field, getText, and Translation. |
lang (keyword) | "RTL/LTR + translation check" — visual verification that /en actually shows translated content. |
See also
- Internationalization — the other system: static UI strings (dictionaries), routing, RTL CSS
- Translation — adoption tracker and per-area coverage
- Multi-Tenancy — why every read here needs a
schoolId - Database — the
langfield on content models
On This Page
StructureHow it worksThe one call you need:localize()Which helper do I use?Single-language storage (the data model)Writing content — stamp the languageReading content — translate on displayCaching — translate once per tenantThe Google layerNames & labels — use the canonical helperBilingual searchMobile APICaching & fine-tuningFine-tuning is already possibleWhy not one global cache"Cache single words" → a glossary, not sentence-splittingA layered cache (where this lands)Upgrade pathsPerformance realityPros & consWhy this approachWhat it costsBest practicesAreas of improvementClaude Code config & keywordsProject level (/.claude/, in-repo)User level (~/.claude/, personal/global)See also