From 0375c9de0da0a8bf27927b9e0ae293e7fe8e4612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Fri, 10 Jul 2026 16:39:58 +0200 Subject: [PATCH 1/2] feat(tutorial): refonte complete du guide interactif V2 avec progression dynamique par chapitres et spotlights --- back/controllers/user.controller.js | 19 ++ back/middleware/rateLimit.middleware.js | 11 +- back/models/User.js | 8 + back/routes/user.routes.js | 3 + back/services/user.service.js | 18 ++ back/tests/user.test.js | 54 ++++++ .../components/tutorial/TutorialOverlay.js | 117 ++++++++---- front/src/context/TutorialContext.js | 18 +- .../data/__tests__/tutorialChapters.test.js | 167 ++++++++++++++++++ front/src/data/tutorialChapters.js | 112 +++++++++++- front/src/screens/Home/HomeScreen.js | 15 +- front/src/screens/Profile/InventoryScreen.js | 37 +++- front/src/screens/Profile/SettingsScreen.js | 4 +- front/src/screens/Social/SocialScreen.js | 26 ++- front/src/screens/Stats/StatsScreen.js | 80 +++++++-- front/src/services/onboarding.service.js | 10 ++ 16 files changed, 622 insertions(+), 77 deletions(-) create mode 100644 front/src/data/__tests__/tutorialChapters.test.js create mode 100644 front/src/services/onboarding.service.js diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index fe581f1..c8a7a8a 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -123,4 +123,23 @@ exports.registerPushToken = async (req, res, next) => { } catch (error) { next(error); } +}; + +/** + * MARQUER LE TUTORIEL COMME TERMINÉ + * Appelé par le front à la fin (ou au skip) du tutoriel interactif. + * Idempotent — voir user.service.js → completeOnboarding. + */ +exports.completeOnboarding = async (req, res, next) => { + try { + const user = await userService.completeOnboarding(req.user.id); + + res.status(200).json({ + success: true, + message: "Onboarding terminé.", + hasCompletedOnboarding: user.hasCompletedOnboarding, + }); + } catch (error) { + next(error); + } }; \ No newline at end of file diff --git a/back/middleware/rateLimit.middleware.js b/back/middleware/rateLimit.middleware.js index c2ce31e..a0c1565 100644 --- a/back/middleware/rateLimit.middleware.js +++ b/back/middleware/rateLimit.middleware.js @@ -4,14 +4,19 @@ const { rateLimit, ipKeyGenerator } = require('express-rate-limit'); const config = require('../config/env'); // Les tests Jest enchaînent des centaines de requêtes : on désactive le -// rate-limiting en environnement de test uniquement. -const isTest = config.nodeEnv === 'test'; +// rate-limiting en environnement de test uniquement. Détection via +// JEST_WORKER_ID (défini par Jest dans chaque worker) plutôt que NODE_ENV : +// certains tests basculent temporairement config.nodeEnv vers 'production' +// (pour vérifier les routes devOnly), ce qui réactivait par erreur le limiter +// au milieu de la suite et provoquait des 429 non-déterministes. Évalué à +// chaque requête (pas figé au chargement du module). +const isTestEnv = () => process.env.JEST_WORKER_ID !== undefined || config.nodeEnv === 'test'; const standardOptions = { windowMs: 15 * 60 * 1000, standardHeaders: true, // RateLimit-* headers pour les clients legacyHeaders: false, - skip: () => isTest, + skip: () => isTestEnv(), message: { success: false, status: 429, diff --git a/back/models/User.js b/back/models/User.js index 2197eb8..e892440 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -181,6 +181,14 @@ const UserSchema = new mongoose.Schema( // ex. "FRAME_SHAPE_DRAGONFANG", "FRAME_COLOR_BLOODSANG", "THEME_BLOODSANG". unlockedCosmetics: { type: [String], default: [] }, + // ── Onboarding / Tutoriel interactif ────────────────────────────────────── + // Passe à true quand l'utilisateur termine (ou passe) le tutoriel interactif. + // Doublé côté client dans AsyncStorage (source immédiate du déclenchement), + // ce flag backend est la vérité inter-appareils : une réinstallation ou une + // connexion sur un nouvel appareil ne re-déclenche pas le tutoriel + // (voir TutorialContext.js → reconcileWithServer). + hasCompletedOnboarding: { type: Boolean, default: false }, + // ── Présence & notifications push ───────────────────────────────────────── // Token Expo Push (ExponentPushToken[...]) enregistré par le front après // acceptation des permissions — voir push.service.js. null = aucun appareil diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js index 6b0a22c..c901d6b 100644 --- a/back/routes/user.routes.js +++ b/back/routes/user.routes.js @@ -28,6 +28,9 @@ router.put("/me/records-showcase", auth, validate(updateRecordsShowcase), userCo // Enregistrer (ou effacer) le token Expo Push de l'appareil courant router.put("/me/push-token", auth, validate(registerPushToken), userController.registerPushToken); +// Marquer le tutoriel/onboarding comme terminé — pas de body (idempotent) +router.post("/me/complete-onboarding", auth, userController.completeOnboarding); + // Suppression définitive du compte (RGPD) router.delete("/delete-account", auth, userController.deleteAccount); diff --git a/back/services/user.service.js b/back/services/user.service.js index 7a01105..a947dcc 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -154,6 +154,24 @@ class UserService { return updatedUser; } + /** + * Marque le tutoriel/onboarding comme terminé pour cet utilisateur. + * Idempotent : rejouer ne change rien une fois le flag à true. Le client + * appelle ceci en best-effort à la fin (ou au skip) du tutoriel — la vérité + * immédiate reste AsyncStorage, ce flag backend sert la cohérence + * inter-appareils (voir TutorialContext.js). + * @param {string} userId + */ + async completeOnboarding(userId) { + const updatedUser = await User.findByIdAndUpdate( + userId, + { $set: { hasCompletedOnboarding: true } }, + { new: true }, + ).select('hasCompletedOnboarding'); + if (!updatedUser) throw new Error('Utilisateur non trouvé.'); + return updatedUser; + } + async addExperience(userId, xpAmount) { const user = await User.findById(userId); user.xp += xpAmount; diff --git a/back/tests/user.test.js b/back/tests/user.test.js index b1632e0..209ce01 100644 --- a/back/tests/user.test.js +++ b/back/tests/user.test.js @@ -75,4 +75,58 @@ describe('User API (Routes Protégées)', () => { expect(res.statusCode).toBe(401); }); }); + + describe('POST /api/users/me/complete-onboarding — completeOnboarding (Tutoriel)', () => { + beforeEach(async () => { + await User.updateOne({ email: 'user@test.fr' }, { $set: { hasCompletedOnboarding: false } }); + }); + + it('✅ hasCompletedOnboarding est false par défaut', async () => { + const res = await request(app) + .get('/api/users/me') + .set('Authorization', `Bearer ${token}`); + + expect(res.body.user.hasCompletedOnboarding).toBe(false); + }); + + it('✅ Passe le flag à true et le persiste en base', async () => { + const res = await request(app) + .post('/api/users/me/complete-onboarding') + .set('Authorization', `Bearer ${token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.hasCompletedOnboarding).toBe(true); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.hasCompletedOnboarding).toBe(true); + }); + + it('✅ Idempotent : rejouer laisse le flag à true', async () => { + await request(app) + .post('/api/users/me/complete-onboarding') + .set('Authorization', `Bearer ${token}`); + const second = await request(app) + .post('/api/users/me/complete-onboarding') + .set('Authorization', `Bearer ${token}`); + + expect(second.statusCode).toBe(200); + expect(second.body.hasCompletedOnboarding).toBe(true); + }); + + it('✅ getMe reflète le flag une fois terminé', async () => { + await request(app) + .post('/api/users/me/complete-onboarding') + .set('Authorization', `Bearer ${token}`); + + const me = await request(app) + .get('/api/users/me') + .set('Authorization', `Bearer ${token}`); + expect(me.body.user.hasCompletedOnboarding).toBe(true); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/users/me/complete-onboarding'); + expect(res.statusCode).toBe(401); + }); + }); }); \ No newline at end of file diff --git a/front/src/components/tutorial/TutorialOverlay.js b/front/src/components/tutorial/TutorialOverlay.js index 75de27b..91a7f0d 100644 --- a/front/src/components/tutorial/TutorialOverlay.js +++ b/front/src/components/tutorial/TutorialOverlay.js @@ -1,25 +1,29 @@ -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { useEffect, useRef, useCallback, useState } from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity, Animated, useWindowDimensions, Platform, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useTutorial } from '../../context/TutorialContext'; +import { CHAPTER_IDS } from '../../data/tutorialChapters'; +import { haptics } from '../../services/haptics.service'; import { Colors } from '../../constants/theme'; // Padding visuel autour du spotlight (léger, ne perturbe pas les coordonnées) const SPOTLIGHT_PADDING = 8; // Marge gauche/droite du tooltip const TOOLTIP_MARGIN = 16; -// Hauteur max estimée du tooltip — sert uniquement au calcul de placement +// Hauteur estimée du tooltip AVANT sa première mesure (remplacée par la vraie +// hauteur via onLayout dès le premier rendu) const TOOLTIP_HEIGHT_EST = 220; -// Seuil vertical (px) : si pageY de l'élément < THRESHOLD → tooltip en-dessous -// sinon → tooltip au-dessus (algorithme strict demandé, indépendant de `position`) -const ELEMENT_Y_THRESHOLD = 250; // Écart gap entre le bord du spotlight et le tooltip const TOOLTIP_GAP = 15; // Marge de sécurité minimale par rapport aux bords de l'écran const SCREEN_SAFE = 12; +// Insets approximés : barre de statut/encoche en haut, indicateur/onglets en bas. +// Gardent le tooltip lisible et cliquable hors des zones système. +const SAFE_INSET_TOP = 44; +const SAFE_INSET_BOTTOM = 28; const GLOW_COLOR = 'rgba(254,116,57,0.55)'; // ─── ProgressDots ───────────────────────────────────────────────────────────── @@ -51,11 +55,13 @@ const dots = StyleSheet.create({ // ─── ChapterBadge ───────────────────────────────────────────────────────────── -function ChapterBadge({ chapter }) { +function ChapterBadge({ chapter, index, total }) { return ( - {chapter.subtitle} · {chapter.title} + Chapitre {index}/{total} + + {chapter.title} ); } @@ -63,12 +69,14 @@ function ChapterBadge({ chapter }) { const badge = StyleSheet.create({ wrap: { flexDirection: 'row', alignItems: 'center', gap: 5, - alignSelf: 'flex-start', + alignSelf: 'flex-start', maxWidth: '100%', backgroundColor: 'rgba(254,116,57,0.12)', borderWidth: 1, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 4, marginBottom: 10, }, - text: { color: Colors.primary, fontSize: 10, fontWeight: '800', letterSpacing: 0.5 }, + counter: { color: Colors.primary, fontSize: 10, fontWeight: '900', letterSpacing: 0.5 }, + sep: { width: 3, height: 3, borderRadius: 2, backgroundColor: Colors.primary, opacity: 0.6 }, + text: { color: Colors.primary, fontSize: 10, fontWeight: '700', letterSpacing: 0.3, flexShrink: 1 }, }); // ─── TutorialOverlay ────────────────────────────────────────────────────────── @@ -87,6 +95,10 @@ export default function TutorialOverlay({ navigation }) { const slideY = useRef(new Animated.Value(24)).current; const opacity = useRef(new Animated.Value(0)).current; + // Hauteur réelle du tooltip, mesurée au rendu (onLayout). Sert au calcul de + // placement : avec une estimation fixe, un texte long débordait sous l'écran + // et le bouton "Suivant" devenait inatteignable. + const [tooltipH, setTooltipH] = useState(TOOLTIP_HEIGHT_EST); const animateIn = useCallback(() => { slideY.setValue(24); @@ -114,36 +126,49 @@ export default function TutorialOverlay({ navigation }) { h: targetRect.height + SPOTLIGHT_PADDING * 2, } : null; - // ─── Positionnement du tooltip (algorithme strict basé sur pageY) ───────── + // ─── Positionnement du tooltip (espace réel + intention du design) ──────── // - // Règle absolue : - // • Pas de cible OU position === 'center' → centré verticalement - // • pageY de l'élément < ELEMENT_Y_THRESHOLD (250 px) - // → tooltip EN-DESSOUS : top = pageY + height + GAP - // • pageY ≥ ELEMENT_Y_THRESHOLD - // → tooltip AU-DESSUS : top = pageY - TOOLTIP_HEIGHT_EST - GAP + // Règles : + // • Pas de cible OU position === 'center' → centré verticalement. + // • Sinon on respecte le côté voulu par le design (position 'top' → tooltip + // AU-DESSUS de la cible, 'bottom' → EN-DESSOUS), MAIS on bascule sur + // l'autre côté s'il n'y a pas la place (cible trop haute/basse ou trop + // grande). On mesure la hauteur RÉELLE du tooltip (tooltipH) pour ne + // jamais le laisser déborder hors de l'écran : sans ça, un texte long + // poussait le bouton "Suivant" sous le bord bas, illisible et incliquable. // - // Les coordonnées utilisées sont celles de l'élément brut (targetRect), - // pas les valeurs paddées du spotlight, pour un placement pixel-perfect. + // Les bornes verticales tiennent compte des marges hautes/basses de sécurité + // (SCREEN_SAFE + insets approximés) pour rester sous la barre d'onglets. const tooltipStyle = (() => { const base = { position: 'absolute', left: TOOLTIP_MARGIN, right: TOOLTIP_MARGIN }; + const safeTop = SCREEN_SAFE + SAFE_INSET_TOP; + const safeBottom = H - SCREEN_SAFE - SAFE_INSET_BOTTOM; + const maxTop = Math.max(safeTop, safeBottom - tooltipH); + if (!hasSpot || activeStep.position === 'center') { - return { ...base, top: Math.max(SCREEN_SAFE, H / 2 - TOOLTIP_HEIGHT_EST / 2) }; + const centered = (safeTop + safeBottom) / 2 - tooltipH / 2; + return { ...base, top: Math.min(Math.max(safeTop, centered), maxTop) }; } - const elemY = targetRect.y; // pageY réel de l'élément (coordonnée native) - const elemH = targetRect.height; // hauteur réelle de l'élément - - if (elemY < ELEMENT_Y_THRESHOLD) { - // Élément dans la partie HAUTE de l'écran → tooltip en-dessous - const topBelow = elemY + elemH + TOOLTIP_GAP; - return { ...base, top: Math.min(topBelow, H - TOOLTIP_HEIGHT_EST - SCREEN_SAFE) }; - } else { - // Élément dans la partie BASSE/MILIEU → tooltip au-dessus - const topAbove = elemY - TOOLTIP_HEIGHT_EST - TOOLTIP_GAP; - return { ...base, top: Math.max(SCREEN_SAFE, topAbove) }; - } + const spotTop = targetRect.y - SPOTLIGHT_PADDING; + const spotBottom = targetRect.y + targetRect.height + SPOTLIGHT_PADDING; + const need = tooltipH + TOOLTIP_GAP; + const roomAbove = spotTop - safeTop; + const roomBelow = safeBottom - spotBottom; + + // Côté souhaité par le design, conservé tant qu'il y a la place ; à défaut + // on prend le côté le plus spacieux (jamais par-dessus la cible). + const prefersAbove = activeStep.position === 'top'; + const placeAbove = prefersAbove + ? (roomAbove >= need || roomAbove >= roomBelow) + : (roomBelow >= need ? false : roomAbove > roomBelow); + + const rawTop = placeAbove + ? spotTop - TOOLTIP_GAP - tooltipH + : spotBottom + TOOLTIP_GAP; + + return { ...base, top: Math.min(Math.max(safeTop, rawTop), maxTop) }; })(); const chapterColor = Colors.primary; @@ -151,7 +176,23 @@ export default function TutorialOverlay({ navigation }) { const isEndCard = !!activeStep.isLast; const isAction = !!activeStep.actionRequired; - const handleNext = () => nextStep(navigation); + // Position globale du chapitre courant (pour l'indicateur "Chapitre X/N"). + const chapterIndex = CHAPTER_IDS.indexOf(activeChapter.id) + 1; + const totalChapters = CHAPTER_IDS.length; + + const handleNext = () => { + // Retour haptique : lourd et marquant à la toute fin du tutoriel, + // léger sur une simple avance (étape ou chapitre suivant). + if (isEndCard) haptics.heavy(); + else haptics.selection(); + nextStep(navigation); + }; + + const handleSkip = () => { + // "Passer" ferme le tutoriel : léger retour haptique de confirmation. + haptics.selection(); + dismiss(); + }; const nextLabel = isEndCard ? 'Terminer le tutoriel ✓' : isLastStep ? 'Chapitre suivant' @@ -190,8 +231,14 @@ export default function TutorialOverlay({ navigation }) { { + const h = e.nativeEvent.layout.height; + // Ne met à jour que sur variation nette (>1px) pour éviter les + // boucles de re-rendu dues aux arrondis sub-pixel. + if (h > 0 && Math.abs(h - tooltipH) > 1) setTooltipH(h); + }} > - + {activeStep.title} {activeStep.body} @@ -206,7 +253,7 @@ export default function TutorialOverlay({ navigation }) { ) : ( - + Passer + Passer le tutoriel )} diff --git a/front/src/context/TutorialContext.js b/front/src/context/TutorialContext.js index 70d7bd8..a6384b4 100644 --- a/front/src/context/TutorialContext.js +++ b/front/src/context/TutorialContext.js @@ -1,6 +1,7 @@ import React, { createContext, useState, useContext, useCallback, useEffect, useRef } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { TUTORIAL_CHAPTERS, CHAPTER_MAP, CHAPTER_IDS } from '../data/tutorialChapters'; +import { completeOnboarding } from '../services/onboarding.service'; const DONE_KEY = 'athly:tutorial:completed:v1'; const PENDING_KEY = 'athly:tutorial:pendingChapter:v1'; @@ -110,6 +111,9 @@ export function TutorialProvider({ children }) { AsyncStorage.setItem(DONE_KEY, 'true'), AsyncStorage.removeItem(PENDING_KEY), ]); + // Persistance backend (best-effort, jamais bloquante) — cohérence + // inter-appareils du flag hasCompletedOnboarding. + completeOnboarding().catch(() => {}); return; } @@ -151,6 +155,18 @@ export function TutorialProvider({ children }) { AsyncStorage.setItem(DONE_KEY, 'true'), AsyncStorage.removeItem(PENDING_KEY), ]); + // "Passer le tutoriel" compte aussi comme terminé côté backend (best-effort). + completeOnboarding().catch(() => {}); + }, []); + + // Réconciliation avec le flag backend (hasCompletedOnboarding) une fois le + // profil chargé : si le serveur dit "déjà fait" alors que ce nouvel appareil + // n'a pas encore le flag local, on marque terminé pour ne pas re-déclencher + // le tutoriel. Ne fait jamais l'inverse (le serveur ne peut pas "ré-ouvrir"). + const reconcileWithServer = useCallback(async (serverDone) => { + if (!serverDone) return; + setHasCompleted(true); + try { await AsyncStorage.setItem(DONE_KEY, 'true'); } catch (_) {} }, []); const clearJustCompleted = useCallback(() => setJustCompleted(false), []); @@ -180,7 +196,7 @@ export function TutorialProvider({ children }) { registerTarget, clearTargets, registerScrollRef, registerRemeasure, scrollToStep, startChapter, nextStep, prevStep, completeActionStep, - dismiss, resetTutorial, + dismiss, resetTutorial, reconcileWithServer, }}> {children} diff --git a/front/src/data/__tests__/tutorialChapters.test.js b/front/src/data/__tests__/tutorialChapters.test.js new file mode 100644 index 0000000..ad8a283 --- /dev/null +++ b/front/src/data/__tests__/tutorialChapters.test.js @@ -0,0 +1,167 @@ +// @ts-check +// Tests de non-régression des données du tutoriel interactif (tutorialChapters.js). +// Ils tournent dans Node.js via Jest + babel-jest (transform ES → CJS) — aucune +// dépendance React Native n'est requise. +// +// Objectif : garantir l'intégrité structurelle qui fait tourner la machine à états +// du TutorialContext (navigation de chapitre en chapitre, spotlights, bouton +// "Terminer" affiché au bon moment). Une régression ici casserait silencieusement +// le tour guidé sans erreur JS. + +const { + TUTORIAL_CHAPTERS, + CHAPTER_MAP, + CHAPTER_IDS, +} = require('../tutorialChapters'); + +const VALID_POSITIONS = ['top', 'bottom', 'center']; + +// ───────────────────────────────────────────────────────────────────────────── +// Structure des chapitres +// ───────────────────────────────────────────────────────────────────────────── +describe('TUTORIAL_CHAPTERS — structure des chapitres', () => { + it('exporte un tableau non vide de chapitres', () => { + expect(Array.isArray(TUTORIAL_CHAPTERS)).toBe(true); + expect(TUTORIAL_CHAPTERS.length).toBeGreaterThan(0); + }); + + it('contient exactement les 8 chapitres attendus, dans l\'ordre', () => { + expect(CHAPTER_IDS).toEqual([ + 'dashboard', + 'workout', + 'profile', + 'trophies', + 'inventory', + 'social', + 'stats', + 'settings', + ]); + }); + + const REQUIRED_CHAPTER_KEYS = ['id', 'title', 'subtitle', 'icon', 'tabName', 'steps']; + + test.each(TUTORIAL_CHAPTERS)( + 'chapitre "$id" — possède toutes les propriétés obligatoires', + (chapter) => { + REQUIRED_CHAPTER_KEYS.forEach((key) => { + expect(chapter).toHaveProperty(key); + expect(chapter[key]).not.toBeUndefined(); + expect(chapter[key]).not.toBeNull(); + }); + }, + ); + + it('tous les IDs de chapitre sont uniques', () => { + const ids = TUTORIAL_CHAPTERS.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('chaque chapitre a au moins une étape', () => { + TUTORIAL_CHAPTERS.forEach((chapter) => { + expect(Array.isArray(chapter.steps)).toBe(true); + expect(chapter.steps.length).toBeGreaterThan(0); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Structure des étapes +// ───────────────────────────────────────────────────────────────────────────── +describe('TUTORIAL_CHAPTERS — structure des étapes', () => { + const allSteps = TUTORIAL_CHAPTERS.flatMap((c) => + c.steps.map((s) => ({ chapterId: c.id, step: s })), + ); + + test.each(allSteps)( + '$chapterId/$step.key — champs texte et position valides', + ({ step }) => { + expect(typeof step.key).toBe('string'); + expect(step.key.length).toBeGreaterThan(0); + expect(typeof step.title).toBe('string'); + expect(step.title.length).toBeGreaterThan(0); + expect(typeof step.body).toBe('string'); + expect(step.body.length).toBeGreaterThan(0); + expect(VALID_POSITIONS).toContain(step.position); + // targetKey est soit null (carte centrée), soit une chaîne non vide. + if (step.targetKey !== null) { + expect(typeof step.targetKey).toBe('string'); + expect(step.targetKey.length).toBeGreaterThan(0); + } + }, + ); + + it('les clés d\'étape sont uniques au sein de chaque chapitre', () => { + TUTORIAL_CHAPTERS.forEach((chapter) => { + const keys = chapter.steps.map((s) => s.key); + expect(new Set(keys).size).toBe(keys.length); + }); + }); + + it('toute étape avec un spotlight (targetKey) l\'associe à une position ancrée', () => { + // Un spotlight n'a de sens qu'ancré en haut/bas de la cible (pas 'center'). + allSteps + .filter(({ step }) => step.targetKey !== null) + .forEach(({ chapterId, step }) => { + expect(['top', 'bottom']).toContain(step.position); + // message d'aide au debug si ça casse + if (!['top', 'bottom'].includes(step.position)) { + throw new Error(`${chapterId}/${step.key} a un targetKey mais position=${step.position}`); + } + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Cohérence du flag isLast (pilote l'affichage du bouton "Terminer") +// ───────────────────────────────────────────────────────────────────────────── +describe('TUTORIAL_CHAPTERS — flag isLast', () => { + it('exactement une étape porte isLast: true dans tout le tutoriel', () => { + const flagged = TUTORIAL_CHAPTERS.flatMap((c) => + c.steps.filter((s) => s.isLast === true), + ); + expect(flagged).toHaveLength(1); + }); + + it('isLast est porté par la toute dernière étape du dernier chapitre', () => { + const lastChapter = TUTORIAL_CHAPTERS[TUTORIAL_CHAPTERS.length - 1]; + const lastStep = lastChapter.steps[lastChapter.steps.length - 1]; + expect(lastStep.isLast).toBe(true); + }); + + it('aucune autre étape que la dernière ne porte isLast', () => { + TUTORIAL_CHAPTERS.forEach((chapter, ci) => { + chapter.steps.forEach((step, si) => { + const isVeryLast = + ci === TUTORIAL_CHAPTERS.length - 1 && si === chapter.steps.length - 1; + if (!isVeryLast) { + expect(step.isLast).not.toBe(true); + } + }); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Maps dérivées (utilisées par TutorialContext pour la navigation d'état) +// ───────────────────────────────────────────────────────────────────────────── +describe('CHAPTER_MAP & CHAPTER_IDS — cohérence dérivée', () => { + it('CHAPTER_IDS correspond à l\'ordre des chapitres', () => { + expect(CHAPTER_IDS).toEqual(TUTORIAL_CHAPTERS.map((c) => c.id)); + }); + + it('CHAPTER_MAP indexe chaque chapitre par son id', () => { + TUTORIAL_CHAPTERS.forEach((chapter) => { + expect(CHAPTER_MAP[chapter.id]).toBe(chapter); + }); + expect(Object.keys(CHAPTER_MAP)).toHaveLength(TUTORIAL_CHAPTERS.length); + }); + + it('chaque chapitre avec stackScreen déclare aussi son tabName', () => { + // La transition inter-chapitres navigue via navigation.navigate(tabName, + // { screen: stackScreen }) — stackScreen sans tabName casserait la navigation. + TUTORIAL_CHAPTERS.filter((c) => c.stackScreen).forEach((c) => { + expect(typeof c.tabName).toBe('string'); + expect(c.tabName.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/front/src/data/tutorialChapters.js b/front/src/data/tutorialChapters.js index b9551e8..66255f8 100644 --- a/front/src/data/tutorialChapters.js +++ b/front/src/data/tutorialChapters.js @@ -1,4 +1,4 @@ -// Définition des 6 chapitres du tutoriel interactif Athly. +// Définition des 8 chapitres du tutoriel interactif Athly. // // Propriétés d'une step : // targetKey – clé d'une cible enregistrée via useTutorialTarget (null = pas de spotlight) @@ -23,7 +23,7 @@ export const TUTORIAL_CHAPTERS = [ { key: 'welcome', title: 'Bienvenue sur Athly !', - body: "Athly transforme chaque séance en progression réelle. Ce guide de 6 chapitres te montrera tout ce que tu peux accomplir.", + body: "Athly transforme chaque séance en progression réelle. Ce guide de 8 chapitres te montrera tout ce que tu peux accomplir.", targetKey: null, position: 'center', scrollY: 0, }, { @@ -139,6 +139,12 @@ export const TUTORIAL_CHAPTERS = [ body: "Ces 3 slots affichent tes trophées préférés. Appuie sur un slot vide pour choisir dans la Salle des Trophées.", targetKey: 'profile_vitrine', position: 'top', scrollY: 260, }, + { + key: 'profile_titles', + title: 'Tes Titres RPG', + body: "Via le bouton 'Titres', équipe un titre affiché sous ton pseudo. 17 titres se débloquent en accomplissant des défis (records, séances Multi, entraide…).", + targetKey: null, position: 'center', scrollY: 0, + }, ], }, @@ -154,7 +160,7 @@ export const TUTORIAL_CHAPTERS = [ { key: 'trophies_intro', title: 'La Salle des Trophées', - body: "40 trophées à débloquer, répartis en 8 catégories : Héritage, Force, Exploration, Secret, Corps, Régularité, Social et Spécial.", + body: "60 trophées à débloquer, répartis en 9 catégories : Héritage, Force, Exploration, Secret, Corps, Régularité, Social, Spécial et Collection.", targetKey: null, position: 'center', scrollY: 0, }, { @@ -166,7 +172,7 @@ export const TUTORIAL_CHAPTERS = [ { key: 'trophies_ultimate', title: 'Le Trophée Ultime', - body: "Le 'Souverain Absolu' se débloque automatiquement quand les 40 autres trophées sont obtenus. Un accomplissement absolu.", + body: "Le 'Souverain Absolu' se débloque automatiquement quand les 60 autres trophées sont obtenus. Un accomplissement absolu.", targetKey: 'trophies_ultimate', position: 'bottom', scrollY: 0, }, { @@ -178,11 +184,95 @@ export const TUTORIAL_CHAPTERS = [ ], }, - // ─── CHAPITRE 5 : Les Statistiques ───────────────────────────────────────── + // ─── CHAPITRE 5 : Inventaire, Coffres & Raretés ──────────────────────────── + { + id: 'inventory', + title: 'Coffres & Raretés', + subtitle: 'Chapitre 5', + icon: 'cube-outline', + tabName: 'ProfileTab', + stackScreen: 'Inventory', + steps: [ + { + key: 'inventory_intro', + title: "L'Inventaire", + body: "Chaque effort te récompense : boissons d'XP, gels de streak, boosts et coupons s'accumulent ici, prêts à être utilisés au bon moment.", + targetKey: null, position: 'center', scrollY: 0, + }, + { + key: 'inventory_chest', + title: 'Coffres à l\'Effort', + body: "Tu gagnes un coffre toutes les 2 h de séance cumulées (débloqué au rang Initié, niveau 11). Ouvre-le pour tirer un objet au hasard.", + targetKey: 'inventory_chest', position: 'bottom', scrollY: 0, + }, + { + key: 'inventory_rarities', + title: '5 Raretés', + body: "Du Commun (gris) au Légendaire (orange), en passant par Rare (bleu) et Épique (violet) : plus c'est rare, plus l'objet est puissant.", + targetKey: null, position: 'center', scrollY: null, + }, + { + key: 'inventory_unique', + title: 'Le Rouge Sang Unique', + body: "La rareté ultime : les cosmétiques Uniques Rouge Sang (cadre, couleur, thème) se méritent par des exploits rares et se réclament ici. Le prestige absolu.", + targetKey: null, position: 'center', scrollY: null, + }, + ], + }, + + // ─── CHAPITRE 6 : Social, Groupes & Multi ────────────────────────────────── + { + id: 'social', + title: 'Amis, Groupes & Multi', + subtitle: 'Chapitre 6', + icon: 'people-outline', + tabName: 'SocialTab', + stackScreen: 'SocialHub', + steps: [ + { + key: 'social_intro', + title: 'Progresse en Meute', + body: "Athly est bien plus fort à plusieurs. Ajoute tes amis, compare-toi, et entraîne-toi en équipe.", + targetKey: null, position: 'center', scrollY: 0, + }, + { + key: 'social_segments', + title: 'Amis, Classement, Groupe', + body: "Ces trois onglets réunissent ton réseau : tes amis, les classements (XP et records par exercice), et ton Groupe de Streak.", + targetKey: 'social_segments', position: 'bottom', scrollY: 0, + }, + { + key: 'social_add', + title: 'Ajouter un Ami', + body: "Chaque joueur a un tag unique façon Discord (ex: Player#1234). Saisis-le pour envoyer une demande, après un aperçu de son profil.", + targetKey: null, position: 'center', scrollY: null, + }, + { + key: 'social_group', + title: 'Groupes de Streak & Météo', + body: "Formez un groupe (5 max) : si TOUS validez votre journée, la streak collective grimpe. La Météo des séances montre en direct qui est Prêt, Actif ou a Validé.", + targetKey: null, position: 'center', scrollY: null, + }, + { + key: 'social_shame', + title: 'Hall of Shame & Secouer', + body: "Si la streak de groupe casse, le dernier Briseur s'affiche dans le Hall of Shame. Le bouton 'Secouer' envoie une notif troll aux retardataires pour les motiver.", + targetKey: null, position: 'center', scrollY: null, + }, + { + key: 'social_multi', + title: 'Le Mode Multi', + body: "Invite des amis dans un Lobby Multi : vous démarrez la séance ensemble, chacun sur son écran. Terminer à plusieurs débloque un bonus d'XP de groupe (+15% à 2, jusqu'à +50% à 5).", + targetKey: null, position: 'center', scrollY: null, + }, + ], + }, + + // ─── CHAPITRE 7 : Les Statistiques ───────────────────────────────────────── { id: 'stats', title: 'Les Statistiques', - subtitle: 'Chapitre 5', + subtitle: 'Chapitre 7', icon: 'stats-chart-outline', tabName: 'Stats', // useMockData = true → StatsScreen injecte les fausses données durant ce chapitre @@ -200,6 +290,12 @@ export const TUTORIAL_CHAPTERS = [ body: "Séances, sets validés et volume total pour la période sélectionnée. Change la période avec les boutons Semaine / Mois / Tout.", targetKey: 'stats_kpis', position: 'bottom', scrollY: 60, }, + { + key: 'stats_weight', + title: 'Suivi de Poids', + body: "Le graphique double courbe suit ton poids réel vers ton objectif. Appuie sur 'Ajouter une pesée' pour l'actualiser et garder un suivi au top.", + targetKey: 'stats_weight_chart', position: 'bottom', scrollY: 60, + }, { key: 'stats_volume', title: 'Graphique de Volume', @@ -222,11 +318,11 @@ export const TUTORIAL_CHAPTERS = [ ], }, - // ─── CHAPITRE 6 : Réglages ────────────────────────────────────────────────── + // ─── CHAPITRE 8 : Réglages ────────────────────────────────────────────────── { id: 'settings', title: 'Réglages & Compte', - subtitle: 'Chapitre 6', + subtitle: 'Chapitre 8', icon: 'settings-outline', tabName: 'ProfileTab', stackScreen: 'Settings', diff --git a/front/src/screens/Home/HomeScreen.js b/front/src/screens/Home/HomeScreen.js index c8aba2c..a5117ff 100644 --- a/front/src/screens/Home/HomeScreen.js +++ b/front/src/screens/Home/HomeScreen.js @@ -13,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons'; import { Colors, MUSCLE_GROUP_COLORS } from '../../constants/theme'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; +import { useUser } from '../../context/UserContext'; import { computeStreak, recommendNextMuscleGroup, @@ -49,9 +50,16 @@ export default function HomeScreen({ navigation }) { const { hasCompleted, bootstrapped, pendingChapterId, startChapter, activeChapterId, activeStep, stepIndex, - registerScrollRef, registerRemeasure, + registerScrollRef, registerRemeasure, reconcileWithServer, } = useTutorial(); + // Réconciliation du flag "tutoriel terminé" avec le backend (cohérence + // inter-appareils) : si le serveur dit "déjà fait", on ne re-déclenche pas. + const { user } = useUser(); + useEffect(() => { + if (user) reconcileWithServer(!!user.hasCompletedOnboarding); + }, [user, reconcileWithServer]); + // Injection de données fantômes pendant le Chapitre 1 pour que le spotlight // puisse pointer les éléments actifs (level chip, hero, stats, quêtes, rituel). const isTutorialDashboard = activeChapterId === 'dashboard'; @@ -98,6 +106,9 @@ export default function HomeScreen({ navigation }) { useFocusEffect( useCallback(() => { if (!bootstrapped) return; + // Garde inter-appareils : ne pas auto-lancer si le backend confirme que + // le tutoriel a déjà été fait (même si le flag local n'est pas encore là). + if (user && user.hasCompletedOnboarding) return; if (!hasCompleted && activeChapterId === null) { const timer = setTimeout(() => startChapter('dashboard'), 600); return () => clearTimeout(timer); @@ -106,7 +117,7 @@ export default function HomeScreen({ navigation }) { const timer = setTimeout(() => startChapter('dashboard'), 400); return () => clearTimeout(timer); } - }, [bootstrapped, hasCompleted, pendingChapterId, activeChapterId, startChapter]), + }, [bootstrapped, hasCompleted, pendingChapterId, activeChapterId, startChapter, user]), ); // ─── Anim d'entrée pour éviter le flash content au 1er chargement ──── diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js index 6001f53..43cc559 100644 --- a/front/src/screens/Profile/InventoryScreen.js +++ b/front/src/screens/Profile/InventoryScreen.js @@ -15,6 +15,8 @@ import { useAvatarFrame } from '../../hooks/useAvatarFrame'; import { useDevSettings } from '../../hooks/useDevSettings'; import ChestOpeningModal from '../../components/inventory/ChestOpeningModal'; import { haptics } from '../../services/haptics.service'; +import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; +import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; const MIN_LEVEL_FOR_CHEST = 11; const RARITY_ORDER = ['unique', 'legendary', 'epic', 'rare', 'common']; @@ -42,8 +44,21 @@ export default function InventoryScreen({ navigation }) { const [busy, setBusy] = useState(false); const [chestModal, setChestModal] = useState({ visible: false, drawnItem: null }); + // ─── Tutorial (chapitre Inventaire) ────────────────────────────────────────── + const { pendingChapterId, activeChapterId, startChapter } = useTutorial(); + const { ref: chestRef, onLayout: onChestLayout } = useTutorialTarget('inventory_chest'); + useFocusEffect(useCallback(() => { refetch(); }, [refetch])); + useFocusEffect( + useCallback(() => { + if (pendingChapterId === 'inventory') { + const t = setTimeout(() => startChapter('inventory'), 400); + return () => clearTimeout(t); + } + }, [pendingChapterId, startChapter]), + ); + const inventory = user?.inventory ?? []; const chestEntry = inventory.find((i) => i.itemType === 'CHEST_KEY'); const chestCount = chestEntry?.quantity ?? 0; @@ -150,12 +165,16 @@ export default function InventoryScreen({ navigation }) { {/* ── Coffres ── */} - + {/* marginTop porté par le wrapper (et non la carte) pour que le + spotlight du tutoriel épouse pile la carte, sans décalage vertical. */} + + + {/* ── Objets ── */} MES OBJETS @@ -187,6 +206,10 @@ export default function InventoryScreen({ navigation }) { drawnItem={chestModal.drawnItem} onClose={closeChestModal} /> + + {activeChapterId === 'inventory' && ( + + )} ); } @@ -338,6 +361,7 @@ const styles = StyleSheet.create({ }, // ── Coffre ── + chestSpot: { marginTop: 8 }, chestCard: { flexDirection: 'row', alignItems: 'center', @@ -346,7 +370,6 @@ const styles = StyleSheet.create({ borderColor: 'rgba(254,116,57,0.30)', borderRadius: 18, padding: 18, - marginTop: 8, }, chestCardLocked: { backgroundColor: 'rgba(255,255,255,0.04)', diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index 4ea224f..4511499 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -1173,7 +1173,7 @@ export default function SettingsScreen({ navigation }) { Rejouer l'intégralité du tutoriel - 5 chapitres · du Dashboard aux Réglages + 8 chapitres · du Dashboard aux Réglages @@ -1192,7 +1192,7 @@ export default function SettingsScreen({ navigation }) { {chapter.title} - {chapter.subtitle} · {chapter.steps.length} étapes + Chapitre {idx + 1} · {chapter.steps.length} étapes diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js index 2a05fef..23289c3 100644 --- a/front/src/screens/Social/SocialScreen.js +++ b/front/src/screens/Social/SocialScreen.js @@ -21,6 +21,8 @@ import { } from '../../services/social.service'; import { MAJOR_EXERCISES } from '../../data/majorExercises'; import ExercisePickerModal from '../../components/social/ExercisePickerModal'; +import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; +import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; // Podium / classements : positions 1-3 affichées en médaille colorée plutôt // qu'en emoji 🥇🥈🥉. @@ -48,6 +50,10 @@ export default function SocialScreen({ navigation }) { const { addBonusXp } = useWorkoutLogs(); const [segment, setSegment] = useState('friends'); + // ─── Tutorial (chapitre Social) ────────────────────────────────────────────── + const { pendingChapterId, activeChapterId, startChapter } = useTutorial(); + const { ref: segmentsRef, onLayout: onSegmentsLayout } = useTutorialTarget('social_segments'); + // ── Données ── const [friends, setFriends] = useState([]); const [pending, setPending] = useState([]); @@ -130,6 +136,15 @@ export default function SocialScreen({ navigation }) { useFocusEffect(useCallback(() => { loadAll(); }, [loadAll])); + useFocusEffect( + useCallback(() => { + if (pendingChapterId === 'social') { + const t = setTimeout(() => startChapter('social'), 400); + return () => clearTimeout(t); + } + }, [pendingChapterId, startChapter]), + ); + const onRefresh = () => { setRefreshing(true); loadAll(); }; // Recherche déclenchée depuis AddFriendModal, avec le tag déjà construit à @@ -173,7 +188,12 @@ export default function SocialScreen({ navigation }) { Social {/* ── Segments ── */} - + {SEGMENTS.map((s) => { const active = segment === s.key; const badge = s.key === 'friends' && pending.length > 0 ? pending.length @@ -335,6 +355,10 @@ export default function SocialScreen({ navigation }) { }} onClose={() => setPreviewResult(null)} /> + + {activeChapterId === 'social' && ( + + )} ); } diff --git a/front/src/screens/Stats/StatsScreen.js b/front/src/screens/Stats/StatsScreen.js index 7ef02db..6c49d54 100644 --- a/front/src/screens/Stats/StatsScreen.js +++ b/front/src/screens/Stats/StatsScreen.js @@ -67,7 +67,12 @@ export default function StatsScreen({ navigation }) { } = useTutorial(); const scrollRef = useRef(null); + // Offset de scroll courant, suivi en direct pour un défilement piloté par la + // position RÉELLE des cibles (robuste aux changements de mise en page comme + // l'ajout du graphique de poids, qui décalait les anciens scrollY fixes). + const scrollOffsetRef = useRef(0); const { ref: kpisRef, onLayout: onKpisLayout, remeasure: rKpis } = useTutorialTarget('stats_kpis'); + const { ref: weightRef, onLayout: onWeightLayout, remeasure: rWeight } = useTutorialTarget('stats_weight_chart'); const { ref: volumeRef, onLayout: onVolumeLayout, remeasure: rVolume } = useTutorialTarget('stats_volume_chart'); const { ref: muscleRef, onLayout: onMuscleLayout, remeasure: rMuscle } = useTutorialTarget('stats_muscle_chart'); const { ref: tabHistoryRef, onLayout: onTabHistoryLayout } = useTutorialTarget('stats_tab_history'); @@ -76,9 +81,24 @@ export default function StatsScreen({ navigation }) { useEffect(() => { registerScrollRef('stats', scrollRef); registerRemeasure('stats', () => { - setTimeout(() => { rKpis(); rVolume(); rMuscle(); }, 50); + setTimeout(() => { rKpis(); rWeight(); rVolume(); rMuscle(); }, 50); }); - }, [registerScrollRef, registerRemeasure, rKpis, rVolume, rMuscle]); + }, [registerScrollRef, registerRemeasure, rKpis, rWeight, rVolume, rMuscle]); + + // Fait défiler pour amener une cible mesurée à une position confortable : + // haut de cible vers ~160 px (tooltip en-dessous) ou ~300 px (tooltip + // au-dessus). On mesure en absolu (pageY) et on combine avec l'offset courant, + // donc aucun nombre magique ne dépend de la hauteur des sections au-dessus. + const scrollTargetIntoView = useCallback((targetRef, prefersAbove, remeasureAll) => { + if (!targetRef?.current || !scrollRef.current) return; + const desiredTop = prefersAbove ? 300 : 160; + targetRef.current.measure((_x, _y, _w, _h, _pageX, pageY) => { + if (pageY == null) return; + const newY = Math.max(0, scrollOffsetRef.current + (pageY - desiredTop)); + scrollRef.current.scrollTo({ y: newY, animated: true }); + if (remeasureAll) setTimeout(remeasureAll, 350); + }); + }, []); // Démarrage du chapitre quand l'écran gagne le focus. // On force d'abord l'onglet Performance pour éviter que l'utilisateur, @@ -93,13 +113,33 @@ export default function StatsScreen({ navigation }) { }, [pendingChapterId, startChapter]), ); - // Auto-scroll + autoActions quand l'étape change + // Auto-scroll + autoActions quand l'étape change. + // Défilement piloté par la cible (robuste) pour les sections défilables ; + // pour la cible d'onglet Historique (barre de nav haute) on remonte en tête. useEffect(() => { if (activeChapterId !== 'stats' || !activeStep) return; - const y = activeStep.scrollY; - if (y != null && scrollRef.current) { - scrollRef.current.scrollTo({ y, animated: true }); - setTimeout(() => { rKpis(); rVolume(); rMuscle(); }, 350); + + const remeasureAll = () => { rKpis(); rWeight(); rVolume(); rMuscle(); }; + const REF_BY_KEY = { + stats_kpis: kpisRef, + stats_weight_chart: weightRef, + stats_volume_chart: volumeRef, + stats_muscle_chart: muscleRef, + }; + const targetRef = activeStep.targetKey ? REF_BY_KEY[activeStep.targetKey] : null; + + if (targetRef) { + // Laisse le rendu se stabiliser puis amène la cible à bonne hauteur. + const t = setTimeout( + () => scrollTargetIntoView(targetRef, activeStep.position === 'top', remeasureAll), + 80, + ); + return () => clearTimeout(t); + } + // Cibles hors flux défilable (onglet) ou cartes centrées : scroll fixe. + if (activeStep.scrollY != null && scrollRef.current) { + scrollRef.current.scrollTo({ y: activeStep.scrollY, animated: true }); + setTimeout(remeasureAll, 350); } if (activeStep.autoAction === 'switchToHistory') { const t = setTimeout(() => setTab('history'), 300); @@ -159,6 +199,8 @@ export default function StatsScreen({ navigation }) { style={styles.scroll} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false} + scrollEventThrottle={16} + onScroll={(e) => { scrollOffsetRef.current = e.nativeEvent.contentOffset.y; }} > Statistiques @@ -194,17 +236,19 @@ export default function StatsScreen({ navigation }) { - - - setWeightEntryVisible(true)} - activeOpacity={0.85} - > - - Ajouter une pesée - - + + + + setWeightEntryVisible(true)} + activeOpacity={0.85} + > + + Ajouter une pesée + + + diff --git a/front/src/services/onboarding.service.js b/front/src/services/onboarding.service.js new file mode 100644 index 0000000..4059778 --- /dev/null +++ b/front/src/services/onboarding.service.js @@ -0,0 +1,10 @@ +import API from '../api/api'; + +// ─── Onboarding / Tutoriel interactif ────────────────────────────────────────── +// Marque le tutoriel terminé côté backend — best-effort : la vérité immédiate +// reste AsyncStorage (voir TutorialContext.js), ce flag serveur ne sert que la +// cohérence inter-appareils. Ne jamais bloquer l'UI sur cet appel. +export async function completeOnboarding() { + const res = await API.post('/users/me/complete-onboarding'); + return res.data; +} From 8c5f2a7706a6bf72b785328904fa6861070be9a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Fri, 10 Jul 2026 16:57:19 +0200 Subject: [PATCH 2/2] fix(sync): restauration de la logique de synchronisation XP perdue lors du merge --- back/controllers/user.controller.js | 18 +++++ back/routes/user.routes.js | 4 ++ back/services/user.service.js | 54 ++++++++++++++ back/tests/user.test.js | 105 ++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+) diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index c8a7a8a..56de9b7 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -142,4 +142,22 @@ exports.completeOnboarding = async (req, res, next) => { } catch (error) { next(error); } +}; + +/** + * SYNCHRONISER L'XP LOCALE + * Pousse l'XP totale accumulée localement (front, AsyncStorage-first) vers + * user.xp/level backend — Source de Vérité pour tout ce qui est gated côté + * serveur (coffres niveau 11+, conditions de titres). Voir user.service.js + * → syncXp pour le détail du ratchet anti-régression et du recalcul de niveau. + */ +exports.syncXp = async (req, res, next) => { + try { + const { xp } = req.body; + const result = await userService.syncXp(req.user.id, xp); + + res.status(200).json({ success: true, ...result }); + } catch (error) { + next(error); + } }; \ No newline at end of file diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js index 073981b..582df12 100644 --- a/back/routes/user.routes.js +++ b/back/routes/user.routes.js @@ -31,6 +31,10 @@ router.put("/me/push-token", auth, validate(registerPushToken), userController.r // Marquer le tutoriel/onboarding comme terminé — pas de body (idempotent) router.post("/me/complete-onboarding", auth, userController.completeOnboarding); +// Synchronise l'XP totale locale (front) vers le backend — Source de Vérité +// pour le gating serveur (coffres niveau 11+, conditions de titres). +router.post("/me/sync-xp", auth, validate(syncXp), userController.syncXp); + // Suppression définitive du compte (RGPD) router.delete("/delete-account", auth, userController.deleteAccount); diff --git a/back/services/user.service.js b/back/services/user.service.js index a947dcc..48f09cf 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -172,6 +172,60 @@ class UserService { return updatedUser; } + /** + * Synchronise l'XP totale calculée localement (front, AsyncStorage-first) + * vers user.xp/level backend — Source de Vérité pour tout ce qui est gated + * côté serveur (coffres niveau 11+, conditions de titres comme PERFORM_LEVEL_50). + * + * Sans ce point de synchro explicite, le niveau backend dérive silencieusement + * de celui vécu par le joueur : `finalizeWorkout` calcule sa PROPRE xp + * (anti-cheat serveur, à partir des exercices soumis) plutôt que d'adopter + * l'xp locale — les deux ledgers divergent avec le temps. + * + * Sécurité : XP en lecture seule ratchet (ne redescend jamais) — un sync + * redondant ou tardif ne peut jamais effacer une progression déjà actée. + * Bornée à xpForLevel(MAX_LEVEL) pour rejeter tout payload absurde (la + * validation Joi en amont ne fait qu'un garde-fou grossier). + * + * @param {string} userId + * @param {number} submittedXp — XP cumulée locale (jamais un delta) + * @returns {{ level, xp, rank, newlyUnlockedTitles }} + */ + async syncXp(userId, submittedXp) { + const { levelFromXP, getRankForLevel, xpForLevel, MAX_LEVEL } = require('../utils/levelHelpers'); + + const user = await User.findById(userId).select('xp level rank'); + if (!user) throw new Error('Utilisateur non trouvé.'); + + const clampedXp = Math.max(0, Math.min(submittedXp, xpForLevel(MAX_LEVEL))); + const nextXp = Math.max(user.xp || 0, clampedXp); + + let newlyUnlockedTitles = []; + + if (nextXp !== user.xp) { + user.xp = nextXp; + user.level = levelFromXP(nextXp); + user.rank = getRankForLevel(user.level); + await user.save(); + + // Require tardif : évite le cycle user.service ↔ title.controller au + // chargement des modules (voir même idiome dans reward.controller.js). + try { + const { checkAndUnlockTitles } = require('../controllers/title.controller'); + newlyUnlockedTitles = await checkAndUnlockTitles(userId); + } catch (_) { + // best-effort — la synchro XP elle-même ne doit jamais échouer pour ça. + } + } + + return { + level: user.level, + xp: user.xp, + rank: user.rank, + newlyUnlockedTitles, + }; + } + async addExperience(userId, xpAmount) { const user = await User.findById(userId); user.xp += xpAmount; diff --git a/back/tests/user.test.js b/back/tests/user.test.js index 209ce01..fa24284 100644 --- a/back/tests/user.test.js +++ b/back/tests/user.test.js @@ -129,4 +129,109 @@ describe('User API (Routes Protégées)', () => { expect(res.statusCode).toBe(401); }); }); + + describe('POST /api/users/me/sync-xp — syncXp (Section X)', () => { + const { xpForLevel } = require('../utils/levelHelpers'); + + beforeEach(async () => { + await User.updateOne( + { email: 'user@test.fr' }, + { $set: { xp: 0, level: 0, rank: 'Novice', unlockedTitles: [] } }, + ); + }); + + it('✅ Recalcule level/rank côté serveur à partir de xp', async () => { + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(12) }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(12); + expect(res.body.rank).toBe('Initié'); + expect(res.body.xp).toBe(xpForLevel(12)); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.level).toBe(12); + expect(user.xp).toBe(xpForLevel(12)); + }); + + it("✅ Ratchet : un xp inférieur à l'existant ne fait jamais redescendre le niveau", async () => { + await User.updateOne({ email: 'user@test.fr' }, { $set: { xp: xpForLevel(20), level: 20, rank: 'Initié' } }); + + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(5) }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(20); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.level).toBe(20); + }); + + it('✅ Idempotent : renvoyer la même valeur ne déclenche pas de re-déblocage', async () => { + await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + const second = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + expect(second.body.newlyUnlockedTitles).not.toContain('PERFORM_LEVEL_10'); + }); + + it('✅ Débloque les titres dont la condition de niveau est franchie', async () => { + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + expect(res.body.newlyUnlockedTitles).toContain('PERFORM_LEVEL_10'); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.unlockedTitles).toContain('PERFORM_LEVEL_10'); + }); + + it('✅ Clampe un xp aberrant (au-delà du niveau 200) sans planter', async () => { + // En dessous du plafond Joi (garde-fou grossier, 100M) mais bien + // au-delà de xpForLevel(200) (~1.72M) — exerce le clamp du service. + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: 50000000 }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(200); + }); + + it('❌ 400 si xp négatif, non numérique ou manquant', async () => { + const negative = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: -5 }); + expect(negative.statusCode).toBe(400); + + const invalid = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: 'abc' }); + expect(invalid.statusCode).toBe(400); + + const missing = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({}); + expect(missing.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/users/me/sync-xp').send({ xp: 100 }); + expect(res.statusCode).toBe(401); + }); + }); }); \ No newline at end of file