DEV Community

Cover image for Building PulseCard: A Privacy-First Emergency Medical ID with 20 Languages
Daniel Kolawole Aina
Daniel Kolawole Aina

Posted on

Building PulseCard: A Privacy-First Emergency Medical ID with 20 Languages

I built a QR-based medical ID that works in 20 languages without storing data on servers. Medical profiles live in the URL fragment. Translation powered by Lingo.dev.Live demo | GitHub

Stack: FastAPI, Vanilla JS, Tailwind, Lingo.dev, Web Speech API

The language problem barriers kill people in emergency rooms. Your medical bracelet says you're allergic to penicillin but it's in English. The doctor doesn't speak English(Horror incoming).
Existing solutions suck:

  • Medical bracelets: single language only
  • Translation apps: require both parties conscious
  • Hospital records: locked away, not portable

The Architecture Decision
Most medical apps do this:

User creates profile → Store in database → Generate QR with ID
Responder scans → Server lookup → Display data
Enter fullscreen mode Exit fullscreen mode

Problems:

  • Database breach = all records exposed
  • Requires authentication
  • Network dependency

My approach:

User creates profile → Encode as Base64 → Stuff in URL fragment
Responder scans → JS decodes locally → Display

Enter fullscreen mode Exit fullscreen mode

Why URL Fragments?

The key insight: URL fragments (everything after # never reach the server)

const profile = {
  name: "John Doe",
  allergies: ["Penicillin"],
  conditions: ["Asthma"],
  medications: ["Albuterol"]
};

const encoded = btoa(JSON.stringify(profile));
// Result: https://pulsecard.onrender.com/#card/eyJuYW1lIjoiSm9obiBEb2UifQ
Enter fullscreen mode Exit fullscreen mode

Benefits:
-No database needed
-Works offline after first load
-Zero breach surface
-Just URLs that makes it scalabe

Tradeoffs:

  • Anyone with link can view (security by obscurity)
  • ~3KB practical payload limit
  • No built-in expiration

The Translation Layer

Supporting 20 languages meant solving two problems:

1. Static UI Translation
Predefined strings (buttons, labels, medical terms) → translate ahead of time.
I used Lingo.dev's CLI:

// i18n/en.json (source file)
{
  "medical": {
    "allergies": "Allergies",
    "bloodType": "Blood Type"
  },
  "communicate": {
    "areYouInPain": "Are you in pain?"
  }
}
Enter fullscreen mode Exit fullscreen mode
# One command generates 19 language files
npx lingo.dev@latest i18n
Enter fullscreen mode Exit fullscreen mode

Result: i18n/es.jsoni18n/ja.jsoni18n/ar.json, etc.
140 strings × 20 languages = 2,800 translations automatically.

2. Dynamic User Content
User notes like "EpiPen in right jacket pocket" can't be pre-translated.
Solution: Realtime API translation via Lingo.dev:

# FastAPI endpoint
@app.post("/api/translate")
async def translate_text(request: Request):
    data = await request.json()

    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://api.lingo.dev/v1/translate",
            headers={"Authorization": f"Bearer {LINGO_API_KEY}"},
            json={
                "text": data["text"],
                "source_locale": "en",
                "target_locale": data["target_locale"]
            }
        )
        return response.json()
Enter fullscreen mode Exit fullscreen mode

Performance:
-Static translations: ~10ms (cached)
-Dynamic translations: ~800ms (API call)
-Total load time: < 1 second

The Flag Grid UX
Problem: When a German paramedic scans a QR from an unconscious Chinese patient, what language is the UI in?

Solution: Visual only language picker.

const languages = [
  { code: 'ja', flag: '🇯🇵', name: '日本語' },
  { code: 'ar', flag: '🇸🇦', name: 'العربية' },
  { code: 'zh', flag: '🇨🇳', name: '中文' }
  // ... 17 more
];

function renderLanguageSelector() {
  return `
    <div class="grid grid-cols-4 gap-4">
      ${languages.map(lang => `
        <button onclick="selectLanguage('${lang.code}')">
          <span class="text-5xl">${lang.flag}</span>
          <span>${lang.name}</span>
        </button>
      `).join('')}
    </div>
  `;
}
Enter fullscreen mode Exit fullscreen mode

Key design: Flags first, native script for names. Someone who speaks zero English can still navigate.

Voice Synthesis (And Its Limits)
For non verbal patients, I added voice enabled communication using Web Speech API:

function speakText(text, locale) {
  const voices = window.speechSynthesis.getVoices();
  const voice = voices.find(v => v.lang.startsWith(locale));

  if (!voice) {
    // Fallback: show text full-screen
    showTextFullscreen(text);
    return;
  }

  const utterance = new SpeechSynthesisUtterance(text);
  utterance.voice = voice;
  utterance.rate = 0.9;

  window.speechSynthesis.speak(utterance);
}
Enter fullscreen mode Exit fullscreen mode

Real-world discovery: Voice support is device dependent, not just browser dependent

Language iOS Safari Android Chrome
English Yes Yes
Japanese Yes Sometimes
Arabic Yes Often missing

Solution: Always display text visually as backup

Deployment: Local vs Production URLs

During development, QR codes encoded:

http://localhost:8000/#card/eyJuYW1l...
Enter fullscreen mode Exit fullscreen mode

This works on my pc. Scan from phone? Worthless

Fix:

// WRONG: hardcoded
const url = `https://pulsecard.onrender.com/#card/${encoded}`;

// RIGHT: deployment-aware
const url = `${window.location.origin}/#card/${encoded}`;
Enter fullscreen mode Exit fullscreen mode

Now it automatically uses the correct domain.

The Debugging Disasters
Bug 1: GitHub's 100MB Limit

remote: error: File is 147MB; exceeds GitHub's 100MB limit
Enter fullscreen mode Exit fullscreen mode

I'd committed node_modules/ before .gitignore.
Even after deleting the folder, the file was still in git history.

Nuclear fix:

rm -rf .git
git init
git add .
git commit -m "Initial commit"
git push --force
Enter fullscreen mode Exit fullscreen mode

Lost all commit history. But it worked.
Lesson: Add .gitignore FIRST.

Bug 2: The Blank Screen
Added a feature. Entire app went white.
The culprit:

// WRONG - executes immediately
`<button onclick="${speakText(text)}">Click</button>`

// RIGHT - executes on click
`<button onclick="speakText('${text}')">Click</button>`
Enter fullscreen mode Exit fullscreen mode

Template literals + inline handlers = footgun.

Bug 3: Missing Comma
Added new JSON keys. Server returned 500.

{
  "setup": {
    "createCard": "Create Card"
    "pinProtection": "PIN Protection"  // Missing comma
  }
}
Enter fullscreen mode Exit fullscreen mode

Took 2 hours to find, i almost gave up here


Security Considerations
What's secure:
✅ No server-side storage
✅ HTTPS transport
✅ Optional AES-256 encryption
What's not:
❌ No access logging(Exccept user added a pin)
❌ No expiration


What I'd Build Next
Short term:
-NFC tag support
-Apple/Google Wallet integration
-Offline PWA mode

Medium term:
-Healthcare provider dashboard
-EHR integration (Epic, Cerner)
-Family linking (parent/child cards)

Long term:
-Clinical validation
-Emergency responder training mode
-Regional medical terminology mapping
-What if the patient was unconscious and he added a pin

Try It
Live demo: https://pulsecard.onrender.com
*Run locally:
*

Bash
git clone https://github.com/Onegreatlion/pulsecard.git
cd pulsecard
pip install -r requirements.txt
uvicorn main:app --reload
Enter fullscreen mode Exit fullscreen mode

Tech Stack Summary
-Backend: FastAPI (Python 3.9)
-Frontend: Vanilla JS, Tailwind CSS
-Translation: Lingo.dev CLI + API
-Voice: Web Speech API
-QR: python-qrcode
-Deployment: Render

Coffee consumed: Too much
Questions for the Community

-What's the minimum medical data needed to save a life?
-Worth partnering with healthcare orgs for validation?
Drop your thoughts below—I respond to everyone.
GitHub: github.com/Onegreatlion/pulsecard
Demo: https://pulsecard.onrender.com
Built with FastAPI, Lingo.dev, and way too much debugging.

Top comments (0)