Contexte
J'enchaîne plusieurs sports dans la semaine — course, vélo, muscu, marche — et aucun outil ne couvrait honnêtement ce mix. Strava est centré course/vélo, les apps de muscu ignorent l'endurance, et les solutions tout-en-un demandent dix minutes de saisie par séance.
Je voulais un journal calendrier-first, mobile-first, qui se remplit en quelques secondes — y compris à la voix après une sortie. Pacelog est le résultat : une PWA installable, multi-disciplines, conçue et développée en solo de bout en bout.
Objectifs
- Saisie en moins de 30 secondes : formulaire calendrier-first, pré-remplissage intelligent, voix optionnelle
- Multi-disciplines unifiées : RUN, STRENGTH, CYCLING, SWIMMING, WALKING, MOBILITY, OTHER dans un seul modèle
- Privacy par défaut : auth Google via Better Auth, DB perso sur Neon, media sur Vercel Blob — aucune donnée envoyée à un tracker tiers
- Mobile-first installable : PWA Serwist avec safe-area iOS, tabbar fixe et shell offline
Architecture technique
Extraction vocale et structurée via OpenAI
Le pipeline IA est volontairement minimaliste : Whisper transcrit l'audio, GPT-4o-mini renvoie un JSON validé par Zod, et rien n'est persisté côté serveur. Le draft revient à l'utilisateur dans le formulaire /activities/new, qui confirme avant écriture en base.
// LLM never persists — output is a draft validated with Zod
// then sent back to the form for user confirmation.
const completion = await ai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0.2,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: buildSystemPrompt(library) },
{ role: "user", content: `Today's date: ${todayKey}\n\nUser said:\n${text}` },
],
});
const parsed = ExtractedActivitySchema.safeParse(JSON.parse(raw));
if (!parsed.success) throw new Error(`AI returned malformed JSON`);
return parsed.data;Le catalogue d'exercices de l'utilisateur est injecté dans le prompt système pour que le LLM sorte des noms qui matchent exactement le résolveur — sans cette injection, on récupère des doublons type "bench press" vs "Bench Press".
Server Components et Server Actions par défaut
L'app tourne sur Next.js 16 avec cacheComponents et le React Compiler activés. Les Server Components couvrent toutes les pages privées, les Server Actions gèrent l'intégralité du CRUD (activities, exercises, sets, settings, onboarding), et les Route Handlers ne sont conservés que pour Better Auth, l'upload Vercel Blob et les routes IA — chacune avec une raison technique précise.
Chaque Server Action et chaque page privée appelle requireUser() ou requireOnboardedUser(), et toutes les requêtes filtrent par userId en première condition.
Modèle de données normalisé pour les stats
Les métriques sont stockées en double : la valeur saisie par l'utilisateur (value + unit) et la valeur normalisée (normalizedValue en METER pour les distances, SECOND pour les durées). Cette normalisation permet d'agréger proprement à travers les unités préférées (KM/MILE, KG/LB) sans recalcul à la lecture.
model ActivityMetric {
// User-entered value + unit
value Float
unit MetricUnit
// Normalized for stats aggregation (METER / SECOND)
normalizedValue Float
normalizedUnit MetricUnit
}PWA installable avec Serwist
Le service worker est généré via Serwist uniquement en production (next build --webpack) — en dev, Next 16 tourne sur Turbopack et le wrap webpack est explicitement bypassé pour garder la DX propre. Le shell mobile gère la safe-area iOS, une tabbar fixe en bas, un header sticky, et désactive le scroll-through du status bar en mode standalone.
Resultats
- Usage personnel quotidien depuis le déploiement, sur 4 disciplines (run, strength, cycling, walking)
- Saisie vocale en moins de 10 secondes : dictée → draft IA pré-rempli → confirmation
- PWA installable iOS/Android avec safe-area, tabbar fixe et shell offline
- 0 donnée de santé partagée avec un tiers — auth Google, DB Neon, media Vercel Blob, tout sous mon compte
Ce que j'ai appris
L'IA générative est utile dans une app produit uniquement si elle ne persiste rien : la sortie de GPT-4o-mini est un draft, jamais une écriture en base. Injecter le catalogue d'exercices de l'utilisateur dans le prompt système a fait plus pour la qualité que doubler la taille du modèle — le LLM sort des noms qui matchent exactement le résolveur, et les doublons accidentels disparaissent.
J'ai aussi appris que Next.js 16 avec cacheComponents change le mental model : on ne pense plus en pages mais en fragments cachables, et les Server Actions remplacent 90% de ce que je faisais en route handlers. Mélanger Serwist et Turbopack en dev demande par contre de désactiver le wrap webpack — sinon le warning pollue toute la DX.
Enfin, une PWA installée n'est pas un site responsive : la safe-area, la tabbar, le scroll du shell, le comportement standalone vs navigateur — tout doit être pensé en pixels physiques, pas en breakpoints. Et un journal sportif vit ou meurt à la friction de saisie : tant qu'une séance prend trente secondes à logguer, l'utilisateur abandonne au bout d'une semaine. La voix a été la vraie unlock.