0%
balqalam Logo
balqalam
FeaturesCommunityPricingDocumentation
Login
  • 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
  • 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
Sales & GTM
  • Marketing Brief
  • Sales
  • Go-to-market
  • Marketing
  • Admission — Feature Spotlight
  • Pilot Program
  • Leads
  • Proposal
  • Outreach Templates
  • Case Study
  • Competitors
  • Landing-page teardown
  • Competitor FAQ
  • Business model
  • Shared economy
  • Traction
Fundraising & Ecosystem
  • Get Support
  • Investor Deck
  • Data Room
  • Investors
  • Accelerators
  • Incubators
  • Grants
  • Sponsors
  • Partners
  • Competitions & Hackathons
  • Universities & Training Centers

Translation Guide

PreviousNext

How DB content is translated on demand — single-language storage, getText, the Google-backed cache, name helpers, trade-offs, best practices, and tooling.

Structure

src/components/translation/— the whole feature — one self-contained module
display.ts— getText, getFields — read-time translation
actions.ts— translate, autoTranslate, translateText/Fields
google.ts— translateRaw, translateBatch — provider calls
person.ts— getName, getNames, getLabels
search.ts— search — bilingual, cache-only
transliterate.ts— transliterate, formatName — ar→Latin
util.ts— withLang, detectScript, detectLang, fullName
types.ts— Lang + request/result types
config.ts— Google API URL + constants
__tests__/
actions.test.ts
display.test.ts
google.test.ts
prisma/models/translation.prisma— Translation model (table translation_cache)
src/app/api/mobile/translate/route.ts— POST /api/mobile/translate
scripts/audit-untranslated.ts— finds DB content rendered without translation

How 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 thing

localize 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…UseFrom
A list/entity of a content modellocalize / localizeOne (batched, default)localize.ts
One stored field to displaygetTextdisplay.ts
Several fields of one entitygetFieldsdisplay.ts
A person's namegetNameperson.ts
A list of rows with namesgetNames (de-dups)person.ts
Arbitrary labels (rooms, subjects, grades)getLabels (de-dups)person.ts
Writing content to the DBwithLang / detectScriptutil.ts
Pre-translate on write (no read latency)prewarmprewarm.ts
Searching across languagessearchsearch.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:

FunctionPurpose
translateTextAuth + tenant-gated single-string translate (returns { success, translated }).
translateFieldsBatch-translate a Record via translateBatch, then upsert every result.
autoTranslateStamp 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 labels

These 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)

LayerScopeHoldsRole
Glossary / term baseglobal defaults + per-school overridessubjects, grades, statuses, common phrasesthe "by-term" + fine-tuning surface; hot terms can sit in memory
Shared auto-cache (optional)globalgeneric, non-sensitive stringsthe global hit-rate win, gated to avoid co-mingling sensitive content
Per-school cache (today)schoolIdfree-form content (announcements) + long tailisolation; 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

BenefitDetail
No bilingual-field sprawlOne title + lang, not titleAr/titleEn per model. One row, one write path.
Pay-once translationTranslation is tenant-scoped with a hitCount; each unique string hits Google once.
Graceful degradationgetText falls back to source; names transliterate ar→Latin offline. Never a blank UI.
Script-awaredetectScript trusts the actual script, so a wrong/absent lang flag doesn't break display.
N+1 avoidancegetNames / getLabels de-dup and translate a whole list in parallel.
Cross-language searchCached translations make search bilingual at zero API cost.
One cache, web + mobileThe mobile API surfaces the same Translation — no duplicate pipeline.

What it costs

Trade-offDetail
Server-only + asyncHelpers need a schoolId and can't run on the client; you must resolve in content.tsx.
First-view latencyLazy-on-read: an uncached string pays the Google round-trip the first time it's viewed.
Machine-translation qualityGoogle MT, weakest on proper nouns/names; transliteration is heuristic.
Single vendor + ceilingOne Google dependency; the free tier (~500K chars/mo) is a real cap; missing key → silent source fallback.
Unbounded cacheTranslation has a lastAccessedAt index but no eviction/TTL policy.
Detection edge casesMixed-script strings resolve to Arabic; short codes like B102 resolve to English.
Adoption gapA 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 driftSome models lack a lang column; some write paths persist the wrong flag.

Best practices

  1. Stamp lang on every write — withLang(data, lang), or detectScript(text) when the locale isn't trustworthy.
  2. Never trust a stored lang flag for names — detect from script (the name helpers already do).
  3. Read with the right helper: fields → getText/getFields; names → getName/getNames; arbitrary labels (rooms, subjects, grades) → getLabels.
  4. Always pass schoolId — it scopes the cache and enforces tenant isolation.
  5. Resolve on the server, render on the client — translate in content.tsx, pass plain strings to columns.tsx (helpers are server-only).
  6. Batch to avoid N+1 — getFields for an entity, getNames/getLabels for lists (they de-dup).
  7. Search via search — bilingual, cache-only, no API cost.
  8. Never reintroduce bilingual fields or per-language helpers (titleAr/titleEn, nameEn) — one generic field plus lang is the only pattern.
  9. Mobile goes through /api/mobile/translate — reuse the cache, don't bake a second pipeline.
  10. Mind the audit ratchet — pnpm i18n:audit-content lists raw-name surfaces, and a Vitest test (src/tests/i18n/audit-untranslated.test.ts) fails if you add one. Fix it (route through getName/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()/localizeOne wired 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 verified PREWARM_EXEMPT entry (provisioning/non-content/draft scaffolding — reasons inline in scripts/audit-untranslated.ts). Notifications warm at the dispatchNotification hub. The existing corpus is covered by pnpm i18n:backfill (dry-run cost report; run --execute at 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.ts pins every registry field to a real Prisma String column and CATALOG_GLOBAL to schema truth (the Department: ["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/health field — so a dashboard can see when /en is 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 provider column only ever stores "google" — abstract the client so a fallback/second provider is possible.
  • Schema consistency. Stamp lang correctly on every write (e.g. admission persisting Latin names as lang="ar"), so script detection becomes a safety net rather than the primary mechanism.
  • Placeholder-preserving template translation. AnnouncementTemplate/NotificationTemplate bodies 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)

FileRole (DB-content relevance)
.claude/rules/translation.mdThe rulebook. Its "Dynamic Content (On-Demand Translation)" section is the authority for getText, withLang, the lang field, and the Translation.
scripts/audit-untranslated.tsThe 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.mdProject 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 / keywordRole
~/.claude/agents/internationalization.mdGlobal i18n playbook (model sonnet) — includes the single-language-storage + on-demand sections.
~/.claude/rules/i18n.mdCondensed 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 lang field on content models
TranslationIcons

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

Built by Databayt ·

Welcome to balqalam.

A great journey is about to begin.