Table of Contents
- Key Highlights:
- Introduction
- Why logging workouts is harder than it looks
- Designing for two taps per set
- Choosing Convex for real-time sync
- Data model: a discriminated-union schema for entries
- Next.js 16: wins and pain points
- The AI layer: plugging OpenRouter into the product
- Routine generation: constraints, equipment audits, and structured prompts
- Measuring training load with RPE
- UI details: the stepper component and the rest timer
- Handling failure: offline recovery, auth quirks, and phone deaths
- Performance and scale considerations
- Privacy, export, and open-source tradeoffs
- Real-world examples: how the system behaves in practice
- What the team would change next time
- How to try OpenTrainer and the repo
- FAQ
Key Highlights:
- OpenTrainer reduces set logging to two taps using optimistic real-time sync (Convex) and a thumb-friendly UI, preserving rest timers and handling offline scenarios.
- The app combines a discriminated-union schema for entries, an AI layer via OpenRouter/Gemini for constrained routine generation, and an RPE-aware training-load metric for better insights.
Introduction
Logging workouts should be frictionless. Instead, most apps treat gym logging like a desk job: dozens of taps, keyboard juggling, and lost rest periods. OpenTrainer flips that script. Its stated goal is simple: log a set in two taps, keep rest timers honest, and provide useful analytics—without forcing users to babysit the UI or the network.
This article examines how OpenTrainer approaches that problem from product, UX, and engineering perspectives. It lays out the stack choices, the data model, the AI strategy for generating practical routines, the methods used to keep timers alive on mobile, and the lessons learned after shipping. The focus is practical: how the app minimizes friction, maintains consistency across devices, and provides meaningful metrics such as training load that account for intensity, not only volume.
Why logging workouts is harder than it looks
Most fitness apps assume users have ample time and a stable connection. In reality, logging happens during 60–90 second rest windows while hands are sweaty and the phone is balanced precariously on a rack. That creates several constraints:
- Inputs must be quick and one-handed friendly.
- Network latency can interrupt flow; slow REST requests create visible loading states.
- Mobile browsers and OSes aggressively suspend background scripts, killing timers.
- Users change devices, go offline, or have phones die mid-session.
Conventional apps force a sequence of UI interactions—open dialog, tap fields, wait for keyboard, type, dismiss keyboard, save—that adds up to nearly a minute. When every second between sets matters, that friction undermines the experience and leads to abandoned logs or inaccurate data.
OpenTrainer began with a single design target: reduce logging to two taps per set. That objective cascaded into engineering choices—real-time sync with optimistic updates, an offline-first mindset, ergonomic controls, and a data model that supports multiple exercise types without complex joins.
Designing for two taps per set
Simplicity in the interface comes from rethinking every interaction. The logging flow compresses into just two actions:
- Tap the weight number to increment/decrement quickly or open direct input.
- Tap "Log Set" to persist the entry.
That short flow removes multiple keyboard round-trips and minimizes the need to dismiss input controls. Two additional UX elements make this reliable in a sweaty, noisy environment:
- Large tap targets (minimum 48px) placed within thumb reach when a phone is held in one hand.
- Immediate haptic feedback on every tap so users receive tactile confirmation even when sound isn’t available.
A practical example: bench press on a commercial gym bench. You finish your set, tap the weight display to increase by five pounds, hit Log Set, and the rest timer starts automatically. No manual save, no keyboard dance. The app then queues the next set or displays the countdown. That small change changes behavior: users log consistently, which improves data quality over time.
Choosing Convex for real-time sync
OpenTrainer replaces traditional REST API requests with Convex for state synchronization. Convex offers several advantages that map directly to OpenTrainer’s requirements:
- Optimistic updates: the UI reflects a change immediately, then the server persists it. No spinners, no blocked timers.
- Offline queueing: mutations are queued while the device is offline and replayed when connectivity returns.
- Deduplication via client IDs: repeated network retries or client restarts won’t create duplicate entries.
- Real-time sync across devices: logs from a paired device (or a trainer’s tablet) show up instantly on your phone.
Contrast this with a REST-based flow: tap → send HTTP request → wait for 2–3 seconds on flaky gym Wi‑Fi → UI waits for response. That latency introduces visible disruption and encourages users to skip logging entirely. Convex eliminates that interruption by decoupling UI feedback from the eventual database write.
A minimal example of a Convex mutation in OpenTrainer:
const logSet = useConvexMutation(api.workouts.addLiftingEntry);
const handleLogSet = () => {
logSet({
workoutId,
exerciseName: "Bench Press",
clientId: generateClientId(), // deduplication
kind: "lifting",
lifting: { setNumber: 1, reps: 8, weight: 225, unit: "lb" }
});
// UI updates instantly. Done.
};
That code illustrates the essential behavior: a single mutation fires, the UI applies an optimistic update, and persistence happens asynchronously. If the mutation fails, Convex rolls it back and surfaces an error, but users rarely experience blocked flows.
Data model: a discriminated-union schema for entries
OpenTrainer stores workout events in a single "entries" table using a discriminated union pattern. Each row has a kind field—lifting, cardio, mobility—and variant attributes for each type. This design avoids multiple tables like lifting_entries, cardio_entries, mobility_entries and simplifies queries that need a workout’s full history.
Schema sketch:
// entries table
{
kind: "lifting" | "cardio" | "mobility",
lifting?: {
setNumber: number,
reps: number,
weight: number,
unit: "kg" | "lb",
rpe?: number
},
cardio?: {
durationSeconds: number,
distance?: number,
intensity?: number
},
mobility?: {
holdSeconds: number,
perSide?: boolean
}
}
Benefits of this approach:
- One query returns a workout’s entire timeline, easing UI rendering and analytics.
- Extending the schema with new activity types is straightforward—add a new variant without changing query logic elsewhere.
- Storage and retrieval are simpler for a real-time system where updates can arrive from multiple devices.
There is a trade-off: a single table can grow large and may require indexing or partitioning strategies for scale. In OpenTrainer’s current scope, the simplicity and query speed outweigh those considerations, but teams building at product scale should plan for sharding or archiving older entries.
Next.js 16: wins and pain points
Next.js 16 introduced a set of features that helped the app’s navigation and SEO workflows, but not without friction.
What worked:
- Parallel routes: they allowed a clean bottom navigation model without complex custom logic.
- Metadata API: streamlined SEO and social preview generation.
- Clerk middleware (v5+): authentication with Clerk integrated smoothly.
Frictions encountered:
- Development server instability: frequent full recompiles and occasional hot-reload misses required manual refreshes.
- Type friction: some Convex types required manual casting to satisfy TypeScript, producing minor developer overhead.
Authentication setup required a standard JWT template in Clerk and an environment variable pointing to the JWT issuer. Once configured, Clerk and Convex play nicely together, but teams should be prepared for issuer domain changes and validate the issuer URL during setup to avoid intermittent validation failures.
The AI layer: plugging OpenRouter into the product
OpenTrainer’s AI layer was designed to add value where generic assistants fail. Instead of a generic “ask me to plan a workout” experience that returns unrealistic or hallucinated exercises, the app constrains the model with two practical inputs:
- An equipment audit collected during onboarding.
- Clear constraints around session duration, weekly availability, and the user’s experience level.
OpenRouter serves as the gateway. It provides a single API to multiple underlying models (Gemini, Claude, GPT), allowing the team to switch models or fall back in a single configuration change. The unified endpoint simplifies operations and helps avoid vendor lock-in.
Why OpenRouter:
- One API key for different models.
- Convenient fallback and cost visibility.
- Easier experimentation with models as they evolve.
A typical chat completion request looks like this:
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "google/gemini-3-flash-preview",
messages: [{ role: "system", content: systemPrompt }, { role: "user", content: userMessage }]
}),
});
OpenTrainer currently uses Gemini 3 Flash for speed and cost characteristics. The team also monitors output quality and falls back to other models when needed.
Routine generation: constraints, equipment audits, and structured prompts
AI can produce plausible-sounding but impractical routines if prompted poorly. OpenTrainer avoids common pitfalls by enforcing three rules:
- Gather precise, structured data about what equipment the user actually has access to.
- Use rigid constraints (days per week, session duration) to bound the plan.
- Require the model to return JSON matching a specific schema to avoid hallucinated outputs.
The onboarding “equipment audit” captures what’s available—e.g., Smith machine, dumbbells, cable machine, squat rack, cardio machines. If a user marks “Planet Fitness,” the routine generator avoids barbell-heavy programming and favors machines and dumbbells.
An example structured prompt (simplified):
Generate a ${goal} program for ${experienceLevel} lifter.
EQUIPMENT AVAILABLE:
${equipment.join(", ")}
CONSTRAINTS:
- ${weeklyAvailability} days per week
- ${sessionDuration} minutes per session
- Must use ONLY the equipment listed above
- Use standard exercise names (no made-up movements)
OUTPUT FORMAT: JSON matching this schema...
Giving the model strict constraints and an output schema keeps the responses useful and machine-readable. In practice, this dramatically reduces hallucinated exercises and produces routines that match the lifter’s context.
Real-world benefit: a user who only has a smith machine and 30 minutes per session will not receive a deadlift-heavy, multi-hour plan—OpenTrainer’s prompt prevents that mismatch.
Measuring training load with RPE
Raw volume (sets × reps × weight) misses intensity. Two sets with identical volume can produce different physiological stress if one is near failure and the other is a warm-up. OpenTrainer introduces RPE into training-load calculations to approximate training stress more accurately.
Formula implemented:
function calculateTrainingLoad(entry) {
if (entry.kind !== "lifting") return 0;
const { reps = 0, weight = 0, rpe = 5 } = entry.lifting;
const volume = reps * weight;
const intensityFactor = rpe / 10;
return volume * intensityFactor;
}
Example comparison:
- 5 reps × 225 lb at RPE 9 yields a higher training load than 10 reps × 135 lb at RPE 5, even if raw volume suggests otherwise. This helps prioritize recovery and progression in analytics and provides a more nuanced view of weekly stress.
RPE is subjective, but when combined with consistent logging it becomes a valuable relative metric. OpenTrainer encourages users to enter an RPE occasionally and uses defaults when users skip it to avoid skewing totals.
UI details: the stepper component and the rest timer
Two UI components deserve special attention: the stepper for adjusting weight/reps and the rest timer.
Stepper design goals:
- Quick increment/decrement (+/- 5 lb) via large, reachable buttons.
- Option for direct numeric input if needed.
- Immediate UI feedback with debounced persistence to avoid hammering the network.
A component example:
<SetStepper
value={weight}
onChange={setWeight}
step={5} // +/- 5 lbs per tap
min={0}
max={1000}
label="Weight"
unit="lb"
enableDirectInput // tap the number to type
/>
The local state updates instantly, and a debounced mutation persists the value to Convex. This balances responsiveness with network efficiency.
Rest timer: browsers suspend background JavaScript and mobile OSes are aggressive about killing timers. OpenTrainer uses a combination of Web Workers, Service Workers, and notifications to keep timers honest even when the app isn’t in the foreground.
Worker sketch:
// rest-timer-worker.ts
self.addEventListener("message", (e) => {
if (e.data.action === "start") {
const interval = setInterval(() => {
self.postMessage({ timeRemaining: getRemainingTime() });
}, 1000);
}
});
When users switch to music or lock the phone, the worker continues ticking. The app requests notification permissions so it can fire a loud alert when the rest period ends. Users complain about the noise but they don’t miss rest periods anymore. In this context, annoyance is preferable to degraded training sessions.
Handling failure: offline recovery, auth quirks, and phone deaths
Shipping a real-world mobile web app uncovers problems that aren’t obvious in a development environment. OpenTrainer encountered three notable failure modes and implemented pragmatic recovery strategies.
Problem: Rest timer killed by the browser Solution: Use Web Workers + Service Workers + loud notifications. Workers keep counting while the UI is suspended. Notifications re-engage users with a vocal cue when time’s up.
Problem: Clerk JWT validation failing randomly Symptom: Convex validates Clerk JWTs and started rejecting tokens intermittently when the issuer domain fluctuated between different domains (clerk.accounts.dev vs accounts.dev). Solution: Make the issuer domain configurable via an environment variable and surface informative logging. Treat the issuer as a first-class config item during deployment and run a validation check during startup to catch mismatches early.
Problem: Phone dies mid-workout and logs are lost Symptom: Users lost 10 sets after a battery failure. Solution: Keep an in_progress workout state on the server and check for incomplete workouts at app load. If an active in_progress workout exists, prompt the user to resume it.
Resume flow sketch:
const checkForIncompleteWorkout = async () => {
const activeWorkout = await getActiveWorkout(userId);
if (activeWorkout && activeWorkout.status === "in_progress") {
showResumeDialog(activeWorkout);
}
};
This small resilience measure prevents data loss from device failure and restores continuity across sessions.
Performance and scale considerations
OpenTrainer’s architecture prioritizes user-perceived instantaneity. That imposes specific performance constraints and future scaling considerations:
- Client-side optimistic updates reduce latency but require robust conflict resolution and deduplication. Convex handles much of this, but teams should design idempotent mutations and generate clientIds correctly.
- Single-table discriminated unions simplify queries but require indexing strategies as the dataset grows. Consider time-based partitioning, per-user sharding, or archiving old sessions to sustain query performance.
- AI calls to OpenRouter add cost and latency. Cache generated routines when possible, and only regenerate them when inputs change (equipment, goals, availability). Provide a local fallback or template library for users with strict privacy requirements.
- Notification delivery depends on OS-level permissions and unreliable push flows. Provide alternative cues (vibration/haptics) and a manual timer override to avoid single points of failure.
Privacy, export, and open-source tradeoffs
OpenTrainer emphasizes user control. The app is free in alpha, with no tracking pixels and an export-to-JSON feature. It is Apache 2.0 licensed on GitHub, enabling users to self-host or examine the source.
Privacy implications to consider:
- AI prompts include user equipment and constraints. Avoid leaking personal identifiers in prompts if you’re sending them to third-party APIs.
- Authentication with Clerk and routing through Convex requires careful secrets management. Keep keys and issuer URLs out of client bundles and rotate them regularly.
- If you self-host or fork the project, review the OpenRouter usage terms and consider a private or on-prem model if data residency is a concern.
Open-source availability reduces vendor lock-in and allows advanced users to run their own stacks. Teams planning a commercial product might split the architecture into a hosted core and self-hosting guides for privacy-focused customers.
Real-world examples: how the system behaves in practice
Example 1: Commercial gym with sketchy Wi‑Fi A user logs five sets on bench press. Convex’s optimistic update shows sets instantly. The device is on flaky Wi‑Fi, but mutations queue and persist when connectivity improves. The user toggles Spotify between sets; the worker keeps the rest timer alive and a notification fires when it’s time to resume.
Example 2: Home gym with limited equipment A user indicates only adjustable dumbbells and a cable machine. The AI generator, constrained by that equipment list, returns a four-day split with dumbbell presses, single-arm rows, and cable-facing accessory work. No barbell-only lifts appear, and the JSON output maps directly to app templates.
Example 3: Trainer demonstration across devices A coach modifies a workout on a tablet while a client views the session on their phone. Convex’s real-time sync streams updates so both see the same timeline. If the client logs a set on their watch or phone concurrently, deduplication prevents double entries.
What the team would change next time
User testing earlier and more often tops the list. Features that seem obvious in development can produce friction in the wild, especially when users are under time pressure and physically engaged.
Other planned changes:
- More rigorous A/B testing of stepper increments and direct input defaults to reduce cognitive friction.
- Offline-first onboarding to let new users complete equipment audits and preferences before requiring sign-in.
- A library of validated, fallback routine templates for times when AI calls are delayed or costs are constrained.
- Increased telemetry around worker lifecycle events to detect and fix cases when timers fail silently.
These are small shifts with outsized returns. The initial release shows that the technical choices were sound; the remaining work sits largely in product tuning and resilience.
How to try OpenTrainer and the repo
OpenTrainer is in alpha and free to try. There are two paths:
- Use the hosted alpha at https://opentrainer.app to test features and provide feedback.
- Clone and run the repository locally from https://github.com/house-of-giants/opentrainer. The repo is Apache 2.0 licensed.
For teams building similar tooling:
- Prioritize the flow that users will use in the gym. Simulate sweaty, hurried usage during testing.
- Adopt optimistic UI patterns for actions that should feel instant.
- Build constrained AI prompts and a schema contract for model outputs to avoid hallucinations.
- Plan for offline recovery and device failures from day one.
FAQ
Q: How does OpenTrainer keep the UI responsive on bad connections? A: It uses Convex for optimistic updates and offline queuing. The UI applies updates immediately while Convex persists mutations asynchronously. If a write fails, Convex rolls the change back and surfaces an error.
Q: What happens if I lose my phone mid-workout? A: Workouts are marked as "in_progress" on the server. When you reopen the app, it detects any incomplete workout and prompts you to resume. This prevents lost sets from device failures.
Q: How does the AI know what exercises I can do? A: During onboarding, OpenTrainer asks for an equipment audit. That equipment list is passed to the AI as part of a structured prompt, which instructs the model to generate a routine using only the listed equipment and to output JSON conforming to a schema.
Q: Why use OpenRouter instead of calling a model provider directly? A: OpenRouter provides a single gateway for multiple models, enabling model switching with minimal code change, cost visibility, and fallback logic. It avoids vendor lock-in and simplifies operations.
Q: Is my data exportable? A: Yes. OpenTrainer lets users export their data as JSON. The app is open-source under Apache 2.0, so you can self-host and manage your own data if required.
Q: How does training load differ from simple volume? A: Training load here is volume multiplied by an intensity factor derived from RPE (Rate of Perceived Exertion). That way, a high-effort, low-rep heavy set counts more toward training stress than a low-effort, high-rep warm-up, even if raw volume is similar.
Q: Can I run OpenTrainer locally? A: Yes. The project repository is on GitHub (house-of-giants/opentrainer) with an Apache 2.0 license. You can clone the repo and run the stack locally for experimentation or self-hosting.
Q: What were the biggest technical surprises? A: Two things: browsers aggressively suspend background timers (prompting the Web Worker + notification approach), and authentication issuer domains can change subtly (requiring a flexible config for Clerk JWT validation).
Q: Is Convex required for the model to work? A: No, Convex is a design choice that enables optimistic real-time sync and offline queuing with minimal infrastructure. You could implement similar behavior with other realtime databases or services, but Convex reduced the amount of custom synchronization code needed.
Q: Are there plans for watch or offline-only versions? A: The team is exploring companion devices and improved offline-first workflows. The core architecture—optimistic client state and queued mutations—supports adding companion devices and offline resilience with incremental engineering.
OpenTrainer demonstrates that thoughtful constraints—two-tap logging, tight AI prompts, worker-based timers, and optimistic persistence—produce a measurably better gym logging experience. The stack choices map directly to the user problem, and the lessons learned emphasize testing with real users under real conditions. For developers and teams building fitness experiences, the core takeaway is straightforward: shave off every millisecond and every unnecessary tap; consistency beats cleverness.