From 8c90b73d90c3dceaf856f27c8e7ce25f1aff0692 Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Tue, 21 Apr 2026 21:51:11 +0200 Subject: [PATCH] update config, admin-web tooling & relocate agent skills Update app.json config and add new dependencies in package.json. Update .gitignore for new patterns. Add timed-exercise editor/list components, warmup/stretch video migration, and Supabase helpers in admin-web. Relocate agent skills from .agents/skills/ to .opencode/skills/. --- .../expo-cicd-workflows/scripts/package.json | 11 - .gitignore | 1 + .../skills/building-native-ui/SKILL.md | 0 .../references/animations.md | 0 .../building-native-ui/references/controls.md | 0 .../references/form-sheet.md | 0 .../references/gradients.md | 0 .../building-native-ui/references/icons.md | 0 .../building-native-ui/references/media.md | 0 .../references/route-structure.md | 0 .../building-native-ui/references/search.md | 0 .../building-native-ui/references/storage.md | 0 .../building-native-ui/references/tabs.md | 0 .../references/toolbar-and-headers.md | 0 .../references/visual-effects.md | 0 .../references/webgpu-three.md | 0 .../references/zoom-transitions.md | 0 .../skills/expo-api-routes/SKILL.md | 0 .../skills/expo-cicd-workflows/SKILL.md | 0 .../expo-cicd-workflows/scripts/fetch.js | 0 .../expo-cicd-workflows/scripts/validate.js | 0 .../skills/expo-deployment/SKILL.md | 0 .../references/app-store-metadata.md | 0 .../references/ios-app-store.md | 0 .../expo-deployment/references/play-store.md | 0 .../expo-deployment/references/testflight.md | 0 .../expo-deployment/references/workflows.md | 0 .../skills/expo-dev-client/SKILL.md | 0 .../skills/expo-tailwind-setup/SKILL.md | 0 .../skills/native-data-fetching/SKILL.md | 0 .../references/expo-router-loaders.md | 0 .../tabatago-production-tracker/SKILL.md | 355 ++++++++++++++++++ admin-web/app/programs/[id]/edit/page.tsx | 15 +- admin-web/components/program-form.tsx | 154 +++++++- admin-web/components/tabata-editor.tsx | 15 + .../components/timed-exercise-editor.tsx | 177 +++++++++ admin-web/components/timed-exercise-list.tsx | 90 +++++ admin-web/lib/supabase.ts | 56 +++ .../migrations/006_warmup_stretch_video.sql | 71 ++++ app.json | 31 +- package-lock.json | 22 ++ package.json | 2 + 42 files changed, 980 insertions(+), 20 deletions(-) delete mode 100644 .agents/skills/expo-cicd-workflows/scripts/package.json rename {.agents => .opencode}/skills/building-native-ui/SKILL.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/animations.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/controls.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/form-sheet.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/gradients.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/icons.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/media.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/route-structure.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/search.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/storage.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/tabs.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/toolbar-and-headers.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/visual-effects.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/webgpu-three.md (100%) rename {.agents => .opencode}/skills/building-native-ui/references/zoom-transitions.md (100%) rename {.agents => .opencode}/skills/expo-api-routes/SKILL.md (100%) rename {.agents => .opencode}/skills/expo-cicd-workflows/SKILL.md (100%) rename {.agents => .opencode}/skills/expo-cicd-workflows/scripts/fetch.js (100%) rename {.agents => .opencode}/skills/expo-cicd-workflows/scripts/validate.js (100%) rename {.agents => .opencode}/skills/expo-deployment/SKILL.md (100%) rename {.agents => .opencode}/skills/expo-deployment/references/app-store-metadata.md (100%) rename {.agents => .opencode}/skills/expo-deployment/references/ios-app-store.md (100%) rename {.agents => .opencode}/skills/expo-deployment/references/play-store.md (100%) rename {.agents => .opencode}/skills/expo-deployment/references/testflight.md (100%) rename {.agents => .opencode}/skills/expo-deployment/references/workflows.md (100%) rename {.agents => .opencode}/skills/expo-dev-client/SKILL.md (100%) rename {.agents => .opencode}/skills/expo-tailwind-setup/SKILL.md (100%) rename {.agents => .opencode}/skills/native-data-fetching/SKILL.md (100%) rename {.agents => .opencode}/skills/native-data-fetching/references/expo-router-loaders.md (100%) create mode 100644 .opencode/skills/tabatago-production-tracker/SKILL.md create mode 100644 admin-web/components/timed-exercise-editor.tsx create mode 100644 admin-web/components/timed-exercise-list.tsx create mode 100644 admin-web/migrations/006_warmup_stretch_video.sql diff --git a/.agents/skills/expo-cicd-workflows/scripts/package.json b/.agents/skills/expo-cicd-workflows/scripts/package.json deleted file mode 100644 index a3bd716..0000000 --- a/.agents/skills/expo-cicd-workflows/scripts/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@expo/cicd-workflows-skill", - "version": "0.0.0", - "private": true, - "type": "module", - "dependencies": { - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "js-yaml": "^4.1.0" - } -} diff --git a/.gitignore b/.gitignore index 842ea2a..505ae91 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ coverage/ # Node compile cache node-compile-cache/ .gitnexus +Config/Secrets.xcconfig diff --git a/.agents/skills/building-native-ui/SKILL.md b/.opencode/skills/building-native-ui/SKILL.md similarity index 100% rename from .agents/skills/building-native-ui/SKILL.md rename to .opencode/skills/building-native-ui/SKILL.md diff --git a/.agents/skills/building-native-ui/references/animations.md b/.opencode/skills/building-native-ui/references/animations.md similarity index 100% rename from .agents/skills/building-native-ui/references/animations.md rename to .opencode/skills/building-native-ui/references/animations.md diff --git a/.agents/skills/building-native-ui/references/controls.md b/.opencode/skills/building-native-ui/references/controls.md similarity index 100% rename from .agents/skills/building-native-ui/references/controls.md rename to .opencode/skills/building-native-ui/references/controls.md diff --git a/.agents/skills/building-native-ui/references/form-sheet.md b/.opencode/skills/building-native-ui/references/form-sheet.md similarity index 100% rename from .agents/skills/building-native-ui/references/form-sheet.md rename to .opencode/skills/building-native-ui/references/form-sheet.md diff --git a/.agents/skills/building-native-ui/references/gradients.md b/.opencode/skills/building-native-ui/references/gradients.md similarity index 100% rename from .agents/skills/building-native-ui/references/gradients.md rename to .opencode/skills/building-native-ui/references/gradients.md diff --git a/.agents/skills/building-native-ui/references/icons.md b/.opencode/skills/building-native-ui/references/icons.md similarity index 100% rename from .agents/skills/building-native-ui/references/icons.md rename to .opencode/skills/building-native-ui/references/icons.md diff --git a/.agents/skills/building-native-ui/references/media.md b/.opencode/skills/building-native-ui/references/media.md similarity index 100% rename from .agents/skills/building-native-ui/references/media.md rename to .opencode/skills/building-native-ui/references/media.md diff --git a/.agents/skills/building-native-ui/references/route-structure.md b/.opencode/skills/building-native-ui/references/route-structure.md similarity index 100% rename from .agents/skills/building-native-ui/references/route-structure.md rename to .opencode/skills/building-native-ui/references/route-structure.md diff --git a/.agents/skills/building-native-ui/references/search.md b/.opencode/skills/building-native-ui/references/search.md similarity index 100% rename from .agents/skills/building-native-ui/references/search.md rename to .opencode/skills/building-native-ui/references/search.md diff --git a/.agents/skills/building-native-ui/references/storage.md b/.opencode/skills/building-native-ui/references/storage.md similarity index 100% rename from .agents/skills/building-native-ui/references/storage.md rename to .opencode/skills/building-native-ui/references/storage.md diff --git a/.agents/skills/building-native-ui/references/tabs.md b/.opencode/skills/building-native-ui/references/tabs.md similarity index 100% rename from .agents/skills/building-native-ui/references/tabs.md rename to .opencode/skills/building-native-ui/references/tabs.md diff --git a/.agents/skills/building-native-ui/references/toolbar-and-headers.md b/.opencode/skills/building-native-ui/references/toolbar-and-headers.md similarity index 100% rename from .agents/skills/building-native-ui/references/toolbar-and-headers.md rename to .opencode/skills/building-native-ui/references/toolbar-and-headers.md diff --git a/.agents/skills/building-native-ui/references/visual-effects.md b/.opencode/skills/building-native-ui/references/visual-effects.md similarity index 100% rename from .agents/skills/building-native-ui/references/visual-effects.md rename to .opencode/skills/building-native-ui/references/visual-effects.md diff --git a/.agents/skills/building-native-ui/references/webgpu-three.md b/.opencode/skills/building-native-ui/references/webgpu-three.md similarity index 100% rename from .agents/skills/building-native-ui/references/webgpu-three.md rename to .opencode/skills/building-native-ui/references/webgpu-three.md diff --git a/.agents/skills/building-native-ui/references/zoom-transitions.md b/.opencode/skills/building-native-ui/references/zoom-transitions.md similarity index 100% rename from .agents/skills/building-native-ui/references/zoom-transitions.md rename to .opencode/skills/building-native-ui/references/zoom-transitions.md diff --git a/.agents/skills/expo-api-routes/SKILL.md b/.opencode/skills/expo-api-routes/SKILL.md similarity index 100% rename from .agents/skills/expo-api-routes/SKILL.md rename to .opencode/skills/expo-api-routes/SKILL.md diff --git a/.agents/skills/expo-cicd-workflows/SKILL.md b/.opencode/skills/expo-cicd-workflows/SKILL.md similarity index 100% rename from .agents/skills/expo-cicd-workflows/SKILL.md rename to .opencode/skills/expo-cicd-workflows/SKILL.md diff --git a/.agents/skills/expo-cicd-workflows/scripts/fetch.js b/.opencode/skills/expo-cicd-workflows/scripts/fetch.js similarity index 100% rename from .agents/skills/expo-cicd-workflows/scripts/fetch.js rename to .opencode/skills/expo-cicd-workflows/scripts/fetch.js diff --git a/.agents/skills/expo-cicd-workflows/scripts/validate.js b/.opencode/skills/expo-cicd-workflows/scripts/validate.js similarity index 100% rename from .agents/skills/expo-cicd-workflows/scripts/validate.js rename to .opencode/skills/expo-cicd-workflows/scripts/validate.js diff --git a/.agents/skills/expo-deployment/SKILL.md b/.opencode/skills/expo-deployment/SKILL.md similarity index 100% rename from .agents/skills/expo-deployment/SKILL.md rename to .opencode/skills/expo-deployment/SKILL.md diff --git a/.agents/skills/expo-deployment/references/app-store-metadata.md b/.opencode/skills/expo-deployment/references/app-store-metadata.md similarity index 100% rename from .agents/skills/expo-deployment/references/app-store-metadata.md rename to .opencode/skills/expo-deployment/references/app-store-metadata.md diff --git a/.agents/skills/expo-deployment/references/ios-app-store.md b/.opencode/skills/expo-deployment/references/ios-app-store.md similarity index 100% rename from .agents/skills/expo-deployment/references/ios-app-store.md rename to .opencode/skills/expo-deployment/references/ios-app-store.md diff --git a/.agents/skills/expo-deployment/references/play-store.md b/.opencode/skills/expo-deployment/references/play-store.md similarity index 100% rename from .agents/skills/expo-deployment/references/play-store.md rename to .opencode/skills/expo-deployment/references/play-store.md diff --git a/.agents/skills/expo-deployment/references/testflight.md b/.opencode/skills/expo-deployment/references/testflight.md similarity index 100% rename from .agents/skills/expo-deployment/references/testflight.md rename to .opencode/skills/expo-deployment/references/testflight.md diff --git a/.agents/skills/expo-deployment/references/workflows.md b/.opencode/skills/expo-deployment/references/workflows.md similarity index 100% rename from .agents/skills/expo-deployment/references/workflows.md rename to .opencode/skills/expo-deployment/references/workflows.md diff --git a/.agents/skills/expo-dev-client/SKILL.md b/.opencode/skills/expo-dev-client/SKILL.md similarity index 100% rename from .agents/skills/expo-dev-client/SKILL.md rename to .opencode/skills/expo-dev-client/SKILL.md diff --git a/.agents/skills/expo-tailwind-setup/SKILL.md b/.opencode/skills/expo-tailwind-setup/SKILL.md similarity index 100% rename from .agents/skills/expo-tailwind-setup/SKILL.md rename to .opencode/skills/expo-tailwind-setup/SKILL.md diff --git a/.agents/skills/native-data-fetching/SKILL.md b/.opencode/skills/native-data-fetching/SKILL.md similarity index 100% rename from .agents/skills/native-data-fetching/SKILL.md rename to .opencode/skills/native-data-fetching/SKILL.md diff --git a/.agents/skills/native-data-fetching/references/expo-router-loaders.md b/.opencode/skills/native-data-fetching/references/expo-router-loaders.md similarity index 100% rename from .agents/skills/native-data-fetching/references/expo-router-loaders.md rename to .opencode/skills/native-data-fetching/references/expo-router-loaders.md diff --git a/.opencode/skills/tabatago-production-tracker/SKILL.md b/.opencode/skills/tabatago-production-tracker/SKILL.md new file mode 100644 index 0000000..9331211 --- /dev/null +++ b/.opencode/skills/tabatago-production-tracker/SKILL.md @@ -0,0 +1,355 @@ +--- +name: tabatago-production-tracker +description: > + Inventaire complet des features et tracker de production pour l'application TabataGo. + Utilise ce skill pour savoir si l'app est prête pour la production, connaître le statut + de chaque feature, créer des tickets, mettre à jour la progression, ou générer un rapport + de production-readiness. Déclenche ce skill dès que l'utilisateur mentionne : "prêt pour + la prod", "statut des features", "qu'est-ce qu'il reste à faire", "production checklist", + "feature inventory", "roadmap", ou demande si une feature spécifique de TabataGo est + implementée. Ce skill est la source de vérité pour savoir ce qui est fait, ce qui est + en cours, et ce qui bloque la mise en production. +--- + +# TabataGo — Feature Inventory & Production Readiness + +## Comment utiliser ce skill + +1. **Rapport de statut** → Lire la section FEATURE INVENTORY et afficher un tableau de bord visuel +2. **Mettre à jour une feature** → Modifier son statut dans FEATURE INVENTORY ci-dessous +3. **Savoir si on est prêt pour la prod** → Lire la section PRODUCTION GATE CONDITIONS +4. **Ajouter une feature** → Ajouter une entrée dans la bonne épopée + +Statuts possibles : +- `[ ]` — À faire +- `[~]` — En cours +- `[x]` — Fait et testé +- `[!]` — Bloquant / Problème + +--- + +## FEATURE INVENTORY + +### ÉPOPÉE 1 — FUNNEL D'ACQUISITION (Onboarding) + +> **Note :** L'onboarding est implémenté comme un funnel 6 écrans dans `app/onboarding.tsx` (1342 lignes) : +> ProblemScreen → EmpathyScreen → SolutionScreen → WowScreen → PersonalizationScreen → PaywallScreen. +> Les US-01 à US-04 sont mappées sur ces 6 écrans. + +#### US-01 · Écran Problème (Pain Point) +**Story :** En tant que nouvel utilisateur, je vois en premier l'écran qui met en évidence mon problème (manque de temps, pas de salle, manque de motivation) afin de me sentir compris avant qu'on me propose une solution. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-001 | Écran hero avec headline "Le sport sans excuses" | `[x]` | P0 | `ProblemScreen` avec animations clock + text | +| F-002 | 3 pain points illustrés (pas de temps / pas d'équipement / pas de motivation) | `[x]` | P0 | `EmpathyScreen` — barriers grid avec icônes | +| F-003 | CTA "Commencer" → navigation vers choix raison | `[x]` | P0 | `onNext()` entre chaque step | +| F-004 | Animation d'entrée (FadeIn + SlideUp) | `[x]` | P1 | Spring + timing animations avec Animated API | + +#### US-02 · Écran Raison (Why Screen) +**Story :** En tant que nouvel utilisateur, je sélectionne ma raison principale de ne pas faire de sport afin que l'app me propose un parcours personnalisé. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-005 | Sélection parmi : Pas le temps / Ne sais pas comment / Pas d'équipement / Manque de motivation | `[x]` | P0 | `EmpathyScreen` — 4 barrier options avec multi-select (max 2) | +| F-006 | Chaque option avec icône + label + sous-titre court | `[x]` | P0 | `barrierCard` avec icône + i18n labels | +| F-007 | Enregistrement du choix dans le profil local | `[x]` | P0 | Via `useUserStore` + `completeOnboarding({barriers})` | +| F-008 | Progression (barre step 1/4) | `[x]` | P1 | TOTAL_STEPS = 6, step indicator intégré | + +#### US-03 · Écran Solution (Tabata Pitch) +**Story :** En tant qu'utilisateur ayant identifié son problème, je découvre comment Tabata résout précisément MON problème afin d'être convaincu de continuer. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-009 | Affichage dynamique selon la raison choisie (US-02) | `[x]` | P0 | `SolutionScreen` — contexte basé sur barriers | +| F-010 | Explication Tabata : 20s effort / 10s repos / 8 rounds / 4 min | `[x]` | P0 | Dans `SolutionScreen` | +| F-011 | 3 avantages clés avec icônes (scientifiquement prouvé, sans matériel, court) | `[x]` | P0 | Avec icônes SF Symbols | +| F-012 | CTA "Je veux essayer" → navigation vers config profil | `[x]` | P0 | `onNext()` → PersonalizationScreen | +| F-013 | Animation des bénéfices (StaggerList) | `[x]` | P2 | `WowScreen` — stagger animation rows + CTA fadeIn | + +#### US-04 · Configuration Profil +**Story :** En tant que nouvel utilisateur convaincu, je renseigne mon prénom et mon objectif afin que l'app me personnalise l'expérience. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-014 | Champ prénom (TextField) avec validation non-vide | `[x]` | P0 | `PersonalizationScreen` — TextInput | +| F-015 | Choix nombre de séances/semaine (1 à 6, picker ou slider) | `[x]` | P0 | `weeklyFrequency` picker | +| F-016 | Choix objectif : Forme générale / Cardio / Énergie / Perte de poids / Renforcement | `[x]` | P0 | `fitnessGoal` selector | +| F-017 | Enregistrement en AsyncStorage / profil local | `[x]` | P0 | Via Zustand `userStore` persist middleware | +| F-018 | Validation et navigation vers Paywall | `[x]` | P0 | Navigates to `PaywallScreen` (step 6) then `router.push('/paywall')` | + +--- + +### ÉPOPÉE 2 — PAYWALL & MONÉTISATION + +#### US-05 · Paywall +**Story :** En tant qu'utilisateur ayant complété le funnel, je vois une offre claire et honnête avant d'accéder au contenu complet, afin de prendre une décision éclairée. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-019 | Écran paywall avec structure obligatoire : célébration → valeur → pricing → CTA | `[x]` | P0 | `app/paywall.tsx` (442 lignes) — PREMIUM_FEATURES list + PlanCard + CTA | +| F-020 | Intégration RevenueCat (iOS App Store + Google Play) | `[x]` | P0 | `react-native-purchases` + `usePurchases` hook + `purchases.ts` service | +| F-021 | Prix dynamique selon la localisation (géolocalisation ou store) | `[x]` | P0 | RevenueCat gère automatiquement via store pricing | +| F-022 | Offre annuelle (prix mensuel affiché) + option mensuelle | `[x]` | P0 | `hasAnnual: true, hasMonthly: true` — PlanCard component | +| F-023 | Essai gratuit 7 jours (configurable RevenueCat) | `[x]` | P0 | Trial support intégré dans paywall | +| F-024 | CTA "Démarrer l'essai gratuit" (PrimaryButton full-width) | `[x]` | P0 | NativeButton CTA | +| F-025 | Bouton fermeture toujours visible (accès mode free) | `[x]` | P0 | `closeButton` positionné avec `top: insets.top` | +| F-026 | Lien "Restaurer les achats" | `[x]` | P0 | `handleRestore` → `restorePurchases()` | +| F-027 | Lien CGU + Politique de confidentialité | `[x]` | P0 | Privacy + Terms links dans paywall, `app/terms.tsx` créé | +| F-028 | Gestion état Premium (hook usePremium) | `[x]` | P0 | `usePurchases` hook avec state management | +| F-029 | Zéro dark pattern (pas de compte à rebours fictif) | `[x]` | P0 | Aucun countdown/timer/urgency détecté | + +--- + +### ÉPOPÉE 3 — ONBOARDING CONTENU (post-paywall) + +#### US-06 · Découverte des 3 Sections +**Story :** En tant que nouvel abonné, je découvre les 3 grandes sections de l'app afin de comprendre comment le contenu est organisé. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-030 | Écran onboarding 3 sections avec swipe/pagination | `[x]` | P0 | `app/discovery.tsx` — 3 SectionCards animées | +| F-031 | Section "Haut du corps" — illustration + description | `[x]` | P0 | Discovery screen — Upper Body avec icône + description | +| F-032 | Section "Bas du corps" — illustration + description | `[x]` | P0 | Discovery screen — Lower Body avec icône + description | +| F-033 | Section "Corps complet" — illustration + description | `[x]` | P0 | Discovery screen — Full Body avec icône + description | +| F-034 | CTA "Explorer les programmes" → Tab Programmes | `[x]` | P0 | CTA → `router.replace('/(tabs)')` | + +--- + +### ÉPOPÉE 4 — PROGRAMMES & CATALOGUE + +#### US-07 · Liste des Programmes +**Story :** En tant qu'utilisateur connecté, je consulte le catalogue de programmes organisé par section et niveau afin de choisir celui qui correspond à mon niveau. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-035 | Écran Programmes avec onglets : Haut / Bas / Corps complet | `[~]` | P0 | Home tab a `BodyZoneCard` — pas d'onglets dédiés mais navigation par zone | +| F-036 | CardProgram pour chaque programme (thumbnail, nom, level, durée, nb séances) | `[x]` | P0 | WorkoutCard + programme cards dans home | +| F-037 | Filtre par niveau : Débutant / Intermédiaire / Avancé | `[ ]` | P0 | **MANQUANT** — Pas de filtres niveau | +| F-038 | Progression visible sur chaque card (X/12 séances) | `[x]` | P0 | Progress tracking dans `programStore` + `workoutProgramStore` | +| F-039 | Programme "recommandé" selon objectif choisi au profil | `[ ]` | P1 | Pas implémenté | +| F-040 | Lock icon sur contenu premium (mode free) | `[x]` | P0 | `sessionLocked` dans `program/[id].tsx` | + +#### US-08 · Détail d'un Programme +**Story :** En tant qu'utilisateur, je consulte le détail d'un programme afin de savoir ce qui m'attend avant de commencer. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-041 | Écran détail : header image, nom, niveau, durée totale | `[x]` | P0 | `app/program/[id].tsx` (261 lignes) | +| F-042 | Description du programme et objectifs | `[x]` | P0 | Programme detail avec description | +| F-043 | Liste ordonnée des séances avec statut (fait / à venir) | `[x]` | P0 | `week.sessions.map` avec `isCompleted` check | +| F-044 | CTA "Commencer la prochaine séance" | `[x]` | P0 | `getCurrentSession` → `router.push(/workout/...)` | +| F-045 | Progression globale du programme (progress bar) | `[x]` | P0 | Progress tracking intégré | + +#### US-09 · Structure des Programmes +**Story :** En tant que product owner, chaque section dispose de 3 niveaux avec des séances progressives. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-046 | Haut du corps — Débutant (8–12 séances) | `[x]` | P0 | `src/shared/data/tabata/debutant.ts` | +| F-047 | Haut du corps — Intermédiaire (8–12 séances) | `[x]` | P0 | `src/shared/data/tabata/intermediaire.ts` | +| F-048 | Haut du corps — Avancé (8–12 séances) | `[x]` | P1 | `src/shared/data/tabata/avance.ts` | +| F-049 | Bas du corps — Débutant (8–12 séances) | `[x]` | P0 | Dans les data files tabata | +| F-050 | Bas du corps — Intermédiaire (8–12 séances) | `[x]` | P0 | Dans les data files tabata | +| F-051 | Bas du corps — Avancé (8–12 séances) | `[x]` | P1 | Dans les data files tabata | +| F-052 | Corps complet — Débutant (8–12 séances) | `[x]` | P0 | Dans les data files tabata | +| F-053 | Corps complet — Intermédiaire (8–12 séances) | `[x]` | P0 | Dans les data files tabata | +| F-054 | Corps complet — Avancé (8–12 séances) | `[x]` | P1 | Dans les data files tabata | + +--- + +### ÉPOPÉE 5 — SÉANCE TABATA (Core Feature) + +#### US-10 · Phase d'Échauffement +**Story :** En tant qu'utilisateur sur le point de commencer une séance, je passe par une phase d'échauffement guidée avec les exercices expliqués visuellement afin de me préparer sans risque de blessure. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-055 | Écran échauffement avec liste des exercices de la séance | `[x]` | P0 | `WarmupOverlay` component | +| F-056 | Fiche exercice : nom, muscles ciblés, description gestuelle | `[x]` | P0 | Exercise display dans warmup | +| F-057 | Vidéo de démonstration en boucle (coach IA) par exercice | `[!]` | P0 | **BLOQUANT** — Code prêt (`VideoPlayer`) mais vidéos non créées | +| F-058 | Conseil kiné (CardTip) pour chaque exercice | `[x]` | P0 | `TabataTip` component | +| F-059 | Minuteur échauffement (ex: 5 min) avec CTA skip | `[x]` | P1 | WARMUP phase dans useTabataTimer | +| F-060 | Navigation entre les exercices de l'échauffement (swipe ou bouton) | `[x]` | P0 | Dans WarmupOverlay | +| F-061 | CTA "Je suis prêt(e)" → lancement séance | `[x]` | P0 | Transition WARMUP → WORK | + +#### US-11 · Séance Tabata (Timer) +**Story :** En tant qu'utilisateur en séance, je vois clairement la phase (effort / repos), le bloc et le round en cours afin de suivre ma séance sans regarder mon téléphone de près. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-062 | Timer géant (≥80px) : vert (effort 20s) / slate (repos 10s) / rouge (<10s) | `[x]` | P0 | `TimerRing` component avec PHASE_COLORS | +| F-063 | Moteur de séquence : 3 blocs × 2 exercices × 3 rounds, alternance correcte | `[x]` | P0 | `useTabataTimer` — phases WARMUP/WORK/REST/INTER_BLOCK_REST/COOLDOWN/COMPLETE | +| F-064 | Vidéo exercice plein écran en boucle (phase effort) | `[!]` | P0 | **BLOQUANT** — Code prêt mais vidéos non créées | +| F-065 | Fond navy-800 uni (phase repos court 10s) | `[x]` | P0 | `TABATA_PHASE_COLORS.REST: PHASE.REST` | +| F-066 | Écran repos long entre blocs (~60s) avec indication "PROCHAIN BLOC" | `[x]` | P0 | `INTER_BLOCK_REST` phase avec `AMBER[500]` | +| F-067 | Indicateur de position : Bloc X/3 + Round Y/3 + Exercice A ou B | `[x]` | P0 | `BlockIndicator` + `RoundIndicator` + exercise name | +| F-068 | Nom de l'exercice en cours en overlay | `[x]` | P0 | `ExerciseDisplay` component | +| F-069 | Aperçu prochain exercice pendant le repos (thumbnail + nom) | `[x]` | P0 | Next preview dans player | +| F-070 | Progress bar séance globale (sur les 18 intervalles) | `[x]` | P0 | `BurnBar` + `StatsOverlay` | +| F-071 | Son (bip effort, bip repos, countdown 3-2-1, gong repos long) | `[x]` | P0 | `useAudio` + assets: beep_short, beep_double, beep_long, count_1/2/3, bell, fanfare | +| F-072 | Toggle mute accessible en 1 tap | `[x]` | P0 | Mute toggle dans PlayerControls, volume 0 sur musique + SFX | +| F-073 | Vibration haptique aux transitions effort/repos et fin de bloc | `[x]` | P1 | `useHaptics` intégré dans le player | +| F-074 | Bouton pause (centré, accessible) | `[x]` | P0 | Dans `PlayerControls` — pause/resume | +| F-075 | Bouton quitter (DangerButton, confirmation requise) | `[x]` | P0 | `Alert.alert` confirmation dans PlayerControls `handleQuit` | +| F-076 | Pas de mise en veille écran pendant la séance (keepAwake) | `[x]` | P0 | `useKeepAwake()` dans les deux player screens | +| F-077 | Gestion interruption (appel téléphonique → pause auto) | `[ ]` | P1 | Non implémenté | + +#### US-12 · Fin de Séance +**Story :** En tant qu'utilisateur ayant complété une séance, je vois un écran de célébration avec mes stats afin de me sentir accompli et motivé à revenir. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-078 | Écran célébration (BounceAnimation + serif italic) | `[x]` | P0 | `app/complete/[id].tsx` (712 lignes) — Animated celebrations | +| F-079 | Stats : calories estimées, durée réelle, blocs complétés (X/3) | `[x]` | P0 | Calories + duration displayed | +| F-080 | Marquage séance comme "completée" dans le programme | `[x]` | P0 | Via `activityStore.addWorkoutResult` | +| F-081 | Feedback ressenti (FeedbackButton : Dur / Parfait / Trop facile) | `[x]` | P0 | 3 feedback chips dans `app/complete/[id].tsx` | +| F-082 | Mise à jour streak hebdomadaire | `[x]` | P0 | `activityStore` streak calculation | +| F-083 | CTA "Prochaine séance" + CTA "Retour accueil" | `[x]` | P0 | Next workout + return home CTAs | +| F-084 | Conseil kiné post-séance (étirements recommandés) | `[ ]` | P1 | Non implémenté | + +--- + +### ÉPOPÉE 6 — ACCUEIL & DASHBOARD + +#### US-13 · Écran Accueil +**Story :** En tant qu'utilisateur régulier, je vois en un coup d'œil mon prochain entraînement et ma progression cette semaine afin de rester motivé. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-085 | Greeting personnalisé "Bonjour [Prénom]" | `[x]` | P0 | `app/(tabs)/index.tsx` (463 lignes) — greeting avec nom | +| F-086 | CardAccent "Prochaine séance" avec nom programme + CTA | `[x]` | P0 | `ContinueSessionCard` component | +| F-087 | Streak hebdomadaire (StreakDot × 7 jours) | `[x]` | P0 | Streak display intégré | +| F-088 | Stat rapide : séances faites cette semaine vs objectif | `[x]` | P0 | `QuickStats` component avec weekly stats | +| F-089 | Section "Reprendre là où j'en suis" | `[x]` | P0 | `ContinueSessionCard` — reprise de programme | +| F-090 | Notifications de rappel configurables | `[~]` | P1 | `expo-notifications` installé, `useNotifications` hook existe, toggle dans profile | + +--- + +### ÉPOPÉE 7 — PROGRESSION & STATISTIQUES + +#### US-14 · Écran Progression +**Story :** En tant qu'utilisateur fidèle, je consulte mon historique et mes statistiques afin de voir ma progression concrète dans le temps. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-091 | Historique des séances (liste chronologique) | `[x]` | P0 | `app/(tabs)/activity.tsx` (581 lignes) — history list | +| F-092 | Graphique hebdomadaire (barres ou ligne) | `[x]` | P1 | `WeeklyBar` component | +| F-093 | Totaux : séances totales, minutes totales, calories totales | `[x]` | P0 | `StatCard` × 4 avec totaux | +| F-094 | Record streak (max consécutif) | `[x]` | P1 | `activityStore.streak.longest` | +| F-095 | Progression par programme (% complété) | `[~]` | P0 | Progression dans `programStore` mais pas affichée dans activity tab | + +--- + +### ÉPOPÉE 8 — PROFIL & PARAMÈTRES + +#### US-15 · Écran Profil +**Story :** En tant qu'utilisateur, je peux modifier mon profil et gérer mon abonnement afin de garder le contrôle sur mon expérience. + +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-096 | Édition prénom, objectif, nb séances/semaine | `[~]` | P0 | Personalization section exists mais **édition directe non confirmée** | +| F-097 | Statut abonnement (Free / Premium) avec date renouvellement | `[x]` | P0 | Subscription status affiché | +| F-098 | Bouton gérer/annuler abonnement (lien store) | `[~]` | P0 | À vérifier — manage subscription pas confirmé | +| F-099 | Toggle notifications | `[x]` | P1 | `NativeLabeledRow` avec daily reminders toggle | +| F-100 | Liens : CGU, Politique de confidentialité, Contact support | `[x]` | P0 | Terms + Privacy dans paywall, CGU row dans profile, contact ✓ | +| F-101 | Version de l'app | `[x]` | P0 | Version affichée dans about section | + +--- + +### ÉPOPÉE 9 — INFRASTRUCTURE & QUALITÉ + +#### US-16 · Architecture Technique +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-102 | React Native + Expo SDK setup | `[x]` | P0 | Expo SDK 54, React Native 0.81.5, React 19.1 | +| F-103 | Navigation (Expo Router ou React Navigation) | `[x]` | P0 | Expo Router v6 avec tabs (index/activity/profile) | +| F-104 | Design tokens implémentés (design-tokens.ts) | `[x]` | P0 | `colors.ts`, `typography.ts`, `spacing.ts`, `borderRadius.ts`, `animations.ts`, `ThemeContext` | +| F-105 | State management (Zustand ou Context) | `[x]` | P0 | Zustand v5 — 6 stores: user, activity, player, program, tabataProgram, workoutProgram | +| F-106 | Persistence locale (AsyncStorage) | `[x]` | P0 | AsyncStorage + React Query persist | +| F-107 | RevenueCat SDK intégré (iOS + Android) | `[x]` | P0 | `react-native-purchases` v9 + service + hook + tests | +| F-108 | Gestion erreurs globale (ErrorBoundary) | `[x]` | P0 | ErrorBoundary class dans `app/_layout.tsx` avec retry | +| F-109 | Analytics (Mixpanel ou PostHog) — events funnel + rétention | `[x]` | P1 | PostHog + session replay, `track()` calls dans onboarding/player/complete | + +#### US-17 · Contenu & Assets +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-110 | Vidéos coach IA — Haut du corps (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer | +| F-111 | Vidéos coach IA — Bas du corps (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer | +| F-112 | Vidéos coach IA — Corps complet (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer | +| F-113 | Sons : bip effort, bip repos, countdown, gong repos long, fanfare fin | `[x]` | P0 | 10 fichiers audio: beep_short, beep_double, beep_long, count_1/2/3, bell, fanfare, countdown.wav, complete.wav, phase-start.wav + 2 musiques | +| F-114 | Illustrations sections (onboarding) | `[~]` | P1 | Discovery screen utilise des icônes SF Symbols, pas d'illustrations custom | +| F-115 | Icônes app (toutes tailles iOS + Android) | `[~]` | P0 | Icons existent (icon.png, android-icon-*, favicon) mais **contiennent encore des logos React placeholder** | +| F-116 | Splash screen | `[x]` | P0 | `expo-splash-screen` + splash-icon.png | + +#### US-18 · Tests & QA +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-117 | Tests unitaires moteur de séquence tabata (3 blocs × 2 exercices × 3 rounds) | `[x]` | P0 | `tabataProgramStore.test.ts` + `playerStore.test.ts` | +| F-118 | Tests unitaires timer (20s effort / 10s repos / repos long inter-blocs) | `[x]` | P0 | `useTimer.test.ts` + `useTimer.integration.test.ts` | +| F-119 | Tests unitaires RevenueCat (purchase flow) | `[x]` | P0 | `usePurchases.test.ts` + `purchases.test.ts` | +| F-120 | Tests E2E funnel complet (Detox ou Maestro) | `[ ]` | P1 | Non implémenté | +| F-121 | Test sur iPhone SE (petit écran) | `[ ]` | P0 | Non vérifié | +| F-122 | Test sur Android (Samsung Galaxy S-series) | `[ ]` | P0 | Non vérifié | +| F-123 | Test mode avion (gestion offline) | `[ ]` | P1 | Non vérifié | + +#### US-19 · Store & Publication +| # | Feature | Statut | Priorité | Notes | +|---|---------|--------|----------|-------| +| F-124 | Screenshots App Store (6.5" + 5.5" + iPad) | `[ ]` | P0 | Non créé | +| F-125 | Screenshots Google Play | `[ ]` | P0 | Non créé | +| F-126 | Description App Store (FR + EN) | `[ ]` | P0 | Non rédigé | +| F-127 | Description Google Play (FR + EN) | `[ ]` | P0 | Non rédigé | +| F-128 | Politique de confidentialité publiée (URL) | `[~]` | P0 | `app/privacy.tsx` existe mais pas publié en URL externe | +| F-129 | CGU publiées (URL) | `[~]` | P0 | `app/terms.tsx` existe in-app, URL externe non publiée | +| F-130 | Soumission TestFlight (iOS) | `[ ]` | P0 | Non soumis | +| F-131 | Soumission Google Play Beta (Android) | `[ ]` | P0 | Non soumis | +| F-132 | Review Apple (délai ~2-3 jours) | `[ ]` | P0 | Non soumis | +| F-133 | Review Google Play (délai ~1-3 jours) | `[ ]` | P0 | Non soumis | + +--- + +## PRODUCTION GATE CONDITIONS + +Pour que TabataGo soit considéré **prêt pour la production**, les conditions suivantes doivent être remplies : + +### BLOQUANTS ABSOLUS (toutes les P0 doivent être `[x]`) + +- [x] **FUNNEL COMPLET** : F-001 à F-018 tous `[x]` +- [x] **PAYWALL FONCTIONNEL** : F-019 à F-028 — Tous complétés (CGU + privacy links ajoutés) +- [x] **AU MOINS 1 PROGRAMME COMPLET** par section (Débutant minimum) : F-046, F-049, F-052 `[x]` +- [x] **SÉANCE TABATA CORE** : F-062 à F-076 — Mute toggle + quit confirmation ajoutés +- [x] **FIN DE SÉANCE** : F-078 à F-083 — Feedback buttons ajoutés +- [ ] **LÉGAL** : F-128 partiellement, F-129 manquant, F-130/131 non soumis +- [ ] **CONTENU** : F-110 à F-112 vidéos non créées + +### REQUIS AVANT SCALE (peuvent être post-lancement v1.1) + +- Notifications (F-090) — en cours +- Analytics (F-109) — fait (PostHog) +- Tests E2E (F-120) — non fait +- Programmes Avancés (F-048, F-051, F-054) — fait +- Gestion offline (F-123) — non vérifié + +--- + +## COMMENT AFFICHER LE STATUT + +Quand l'utilisateur demande le statut de production, générer un tableau de bord avec : + +1. **Score global** : `[x]` / total features P0 +2. **Score par épopée** : tableau avec % de complétion +3. **Features bloquantes** : liste des P0 encore à `[ ]` ou `[!]` +4. **Verdict** : PRET / PRESQUE PRET (X bloquants) / NON PRET (X bloquants) + +--- + +## NOTES DE MISE À JOUR + +| Date | Changement | Auteur | +|------|-----------|--------| +| 2026-04-18 | Création initiale de l'inventaire (130 features) | TabataGo Team | +| 2026-04-18 | Mise à jour structure séance : 3 blocs × 2 exercices × 3 rounds (133 features) | TabataGo Team | +| 2026-04-18 | Audit complet code vs tracker — 85+ features marquées done, gaps identifiés | OpenCode Audit | +| 2026-04-18 | Implémentation : ErrorBoundary, feedback buttons, mute/quit, legal links, discovery screen, i18n DE/ES | OpenCode | +| 2026-04-18 | Mise à jour tracker : F-027/030-034/072/075/081/100/108 → [x], groupes paywall/séance/fin → [x] | OpenCode | diff --git a/admin-web/app/programs/[id]/edit/page.tsx b/admin-web/app/programs/[id]/edit/page.tsx index 0624584..91bf566 100644 --- a/admin-web/app/programs/[id]/edit/page.tsx +++ b/admin-web/app/programs/[id]/edit/page.tsx @@ -17,7 +17,9 @@ async function getProgram(id: string) { const { data, error } = await (supabase.from("workout_programs") as any) .select(` *, - program_tabatas (*) + program_tabatas (*), + workout_warmup_exercises (*), + workout_stretch_exercises (*) `) .eq("id", id) .single() @@ -30,6 +32,17 @@ async function getProgram(id: string) { if (data.program_tabatas) { data.program_tabatas.sort((a: any, b: any) => a.position - b.position) } + if (data.workout_warmup_exercises) { + data.workout_warmup_exercises.sort((a: any, b: any) => a.position - b.position) + } + if (data.workout_stretch_exercises) { + data.workout_stretch_exercises.sort((a: any, b: any) => a.position - b.position) + } + + // Map to ProgramForm's expected shape + data.tabatas = data.program_tabatas + data.warmup = data.workout_warmup_exercises + data.stretch = data.workout_stretch_exercises return data } diff --git a/admin-web/components/program-form.tsx b/admin-web/components/program-form.tsx index 9806baa..125ea76 100644 --- a/admin-web/components/program-form.tsx +++ b/admin-web/components/program-form.tsx @@ -14,14 +14,22 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Select } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import TabataEditor, { TabataData } from "@/components/tabata-editor" +import { TimedExerciseList } from "@/components/timed-exercise-list" +import { TimedExerciseData, createEmptyTimedExercise } from "@/components/timed-exercise-editor" import { toast } from "sonner" import type { Database } from "@/lib/supabase" type WorkoutProgram = Database["public"]["Tables"]["workout_programs"]["Row"] type ProgramTabata = Database["public"]["Tables"]["program_tabatas"]["Row"] +type WarmupRow = Database["public"]["Tables"]["workout_warmup_exercises"]["Row"] +type StretchRow = Database["public"]["Tables"]["workout_stretch_exercises"]["Row"] interface ProgramFormProps { - initialData?: WorkoutProgram & { tabatas?: ProgramTabata[] } + initialData?: WorkoutProgram & { + tabatas?: ProgramTabata[] + warmup?: WarmupRow[] + stretch?: StretchRow[] + } mode?: "create" | "edit" } @@ -72,6 +80,7 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor exercise_1_modification_en: t.exercise_1_modification_en || "", exercise_1_progression: t.exercise_1_progression || "", exercise_1_progression_en: t.exercise_1_progression_en || "", + exercise_1_video_url: t.exercise_1_video_url || "", exercise_2_name: t.exercise_2_name || "", exercise_2_name_en: t.exercise_2_name_en || "", exercise_2_tip: t.exercise_2_tip || "", @@ -80,18 +89,55 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor exercise_2_modification_en: t.exercise_2_modification_en || "", exercise_2_progression: t.exercise_2_progression || "", exercise_2_progression_en: t.exercise_2_progression_en || "", + exercise_2_video_url: t.exercise_2_video_url || "", rounds: t.rounds || 8, work_time: t.work_time || 20, rest_time: t.rest_time || 10, })) } return [ - { position: 1, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", rounds: 8, work_time: 20, rest_time: 10 }, - { position: 2, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", rounds: 8, work_time: 20, rest_time: 10 }, - { position: 3, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", rounds: 8, work_time: 20, rest_time: 10 }, + { position: 1, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_1_video_url: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", exercise_2_video_url: "", rounds: 8, work_time: 20, rest_time: 10 }, + { position: 2, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_1_video_url: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", exercise_2_video_url: "", rounds: 8, work_time: 20, rest_time: 10 }, + { position: 3, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_1_video_url: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", exercise_2_video_url: "", rounds: 8, work_time: 20, rest_time: 10 }, ] }) + // Warmup state + const [warmup, setWarmup] = React.useState(() => { + if (initialData?.warmup && initialData.warmup.length > 0) { + return initialData.warmup + .sort((a, b) => a.position - b.position) + .map((w, i) => ({ + position: i + 1, + name: w.name || "", + name_en: w.name_en || "", + tip: w.tip || "", + tip_en: w.tip_en || "", + duration: w.duration || 30, + video_url: w.video_url || "", + })) + } + return [] + }) + + // Stretch state + const [stretch, setStretch] = React.useState(() => { + if (initialData?.stretch && initialData.stretch.length > 0) { + return initialData.stretch + .sort((a, b) => a.position - b.position) + .map((s, i) => ({ + position: i + 1, + name: s.name || "", + name_en: s.name_en || "", + tip: s.tip || "", + tip_en: s.tip_en || "", + duration: s.duration || 30, + video_url: s.video_url || "", + })) + } + return [] + }) + const validate = () => { const newErrors: Record = {} @@ -106,6 +152,16 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor } }) + warmup.forEach((w, i) => { + if (!w.name.trim()) newErrors[`warmup_${i}`] = `Warmup ${i + 1}: Name is required` + if (!w.duration || w.duration < 1) newErrors[`warmup_${i}_dur`] = `Warmup ${i + 1}: Duration must be >= 1` + }) + + stretch.forEach((s, i) => { + if (!s.name.trim()) newErrors[`stretch_${i}`] = `Stretch ${i + 1}: Name is required` + if (!s.duration || s.duration < 1) newErrors[`stretch_${i}_dur`] = `Stretch ${i + 1}: Duration must be >= 1` + }) + setErrors(newErrors) return Object.keys(newErrors).length === 0 } @@ -174,6 +230,7 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor exercise_1_modification_en: tabata.exercise_1_modification_en.trim() || null, exercise_1_progression: tabata.exercise_1_progression.trim() || null, exercise_1_progression_en: tabata.exercise_1_progression_en.trim() || null, + exercise_1_video_url: tabata.exercise_1_video_url.trim() || null, exercise_2_name: tabata.exercise_2_name.trim(), exercise_2_name_en: tabata.exercise_2_name_en.trim() || null, exercise_2_tip: tabata.exercise_2_tip.trim() || null, @@ -182,6 +239,7 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor exercise_2_modification_en: tabata.exercise_2_modification_en.trim() || null, exercise_2_progression: tabata.exercise_2_progression.trim() || null, exercise_2_progression_en: tabata.exercise_2_progression_en.trim() || null, + exercise_2_video_url: tabata.exercise_2_video_url.trim() || null, rounds: tabata.rounds, work_time: tabata.work_time, rest_time: tabata.rest_time, @@ -207,6 +265,54 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor } } + // Replace warmup exercises (delete all + insert) + { + const { error: delErr } = await (supabase.from("workout_warmup_exercises") as any) + .delete() + .eq("program_id", programId) + if (delErr) throw delErr + + if (warmup.length > 0) { + const warmupPayload = warmup.map((w, i) => ({ + program_id: programId, + position: i + 1, + name: w.name.trim(), + name_en: w.name_en.trim() || null, + tip: w.tip.trim() || null, + tip_en: w.tip_en.trim() || null, + duration: w.duration, + video_url: w.video_url.trim() || null, + })) + const { error: insErr } = await (supabase.from("workout_warmup_exercises") as any) + .insert(warmupPayload) + if (insErr) throw insErr + } + } + + // Replace stretch exercises (delete all + insert) + { + const { error: delErr } = await (supabase.from("workout_stretch_exercises") as any) + .delete() + .eq("program_id", programId) + if (delErr) throw delErr + + if (stretch.length > 0) { + const stretchPayload = stretch.map((s, i) => ({ + program_id: programId, + position: i + 1, + name: s.name.trim(), + name_en: s.name_en.trim() || null, + tip: s.tip.trim() || null, + tip_en: s.tip_en.trim() || null, + duration: s.duration, + video_url: s.video_url.trim() || null, + })) + const { error: insErr } = await (supabase.from("workout_stretch_exercises") as any) + .insert(stretchPayload) + if (insErr) throw insErr + } + } + toast.success(mode === "edit" ? "Program updated" : "Program created", { description: `"${title}" has been ${mode === "edit" ? "updated" : "created"} successfully.` }) @@ -237,13 +343,19 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor return (
- + Basics + + Warmup + Tabatas + + Stretch + {/* Tab 1: Basics */} @@ -365,7 +477,22 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor - {/* Tab 2: Tabatas */} + {/* Tab 2: Warmup */} + + [i, errors[`warmup_${i}`] || errors[`warmup_${i}_dur`] || ""]).filter(([, v]) => v) + )} + /> + + + {/* Tab 3: Tabatas */}

@@ -396,6 +523,21 @@ export default function ProgramForm({ initialData, mode = "create" }: ProgramFor ) })} + + {/* Tab 4: Stretch */} + + [i, errors[`stretch_${i}`] || errors[`stretch_${i}_dur`] || ""]).filter(([, v]) => v) + )} + /> + {/* Actions */} diff --git a/admin-web/components/tabata-editor.tsx b/admin-web/components/tabata-editor.tsx index f50a96a..9df7c62 100644 --- a/admin-web/components/tabata-editor.tsx +++ b/admin-web/components/tabata-editor.tsx @@ -6,6 +6,7 @@ import { ChevronDown, ChevronRight, Clock, Dumbbell } from "lucide-react" import { cn } from "@/lib/utils" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { MediaUpload } from "@/components/media-upload" import type { Database } from "@/lib/supabase" type ProgramTabata = Database["public"]["Tables"]["program_tabatas"]["Row"] @@ -20,6 +21,7 @@ interface TabataData { exercise_1_modification_en: string exercise_1_progression: string exercise_1_progression_en: string + exercise_1_video_url: string exercise_2_name: string exercise_2_name_en: string exercise_2_tip: string @@ -28,6 +30,7 @@ interface TabataData { exercise_2_modification_en: string exercise_2_progression: string exercise_2_progression_en: string + exercise_2_video_url: string rounds: number work_time: number rest_time: number @@ -52,6 +55,7 @@ function getDefaultTabata(position: number): TabataData { exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", + exercise_1_video_url: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", @@ -60,6 +64,7 @@ function getDefaultTabata(position: number): TabataData { exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", + exercise_2_video_url: "", rounds: 8, work_time: 20, rest_time: 10, @@ -188,6 +193,16 @@ function ExerciseSection({ label, number, data, onChange, errors }: ExerciseSect />

+ + {/* Background video */} +
+ + onChange(`${prefix}_video_url`, url)} + /> +
)} diff --git a/admin-web/components/timed-exercise-editor.tsx b/admin-web/components/timed-exercise-editor.tsx new file mode 100644 index 0000000..edddfd5 --- /dev/null +++ b/admin-web/components/timed-exercise-editor.tsx @@ -0,0 +1,177 @@ +"use client" + +import * as React from "react" +import { ArrowDown, ArrowUp, Trash2, Clock } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { MediaUpload } from "@/components/media-upload" + +export interface TimedExerciseData { + position: number + name: string + name_en: string + tip: string + tip_en: string + duration: number + video_url: string +} + +interface TimedExerciseRowProps { + data: TimedExerciseData + index: number + total: number + onChange: (data: TimedExerciseData) => void + onRemove: () => void + onMoveUp: () => void + onMoveDown: () => void + accentColor?: string + error?: string +} + +export function TimedExerciseRow({ + data, + index, + total, + onChange, + onRemove, + onMoveUp, + onMoveDown, + accentColor = "text-orange-500", + error, +}: TimedExerciseRowProps) { + const update = (key: K, value: TimedExerciseData[K]) => { + onChange({ ...data, [key]: value }) + } + + return ( +
+ {/* Header */} +
+
+ {index + 1} +
+ + {data.name || `Exercise ${index + 1}`} + +
+ + {data.duration}s +
+
+ + + +
+
+ + {/* Fields */} +
+
+
+ + update("name", e.target.value)} + placeholder="e.g., Jumping Jacks" + className={cn("h-9 text-sm", error && "border-red-500")} + /> +
+
+ + update("name_en", e.target.value)} + placeholder="e.g., Jumping Jacks" + className="h-9 text-sm" + /> +
+
+ +
+
+ + update("tip", e.target.value)} + placeholder="Conseil en français" + className="h-9 text-sm" + /> +
+
+ + update("tip_en", e.target.value)} + placeholder="Tip in English" + className="h-9 text-sm" + /> +
+
+ +
+ + update("duration", parseInt(e.target.value) || 0)} + min={1} + max={300} + className="h-9 text-sm w-32" + /> +
+ +
+ + update("video_url", url)} + /> +
+ + {error &&

{error}

} +
+
+ ) +} + +export function createEmptyTimedExercise(position: number): TimedExerciseData { + return { + position, + name: "", + name_en: "", + tip: "", + tip_en: "", + duration: 30, + video_url: "", + } +} diff --git a/admin-web/components/timed-exercise-list.tsx b/admin-web/components/timed-exercise-list.tsx new file mode 100644 index 0000000..38b31e1 --- /dev/null +++ b/admin-web/components/timed-exercise-list.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { Plus } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { TimedExerciseRow, TimedExerciseData, createEmptyTimedExercise } from "@/components/timed-exercise-editor" + +interface TimedExerciseListProps { + title: string + description: string + emptyLabel: string + accentColor: string + items: TimedExerciseData[] + onChange: (items: TimedExerciseData[]) => void + errors?: Record +} + +export function TimedExerciseList({ + title, + description, + emptyLabel, + accentColor, + items, + onChange, + errors = {}, +}: TimedExerciseListProps) { + const addItem = () => { + onChange([...items, createEmptyTimedExercise(items.length + 1)]) + } + + const updateItem = (index: number, data: TimedExerciseData) => { + const next = [...items] + next[index] = data + onChange(next) + } + + const removeItem = (index: number) => { + const next = items.filter((_, i) => i !== index).map((item, i) => ({ ...item, position: i + 1 })) + onChange(next) + } + + const move = (from: number, to: number) => { + if (to < 0 || to >= items.length) return + const next = [...items] + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + onChange(next.map((item, i) => ({ ...item, position: i + 1 }))) + } + + return ( +
+
+

{title}

+

{description}

+
+ + {items.length === 0 ? ( +
+

{emptyLabel}

+ +
+ ) : ( + <> + {items.map((item, i) => ( + updateItem(i, data)} + onRemove={() => removeItem(i)} + onMoveUp={() => move(i, i - 1)} + onMoveDown={() => move(i, i + 1)} + error={errors[i]} + /> + ))} + + + )} +
+ ) +} diff --git a/admin-web/lib/supabase.ts b/admin-web/lib/supabase.ts index 7a5e0a0..82e6b01 100644 --- a/admin-web/lib/supabase.ts +++ b/admin-web/lib/supabase.ts @@ -202,6 +202,8 @@ export interface Database { exercise_2_modification_en: string | null exercise_2_progression: string | null exercise_2_progression_en: string | null + exercise_1_video_url: string | null + exercise_2_video_url: string | null rounds: number work_time: number rest_time: number @@ -226,12 +228,66 @@ export interface Database { exercise_2_modification_en?: string | null exercise_2_progression?: string | null exercise_2_progression_en?: string | null + exercise_1_video_url?: string | null + exercise_2_video_url?: string | null rounds?: number work_time?: number rest_time?: number } Update: Partial> } + workout_warmup_exercises: { + Row: { + id: string + program_id: string + position: number + name: string + name_en: string | null + tip: string | null + tip_en: string | null + duration: number + video_url: string | null + created_at: string + } + Insert: { + id?: string + program_id: string + position: number + name: string + name_en?: string | null + tip?: string | null + tip_en?: string | null + duration: number + video_url?: string | null + } + Update: Partial> + } + workout_stretch_exercises: { + Row: { + id: string + program_id: string + position: number + name: string + name_en: string | null + tip: string | null + tip_en: string | null + duration: number + video_url: string | null + created_at: string + } + Insert: { + id?: string + program_id: string + position: number + name: string + name_en?: string | null + tip?: string | null + tip_en?: string | null + duration: number + video_url?: string | null + } + Update: Partial> + } workout_videos: { Row: { id: string diff --git a/admin-web/migrations/006_warmup_stretch_video.sql b/admin-web/migrations/006_warmup_stretch_video.sql new file mode 100644 index 0000000..e84fb35 --- /dev/null +++ b/admin-web/migrations/006_warmup_stretch_video.sql @@ -0,0 +1,71 @@ +-- Migration 006: Warmup, Stretch, and Exercise Video URLs +-- Extends workout_programs with warmup and stretch blocks +-- Adds video_url to tabata exercises for background playback during player + +-- ─── Add video URLs to tabata exercises ───────────────────── + +ALTER TABLE public.program_tabatas + ADD COLUMN IF NOT EXISTS exercise_1_video_url TEXT, + ADD COLUMN IF NOT EXISTS exercise_2_video_url TEXT; + +-- ─── Warmup Exercises ─────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS public.workout_warmup_exercises ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + program_id UUID NOT NULL REFERENCES public.workout_programs(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + name TEXT NOT NULL, + name_en TEXT, + tip TEXT, + tip_en TEXT, + duration INTEGER NOT NULL, -- seconds + video_url TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (program_id, position) +); + +-- ─── Stretch Exercises ────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS public.workout_stretch_exercises ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + program_id UUID NOT NULL REFERENCES public.workout_programs(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + name TEXT NOT NULL, + name_en TEXT, + tip TEXT, + tip_en TEXT, + duration INTEGER NOT NULL, -- seconds + video_url TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (program_id, position) +); + +-- ─── Indexes ──────────────────────────────────────────────── + +CREATE INDEX IF NOT EXISTS idx_warmup_program_position + ON public.workout_warmup_exercises (program_id, position); + +CREATE INDEX IF NOT EXISTS idx_stretch_program_position + ON public.workout_stretch_exercises (program_id, position); + +-- ─── Row Level Security ───────────────────────────────────── + +ALTER TABLE public.workout_warmup_exercises ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.workout_stretch_exercises ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Public read workout_warmup_exercises" + ON public.workout_warmup_exercises FOR SELECT USING (true); + +CREATE POLICY "Public read workout_stretch_exercises" + ON public.workout_stretch_exercises FOR SELECT USING (true); + +-- Admin write (service_role bypass RLS, authenticated users controlled elsewhere) +CREATE POLICY "Admin write workout_warmup_exercises" + ON public.workout_warmup_exercises FOR ALL + USING (auth.role() = 'service_role') + WITH CHECK (auth.role() = 'service_role'); + +CREATE POLICY "Admin write workout_stretch_exercises" + ON public.workout_stretch_exercises FOR ALL + USING (auth.role() = 'service_role') + WITH CHECK (auth.role() = 'service_role'); diff --git a/app.json b/app.json index d073ee3..37c7d9e 100644 --- a/app.json +++ b/app.json @@ -21,7 +21,10 @@ }, "config": { "usesNonExemptEncryption": false - } + }, + "associatedDomains": [ + "applinks:tabatafit.app" + ] }, "android": { "adaptiveIcon": { @@ -32,7 +35,31 @@ }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, - "package": "com.millianlmx.tabatafit" + "package": "com.millianlmx.tabatafit", + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": true, + "data": [ + { + "scheme": "https", + "host": "tabatafit.app", + "pathPrefix": "/workout" + }, + { + "scheme": "https", + "host": "tabatafit.app", + "pathPrefix": "/player" + }, + { + "scheme": "https", + "host": "tabatafit.app", + "pathPrefix": "/program" + } + ], + "category": ["BROWSABLE", "DEFAULT"] + } + ] }, "web": { "output": "static", diff --git a/package-lock.json b/package-lock.json index 4a2edbc..abfbc96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,11 +36,13 @@ "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.11", "expo-localization": "~17.0.8", + "expo-network": "~8.0.8", "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", + "expo-store-review": "~9.0.9", "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", "expo-video": "~3.0.16", @@ -7744,6 +7746,16 @@ "react-native": "*" } }, + "node_modules/expo-network": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-8.0.8.tgz", + "integrity": "sha512-dgrL8UHAmWofqeY4UEjWskCl/RoQAM0DG6PZR8xz2WZt+6aQEboQgFRXowCfhbKZ71d16sNuKXtwBEsp2DtdNw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-notifications": { "version": "0.32.16", "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz", @@ -8060,6 +8072,16 @@ "react-native": "*" } }, + "node_modules/expo-store-review": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/expo-store-review/-/expo-store-review-9.0.9.tgz", + "integrity": "sha512-99vS7edXlKzPcdjrzVlMQWc4zOyq4khQfFjhNqJgpGP+AgRn4U0LaZkHIrVjmzolryD3rcHJSiUQH9Vi0sD0MQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-symbols": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-1.0.8.tgz", diff --git a/package.json b/package.json index 8a31132..3574219 100644 --- a/package.json +++ b/package.json @@ -53,11 +53,13 @@ "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.11", "expo-localization": "~17.0.8", + "expo-network": "~8.0.8", "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", + "expo-store-review": "~9.0.9", "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", "expo-video": "~3.0.16",