No Backend, No Excuses: Building a Pain Tracker That Doesn't Sell You Out
Source: Dev.to
Overview
Pain Tracker is a chronic pain‑management app that runs entirely on the user’s device.
There are no accounts, no cloud sync, and no telemetry—your health data stays on your device or never exists.
Typical “free” health apps require you to create an account and sync data to external servers, exposing your pain levels, medication history, and worst days to insurers, data breaches, or legal subpoenas. The people who need pain tracking most—those dealing with chronic illness, disability claims, workers’ compensation fights, and medical trauma—are often the most vulnerable to these risks.
I built Pain Tracker because I’ve personally had my health data used against me in court. This app puts you in control of your data.
Architecture
┌─────────────────────────────────────────────────────────────┐
│ YOUR DEVICE │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ React UI │ → │ Zustand │ → │ IndexedDB │ │
│ │ │ │ │ │ (Encrypted) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ↓ │
│ ┌─────────────┐ │
│ │ PDF/CSV │ → Your doctor. Your call. │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
Key points
- No cloud, no servers, no telemetry. Data never leaves the device unless you manually export it.
- Zustand with Immer provides immutable updates and a full audit trail.
- IndexedDB stores encrypted data locally.
State Management
import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export const usePainTrackerStore = create()(
subscribeWithSelector(
persist(
devtools(
immer((set) => ({
entries: [],
addEntry: (entryData) =>
set((state) => {
state.entries.push({
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
version: 1,
...entryData,
});
}),
}))
),
{
name: 'pain-tracker-storage',
storage: createJSONStorage(() => localStorage),
}
)
)
);
- Every entry includes a version and a timestamp.
- Immutable updates make it easy to prove what the data looked like at any point in time.
Client‑Side Encryption
// Generate a fresh IV for each encryption
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
plaintextBytes
);
- AES‑256‑GCM via the Web Crypto API.
- No external dependencies.
- HMAC verification on decrypt.
- Password‑derived keys use 150,000 PBKDF2 iterations (the minimum needed to make brute‑force attacks impractical).
Full encryption implementation is covered in Part 2.
Adaptive Interface for Pain Flares
const activateEmergencyMode = useCallback(() => {
updatePreferences({
simplifiedMode: true,
touchTargetSize: 'extra-large', // 72 px
autoSave: true,
showMemoryAids: true,
});
}, [updatePreferences]);
Crisis detection watches for:
- Pain level spikes (≥ 7)
- High error rates (cognitive fog)
- Erratic input patterns (distress)
When a flare is detected, the UI automatically:
- Switches to a simplified layout
- Increases button size
- Reduces options
- Enables auto‑save on every change
All data remains on the device and can be deleted at any time.
Full hooks implementation is covered in Part 3.
Fibromyalgia Assessment (ACR 2016 Criteria)
export function calculateFibromyalgiaScore(
painLocations: string[],
symptomScores: SymptomScores
): FibromyalgiaAssessment {
const wpi = painLocations.length; // 0‑19
const sss =
symptomScores.fatigue +
symptomScores.wakingUnrefreshed +
symptomScores.cognitiveSymptoms; // 0‑12
const meetsCriteria =
(wpi >= 7 && sss >= 5) ||
(wpi >= 4 && wpi = 9);
return {
wpiScore: wpi,
sssScore: sss,
meetsFibromyalgiaCriteria: meetsCriteria,
assessmentDate: new Date().toISOString(),
};
}
- Provides a validated assessment (not a diagnosis) that generates documentation for clinicians.
- Built to replace vague explanations like “everything hurts.”
Export for Workers’ Compensation (WCB)
interface WCBExportOptions {
format: 'csv' | 'json' | 'pdf';
dateRange: { start: Date; end: Date };
includeMetadata: boolean;
wcbClaimNumber?: string;
}
export async function exportForWCB(
entries: PainEntry[],
options: WCBExportOptions
): Promise {
const filtered = entries.filter(
(e) =>
new Date(e.timestamp) >= options.dateRange.start &&
new Date(e.timestamp)
Files worth reading
src/services/EmpathyIntelligenceEngine.ts– heuristic pain analysis (2,076 lines)src/services/EncryptionService.ts– client‑side encryption implementationsrc/components/accessibility/– trauma‑informed hookssrc/stores/pain-tracker-store.ts– state management with audit trail
Conclusion
Still housing‑unstable. Still shipping.
Take what’s useful, improve it, and build something better.